diff --git a/llama-index-packs/llama-index-packs-finchat/.gitignore b/llama-index-packs/llama-index-packs-finchat/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..990c18de229088f55c6c514fd0f2d49981d1b0e7 --- /dev/null +++ b/llama-index-packs/llama-index-packs-finchat/.gitignore @@ -0,0 +1,153 @@ +llama_index/_static +.DS_Store +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +bin/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +etc/ +include/ +lib/ +lib64/ +parts/ +sdist/ +share/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +.ruff_cache + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints +notebooks/ + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ +pyvenv.cfg + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# Jetbrains +.idea +modules/ +*.swp + +# VsCode +.vscode + +# pipenv +Pipfile +Pipfile.lock + +# pyright +pyrightconfig.json diff --git a/llama-index-packs/llama-index-packs-finchat/BUILD b/llama-index-packs/llama-index-packs-finchat/BUILD new file mode 100644 index 0000000000000000000000000000000000000000..0896ca890d8bffd60a44fa824f8d57fecd73ee53 --- /dev/null +++ b/llama-index-packs/llama-index-packs-finchat/BUILD @@ -0,0 +1,3 @@ +poetry_requirements( + name="poetry", +) diff --git a/llama-index-packs/llama-index-packs-finchat/Makefile b/llama-index-packs/llama-index-packs-finchat/Makefile new file mode 100644 index 0000000000000000000000000000000000000000..b9eab05aa370629a4a3de75df3ff64cd53887b68 --- /dev/null +++ b/llama-index-packs/llama-index-packs-finchat/Makefile @@ -0,0 +1,17 @@ +GIT_ROOT ?= $(shell git rev-parse --show-toplevel) + +help: ## Show all Makefile targets. + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[33m%-30s\033[0m %s\n", $$1, $$2}' + +format: ## Run code autoformatters (black). + pre-commit install + git ls-files | xargs pre-commit run black --files + +lint: ## Run linters: pre-commit (black, ruff, codespell) and mypy + pre-commit install && git ls-files | xargs pre-commit run --show-diff-on-failure --files + +test: ## Run tests via pytest. + pytest tests + +watch-docs: ## Build and watch documentation. + sphinx-autobuild docs/ docs/_build/html --open-browser --watch $(GIT_ROOT)/llama_index/ diff --git a/llama-index-packs/llama-index-packs-finchat/README.md b/llama-index-packs/llama-index-packs-finchat/README.md new file mode 100644 index 0000000000000000000000000000000000000000..fda963f89b8d8518c29f0c177124ffcb72b60c21 --- /dev/null +++ b/llama-index-packs/llama-index-packs-finchat/README.md @@ -0,0 +1,61 @@ +# Finance Chat Llama Pack based on OpenAIAgent + +This LlamaPack implements a hierarchical agent based on LLM for financial chat and information extraction purposed. + +LLM agent is connected to various open financial apis as well as daily updated SP500 postgres SQL database storing +opening & closing price, volume as well as past earnings. + +Based on the query, the agent reasons and routes to available tools or runs SQL query to retrieve information and +combine information to answer. + +### Installation + +```bash +pip install llama-index llama-index-tools-finchat +``` + +## CLI Usage + +You can download llamapacks directly using `llamaindex-cli`, which comes installed with the `llama-index` python package: + +```bash +llamaindex-cli download-llamapack FinanceChatPack --download-dir ./finchat_pack +``` + +You can then inspect the files at `./finchat_pack` and use them as a template for your own project. + +## Code Usage + +You can download the pack to a the `./finchat_pack` directory: + +```python +from llama_index.core.llama_pack import download_llama_pack + +FinanceChatPack = download_llama_pack("FinanceChatPack", "./finchat_pack") +``` + +To use this tool, you'll need a few API keys: + +- POLYGON_API_KEY -- <https://polygon.io/> +- FINNHUB_API_KEY -- <https://finnhub.io/> +- ALPHA_VANTAGE_API_KEY -- <https://www.alphavantage.co/> +- NEWSAPI_API_KEY -- <https://newsapi.org/> +- POSTGRES_DB_URI -- 'postgresql://postgres.xhlcobfkbhtwmckmszqp:fingptpassword#123@aws-0-us-east-1.pooler.supabase.com:5432/postgres' (You can also host your own postgres SQL DB with the same table signatures. To use different signatures, modification is required in giving query examples for SQL code generation.) + +```python +fin_agent = FinanceChatPack( + POLYGON_API_KEY, + FINNHUB_API_KEY, + ALPHA_VANTAGE_API_KEY, + NEWSAPI_API_KEY, + OPENAI_API_KEY, +) +``` + +From here, you can use the pack, or inspect and modify the pack in `./finchat_pack`. + +The `run()` function chats with the agent and sends the response of the input query. + +```python +response = fin_agent.run("<query>") +``` diff --git a/llama-index-packs/llama-index-packs-finchat/examples/BUILD b/llama-index-packs/llama-index-packs-finchat/examples/BUILD new file mode 100644 index 0000000000000000000000000000000000000000..db46e8d6c978c67e301dd6c47bee08c1b3fd141c --- /dev/null +++ b/llama-index-packs/llama-index-packs-finchat/examples/BUILD @@ -0,0 +1 @@ +python_sources() diff --git a/llama-index-packs/llama-index-packs-finchat/examples/example.py b/llama-index-packs/llama-index-packs-finchat/examples/example.py new file mode 100644 index 0000000000000000000000000000000000000000..b414f15ebc821a71eaf6941d3ccc716eaae2176d --- /dev/null +++ b/llama-index-packs/llama-index-packs-finchat/examples/example.py @@ -0,0 +1,37 @@ +""" +This example demonstrates how to set up and test chatting with a finance agent using the FinanceChatPack. +It involves collecting necessary API keys and initializing the FinanceChatPack with these keys and a PostgreSQL database URI. +""" + +import getpass +from llama_index.packs.finchat import FinanceChatPack + +# Prompting the user to enter all necessary API keys for finance data access and OpenAI +openai_api_key = getpass.getpass("Enter your OpenAI API key: ") +polygon_api_key = getpass.getpass("Enter your Polygon API key: ") +finnhub_api_key = getpass.getpass("Enter your Finnhub API key: ") +alpha_vantage_api_key = getpass.getpass("Enter your Alpha Vantage API key: ") +newsapi_api_key = getpass.getpass("Enter your NewsAPI API key: ") + +# PostgreSQL database URI for storing and accessing financial data +postgres_db_uri = "postgresql://postgres.xhlcobfkbhtwmckmszqp:fingptpassword#123@aws-0-us-east-1.pooler.supabase.com:5432/postgres" + +# Initializing the FinanceChatPack with the collected API keys and database URI +finance_chat_pack = FinanceChatPack( + polygon_api_key=polygon_api_key, + finnhub_api_key=finnhub_api_key, + alpha_vantage_api_key=alpha_vantage_api_key, + newsapi_api_key=newsapi_api_key, + openai_api_key=openai_api_key, + postgres_db_uri=postgres_db_uri, +) + +# Notifying the user that the FinanceChatPack has been initialized and is ready for testing +print( + "FinanceChatPack initialized successfully. Ready for testing chat interactions with the finance agent." +) + + +user_query = "Find similar companies to Rivian?" +response = finance_chat_pack.run(user_query) +print("Finance agent response:", response) diff --git a/llama-index-packs/llama-index-packs-finchat/llama_index/packs/finchat/BUILD b/llama-index-packs/llama-index-packs-finchat/llama_index/packs/finchat/BUILD new file mode 100644 index 0000000000000000000000000000000000000000..db46e8d6c978c67e301dd6c47bee08c1b3fd141c --- /dev/null +++ b/llama-index-packs/llama-index-packs-finchat/llama_index/packs/finchat/BUILD @@ -0,0 +1 @@ +python_sources() diff --git a/llama-index-packs/llama-index-packs-finchat/llama_index/packs/finchat/__init__.py b/llama-index-packs/llama-index-packs-finchat/llama_index/packs/finchat/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e85603546160de936731808f4b5af04c42acac38 --- /dev/null +++ b/llama-index-packs/llama-index-packs-finchat/llama_index/packs/finchat/__init__.py @@ -0,0 +1,3 @@ +from llama_index.packs.finchat.base import FinanceChatPack + +__all__ = ["FinanceChatPack"] diff --git a/llama-index-packs/llama-index-packs-finchat/llama_index/packs/finchat/base.py b/llama-index-packs/llama-index-packs-finchat/llama_index/packs/finchat/base.py new file mode 100644 index 0000000000000000000000000000000000000000..4a7082d7fe64714fbc8b9b22770e2c72193c2b3d --- /dev/null +++ b/llama-index-packs/llama-index-packs-finchat/llama_index/packs/finchat/base.py @@ -0,0 +1,375 @@ +"""Finance Chat LlamaPack class.""" + +from typing import Optional, List, Any + +# The following imports have been adjusted to fix the ModuleNotFoundError +from llama_index.core.llama_pack.base import BaseLlamaPack +from llama_index.llms.openai import OpenAI +from llama_index.tools.finance import FinanceAgentToolSpec +from llama_index.core.tools.tool_spec.base import BaseToolSpec +from llama_index.core.readers.base import BaseReader +from llama_index.core.utilities.sql_wrapper import SQLDatabase +from llama_index.core.tools.query_engine import QueryEngineTool +from llama_index.agent.openai import OpenAIAgent +from llama_index.core.schema import Document +from llama_index.core.base.llms.types import ChatMessage +from sqlalchemy import MetaData, text +from sqlalchemy.engine import Engine +from sqlalchemy.exc import NoSuchTableError +from sqlalchemy.schema import CreateTable + + +class SQLDatabaseToolSpec(BaseToolSpec, BaseReader): + """ + A tool to query and retrieve results from a SQL Database. + + Args: + sql_database (Optional[SQLDatabase]): SQL database to use, + including table names to specify. + See :ref:`Ref-Struct-Store` for more details. + + OR + + engine (Optional[Engine]): SQLAlchemy Engine object of the database connection. + + OR + + uri (Optional[str]): uri of the database connection. + + OR + + scheme (Optional[str]): scheme of the database connection. + host (Optional[str]): host of the database connection. + port (Optional[int]): port of the database connection. + user (Optional[str]): user of the database connection. + password (Optional[str]): password of the database connection. + dbname (Optional[str]): dbname of the database connection. + + """ + + spec_functions = ["run_sql_query", "describe_tables", "list_tables"] + + def __init__( + self, + sql_database: Optional[SQLDatabase] = None, + engine: Optional[Engine] = None, + uri: Optional[str] = None, + scheme: Optional[str] = None, + host: Optional[str] = None, + port: Optional[str] = None, + user: Optional[str] = None, + password: Optional[str] = None, + dbname: Optional[str] = None, + *args: Optional[Any], + **kwargs: Optional[Any], + ) -> None: + """Initialize with parameters.""" + if sql_database: + self.sql_database = sql_database + elif engine: + self.sql_database = SQLDatabase(engine, *args, **kwargs) + elif uri: + self.uri = uri + self.sql_database = SQLDatabase.from_uri(uri, *args, **kwargs) + elif scheme and host and port and user and password and dbname: + uri = f"{scheme}://{user}:{password}@{host}:{port}/{dbname}" + self.uri = uri + self.sql_database = SQLDatabase.from_uri(uri, *args, **kwargs) + else: + raise ValueError( + "You must provide either a SQLDatabase, " + "a SQL Alchemy Engine, a valid connection URI, or a valid " + "set of credentials." + ) + self._metadata = MetaData() + self._metadata.reflect(bind=self.sql_database.engine) + + def run_sql_query(self, query: str) -> Document: + r"""Runs SQL query on the provided SQL database, returning a Document storing all the rows separated by \n. + + Args: + query (str): SQL query in text format which can directly be executed using SQLAlchemy engine. + + Returns: + Document: Document storing all the output result of the sql-query generated. + """ + with self.sql_database.engine.connect() as connection: + if query is None: + raise ValueError("A query parameter is necessary to filter the data") + else: + result = connection.execute(text(query)) + all_doc_str = "" + for item in result.fetchall(): + if all_doc_str: + all_doc_str += "\n" + # fetch each item + doc_str = ", ".join([str(entry) for entry in item]) + all_doc_str += doc_str + return Document(text=all_doc_str) + + def list_tables(self) -> List[str]: + """ + Returns a list of available tables in the database. + """ + return [x.name for x in self._metadata.sorted_tables] + + def describe_tables(self, tables: Optional[List[str]] = None) -> str: + """ + Describes the specified tables in the database. + + Args: + tables (List[str]): A list of table names to retrieve details about + """ + table_names = tables or [table.name for table in self._metadata.sorted_tables] + table_schemas = [] + + for table_name in table_names: + table = next( + ( + table + for table in self._metadata.sorted_tables + if table.name == table_name + ), + None, + ) + if table is None: + raise NoSuchTableError(f"Table '{table_name}' does not exist.") + schema = str(CreateTable(table).compile(self.sql_database._engine)) + table_schemas.append(f"{schema}\n") + + return "\n".join(table_schemas) + + def get_table_info(self) -> str: + """Construct table info for the all tables in DB which includes information about the columns of the table and also shows top row of the table.""" + all_table_info = "" + for table_name in self.list_tables(): + table_info = self.sql_database.get_single_table_info(table_name) + table_info += f"\n\nHere is the DDL statement for this table:\n" + table_info += self.describe_tables([table_name]) + _, output = self.sql_database.run_sql(f"SELECT * FROM {table_name} LIMIT 1") + table_info += f"\nTop row of {table_name}:\n\n" + for colname in output["col_keys"]: + table_info += colname + "\t" + table_info += "\n" + for data in output["result"]: + for val in data: + table_info += str(val) + "\t" + table_info += "\n" + all_table_info += f"\n{table_info}\n" + return all_table_info + + +class FinanceChatPack(BaseLlamaPack): + def __init__( + self, + polygon_api_key: str, + finnhub_api_key: str, + alpha_vantage_api_key: str, + newsapi_api_key: str, + openai_api_key: str, + postgres_db_uri: str, + gpt_model_name: str = "gpt-4-0613", + ): + llm = OpenAI(temperature=0, model=gpt_model_name, api_key=openai_api_key) + self.db_tool_spec = SQLDatabaseToolSpec(uri=postgres_db_uri) + self.fin_tool_spec = FinanceAgentToolSpec( + polygon_api_key, finnhub_api_key, alpha_vantage_api_key, newsapi_api_key + ) + + self.db_table_info = self.db_tool_spec.get_table_info() + prefix_messages = self.construct_prefix_db_message(self.db_table_info) + # add some role play in the system . + database_agent = OpenAIAgent.from_tools( + [ + tool + for tool in self.db_tool_spec.to_tool_list() + if tool.metadata.name == "run_sql_query" + ], + prefix_messages=prefix_messages, + llm=llm, + verbose=True, + ) + database_agent_tool = QueryEngineTool.from_defaults( + database_agent, + name="database_agent", + description="""" + This agent analyzes a text query and add further explanations and thoughts to help a data scientist who has access to following tables: + + {table_info} + + Be concise and do not lose any information about original query while passing to the data scientist. + """, + ) + + fin_api_agent = OpenAIAgent.from_tools( + self.fin_tool_spec.to_tool_list(), + system_prompt=f""" + You are a helpful AI financial assistant designed to understand the intent of the user query and then use relevant tools/apis to help answer it. + You can use more than one tool/api only if needed, but final response should be concise and relevant. If you are not able to find + relevant tool/api, respond respectfully suggesting that you don't know. Think step by step""", + llm=llm, + verbose=True, + ) + + fin_api_agent_tool = QueryEngineTool.from_defaults( + fin_api_agent, + name="fin_api_agent", + description=f""" + This agent has access to another agent which can access certain open APIs to provide information based on user query. + Analyze the query and add any information if needed which can help to decide which API to call. + Be concise and do not lose any information about original query. + """, + ) + + self.fin_hierarchical_agent = OpenAIAgent.from_tools( + [database_agent_tool, fin_api_agent_tool], + system_prompt=""" + You are a specialized financial assistant with access to certain tools which can access open APIs and SP500 companies database containing information on + daily opening price, closing price, high, low, volume, reported earnings, estimated earnings since 2010 to 2023. Before answering query you should check + if the question can be answered via querying the database or using specific open APIs. If you try to find answer via querying database first and it did + not work out, think if you can use other tool APIs available before replying gracefully. + """, + llm=llm, + verbose=True, + ) + + def construct_prefix_db_message(self, table_info: str) -> str: + system_prompt = f""" + You are a smart data scientist working in a reputed trading firm like Jump Trading developing automated trading algorithms. Take a deep breathe and think + step by step to design queries over a SQL database. + + Here is a complete description of tables in SQL database you have access to: + + {table_info} + + Use responses to past questions also to guide you. + + + """ + + prefix_messages = [] + prefix_messages.append(ChatMessage(role="system", content=system_prompt)) + + prefix_messages.append( + ChatMessage( + role="user", + content="What is the average price of Google in the month of July in 2023", + ) + ) + prefix_messages.append( + ChatMessage( + role="assistant", + content=""" + SELECT AVG(close) AS AvgPrice + FROM stock_data + WHERE stock = 'GOOG' + AND date >= '2023-07-01' + AND date <= '2023-07-31'; + """, + ) + ) + + prefix_messages.append( + ChatMessage( + role="user", + content="Which stock has the maximum % change in any month in 2023", + ) + ) + # prefix_messages.append(ChatMessage(role="user", content="Which stocks gave more than 2% return constantly in month of July from past 5 years")) + prefix_messages.append( + ChatMessage( + role="assistant", + content=""" + WITH MonthlyPrices AS ( + SELECT + stock, + EXTRACT(YEAR FROM date) AS year, + EXTRACT(MONTH FROM date) AS month, + FIRST_VALUE(close) OVER (PARTITION BY stock, EXTRACT(YEAR FROM date), EXTRACT(MONTH FROM date) ORDER BY date ASC) AS opening_price, + LAST_VALUE(close) OVER (PARTITION BY stock, EXTRACT(YEAR FROM date), EXTRACT(MONTH FROM date) ORDER BY date ASC ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS closing_price + FROM + stock_data + WHERE + EXTRACT(YEAR FROM date) = 2023 + ), + PercentageChanges AS ( + SELECT + stock, + year, + month, + CASE + WHEN opening_rice IS NULL OR closing_price IS NULL THEN NULL + WHEN opening_price = 0 THEN NULL + ELSE ((closing_price - opening_price) / opening_price) * 100 + END AS pct + FROM + MonthlyPrices + ) + SELECT * + FROM + PercentageChanges + WHERE pct IS NOT NULL + ORDER BY + pct DESC + LIMIT 1; + """, + ) + ) + + prefix_messages.append( + ChatMessage( + role="user", + content="How many times Microsoft beat earnings estimates in 2022", + ) + ) + prefix_messages.append( + ChatMessage( + role="assistant", + content=""" + SELECT + COUNT(*) + FROM + earnings + WHERE + stock = 'MSFT' AND reported > estimated and EXTRACT(YEAR FROM date) = 2022 + """, + ) + ) + + prefix_messages.append( + ChatMessage( + role="user", + content="Which stocks have beaten earnings estimate by more than 1$ consecutively from last 4 reportings?", + ) + ) + prefix_messages.append( + ChatMessage( + role="assistant", + content=""" + WITH RankedEarnings AS( + SELECT + stock, + date, + reported, + estimated, + RANK() OVER (PARTITION BY stock ORDER BY date DESC) as ranking + FROM + earnings + ) + SELECT + stock + FROM + RankedEarnings + WHERE + ranking <= 4 AND reported - estimated > 1 + GROUP BY + stock + HAVING COUNT(*) = 4 + """, + ) + ) + + return prefix_messages + + def run(self, query: str): + return self.fin_hierarchical_agent.chat(query) diff --git a/llama-index-packs/llama-index-packs-finchat/pyproject.toml b/llama-index-packs/llama-index-packs-finchat/pyproject.toml new file mode 100644 index 0000000000000000000000000000000000000000..b5e23a3bb299f38f895604f55f9fc20cd00eebe0 --- /dev/null +++ b/llama-index-packs/llama-index-packs-finchat/pyproject.toml @@ -0,0 +1,61 @@ +[build-system] +build-backend = "poetry.core.masonry.api" +requires = ["poetry-core"] + +[tool.codespell] +check-filenames = true +check-hidden = true +# Feel free to un-skip examples, and experimental, you will just need to +# work through many typos (--write-changes and --interactive will help) +skip = "*.csv,*.html,*.json,*.jsonl,*.pdf,*.txt,*.ipynb" + +[tool.llamahub] +contains_example = true +import_path = "llama_index.packs.finchat" + +[tool.llamahub.class_authors] +FinanceChatPack = "345ishaan" + +[tool.mypy] +disallow_untyped_defs = true +# Remove venv skip when integrated with pre-commit +exclude = ["_static", "build", "examples", "notebooks", "venv"] +ignore_missing_imports = true +python_version = "3.8" + +[tool.poetry] +authors = ["Ishan Gupta <345ishaan@gmail.com>"] +description = "llama-index packs implementation of a hierarchical agent for finance chat." +keywords = ["agent", "finance", "finchat"] +license = "MIT" +maintainers = ["345ishaan"] +name = "llama-index-packs-finchat" +packages = [{include = "llama_index/"}] +readme = "README.md" +version = "0.1.0" + +[tool.poetry.dependencies] +python = ">=3.8.1,<3.12" +llama-index-core = "^0.10.0" +tavily-python = "^0.3.1" +llama-index-agent-openai = "^0.1.5" +llama-index-tools-finance = "^0.1.0" + +[tool.poetry.group.dev.dependencies] +black = {extras = ["jupyter"], version = "<=23.9.1,>=23.7.0"} +codespell = {extras = ["toml"], version = ">=v2.2.6"} +ipython = "8.10.0" +jupyter = "^1.0.0" +mypy = "0.991" +pre-commit = "3.2.0" +pylint = "2.15.10" +pytest = "7.2.1" +pytest-mock = "3.11.1" +ruff = "0.0.292" +tree-sitter-languages = "^1.8.0" +types-Deprecated = ">=0.1.0" +types-PyYAML = "^6.0.12.12" +types-protobuf = "^4.24.0.4" +types-redis = "4.5.5.0" +types-requests = "2.28.11.8" # TODO: unpin when mypy>0.991 +types-setuptools = "67.1.0.0"