From 62b68c37176ac09b36a8d2eb5c163579764e5c12 Mon Sep 17 00:00:00 2001 From: lasseedfast <> Date: Fri, 30 May 2025 21:05:50 +0200 Subject: [PATCH] Add models, testing scripts, and result viewing functionality - Implemented Pydantic models for article processing and summarization. - Created `test_and_view.py` for testing LLM server document summarization. - Developed `test_llm_server.py` for unit testing summarization functionality. - Added `test_server.py` for additional testing of document and chunk summarization. - Introduced `view_latest_results.py` to display the latest summaries from the LLM server. - Established a structured plan for handling document chunks and their metadata. - Enhanced error handling and user feedback in testing scripts. --- _arango.py | 967 ++++++++++++++++++++++-- _base_class.py | 257 ++++++- _bots.py | 800 -------------------- _bots_dont_use.py | 497 +++++++++++++ _chromadb.py | 299 +++++++- _llm.py | 574 -------------- _llmOLD.py | 581 +++++++++++++++ agent_research.py | 1448 ++++++++++++++++++++++++++++++++++++ article2db.py | 335 +++++++-- bot_tools.py | 0 info.py | 1 + llm_queries.py | 5 + llm_server.py | 373 +++++++++- manage_users.py | 19 +- models.py | 334 +++++++++ ollama_response_classes.py | 6 - projects_page.py | 239 +++--- research_page.py | 249 ++++--- streamlit_app.py | 36 +- streamlit_chatbot.py | 737 +++++++++++------- test.py | 37 +- test_ tortoise.py | 31 - test_and_view.py | 209 ++++++ test_fairseq.py | 51 -- test_highlight.py | 91 --- test_llm_server.py | 191 +++++ test_ollama_client.py | 38 - test_ollama_image.py | 9 - test_research.py | 206 ----- test_server.py | 123 +++ test_tts.py | 45 -- test_tts_call_server.py | 22 - tts_save_speaker.py | 33 - utils.py | 94 ++- view_latest_results.py | 111 +++ 35 files changed, 6481 insertions(+), 2567 deletions(-) delete mode 100644 _bots.py create mode 100644 _bots_dont_use.py delete mode 100644 _llm.py create mode 100644 _llmOLD.py create mode 100644 agent_research.py create mode 100644 bot_tools.py create mode 100644 models.py delete mode 100644 ollama_response_classes.py delete mode 100644 test_ tortoise.py create mode 100644 test_and_view.py delete mode 100644 test_fairseq.py delete mode 100644 test_highlight.py create mode 100644 test_llm_server.py delete mode 100644 test_ollama_client.py delete mode 100644 test_ollama_image.py delete mode 100644 test_research.py create mode 100644 test_server.py delete mode 100644 test_tts.py delete mode 100644 test_tts_call_server.py delete mode 100644 tts_save_speaker.py create mode 100644 view_latest_results.py diff --git a/_arango.py b/_arango.py index 661ddce..c0907d9 100644 --- a/_arango.py +++ b/_arango.py @@ -1,75 +1,950 @@ -import re -from arango import ArangoClient -from dotenv import load_dotenv import os +from datetime import datetime + +from dotenv import load_dotenv +from arango import ArangoClient +from arango.collection import StandardCollection as ArangoCollection + +from models import UnifiedDataChunk, UnifiedSearchResults +from utils import fix_key + if "INFO" not in os.environ: import env_manager + env_manager.set_env() load_dotenv() # Install with pip install python-dotenv +COLLECTIONS_IN_BASE = [ + "sci_articles", +] + class ArangoDB: - def __init__(self, user=None, password=None, db_name=None): + """ + ArangoDB Client Wrapper + This class provides a wrapper around the ArangoClient to simplify working with ArangoDB databases + and collections in a scientific document management context. It handles authentication, database + connections, and provides high-level methods for common operations. + Key features: + - Database and collection management + - Document CRUD operations (Create, Read, Update, Delete) + - AQL query execution + - Scientific article storage and retrieval + - Project and note management + - Chat history storage + - Settings management + Usage example: + arango = ArangoDB(user="admin", password="password") + # Create a collection + arango.create_collection("my_collection") + # Insert a document + doc = arango.insert_document("my_collection", {"name": "Test Document"}) + # Query documents + results = arango.execute_aql("FOR doc IN my_collection RETURN doc") + Environment variables: + ARANGO_HOST: The ArangoDB host URL + ARANGO_PASSWORD: The default password for authentication + """ + def __init__(self, user="admin", password=None, db_name="base"): """ - Initializes an instance of the ArangoClass. - - Args: - db_name (str): The name of the database. - username (str): The username for authentication. - password (str): The password for authentication. + Initialize a connection to an ArangoDB database. + This constructor establishes a connection to an ArangoDB instance using the provided + credentials and database name. It uses environment variables for host and password + if not explicitly provided. + Parameters + ---------- + user : str, optional + Username for database authentication. Defaults to "admin". + If db_name is not "base", then user will be set to db_name. + password : str, optional + Password for database authentication. If not provided, + the password will be retrieved from the ARANGO_PASSWORD environment variable. + db_name : str, optional + Name of the database to connect to. Defaults to "base". + If not "base", this value will also be used as the username. + Notes + ----- + - The host URL is always retrieved from the ARANGO_HOST environment variable. + - For the "base" database, the username will be either "admin" or the provided user. + - For other databases, the username will be the same as the database name. + Attributes + ---------- + user : str + The username used for authentication. + password : str + The password used for authentication. + db_name : str + The name of the connected database. + client : ArangoClient + The ArangoDB client instance. + db : Database + The database instance for executing operations. """ - + host = os.getenv("ARANGO_HOST") if not password: - password = os.getenv("ARANGO_PASSWORD") - if not db_name: - if user: - db_name = user - else: - db_name = os.getenv("ARANGO_DB") - if not user: - user = os.getenv("ARANGO_USER") + self.password = os.getenv("ARANGO_PASSWORD") + # This is the default user for the base database + if db_name != "base": + self.user = db_name + self.db_name = db_name + + elif user == "admin": + self.user = "admin" + self.db_name = "base" + else: + self.user = user + self.db_name = user self.client = ArangoClient(hosts=host) - if user=='lasse': #! This need to be fixed to work with all users! - password = os.getenv("ARANGO_PWD_LASSE") - self.db = self.client.db(db_name, username=user, password=password) + self.db = self.client.db( + self.db_name, username=self.user, password=self.password + ) + + def fix_key(self, _key): + return fix_key(_key) + # Collection operations + def get_collection(self, collection_name: str) -> ArangoCollection: + """ + Get a collection by name. + Args: + collection_name (str): The name of the collection. - def fix_key(self, _key): + Returns: + ArangoCollection: The collection object. + """ + return self.db.collection(collection_name) + + def has_collection(self, collection_name: str) -> bool: + """ + Check if a collection exists. + + Args: + collection_name (str): The name of the collection. + + Returns: + bool: True if the collection exists, False otherwise. + """ + return self.db.has_collection(collection_name) + + def create_collection(self, collection_name: str) -> ArangoCollection: + """ + Create a new collection. + + Args: + collection_name (str): The name of the collection to create. + + Returns: + ArangoCollection: The created collection. + """ + return self.db.create_collection(collection_name) + + def delete_collection(self, collection_name: str) -> bool: + """ + Delete a collection. + + Args: + collection_name (str): The name of the collection to delete. + + Returns: + bool: True if the collection was deleted successfully. + """ + if self.has_collection(collection_name): + return self.db.delete_collection(collection_name) + return False + + def truncate_collection(self, collection_name: str) -> bool: + """ + Truncate a collection (remove all documents). + + Args: + collection_name (str): The name of the collection to truncate. + + Returns: + bool: True if the collection was truncated successfully. + """ + if self.has_collection(collection_name): + return self.db.collection(collection_name).truncate() + return False + + # Document operations + def get_document(self, document_id: str): + """ + Get a document by ID. + + Args: + document_id (str): The ID of the document to get. + + Returns: + dict: The document if found, None otherwise. + """ + try: + return self.db.document(document_id) + except: + return None + + def has_document(self, collection_name: str, document_key: str) -> bool: + """ + Check if a document exists in a collection. + + Args: + collection_name (str): The name of the collection. + document_key (str): The key of the document. + + Returns: + bool: True if the document exists, False otherwise. + """ + return self.db.collection(collection_name).has(document_key) + + def insert_document( + self, + collection_name: str, + document: dict, + overwrite: bool = False, + overwrite_mode: str = "update", + keep_none: bool = False, + ): + """ + Insert a document into a collection. + + Args: + collection_name (str): The name of the collection. + document (dict): The document to insert. + overwrite (bool, optional): Whether to overwrite an existing document. Defaults to False. + overwrite_mode (str, optional): The mode for overwriting ('replace' or 'update'). Defaults to "replace". + keep_none (bool, optional): Whether to keep None values. Defaults to False. + + Returns: + dict: The inserted document with its metadata (_id, _key, etc.) + """ + assert '_id' in document or '_key' in document, "Document must have either _id or _key" + if '_id' not in document: + document['_id'] = f"{collection_name}/{document['_key']}" + + return self.db.collection(collection_name).insert( + document, + overwrite=overwrite, + overwrite_mode=overwrite_mode, + keep_none=keep_none, + ) + + def update_document( + self, document: dict, check_rev: bool = False, silent: bool = False + ): + """ + Update a document that already has _id or _key. + + Args: + document (dict): The document to update. + check_rev (bool, optional): Whether to check document revision. Defaults to False. + silent (bool, optional): Whether to return the updated document. Defaults to False. + + Returns: + dict: The updated document if silent is False. + """ + return self.db.update_document(document, check_rev=check_rev, silent=silent) + + def update_document_by_match( + self, collection_name: str, filters: dict, body: dict, merge: bool = True + ): + """ + Update documents that match a filter. + + Args: + collection_name (str): The name of the collection. + filters (dict): The filter to match documents. + body (dict): The update to apply. + merge (bool, optional): Whether to merge the update with existing data. Defaults to True. + + Returns: + dict: The result of the update operation. + """ + return self.db.collection(collection_name).update_match( + filters=filters, body=body, merge=merge + ) + + def delete_document(self, collection_name: str, document_key: str): + """ + Delete a document from a collection. + + Args: + collection_name (str): The name of the collection. + document_key (str): The key of the document to delete. + + Returns: + dict: The deletion result. + """ + return self.db.collection(collection_name).delete(document_key) + + def delete_document_by_match(self, collection_name: str, filters: dict): + """ + Delete documents that match a filter. + + Args: + collection_name (str): The name of the collection. + filters (dict): The filter to match documents. + + Returns: + dict: The deletion result. + """ + return self.db.collection(collection_name).delete_match(filters=filters) + + # Query operations + def execute_aql(self, query: str, bind_vars: dict = None): + """ + Execute an AQL query. + + Args: + query (str): The AQL query to execute. + bind_vars (dict, optional): Bind variables for the query. Defaults to None. + + Returns: + Cursor: A cursor to the query results. + """ + return self.db.aql.execute(query, bind_vars=bind_vars) + + def get_all_documents(self, collection_name: str): + """ + Get all documents from a collection. + + Args: + collection_name (str): The name of the collection. + + Returns: + list: All documents in the collection. + """ + return list(self.db.collection(collection_name).all()) + + # Database operations + def has_database(self, db_name: str) -> bool: + """ + Check if a database exists. + + Args: + db_name (str): The name of the database. + + Returns: + bool: True if the database exists, False otherwise. + """ + return self.client.has_database(db_name) + + def create_database(self, db_name: str, users: list = None) -> bool: + """ + Create a new database. + + Args: + db_name (str): The name of the database to create. + users (list, optional): List of user objects with access to the database. Defaults to None. + + Returns: + bool: True if the database was created successfully. + """ + return self.client.create_database(db_name, users=users) + + def delete_database(self, db_name: str) -> bool: + """ + Delete a database. + + Args: + db_name (str): The name of the database to delete. + + Returns: + bool: True if the database was deleted successfully. + """ + if self.client.has_database(db_name): + return self.client.delete_database(db_name) + return False + + # Domain-specific operations + + # Scientific Articles + def get_article( + self, + article_key: str, + db_name: str = None, + collection_name: str = "sci_articles", + ): + """ + Get a scientific article by key. + + Args: + article_key (str): The key of the article. + db_name (str, optional): The database name to search in. Defaults to current database. + + Returns: + dict: The article document if found, None otherwise. + """ + try: + return self.db.collection("sci_articles").get(article_key) + except Exception as e: + print(f"Error retrieving article {article_key}: {e}") + raise e + return None + + def get_article_by_doi(self, doi: str): + """ + Get a scientific article by DOI. + + Args: + doi (str): The DOI of the article. + + Returns: + dict: The article document if found, None otherwise. + """ + query = """ + FOR doc IN sci_articles + FILTER doc.metadata.doi == @doi + RETURN doc + """ + cursor = self.db.aql.execute(query, bind_vars={"doi": doi}) + try: + return next(cursor) + except StopIteration: + return None + + def get_document_text( + self, _id: str = None, _key: str = None, collection: str = None + ): + """ + Get the text content of a document. If _key is used, collection must be provided. + * Use base_arango for sci_articles and user_arango for other collections. * + Args: + _id (str, optional): The ID of the document. Defaults to None. + _key (str, optional): The key of the document. Defaults to None. + collection (str, optional): The name of the collection. Defaults to None. + Returns: + str: The text content of the document, or None if not found. + """ + if collection == "sci_articles" or _id.startswith("sci_articles"): + assert ( + self.db_name == "base" + ), "If requesting sci_articles base_arango must be used" + else: + assert ( + self.db_name != "base" + ), "If not requesting sci_articles user_arango must be used" + + try: + if _id: + doc = self.db.document(_id) + elif _key: + assert ( + collection is not None + ), "Collection name must be provided if _key is used" + doc = self.db.collection(collection).get(_key) + + text = [chunk.get("text") for chunk in doc.get("chunks", [])] + except Exception as e: + print(f"Error retrieving text for document {_id or _key}: {e}") + return None + return "\n".join(text) if text else None + + def store_article_chunks( + self, article_data: dict, chunks: list, document_key: str = None + ): + """ + Store article chunks in the database. + + Args: + article_data (dict): The article metadata. + chunks (list): The chunks of text from the article. + document_key (str, optional): The key to use for the document. Defaults to None. + + Returns: + tuple: (document_id, database_name, document_doi) """ - Sanitize a given key by replacing all characters that are not alphanumeric, - underscore, hyphen, dot, at symbol, parentheses, plus, equals, semicolon, - dollar sign, asterisk, single quote, percent, or colon with an underscore. + collection = "sci_articles" + + arango_chunks = [] + for index, chunk in enumerate(chunks): + chunk_id = f"{document_key}_{index}" if document_key else f"chunk_{index}" + page_numbers = chunk.get("pages", []) + text = chunk.get("text", "") + arango_chunks.append({"text": text, "pages": page_numbers, "id": chunk_id}) + + arango_document = { + "_key": document_key, + "chunks": arango_chunks, + "metadata": article_data.get("metadata", {}), + } + + if article_data.get("summary"): + arango_document["summary"] = article_data.get("summary") + + if article_data.get("doi"): + arango_document["crossref"] = True + + doc = self.insert_document( + collection_name=collection, + document=arango_document, + overwrite=True, + overwrite_mode="update", + keep_none=False, + ) + + return doc["_id"], self.db_name, article_data.get("doi") + + def add_article_to_collection(self, article_id: str, collection_name: str): + """ + Add an article to a user's article collection. Args: - _key (str): The key to be sanitized. + article_id (str): The ID of the article. + collection_name (str): The name of the user's collection. Returns: - str: The sanitized key with disallowed characters replaced by underscores. + bool: True if the article was added successfully. + """ + query = """ + FOR collection IN article_collections + FILTER collection.name == @collection_name + UPDATE collection WITH { + articles: PUSH(collection.articles, @article_id) + } IN article_collections + RETURN NEW + """ + cursor = self.db.aql.execute( + query, + bind_vars={"collection_name": collection_name, "article_id": article_id}, + ) + try: + return next(cursor) is not None + except StopIteration: + return False + + def remove_article_from_collection(self, article_id: str, collection_name: str): + """ + Remove an article from a user's article collection. + + Args: + article_id (str): The ID of the article. + collection_name (str): The name of the user's collection. + + Returns: + bool: True if the article was removed successfully. + """ + query = """ + FOR collection IN article_collections + FILTER collection.name == @collection_name + UPDATE collection WITH { + articles: REMOVE_VALUE(collection.articles, @article_id) + } IN article_collections + RETURN NEW + """ + cursor = self.db.aql.execute( + query, + bind_vars={"collection_name": collection_name, "article_id": article_id}, + ) + try: + return next(cursor) is not None + except StopIteration: + return False + + # Projects + def get_projects(self, username: str = None): """ + Get all projects for a user. + + Returns: + list: A list of project documents. + """ + if username: + query = """ + FOR p IN projects + SORT p.name ASC + RETURN p + """ + return list(self.db.aql.execute(query)) + else: + return self.get_all_documents("projects") + + def get_project(self, project_name: str, username: str = None): + """ + Get a project by name. + + Args: + project_name (str): The name of the project. + + Returns: + dict: The project document if found, None otherwise. + """ + if username: + query = """ + FOR p IN projects + FILTER p.name == @project_name + RETURN p + """ + cursor = self.db.aql.execute( + query, bind_vars={"project_name": project_name} + ) + try: + return next(cursor) + except StopIteration: + return None + else: + query = """ + FOR p IN projects + FILTER p.name == @project_name + RETURN p + """ + cursor = self.db.aql.execute( + query, bind_vars={"project_name": project_name} + ) + try: + return next(cursor) + except StopIteration: + return None + + def create_project(self, project_data: dict): + """ + Create a new project. + + Args: + project_data (dict): The project data. + + Returns: + dict: The created project document. + """ + return self.insert_document("projects", project_data) + + def update_project(self, project_data: dict): + """ + Update an existing project. + + Args: + project_data (dict): The project data. + + Returns: + dict: The updated project document. + """ + return self.update_document(project_data, check_rev=False) + + def delete_project(self, project_name: str, username: str = None): + """ + Delete a project. + + Args: + project_name (str): The name of the project. + username (str, optional): The username. Defaults to None. + + Returns: + bool: True if the project was deleted successfully. + """ + filters = {"name": project_name} + if username: + filters["username"] = username + + return self.delete_document_by_match("projects", filters) + + def get_project_notes(self, project_name: str, username: str = None): + """ + Get notes for a project. + + Args: + project_name (str): The name of the project. + username (str, optional): The username. Defaults to None. + + Returns: + list: A list of note documents. + """ + query = """ + FOR note IN notes + FILTER note.project == @project_name + """ + + if username: + query += " AND note.username == @username" + + query += """ + SORT note.timestamp DESC + RETURN note + """ + + bind_vars = {"project_name": project_name} + if username: + bind_vars["username"] = username + + return list(self.db.aql.execute(query, bind_vars=bind_vars)) + + def add_note_to_project(self, note_data: dict): + """ + Add a note to a project. + + Args: + note_data (dict): The note data. + + Returns: + dict: The created note document. + """ + return self.insert_document("notes", note_data) + + def fetch_notes_tool( + self, project_name: str, username: str = None + ) -> UnifiedSearchResults: + """ + Fetch notes for a project and return them in a unified format. + + Args: + project_name (str): The name of the project. + username (str, optional): The username. Defaults to None. + + Returns: + UnifiedSearchResults: A unified representation of the notes. + """ + notes = self.get_project_notes(project_name, username) + chunks = [] + source_ids = [] + + for note in notes: + chunk = UnifiedDataChunk( + content=note.get("content", ""), + metadata={ + "title": note.get("title", "No title"), + "timestamp": note.get("timestamp", ""), + }, + source_type="note", + ) + chunks.append(chunk) + source_ids.append(note.get("_id", "unknown_id")) + + return UnifiedSearchResults(chunks=chunks, source_ids=source_ids) + + # Chat operations + def get_chat(self, chat_key: str): + """ + Get a chat by key. + + Args: + chat_key (str): The key of the chat. + + Returns: + dict: The chat document if found, None otherwise. + """ + try: + return self.db.collection("chats").get(chat_key) + except: + return None + + def create_or_update_chat(self, chat_data: dict): + """ + Create or update a chat. + + Args: + chat_data (dict): The chat data. + + Returns: + dict: The created or updated chat document. + """ + return self.insert_document("chats", chat_data, overwrite=True) + + def get_chats_for_project(self, project_name: str, username: str = None): + """ + Get all chats for a project. + + Args: + project_name (str): The name of the project. + username (str, optional): The username. Defaults to None. + + Returns: + list: A list of chat documents. + """ + query = """ + FOR chat IN chats + FILTER chat.project == @project_name + """ + + if username: + query += " AND chat.username == @username" + + query += """ + SORT chat.timestamp DESC + RETURN chat + """ + + bind_vars = {"project_name": project_name} + if username: + bind_vars["username"] = username + + return list(self.db.aql.execute(query, bind_vars=bind_vars)) + + def delete_chat(self, chat_key: str): + """ + Delete a chat. + + Args: + chat_key (str): The key of the chat. + + Returns: + dict: The deletion result. + """ + return self.delete_document("chats", chat_key) + + def delete_old_chats(self, days: int = 30): + """ + Delete chats older than a certain number of days. + + Args: + days (int, optional): The number of days. Defaults to 30. + + Returns: + int: The number of deleted chats. + """ + query = """ + FOR chat IN chats + FILTER DATE_DIFF(chat.timestamp, DATE_NOW(), "d") > @days + REMOVE chat IN chats + RETURN OLD + """ + cursor = self.db.aql.execute(query, bind_vars={"days": days}) + return len(list(cursor)) + + # Settings operations + def get_settings(self): + """ + Get settings document. + + Returns: + dict: The settings document if found, None otherwise. + """ + try: + return self.db.document("settings/settings") + except: + return None + + def initialize_settings(self, settings_data: dict): + """ + Initialize settings. + + Args: + settings_data (dict): The settings data. + + Returns: + dict: The created settings document. + """ + settings_data["_key"] = "settings" + return self.insert_document("settings", settings_data) + + def update_settings(self, settings_data: dict): + """ + Update settings. + + Args: + settings_data (dict): The settings data. + + Returns: + dict: The updated settings document. + """ + return self.update_document_by_match( + collection_name="settings", filters={"_key": "settings"}, body=settings_data + ) + + def get_document_metadata(self, document_id: str) -> dict: + """ + Retrieve document metadata with merged user notes if available. + + This method determines the appropriate database based on the document ID, + retrieves the document, and enriches its metadata with any user notes. + + Args: + document_id (str): The document ID to retrieve metadata for + + Returns: + dict: The document metadata dictionary, or empty dict if not found + """ + if not document_id: + return {} + + try: + # Determine which database to use based on document ID prefix + if document_id.startswith("sci_articles"): + # Science articles are in the base database + db_to_use = self.client.db( + "base", + username=os.getenv("ARANGO_USER"), + password=os.getenv("ARANGO_PASSWORD"), + ) + arango_doc = db_to_use.document(document_id) + else: + # User documents are in the user's database + arango_doc = self.db.document(document_id) + + if not arango_doc: + return {} + + # Get metadata and merge user notes if available + arango_metadata = arango_doc.get("metadata", {}) + if "user_notes" in arango_doc: + arango_metadata["user_notes"] = arango_doc["user_notes"] + + return arango_metadata + except Exception as e: + print(f"Error retrieving metadata for document {document_id}: {e}") + return {} + + def summarise_chunks(self, document: dict, is_sci=False): + from _llm import LLM + from models import ArticleChunk + + assert "_id" in document, "Document must have an _id field" + + if is_sci: + system_message = """You are a science assistant summarizing scientific articles. + You will get an article chunk by chunk, and you have three tasks for each chunk: + 1. Summarize the content of the chunk. + 2. Tag the chunk with relevant tags. + 3. Extract the scientific references from the chunk. + """ + else: + system_message = """You are a general assistant summarizing articles. + You will get an article chunk by chunk, and you have two tasks for each chunk: + 1. Summarize the content of the chunk. + 2. Tag the chunk with relevant tags. + """ + + system_message += """\nPlease make use of the previous chunks you have already seen to understand the current chunk in context and make the summary stand for itself. But remember, *it is the current chunk you are summarizing* + ONLY use the information in the chunks to make the summary, and do not add any information that is not in the chunks.""" + + llm = LLM(system_message=system_message) + chunks = [] + for chunk in document["chunks"]: + if "summary" in chunk: + chunks.append(chunk) + continue + prompt = f"""Summarize the following text to make it stand on its own:\n + ''' + {chunk['text']} + '''\n + Your tasks are: + 1. Summarize the content of the chunk. Make sure to include all relevant details! + 2. Tag the chunk with relevant tags. + """ + if is_sci: + prompt += "\n3. Extract the scientific references mentioned in this specific chunk. If there is a DOI reference, include that in the reference. Sometimes the reference is only a number in brackets, like [1], so make sure to include that as well (in brackets)." + prompt += "\nONLY use the information in the chunks to make the summary, and do not add any information that is not in the chunks." - return re.sub(r"[^A-Za-z0-9_\-\.@()+=;$!*\'%:]", "_", _key) + try: + response = llm.generate(prompt, format=ArticleChunk.model_json_schema()) + structured_response = ArticleChunk.model_validate_json(response.content) + chunk["summary"] = structured_response.summary + chunk["tags"] = [i.lower() for i in structured_response.tags] + chunk["summary_meta"] = { + "model": llm.model, + "date": datetime.now().strftime("%Y-%m-%d"), + } + except Exception as e: + print(f"Error processing chunk: {e}") + chunks.append(chunk) + document["chunks"] = chunks + self.update_document(document, check_rev=False) if __name__ == "__main__": + arango = ArangoDB(user='lasse') + random_doc = arango.db.aql.execute( + "FOR doc IN other_documents LIMIT 1 RETURN doc" + ) + print(next(random_doc)) - arango = ArangoDB(db_name='base') - articles = arango.db.collection('sci_articles').all() - for article in articles: - if 'metadata' in article and article['metadata']: - if 'abstract' in article['metadata']: - abstract = article['metadata']['abstract'] - if isinstance(abstract, str): - # Remove text within <> brackets and the brackets themselves - article['metadata']['abstract'] = re.sub(r'<[^>]*>', '', abstract) - arango.db.collection('sci_articles').update_match( - filters={'_key': article['_key']}, - body={'metadata': article['metadata']}, - merge=True - ) - print(f"Updated abstract for {article['_key']}") - - diff --git a/_base_class.py b/_base_class.py index cf40829..a150329 100644 --- a/_base_class.py +++ b/_base_class.py @@ -5,17 +5,17 @@ import streamlit as st from _arango import ArangoDB from _chromadb import ChromaDB + class BaseClass: def __init__(self, username: str, **kwargs) -> None: self.username: str = username - self.project_name: str = kwargs.get('project_name', None) - self.collection: str = kwargs.get('collection_name', None) + self.project_name: str = kwargs.get("project_name", None) + self.collection: str = kwargs.get("collection_name", None) self.user_arango: ArangoDB = self.get_arango() self.base_arango: ArangoDB = self.get_arango(admin=True) for key, value in kwargs.items(): setattr(self, key, value) - def get_arango(self, admin: bool = False, db_name: str = None) -> ArangoDB: if db_name: return ArangoDB(db_name=db_name) @@ -25,29 +25,41 @@ class BaseClass: return ArangoDB(user=self.username, db_name=self.username) def get_article_collections(self) -> list: - article_collections = self.user_arango.db.aql.execute( + """ + Gets the names of all article collections for the current user. + + Returns: + list: A list of article collection names. + """ + article_collections = self.user_arango.execute_aql( 'FOR doc IN article_collections RETURN doc["name"]' ) return list(article_collections) def get_projects(self) -> list: - projects = self.user_arango.db.aql.execute( - 'FOR doc IN projects RETURN doc["name"]' - ) - return list(projects) + """ + Gets the names of all projects for the current user. + Returns: + list: A list of project names. + """ + projects = self.user_arango.get_projects(username=self.username) + return [project["name"] for project in projects] def get_chromadb(self): return ChromaDB() def get_project(self, project_name: str): - doc = self.user_arango.db.aql.execute( - f'FOR doc IN projects FILTER doc["name"] == "{project_name}" RETURN doc', - count=True, - ) - if doc: - return doc.next() + """ + Get a project by name for the current user. + + Args: + project_name (str): The name of the project. + Returns: + dict: The project document if found, None otherwise. + """ + return self.user_arango.get_project(project_name, username=self.username) def set_filename(self, filename=None, folder="other_documents"): """ @@ -77,6 +89,12 @@ class BaseClass: self.file_path = file_path + ".pdf" return file_path + def remove_thinking(self, response): + """Remove the thinking section from the response""" + response_text = response.content if hasattr(response, "content") else str(response) + if "" in response_text: + return response_text.split("")[1].strip() + return response_text class StreamlitBaseClass(BaseClass): """ @@ -98,10 +116,11 @@ class StreamlitBaseClass(BaseClass): Displays a select box for choosing a collection of favorite articles. Updates the current collection in the session state and the database. choose_project(text="Select a project") -> str: Displays a select box for choosing a project. Updates the current project in the session state and the database. - """ + """ + def __init__(self, username: str, **kwargs) -> None: super().__init__(username, **kwargs) - + def get_settings(self, field: str = None): """ Retrieve or initialize user settings from the database. @@ -112,24 +131,31 @@ class StreamlitBaseClass(BaseClass): are then stored in the Streamlit session state. Args: - field (str, optional): The specific field to retrieve from the settings. + field (str, optional): The specific field to retrieve from the settings. If not provided, the entire settings document is returned. Returns: - dict or any: The entire settings document if no field is specified, + dict or any: The entire settings document if no field is specified, otherwise the value of the specified field. """ - settings = self.user_arango.db.document("settings/settings") + settings = self.user_arango.get_settings() if not settings: - self.user_arango.db.collection("settings").insert( - {"_key": "settings", "current_collection": None, "current_page": None} - ) + default_settings = { + "_key": "settings", + "current_collection": None, + "current_page": None, + } + self.user_arango.initialize_settings(default_settings) + settings = default_settings + + # Ensure required fields exist for i in ["current_collection", "current_page"]: if i not in settings: settings[i] = None + st.session_state["settings"] = settings if field: - return settings[field] + return settings.get(field) return settings def update_settings(self, key, value) -> None: @@ -189,7 +215,6 @@ class StreamlitBaseClass(BaseClass): st.session_state["current_page"] = page_name self.update_settings("current_page", page_name) - def choose_collection(self, text="Select a collection of favorite articles") -> str: """ Prompts the user to select a collection of favorite articles from a list. @@ -214,7 +239,7 @@ class StreamlitBaseClass(BaseClass): self.update_settings("current_collection", collection) self.update_session_state() return collection - + def choose_project(self, text="Select a project") -> str: """ Prompts the user to select a project from a list of available projects. @@ -231,16 +256,188 @@ class StreamlitBaseClass(BaseClass): - Prints the chosen project name to the console. """ projects = self.get_projects() - print('projects', projects) + print("projects", projects) print(self.project_name) - - project = st.selectbox(text, projects, index=projects.index(self.project_name) if self.project_name in projects else None) - print('Choosing project...') + + project = st.selectbox( + text, + projects, + index=( + projects.index(self.project_name) + if self.project_name in projects + else None + ), + ) + print("Choosing project...") if project: from projects_page import Project + self.project = Project(self.username, project, self.user_arango) self.collection = None self.update_settings("current_project", self.project.name) self.update_session_state() - print('CHOOSEN PROJECT:', self.project.name) + print("CHOOSEN PROJECT:", self.project.name) return self.project + + def add_article_to_collection(self, article_id: str, collection_name: str = None): + """ + Add an article to a user's collection. + + Args: + article_id (str): The ID of the article. + collection_name (str, optional): The name of the collection. Defaults to current collection. + + Returns: + bool: True if the article was added successfully. + """ + if collection_name is None: + collection_name = self.collection + + return self.user_arango.add_article_to_collection(article_id, collection_name) + + def remove_article_from_collection( + self, article_id: str, collection_name: str = None + ): + """ + Remove an article from a user's collection. + + Args: + article_id (str): The ID of the article. + collection_name (str, optional): The name of the collection. Defaults to current collection. + + Returns: + bool: True if the article was removed successfully. + """ + if collection_name is None: + collection_name = self.collection + + return self.user_arango.remove_article_from_collection( + article_id, collection_name + ) + + def get_project_notes(self, project_name: str = None): + """ + Get notes for a project. + + Args: + project_name (str, optional): The name of the project. Defaults to current project. + + Returns: + list: A list of note documents. + """ + if project_name is None: + project_name = self.project_name + + return self.user_arango.get_project_notes(project_name, username=self.username) + + def add_note_to_project(self, note_data: dict): + """ + Add a note to a project. + + Args: + note_data (dict): The note data. Should contain project, username, and timestamp. + + Returns: + dict: The created note document. + """ + if "project" not in note_data: + note_data["project"] = self.project_name + if "username" not in note_data: + note_data["username"] = self.username + + return self.user_arango.add_note_to_project(note_data) + + def create_project(self, project_data: dict): + """ + Create a new project for the current user. + + Args: + project_data (dict): The project data. Should include a name field. + + Returns: + dict: The created project document. + """ + if "username" not in project_data: + project_data["username"] = self.username + + return self.user_arango.create_project(project_data) + + def update_project(self, project_data: dict): + """ + Update an existing project. + + Args: + project_data (dict): The project data. Must include _key. + + Returns: + dict: The updated project document. + """ + return self.user_arango.update_project(project_data) + + def delete_project(self, project_name: str): + """ + Delete a project for the current user. + + Args: + project_name (str): The name of the project. + + Returns: + bool: True if the project was deleted successfully. + """ + return self.user_arango.delete_project(project_name, username=self.username) + + def get_chat(self, chat_key: str): + """ + Get a chat by key. + + Args: + chat_key (str): The key of the chat. + + Returns: + dict: The chat document if found, None otherwise. + """ + return self.user_arango.get_chat(chat_key) + + def create_or_update_chat(self, chat_data: dict): + """ + Create or update a chat. + + Args: + chat_data (dict): The chat data. + + Returns: + dict: The created or updated chat document. + """ + if "username" not in chat_data: + chat_data["username"] = self.username + + return self.user_arango.create_or_update_chat(chat_data) + + def get_chats_for_project(self, project_name: str = None): + """ + Get all chats for a project. + + Args: + project_name (str, optional): The name of the project. Defaults to current project. + + Returns: + list: A list of chat documents. + """ + if project_name is None: + project_name = self.project_name + + return self.user_arango.get_chats_for_project( + project_name, username=self.username + ) + + def delete_chat(self, chat_key: str): + """ + Delete a chat. + + Args: + chat_key (str): The key of the chat. + + Returns: + dict: The deletion result. + """ + return self.user_arango.delete_chat(chat_key) diff --git a/_bots.py b/_bots.py deleted file mode 100644 index f172b43..0000000 --- a/_bots.py +++ /dev/null @@ -1,800 +0,0 @@ -from datetime import datetime -import streamlit as st -from _base_class import StreamlitBaseClass, BaseClass -from _llm import LLM -from prompts import * -from colorprinter.print_color import * -from llm_tools import ToolRegistry - -class Chat(StreamlitBaseClass): - def __init__(self, username=None, **kwargs): - super().__init__(username=username, **kwargs) - self.name = kwargs.get("name", None) - self.chat_history = kwargs.get("chat_history", []) - - - def add_message(self, role, content): - self.chat_history.append( - { - "role": role, - "content": content.strip().strip('"'), - "role_type": self.role, - } - ) - - def to_dict(self): - return { - "_key": self._key, - "name": self.name, - "chat_history": self.chat_history, - "role": self.role, - "username": self.username, - } - - def update_in_arango(self): - self.last_updated = datetime.now().isoformat() - self.user_arango.db.collection("chats").insert( - self.to_dict(), overwrite=True, overwrite_mode="update" - ) - - def set_name(self, user_input): - llm = LLM( - model="small", - max_length_answer=50, - temperature=0.4, - system_message="You are a chatbot who will be chatting with a user", - ) - prompt = ( - f'Give a short name to the chat based on this user input: "{user_input}" ' - "No more than 30 characters. Answer ONLY with the name of the chat." - ) - name = llm.generate(prompt).content.strip('"') - name = f'{name} - {datetime.now().strftime("%B %d")}' - existing_chat = self.user_arango.db.aql.execute( - f'FOR doc IN chats FILTER doc.name == "{name}" RETURN doc', count=True - ) - if existing_chat.count() > 0: - name = f'{name} ({datetime.now().strftime("%H:%M")})' - name += f" - [{self.role}]" - self.name = name - return name - - @classmethod - def from_dict(cls, data): - return cls( - username=data.get("username"), - name=data.get("name"), - chat_history=data.get("chat_history", []), - role=data.get("role", "Research Assistant"), - _key=data.get("_key"), - ) - - def chat_history2bot(self, n_messages: int = None, remove_system: bool = False): - history = [ - {"role": m["role"], "content": m["content"]} for m in self.chat_history - ] - if n_messages and len(history) > n_messages: - history = history[-n_messages:] - if ( - all([history[0]["role"] == "system", remove_system]) - or history[0]["role"] == "assistant" - ): - history = history[1:] - return history - - -class Bot(BaseClass): - def __init__(self, username: str, chat: Chat = None, tools: list = None, **kwargs): - super().__init__(username=username, **kwargs) - - # Use the passed in chat or create a new Chat - self.chat = chat if chat else Chat(username=username, role="Research Assistant") - print_yellow(f"Chat:", chat, type(chat)) - # Store or set up project/collection if available - self.project = kwargs.get("project", None) - self.collection = kwargs.get("collection", None) - if self.collection and not isinstance(self.collection, list): - self.collection = [self.collection] - - # Load articles in the collections - self.arango_ids = [] - if self.collection: - for c in self.collection: - for _id in self.user_arango.db.aql.execute( - """ - FOR doc IN article_collections - FILTER doc.name == @collection - FOR article IN doc.articles - RETURN article._id - """, - bind_vars={"collection": c}, - ): - self.arango_ids.append(_id) - - # A standard LLM for normal chat - self.chatbot = LLM(messages=self.chat.chat_history2bot()) - # A helper bot for generating queries or short prompts - self.helperbot = LLM( - temperature=0, - model="small", - max_length_answer=500, - system_message=get_query_builder_system_message(), - messages=self.chat.chat_history2bot(n_messages=4, remove_system=True), - ) - # A specialized LLM picking which tool to use - self.toolbot = LLM( - temperature=0, - system_message=""" - You are an assistant bot helping an answering bot to answer a user's messages. - Your task is to choose one or multiple tools that will help the answering bot to provide the user with the best possible answer. - You should NEVER directly answer the user. You MUST choose a tool. - """, - chat=False, - model="small", - ) - - # Load or register the passed-in tools - if tools: - self.tools = ToolRegistry.get_tools(tools=tools) - else: - self.tools = ToolRegistry.get_tools() - - # Store other kwargs - for arg in kwargs: - setattr(self, arg, kwargs[arg]) - - - - - def get_chunks( - self, - user_input, - collections=["sci_articles", "other_documents"], - n_results=7, - n_sources=4, - filter=True, - ): - # Basic version without Streamlit calls - query = self.helperbot.generate( - get_generate_vector_query_prompt(user_input, self.chat.role) - ).content.strip('"') - - combined_chunks = [] - if collections: - for collection in collections: - where_filter = {"_id": {"$in": self.arango_ids}} if filter else {} - chunks = self.get_chromadb().query( - query=query, - collection=collection, - n_results=n_results, - n_sources=n_sources, - where=where_filter, - max_retries=3, - ) - for doc, meta, dist in zip( - chunks["documents"][0], - chunks["metadatas"][0], - chunks["distances"][0], - ): - combined_chunks.append( - {"document": doc, "metadata": meta, "distance": dist} - ) - combined_chunks.sort(key=lambda x: x["distance"]) - - # Keep the best chunks according to n_sources - sources = set() - closest_chunks = [] - for chunk in combined_chunks: - source_id = chunk["metadata"].get("_id", "no_id") - if source_id not in sources: - sources.add(source_id) - closest_chunks.append(chunk) - if len(sources) >= n_sources: - break - if len(closest_chunks) < n_results: - remaining_chunks = [ - c for c in combined_chunks if c not in closest_chunks - ] - closest_chunks.extend(remaining_chunks[: n_results - len(closest_chunks)]) - - # Now fetch real metadata from Arango - for chunk in closest_chunks: - _id = chunk["metadata"].get("_id") - if not _id: - continue - if _id.startswith("sci_articles"): - arango_doc = self.base_arango.db.document(_id) - else: - arango_doc = self.user_arango.db.document(_id) - if arango_doc: - arango_metadata = arango_doc.get("metadata", {}) - # Possibly merge notes - if "user_notes" in arango_doc: - arango_metadata["user_notes"] = arango_doc["user_notes"] - chunk["metadata"] = arango_metadata - - # Group by article title - grouped_chunks = {} - article_number = 1 - for chunk in closest_chunks: - title = chunk["metadata"].get("title", "No title") - chunk["article_number"] = article_number - if title not in grouped_chunks: - grouped_chunks[title] = { - "article_number": article_number, - "chunks": [], - } - article_number += 1 - grouped_chunks[title]["chunks"].append(chunk) - return grouped_chunks - - def answer_tool_call(self, response, user_input): - bot_responses = [] - # This method returns / stores responses (no Streamlit calls) - if not response.get("tool_calls"): - return "" - - for tool in response.get("tool_calls"): - function_name = tool.function.get('name') - arguments = tool.function.arguments - arguments["query"] = user_input - - if hasattr(self, function_name): - if function_name in [ - "fetch_other_documents_tool", - "fetch_science_articles_tool", - "fetch_science_articles_and_other_documents_tool", - ]: - chunks = getattr(self, function_name)(**arguments) - bot_responses.append( - self.generate_from_chunks(user_input, chunks).strip('"') - ) - elif function_name == "fetch_notes_tool": - notes = getattr(self, function_name)() - bot_responses.append( - self.generate_from_notes(user_input, notes).strip('"') - ) - elif function_name == "conversational_response_tool": - bot_responses.append( - getattr(self, function_name)(user_input).strip('"') - ) - return "\n\n".join(bot_responses) - - def process_user_input(self, user_input, content_attachment=None): - # Add user message - self.chat.add_message("user", user_input) - - if not content_attachment: - prompt = get_tools_prompt(user_input) - response = self.toolbot.generate(prompt, tools=self.tools, stream=False) - if response.get("tool_calls"): - bot_response = self.answer_tool_call(response, user_input) - else: - # Just respond directly - bot_response = response.content.strip('"') - else: - # If there's an attachment, do something minimal - bot_response = "Content attachment received (Base Bot)." - - # Add assistant message - if self.chat.chat_history[-1]["role"] != "assistant": - self.chat.add_message("assistant", bot_response) - - # Update in Arango - self.chat.update_in_arango() - return bot_response - - def generate_from_notes(self, user_input, notes): - # No Streamlit calls - notes_string = "" - for note in notes: - notes_string += f"\n# {note.get('title','No title')}\n{note.get('content','')}\n---\n" - prompt = get_chat_prompt(user_input, content_string=notes_string, role=self.chat.role) - return self.chatbot.generate(prompt, stream=True) - - def generate_from_chunks(self, user_input, chunks): - # No Streamlit calls - chunks_string = "" - for title, group in chunks.items(): - user_notes_string = "" - if "user_notes" in group["chunks"][0]["metadata"]: - notes = group["chunks"][0]["metadata"]["user_notes"] - user_notes_string = f'\n\nUser notes:\n"""\n{notes}\n"""\n\n' - docs = "\n(...)\n".join([c["document"] for c in group["chunks"]]) - chunks_string += ( - f"\n# {title}\n## Article #{group['article_number']}\n{user_notes_string}{docs}\n---\n" - ) - prompt = get_chat_prompt(user_input, content_string=chunks_string, role=self.chat.role) - return self.chatbot.generate(prompt, stream=True) - - def run(self): - # Base Bot has no Streamlit run loop - pass - - def get_notes(self): - # Minimal note retrieval - notes = self.user_arango.db.aql.execute( - f'FOR doc IN notes FILTER doc.project == "{self.project.name if self.project else ""}" RETURN doc' - ) - return list(notes) - - @ToolRegistry.register - def fetch_science_articles_tool(self, query: str, n_documents: int): - """ - "Fetches information from scientific articles. Use this tool when the user is looking for information from scientific articles." - - Parameters: - query (str): The search query to find relevant scientific articles. - n_documents (int): How many documents to fetch. A complex query may require more documents. Min: 3, Max: 10. - - Returns: - list: A list of chunks containing information from the fetched scientific articles. - """ - print_purple('Query:', query) - - n_documents = int(n_documents) - if n_documents < 3: - n_documents = 3 - elif n_documents > 10: - n_documents = 10 - return self.get_chunks( - query, collections=["sci_articles"], n_results=n_documents - ) - - @ToolRegistry.register - def fetch_other_documents_tool(self, query: str, n_documents: int): - """ - Fetches information from other documents based on the user's query. - - This method retrieves information from various types of documents such as reports, news articles, and other texts. It should be used only when it is clear that the user is not seeking scientific articles. - - Args: - query (str): The search query provided by the user. - n_documents (int): How many documents to fetch. A complex query may require more documents. Min: 2, Max: 10. - - Returns: - list: A list of document chunks that match the query. - """ - assert isinstance(self, Bot), "The first argument must be a Bot object." - n_documents = int(n_documents) - if n_documents < 2: - n_documents = 2 - elif n_documents > 10: - n_documents = 10 - return self.get_chunks( - query, - collections=[f"{self.username}__other_documents"], - n_results=n_documents, - ) - - @ToolRegistry.register - def fetch_science_articles_and_other_documents_tool( - self, query: str, n_documents: int - ): - """ - Fetches information from both scientific articles and other documents. - - This method is often used when the user hasn't specified what kind of sources they are interested in. - - Args: - query (str): The search query to fetch information for. - n_documents (int): How many documents to fetch. A complex query may require more documents. Min: 3, Max: 10. - - Returns: - list: A list of document chunks that match the search query. - """ - assert isinstance(self, Bot), "The first argument must be a Bot object." - n_documents = int(n_documents) - if n_documents < 3: - n_documents = 3 - elif n_documents > 10: - n_documents = 10 - return self.get_chunks( - query, - collections=["sci_articles", f"{self.username}__other_documents"], - n_results=n_documents, - ) - - @ToolRegistry.register - def fetch_notes_tool(bot): - """ - Fetches information from the project notes when you as an editor need context from the project notes to understand other information. ONLY use this together with other tools! No arguments needed. - - Returns: - list: A list of notes. - """ - assert isinstance(bot, Bot), "The first argument must be a Bot object." - return bot.get_notes() - - @ToolRegistry.register - def conversational_response_tool(self, query: str): - """ - Generate a conversational response to a user's query. - - This method is designed to provide a short and conversational response - without fetching additional data. It should be used only when it is clear - that the user is engaging in small talk (like saying 'hi') and not seeking detailed information. - - Args: - query (str): The user's message to which the bot should respond. - - Returns: - str: The generated conversational response. - """ - query = f""" - User message: "{query}". - Make your answer short and conversational. - This is perhaps not a conversation about a journalistic project, so try not to be too informative. - Don't answer with anything you're not sure of! - """ - - result = ( - self.chatbot.generate(query, stream=True) - if self.chatbot - else self.llm.generate(query, stream=True) - ) - return result - -class StreamlitBot(Bot): - def __init__(self, username: str, chat: StreamlitChat = None, tools: list = None, **kwargs): - print_purple("StreamlitBot init chat:", chat) - super().__init__(username=username, chat=chat, tools=tools, **kwargs) - - # For Streamlit, we can override or add attributes - if 'llm_chosen_backend' not in st.session_state: - st.session_state['llm_chosen_backend'] = None - - self.chatbot.chosen_backend = st.session_state['llm_chosen_backend'] - if not st.session_state['llm_chosen_backend']: - st.session_state['llm_chosen_backend'] = self.chatbot.chosen_backend - - def run(self): - # Example Streamlit run loop - self.chat.show_chat_history() - if user_input := st.chat_input("Write your message here...", accept_file=True): - text_input = user_input.text.replace('"""', "---") - if len(user_input.files) > 1: - st.error("Please upload only one file at a time.") - return - attached_file = user_input.files[0] if user_input.files else None - - content_attachment = None - if attached_file: - if attached_file.type == "application/pdf": - import fitz - pdf_document = fitz.open(stream=attached_file.read(), filetype="pdf") - pdf_text = "" - for page_num in range(len(pdf_document)): - page = pdf_document.load_page(page_num) - pdf_text += page.get_text() - content_attachment = pdf_text - elif attached_file.type in ["image/png", "image/jpeg"]: - self.chat.message_attachments = "image" - content_attachment = attached_file.read() - with st.chat_message("user", avatar=self.chat.get_avatar(role="user")): - st.image(content_attachment) - - with st.chat_message("user", avatar=self.chat.get_avatar(role="user")): - st.write(text_input) - - if not self.chat.name: - self.chat.set_name(text_input) - self.chat.last_updated = datetime.now().isoformat() - self.chat.saved = False - self.user_arango.db.collection("chats").insert( - self.chat.to_dict(), overwrite=True, overwrite_mode="update" - ) - - self.process_user_input(text_input, content_attachment) - - def process_user_input(self, user_input, content_attachment=None): - # We override to show messages in Streamlit instead of just storing - self.chat.add_message("user", user_input) - if not content_attachment: - prompt = get_tools_prompt(user_input) - response = self.toolbot.generate(prompt, tools=self.tools, stream=False) - if response.get("tool_calls"): - bot_response = self.answer_tool_call(response, user_input) - else: - bot_response = response.content.strip('"') - with st.chat_message("assistant", avatar=self.chat.get_avatar(role="assistant")): - st.write(bot_response) - else: - with st.chat_message("assistant", avatar=self.chat.get_avatar(role="assistant")): - with st.spinner("Reading the content..."): - if self.chat.message_attachments == "image": - prompt = get_chat_prompt(user_input, role=self.chat.role, image_attachment=True) - bot_resp = self.chatbot.generate(prompt, stream=False, images=[content_attachment], model="vision") - st.write(bot_resp) - bot_response = bot_resp - else: - prompt = get_chat_prompt(user_input, content_attachment=content_attachment, role=self.chat.role) - response = self.chatbot.generate(prompt, stream=True) - bot_response = st.write_stream(response) - - if self.chat.chat_history[-1]["role"] != "assistant": - self.chat.add_message("assistant", bot_response) - - self.chat.update_in_arango() - - def answer_tool_call(self, response, user_input): - bot_responses = [] - for tool in response.get("tool_calls", []): - function_name = tool.function.get('name') - arguments = tool.function.arguments - arguments["query"] = user_input - - with st.chat_message("assistant", avatar=self.chat.get_avatar(role="assistant")): - if function_name in [ - "fetch_other_documents_tool", - "fetch_science_articles_tool", - "fetch_science_articles_and_other_documents_tool", - ]: - chunks = getattr(self, function_name)(**arguments) - response_text = self.generate_from_chunks(user_input, chunks) - bot_response = st.write_stream(response_text).strip('"') - if chunks: - sources = "###### Sources:\n" - for title, group in chunks.items(): - j = group["chunks"][0]["metadata"].get("journal", "No Journal") - d = group["chunks"][0]["metadata"].get("published_date", "No Date") - sources += f"[{group['article_number']}] **{title}** :gray[{j} ({d})]\n" - st.markdown(sources) - bot_response += f"\n\n{sources}" - bot_responses.append(bot_response) - - elif function_name == "fetch_notes_tool": - notes = getattr(self, function_name)() - response_text = self.generate_from_notes(user_input, notes) - bot_responses.append(st.write_stream(response_text).strip('"')) - - elif function_name == "conversational_response_tool": - response_text = getattr(self, function_name)(user_input) - bot_responses.append(st.write_stream(response_text).strip('"')) - - return "\n\n".join(bot_responses) - - def generate_from_notes(self, user_input, notes): - with st.spinner("Reading project notes..."): - return super().generate_from_notes(user_input, notes) - - def generate_from_chunks(self, user_input, chunks): - # For reading articles with a spinner - magazines = set() - for group in chunks.values(): - j = group["chunks"][0]["metadata"].get("journal", "No Journal") - magazines.add(f"*{j}*") - s = ( - f"Reading articles from {', '.join(list(magazines)[:-1])} and {list(magazines)[-1]}..." - if len(magazines) > 1 - else "Reading articles..." - ) - with st.spinner(s): - return super().generate_from_chunks(user_input, chunks) - - def sidebar_content(self): - with st.sidebar: - st.write("---") - st.markdown(f'#### {self.chat.name if self.chat.name else ""}') - st.button("Delete this chat", on_click=self.delete_chat) - - def delete_chat(self): - self.user_arango.db.collection("chats").delete_match( - filters={"name": self.chat.name} - ) - self.chat = Chat() - - def get_notes(self): - # We can show a spinner or messages too - with st.spinner("Fetching notes..."): - return super().get_notes() - - -class EditorBot(StreamlitBot(Bot)): - def __init__(self, chat: Chat, username: str, **kwargs): - print_blue("EditorBot init chat:", chat) - super().__init__(chat=chat, username=username, **kwargs) - self.role = "Editor" - self.tools = ToolRegistry.get_tools() - self.chatbot = LLM( - system_message=get_editor_prompt(kwargs.get("project")), - messages=self.chat.chat_history2bot(), - chosen_backend=kwargs.get("chosen_backend"), - ) - - -class ResearchAssistantBot(StreamlitBot(Bot)): - def __init__(self, chat: Chat, username: str, **kwargs): - super().__init__(chat=chat, username=username, **kwargs) - self.role = "Research Assistant" - self.chatbot = LLM( - system_message=get_assistant_prompt(), - temperature=0.1, - messages=self.chat.chat_history2bot(), - ) - self.tools = [ - self.fetch_science_articles_tool, - self.fetch_science_articles_and_other_documents_tool, - ] - - -class PodBot(StreamlitBot(Bot)): - """Two LLM agents construct a conversation using material from science articles.""" - - def __init__( - self, - chat: Chat, - subject: str, - username: str, - instructions: str = None, - **kwargs, - ): - super().__init__(chat=chat, username=username, **kwargs) - self.subject = subject - self.instructions = instructions - self.guest_name = kwargs.get("name_guest", "Merit") - self.hostbot = HostBot( - Chat(username=self.username, role="Host"), - subject, - username, - instructions=instructions, - **kwargs, - ) - self.guestbot = GuestBot( - Chat(username=self.username, role="Guest"), - subject, - username, - name_guest=self.guest_name, - **kwargs, - ) - - def run(self): - - notes = self.get_notes() - notes_string = "" - if self.instructions: - instructions_string = f''' - These are the instructions for the podcast from the producer: - """ - {self.instructions} - """ - ''' - else: - instructions_string = "" - - for note in notes: - notes_string += f"\n# {note['title']}\n{note['content']}\n---\n" - a = f'''You will make a podcast interview with {self.guest_name}, an expert on "{self.subject}". - {instructions_string} - Below are notes on the subject that you can use to ask relevant questions: - """ - {notes_string} - """ - Say hello to the expert and start the interview. Remember to keep the interview to the subject of {self.subject} throughout the conversation. - ''' - - # Stop button for the podcast - with st.sidebar: - stop = st.button("Stop podcast", on_click=self.stop_podcast) - - while st.session_state["make_podcast"]: - # Stop the podcast if there are more than 14 messages in the chat - self.chat.show_chat_history() - if len(self.chat.chat_history) == 14: - result = self.hostbot.generate( - "The interview has ended. Say thank you to the expert and end the conversation." - ) - self.chat.add_message("Host", result) - with st.chat_message( - "assistant", avatar=self.chat.get_avatar(role="assistant") - ): - st.write(result.strip('"')) - st.stop() - - _q = self.hostbot.toolbot.generate( - query=f"{self.guest_name} has answered: {a}. You have to choose a tool to help the host continue the interview.", - tools=self.hostbot.tools, - temperature=0.6, - stream=False, - ) - if "tool_calls" in _q: - q = self.hostbot.answer_tool_call(_q, a) - else: - q = _q - - self.chat.add_message("Host", q) - - _a = self.guestbot.toolbot.generate( - f'The podcast host has asked: "{q}" Choose a tool to help the expert answer with relevant facts and information.', - tools=self.guestbot.tools, - ) - if "tool_calls" in _a: - print_yellow("Tool call response (guest)", _a) - print_yellow(self.guestbot.chat.role) - a = self.guestbot.answer_tool_call(_a, q) - else: - a = _a - self.chat.add_message("Guest", a) - - self.update_session_state() - - def stop_podcast(self): - st.session_state["make_podcast"] = False - self.update_session_state() - self.chat.show_chat_history() - - -class HostBot(StreamlitBot(Bot)): - def __init__( - self, chat: Chat, subject: str, username: str, instructions: str, **kwargs - ): - super().__init__(chat=chat, username=username, **kwargs) - self.chat.role = kwargs.get("role", "Host") - self.tools = ToolRegistry.get_tools( - tools=[ - self.fetch_notes_tool, - self.conversational_response_tool, - # "fetch_other_documents", #TODO Should this be included? - ] - ) - self.instructions = instructions - self.llm = LLM( - system_message=f''' - You are the host of a podcast and an expert on {subject}. You will ask one question at a time about the subject, and then wait for the guest to answer. - Don't ask the guest to talk about herself/himself, only about the subject. - Make your questions short and clear, only if necessary add a brief context to the question. - These are the instructions for the podcast from the producer: - """ - {self.instructions} - """ - If the experts' answer is complicated, try to make a very brief summary of it for the audience to understand. You can also ask follow-up questions to clarify the answer, or ask for examples. - ''', - messages=self.chat.chat_history2bot() - ) - self.toolbot = LLM( - temperature=0, - system_message=""" - You are assisting a podcast host in asking questions to an expert. - Choose one or many tools to use in order to assist the host in asking relevant questions. - Often "conversational_response_tool" is enough, but sometimes project notes are needed. - Make sure to read the description of the tools carefully!""", - chat=False, - model="small", - ) - - def generate(self, query): - return self.llm.generate(query) - - -class GuestBot(StreamlitBot(Bot)): - def __init__(self, chat: Chat, subject: str, username: str, **kwargs): - super().__init__(chat=chat, username=username, **kwargs) - self.chat.role = kwargs.get("role", "Guest") - self.tools = ToolRegistry.get_tools( - tools=[ - self.fetch_notes_tool, - self.fetch_science_articles_tool, - ] - ) - - self.llm = LLM( - system_message=f""" - You are {kwargs.get('name', 'Merit')}, an expert on {subject}. - Today you are a guest in a podcast about {subject}. A host will ask you questions about the subject and you will answer by using scientific facts and information. - When answering, don't say things like "based on the documents" or alike, as neither the host nor the audience can see the documents. Act just as if you were talking to someone in a conversation. - Try to be concise when answering, and remember that the audience of the podcast is not expert on the subject, so don't complicate things too much. - It's very important that you answer in a "spoken" way, as if you were talking to someone in a conversation. That means you should avoid using scientific jargon and complex terms, too many figures or abstract concepts. - Lists are also not recommended, instead use "for the first reason", "secondly", etc. - Instead, use "..." to indicate a pause, "-" to indicate a break in the sentence, as if you were speaking. - """, - messages=self.chat.chat_history2bot() - ) - self.toolbot = LLM( - temperature=0, - system_message=f"You are an assistant to an expert on {subject}. Choose one or many tools to use in order to assist the expert in answering questions. Make sure to read the description of the tools carefully.", - chat=False, - model="small", - ) - - def generate(self, query): - return self.llm.generate(query) diff --git a/_bots_dont_use.py b/_bots_dont_use.py new file mode 100644 index 0000000..e1fd68f --- /dev/null +++ b/_bots_dont_use.py @@ -0,0 +1,497 @@ +from datetime import datetime +import streamlit as st +import uuid + +from _base_class import StreamlitBaseClass, BaseClass +from _llm import LLM +from _arango import ArangoDB +from prompts import * +from colorprinter.print_color import * +from llm_tools import ToolRegistry +from streamlit_chatbot import StreamlitBot, PodBot, EditorBot, ResearchAssistantBot + +class Chat(StreamlitBaseClass): + def __init__(self, username=None, **kwargs): + super().__init__(username=username, **kwargs) + self.name = kwargs.get("name", None) + self.chat_history = kwargs.get("chat_history", []) + self.role = kwargs.get("role", "Research Assistant") + self._key = kwargs.get("_key", str(uuid.uuid4())) + self.saved = kwargs.get("saved", False) + self.last_updated = kwargs.get("last_updated", datetime.now().isoformat()) + self.message_attachments = None + self.project = kwargs.get("project", None) + + def add_message(self, role, content): + self.chat_history.append( + { + "role": role, + "content": content.strip().strip('"'), + "role_type": self.role, + } + ) + + def to_dict(self): + return { + "_key": self._key, + "name": self.name, + "chat_history": self.chat_history, + "role": self.role, + "username": self.username, + "project": self.project, + "last_updated": self.last_updated, + "saved": self.saved, + } + + def update_in_arango(self): + """Update chat in ArangoDB using the new API""" + self.last_updated = datetime.now().isoformat() + + # Use the create_or_update_chat method from the new API + self.user_arango.create_or_update_chat(self.to_dict()) + + def set_name(self, user_input): + llm = LLM( + model="small", + max_length_answer=50, + temperature=0.4, + system_message="You are a chatbot who will be chatting with a user", + ) + prompt = ( + f'Give a short name to the chat based on this user input: "{user_input}" ' + "No more than 30 characters. Answer ONLY with the name of the chat." + ) + name = llm.generate(prompt).content.strip('"') + name = f'{name} - {datetime.now().strftime("%B %d")}' + + # Check for existing chat with the same name + existing_chat = self.user_arango.execute_aql( + """ + FOR chat IN chats + FILTER chat.name == @name AND chat.username == @username + RETURN chat + """, + bind_vars={"name": name, "username": self.username} + ) + + if list(existing_chat): + name = f'{name} ({datetime.now().strftime("%H:%M")})' + name += f" - [{self.role}]" + self.name = name + return name + + def show_chat_history(self): + """Display chat history in the Streamlit UI""" + for message in self.chat_history: + with st.chat_message( + name="assistant" if message["role"] == "assistant" else "user", + avatar=self.get_avatar(role=message["role"]) + ): + st.write(message["content"]) + + def get_avatar(self, role): + """Get avatar for a role""" + if role == "user": + return None + elif role == "Host": + return "🎙️" + elif role == "Guest": + return "🎤" + elif role == "assistant": + if self.role == "Research Assistant": + return "🔬" + elif self.role == "Editor": + return "📝" + else: + return "🤖" + return None + + @classmethod + def from_dict(cls, data): + return cls( + username=data.get("username"), + name=data.get("name"), + chat_history=data.get("chat_history", []), + role=data.get("role", "Research Assistant"), + _key=data.get("_key"), + project=data.get("project"), + last_updated=data.get("last_updated"), + saved=data.get("saved", False), + ) + + def chat_history2bot(self, n_messages: int = None, remove_system: bool = False): + history = [ + {"role": m["role"], "content": m["content"]} for m in self.chat_history + ] + if n_messages and len(history) > n_messages: + history = history[-n_messages:] + if ( + all([history[0]["role"] == "system", remove_system]) + or history[0]["role"] == "assistant" + ): + history = history[1:] + return history + + +class Bot(BaseClass): + def __init__(self, username: str, chat: Chat = None, tools: list = None, **kwargs): + super().__init__(username=username, **kwargs) + + # Use the passed in chat or create a new Chat + self.chat = chat if chat else Chat(username=username, role="Research Assistant") + print_yellow(f"Chat:", chat, type(chat)) + + # Store or set up project/collection if available + self.project = kwargs.get("project", None) + self.collection = kwargs.get("collection", None) + if self.collection and not isinstance(self.collection, list): + self.collection = [self.collection] + + # Load articles in the collections using the new API + self.arango_ids = [] + if self.collection: + for c in self.collection: + # Use execute_aql from the new API + article_ids = self.user_arango.execute_aql( + """ + FOR doc IN article_collections + FILTER doc.name == @collection + FOR article IN doc.articles + RETURN article + """, + bind_vars={"collection": c} + ) + for _id in article_ids: + self.arango_ids.append(_id) + + # A standard LLM for normal chat + self.chatbot = LLM(messages=self.chat.chat_history2bot()) + # A helper bot for generating queries or short prompts + self.helperbot = LLM( + temperature=0, + model="small", + max_length_answer=500, + system_message=get_query_builder_system_message(), + messages=self.chat.chat_history2bot(n_messages=4, remove_system=True), + ) + # A specialized LLM picking which tool to use + self.toolbot = LLM( + temperature=0, + system_message=""" + You are an assistant bot helping an answering bot to answer a user's messages. + Your task is to choose one or multiple tools that will help the answering bot to provide the user with the best possible answer. + You should NEVER directly answer the user. You MUST choose a tool. + """, + chat=False, + model="small", + ) + + # Load or register the passed-in tools + if tools: + self.tools = ToolRegistry.get_tools(tools=tools) + else: + self.tools = ToolRegistry.get_tools() + + # Store other kwargs + for arg in kwargs: + setattr(self, arg, kwargs[arg]) + + def get_chunks( + self, + user_input, + collections=["sci_articles", "other_documents"], + n_results=7, + n_sources=4, + filter=True, + ): + # Basic version without Streamlit calls + query = self.helperbot.generate( + get_generate_vector_query_prompt(user_input, self.chat.role) + ).content.strip('"') + + combined_chunks = [] + if collections: + for collection in collections: + where_filter = {"_id": {"$in": self.arango_ids}} if filter else {} + chunks = self.get_chromadb().query( + query=query, + collection=collection, + n_results=n_results, + n_sources=n_sources, + where=where_filter, + max_retries=3, + ) + for doc, meta, dist in zip( + chunks["documents"][0], + chunks["metadatas"][0], + chunks["distances"][0], + ): + combined_chunks.append( + {"document": doc, "metadata": meta, "distance": dist} + ) + combined_chunks.sort(key=lambda x: x["distance"]) + + # Keep the best chunks according to n_sources + sources = set() + closest_chunks = [] + for chunk in combined_chunks: + source_id = chunk["metadata"].get("_id", "no_id") + if source_id not in sources: + sources.add(source_id) + closest_chunks.append(chunk) + if len(sources) >= n_sources: + break + if len(closest_chunks) < n_results: + remaining_chunks = [ + c for c in combined_chunks if c not in closest_chunks + ] + closest_chunks.extend(remaining_chunks[: n_results - len(closest_chunks)]) + + # Now fetch real metadata from Arango using the new API + for chunk in closest_chunks: + _id = chunk["metadata"].get("_id") + if not _id: + continue + + try: + # Determine which database to use based on collection name + if _id.startswith("sci_articles"): + # Use base_arango for common documents + arango_doc = self.base_arango.get_document(_id) + else: + # Use user_arango for user-specific documents + arango_doc = self.user_arango.get_document(_id) + + if arango_doc: + arango_metadata = arango_doc.get("metadata", {}) + # Possibly merge notes + if "user_notes" in arango_doc: + arango_metadata["user_notes"] = arango_doc["user_notes"] + chunk["metadata"] = arango_metadata + except Exception as e: + print_red(f"Error fetching document {_id}: {e}") + + # Group by article title + grouped_chunks = {} + article_number = 1 + for chunk in closest_chunks: + title = chunk["metadata"].get("title", "No title") + chunk["article_number"] = article_number + if title not in grouped_chunks: + grouped_chunks[title] = { + "article_number": article_number, + "chunks": [], + } + article_number += 1 + grouped_chunks[title]["chunks"].append(chunk) + return grouped_chunks + + def answer_tool_call(self, response, user_input): + bot_responses = [] + # This method returns / stores responses (no Streamlit calls) + if not response.get("tool_calls"): + return "" + + for tool in response.get("tool_calls"): + function_name = tool.function.get('name') + arguments = tool.function.arguments + arguments["query"] = user_input + + if hasattr(self, function_name): + if function_name in [ + "fetch_other_documents_tool", + "fetch_science_articles_tool", + "fetch_science_articles_and_other_documents_tool", + ]: + chunks = getattr(self, function_name)(**arguments) + bot_responses.append( + self.generate_from_chunks(user_input, chunks).strip('"') + ) + elif function_name == "fetch_notes_tool": + notes = getattr(self, function_name)() + bot_responses.append( + self.generate_from_notes(user_input, notes).strip('"') + ) + elif function_name == "conversational_response_tool": + bot_responses.append( + getattr(self, function_name)(user_input).strip('"') + ) + return "\n\n".join(bot_responses) + + def process_user_input(self, user_input, content_attachment=None): + # Add user message + self.chat.add_message("user", user_input) + + if not content_attachment: + prompt = get_tools_prompt(user_input) + response = self.toolbot.generate(prompt, tools=self.tools, stream=False) + if response.get("tool_calls"): + bot_response = self.answer_tool_call(response, user_input) + else: + # Just respond directly + bot_response = response.content.strip('"') + else: + # If there's an attachment, do something minimal + bot_response = "Content attachment received (Base Bot)." + + # Add assistant message + if self.chat.chat_history[-1]["role"] != "assistant": + self.chat.add_message("assistant", bot_response) + + # Update in Arango + self.chat.update_in_arango() + return bot_response + + def generate_from_notes(self, user_input, notes): + # No Streamlit calls + notes_string = "" + for note in notes: + notes_string += f"\n# {note.get('title','No title')}\n{note.get('text','')}\n---\n" + prompt = get_chat_prompt(user_input, content_string=notes_string, role=self.chat.role) + return self.chatbot.generate(prompt, stream=True) + + def generate_from_chunks(self, user_input, chunks): + # No Streamlit calls + chunks_string = "" + for title, group in chunks.items(): + user_notes_string = "" + if "user_notes" in group["chunks"][0]["metadata"]: + notes = group["chunks"][0]["metadata"]["user_notes"] + user_notes_string = f'\n\nUser notes:\n"""\n{notes}\n"""\n\n' + docs = "\n(...)\n".join([c["document"] for c in group["chunks"]]) + chunks_string += ( + f"\n# {title}\n## Article #{group['article_number']}\n{user_notes_string}{docs}\n---\n" + ) + prompt = get_chat_prompt(user_input, content_string=chunks_string, role=self.chat.role) + return self.chatbot.generate(prompt, stream=True) + + def run(self): + # Base Bot has no Streamlit run loop + pass + + def get_notes(self): + # Get project notes using the new API + if self.project and hasattr(self.project, "name"): + notes = self.user_arango.get_project_notes( + project_name=self.project.name, + username=self.username + ) + return list(notes) + return [] + + @ToolRegistry.register + def fetch_science_articles_tool(self, query: str, n_documents: int): + """ + "Fetches information from scientific articles. Use this tool when the user is looking for information from scientific articles." + + Parameters: + query (str): The search query to find relevant scientific articles. + n_documents (int): How many documents to fetch. A complex query may require more documents. Min: 3, Max: 10. + + Returns: + list: A list of chunks containing information from the fetched scientific articles. + """ + print_purple('Query:', query) + + n_documents = int(n_documents) + if n_documents < 3: + n_documents = 3 + elif n_documents > 10: + n_documents = 10 + return self.get_chunks( + query, collections=["sci_articles"], n_results=n_documents + ) + + @ToolRegistry.register + def fetch_other_documents_tool(self, query: str, n_documents: int): + """ + Fetches information from other documents based on the user's query. + + This method retrieves information from various types of documents such as reports, news articles, and other texts. It should be used only when it is clear that the user is not seeking scientific articles. + + Args: + query (str): The search query provided by the user. + n_documents (int): How many documents to fetch. A complex query may require more documents. Min: 2, Max: 10. + + Returns: + list: A list of document chunks that match the query. + """ + assert isinstance(self, Bot), "The first argument must be a Bot object." + n_documents = int(n_documents) + if n_documents < 2: + n_documents = 2 + elif n_documents > 10: + n_documents = 10 + return self.get_chunks( + query, + collections=[f"{self.username}__other_documents"], + n_results=n_documents, + ) + + @ToolRegistry.register + def fetch_science_articles_and_other_documents_tool( + self, query: str, n_documents: int + ): + """ + Fetches information from both scientific articles and other documents. + + This method is often used when the user hasn't specified what kind of sources they are interested in. + + Args: + query (str): The search query to fetch information for. + n_documents (int): How many documents to fetch. A complex query may require more documents. Min: 3, Max: 10. + + Returns: + list: A list of document chunks that match the search query. + """ + assert isinstance(self, Bot), "The first argument must be a Bot object." + n_documents = int(n_documents) + if n_documents < 3: + n_documents = 3 + elif n_documents > 10: + n_documents = 10 + return self.get_chunks( + query, + collections=["sci_articles", f"{self.username}__other_documents"], + n_results=n_documents, + ) + + @ToolRegistry.register + def fetch_notes_tool(bot): + """ + Fetches information from the project notes when you as an editor need context from the project notes to understand other information. ONLY use this together with other tools! No arguments needed. + + Returns: + list: A list of notes. + """ + assert isinstance(bot, Bot), "The first argument must be a Bot object." + return bot.get_notes() + + @ToolRegistry.register + def conversational_response_tool(self, query: str): + """ + Generate a conversational response to a user's query. + + This method is designed to provide a short and conversational response + without fetching additional data. It should be used only when it is clear + that the user is engaging in small talk (like saying 'hi') and not seeking detailed information. + + Args: + query (str): The user's message to which the bot should respond. + + Returns: + str: The generated conversational response. + """ + query = f""" + User message: "{query}". + Make your answer short and conversational. + This is perhaps not a conversation about a journalistic project, so try not to be too informative. + Don't answer with anything you're not sure of! + """ + + result = ( + self.chatbot.generate(query, stream=True) + if self.chatbot + else self.llm.generate(query, stream=True) + ) + return result diff --git a/_chromadb.py b/_chromadb.py index d73dec0..47413bc 100644 --- a/_chromadb.py +++ b/_chromadb.py @@ -1,8 +1,13 @@ import chromadb import os +from typing import Union, List, Dict, Tuple, Any, Union +import re + from chromadb.config import Settings from dotenv import load_dotenv from colorprinter.print_color import * +from models import ChunkSearchResults + load_dotenv(".env") @@ -20,6 +25,7 @@ class ChromaDB: ) self.db = chromadb.HttpClient( host=host, + #database=db, settings=Settings( chroma_client_auth_provider="chromadb.auth.token_authn.TokenAuthClientProvider", chroma_client_auth_credentials=credentials, @@ -63,14 +69,20 @@ class ChromaDB: col = self.db.get_collection(collection) sources = [] n = 0 - + print('Collection', collection) result = {"ids": [[]], "metadatas": [[]], "documents": [[]], "distances": [[]]} + while True: n += 1 if n > max_retries: break if where == {}: - where = None + where = None + + print_rainbow(kwargs) + print('N_results:', n_results) + print('Sources:', sources) + print('Query:', query) r = col.query( query_texts=query, n_results=n_results - len(sources), @@ -79,6 +91,7 @@ class ChromaDB: ) if r["ids"][0] == []: if result["ids"][0] == []: + print_rainbow(r) print_red("No results found in vector database.") else: print_red("No more results found in vector database.") @@ -123,6 +136,210 @@ class ChromaDB: break return result + def search( + self, + query: str, + collection: str, + n_results: int = 6, + n_sources: int = 3, + where: dict = None, + format_results: bool = False, + **kwargs, + ) -> Union[dict, ChunkSearchResults]: + """ + An enhanced search method that provides a cleaner interface for querying and processing results. + + Args: + query (str): The search query + collection (str): Collection name to search in + n_results (int): Maximum number of results to return + n_sources (int): Maximum number of unique sources to include + where (dict, optional): Additional filtering criteria + format_results (bool): Whether to return formatted ChunkSearchResults + **kwargs: Additional arguments to pass to the query + + Returns: + List[dict]: List of dictionaries containing the search results + """ + # Get raw query results with existing query method + result = self.query( + query=query, + collection=collection, + n_results=n_results, + n_sources=n_sources, + where=where, + **kwargs, + ) + + + # If no formatting requested, return raw results + if not format_results: + return result + + # Process results into dictionary format + combined_chunks = [] + for doc, meta, dist, _id in zip( + result["documents"][0], + result["metadatas"][0], + result["distances"][0], + result["ids"][0], + ): + combined_chunks.append( + {"document": doc, "metadata": meta, "distance": dist, "id": _id} + ) + + return combined_chunks + + def clean_result_text(self, documents: list) -> list: + """ + Clean text in document results by removing footnote references. + + Args: + documents (list): List of document dictionaries + + Returns: + list: Documents with cleaned text + """ + import re + + for doc in documents: + if "document" in doc: + doc["document"] = re.sub(r"\[\d+\]", "", doc["document"]) + return documents + + def filter_by_unique_sources( + self, results: list, n_sources: int, source_key: str = "_id" + ) -> Tuple[List, List]: + """ + Filters search results to keep only a specified number of unique sources. + + Args: + results (list): List of documents from search + n_sources (int): Maximum number of unique sources to include + source_key (str): The key in metadata that identifies the source + + Returns: + tuple: (filtered_results, remaining_results) + """ + sources = set() + filtered_results = [] + remaining_results = [] + + for item in results: + source_id = item["metadata"].get(source_key, "no_id") + if source_id not in sources and len(sources) < n_sources: + sources.add(source_id) + filtered_results.append(item) + else: + remaining_results.append(item) + + return filtered_results, remaining_results + + def backfill_results( + self, filtered_results: list, remaining_results: list, n_results: int + ) -> list: + """ + Adds additional results from remaining_results to filtered_results + until n_results is reached. + + Args: + filtered_results (list): Initial filtered results + remaining_results (list): Other results that can be added + n_results (int): Target number of total results + + Returns: + list: Combined results up to n_results + """ + if len(filtered_results) >= n_results: + return filtered_results[:n_results] + + needed = n_results - len(filtered_results) + return filtered_results + remaining_results[:needed] + + def search_chunks( + self, + query: str, + collections: List[str], + n_results: int = 7, + n_sources: int = 4, + where: dict = None, + **kwargs, + ) -> ChunkSearchResults: + """ + Complete pipeline for processing chunks: search, filter, clean, and format. + + Args: + query (str): The search query + collections (List[str]): List of collection names to search + n_results (int): Maximum number of results to return + n_sources (int): Maximum number of unique sources to include + where (dict, optional): Additional filtering criteria + **kwargs: Additional arguments to pass to search + + Returns: + ChunkSearchResults: Processed chunks with Chroma IDs + """ + combined_chunks = [] + + if isinstance(collections, str): + collections = [collections] + + # Search all collections + + for collection in collections: + chunks = self.search( + query=query, + collection=collection, + n_results=n_results, + n_sources=n_sources, + where=where, + format_results=True, + **kwargs, + ) + + + for chunk in chunks: + combined_chunks.append({ + "document": chunk["document"], + "metadata": chunk["metadata"], + "distance": chunk["distance"], + "id": chunk["id"], + }) + + # Sort and filter results + combined_chunks.sort(key=lambda x: x["distance"]) + + # Filter by unique sources and backfill + closest_chunks, remaining_chunks = self.filter_by_unique_sources( + combined_chunks, n_sources + ) + closest_chunks = self.backfill_results( + closest_chunks, remaining_chunks, n_results + ) + + # Clean text + closest_chunks = self.clean_result_text(closest_chunks) + return closest_chunks + + + def add_document(self, _id, collection: str, document: str, metadata: dict = None): + """ + Adds a single document to a specified collection in the database. + + Args: + _id (str): Arango ID for the document, used as a unique identifier. + collection (str): The name of the collection to add the document to. + document (str): The document text to be added. + metadata (dict, optional): Metadata to be associated with the document. Defaults to None. + + Returns: + None + """ + col = self.db.get_or_create_collection(collection) + if metadata is None: + metadata = {} + col.add(ids=[_id], documents=[document], metadatas=[metadata]) + def add_chunks(self, collection: str, chunks: list, _key, metadata: dict = None): """ Adds chunks to a specified collection in the database. @@ -148,18 +365,88 @@ class ChromaDB: ids.append(f"{_key}_{number}") col.add(ids=ids, metadatas=metadatas, documents=chunks) + def get_collection(self, collection: str) -> chromadb.Collection: + """ + Retrieves a collection from the database. + + Args: + collection (str): The name of the collection to retrieve. + + Returns: + chromadb.Collection: The requested collection. + """ + return self.db.get_or_create_collection(collection) + +def is_reference_chunk(text: str) -> bool: + """ + Determine if a text chunk primarily consists of academic references. + + Args: + text (str): Text chunk to analyze + + Returns: + bool: True if the chunk appears to be mainly references + """ + # Count significant reference indicators + indicators = 0 + + # Check for DOI links (very strong indicator) + doi_matches = len(re.findall(r'https?://doi\.org/10\.\d+/\S+', text)) + if doi_matches >= 2: # Multiple DOIs almost certainly means references + return True + elif doi_matches == 1: + indicators += 3 + + # Check for citation patterns with year, volume, pages (e.g., 2018;178:551–60) + citation_patterns = len(re.findall(r'\d{4};\d+:\d+[-–]\d+', text)) + indicators += citation_patterns * 2 + + # Check for year patterns in brackets [YYYY] + year_brackets = len(re.findall(r'\[\d{4}\]', text)) + indicators += year_brackets + + # Check for multiple lines starting with author name patterns + lines = [line.strip() for line in text.split('\n') if line.strip()] + author_started_lines = 0 + + for line in lines: + # Common pattern in references: starts with Author Name(s) + if re.match(r'^\s*[A-Z][a-z]+\s+[A-Z][a-z]+', line): + author_started_lines += 1 + + # If multiple lines start with author names (common in reference lists) + if author_started_lines >= 2: + indicators += 2 + + # Check for academic reference terms + if re.search(r'\bet al\b|\bet al\.\b', text, re.IGNORECASE): + indicators += 1 + + # Return True if we have sufficient indicators + return indicators >= 4 # Adjust threshold as needed if __name__ == "__main__": from colorprinter.print_color import * + chroma = ChromaDB() print(chroma.db.list_collections()) - exit() - result = chroma.query( + print('DB', chroma.db.database) + print('SETTINGS', chroma.db.get_version()) + + result = chroma.search_chunks( query="What is Open Science)", - collection="sci_articles", + collections="lasse__other_documents", n_results=2, n_sources=3, max_retries=4, ) - print_rainbow(result["metadatas"][0]) + + collection = chroma.db.get_or_create_collection("lasse__other_documents") + result = collection.query( + query_texts="What is Open Science?", + n_results=2, + ) + from pprint import pprint + pprint(result) + #print_rainbow(result["metadatas"][0]) diff --git a/_llm.py b/_llm.py deleted file mode 100644 index 05ebe67..0000000 --- a/_llm.py +++ /dev/null @@ -1,574 +0,0 @@ -import os -import base64 -import re -from typing import Literal, Optional -import requests -import tiktoken -from ollama import ( - Client, - AsyncClient, - ResponseError, - ChatResponse, - Tool, - Options, -) - -import env_manager -from colorprinter.print_color import * - -env_manager.set_env() - -tokenizer = tiktoken.get_encoding("cl100k_base") - - -class LLM: - """ - LLM class for interacting with an instance of Ollama. - - Attributes: - model (str): The model to be used for response generation. - system_message (str): The system message to be used in the chat. - options (dict): Options for the model, such as temperature. - messages (list): List of messages in the chat. - max_length_answer (int): Maximum length of the generated answer. - chat (bool): Whether the chat mode is enabled. - chosen_backend (str): The chosen backend server for the API. - client (Client): The client for synchronous API calls. - async_client (AsyncClient): The client for asynchronous API calls. - tools (list): List of tools to be used in generating the response. - - Methods: - __init__(self, system_message, temperature, model, max_length_answer, messages, chat, chosen_backend): - Initializes the LLM class with the provided parameters. - - get_model(self, model_alias): - Retrieves the model name based on the provided alias. - - count_tokens(self): - Counts the number of tokens in the messages. - - get_least_conn_server(self): - Retrieves the least connected server from the backend. - - generate(self, query, user_input, context, stream, tools, images, model, temperature): - Generates a response based on the provided query and options. - - make_summary(self, text): - Generates a summary of the provided text. - - read_stream(self, response): - Handles streaming responses. - - async_generate(self, query, user_input, context, stream, tools, images, model, temperature): - Asynchronously generates a response based on the provided query and options. - - prepare_images(self, images, message): - """ - - def __init__( - self, - system_message: str = "You are an assistant.", - temperature: float = 0.01, - model: Optional[ - Literal["small", "standard", "vision", "reasoning", "tools"] - ] = "standard", - max_length_answer: int = 4096, - messages: list[dict] = None, - chat: bool = True, - chosen_backend: str = None, - tools: list = None, - ) -> None: - """ - Initialize the assistant with the given parameters. - - Args: - system_message (str): The initial system message for the assistant. Defaults to "You are an assistant.". - temperature (float): The temperature setting for the model, affecting randomness. Defaults to 0.01. - model (Optional[Literal["small", "standard", "vision", "reasoning"]]): The model type to use. Defaults to "standard". - max_length_answer (int): The maximum length of the generated answer. Defaults to 4096. - messages (list[dict], optional): A list of initial messages. Defaults to None. - chat (bool): Whether the assistant is in chat mode. Defaults to True. - chosen_backend (str, optional): The backend server to use. If not provided, the least connected server is chosen. - - Returns: - None - """ - - self.model = self.get_model(model) - self.call_model = ( - self.model - ) # This is set per call to decide what model that was actually used - self.system_message = system_message - self.options = {"temperature": temperature} - self.messages = messages or [{"role": "system", "content": self.system_message}] - self.max_length_answer = max_length_answer - self.chat = chat - - if not chosen_backend: - chosen_backend = self.get_least_conn_server() - self.chosen_backend = chosen_backend - - - headers = { - "Authorization": f"Basic {self.get_credentials()}", - "X-Chosen-Backend": self.chosen_backend, - } - self.host_url = os.getenv("LLM_API_URL").rstrip("/api/chat/") - self.host_url = 'http://192.168.1.12:3300' #! Change back when possible - self.client: Client = Client(host=self.host_url, headers=headers, timeout=120) - self.async_client: AsyncClient = AsyncClient() - - def get_credentials(self): - # Initialize the client with the host and default headers - credentials = f"{os.getenv('LLM_API_USER')}:{os.getenv('LLM_API_PWD_LASSE')}" - return base64.b64encode(credentials.encode()).decode() - - def get_model(self, model_alias): - - models = { - "standard": "LLM_MODEL", - "small": "LLM_MODEL_SMALL", - "vision": "LLM_MODEL_VISION", - "standard_64k": "LLM_MODEL_LARGE", - "reasoning": "LLM_MODEL_REASONING", - "tools": "LLM_MODEL_TOOLS", - } - model = os.getenv(models.get(model_alias, "LLM_MODEL")) - self.model = model - return model - - def count_tokens(self): - num_tokens = 0 - for i in self.messages: - for k, v in i.items(): - if k == "content": - if not isinstance(v, str): - v = str(v) - tokens = tokenizer.encode(v) - num_tokens += len(tokens) - return int(num_tokens) - - def get_least_conn_server(self): - try: - response = requests.get("http://192.168.1.12:5000/least_conn") - response.raise_for_status() - # Extract the least connected server from the response - least_conn_server = response.headers.get("X-Upstream-Address") - return least_conn_server - except requests.RequestException as e: - print_red("Error getting least connected server:", e) - return None - - def generate( - self, - query: str = None, - user_input: str = None, - context: str = None, - stream: bool = False, - tools: list = None, - images: list = None, - model: Optional[ - Literal["small", "standard", "vision", "reasoning", "tools"] - ] = None, - temperature: float = None, - messages: list[dict] = None, - format = None, - think = False - ): - """ - Generate a response based on the provided query and context. - Parameters: - query (str): The query string from the user. - user_input (str): Additional user input to be appended to the last message. - context (str): Contextual information to be used in generating the response. - stream (bool): Whether to stream the response. - tools (list): List of tools to be used in generating the response. - images (list): List of images to be included in the response. - model (Optional[Literal["small", "standard", "vision", "tools"]]): The model type to be used. - temperature (float): The temperature setting for the model. - messages (list[dict]): List of previous messages in the conversation. - format (Optional[BaseModel]): The format of the response. - think (bool): Whether to use the reasoning model. - - Returns: - str: The generated response or an error message if an exception occurs. - """ - print_yellow(stream) - print_yellow("GENERATE") - # Prepare the model and temperature - - model = self.get_model(model) if model else self.model - # if model == self.get_model('tools'): - # stream = False - temperature = temperature if temperature else self.options["temperature"] - - if messages: - messages = [ - {"role": i["role"], "content": re.sub(r"\s*\n\s*", "\n", i["content"])} - for i in messages - ] - message = messages.pop(-1) - query = message["content"] - self.messages = messages - else: - # Normalize whitespace and add the query to the messages - query = re.sub(r"\s*\n\s*", "\n", query) - message = {"role": "user", "content": query} - - # Handle images if any - if images: - message = self.prepare_images(images, message) - model = self.get_model("vision") - - self.messages.append(message) - - # Prepare headers - headers = {"Authorization": f"Basic {self.get_credentials()}"} - if self.chosen_backend and model not in [self.get_model("vision"), self.get_model("tools"), self.get_model("reasoning")]: #TODO Maybe reasoning shouldn't be here. - headers["X-Chosen-Backend"] = self.chosen_backend - - if model == self.get_model("small"): - headers["X-Model-Type"] = "small" - if model == self.get_model("tools"): - headers["X-Model-Type"] = "tools" - - reasoning_models = ['qwen3', 'deepseek'] #TODO Add more reasoning models here when added to ollama - if any([model_name in model for model_name in reasoning_models]): - if think: - query = f"/think\n{query}" - else: - query = f"/no_think\n{query}" - - # Prepare options - options = Options(**self.options) - options.temperature = temperature - - print_yellow("Stream the answer?", stream) - - # Call the client.chat method - try: - self.call_model = model - self.client: Client = Client(host=self.host_url, headers=headers, timeout=300) #! - #print_rainbow(self.client._client.__dict__) - print_yellow("Model used in call:", model) - # if headers: - # self.client.headers.update(headers) - - response = self.client.chat( - model=model, - messages=self.messages, - tools=tools, - stream=stream, - options=options, - keep_alive=3600 * 24 * 7, - format=format - ) - - except ResponseError as e: - print_red("Error!") - print(e) - return "An error occurred." - # print_rainbow(response.__dict__) - # If user_input is provided, update the last message - - if user_input: - if context: - if len(context) > 2000: - context = self.make_summary(context) - user_input = ( - f"{user_input}\n\nUse the information below to answer the question.\n" - f'"""{context}"""\n[This is a summary of the context provided in the original message.]' - ) - system_message_info = "\nSometimes some of the messages in the chat history are summarised, then that is clearly indicated in the message." - if system_message_info not in self.messages[0]["content"]: - self.messages[0]["content"] += system_message_info - self.messages[-1] = {"role": "user", "content": user_input} - - # self.chosen_backend = self.client.last_response.headers.get("X-Chosen-Backend") - - # Handle streaming response - if stream: - print_purple("STREAMING") - return self.read_stream(response) - else: - print_purple("NOT STREAMING") - # Process the response - if isinstance(response, ChatResponse): - result = response.message.content.strip('"') - if '' in result: - result = result.split('')[-1] - self.messages.append( - {"role": "assistant", "content": result.strip('"')} - ) - if tools and not response.message.get("tool_calls"): - print_yellow("No tool calls in response".upper()) - if not self.chat: - self.messages = [self.messages[0]] - - if not think: - response.message.content = remove_thinking(response.message.content) - return response.message - else: - print_red("Unexpected response type") - return "An error occurred." - - def make_summary(self, text): - # Implement your summary logic using self.client.chat() - summary_message = { - "role": "user", - "content": f'Summarize the text below:\n"""{text}"""\nRemember to be concise and detailed. Answer in English.', - } - messages = [ - { - "role": "system", - "content": "You are summarizing a text. Make it detailed and concise. Answer ONLY with the summary. Don't add any new information.", - }, - summary_message, - ] - try: - response = self.client.chat( - model=self.get_model("small"), - messages=messages, - options=Options(temperature=0.01), - keep_alive=3600 * 24 * 7, - ) - summary = response.message.content.strip() - print_blue("Summary:", summary) - return summary - except ResponseError as e: - print_red("Error generating summary:", e) - return "Summary generation failed." - - def read_stream(self, response): - """ - Yields tuples of (chunk_type, text). The first tuple is ('thinking', ...) - if in_thinking is True and stops at . After that, yields ('normal', ...) - for the rest of the text. - """ - thinking_buffer = "" - in_thinking = self.call_model == self.get_model("reasoning") - first_chunk = True - prev_content = None - - for chunk in response: - if not chunk: - continue - content = chunk.message.content - - # Remove leading quote if it's the first chunk - if first_chunk and content.startswith('"'): - content = content[1:] - first_chunk = False - - if in_thinking: - thinking_buffer += content - if "" in thinking_buffer: - end_idx = thinking_buffer.index("") + len("") - yield ("thinking", thinking_buffer[:end_idx]) - remaining = thinking_buffer[end_idx:].strip('"') - if chunk.done and remaining: - yield ("normal", remaining) - break - else: - prev_content = remaining - in_thinking = False - else: - if prev_content: - yield ("normal", prev_content) - prev_content = content - - if chunk.done: - if prev_content and prev_content.endswith('"'): - prev_content = prev_content[:-1] - if prev_content: - yield ("normal", prev_content) - break - - self.messages.append({"role": "assistant", "content": ""}) - - async def async_generate( - self, - query: str = None, - user_input: str = None, - context: str = None, - stream: bool = False, - tools: list = None, - images: list = None, - model: Optional[Literal["small", "standard", "vision"]] = None, - temperature: float = None, - ): - """ - Asynchronously generates a response based on the provided query and other parameters. - - Args: - query (str, optional): The query string to generate a response for. - user_input (str, optional): Additional user input to be included in the response. - context (str, optional): Context information to be used in generating the response. - stream (bool, optional): Whether to stream the response. Defaults to False. - tools (list, optional): List of tools to be used in generating the response. Will set the model to 'tools'. - images (list, optional): List of images to be included in the response. - model (Optional[Literal["small", "standard", "vision", "tools"]], optional): The model to be used for generating the response. - temperature (float, optional): The temperature setting for the model. - - Returns: - str: The generated response or an error message if an exception occurs. - - Raises: - ResponseError: If an error occurs during the response generation. - - Notes: - - The function prepares the model and temperature settings. - - It normalizes whitespace in the query and handles images if provided. - - It prepares headers and options for the request. - - It adjusts options for long messages and calls the async client's chat method. - - If user_input is provided, it updates the last message. - - It updates the chosen backend based on the response headers. - - It handles streaming responses and processes the response accordingly. - - It's not neccecary to set model to 'tools' if you provide tools as an argument. - """ - print_yellow("ASYNC GENERATE") - # Normaliz e whitespace and add the query to the messages - query = re.sub(r"\s*\n\s*", "\n", query) - message = {"role": "user", "content": query} - self.messages.append(message) - - # Prepare the model and temperature - model = self.get_model(model) if model else self.model - temperature = temperature if temperature else self.options["temperature"] - - # Prepare options - options = Options(**self.options) - options.temperature = temperature - - # Prepare headers - headers = {} - - # Set model depending on the input - if images: - message = self.prepare_images(images, message) - model = self.get_model("vision") - elif tools: - model = self.get_model("tools") - headers["X-Model-Type"] = "tools" - tools = [Tool(**tool) if isinstance(tool, dict) else tool for tool in tools] - elif self.chosen_backend and model not in [self.get_model("vision"), self.get_model("tools"), self.get_model("reasoning")]: - headers["X-Chosen-Backend"] = self.chosen_backend - elif model == self.get_model("small"): - headers["X-Model-Type"] = "small" - - # Adjust options for long messages - if self.chat or len(self.messages) > 15000: - num_tokens = self.count_tokens() + self.max_length_answer // 2 - if num_tokens > 8000 and model not in [ - self.get_model("vision"), - self.get_model("tools"), - ]: - model = self.get_model("standard_64k") - headers["X-Model-Type"] = "large" - - # Call the async client's chat method - try: - response = await self.async_client.chat( - model=model, - messages=self.messages, - headers=headers, - tools=tools, - stream=stream, - options=options, - keep_alive=3600 * 24 * 7, - ) - except ResponseError as e: - print_red("Error!") - print(e) - return "An error occurred." - - # If user_input is provided, update the last message - if user_input: - if context: - if len(context) > 2000: - context = self.make_summary(context) - user_input = ( - f"{user_input}\n\nUse the information below to answer the question.\n" - f'"""{context}"""\n[This is a summary of the context provided in the original message.]' - ) - system_message_info = "\nSometimes some of the messages in the chat history are summarised, then that is clearly indicated in the message." - if system_message_info not in self.messages[0]["content"]: - self.messages[0]["content"] += system_message_info - self.messages[-1] = {"role": "user", "content": user_input} - - print_red(self.async_client.last_response.headers.get("X-Chosen-Backend", "No backend")) - # Update chosen_backend - if model not in [self.get_model("vision"), self.get_model("tools"), self.get_model("reasoning")]: - self.chosen_backend = self.async_client.last_response.headers.get( - "X-Chosen-Backend" - ) - - # Handle streaming response - if stream: - return self.read_stream(response) - else: - # Process the response - if isinstance(response, ChatResponse): - result = response.message.content.strip('"') - self.messages.append( - {"role": "assistant", "content": result.strip('"')} - ) - if tools and not response.message.get("tool_calls"): - print_yellow("No tool calls in response".upper()) - if not self.chat: - self.messages = [self.messages[0]] - return result - else: - print_red("Unexpected response type") - return "An error occurred." - - def prepare_images(self, images, message): - """ - Prepares a list of images by converting them to base64 encoded strings and adds them to the provided message dictionary. - Args: - images (list): A list of images, where each image can be a file path (str), a base64 encoded string (str), or bytes. - message (dict): A dictionary to which the base64 encoded images will be added under the key "images". - Returns: - dict: The updated message dictionary with the base64 encoded images added under the key "images". - Raises: - ValueError: If an image is not a string or bytes. - """ - import base64 - - base64_images = [] - base64_pattern = re.compile(r"^[A-Za-z0-9+/]+={0,2}$") - - for image in images: - if isinstance(image, str): - if base64_pattern.match(image): - base64_images.append(image) - else: - with open(image, "rb") as image_file: - base64_images.append( - base64.b64encode(image_file.read()).decode("utf-8") - ) - elif isinstance(image, bytes): - base64_images.append(base64.b64encode(image).decode("utf-8")) - else: - print_red("Invalid image type") - - message["images"] = base64_images - # Use the vision model - - return message - -def remove_thinking(response): - """Remove the thinking section from the response""" - response_text = response.content if hasattr(response, "content") else str(response) - if "" in response_text: - return response_text.split("")[1].strip() - return response_text - -if __name__ == "__main__": - - llm = LLM() - - result = llm.generate( - query="I want to add 2 and 2", - ) - print(result.content) diff --git a/_llmOLD.py b/_llmOLD.py new file mode 100644 index 0000000..93ea770 --- /dev/null +++ b/_llmOLD.py @@ -0,0 +1,581 @@ +from _llm import LLM + + +if __name__ == "__main__": + llm = LLM() + + result = llm.generate( + query="I want to add 2 and 2", + think=True, + ) + print(result) +# import os +# import base64 +# import re +# from typing import Literal, Optional +# from pydantic import BaseModel +# import requests +# import tiktoken +# from ollama import ( +# Client, +# AsyncClient, +# ResponseError, +# ChatResponse, +# Tool, +# Options, +# ) + +# import env_manager +# from colorprinter.print_color import * + +# env_manager.set_env() + +# tokenizer = tiktoken.get_encoding("cl100k_base") + + +# class LLM: +# """ +# LLM class for interacting with an instance of Ollama. + +# Attributes: +# model (str): The model to be used for response generation. +# system_message (str): The system message to be used in the chat. +# options (dict): Options for the model, such as temperature. +# messages (list): List of messages in the chat. +# max_length_answer (int): Maximum length of the generated answer. +# chat (bool): Whether the chat mode is enabled. +# chosen_backend (str): The chosen backend server for the API. +# client (Client): The client for synchronous API calls. +# async_client (AsyncClient): The client for asynchronous API calls. +# tools (list): List of tools to be used in generating the response. + +# Methods: +# __init__(self, system_message, temperature, model, max_length_answer, messages, chat, chosen_backend): +# Initializes the LLM class with the provided parameters. + +# get_model(self, model_alias): +# Retrieves the model name based on the provided alias. + +# count_tokens(self): +# Counts the number of tokens in the messages. + +# get_least_conn_server(self): +# Retrieves the least connected server from the backend. + +# generate(self, query, user_input, context, stream, tools, images, model, temperature): +# Generates a response based on the provided query and options. + +# make_summary(self, text): +# Generates a summary of the provided text. + +# read_stream(self, response): +# Handles streaming responses. + +# async_generate(self, query, user_input, context, stream, tools, images, model, temperature): +# Asynchronously generates a response based on the provided query and options. + +# prepare_images(self, images, message): +# """ + +# def __init__( +# self, +# system_message: str = "You are an assistant.", +# temperature: float = 0.01, +# model: Optional[ +# Literal["small", "standard", "vision", "reasoning", "tools"] +# ] = "standard", +# max_length_answer: int = 4096, +# messages: list[dict] = None, +# chat: bool = True, +# chosen_backend: str = None, +# tools: list = None, +# ) -> None: +# """ +# Initialize the assistant with the given parameters. + +# Args: +# system_message (str): The initial system message for the assistant. Defaults to "You are an assistant.". +# temperature (float): The temperature setting for the model, affecting randomness. Defaults to 0.01. +# model (Optional[Literal["small", "standard", "vision", "reasoning"]]): The model type to use. Defaults to "standard". +# max_length_answer (int): The maximum length of the generated answer. Defaults to 4096. +# messages (list[dict], optional): A list of initial messages. Defaults to None. +# chat (bool): Whether the assistant is in chat mode. Defaults to True. +# chosen_backend (str, optional): The backend server to use. If not provided, the least connected server is chosen. + +# Returns: +# None +# """ + +# self.model = self.get_model(model) +# self.call_model = ( +# self.model +# ) # This is set per call to decide what model that was actually used +# self.system_message = system_message +# self.options = {"temperature": temperature} +# self.messages = messages or [{"role": "system", "content": self.system_message}] +# self.max_length_answer = max_length_answer +# self.chat = chat + +# if not chosen_backend: +# chosen_backend = self.get_least_conn_server() +# self.chosen_backend = chosen_backend + + +# headers = { +# "Authorization": f"Basic {self.get_credentials()}", +# "X-Chosen-Backend": self.chosen_backend, +# } +# self.host_url = os.getenv("LLM_API_URL").rstrip("/api/chat/") +# self.host_url = 'http://192.168.1.12:3300' #! Change back when possible +# self.client: Client = Client(host=self.host_url, headers=headers, timeout=240) +# self.async_client: AsyncClient = AsyncClient() + +# def get_credentials(self): +# # Initialize the client with the host and default headers +# credentials = f"{os.getenv('LLM_API_USER')}:{os.getenv('LLM_API_PWD_LASSE')}" +# return base64.b64encode(credentials.encode()).decode() + +# def get_model(self, model_alias): + +# models = { +# "standard": "LLM_MODEL", +# "small": "LLM_MODEL_SMALL", +# "vision": "LLM_MODEL_VISION", +# "standard_64k": "LLM_MODEL_LARGE", +# "reasoning": "LLM_MODEL_REASONING", +# "tools": "LLM_MODEL_TOOLS", +# } +# model = os.getenv(models.get(model_alias, "LLM_MODEL")) +# self.model = model +# return model + +# def count_tokens(self): +# num_tokens = 0 +# for i in self.messages: +# for k, v in i.items(): +# if k == "content": +# if not isinstance(v, str): +# v = str(v) +# tokens = tokenizer.encode(v) +# num_tokens += len(tokens) +# return int(num_tokens) + +# def get_least_conn_server(self): +# try: +# response = requests.get("http://192.168.1.12:5000/least_conn") +# response.raise_for_status() +# # Extract the least connected server from the response +# least_conn_server = response.headers.get("X-Upstream-Address") +# return least_conn_server +# except requests.RequestException as e: +# print_red("Error getting least connected server:", e) +# return None + +# def generate( +# self, +# query: str = None, +# user_input: str = None, +# context: str = None, +# stream: bool = False, +# tools: list = None, +# images: list = None, +# model: Optional[ +# Literal["small", "standard", "vision", "reasoning", "tools"] +# ] = None, +# temperature: float = None, +# messages: list[dict] = None, +# format: BaseModel = None, +# think: bool = False +# ): +# """ +# Generate a response based on the provided query and context. +# Parameters: +# query (str): The query string from the user. +# user_input (str): Additional user input to be appended to the last message. +# context (str): Contextual information to be used in generating the response. +# stream (bool): Whether to stream the response. +# tools (list): List of tools to be used in generating the response. +# images (list): List of images to be included in the response. +# model (Optional[Literal["small", "standard", "vision", "tools"]]): The model type to be used. +# temperature (float): The temperature setting for the model. +# messages (list[dict]): List of previous messages in the conversation. +# format (Optional[BaseModel]): The format of the response. +# think (bool): Whether to use the reasoning model. + +# Returns: +# str: The generated response or an error message if an exception occurs. +# """ + +# # Prepare the model and temperature + +# model = self.get_model(model) if model else self.model +# # if model == self.get_model('tools'): +# # stream = False +# temperature = temperature if temperature else self.options["temperature"] + +# if messages: +# messages = [ +# {"role": i["role"], "content": re.sub(r"\s*\n\s*", "\n", i["content"])} +# for i in messages +# ] +# message = messages.pop(-1) +# query = message["content"] +# self.messages = messages +# else: +# # Normalize whitespace and add the query to the messages +# query = re.sub(r"\s*\n\s*", "\n", query) +# message = {"role": "user", "content": query} + +# # Handle images if any +# if images: +# message = self.prepare_images(images, message) +# model = self.get_model("vision") + +# self.messages.append(message) + +# # Prepare headers +# headers = {"Authorization": f"Basic {self.get_credentials()}"} +# if self.chosen_backend and model not in [self.get_model("vision"), self.get_model("tools"), self.get_model("reasoning")]: #TODO Maybe reasoning shouldn't be here. +# headers["X-Chosen-Backend"] = self.chosen_backend + +# if model == self.get_model("small"): +# headers["X-Model-Type"] = "small" +# if model == self.get_model("tools"): +# headers["X-Model-Type"] = "tools" + +# reasoning_models = ['qwen3', 'deepseek'] #TODO Add more reasoning models here when added to ollama +# if any([model_name in model for model_name in reasoning_models]): +# if think: +# self.messages[-1]['content'] = f"/think\n{self.messages[-1]['content']}" +# else: +# self.messages[-1]['content'] = f"/no_think\n{self.messages[-1]['content']}" + +# # Prepare options +# options = Options(**self.options) +# options.temperature = temperature + +# # Call the client.chat method +# try: +# self.call_model = model +# self.client: Client = Client(host=self.host_url, headers=headers, timeout=300) #! +# #print_rainbow(self.client._client.__dict__) +# print_yellow(f"🤖 Generating using {model}...") +# # if headers: +# # self.client.headers.update(headers) +# response = self.client.chat( +# model=model, +# messages=self.messages, +# tools=tools, +# stream=stream, +# options=options, +# keep_alive=3600 * 24 * 7, +# format=format +# ) + +# except ResponseError as e: +# print_red("Error!") +# print(e) +# return "An error occurred." +# # print_rainbow(response.__dict__) +# # If user_input is provided, update the last message + +# if user_input: +# if context: +# if len(context) > 2000: +# context = self.make_summary(context) +# user_input = ( +# f"{user_input}\n\nUse the information below to answer the question.\n" +# f'"""{context}"""\n[This is a summary of the context provided in the original message.]' +# ) +# system_message_info = "\nSometimes some of the messages in the chat history are summarised, then that is clearly indicated in the message." +# if system_message_info not in self.messages[0]["content"]: +# self.messages[0]["content"] += system_message_info +# self.messages[-1] = {"role": "user", "content": user_input} + +# # self.chosen_backend = self.client.last_response.headers.get("X-Chosen-Backend") + +# # Handle streaming response +# if stream: +# print_purple("STREAMING") +# return self.read_stream(response) +# else: +# # Process the response +# if isinstance(response, ChatResponse): +# result = response.message.content.strip('"') +# if '' in result: +# result = result.split('')[-1] +# self.messages.append( +# {"role": "assistant", "content": result.strip('"')} +# ) +# if tools and not response.message.get("tool_calls"): +# print_yellow("No tool calls in response".upper()) +# if not self.chat: +# self.messages = [self.messages[0]] + +# if not think: +# response.message.content = remove_thinking(response.message.content) +# return response.message +# else: +# print_red("Unexpected response type") +# return "An error occurred." + +# def make_summary(self, text): +# # Implement your summary logic using self.client.chat() +# summary_message = { +# "role": "user", +# "content": f'Summarize the text below:\n"""{text}"""\nRemember to be concise and detailed. Answer in English.', +# } +# messages = [ +# { +# "role": "system", +# "content": "You are summarizing a text. Make it detailed and concise. Answer ONLY with the summary. Don't add any new information.", +# }, +# summary_message, +# ] +# try: +# response = self.client.chat( +# model=self.get_model("small"), +# messages=messages, +# options=Options(temperature=0.01), +# keep_alive=3600 * 24 * 7, +# ) +# summary = response.message.content.strip() +# print_blue("Summary:", summary) +# return summary +# except ResponseError as e: +# print_red("Error generating summary:", e) +# return "Summary generation failed." + +# def read_stream(self, response): +# """ +# Yields tuples of (chunk_type, text). The first tuple is ('thinking', ...) +# if in_thinking is True and stops at . After that, yields ('normal', ...) +# for the rest of the text. +# """ +# thinking_buffer = "" +# in_thinking = self.call_model == self.get_model("reasoning") +# first_chunk = True +# prev_content = None + +# for chunk in response: +# if not chunk: +# continue +# content = chunk.message.content + +# # Remove leading quote if it's the first chunk +# if first_chunk and content.startswith('"'): +# content = content[1:] +# first_chunk = False + +# if in_thinking: +# thinking_buffer += content +# if "" in thinking_buffer: +# end_idx = thinking_buffer.index("") + len("") +# yield ("thinking", thinking_buffer[:end_idx]) +# remaining = thinking_buffer[end_idx:].strip('"') +# if chunk.done and remaining: +# yield ("normal", remaining) +# break +# else: +# prev_content = remaining +# in_thinking = False +# else: +# if prev_content: +# yield ("normal", prev_content) +# prev_content = content + +# if chunk.done: +# if prev_content and prev_content.endswith('"'): +# prev_content = prev_content[:-1] +# if prev_content: +# yield ("normal", prev_content) +# break + +# self.messages.append({"role": "assistant", "content": ""}) + +# async def async_generate( +# self, +# query: str = None, +# user_input: str = None, +# context: str = None, +# stream: bool = False, +# tools: list = None, +# images: list = None, +# model: Optional[Literal["small", "standard", "vision"]] = None, +# temperature: float = None, +# ): +# """ +# Asynchronously generates a response based on the provided query and other parameters. + +# Args: +# query (str, optional): The query string to generate a response for. +# user_input (str, optional): Additional user input to be included in the response. +# context (str, optional): Context information to be used in generating the response. +# stream (bool, optional): Whether to stream the response. Defaults to False. +# tools (list, optional): List of tools to be used in generating the response. Will set the model to 'tools'. +# images (list, optional): List of images to be included in the response. +# model (Optional[Literal["small", "standard", "vision", "tools"]], optional): The model to be used for generating the response. +# temperature (float, optional): The temperature setting for the model. + +# Returns: +# str: The generated response or an error message if an exception occurs. + +# Raises: +# ResponseError: If an error occurs during the response generation. + +# Notes: +# - The function prepares the model and temperature settings. +# - It normalizes whitespace in the query and handles images if provided. +# - It prepares headers and options for the request. +# - It adjusts options for long messages and calls the async client's chat method. +# - If user_input is provided, it updates the last message. +# - It updates the chosen backend based on the response headers. +# - It handles streaming responses and processes the response accordingly. +# - It's not neccecary to set model to 'tools' if you provide tools as an argument. +# """ +# print_yellow("ASYNC GENERATE") +# # Normaliz e whitespace and add the query to the messages +# query = re.sub(r"\s*\n\s*", "\n", query) +# message = {"role": "user", "content": query} +# self.messages.append(message) + +# # Prepare the model and temperature +# model = self.get_model(model) if model else self.model +# temperature = temperature if temperature else self.options["temperature"] + +# # Prepare options +# options = Options(**self.options) +# options.temperature = temperature + +# # Prepare headers +# headers = {} + +# # Set model depending on the input +# if images: +# message = self.prepare_images(images, message) +# model = self.get_model("vision") +# elif tools: +# model = self.get_model("tools") +# headers["X-Model-Type"] = "tools" +# tools = [Tool(**tool) if isinstance(tool, dict) else tool for tool in tools] +# elif self.chosen_backend and model not in [self.get_model("vision"), self.get_model("tools"), self.get_model("reasoning")]: +# headers["X-Chosen-Backend"] = self.chosen_backend +# elif model == self.get_model("small"): +# headers["X-Model-Type"] = "small" + +# # Adjust options for long messages +# if self.chat or len(self.messages) > 15000: +# num_tokens = self.count_tokens() + self.max_length_answer // 2 +# if num_tokens > 8000 and model not in [ +# self.get_model("vision"), +# self.get_model("tools"), +# ]: +# model = self.get_model("standard_64k") +# headers["X-Model-Type"] = "large" + +# # Call the async client's chat method +# try: +# response = await self.async_client.chat( +# model=model, +# messages=self.messages, +# headers=headers, +# tools=tools, +# stream=stream, +# options=options, +# keep_alive=3600 * 24 * 7, +# ) +# except ResponseError as e: +# print_red("Error!") +# print(e) +# return "An error occurred." + +# # If user_input is provided, update the last message +# if user_input: +# if context: +# if len(context) > 2000: +# context = self.make_summary(context) +# user_input = ( +# f"{user_input}\n\nUse the information below to answer the question.\n" +# f'"""{context}"""\n[This is a summary of the context provided in the original message.]' +# ) +# system_message_info = "\nSometimes some of the messages in the chat history are summarised, then that is clearly indicated in the message." +# if system_message_info not in self.messages[0]["content"]: +# self.messages[0]["content"] += system_message_info +# self.messages[-1] = {"role": "user", "content": user_input} + +# print_red(self.async_client.last_response.headers.get("X-Chosen-Backend", "No backend")) +# # Update chosen_backend +# if model not in [self.get_model("vision"), self.get_model("tools"), self.get_model("reasoning")]: +# self.chosen_backend = self.async_client.last_response.headers.get( +# "X-Chosen-Backend" +# ) + +# # Handle streaming response +# if stream: +# return self.read_stream(response) +# else: +# # Process the response +# if isinstance(response, ChatResponse): +# result = response.message.content.strip('"') +# self.messages.append( +# {"role": "assistant", "content": result.strip('"')} +# ) +# if tools and not response.message.get("tool_calls"): +# print_yellow("No tool calls in response".upper()) +# if not self.chat: +# self.messages = [self.messages[0]] +# return result +# else: +# print_red("Unexpected response type") +# return "An error occurred." + +# def prepare_images(self, images, message): +# """ +# Prepares a list of images by converting them to base64 encoded strings and adds them to the provided message dictionary. +# Args: +# images (list): A list of images, where each image can be a file path (str), a base64 encoded string (str), or bytes. +# message (dict): A dictionary to which the base64 encoded images will be added under the key "images". +# Returns: +# dict: The updated message dictionary with the base64 encoded images added under the key "images". +# Raises: +# ValueError: If an image is not a string or bytes. +# """ +# import base64 + +# base64_images = [] +# base64_pattern = re.compile(r"^[A-Za-z0-9+/]+={0,2}$") + +# for image in images: +# if isinstance(image, str): +# if base64_pattern.match(image): +# base64_images.append(image) +# else: +# with open(image, "rb") as image_file: +# base64_images.append( +# base64.b64encode(image_file.read()).decode("utf-8") +# ) +# elif isinstance(image, bytes): +# base64_images.append(base64.b64encode(image).decode("utf-8")) +# else: +# print_red("Invalid image type") + +# message["images"] = base64_images +# # Use the vision model + +# return message + +# def remove_thinking(response): +# """Remove the thinking section from the response""" +# response_text = response.content if hasattr(response, "content") else str(response) +# if "" in response_text: +# return response_text.split("")[1].strip() +# return response_text + +# if __name__ == "__main__": + +# llm = LLM() + +# result = llm.generate( +# query="I want to add 2 and 2", +# ) +# print(result.content) diff --git a/agent_research.py b/agent_research.py new file mode 100644 index 0000000..3310fe8 --- /dev/null +++ b/agent_research.py @@ -0,0 +1,1448 @@ +from _llm import LLM +from streamlit_chatbot import Bot +from typing import Dict, List, Tuple, Optional, Any +from colorprinter.print_color import * +from projects_page import Project +from _base_class import BaseClass +from prompts import get_tools_prompt +import time +import traceback +import json +from datetime import datetime + +import llm_queries +from models import EvaluateFormat, Plan, ChunkSearchResults, UnifiedSearchResults, UnifiedDataChunk, UnifiedToolResponse + + +class ResearchReport: + """Class for tracking and logging decisions and data access during research""" + + def __init__(self, question, username, project_name=None): + self.report = { + "metadata": { + "question": question, + "username": username, + "project_name": project_name, + "started_at": datetime.now().isoformat(), + "finished_at": None, + }, + "plan": {"original_text": None, "structured": None, "subquestions": []}, + "steps": {}, + "evaluation": None, + "final_report": None, + "statistics": { + "tools_used": {}, + "sources_accessed": [], + "total_time": None, + }, + } + # Track current context for easier logging + self.current_step = None + self.current_task = None + + def log_plan(self, original_plan, structured_plan=None): + """Log the research plan""" + self.report["plan"]["original_text"] = original_plan + if structured_plan: + self.report["plan"]["structured"] = structured_plan + + def start_step(self, step_name): + """Mark the beginning of a new step""" + self.current_step = step_name + if step_name not in self.report["steps"]: + self.report["steps"][step_name] = { + "started_at": datetime.now().isoformat(), + "finished_at": None, + "tasks": {}, + "tools_used": [], + "information_gathered": [], + "summary": None, + "evaluation": None, + } + return self.current_step + + def start_task(self, task_name, task_description): + """Mark the beginning of a new task within the current step""" + if not self.current_step: + raise ValueError("Cannot start task without active step") + + self.current_task = task_name + self.report["steps"][self.current_step]["tasks"][task_name] = { + "description": task_description, + "started_at": datetime.now().isoformat(), + "finished_at": None, + "tools_used": [], + "information_gathered": [], + } + return self.current_task + + def log_tool_use(self, tool_name, tool_args): + """Log when a tool is used""" + if not self.current_step: + raise ValueError("Cannot log tool use without active step") + + # Add to step level + self.report["steps"][self.current_step]["tools_used"].append( + { + "tool": tool_name, + "args": tool_args, + "timestamp": datetime.now().isoformat(), + } + ) + + # Add to task level if we have an active task + if self.current_task: + self.report["steps"][self.current_step]["tasks"][self.current_task][ + "tools_used" + ].append( + { + "tool": tool_name, + "args": tool_args, + "timestamp": datetime.now().isoformat(), + } + ) + + # Update global statistics + if tool_name in self.report["statistics"]["tools_used"]: + self.report["statistics"]["tools_used"][tool_name] += 1 + else: + self.report["statistics"]["tools_used"][tool_name] = 1 + + def log_information(self, information): + """Log information gathered from tools""" + if not self.current_step: + raise ValueError("Cannot log information without active step") + + # Process information to extract sources + sources = self._extract_sources(information) + + # Add unique sources to global statistics + for source in sources: + if source not in self.report["statistics"]["sources_accessed"]: + self.report["statistics"]["sources_accessed"].append(source) + + # Add to step level + self.report["steps"][self.current_step]["information_gathered"].append( + { + "data": information, + "sources": sources, + "timestamp": datetime.now().isoformat(), + } + ) + + # Add to task level if we have an active task + if self.current_task: + self.report["steps"][self.current_step]["tasks"][self.current_task][ + "information_gathered" + ].append( + { + "data": information, + "sources": sources, + "timestamp": datetime.now().isoformat(), + } + ) + + def _extract_sources(self, information): + """Extract source information from gathered data""" + sources = [] + + # Handle different result formats + for item in information: + try: + if "result" in item and "content" in item["result"]: + if isinstance(item["result"]["content"], dict): + # Handle structured content like chunks + for title, group in item["result"]["content"].items(): + if "chunks" in group: + for chunk in group["chunks"]: + metadata = chunk.get("metadata", {}) + source = f"{metadata.get('title', 'Unknown')}" + if metadata.get("journal"): + source += f" ({metadata.get('journal')})" + if source not in sources: + sources.append(source) + except Exception as e: + print_yellow(f"Error extracting sources: {e}") + sources.append("No source") + + return sources + + def update_step_summary(self, summary): + """Log summary of gathered information""" + if not self.current_step: + raise ValueError("Cannot log summary without active step") + + self.report["steps"][self.current_step]["summary"] = remove_thinking(summary) + + def log_evaluation(self, evaluation): + """Log evaluation of gathered information""" + if not self.current_step: + raise ValueError("Cannot log evaluation without active step") + + self.report["steps"][self.current_step]["evaluation"] = evaluation + + def finish_task(self): + """Mark the end of the current task""" + if not self.current_step or not self.current_task: + raise ValueError("No active task to finish") + + self.report["steps"][self.current_step]["tasks"][self.current_task][ + "finished_at" + ] = datetime.now().isoformat() + self.current_task = None + + def finish_step(self): + """Mark the end of the current step""" + if not self.current_step: + raise ValueError("No active step to finish") + + self.report["steps"][self.current_step][ + "finished_at" + ] = datetime.now().isoformat() + self.current_step = None + + def log_plan_evaluation(self, evaluation): + """Log the overall plan evaluation""" + self.report["evaluation"] = evaluation + + def log_final_report(self, report): + """Log the final generated report""" + self.report["final_report"] = report + self.report["metadata"]["finished_at"] = datetime.now().isoformat() + # Calculate total time + start = datetime.fromisoformat(self.report["metadata"]["started_at"]) + end = datetime.fromisoformat(self.report["metadata"]["finished_at"]) + self.report["statistics"]["total_time"] = (end - start).total_seconds() + + def get_full_report(self): + """Get the complete report data""" + return self.report + + def get_markdown_report(self): + """Get the report formatted as markdown for easy viewing""" + md = f"# Research Report: {self.report['metadata']['question']}\n\n" + + # Metadata + md += "## Metadata \n" + md += f"- **Project**: {self.report['metadata']['project_name'] or 'None'} \n" + md += f"- **User**: {self.report['metadata']['username']} \n" + md += f"- **Started**: {self.report['metadata']['started_at']} \n" + if self.report["metadata"]["finished_at"]: + md += f"- **Finished**: {self.report['metadata']['finished_at']} \n" + md += f"- **Total time**: {self.report['statistics']['total_time']:.2f} seconds \n" + + # Statistics + md += "\n## Statistics \n" + md += "### Tools Used \n" + for tool, count in self.report["statistics"]["tools_used"].items(): + md += f"- {tool}: {count} times \n" + + md += "\n### Sources Accessed \n" + for source in self.report["statistics"]["sources_accessed"]: + md += f"- {source} \n" + + # Research plan + md += "\n## Research Plan \n" + if self.report["plan"]["original_text"]: + md += f"\n{self.report['plan']['original_text']} \n\n" + + # Steps + md += "\n## Research Steps \n" + for step_name, step_data in self.report["steps"].items(): + md += f"### {step_name}\n" + + if step_data.get("summary"): + md += f"**Summary**: {step_data['summary']} \n\n" + + md += "**Tools used**: \n" + for tool in step_data["tools_used"]: + md += f"- {tool['tool']} with query: _{tool['args'].get('query', 'No query').replace('_', ' ')}_\n" + + md += "\n**Tasks**:\n" + for task_name, task_data in step_data.get("tasks", {}).items(): + md += f"- {task_name}: {task_data['description']}\n" + + # Final report + if self.report["final_report"]: + md += "\n## Final Report \n" + md += self.report["final_report"] + + return md + + def save_to_file(self, filepath=None): + """Save the report to a file""" + if not filepath: + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filepath = f"research_report_{timestamp}.json" + + # Create a deep copy of the report that's JSON serializable + def make_json_serializable(obj): + """Convert any non-JSON serializable objects to dictionaries""" + if hasattr(obj, "model_dump"): # Check if it's a Pydantic model + return obj.model_dump() # Convert Pydantic models to dict + elif isinstance(obj, dict): + return {k: make_json_serializable(v) for k, v in obj.items()} + elif isinstance(obj, list): + return [make_json_serializable(item) for item in obj] + else: + return obj + + # Create a JSON-serializable version of the report + json_report = make_json_serializable(self.report) + + with open(filepath, "w") as f: + json.dump(json_report, f, indent=2) + + print_green(f"Report saved to {filepath}") + return filepath + + +class ResearchBase(Bot): + """Base class for all research agents with improved integration with Bot functionality""" + + def __init__( + self, + username: str, + model: str = "standard", + chat: bool = True, + report=None, + **kwargs, + ): + super().__init__(username=username, **kwargs) + self.model: str = model + self.llm = LLM( + system_message="You are a research assistant.", + model=model, + chat=chat, + messages=[], + ) + + # Tracking for research flow + self.research_state = { + "current_step": None, + "current_task": None, + "start_time": time.time(), + "steps_completed": 0, + "tasks_completed": 0, + } + + self.report: ResearchReport = report + + # Define available tool functions + self.available_functions = { + "fetch_science_articles_tool": self.fetch_science_articles_tool, + "fetch_notes_tool": self.fetch_notes_tool, + "fetch_other_documents_tool": self.fetch_other_documents_tool, + "fetch_science_articles_and_other_documents_tool": self.fetch_science_articles_and_other_documents_tool, + "analyze_tool": self.analyze_tool, + } + + self.tools = [ + self.available_functions[tool] if isinstance(tool, str) else tool + for tool in self.available_functions + ] + + def update_research_state(self, **kwargs): + """Update the research state with new information""" + self.research_state.update(kwargs) + current_time = time.time() + elapsed = current_time - self.research_state["start_time"] + + # Log progress info + if "current_step" in kwargs or "current_task" in kwargs: + print_yellow( + f"Progress: Step {self.research_state.get('steps_completed', 0)}, " + f"Time elapsed: {elapsed:.1f}s" + ) + + # Update report if available + if self.report: + if "current_step" in kwargs and kwargs["current_step"]: + self.report.start_step(kwargs["current_step"]) + if ( + "current_task" in kwargs + and kwargs["current_task"] + and self.report.current_step + ): + # For simplicity, we're using the task description as the name too + self.report.start_task( + kwargs["current_task"], kwargs["current_task"] + ) + + def use_tools(self, tool_calls, task_description, task_description_as_query=True) -> UnifiedToolResponse: + """Execute the selected tools to gather information""" + self.update_research_state(current_task=f"Gathering information with tools") + + gathered_information = UnifiedToolResponse() + + for tool_call in tool_calls: + tool_name = tool_call.function.name + tool_args = tool_call.function.arguments + + print_green(f"Using tool: {tool_name} with args: {tool_args}") + + # Add the query to arguments if not already present + if "query" in tool_args: + if task_description_as_query: + tool_args["query"] = task_description + else: + if "query" not in tool_args and task_description: + tool_args["query"] = task_description + + # Log tool use in report + if self.report: + self.report.log_tool_use(tool_name, tool_args) + + try: + # Call the tool function + function_to_call = self.available_functions.get(tool_name) + if function_to_call: + result = function_to_call(**tool_args) + else: + result = f"Unknown tool: {tool_name}" + + # Process the result + if isinstance(result, UnifiedSearchResults): + # Convert to a unified format + gathered_information.extend_search_results(result) + gathered_information.extend_tool_name(tool_name) + elif isinstance(result, str): + # Already in the correct format + gathered_information.extend_text_results(result) + gathered_information.extend_tool_name(tool_name) + + # Log gathered information in report + # if self.report: + # self.report.log_information([gathered_info]) + + except Exception as e: + print_red(f"Error executing tool {tool_name}: {e}") + traceback.print_exc() + import sys + + sys.exit(1) + + return gathered_information + + # Tool function definitions + def fetch_science_articles_tool( + self, query: str, n_documents: int = 6 + ) -> ChunkSearchResults: + """ + Fetches information from scientific articles. + + Parameters: + query (str): The search query to find relevant scientific articles in a vector database. + n_documents (int): How many documents to fetch. A complex query may require more documents. Min: 3, Max: 10. + + Returns: + ChunkSearchResults: A structured result containing articles with their chunks. + """ + + where_filter = {} + if hasattr(self, "chroma_ids_retrieved") and len(self.chroma_ids_retrieved) > 0: + where_filter = {"_id": {"$in": self.chroma_ids_retrieved}} + + + found_chunks = self.get_chunks( + user_input=query, + collections=["sci_articles"], + n_results=n_documents, + n_sources=max(n_documents, 4) + ) + + # Standardize the chunks using UnifiedDataChunk + unified_chunks = [ + UnifiedDataChunk( + content=chunk.content, + metadata=chunk.metadata.model_dump(), + source_type="other_documents", + ) + for chunk in found_chunks.chunks + ] + # Return the unified search results + return UnifiedSearchResults(chunks=unified_chunks, source_ids=[]) + + + def fetch_notes_tool(self, **argv) -> UnifiedSearchResults: + """ + Fetches project notes as a list for the researcher to understand what's important in the project. + This tool is useful for getting a quick overview of the project's key points. + Takes no arguments! + + Returns: + UnifiedSearchResults: A structured result containing notes as data chunks. + """ + chunks = [] + for i, note in enumerate(self.get_notes()): + # Create a unified data chunk for each note + unified_chunk = UnifiedDataChunk( + content=note, + metadata={ + "title": f"Note {i+1}", # Use a default string instead of None + "source": "Project Notes" # Add more metadata for better identification + }, + source_type="note", + ) + chunks.append(unified_chunk) + return UnifiedSearchResults(chunks=chunks, source_ids=[]) + + +class MasterAgent(ResearchBase): + """A large and reasoning (if not specified not to be) LLM that handles the complex thinking tasks and coordinates other agents""" + + def __init__( + self, username: str, project: Project = None, model: str = "reasoning", tools: list=[], **kwargs + ): + # Configure for reasoning model + kwargs["model_config"] = { + "system_message": "You are an assistant helping a journalist writing a report based on extensive research.", + "temperature": 0.3, + "model": model, + "chat": True, + } + super().__init__(username=username, project=project, tools=tools, **kwargs) + self.model = model + self.available_sources = {} + + # Initialize sub-agents + self.structure_agent = StructureAgent( + username=username, model="small", report=self.report + ) + self.tool_agent = ToolAgent( + username=username, + model="tools", + system_message=f"You are an assistant with some tools. The tools you can choose from are {tools} Always choose a tool to help with the task. Your task is to choose one or multiple tools to answer a user's query. DON'T come up with your own tools, only use the ones provided.", + report=self.report, + project=project, + chat=True, + ) + self.archive_agent = ArchiveAgent( + username=username, + report=self.report, + project=project, + system_message=""" + You are an assistant specialized in reading and summarizing research information. + You are helping a researcher with a research divided in many steps, where you will get information for each step. + Your goal is to provide clear, accurate summaries that capture the essential points while maintaining context. + If your summary is deemed insufficient to complete the step, you will be given more information and asked for a updated summary. Please then include the previous information you got in your new summary so that all information is taken into account. + """, + chat=True, + ) + self.assistant_agent = AssistantAgent( + username=username, + report=self.report, + project=project, + system_message=""" + You are an assistant specialized in summarizing steps and keeping track of the research process. + Your job is to maintain a structured record of what has been done and help the researcher navigate + through the research process by providing clear summaries of steps completed. + """, + chat=True, + ) + + # Track execution results + self.execution_results = {} + + def check_available_sources(self): + """ + Check the available sources in the database and update the report. + + This method iterates through the arango_ids stored in the instance and + counts the number of documents by type based on their ID prefixes. + It then updates the self.available_sources dictionary with counts for: + - other_documents: Documents with IDs starting with "other_documents" + - sci_articles: Scientific articles with IDs starting with "sci_articles" + - notes: Notes with IDs starting with "note" + - interviews: Interviews with IDs starting with "interview" + + Returns: + None + """ + #! Update when more sources are added! + other_documents = 0 + science_articles = 0 + notes = 0 + interviews = 0 + + for id in self.arango_ids: + if id.startswith("other_documents"): + other_documents += 1 + elif id.startswith("sci_articles"): + science_articles += 1 + elif id.startswith("note"): + notes += 1 + elif id.startswith("interview"): + interviews += 1 + + for source in [ + "other_documents", + "sci_articles", + "notes", + "interviews", + ]: + if source == "other_documents": + self.available_sources[source] = other_documents + elif source == "sci_articles": + self.available_sources[source] = science_articles + elif source == "notes": + self.available_sources[source] = notes + elif source == "interviews": + self.available_sources[source] = interviews + + def make_plan(self, question): + """Generate a research plan for answering the question/exploring the topic""" + + self.update_research_state( + current_step="Plan Creation", current_task="Splitting into questions." + ) + + query = llm_queries.create_plan_questions(self, question) + + response = self.llm.generate(query=query, model=self.model, think=True) + print_purple(response.content) + subquestions = [i for i in remove_thinking(response).split("\n") if "?" in i] + + self.report.report["plan"]["subquestions"] = subquestions + + self.update_research_state( + current_step="Plan Creation", current_task="Creating initial research plan" + ) + + # TODO Update the available resources in the query when more resources are added! + make_plan_query = llm_queries.create_plan(self, question) + + # Generate the plan and handle potential formatting issues + try: + response = self.llm.generate(query=make_plan_query, model=self.model, think=True) + plan = self.structure_agent.make_structured(response.content, question) + + + print("THIS IS THE PLAN\n") + print_rainbow(plan.__dict__) + self.update_research_state(steps_completed=1) + return plan + + except Exception as e: + print_red(f"Error creating research plan: {e}") + traceback.print_exc() + return f"Error creating research plan: {str(e)}" + + def process_step(self, step_name, step_tasks, max_attempts=3): + """ + Process a research step with multiple tasks using various agents to gather and organize information. + This function handles the complete workflow for a research step: + 1. Determines required tools for all tasks + 2. Gathers information using the selected tools + 3. Summarizes the gathered information + 4. Evaluates if the information is sufficient + 5. Iteratively gathers more information if needed (up to 3 attempts) + 6. Finalizes the step results + """ + print_purple(f"\nProcessing Step: {step_name}") + self.update_research_state( + current_step=step_name, current_task="Processing entire step" + ) + + # 1. Determine tools needed for all tasks in the step + print_blue("Determining tools for all tasks in step...") + all_tasks_description = f"## Step: {step_name}\n" + for task in step_tasks: + all_tasks_description += ( + f"- {task['task_name']}: {task['task_description']}\n" + ) + all_tasks_description += "\nWhat tools should I use to gather all necessary information for these tasks efficiently?" + + tool_calls = self.tool_agent.task_tools(all_tasks_description) + print_purple("Tools selected for the entire step:") + for i, tool_call in enumerate(tool_calls, 1): + args_dict = tool_call.function.arguments + # Format the arguments as a comma-separated list of key=value pairs + args_formatted = ", ".join([f"{k}={v}" for k, v in args_dict.items()]) + print_purple(f"{i}. {tool_call.function.name} ({args_formatted})") + + # 2. Gather data according to the selected tools + print_blue("Gathering data for all tasks...") + gathered_info = self.archive_agent.use_tools(tool_calls, all_tasks_description) + self.archive_agent.chroma_ids_retrieved += gathered_info.get_chroma_ids + + # 3. Summarize the gathered data + print_blue("Summarizing gathered information...") + self.archive_agent.reset_chat_history() + print_yellow("Step description:") + print_yellow(all_tasks_description) + print() + print_yellow("Summarizing all gathered information...") + print("Gathered information:") + for info in gathered_info: + print(info) + print_yellow("Summarizing all gathered information...") + summary = self.archive_agent.read_and_summarize( + gathered_info, all_tasks_description + ) + summary = remove_thinking(summary) + print_green("Step Information Summary:") + print(summary) + + # Have the assistant agent track this step + self.assistant_agent.summarize_step(step_name, summary) + + # 4. Evaluate if the data is sufficient for all tasks + print_blue("Evaluating if information is sufficient for all tasks...") + evaluation = self.evaluate_step_completeness(step_tasks, summary) + + self.report.log_evaluation(evaluation) + + # 5. If not enough information, gather more + attempt = 1 + + while not evaluation["status"] and attempt < max_attempts: + print_yellow( + f"Information not sufficient. Attempt {attempt}/{max_attempts} to gather more..." + ) + + # Create a query focusing on missing information + additional_query = f"For step '{step_name}', I need additional information on:\n{evaluation['missing_info']}\n\nWhat tools should I use to fill these gaps?" + + # Get additional tools + additional_tool_calls = self.tool_agent.task_tools(additional_query) + + # Use additional tools to gather more information + additional_info = self.tool_agent.use_tools( + additional_tool_calls, additional_query + ) + + # Add to gathered information + # TODO Is it better to append or or make the LLM use the chat history? + # gathered_info.extend(additional_info) + gathered_info = additional_info + + # Update summary with all information + updated_summary = self.archive_agent.read_and_summarize( + gathered_info, all_tasks_description + ) + summary = remove_thinking(updated_summary) + print_green("Updated Summary:") + print(summary) + + # Update assistant agent with new summary + self.assistant_agent.summarize_step(step_name, summary) + + # Re-evaluate + evaluation = self.evaluate_step_completeness(step_tasks, summary) + attempt += 1 + + self.report.update_step_summary(summary) + + # 6. Let the MasterAgent use the gathered data to finalize the step + print_blue("Finalizing step results...") + step_result = self.finalize_step_result(step_name, step_tasks, summary) + + # Pack and store results + step_result = { + "step_name": step_name, + "tasks": [ + { + "task_name": task["task_name"], + "task_description": task["task_description"], + } + for task in step_tasks + ], + "information_gathered": gathered_info, + "summary": summary, + "evaluation": evaluation, + "result": step_result, + } + + self.execution_results[step_name] = step_result + + def execute_research_plan(self, structured_plan): + """Execute the structured research plan step by step""" + # Execute the plan step by step + print_blue("\n--- EXECUTING RESEARCH PLAN ---") + + for step_name, tasks in structured_plan.steps.items(): + print_blue(f"\n### Processing Step: {step_name}") + self.report.start_step(step_name) + + # Collect all task descriptions in this step + step_tasks = [ + {"task_name": task_name, "task_description": task_description} + for task_name, task_description in tasks + ] + + # Process the entire step + self.archive_agent.reset_chroma_ids() + self.process_step(step_name, step_tasks) + + # Finish the step in report + self.report.finish_step() + + # Evaluate if more steps are needed + print_blue("\n--- EVALUATING RESEARCH PLAN ---") + plan_evaluation = self.evaluate_plan(self.execution_results) + self.report.log_plan_evaluation(plan_evaluation) + print_yellow("Plan Evaluation:") + print(plan_evaluation["explanation"]) + + return self.execution_results + + def evaluate_step_completeness(self, step_tasks, summary): + """Evaluate if the information is sufficient for all tasks in the step""" + + # Add None for additional_info if it's not present + + self.update_research_state(current_task="Evaluating step completeness") + + # Create a query to evaluate all tasks + step_description = "\n".join( + [ + f"- {task['task_name']}: {task['task_description']}" + for task in step_tasks + ] + ) + + query = f""" + You are evaluating if the gathered information is sufficient for completing ALL the following tasks: + + {step_description} + + Information gathered: + """ + {summary} + """ + + Is this information sufficient to complete ALL tasks in this step? + + First, analyze each task individually and determine if the information is sufficient. + Then, provide an overall assessment where "status" is True if all tasks are complete and False if not. + Explain why the information is sufficient or not in the "explanation" field. + If ANY task has insufficient information, specify exactly what additional information is needed. + """ + + response = self.llm.generate( + query=query, + format=EvaluateFormat.model_json_schema(), + model="standard", + think=True, + ) + structured_response = EvaluateFormat.model_validate_json(response.content) + # Add None for additional_info if it's not present + if not hasattr(structured_response, "additional_info"): + structured_response.additional_info = None + + if structured_response.status: + print_green( + f'\nEVALUATION PASSED\n"Step: {step_description}\n{structured_response.explanation}' + ) + elif not structured_response.status: + print_red(f"EVALUATION FAILED\n{structured_response.explanation}") + + return { + "status": structured_response.status, + "explanation": structured_response.explanation, + "missing_info": structured_response.additional_info, + } + + def evaluate_step(self, information, task_description): + """Evaluate if the information is sufficient for the current step/task""" + + self.update_research_state(current_task=f"Evaluating '{task_description}'") + + query = f''' + You are evaluating if the gathered information is sufficient for completing this research task. + + Task: {task_description} + + Information gathered: + """ + {information} + """ + + Is this information sufficient to complete the task? Respond in the format requested. + If insufficient, explain exactly what additional information would be needed. + ''' + + response = self.llm.generate( + query=query, format=EvaluateFormat.model_json_schema() + ) + structured_response = EvaluateFormat.model_validate_json(response.content) + if structured_response.status: + print_green( + f'\nEVALUATION PASSED\n"Task: {task_description}\n{structured_response.explanation}' + ) + + # Determine status based on the response + else: + print_red("EVALUATION FAILED") + print_yellow(f"Task: {task_description}") + print_rainbow(structured_response.__dict__) + return { + "status": structured_response.status, + "explanation": structured_response.explanation, + "additional_info": structured_response.additional_info, + } + + def evaluate_plan(self, execution_results): + """Evaluate if more research steps are needed""" + self.update_research_state( + current_step="Plan Evaluation", + current_task="Evaluating overall research progress", + ) + + # Create a summary of completed research + steps_summary = "" + for step_name, step_data in execution_results.items(): + steps_summary += f"\n## {step_name} \n" + # Add the step's summary + steps_summary += f"{step_data.get('summary', 'No summary available')} \n" + + # If you want to include individual tasks + for task in step_data.get('tasks', []): + task_name = task.get('task_name', 'Unnamed task') + steps_summary += f"- {task_name} \n" + + + query = f''' + Based on the research that has been conducted so far, determine if additional steps are needed + to create a comprehensive report. + + Research completed: + """ + {steps_summary} + """""" + + Original question to answer: {self.research_state.get('original_question', 'No question provided')} + + Are additional research steps needed? Respond with COMPLETE or INCOMPLETE, + followed by a brief explanation. If INCOMPLETE, suggest what additional steps would be valuable. + ''' + + response = self.llm.generate(query=query, think=True) + evaluation = response.content if hasattr(response, "content") else str(response) + + if "COMPLETE" in evaluation.upper().split(" "): + print_green(f'\nEVALUATION PASSED\n"Evaluation: {evaluation}') + return { + "status": "no more information is needed", + "explanation": evaluation, + } + else: + print_red(f'\nEVALUATION FAILED\n"Evaluation: {evaluation}') + return {"status": "more information is needed", "explanation": evaluation} + + def finalize_step_result(self, step_name, step_tasks, summary): + """Generate a comprehensive result for the entire step using all gathered information""" + self.update_research_state( + current_task=f"Finalizing results for step: {step_name}" + ) + + tasks_description = "\n".join( + [ + f"- {task['task_name']}: {task['task_description']}" + for task in step_tasks + ] + ) + + query = f""" + Based on the following information gathered for step "{step_name}", + create a comprehensive analysis that addresses all the tasks in this step. + + Step tasks: + {tasks_description} + + Information gathered: + {summary} + + Your response should: + 1. Be structured with clear sections for each aspect of the analysis + 2. Draw connections between different pieces of information + 3. Highlight key insights relevant to the original research question + 4. Provide a comprehensive understanding of this step's contribution to the overall research + + Sometimes the information is limited, if so do not make up information, but rather say that the information is limited and write a shorter response. + """ + + response = self.llm.generate(query=query) + step_result = remove_thinking(response) + print_green("Step Result:") + print(step_result) + + self.update_research_state( + steps_completed=self.research_state.get("steps_completed", 0) + 1 + ) + return step_result + + def write_report(self, execution_results): + """Generate the final report based on the collected information""" + self.update_research_state( + current_step="Report Writing", current_task="Generating final report" + ) + + # Prepare all the gathered information in a structured way + gathered_info = "" + for step_name, step_data in execution_results.items(): + gathered_info += f"\n## {step_name}\n" + # Add the step's summary + gathered_info += f"Step Summary: {step_data.get('summary', 'No summary available')}\n\n" + + # Add information about tasks + for task in step_data.get('tasks', []): + task_name = task.get('task_name', 'Unnamed task') + task_description = task.get('task_description', 'No description') + gathered_info += f"### {task_name}\n" + gathered_info += f"Description: {task_description}\n\n" + + # Include sources when available + sources = [] + for info in step_data.get('information_gathered', []): + if isinstance(info, dict) and "result" in info and "content" in info["result"]: + if (isinstance(info["result"]["content"], dict) and + "chunks" in info["result"]["content"]): + for chunk in info["result"]["content"].get("chunks", []): + metadata = chunk.get("metadata", {}) + source = f"{metadata.get('title', 'Unknown')}" + if metadata.get("journal"): + source += f" ({metadata.get('journal')})" + if source not in sources: + sources.append(source) + + if sources: + gathered_info += "\n### Sources:\n" + for i, source in enumerate(sources): + gathered_info += f"- [{i+1}] {source}\n" + + # Rest of the method continues... + + print_blue("\n\nGathered information:\n".upper()) + print(gathered_info, "\n") + + query = f''' + Based on the following research information, write a extensive report that in detail answers the question: + "{self.research_state.get('original_question', 'No question provided').replace('"', "'")}" + + Research Information: + """ + {gathered_info} + """ + + The report should be well-structured with appropriate headings, present the information + accurately, and highlight key insights. Cite sources using [number] notation when referencing specific information. + As the report is for journalistic reseach, please be generous with details and cases that can be used when reporting on the subject! + ''' + + response = self.llm.generate(query=query) + report = response.content if hasattr(response, "content") else str(response) + report = remove_thinking(report) + + self.update_research_state( + steps_completed=self.research_state.get("steps_completed", 0) + 1 + ) + return report + + +class StructureAgent(ResearchBase): + """A small LLM for structuring text as JSON""" + + def __init__(self, username, model: str = "standard", **kwargs): + + super().__init__(username=username, **kwargs) + self.model = model + self.system_message = """You are helping a researcher to structure a text. You will get a text and make it into structured data. + Make sure not to change the meaning of the text and keeps all the details in the subtasks. + The content and/or of each step and task should be understandable by itself. Therefore, if a task seems to refer to something that is not mentioned in the step, you should include the necessary information in the task itself. Example: if a task consists of "Collect relevant information for the subject", you should include what the subject is in the task itself. + """ + self.llm = LLM( + system_message="You are a research assistant.", + model=self.model, + chat=False, + messages=[], + ) + + def make_structured(self, text, question=None): + """Convert the research plan into a structured format""" + self.update_research_state( + current_step="Plan Structuring", + current_task="Converting plan to structured format", + ) + + # Prepare query based on whether a question is provided + if question: + query = f'''This is a proposed plan for how to write a report on "{question}":\n"""{text}"""\nPlease make the plan into structured data with subtasks. Make sure to keep all the details in the subtasks.''' + else: + query = f'''This is a proposed plan for how to write a report:\n"""{text}"""\nPlease make the plan into structured data with subtasks. Make sure to keep all the details in the subtasks.''' + + # Generate the structured plan + try: + response = self.llm.generate( + query=query, format=Plan.model_json_schema(), model=self.model + ) + response_content = ( + response.content if hasattr(response, "content") else str(response) + ) + structured_response = Plan.model_validate_json(response_content) + + self.update_research_state( + steps_completed=self.research_state.get("steps_completed", 0) + 1 + ) + return Plan.model_validate_json(response_content) + + except Exception as e: + print_red(f"Error structuring plan: {e}") + traceback.print_exc() + # Create a basic fallback structure + import sys + + sys.exit(1) + + +class ToolAgent(ResearchBase): + """An LLM specialized in choosing tools based on information needs""" + + def __init__(self, username, **kwargs): + # Initialize the LLM configuration + kwargs["model_config"] = { + "system_message": kwargs.get( + "system_message", + f""" + You are a helpful assistant with tools. + Your task is to choose one or multiple tools to answer a user's query. + DON'T come up with your own tools, only use the ones provided. + """, + ), + "temperature": 0.1, + "model": "tools", + "chat": kwargs.get("chat", True), + } + + super().__init__(username=username, **kwargs) + + def task_tools(self, task_description): + """Determine which tools to use for a task""" + self.update_research_state(current_task=f"Selecting tools for task") + + query = f'''Research task description: + """ + {task_description} + """ + You have to choose one or many tools in order fetch information neccessary to complete the task. + It's important that you think of what information is needed, and choose the right tool for the job considering the tools descriptions. + Make sure to read the description of the tools carefully before choosing! + You can ONLY chose a tool you are provided with, don't make up a tool! + You HAVE TO CHOOSE A TOOL, even if you think you can answer without it. Don't answer the question without choosing a tool. + ''' + + response = self.llm.generate(query=query, tools=self.tools, model="tools") + + # Extract tool calls from the response + tool_calls = response.tool_calls if hasattr(response, "tool_calls") else [] + return tool_calls + + +class AssistantAgent(ResearchBase): + """A small LLM agent for summarizing steps and keeping track of the research process by managing "research notes"/common memory. + This agent is designed to work with smaller language models and maintain a structured + record of the research process through its Notes system. + """ + + class Notes: + """ + A class for storing and retrieving notes related to different steps in a process. + This class allows adding notes with step name, information, and summary, + and retrieving notes for specific steps. + Attributes: + step_notes (list): A list of dictionaries containing step notes. + Each dictionary has keys 'step_name', 'step_information', and 'summary'. + """ + + def __init__(self): + self.step_notes = [] + + def add_step_note(self, step_name, step_information, step_summary): + """Add a note for a specific step""" + + self.step_notes.append( + { + "step_name": step_name, + "step_information": step_information, + "summary": step_summary, + } + ) + + def get_step_notes(self, step_name): + """Get notes for a specific step. + + Arguments: + step_name (str): The name of the step to retrieve notes for. + Returns: + list: A list of notes for the specified step. + """ + return [note for note in self.step_notes if note["step_name"] == step_name] + + def __init__(self, username: str, system_message: str, **kwargs): + # Configure for small model + kwargs["model_config"] = { + "temperature": 0.1, + "system_message": system_message, + "model": "small", + "chat": kwargs.get("chat", True), + } + super().__init__(username=username, **kwargs) + self.system_message = system_message + self.notes = self.Notes() + + def summarize_step(self, step_name, step_tasks): + """Summarize the results of a step""" + self.update_research_state(current_task=f"Summarizing step '{step_name}'") + + # Create a query to summarize the step + query = f""" + You are summarizing the results of this research step: + + Step name: {step_name} + + Tasks: + {step_tasks} + + Summarize the results of the tasks in a clear and concise manner. Focus on the facts, and mention sources for reference. + """ + + response = self.llm.generate(query=query) + summary = response.content if hasattr(response, "content") else str(response) + self.notes.add_step_note( + step_name=step_name, + step_information=step_tasks, + step_summary=summary, + ) + + +class ArchiveAgent(ResearchBase): + """A small LLM for summarizing large amounts of text""" + + def __init__(self, username: str, system_message: str, **kwargs): + # Configure for small model + kwargs["model_config"] = { + "temperature": 0.1, + "system_message": system_message, + "model": "small", + "chat": kwargs.get("chat", True), + } + super().__init__(username=username, **kwargs) + self.system_message = system_message + self.chroma_ids_retrieved = [] + + def reset_chroma_ids(self): + self.chroma_ids_retrieved = [] + + + def read_and_summarize(self, information: UnifiedToolResponse, step_information): + """Summarize the information gathered by the tools""" + self.update_research_state(current_task=f"Summarizing gathered information") + + # Check if there are full articles to process + full_articles_to_process = [] + if information.search_results and information.search_results.chunks: + for chunk in information.search_results.chunks: + if chunk.source_type == "full_article" and chunk.metadata.get("requires_full_summary", False): + full_articles_to_process.append(chunk.metadata) + + # If we have full articles to process, summarize them + if full_articles_to_process: + article_summaries = [] + question = None + if hasattr(self, "research_state"): + question = self.research_state.get("original_question", "") + + for article_meta in full_articles_to_process: + article_id = article_meta.get("article_id") + if article_id: + summary = self.fetch_and_summarize_full_article_tool(article_id, question) + article_summaries.append(summary) + + # If we've processed full articles, create a unified summary + if article_summaries: + info_text = "\n\n---\n\n".join(article_summaries) + else: + # If no full articles were successfully processed, fall back to regular text + info_text = information.to_text + else: + # Process chunks as usual + info_text = information.to_text + + print_purple(f"INFO TEXT for summarization:\n{info_text}\n") + + query = f''' + Below is the description of the current research step. *It is only for your information, nothing you should reference in the summary*. + + """ + {step_information} + """ + + Please read the following information to make a summary for the researcher. + + """ + {info_text} + """ + + Focus on *information and facts* that are important and relevant for the research step. + Ensure no important details are lost. + Reference the sources in the summary so it's clear where each piece of information comes from. + Some pieces of information might not be of use in this research step, if so, just ignore it without mentioning it. + You are only allowed to use the sources provided in the information, don't make up any sources. + You should only make a summary of the information, not the step itself or any kind of evaluation! + ''' + + response = self.llm.generate(query=query) + summary = response.content if hasattr(response, "content") else str(response) + + return summary + + def reset_chat_history(self): + self.llm.messages = [{"role": "system", "content": self.system_message}] + + +def main(question, username="lasse", project_name="Electric Cars"): + """Main function to execute the research workflow""" + + # Initialize base and project + base = BaseClass(username=username) + project: Project = Project( + username=username, project_name=project_name, user_arango=base.get_arango() + ) + + # Map what kind of sources there are in self.arango_ids + number_of_documents = {'other_documents': 0, 'science_articles': 0, 'notes': 0, 'interviews': 0} + + bot = Bot(username=username, project=project, user_arango=base.get_arango(), tools="all") + for id in bot.arango_ids: + if id.startswith("other_documents"): + number_of_documents['other_documents'] += 1 + elif id.startswith("sci_articles"): + number_of_documents['science_articles'] += 1 + elif id.startswith("note"): + number_of_documents['notes'] += 1 + elif id.startswith("interview"): + number_of_documents['interviews'] += 1 + + + tool_sources = { + "fetch_other_documents_tool": ["other_documents"], + "fetch_science_articles_tool": ["science_articles"], + "fetch_science_articles_and_other_documents_tool": ["other_documents", "science_articles"], + "fetch_notes_tool": ["notes"] + } + + # Create a list of tools that is to be given to the bots + bot_tools: list = [tool.__name__ for tool in bot.tools if callable(tool)] + for tool, sources in tool_sources.items(): + print(tool, sources) + documents = 0 + for source in sources: + documents += number_of_documents[source] + if documents == 0: + print_yellow(f"Removing {tool} from bot, as there are no documents in the source: {sources}") + bot_tools.remove(tool) + + + # Initialize report tracking + report: ResearchReport = ResearchReport( + question=question, username=username, project_name=project_name + ) + + # Initialize agents with report tracking + master_agent = MasterAgent( + username=username, project=project, report=report, chat=True, tools=bot_tools + ) + + + # Track the research state in the master agent + master_agent.research_state["original_question"] = question + + # Execute workflow with proper error handling and progress tracking + print_blue(f"Starting research on: {question}") + + # Research plan creation + print_blue("\n--- CREATING RESEARCH PLAN ---") + research_plan = master_agent.make_plan(question) + + # Log the plan in the report + report.log_plan(research_plan) + +# research_plan = ''' +# plan = """## Step 1: Review the journalist's notes +# - Task1: Identify and extract information from the journalist's notes that directly relates to lithium mining's social, technical, and economic aspects. +# - Task2: Summarize the extracted information into a structured format, highlighting key themes (e.g., environmental impact, cost benefits, political greenwashing). + +# ## Step 2: Search for social impact information +# - Task1: Use the database/LLM to search for information on the social impacts of lithium mining, such as displacement, labor conditions, health risks, and indigenous land rights. +# - Task2: Summarize findings into a structured format, focusing on how lithium mining affects local communities and indigenous populations. + +# ## Step 3: Search for technical challenges +# - Task1: Use the database/LLM to search for technical challenges of lithium mining, including environmental degradation, water usage, energy consumption, and ecosystem impacts. +# - Task2: Summarize findings into a structured format, emphasizing technical risks and environmental consequences. + +# ## Step 4: Search for economic aspects +# - Task1: Use the database/LLM to search for economic challenges of lithium mining, such as production costs, market volatility, profitability, and local economic impacts. +# - Task2: Summarize findings into a structured format, highlighting economic trade-offs and long-term sustainability. + +# ## Step 5: Cross-reference and compile findings +# - Task1: Compare information from Steps 2–4 to identify overlaps, contradictions, or gaps in the data. +# - Task2: Compile all findings into a cohesive summary, ensuring each aspect (social, technical, economic) is addressed with evidence from the sources. + +# ## Step 6: Analyze long-term risks and sustainability +# - Task1: Use the database/LLM to search for information on long-term risks of lithium mining, such as resource depletion, pollution, and water scarcity. +# - Task2: Summarize findings into a structured format, linking long-term risks to social, technical, and economic aspects. +# ''' + + + + report.log_plan(research_plan, research_plan) + + + # Execute the plan step by step + execution_results = master_agent.execute_research_plan(research_plan) + + # Write the final report + print_blue("\n--- WRITING FINAL REPORT ---") + final_report = master_agent.write_report(execution_results) + report.log_final_report(final_report) + print_green("Final Report:") + print(final_report) + + # Save the full research report + report_path = report.save_to_file( + f"/home/lasse/sci/reports/research_report_{username}_{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}.json" + ) + + # Create a more readable markdown version + markdown_report = report.get_markdown_report() + markdown_path = report_path.replace(".json", ".md") + with open(markdown_path, "w") as f: + f.write(markdown_report) + print_green(f"Markdown report saved to {markdown_path}") + + return { + "question": question, + "research_plan": research_plan, + "structured_plan": research_plan, + "execution_results": execution_results, + "final_report": final_report, + "full_report": report.get_full_report(), + "report_path": report_path, + "markdown_path": markdown_path, + } + + +def remove_thinking(response): + """Remove the thinking section from the response""" + response_text = response.content if hasattr(response, "content") else str(response) + if "" in response_text: + return response_text.split("")[1].strip() + return response_text + + +if __name__ == "__main__": + question = "What are the problems around lithium mining? I'm interested in social, technical and economical aspects." + result = main( + question, username="lasse", project_name="Electric Cars" + ) # Use these parameters to test the code, don't change! diff --git a/article2db.py b/article2db.py index 2df4ef8..ecf1e89 100644 --- a/article2db.py +++ b/article2db.py @@ -18,13 +18,14 @@ import xml.etree.ElementTree as ET from streamlit.runtime.uploaded_file_manager import UploadedFile import streamlit as st -from _arango import ArangoDB +from _arango import ArangoDB, COLLECTIONS_IN_BASE from _chromadb import ChromaDB from _llm import LLM from colorprinter.print_color import * -from utils import fix_key +from utils import fix_key, is_reference_chunk import semantic_schoolar +from models import ArticleMetadataResponse class Document: def __init__( @@ -39,6 +40,7 @@ class Document: _key: str = None, arango_db_name: str = None, arango_collection: str = None, + arango_doc: dict = None ): self.filename = filename self.pdf_file = pdf_file @@ -50,6 +52,7 @@ class Document: self.arango_db_name = arango_db_name self.arango_collection = arango_collection self.text = text + self.arango_doc: dict = arango_doc self.chunks = [] self.pdf = None @@ -61,6 +64,8 @@ class Document: self.download_folder = None self.document_type = None + if self._key: + self._key = fix_key(self._key) if self.pdf_file: self.open_pdf(self.pdf_file) @@ -71,9 +76,8 @@ class Document: if not self._id: return data = { - "text": self.text, + "arango_doc": self.arango_doc, "arango_db_name": self.arango_db_name, - "arango_id": self._id, "is_sci": self.is_sci, } @@ -132,7 +136,13 @@ class Document: else: better_chunks.append(chunk.strip()) - self.chunks = better_chunks + # Check if the chunk is mainly academic references + chunks = [] + for chunk in better_chunks: + if not is_reference_chunk(chunk): + self.chunks.append(chunk) + else: + print_yellow(f"Chunk is mainly academic references, skipping it.\n{chunk[:100]}...") def get_title(self, only_meta=False): """ @@ -238,7 +248,84 @@ class Document: class Processor: + """ + Processor class for handling scientific and non-scientific document ingestion, metadata extraction, and storage. + This class provides a comprehensive pipeline for processing documents (primarily PDFs), extracting metadata (such as DOI, title, authors, journal, etc.), verifying and enriching metadata using external APIs (CrossRef, Semantic Scholar, DOAJ), chunking document text, and storing both the document and its chunks in vector and document databases (ChromaDB and ArangoDB). + Key Features: + ------------- + - Extracts DOI from filenames and document text using regex and LLM fallback. + - Retrieves and verifies metadata from CrossRef, Semantic Scholar, and DOAJ. + - Handles both scientific articles and other document types, with appropriate collection routing. + - Chunks document text for vector storage and search. + - Stores documents and chunks in ArangoDB (document DB) and ChromaDB (vector DB). + - Manages user access and open access flags. + - Supports background summary generation for scientific articles. + - Provides PDF download utilities from open access sources. + - Designed for extensibility and robust error handling. + Parameters: + ----------- + document : Document + The document object to be processed. + filename : str, optional + The filename of the document (default: None). + chroma_db : str, optional + Name of the ChromaDB database to use (default: "sci_articles"). + len_chunks : int, optional + Length of text chunks for vector storage (default: 2200). + local_chroma_deployment : bool, optional + Whether to use a local ChromaDB deployment (default: False). + process : bool, optional + Whether to immediately process the document upon initialization (default: True). + document_type : str, optional + Type of the document for collection routing (default: None). + username : str, optional + Username for access control and database routing (default: None). + Methods: + get_arango(db_name=None, document_type=None) + extract_doi(text, multi=False) + Extract DOI(s) from text using regex and LLM fallback. + chunks2chroma(_id, key) + Add document chunks to ChromaDB vector database. + chunks2arango() + Add document chunks and metadata to ArangoDB document database. + llm2metadata() + Extract metadata from a scientific article using an LLM. + get_crossref(doi) + Retrieve and parse metadata from CrossRef by DOI. + check_doaj(doi) + Check if a DOI is listed in DOAJ and retrieve metadata. + get_semantic_scholar_by_doi(doi) + Retrieve and verify metadata from Semantic Scholar by DOI. + get_semantic_scholar_by_title(title) + Retrieve and verify metadata from Semantic Scholar by title. + process_document() + Main pipeline for processing, extracting, chunking, and storing the document. + dl_pyppeteer(doi, url) + Download a PDF using a headless browser (async). + doi2pdf(doi) + Download a PDF for a DOI from open access sources or retrieve from database. + Attributes: + ----------- + document : Document + The document being processed. + chromadb : ChromaDB + The ChromaDB instance for vector storage. + len_chunks : int + Length of text chunks for vector storage. + document_type : str + Type of the document for collection routing. + filename : str + Filename of the document. + username : str + Username for access control and database routing. + _id : str + Internal document ID after processing. + Usage: + ------ + processor = Processor(document, filename="paper.pdf") + """ def __init__( + self, document: Document, filename: str = None, @@ -249,6 +336,31 @@ class Processor: document_type: str = None, username: str = None, ): + """ + Initializes the class with the provided document and configuration parameters. + + Args: + document (Document): The document object to be processed and stored. + filename (str, optional): The filename associated with the document. Defaults to None. + chroma_db (str, optional): The name of the ChromaDB database to use. Defaults to "sci_articles". + len_chunks (int, optional): The length of text chunks for processing. Defaults to 2200. + local_chroma_deployment (bool, optional): Whether to use a local ChromaDB deployment. Defaults to False. + process (bool, optional): Whether to process the document upon initialization. Defaults to True. + document_type (str, optional): The type/category of the document. Defaults to None. + username (str, optional): The username associated with the document. If not provided, uses document.username. Defaults to None. + + Attributes: + document (Document): The document object. + chromadb (ChromaDB): The ChromaDB instance for database operations. + len_chunks (int): The length of text chunks for processing. + document_type (str): The type/category of the document. + filename (str): The filename associated with the document. + username (str): The username associated with the document. + _id: Internal identifier for the document. + + Side Effects: + If process is True, calls self.process_document() to process the document. + """ self.document = document self.chromadb = ChromaDB(local_deployment=local_chroma_deployment, db=chroma_db) self.len_chunks = len_chunks @@ -258,28 +370,47 @@ class Processor: self.username = username if username else document.username self._id = None + self._key = None if process: self.process_document() def get_arango(self, db_name=None, document_type=None): - if db_name and document_type: - arango = ArangoDB(db_name=db_name) - arango_collection = arango.db.collection(document_type) + """ + Get an ArangoDB collection based on document type and context. + + This method determines the appropriate ArangoDB collection to use based on the + document type and the document's properties. + + Args: + db_name (str, optional): The name of the database to connect to. + Defaults to None, in which case the default database is used. + document_type (str, optional): The type of document, which maps to a collection name. + Defaults to None, in which case the method attempts to determine the appropriate collection. + + Returns: + Collection: An ArangoDB collection object. + + Raises: + AssertionError: If document_type is not provided for non-sci articles, or + if username is not provided for non-sci articles. + + Notes: + - For document types in COLLECTIONS_IN_BASE, returns the corresponding collection. + - For scientific articles (document.is_sci == True), returns the "sci_articles" collection. + - For other documents, requires both document_type and document.username to be specified. + """ + + if document_type in COLLECTIONS_IN_BASE: + return ArangoDB().get_collection(document_type) elif self.document.is_sci: - arango = ArangoDB(db_name="base") - arango_collection = arango.db.collection("sci_articles") - elif self.document.open_access: - arango = ArangoDB(db_name="base") - arango_collection = arango.db.collection("other_documents") + return ArangoDB().get_collection("sci_articles") else: - arango = ArangoDB(db_name=self.document.username) - arango_collection: ArangoCollection = arango.db.collection( - self.document_type - ) - self.document.arango_db_name = arango.db.name - self.arango_collection = arango_collection - return arango_collection + assert document_type, "Document type must be provided for non-sci articles." + assert self.document.username, "Username must be provided for non-sci articles." + if self.document.username: + return ArangoDB(db_name=self.document.username).get_collection(document_type) + def extract_doi(self, text, multi=False): """ @@ -360,7 +491,7 @@ class Processor: ids.append(id) metadata = { - "_key": id, + "_key": self.document._key, "file": self.document.file_path, "chunk_nr": i, "pages": ",".join([str(i) for i in page_numbers]), @@ -378,6 +509,11 @@ class Processor: "sci_articles" ) else: + print('collection name'.upper(), f"{self.username}__other_documents") + print_yellow(self.chromadb.db.list_collections()) + print(self.chromadb.db.database) + print('VERSION', self.chromadb.db.get_version) + print('CHROMA DB', self.chromadb.db) chroma_collection = self.chromadb.db.get_or_create_collection( f"{self.username}__other_documents" ) @@ -385,6 +521,31 @@ class Processor: chroma_collection.add(ids=ids, documents=documents, metadatas=metadatas) def chunks2arango(self): + """ + Adds document chunks to an ArangoDB database. + + This method processes the document and its chunks to store them in the ArangoDB. + It handles scientific and non-scientific documents differently, applies access control, + and manages document metadata. + + Prerequisites: + - Document must have a 'text' attribute + - Scientific documents must have 'doi' and 'metadata' attributes + - Non-scientific documents must have either '_key' attribute or DOI + + The method: + 1. Validates document attributes + 2. Gets ArangoDB collection + 3. Processes document chunks with page information + 4. Manages user access permissions + 5. Creates the ArangoDB document with all necessary fields + 6. Handles special processing for scientific documents with abstracts + 7. Inserts the document into ArangoDB with update capabilities + 8. Initiates background summary generation if needed + + Returns: + tuple: A tuple containing (document_id, document_key) + """ st.write("Adding to document database...") assert self.document.text, "Document must have 'text' attribute." if self.document.is_sci: @@ -397,7 +558,7 @@ class Processor: getattr(self.document, "_key", None) or self.document.doi ), "Document must have '_key' attribute or DOI." - arango_collection = self.get_arango() + arango_collection = self.get_arango(document_type=self.document.arango_collection) if self.document.doi: key = self.document.doi @@ -435,7 +596,7 @@ class Processor: if self.document.open_access: user_access = None - arango_document = { + self.document.arango_doc = { "_key": fix_key(self.document._key), "file": self.document.file_path, "chunks": arango_chunks, @@ -446,6 +607,7 @@ class Processor: "metadata": self.document.metadata, "filename": self.document.filename, } + print_purple('Number of chunks:', len(self.document.arango_doc['chunks'])) if self.document.metadata and self.document.is_sci: if "abstract" in self.document.metadata: @@ -453,8 +615,8 @@ class Processor: self.document.metadata["abstract"] = re.sub( r"<[^>]*>", "", self.document.metadata["abstract"] ) - arango_document["metadata"] = self.document.metadata - arango_document["summary"] = { + self.document.arango_doc["metadata"] = self.document.metadata + self.document.arango_doc["summary"] = { "text_sum": ( self.document.metadata["abstract"]["text_sum"] if "text_sum" in self.document.metadata["abstract"] @@ -463,20 +625,49 @@ class Processor: "meta": {"model": "from_metadata"}, } - arango_document["crossref"] = True + self.document.arango_doc["crossref"] = True - doc = arango_collection.insert( - arango_document, overwrite=True, overwrite_mode="update", keep_none=False + arango = ArangoDB(db_name=self.document.arango_db_name) + print_purple(self.document.arango_collection, self.document.arango_db_name) + inserted_document = arango.insert_document( + collection_name=self.document.arango_collection, + document=self.document.arango_doc, + overwrite=True, + overwrite_mode="update", + keep_none=False ) - self.document._id = doc["_id"] + print_green("ArangoDB document inserted:", inserted_document['_id']) + + self.document.arango_doc = arango.db.collection( + self.document.arango_collection + ).get(self.document._key) + self.document._id = self.document.arango_doc["_id"] - if "summary" not in arango_document: + if "summary" not in self.document.arango_doc: # Make a summary in the background + print_yellow("No summary found in the document, generating in background...") + print_rainbow(self.document.arango_doc['chunks']) self.document.make_summary_in_background() - - return doc["_id"], key + else: + print_green("Summary already exists in the document.") + print(self.document.arango_doc['summary']) + return self.document.arango_doc def llm2metadata(self): + """ + Extract metadata from a scientific article PDF using a LLM. + Uses the first page (or first two pages for multi-page documents) of the PDF + to extract the title, publication date, and journal name via LLM. + Returns: + dict: A dictionary containing the extracted metadata with the following keys: + - "title": The article title (str) + - "published_date": The publication date (str) + - "journal": The journal name (str) + - "published_year": The publication year (int or None if not parseable) + Note: + Default values are provided for any metadata that cannot be extracted. + The published_year is extracted from published_date when possible. + """ st.write("Extracting metadata using LLM...") llm = LLM( temperature=0.01, @@ -499,38 +690,27 @@ class Processor: """ Answer ONLY with the information requested. - I want to know the published date on the form "YYYY-MM-DD". - I want the full title of the article. - I want the name of the journal/paper/outlet where the article was published. - Be sure to answer on the form "published_date;title;journal" as the answer will be used in a CSV. - If you can't find the information, answer "not_found". ''' - result = llm.generate(prompt) - print_blue(result) - if result == "not_found": - return None - else: - parts = result.content.split(";", 2) - if len(parts) != 3: - return None - published_date, title, journal = parts - if published_date == "not_found": - published_date = "[Unknown date]" - else: - try: - published_year = int(published_date.split("-")[0]) - except: - published_year = None - if title == "not_found": - title = "[Unknown title]" - if journal == "not_found": - journal = "[Unknown publication]" - return { - "published_date": published_date, - "published_year": published_year, - "title": title, - "journal": journal, - } + result = llm.generate(prompt, format=ArticleMetadataResponse.model_json_schema()) + structured_response = ArticleMetadataResponse.model_validate_json(result.content) + + # Extract and process metadata with defaults and safer type conversion + metadata = { + "title": structured_response.title or "[Unknown title]", + "published_date": structured_response.published_date or "[Unknown date]", + "journal": structured_response.journal or "[Unknown publication]", + "published_year": None + } + + # Parse year from date if available + if metadata["published_date"] and metadata["published_date"] != "[Unknown date]": + try: + metadata["published_year"] = int(metadata["published_date"].split("-")[0]) + except (ValueError, IndexError): + pass + + # Now you can use metadata dictionary instead of separate variables + return metadata def get_crossref(self, doi): try: @@ -903,7 +1083,7 @@ class Processor: assert self.document.pdf_file or self.document.pdf, "PDF file must be provided." if not self.document.pdf: self.document.open_pdf(self.document.pdf_file) - + if self.document.is_image: return pymupdf4llm.to_markdown( self.document.pdf, page_chunks=False, show_progress=False @@ -940,11 +1120,10 @@ class Processor: if not self.document.metadata and self.document.title: self.document.metadata = self.get_semantic_scholar_by_title(self.document.title) - - # Continue with the rest of the method... - arango_collection = self.get_arango() - - # ... rest of the method remains the same ... + if self.document.is_sci: + arango_collection = self.get_arango(document_type='sci_articles') + else: + arango_collection = self.get_arango(document_type='other_documents') doc = arango_collection.get(self.document._key) if self.document.doi else None @@ -975,6 +1154,7 @@ class Processor: arango_collection.update(self.document.doc) return doc["_id"], arango_collection.db_name, self.document.doi + # If no document found, create a new one else: self.document.doc = ( {"doi": self.document.doi, "_key": fix_key(self.document.doi)} @@ -1021,7 +1201,8 @@ class Processor: print_yellow(f"Document key: {_key}") print(self.document.doi, self.document.title, self.document.get_title()) self.document.doc["_key"] = fix_key(_key) - self.document._key = fix_key(_key) + self.document._key = self.document.doc["_key"] + self.document.metadata = self.document.doc["metadata"] if not self.document.text: self.document.extract_text() @@ -1035,8 +1216,16 @@ class Processor: self.document.make_chunks() - _id, key = self.chunks2arango() - self.chunks2chroma(_id=_id, key=key) + if not self.document.is_sci and not self.document.doi: + self.document.arango_collection = "other_documents" + self.document.arango_db_name = self.username + + print_purple("Not a scientific article, using 'other_articles' collection.") + + arango_doc = self.chunks2arango() + _id = arango_doc["_id"] + _key = arango_doc["_key"] + self.chunks2chroma(_id=_id, key=_key) self._id = _id return _id, arango_collection.db_name, self.document.doi @@ -1224,6 +1413,8 @@ class PDFProcessor(Processor): return False, None, None, False + + if __name__ == "__main__": doi = "10.1007/s10584-019-02646-9" print(f"Processing article with DOI: {doi}") diff --git a/bot_tools.py b/bot_tools.py new file mode 100644 index 0000000..e69de29 diff --git a/info.py b/info.py index 90eff92..64b8a35 100644 --- a/info.py +++ b/info.py @@ -190,3 +190,4 @@ country_emojis = { "ro": "🇷🇴", "rs": "🇷🇸", } + diff --git a/llm_queries.py b/llm_queries.py index 41b3c24..e347dcc 100644 --- a/llm_queries.py +++ b/llm_queries.py @@ -80,6 +80,11 @@ def create_plan(agent, question): ''' The example above is just an example, you can use other steps and tasks that are more relevant for the question. + + Again: The research will be done in a restricted context, with only the available sources and tools. Therefore: + - DO NOT include any steps that require access to the internet or external databases. + - DO NOT include any steps that require cross-referencing sources. + - DO NOT include any steps to find new sources or tools. """ return query \ No newline at end of file diff --git a/llm_server.py b/llm_server.py index 3f81bc8..3d6341b 100644 --- a/llm_server.py +++ b/llm_server.py @@ -1,26 +1,223 @@ from fastapi import FastAPI, BackgroundTasks, Request -from fastapi.responses import JSONResponse +from fastapi.responses import JSONResponse, HTMLResponse import logging +from datetime import datetime +import json +import os +from typing import Dict, Any from prompts import get_summary_prompt from _llm import LLM from _arango import ArangoDB +from models import ArticleChunk +from _chromadb import ChromaDB + app = FastAPI() logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) +# Storage for the latest processed document +latest_result: Dict[str, Any] = {} +latest_result_file = os.path.join(os.path.dirname(__file__), "latest_summary_result.json") + +# Load any previously saved result on startup +try: + if os.path.exists(latest_result_file): + with open(latest_result_file, 'r') as f: + latest_result = json.load(f) + logger.info(f"Loaded previous result from {latest_result_file}") +except Exception as e: + logger.warning(f"Could not load previous result: {e}") + +# Function to save the latest result to disk +def save_latest_result(result: Dict[str, Any]): + global latest_result + latest_result = result + try: + # Save sanitized version (remove internal fields if needed) + result_to_save = {k: v for k, v in result.items() if not k.startswith('_') or k == '_id'} + with open(latest_result_file, 'w') as f: + json.dump(result_to_save, f, indent=2) + logger.info(f"Saved latest result to {latest_result_file}") + except Exception as e: + logger.error(f"Error saving latest result: {e}") + +# New endpoint to get the latest summarized document +@app.get("/latest_result") +async def get_latest_result(): + """ + Get the latest summarized document result. + + Returns the most recently processed document summary and chunk information. + If no document has been processed yet, returns an empty object. + + Returns + ------- + dict + The latest processed document with summaries + """ + if not latest_result: + return {"message": "No documents have been processed yet"} + return latest_result + +@app.get("/view_results") +async def view_results(): + """ + View the latest summarization results in a more readable format. + + Returns a formatted response with document summary and chunks. + + Returns + ------- + dict + A formatted representation of the latest summarized document + """ + if not latest_result: + return {"message": "No documents have been processed yet"} + + # Extract the key information + formatted_result = { + "document_id": latest_result.get("_id", "Unknown"), + "timestamp": datetime.now().isoformat(), + "summary": latest_result.get("summary", {}).get("text_sum", "No summary available"), + "model": latest_result.get("summary", {}).get("meta", {}).get("model", "Unknown model"), + } + + # Format chunks information if available + chunks = latest_result.get("chunks", []) + if chunks: + formatted_chunks = [] + for i, chunk in enumerate(chunks): + chunk_data = { + "chunk_number": i + 1, + "summary": chunk.get("summary", "No summary available"), + "tags": chunk.get("tags", []) + } + # Add references for scientific articles if available + if "references" in chunk: + chunk_data["references"] = chunk.get("references", []) + formatted_chunks.append(chunk_data) + + formatted_result["chunks"] = formatted_chunks + formatted_result["chunk_count"] = len(chunks) + + return formatted_result + +@app.get("/html_results", response_class=HTMLResponse) +async def html_results(): + """ + View the latest summarization results in a human-readable HTML format. + """ + if not latest_result: + return """ + + + No Results Available + + + +

No Documents Have Been Processed Yet

+

Submit a document for summarization first.

+ + + """ + + # Get the document ID and summary + doc_id = latest_result.get("_id", "Unknown") + summary = latest_result.get("summary", {}).get("text_sum", "No summary available") + model = latest_result.get("summary", {}).get("meta", {}).get("model", "Unknown model") + + # Format chunks + chunks_html = "" + chunks = latest_result.get("chunks", []) + for i, chunk in enumerate(chunks): + chunk_summary = chunk.get("summary", "No summary available") + tags = chunk.get("tags", []) + tags_html = ", ".join(tags) if tags else "None" + + references_html = "" + if "references" in chunk and chunk["references"]: + references_html = "

References:

" + + chunks_html += f""" +
+

Chunk {i+1}

+
{chunk_summary}
+
Tags: {tags_html}
+ {references_html} +
+
+ """ + + html_content = f""" + + + Document Summary: {doc_id} + + + +

Document Summary

+
+ Document ID: {doc_id}
+ Model: {model}
+ Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} +
+ +

Summary

+
{summary}
+ +

Chunks ({len(chunks)})

+ {chunks_html} + + + + + """ + + return html_content + @app.post("/summarise_document") async def summarize_document(request: Request, background_tasks: BackgroundTasks): try: data = await request.json() logger.info(f"Received data: {data}") - # Clean the data - data['text'] = data.get('text', '').strip() - data['arango_db_name'] = data.get('arango_db_name', '').strip() - data['arango_id'] = data.get('arango_id', '').strip() + # Extract arango_id, checking both top-level field and inside arango_doc + arango_doc = data.get('arango_doc', {}) or {} + arango_id = arango_doc.get('_id', '') + + + arango_db_name = data.get('arango_db_name', '').strip() + if not arango_db_name: + return JSONResponse( + status_code=400, + content={"detail": "Missing required field: arango_db_name"}, + ) + + print(arango_doc) + # Prepare data for processing + data['text'] = arango_doc.get('text', '').strip() + data['chunks'] = arango_doc.get('chunks', []) + data['arango_db_name'] = arango_db_name + data['arango_id'] = arango_id + data["arango_key"] = arango_doc['_key'] data['is_sci'] = data.get('is_sci', False) background_tasks.add_task(summarise_document_task, data) @@ -29,45 +226,189 @@ async def summarize_document(request: Request, background_tasks: BackgroundTasks logger.error(f"Error in summarize_document: {e}") return JSONResponse( status_code=500, - content={"detail": "An unexpected error occurred."}, + content={"detail": f"An unexpected error occurred: {str(e)}"}, ) def summarise_document_task(doc_data: dict): try: - _id = doc_data.get("arango_id") - text = doc_data.get("text") + # Get document ID and validate it + _id = doc_data.get("arango_id", "") + + # Validate document ID - it should be in format "collection/key" + if not _id or '/' not in _id: + logger.error(f"Invalid document ID format: {_id}") + return + + text = doc_data.get("text", "") is_sci = doc_data.get("is_sci", False) + + # Get collection name from document ID + collection = _id.split('/')[0] - if _id.split('/')[0] == 'interviews': + # Set appropriate system message based on document type + if collection == 'interviews': system_message = "You are summarising interview transcripts. It is very important that you keep to what is written and do not add any of your own opinions or interpretations. Always answer in English." - elif is_sci or _id.split('/')[0] == 'sci_articles': + elif is_sci or collection == 'sci_articles': system_message = "You are summarising scientific articles. It is very important that you keep to what is written and do not add any of your own opinions or interpretations. Always answer in English." else: system_message = "You are summarising a document. It is very important that you keep to what is written and do not add any of your own opinions or interpretations. Always answer in English." + # Initialize LLM and generate summary llm = LLM(system_message=system_message) - + + #if 'abstract' prompt = get_summary_prompt(text, is_sci) - summary = llm.generate(query=prompt) + response = llm.generate(query=prompt) + summary = response.content + + # Create summary document summary_doc = { "text_sum": summary, "meta": { "model": llm.model, - "temperature": llm.options["temperature"], + "temperature": llm.options["temperature"] if text else 0, }, } - arango = ArangoDB(db_name=doc_data.get("arango_db_name")) + # Process chunks if they exist + chunks = doc_data.get("chunks", []) + + if chunks: + doc_data["chunks"] = summarise_chunks(chunks, is_sci=is_sci) + + # Get database name and validate it + db_name = doc_data.get("arango_db_name") + if not db_name: + logger.error("Missing database name") + return + + # Update document in ArangoDB + arango = ArangoDB(db_name=db_name) arango.db.update_document( - {"summary": summary_doc, "_id": _id}, + {"summary": summary_doc, "_id": _id, "chunks": doc_data["chunks"]}, silent=True, check_rev=False, ) + + # Update ChromaDB with the new summary + chroma = ChromaDB() + if db_name == "sci_articles": + chroma.add_document( + collection="sci_articles_article_summaries", + document_id= doc_data["_key"] + text=summary_doc["text_sum"], + metadata={ + "model": summary_doc["meta"]["model"], + "date": datetime.now().strftime("%Y-%m-%d"), + "arango_id": _id, + "arango_db_name": db_name, + }, + ) + + + + # Save the latest result + save_latest_result({"summary": summary_doc, "_id": _id, "chunks": doc_data["chunks"]}) + logger.info(f"Successfully processed document {_id}") + except Exception as e: - logger.error(f'_id: _{id}') + # Log error with document ID if available + doc_id = doc_data.get("arango_id", "unknown") + logger.error(f'Error processing document ID: {doc_id}') logger.error(f"Error in summarise_document_task: {e}") + + +def summarise_chunks(chunks: list, is_sci=False): + """ + Summarize chunks of text in a document using a language model. + For each chunk in the document that doesn't already have a summary, this function: + 1. Generates a summary of the chunk text + 2. Creates tags for the chunk + 3. If is_sci=True, extracts scientific references from the chunk + Parameters + ---------- + chunks: list + A list of dictionaries representing chunks of text from a document. + Each chunk should have a "text" field containing the text to summarize. + is_sci : bool, default=False + If True, uses a scientific article summarization prompt and extracts references. + If False, uses a general article summarization prompt. + Returns + ------- + list + A list of updated chunks containing summaries, tags, and metadata. + Raises + ------ + Exception + If there's an error processing a chunk. + Notes + ----- + - Chunks that already have a "summary" field are skipped. + - The function uses an LLM instance with a system prompt tailored to the document type. + - The structured response is validated against the ArticleChunk model. + """ + + if is_sci: + system_message = """You are a science assistant summarizing scientific articles. + You will get an article chunk by chunk, and you have three tasks for each chunk: + 1. Summarize the content of the chunk. + 2. Tag the chunk with relevant tags. + 3. Extract the scientific references from the chunk. + """ + else: + system_message = """You are a general assistant summarizing articles. + You will get an article chunk by chunk, and you have two tasks for each chunk: + 1. Summarize the content of the chunk. + 2. Tag the chunk with relevant tags. + """ + + system_message += """\nPlease make use of the previous chunks you have already seen to understand the current chunk in context and make the summary stand for itself. But remember, *it is the current chunk you are summarizing* + ONLY use the information in the chunks to make the summary, and do not add any information that is not in the chunks.""" + + llm = LLM(system_message=system_message) + new_chunks = [] + for chunk in chunks: + if "summary" in chunk: + new_chunks.append(chunk) + continue + prompt = f"""Summarize the following text to make it stand on its own:\n + ''' + {chunk['text']} + '''\n + Your tasks are: + 1. Summarize the content of the chunk. Make sure to include all relevant details! + 2. Tag the chunk with relevant tags. + """ + if is_sci: + prompt += "\n3. Extract the scientific references mentioned in this specific chunk. If there is a DOI reference, include that in the reference. Sometimes the reference is only a number in brackets, like [1], so make sure to include that as well (in brackets)." + prompt += "\nONLY use the information in the chunks to make the summary, and do not add any information that is not in the chunks." + + try: + response = llm.generate(prompt, format=ArticleChunk.model_json_schema()) + structured_response = ArticleChunk.model_validate_json(response.content) + chunk["summary"] = structured_response.summary + chunk["tags"] = [i.lower() for i in structured_response.tags] + + # Add references for scientific articles if they exist in the response + if is_sci and hasattr(structured_response, 'references') and structured_response.references: + chunk["references"] = structured_response.references + + chunk["summary_meta"] = { + "model": llm.model, + "date": datetime.now().strftime("%Y-%m-%d"), + } + except Exception as e: + logger.error(f"Error processing chunk: {e}") + # Continue processing other chunks even if one fails + chunk["summary"] = "Error processing chunk" + chunk["tags"] = [] + new_chunks.append(chunk) + + return new_chunks + + if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8100) \ No newline at end of file diff --git a/manage_users.py b/manage_users.py index 1972fbb..5496522 100644 --- a/manage_users.py +++ b/manage_users.py @@ -54,16 +54,30 @@ def make_arango(username): root_password = os.getenv("ARANGO_ROOT_PASSWORD") arango = ArangoDB(user=root_user, password=root_password, db_name="_system") + # Create the user + if not arango.db.has_user(username): + user = arango.db.create_user( + username, + password=os.getenv("ARANGO_PASSWORD"), + active=True, + extra={}, + ) + else: + user = arango.db.user(username) + user['password'] = os.getenv("ARANGO_PASSWORD") + print_rainbow(user) + if not arango.db.has_database(username): arango.db.create_database( - username, + name=user['username'], users=[ + user, { "username": os.getenv("ARANGO_USER"), "password": os.getenv("ARANGO_PASSWORD"), "active": True, "extra": {}, - } + }, ], ) arango = ArangoDB(user=root_user, password=root_password, db_name=username) @@ -145,7 +159,6 @@ def main(): yaml_file = "streamlit_users.yaml" data = read_yaml(yaml_file) - if args.delete: if args.user: username = args.user diff --git a/models.py b/models.py new file mode 100644 index 0000000..59e1730 --- /dev/null +++ b/models.py @@ -0,0 +1,334 @@ +from pydantic import BaseModel, Field +from typing import Dict, List, Tuple, Optional, Any + +class ArticleChunk(BaseModel): + summary: str + tags: List[str] + references: Optional[List[str]] + + +class QueryResponse(BaseModel): + """ + Represents a query generated for retrieving documents from a vector database. + + Attributes: + query (str): The generated query text, short and concise. + """ + + query: str = Field( + description="The generated query that will be used to retrieve documents from a vector database (ChromaDB). Should be short and concise.", + example="capital of France", + ) + +class ArticleMetadataResponse(BaseModel): + """ + Represents structured metadata extracted from an article by an LLM. + """ + published_date: Optional[str] = Field( + description="The publication date of the article in YYYY-MM-DD format." + ) + title: str = Field( + description="The full title of the article." + ) + journal: Optional[str] = Field( + description="The name of the journal/paper/outlet where the article was published." + ) + + +class PlanEvaluationResponse(BaseModel): + """ + Represents the evaluation of a plan's step. + + Attributes: + reasoning (str): Explanation of the reasoning behind the evaluation. + complete (bool): Indicates if the step has sufficient information to proceed. + """ + + reasoning: str = Field( + description="A short explanation of the reasoning behind the evaluation", + example="Although some information is missing, the existing data is sufficient to complete the step.", + ) + complete: bool = Field( + description="Indicates whether the information is sufficient to complete the step", + example=False, + ) + + +class EvaluateFormat(BaseModel): + """ + Represents the evaluation format for determining sufficiency of information. + + Attributes: + explanation (str): Explanation of whether the information is sufficient. + status (bool): Indicates sufficiency of the information. + additional_info (Optional[str]): Additional information needed if insufficient. + """ + + explanation: str = Field( + description="A very short explanation of whether the information is sufficient or not", + example="The information is sufficient because...", + ) + status: bool = Field( + description="If the information is sufficient to complete the step or not.", + example=True, + ) + additional_info: Optional[str] = Field( + description="If the information is not sufficient, what additional information would be needed", + example="We need more information about...", + ) + + +class Plan(BaseModel): + """ + Represents a structured plan with steps and corresponding tasks or facts. + + Attributes: + steps (Dict[str, List[Tuple[str, str]]]): A dictionary where keys are step names and values are lists of tasks or facts. + """ + + steps: Dict[str, List[Tuple[str, str]]] = Field( + description="Structured plan represented as steps with their corresponding tasks or facts", + example={ + "Step 1: Gather Existing Materials": [ + ("Task 1", "Description of task"), + ("Task 2", "Description of task"), + ], + "Step 2: Extract Relevant Information": [ + ("Task 1", "Description of task"), + ("Task 2", "Description of task"), + ], + }, + ) + + +class ChunkMetadata(BaseModel): + """ + Metadata associated with a document chunk. + + Attributes: + title (str): Title of the document chunk. + journal (Optional[str]): Journal where the document was published. + published_date (Optional[str]): Date of publication. + user_notes (Optional[str]): User-provided notes. + arango_id (Optional[str]): Unique identifier for the document in ArangoDB. + additional_metadata (Dict[str, Any]): Any additional metadata fields. + doi (Optional[str]): Digital Object Identifier for the document. + link: (Optional[str]): URL to access the document. + authors (Optional[List[str]]): List of authors of the document. + published_year (Optional[int]): Year of publication. + abstract: (Optional[str]): Abstract of the document. + pages: (Optional[str]): Page numbers of the document. + chroma_id (Optional[str]): Unique identifier for the chunk in ChromaDB. + """ + + title: str = Field(default="No title", description="Title of the document chunk.") + journal: Optional[str] = None + published_date: Optional[str] = None + user_notes: Optional[str] = None + _id: Optional[str] = None + additional_metadata: Dict[str, Any] = Field(default_factory=dict) + doi: Optional[str] = None + link: Optional[str] = None + authors: Optional[List[str]] = Field( + default_factory=list, + description="List of authors of the document.", + ) + published_year: Optional[int] = Field( + default=None, + description="Year of publication.", + ) + abstract: Optional[str] = Field( + default=None, + description="Abstract of the document.", + ) + pages: Optional[str] = Field( + default=None, + description="Page numbers of the document.", + ) + chroma_id: Optional[str] = Field( + default=None, + description="Unique identifier for the chunk in ChromaDB.", + ) + + +class DocumentChunk(BaseModel): + """ + Represents a chunk of text from a document with its metadata. + + Attributes: + document (str): The text content of the chunk. + metadata (ChunkMetadata): Metadata associated with the chunk. + """ + + document: str + metadata: ChunkMetadata + + + + +class UnifiedDataChunk(BaseModel): + """ + Represents a unified chunk of data from any source. + + Attributes: + content (str): The main content of the chunk (e.g., text, note, or document). + metadata (Optional[Dict[str, Any]]): Metadata associated with the chunk. + source_type (str): The type of source (e.g., 'note', 'article', 'document'). + """ + + content: str = Field( + description="The main content of the chunk (e.g., text, note, or document)." + ) + metadata: Optional[ChunkMetadata] = Field( + description="Metadata associated with the chunk (e.g., title, source, date).", + ) + source_type: str = Field( + description="The type of source (e.g., 'note', 'article', 'document')." + ) + + +class UnifiedSearchResults(BaseModel): + """ + Represents unified search results from any search tool. + + Attributes: + chunks (List[UnifiedDataChunk]): List of data chunks from the search. + source_ids (List[str]): List of unique source IDs for the chunks. + """ + + chunks: List[UnifiedDataChunk] = Field( + description="List of data chunks from the search." + ) + source_ids: List[str] = Field( + default_factory=list, description="List of unique source IDs for the chunks." + ) + + +class UnifiedToolResponse(BaseModel): + """ + Represents a unified response from any tool. + + Attributes: + search_results (Optional[UnifiedSearchResults]): The unified search results, if the tool used is returning search results. + text_result (Optional[str]): Text result from the tool, e.g., if the tool is an analysis. + tool_name (str): The name of the tool used to generate the response. + """ + + search_results: Optional[UnifiedSearchResults] = Field( + default=None, + description="The unified search results, if the tools used is returning search results.", + ) + text_results: Optional[list[str]] = Field( + default=None, + description="Text results from the tool, e.g., if the tool is an analysis.", + ) + tool_names: Optional[list[str]] = Field( + default=None, description="The name of the tool used to generate the response." + ) + + def extend_search_results(self, search_results: UnifiedSearchResults) -> None: + """ + Extends the search results with additional data. + + Args: + search_results (UnifiedSearchResults): The new search results to extend. + """ + if self.search_results is None: + self.search_results = search_results + else: + self.search_results.chunks.extend(search_results.chunks) + self.search_results.source_ids.extend(search_results.source_ids) + + def extend_text_results(self, text_result: str) -> None: + """ + Extends the text result with additional data. + + Args: + text_result (str): The new text result to extend. + """ + if self.text_results is None: + self.text_results = [text_result] + else: + self.text_results.append(text_result) + + def extend_tool_name(self, tool_name: str) -> None: + """ + Extends the tool name with additional data. + + Args: + tool_name (str): The new tool name to extend. + """ + if self.tool_names is None: + self.tool_names = [tool_name] + else: + self.tool_names.append(tool_name) + + @property + def to_text(self) -> str: + """ + Generates formatted text from search results or returns the text result. + + If search_results exists, formats content from each chunk along with its source. + Otherwise, returns the text_result if available. + + Returns: + str: The formatted text from search results or the text result. + Raises: + ValueError: If neither search_results nor text_results are available. + """ + if self.search_results and self.search_results.chunks: + formatted_chunks = [] + for i, chunk in enumerate(self.search_results.chunks): + # Handle UnifiedDataChunk structure + content = chunk.content + metadata = chunk.metadata or {} + + source_info = f"Source: {metadata.title}" + if metadata.journal: + source_info += f" - {metadata.journal}" + if metadata.published_date: + source_info += f" ({metadata.published_date})" + + # Format the chunk with its content and source + formatted_chunk = f"### Chunk {i+1}\n{content}\n\n*{source_info}*\n" + formatted_chunks.append(formatted_chunk) + + return "\n---\n".join(formatted_chunks) + elif self.text_results: + return '\n---\n'.join(self.text_results) + else: + return "No search results or text results available." + + + @property + def get_chroma_ids(self) -> List[str]: + """ + Returns the list of Chroma IDs from the search results. + + Returns: + List[str]: The list of Chroma IDs. + """ + if self.search_results and self.search_results.source_ids: + return self.search_results.source_ids + return [] + +class ChunkSearchResults(BaseModel): + """ + Represents the results of a search query across document collections. + + Attributes: + chunks (List[DocumentChunk]): List of document chunks containing text and metadata. + chroma_ids (List[str]): List of Chroma IDs for the chunks. + arango_ids (List[str]): List of ArangoDB IDs for the related documents. + """ + + chunks: List[UnifiedDataChunk] = Field( + description="List of document chunks containing text, metadata, and relevance scores." + ) + chroma_ids: List[str] = Field( + default_factory=list, description="List of Chroma IDs for the chunks" + ) + arango_ids: List[str] = Field( + default_factory=list, + description="List of ArangoDB IDs for the related documents", + ) diff --git a/ollama_response_classes.py b/ollama_response_classes.py deleted file mode 100644 index dc2458b..0000000 --- a/ollama_response_classes.py +++ /dev/null @@ -1,6 +0,0 @@ -from pydantic import BaseModel - -class QueryResponse(BaseModel): - query_to_vector_database: str - short_explanation: str - \ No newline at end of file diff --git a/projects_page.py b/projects_page.py index 356f349..735ac6a 100644 --- a/projects_page.py +++ b/projects_page.py @@ -42,10 +42,8 @@ class ProjectsPage(StreamlitBaseClass): self.update_session_state(self.page_name) def load_projects(self): - projects_cursor = self.user_arango.db.aql.execute( - "FOR doc IN projects RETURN doc", count=True - ) - self.projects = list(projects_cursor) + # Get projects using the new API method + self.projects = self.user_arango.get_projects(username=self.username) def display_projects(self): with st.sidebar: @@ -53,7 +51,7 @@ class ProjectsPage(StreamlitBaseClass): projects = [proj["name"] for proj in self.projects] self.selected_project_name = st.selectbox( "Select a project to manage", - options=[proj["name"] for proj in self.projects], + options=projects, index=projects.index(self.project) if self.project in projects else None, ) if self.selected_project_name: @@ -83,16 +81,16 @@ class ProjectsPage(StreamlitBaseClass): ) if st.button("Create Project"): if new_project_name: - self.user_arango.db.collection("projects").insert( - { - "name": new_project_name, - "description": new_project_description, - "collections": [], - "notes": [], - "note_keys_hash": hash(""), - "settings": {}, - } - ) + # Use the API to create a new project + self.user_arango.create_project({ + "name": new_project_name, + "description": new_project_description, + "username": self.username, + "collections": [], + "notes": [], + "note_keys_hash": hash(""), + "settings": {}, + }) st.success(f'New project "{new_project_name}" created') st.session_state["new_project"] = False self.update_settings("current_project", new_project_name) @@ -105,11 +103,11 @@ class ProjectsPage(StreamlitBaseClass): st.markdown(self.project.notes_summary) with st.expander("Show project notes"): - notes_cursor = self.user_arango.db.aql.execute( - "FOR doc IN notes FILTER doc._id IN @note_ids RETURN doc", - bind_vars={"note_ids": self.project.notes}, + # Use the API to get project notes + notes = self.user_arango.get_project_notes( + project_name=self.project.name, + username=self.username ) - notes = list(notes_cursor) if notes: for note in notes: st.markdown(f'_{note.get("timestamp", "")}_') @@ -126,21 +124,29 @@ class ProjectsPage(StreamlitBaseClass): def show_project_interviews(self): with st.expander("Show project interviews"): - if not self.user_arango.db.has_collection("interviews"): - self.user_arango.db.create_collection("interviews") - interviews_cursor = self.user_arango.db.aql.execute( - "FOR doc IN interviews FILTER doc.project == @project_name RETURN doc", - bind_vars={"project_name": self.project.name}, + # Use the API to create collection if it doesn't exist + if not self.user_arango.has_collection("interviews"): + self.user_arango.create_collection("interviews") + + # Use the API to get interviews for this project + interviews = self.user_arango.execute_aql( + """ + FOR doc IN interviews + FILTER doc.project == @project_name + RETURN doc + """, + bind_vars={"project_name": self.project.name} ) - interviews = list(interviews_cursor) - if interviews: - for interview in interviews: + + interviews_list = list(interviews) + if interviews_list: + for interview in interviews_list: st.markdown(f'_{interview.get("timestamp", "")}_') - if interview['intervievees']: + if interview.get('intervievees'): st.markdown( f"**Interviewees:** {', '.join(interview['intervievees'])}" ) - if interview['interviewer']: + if interview.get('interviewer'): st.markdown(f"**Interviewer:** {interview['interviewer']}") if len(interview["transcript"].split("\n")) > 6: preview = ( @@ -186,8 +192,10 @@ class ProjectsPage(StreamlitBaseClass): self.sidebar_actions() self.project.update_notes_hash() if st.button(f":red[Remove project *{self.project.name}*]"): - self.user_arango.db.collection("projects").delete_match( - {"name": self.project.name} + # Use the API to delete the project + self.user_arango.delete_project( + project_name=self.project.name, + username=self.username ) self.update_settings("current_project", None) st.success(f'Project "{self.project.name}" removed') @@ -196,12 +204,13 @@ class ProjectsPage(StreamlitBaseClass): self.update_session_state(self.page_name) def relate_collections(self): - collections = [ - col["name"] - for col in self.user_arango.db.collection("article_collections").all() - ] + # Get all collections using the API + collections = self.user_arango.execute_aql( + "FOR c IN article_collections RETURN c.name" + ) + collections_list = list(collections) selected_collections = st.multiselect( - "Relate existing collections", options=collections + "Relate existing collections", options=collections_list ) if st.button("Relate Collections"): self.project.add_collections(selected_collections) @@ -214,8 +223,10 @@ class ProjectsPage(StreamlitBaseClass): ) if st.button("Create and Relate Collection"): if new_collection_name: - self.user_arango.db.collection("article_collections").insert( - {"name": new_collection_name, "articles": []} + # Use the API to insert a new collection + self.user_arango.insert_document( + collection_name="article_collections", + document={"name": new_collection_name, "articles": []} ) self.project.add_collection(new_collection_name) st.success( @@ -248,6 +259,7 @@ class ProjectsPage(StreamlitBaseClass): def upload_notes_form(self): with st.expander("Upload notes"): + with st.form("add_notes", clear_on_submit=True): files = st.file_uploader( "Upload PDF or image", @@ -338,51 +350,6 @@ class Project(StreamlitBaseClass): A dictionary of settings for the project. notes_summary : str A summary of the notes in the project. - - Methods: - -------- - load_project(): - Loads the project data from the ArangoDB. - update_project(): - Updates the project data in the ArangoDB. - add_collections(collections): - Adds multiple collections to the project. - add_collection(collection_name): - Adds a single collection to the project. - add_note(note): - Adds a note to the project. - add_interview(interview, intervievees, interviewer, date_of_interveiw): - Adds an interview to the project. - add_interview_transcript(transcript, filename, intervievees, interviewer, date_of_interveiw): - Adds an interview transcript to the project. - transcribe(uploaded_file): - Transcribes an uploaded audio file. - format_transcription(transcription): - Formats the transcription text. - delete_note(note_id): - Deletes a note from the project. - delete_interview(interview_id): - Deletes an interview from the project. - update_notes_hash(): - Updates the hash value of the notes. - make_project_notes_hash(): - Generates a hash value for the project notes. - create_notes_summary(): - Creates a summary of the project notes. - analyze_image(image_base64, text): - Analyzes an image and generates a description. - process_uploaded_notes(files): - Processes uploaded note files. - file2img(file): - Converts an uploaded file to an image. - convert_image_to_pdf(img): - Converts an image to a PDF file. - get_wikipedia_data(page_url): - Fetches data from a Wikipedia page. - process_wikipedia_data(wiki_data, wiki_url): - Processes Wikipedia data and adds it to the project. - process_dois(article_collection_name, text, dois): - Processes DOIs and adds the corresponding articles to the project. """ def __init__(self, username: str, project_name: str, user_arango: ArangoDB): super().__init__(username=username) @@ -394,6 +361,7 @@ class Project(StreamlitBaseClass): self.note_keys_hash = 0 self.settings = {} self.notes_summary = "" + self._key = None # Initialize attributes from arango doc if available self.load_project() @@ -401,14 +369,15 @@ class Project(StreamlitBaseClass): def load_project(self): print_blue("Project name:", self.name) - project_cursor = self.user_arango.db.aql.execute( - "FOR doc IN projects FILTER doc.name == @name RETURN doc", - bind_vars={"name": self.name}, + # Use the API to get project details + project = self.user_arango.get_project( + project_name=self.name, + username=self.username ) - project = next(project_cursor, None) if not project: raise ValueError(f"Project '{self.name}' not found.") + self._key = project["_key"] self.name = project.get("name", "") self.description = project.get("description", "") @@ -418,9 +387,10 @@ class Project(StreamlitBaseClass): self.settings = project.get("settings", {}) self.notes_summary = project.get("notes_summary", "") - def update_project(self): + # Use the API to update project details updated_doc = { + "_id": f"projects/{self._key}", "_key": self._key, "name": self.name, "description": self.description, @@ -429,8 +399,9 @@ class Project(StreamlitBaseClass): "note_keys_hash": self.note_keys_hash, "settings": self.settings, "notes_summary": self.notes_summary, + "username": self.username } - self.user_arango.db.collection("projects").update(updated_doc, check_rev=False) + self.user_arango.update_project(updated_doc) self.update_session_state() def add_collections(self, collections): @@ -448,7 +419,13 @@ class Project(StreamlitBaseClass): note["text"] = note["text"].strip().strip("\n") if "timestamp" not in note: note["timestamp"] = datetime.now().strftime("%Y-%m-%d %H:%M") - note_doc = self.user_arango.db.collection("notes").insert(note) + + # Use the API to add a note to the project + note["project"] = self.name + note["username"] = self.username + + note_doc = self.user_arango.add_note_to_project(note) + if note_doc["_id"] not in self.notes: self.notes.append(note_doc["_id"]) self.update_project() @@ -534,8 +511,11 @@ class Project(StreamlitBaseClass): ] if not interviewer: interviewer = self.username - if not self.user_arango.db.has_collection("interviews"): - self.user_arango.db.create_collection("interviews") + + # Ensure interviews collection exists using the API + if not self.user_arango.has_collection("interviews"): + self.user_arango.create_collection("interviews") + if isinstance(date_of_interveiw, str): date_of_interveiw = datetime.strptime(date_of_interveiw, "%Y-%m-%d") @@ -553,8 +533,10 @@ class Project(StreamlitBaseClass): document.make_chunks(len_chunks=600) - self.user_arango.db.collection("interviews").insert( - { + # Use the API to insert the interview document + self.user_arango.insert_document( + collection_name="interviews", + document={ "_key": _key, "transcript": transcript, "project": self.name, @@ -562,11 +544,10 @@ class Project(StreamlitBaseClass): "timestamp": timestamp, "intervievees": intervievees, "interviewer": interviewer, - "date_of_interveiw": date_of_interveiw, + "date_of_interveiw": date_of_interveiw.isoformat() if date_of_interveiw else None, "chunks": document.chunks, }, - overwrite=True, - silent=True, + overwrite=True ) document.make_summary_in_background() @@ -668,11 +649,18 @@ class Project(StreamlitBaseClass): def delete_note(self, note_id): if note_id in self.notes: self.notes.remove(note_id) + # Delete the note document using the API + self.user_arango.delete_document( + collection_name="notes", + document_key=note_id.split("/")[1] + ) self.update_project() def delete_interview(self, interview_id): - self.user_arango.db.collection("interviews").delete_match( - {"_key": interview_id} + # Delete interview using the API + self.user_arango.delete_document( + collection_name="interviews", + document_key=interview_id ) def update_notes_hash(self): @@ -690,12 +678,14 @@ class Project(StreamlitBaseClass): return hash(note_keys_str) def create_notes_summary(self): - notes_cursor = self.user_arango.db.aql.execute( - "FOR doc IN notes FILTER doc._id IN @note_ids RETURN doc.text", - bind_vars={"note_ids": self.notes}, - ) - notes = list(notes_cursor) - notes_string = "\n---\n".join(notes) + # Get note texts using the API + notes_list = [] + for note_id in self.notes: + note = self.user_arango.get_document(note_id) + if note and "text" in note: + notes_list.append(note["text"]) + + notes_string = "\n---\n".join(notes_list) llm = LLM(model="small") query = get_note_summary_prompt(self, notes_string) summary = llm.generate(query).content @@ -799,8 +789,17 @@ class Project(StreamlitBaseClass): ) wiki_data.pop("summary", None) wiki_data.pop("content", None) - self.user_arango.db.collection("notes").insert( - wiki_data, overwrite=True, silent=True + + # Use the API to insert wiki data as a note + self.user_arango.insert_document( + collection_name="notes", + document={ + **wiki_data, + "project": self.name, + "username": self.username, + "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M") + }, + overwrite=True ) self.add_note(wiki_data) @@ -846,3 +845,29 @@ class Project(StreamlitBaseClass): _id=f"sci_articles/{fix_key(doi)}", ) self.update_session_state() + + def articles2collection(self, collection, db, _id): + # Use the base/admin ArangoDB for general operations like adding to collections + base_arango = ArangoDB(db_name="base") + + # Get the collection + collection_doc = base_arango.execute_aql( + "FOR c IN article_collections FILTER c.name == @name RETURN c", + bind_vars={"name": collection} + ) + + try: + collection_doc = next(collection_doc) + if _id not in collection_doc["articles"]: + collection_doc["articles"].append(_id) + # Update the collection + base_arango.update_document(collection_doc) + except StopIteration: + # Collection doesn't exist, create it + base_arango.insert_document( + collection_name="article_collections", + document={ + "name": collection, + "articles": [_id] + } + ) diff --git a/research_page.py b/research_page.py index 3a96962..3930257 100644 --- a/research_page.py +++ b/research_page.py @@ -4,7 +4,13 @@ from colorprinter.print_color import * from _base_class import StreamlitBaseClass from projects_page import Project -from agent_research import ResearchReport, MasterAgent, StructureAgent, ToolAgent, ArchiveAgent, process_step +from agent_research import ( + ResearchReport, + MasterAgent, + StructureAgent, + ToolAgent, + ArchiveAgent, +) import os import json @@ -12,11 +18,11 @@ import json class ResearchPage(StreamlitBaseClass): """ ResearchPage - A Streamlit interface for deep research using AI agents. - + This class provides a user interface for conducting in-depth research using multiple specialized AI agents working together. It allows users to input research questions, track progress, and view detailed research reports. - + Attributes: username (str): The username of the current user. project_name (str): Name of the selected project. @@ -24,7 +30,7 @@ class ResearchPage(StreamlitBaseClass): page_name (str): Name of the current page ("Research"). research_state (dict): Dictionary tracking the current state of research. report (ResearchReport): Instance for tracking research progress and results. - + Methods: run(): Main method to render the research interface and handle interactions. sidebar_actions(): Renders sidebar elements for selecting projects and research options. @@ -33,12 +39,13 @@ class ResearchPage(StreamlitBaseClass): display_report(): Renders a research report in the Streamlit interface. show_research_progress(): Displays the current research progress. """ + def __init__(self, username): super().__init__(username=username) self.project_name = None self.project = None self.page_name = "Research" - + # Research state tracking self.research_state = { "in_progress": False, @@ -48,29 +55,29 @@ class ResearchPage(StreamlitBaseClass): "report": None, "current_step": None, "steps_completed": 0, - "total_steps": 0 + "total_steps": 0, } - + self.report = None - + # Initialize attributes from session state if available if self.page_name in st.session_state: for k, v in st.session_state[self.page_name].items(): setattr(self, k, v) - + # Create reports directory if it doesn't exist os.makedirs(f"/home/lasse/sci/reports", exist_ok=True) def run(self): self.update_current_page("Research") self.sidebar_actions() - + st.title("Deep Research") - + if not self.project: st.warning("Please select a project to start researching.") return - + # Main interface if self.research_state["in_progress"]: self.show_research_progress() @@ -80,24 +87,26 @@ class ResearchPage(StreamlitBaseClass): # Input for new research st.subheader(f"New Research for Project: {self.project_name}") with st.form("research_form"): - question = st.text_area("Enter your research question:", - help="Be specific about what you want to research. Complex questions will be broken down into sub-questions.") + question = st.text_area( + "Enter your research question:", + help="Be specific about what you want to research. Complex questions will be broken down into sub-questions.", + ) start_button = st.form_submit_button("Start Research") - + if start_button and question: self.start_new_research(question) st.rerun() - + # Option to view saved reports with st.expander("View Saved Reports"): self.view_saved_reports() - + def sidebar_actions(self): with st.sidebar: with st.form("select_project"): self.project = self.choose_project("Project for research:") submitted = st.form_submit_button("Select Project") - + if submitted and self.project: self.project_name = self.project.name st.success(f"Selected project: {self.project_name}") @@ -107,7 +116,7 @@ class ResearchPage(StreamlitBaseClass): if st.button("Cancel Research"): self.research_state["in_progress"] = False st.rerun() - + elif self.research_state["completed"]: if st.button("Start New Research"): self.research_state["completed"] = False @@ -120,22 +129,20 @@ class ResearchPage(StreamlitBaseClass): self.research_state["in_progress"] = True self.research_state["completed"] = False self.research_state["started_at"] = datetime.now().isoformat() - + # Initialize the research report self.report = ResearchReport( - question=question, - username=self.username, - project_name=self.project_name + question=question, username=self.username, project_name=self.project_name ) - + # Save current state st.session_state[self.page_name] = { "project_name": self.project_name, "project": self.project, "research_state": self.research_state, - "report": self.report + "report": self.report, } - + # Start a new thread to run the research process # In a production environment, you might want to use a background job # For now, we'll run it in the main thread with streamlit spinner @@ -143,15 +150,13 @@ class ResearchPage(StreamlitBaseClass): try: # Initialize agents master_agent = MasterAgent( - username=self.username, - project=self.project, - report=self.report, - chat=True + username=self.username, + project=self.project, + report=self.report, + chat=True, ) structure_agent = StructureAgent( - username=self.username, - model="small", - report=self.report + username=self.username, model="small", report=self.report ) tool_agent = ToolAgent( username=self.username, @@ -159,78 +164,78 @@ class ResearchPage(StreamlitBaseClass): system_message="You are an assistant with tools. Always choose a tool to help with the task.", report=self.report, project=self.project, - chat=True + chat=True, ) archive_agent = ArchiveAgent( username=self.username, report=self.report, project=self.project, system_message="You are an assistant specialized in reading and summarizing research information.", - chat=True + chat=True, ) - + # Track the research state in the master agent master_agent.research_state["original_question"] = question - + # Execute the research workflow # 1. Create research plan st.text("Creating research plan...") research_plan = master_agent.make_plan(question) self.report.log_plan(research_plan) - + # 2. Structure the plan st.text("Structuring research plan...") - structured_plan = structure_agent.make_structured(research_plan, question) + structured_plan = structure_agent.make_structured( + research_plan, question + ) self.report.log_plan(research_plan, structured_plan.model_dump()) - + # Update total steps count self.research_state["total_steps"] = len(structured_plan.steps) - + # 3. Execute the plan step by step execution_results = {} - + for step_name, tasks in structured_plan.steps.items(): st.text(f"Processing step: {step_name}") self.research_state["current_step"] = step_name self.research_state["steps_completed"] += 1 - + # Collect all task descriptions in this step step_tasks = [ {"task_name": task_name, "task_description": task_description} for task_name, task_description in tasks ] - + # Process the entire step - step_result = process_step( - step_name, step_tasks, master_agent, tool_agent, archive_agent - ) + step_result = master_agent.process_step(step_name, step_tasks) execution_results[step_name] = step_result - + # 4. Evaluate if more steps are needed st.text("Evaluating research plan...") plan_evaluation = master_agent.evaluate_plan(execution_results) self.report.log_plan_evaluation(plan_evaluation) - + # 5. Write the final report st.text("Writing final report...") final_report = master_agent.write_report(execution_results) self.report.log_final_report(final_report) - + # 6. Save the reports timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") report_path = f"/home/lasse/sci/reports/research_report_{self.username}_{timestamp}" - + # Save JSON report json_path = f"{report_path}.json" with open(json_path, "w") as f: json.dump(self.report.get_full_report(), f, indent=2) - + # Save markdown report markdown_report = self.report.get_markdown_report() markdown_path = f"{report_path}.md" with open(markdown_path, "w") as f: f.write(markdown_report) - + # Update research state self.research_state["in_progress"] = False self.research_state["completed"] = True @@ -238,21 +243,22 @@ class ResearchPage(StreamlitBaseClass): "json_path": json_path, "markdown_path": markdown_path, "report_data": self.report.get_full_report(), - "markdown_content": markdown_report + "markdown_content": markdown_report, } - + except Exception as e: st.error(f"An error occurred during research: {str(e)}") import traceback + st.code(traceback.format_exc()) self.research_state["in_progress"] = False - + # Update session state st.session_state[self.page_name] = { "project_name": self.project_name, "project": self.project, "research_state": self.research_state, - "report": self.report + "report": self.report, } def view_saved_reports(self): @@ -261,58 +267,68 @@ class ResearchPage(StreamlitBaseClass): if not os.path.exists(reports_dir): st.info("No saved reports found.") return - + # Get all report files - json_files = [f for f in os.listdir(reports_dir) if f.endswith('.json') and f.startswith('research_report')] - + json_files = [ + f + for f in os.listdir(reports_dir) + if f.endswith(".json") and f.startswith("research_report") + ] + if not json_files: st.info("No saved reports found.") return - + for file in sorted(json_files, reverse=True): file_path = os.path.join(reports_dir, file) try: - with open(file_path, 'r') as f: + with open(file_path, "r") as f: report_data = json.load(f) - + # Extract basic info - question = report_data.get("metadata", {}).get("question", "Unknown question") - project = report_data.get("metadata", {}).get("project_name", "No project") - started_at = report_data.get("metadata", {}).get("started_at", "Unknown time") - + question = report_data.get("metadata", {}).get( + "question", "Unknown question" + ) + project = report_data.get("metadata", {}).get( + "project_name", "No project" + ) + started_at = report_data.get("metadata", {}).get( + "started_at", "Unknown time" + ) + # Format the date try: date_obj = datetime.fromisoformat(started_at) date_str = date_obj.strftime("%Y-%m-%d %H:%M") except: date_str = started_at - + # Create an expandable section for each report st.markdown(f"_{question} ({project} - {date_str})_") st.markdown(f"**Project:** {project}") st.markdown(f"**Date:** {date_str}") - + # Button to view full report if st.button("View Full Report", key=f"view_{file}"): # Load corresponding markdown file if it exists - md_file = file.replace('.json', '.md') + md_file = file.replace(".json", ".md") md_path = os.path.join(reports_dir, md_file) - + if os.path.exists(md_path): - with open(md_path, 'r') as f: + with open(md_path, "r") as f: markdown_content = f.read() else: markdown_content = None - + self.research_state["completed"] = True self.research_state["report"] = { "json_path": file_path, "markdown_path": md_path if os.path.exists(md_path) else None, "report_data": report_data, - "markdown_content": markdown_content + "markdown_content": markdown_content, } st.rerun() - + except Exception as e: st.error(f"Error loading report {file}: {str(e)}") @@ -321,13 +337,13 @@ class ResearchPage(StreamlitBaseClass): if not report_data: st.warning("No report data available.") return - + st.title("Research Report") - + # Get report data markdown_content = report_data.get("markdown_content") json_data = report_data.get("report_data") - + if markdown_content: # Display the markdown report st.markdown(markdown_content) @@ -335,80 +351,91 @@ class ResearchPage(StreamlitBaseClass): # Fallback to displaying JSON data in a more readable format question = json_data.get("metadata", {}).get("question", "Unknown question") st.header(f"Research on: {question}") - + # Display metadata st.subheader("Metadata") metadata = json_data.get("metadata", {}) st.markdown(f"**Project:** {metadata.get('project_name', 'None')}") st.markdown(f"**Started:** {metadata.get('started_at', 'Unknown')}") st.markdown(f"**Finished:** {metadata.get('finished_at', 'Unknown')}") - + # Display final report st.subheader("Research Findings") st.markdown(json_data.get("final_report", "No final report available.")) - + # Display steps st.subheader("Research Steps") steps = json_data.get("steps", {}) for step_name, step_data in steps.items(): with st.expander(step_name): - st.markdown(f"**Summary:** {step_data.get('summary', 'No summary available.')}") - + st.markdown( + f"**Summary:** {step_data.get('summary', 'No summary available.')}" + ) + # Display tools used st.markdown("**Tools used:**") for tool in step_data.get("tools_used", []): - st.markdown(f"- {tool.get('tool', 'Unknown tool')} with query: _{tool.get('args', {}).get('query', 'No query')}_") - + st.markdown( + f"- {tool.get('tool', 'Unknown tool')} with query: _{tool.get('args', {}).get('query', 'No query')}_" + ) + else: st.error("No report content available to display.") - + # Download buttons col1, col2 = st.columns(2) with col1: - if report_data.get("markdown_path") and os.path.exists(report_data["markdown_path"]): + if report_data.get("markdown_path") and os.path.exists( + report_data["markdown_path"] + ): with open(report_data["markdown_path"], "r") as f: markdown_content = f.read() st.download_button( label="Download as Markdown", data=markdown_content, file_name=os.path.basename(report_data["markdown_path"]), - mime="text/markdown" + mime="text/markdown", ) - + with col2: - if report_data.get("json_path") and os.path.exists(report_data["json_path"]): + if report_data.get("json_path") and os.path.exists( + report_data["json_path"] + ): with open(report_data["json_path"], "r") as f: json_content = f.read() st.download_button( label="Download as JSON", data=json_content, file_name=os.path.basename(report_data["json_path"]), - mime="application/json" + mime="application/json", ) def show_research_progress(self): """Displays the current research progress""" st.subheader("Research in Progress") st.markdown(f"**Question:** {self.research_state['question']}") - + # Show progress bar progress = 0 if self.research_state["total_steps"] > 0: - progress = self.research_state["steps_completed"] / self.research_state["total_steps"] - + progress = ( + self.research_state["steps_completed"] + / self.research_state["total_steps"] + ) + st.progress(progress) - + # Show current step current_step = self.research_state.get("current_step", "Planning") st.markdown(f"**Current step:** {current_step}") - + # Display research plan and progress in expandable sections if self.report: with st.expander("Research Plan", expanded=True): if self.report.report["plan"]["original_text"]: st.markdown("### Original Research Plan") st.markdown(self.report.report["plan"]["original_text"]) - + if self.report.report["plan"]["structured"]: st.markdown("### Structured Plan") structured_plan = self.report.report["plan"]["structured"] @@ -416,7 +443,7 @@ class ResearchPage(StreamlitBaseClass): st.markdown(f"**{step_name}**") for task_name, task_description in tasks: st.markdown(f"- {task_name}: {task_description}") - + # Show completed steps if self.report.report["steps"]: with st.expander("Completed Steps", expanded=True): @@ -426,25 +453,29 @@ class ResearchPage(StreamlitBaseClass): st.markdown(f"### {step_name}") if step_data.get("summary"): st.markdown(f"**Summary:** {step_data['summary']}") - + # Show tools used if step_data.get("tools_used"): st.markdown("**Tools used:**") for tool in step_data["tools_used"]: - st.markdown(f"- {tool.get('tool')} with query: _{tool.get('args', {}).get('query', 'No query')}_") - + st.markdown( + f"- {tool.get('tool')} with query: _{tool.get('args', {}).get('query', 'No query')}_" + ) + # Show information gathering in the current step current_step_data = self.report.report["steps"].get(current_step, {}) if current_step_data and not current_step_data.get("finished_at"): with st.expander("Current Step Progress", expanded=True): st.markdown(f"### {current_step}") - + # Show tools used in current step if current_step_data.get("tools_used"): st.markdown("**Tools used so far:**") for tool in current_step_data["tools_used"]: - st.markdown(f"- {tool.get('tool')} with query: _{tool.get('args', {}).get('query', 'No query')}_") - + st.markdown( + f"- {tool.get('tool')} with query: _{tool.get('args', {}).get('query', 'No query')}_" + ) + # Show information gathered so far if current_step_data.get("information_gathered"): st.markdown("**Information gathered:**") @@ -454,6 +485,10 @@ class ResearchPage(StreamlitBaseClass): if source not in sources_seen: st.markdown(f"- {source}") sources_seen.add(source) - - st.info("Research is ongoing. This may take several minutes depending on the complexity of the question.") - st.warning("Please do not navigate away from this page while research is in progress.") \ No newline at end of file + + st.info( + "Research is ongoing. This may take several minutes depending on the complexity of the question." + ) + st.warning( + "Please do not navigate away from this page while research is in progress." + ) diff --git a/streamlit_app.py b/streamlit_app.py index 3cb1cdb..58ebfdd 100644 --- a/streamlit_app.py +++ b/streamlit_app.py @@ -11,14 +11,25 @@ from _arango import ArangoDB def get_settings(): """ - Function to get the settings from the ArangoDB. + Function to get the settings from the ArangoDB using the new API. """ + if "username" not in st.session_state: + return {} + + # Create ArangoDB instance with user's database arango = ArangoDB(db_name=st.session_state["username"]) - settings = arango.db.collection("settings").get("settings") + + # Use the get_settings method from the new API + settings = arango.get_settings() + if settings: st.session_state["settings"] = settings else: - st.session_state["settings"] = {'current_collection': None, 'current_page': None} + # Initialize default settings if none exist + default_settings = {'current_collection': None, 'current_page': None} + arango.initialize_settings(default_settings) + st.session_state["settings"] = default_settings + return st.session_state["settings"] @@ -40,19 +51,21 @@ except LoginError as e: st.error(e) if st.session_state["authentication_status"]: + # Set username in session state + st.session_state["username"] = st.session_state["username"] + sleep(0.1) # Retry mechanism for importing get_settings for _ in range(3): try: get_settings() - except ImportError as e: + except Exception as e: sleep(0.3) - print_red(e) - print("Retrying to import get_settings...") + print_red(f"Error getting settings: {e}") + print("Retrying to get settings...") # Retry mechanism for importing pages for _ in range(3): - try: from streamlit_pages import ( Article_Collections, @@ -63,7 +76,6 @@ if st.session_state["authentication_status"]: Research, Search_Papers ) - break except ImportError as e: # Write the full error traceback @@ -90,7 +102,6 @@ if st.session_state["authentication_status"]: research = st.Page(Research) search_papers = st.Page(Search_Papers) - sleep(0.1) pg = st.navigation([bot_chat, projects, article_collections, research, search_papers, rss_feeds, settings]) sleep(0.1) @@ -112,13 +123,14 @@ if st.session_state["authentication_status"]: # session_state = st.session_state.to_dict() # if 'bot' in session_state: # del session_state['bot'] - # arango.db.collection("error_logs").insert( - # { + # arango.insert_document( + # collection_name="error_logs", + # document={ # "error": traceback_string, # "_key": timestamp, # "session_state": session_state, # }, - # overwrite=True, + # overwrite=True # ) # with st.status(":red[An error occurred. The site will be reloaded.]"): # for i in range(5): diff --git a/streamlit_chatbot.py b/streamlit_chatbot.py index 1e16c67..e2f40cc 100644 --- a/streamlit_chatbot.py +++ b/streamlit_chatbot.py @@ -1,13 +1,23 @@ from datetime import datetime import streamlit as st -from _base_class import StreamlitBaseClass, BaseClass + from _llm import LLM from prompts import * from colorprinter.print_color import * -from ollama._types import Message as OllamaMessage from projects_page import Project -from ollama_response_classes import QueryResponse - +from ollama._types import Message as OllamaMessage +from _base_class import StreamlitBaseClass, BaseClass +from typing import List +from models import ( + ChunkSearchResults, + DocumentChunk, + DocumentChunk, + ChunkMetadata, + QueryResponse, + UnifiedSearchResults, + UnifiedDataChunk, +) + class Chat(StreamlitBaseClass): """ @@ -32,25 +42,26 @@ class Chat(StreamlitBaseClass): -------- add_message(role, content): Adds a message to the chat history. - + to_dict(): Converts the chat object to a dictionary. - + update_in_arango(): Updates the chat object in the ArangoDB. - + set_name(user_input): Sets the name of the chat based on user input. - + show_title(title=None): Displays the title of the chat in the Streamlit application. - + from_dict(data): Creates a Chat object from a dictionary. - + chat_history2bot(n_messages=None, remove_system=False): Converts the chat history to a format suitable for a bot. """ + def __init__( self, username=None, @@ -140,7 +151,6 @@ class Chat(StreamlitBaseClass): f"""### Chat about *{title.strip()}* with *{self.role}*""", ) - @classmethod def from_dict(cls, data): return cls( @@ -166,7 +176,7 @@ class Chat(StreamlitBaseClass): class StreamlitChat(Chat): - ''' + """ A class to manage chat interactions within a Streamlit application. Inherits from the Chat class and provides additional functionality to handle @@ -186,7 +196,8 @@ class StreamlitChat(Chat): Methods: show_chat_history(): get_avatar(message: dict = None, role=None) -> str: - ''' + """ + def __init__(self, username: str, role: str, _key: str = None, **kwargs): super().__init__(username, role, _key, **kwargs) self.project = kwargs.get("project", None) @@ -229,7 +240,6 @@ class StreamlitChat(Chat): avatar = self.get_avatar(message) with st.chat_message(message["role"], avatar=avatar): if message["content"]: - print_blue('CONTENT', message["content"]) st.markdown(message["content"].strip('"')) def get_avatar(self, message: dict = None, role=None) -> str: @@ -269,7 +279,7 @@ class StreamlitChat(Chat): class Bot(BaseClass): - ''' + """ A chatbot class that integrates with research tools and document retrieval systems. The Bot class provides an interface for conversational AI that can access and process various document sources, including scientific articles, user notes, and other documents. @@ -298,7 +308,9 @@ class Bot(BaseClass): fetch_science_articles_and_other_documents_tool(): Retrieve both document types. fetch_notes_tool(): Retrieve user notes. conversational_response_tool(): Generate a simple conversational response. - ''' + + """ + def __init__(self, username: str, chat: Chat = None, tools: list = None, **kwargs): super().__init__(username=username, **kwargs) # Use the passed in chat or create a new Chat @@ -333,8 +345,7 @@ class Bot(BaseClass): ): self.arango_ids.append(_id) - - # Convert tool names to function references + # Give tools to the bot if tools: # Map tool names to functions tool_mapping = { @@ -343,10 +354,15 @@ class Bot(BaseClass): "fetch_science_articles_and_other_documents_tool": self.fetch_science_articles_and_other_documents_tool, "fetch_notes_tool": self.fetch_notes_tool, "conversational_response_tool": self.conversational_response_tool, + "analyze_tool": self.analyze_tool, } - self.tools = [ - tool_mapping[tool] if isinstance(tool, str) else tool for tool in tools - ] + if tools == "all": + self.tools = list(tool_mapping.values()) + else: + self.tools = [ + tool_mapping[tool] if isinstance(tool, str) else tool + for tool in tools + ] else: self.tools = None @@ -364,16 +380,16 @@ class Bot(BaseClass): def initiate_bots(self): """ Initialize the different bot instances used in the chatbot application. - + Creates three types of bots: 1. chatbot: A standard LLM for normal conversation with the user 2. helperbot: A specialized LLM with low temperature for generating concise queries or prompts 3. toolbot: A specialized LLM for selecting which tool to use when responding to user queries (only created if tools are provided) - + The toolbot is configured to prefer specialized tools over conversational responses when the user is seeking information rather than engaging in small talk. - + Note: - The chatbot uses the full chat history - The helperbot uses a limited chat history (last 4 messages) with system message removed @@ -408,7 +424,6 @@ class Bot(BaseClass): if len(tools_names) > 1 and "conversational_response_tool" in tools_names: self.toolbot.system_message += "\n\nMake sure to only use the conversational response tool if the user is engaging in small talk. If the user is asking a question or looking for information, make sure to use one of the other tools!" - def get_chunks( self, user_input, @@ -416,7 +431,9 @@ class Bot(BaseClass): n_results=7, n_sources=4, filter=True, - ): + where_filter: dict = {}, + get_full_text=False, + ) -> UnifiedSearchResults: # Changed return type to match what's expected """ Retrieves relevant text chunks from the vector database based on user input. @@ -427,8 +444,8 @@ class Bot(BaseClass): 4. Limits results to the specified number of unique sources 5. Cleans the text by removing footnote references 6. Enriches the chunks with detailed metadata from ArangoDB - 7. Groups chunks by article title - + 7. Returns chunks as UnifiedDataChunk objects in a UnifiedSearchResults container + Parameters: ----------- user_input : str @@ -441,126 +458,132 @@ class Bot(BaseClass): Maximum number of unique document sources to include (default: 4) filter : bool, optional Whether to filter results by ArangoDB IDs (default: True) - + where_filter : dict, optional + Additional filter criteria for the search (default: empty dict) + get_full_text : bool, optional + Whether to return the full text of the documents (default: False) + Returns: -------- - dict - A dictionary of grouped chunks where: - - Keys are article titles - - Values are dictionaries containing: - - 'article_number': A sequential number for the article - - 'chunks': A list of chunk dictionaries, each containing: - - 'document': The document text - - 'metadata': The document metadata - - 'distance': The similarity distance (lower is better) - - 'article_number': The sequential number of the article + UnifiedSearchResults + A Pydantic model containing the search results with: + - chunks: List of UnifiedDataChunk objects containing: + - content: The document text + - metadata: Document metadata + - source_type: The type of the source + - source_ids: List of IDs for the sources """ - + print_blue("CHROMA FILTER:", filter) + # Generate vector query using LLM response = self.helperbot.generate( get_generate_vector_query_prompt(user_input, self.chat.role), format=QueryResponse.model_json_schema(), ) - print(response) - print_yellow("RESPONSE:", response.content) - query_response = QueryResponse.model_validate_json(response.content) - query = query_response.query_to_vector_database + query = QueryResponse.model_validate_json(response.content).query print_purple(f"Query for vector DB:\n {query}") - - combined_chunks = [] - if collections: - for collection in collections: - - if filter: - where_filter = {"_id": {"$in": self.arango_ids}} - chunks = self.get_chromadb().query( - query=query, - collection=collection, - n_results=n_results, - n_sources=n_sources, - where=where_filter, - max_retries=3, - ) - for doc, meta, dist in zip( - chunks["documents"][0], - chunks["metadatas"][0], - chunks["distances"][0], - ): - combined_chunks.append( - {"document": doc, "metadata": meta, "distance": dist} - ) - - combined_chunks.sort(key=lambda x: x["distance"]) - - # Keep the best chunks according to n_sources - sources = set() - closest_chunks = [] - for chunk in combined_chunks: - source_id = chunk["metadata"].get("_id", "no_id") - if source_id not in sources: - sources.add(source_id) - closest_chunks.append(chunk) - if len(sources) >= n_sources: - break - if len(closest_chunks) < n_results: - remaining_chunks = [c for c in combined_chunks if c not in closest_chunks] - closest_chunks.extend(remaining_chunks[: n_results - len(closest_chunks)]) - - # Remove footnoot references like [\d+] from the text chunks - for chunk in closest_chunks: - chunk["document"] = re.sub(r"\[\d+\]", "", chunk["document"]) - - # Fetch real metadata from Arango - for chunk in closest_chunks: - _id = chunk["metadata"].get("_id") - if not _id: - continue - if _id.startswith("sci_articles"): - arango_doc = self.base_arango.db.document(_id) + + # Process chunks using ChromaDB's enhanced methods + chromadb = self.get_chromadb() + + if filter: + if where_filter in [None, {}]: + where_filter = {"_id": {"$in": self.arango_ids}} + else: + where_filter = None + + # Get processed chunks from ChromaDB + closest_chunks: list = chromadb.search_chunks( + query=query, + collections=collections, + n_results=n_results, + n_sources=n_sources, + where=where_filter, + max_retries=3, + ) + + # Fetch metadata from Arango and prepare uniform chunks + source_ids = [] + unified_chunks = [] + + for i, chunk in enumerate(closest_chunks): + # Track IDs + chunk_id = chunk["id"] + arango_id = chunk["metadata"].get("_id") + source_ids.append(chunk_id) + + # Get enhanced metadata from ArangoDB + if arango_id: + arango_metadata = self.user_arango.get_document_metadata(arango_id) + if isinstance(arango_metadata, dict): + # Add tracking IDs to metadata + arango_metadata["chroma_id"] = chunk_id + arango_metadata["arango_id"] = arango_id + + # Set metadata or create minimal version if not available + metadata = arango_metadata + else: + # Create minimal metadata if ArangoDB doesn't return any + metadata = { + "title": "Unknown Document", + "journal": None, + "published_date": None, + "chroma_id": chunk_id, + "arango_id": arango_id + } else: - arango_doc = self.user_arango.db.document(_id) - if arango_doc: - arango_metadata = arango_doc.get("metadata", {}) - # Possibly merge notes - if "user_notes" in arango_doc: - arango_metadata["user_notes"] = arango_doc["user_notes"] - chunk["metadata"] = arango_metadata - - # Group by article title - grouped_chunks = {} - article_number = 1 - for chunk in closest_chunks: - title = chunk["metadata"].get("title", "No title") - chunk["article_number"] = article_number - if title not in grouped_chunks: - grouped_chunks[title] = { - "article_number": article_number, - "chunks": [], + # Minimal metadata for chunks without arango_id + metadata = { + "title": "Unknown Document", + "chroma_id": chunk_id } - article_number += 1 - grouped_chunks[title]["chunks"].append(chunk) - - return grouped_chunks + + # Get full document text if requested + document_content = "" + if get_full_text and arango_id: + doc = self.user_arango.db.collection("sci_articles").get(arango_id) + document_content = doc.get("text", "") + else: + # Use the chunk text + document_content = chunk.get("document", chunk.get("text", "")) + + # Determine source type based on collection + source_type = "science_article" if "sci_article" in collections[0] else "other_document" + + # Create a UnifiedDataChunk (what the model expects) + unified_chunk = UnifiedDataChunk( + content=document_content, + metadata=metadata, + source_type=source_type, + article_number=i+1 # Add article numbering + ) + unified_chunks.append(unified_chunk) + + # Return the properly structured results + return UnifiedSearchResults( + chunks=unified_chunks, + source_ids=source_ids + ) def answer_tool_call(self, response, user_input): """ Process tool calls returned by the AI and execute the corresponding functions. - + This method evaluates tool calls in the AI response, executes the appropriate functions with the provided arguments, and collects the resulting responses. - + Parameters: ----------- response : dict The AI response containing potential tool_calls to be executed user_input : str The original user query that will be passed to tool functions - + Returns: -------- list A list of string responses generated from executing the tool calls. Returns an empty string if no tool calls are present. - + Notes: ------ Supported tool functions include: @@ -628,22 +651,22 @@ class Bot(BaseClass): def generate_from_notes(self, user_input, notes): """ Generate a response based on user input and a collection of notes. - + This method takes a user query and relevant notes, formats the notes into a string, creates a prompt with the formatted notes and user input, and generates a streamed response. - + Parameters ---------- user_input : str The user's query or message to respond to notes : list of dict A list of note dictionaries, where each note has 'title' and 'content' keys - + Returns ------- generator A generator that streams the AI-generated response - + Notes ----- This method does not make any Streamlit calls and is safe to use outside of the Streamlit context. @@ -660,57 +683,48 @@ class Bot(BaseClass): ) return self.chatbot.generate(prompt, stream=True) - def generate_from_chunks(self, user_input, chunks): + def generate_from_chunks(self, user_input, chunks: UnifiedSearchResults): """ Generate a response based on user input and retrieved document chunks. - + This method formats the retrieved document chunks into a structured string, combines it with the user's input in a prompt, and generates a streaming response using the chatbot. - + Parameters: ----------- user_input : str The user's query or message to respond to. - chunks : dict - A dictionary containing document chunks organized by title. - Expected structure: - { - "title1": { - "chunks": [ - { - "document": "content...", - "metadata": { - "user_notes": "optional notes..." - } - }, - ... - ], - "article_number": int - }, - ... - } - + chunks : UnifiedSearchResults + A Pydantic model containing document chunks as UnifiedDataChunk objects. + Returns: -------- generator A streaming generator of the chatbot's response. - - Notes: - ------ - - This method does not make any Streamlit API calls. - - User notes are included in the formatted content if available. - - The formatted content includes titles, article numbers, and document text. """ # No Streamlit calls chunks_string = "" - for title, group in chunks.items(): + for chunk in chunks.chunks: user_notes_string = "" - if "user_notes" in group["chunks"][0]["metadata"]: - notes = group["chunks"][0]["metadata"]["user_notes"] - user_notes_string = f'\n\nUser notes:\n"""\n{notes}\n"""\n\n' - docs = "\n(...)\n".join([c["document"] for c in group["chunks"]]) - chunks_string += f"\n# {title}\n## Article #{group['article_number']}\n{user_notes_string}{docs}\n---\n" + # Handle metadata from either a dict or object structure + metadata = chunk.metadata if hasattr(chunk, 'metadata') else {} + + # Get user notes if available + user_notes = metadata.get("user_notes") if isinstance(metadata, dict) else getattr(metadata, "user_notes", None) + if user_notes: + user_notes_string = f'\n\nUser notes:\n"""\n{user_notes}\n"""\n\n' + + # Get title + title = metadata.get("title", "Untitled Document") if isinstance(metadata, dict) else getattr(metadata, "title", "Untitled Document") + + # Get content from either 'document' or 'content' + content = chunk.content if hasattr(chunk, 'content') else getattr(chunk, "document", "") + + # Combine into structured format + chunks_string += f"\n# {title}\n{user_notes_string}{content}\n---\n" + + # Create prompt and generate response prompt = get_chat_prompt( user_input, content_string=chunks_string, role=self.chat.role ) @@ -720,7 +734,10 @@ class Bot(BaseClass): # Base Bot has no Streamlit run loop pass - def get_notes(self): + def get_notes(self) -> List: + """ + Returns all projects notes as a list of strings. + """ # Minimal note retrieval notes_cursor = self.user_arango.db.aql.execute( "FOR doc IN notes FILTER doc._id IN @note_ids RETURN doc.text", @@ -728,65 +745,104 @@ class Bot(BaseClass): ) return list(notes_cursor) - def fetch_science_articles_tool(self, query: str, n_documents: int = 6): + def fetch_science_articles_tool( + self, query: str, n_documents: int = 6, retrieve_full_articles: bool = False + ) -> UnifiedSearchResults: """ - "Fetches information from scientific articles. Use this tool when the user is looking for information from scientific articles." - + Fetches information from scientific articles. + Parameters: query (str): The search query to find relevant scientific articles. n_documents (int): How many documents to fetch. A complex query may require more documents. Min: 3, Max: 10. - + retrieve_full_articles (bool): If True, returns article IDs for full article processing. Default: False. + Returns: - list: A list of chunks containing information from the fetched scientific articles. + UnifiedSearchResults: A structured result containing articles with their chunks or article IDs for full retrieval. """ - print_purple("Query:", query) - - n_documents = int(n_documents) - if n_documents < 3: - n_documents = 3 - elif n_documents > 10: - n_documents = 10 - return self.get_chunks( - query, collections=["sci_articles"], n_results=n_documents + + where_filter = {} + if hasattr(self, "chroma_ids_retrieved") and len(self.chroma_ids_retrieved) > 0: + where_filter = {"_id": {"$in": self.chroma_ids_retrieved}} + + found_chunks = self.get_chunks( + user_input=query, + collections=["sci_articles"], + n_results=n_documents, + n_sources=max(n_documents, 4) ) + + # Collect unique article IDs if full articles are requested + if retrieve_full_articles: + # Get unique article IDs from the chunks + unique_article_ids = list(set([chunk.metadata._id for chunk in found_chunks.chunks + if chunk.metadata and hasattr(chunk.metadata, '_id')])) + + # Return article IDs and metadata for full article processing + return UnifiedSearchResults( + chunks=[ + UnifiedDataChunk( + metadata=chunk.metadata, + source_type="sci_article_full" + ) for chunk in found_chunks + ], + source_ids=unique_article_ids + ) + else: + # Chunk-based processing + unified_chunks = [ + UnifiedDataChunk( + content=chunk.content, + metadata=chunk.metadata.model_dump(), + source_type="science_article_chunk", + ) + for chunk in found_chunks.chunks + ] + return UnifiedSearchResults(chunks=unified_chunks, source_ids=found_chunks.chroma_ids) - def fetch_other_documents_tool(self, query: str, n_documents: int = 6): + def fetch_other_documents_tool( + self, query: str, n_documents: int = 6 + ) -> UnifiedSearchResults: """ Fetches information from other documents based on the user's query. - This method retrieves information from various types of documents such as reports, news articles, and other texts. It should be used only when it is clear that the user is not seeking scientific articles. - - Args: + Parameters: query (str): The search query provided by the user. - n_documents (int): How many documents to fetch. A complex query may require more documents. Min: 2, Max: 10. + n_documents (int): How many documents to fetch. Min: 2, Max: 10. Returns: - list: A list of document chunks that match the query. + UnifiedSearchResults: A structured result containing document chunks. """ - assert isinstance(self, Bot), "The first argument must be a Bot object." - n_documents = int(n_documents) - if n_documents < 2: - n_documents = 2 - elif n_documents > 10: - n_documents = 10 - return self.get_chunks( - query, + n_documents = max(2, min(n_documents, 10)) + + found_chunks = self.get_chunks( + user_input=query, collections=[f"{self.username}__other_documents"], n_results=n_documents, + n_sources=max(n_documents, 4) ) + # Standardize the chunks using UnifiedDataChunk + unified_chunks = [ + UnifiedDataChunk( + content=chunk.content, + metadata=chunk.metadata.model_dump(), + source_type="other_documents", + ) + for chunk in found_chunks.chunks + ] + + return UnifiedSearchResults(chunks=unified_chunks, source_ids=found_chunks.chroma_ids) + def fetch_science_articles_and_other_documents_tool( - self, query: str, n_documents: int - ): + self, query: str, n_documents: int, whole_articles: bool = False + ) -> UnifiedSearchResults: """ Fetches information from both scientific articles and other documents. - This method is often used when the user hasn't specified what kind of sources they are interested in. - Args: query (str): The search query to fetch information for. n_documents (int): How many documents to fetch. A complex query may require more documents. Min: 3, Max: 10. - + whole_articles (bool): If True, fetches the entire article instead of just chunks, so that the whole article can be analyzed. Takes a lot of resources so use this only if important. Default is False. Returns: list: A list of document chunks that match the search query. """ @@ -796,22 +852,120 @@ class Bot(BaseClass): n_documents = 3 elif n_documents > 10: n_documents = 10 - return self.get_chunks( + + found_chunks: ChunkSearchResults = self.get_chunks( query, collections=["sci_articles", f"{self.username}__other_documents"], n_results=n_documents, ) - def fetch_notes_tool(bot): + # Standardize the chunks using UnifiedDataChunk + unified_chunks = [] + for chunk in found_chunks.chunks: + unified_chunk = UnifiedDataChunk( + content=chunk.content, + metadata=chunk.metadata, + source_type="other_and_sci_documents", + ) + unified_chunks.append(unified_chunk) + # Return the unified search results + result = UnifiedSearchResults(chunks=unified_chunks, source_ids=[]) + return result + + def fetch_notes_tool(self) -> UnifiedSearchResults: """ - Fetches information from the project notes when you as an editor need context from the project notes to understand other information. ONLY use this together with other tools! No arguments needed. + Fetches information from the project notes and returns it in a unified format. Returns: - list: A list of notes. + UnifiedSearchResults: A unified representation of the notes. """ - assert isinstance(bot, Bot), "The first argument must be a Bot object." - return bot.get_notes() + notes: list = self.get_notes() + + # Standardize the notes using UnifiedDataChunk + unified_chunks = [ + UnifiedDataChunk( + content=note, + metadata={"source_type": "notes"}, + source_type="notes", + ) + for note in notes + ] + return UnifiedSearchResults(chunks=unified_chunks) + + def summarize_full_article_tool( + self, article_id: str, question: str = None, arango_collection: str = "sci_articles" + ) -> str: + """ + Fetches a complete scientific article by ID and summarizes its content. + This tool is useful when a comprehensive understanding of an entire article is needed. + + Parameters: + article_id (str): The ID of the article to retrieve and summarize. + question (str, optional): A specific question to focus the summary on. + + Returns: + str: A detailed summary of the article focused on relevant information. + """ + + try: + if arango_collection == 'sci_articles': + doc = self.base_arango.db.collection("sci_articles").get(article_id) + full_text = self.base_arango.get_document_text(_id=article_id) + else: + arango_key = article_id.split("/")[-1] + doc = self.user_arango.db.collection(arango_collection).get(article_id) + full_text = self.base_arango.get_document_text(_ket=arango_key, collection=arango_collection) + + # Get article metadata + metadata = { + "title": doc.get("title", None), + "authors": doc.get("authors", None), + "journal": doc.get("journal", None), + "published_date": doc.get("published_date", None), + "doi": doc.get("doi", ""), + "abstract": doc.get("abstract", None), + } + + metadata_string = "" + for k, v in metadata.items(): + if v: + metadata_string += f"{k.capitalize()}: {v}\n" + + # Create a prompt for summarization + summary_prompt = f''' + You are a research assistant helping with an investigation on: + "{question}" + + Please read this complete scientific article and create a comprehensive PM. + + {metadata_string} + + FULL TEXT: + """ + {full_text} + """ + + Create a structured, detailed PM of this article focusing on information relevant to + the research question. Include key findings, methodologies, and conclusions. + Do not answer the research question directly - just summarize the article's content. A researcher will later draw conclusions etc. + Make sure to preserve important details and evidence from the original. + ''' + + # Use a small model for efficient summarization + summary: OllamaMessage = self.generate(query=summary_prompt, model="small", stream=False) + summary_text = summary.content.strip('"') + summary_text = self.remove_thinking(summary_text) + + # Format with source information + formatted_summary = f"{metadata_string}\n\nSUMMARY:\n{summary_text}" + + + return formatted_summary + + except Exception as e: + print_red(f"Error summarizing article {article_id}: {str(e)}") + return f"Error processing article {article_id}: {str(e)}" def conversational_response_tool(self, query: str): """ Generate a conversational response to a user's query. @@ -833,6 +987,30 @@ class Bot(BaseClass): return self.chatbot.generate(query, stream=True) + def analyze_tool(self, text: str, instructions: str) -> str: + """ + This tool is used to analyze information based on the provided instructions. + Use it to extract insights or perform other analytical tasks. + The instructions should be clear and specific for the information provided. + + Args: + text (str): The text content to be analyzed. + instructions (str): Specific instructions guiding how the analysis should be performed. + + Returns: + str: The analysis result from the language model. + """ + + query = f''' + Analyze the following information based on the instructions provided. + following: \n"""\n{text}\n""\n\n + Instructions: \n"""\n{instructions}\n""" + ''' + + print_blue("\nQuery for analysis:\n", query, "\n") + response = self.llm.generate(query=query, model=self.model) + return response.content if hasattr(response, "content") else str(response) + class StreamlitBot(Bot): def __init__( @@ -853,7 +1031,7 @@ class StreamlitBot(Bot): self.chatbot.model = self.chatbot.get_model("reasoning") print_rainbow(settings) - print('MODEL', self.chatbot.model) + print("MODEL", self.chatbot.model) def run(self): # Example Streamlit run loop @@ -988,10 +1166,11 @@ class StreamlitBot(Bot): "fetch_science_articles_tool", "fetch_science_articles_and_other_documents_tool", ]: + print_purple('Tool name:', function_name) chunks = getattr(self, function_name)(**arguments) response_text = self.generate_from_chunks(user_input, chunks) + # Separate thinking chunk and normal chunk - print_red("Model:", self.chatbot.model) if self.chatbot.model == self.chatbot.get_model("reasoning"): bot_response = self.write_reasoning(response_text) @@ -1002,17 +1181,27 @@ class StreamlitBot(Bot): if chunks: sources = "###### Sources:\n" - for title, group in chunks.items(): - j = group["chunks"][0]["metadata"].get( - "journal", "No Journal" - ) - d = group["chunks"][0]["metadata"].get( - "published_date", "No Date" - ) - sources += f"[{group['article_number']}] **{title}** :gray[*{j}* ({d})] \n" + for i, chunk in enumerate(chunks.chunks): + # Get metadata (handle both dict and object forms) + metadata = chunk.metadata if isinstance(chunk.metadata, dict) else chunk.metadata + + # Get journal and date info (handle both dict and object access) + if isinstance(metadata, dict): + journal = metadata.get("journal", "No Journal") or "No Journal" + date = metadata.get("published_date", "No Date") or "No Date" + title = metadata.get("title", "Untitled") or "Untitled" + else: + journal = getattr(metadata, "journal", "No Journal") or "No Journal" + date = getattr(metadata, "published_date", "No Date") or "No Date" + title = getattr(metadata, "title", "Untitled") or "Untitled" + + # Get article number (either from attribute or use index+1) + article_num = getattr(chunk, "article_number", i+1) + + sources += f"[{article_num}] **{title}** :gray[*{journal}* ({date})] \n" + st.markdown(sources) bot_response += f"\n\n{sources}" - bot_responses.append(str(bot_response)) elif function_name == "fetch_notes_tool": notes = getattr(self, function_name)() @@ -1039,76 +1228,78 @@ class StreamlitBot(Bot): return "\n\n".join(bot_responses) def write_reasoning(self, response): + """Handle streaming responses that may contain thinking chunks""" if isinstance(response, str): - # If the response is a string, just return it - print_yellow('Response is string:', response) + # If the response is a string, just display it return st.write(response) - - chunks_iter = iter(response) # convert generator to iterator + + chunks_iter = iter(response) # Convert generator to iterator try: - first_mode, first_text = next(chunks_iter) # get first chunk + first_mode, first_text = next(chunks_iter) # Get first chunk except StopIteration: - # no chunks at all - first_mode, first_text = None, None - print_purple("FIRST MODE:", first_mode, first_text) - # if it's thinking, show that in an expander + return "" + + # If it's a thinking chunk, show it in an expander if first_mode == "thinking": - with st.expander("How the bot has been reasoning"): - st.write(first_text.replace("", "").replace("", "")) + thinking_text = first_text.replace("", "").replace("", "") + if len(thinking_text) > 10: + st.write(thinking_text) + with st.expander("How the bot has been reasoning"): + st.write(thinking_text) - # define a generator for the rest + # Define a generator for the remaining normal content def rest_gen(): - for _, text in chunks_iter: - yield text - - bot_response = st.write_stream(rest_gen()) - return bot_response + for mode, text in chunks_iter: + if mode == "normal": + yield text + return st.write_stream(rest_gen()) else: - + # If the first chunk isn't thinking, include it in the stream def full_gen(): - if first_mode: - yield (first_mode, first_text) + yield first_text for mode, text in chunks_iter: - yield (mode, text) + if mode == "normal": + yield text - bot_response = st.write_stream(full_gen()) + return st.write_stream(full_gen()) def write_normal(self, response): + """Handle regular streaming responses without thinking chunks""" if isinstance(response, str): - # If the response is a string, just return it - print_yellow('Response is string:', response) return st.write(response) - chunks_iter = iter(response) # convert generator to iterator - def full_gen(): - for chunk in chunks_iter: + # Extract just the text content from the stream + def text_only_gen(): + for chunk in response: if isinstance(chunk, tuple) and len(chunk) == 2: _, text = chunk yield text else: yield chunk - bot_response = st.write_stream(full_gen()) - return bot_response + return st.write_stream(text_only_gen()) def generate_from_notes(self, user_input, notes): with st.spinner("Reading project notes..."): return super().generate_from_notes(user_input, notes) - def generate_from_chunks(self, user_input, chunks): + def generate_from_chunks(self, user_input, chunks: ChunkSearchResults): # For reading articles with a spinner magazines = set() - for group in chunks.values(): - j = group["chunks"][0]["metadata"].get("journal", "No Journal") - magazines.add(f"*{j}*") - s = ( - f"Reading articles from {', '.join(list(magazines)[:-1])} and {list(magazines)[-1]}..." - if len(magazines) > 1 - else "Reading articles..." - ) - with st.spinner(s): + for chunk in chunks.chunks: + if chunk.metadata: + journal = chunk.metadata.journal or "No Journal" + magazines.add(f"*{journal}*") + + # Create spinner message + if len(magazines) > 1: + spinner_text = f"Reading articles from {', '.join(list(magazines)[:-1])} and {list(magazines)[-1]}..." + else: + spinner_text = "Reading articles..." + + with st.spinner(spinner_text): return super().generate_from_chunks(user_input, chunks) def sidebar_content(self): @@ -1330,3 +1521,51 @@ class GuestBot(StreamlitBot): def generate(self, query): return self.llm.generate(query) + + +# if __name__ == "__main__": +# from _arango import ArangoDB + +# # Example usage +# from dotenv import load_dotenv +# import os + +# load_dotenv() +# question = "What are the environmental impacts of lithium mining?" +# username = "lasse" +# user_arango = ArangoDB(user="lasse", password=os.getenv("ARANGO_PASSWORD")) +# base_arango = ArangoDB( +# user="admin", password=os.getenv("ARANGO_PASSWORD"), db_name="base" +# ) +# project = Project( +# username=username, project_name="Electric Cars", user_arango=user_arango +# ) +# bot = Bot(username=username, project=project) +# bot.run() + +# result = bot.fetch_science_articles_tool( +# "lithium mining", n_documents=4, whole_articles=True +# ) +# print(result.arango_ids) +# for _id in result.arango_ids: +# doc = base_arango.db.collection("sci_articles").get(_id) +# text = '' +# for chunk in doc["chunks"]: +# text += chunk['text'] + +# q = f''' +# You are a research assistant. You are helping a research to answer the question "{question}". +# The article below is probably relevant to the question. Please read it to make a PM. + +# """ +# {text} +# """ + +# Please write a PM based on the article with focus on the question: {question} +# *Don't answer the question directly!* Just make a summary of the article – the researcher will use your summary to answer the question. +# Make the PM structured and clear, and make sure to include all the relevant derails. +# ''' +# pm = bot.chatbot.generate(q, model="small") +# print(pm) + +# exit() diff --git a/test.py b/test.py index 0858078..8ae8238 100644 --- a/test.py +++ b/test.py @@ -1,14 +1,27 @@ +import ollama + +# response = ollama.chat( +# model="qwen3_4b_32k", +# messages=[ +# {"role": "system", "content": "You are a helpful assistant."}, +# {"role": "user", "content": "What is the capital of France?"}], +# think=True +# ) + +# print(response) + import requests -text = '''Available online at www.sciencedirect.com\n\n## ScienceDirect\n\n[Green Energy & Environment 9 (2024) 802e830](https://doi.org/10.1016/j.gee.2023.09.001)\n\n[www.keaipublishing.com/gee](http://www.keaipublishing.com/gee)\n\n#### Review article\n# Development of sustainable and efficient recycling technology for spent Li-ion batteries: Traditional and transformation go hand in hand\n\n### Zejian Liu [a][,][b][,][c], Gongqi Liu [a][,][c], Leilei Cheng [a][,][b][,][c], Jing Gu [a][,][c], Haoran Yuan [a][,][b][,][c][,]*, Yong Chen [a][,][b][,][c], Yufeng Wu [d]\n\na Guangzhou Institute of Energy Conversion, Chinese Academy of Sciences (CAS), Guangzhou, 510640, China\nb School of Engineering Science, University of Science and Technology of China, Hefei, 230026, China\nc Guangdong Provincial Key Laboratory of New and Renewable Energy Research and Development, Guangzhou, 510640, China\nd Faculty of Materials and Manufacturing, Beijing University of Technology, Beijing, 100124, China\n\nReceived 16 May 2023; revised 10 September 2023; accepted 24 September 2023\nAvailable online 27 September 2023\n\nAbstract\n\nClean and efficient recycling of spent lithium-ion batteries (LIBs) has become an urgent need to promote sustainable and rapid development\nof human society. Therefore, we provide a critical and comprehensive overview of the various technologies for recycling spent LIBs, starting\nwith lithium-ion power batteries. Recent research on raw material collection, metallurgical recovery, separation and purification is highlighted,\nparticularly in terms of all aspects of economic efficiency, energy consumption, technology transformation and policy management. Mechanisms\nand pathways for transformative full-component recovery of spent LIBs are explored, revealing a clean and efficient closed-loop recovery\nmechanism. Optimization methods are proposed for future recycling technologies, with a focus on how future research directions can be\nindustrialized. Ultimately, based on life-cycle assessment, the challenges of future recycling are revealed from the LIBs supply chain and\nstability of the supply chain of the new energy battery industry to provide an outlook on clean and efficient short process recycling technologies.\nThis work is designed to support the sustainable development of the new energy power industry, to help meet the needs of global decarbonization\nstrategies and to respond to the major needs of industrialized recycling.\n© 2024 Institute of Process Engineering, Chinese Academy of Sciences. Publishing services by Elsevier B.V. on behalf of KeAi Communi[cations Co., Ltd. This is an open access article under the CC BY-NC-ND license (http://creativecommons.org/licenses/by-nc-nd/4.0/).](http://creativecommons.org/licenses/by-nc-nd/4.0/)\n\nKeywords: Spent LIBs; Transformative recycling; LCA analysis; Policy guidance; High value utilization\n\n1. Introduction\n\nGreen energy and environmental friendliness have become\nthe global goal of actively seeking sustainable and rapid\ndevelopment. Developing a circular economy and realizing\ngreen transformation facilitate blood circulation of the world\neconomy and energy [1]. The global consumption of fossil\nfuels in 2021 reached 595.15 EJ, accounting for 82% of the\ntotal primary energy in 2020. Although statistics show that\n\n - Corresponding author. Guangzhou Institute of Energy Conversion, Chinese\nAcademy of Sciences (CAS), Guangzhou, 510640, China.\n[E-mail address: yuanhaoran81@163.com (H. Yuan).](mailto:yuanhaoran81@163.com)\n\nfossil fuels still occupy the leading position in the global energy consumption, it is encouraging that the growth rate of\nfossil fuel consumption is extremely low every year [2]. According to the World Energy Outlook of the International\nEnergy Agency (IEA), it is estimated that the total energy\ndemand under the State Policies Scenario (STEPS) will increase to 743.9 EJ in 2050, of which renewable energy will\nonly account for 25.8% (Fig. 1a) [3]. However, as a pillar of\nthe global energy demand, the consumption of fossil fuels\ninevitably releases a large amount of greenhouse gases. In\n2021, the global energy-related total CO2 emissions rebounded\nsignificantly to 36.3 Gt. The use of fossil fuels has led to a\nsignificant increase in carbon emissions, which has aggravated\n\n[https://doi.org/10.1016/j.gee.2023.09.001](https://doi.org/10.1016/j.gee.2023.09.001)\n2468 0257/© 2024 Institute of Process Engineering Chinese Academy of Sciences Publishing services by Elsevier B V on behalf of KeAi Communications Co\n\n@1@\nZ. Liu et al. / Green Energy & Environment 9 (2024) 802–830 803\n\nFig. 1. (a) Main global energy demand from 2021 to 2050 under the STEPS. Data derived from Ref. [3]. (b) Power generation of the major energy sources in the\nworld from 2010 to 2050 under STEPS. Data derived from Ref. [3]. (c) Global sales of BEVs and PHEVs from 2018 to 2021 Data derived from Ref. [5]. (d) Global\nLIBs metal demand in 2021 and 2030 under the APS. Data derived from Ref. [3].\n\nthe greenhouse effect [4]. To solve the environmental, energy\nand security problems due to fossil energy combustion, many\nnew energy sources, such as solar photovoltaic, wind, water,\nbiomass, and geothermal energy, have been created and converted into flexible electric energy to reduce the carbon\nemissions associated with fossil energy and to improve energy\nsustainability (Fig. 1b) [3]. However, since the conversion of\nnew energy from the natural environment into direct energy\nhighly depends on environmental conditions, new energy exhibits obvious intermittent characteristics when supplied to\nhumans. In response, the government and enterprises are the\nbest options for employing carbon-neutral electric transport\nfacilities on a large scale in transportation electrification.\nTherefore, researchers have made great efforts to develop\nadvanced electric energy storage facilities and improve the\nservice lifespan of electric vehicles (EVs).\nTo maintain a sustainable harmony between energy and the\nenvironment, energy-efficient and environmentally friendly\nlithium-ion batteries (LIBs) stand out among power sources.\nMany countries hope that this advanced technology can provide a strong impetus for their development within the context\nof carbon neutrality, reduce the use of local fossil fuels, and\nprovide corresponding incentive policies To date the market\n\nshares of LIBs in certain industries, such as electric vehicles\n(EVs), portable devices, and defense, are significantly\nincreasing annually. A total of 6.6 million battery electric\nvehicles (BEVs) and plug-in hybrid electric vehicles (PHEVs)\nwere sold worldwide in 2021, with the most significant growth\nin BEVs reaching approximately 70% (Fig. 1c) [5]. In\nparticular, the 3.3 million EVs on the road in China in 2021\nexceeded the global sales of EVs in 2020. This increase is\nclosely linked to various actions in China, including rapidly\nbuilding charging infrastructure and creating economic subsidies. Europe has exhibited a notable growth trend since\n2020, with EVs accounting for 65% of the total vehicles on the\nroad. The U.S. saw a 60% increase in sales during the first\nquarter of 2022 relative to the first quarter of 2021. Within the\ncontext of the Announced Pledges Scenario (APS) EV30@30,\nthe EV inventory is expected to exceed 85 million units in\n2025 and 270 million units in 2030. The demand for batteries\nwill reach 3.5 TWh, and the demand for cathode materials will\nreach 20 Mt. Such a significant increase in EV sales will lead\nto a tight supply chain of minerals for manufacturing batteries\n(Fig. 1d) [5].\nThus, the strategy for carbon reduction in electricity faces\nthe challenges of limited mineral resources and high prices\n\n@2@\n804 Z. Liu et al. / Green Energy & Environment 9 (2024) 802–830\n\nincreasing the national demand for more efficient EVs and\ncontinuously driving the battery industry toward LIBs sustainability. According to the United States Geological Survey\n(USGS), 8900 kt of lithium resources exist in the world; in\nrecent years, mining has exceeded 10 kt to meet the high\ndemand in the LIBs market [6,7]. The lifespan of both EVs\nand portable devices is approximately 5 years, and the bull\nmarket of LIBs will likely generate many spent LIBs and\nwidespread environmental pollution, hence the growing\nconcern for clean and efficient recycling systems. According\nto global spent LIBs recovery market forecast, from 2021 to\n2030, the spent LIBs recovery market will grow from US $4.6\nbillion to US $22.8 billion [8]. According to GII statistics, the\ntheoretical decommissioning volume of LIBs in China reached\n512,000 tons in 2021, with 49.5% recycled and 8% laddered,\nand the remaining unknown amount of LIBs were recycled by\nsmall workshops or abandoned as garbage [9]. Moreover, there\nare fire safety threats stemming from stacked spent batteries\nand noncompliant recycling methods, personal health threats\nand groundwater contamination resulting from toxic electrolyte leakage. Therefore, the proper disposal of spent LIBs and\nefficient recycling of electrode materials are crucial to for the\nsustainable development of a harmonious coexistence between\nhumans and the environment in within the context of carbon\nneutrality policies.\nLithium and cobalt, which are expensive metals, exhibit the\ncharacteristics of low relative abundance and high demand in\nthe field of LIBs. Therefore, studies on spent LIBs electrode\nmaterials have focused on methods for developing an efficient,\ninexpensive and pollution-free recovery system. However, the\nvariety of LIBs is rapidly changing, and the corresponding\nrecycling processes are facing unprecedented challenges. Over\nthe past five years, research into the recycling of used batteries\nhas rapidly progressed. There has been an increase in research\nrelated to the recycling of spent batteries in laboratories and\nindustry (Fig. 2). However, a sustainable recycling strategy is\n\ngreatly limited once economic, political, environmental and\nsafety factors are taken out of the equation.\nTherefore, when considering the high value and urgency\nof recycling spent LIBs, it is imperative to enable more\ninterdisciplinary and innovative technologies to fully understand the existing key recycling equipment for transforming the future direction of recycling technologies and\nfor achieving the sustainable development goal of disruptive\nand clean recovery of all components. It is vital to promote\nthe rapid development of society, economy, science and\ntechnology. First, based on the recycling of spent LIBs\nelectrode materials, the importance of spent battery recycling\nis explained in depth from several key perspectives,\nincluding development, application, safety, environment,\nprocess and policy. Recent advances in different recovered\ncathode materials are highlighted, including traditional hydrometallurgy and pyrometallurgy, in particular transformative organic leaching and bioleaching, thereby\nsummarizing the challenges and latest developments in\nvarious recovery systems and proposing sustainable development strategies for the corresponding recovery technologies. Finally, the above aspects are systematically analyzed\nusing life-cycle assessment (LCA) to further propose future\ndirections for the spent LIBs recycling system based on the\nfour roles of the government, suppliers, consumers and recyclers and to holistically provide an outlook on future\nrecycling methods.\n\n2. Spent LIBs failure mechanism\n\n2.1. LIBs progress\n\nCommercial LIBs first appeared in the 1970s. The anode\nand cathode are defined in Eqs. (1) and (2), respectively.\n\nLi/Li[þ] e[�] anode 1\nþ ð Þ ð Þ\n\nFig 2 Development history of spent LIBs industrial recovery\n\n@3@\nZ. Liu et al. / Green Energy & Environment 9 (2024) 802–830 805\n\nTable 1\nComprehensive comparison of commercial LIBs.\n\nTypes of Li-ion LCO LMO NCM LFP NCA LTO\nbatteries\n\nCathode LiCoO2 LiMn2O4 LiNi1-x-yCoxMnyO2 LiFePO4 LiNixCoyAlzO2 LiNiMnCoO2/\n\nAnode Graphite Graphite Graphite Graphite Graphite Li2TiO3\n\nStructure\n\nCycle life 500e1000 300e700 1000e2000 2000 500 3000e7000\nMarket share Outdated. Portable Small. EVs. Growing. Evs and Growing. EVs, energy Steady. Evs Limted. PHEV,\nelectronic devices portable electronic storage and power BRT\ndevices. tools\n\nActual specific 130e140 90e120 150e200 130e165 170e200 150e160\ncapacity (mAh g[�][1])\n\nDischarge (C-rate) Discharge current\nabove 1 C shortens\nbattery life\n\n1 C, 10 C and 30 1 C and 2 C 1 C and 25 C 1 C 10 C\nC\n\nSafety Low Medium Medium High Medium High\nEnergy density High Low Higher Medium Higher Low\nReferences [57] [58] [59] [58] [60] [15]\n\nLi[þ]þ e[�] þ MnO2/LiMnO2 ðcathodeÞ ð2Þ\n\nAfter the success of primary LIBs, there has been an increase\ninsecondarychargingLIBs.Fromthe firstrelease ofrechargeable\ntitanium disulfide (TiS) batteries [10] to the commercialization of\n\nLiCoO2 (LCO) [11], the energy density, lifetime, and safety of\nLIBs have been rapidly improved. The discharge reaction processes of the anodes and cathodes of rechargeable batteries can be\nexpressed as Eqs. (3) and (4), respectively.\n\nLiCx ↔ Cx þ Li[þ] þ e[�] ðanodeÞ ð3Þ\n\nFig. 3. (a) Relationship between the energy density of NIBs and the average voltage and energy density. Reprinted with permission from Ref. [17]. Copyright\n(2014) American Chemical Society. (b) Comparison of the reversible capacity and average voltage values of Li, Na and K half cells. Reprinted with permission\nfrom Ref. [20]. Copyright (2017) Royal Society of Chemistry. (c) Schematic of the charging and discharging mechanisms of the Al/graphite battery. (d) Relationship between MIB capacity and voltage. Reprinted with permission from Ref. [25]. Copyright (2016) Wiley. (e) New Al-based battery. (c) and (e) are reprinted\nwith permission from Ref [26] Copyright (2015) Springer Nature (f) Comparison of LIBs NIBs and KIBs\n\n@4@\n806 Z. Liu et al. / Green Energy & Environment 9 (2024) 802–830\n\nLiCoO2 ↔ Li1�xCoO2 þ xLi[þ] þ xe[�] ðcathodeÞ ð4Þ\n\nRechargeable batteries have become key means supporting\ninformation technology, strategic deployment, decarbonized\nelectricity, and energy storage. Common commercial LIBs are\ncompared Table 1, from various aspects for battery types\ncomprising of various material systems that have emerged. In\ncontrast, there are no perfect LIBs yet. LCO became popular\nin portable electronic devices, followed by Tesla's first generation of electric sports cars; however, EVs placed a greater\nemphasis on the battery cycle time and energy density, while\nEV suppliers later subsequently replaced the battery type with\nLiNi1-x-yCoxMnyO2(NCM) or LiNixCoyAlzO2 (NCA) [12,13].\nThese materials have the advantages of three types of metal\nmaterials, including the excellent cycle performance of LCO,\nthe high specific capacity of LiNiO2 and the notable stability\nof LiMn2O4(LMO) [14]. LCO is expensive, which greatly\nlimits its large-scale commercialization. However, compared\nto LCO, LiFePO4 (LFP) and LMO dominate the main EV\nmarket due to their lower prices. LMO batteries were the\npreferred power batteries for early EVs, with certain advantages in terms of rate capability and manufacturing costs.\nHowever, the high-temperature and cycling capacity disadvantages limited their long-term development. LFP is widely\nsought after in today's EV and energy storage system markets\ndue to its long cycle life, low cost, and high safety performance [15,16]. Na[þ] batteries (NIBs) (Fig. 3a) [17,18], K\nbatteries (KIBs) (Fig. 3b) [19,20], Mg[2][þ] batteries (MIBs)\n(Fig. 3d) [21–25], and Al-based batteries (AIBs) (Fig. 3c and\ne) [26,27] show the possibility of replacing LIBs in the\ncommercial development of batteries because of their\noutstanding advantages. For example, Contemporary Amperex\nTechnology (CTAL) launched a new generation of ultrahigh\n\nenergy density NIBs in 2021 [28]. However, due to the lack of\nlithium's ultra-high specific capacity (3860 mAh g[�][1]), low\nredox reaction potential ( 3.04 eV vs. standard hydrogen\n�\nelectrodes), and other advantages that make LIBs stand out in\nthe battery industry (Fig. 3f). Therefore, it has been established that metallic lithium is an essential component of\nrechargeable batteries [29]. Recently, LiF nanocrystalenriched solid-state electrolyte membranes (SEIs) have been\nutilized in anode-free lithium metal batteries (LMBs), suppressing Li dendrite growth and facilitating rapid Li[þ] transfer\n\n[30]. The research and development of ultralong-lifespan\nLMBs have opened possibilities for the LIBs market in the\nfuture.\nHowever, the vigorous development of LIBs is accompanied by large amounts of spent LIBs, and the service life and\nfailure are the main reasons for spent. Therefore, ascertaining\nthe failure mechanisms of LIBs is very important for optimizing the recycling of spent LIBs.\n\n2.2. Failure mechanisms\n\nThe main feature of scrapped LIBs is electrochemical\nperformance failure, and the failure state is closely related to\nthe recovery technology in the pretreatment process and the\nmetallurgical technique. For example, Fig. 4 shows a diagram\nof the electrochemical performance failure mechanism, which\nis mainly due to the failure of key materials such as the\ncathode, anode, electrolyte, and diaphragm [31–34]. The reaction triggers can be divided into mechanical, electrochemical and thermal triggers (Fig. 4a). The side reaction of\ngas production increases; under the influence of the internal\npressure, the outer packaging can burst; and overcharging and\n\nFig. 4. Schematic of the electrolyte failure mechanism. (a) Out-of-control heat causes the electrolyte to dry. (b) Electrolyte consumption in the Mn[2][þ] catalytic\nreaction (c) Decomposition of electrolyte LiPF\n\n@5@\nZ. Liu et al. / Green Energy & Environment 9 (2024) 802–830 807\n\noverdischarging can cause the risk of heat generation (Fig. 4b).\nThe decomposition of lithium salt LiPF6, solvolysis, or the\nconsumption of decomposed solvent molecules embedded in\nthe graphite layer can explain electrolyte failure (Fig. 4c).\nIn general, regarding spent LIBs, the focus should be on\nconfirming the failure state and dividing the failure level to\nrealize efficient cleaning and recycling of available spent\nbattery components. Therefore, it is very important to explore\nthe failure mechanism of LIBs and establish failure models for\nefficient dismantling and recycling of spent LIBs.\n\n3. Traditional to transformative LIBs recycling\ntechnology\n\nBased on a thorough understanding of traditional recycling\ntechnologies, we should comprehensively improve key factors,\nsuch as the environment, economy, safety, and technology,\ntransform traditional recycling technologies, and establish a\nnovel clean and efficient recycling system for all components\nof spent LIBs.\n\n3.1. Environment\n\nThe whole entire recycling process of spent power batteries\nretired from EVs can be divided into three stages, entailing the\nconsumption of multiple types of energy (Fig. 5a) [35],\nnamely, collection and transport (Stage 1), pretreatment and\ndismantling (Stage 2), and recycling and integrated use (Stage\n3). These stages generate a multifaceted coupled adverse\n\nimpact. At Stage 1, the use of rail and truck transport reduces\ngreenhouse gas (GHG) emissions by 23%–45%. The potential\nthreat of thermal runaway from spent LIBs, the high weights\nof LIBs, and the high GHG emissions resulting from the\nelectrolytic corrosion of railway equipment make long-distance rail transport to recycling facilities unattractive (Fig. 5c)\n\n[36,37]. In addition, Thomas and colleagues skillfully combined LCA and geographical profiles and conducted modeling\nanalysis to comprehensively understand the impact of recycling infrastructure on the environment. The trucking of spent\nLIBs and the use of advanced dismantling infrastructure,\nbased on the identification of optimal locations for recycling\nfacilities, could significantly increase the economic benefits of\nrecycling. At Stage 2, pyrometallurgy and hydrometallurgy\nhave been evaluated in regard to end-of-life (EoL) battery\nrecycling systems. The results show that the final recovery of\nvaluable materials from both metallurgical methods could\nreduce the environmental damage of LIBs production.\nNotably, Yang et al. [38] systematically analyzed the GHG\nemissions of components of recycled and spent LIBs; they\nfound that recycled aluminum could release higher GHG\nemissions. From detailed data of GHG emissions, pyrometallurgy releases higher GHG emissions than hydrometallurgy\n(Fig. 5b) [38].\nPyrometallurgical recovery methods (pyrometallurgy, vacuum reduction roasting and inert gas reduction roasting) and\nhydrometallurgical recovery methods (inorganic acid leaching\nand organic acid leaching) have been compared using\nOpenLCA software to evaluate the environmental impacts of\n\nFig. 5. (a) Various energy consumption levels of recycled and spent LIBs. (b) Recycling GHG emissions stemming from various structures of spent LIBs. (c)\nCollection and transmission costs of spent LIBs. (b) and (c) are reprinted with permission from Ref. [38]. Copyright (2021) Elsevier. (d) Relationship between the\nrecovery amount of spent LIBs, number of dismantling facilities, recovery capacity and marginal cost when using road transport. (a) and (d) are reprinted with\npermission from Ref [35] Copyright (2015) IOP Publishing Ltd (e) GHG emission contributions of hydrometallurgy and pyrometallurgy\n\n@6@\n808 Z. Liu et al. / Green Energy & Environment 9 (2024) 802–830\n\nthe different recovery methods, with the global warming potential (GWP) as the midpoint and quantitative analysis based\non the total GHG emissions [39]. The GWP can be assessed by\ntwo parameters, i.e., the energy reduction rate (xE) and the\nGHG reduction rate (xG), which can be calculated with Eqs.\n(5) and (6), respectively:\n\nxE ¼ [E][v][ �]En [E][r] ð5Þ\n\nxG ¼ [G][v][ �]Gn[G][r] ð6Þ\n\nwhere Ev is the raw material production energy consumption,\nEr is the recycling energy consumption, Gn is the raw material\nproduction process of GHG emissions and Gr is the recycling\nprocess of GHG emissions. Quantitative results have indicated\nthat the differences in the GHG emissions resulting from the\nrecovery of 1 functional unit (FU) of LCO between the two\nmethods are not significant, ranging from 80.5–361.5 CO2-eq\nFU[�][1]. The GHG emissions resulting from the recycling of 1\nFU are significantly lower than those produced by industry\nresulting from the production of an equivalent amount of LCO\nfrom virgin materials. These results demonstrate the significant potential of recovering LIBs in terms of GHG emission\nreduction. A sensitivity analysis was conducted of the extent\nand trend of the impacts of two key factorsdthe metal ion\nrecovery rate and energy mixdon the system energy consumption and GHG emissions. These quantities can be\ncalculated with Eqs. (7) and (8), respectively:\n\ndE ¼ [E][0][ �]E[0] [E][i] ð7Þ\n\ndG ¼ [G][0]G[ �][0] [G][i] ð8Þ\n\nwhere E[0] and G[0] denote the initial values of the system energy\nconsumption and GHG emission indicators, respectively, E[i]\n\nand G[i] denote the corresponding system energy consumption\nand GHG emissions, respectively, after key factor variation,\nand dE and dG denote the rates of change of the system energy\nconsumption and GHG emission indicator values, respectively.\nDunn and colleagues found that the energy consumption\nassociated with the production of LCO from new materials is\n147 MJ kg[�][1] [40]. Furthermore, there were no significant\ndifferences between the various methods at the collection and\ntransport stage. The energy consumption of the different recovery methods is identical at Stages 1 and 2, at 781 and\n474 MJ FU[�][1], respectively, accounting for 3% (hydrometallurgy) to 8% (pyrometallurgy) of the total energy consumption\n(Fig. 5e). Regarding pyrometallurgy, both the conversion and\nregeneration phases are major contributors to energy consumption. In general, sound recovery of spent LIBs should\ninvolve a combination of the advantages of multiple recovery\n\nmethods. For example, by combining the advantages of multiple metallurgical methods for LIBs recovery and applying\nLCA to a corporate LIBs recovery system, the advantages of\neach recovery method can be weighted and combined to\nestablish an optimal recovery technology [41–44]. Accurec\nRecycling GmbH Co., Ltd., uses vacuum roasting to recover\nand treat spent LIBs [45]. Pyrometallurgy and hydrometallurgy were combined in pretreatment, and the sintered cobaltbased alloy was recycled [46]. Sony and Sumitomo adopted a\ntechnical partnership approach to the recycling of spent LIBs\n\n[47]. Sony completed the preprocessing dismantling magnetic\nseparation step via calcination at 1000 [�]C to remove plastic\nand electrolyte materials. The electrode material was hydrometallurgically recycled by Sumitomo into high-purity CoO\nthat meets the standard for the direct preparation of new cells.\nThe combination of these technologies demonstrated\noutstanding advantages in terms of reduced waste liquid and\nwater consumption during hydrometallurgical recovery and\nreduced waste gas and electricity consumption during thermal\nrecovery.\n\n3.2. Economic factors\n\nThe economic viability of recovery technology determines\nthe potential for large-scale LIBs recovery, and recovery\ntechnology constitutes the cornerstone of large-scale\ncommercialization. Relative to long-distance rail transport,\nroad transport allows a more flexible approach for recycling,\nsuch as loading spent batteries at smaller recycling sites when\ntraveling to the recycling site to increase the recycling revenue. However, the issue of high costs over long distances must\nstill be addressed. For example (Fig. 5d), when comparing the\ncosts of heavy truck transportation in different countries, the\ntrend exponentially increases with distance traveled [35].\nThe economic benefits of spent LIBs recovery result from\nthe cascade utilization and recovery of electrode materials.\nGenerally, the evaluation method of discounted cash flow\neconomic benefits is used to analyze the economic feasibility\nthrough the relationship between the availability price and the\nmarket price for each battery structure [48,49]. First, this can\nbe achieved based on the reserves, market price, and recovery\ncost of producing LIBs resources. Then, combined with the\ndiscount rate, the economic feasibility can be evaluated based\non the price relationship between the recovery and treatment\ncosts and benefits. Finally, the discounted cash flow method\ncan be used to analyze the availability in each year. This\nmethod can provide considerable support for achieving the\ndouble carbon target and formulating recycling policies from\nnational economic strategy and social and human environment\nperspectives. The resource availability price can be calculated\nwith Eqs. (9) and (10), where W is the resource stock of each\ntype of spent LIBs, CR is the average recycling treatment cost,\ni is the discount rate (5%), n is the year and P is the resource\navailability price.\n\n@7@\nZ. Liu et al. / Green Energy & Environment 9 (2024) 802–830 809\n\nXn\n\n½ðP � WÞ� CR� $ ð1 þ iÞ[�][t] ¼ 0 ð9Þ\n\nt¼1\n\ncells could exhibit significant EoL needs. Therefore, the\nrecycling of LFP batteries has a very promising value.\nNCM batteries suffer from low thermal stability and\nnotable waste liquid generation, which renders safe mechanical dismantling extremely challenging and reduces the economic nature of recycling. Ma et al. [53] calculated that one\nregenerated NCM battery could save $2510 t[�][1]. They determined that the cost of recovering 1 t of NCM at full load was\n$2742. However, the role of Mn and Co in the cathode remains\ncontroversial, and numerous issues have limited their market\nadoption on a large scale and reduced the recovery scale [54].\nNCM batteries require high temperatures for pyrometallurgical recovery and complex and lengthy hydrometallurgical recovery processes, and few detailed economic analysis studies\nhave been performed targeting the recovery process. Direct\nleaching with inorganic acids, by exploiting the low price and\nhigh efficiency of leaching, is the main method used by\ncompanies to recover valuable substances from NCM batteries. In terms of recovering Co, extraction can generate a\nrelatively high yield of CoCO3 [55]. From economic, efficiency and green recycling perspectives [56], predicted that\nthe future NCM recycling process system should be as follows: alkaline solution dissolution / calcination pretreatment\n/ H2SO4 leaching / H2O2 reduction / NCM coprecipitation regeneration.\nUnder the LFP scenario, Li reaches a maximum availability\nof 85,200 t at an availability price of $76,428.67. Cu, Al and\nFe are available at prices above the market price, and all\nindicate unavailability. Overall, the resource availability prices\nof Ni/Co/Mn under three scenarios are much lower than the\nmarket prices. The resource availability levels of LIBs under\nthe baseline and LFP scenarios show clear advantages by\nimproving the resource recovery efficiency, which is closely\nrelated to the types of metals contained in LIBs.\nIn summary, the metal content, structure, and cost of the\ndifferent spent LIBs vary. The recycling scale is positively\nrelated to recycling economic benefits. Appropriate recycling\nmethods should be selected according to the capacity of the\nspent LIBs and the scale of recycling enterprises to improve\nthe economic benefits. Pyrometallurgy is often difficult to\napply for collecting high-purity metal materials, and the\nproducts are mostly alloys of Ni, Co, and Mn. To recover a\nsingle metal, one often must combine hydrometallurgy with\n\nCRij ¼ CR$Wi1$Pi1 þ WWi2ij$$PPi2ij þ …Wij$Pij ð10Þ\n\nBased on Commodity Futures Trading Commission metal\nprices, the treatment cost and availability price range for each\ntype of resource in the recovered spent LIBs can be calculated,\nwhere CRij is the resource recovery cost, CR is the average\nrecovery treatment cost and Wij is the resource quality. Based\non research data of recycling waste battery enterprises and\ncombined with the above equations, the cost and availability\nprice range values of eight resources for the recycling and\nprocessing of mainstream LIBs can be calculated, as summarized in Table 2. By assuming that the collected ternary\nlithium batteries, i.e., LFP and LMO batteries, are of the same\nquality and all weigh one ton, they are mechanically pretreated, after which they are processed by pyro- or hydrometallurgical methods to obtain various valuable resources\n\n[50,51]. The recovery profit considers material, security, labor\nand environmental costs. Clearly, ternary lithium batteries gain\napproximately 28% of the market price cost due to the relatively high Co and Li contents. The average cost of LFP\nbatteries reaches a maximum value of $76,408.92, recovering\n14% of the cost. Ref. [52] suggested that the more common\nacetic acid leaching process chosen for LFP batteries is the\nbest option for improving economics. The profit difference\nbetween the LMO and LFP recovery methods of Li is not\nsignificant. However, the cost to recover 1 t of LMO for\nobtaining Mn is $3114.57, which is much higher than the\ndirect purchase price of $2200.40. According to the above\ncalculations, the low or even negative profits due to Mn recovery can be avoided in the recycling process. Additionally,\nnew challenges are encountered in the development of efficient and inexpensive LFP and LMO recovery technologies.\nThe prices and reserves of Li and Co indicate that ternary LIBs\nrich in these metals are of high economic value. However, the\nmanufacturing of LFP batteries does not require Co addition,\nwhich significantly reduces the manufacturing cost. By\ncomparing the costs of plastic (polyethylene (PE)) and Mn,\nLFP batteries exhibit a very high economic potential for\nrecycling. Furthermore, EVs that use LFP batteries as power\n\nTable 2\nResource recovery processing cost and availability price range.\n\nResource Market Ternary lithium LFP LMO Average cost Available price range (3 situations)\n(1 t) price battery\n\nBenchmark Ternary lithium LFP\nbattery\n\nNi $17,083.31 $4869.47 e e $4869.47 $1111.12e$17361.14 $3472.26e$17361.12 $5277.78e$18055.56\nCo $55,069.47 $15,995.92 e e $15,995.92 $14583.37e$56389 $14027.78e$55569.45 $14583.33e$55694.44\nMn $2200.42 $645.53 e $3114.57 $1880.06 $1833.53e$2236 $1833.30e$2222.21 $1840.28e$2222.22\nLi $74,305.54 $21,582.60 $103,500.92 $104,143.22 $76,408.92 $65069.44e$74514 $65152.78e$74305.56 $61111e$76428.67\nCu $8990.97 $2611.64 $12,523.69 $12,601.68 $9245.67 $8722.22e$9000 $8944.44e$8993.06 $8750e$9027.78\nAl $2375.00 $689.96 $3308.19 $3328.36 $2442.17 $2367.78e$2375.69 $2368.06e$2375 $2305.56e$2386.11\nFe $156.25 e $217.56 e $217.56 $161.12e$211.11 $155.69e$211.11 $141.67e$157.72\nPe $972.22 $282.18 $1354.17 $1362.57 $999.64 $694.44e$875 $791.67e$979.17 $277.78e$930.56\n\n@8@\n810 Z. Liu et al. / Green Energy & Environment 9 (2024) 802–830\n\nother processes. Compared to spent ternary LIBs, LMO and\nLFP batteries contain less expensive metals and exhibit a high\nproduction value within the context of LCA. Therefore, the\ncombination of mechanochemical pretreatment, roasting,\nleaching, coprecipitation, and reloading could achieve the goal\nof efficiently recovering the valuable target materials.\n\n3.3. Traditional to transformative recycling processes\n\nThe goal of recycling spent LIBs is the laddering of EoL\nbatteries or the conversion of valuable components into valuable materials at maximum recovery rate. Similar to the\nrecycling of electrode materials, academia and the business\ncommunity are constantly seeking to maximize ladder utilization rates. Our comprehensive review of the literature on the\ntreatment of spent LIBs today falls into two broad technology\ncategories: ladder utilization and material extraction. At the\nearly stages of research on the recycling of spent LIBs, cells\nare typically inactivated at the pretreatment stage, eliminating\npotential thermal and electrocution risks, e.g., through\ndischarge treatment [61,62] and low-temperature stripping and\ndismantling in an inert environment. Cryogenic inert gas\ntechnology is widely used in companies to eliminate the\nthermal risk of the residual charge occurring in warehouses\nand transportation. However, through a ladder utilization\napproach, when the battery EoL stage is reached after energy\nexhaustion, batteries are physically dismantled or chemically\nroasted. Then, precursor materials are recovered through\ndifferent metallurgical and impurity removal techniques to\nfinally complete the reloading and reuse of spent LIBs,\nforming a closed recycling loop, as shown in Fig. 6.\n\n3.3.1. Ladder utilization and pretreatment\nBefore recycling, spent LIBs are subjected to ladder utilization according to their remaining capacity, degree of damage\nand remaining lifetime, aiming to maximize the recycling of\nreusable individual cells from used battery packs and increase\nthe battery life cycle [43]. Obsolete power packs that have\nreached the end of their useful life have low energy levels and\npower densities, and they cannot properly propel EVs. However, dismantled battery modules can be used in energy storage devices, household appliances and portable electronic\ndevices. Ladder utilization has been vigorously promoted by\nthe business community and the government and has been\nincluded in compulsory measures. Ref. [36] found that 1000\ntons of batteries was utilized in echelons, reducing the battery\nsupply by 2%. Industry has tended to opt for more automated\nmechanical crushing, and the crushed mixed fraction is\nscreened and subjected to flotation and magnetic separation,\nwhich is conducive to obtaining positive and negative materials [63–71]. However, ladder utilization inevitably involves\nthe use of manual disassembly, which increases the cost [72].\nIn laboratory studies, spent LIBs have been manually disassembled in a safe environment; binder removal is difficult\nduring the separation of collector fluid from the electrode\nmaterial. Organic adhesives, such as polyvinylidene fluoride\n(PVDF), are often used, and organic solvents, such as Nmethyl-pyrrolidone (NMP) [73–75], dimethylformamide\n(DMF) [76], and dimethylacetamide (DMAC) [77], are\ncommonly used. However, organic solvents are expensive and\ntoxic, and the cathode material is encapsulated in organic\nmaterial, making dewatering very difficult and preventing its\nwidespread adoption by industry. Heat treatment methods are\n\nFig 6 Schematic diagram of closed loop recycling of spent LIBs\n\n@9@\nZ. Liu et al. / Green Energy & Environment 9 (2024) 802–830 811\n\nwidely employed in industrial production; however, the generation of large amounts of exhaust gases and the high energy\nconsumption remain unresolved [78–80]. Moreover, alkaline\nsolutions could explode due to the hydrogen produced in the\nrecovery process, and the large equipment requires a high\ncorrosion resistance. For this reason, strong alkaline leaching\nis limited to the laboratory scale [81]. With the continuous\ninnovation of technology, the use of electromagnetic waves\nwith frequencies ranging from 300 MHz to 300 GHz to\nstimulate intermolecular interactionsdmicrowave-assisted\nenhanced disassemblydhas been widely adopted [82–84].\nIn general, ladder utilization increases the recycling and use\nvalues of spent LIBs and reduces the processing capacity and\npollution due to the recycling process. Additionally, spent\nLIBs are closely structured with various valuable components\nand exhibit a diverse electrolyte composition, and these battery-specific problems add to the recycling challenge.\nResearch on the electrolytes of spent LIBs should focus on\nmaximizing the value of the recovered electrolyte for other\napplications or modifying it into other industrial byproducts,\nrather than producing complex electrolytes and reloading\nbatteries. Manual dismantling maximizes the recovery rate,\nbut this method is not widely adopted by the industry due to\nthe high cost and inefficiency.\n\n3.3.2. Transformative technology for extracting valuable\nmaterials\n\n3.3.2.1. Cathode. High-temperature pyrometallurgy: Hightemperature pyrometallurgical recovery technology aims to\npretreat spent LIBs with high-temperature roasting for molten\nmetal recovery or hydrometallurgical separation recovery from\nthe treatment product. To date, thermal treatment is divided into\nthree main categories based on the recovery technology: hightemperature cracking, high-temperature reduction roasting\nand low-temperature molten salt roasting. Umicore Co., Ltd.\ndirectly places scrap batteries in their original form in a smelting\nfurnace and collects Co–Ni–Cu (Mn) alloy metal products [85].\nHowever, the boiling point of Li is relatively low, and the high\ntemperaturesand lengthy recovery processes increase the amount\nlost. Bak et al. [86] investigated the migration preferences of\ncations in high-temperature roasted ternary electrode materials at\nthe spinel structure position, where Co ions preferentially\nmigrated to the tetrahedral 8a position of the spinel structure at\ntimes. He analyzed the effect of different high-temperature environments on crystal structure transformation and the fugacity\nof valuable metals, laying the foundation for pyrometallurgical\nrecovery of spent ternary LIBs. Pyrolysis is a recovery technology that involves the use of high temperatures to convert previously unstable electrode materials into stable states. Ref. [87]\nreduced LiNixCoyMn1-x-yO2 to metal Co and Ni at high temperatures ranging from 500 to 700 [�]C. Zhang et al. [88] employed\nhigh-temperature calcination of spent LCO batteries at 550 [�]C to\nultimately recover 30% Li and 50% Co and obtained stable LCO\nprecursor materials Li2CO3 and Co3O4. This indicates that\nwhen using high-temperature recovery of spent LIBs, it is\nnecessary to select an appropriate temperature range based on the\n\nthermodynamic stability zone of the metal. However, high temperatures could generate significant energy consumption. To\nreduce the reaction energy consumption, researchers have often\nadded inexpensive reducing agents to reduce the high-temperature reduction energy consumption and increase the recovery\nefficiency. Hu et al. [89,90] mixed cathode LCO material with\nanode graphite material and used halide doping (CaF2 and CaCl2)\nas a variable condition to reduce and calcine the positive electrode material. Co ions were reduced to monomers and recovered\nby magnetic separation. Li was recovered in gaseous and halide\nforms (LiF and LiCl, respectively). Vacuum roasting of anode\ngraphite and cathode LMO materials at 800 [�]C yielded 91.3%\nLCO, confirming the notable role of spent graphite in pyrometallurgical recovery of valuable cathode metals [91]. To further\nreduce energy consumption and improve the recovery rate, researchers have continuously adjusted the doping ratio and temperature interval of the electrode material and reducing agent for\nroasting purposes and have recovered valuable metals by water\nleaching [91–93], evaporation crystallization [94], and acid solution leaching [95]. The reduction roasting techniques all yielded metal recovery rates exceeding 90%. From a crystal structure\nperspective, the high affinity of graphite for oxygen is more likely\nto destabilize the oxygen octahedra of LCO. Moreover, regarding\nthe coupled reaction of graphite combustion and metal compound pyrolysis, the reaction pathway is expressed in Eqs. (12)–\n(14), and the mechanism of lamellar structure decomposition\ncollapse is shown in Fig. 7a and b.\n\n4LiCoO2 þ C / 2Li2O þ CO2 þ 4CoO ð11Þ\n\n4LiCoO2 þ 2C / 2Li2O þ 2CO þ 4CoO ð12Þ\n\nCO þ CoO þ Li2O / Co þ Li2CO3 ð13Þ\n\nC þ 2CoO þ Li2O / 2Co þ Li2CO3 ð14Þ\n\nThe recycling idea of recovering cathode waste through\nreduction with anode graphite waste is more widely adopted in\nreduction roasting technology versus purchasing additional\nreducing agents. In addition, the dismantling of spent LIBs yields\nbinders, aluminum foil, electrolyzed organic material, diaphragms, and high-sulfur flue gas from boiler houses, coal and\nslag. All of these solid wastes can be used to reduce cathode\nmaterials to metal oxides, monomers, alloys, acid salts and other\nforms. Low-cost waste biomass as a reducing agent is a future\ndirection for the recycling of spent LIBs. However, attention\nshould be given to the impact of their complex organic structures\non ecological stability. Moreover, the shortcomings of pyrometallurgy, such as high energy consumption, difficult collection of\nmetallic materials and polluting gases, and dependence of\nbiomass reduction on high temperatures, limit its application.\nConsequently, to further improve the technology, these shortcomings can be overcome by coupling multiple recycling techniques. At present, combined pyrometallurgical and\nhydrometallurgical recovery techniques have been developed and\nsupplemented with external conditions, such as microwaves, ultrasound and heating, to compensate for the shortcomings of\nthese two traditional recovery techniques (Table 3).\n\n@10@\n812 Z. Liu et al. / Green Energy & Environment 9 (2024) 802–830\n\nFig. 7. Collapse model of recycling metals from LiCoO2 batteries by roasting: (a) Crystal structure of LiCoO2 and basic cells of LiCoO2. Reprinted with permission\nfrom Ref. [96]. Copyright 2018 Elsevier. (b) Pyrolysis of LiCoO2 and reduction of LiCoO2 by C. (c) Plausible pathways for the conversion of cathode powder from\nspent LiCoO2 batteries. (b) and (c) are reprinted with permission from Ref. [97]. Copyright (2019) Royal Society of Chemistry.\n\nThe principle of low-temperature molten salt roasting technology is to use the transformation reaction of the cathode material in the molten salt environment, where insoluble\ncompounds are reduced in valence and converted into soluble\nsalts and oxides. SO3 is usually adopted to enhance solubility,\nwhile reducing sulfates (Na2S2O7, NaHSO4, K2S2O7, and\nKHSO4) are employed for roasting with the cathode material to\nreduce the metal valence. The product is subjected to hydrometallurgy and roasting to prepare precursor and electrode materials [98,99]. Nevertheless, the complex process and the\nintroduction of impurity metal ions make it difficult to separate\nand purify the cathode metal material [98,100]. To further\naddress the interference of waste gas, Na and K, researchers have\napplied leaching reagents in separation and purification. Lin\net al. [101] conducted concentrated sulfuric acid roasting after\nLCO pretreatment to selectively separate LiSO4 and Co3O4,\nyielding a very high Li leaching rate and trace amounts of Co.\nThe entire roasting process is environmentally friendly and\nproduces no toxic or harmful emissions. Furthermore, no acidic\nor alkaline wastewater is produced in the leaching process. Fan\net al. [102] added NH4Cl in the roasting process to obtain watersoluble chloride salts of Li and Co by water immersion.\nTransformative leaching: Leaching is a common technical\ntechnique for dissolving stable-state valent metals and converting them into their ionic forms in a solution medium of a\nnegative metal material for subsequent metal separation and\npurification. Transformative leaching technology has been\ndeveloped for using plants, tea (wood cellulose), orange peel,\nand grape seed materials as leaching agents, these kitchen\n\nwaste products after enzymatic fermentation of glucose and\nethanol as reducing agents. The mechanisms of several\ntransformative leaching techniques are shown in Fig. 8.\nTraditional reagents for leaching spent LIBs electrode materials include acids, bases and organic solvents. On this basis, the\nincorporation of auxiliary methods, such as acoustic, mechanical\nforce and chemical methods, has yielded brilliant results in\nimproving recovery rates and reducing energy consumption\nlevels. However, the discharge ofhighlyconcentratedsodiumsalt\neffluent, the lengthy process and the large number ofimpurities in\nthe enrichment solution are the largest challenges for leaching.\nInorganic acids: In conventional hydrometallurgical leaching techniques, the commonly used inorganic strong acid\nleaching agents H2SO4 [118–120], HCl [55,121,122] and HNO3\n\n[55,123] are well established and widely used for dissolving\nvarious electrode materials. Similar to pyrometallurgical recovery, these three types of acid leach recovery systems introduce\ninorganic reducing agents, such as Na2S2O3 [124], Na2SO3 [125]\nor hydrazine sulfate [126], biomass reducing agents, such as\ngrape seeds, tea pomace, glucose, ascorbic acid and straw,\nultrasonication, microwave-assisted conditions, and conventional H2O2 reducing agents to increase leaching rates. Notably,\nsodium bisulfite [127] achieves better leaching in the H2SO4\nsystem, which may be related to the generation of SO2 gas, as\nexpressed in Eq. (15); gas escape enhances the reactivity in the\nlocal environment, increasing the perturbation between the positive material and reducing agent and increasing the heat released\nduring the reaction. The lifting mechanism is similar to that of\nultrasonic treatment.\n\n@11@\nZ. Liu et al. / Green Energy & Environment 9 (2024) 802–830 813\n\nTable 3\nSummary of pyro-hydrometallurgical studies with reduction roasting.\n\nMaterial Reductant Leaching Composition (%) Optimized Recovery\nagent parameters efficiency\n(%)\n\nRef.\n\nLCO H2SO4 Water, Co ¼ 55.31, 600 [�]C, 2 h, Li ¼ 92, [103]\nH2SO4 Li ¼ 6.7, LCO/H2SO4 Co ¼ 100\nNi ¼ 0.1, molar ratio 2:1\nMn ¼ 0.15\n\nH2SO4 Water Co ¼ 56.96, 800 [�]C, 1 h, Li > 99, [97]\nLi ¼ 6.75, LCO/H2SO4 Co > 98\nNi ¼ 0.03, molar ratio 2:1\nMn ¼ 0.01\n\nGraphite H2SO4 Co ¼ 22.46, 600 [�]C, Li, Co, [104]\nLi ¼ 5.93, 3 h, 14.3 wt.% Ni > 99,\nNi ¼ 23.01, graphite Mn > 97\nMn ¼ 17.51\n\nAl H2SO4 Co ¼ 56.36, 600 [�]C, 1 h Li > 93, [105]\nLi ¼ 3.66 Co > 80\n\nLignite H2SO4 Co ¼ 14.9, 55 [�]C, 2.5 h Ni, [106]\nLi ¼ 5.75, Mn > 98%,\nNi ¼ 21.9, Li > 96%\nMn ¼ 17\n\nNCM H2SO4 Water, Co ¼ 7.87, 550 [�]C, 3 h, Li ¼ 90, [107]\nH2SO4 Li ¼ 4.38, H2SO4/Li Co ¼ 97,\nNi ¼ 18.7, molar ratio 0.95 Ni ¼ 98,\nMn ¼ 22.26 Mn ¼ 90\n\nGraphite Acid Co ¼ 22.02, 900 [�]C, 0.5 h, Co, Ni, [108]\nLi ¼ 8.4, 500 W microwave Mn > 96,\nNi ¼ 21.85, power Li > 99\nMn ¼ 22.26\n\nGraphite Water, Co ¼ 22.02, 0.25 h, 500 W Li > 99, [109]\nfumaric Li ¼ 8.4, microwave power, Co, Ni,\nacid Ni ¼ 21.85, 0.75 mol L[�][1] acid Mn > 97\n\nMn ¼ 22.26\n\nGraphite Water, Co ¼ 17.2, 900 [�]C, 1 h, Li ¼ 82.2, [110]\nNH3$H2O, Li ¼ 7.1, 5% anode Co ¼ 99.1,\n(NH4)2SO3 Ni ¼ 21.1, Ni ¼ 97.7,\n\nMn ¼ 18.4 Mn ¼ 1.6\n\nGraphite H3PO4 Co ¼ 21.58, 750 [�]C, 3 h Li ¼ 99.1, [111]\nLi ¼ 6.17, Co ¼ 97,\nNi ¼ 21.96, Ni ¼ 98,\nMn ¼ 16.63 Mn ¼ 96.3\n\nAl H2SO4, Co ¼ 16.53, 520 [�]C, 1 h Li ¼ 99.78, [112]\nNaOH, Li ¼ 6.47, Co ¼ 99.29,\nNa3PO4 Ni ¼ 16.94, Ni ¼ 98.62,\n\nMn ¼ 13.14 Mn ¼ 99.91\n\nCarbon Water, H2SO4, Co ¼ 20.46, 550 [�]C, 0.5 h Li ¼ 93.68, [113]\nblack Li ¼ 7.16, Co ¼ 99.87,\nNi ¼ 20.02, Ni ¼ 99.56,\nMn ¼ 19.35 Mn ¼ 99.9\n\nCoke Water, H2SO4, Co ¼ 20.46, 650 [�]C, 0.5 h, Li ¼ 93.68, [93]\nLi ¼ 7.16, 10% coke Co ¼ 99.87,\nNi ¼ 20.02, Ni ¼ 99.56,\nMn ¼ 19.35 Mn ¼ 99.9\n\nLiNi0.6 Co0.2Mn N2 H2SO4 Co ¼ 6.06, 350 [�]C, 1.5 h Li, Co, Ni, [114]\n\n0.2[O]2 Li ¼ 4.84, Mn > 99\nNi ¼ 18.82,\nMn ¼ 5.94\n\nLCO, LiNi0.5Mn Graphite Water Co ¼ 35, 885 [�]C, 59 min, Li:885 [�]C ¼ 83, [115]\n\n1.5[O]4 Li ¼ 5, 30 vol% C, 870 microwave ¼ 82\nNi ¼ 8, W microwave\nMn ¼ 18 power, 7.8 min\n\n(continued on next page)\n\n@12@\n814 Z. Liu et al. / Green Energy & Environment 9 (2024) 802–830\n\nTable 3 (continued )\n\nMaterial Reductant Leaching Composition (%) Optimized Recovery\nagent parameters efficiency\n(%)\n\nRef.\n\nLCO, LMO Graphite Water Co ¼ 40, 30 vol% graphite, Li ¼ 84, Co¼72.3, Mn ¼ 15.3 [116]\nLi ¼ 5, 900 W microwave\nMn ¼ 21 power, 10 min\n\nGraphite Water Co ¼ 39.93, Li ¼ 6.02, Mn ¼ 21.07 800 [�]C, 0.75 h Li ¼ 96.7, Co ¼ 81.6, Mn ¼ 67.3 [117]\nLCO, NCM Lignite H2SO4 Co ¼ 40, Li ¼ 5, Ni ¼ 6, Mn ¼ 17 650 [�]C, 3 h, 19.9 vol% C Li ¼ 84.7, Co, Ni, Mn > 99 [106]\n\n2NaHSO3 þ H2SO4 ¼ Na2SO4 þ 2SO2[ þ 2H2O ð15Þ\n\nThe leaching agent dosage, reducing agent quantity, temperature, time and auxiliary leaching parameters can be adjusted to\nobtain the optimal process conditions for acid leach recovery.\nThen, a separation process can be used to separate and extract or\n\nregenerate spent LIBs for preparing precursors, achieving full\nrecovery and utilization of valuable metals. Even though these\ninorganic strong acids are extremely efficient at leaching metals,\nthe standards are high for certain factors, such as the operating\nenvironment and equipment and the treatment of wastewater and\n\nFig 8 Schematic diagram of the hydrometallurgical leaching mechanism\n\n@13@\nZ. Liu et al. / Green Energy & Environment 9 (2024) 802–830 815\n\nFig. 9. (a) Mechanism of liquid�liquid extraction. Reprinted with permission from Ref. [160]. Copyright (2019) Elsevier. (b) Extraction mechanism of Li.\nReprinted with permission from Ref. [161]. Copyright (2020) Elsevier.\n\nacidic fumes. In addition to commonly used inorganic strong\nacids, hydrofluoric acid can be used to leach Li and Co from LCO\ndue to its self-coupling ionization at high concentrations [128].\nThe leaching solution is precipitated using NaOH to adjust the\npH, and it is roasted and concentrated through evaporation to\nremove F ions as CaF2, resulting in a final recovery of 98% Li and\n80% Co. However, the low acidity and nonoxidizing, nonreducing acidic nature of HF at low concentrations reduces the\nleaching rate. In recent studies, the use of chemically stable\nH3PO4 leaching systems has been replaced to solve the problem\nof volatile acidic fumes. Notably, the NCM cathode material was\nroasted at 60 [�]C for 60 min with a H3PO4 concentration of\n2 mol L[�][1], a solid-to-liquid ratio (L/S) of 20 mL g[�][1] and a H2O2\nvolume fraction of 4%. The leaching rates of Li, Ni, Mn and Co\nall exceeded 96%, and in some cases, Li could even be\ncompletely recovered. Moreover, researchers have added metal\nions to the leaching solvent and obtained a recyclable phosphoric\nacid solution after coprecipitation. More importantly, the influence strengths of the leaching factors for the phosphoric acid\nsystem are as follows: reducing agent > phosphoric acid\nconcentration > time z solid–liquid ratio > leaching temperature. Many scholars have compared the leaching effects of\nvarious types of strong acids, and the HCl system is the best\nleaching agent in terms of cost [129–131]. The systems for LCO,\nLFP, and LMO leaching differ in the temperature, nitric acid\nconcentration, and reductant dosage. Therefore, further research\n\nTable 4\nCommon extraction methods of valuable metals.\n\nis needed on the composition of spent LIBs and the application of\nrecovery systems.\nGreen organic acids: The trivalent waste of the inorganic\nacid leaching system damages the environment and is contrary\nto today's green recycling concept of sustainability. To solve\nthis problem, Li's group first proposed the use of organic acids\nto recycle spent LIBs by exploiting their eco-friendliness and\ndegradability; they comprehensively investigated the leaching\nmechanisms and acid dissociation constants (pKa) of organic\nacid functional groups [132]. This system shifts the focus of\nfuture hydrometallurgical recovery research to green organic\nacid leaching to increase the leaching efficiency and reduce\nthe contamination level. By using the chelating function between the functional groups of organic acids and metal elements and the reducing properties of certain specific groups,\nthe main organic acids with chelating functions are citric acid\n\n[132], aspartic acid [133], succinic acid [134] and malic acid\n\n[135]. Reducing functional organic acids can be used with less\nof the reducing agent H2O2; ascorbic acid [136] and lactic acid\n\n[137] are commonly used. Except aspartic acid, each chelated\nfunctional organic acid can leach over 90% of Li and Co for\nspent LCO recovery. Oxalic acid [138] and carrot acid [139],\nwhich provide precipitation functions, can be recovered by a\nmechanism that uses organic groups to leach valuable metals\nto produce precipitates. The leaching properties follow the\norder of succinic acid > citric acid > ascorbic acid > malic\n\nElement Extraction solvent Extractant Stripping agent\n\nLi High magnesium lithium brine 60% TBP-40% 200[#] Kerosene HCl\nNi Ni-containing sulfate solution Carbonate 10 acid þ LIX84-I H2SO4\nSulfate solution of Ni and Co P507 H2SO4\nSulfate solution of Ni and Co Cyanex27 þ 5%TOA2, O/A ¼ 1.3 (pH 6.8e7.1) H2SO4\nAmmoniacal solution LIX64N, LIX84, LIX84I, SME-529, LIX87QN, LIX973N, ACORGA M5640 e\nCo Leaching solution of Li[þ], Co[2][þ] and Mn[2][þ] Cyanex272 þ PC-88A H2SO4\nLeaching solution of Li[þ] and Co[2][þ] PC-88A þ Kerosene H2SO4\nSulfate solution of Ni and Co P507 H2SO4\nSulfate solution of Ni and Co Cyanex272 (pH 6.3e6.5) H2SO4\nMn Leaching solution of Li[þ], Co[2][þ] and Mn[2][þ] Cyanex272 þ PC-88A þ EDTA H2SO4\n\n@14@\n816 Z. Liu et al. / Green Energy & Environment 9 (2024) 802–830\n\nacid > aspartic acid. Citric acid exhibits excellent leaching\nproperties and is inexpensive and readily available.\nOther functional organic acids: Natural biomass rich in\nacids is extracted from acidic solutions for leaching, such as\nrich complexing agents in citrus juice and apple juice. When\nrecovering NCM, certain counter ions, such as Na[þ], Mg[2][þ],\nand Ca[2][þ], can indirectly improve the leaching efficiency of\norganic acids. The recovery rates of valuable metals can\nexceed 94%. Acid leaching methods for maleic acid [140],\nacetic acid [140], tartaric acid [141], trichloroacetic acid\n\n[142], benzenesulfonic acid [143], formic acid [144], iminodiacetic acid [145], etc., have gradually entered the research\nfield of spent LIBs recycling.\nHowever, Li recovery could be guaranteed to exceed 90%,\nwhile the leaching rates of all other metals are low. From the\nperspective of recycling, organic acids are more environmentally friendly. However, the leaching process has high requirements on the acid concentration and dosage, inhibiting\nthe industrial application of organic acidic wastewater that\ncannot be quickly degraded, while the ecological species\nbalance cannot be maintained, which remain real challenges\nfor large-scale applications.\nSelective extraction and chemical precipitation: After the\ncathodically active material of spent LIBs is acid leached by\ninorganic/organic acids, valuable metals are enriched in the\nleaching solution. Afterward, the separation, recovery, purification, dehybridization and preparation of precursors are\nrealized to finally achieve valuable metal recovery of spent\nLIBs. The leaching solution contains Ni, Co, Mn, Li and other\nvaluable metal ions and Fe, Al, Cu and other impurity metal\nions. To date, the methods for separating various valuable\nmetals are selective extraction, precipitation, ion exchange and\nelectrolytic deposition.\nSelective extraction: Spent LIBs are extracted mainly in\nliquid–liquid form, consisting of organic extractants and\nreactive reagents (Fig. 9a), which can selectively extract specific metal ions from the aqueous phase to the organic phase to\nachieve selective separation. The frequently used selective\nextraction techniques are listed in Table 4.\nThe extraction effect is closely related to the reaction kinetic condition parameters. Ref. [146] used 3% isodecanol as a\nphase modifier in Cyanex272 and kerosene system extraction\nand obtained 99.99% cobalt in vapor extraction relative to\n95% in the extract. More importantly, an extremely acidic\nenvironment does not indicate an extremely high extraction\nrate. In PC-88A and kerosene extraction systems, the separation of Co and Ni cations becomes increasingly effective with\nincreasing pH; however, the separation effectiveness decreases\nwith decreasing pH [147]. In particular, according to the\nrelevant mechanism of metal extraction technology (Fig. 9b),\nnickel and cobalt are located very close to each other in the\nperiodic table, and both are transition elements. Separating\nthese metals is extremely challenging and requires high costs.\nHowever, the expensive extractant and harsh extraction operating environment reduce the profits of recycling spent LIBs.\nTherefore, it is possible to combine multiple extraction systems and separation techniques to obtain inexpensive\n\nextraction materials for reducing the recovery cost and\nimproving the purification and separation rates.\nChemical precipitation: The properties of valuable metal\ncations in the leaching solution, which can yield insoluble\ncompounds in various anionic precipitators, can be utilized to\nseparate, purify and remove impurities from the substances in\nthe leaching solution. The method for separating and recovering valuable metals from the leaching solution of cathode\nmaterials is usually determined based on the differences in the\noccurrence states of metal elements in different pH environments. In the recovery process, Li usually exists in the form of\nLi2CO3 [148], LiF [149], and Li3PO4 [150,151]. Specific elements, such as Ni, Co, and Mn, with similar chemical\nproperties commonly occur in the form of CO23�, C2O24�,\nPO34�, OH�, and organic chelates [145,150–156]. Fe, Al and\nCu, as impurities, can be removed as Fe(OH)3 [157],\nFe2(C2O4)3 [158], AlF3 [158], Al(OH)3 [158] and Cu(OH)2\n\n[159], and purification is finally achieved. By adjusting the pH,\ntemperature, ratio of the oil phase to the water phase and other\nparameters in the chemical precipitation process, high-efficiency and inexpensive separation and purification can be\nachieved. However, the heterogeneous nucleation phenomenon during metal ion precipitation reduces the product purity.\nClean and efficient combined purification: Scholars have\napplied various precipitation and extraction methods sequentially to increase the product purity. Chen et al. [162] balanced\nthe pH to 6 in the leaching solution of recovered waste NCM.\nAfter adding dimethylglyoxime reagent, approximately 98%\nof Ni could be selectively precipitated, and Co[2][þ] was\nprecipitated as CoC2O4 when the temperature was adjusted to\n55 [�]C. Therefore, it is very important to combine multiple\nrecovery methods for purification and separation.\nElectrochemical deposition: After an electric field is applied\nto the leaching solution, the valuable metals in the solvent can\nbe deposited on the electrode through redox reactions and then\npurified and recovered. Strauss et al. [163] used the electrochemical deposition method to obtain Ni and Co, adjusted the\npH value of the leaching solution, used Dowex M4195 resin as\nthe extraction agent, and finally extracted 99.0% nickel, 98.5%\ncobalt and Li/Mn-rich products. However, the application of an\nelectric field does not significantly improve the recovery relative to the use of a chemical reducing agent for final metal\nrecovery [164]. Moreover, reuse of the leaching solution and the\nresidual electricity of the spent LIBs prevent the single consumption of chemical reagents and secondary waste liquid\n\n[165].\nLiquid membrane separation: Similar to chemical precipitation, the separation of impurity metals mainly depends on\nthe different dissolution and diffusion capabilities of electrode\nmaterials in liquid membrane media. Hoshino et al. [166]\ndeveloped a new method for lithium recovery by electrodialysis using PP13-TFSI ionic liquid, effectively filtering impurity ions, such as Na, Mg, Ca and K; Li[þ] was selectively\nconcentrated on the cathode side. Yuliusman et al. [167] set\nthe ratio between the emulsion film and electrode waste to 1:2,\nand the temperature was 80 [�]C. Cyanex 272 and SPAN 80\nextractants were selected to carry metal elements through the\n\n@15@\nZ. Liu et al. / Green Energy & Environment 9 (2024) 802–830 817\n\nliquid film, and approximately 83% Co was recovered by\nleaching.\nThe combined method provides a new idea for selective\nseparation and purification. However, the complex technical\nrequirements make it difficult to apply in large-scale recycling\nof spent LIBs electrode materials. We suggest defining the\nfuture research goal of purification and separation as the use of\nresidual electricity to perform electrochemical deposition of\nthe leachate to achieve green and low-consumption separation\nand purification. Based on traditional mineral metallurgy\ntechnology, we established a complete recycling framework\nsystem, determined an innovative recycling technology system\nthat could be applied to large-scale production and realized the\nmost economical and environmentally friendly regeneration of\nelectrode materials.\nMicrobiological green leaching: Valuable metals in spent\nLIBs can be extracted by using the metabolic functions of\nmicroorganisms or the actions of their metabolites. The types\nof leaching microorganisms can be divided into autotrophic\nand heterotrophic bacteria. Autotrophic bacteria include\nsulfur- and Fe-oxidizing bacteria, and heterotrophic bacteria\ninclude Aspergillus and Penicillium [168–174]. The most\ncommon method involves the use of Acidithiobacillus ferrooxidans and Leptospirillum ferriphilum, S and Fe[2][þ] as\nenergy products, biological oxidation of acidophilus and a\nlow-pH environment to recover spent LIBs. Naseri et al.\n\n[175] used a single A. ferrooxidans solution with a pulp\ndensity of 10% and a warm environment with pH 2 in\n¼\n\nnutrient media of 9 K and FeSO4,7H2O; Li was completely\nleached to obtain 88% Co. Based on the research results of\nRoy et al. Ref. [176] used the nutrient media of modified 9 K\nand FeSO4,7H2O to increase the solid‒liquid ratio to\n150 g L[�][1]; then, more than 90% of Co, Ni and Mn and 80%\nof Li and Co could be leached and recovered. The mechanism of bioleaching is closely related to redox and\ncomplexation reactions. Autotrophic bacterial leaching occurs as follows: a) relying on the change in the valence state\nof Fe and S, leaching directly occurs after contacting valuable metals, and b) biological bacteria play a catalytic role in\naccelerating the redox rate of Fe and S but do not directly\nreact with the metals in the spent LIBs leaching solution\n\n[177–179]. Improving the efficiency of bioleaching electrode\nmaterials requires special attention to the optimization of the\nmetabolic rate of microorganisms. Therefore, environmental\nparameters for leaching include the conventional experimental parameters of hydrometallurgy and are closely related\nto various factors, including biological nutrients [180], carbon and oxygen supply levels [181], biopotential [182], and\nmetal tolerance [180]. However, the toxic metal waste liquid,\nlow leaching power, and inability to use large-scale enterprises have become the greatest threats restricting the growth\nand metabolic characteristics of biological cells. Researchers\nhave continued to explore the adaptive conditions of biological flora, including reducing the concentration when\nrecycling spent LIBs and increasing the pulp density\n\n[183,184]. Leaching kinetics can be upgraded as follows: (a)\n\nFig. 10. (a) Leaching mechanism of LCO. Reprinted with permission from Ref. [185]. Copyright (2019) Elsevier. (b) Fungal metal–mineral transformation process.\nReprinted with permission from Ref. [186]. Copyright (2017) John Wiley and Sons. (c) Metabolic pathway for the production of oxalic acid, malic acid, gluconic\nacid and citric acid in A. niger. Reprinted with permission from Ref. [188]. Copyright (2017) Elsevier. (d) Four potential targets of engineering pathways for\nbiomining microorganisms Reprinted with permission from Ref [189] Copyright (2018) MDPI\n\n@16@\n818 Z. Liu et al. / Green Energy & Environment 9 (2024) 802–830\n\nFig. 11. (a) SEM images of electrolytes after no treatment, heat treatment, subcritical CO2 treatment and supercritical CO2 treatment. Reprinted with permission\nfrom Ref. [201]. Copyright (2016) John Wiley and sons. (b) Catalyst applications. Reprinted with permission from Ref. [204]. Copyright (2021) Elsevier. (c) Model\nfor the preparation of graphene oxide from waste graphite. Reprinted with permission from Ref. [207]. Copyright (2021) Elsevier. (d) Structural models of acidleached graphite (AG) and residual graphite (RG) with different interlayer spacings. Reprinted with permission from Ref. [203]. Copyright (2020) Elsevier. (e)\nSchematic illustration of the regeneration process of graphite from spent LIBs. Reprinted with permission from Ref. [200]. Copyright (2022) Elsevier. (f) Flowchart\nof the preparation of polymer–graphite nanocomposite thin films. Reprinted with permission from Ref. [208]. Copyright (2015) Elsevier.\n\nadditives: the use of inorganic low-valence metal salt additives or combined leaching with multiple flora (Fig. 10a)\n\n[185]; (b) assisted leaching: ultrasonication can assist\nleaching, enhancing the penetration of biota into the solid\nphase material (Fig. 10b) [186]; and (c) genetic modification:\naltering the metabolic pathways and tolerance levels of\nbacteria [187], which is a modification technique similar to\nthat used to improve conventional hydrometallurgical recovery (Fig. 10c) [188]. However, there is a lack of research\non the recovery of spent anode materials, electrolytes and\ncollectors, and further research is needed on the tolerance of\nbiological cells and efficient metabolic generation. The\nconcept of low-consumption, end-of-pipe low-carbon treatment should be vigorously developed for large-scale corporate applications (Fig. 10d) [189].\nAlkali leaching: Alkaline solvents provide a more limited\nrange of dissolved metals than acidic solvents, and they are\nlimited by the mechanisms of metal and alkali leaching of\ncathode materials. When selecting leaching solutions, only\namino solutions with metal coordination synthesis of stable\nmetal complexes can be chosen for selective leaching with\nelectrochemical and extraction methods for efficient recovery. Ammonia leaching exhibits the advantages of\n\nselective metal recovery, reuse and simple recovery. As a\nresult, alkaline leaching is increasingly recognized as a\nmore cost-effective technology than acidic leaching, which\nagrees with the strategic objective of sustainable energy\ndevelopment. Leaching solvents are often amino-alkaline\nsolutions, including acid ammonium salts formed with\nSO24� [190], CO23� [191], Cl� [192], HCO�3 [193], SO23�\n\n[194] and NH3$H2O [195]. In particular, the ammonia\nleaching process depends on mixing the solution with the\npretreated electrolyte to obtain fluorine and lithium salts in\na stable state. These two salts are rendered harmless according to the composition of the electrolyte, and they can\nbe recycled and reused. However, the trends of ion migration, enrichment and evolution of metallic elements in hydrometallurgical leaching systems remain unclear, and the\nrelevant mechanisms have not been studied in depth. In\naddition, from the perspective of the reaction mechanism of\nthe alkali leaching process, the molecular dynamics of the\nleaching process are poor, and the leaching system is\nincomplete, which is the main bottleneck limiting the recovery rate of alkali leaching. In addition to optimizing the\nalkali leaching conditions, the degree of difficulty of the\nleaching conditions should be significantly reduced at the\n\n@17@\nZ. Liu et al. / Green Energy & Environment 9 (2024) 802–830 819\n\npretreatment stage through pretreatment techniques, such as\nmechanical activation.\nOther methods: Activation leaching is an effective method\nfor increasing the activity of positive materials by reducing the\napparent activation energy of the reaction through physical\nand chemical activation, with the advantages of acoustic waveassisted treatment. For example, EDTA-2Na can be activated\nby ball milling with LFP prior to acid leaching, and the\nvaluable metals in LFP can be leached out with H2SO4 [196].\nDirect oxidative leaching, involving the use of E–pH diagrams\nto obtain the optimum high temperature and low redox potential for hydrothermal synthesis of LFP, enables short-range\nefficient recovery of positive materials by directly increasing\nthe oxidation potential. The high solubility of deep eutectic\nsolvents (DESs), similar to that of ionic liquids, can be used\nfor recovery purposes. Through economic and technical analyses, DESs represent a green, inexpensive and efficient\nleaching method. However, various drawbacks, such as hightemperature experimental conditions, low recovery rates,\ncomplex purification processes and high prices, have greatly\nlimited the large-scale application of this solvent [197,198].\nWe must select the optimal acid leaching process conditions,\nconstruct an acid leaching kinetic model, establish the control\nsteps for efficient leaching, explore pathways and related\nmechanisms for selective and efficient migration and transformation of metals in short-range leaching of organic acids,\nand solve key problems, such as long processes, high chemical\nreagent consumption and damage to equipment, during traditional hydrometallurgical leaching of valuable metals.\n\n3.3.2.2. Anodes. As the first commercial anode material for\nLIBs, graphite provides the advantages of high capacity, stable\nstructure and favorable electrical conductivity. Depending on\nthe type of LIBs and the range of applications, the graphite\ncontent is more than 11 times that of metallic Li, and the\npercentage of graphite in EVs is even higher. The SEIs formed\nby the charging and discharging of LIBs and the unembedded\nLi[þ] in the graphite layer are the main sources of Li in the\nanode material.\nThere are two main recovery techniques to date. One is the\nuse of pyro- or hydrometallurgical recovery to remove trace\nmetal impurities from graphite when recovering the anode\ncurrent collector. The other is selective extraction of lithium\nsalts using thermal evaporation and supercritical CO2 extraction methods. Ref. [199] adjusted the microwave calcination\ntemperature and time to obtain an initial Coulombic efficiency\nof 83.4% at 0.1 [�]C and a charge specific capacity of 354.1\nmAh g[�][1]. The capacity retention rate increased to 98.3% after\n60 cycles, recovering graphite with excellent electrochemical\nproperties. Chen et al. [200] used cobalt salts to catalyze the\nanode material after pretreatment with H2SO4 to obtain highpurity graphite and to recover the metal Co as a salt to close\nthe loop (Fig. 11e).\n\nResidual electrolyte in the anode material is a major factor\nlimiting the reuse of graphite and its recovery on an industrial\nscale. Expensive extraction techniques and energy-intensive\npyrolysis methods have disappeared from the recovery field.\nRothermel et al. [201] employed several methods for eliminating electrolyte from the anode material and concluded that\nthe subcritical CO2 extraction method performed the best,\nrecovering more than 90% of the electrolyte with conductive\nsalts. Capacity decay and poor cycling stability of the anode\nmaterial are the main challenges in recycling waste graphite\nand using it for preparing battery cathodes (Fig. 11a) [201].\nRecent studies have shown that adjusting the electrochemical\nactivity of waste graphite and reconstituting graphite layers\ncan solve these problems. However, the technical difficulties\nare too high to widely use waste graphite in industrial-scale\nproduction (Fig. 11d) [202,203]. However, this line of thinking\nprovides an important reference value for future applications\nand recycling prospects for similar Na- and K-based alkaline\nbatteries. In addition to regenerating negative electrode materials, peroxymonosulfate-activated catalysts can be obtained\nby recycling waste graphite (Fig. 11b) [204], improving the\nHummers method to achieve high-purity graphene (Fig. 11c)\n\n[205–207], and mixing graphite with diaphragms to acquire\nhigh-tensile-strength polymer–graphite composite films\n(Fig. 11f) [208]. The dismantled graphite negative electrodes\nof spent LIBs can be recycled by the existing recycling process\nto achieve near-zero waste emission resource comprehensive\nuse, reduce a large amount of solid waste, increase the value of\nthe recycling process and meet regional environmental emission standards. However, contradictions still exist between\nefficient recovery of all components, recycling treatment and\nminimization of environmental hazards. Future work should\nfocus on the development of efficient recycling technologies\nfor waste anode materials.\nThe complexity of battery electrode components is a great\nchallenge for hydrometallurgical leaching technology. Most\nproducts after industrial large-scale disassembly and separation are black powders with an ambiguous composition. The\ncoupling of multiple material components with leaching\nagents and reaction parameters should be explored, and dynamic monitoring of composition ratios and intelligent\nparameter regulation techniques should be established to avoid\nsingle leaching parameters that reduce the recovery rate.\n\n3.3.2.3. Electrolyte. In addition to the cathode material, the\nelectrolyte is an abundant source of lithium. However, lithium\nis mostly present in the electrolyte as a salt with toxic properties. Furthermore, the multiple processes involved in thermal\ntreatment to recover spent LIBs can drastically degrade the\ncomposition of the electrolyte. Therefore, the adsorption of\nporous electrodes, complex composition and low safety are\nmore notable challenges for electrolyte recovery technology\nthan the high recycling cost of other battery components.\n\n@18@\n820 Z. Liu et al. / Green Energy & Environment 9 (2024) 802–830\n\nToday, progress has been made in the laboratory and in smallscale industrial production.\nUsually, physical and chemical methods are adopted for\nrecovering electrolytes, with physical methods including\ncryogenic and mechanical methods. The Atomic Energy Authority (AEA) in the UK used cryogenic crushing of spent\nLIBs and then extracted the electrolyte with acetonitrile to\nrecover PVDF, Cu, Al, PE and CoO2 using other process\ntechnologies [209]. He et al. [210] dissolved cores with a\ncustom prepared exfoliating extractant instead of an organic\nsolvent and then used a mechanical method to obtain the\nelectrolyte and electrode material using a high-speed rotating\ndevice that dissolved the anode binder for separating graphite\nand copper foil. Toxic LiPF6 can be precipitated from\nethylene carbonate and propylene carbonate. The physical\nmethod is used on a large scale in industry and exhibits the\nadvantages of a simple process that is environmentally\nfriendly and easy to control. However, certain disadvantages,\nsuch as low separation after electrolyte freezing, high energy\nconsumption, easy decomposition of LiPF6 and low purity,\nhave become the main economic considerations for companies\nwhen recycling.\nChemical methods can be divided into vacuum pyrolysis\nand extraction methods. Vacuum pyrolysis is a similar process\nto pyrometallurgy, where the electrolyte and organic binder\nare pyrolyzed into fluorocarbon organic compounds at high\ntemperatures. The products are evaporated and condensed for\nrecovery after adjusting the vacuum pressure, effectively\navoiding equipment corrosion and toxic fluoride hazards, and\nthe cathodic active material on the collector is completely\nstripped during organic binder removal. However, the disadvantages of high-temperature pyrometallurgy are inevitable.\nExtraction methods include supercritical methods. The\nrelationship between the thermodynamic parameters of supercritical CO2 (temperature and pressure) and the solubility\ncapacity has been exploited by adjusting the thermodynamic\nparameters to dissolve the electrolyte. Spectroscopic and\nchromatographic techniques were used to determine that the\nelectrolyte degradation aging products are esters (diethyl\ncarbonate (DEC), dimethyl-2,5-dioxhexane dicarboxylic acid\nester (DMDOHC), methyl-2,5-dioxhexane dicarboxylic acid\nethyl ester (EMDOHC), and diethyl-2,5-dioxhexane dicarboxylic acid ester (DEDOHC)). An ester-based organic solvent was used to soak the cores, and the electrolyte was\ndissolved in an organic solvent to separate the electrolyte and\ncore. At the early stages of the process, a supercritical helium\npressure head CO2 was used to extract electrolytes from spent\nLIBs. However, the recovery of trace amounts of LiPF6 was\nonly possible with a high cost and low recovery rate.\nTherefore, to overcome these drawbacks, researchers have\nchanged CO2 to a supercritical state or a liquid state, where\nlinear and chain carbonates are extracted in these two states,\nrespectively. Furthermore, since CO2 is an inorganic solvent,\nLiPF6 can be effectively extracted from electrolytes by\nincreasing the solubility of nonpolar solvents relative to polar\nsolvents by adding corresponding inexpensive modifiers,\n\nentraining agents (liquid alcohols) and cointegrating agents\n(small gaseous alkanes). For instance, in the liquid CO2\nextraction method, the entrainer ACN:PC ratio can be adjusted\nto 3:1 to obtain 89.1 ± 3.4 wt.% [211]. However, the experimental results showed that the type of diaphragm affects the\nextraction efficiency, with the differences between the\nextraction efficiencies of the PE diaphragm and the porous\nglass fiber adsorbed electrolyte reaching nearly double, with\nvalues of 73.5 ± 3.6 wt.% and 36.7 ± 1.6 wt.%, respectively.\nThe effect of supercritical extraction is mainly influenced by\ntemperature, pressure, time and entrainment agent. In terms of\nthe supercritical CO2 extraction mechanism, external factors\nmainly affect the extraction behavior by increasing the polarity\nof CO2.\nToday, closed-loop electrolyte recycling remains challenging. Liu et al. [212] developed a technique involving the\nrecovery of supercritical CO2 from the electrolyte and obtained an electrical conductivity of 0.19 mS cm[�][1] and an\ninitial discharge capacity of 115 mAh g[�][1] after a series of\nprocesses, including extraction, molecular sieve dehydration\nand component replenishment. The electrolyte obtained from\nrecycling was reloaded into the battery, and the electrochemical properties were measured to ensure that the standards for normal use were met. Although the recovery of\nelectrolytes that meet the standards for battery use provides a\nnew idea for closed-loop efficient recycling of spent LIBs, it is\ndifficult to establish a complete recycling system due to the\ncomplexity of both the recycling process and electrolyte\ncomposition. Furthermore, certain factors, such as electrolyte\npurity, removal of hexafluorophosphate impurities, LiPF6 purification, economics of recovery, and unrecovered CO2, after\nuse remain major challenges for a closed-loop recovery\nstrategy.\n\n3.3.3. Future recycling strategies for spent LIBs\nTechnological advances have accelerated the rate of change\nof high-energy density power battery types, and the need to\nexplore new recycling technologies for retired batteries is\nimminent. There is a need to improve the safety and cycle\ntime, the relationship between clean and efficient recycling\ntechnologies and the quality and cost of reinstalled batteries to\nmeet the EoL of spent LIBs.\nModern recycling technology for spent LIBs can be suitably applied to nickel- and lead-acid batteries. In the future,\nrecycling technology should focus on the close link with the\nway batteries are manufactured and designed. Future recycling\ntechnology for spent LIBs can adopt a similar basic approach\nto modern technology. Cooperation is needed between recycling companies and battery manufacturers to develop relevant\npolicies and industry standards, unified classification codes,\nvarious battery structures, and full life-cycle traceability\nmanagement of the different components of batteries. For\nexample, the University of Grasse has developed new recyclable 3D printed batteries in which biodegradable polylactic\nacid (PLA) materials are applied, further enabling green\nrecycling.\n\n@19@\nZ. Liu et al. / Green Energy & Environment 9 (2024) 802–830 821\n\nThe recycling of LIBs requires full-component recycling of\nthe key energy metals, such as Li, Ni, Co and Mn, in short\nprocesses. Nickel-based power batteries require special\nattention to the recycling of scarce element Ni, while lead-acid\nbatteries should focus on the high lead content and highquality plastic casings. The metal components of the three\ntypes of spent batteries can be recovered using clean and\nefficient hydrometallurgical techniques, the electrolyte can be\nextracted using supercritical CO2, and graphite-based materials can be applied to adsorb heavy metals from wastewater.\nThe organic material components of batteries should be\nreplaced by environmentally friendly and easily degradable\norganic materials, and the electrolytes should comprise safe\nand stable solid phase materials.\n\n4. LCA prediction and recycling optimization\n\nThe main mainstream LCA software options available\ntoday are OpenLCA [213], Apeironpro [214], SUSB-LCA\n\n[215], PLCAT [216], Eco-LCA [217] and GPLCD [218],\nwhich enable quantitative evaluation studies of waste treatment, production, transport, energy consumption, and social\nand environmental relationships. LCA methods for recycling\nspent LIBs mostly consider energy consumption and GHG\nemissions for assessment, including vehicle dismantling,\nvehicle recycling, battery recycling, and tire seat recycling. In\nparticular, battery recycling is a major contributor to GHG\nemission reduction. However, the negative impacts of energy\nconsumption and three-waste discharge associated with multitype recycling technologies, whether the output is higher\nthan the input, whether the environmental protection is greater\nthan the environmental damage, and the relationship between\nprofit and the environment must still be revealed via LCA.\nIn general, the recycling of spent batteries can mitigate\nmost environmental impact categories. Lin et al. [219] used\nLCA to compare three systems of organic water leaching,\ninorganic acid (HNO3) leaching and organic acid (citric acid)\nleaching. Citric acid and organic water require lower activation energy and GWP levels when recycling spent LIBs,\nsignificantly reducing environmental pollution; the leaching\nrate of the inorganic acid system is similar. Sun et al. [220]\nemployed biological strain hydrometallurgy technology to\nrecycle EoL waste Zn–Mn batteries. The only environmental\nimpacts are human and marine ecotoxicity. Relative to traditional hydrometallurgy and pyrometallurgy technologies, the\noverall environmental impacts can be significantly reduced.\nAfter optimizing the pretreatment stage, higher environmental\ngains could be achieved. Rinne et al. [221] compared the\nimpacts of spent LIBs on the environmental footprint in the\nrecycling process. The use of waste as a reducing agent could\nfurther reduce the chemical consumption of hydrometallurgical recycling, and H2O2 was deemed unsuitable as a reducing\nagent for large-scale recycling. Kallisis et al. [222] demonstrated that the recycling of Ni, Co, and Mn cathode materials\nis the main contributor to environmental safety, with the\nburden of over 85% of all environmental impact categories\nreduced to 35% Al Cu and Fe are the main burden in the\n\nrecycling chain, leading to toxicity (Fig. 12a). Raugei et al.\n\n[223] calculated that 23 MJ kg[�][1] of primary energy is lost per\nkWh of battery energy recovered and that the reduction in CO2\nemissions is reflected in the recovery of graphite and valuable\nmetals (Fig. 12b). Planning a green recycling chain and\nreducing emissions, energy consumption and use of raw materials through LCA at the EoL stage could effectively offset\nthe environmental impact of the production chain. Today, LFP\nbatteries still account for the majority of retired batteries, and\nby using LCA, the recycling of 50% of the LFP batteries could\nfully offset the environmental impact, and 100% recycling\ncould yield energy savings of 313.02 kg CO2-eq and 270.89 kg\noil-eq of GHG emissions (Fig. 12c) [224]. The EoL phase of\nrecycling has a more prominent contribution to compensating\nfor environmental change, mitigating the high demand for\nfossil energy and biological health. However, transport, recycling technology, three-waste impurities and complex battery\ncomposition pose several negative challenges to the recycling\nof spent LIBs. Recycling strategies are an important part of\nGHG emission and disease risk reduction, and losses from\nrecycled metals could be significantly reduced but not\ncompletely offset [225].\nAmong the various recovery technologies, from an LCA\nperspective, hydrometallurgical recovery and organic acid\nrecovery are the most effective means to reduce environmental\nburdens to date. However, it is still necessary to reduce the\nenergy consumption of leaching at the pretreatment stage to\nimprove the environmental benefits. Comparing the full lifecycle carbon emissions of NCM, LMO, and LFP battery packs\nprovides advantages, but it is difficult to determine which\nbattery is more advantageous [226–228]. Therefore, reducing\nthe overall emissions must be approached from the perspective\nof recycling cathodes while enhancing the cleanliness of the\npower mix. Upgrading battery preparation technology and\ndeveloping renewable and clean energy sources could improve\nthe overall environmental benefits of vehicles, reducing\nemissions and energy consumption [229]. Furthermore, recycling cathode waste could greatly contribute to protecting the\nenvironment, mitigating toxic organic solvents, mining rare\nminerals, resolving heavy metal pollution, and promoting\nsustainability. In addition, data on the impacts of transporting\nbattery materials obtained through custom specialized recycling of spent LIBs, such as trucks and trains, is a valuable\nmethod for establishing cascading use and recycling networks.\nFuture of sustainable battery LCAs: Considering the impact\non environmental benefits, clean fuels, such as hydrogen fuel\nand compressed natural gas, have recently become the main\ntrend in batteries. Clean fuel cells produce zero emissions.\nHowever, hydrogen-fueled electric vehicles (FCVs) are\naccompanied by pollutant emissions during manufacturing,\nhydrogen energy acquisition, storage and transportation from\nan LCA perspective, with carbon emissions ranging from 36 to\n112 kg CO2 eq-kW[�][1] (Fig. 12d) [230–232]. Improving the\nfuel cell performance and reducing the loss of key components\nthroughout the process contributes to the life-cycle carbon\nemissions of the fuel cell system and is an important method\nfor improving the environmental benefits of FCVs\n\n@20@\n822 Z. Liu et al. / Green Energy & Environment 9 (2024) 802–830\n\nFig. 12. (a) Comparison of EoL recovery production in metallurgical cases in China, North America and Europe. Reprinted with permission from Ref. [222].\nCopyright (2022) Elsevier. (b) Cumulative GHG emissions of the 1 kWh battery capacity class, no scrap (EoL), and single contributions of major components\n(BMS ¼ battery management system). Reprinted with permission from Ref. [223]. Copyright (2019) Elsevier. (c) Influence of the different components on the net\nlife cycle of LFP batteries. Reprinted with permission from Ref. [224]. Copyright (2022) Elsevier. (d) Characterization results of the storage capacity of 1 kWh\nNIBs and the contribution of components to the overall impact. Reprinted with permission from Ref. [222]. Copyright (2022) Elsevier.\n\nIn response to geographical climate differences, the share\nof hybrid vehicles in the market is gradually expanding. According to a comparison of vehicle LCA results encompassing\ndifferent power sources, renewable fuels have a greater potential to reduce life-cycle carbon emissions than low-carbon\nelectricity portfolios. By considering advances in vehicle\npowertrain technology and changes in electricity and fuel\nsupplies, PHEVs and HEVs produce lower life-cycle carbon\nemissions than internal combustion engine vehicles (ICEVs)\nand significantly higher emissions than BEVs. However, the\nfine particulate matter (PM2.5) and SO2 emissions of ICEVs\nare low, and vehicle infrastructure can be identified as a major\nsource of the environmental burden.\nWith the rapid growth in the lithium market, transition\nmetal minerals are consumed in large quantities, and various\nlow-carbon alternative batteries have been developed. For\nexample, NIBs, Li–S batteries, and Li-air batteries have high\nenergy densities [233–235]. Future LCA research should focus\non the recycling of these types of batteries. This research has\nconsiderably contributed to promoting the long-term sustainable development of these batteries, improving performance,\nreducing the use of nonrenewable energy, and decreasing the\nimpact of environmental climate change. There are few studies\non the potential impacts of potassium-ion batteries, aluminumion batteries, magnesium-ion batteries, and sodium-ion batteries on the environment. According to their composition and\n\nLIBs similarity characteristics, the heavy metal content in the\ncathode can be reduced, and most of the compositions and\nstructures of batteries can be made compatible with the environment to the highest degree. Energy efficiency and reducing\nthe loss of key equipment are key goals for reducing EoL\nenvironmental burdens.\nTherefore, the recycling of used batteries should be regarded\nas an important part of battery LCA, especially because the\npositive impact of recycling on battery production cannot be\nignored. Moreover, the environmental impacts of the best options for recycling various types of batteries should be\ncompared. Finally, the impact of each link on the recycling of\nspent LIBs should be analyzed in detail. Based on a comparison\nof multiple evaluation studies, an LCA dynamic model of spent\nLIBs recycling was formulated, and the undiscovered forwardlooking problems were dynamically evaluated. Further warning\nof the environmental impacts of various recycling technologies\ncould provide theoretical support for large-scale recycling\ntechnology routes and policy decisions for enterprises. In terms\nof recycling technology, certain variables, such as heating\nconditions and acid–base concentrations and types, could be\ncoupled to obtain the optimal leaching method. The recycled\ncomponents and products in the recycling chain should be\nincreased to increase recycling profits.\nCountries worldwide have focused on the management of\nrecycling large quantities of EoL LIBs. To achieve the\n\n@21@\nZ. Liu et al. / Green Energy & Environment 9 (2024) 802–830 823\n\ndevelopment of a circular economy and green transition, major\nbattery-consuming countries, such as the USA, China, Japan\nand the EU, have adopted systems and regulations based on\nthe principle of production chain responsibility through subsidies, levies, tax subsidies, incentives–penalties, deposits–\nreturns, etc., thus generating significant social, economic and\nenvironmental benefits.\n\n4.1. Existing proposals\n\nUSA: Optimizer of recycling policies.\nThe implementation schemes include the extended producer responsibility system and the deposit system. Regarding\nthe recycling of used batteries, the International Battery Association has been established, and different battery recycling\nlaws and regulations and deposit, trade-in, mandatory recycling and retailer recycling labeling systems have been\ndeveloped on each continent for the battery supply chain and\nthe consumer side. For example, the government can use\nfinancial incentives to subsidize public EVs; through a policy\nof immediate access to tax rebates, low-income sellers can\nreceive a tax credit of US $2,630, providing additional benefits\nto many customers [236]. To develop a sound system for\nrecycling used batteries, legislation is enacted at the federal,\ncontinental and prefectural levels and regulated by each other.\nAt the federal level, the Resource Conservation and Recycling\nAct, the Act Relating to the Reduction in Lead Exposure, the\nFederal Act on Batteries and the General Waste Management\nAct have been established to legislate the entire life cycle of\nbatteries. Recycling recommendations provided by the International Battery Association are mostly adopted at the continental level, with a deposit mechanism to guide consumers\nand a time mechanism to regulate retailers. Nonprofit public\nservice organizations for battery recycling have emerged in the\nprivate sector, such as the U.S. Rechargeable Battery Recycling Corporation, which has established separate programs\nfor the collection and transportation of renewable batteries in\nthe retail, community, corporate business and public sectors.\nManufacturers have established a uniform code for batteries to\nbe recycled through sales. Retailers are required to pay a deposit of at least $10 for each battery at the time of purchase\nand deliver it to the retailer within a specified time limit.\nOtherwise, the deposit is not returned.\nIn response to geographical climate differences, the share\nof hybrid vehicles in the market is gradually expanding. According to a comparison of vehicle LCA results including\ndifferent power sources, renewable fuels have a greater potential to reduce life-cycle carbon emissions than low-carbon\nelectricity portfolios. Consumers are obliged to provide batteries to the seller, the manufacturer or the Battery Association. All parties are subject to severe penalties if they do not\ncomply with these regulations. This model of recycling in the\nU.S. has successfully addressed the front-end challenges of\nlow efficiency and poor economics of spent LIBs recycling.\nEurope: The world's first advocate of recycling battery\nnorms.\n\nThe approach taken by the EU relies on the Union System's 1988 Production Responsibility Scheme and the\nenactment of the Batteries and Accumulators Containing\nCertain Hazardous Substances Directive issued back in\n1991, which provides the recycling of 3 C batteries and leadacid batteries.\nA series of relevant directives, such as 2006/66/EC and\n2013/56/EU, have been introduced for recycling management\nof all spent LIBs, with a detailed division of battery production, collection and treatment aspects according to the\ndifferent subjects applying batteries. Additionally, the EU requires all collected spent LIBs materials in all member states\nto be recycled at the end of their useful life using a time-lapse\napproach and clean and efficient methods, especially for\ncertain materials, such as valuable metals. Within Europe, the\ntarget Li metal recovery rate should reach 50% by 2027 and\n80% by 2031. Between 2024 and 2028, the EU will use a large\namount of statistical data to build a new regulatory framework\nfor batteries, replacing the 2006 battery directive that has been\nin place ever since [237]. On the economic side, Germany has\nadopted fund and deposit mechanisms to build a recycling\nsystem and has legislated that the battery sales side must take\nresponsibility for recycling used batteries that have been sold,\ndelivering the recovered used batteries centrally to a designated institution. Furthermore, the EU has created a legislative\nframework for sustainable LCA of LIBs, established a common recycling system fund, set up approximately 200,000\nrecycling sites for half of Germany's production of spent LIBs\nand defined minimum thresholds for recycling companies\nregarding the recycling of the various components of used\nbatteries [238].\nChina: Beginners in recycling technology and recycling\npolicy.\nThe development and improvement in battery recycling\npolicies draw on the experiences of developed countries, with\nthe extension of the production chain responsibility as a basic\nprinciple. In 2018, the state, the Ministry of Industry and Information Technology, the Ministry of Environmental Protection, the Ministry of Science and Technology, the Ministry\nof Transport, the Ministry of Commerce, the General\nAdministration of Quality Supervision, Inspection and Quarantine, the Energy Bureau and seven other ministries and\ncommissions jointly issued the Interim Measures for the\nManagement of the Recycling of New Energy Vehicle Power\nBatteries. These measures clearly stipulate that in the recycling process, EV manufacturers bear the main responsibility\nfor power batteries, recycling dismantling and comprehensive\nutilization enterprises, etc., as a way of ensuring the effective\nuse and environmentally friendly disposal of power batteries\n\n[239].\nIn July of the same year, the Ministry of Industry and Information Technology also issued the Interim Provisions on\nthe Management of New Energy Vehicle Power Battery\nRecycling and Traceability, which strictly stipulates the need\nto upload traceability information on recycled battery life\ncycles, time nodes, technical requirements and other clear\n\n@22@\n824 Z. Liu et al. / Green Energy & Environment 9 (2024) 802–830\n\nrequirements for unified coding, information collection and\nmanagement of power batteries [240]. Additionally, to accelerate the recommended standardization work requirements in\nthe field of new energy battery recycling, the Ministry of Industry and Information Technology is establishing a standardization working group for the new energy battery\nrecycling industry to strengthen the relevant standardization\nconstruction team efforts [240]. In April 2020, the Ministry of\nIndustry issued the Management Measures for Gradient Utilization of New Energy Vehicle Power Batteries, with a focus\non the gradient utilization method of waste LIBs and how to\npromote large-scale gradient utilization, which is the focus of\napplication in business models. On the economic side, the\nShanghai government provides a subsidy of RMB1,000 to\nrecycling companies for used EVs when each is sold by the\nselling company. Shenzhen has established a special accrual\nfund to give half of the subsidy funds to recycling companies\nby reviewing the charging standards.\nJapan: A pioneer in advanced recycling technology.\nAffected by various natural disasters, such as earthquakes and\ntsunamis, and the limitations of its land area, Asia's earliest\nconcentrated research and commercialization of hybrid vehicle\nbattery recycling technology occurred in Japan. Moreover, it is\nalsothe firstcountry in Asiatoimplement relevant policiesfor the\nbattery recycling industry and hosts a world-leading battery\nrecycling system [241]. Thewell-defined, sound and step-by-step\ncircular economy legislation system has laid the foundation for\nthe development of a circular economy in Japan. The Japanese\ngovernment has formulated the basic system of a recycling society based on relevant regulations of the battery production\nchain. In addition, in public media and policy documents, enterprises are actively guided to follow the concept of a circular\neconomy and achieve a suitable awareness of voluntary recycling\namong the people [242].\nSix Japanese firms, including Sumitomo Metal Mining, JX\nNippon Mining and Metals, Sumitomo Chemical, Kanto\nDenka Kogyo, Jera and Nissan Motor, are now cooperating to\ndevelop a highly sophisticated recycling technology to recover\nrare metals, mainly from used storage batteries for EVs [243].\nManufacturers have established a battery recycling system of\nbattery production–sales–recycling . Moreover, the consid“ ”\neration of locations where batteries frequently emerge, such as\nbattery sellers, EV sales stores, or large-scale charging service\nfacilities, to establish a spent battery recycling service network\nfully reflects convenience and economy.\n\n4.2. Transformative proposals\n\nWith large quantities of EVs in use, a booming recycling\nindustry, and the continued increase in metal prices, the\nrecycling of spent LIBs can be profitable to the tune of $1000\nor more per ton. Moreover, recycling valuable metals is\nconsidered profitable. Therefore, the government should focus\non considering various LCA data, enhancing the awareness of\nactive recycling among managers across all levels, balancing\nthe interests of all parties, increasing policy support and supervision and establishing mandatory recycling methods for\n\nrecycling processes that threaten human health and the environment. To develop a circular economy and achieve green\ntransformation, governments must fulfil an important role in\nthe supply chain characterized by the recycling of spent LIBs\nfrom a battery life-cycle management perspective. Based on\nthe provided review, a future strategy for closed-loop battery\nrecycling is proposed (Fig. 13):\nConsumers: Consumers can choose a strategy to ladder or\nreplace their spent LIBs depending on the health status of the\nbatteries used, thus maximizing benefits at the consumer end.\nGovernment: (i) The government can introduce policies to\nincrease incentives and penalties to promote corporate\nenthusiasm for the recycling of spent LIBs to increase recycling rates. However, a punitive policy not in line with the\neconomic benefits to businesses will most likely result in a\nreluctance on the battery supply side to provide the recycling\nside with the required convenient composition structure at the\nbattery assembly stage. Therefore, the government should\ndevelop relevant optimization strategies based on the energy,\neconomic and environmental impacts of the spent LIBs recovery phase, as outlined in this paper, coupled with the\nestablishment of relevant reward and penalty factors. The interests of the supply and recycling sides and political performance can be safeguarded in many ways. (ii) In terms of the\neconomic structure or energy reserves, the recycling strategy\nshould be firmly based on a harmonious coexistence between\nhumans and the environment, thus enhancing the recycling\nrate. Recycling policies should keep pace with the recycling of\nspent LIBs technology, which is constantly evolving, and\npolicies must keep pace with the continuous development of\nrecycling technology. (iii) More policy benefits should be\nshifted in favor of consumers, with incentives to submit retired\nbatteries through subsidies. Through various legislative\n\nFig 13 Future spent LIBs closed-loop recycling strategy\n\n@23@\nZ. Liu et al. / Green Energy & Environment 9 (2024) 802–830 825\n\nFig. 14. Green supply chain recycling system for spent LIBs.\n\nmeasures, such as subsidies, taxes, tax subsidies, reward\npenalties, and deposit refunds, we should improve the interrelationship between sales, use, and recovery and implement a\npolicy involving parallel enforcement and incentives. (iv) We\nshould develop regulations related to the transportation and\nstorage of spent LIBs and encourage the development of\nlightweight and convenient battery testing systems.\nSellers: Sellers of LIBs are responsible for LIB recycling and disposal at or below capacity thresholds, and\noptimization decisions should focus on battery recycling\nrates and government subsidies to protect the environment.\nSellers should work with car repairers to increase the price\nof spent LIBs recycling, accurately rate the battery capacity in customer repairs and give advice to customers to\nincrease their enthusiasm to actively return spent LIBs.\nThe government should regulate the recycling rate, while\nthe sellers are given recycling subsidies. This two-pronged\nregulation and subsidy policy approach, which is mandatory and incentive-based, could ensure that the recycling\nrate of spent LIBs meets government requirements, confirming that sellers obtain sufficient profits and increasing\nthe subsidy efficiency.\nBy summarizing the mature laws and regulations of countries in terms of spent LIBs recycling and by analyzing their\nwell-established policies, this study could serve as a reference\nfor governments initially starting to adopt LIBs on a large\n\nscale and for countries about to encounter a wave of EoL\npower battery recycling policies. By considering the recent\nimplementations of the production responsibility system in\nsuch battery developing countries and the existing problems,\nwe propose a green supply chain recycling system for spent\nLIBs, as shown in Fig. 14. In this system, we consider legal,\ntechnical, model and theoretical knowledge and various factors that arise in regard to transportation, profit and the\nenvironment.\n\n5. Conclusions and future outlooks\n\nThe increasing use of decarbonized electrical energy in the\nautomotive industry has presented unique opportunities for\nthis sector. However, the increasing consumption of LIBs has\nresulted in a significant increase in the volume of spent batteries. If not managed properly, this consumption could pose\nsignificant risks to the economy, the environment, and human\nhealth. In light of the potential threats posed by this situation,\nthere is a pressing need for research on the entire process of\nmanaging spent batteries, from collection to recycling and\npollution control. In this article, we describe recent research\nefforts in this area and explore the key issues surrounding\nclean and efficient recycling technologies and their industrialization. To promote sustainable development within our\nsociety, a strategic approach that emphasizes the redesign,\n\n@24@\n826 Z. Liu et al. / Green Energy & Environment 9 (2024) 802–830\n\nreuse, and recycling of batteries must be jointly pursued by\ngovernments, consumers, recyclers and suppliers.\n\n(i) Recycling: Recyclers are the key links for the recycling of spent LIBs. a) The development of unmanned\ndismantling technologies and lines should be given\nhigher priority to ensure the safe and efficient operation of other processes. b) Whether GHG emissions or\nenergy consumption, the recycling of spent LIBs\nprovides obvious advantages over the production of\nLIBs from new raw materials, especially hydrometallurgical recycling. c) The recycling scale is directly\nproportional to the recycling benefits. Enterprises\nshould choose the appropriate recycling process according to the scale, and thermal runaway should be\neliminated in every link. d) Spent electrolytes should\nbe studied to determine methods for applying them in\nfields other than recycling. e) Organic acid leaching\nand bioleaching are techniques with the highest potential to move from the laboratory to industrial\napplications in the future. f) Exploring clean, highefficiency, and low-energy recovery technology options is as important as controlling pollutant discharge\ntechnologies. It is especially important to eliminate\nthe potential threat of trivalent toxic waste in the\nrelationship between toxic electrolytes, storage, fine\ndismantling, metallurgical recovery, dissociation and\npurification.\n(ii) Supply: a) An application design should be invented for\nelectrode materials and other components that can be\neasily disassembled, assembled, and used in stages.\nRegional energy endowment should be effectively used\nto build factories. b) Heavy metal content and\nmanufacturing energy consumption should be reduced,\nand biodegradable binders and fluorine-free electrolytes\nshould be selected to reduce environmental pollution\nfactors at the source. c) Battery companies should work\nclosely with downstream vehicle companies and upstream raw material companies to compile a complete\nwhole life-cycle database. d) The main ingredients must\nbe labeled on multiple sides of battery outer packaging\nto facilitate recycling.\n(iii) Policies: a) It is necessary to uniformly plan network\nconstruction of recycling enterprises and force the\nelimination of old recycling technologies. b) A battery\ntraceability LCA network database should be established to implement the main responsibilities of each\nrecycling link, control from the production source,\ncease treatment monitoring, and provide transparent\nclosed-loop recycling-specific processes. c) A favorable\npublic opinion and publicity atmosphere for spent LIBs\nrecycling should be created to guide consumers to\nactively cooperate with recycling activities. d) The key\ntechnologies for hydrometallurgical recovery in the\nclosed-loop industrial chain require the formulation of\nproprietary subsidy policies.\n\n(iv) Consumption: a) Participation should be actively supported in waste battery recycling activities executed by\nthe government and social organizations, the implementation of the extended producer responsibility system should be supported, and a circular economy and\nlow-carbon development should be promoted. b)\nFormal waste battery recycling channels should be\nchosen, waste batteries should be properly stored, and\nrechargeable or environmentally friendly batteries\nshould be adopted.\n\nConflict of interest\n\nThe authors declare that they have no known competing\nfinancial interests or personal relationships that could have\nappeared to influence the work reported in this paper.\n\nAcknowledgments\n\nThis work was financially supported by the National Key\nR&D Program of China, China (2022YFC3902600), CAS\nProject for Young Scientists in Basic Research, China (YSBR044), Guangdong Basic and Applied Basic Research Foundation, China (2021B1515020068), and China Postdoctoral\nScience Foundation, China (2023M733510). We would like to\n[thank AJE (https://www.aje.cn/services/editing) for its lin-](https://www.aje.cn/services/editing)\nguistic assistance during the preparation of this manuscript.\n\nReferences\n\n[[1] E. Fan, L. Li, Z. Wang, J. Lin, F. Wu, Chem. Rev. 120 (2020) 7020[–]](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref1)\n[7063.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref1)\n\n[2] B.S.R.O.W. Energy, BCP, 2022. [https://www.bp.com/en/global/](https://www.bp.com/en/global/corporate/energy-economics/statistical-review-of-world-energy/primary-energy.html)\n[corporate/energy-economics/statistical-review-of-world-energy/](https://www.bp.com/en/global/corporate/energy-economics/statistical-review-of-world-energy/primary-energy.html)\n[primary-energy.html. (Accessed 29 August 2022).](https://www.bp.com/en/global/corporate/energy-economics/statistical-review-of-world-energy/primary-energy.html)\n\n[[3] W.E. Outlook, International Energy Agency, 2021. https://www.iea.org/](https://www.iea.org/data-and-statistics/data-product/world-energy-outlook-2021-free-dataset)\n[data-and-statistics/data-product/world-energy-outlook-2021-free-](https://www.iea.org/data-and-statistics/data-product/world-energy-outlook-2021-free-dataset)\n[dataset. (Accessed 2 September 2022).](https://www.iea.org/data-and-statistics/data-product/world-energy-outlook-2021-free-dataset)\n\n[[4] J.Y. Li, S.S. Li, Energy Pol. 140 (2020) 111425.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref4)\n\n[[5] International Energy Agency., G. E. O. https://www.iea.org/reports/](https://www.iea.org/reports/global-ev-outlook-2022)\n[global-ev-outlook-2022, 2022. (Accessed 2 September 2022).](https://www.iea.org/reports/global-ev-outlook-2022)\n\n[6] Mineral Commodity Summaries. [https://www.mining.com/global-](https://www.mining.com/global-lithium-production-hits-record-high-on-electric-vehicle-demand/)\n[lithium-production-hits-record-high-on-electric-vehicle-demand/, 2022.](https://www.mining.com/global-lithium-production-hits-record-high-on-electric-vehicle-demand/)\n(Accessed 3 September 2022).\n\n[[7] Mineral Commodity Summaries. https://pubs.er.usgs.gov/publication/](https://pubs.er.usgs.gov/publication/mcs2022)\n[mcs2022, 2022. (Accessed 4 September 2022).](https://pubs.er.usgs.gov/publication/mcs2022)\n\n[[8] Lithium-ion Battery Recycling Market. https://www.marketsandmarkets](https://www.marketsandmarkets.com/Market-Reports/lithium-ion-battery-recycling-market-153488928.html)\n[.com/Market-Reports/lithium-ion-battery-recycling-market-153488928.](https://www.marketsandmarkets.com/Market-Reports/lithium-ion-battery-recycling-market-153488928.html)\n[html, 2023. (Accessed 3 March 2023).](https://www.marketsandmarkets.com/Market-Reports/lithium-ion-battery-recycling-market-153488928.html)\n\n[[9] GGII. https://baijiahao.baidu.com/s?id¼1749926828424184426&wfr¼](https://baijiahao.baidu.com/s?id=1749926828424184426&wfr=spider&for=pc)\n[spider&for¼pc, 2022. (Accessed 3 December 2022).](https://baijiahao.baidu.com/s?id=1749926828424184426&wfr=spider&for=pc)\n\n[[10] M., S, Whittingham, Science 192 (1976) 1126–4244.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref10)\n\n[[11] K. Mizushima, P.C. Jones, P.J. Wiseman, J.B. Goodenough, Solid State](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref11)\n[Ionics 3–4 (1981) 171–174.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref11)\n\n[[12] G. Zubi, R. Dufo-Lopez, M. Carvalho, G. Pasaoglu, Renew. Sustain.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref12)\n[Energy Rev. 89 (2018) 292–308.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref12)\n\n[[13] Y.L. Ding, Z.P. Cano, A.P. Yu, J. Lu, Z.W. Chen, Electrochem. Energy](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref13)\n[Rev. 2 (2019) 1–28.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref13)\n\n[[14] M. Li, J. Lu, Z. Chen, K. Amine, Adv. Mater. 30 (2018) 1800561.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref14)\n\n[[15] J.K. Kim, S.M. Jeong, Appl. Surf. Sci. 505 (2020) 144630.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref15)\n\n@25@\nZ. Liu et al. / Green Energy & Environment 9 (2024) 802–830 827\n\n[[16] F.H. Zheng, C.H. Yang, X.H. Xiong, J.W. Xiong, R.Z. Hu, Y. Chen,](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref16)\n[M.L. Liu, Angew. Chem. Int. Ed. 54 (2015) 13058–13062.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref16)\n\n[[17] N. Yabuuchi, K. Kubota, M. Dahbi, S. Komaba, Chem. Rev. 114 (2014)](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref17)\n[11636–11682.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref17)\n\n[[18] T. Hosaka, K. Kubota, A.S. Hameed, S. Komaba, Chem. Rev. 120](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref18)\n[(2020) 6358–6466.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref18)\n\n[[19] A. Eftekhari, J. Power Sources 136 (2004) 201.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref19)\n\n[[20] X. Bie, K. Kubota, T. Hosaka, K. Chihara, S. Komaba, J. Mater. Chem.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref20)\n[A 5 (2017) 4325–4330.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref20)\n\n[[21] M.M. Huie, D.C. Bock, E.S. Takeuchi, A.C. Marschilok, K.J. Takeuchi,](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref21)\n[Coord. Chem. Rev. 287 (2015) 15–27.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref21)\n\n[[22] W. Kaveevivitchai, A.J. Jacobson, Chem. Mater. 28 (2016) 4593–4601.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref22)\n\n[[23] A. Mukherjee, N. Sa, P.J. Phillips, A. Burrell, J. Vaughey, R.F. Klie,](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref23)\n[Chem. Mater. 29 (2017) 2218–2226.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref23)\n\n[[24] C. Ling, D. Banerjee, M. Matsui, Electrochim. Acta 76 (2012) 270–274.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref24)\n\n[[25] R. Chen, R. Luo, Y. Huang, F. Wu, L. Li, Adv. Sci. 3 (2016) 1600051.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref25)\n\n[[26] M.C. Lin, M. Gong, B.G. Lu, Y.P. Wu, D.Y. Wang, M.Y. Guan, M. Angell,](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref26)\n[C.X. Chen, J. Yang, B.J. Hwang, H.J. Dai, Nature 520 (2015) 325.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref26)\n\n[[27] M.C. Lin, M. Gong, B.A. Lu, Y.P. Wu, D.Y. Wang, M.Y. Guan,](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref27)\n[M. Angell, C.X. Chen, J. Yang, B.J. Hwang, H.J. Dai, Chinese Science](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref27)\n[Foundation, 2015, pp. 1. English version. 4.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref27)\n\n[[28] L. Contemporary, Amperex Technology Co. https://translate.google.cn/](https://translate.google.cn/?sl=zh-CN&tl=en&text=%E4%B9%9D%E6%9C%88&op=translate)\n[?sl¼zh-CN&tl¼en&text¼%E4%B9%9D%E6%9C%88&op¼translate,](https://translate.google.cn/?sl=zh-CN&tl=en&text=%E4%B9%9D%E6%9C%88&op=translate)\n2022. (Accessed 12 September 2022).\n\n[[29] J. Liu, Z.N. Bao, Y. Cui, E.J. Dufek, J.B. Goodenough, P. Khalifah,](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref29)\nQ.Y. Li, B.Y. Liaw, P. [Liu,](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref29) A. Manthiram, Y.S. Meng,\n[V.R. Subramanian, M.F. Toney, V.V. Viswanathan, M.S. Whittingham,](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref29)\n[J. Xiao, W. Xu, J.H. Yang, X.Q. Yang, J.G. Zhang, Nat. Energy 4](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref29)\n[(2019) 180–186.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref29)\n\n[[30] Y. Liu, X. Tao, Y. Wang, C. Jiang, C. Ma, O. Sheng, G. Lu, X.W. Lou,](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref30)\n[Science 375 (2022) 739–745.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref30)\n\n[[31] R. Yazami, Y.F. Reynier, Electrochim. Acta 47 (2002) 1217–1223.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref31)\n\n[[32] W.F. Hao, Z.R. Yuan, D.D. Li, Z.Y. Zhu, S.P. Jiang, J. Energy Storage](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref32)\n[41 (2021) 102894.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref32)\n\n[[33] Y. Lu, S.Q. Zhang, S.L. Dai, D.P. Liu, X. Wang, W. Tang, X.J. Guo,](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref33)\n[J. Duan, W. Luo, B.B. Yang, J. Zou, Y.H. Huang, H.E. Katz, J. Huang,](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref33)\n[Matter 3 (2020) 904–919.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref33)\n\n[[34] J.S. Kim, D.C. Lee, J.J. Lee, C.W. Kim, J. Electrochem. Soc. 168 (2021)](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref34)\n[030536.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref34)\n\n[[35] T.P. Hendrickson, O. Kavvada, N. Shah, R. Sathre, C.D. Scown, En-](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref35)\n[viron. Res. Lett. 10 (2015) 014011.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref35)\n\n[[36] M. Chen, X. Ma, B. Chen, R. Arsenault, P. Karlson, N. Simon, Y. Wang,](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref36)\n[Joule 3 (2019) 2622–2646.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref36)\n\n[[37] J.N. Meegoda, S. Malladi, I.C. Zayas, Cleanroom Technol. 4 (2022)](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref37)\n[1162–1174.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref37)\n\n[[38] Y. Yang, E.G. Okonkwo, G.Y. Huang, S.M. Xu, W. Sun, Y.H. He,](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref38)\n[Energy Storage Mater. 36 (2021) 186–212.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref38)\n\n[[39] C. Sun, A. Xia, Q. Liao, Q. Fu, Y. Huang, X. Zhu, Renew. Sustain.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref39)\n[Energy Rev. 112 (2019) 395–410.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref39)\n\n[[40] J.B. Dunn, L. Gaines, J. Sullivan, M.Q. Wang, Environ. Sci. Technol. 46](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref40)\n[(2012) 12704–12710.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref40)\n\n[[41] S. Al-Thyabat, T. Nakamura, E. Shibata, A. Iizuka, Miner. Eng. 45](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref41)\n[(2013) 4[–]17.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref41)\n\n[[42] G. Harper, R. Sommerville, E. Kendrick, L. Driscoll, P. Slater, R. Stolkin,](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref42)\n[A. Walton, P. Christensen, O. Heidrich, S. Lambert, A. Abbott,](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref42)\n[K.S. Ryder, L. Gaines, P. Anderson, Nature 575 (2019) 75–86.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref42)\n\n[[43] L. Ahmadi, S.B. Young, M. Fowler, R.A. Fraser, M.A. Achachlouei, Int.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref43)\n[J. Life Cycle Assess. 22 (2017) 111–124.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref43)\n\n[[44] L. Gaines, Sustain. Mater. Technol. 17 (2018) e00068.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref44)\n\n[[45] T. Georgi-Maschler, B. Friedrich, R. Weyhe, H. Heegn, M. Rutz,](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref45)\n[J. Power Sources 207 (2012) 173–182.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref45)\n\n[[46] T. Traeger, B. Friedrich, R. Weyhe, Chem. Ing. Tech. 87 (2015) 1550–](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref46)\n[1557.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref46)\n\n[[47] D. Cr Espinosa, Waste Electrical and Electronic Equipment (WEEE)](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref47)\n[Handbook jj Recycling Batteries, Dutch, 2012, pp. 365–384.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref47)\n\n[[48] L. Schneider, M. Berger, M. Finkbeiner, Int. J. Life Cycle. Ass. 20](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref48)\n[(2015) 709–721.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref48)\n\n[[49] M.L.C.M. Henckens, Sustainability 14 (2022) 4781.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref49)\n\n[[50] H. Yang, X. Song, X. Zhang, B. Lu, D. Yang, B. Li, Environ. Sci. Pollut.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref50)\n[R. 28 (2021) 45867[–]45878.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref50)\n\n[[51] X.D. Liang, B.Y. Yao, M. Ye, Y.Q. Wang, Z.Y. Li, Iop, Decision](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref51)\n[Analysis of Spent Power Battery Recovery Mode under Hybrid Dual-](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref51)\n[Channel Collection, in: 6th International Conference on Advances in](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref51)\n[Energy Resources and Environment Engineering vol. 647, 2021.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref51)\n\n[[52] Y. Yang, X. Meng, H. Cao, C. Liu, Y. Sun, Z. Sun, Green Chem. 20](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref52)\n[(2018) 3121–3133.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref52)\n\n[[53] X. Ma, Y. Ma, J. Zhou, S. Xiong, IOP Conf. Ser. Earth Environ. Sci. 159](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref53)\n[(2018) 012017.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref53)\n\n[[54] X.M. Ma, Y. Ma, J.P. Zhou, S.Q. Xiong, Iop, The Recycling of Spent](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref54)\n[Power Battery: Economic Benefits and Policy Suggestions, in: 2018 4th](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref54)\n[International Conference on Environment and Renewable Energy](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref54)\n[(ICERE 2018), 2018, pp. 159.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref54)\n\n[[55] G. Granata, E. Moscardini, F. Pagnanelli, F. Trabucco, L. Toro, J. Power](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref55)\n[Sources 206 (2012) 393–401.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref55)\n\n[[56] L.Z. Duan, Y.R. Cui, Q. Li, J. Wang, C.H. Man, X.Y. Wang, Johnson.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref56)\n[Matthey. Tech. 65 (2021) 431–452.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref56)\n\n[[57] X. Dai, A. Zhou, Z., J. Xu, Y. Lu, L. Wang, C. Fan, J. Li, J. Phys. Chem.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref57)\n[C 120 (2016) 422–430.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref57)\n\n[[58] O.K. Park, Y. Cho, S. Lee, H.C. Yoo, H.K. Song, J. Cho, Energy En-](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref58)\n[viron. Sci. 4 (2011) 1621–1633.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref58)\n\n[[59] H. Pan, S. Zhang, J. Chen, M. Gao, Y. Liu, T. Zhu, Y. Jiang, Mol. Syst.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref59)\n[Des. Eng. 3 (2018) 748–803.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref59)\n\n[[60] J.A. Shan, B. Dma, A. Zl, A. Rl, L.A. Zhu, W.C. Yue, T.D. Shuang,](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref60)\n[A. Cd, J. Clean. Prod. (2022) 130535.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref60)\n\n[[61] F. Wang, T. Zhang, Y. He, Y. Zhao, S. Wang, G. Zhang, Y. Zhang,](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref61)\n[Y. Feng, J. Clean. Prod. 185 (2018) 646–652.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref61)\n\n[[62] S. Ojanen, M. Lundstrom, A. Santasalo-Aarnio, R. Serna-Guerrero,€](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref62)\n[Waste Manage. (Tucson, Ariz.) 76 (2018) 242–249.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref62)\n\n[[63] J. Yu, Y. He, Z. Ge, H. Li, W. Xie, S. Wang, Sep. Purif. Technol. 190](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref63)\n[(2018) 45–52.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref63)\n\n[[64] T. Zhang, Y. He, F. Wang, H. Li, C. Duan, C. Wu, Sep. Purif. Technol.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref64)\n[138 (2014) 21–27.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref64)\n\n[[65] Y. He, T. Zhang, F. Wang, G. Zhang, W. Zhang, J. Wang, J. Clean. Prod.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref65)\n[143 (2017) 319–325.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref65)\n\n[[66] J. Diekmann, C. Hanisch, L. Frobose, G. Sch€](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref66) [€alicke, T. Loellhoeffel, A.-](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref66)\n[S. Folster, A. Kwade, J. Electrochem. Soc. 164 (2016) 6184–6191€](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref66) .\n\n[[67] A.J. Da Costa, J.F. Matos, A.M. Bernardes, I.L. Muller, Int. J. Miner.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref67)\n[Process. 145 (2015) 77–82.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref67)\n\n[[68] F. Pagnanelli, E. Moscardini, P. Altimari, T.A. Atia, L. Toro, Waste](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref68)\n[Manage. (Tucson, Ariz.) 60 (2017) 706–715.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref68)\n\n[[69] J.D. Yu, Y.Q. He, H. Li, W.N. Xie, T. Zhang, Powder Technol. 315](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref69)\n[(2017) 139–146.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref69)\n\n[[70] J.D. Yu, Y.Q. He, Z.Z. Ge, H. Li, W.N. Xie, S. Wang, Sep. Purif.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref70)\n[Technol. 190 (2018) 45–52.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref70)\n\n[[71] T. Zhang, Y.Q. He, F.F. Wang, H. Li, C.L. Duan, C.B. Wu, Sep. Purif.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref71)\n[Technol. 138 (2014) 21–27.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref71)\n\n[[72] B.J. Li, J. Shi, H. Li, L.Y. Wang, G.J. Liu, H.D. Zhao, IEEE](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref72)\n[Innovative Smart Grid Technologies - Asia (ISGT Asia), 2019,](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref72)\n[pp. 3519–3524.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref72)\n\n[[73] L. Li, L.Y. Zhai, X.X. Zhang, J. Lu, R.J. Chen, F. Wu, K. Amine,](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref73)\n[J. Power Sources 262 (2014) 380–385.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref73)\n\n[[74] L.P. He, S.Y. Sun, X.F. Song, J.G. Yu, Waste Manage. (Tucson, Ariz.)](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref74)\n[46 (2015) 523–528.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref74)\n\n[[75] M. Contestabile, S. Panero, B. Scrosati, J. Power Sources 92 (2001) 65–](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref75)\n[69.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref75)\n\n[[76] Y.A. Xu, D.W. Song, L. Li, C.H. An, Y.J. Wang, L.F. Jiao, H.T. Yuan,](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref76)\n[J. Power Sources 252 (2014) 286–291.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref76)\n\n[[77] Y. Zhen, H.L. Long, L. Zhou, Z.S. Wu, X. Zhou, L. You, Y. Yang,](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref77)\n[J.W. Liu, Int. J. Environ. Res. 10 (2016) 159–168.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref77)\n\n[[78] J.G. Li, R.S. Zhao, X.M. He, H.C. Liu, Ionics 15 (2009) 111–113.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref78)\n\n@26@\n828 Z. Liu et al. / Green Energy & Environment 9 (2024) 802–830\n\n[[79] L. Sun, K.Q. Qiu, J. Hazard. Mater. 194 (2011) 378–384.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref79)\n\n[[80] L. Sun, K.Q. Qiu, Waste Manage. (Tucson, Ariz.) 32 (2012) 1575–1582.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref80)\n\n[[81] D.A. Ferreira, L.M.Z. Prados, D. Majuste, M.B. Mansur, J. Power](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref81)\n[Sources 187 (2009) 238–246.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref81)\n\n[[82] M. Al-Harahsheh, S.W. Kingman, Hydrometallurgy 73 (2004) 189[–]203.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref82)\n\n[[83] J.J. Du, Y. Yang, M. Omran, S.H. Guo, Curr. Microwave Chem. 8](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref83)\n[(2021) 7–11.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref83)\n\n[[84] R.K. Amankwah, G. Ofori-Sarpong, Miner. Eng. 24 (2011) 541–544.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref84)\n\n[[85] C.E.M. Meskers, C. Hageluken, G. Van Damme, Green Recycling of](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref85)\n[EEE: Special and Precious Metal Recovery from EEE, in: EPD](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref85)\n[Congress, 2009, pp. 1131.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref85)\n\n[[86] S.M. Bak, E.Y. Hu, Y.N. Zhou, X.Q. Yu, S.D. Senanayake, S.J. Cho,](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref86)\n[K.B. Kim, K.Y. Chung, X.Q. Yang, K.W. Nam, ACS Appl. Mater. In-](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref86)\n[terfaces 6 (2014) 22594–22601.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref86)\n\n[[87] X.P. Chen, Y. Wang, L. Yuan, S.B. Wang, S.X. Yan, H.B. Liu, J.H. Xu,](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref87)\n[Green Chem. 25 (2023) 1559[–]1570.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref87)\n\n[[88] B.C. Zhang, Y.L. Xu, B. Makuza, F.J. Zhu, H.J. Wang, N.Y. Hong,](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref88)\n[Z. Long, W.T. Deng, G.Q. Zou, H.S. Hou, X.B. Ji, Chem. Eng. J. 452](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref88)\n[(2023) 242–249.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref88)\n\n[[89] X.F. Hu, E. Mousa, G.Z. Ye, J. Power Sources 483 (2021) 228936.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref89)\n\n[[90] X.F. Hu, E. Mousa, Y. Tian, G.Z. Ye, J. Power Sources 483 (2021)](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref90)\n[132096.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref90)\n\n[[91] J.F. Xiao, J. Li, Z.M. Xu, Environ. Sci. Technol. 51 (2017) 11960–11966.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref91)\n\n[[92] S. Vishvakarma, N. Dhawan, J. Sustaina. Metall. 5 (2019) 204–209.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref92)\n\n[[93] P. Liu, L. Xiao, Y. Tang, Y. Chen, L. Ye, Y. Zhu, J. Therm. Anal.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref93)\n[Calorim. 136 (2019) 1323–1332.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref93)\n\n[[94] J. Hu, J. Zhang, H. Li, Y. Chen, C. Wang, J. Power Sources 351 (2017)](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref94)\n[192–199.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref94)\n\n[[95] Y. Yang, S. Song, S. Lei, W. Sun, H. Hou, F. Jiang, X. Ji, W. Zhao,](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref95)\n[Y. Hu, Waste Manage. (Tucson, Ariz.) 85 (2019) 529–537.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref95)\n\n[[96] J.K. Mao, J. Li, Z. Xu, J. Clean. Prod. 205 (2018) 923–929.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref96)\n\n[[97] J. Lin, C. Liu, H. Cao, R. Chen, Y. Yang, L. Li, Z. Sun, Green Chem. 21](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref97)\n[(2019) 5904–5913.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref97)\n\n[[98] J.F. Paulino, N.G. Busnardo, J.C. Afonso, J. Hazard. Mater. 150 (2008)](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref98)\n[843–849.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref98)\n\n[[99] D. Wang, H. Wen, H. Chen, Y. Yang, H. Liang, Chem. Res. Chin. Univ.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref99)\n[32 (2016) 674–677.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref99)\n\n[[100] G. Ren, S. Xiao, M. Xie, B. Pan, Y. Fan, F. Wang, X. Xia, Adv. Molt](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref100)\n[Slag, Flu. Salt (2016) 211–218.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref100)\n\n[[101] J. Lin, C.W. Liu, H.B. Cao, R.J. Chen, Y.X. Yang, L. Li, Z. Sun, Green](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref101)\n[Chem. 21 (2019) 5904–5913.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref101)\n\n[[102] E.S. Fan, L. Li, J. Lin, J.W. Wu, J.B. Yang, F. Wu, R.J. Chen, ACS](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref102)\n[Sustain. Chem. Eng. 7 (2019) 16144–16150.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref102)\n\n[[103] P. Xu, C. Liu, X. Zhang, X. Zheng, W. Lv, F. Rao, P. Yao, J. Wang,](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref103)\n[Z. Sun, ACS Sustain. Chem. Eng. 9 (2021) 2271–2279.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref103)\n\n[[104] Y. Zhang, W. Wang, Q. Fang, S. Xu, Waste Manage. (Tucson, Ariz.) 102](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref104)\n[(2020) 847–855.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref104)\n\n[[105] W. Wang, Y. Zhang, X. Liu, S. Xu, ACS Sustain. Chem. Eng. 7 (2019)](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref105)\n[12222–12230.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref105)\n\n[[106] J. Zhang, J. Hu, W. Zhang, Y. Chen, C. Wang, J. Clean. Prod. 204](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref106)\n[(2018) 437–446.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref106)\n\n[[107] Y. Chen, P. Shi, D. Chang, Y. Jie, S. Yang, G. Wu, H. Chen, J. Zhu,](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref107)\n[F. Hu, B.P. Wilson, M. Lundstrom, Sep. Purif. Technol. 258 (2021)](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref107)\n[118078.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref107)\n\n[[108] Y. Fu, Y. He, Y. Yang, L. Qu, J. Li, R. Zhou, J. Alloys Compd. 832](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref108)\n[(2020) 154920.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref108)\n\n[[109] Y. Fu, Y. He, J. Li, L. Qu, Y. Yang, X. Guo, W. Xie, J. Alloys Compd.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref109)\n[847 (2020) 156489.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref109)\n\n[[110] Y. Ma, J. Tang, R. Wanaldi, X. Zhou, H. Wang, C. Zhou, J. Yang,](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref110)\n[J. Hazard. Mater. 402 (2021) 123491.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref110)\n\n[[111] Y. Zhang, W. Wang, J. Hu, T. Zhang, S. Xu, ACS Sustain. Chem. Eng. 8](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref111)\n[(2020) 15496–15506.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref111)\n\n[[112] W. Wang, Y. Zhang, L. Zhang, S. Xu, J. Clean. Prod. 249 (2020)](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref112)\n[119340.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref112)\n\n[[113] P. Liu, L. Xiao, Y. Chen, Y. Tang, J. Wu, H. Chen, J. Alloys Compd. 783](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref113)\n[(2019) 743–752.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref113)\n\n[[114] D. Wang, W. Li, S. Rao, J. Tao, L. Duan, K. Zhang, H. Cao, Z. Liu, Sep.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref114)\n[Purif. Technol. 259 (2021) 118212.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref114)\n\n[[115] S. Pindar, N. Dhawan, T., Indian. I. Metals. 73 (2020) 2041[–]2051.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref115)\n\n[[116] S. Pindar, N. Dhawan, Mining, Metall. Explor. 37 (2020) 1285–1295.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref116)\n\n[[117] S.R. Sunil, N. Dhawan, T., Indian. I. Metals. 72 (2019) 3035–3044.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref117)\n\n[[118] L. Chen, X. Tang, Y. Zhang, L. Li, Z. Zeng, Y. Zhang, Hydrometallurgy](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref118)\n[108 (2011) 80–86.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref118)\n\n[[119] B. Swain, J. Jeong, J.-C. Lee, G.-H. Lee, J.-S. Sohn, J. Power Sources](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref119)\n[167 (2007) 536–544.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref119)\n\n[[120] J.M. Zhao, X.Y. Shen, F.L. Deng, F.C. Wang, Y. Wu, H.Z. Liu, Sep.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref120)\n[Purif. Technol. 78 (2011) 345–351.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref120)\n\n[[121] H. Bing, A. Porvali, M. Lundstrom, M. Louhi-Kultanen, Chem. Eng.€](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref121)\n[Technol. 41 (2018) 1205–1210.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref121)\n\n[[122] Y. Guo, F. Li, H. Zhu, G. Li, J. Huang, W. He, Waste Manage. (Tucson,](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref122)\n[Ariz.) 51 (2016) 227–233.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref122)\n\n[[123] S. Sakultung, K. Pruksathorn, M. Hunsom, J. Chem. Eng. 3 (2008) 374–](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref123)\n[379.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref123)\n\n[[124] L. Shi, Nonferrous Met. 10 (2018) 77–90.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref124)\n\n[[125] J.B. Wang, M.J. Chen, H.Y. Chen, T. Luo, Z.H. Xu, Procedia Environ.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref125)\n[Sci. (2012) 3632–3639.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref125)\n\n[[126] J. Yang, L.X. Jiang, F.Y. Liu, M. Jia, Y.Q. Lai, T. Nonferr, Metals. Soc.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref126)\n[China. 30 (2020) 2256–2264.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref126)\n\n[[127] P. Meshram, B.D. Pandey, T.R. Mankhand, Chem. Eng. J. 281 (2015)](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref127)\n[418–427.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref127)\n\n[[128] D.S. Suarez, E.G. Pinna, G.D. Rosales, M.H. Rodriguez, Minerals 7](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref128)\n[(2017) 81.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref128)\n\n[[129] M. Jouli�e, E. Billy, R. Laucournet, D. Meyer, Hydrometallurgy 169](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref129)\n[(2017) 426–432.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref129)\n\n[[130] Z.T. A, T.H. A, F.K. B, D.O. A, Hydrometallurgy 163 (2016) 9–17.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref130)\n\n[[131] M. Joulie, R. Laucournet, E. Billy, J. Power Sources 247 (2014) 551–555.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref131)\n\n[[132] L. Li, J.B. Dunn, X.X. Zhang, L. Gaines, R.J. Chen, F. Wu, K. Amine,](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref132)\n[J. Power Sources 233 (2013) 180–189.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref132)\n\n[[133] L. Li, J. Lu, Y. Ren, X.X. Zhang, R.J. Chen, F. Wu, K. Amine, J. Power](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref133)\n[Sources 218 (2012) 21–27.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref133)\n\n[[134] L. Li, W.J. Qu, X.X. Zhang, J. Lu, R.J. Chen, F. Wu, K. Amine, J. Power](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref134)\n[Sources 282 (2015) 544–551.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref134)\n\n[[135] L. Li, J. Ge, R. Chen, F. Wu, S. Chen, X. Zhang, Waste Manage.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref135)\n[(Tucson, Ariz.) 30 (2010) 2615–2621.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref135)\n\n[[136] E. Sayilgan, T. Kukrer, N.O. Yigit, G. Civelekoglu, M. Kitis, J. Hazard.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref136)\n[Mater. 173 (2010) 137[–]143.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref136)\n\n[[137] L. Li, E.S. Fan, Y.B.A. Guan, X.X. Zhang, Q. Xue, L. Wei, F. Wu,](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref137)\n[R.J. Chen, ACS Sustain. Chem. Eng. 5 (2017) 5224–5233.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref137)\n\n[[138] X. Zhang, Y. Bian, S. Xu, E. Fan, Q. Xue, Y. Guan, F. Wu, L. Li,](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref138)\n[R. Chen, ACS Sustain. Chem. Eng. (2018) 704373.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref138)\n\n[[139] E. Fan, J. Yang, Y. Huang, J. Lin, F. Arshad, F. Wu, L. Li, R. Chen, ACS](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref139)\n[Appl. Energy Mater. 3 (2020) 8532–8542.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref139)\n\n[[140] L. Li, Y.F. Bian, X.X. Zhang, Q. Xue, E.S. Fan, F. Wu, R.J. Chen,](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref140)\n[J. Power Sources 377 (2018) 70–79.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref140)\n\n[[141] G.P. Nayaka, K.V. Pai, G. Santhosh, J. Manjanna, Hydrometallurgy 161](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref141)\n[(2016) 54–57.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref141)\n\n[[142] R.J. Zheng, W.H. Wang, Y.K. Dai, Q.X. Ma, Y.L. Liu, D.Y. Mu,](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref142)\n[R.H. Li, J. Ren, C.S. Dai, Green Energy Environ. 2 (2017) 42–50.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref142)\n\n[[143] Y. Fu, Y. He, L. Qu, Y. Feng, J. Li, J. Liu, G. Zhang, W. Xie, Waste](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref143)\n[Manage. (Tucson, Ariz.) 88 (2019) 191–199.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref143)\n\n[[144] W.F. Gao, X.H. Zhang, X.H. Zheng, X. Lin, H.B. Cao, Y. Zhi, Z. Sun,](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref144)\n[Environ. Sci. Technol. 51 (2017) 1662–1669.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref144)\n\n[[145] G.P. Nayaka, K.V. Pai, J. Manjanna, S.J. Keny, Waste Manage. (Tucson,](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref145)\n[Ariz.) 51 (2016) 234–238.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref145)\n\n[[146] A.K. Jha, M.K. Jha, A. Kumari, S.K. Sahu, V. Kumar, B.D. Pandey, Sep.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref146)\n[Purif. Technol. 104 (2013) 160–166.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref146)\n\n[[147] W. Feng, S. Rong, J. Xu, C. Zheng, K. Ming, RSC Adv. 6 (2016)](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref147)\n[85303–85311.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref147)\n\n@27@\nZ. Liu et al. / Green Energy & Environment 9 (2024) 802–830 829\n\n[[148] W. Gao, X. Zhang, X. Zheng, X. Lin, H. Cao, Y. Zhang, Z. Sun, En-](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref148)\n[viron. Sci. Technol. 51 (2017) 1662–1669.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref148)\n\n[[149] T. Shiga, H. Kondo, Y. Kato, K. Fukumoto, Y. Hase, ACS Sustain.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref149)\n[Chem. Eng. 8 (2020) 2260–2266.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref149)\n\n[[150] X. Chen, C. Luo, J. Zhang, J. Kong, T. Zhou, ACS Sustain. Chem. Eng.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref150)\n[3 (2015) 3104–3113.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref150)\n\n[[151] X. Chen, B. Xu, T. Zhou, D. Liu, H. Hu, S. Fan, Sep. Purif. Technol.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref151)\n[144 (2015) 197–205.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref151)\n\n[[152] E.M.S. Barbieri, E.P.C. Lima, M.F.F. Lelis, M.B.J.G. Freitas, J. Power](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref152)\n[Sources 270 (2014) 158–165.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref152)\n\n[[153] A.A. Nayl, R.A. Elkhashab, S.M. Badawy, M.A. El-Khateeb, Arab. J.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref153)\n[Chem. 10 (2017) 3632–3639.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref153)\n\n[[154] T.Huang,D.Song,L.Liu,S.Zhang,Sep.Purif.Technol.215(2019)51–61.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref154)\n\n[[155] X. Zeng, J. Li, B. Shen, J. Hazard. Mater. 295 (2015) 112–118.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref155)\n\n[[156] G.P. Nayaka, Y. Zhang, P. Dong, D. Wang, K.V. Pai, J. Manjanna,](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref156)\n[G. Santhosh, J. Duan, Z. Zhou, J. Xiao, Waste Manage. (Tucson, Ariz.)](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref156)\n[78 (2018) 51–57.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref156)\n\n[[157] G.R. Hu, Y.F. Gong, Z.D. Peng, K. Du, M. Huang, J.H. Wu, D.C. Guan,](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref157)\n[J.Y. Zeng, B.C. Zhang, Y.B. Cao, ACS Sustain. Chem. Eng. 10 (2022)](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref157)\n[11606–11616.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref157)\n\n[[158] E.S. Fan, L. Li, X.X. Zhang, Y.F. Bian, Q. Xue, J.W. Wu, F. Wu,](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref158)\n[R.J. Chen, ACS Sustain. Chem. Eng. 6 (2018) 11029–11035.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref158)\n\n[[159] J. Kang, G. Senanayake, J. Sohn, S.M. Shin, Hydrometallurgy 100](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref159)\n[(2010) 168–171.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref159)\n\n[[160] F. Vasilyev, S. Virolainen, T. Sainio, Sep. Purif. Technol. 210 (2019)](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref160)\n[530–540.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref160)\n\n[[161] L. Zhang, L. Li, H. Rui, D. Shi, X. Peng, L. Ji, X. Song, J. Hazard.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref161)\n[Mater. 398 (2020) 122840.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref161)\n\n[[162] X.P. Chen, T. Zhou, J.R. Kong, H.X. Fang, Y.B. Chen, Sep. Purif.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref162)\n[Technol. 141 (2015) 76–83.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref162)\n\n[[163] M.L. Strauss, L.A. Diaz, J. Mcnally, J. Klaehn, T.E. Lister, Hydro-](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref163)\n[metallurgy 206 (2021) 105757.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref163)\n\n[[164] D. Dutta, A. Kumari, R. Panda, S. Jha, D. Gupta, S. Goel, M. Kumar](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref164)\n[Jha, Sep. Purif. Technol. (2018) 327–334.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref164)\n\n[[165] F. Larouche, F. Tedjar, K. Amouzegar, G. Houlachi, P. Bouchard,](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref165)\n[G.P. Demopoulos, K. Zaghib, Materials 13 (2020) 13030801.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref165)\n\n[[166] T. Hoshino, Lithium ECS Transactions 58 (2014) 173–177.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref166)\n\n[167] P.T. Wulandari Yuliusman, [R.A.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref167) Amiliana, Silvia M. Huda,\n[F.A. Kusumadewi, In 2nd International Tropical Renewable Energy](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref167)\n[Conference (I-TREC) 333 (2017) 012036.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref167)\n\n[[168] O.V. Tupikina, I.E. Ngoma, S. Minnaar, S. Harrison, Miner. Eng. 24](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref168)\n[(2011) 1209[–]1214.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref168)\n\n[[169] J. Sedlakova-Kadukova, R. Marcincakova, A. Mrazikova, J. Willner,](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref169)\n[A. Fornalczyk, Arch. Metall. Mater. 62 (2017) 1459–1466.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref169)\n\n[[170] J. Li, T. Xu, J. Liu, J. Wen, S. Gong, Environ. Sci. Pollut. R. 28 (2021)](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref170)\n[44622–44637.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref170)\n\n[[171] X. Cai, L. Tian, C. Chen, W. Huang, Y. Yu, C. Liu, B. Yang, X. Lu,](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref171)\n[Y. Mao, Ecotoxicol. Environ. Saf. 223 (2021) 112592.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref171)\n\n[[172] W. Gu, J. Bai, B. Dong, X. Zhuang, J. Zhao, C. Zhang, J. Wang,](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref172)\n[K. Shih, Hydrometallurgy 171 (2017) 172–178.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref172)\n\n[[173] Z. Kazemian, M. Larypoor, R. Marandi, Int. J. Environ. Anal. Chem.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref173)\n[103 (2020) 514–527.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref173)\n\n[[174] M.-J. Kim, J.-Y. Seo, Y.-S. Choi, G.-H. Kim, Waste Manage. (Tucson,](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref174)\n[Ariz.) 51 (2016) 168–173.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref174)\n\n[[175] T. Naseri, N. Bahaloo-Horeh, S.M. Mousavi, J. Environ. Manag. 235](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref175)\n[(2019) 357–367.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref175)\n\n[[176] J.J. Roy, S. Madhavi, B. Cao, J. Clean. Prod. 280 (2021) 124242.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref176)\n\n[[177] Z. Niu, Y. Zou, B. Xin, S. Chen, C. Liu, Y. Li, Chemosphere 109 (2014)](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref177)\n[92–98.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref177)\n\n[[178] Y. Xin, X. Guo, S. Chen, J. Wang, F. Wu, B. Xin, J. Clean. Prod. 116](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref178)\n[(2016) 249–258.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref178)\n\n[[179] T. Huang, L. Liu, S. Zhang, Hydrometallurgy 188 (2019) 101–111.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref179)\n\n[[180] R. Quatrini, D.B. Johnson, Trends Microbiol. 27 (2019) 282–283.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref180)\n\n[[181] T. Naseri, N. Bahaloo-Horeh, S.M. Mousavi, J. Clean. Prod. 220 (2019)](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref181)\n[483–492.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref181)\n\n[[182] Y. Masaki, T. Hirajima, K. Sasaki, H. Miki, N. Okibe, Geomicrobiol. J.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref182)\n[35 (2018) 648–656.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref182)\n\n[[183] A. Heydarian, S.M. Mousavi, F. Vakilchap, M. Baniasadi, J. Power](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref183)\n[Sources 378 (2018) 19–30.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref183)\n\n[[184] N. Bahaloo-Horeh, S.M. Mousavi, M. Baniasadi, J. Clean. Prod. 197](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref184)\n[(2018) 1546–1557.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref184)\n\n[[185] W. Wu, X. Liu, X. Zhang, X. Li, Y. Qiu, M. Zhu, W. Tan, J. Biosci.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref185)\n[Bioeng. 128 (2019) 344–354.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref185)\n\n[[186] X. Liang, G.M. Gadd, Microb. Biotechnol. 10 (2017) 1199–1205.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref186)\n\n[[187] L.H.M. Vargas, A.C.S. Pi~ao, R.N. Domingos, E.C. Carmona, World J.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref187)\n[Microbiol. Biotechnol. 20 (2004) 137–142.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref187)\n\n[[188] N. Bahaloo-Horeh, S.M. Mousavi, Waste Manage. (Tucson, Ariz.) 60](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref188)\n[(2017) 666–679.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref188)\n\n[[189] Y. Gumulya, N.J. Boxall, H.N. Khaleque, V. Santala, R.P. Carlson,](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref189)\n[A.H. Kaksonen, Genes 9 (2018) 116.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref189)\n\n[[190] J. Wang, B. Tian, Y. Bao, C. Qian, Y. Yang, T. Niu, B. Xin, J. Hazard.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref190)\n[Mater. 354 (2018) 250–257.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref190)\n\n[[191] H. Ku, Y. Jung, M. Jo, S. Park, S. Kim, D. Yang, K. Rhee, E.-M. An,](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref191)\n[J. Sohn, K. Kwon, J. Hazard. Mater. 313 (2016) 138–146.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref191)\n\n[[192] W. Lv, Z. Wang, H. Cao, X. Zheng, W. Jin, Y. Zhang, Z. Sun, Waste](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref192)\n[Manage. (Tucson, Ariz.) 79 (2018) 545–553.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref192)\n\n[[193] G. Zhang, X. Yuan, Y. He, H. Wang, T. Zhang, W. Xie, J. Hazard.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref193)\n[Mater. 406 (2021) 124332.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref193)\n\n[[194] Y.Y. Ma, J.J. Tang, R. Wanaldi, X.Y. Zhou, H. Wang, C.Y. Zhou,](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref194)\n[J. Yang, J. Hazard. Mater. 402 (2021) 123491.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref194)\n\n[[195] Y. Chen, N. Liu, F. Hu, L. Ye, Y. Xi, S. Yang, Waste Manage. (Tucson,](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref195)\n[Ariz.) 75 (2018) 469–476.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref195)\n\n[[196] Y.X. Yang, X.H. Zheng, H.B. Cao, C.L. Zhao, X. Lin, P.G. Ning, Y. Zhang,](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref196)\n[W. Jin, Z. Sun, ACS Sustain. Chem. Eng. 5 (2017) 9972–9980.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref196)\n\n[[197] C. Padwal, H.D. Pham, S. Jadhav, T.T. Do, J. Nerkar, L.T.M. Hoang,](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref197)\n[A.K. Nanjundan, S.G. Mundree, D.P. Dubal, Adv. Energ. Sust. Res. 3](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref197)\n[(2022) 2100133.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref197)\n\n[[198] M.K. Tran, M.T.F. Rodrigues, K. Kato, G. Babu, P.M. Ajayan, Nat.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref198)\n[Energy 4 (2019) 339–345.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref198)\n\n[[199] W. Fan, J. Zhang, R. Ma, Y. Chen, C. Wang, Electroanal. Chem. 908](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref199)\n[(2022) 116087.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref199)\n\n[[200] Q.H. Chen, L.W. Huang, J.B. Liu, Y.T. Luo, Y.G. Chen, Carbon 189](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref200)\n[(2022) 293–304.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref200)\n\n[[201] S. Rothermel, M. Evertz, J. Kasnatscheew, X. Qi, M. Gruetzke,](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref201)\n[M. Winter, S. Nowak, ChemSusChem (2016) 3473–3484.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref201)\n\n[[202] F.A. Kayakool, B. Gangaja, S. Nair, D. Santhanagopalan, Sustain.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref202)\n[Mater. Techno. 28 (2021) e00262.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref202)\n\n[[203] K. Liu, S. Yang, L. Luo, Q. Pan, P. Zhang, Y. Huang, F. Zheng,](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref203)\n[H. Wang, Q. Li, Electrochim. Acta 356 (2020) 136856.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref203)\n\n[[204] Y.L. Zhao, H. Wang, X.D. Li, X.Z. Yuan, L.B. Jiang, X.W. Chen,](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref204)\n[J. Hazard. Mater. 420 (2021) 126552.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref204)\n\n[[205] L. Yang, L. Yang, G.R. Xu, Q.G. Feng, Y.C. Li, E.Q. Zhao, J.J. Ma,](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref205)\n[S.M. Fan, X.B. Li, Sci. Rep. 9 (2019) 9823.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref205)\n\n[[206] J.D. Yu, M.S. Lin, Q.Y. Tan, J.H. Li, J. Hazard. Mater. 401 (2021)](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref206)\n[123715.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref206)\n\n[[207] J. Yu, M. Lin, Q. Tan, J. Li, J. Hazard. Mater. 401 (2021) 123715.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref207)\n\n[[208] S. Natarajan, D. Shanthana Lakshmi, H.C. Bajaj, D.N. Srivastava,](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref208)\n[J. Environ. Chem. Eng. 3 (2015) 2538–2545.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref208)\n\n[[209] M.J. Lain, Fuel Energy Abstr. 43 (2002) 295–296.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref209)\n\n[[210] K. He, Z.-Y. Zhang, L. Alai, F.-S. Zhang, J. Hazard. Mater. 375 (2019)](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref210)\n[43–51.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref210)\n\n[[211] M. Gru¨tzke, S. Kru¨ger, V. Kraft, B. Vortmann, S. Rothermel, M. Winter,](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref211)\n[S. Nowak, ChemSusChem 8 (2015) 3433–3438.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref211)\n\n[[212] Y. Liu, D. Mu, R. Li, Q. Ma, R. Zheng, C. Dai, J. Phys. Chem. C 121](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref212)\n[(2017) 4181–4187.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref212)\n\n[[213] A. Ciroth, Int. J. Life Cycle Ass. 12 (2007) 209–210.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref213)\n\n[[214] E. Botero, C. Naranjo, J. Aguirre, Int. J. Life Cycle Ass. 13 (2008) 172–](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref214)\n[174.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref214)\n\n[[215] K. Lee, S. Tae, S. Shin, Renew. Sustain. Energy Rev. 13 (2009) 1994–](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref215)\n[2002.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref215)\n\n[[216] S.K. Ong, T.H. Koh, A.Y.C. Nee, J. Mater. Process. Technol. 90 (1999)](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref216)\n[574–582.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref216)\n\n[[217] S. Singh, B.R. Bakshi, IEEE International Symposium on Sustainable](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref217)\n[Systems and Technology 416 (2009) 416.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref217)\n\n@28@\n830 Z. Liu et al. / Green Energy & Environment 9 (2024) 802–830\n\n[[218] D. Xiang, X.P. Liu, Y. Wu, J.S. Wang, G.H. Duan, IEEE International](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref218)\n[Symposium on Electronics and the Environment: Conference Record,](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref218)\n[2003, pp. 120–124.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref218)\n\n[[219] L. Lin, Z. Lu, W. Zhang, Resour. Conserv. Recycl. 167 (2021) 105416.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref219)\n\n[[220] M.X. Sun, Y.T. Wang, J.L. Hong, J.L. Dai, R.Q. Wang, Z.R. Niu,](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref220)\n[B.P. Xin, J. Clean. Prod. 129 (2016) 350–358.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref220)\n\n[[221] M. Rinne, H. Elomaa, A. Porvali, M. Lundstrom, Resour. Conserv.€](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref221)\n[Recycl. 170 (2021) 105586.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref221)\n\n[[222] E. Kallitsis, A. Korre, G.H. Kelsall, J. Clean. Prod. 371 (2022) 133636.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref222)\n\n[[223] M. Raugei, P. Winfield, J. Clean. Prod. 213 (2019) 926–932.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref223)\n\n[[224] Y.X. Wang, B.J. Tang, M. Shen, Y.Z. Wu, S. Qu, Y.J. Hu, Y. Feng,](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref224)\n[J. Environ. Manag. 314 (2022) 115083.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref224)\n\n[[225] U.S.E.P. Agency. https://cfpub.epa.gov/ncer_abstracts/index.cfm/fuseaction/](https://cfpub.epa.gov/ncer_abstracts/index.cfm/fuseaction/display.abstractDetail/abstract_id/788/report/0)\n[display.abstractDetail/abstract_id/788/report/0, 2003. (Accessed 11 March](https://cfpub.epa.gov/ncer_abstracts/index.cfm/fuseaction/display.abstractDetail/abstract_id/788/report/0)\n2023).\n\n[[226] H. Hao, Z. Mu, S. Jiang, Z. Liu, F. Zhao, Sustainability 9 (2017) 504.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref226)\n\n[[227] X. Shu, Y. Guo, W. Yang, K. Wei, G. Zhu, Energy Rep. 7 (2021) 2302–](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref227)\n[2315.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref227)\n\n[[228] P. Marques, R. Garcia, L. Kulay, F. Freire, J. Clean. Prod. 229 (2019)](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref228)\n[787–794.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref228)\n\n[[229] X. Xia, P. Li, Sci. Total Environ. 814 (2022) 152870.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref229)\n\n[[230] Q. Wang, M. Xue, B.L. Lin, Z. Lei, Z. Zhang, J. Clean. Prod. 275](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref230)\n[(2020) 123061.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref230)\n\n[[231] E. Yoo, M. Kim, H.H. Song, Int. J. Hydrogen Energy 43 (2018) 19267–](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref231)\n[19278.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref231)\n\n[[232] C. Bauer, J. Hofer, H.-J. Althsus, A.D. Duce, A. Simons, Appl. Energy](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref232)\n[157 (2015) 871–883.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref232)\n\n[[233] J. Peters, D. Buchholz, S. Passerini, M. Weil, Energy Environ. Sci. 9](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref233)\n[(2016) 1744–1751.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref233)\n\n[[234] Y.Deng,J.Li,T.Li,X.Gao, C.Yuan,J.PowerSources343(2017) 284–295.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref234)\n\n[[235] M. Zackrisson, K. Fransson, J. Hildenbrand, G. Lampic, C. O'dwyer,](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref235)\n[J. Clean. Prod. 135 (2016) 299–311.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref235)\n\n[[236] L. Roberson, J.P. Helveston, Environ. Res. Lett. 17 (2022) 084003.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref236)\n\n[[237] Polaris Energy Storage Network, 2022. https://news.bjx.com.cn/html/](https://news.bjx.com.cn/html/20221219/1277150.shtml)\n[20221219/1277150.shtml. (Accessed 3 February 2023).](https://news.bjx.com.cn/html/20221219/1277150.shtml)\n\n[[238] Q. Hoarau, E. Lorang, Energy Pol. 162 (2022) 112770.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref238)\n\n[[239] Central Government of the People's Republic of China, 2018. http://www.](http://www.gov.cn/xinwen/2018-02/26/content_5268875.htm)\n[gov.cn/xinwen/2018-02/26/content_5268875.htm. (Accessed 2 March](http://www.gov.cn/xinwen/2018-02/26/content_5268875.htm)\n2023).\n\n[[240] Polaris Energy Storage Network, 2022. https://news.bjx.com.cn/html/](https://news.bjx.com.cn/html/20221219/1277150.shtml)\n[20221219/1277150.shtml. (Accessed 3 February 2023).](https://news.bjx.com.cn/html/20221219/1277150.shtml)\n\n[[241] S. Matsumoto, Resour. Conserv. Recycl. 55 (2011) 325–334.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref241)\n\n[[242] J. Hiratsuka, N. Sato, H. Yoshida, J Mater Cycles Waste 16 (2014) 21–30.](http://refhub.elsevier.com/S2468-0257(23)00126-7/sref242)\n\n[243] Viewpoint: Battery Recycling Key to Net Zero in Japan, Argus, 2022.\n\n[https://www.argusmedia.com/en/news/2400483-viewpoint-battery-](https://www.argusmedia.com/en/news/2400483-viewpoint-battery-recycling-key-to-net-zero-in-japan)\n[recycling-key-to-net-zero-in-japan. (Accessed 3 January 2023).](https://www.argusmedia.com/en/news/2400483-viewpoint-battery-recycling-key-to-net-zero-in-japan)\n\n@29@\n''' -data = { - "text": text, - "arango_db_name": 'base', - "arango_id": 'sci_articles/test', - "is_sci": True -} - -# Send the data to the FastAPI server -url = "http://192.168.1.11:8100/summarise_document" -response = requests.post(url, json=data) -print(response.json()) \ No newline at end of file +response = requests.post( + "http://localhost:11434/api/chat", + json={ + "model": "qwen3_4b_32k", + "messages": [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "What is the capital of France?"} + ], + "think": True, + "stream": False + } +) +print(response.content.decode('utf-8')) \ No newline at end of file diff --git a/test_ tortoise.py b/test_ tortoise.py deleted file mode 100644 index df92d0a..0000000 --- a/test_ tortoise.py +++ /dev/null @@ -1,31 +0,0 @@ -from TTS.api import TTS -import torch -from datetime import datetime -tts = TTS("tts_models/en/multi-dataset/tortoise-v2") -device = torch.device("cuda" if torch.cuda.is_available() else "cpu") -tts.to(device) -text="There is, therefore, an increasing need to understand BEVs from a systems perspective. This involves an in-depth consideration of the environmental impact of the product using life cycle assessment (LCA) as well as taking a broader 'circular economy' approach. On the one hand, LCA is a means of assessing the environmental impact associated with all stages of a product's life from cradle to grave: from raw material extraction and processing to the product's manufacture to its use in everyday life and finally to its end of life." - - -# cloning `lj` voice from `TTS/tts/utils/assets/tortoise/voices/lj` -# with custom inference settings overriding defaults. -time_now = datetime.now().strftime("%Y%m%d%H%M%S") -output_path = f"output/tortoise_{time_now}.wav" -tts.tts_to_file(text, - file_path=output_path, - voice_dir="voices", - speaker="test", - split_sentences=False, # Change to True if context is not enough - num_autoregressive_samples=20, - diffusion_iterations=50) - -# # Using presets with the same voice -# tts.tts_to_file(text, -# file_path="output.wav", -# voice_dir="path/to/tortoise/voices/dir/", -# speaker="lj", -# preset="ultra_fast") - -# # Random voice generation -# tts.tts_to_file(text, -# file_path="output.wav") \ No newline at end of file diff --git a/test_and_view.py b/test_and_view.py new file mode 100644 index 0000000..41030ae --- /dev/null +++ b/test_and_view.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +""" +Test LLM Server and View Results + +This script sends a test document to the LLM server for summarization, +waits for processing to complete, and displays the results. + +Usage: + python test_and_view.py [--wait SECONDS] [--retries COUNT] + +Options: + --wait SECONDS Number of seconds to wait between polling attempts (default: 5) + --retries COUNT Maximum number of polling attempts (default: 20) +""" + +import requests +import json +import time +import os +import argparse +import sys +from _arango import ArangoDB + + +def send_test_document(): + """Send a test document to the LLM server for summarization.""" + print("Sending test document to LLM server...") + + # Define server endpoint + url = "http://localhost:8100/summarise_document" + + # Create a sample document with unique ID based on timestamp + doc_id = f"test_articles/climate_impact_{int(time.time())}" + + sample_document = { + "arango_doc": { + "text": """ + The Impact of Climate Change on Coral Reefs + + Climate change has significantly affected marine ecosystems worldwide, with coral reefs being among the most vulnerable. + Rising sea temperatures have led to increased coral bleaching events, where corals expel their symbiotic algae, + leading to whitening and potential death. Studies show that even a 1-2°C increase in water temperature + can trigger mass bleaching events. Additionally, ocean acidification caused by increased CO2 absorption + makes it difficult for corals to build their calcium carbonate skeletons. + + Recent research by Johnson et al. (2023) suggests that if current trends continue, we may lose up to 90% + of coral reefs by 2050. However, some corals have shown remarkable resilience. Certain species can adapt + to higher temperatures through a process called adaptive bleaching, where they exchange their algal symbionts + for more heat-tolerant varieties. Conservation efforts focused on cultivating these resilient species may + provide hope for reef preservation. + """, + "chunks": [] + }, + "arango_db_name": "test_db", + "arango_id": doc_id, + "is_sci": True + } + + try: + # Send request to server + response = requests.post(url, json=sample_document) + + if response.status_code == 200: + print("✓ Request accepted by server") + print(f"Document ID: {doc_id}") + return { + "db_name": "test_db", + "doc_id": doc_id + } + else: + print(f"✗ Error: {response.status_code}") + print(response.text) + return None + except Exception as e: + print(f"✗ Connection error: {e}") + return None + + +def poll_for_results(doc_info, max_retries=20, wait_time=5): + """Poll the database until the document is summarized.""" + if not doc_info: + return None + + db_name = doc_info["db_name"] + doc_id = doc_info["doc_id"] + + print(f"\nPolling for results in {db_name}/{doc_id}...") + print(f"Will check every {wait_time} seconds, up to {max_retries} times.") + + arango = ArangoDB(db_name=db_name) + + for attempt in range(max_retries): + print(f"Attempt {attempt+1}/{max_retries}... ", end="", flush=True) + + try: + # Get the document from ArangoDB + document = arango.get_document(doc_id) + + # Check if the document has been summarized + if document and "summary" in document: + print("✓ Document summary found!") + return document + + print("Document exists but no summary yet") + time.sleep(wait_time) + + except Exception as e: + print(f"Error: {e}") + time.sleep(wait_time) + + print("\n✗ Summarization not completed after maximum retries.") + return None + + +def display_results(document): + """Display the summarization results.""" + if not document: + print("\nNo results to display") + return + + print("\n" + "=" * 80) + print(f"RESULTS FOR DOCUMENT: {document.get('_id', 'Unknown')}") + print("=" * 80) + + # Document summary + print("\n📄 DOCUMENT SUMMARY") + print("-" * 80) + print(document["summary"]["text_sum"]) + + # Model info if available + if "meta" in document["summary"]: + meta = document["summary"]["meta"] + model = meta.get("model", "Unknown") + temp = meta.get("temperature", "Unknown") + print(f"\nGenerated using: {model} (temperature: {temp})") + + # Check for summarized chunks + if "chunks" in document and document["chunks"]: + summarized_chunks = [chunk for chunk in document["chunks"] if "summary" in chunk] + print(f"\n🧩 CHUNK SUMMARIES ({len(summarized_chunks)}/{len(document['chunks'])} chunks processed)") + + for i, chunk in enumerate(summarized_chunks): + print("\n" + "-" * 80) + print(f"Chunk {i+1}:") + print("-" * 80) + print(chunk["summary"]) + + # Display tags + if "tags" in chunk and chunk["tags"]: + print("\nTags:", ", ".join(chunk["tags"])) + + # Display references + if "references" in chunk and chunk["references"]: + print("\nReferences:") + for ref in chunk["references"]: + print(f"- {ref}") + + print("\n" + "=" * 80) + + # Provide links to web views + print("\nView in browser:") + print("- HTML view: http://localhost:8100/html_results") + print("- JSON view: http://localhost:8100/view_results") + + +def check_server_status(): + """Check if the LLM server is running.""" + try: + response = requests.get("http://localhost:8100/latest_result", timeout=2) + return True + except: + return False + + +def main(): + parser = argparse.ArgumentParser(description='Test LLM server and view results') + parser.add_argument('--wait', type=int, default=5, help='Seconds to wait between polling attempts') + parser.add_argument('--retries', type=int, default=20, help='Maximum number of polling attempts') + args = parser.parse_args() + + print("LLM Server Test and View") + print("======================\n") + + # Check if server is running + if not check_server_status(): + print("ERROR: Cannot connect to LLM server at http://localhost:8100") + print("Make sure the server is running before continuing.") + sys.exit(1) + + print("✓ Server is running\n") + + # Send test document + doc_info = send_test_document() + if not doc_info: + print("Failed to send test document") + sys.exit(1) + + print("\n⏳ Processing document...") + print("(This may take some time depending on model size and document complexity)") + + # Poll for results + result = poll_for_results(doc_info, max_retries=args.retries, wait_time=args.wait) + + # Display results + display_results(result) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/test_fairseq.py b/test_fairseq.py deleted file mode 100644 index 8cd9eff..0000000 --- a/test_fairseq.py +++ /dev/null @@ -1,51 +0,0 @@ -from fairseq.checkpoint_utils import load_model_ensemble_and_task_from_hf_hub -from fairseq.models.text_to_speech.hub_interface import TTSHubInterface -from fairseq import utils -import nltk -import torch - -# Download the required NLTK resource -nltk.download('averaged_perceptron_tagger') - -# Model loading -models, cfg, task = load_model_ensemble_and_task_from_hf_hub( - "facebook/fastspeech2-en-ljspeech", - arg_overrides={"vocoder": "hifigan", "fp16": False} -) - -# Set device -device = torch.device("cuda" if torch.cuda.is_available() else "cpu") - -# Move all models to the correct device -for model in models: - model.to(device) - -# Update configuration and build generator after moving models -TTSHubInterface.update_cfg_with_data_cfg(cfg, task.data_cfg) -generator = task.build_generator(models, cfg) - -# Ensure the vocoder is on the correct device -generator.vocoder.model.to(device) - -# Define your text -text = """Hi there, thanks for having me! My interest in electric cars really started back when I was a teenager...""" - -# Convert text to model input -sample = TTSHubInterface.get_model_input(task, text) - -# Recursively move all tensors in sample to the correct device -sample = utils.move_to_cuda(sample) if torch.cuda.is_available() else sample - - - -# Generate speech -wav, rate = TTSHubInterface.get_prediction(task, models[0], generator, sample) - -from scipy.io.wavfile import write - -# If wav is a tensor, convert it to a NumPy array -if isinstance(wav, torch.Tensor): - wav = wav.cpu().numpy() - -# Save the audio to a WAV file -write('output_fair.wav', rate, wav) \ No newline at end of file diff --git a/test_highlight.py b/test_highlight.py deleted file mode 100644 index b1a13e8..0000000 --- a/test_highlight.py +++ /dev/null @@ -1,91 +0,0 @@ -import asyncio -import re -from pdf_highlighter import Highlighter -from _chromadb import ChromaDB -from _llm import LLM -import ollama -from colorprinter.print_color import * -from concurrent.futures import ThreadPoolExecutor - -# Wrap the synchronous generate method -async def async_generate(llm, prompt): - loop = asyncio.get_event_loop() - with ThreadPoolExecutor() as pool: - return await loop.run_in_executor(pool, llm.generate, prompt) - - -# Define the main asynchronous function to highlight the PDFs -async def highlight_pdf(data): - # Use the highlight method to highlight the relevant sentences in the PDFs - highlighted_pdf_buffer = await highlighter.highlight( - data=data, zero_indexed_pages=True # Pages are zero-based (e.g., 0, 1, 2, ...) - ) - - # Save the highlighted PDF to a new file - with open("highlighted_combined_documents.pdf", "wb") as f: - f.write(highlighted_pdf_buffer.getbuffer()) - print_green("PDF highlighting completed successfully!") - - -# Initialize ChromaDB client -chromadb = ChromaDB() - -# Define the query to fetch relevant text snippets and metadata from ChromaDB -query = "How are climate researchers advocating for change in the society?" - - -# Perform the query on ChromaDB -result = chromadb.query(query, collection="sci_articles", n_results=5) -# Use zip to combine the lists into a list of dictionaries -results = [ - {"id": id_, "metadata": metadata, "document": document, "distance": distance} - for id_, metadata, document, distance in zip( - result["ids"][0], - result["metadatas"][0], - result["documents"][0], - result["distances"][0], - ) -] - -for r in results: - print_rainbow(r["metadata"]) - print_yellow(type(r["metadata"]['pages'])) -# Ask a LLM a question about the text snippets -llm = LLM(model="small") -documents_string = "\n\n---\n\n".join(result["documents"][0]) -answer = llm.generate( - f'''{query} Write your answer from the information below?\n\n"""{documents_string}"""\n\n{query}''' -) -print_green(answer) -# Now you want to highlight relevant information in the PDFs to understand what the LLM is using! - -# Each result from ChromaDB contains the PDF filename and the pages where the text is found -data = [] -for result in results: - pages = result["metadata"].get("pages") - try: - pages = [int(pages)] - except: - # Use re to extraxt the page numbers separated by commas - pages = list(map(int, re.findall(r"\d+", pages))) - - data.append( - { - "user_input": query, - "pdf_filename": result["metadata"]["_id"], - "pages": pages, - 'chunk': result['document'] - } - ) - -# Initialize the Highlighter -highlighter = Highlighter( - llm=llm, # Pass the LLM to the Highlighter - comment=False, # Enable comments to understand the context - use_llm=False -) - - - -# Run the main function using asyncio -asyncio.run(highlight_pdf(data)) diff --git a/test_llm_server.py b/test_llm_server.py new file mode 100644 index 0000000..04bc1ac --- /dev/null +++ b/test_llm_server.py @@ -0,0 +1,191 @@ +import requests +import json +import time +from _arango import ArangoDB # Import ArangoDB client to fetch results + +def test_summarize_document(): + """ + Test the document summarization functionality of the LLM server by sending a POST request + to the summarize_document endpoint. + + This function creates a sample document, sends it to the LLM server, and then polls for results. + """ + print("Testing document summarization...") + + # Define server endpoint + url = "http://localhost:8100/summarise_document" + + # Create a sample document + sample_document = { + "arango_doc": { + "text": """ + The Impact of Climate Change on Coral Reefs + + Climate change has significantly affected marine ecosystems worldwide, with coral reefs being among the most vulnerable. + Rising sea temperatures have led to increased coral bleaching events, where corals expel their symbiotic algae, + leading to whitening and potential death. Studies show that even a 1-2°C increase in water temperature + can trigger mass bleaching events. Additionally, ocean acidification caused by increased CO2 absorption + makes it difficult for corals to build their calcium carbonate skeletons. + + Recent research by Johnson et al. (2023) suggests that if current trends continue, we may lose up to 90% + of coral reefs by 2050. However, some corals have shown remarkable resilience. Certain species can adapt + to higher temperatures through a process called adaptive bleaching, where they exchange their algal symbionts + for more heat-tolerant varieties. Conservation efforts focused on cultivating these resilient species may + provide hope for reef preservation. + """, + "chunks": [] + }, + "arango_db_name": "test_db", + "arango_id": "articles/test_article", + "is_sci": True + } + + # Send request to server + print("Sending document to server for summarization...") + response = requests.post(url, json=sample_document) + + if response.status_code == 200: + print("Request accepted. Response:", response.json()) + + # Save values for checking results later + return { + "db_name": sample_document["arango_db_name"], + "doc_id": sample_document["arango_id"] + } + else: + print(f"Error: {response.status_code}") + print(response.text) + return None + +def test_summarize_chunks(): + """ + Test the chunk summarization functionality directly by creating a sample document with chunks. + + In a real application, you'd typically query the results from the database after processing. + """ + print("\nTesting chunk summarization example...") + + # Sample document with chunks + sample_document_with_chunks = { + "arango_doc": { + "text": "", + "chunks": [ + { + "text": "Climate change has significantly affected marine ecosystems worldwide, with coral reefs being among the most vulnerable. Rising sea temperatures have led to increased coral bleaching events.", + "pages": [1] + }, + { + "text": "Studies by Smith et al. [1] show that even a 1-2°C increase in water temperature can trigger mass bleaching events. Additionally, ocean acidification makes it difficult for corals to build their calcium carbonate skeletons.", + "pages": [1, 2] + } + ] + }, + "arango_db_name": "test_db", + "arango_id": "interviews/test_interview", + "is_sci": False + } + + url = "http://localhost:8100/summarise_document" + print("Sending document with chunks for summarization...") + response = requests.post(url, json=sample_document_with_chunks) + + if response.status_code == 200: + print("Request accepted. Response:", response.json()) + return { + "db_name": sample_document_with_chunks["arango_db_name"], + "doc_id": sample_document_with_chunks["arango_id"] + } + else: + print(f"Error: {response.status_code}") + print(response.text) + return None + +def poll_for_results(doc_info, max_retries=10, wait_time=5): + """ + Poll the ArangoDB database to check if the document has been summarized. + + Args: + doc_info (dict): Dictionary containing db_name and doc_id + max_retries (int): Maximum number of polling attempts + wait_time (int): Time to wait between polling attempts (seconds) + + Returns: + dict or None: The document with summaries if available, None otherwise + """ + if not doc_info: + return None + + db_name = doc_info["db_name"] + doc_id = doc_info["doc_id"] + + print(f"\nPolling for results in {db_name}/{doc_id}...") + + arango = ArangoDB(db_name=db_name) + + for attempt in range(max_retries): + print(f"Attempt {attempt+1}/{max_retries}...") + + try: + # Get the document from ArangoDB + document = arango.get_document(doc_id) + + # Check if the document has been summarized + if document and "summary" in document: + print("✓ Document summary found!") + print("-" * 50) + print("Document Summary:") + print("-" * 50) + print(document["summary"]["text_sum"]) + print("-" * 50) + + # Check if chunks have been summarized + if "chunks" in document and document["chunks"] and "summary" in document["chunks"][0]: + print("✓ Chunk summaries found!") + print("-" * 50) + print("First Chunk Summary:") + print("-" * 50) + print(document["chunks"][0]["summary"]) + print("-" * 50) + if len(document["chunks"]) > 1: + print("Tags:", document["chunks"][0]["tags"]) + + return document + + # If we haven't found summaries yet, wait and try again + time.sleep(wait_time) + + except Exception as e: + print(f"Error checking document: {e}") + time.sleep(wait_time) + + print("❌ Summarization not completed after maximum retries.") + return None + +if __name__ == "__main__": + print("LLM Server Test Script") + print("=====================\n") + + # Test if server is running + try: + requests.get("http://localhost:8100") + print("Server is running at http://localhost:8100\n") + except requests.exceptions.ConnectionError: + print("ERROR: Cannot connect to server at http://localhost:8100") + print("Make sure the server is running before continuing.\n") + exit(1) + + # Run tests and store document info for polling + doc1_info = test_summarize_document() + time.sleep(2) # Brief pause between tests + doc2_info = test_summarize_chunks() + + print("\nWaiting for background tasks to complete...") + print("This may take some time depending on LLM response speed.") + + # Poll for results (with longer wait time for the first document which needs to be chunked) + poll_for_results(doc1_info, max_retries=20, wait_time=6) + poll_for_results(doc2_info, max_retries=12, wait_time=5) + + print("\nTest script completed.") + print("If you didn't see results, the background tasks might still be processing.") + print("You can run this script again later to check, or query the database directly.") \ No newline at end of file diff --git a/test_ollama_client.py b/test_ollama_client.py deleted file mode 100644 index 85f734c..0000000 --- a/test_ollama_client.py +++ /dev/null @@ -1,38 +0,0 @@ -import os -import base64 -from ollama import Client, ChatResponse -import env_manager -from colorprinter.print_color import * -import httpx - -env_manager.set_env() - -# Encode the credentials -auth = httpx.BasicAuth( - username='lasse', password=os.getenv("LLM_API_PWD_LASSE") -) -client = httpx.Client(auth=auth) -client = Client( - host="http://localhost:11434", - headers={ - "X-Chosen-Backend": "backend_ollama" # Add this header to specify the chosen backend - }, - auth=auth -) -response = client.chat( - model=os.getenv("LLM_MODEL"), - messages=[ - { - "role": "user", - "content": "Why is the sky blue?", - }, - ], -) - -# Print the response headers - -# Print the chosen backend from the headers -print("Chosen Backend:", response.headers.get("X-Chosen-Backend")) - -# Print the response content -print(response) \ No newline at end of file diff --git a/test_ollama_image.py b/test_ollama_image.py deleted file mode 100644 index 638771c..0000000 --- a/test_ollama_image.py +++ /dev/null @@ -1,9 +0,0 @@ -from _llm import LLM - -llm = LLM() - -image = '/home/lasse/sci/test_image.png' -image_bytes = open(image, 'rb').read() -print(type(image_bytes)) -response = llm.generate('What is this?', images=[image_bytes]) -print(response) \ No newline at end of file diff --git a/test_research.py b/test_research.py deleted file mode 100644 index 666a30c..0000000 --- a/test_research.py +++ /dev/null @@ -1,206 +0,0 @@ -from _llm import LLM -from _arango import ArangoDB -from _chromadb import ChromaDB -from streamlit_chatbot import Bot -from pydantic import BaseModel, Field -from typing import Dict, List, Tuple -from colorprinter.print_color import * -from projects_page import Project -from _base_class import StreamlitBaseClass -from prompts import get_tools_prompt - -class ResearchBase(Bot): - def __init__(self, username, **args): - super().__init__(username=username, **args) - self.llm = LLM() - self.arango = ArangoDB() - self.chromadb = ChromaDB() - self.messages = [] - - def start(self): - self.messages = [{"role": "system", "message": self.llm.system_message}] - if self.llm.model in ["small", "standard", "vision", "reasoning", "tools"]: - self.llm.get_model(self.llm.model) - - -class ResearchManager(ResearchBase): - def __init__(self, username, project=None): - super().__init__(username=username, project=project) - self.llm.system_message = "You are an assistant helping a journalist writing a report based on extensive research." - self.llm.model = "reasoning" - self.start() - - def generate_plan(self, question): - query = f""" - A journalist wants to get a report that answers this question: "{question}" - THIS IS *NOT* A QUESTION YOU CAN ANSWER! Instead, you need to make a plan for how to answer this question. - Include what type of information you need from what available sources. - Available sources are: - - Scientific articles - - Other articles the journalists has gathered, such as blog posts, news articles, etc. - - The journalists own notes. - - Transcribed interviews (already done, you can't produce new ones). - All of the above sources are available in a database, but you need to specify what you need. Be as precise as possible. - As you don't have access to the sources, include steps to retrieve excerpts from articles and retrieve those that might be interesting. - Also include steps to verify the information. - Make the plan easy to follow and structured. - Remember: You are not answering the question, you are making *a plan* for how to answer the question using the available sources. - """ - query += f"\nTo help you understand the subject, here is a summary of notes the journalist has done: {project.notes_summary}" - query += """Please structure the plan like: - ## Step 1: - - Task1: Description of task - - Task2: Description of task - ## Step 2: - - Task1: Description of task - - Task2: Description of task - Etc, with as many steps and tasks as needed. - """ - return self.llm.generate(query).content - - -class ResearchAssistant(ResearchBase): - def __init__(self, username): - super().__init__(username) - self.llm.system_message = "You are a Research Assistant" - self.start() - - -class HelperBot(ResearchBase): - def __init__(self, username): - super().__init__(username) - self.llm.system_message = "You are helping a researcher to structure a text. You will get a text and make it into structured data. Make sure not to change the meaning of the text and keeps all the details in the subtasks." - self.llm.model = "small" - self.start() - - def make_structured_plan(self, text, question=None): - - class Plan(BaseModel): - steps: Dict[str, List[Tuple[str, str]]] = Field( - description="Structured plan represented as steps with their corresponding tasks or facts", - example={ - "Step 1: Gather Existing Materials": [ - ("Task 1", "Description of task"), - ("Task 2", "Description of task"), - ], - "Step 2: Extract Relevant Information": [ - ("Task 1", "Description of task"), - ("Task 2", "Description of task"), - ], - }, - ) - - if question: - query = f''' This is a proposed plan for how to write a report on "{question}":\n"""{text}"""\nPlease make the plan into structured data with subtasks. Make sure to keep all the details in the subtasks.''' - else: - query = f''' This is a proposed plan for how to write a report:\n"""{text}"""\nPlease make the plan into structured data with subtasks. Make sure to keep all the details in the subtasks.''' - response = self.llm.generate(query, format=Plan.model_json_schema()) - print(response) - structured_response = Plan.model_validate_json(response.content) - print('PLAN') - print_rainbow(structured_response) - print() - return structured_response - - -class ToolBot(ResearchBase): - def __init__(self, username, tools: list): - super().__init__(username, tools=tools) - self.start() - tools_names = [tool.__name__ for tool in self.tools] - tools_name_string = "\n– ".join(tools_names) - self.llm = LLM( - temperature=0, - system_message=f""" - You are an helpful assistant with tools. The tools you can choose from are: - {tools_name_string} - Your task is to choose one or multiple tools to answering a user's query. - DON'T come up with your own tools, only use the ones provided. - """, - chat=False, - model="tools", - ) - - def propose_tools(self, task): - query = f"""What tool(s) would you use to help with this task: - "{task}" - Answer in a structured way using the tool_calls field! - """ - query = get_tools_prompt(task) - response = self.llm.generate(query) - print_yellow('Model:', self.llm.model) - print_rainbow(response) - return response.tool_calls - -if __name__ == "__main__": - - base = StreamlitBaseClass(username="lasse") - project = Project( - username="lasse", - project_name="Monarch butterflies", - user_arango=base.get_arango(), - ) - rm = ResearchManager(username="lasse", project=project) - tb = ToolBot( - username="lasse", - tools=[ - "fetch_science_articles_tool", - "fetch_notes_tool", - "fetch_other_documents_tool", - "fetch_science_articles_and_other_documents_tool", - ] - ) - # ra = ResearchAssistant(username="lasse") - hb = HelperBot(username="lasse") - - question = "Tell me five interesting facts about the Monarch butterfly" - - # Generate plan - plan = rm.generate_plan(question) -# -- Example of what a plan can look like -- -# plan = """## Step-by-Step Plan for Answering the Question: "Tell Me Five Interesting Facts About the Monarch Butterfly" - -# ### Step 1: Gather and Organize Existing Materials -# - **Task 1:** Retrieve all existing materials related to Monarch butterflies from the database using keywords such as "Monarch butterfly migration," "habitat loss," "milkweed," "insecticides," "climate change," "Monarch Butterfly Biosphere Reserve," and "migration patterns." -# - **Task 2:** Categorize these materials into scientific articles, other articles (blogs, news), own notes, and transcribed interviews for easy access. - -# ### Step 2: Extract Relevant Excerpts -# - **Task 1:** From the retrieved scientific articles, extract information on migration patterns, genetic studies, and population decline factors. -# - **Task 2:** From blogs and news articles, look for interesting anecdotes or recent findings about conservation efforts and unique behaviors of Monarch butterflies. - -# ### Step 3: Identify Potential Interesting Facts -# - **Task 1:** Review the extracted excerpts to identify potential facts such as migration patterns, threats faced by Monarchs, population decline statistics, conservation efforts, and unique behaviors. -# - **Task 2:** Compile a list of five compelling and accurate facts based on the extracted information. - -# ### Step 4: Verify Information -# - **Task 1:** Cross-check each fact with multiple sources to ensure accuracy. For example, verify migration details across scientific articles and recent news reports. -# - **Task 2:** Look for consensus among sources regarding population trends and threats to Monarchs. - -# ### Step 5: Structure the Report -# - **Task 1:** Organize the five selected facts into a coherent structure, ensuring each fact is clearly explained and engaging. -# - **Task 2:** Incorporate quotes or statistics from sources to add depth and credibility to each fact. - -# ### Step 6: Review and Finalize -# - **Task 1:** Proofread the report for clarity, accuracy, and grammar. -# - **Task 2:** Ensure all information is presented in an engaging manner suitable for a journalistic report. - -# This plan ensures that the journalist systematically gathers, verifies, and presents five interesting facts about Monarch butterflies, providing a comprehensive and accurate report. -# """ - #print_blue(plan) - if "" in plan: - plan = plan.split("")[1] - - # Make structured plan - structured_plan = hb.make_structured_plan(plan, question) - - - for step, tasks in structured_plan.steps.items(): - print_blue("\n### Step:", step) - for task in tasks: - - print_blue("Task:", task[0]) - print_yellow(task[1]) - - tools = tb.propose_tools(task[1]) - print_green("Tools:", tools) - print('\n') diff --git a/test_server.py b/test_server.py new file mode 100644 index 0000000..3cbb5a7 --- /dev/null +++ b/test_server.py @@ -0,0 +1,123 @@ +import requests +import json +import time + +def test_summarize_document(): + """ + Test the document summarization functionality of the LLM server by sending a POST request + to the summarize_document endpoint. + + This function creates a sample document, sends it to the LLM server, and then polls for results. + """ + print("Testing document summarization...") + + # Define server endpoint + url = "http://localhost:8100/summarise_document" + + # Create a sample document + sample_document = { + "arango_doc": { + "text": """ + The Impact of Climate Change on Coral Reefs + + Climate change has significantly affected marine ecosystems worldwide, with coral reefs being among the most vulnerable. + Rising sea temperatures have led to increased coral bleaching events, where corals expel their symbiotic algae, + leading to whitening and potential death. Studies show that even a 1-2°C increase in water temperature + can trigger mass bleaching events. Additionally, ocean acidification caused by increased CO2 absorption + makes it difficult for corals to build their calcium carbonate skeletons. + + Recent research by Johnson et al. (2023) suggests that if current trends continue, we may lose up to 90% + of coral reefs by 2050. However, some corals have shown remarkable resilience. Certain species can adapt + to higher temperatures through a process called adaptive bleaching, where they exchange their algal symbionts + for more heat-tolerant varieties. Conservation efforts focused on cultivating these resilient species may + provide hope for reef preservation. + """, + "chunks": [] + }, + "arango_db_name": "test_db", + "arango_id": "articles/test_article", + "is_sci": True + } + + # Send request to server + print("Sending document to server for summarization...") + response = requests.post(url, json=sample_document) + + if response.status_code == 200: + print("Request accepted. Response:", response.json()) + + # In a real-world scenario, you might poll the database to see when the summary is ready + print("Note: In a real implementation, you would check the database for results.") + print("Since this is just a test, we're showing how the request works.") + + return True + else: + print(f"Error: {response.status_code}") + print(response.text) + return False + +def test_summarize_chunks(): + """ + Test the chunk summarization functionality directly by creating a sample document with chunks. + + In a real application, you'd typically query the results from the database after processing. + """ + print("\nTesting chunk summarization example...") + + # Sample document with chunks + sample_document_with_chunks = { + "arango_doc": { + "text": "", + "chunks": [ + { + "text": "Climate change has significantly affected marine ecosystems worldwide, with coral reefs being among the most vulnerable. Rising sea temperatures have led to increased coral bleaching events.", + "pages": [1] + }, + { + "text": "Studies by Smith et al. [1] show that even a 1-2°C increase in water temperature can trigger mass bleaching events. Additionally, ocean acidification makes it difficult for corals to build their calcium carbonate skeletons.", + "pages": [1, 2] + } + ] + }, + "arango_db_name": "test_db", + "arango_id": "interviews/test_interview", + "is_sci": False + } + + # In a real implementation, you would: + # 1. Send this document to the server + # 2. Check the database later to see the summarized chunks + + url = "http://localhost:8100/summarise_document" + print("Sending document with chunks for summarization...") + response = requests.post(url, json=sample_document_with_chunks) + + if response.status_code == 200: + print("Request accepted. Response:", response.json()) + return True + else: + print(f"Error: {response.status_code}") + print(response.text) + return False + +if __name__ == "__main__": + print("LLM Server Test Script") + print("=====================\n") + + # Test if server is running + try: + requests.get("http://localhost:8100") + print("Server is running at http://localhost:8100\n") + except requests.exceptions.ConnectionError: + print("ERROR: Cannot connect to server at http://localhost:8100") + print("Make sure the server is running before continuing.\n") + exit(1) + + # Run tests + test_summarize_document() + time.sleep(2) # Brief pause between tests + test_summarize_chunks() + + print("\nTest script completed. Check your ArangoDB instance for results.") + print("Note: Document summarization happens in background tasks, so results may not be immediate.") + print("You would typically query the database to see the updated documents with summaries.") diff --git a/test_tts.py b/test_tts.py deleted file mode 100644 index 0ce5402..0000000 --- a/test_tts.py +++ /dev/null @@ -1,45 +0,0 @@ -import torch -from TTS.api import TTS -from datetime import datetime -# Get device -from TTS.tts.utils.speakers import SpeakerManager -device = "cuda" if torch.cuda.is_available() else "cpu" - - -# Init TTS -tts = TTS("tts_models/multilingual/multi-dataset/xtts_v2").to(device) - - -exit() - - - - -text = """Hi there, thanks for having me! My interest in electric cars really started back when I was a teenager. I remember learning about the history of EVs and how they've been around since the late 1800s, even before gasoline cars took over. The fact that these vehicles could run on electricity instead of fossil fuels just fascinated me. - -Then, in the 90s, General Motors introduced the EV1 - it was a real game-changer. It showed that electric cars could be practical and enjoyable to drive. And when Tesla came along with their Roadster in 2007, proving that EVs could have a long range, I was hooked. - -But what really sealed my interest was learning about the environmental impact of EVs. They produce zero tailpipe emissions, which means they can help reduce air pollution and greenhouse gas emissions. That's something I'm really passionate about. -""" -text_se = """Antalet bilar ger dock bara en del av bilden. För att förstå bilberoendet bör vi framför allt titta på hur mycket bilarna faktiskt används. -Stockholmarnas genomsnittliga körsträcka med bil har minskat sedan millennieskiftet. Den är dock lägre i Göteborg och i Malmö. -I procent har bilanvändningen sedan år 2000 minskat lika mycket i Stockholm och Malmö, 9 procent. I Göteborg är minskningen 13 procent, i riket är minskningen 7 procent.""" -# Run TTS -# ❗ Since this model is multi-lingual voice cloning model, we must set the target speaker_wav and language -# Text to speech list of amplitude values as output -#wav = tts.tts(text=text, speaker_wav="my/cloning/audio.wav", language="en") -# Text to speech to a file -time_now = datetime.now().strftime("%Y%m%d%H%M%S") -output_path = f"output/tts_{time_now}.wav" -tts.tts_to_file(text=text, speaker_wav='voices/test/test_en.wav', language="en", file_path=output_path) - - - - -# api = TTS("tts_models/se/fairseq/vits") - -# api.tts_with_vc_to_file( -# text_se, -# speaker_wav="test_audio_se.wav", -# file_path="output_se.wav" -# ) \ No newline at end of file diff --git a/test_tts_call_server.py b/test_tts_call_server.py deleted file mode 100644 index 704ea77..0000000 --- a/test_tts_call_server.py +++ /dev/null @@ -1,22 +0,0 @@ -import requests - -# Define the server URL -server_url = "http://localhost:5002/api/tts" - -# Define the payload -payload = { - "text": "It took me quite a long time to develop a voice, and now that I have it I'm not going to be silent.", - "speaker": "Ana Florence", - "language": "en", - "split_sentences": True -} - -# Send the request to the TTS server -response = requests.post(server_url, json=payload) - -# Save the response audio to a file -if response.status_code == 200: - with open("output.wav", "wb") as f: - f.write(response.content) -else: - print(f"Error: {response.status_code}") \ No newline at end of file diff --git a/tts_save_speaker.py b/tts_save_speaker.py deleted file mode 100644 index 19f64f3..0000000 --- a/tts_save_speaker.py +++ /dev/null @@ -1,33 +0,0 @@ -from TTS.tts.configs.tortoise_config import TortoiseConfig -from TTS.tts.models.tortoise import Tortoise -import torch -import os -import torchaudio - -# Initialize Tortoise model -config = TortoiseConfig() -model = Tortoise.init_from_config(config) -model.load_checkpoint(config, checkpoint_dir="tts_models/en/multi-dataset/tortoise-v2", eval=True) - -# Move model to GPU if available -device = torch.device("cuda" if torch.cuda.is_available() else "cpu") -print(device) -model.to(device) - -# Define the text and voice directory -text = "There is, therefore, an increasing need to understand BEVs from a systems perspective." -voice_dir = "voices" -speaker = "test" - -# Load voice samples -voice_samples = [] -for file_name in os.listdir(os.path.join(voice_dir, speaker)): - file_path = os.path.join(voice_dir, speaker, file_name) - waveform, sample_rate = torchaudio.load(file_path) - voice_samples.append(waveform) - -# Get conditioning latents -conditioning_latents = model.get_conditioning_latents(voice_samples) - -# Save conditioning latents to a file -torch.save(conditioning_latents, "conditioning_latents.pth") \ No newline at end of file diff --git a/utils.py b/utils.py index 11b8557..f7ca79b 100644 --- a/utils.py +++ b/utils.py @@ -13,4 +13,96 @@ def fix_key(_key: str) -> str: Returns: str: The sanitized key with disallowed characters replaced by underscores. """ - return re.sub(r"[^A-Za-z0-9_\-\.@()+=;$!*\'%:]", "_", _key) \ No newline at end of file + return re.sub(r"[^A-Za-z0-9_\-\.@()+=;$!*\'%:]", "_", _key) + + + + +def is_reference_chunk(text: str) -> bool: + """ + Determine if a text chunk consists PREDOMINANTLY of references or end matter. + Conservative approach: only returns True for chunks that are clearly mostly references. + + Args: + text (str): Text chunk to analyze + + Returns: + bool: True if the chunk appears to be mostly references/end matter + """ + # Split text into lines for analysis + lines = [line.strip() for line in text.split('\n') if line.strip()] + if not lines: + return False + + # First, check for unambiguous reference chunks (many DOIs or reference links) + doi_pattern = r"10\.\d{4,9}/[-._;()/:A-Za-z0-9]+" + doi_matches = len(re.findall(doi_pattern, text)) + refhub_matches = len(re.findall(r'http://refhub\.elsevier\.com/\S+', text)) + + # If there are many DOIs or refhub links, it's almost certainly primarily references + if doi_matches >= 15 or refhub_matches >= 10: + return True + + # Find positions of common end matter section headers + end_matter_patterns = [ + r"\*\*Credit author statement\*\*", + r"\*\*Declaration of competing interest\*\*", + r"\*\*Acknowledgment\*\*", + r"\*\*Acknowledgement\*\*", + r"\*\*Appendix\b.*\*\*", + r"\*\*References\*\*", + r"^References[\s]*$" + ] + + # Try to identify where end matter begins + end_matter_positions = [] + for pattern in end_matter_patterns: + matches = list(re.finditer(pattern, text, re.IGNORECASE | re.MULTILINE)) + for match in matches: + end_matter_positions.append(match.start()) + + # If we found end matter sections + if end_matter_positions: + # Find the earliest end matter position + first_end_matter = min(end_matter_positions) + # Calculate ratio of substantive content + substantive_ratio = first_end_matter / len(text) + + # If less than 30% of the chunk is substantive content, filter it + # This is conservative - only filter if the chunk is predominantly end matter + if substantive_ratio < 0.10: + return True + else: + # There's significant substantive content before end matter + return False + + # Count reference indicators + reference_indicators = 0 + + # Citation patterns with year, volume, pages + citation_patterns = len(re.findall(r'\d{4};\d+:\d+[-–]\d+', text)) + reference_indicators += citation_patterns * 2 + + # Check for lines starting with citation numbers + lines_starting_with_citation = 0 + for line in lines: + if re.match(r'^\s*\[\d+\]', line): + lines_starting_with_citation += 1 + + # If more than half the lines start with reference numbers, it's a reference list + if lines_starting_with_citation > len(lines) / 2: + return True + + # Check for abbreviation list (only if it makes up most of the chunk) + abbreviation_lines = 0 + for line in lines: + if re.match(r'^[A-Z0-9]{2,}\s+[A-Z][a-z]+', line): + abbreviation_lines += 1 + + # If more than 70% of lines are abbreviations, it's an abbreviation list + if abbreviation_lines > len(lines) * 0.7: + return True + + # Conservative approach: only filter if it's clearly mostly references + return False + diff --git a/view_latest_results.py b/view_latest_results.py new file mode 100644 index 0000000..fd50a9c --- /dev/null +++ b/view_latest_results.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +""" +View Latest LLM Server Results + +This script displays the latest document summaries generated by the LLM server +directly in the terminal, providing a quick way to check results without +having to use a web browser. + +Usage: + python view_latest_results.py [--raw] [--json] + +Options: + --raw Display the raw result data + --json Format the output as JSON +""" + +import json +import os +import sys +import argparse +from datetime import datetime + + +def load_latest_result(): + """Load the latest result from the JSON file.""" + latest_result_file = os.path.join(os.path.dirname(__file__), "latest_summary_result.json") + try: + if os.path.exists(latest_result_file): + with open(latest_result_file, 'r') as f: + return json.load(f) + else: + print(f"No results file found at {latest_result_file}") + return None + except Exception as e: + print(f"Error loading results: {e}") + return None + + +def display_raw(result): + """Display the raw result data.""" + print(json.dumps(result, indent=2)) + + +def display_formatted(result): + """Display the result in a nicely formatted way.""" + if not result: + print("No results available") + return + + print("\n" + "=" * 80) + print(f"DOCUMENT: {result.get('_id', 'Unknown')}") + print("=" * 80) + + # Document summary + summary = result.get("summary", {}).get("text_sum", "No summary available") + print("\n📄 DOCUMENT SUMMARY") + print("-" * 80) + print(summary) + + # Model info if available + if "summary" in result and "meta" in result["summary"]: + meta = result["summary"]["meta"] + model = meta.get("model", "Unknown") + temp = meta.get("temperature", "Unknown") + print(f"\nGenerated using: {model} (temperature: {temp})") + + # Display chunks + chunks = result.get("chunks", []) + if chunks: + summarized_chunks = [chunk for chunk in chunks if "summary" in chunk] + print(f"\n🧩 CHUNK SUMMARIES ({len(summarized_chunks)}/{len(chunks)} chunks processed)") + + for i, chunk in enumerate(summarized_chunks): + print("\n" + "-" * 80) + print(f"Chunk {i+1}:") + print("-" * 80) + print(chunk["summary"]) + + # Display tags + if "tags" in chunk and chunk["tags"]: + print("\nTags:", ", ".join(chunk["tags"])) + + # Display references + if "references" in chunk and chunk["references"]: + print("\nReferences:") + for ref in chunk["references"]: + print(f"- {ref}") + + print("\n" + "=" * 80) + + +def main(): + parser = argparse.ArgumentParser(description='View latest LLM server results') + parser.add_argument('--raw', action='store_true', help='Display raw result data') + parser.add_argument('--json', action='store_true', help='Format output as JSON') + args = parser.parse_args() + + result = load_latest_result() + + if not result: + print("No results available") + return + + if args.raw or args.json: + display_raw(result) + else: + display_formatted(result) + + +if __name__ == "__main__": + main() \ No newline at end of file