From d1117c73c427fb6747bf541bace9c29f723565a1 Mon Sep 17 00:00:00 2001 From: chentianrui Date: Fri, 23 Aug 2024 15:05:48 +0800 Subject: [PATCH 01/18] =?UTF-8?q?=E5=B0=86=E9=A1=B9=E7=9B=AE=E5=88=92?= =?UTF-8?q?=E5=88=86=E8=A1=A8=E6=8C=89=E7=85=A7=E4=B8=9A=E5=8A=A1=E6=8B=86?= =?UTF-8?q?=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/engine/__init__.py | 12 +++---- backend/app/engine/engine.py | 4 +-- backend/app/engine/loaders/__init__.py | 2 +- backend/app/engine/loaders/db.py | 45 +++++++++----------------- backend/app/engine/prompt.py | 9 +++--- 5 files changed, 29 insertions(+), 43 deletions(-) diff --git a/backend/app/engine/__init__.py b/backend/app/engine/__init__.py index 1eaf1fe..6e2a97a 100644 --- a/backend/app/engine/__init__.py +++ b/backend/app/engine/__init__.py @@ -4,7 +4,7 @@ from llama_index.core.agent import AgentRunner, ReActChatFormatter from llama_index.core.settings import Settings from llama_index.core.tools.query_engine import QueryEngineTool -from app.engine.engine import create_query_engine, create_summary_query_engine, create_sql_query_engine +from app.engine.engine import create_query_engine, create_summary_query_engine from app.engine.index import get_index #from app.engine.loaders.db import makeDescriptionByEngine from app.engine.tools import ToolFactory @@ -17,11 +17,11 @@ def get_chat_engine(filters=None, params=None): tools = [] # 创建SQL查询工具 - sql_query_engine = create_sql_query_engine() - sql_query_tool = QueryEngineTool.from_defaults(query_engine=sql_query_engine, - name="zjdata_query_tool", - description="来源于一个由博微公司电力造价软件编制的造价工程文件。该文件以多张表格的形式存储存储了整个工程的全部数据内容。适用于以详细的自然语言查询表格数据方式查询造价工程各项具体属性、费用的数值。请先使用“zj_query_tool”无法解决才使用本工具" - ) +# sql_query_engine = create_summary_query_engine(index) + # sql_query_tool = QueryEngineTool.from_defaults(query_engine=sql_query_engine, + # name="zjdata_query_tool", + # description="来源于一个由博微公司电力造价软件编制的造价工程文件。该文件以多张表格的形式存储存储了整个工程的全部数据内容。适用于以详细的自然语言查询表格数据方式查询造价工程各项具体属性、费用的数值。请先使用“zj_query_tool”无法解决才使用本工具" + # ) #tools.append(sql_query_tool) # Add query tool if index exists diff --git a/backend/app/engine/engine.py b/backend/app/engine/engine.py index 6cb552f..379275e 100644 --- a/backend/app/engine/engine.py +++ b/backend/app/engine/engine.py @@ -52,8 +52,8 @@ def get_Retriever(index,**kwargs): sql_database = None sql_obj_index = None -# Create a sql query engine -def create_sql_query_engine(top_k=3, use_reranker=False, filters=None): +# Create a summary query engine +def create_summary_query_engine(top_k=3, use_reranker=False, filters=None): global sql_obj_index global sql_database if sql_obj_index is None or sql_database is None: diff --git a/backend/app/engine/loaders/__init__.py b/backend/app/engine/loaders/__init__.py index a220170..d311124 100644 --- a/backend/app/engine/loaders/__init__.py +++ b/backend/app/engine/loaders/__init__.py @@ -9,7 +9,7 @@ logger = logging.getLogger(__name__) def load_configs(): - with open("config/loaders.yaml") as f: + with open("config/loaders.yaml",'r', encoding='utf-8') as f: configs = yaml.safe_load(f) return configs diff --git a/backend/app/engine/loaders/db.py b/backend/app/engine/loaders/db.py index d6310e2..4be984d 100644 --- a/backend/app/engine/loaders/db.py +++ b/backend/app/engine/loaders/db.py @@ -2,17 +2,14 @@ import logging from typing import Any, List, Optional from llama_index.core import SQLDatabase, Document -from llama_index.core.objects import SQLTableSchema -from llama_index.core.readers.base import BaseReader from llama_index.readers.database import DatabaseReader from pydantic import BaseModel -from sqlalchemy import create_engine -from sqlalchemy import text +from sqlalchemy import create_engine, text from sqlalchemy.engine import Engine logger = logging.getLogger(__name__) -class CustomDatabaseReader(BaseReader): +class CustomDatabaseReader(DatabaseReader): """Simple Database reader. Concatenates each row into Document used by LlamaIndex. @@ -76,28 +73,30 @@ class CustomDatabaseReader(BaseReader): "set of credentials." ) - def load_data(self, query: str) -> List[Document]: + def load_data(self, query: str, explanation: str) -> List[Document]: """Query and load data from the Database, returning a list of Documents. Args: query (str): Query parameter to filter tables and rows. + explanation (str): Explanation for the query to be included in the document. Returns: List[Document]: A list of Document objects. """ - dco_str = "" + dco_str = explanation + "\n" + 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)) - dco_str = ", ".join( + dco_str += ", ".join( [f"{entry}" for entry in result.keys()] - ) + ) + "\n" for item in result.fetchall(): - # fetch each item + # Fetch each item record_str = ", ".join( [f"{entry}" for col, entry in zip(result.keys(), item)] ) @@ -111,7 +110,7 @@ class CustomDatabaseReader(BaseReader): class DBLoaderConfig(BaseModel): uri: str - queries: List[str] + queries: List[dict] def get_db_documents(configs: list[DBLoaderConfig]): docs = [] @@ -123,33 +122,19 @@ def get_db_documents(configs: list[DBLoaderConfig]): return docs metadata = { - #'file_name':'', - 'file_type':'application/booway.document.zj', - #'file_path':'', - #'file_size':'', - #'creation_date':'', - #'last_modified_date':'', + 'file_type': 'application/booway.document.zj', } - #from llama_index.readers.database import DatabaseReader for entry in configs: engine = create_engine(entry.uri) sql_database = SQLDatabase(engine) - # table_schema_objs = makeDescriptionByEngine(sql_database) - # table_node_mapping = SQLTableNodeMapping(sql_database) - # - # nodes = table_node_mapping.to_nodes(table_schema_objs) - # for node in nodes: - # node.metadata.update(metadata) - # - # docs.extend(nodes) - - queries = entry.queries or [] loader = CustomDatabaseReader(sql_database) - for query in queries: + for query_dict in entry.queries: + query = query_dict.get("sql", "") + explanation = query_dict.get("explanation", "") logger.info(f"Loading data from database with query: {query}") - documents = loader.load_data(query=query) + documents = loader.load_data(query=query, explanation=explanation) docs.extend(documents) return docs diff --git a/backend/app/engine/prompt.py b/backend/app/engine/prompt.py index 101b6bf..511040e 100644 --- a/backend/app/engine/prompt.py +++ b/backend/app/engine/prompt.py @@ -39,15 +39,16 @@ refine_template_str = ( "这是原本的问题: {query_str}\n" "我们已经提供了回答: {existing_answer}\n" "现在我们有机会改进这个回答 " - "使用以下更多上下文(仅当需要用时)\n" + "使用以下更多上下文(仅当有助于改进回答时使用)\n" "------------\n" "{context_msg}\n" "------------\n" - "根据新的上下文, 请改进原来的回答。" - "如果新的上下文没有用, 直接返回原本的回答。\n" - "如果是表结构或者是数据库的相关内容,只用于推导问题,不需要告诉用户数据库或表结构等物理信息。\n" + "如果新的上下文对回答没有影响,或者原来的回答已经正确,直接返回原本的回答。\n" + "如果新的上下文有助于改进,请基于它更新回答,但不要引入与问题无关的信息。\n" + "如果是表结构或者是数据库的相关内容,仅用于推导问题,不需要告诉用户数据库或表结构等物理信息。\n" "改进的回答: " ) + refine_template = PromptTemplate(refine_template_str) summary_template_str = ( -- 2.52.0 From 7691b222749f47e53db51b2d7fc41ec3208ae210 Mon Sep 17 00:00:00 2001 From: chentianrui Date: Fri, 23 Aug 2024 15:07:26 +0800 Subject: [PATCH 02/18] =?UTF-8?q?=E5=B0=86=E9=A1=B9=E7=9B=AE=E5=88=92?= =?UTF-8?q?=E5=88=86=E8=A1=A8=E6=8C=89=E7=85=A7=E4=B8=9A=E5=8A=A1=E6=8B=86?= =?UTF-8?q?=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/config/loaders.yaml | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/backend/config/loaders.yaml b/backend/config/loaders.yaml index 66b19d9..c69c13e 100644 --- a/backend/config/loaders.yaml +++ b/backend/config/loaders.yaml @@ -10,10 +10,23 @@ db: #- uri: mysql+pymysql://zjinfo:Y6EAjEEdSYmskA8B@110.42.234.166:3306/zjinfo # - uri: mysql+pymysql://zjinfo2:GSKcziSdBixDXwcd@110.42.234.166:3306/zjinfo2 queries: - - select * from ProjectProperties limit 30; - - select Name, Code, Amount, Amount_Total from TotalCalculateTable - - select SerialNumber, Name, Quantity, Rate, Sum_Price from ProjectDivision where Level = 3 limit 50; - - select Name, Code, Rate, Amount from OtherFee + - sql: select * from ProjectProperties limit 30; + explanation: "工程属性表数据,层级关系包含在博微电力造价工程文件格式_ProjectProperties.json文件中。" + + - sql: select Id, ParentId, Level, Name, Code, Amount, Amount_Total from TotalCalculateTable; + explanation: "总算表数据,层级关系包含在博微电力造价工程文件格式_TotalCalculateTable.json文件中。" + + - sql: select Id, ParentId, Level, SerialNumber, Name, Quantity, Rate, Sum_Price from ProjectDivision where Level = 3 and ProfessionalType = '线路' limit 50; + explanation: "专业类型为线路的项目划分表数据,层级关系包含在博微电力造价工程文件格式_ProjectDivision.json文件中。" + + - sql: select Id, ParentId, Level, SerialNumber, Name, Quantity, Rate, Sum_Price from ProjectDivision where Level = 3 and ProfessionalType = '余物清理' limit 50; + explanation: "专业类型为余物清理的项目划分表数据,层级关系包含在博微电力造价工程文件格式_ProjectDivision.json文件中。" + + - sql: select Id, ParentId, Level, SerialNumber, Name, Quantity, Rate, Sum_Price from ProjectDivision where Level = 3 and ProfessionalType = '拆除线路' limit 50; + explanation: "专业类型为拆除线路的项目划分表数据,层级关系包含在博微电力造价工程文件格式_ProjectDivision.json文件中。" + + - sql: select Id, ParentId, Level, Name, Code, Rate, Amount from OtherFee; + explanation: "其他费用表数据,层级关系包含在博微电力造价工程文件格式_OtherFee.json文件中" #web: # driver_arguments: -- 2.52.0 From 2b64aca26b9a793f1694d653b1cc5f2f07b857c0 Mon Sep 17 00:00:00 2001 From: paituo <330435863@qq.com> Date: Fri, 23 Aug 2024 16:57:27 +0800 Subject: [PATCH 03/18] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F=EF=BC=8C=E5=9B=A0=E4=B8=BA=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E4=B8=8D=E6=94=AF=E6=8C=81doc=E6=A0=BC=E5=BC=8F=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...造价基础知识.doc => 工程造价基础知识.docx} | Bin 141312 -> 152064 bytes 1 file changed, 0 insertions(+), 0 deletions(-) rename backend/data/{工程造价基础知识.doc => 工程造价基础知识.docx} (64%) diff --git a/backend/data/工程造价基础知识.doc b/backend/data/工程造价基础知识.docx similarity index 64% rename from backend/data/工程造价基础知识.doc rename to backend/data/工程造价基础知识.docx index 27d4d9fe1883bb8f3090d91ce3ee7b9f018aac79..b328c09e92c9afad8878f99bb8e4291804b7db88 100644 GIT binary patch delta 47064 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 GqxS!wcxPJx delta 31418 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 Date: Fri, 23 Aug 2024 18:35:19 +0800 Subject: [PATCH 04/18] =?UTF-8?q?=E4=BC=98=E5=8C=96=E4=BA=86=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E8=AF=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/18时33分46秒_副本___init__.py | 0 backend/app/18时33分46秒_副本_llmhub.py | 61 ++++ .../app/18时33分46秒_副本_observability.py | 20 ++ backend/app/18时33分46秒_副本_settings.py | 235 +++++++++++++++ backend/app/api/18时33分47秒_副本___init__.py | 0 .../api/routers/18时33分49秒_副本___init__.py | 0 .../app/api/routers/18时33分49秒_副本_chat.py | 150 ++++++++++ .../api/routers/18时33分49秒_副本_events.py | 149 ++++++++++ .../api/routers/18时33分49秒_副本_models.py | 253 ++++++++++++++++ .../api/routers/18时33分49秒_副本_upload.py | 25 ++ .../18时33分49秒_副本_vercel_response.py | 109 +++++++ .../api/services/18时33分49秒_副本_file.py | 113 ++++++++ .../services/18时33分49秒_副本_llama_cloud.py | 114 ++++++++ .../services/18时33分49秒_副本_suggestion.py | 48 ++++ backend/app/engine/18时33分46秒_副本_index.py | 22 ++ .../app/engine/18时33分47秒_副本___init__.py | 61 ++++ .../app/engine/18时33分47秒_副本_constants.py | 1 + .../app/engine/18时33分47秒_副本_engine.py | 108 +++++++ .../app/engine/18时33分47秒_副本_generate.py | 94 ++++++ .../app/engine/18时33分47秒_副本_prompt.py | 93 ++++++ .../app/engine/18时33分47秒_副本_vectordb.py | 71 +++++ .../loaders/18时33分48秒_副本___init__.py | 40 +++ .../engine/loaders/18时33分48秒_副本_db.py | 140 +++++++++ .../engine/loaders/18时33分48秒_副本_file.py | 88 ++++++ .../engine/loaders/18时33分48秒_副本_web.py | 37 +++ backend/app/engine/loaders/__init__.py | 2 +- backend/app/engine/loaders/db.py | 45 ++- backend/app/engine/prompt.py | 9 +- .../18时33分48秒_副本_CHBM25Retriever.py | 133 +++++++++ .../retriever/18时33分48秒_副本_CHTokener.py | 46 +++ .../18时33分49秒_副本_HybridRetriever.py | 67 +++++ .../tools/18时33分47秒_副本_duckduckgo.py | 36 +++ .../tools/18时33分48秒_副本___init__.py | 60 ++++ .../engine/tools/18时33分48秒_副本_img_gen.py | 108 +++++++ .../tools/18时33分48秒_副本_interpreter.py | 143 +++++++++ .../tools/18时33分48秒_副本_openapi_action.py | 78 +++++ .../engine/tools/18时33分48秒_副本_weather.py | 73 +++++ .../xinference/18时33分46秒_副本___init__.py | 0 .../app/xinference/18时33分46秒_副本_base.py | 272 ++++++++++++++++++ 39 files changed, 3083 insertions(+), 21 deletions(-) create mode 100644 backend/app/18时33分46秒_副本___init__.py create mode 100644 backend/app/18时33分46秒_副本_llmhub.py create mode 100644 backend/app/18时33分46秒_副本_observability.py create mode 100644 backend/app/18时33分46秒_副本_settings.py create mode 100644 backend/app/api/18时33分47秒_副本___init__.py create mode 100644 backend/app/api/routers/18时33分49秒_副本___init__.py create mode 100644 backend/app/api/routers/18时33分49秒_副本_chat.py create mode 100644 backend/app/api/routers/18时33分49秒_副本_events.py create mode 100644 backend/app/api/routers/18时33分49秒_副本_models.py create mode 100644 backend/app/api/routers/18时33分49秒_副本_upload.py create mode 100644 backend/app/api/routers/18时33分49秒_副本_vercel_response.py create mode 100644 backend/app/api/services/18时33分49秒_副本_file.py create mode 100644 backend/app/api/services/18时33分49秒_副本_llama_cloud.py create mode 100644 backend/app/api/services/18时33分49秒_副本_suggestion.py create mode 100644 backend/app/engine/18时33分46秒_副本_index.py create mode 100644 backend/app/engine/18时33分47秒_副本___init__.py create mode 100644 backend/app/engine/18时33分47秒_副本_constants.py create mode 100644 backend/app/engine/18时33分47秒_副本_engine.py create mode 100644 backend/app/engine/18时33分47秒_副本_generate.py create mode 100644 backend/app/engine/18时33分47秒_副本_prompt.py create mode 100644 backend/app/engine/18时33分47秒_副本_vectordb.py create mode 100644 backend/app/engine/loaders/18时33分48秒_副本___init__.py create mode 100644 backend/app/engine/loaders/18时33分48秒_副本_db.py create mode 100644 backend/app/engine/loaders/18时33分48秒_副本_file.py create mode 100644 backend/app/engine/loaders/18时33分48秒_副本_web.py create mode 100644 backend/app/engine/retriever/18时33分48秒_副本_CHBM25Retriever.py create mode 100644 backend/app/engine/retriever/18时33分48秒_副本_CHTokener.py create mode 100644 backend/app/engine/retriever/18时33分49秒_副本_HybridRetriever.py create mode 100644 backend/app/engine/tools/18时33分47秒_副本_duckduckgo.py create mode 100644 backend/app/engine/tools/18时33分48秒_副本___init__.py create mode 100644 backend/app/engine/tools/18时33分48秒_副本_img_gen.py create mode 100644 backend/app/engine/tools/18时33分48秒_副本_interpreter.py create mode 100644 backend/app/engine/tools/18时33分48秒_副本_openapi_action.py create mode 100644 backend/app/engine/tools/18时33分48秒_副本_weather.py create mode 100644 backend/app/xinference/18时33分46秒_副本___init__.py create mode 100644 backend/app/xinference/18时33分46秒_副本_base.py diff --git a/backend/app/18时33分46秒_副本___init__.py b/backend/app/18时33分46秒_副本___init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/18时33分46秒_副本_llmhub.py b/backend/app/18时33分46秒_副本_llmhub.py new file mode 100644 index 0000000..69e0e32 --- /dev/null +++ b/backend/app/18时33分46秒_副本_llmhub.py @@ -0,0 +1,61 @@ +from llama_index.embeddings.openai import OpenAIEmbedding +from llama_index.core.settings import Settings +from typing import Dict +import os + +DEFAULT_MODEL = "gpt-3.5-turbo" +DEFAULT_EMBEDDING_MODEL = "text-embedding-3-large" + +class TSIEmbedding(OpenAIEmbedding): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._query_engine = self._text_engine = self.model_name + +def llm_config_from_env() -> Dict: + from llama_index.core.constants import DEFAULT_TEMPERATURE + + model = os.getenv("MODEL", DEFAULT_MODEL) + temperature = os.getenv("LLM_TEMPERATURE", DEFAULT_TEMPERATURE) + max_tokens = os.getenv("LLM_MAX_TOKENS") + api_key = os.getenv("T_SYSTEMS_LLMHUB_API_KEY") + api_base = os.getenv("T_SYSTEMS_LLMHUB_BASE_URL") + + config = { + "model": model, + "api_key": api_key, + "api_base": api_base, + "temperature": float(temperature), + "max_tokens": int(max_tokens) if max_tokens is not None else None, + } + return config + + +def embedding_config_from_env() -> Dict: + from llama_index.core.constants import DEFAULT_EMBEDDING_DIM + + model = os.getenv("EMBEDDING_MODEL", DEFAULT_EMBEDDING_MODEL) + dimension = os.getenv("EMBEDDING_DIM", DEFAULT_EMBEDDING_DIM) + api_key = os.getenv("T_SYSTEMS_LLMHUB_API_KEY") + api_base = os.getenv("T_SYSTEMS_LLMHUB_BASE_URL") + + config = { + "model_name": model, + "dimension": int(dimension) if dimension is not None else None, + "api_key": api_key, + "api_base": api_base, + } + return config + +def init_llmhub(): + from llama_index.llms.openai_like import OpenAILike + + llm_configs = llm_config_from_env() + embedding_configs = embedding_config_from_env() + + Settings.embed_model = TSIEmbedding(**embedding_configs) + Settings.llm = OpenAILike( + **llm_configs, + is_chat_model=True, + is_function_calling_model=False, + context_window=4096, + ) \ No newline at end of file diff --git a/backend/app/18时33分46秒_副本_observability.py b/backend/app/18时33分46秒_副本_observability.py new file mode 100644 index 0000000..780ae04 --- /dev/null +++ b/backend/app/18时33分46秒_副本_observability.py @@ -0,0 +1,20 @@ +import os + +import llama_index.core + +def init_observability(): + + PHOENIX_API_KEY = os.getenv("PHOENIX_API_KEY") + if not PHOENIX_API_KEY: + raise ValueError("PHOENIX_API_KEY environment variable is not set") + os.environ["OTEL_EXPORTER_OTLP_HEADERS"] = f"api_key={PHOENIX_API_KEY}" + PHOENIX_URL = os.getenv("PHOENIX_URL") + llama_index.core.set_global_handler( + "arize_phoenix", endpoint=PHOENIX_URL, eval_params={} + ) + + #debugHandle=[] + # llama_debug = LlamaDebugHandler(print_trace_on_end=True) + # debugHandle.append(llama_debug) + # callback_manager = CallbackManager(debugHandle) + # settings.Settings.callback_manager = callback_manager diff --git a/backend/app/18时33分46秒_副本_settings.py b/backend/app/18时33分46秒_副本_settings.py new file mode 100644 index 0000000..58333d9 --- /dev/null +++ b/backend/app/18时33分46秒_副本_settings.py @@ -0,0 +1,235 @@ +import os +from typing import Dict + +from llama_index.core.constants import DEFAULT_TEMPERATURE +from llama_index.core.settings import Settings +from llama_index.llms.xinference import Xinference +from llama_index.llms.xinference.base import DEFAULT_XINFERENCE_TEMP + +from app.xinference.base import XinferenceEmbedding, XinferenceRerank + + +def get_node_postprocessors(): + rerank_enabled = os.getenv("RERANK_ENABLED").title() + if rerank_enabled is None or rerank_enabled == 'False': + return [] + + rerank_model = os.getenv("RERANK_MODEL") + rerank_url = os.getenv("RERANK_BASE_URL") + rerank_top_n = os.getenv("RERANK_TOP_N") + rerank_threshold = os.getenv("RERANK_THRESHOLD") + postprocess = None + if rerank_model is not None: + postprocess = [XinferenceRerank(rerank_model, rerank_url, top_n=rerank_top_n, threshold=rerank_threshold)] + return postprocess + +def init_settings(): + model_provider = os.getenv("MODEL_PROVIDER") + match model_provider: + case "openai": + init_openai() + case "dashscope": + init_dashscope() + case "groq": + init_groq() + case "ollama": + init_ollama() + case "anthropic": + init_anthropic() + case "gemini": + init_gemini() + case "mistral": + init_mistral() + case "azure-openai": + init_azure_openai() + case "t-systems": + from .llmhub import init_llmhub + init_llmhub() + case "xinference": + init_xinference() + case _: + raise ValueError(f"Invalid model provider: {model_provider}") + + Settings.chunk_size = int(os.getenv("CHUNK_SIZE", "1024")) + Settings.chunk_overlap = int(os.getenv("CHUNK_OVERLAP", "20")) + + +def init_ollama(): + # from llama_index.embeddings.ollama import OllamaEmbedding + # from llama_index.llms.ollama.base import DEFAULT_REQUEST_TIMEOUT, Ollama + # + # base_url = os.getenv("OLLAMA_BASE_URL") or "http://127.0.0.1:11434" + # request_timeout = float( + # os.getenv("OLLAMA_REQUEST_TIMEOUT", DEFAULT_REQUEST_TIMEOUT) + # ) + # Settings.embed_model = OllamaEmbedding( + # base_url=base_url, + # model_name=os.getenv("EMBEDDING_MODEL"), + # ) + # Settings.llm = Ollama( + # base_url=base_url, model=os.getenv("MODEL"), request_timeout=request_timeout + # ) + pass + +def init_xinference(): + base_url = os.getenv("BASE_URL") + model = os.getenv("MODEL") + max_tokens = int(os.getenv("LLM_MAX_TOKENS")) if os.getenv("LLM_MAX_TOKENS") is not None else None + temperature = float(os.getenv("LLM_TEMPERATURE", DEFAULT_XINFERENCE_TEMP)) + + Settings.llm = Xinference(model, base_url, temperature, max_tokens) + + embedding_base_url = os.getenv("EMBEDDING_BASE_URL") + embedding_base_url = embedding_base_url if embedding_base_url != None and embedding_base_url != "" else base_url + + embed_model_name = os.getenv("EMBEDDING_MODEL") + dimensions = os.getenv("EMBEDDING_DIM") + dimensions = int(dimensions) if dimensions is not None else None + Settings.embed_model = XinferenceEmbedding(embed_model_name, embedding_base_url, dimensions=dimensions) + +def init_openai(): + from llama_index.core.constants import DEFAULT_TEMPERATURE + from llama_index.embeddings.openai import OpenAIEmbedding + from llama_index.llms.openai import OpenAI + + max_tokens = os.getenv("LLM_MAX_TOKENS") + config = { + "model": os.getenv("MODEL"), + "temperature": float(os.getenv("LLM_TEMPERATURE", DEFAULT_TEMPERATURE)), + "max_tokens": int(max_tokens) if max_tokens is not None else None, + } + Settings.llm = OpenAI(**config) + + dimensions = os.getenv("EMBEDDING_DIM") + config = { + "model": os.getenv("EMBEDDING_MODEL"), + "dimensions": int(dimensions) if dimensions is not None else None, + } + Settings.embed_model = OpenAIEmbedding(**config) + +def init_dashscope(): + from llama_index.llms.dashscope import DashScope,DashScopeGenerationModels + from llama_index.embeddings.dashscope import DashScopeEmbedding,DashScopeBatchTextEmbeddingModels,DashScopeTextEmbeddingType,DashScopeTextEmbeddingModels + + max_tokens = os.getenv("LLM_MAX_TOKENS") + config = { + "model": os.getenv("MODEL"), + "temperature": float(os.getenv("LLM_TEMPERATURE", DEFAULT_TEMPERATURE)), + "max_tokens": int(max_tokens) if max_tokens is not None else None, + } + Settings.llm = llm = DashScope(model_name=DashScopeGenerationModels.QWEN_MAX) + + dimensions = os.getenv("EMBEDDING_DIM") + config = { + "model": os.getenv("EMBEDDING_MODEL"), + "dimensions": int(dimensions) if dimensions is not None else None, + } + Settings.embed_model = DashScopeEmbedding(model_name=DashScopeTextEmbeddingModels.TEXT_EMBEDDING_V2, + text_type=DashScopeTextEmbeddingType.TEXT_TYPE_QUERY) + + +def init_azure_openai(): + # from llama_index.core.constants import DEFAULT_TEMPERATURE + # from llama_index.embeddings.azure_openai import AzureOpenAIEmbedding + # from llama_index.llms.azure_openai import AzureOpenAI + # + # llm_deployment = os.environ["AZURE_OPENAI_LLM_DEPLOYMENT"] + # embedding_deployment = os.environ["AZURE_OPENAI_EMBEDDING_DEPLOYMENT"] + # max_tokens = os.getenv("LLM_MAX_TOKENS") + # temperature = os.getenv("LLM_TEMPERATURE", DEFAULT_TEMPERATURE) + # dimensions = os.getenv("EMBEDDING_DIM") + # + # azure_config = { + # "api_key": os.environ["AZURE_OPENAI_KEY"], + # "azure_endpoint": os.environ["AZURE_OPENAI_ENDPOINT"], + # "api_version": os.getenv("AZURE_OPENAI_API_VERSION") + # or os.getenv("OPENAI_API_VERSION"), + # } + # + # Settings.llm = AzureOpenAI( + # model=os.getenv("MODEL"), + # max_tokens=int(max_tokens) if max_tokens is not None else None, + # temperature=float(temperature), + # deployment_name=llm_deployment, + # **azure_config, + # ) + # + # Settings.embed_model = AzureOpenAIEmbedding( + # model=os.getenv("EMBEDDING_MODEL"), + # dimensions=int(dimensions) if dimensions is not None else None, + # deployment_name=embedding_deployment, + # **azure_config, + # ) + pass + + +def init_fastembed(): + """ + Use Qdrant Fastembed as the local embedding provider. + """ + # from llama_index.embeddings.fastembed import FastEmbedEmbedding + # + # embed_model_map: Dict[str, str] = { + # # Small and multilingual + # "all-MiniLM-L6-v2": "sentence-transformers/all-MiniLM-L6-v2", + # # Large and multilingual + # "paraphrase-multilingual-mpnet-base-v2": "sentence-transformers/paraphrase-multilingual-mpnet-base-v2", # noqa: E501 + # } + # + # # This will download the model automatically if it is not already downloaded + # Settings.embed_model = FastEmbedEmbedding( + # model_name=embed_model_map[os.getenv("EMBEDDING_MODEL")] + # ) + pass + + +def init_groq(): + # from llama_index.llms.groq import Groq + # + # model_map: Dict[str, str] = { + # "llama3-8b": "llama3-8b-8192", + # "llama3-70b": "llama3-70b-8192", + # "mixtral-8x7b": "mixtral-8x7b-32768", + # } + # + # Settings.llm = Groq(model=model_map[os.getenv("MODEL")]) + # # Groq does not provide embeddings, so we use FastEmbed instead + # init_fastembed() + pass + + +def init_anthropic(): + # from llama_index.llms.anthropic import Anthropic + # + # model_map: Dict[str, str] = { + # "claude-3-opus": "claude-3-opus-20240229", + # "claude-3-sonnet": "claude-3-sonnet-20240229", + # "claude-3-haiku": "claude-3-haiku-20240307", + # "claude-2.1": "claude-2.1", + # "claude-instant-1.2": "claude-instant-1.2", + # } + # + # Settings.llm = Anthropic(model=model_map[os.getenv("MODEL")]) + # # Anthropic does not provide embeddings, so we use FastEmbed instead + # init_fastembed() + pass + + +def init_gemini(): + # from llama_index.embeddings.gemini import GeminiEmbedding + # from llama_index.llms.gemini import Gemini + # + # model_name = f"models/{os.getenv('MODEL')}" + # embed_model_name = f"models/{os.getenv('EMBEDDING_MODEL')}" + # + # Settings.llm = Gemini(model=model_name) + # Settings.embed_model = GeminiEmbedding(model_name=embed_model_name) + pass + +def init_mistral(): + # from llama_index.embeddings.mistralai import MistralAIEmbedding + # from llama_index.llms.mistralai import MistralAI + # + # Settings.llm = MistralAI(model=os.getenv("MODEL")) + # Settings.embed_model = MistralAIEmbedding(model_name=os.getenv("EMBEDDING_MODEL")) + pass \ No newline at end of file diff --git a/backend/app/api/18时33分47秒_副本___init__.py b/backend/app/api/18时33分47秒_副本___init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/routers/18时33分49秒_副本___init__.py b/backend/app/api/routers/18时33分49秒_副本___init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/routers/18时33分49秒_副本_chat.py b/backend/app/api/routers/18时33分49秒_副本_chat.py new file mode 100644 index 0000000..6476567 --- /dev/null +++ b/backend/app/api/routers/18时33分49秒_副本_chat.py @@ -0,0 +1,150 @@ +import logging +import os +from typing import List + +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, status +from llama_index.core.chat_engine.types import BaseChatEngine, NodeWithScore +from llama_index.core.llms import MessageRole +from llama_index.core.vector_stores.types import MetadataFilter, MetadataFilters + +from app.api.routers.events import EventCallbackHandler +from app.api.routers.models import ( + ChatConfig, + ChatData, + Message, + Result, + SourceNodes, +) +from app.api.routers.vercel_response import VercelStreamResponse +from app.api.services.llama_cloud import LLamaCloudFileService +from app.engine import get_chat_engine + +chat_router = r = APIRouter() + +logger = logging.getLogger("uvicorn") + + +def process_response_nodes( + nodes: List[NodeWithScore], + background_tasks: BackgroundTasks, +): + """ + Start background tasks on the source nodes if needed. + """ + files_to_download = SourceNodes.get_download_files(nodes) + for file in files_to_download: + background_tasks.add_task( + LLamaCloudFileService.download_llamacloud_pipeline_file, file + ) + + +# streaming endpoint - delete if not needed +@r.post("") +async def chat( + request: Request, + data: ChatData, + background_tasks: BackgroundTasks, + chat_engine: BaseChatEngine = Depends(get_chat_engine), +): + try: + last_message_content = data.get_last_message_content() + # 由于基于历史消息的提示词没有调整好,所以暂时屏蔽历史消息 + data.messages.clear() + messages = data.get_history_messages() + + doc_ids = data.get_chat_document_ids() + filters = generate_filters(doc_ids) + params = data.data or {} + logger.info("Creating chat engine with filters", filters.dict()) + chat_engine = get_chat_engine(filters=filters, params=params) + + event_handler = EventCallbackHandler() + chat_engine.callback_manager.handlers.append(event_handler) # type: ignore + + response = await chat_engine.astream_chat(last_message_content, messages) + process_response_nodes(response.source_nodes, background_tasks) + + return VercelStreamResponse(request, event_handler, response, data) + except Exception as e: + logger.exception("Error in chat engine", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error in chat engine: {e}", + ) from e + + +def generate_filters(doc_ids): + if len(doc_ids) > 0: + filters = MetadataFilters( + filters=[ + MetadataFilter( + key="private", + value=["true"], + operator="nin", # type: ignore + ), + MetadataFilter( + key="doc_id", + value=doc_ids, + operator="in", # type: ignore + ), + ], + condition="or", # type: ignore + ) + else: + filters = MetadataFilters( + # Use the "NIN" - "not in" operator to include all public documents (don't have the private key set) + filters=[ + MetadataFilter( + key="private", + value=["true"], + operator="nin", # type: ignore + ), + ] + ) + + return filters + + +# non-streaming endpoint - delete if not needed +@r.post("/request") +async def chat_request( + data: ChatData, + chat_engine: BaseChatEngine = Depends(get_chat_engine), +) -> Result: + last_message_content = data.get_last_message_content() + messages = data.get_history_messages() + + response = await chat_engine.achat(last_message_content, messages) + return Result( + result=Message(role=MessageRole.ASSISTANT, content=response.response), + nodes=SourceNodes.from_source_nodes(response.source_nodes), + ) + + +@r.get("/config") +async def chat_config() -> ChatConfig: + starter_questions = None + conversation_starters = os.getenv("CONVERSATION_STARTERS") + if conversation_starters and conversation_starters.strip(): + starter_questions = conversation_starters.strip().split("\\n") + return ChatConfig(starter_questions=starter_questions) + + +@r.get("/config/llamacloud") +async def chat_llama_cloud_config(): + projects = LLamaCloudFileService.get_all_projects_with_pipelines() + pipeline = os.getenv("LLAMA_CLOUD_INDEX_NAME") + project = os.getenv("LLAMA_CLOUD_PROJECT_NAME") + pipeline_config = ( + pipeline + and project + and { + "pipeline": pipeline, + "project": project, + } + or None + ) + return { + "projects": projects, + "pipeline": pipeline_config, + } diff --git a/backend/app/api/routers/18时33分49秒_副本_events.py b/backend/app/api/routers/18时33分49秒_副本_events.py new file mode 100644 index 0000000..a1d2ea8 --- /dev/null +++ b/backend/app/api/routers/18时33分49秒_副本_events.py @@ -0,0 +1,149 @@ +import json +import asyncio +import logging +from typing import AsyncGenerator, Dict, Any, List, Optional +from llama_index.core.callbacks.base import BaseCallbackHandler +from llama_index.core.callbacks.schema import CBEventType +from llama_index.core.tools.types import ToolOutput +from pydantic import BaseModel + + +logger = logging.getLogger(__name__) + + +class CallbackEvent(BaseModel): + event_type: CBEventType + payload: Optional[Dict[str, Any]] = None + event_id: str = "" + + def get_retrieval_message(self) -> dict | None: + if self.payload: + nodes = self.payload.get("nodes") + if nodes: + msg = f"根据查询检索到 {len(nodes)} 源文件" + else: + msg = f"查询检索中: '{self.payload.get('query_str')}'" + return { + "type": "events", + "data": {"title": msg}, + } + else: + return None + + def get_tool_message(self) -> dict | None: + func_call_args = self.payload.get("function_call") + if func_call_args is not None and "tool" in self.payload: + tool = self.payload.get("tool") + return { + "type": "events", + "data": { + "title": f"调用工具 {tool.name} ,参数: {func_call_args}", + }, + } + + def _is_output_serializable(self, output: Any) -> bool: + try: + json.dumps(output) + return True + except TypeError: + return False + + def get_agent_tool_response(self) -> dict | None: + response = self.payload.get("response") + if response is not None: + sources = response.sources + for source in sources: + # Return the tool response here to include the toolCall information + if isinstance(source, ToolOutput): + if self._is_output_serializable(source.raw_output): + output = source.raw_output + else: + output = source.content + + return { + "type": "tools", + "data": { + "toolOutput": { + "output": output, + "isError": source.is_error, + }, + "toolCall": { + "id": None, # There is no tool id in the ToolOutput + "name": source.tool_name, + "input": source.raw_input, + }, + }, + } + + def to_response(self): + try: + match self.event_type: + case "retrieve": + return self.get_retrieval_message() + case "function_call": + return self.get_tool_message() + case "agent_step": + return self.get_agent_tool_response() + case _: + return None + except Exception as e: + logger.error(f"转换回应时间时发生错误,原因: {e}") + return None + + +class EventCallbackHandler(BaseCallbackHandler): + _aqueue: asyncio.Queue + is_done: bool = False + + def __init__( + self, + ): + """Initialize the base callback handler.""" + ignored_events = [ + CBEventType.CHUNKING, + CBEventType.NODE_PARSING, + CBEventType.EMBEDDING, + CBEventType.LLM, + CBEventType.TEMPLATING, + ] + super().__init__(ignored_events, ignored_events) + self._aqueue = asyncio.Queue() + + def on_event_start( + self, + event_type: CBEventType, + payload: Optional[Dict[str, Any]] = None, + event_id: str = "", + **kwargs: Any, + ) -> str: + event = CallbackEvent(event_id=event_id, event_type=event_type, payload=payload) + if event.to_response() is not None: + self._aqueue.put_nowait(event) + + def on_event_end( + self, + event_type: CBEventType, + payload: Optional[Dict[str, Any]] = None, + event_id: str = "", + **kwargs: Any, + ) -> None: + event = CallbackEvent(event_id=event_id, event_type=event_type, payload=payload) + if event.to_response() is not None: + self._aqueue.put_nowait(event) + + def start_trace(self, trace_id: Optional[str] = None) -> None: + """No-op.""" + + def end_trace( + self, + trace_id: Optional[str] = None, + trace_map: Optional[Dict[str, List[str]]] = None, + ) -> None: + """No-op.""" + + async def async_event_gen(self) -> AsyncGenerator[CallbackEvent, None]: + while not self._aqueue.empty() or not self.is_done: + try: + yield await asyncio.wait_for(self._aqueue.get(), timeout=0.1) + except asyncio.TimeoutError: + pass diff --git a/backend/app/api/routers/18时33分49秒_副本_models.py b/backend/app/api/routers/18时33分49秒_副本_models.py new file mode 100644 index 0000000..b064218 --- /dev/null +++ b/backend/app/api/routers/18时33分49秒_副本_models.py @@ -0,0 +1,253 @@ +import logging +import os +from typing import Any, Dict, List, Literal, Optional, Set + +from llama_index.core.llms import ChatMessage, MessageRole +from llama_index.core.schema import NodeWithScore +from pydantic import BaseModel, Field, validator, field_validator +from pydantic.alias_generators import to_camel + +logger = logging.getLogger("uvicorn") + + +class FileContent(BaseModel): + type: Literal["text", "ref"] + # If the file is pure text then the value is be a string + # otherwise, it's a list of document IDs + value: str | List[str] + + +class File(BaseModel): + id: str + content: FileContent + filename: str + filesize: int + filetype: str + + +class AnnotationFileData(BaseModel): + files: List[File] = Field( + default=[], + description="List of files", + ) + + class Config: + json_schema_extra = { + "example": { + "csvFiles": [ + { + "content": "Name, Age\nAlice, 25\nBob, 30", + "filename": "example.csv", + "filesize": 123, + "id": "123", + "type": "text/csv", + } + ] + } + } + alias_generator = to_camel + + +class Annotation(BaseModel): + type: str + data: AnnotationFileData | List[str] + + def to_content(self) -> str | None: + if self.type == "document_file": + # We only support generating context content for CSV files for now + csv_files = [file for file in self.data.files if file.filetype == "csv"] + if len(csv_files) > 0: + return "Use data from following CSV raw content\n" + "\n".join( + [f"```csv\n{csv_file.content.value}\n```" for csv_file in csv_files] + ) + else: + logger.warning( + f"The annotation {self.type} is not supported for generating context content" + ) + return None + + +class Message(BaseModel): + role: MessageRole + content: str + annotations: List[Annotation] | None = None + + +class ChatData(BaseModel): + messages: List[Message] + data: Any = None + + class Config: + json_schema_extra = { + "example": { + "messages": [ + { + "role": "user", + "content": "What standards for letters exist?", + } + ] + } + } + + @field_validator("messages") + def messages_must_not_be_empty(cls, v): + if len(v) == 0: + raise ValueError("Messages must not be empty") + return v + + def get_last_message_content(self) -> str: + """ + Get the content of the last message along with the data content if available. + Fallback to use data content from previous messages + """ + if len(self.messages) == 0: + raise ValueError("There is not any message in the chat") + last_message = self.messages[-1] + message_content = last_message.content + for message in reversed(self.messages): + if message.role == MessageRole.USER and message.annotations is not None: + annotation_contents = filter( + None, + [annotation.to_content() for annotation in message.annotations], + ) + if not annotation_contents: + continue + annotation_text = "\n".join(annotation_contents) + message_content = f"{message_content}\n{annotation_text}" + break + return message_content + + def get_history_messages(self) -> List[ChatMessage]: + """ + Get the history messages + """ + return [ + ChatMessage(role=message.role, content=message.content) + for message in self.messages[:-1] + ] + + def is_last_message_from_user(self) -> bool: + return self.messages[-1].role == MessageRole.USER + + def get_chat_document_ids(self) -> List[str]: + """ + Get the document IDs from the chat messages + """ + document_ids: List[str] = [] + for message in self.messages: + if message.role == MessageRole.USER and message.annotations is not None: + for annotation in message.annotations: + if ( + annotation.type == "document_file" + and annotation.data.files is not None + ): + for fi in annotation.data.files: + if fi.content.type == "ref": + document_ids += fi.content.value + return list(set(document_ids)) + + +class LlamaCloudFile(BaseModel): + file_name: str + pipeline_id: str + + def __eq__(self, other): + if not isinstance(other, LlamaCloudFile): + return NotImplemented + return ( + self.file_name == other.file_name and self.pipeline_id == other.pipeline_id + ) + + def __hash__(self): + return hash((self.file_name, self.pipeline_id)) + + +class SourceNodes(BaseModel): + id: str + metadata: Dict[str, Any] + score: Optional[float] + text: str + url: Optional[str] + + @classmethod + def from_source_node(cls, source_node: NodeWithScore): + metadata = source_node.node.metadata + url = cls.get_url_from_metadata(metadata) + #text = 'filename' in metadata and metadata['filename'] or source_node.node.node_id + text = source_node.node.text + return cls( + id=source_node.node.node_id, + metadata=metadata, + score=source_node.score, + text=text, # type: ignore + url=url, + ) + + @classmethod + def get_url_from_metadata(cls, metadata: Dict[str, Any]) -> str: + url_prefix = os.getenv("FILESERVER_URL_PREFIX") + if not url_prefix: + logger.warning( + "Warning: FILESERVER_URL_PREFIX not set in environment variables. Can't use file server" + ) + file_name = metadata.get("file_name") + if file_name and url_prefix: + # file_name exists and file server is configured + pipeline_id = metadata.get("pipeline_id") + if pipeline_id and metadata.get("private") is None: + # file is from LlamaCloud and was not ingested locally + file_name = f"{pipeline_id}${file_name}" + return f"{url_prefix}/output/llamacloud/{file_name}" + is_private = metadata.get("private", "false") == "true" + if is_private: + return f"{url_prefix}/output/uploaded/{file_name}" + return f"{url_prefix}/data/{file_name}" + else: + # fallback to URL in metadata (e.g. for websites) + return metadata.get("URL") + + @classmethod + def from_source_nodes(cls, source_nodes: List[NodeWithScore]): + return [cls.from_source_node(node) for node in source_nodes] + + @staticmethod + def get_download_files(nodes: List[NodeWithScore]) -> Set[LlamaCloudFile]: + source_nodes = SourceNodes.from_source_nodes(nodes) + llama_cloud_files = [ + LlamaCloudFile( + file_name=node.metadata.get("file_name"), + pipeline_id=node.metadata.get("pipeline_id"), + ) + for node in source_nodes + if ( + node.metadata.get("private") + is None # Only download files are from LlamaCloud and were not ingested locally + and node.metadata.get("pipeline_id") is not None + and node.metadata.get("file_name") is not None + ) + ] + # Remove duplicates and return + return set(llama_cloud_files) + + +class Result(BaseModel): + result: Message + nodes: List[SourceNodes] + + +class ChatConfig(BaseModel): + starter_questions: Optional[List[str]] = Field( + default=None, + description="List of starter questions", + serialization_alias="starterQuestions", + ) + + class Config: + json_schema_extra = { + "example": { + "starterQuestions": [ + "What standards for letters exist?", + "What are the requirements for a letter to be considered a letter?", + ] + } + } diff --git a/backend/app/api/routers/18时33分49秒_副本_upload.py b/backend/app/api/routers/18时33分49秒_副本_upload.py new file mode 100644 index 0000000..94f3ce7 --- /dev/null +++ b/backend/app/api/routers/18时33分49秒_副本_upload.py @@ -0,0 +1,25 @@ +import logging +from typing import List + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from app.api.services.file import PrivateFileService + +file_upload_router = r = APIRouter() + +logger = logging.getLogger("uvicorn") + + +class FileUploadRequest(BaseModel): + base64: str + + +@r.post("") +def upload_file(request: FileUploadRequest) -> List[str]: + try: + logger.info("Processing file") + return PrivateFileService.process_file(request.base64) + except Exception as e: + logger.error(f"Error processing file: {e}", exc_info=True) + raise HTTPException(status_code=500, detail="Error processing file") diff --git a/backend/app/api/routers/18时33分49秒_副本_vercel_response.py b/backend/app/api/routers/18时33分49秒_副本_vercel_response.py new file mode 100644 index 0000000..0222a14 --- /dev/null +++ b/backend/app/api/routers/18时33分49秒_副本_vercel_response.py @@ -0,0 +1,109 @@ +import json + +from aiostream import stream +from fastapi import Request +from fastapi.responses import StreamingResponse +from llama_index.core.chat_engine.types import StreamingAgentChatResponse + +from app.api.routers.events import EventCallbackHandler +from app.api.routers.models import ChatData, Message, SourceNodes +from app.api.services.suggestion import NextQuestionSuggestion + + +class VercelStreamResponse(StreamingResponse): + """ + Class to convert the response from the chat engine to the streaming format expected by Vercel + """ + + TEXT_PREFIX = "0:" + DATA_PREFIX = "8:" + + @classmethod + def convert_text(cls, token: str): + # Escape newlines and double quotes to avoid breaking the stream + token = json.dumps(token) + return f"{cls.TEXT_PREFIX}{token}\n" + + @classmethod + def convert_data(cls, data: dict): + data_str = json.dumps(data) + return f"{cls.DATA_PREFIX}[{data_str}]\n" + + def __init__( + self, + request: Request, + event_handler: EventCallbackHandler, + response: StreamingAgentChatResponse, + chat_data: ChatData, + ): + content = VercelStreamResponse.content_generator( + request, event_handler, response, chat_data + ) + super().__init__(content=content) + + @classmethod + async def content_generator( + cls, + request: Request, + event_handler: EventCallbackHandler, + response: StreamingAgentChatResponse, + chat_data: ChatData, + ): + # Yield the text response + async def _chat_response_generator(): + final_response = "" + async for token in response.async_response_gen(): + final_response += token + yield VercelStreamResponse.convert_text(token) + + # Generate questions that user might interested to + conversation = chat_data.messages + [ + Message(role="assistant", content=final_response) + ] + questions = await NextQuestionSuggestion.suggest_next_questions( + conversation + ) + if len(questions) > 0: + yield VercelStreamResponse.convert_data( + { + "type": "suggested_questions", + "data": questions, + } + ) + + # the text_generator is the leading stream, once it's finished, also finish the event stream + event_handler.is_done = True + + # Yield the source nodes + yield cls.convert_data( + { + "type": "sources", + "data": { + "nodes": [ + SourceNodes.from_source_node(node).dict() + for node in response.source_nodes + ] + }, + } + ) + + # Yield the events from the event handler + async def _event_generator(): + async for event in event_handler.async_event_gen(): + event_response = event.to_response() + if event_response is not None: + yield VercelStreamResponse.convert_data(event_response) + + combine = stream.merge(_chat_response_generator(), _event_generator()) + is_stream_started = False + async with combine.stream() as streamer: + async for output in streamer: + if not is_stream_started: + is_stream_started = True + # Stream a blank message to start the stream + yield VercelStreamResponse.convert_text("") + + yield output + + if await request.is_disconnected(): + break diff --git a/backend/app/api/services/18时33分49秒_副本_file.py b/backend/app/api/services/18时33分49秒_副本_file.py new file mode 100644 index 0000000..a478570 --- /dev/null +++ b/backend/app/api/services/18时33分49秒_副本_file.py @@ -0,0 +1,113 @@ +import base64 +import mimetypes +import os +from pathlib import Path +from typing import Dict, List +from uuid import uuid4 + +from app.engine.index import get_index +from llama_index.core import VectorStoreIndex +from llama_index.core.ingestion import IngestionPipeline +from llama_index.core.readers.file.base import ( + _try_loading_included_file_formats as get_file_loaders_map, +) +from llama_index.core.readers.file.base import ( + default_file_metadata_func, +) +from llama_index.core.schema import Document +from llama_index.indices.managed.llama_cloud.base import LlamaCloudIndex +from llama_index.readers.file import FlatReader + + +def get_llamaparse_parser(): + from app.engine.loaders import load_configs + from app.engine.loaders.file import FileLoaderConfig, llama_parse_parser + + config = load_configs() + file_loader_config = FileLoaderConfig(**config["file"]) + if file_loader_config.use_llama_parse: + return llama_parse_parser() + else: + return None + + +def default_file_loaders_map(): + default_loaders = get_file_loaders_map() + default_loaders[".txt"] = FlatReader + return default_loaders + + +class PrivateFileService: + PRIVATE_STORE_PATH = "output/uploaded" + + @staticmethod + def preprocess_base64_file(base64_content: str) -> tuple: + header, data = base64_content.split(",", 1) + mime_type = header.split(";")[0].split(":", 1)[1] + extension = mimetypes.guess_extension(mime_type) + # File data as bytes + return base64.b64decode(data), extension + + @staticmethod + def store_and_parse_file(file_data, extension) -> List[Document]: + # Store file to the private directory + os.makedirs(PrivateFileService.PRIVATE_STORE_PATH, exist_ok=True) + + # random file name + file_name = f"{uuid4().hex}{extension}" + file_path = Path(os.path.join(PrivateFileService.PRIVATE_STORE_PATH, file_name)) + + # write file + with open(file_path, "wb") as f: + f.write(file_data) + + # Load file to documents + # If LlamaParse is enabled, use it to parse the file + # Otherwise, use the default file loaders + reader = get_llamaparse_parser() + if reader is None: + reader_cls = default_file_loaders_map().get(extension) + if reader_cls is None: + raise ValueError(f"File extension {extension} is not supported") + reader = reader_cls() + documents = reader.load_data(file_path) + # Add custom metadata + for doc in documents: + doc.metadata["file_name"] = file_name + doc.metadata["private"] = "true" + return documents + + @staticmethod + def process_file(base64_content: str) -> List[str]: + file_data, extension = PrivateFileService.preprocess_base64_file(base64_content) + documents = PrivateFileService.store_and_parse_file(file_data, extension) + + # Only process nodes, no store the index + pipeline = IngestionPipeline() + nodes = pipeline.run(documents=documents) + + # Add the nodes to the index and persist it + current_index = get_index() + + # Insert the documents into the index + if isinstance(current_index, LlamaCloudIndex): + # LlamaCloudIndex is a managed index so we don't need to process the nodes + # just insert the documents + for doc in documents: + current_index.insert(doc) + else: + # Only process nodes, no store the index + pipeline = IngestionPipeline() + nodes = pipeline.run(documents=documents) + + # Add the nodes to the index and persist it + if current_index is None: + current_index = VectorStoreIndex(nodes=nodes) + else: + current_index.insert_nodes(nodes=nodes) + current_index.storage_context.persist( + persist_dir=os.environ.get("STORAGE_DIR", "storage") + ) + + # Return the document ids + return [doc.doc_id for doc in documents] diff --git a/backend/app/api/services/18时33分49秒_副本_llama_cloud.py b/backend/app/api/services/18时33分49秒_副本_llama_cloud.py new file mode 100644 index 0000000..852ae7c --- /dev/null +++ b/backend/app/api/services/18时33分49秒_副本_llama_cloud.py @@ -0,0 +1,114 @@ +import logging +import os +from typing import Any, Dict, List, Optional + +import requests +from app.api.routers.models import LlamaCloudFile + +logger = logging.getLogger("uvicorn") + + +class LLamaCloudFileService: + LLAMA_CLOUD_URL = "https://cloud.llamaindex.ai/api/v1" + LOCAL_STORE_PATH = "output/llamacloud" + + DOWNLOAD_FILE_NAME_TPL = "{pipeline_id}${filename}" + + @classmethod + def get_all_projects(cls) -> List[Dict[str, Any]]: + url = f"{cls.LLAMA_CLOUD_URL}/projects" + return cls._make_request(url) + + @classmethod + def get_all_pipelines(cls) -> List[Dict[str, Any]]: + url = f"{cls.LLAMA_CLOUD_URL}/pipelines" + return cls._make_request(url) + + @classmethod + def get_all_projects_with_pipelines(cls) -> List[Dict[str, Any]]: + try: + projects = cls.get_all_projects() + pipelines = cls.get_all_pipelines() + return [ + { + **project, + "pipelines": [p for p in pipelines if p["project_id"] == project["id"]], + } + for project in projects + ] + except Exception as error: + logger.error(f"Error listing projects and pipelines: {error}") + return [] + + @classmethod + def _get_files(cls, pipeline_id: str) -> List[Dict[str, Any]]: + url = f"{cls.LLAMA_CLOUD_URL}/pipelines/{pipeline_id}/files" + return cls._make_request(url) + + @classmethod + def _get_file_detail(cls, project_id: str, file_id: str) -> Dict[str, Any]: + url = f"{cls.LLAMA_CLOUD_URL}/files/{file_id}/content?project_id={project_id}" + return cls._make_request(url) + + @classmethod + def _download_file(cls, url: str, local_file_path: str): + logger.info(f"Downloading file to {local_file_path}") + # Create directory if it doesn't exist + os.makedirs(cls.LOCAL_STORE_PATH, exist_ok=True) + # Download the file + with requests.get(url, stream=True) as r: + r.raise_for_status() + with open(local_file_path, "wb") as f: + for chunk in r.iter_content(chunk_size=8192): + f.write(chunk) + logger.info("File downloaded successfully") + + @classmethod + def download_llamacloud_pipeline_file( + cls, + file: LlamaCloudFile, + force_download: bool = False, + ): + file_name = file.file_name + pipeline_id = file.pipeline_id + + # Check is the file already exists + downloaded_file_path = cls.get_file_path(file_name, pipeline_id) + if os.path.exists(downloaded_file_path) and not force_download: + logger.debug(f"File {file_name} already exists in local storage") + return + try: + logger.info(f"Downloading file {file_name} for pipeline {pipeline_id}") + files = cls._get_files(pipeline_id) + if not files or not isinstance(files, list): + raise Exception("No files found in LlamaCloud") + for file_entry in files: + if file_entry["name"] == file_name: + file_id = file_entry["file_id"] + project_id = file_entry["project_id"] + file_detail = cls._get_file_detail(project_id, file_id) + cls._download_file(file_detail["url"], downloaded_file_path) + break + except Exception as error: + logger.info(f"Error fetching file from LlamaCloud: {error}") + + @classmethod + def get_file_name(cls, name: str, pipeline_id: str) -> str: + return cls.DOWNLOAD_FILE_NAME_TPL.format(pipeline_id=pipeline_id, filename=name) + + @classmethod + def get_file_path(cls, name: str, pipeline_id: str) -> str: + return os.path.join(cls.LOCAL_STORE_PATH, cls.get_file_name(name, pipeline_id)) + + @staticmethod + def _make_request( + url: str, data=None, headers: Optional[Dict] = None, method: str = "get" + ): + if headers is None: + headers = { + "Accept": "application/json", + "Authorization": f'Bearer {os.getenv("LLAMA_CLOUD_API_KEY")}', + } + response = requests.request(method, url, headers=headers, data=data) + response.raise_for_status() + return response.json() diff --git a/backend/app/api/services/18时33分49秒_副本_suggestion.py b/backend/app/api/services/18时33分49秒_副本_suggestion.py new file mode 100644 index 0000000..7017307 --- /dev/null +++ b/backend/app/api/services/18时33分49秒_副本_suggestion.py @@ -0,0 +1,48 @@ +from typing import List + +from app.api.routers.models import Message +from llama_index.core.prompts import PromptTemplate +from llama_index.core.settings import Settings +from pydantic import BaseModel + +NEXT_QUESTIONS_SUGGESTION_PROMPT = PromptTemplate( + "你是一个乐于助人的助手!你的任务是对用户可能会问的下一个问题给出建议。 " + "\n这是对话历史记录" + "\n---------------------\n{conversation}\n---------------------" + "考虑到对话历史记录,仅限于现在知识库已有内容, 请给我 $number_of_questions 个你接下来可能会问题的问题!" +) +N_QUESTION_TO_GENERATE = 3 + + +class NextQuestions(BaseModel): + """A list of questions that user might ask next""" + + questions: List[str] + + +class NextQuestionSuggestion: + @staticmethod + async def suggest_next_questions( + messages: List[Message], + number_of_questions: int = N_QUESTION_TO_GENERATE, + ) -> List[str]: + # Reduce the cost by only using the last two messages + last_user_message = None + last_assistant_message = None + for message in reversed(messages): + if message.role == "user": + last_user_message = f"User: {message.content}" + elif message.role == "assistant": + last_assistant_message = f"Assistant: {message.content}" + if last_user_message and last_assistant_message: + break + conversation: str = f"{last_user_message}\n{last_assistant_message}" + + output: NextQuestions = await Settings.llm.astructured_predict( + NextQuestions, + prompt=NEXT_QUESTIONS_SUGGESTION_PROMPT, + conversation=conversation, + nun_questions=number_of_questions, + ) + + return output.questions diff --git a/backend/app/engine/18时33分46秒_副本_index.py b/backend/app/engine/18时33分46秒_副本_index.py new file mode 100644 index 0000000..b21e695 --- /dev/null +++ b/backend/app/engine/18时33分46秒_副本_index.py @@ -0,0 +1,22 @@ +import logging +from llama_index.core.indices import VectorStoreIndex +from app.engine.vectordb import get_vector_store + + +logger = logging.getLogger("uvicorn") + +index = None + +def get_index(params=None): + global index + if index is None: + logger.info("Connecting vector store...") + + store = get_vector_store() + # Load the index from the vector store + # If you are using a vector store that doesn't store text, + # you must load the index from both the vector store and the document store + index = VectorStoreIndex.from_vector_store(store) + logger.info("Finished load index from vector store.") + + return index diff --git a/backend/app/engine/18时33分47秒_副本___init__.py b/backend/app/engine/18时33分47秒_副本___init__.py new file mode 100644 index 0000000..6e2a97a --- /dev/null +++ b/backend/app/engine/18时33分47秒_副本___init__.py @@ -0,0 +1,61 @@ +import os + +from llama_index.core.agent import AgentRunner, ReActChatFormatter +from llama_index.core.settings import Settings +from llama_index.core.tools.query_engine import QueryEngineTool + +from app.engine.engine import create_query_engine, create_summary_query_engine +from app.engine.index import get_index +#from app.engine.loaders.db import makeDescriptionByEngine +from app.engine.tools import ToolFactory + + +def get_chat_engine(filters=None, params=None): + system_prompt = os.getenv("SYSTEM_PROMPT") + top_k = int(os.getenv("TOP_K", "3")) + use_reranker = os.getenv("RERANK_ENABLED") + tools = [] + + # 创建SQL查询工具 +# sql_query_engine = create_summary_query_engine(index) + # sql_query_tool = QueryEngineTool.from_defaults(query_engine=sql_query_engine, + # name="zjdata_query_tool", + # description="来源于一个由博微公司电力造价软件编制的造价工程文件。该文件以多张表格的形式存储存储了整个工程的全部数据内容。适用于以详细的自然语言查询表格数据方式查询造价工程各项具体属性、费用的数值。请先使用“zj_query_tool”无法解决才使用本工具" + # ) + #tools.append(sql_query_tool) + + # Add query tool if index exists + index = get_index() + if index is not None: + summary_query_engine = create_summary_query_engine(index,top_k,use_reranker,filters) + summary_query_tool = QueryEngineTool.from_defaults( query_engine=summary_query_engine, name="summary_query_tool", + description="适用于任何需要进行全面总结、概括的要求。", + ) + query_engine = create_query_engine(index,top_k,use_reranker,filters) + query_engine_tool = QueryEngineTool.from_defaults(query_engine=query_engine, name="zj_query_tool", + description="由博微公司编制的关于电力造价知识、电力造价编制软件知识和造价工程文件结构的知识库。适用于查询电力领域、电力造价领域、博微、博微电力、博微造价等业务等内容。如果本知识库没有直接答案但有解决思路的可以返回解决办法后建议使用“zjdata_query_tool”工具。", + ) + + tools.append(summary_query_tool) + tools.append(query_engine_tool) + + # Add additional tools + tools += ToolFactory.from_env() + + prefix_messages = ("""您的设计旨在帮助完成各种任务,从回答问题到提供其他类型分析的摘要。\n\n##工具\n\n你可以访问各种工具。你有责任按照你认为合适的顺序使用这些工具来完成当前的任务。\n这可能需要将任务分解为子任务,并使用不同的工具来完成每个子任务。\n\n你可以访问以下工具:\n{tool_desc}\n\n\n##输出格式\n\n请用与问题相同的语言回答,并使用以下格式:\n\n \nThought: 用户当前的语言是:(user's language)。我需要使用工具来帮助我回答问题。\nAction: 如果使用工具,则为工具名称(one of {tool_names})。\nAction Input: 输入给工具的内容,使用JSON格式表示kwargs(例如{{\"input\": \"hello world\", \"num_beams\": 5}})\n \n\n请始终以Thought开始。\n\n请始终以Thought开始。\n\n请始终以Thought开始。\n\n请始终以Thought开始。\n\n切勿用Markdown代码标记包围你的响应。如果需要,可以在响应中使用代码标记。\n\n请为Action Input使用有效的JSON格式。不要这样做{{\'input\': \'hello world\', \'num_beams\': 5}}。\n\n如果使用此格式,用户将以下面的格式进行回应:\n\n \nObservation: 工具响应\n \n\n你应该继续重复上述格式,直到你有足够的信息来回答问题而无需使用更多工具。此时,你必须使用以下两种格式之一进行回答:\n\n \nThought: 我可以不用任何工具来回答。我将使用用户的语言来回答。\nAnswer: [你的答案(与用户问题相同的语言)]\n \n\n \nThought: 我无法使用提供的工具回答问题。\nAnswer: [你的答案(与用户问题相同的语言)]\n \n\n##如果从工具中得到的回应是Empty Response,那么只需要回答“我不知道”,不需要额外回答别的内容。## 当前对话\n\n以下是当前对话,由人类和助手的消息交替组成。\n""") + react_chat_formatter = ReActChatFormatter.from_defaults(prefix_messages) + agentrunner = AgentRunner.from_llm( + llm=Settings.llm, + tools=tools, + react_chat_formatter=react_chat_formatter, + system_prompt=system_prompt, + verbose=True, + ) + return agentrunner + # create the function calling worker for reasoning + # worker = FunctionCallingAgentWorker.from_tools( + # tools, verbose=True + # ) + # + # # wrap the worker in the top-level planner + # return StructuredPlannerAgent(worker, tools) diff --git a/backend/app/engine/18时33分47秒_副本_constants.py b/backend/app/engine/18时33分47秒_副本_constants.py new file mode 100644 index 0000000..bd93bb8 --- /dev/null +++ b/backend/app/engine/18时33分47秒_副本_constants.py @@ -0,0 +1 @@ +STORAGE_DIR = "storage" # directory to cache the generated index \ No newline at end of file diff --git a/backend/app/engine/18时33分47秒_副本_engine.py b/backend/app/engine/18时33分47秒_副本_engine.py new file mode 100644 index 0000000..379275e --- /dev/null +++ b/backend/app/engine/18时33分47秒_副本_engine.py @@ -0,0 +1,108 @@ +import os + +from llama_index.core import SummaryIndex, SQLDatabase, VectorStoreIndex +from llama_index.core.indices.struct_store import SQLTableRetrieverQueryEngine +from llama_index.core.objects import SQLTableNodeMapping, ObjectIndex, SQLTableSchema +from llama_index.core.query_engine import RetrieverQueryEngine +from llama_index.core.response_synthesizers import ResponseMode +from llama_index.readers.database import DatabaseReader +from sqlalchemy import create_engine + +from app.engine.prompt import text_qa_template, refine_template, summary_template, simple_template +from app.engine.retriever.HybridRetriever import HybridRetriever +from app.settings import get_node_postprocessors + +def makeDescriptionByEngine(sql_database:SQLDatabase): + reader = DatabaseReader(sql_database) + + table_names = sql_database.get_usable_table_names() + table_schema_objs = [] + for table_name in table_names: + columns = sql_database.get_table_columns(table_name) + if len(columns) > 150: + continue + stats_txt = "" + + if table_name == 'gongchengshuxing': + stats_txt = '该表中有以下属性:' + documents = reader.load_data(query='select name from gongchengshuxing') + for index in range(len(documents) if len(documents) < 30 else 30): + if index == 0: + continue + elif index > 1: + stats_txt += ',' + stats_txt += documents[index].text.split(':')[1] + + tbSchema = (SQLTableSchema(table_name=table_name, context_str=stats_txt)) + table_schema_objs.append(tbSchema) + + return table_schema_objs + +def get_Retriever(index,**kwargs): + strEnableHybrid = os.getenv("HYBRID_ENABLED",'False') + bEnableHybrid = True if strEnableHybrid is not None and strEnableHybrid.title() == 'True' else False + if bEnableHybrid: + alpha = float(os.getenv("HYBRID_ALPHA", "0.5")) + retriever = HybridRetriever(index,alpha = alpha,**kwargs) + else: + retriever = index.as_retriever(**kwargs) + return retriever + + +sql_database = None +sql_obj_index = None + +# Create a summary query engine +def create_summary_query_engine(top_k=3, use_reranker=False, filters=None): + global sql_obj_index + global sql_database + if sql_obj_index is None or sql_database is None: + sqlengine = create_engine(os.getenv("SQL_DATABASE_URL", "")) + sql_database = SQLDatabase(sqlengine) + table_schema_objs = makeDescriptionByEngine(sql_database) + table_node_mapping = SQLTableNodeMapping(sql_database) + + sql_obj_index = ObjectIndex.from_objects( + table_schema_objs, + table_node_mapping, + index_cls=VectorStoreIndex, + ) + + # 创建SQL查询工具 + sql_query_engine = SQLTableRetrieverQueryEngine(sql_database, + sql_obj_index.as_retriever(similarity_top_k=top_k), + verbose=True, + ) + return sql_query_engine + +# Create a summary query engine +def create_summary_query_engine(index, top_k=3, use_reranker=False, filters=None): + summary_index = SummaryIndex(index.vector_store.get_nodes(node_ids=None)) + summary_query_engine = summary_index.as_query_engine( + response_mode=ResponseMode.TREE_SUMMARIZE, + use_async=True, + streaming=True, + ) + return summary_query_engine + +# Create a query engine +def create_query_engine(index, top_k=3, use_reranker=False, filters=None): + # 创建向量检索查询工具 + postprocess = None + if use_reranker: + postprocess = get_node_postprocessors() + + query_engine = RetrieverQueryEngine.from_args( + get_Retriever(index, + similarity_top_k=top_k, + filters=filters), + text_qa_template=text_qa_template, + refine_template=refine_template, + summary_template = summary_template, + simple_template = simple_template, + node_postprocessors=postprocess, + use_async=True, + streaming=True, + ) + + return query_engine \ No newline at end of file diff --git a/backend/app/engine/18时33分47秒_副本_generate.py b/backend/app/engine/18时33分47秒_副本_generate.py new file mode 100644 index 0000000..87ecfa1 --- /dev/null +++ b/backend/app/engine/18时33分47秒_副本_generate.py @@ -0,0 +1,94 @@ +from dotenv import load_dotenv + +load_dotenv() + +import logging +import os + +from app.engine.loaders import get_documents +from app.engine.vectordb import get_vector_store +from app.settings import init_settings +from app.engine.retriever.CHBM25Retriever import CHBM25Retriever +from llama_index.core.ingestion import IngestionPipeline +from llama_index.core.node_parser import SentenceSplitter +from llama_index.core.settings import Settings +from llama_index.core.storage import StorageContext +from llama_index.core.storage.docstore import SimpleDocumentStore + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger() + +STORAGE_DIR = os.getenv("STORAGE_DIR", "storage") + + +def get_doc_store(): + + # If the storage directory is there, load the document store from it. + # If not, set up an in-memory document store since we can't load from a directory that doesn't exist. + if os.path.exists(STORAGE_DIR): + return SimpleDocumentStore.from_persist_dir(STORAGE_DIR) + else: + return SimpleDocumentStore() + + +def run_pipeline(docstore, vector_store, documents): + pipeline = IngestionPipeline( + transformations=[ + SentenceSplitter( + chunk_size=Settings.chunk_size, + chunk_overlap=Settings.chunk_overlap, + ), + Settings.embed_model, + ], + docstore=docstore, + docstore_strategy="upserts_and_delete", + vector_store=vector_store, + ) + + # Run the ingestion pipeline and store the results + nodes = pipeline.run(show_progress=True, documents=documents) + + return nodes + + +def persist_storage(docstore, vector_store): + storage_context = StorageContext.from_defaults( + docstore=docstore, + vector_store=vector_store, + ) + storage_context.persist(STORAGE_DIR) + + +def persist_BMRetriever(vector_store): + STORAGE_DIR = os.getenv("BM_RETRIEVER_PATH", "storage_bm") + top_k = int(os.getenv("TOP_K", "3")) + bmRetriver = CHBM25Retriever.from_defaults(similarity_top_k=top_k,nodes=vector_store.get_nodes([])) + bmRetriver.persist(STORAGE_DIR) + + +def generate_datasource(): + init_settings() + logger.info("Generate index for the provided data") + + # Get the stores and documents or create new ones + documents = get_documents() + # Set private=false to mark the document as public (required for filtering) + for doc in documents: + doc.metadata["private"] = "false" + docstore = get_doc_store() + vector_store = get_vector_store() + + # Run the ingestion pipeline + _ = run_pipeline(docstore, vector_store, documents) + + # Build the index and persist storage + persist_storage(docstore, vector_store) + persist_BMRetriever(vector_store) + + logger.info("Finished generating the index") + + +if __name__ == "__main__": + from phoenix.trace import using_project + with using_project(os.getenv("PHOENIX_PROJECT_NAME") + "_generate") as obj: + generate_datasource() diff --git a/backend/app/engine/18时33分47秒_副本_prompt.py b/backend/app/engine/18时33分47秒_副本_prompt.py new file mode 100644 index 0000000..29a2283 --- /dev/null +++ b/backend/app/engine/18时33分47秒_副本_prompt.py @@ -0,0 +1,93 @@ +from llama_index.core import PromptTemplate + +text_qa_template_str = ( + "# 角色\n" + "你是一名博微造价工程数据查询助手,专精于电力工程文件中的信息。" + "你的职责是提供有关电力造价、造价编制软件、文件结构及相关数据的精准、客观的回答," + "如同直接从文件中提取的内容。\n" + "知识库中已经导入一个工程的全部数据,请你站在当前工程的角度回答用户关于工程文件的问题。\n" + "例如:询问“此工程”指当前导入的工程。询问“此工程名称”指当前导入的工程的工程名称。\n" + + "## 技能\n" + "### 技能 1: 数据查询与提供\n" + "- 准确回答所有关于电力工程造价的相关问题。\n" + "- 提供具体数据,如成本估算、材料清单、劳动力需求等。\n" + "- 确保提供的信息严格基于工程文档中的记录。\n" + + "### 技能 2: 技术性解释\n" + "- 解释造价工程中的技术术语和概念。\n" + "- 为复杂的工程细节提供清晰易懂的说明。\n" + + "## 约束\n" + "- 仅回答与电力工程造价文件相关的具体问题。\n" + "- 不进行任何超出文件内容的猜测或假设。\n" + "- 所有回答均基于文件内容,采用客观和技术性的语言。\n" + "- 请基于这些信息回答问题。如果无法找到相关信息,请不要额外发散回答,不要回答多余的信息,只需要回答“我不知道这个问题的答案”。\n" + "以下为上下文信息\n" + "---------------------\n" + "{context_str}\n" + "---------------------\n" + "请根据上下文信息而非先前知识回答我的问题或回复我的指令。前面的上下文信息可能有用,也可能没用,你需要从我给出的上下文信息中选出与我的问题最相关的那些,来为你的回答提供依据。回答一定要忠于原文,简洁但不丢信息,不要胡乱编造。如果无法找到相关信息,请不要额外发散回答,不要回答多余的信息,只需要回答“我不知道这个问题的答案”。我的问题或指令是什么语种,你就用什么语种回复。\n" + "如果是表结构或者是数据库的相关内容,只用于推导问题,不需要告诉用户数据库或表结构等物理信息。\n" + + "问题:{query_str}\n" + "你的回复: " +) + + +text_qa_template = PromptTemplate(text_qa_template_str) + +refine_template_str = ( + "这是原本的问题: {query_str}\n" + "我们已经提供了回答: {existing_answer}\n" + "现在我们有机会改进这个回答 " + "使用以下更多上下文(仅当有助于改进回答时使用)\n" + "如果新的上下文对回答没有影响,或者原来的回答已经正确,不要在上次回答的后边再加上多余的补充信息,直接返回原本的回答。\n" + "如果新的上下文对回答没有影响,或者原来的回答已经正确,不要在上次回答的后边再加上多余的补充信息,直接返回原本的回答。\n" + "------------\n" + "{context_msg}\n" + "------------\n" + "如果回答中已经包含有正确答案,不要返回多余的解释等信息,只返回正确答案\n" + "如果是表结构或者是数据库的相关内容,仅用于推导问题,不需要告诉用户数据库或表结构等物理信息。\n" + "改进的回答: " +) + +refine_template = PromptTemplate(refine_template_str) + +summary_template_str = ( + "# 角色\n" + "你是一名博微造价工程数据查询助手,专精于电力工程文件中的信息。" + "你的职责是提供有关电力造价、造价编制软件、文件结构及相关数据的精准、客观的回答," + "如同直接从文件中提取的内容。\n" + + "## 技能\n" + "### 技能 1: 数据查询与提供\n" + "- 准确回答所有关于电力工程造价的相关问题。\n" + "- 提供具体数据,如成本估算、材料清单、劳动力需求等。\n" + "- 确保提供的信息严格基于工程文档中的记录。\n" + + "### 技能 2: 技术性解释\n" + "- 解释造价工程中的技术术语和概念。\n" + "- 为复杂的工程细节提供清晰易懂的说明。\n" + + "## 约束\n" + "- 仅回答与电力工程造价文件相关的具体问题。\n" + "- 不进行任何超出文件内容的猜测或假设。\n" + "- 所有回答均基于文件内容,采用客观和技术性的语言。\n" + "- 请基于这些信息回答问题。如果无法找到相关信息,请不要额外发散回答,不要回答多余的信息,只需要回答“我不知道这个问题的答案”。\n" + "来自多个来源的上下文信息如下。\n" + "---------------------\n" + "{context_str}\n" + "---------------------\n" + "鉴于来自多个来源的信息而非先验知识, " + "回答查询。\n" + "如果是表结构或者是数据库的相关内容,只用于推导问题,不需要告诉用户数据库或表结构等物理信息。\n" + "Query: {query_str}\n" + "Answer: " +) +summary_template = PromptTemplate(summary_template_str) + +simple_template_str = ( + "{query_str}" +) +simple_template = PromptTemplate(simple_template_str) diff --git a/backend/app/engine/18时33分47秒_副本_vectordb.py b/backend/app/engine/18时33分47秒_副本_vectordb.py new file mode 100644 index 0000000..f3f2a7d --- /dev/null +++ b/backend/app/engine/18时33分47秒_副本_vectordb.py @@ -0,0 +1,71 @@ +import os +from llama_index.vector_stores.chroma import ChromaVectorStore +from llama_index.vector_stores.qdrant import QdrantVectorStore +from qdrant_client import qdrant_client + +qclient = None + +def get_qdrant_vector_store(): + collection_name = os.getenv("VECTOR_STORE_COLLECTION", "default") + vector_store_path = os.getenv("VECTOR_STORE_PATH") + host=os.getenv("VECTOR_STORE_HOST", "127.0.0.1"), + port=int(os.getenv("VECTOR_STORE_PORT", "6333")), + + if not vector_store_path or not host: + raise ValueError( + "Please provide either VECTOR_STORE_PATH or VECTOR_STORE_HOST and VECTOR_STORE_PORT" + ) + # if VECTOR_STORE_PATH is set, use a local QdrantVectorStore from the path + # otherwise, use a remote QdrantVectorStore + global qclient + if qclient == None: + if vector_store_path: + qclient = qdrant_client.QdrantClient( + path=vector_store_path, + ) + else: + qclient = qdrant_client.QdrantClient( + host=host, + port=port, + ) + + vector_store = QdrantVectorStore(client=qclient, collection_name=collection_name) + return vector_store + +def get_chroma_vector_store(): + collection_name = os.getenv("VECTOR_STORE_COLLECTION", "default") + vector_store_path = os.getenv("VECTOR_STORE_PATH") + # if VECTOR_STORE_PATH is set, use a local ChromaVectorStore from the path + # otherwise, use a remote ChromaVectorStore (ChromaDB Cloud is not supported yet) + if vector_store_path: + store = ChromaVectorStore.from_params( + persist_dir=vector_store_path, collection_name=collection_name, + collection_kwargs={"metadata":{"hnsw:space":"cosine"}}, + ) + else: + if not os.getenv("VECTOR_STORE_HOST") or not os.getenv("VECTOR_STORE_PORT"): + raise ValueError( + "Please provide either VECTOR_STORE_PATH or VECTOR_STORE_HOST and VECTOR_STORE_PORT" + ) + store = ChromaVectorStore.from_params( + host=os.getenv("VECTOR_STORE_HOST"), + port=int(os.getenv("VECTOR_STORE_PORT")), + collection_name=collection_name, + collection_kwargs={"metadata":{"hnsw:space":"cosine"}}, + ) + return store + +def get_vector_store(): + store_type=os.getenv("VECTOR_STORE_TYPE") + + store = None + + match store_type: + case "chroma": + store = get_chroma_vector_store() + case "qdrant": + store = get_qdrant_vector_store() + case _: + raise ValueError(f"Invalid vector store type: {store_type}") + + return store \ No newline at end of file diff --git a/backend/app/engine/loaders/18时33分48秒_副本___init__.py b/backend/app/engine/loaders/18时33分48秒_副本___init__.py new file mode 100644 index 0000000..a220170 --- /dev/null +++ b/backend/app/engine/loaders/18时33分48秒_副本___init__.py @@ -0,0 +1,40 @@ +import logging + +import yaml +from app.engine.loaders.db import DBLoaderConfig, get_db_documents +from app.engine.loaders.file import FileLoaderConfig, get_file_documents +from app.engine.loaders.web import WebLoaderConfig, get_web_documents + +logger = logging.getLogger(__name__) + + +def load_configs(): + with open("config/loaders.yaml") as f: + configs = yaml.safe_load(f) + return configs + + +def get_documents(): + documents = [] + config = load_configs() + if config is None or len(config.items()) == 0: + return documents + + for loader_type, loader_config in config.items(): + logger.info( + f"Loading documents from loader: {loader_type}, config: {loader_config}" + ) + + loader_config = loader_config or [] + match loader_type: + case "file": + document = get_file_documents(FileLoaderConfig(**loader_config)) + case "web": + document = get_web_documents(WebLoaderConfig(**loader_config)) + case "db": + document = get_db_documents(configs=[DBLoaderConfig(**cfg) for cfg in loader_config]) + case _: + raise ValueError(f"Invalid loader type: {loader_type}") + documents.extend(document) + + return documents diff --git a/backend/app/engine/loaders/18时33分48秒_副本_db.py b/backend/app/engine/loaders/18时33分48秒_副本_db.py new file mode 100644 index 0000000..4be984d --- /dev/null +++ b/backend/app/engine/loaders/18时33分48秒_副本_db.py @@ -0,0 +1,140 @@ +import logging +from typing import Any, List, Optional + +from llama_index.core import SQLDatabase, Document +from llama_index.readers.database import DatabaseReader +from pydantic import BaseModel +from sqlalchemy import create_engine, text +from sqlalchemy.engine import Engine + +logger = logging.getLogger(__name__) + +class CustomDatabaseReader(DatabaseReader): + """Simple Database reader. + + Concatenates each row into Document used by LlamaIndex. + + 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. + + Returns: + DatabaseReader: A DatabaseReader object. + """ + + 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: Any, + **kwargs: 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." + ) + + def load_data(self, query: str, explanation: str) -> List[Document]: + """Query and load data from the Database, returning a list of Documents. + + Args: + query (str): Query parameter to filter tables and rows. + explanation (str): Explanation for the query to be included in the document. + + Returns: + List[Document]: A list of Document objects. + """ + dco_str = explanation + "\n" + + 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)) + + dco_str += ", ".join( + [f"{entry}" for entry in result.keys()] + ) + "\n" + + for item in result.fetchall(): + # Fetch each item + record_str = ", ".join( + [f"{entry}" for col, entry in zip(result.keys(), item)] + ) + dco_str += record_str + "\n" + + doc = Document(text=dco_str) + doc.metadata["name"] = query + doc.metadata["context"] = query + doc.metadata["file_type"] = "application/vnd.ms-excel" + return [doc] + +class DBLoaderConfig(BaseModel): + uri: str + queries: List[dict] + +def get_db_documents(configs: list[DBLoaderConfig]): + docs = [] + + if len(configs) == 0 or configs[0].uri == "": + logger.warning( + f"Failed to load database, error message: uri is empty. Return as empty document list." + ) + return docs + + metadata = { + 'file_type': 'application/booway.document.zj', + } + + for entry in configs: + engine = create_engine(entry.uri) + sql_database = SQLDatabase(engine) + + loader = CustomDatabaseReader(sql_database) + for query_dict in entry.queries: + query = query_dict.get("sql", "") + explanation = query_dict.get("explanation", "") + logger.info(f"Loading data from database with query: {query}") + documents = loader.load_data(query=query, explanation=explanation) + + docs.extend(documents) + return docs diff --git a/backend/app/engine/loaders/18时33分48秒_副本_file.py b/backend/app/engine/loaders/18时33分48秒_副本_file.py new file mode 100644 index 0000000..dc199db --- /dev/null +++ b/backend/app/engine/loaders/18时33分48秒_副本_file.py @@ -0,0 +1,88 @@ +import os +import logging +from typing import Dict + +from llama_index.core.readers.base import BaseReader +from llama_index.core.readers.json import JSONReader +from llama_parse import LlamaParse +from pydantic import BaseModel, validator + +logger = logging.getLogger(__name__) + + +class FileLoaderConfig(BaseModel): + data_dir: str = "data" + use_llama_parse: bool = False + + @validator("data_dir") + def data_dir_must_exist(cls, v): + if not os.path.isdir(v): + raise ValueError(f"Directory '{v}' does not exist") + return v + + +def llama_parse_parser(): + if os.getenv("LLAMA_CLOUD_API_KEY") is None: + raise ValueError( + "LLAMA_CLOUD_API_KEY environment variable is not set. " + "Please set it in .env file or in your shell environment then run again!" + ) + parser = LlamaParse( + result_type="markdown", + verbose=True, + language="en", + ignore_errors=False, + ) + return parser + + +def llama_parse_extractor() -> Dict[str, LlamaParse]: + from llama_parse.utils import SUPPORTED_FILE_TYPES + + parser = llama_parse_parser() + return {file_type: parser for file_type in SUPPORTED_FILE_TYPES} + +def llama_local_extractor() -> Dict[str, BaseReader]: + return {".json" : JSONReader(clean_json=False,levels_back=0)} + + +def get_file_documents(config: FileLoaderConfig): + from llama_index.core.readers import SimpleDirectoryReader + + try: + file_extractor = None + if config.use_llama_parse: + # LlamaParse is async first, + # so we need to use nest_asyncio to run it in sync mode + import nest_asyncio + + nest_asyncio.apply() + + file_extractor = llama_parse_extractor() + else: + file_extractor = llama_local_extractor() + + reader = SimpleDirectoryReader( + config.data_dir, + recursive=True, + filename_as_id=True, + raise_on_error=True, + file_extractor=file_extractor, + ) + return reader.load_data() + except Exception as e: + import sys + import traceback + + # Catch the error if the data dir is empty + # and return as empty document list + _, _, exc_traceback = sys.exc_info() + function_name = traceback.extract_tb(exc_traceback)[-1].name + if function_name == "_add_files": + logger.warning( + f"Failed to load file documents, error message: {e} . Return as empty document list." + ) + return [] + else: + # Raise the error if it is not the case of empty data dir + raise e diff --git a/backend/app/engine/loaders/18时33分48秒_副本_web.py b/backend/app/engine/loaders/18时33分48秒_副本_web.py new file mode 100644 index 0000000..e667a69 --- /dev/null +++ b/backend/app/engine/loaders/18时33分48秒_副本_web.py @@ -0,0 +1,37 @@ +import os +import json +from pydantic import BaseModel, Field + + +class CrawlUrl(BaseModel): + base_url: str + prefix: str + max_depth: int = Field(default=1, ge=0) + + +class WebLoaderConfig(BaseModel): + driver_arguments: list[str] = Field(default=None) + urls: list[CrawlUrl] = [] + + +def get_web_documents(config: WebLoaderConfig): + from llama_index.readers.web import WholeSiteReader + from selenium import webdriver + from selenium.webdriver.chrome.options import Options + + options = Options() + driver_arguments = config.driver_arguments or [] + for arg in driver_arguments: + options.add_argument(arg) + + docs = [] + urls = config.urls or [] + for url in config.urls: + scraper = WholeSiteReader( + prefix=url.prefix, + max_depth=url.max_depth, + driver=webdriver.Chrome(options=options), + ) + docs.extend(scraper.load_data(url.base_url)) + + return docs diff --git a/backend/app/engine/loaders/__init__.py b/backend/app/engine/loaders/__init__.py index d311124..a220170 100644 --- a/backend/app/engine/loaders/__init__.py +++ b/backend/app/engine/loaders/__init__.py @@ -9,7 +9,7 @@ logger = logging.getLogger(__name__) def load_configs(): - with open("config/loaders.yaml",'r', encoding='utf-8') as f: + with open("config/loaders.yaml") as f: configs = yaml.safe_load(f) return configs diff --git a/backend/app/engine/loaders/db.py b/backend/app/engine/loaders/db.py index 4be984d..d6310e2 100644 --- a/backend/app/engine/loaders/db.py +++ b/backend/app/engine/loaders/db.py @@ -2,14 +2,17 @@ import logging from typing import Any, List, Optional from llama_index.core import SQLDatabase, Document +from llama_index.core.objects import SQLTableSchema +from llama_index.core.readers.base import BaseReader from llama_index.readers.database import DatabaseReader from pydantic import BaseModel -from sqlalchemy import create_engine, text +from sqlalchemy import create_engine +from sqlalchemy import text from sqlalchemy.engine import Engine logger = logging.getLogger(__name__) -class CustomDatabaseReader(DatabaseReader): +class CustomDatabaseReader(BaseReader): """Simple Database reader. Concatenates each row into Document used by LlamaIndex. @@ -73,30 +76,28 @@ class CustomDatabaseReader(DatabaseReader): "set of credentials." ) - def load_data(self, query: str, explanation: str) -> List[Document]: + def load_data(self, query: str) -> List[Document]: """Query and load data from the Database, returning a list of Documents. Args: query (str): Query parameter to filter tables and rows. - explanation (str): Explanation for the query to be included in the document. Returns: List[Document]: A list of Document objects. """ - dco_str = explanation + "\n" - + dco_str = "" 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)) - dco_str += ", ".join( + dco_str = ", ".join( [f"{entry}" for entry in result.keys()] - ) + "\n" + ) for item in result.fetchall(): - # Fetch each item + # fetch each item record_str = ", ".join( [f"{entry}" for col, entry in zip(result.keys(), item)] ) @@ -110,7 +111,7 @@ class CustomDatabaseReader(DatabaseReader): class DBLoaderConfig(BaseModel): uri: str - queries: List[dict] + queries: List[str] def get_db_documents(configs: list[DBLoaderConfig]): docs = [] @@ -122,19 +123,33 @@ def get_db_documents(configs: list[DBLoaderConfig]): return docs metadata = { - 'file_type': 'application/booway.document.zj', + #'file_name':'', + 'file_type':'application/booway.document.zj', + #'file_path':'', + #'file_size':'', + #'creation_date':'', + #'last_modified_date':'', } + #from llama_index.readers.database import DatabaseReader for entry in configs: engine = create_engine(entry.uri) sql_database = SQLDatabase(engine) + # table_schema_objs = makeDescriptionByEngine(sql_database) + # table_node_mapping = SQLTableNodeMapping(sql_database) + # + # nodes = table_node_mapping.to_nodes(table_schema_objs) + # for node in nodes: + # node.metadata.update(metadata) + # + # docs.extend(nodes) + + queries = entry.queries or [] loader = CustomDatabaseReader(sql_database) - for query_dict in entry.queries: - query = query_dict.get("sql", "") - explanation = query_dict.get("explanation", "") + for query in queries: logger.info(f"Loading data from database with query: {query}") - documents = loader.load_data(query=query, explanation=explanation) + documents = loader.load_data(query=query) docs.extend(documents) return docs diff --git a/backend/app/engine/prompt.py b/backend/app/engine/prompt.py index 511040e..101b6bf 100644 --- a/backend/app/engine/prompt.py +++ b/backend/app/engine/prompt.py @@ -39,16 +39,15 @@ refine_template_str = ( "这是原本的问题: {query_str}\n" "我们已经提供了回答: {existing_answer}\n" "现在我们有机会改进这个回答 " - "使用以下更多上下文(仅当有助于改进回答时使用)\n" + "使用以下更多上下文(仅当需要用时)\n" "------------\n" "{context_msg}\n" "------------\n" - "如果新的上下文对回答没有影响,或者原来的回答已经正确,直接返回原本的回答。\n" - "如果新的上下文有助于改进,请基于它更新回答,但不要引入与问题无关的信息。\n" - "如果是表结构或者是数据库的相关内容,仅用于推导问题,不需要告诉用户数据库或表结构等物理信息。\n" + "根据新的上下文, 请改进原来的回答。" + "如果新的上下文没有用, 直接返回原本的回答。\n" + "如果是表结构或者是数据库的相关内容,只用于推导问题,不需要告诉用户数据库或表结构等物理信息。\n" "改进的回答: " ) - refine_template = PromptTemplate(refine_template_str) summary_template_str = ( diff --git a/backend/app/engine/retriever/18时33分48秒_副本_CHBM25Retriever.py b/backend/app/engine/retriever/18时33分48秒_副本_CHBM25Retriever.py new file mode 100644 index 0000000..fa5d5ec --- /dev/null +++ b/backend/app/engine/retriever/18时33分48秒_副本_CHBM25Retriever.py @@ -0,0 +1,133 @@ +import json +import logging +import os + +from typing import Any, Callable, Dict, List, Optional, cast + +from llama_index.core.base.base_retriever import BaseRetriever +from llama_index.core.callbacks.base import CallbackManager +from llama_index.core.constants import DEFAULT_SIMILARITY_TOP_K +from llama_index.core.indices.vector_store.base import VectorStoreIndex +from llama_index.core.schema import BaseNode, IndexNode, NodeWithScore, QueryBundle +from llama_index.core.storage.docstore.types import BaseDocumentStore +from llama_index.core.vector_stores.utils import ( + node_to_metadata_dict, + metadata_dict_to_node, +) + +import bm25s +from app.engine.retriever.CHTokener import chTokenize + +CHDEFAULT_PERSIST_ARGS = {"similarity_top_k": "similarity_top_k", "_verbose": "verbose"} + +CHDEFAULT_PERSIST_FILENAME = "retriever.json" + +class CHBM25Retriever(BaseRetriever): + def __init__( + self, + nodes: Optional[List[BaseNode]] = None, + existing_bm25: Optional[bm25s.BM25] = None, + similarity_top_k: int = DEFAULT_SIMILARITY_TOP_K, + callback_manager: Optional[CallbackManager] = None, + objects: Optional[List[IndexNode]] = None, + object_map: Optional[dict] = None, + verbose: bool = False, + ) -> None: + self.similarity_top_k = similarity_top_k + if existing_bm25 is not None: + self.bm25 = existing_bm25 + self.corpus = existing_bm25.corpus + else: + from nltk.corpus import stopwords + if nodes is None: + raise ValueError("Please pass nodes or an existing BM25 object.") + + self.corpus = [node_to_metadata_dict(node) for node in nodes] + + corpus_tokens = chTokenize( + [node.get_content() for node in nodes], + show_progress=verbose, + ) + self.bm25 = bm25s.BM25() + self.bm25.index(corpus_tokens, show_progress=verbose) + super().__init__( + callback_manager=callback_manager, + object_map=object_map, + objects=objects, + verbose=verbose, + ) + + @classmethod + def from_defaults( + cls, + index: Optional[VectorStoreIndex] = None, + nodes: Optional[List[BaseNode]] = None, + docstore: Optional[BaseDocumentStore] = None, + similarity_top_k: int = DEFAULT_SIMILARITY_TOP_K, + verbose: bool = False, + ) -> "CHBM25Retriever": + if sum(bool(val) for val in [index, nodes, docstore]) != 1: + raise ValueError("Please pass exactly one of index, nodes, or docstore.") + + if index is not None: + docstore = index.docstore + + if docstore is not None: + nodes = cast(List[BaseNode], list(docstore.docs.values())) + + assert ( + nodes is not None + ), "Please pass exactly one of index, nodes, or docstore." + + return cls( + nodes=nodes, + similarity_top_k=similarity_top_k, + verbose=verbose, + ) + + def get_persist_args(self) -> Dict[str, Any]: + """Get Persist Args Dict to Save.""" + return { + CHDEFAULT_PERSIST_ARGS[key]: getattr(self, key) + for key in CHDEFAULT_PERSIST_ARGS + if hasattr(self, key) + } + + def persist(self, path: str, **kwargs: Any) -> None: + """Persist the retriever to a directory.""" + self.bm25.save(path, corpus=self.corpus, **kwargs) + with open(os.path.join(path, CHDEFAULT_PERSIST_FILENAME), "w") as f: + json.dump(self.get_persist_args(), f, indent=2) + + @classmethod + def from_persist_dir(cls, path: str, **kwargs: Any) -> "CHBM25Retriever": + """Load the retriever from a directory.""" + bm25 = bm25s.BM25.load(path, load_corpus=True, **kwargs) + with open(os.path.join(path, CHDEFAULT_PERSIST_FILENAME)) as f: + retriever_data = json.load(f) + return cls(existing_bm25=bm25, **retriever_data) + + def _retrieve(self, query_bundle: QueryBundle) -> List[NodeWithScore]: + query = query_bundle.query_str + tokenized_query = chTokenize( + query,show_progress=self._verbose + ) + indexes, scores = self.bm25.retrieve( + tokenized_query, k=self.similarity_top_k, show_progress=self._verbose + ) + + # batched, but only one query + indexes = indexes[0] + scores = scores[0] + + nodes: List[NodeWithScore] = [] + for idx, score in zip(indexes, scores): + # idx can be an int or a dict of the node + if isinstance(idx, dict): + node = metadata_dict_to_node(idx) + else: + node_dict = self.corpus[int(idx)] + node = metadata_dict_to_node(node_dict) + nodes.append(NodeWithScore(node=node, score=float(score))) + + return nodes \ No newline at end of file diff --git a/backend/app/engine/retriever/18时33分48秒_副本_CHTokener.py b/backend/app/engine/retriever/18时33分48秒_副本_CHTokener.py new file mode 100644 index 0000000..9c5a071 --- /dev/null +++ b/backend/app/engine/retriever/18时33分48秒_副本_CHTokener.py @@ -0,0 +1,46 @@ +from typing import Any, Dict, List, Union, Callable, NamedTuple +from bm25s.tokenization import * + +try: + from tqdm.auto import tqdm +except ImportError: + + def tqdm(iterable, *args, **kwargs): + return iterable + + +def chinese_tokenizer(text: str) -> List[str]: + import jieba + from nltk.corpus import stopwords + tokens = jieba.lcut(text) + return [token for token in tokens if token not in stopwords.words('chinese')] + +def chTokenize( + texts, + show_progress: bool = True, + leave: bool = False, +) -> Union[List[List[str]], Tokenized]: + if isinstance(texts, str): + texts = [texts] + + corpus_ids = [] + token_to_index = {} + + for text in tqdm( + texts, desc="Split strings", leave=leave, disable=not show_progress + ): + + splitted = chinese_tokenizer(text) + doc_ids = [] + + for token in splitted: + if token not in token_to_index: + token_to_index[token] = len(token_to_index) + + token_id = token_to_index[token] + doc_ids.append(token_id) + + corpus_ids.append(doc_ids) + + return Tokenized(ids=corpus_ids, vocab=token_to_index) + diff --git a/backend/app/engine/retriever/18时33分49秒_副本_HybridRetriever.py b/backend/app/engine/retriever/18时33分49秒_副本_HybridRetriever.py new file mode 100644 index 0000000..4bf0b8d --- /dev/null +++ b/backend/app/engine/retriever/18时33分49秒_副本_HybridRetriever.py @@ -0,0 +1,67 @@ +import os +from typing import Optional, Any, Dict, List + +from llama_index.core.base.base_retriever import BaseRetriever +from llama_index.core.schema import NodeWithScore, QueryBundle + +from app.engine.retriever.CHBM25Retriever import CHBM25Retriever + + +class HybridRetriever(BaseRetriever): + def __init__( + self, + vector_index, + similarity_top_k: int = 2, + out_top_k: Optional[int] = None, + alpha: float = 0.5, + filters = None, + **kwargs: Any, + ) -> None: + super().__init__(**kwargs) + self._vector_index = vector_index + self._embed_model = vector_index._embed_model + self._out_top_k = out_top_k or similarity_top_k + self._vecRetriever = vector_index.as_retriever( + similarity_top_k=similarity_top_k,filters = filters + ) + + STORAGE_DIR = os.getenv("BM_RETRIEVER_PATH", "storage_bm") + if os.path.exists(STORAGE_DIR) and len(os.listdir(STORAGE_DIR)) > 0: + self._bm25Retriever = CHBM25Retriever.from_persist_dir(STORAGE_DIR) + else: + bmRetriver = CHBM25Retriever.from_defaults(similarity_top_k=similarity_top_k,nodes=self._vector_index.vector_store.get_nodes(None)) + bmRetriver.persist(STORAGE_DIR) + self._alpha = alpha + + + + def _retrieve(self, query_bundle: QueryBundle) -> List[NodeWithScore]: + vecNodes:List[NodeWithScore] = self._vecRetriever.retrieve(query_bundle.query_str) + bmNodes:List[NodeWithScore] = self._bm25Retriever.retrieve(query_bundle.query_str) + + bmDic:Dict[str,NodeWithScore] = {} + for node in bmNodes: + bmDic[node.node_id] = node + + result_tups = [] + for i in range(len(vecNodes)): + node = vecNodes[i] + bmScore = 0.0 + if node.node_id in bmDic: + bmScore = bmDic[node.node_id].score + bmDic.pop(node.node_id) + else: + bmScore = 0.0 + full_similarity = (self._alpha * node.score) + ( + (1 - self._alpha) * bmScore + ) + result_tups.append((full_similarity, node)) + + for _,node in bmDic.items(): + full_similarity = (1 - self._alpha) * node.score + result_tups.append((full_similarity, node)) + + result_tups = sorted(result_tups, key=lambda x: x[0], reverse=True) + for full_score, node in result_tups: + node.score = full_score + return [n for _, n in result_tups][:self._out_top_k] \ No newline at end of file diff --git a/backend/app/engine/tools/18时33分47秒_副本_duckduckgo.py b/backend/app/engine/tools/18时33分47秒_副本_duckduckgo.py new file mode 100644 index 0000000..b63612a --- /dev/null +++ b/backend/app/engine/tools/18时33分47秒_副本_duckduckgo.py @@ -0,0 +1,36 @@ +from llama_index.core.tools.function_tool import FunctionTool + + +def duckduckgo_search( + query: str, + region: str = "wt-wt", + max_results: int = 10, +): + """ + Use this function to search for any query in DuckDuckGo. + Args: + query (str): The query to search in DuckDuckGo. + region Optional(str): The region to be used for the search in [country-language] convention, ex us-en, uk-en, ru-ru, etc... + max_results Optional(int): The maximum number of results to be returned. Default is 10. + """ + try: + from duckduckgo_search import DDGS + except ImportError: + raise ImportError( + "duckduckgo_search package is required to use this function." + "Please install it by running: `poetry add duckduckgo_search` or `pip install duckduckgo_search`" + ) + + params = { + "keywords": query, + "region": region, + "max_results": max_results, + } + results = [] + with DDGS() as ddg: + results = list(ddg.text(**params)) + return results + + +def get_tools(**kwargs): + return [FunctionTool.from_defaults(duckduckgo_search)] diff --git a/backend/app/engine/tools/18时33分48秒_副本___init__.py b/backend/app/engine/tools/18时33分48秒_副本___init__.py new file mode 100644 index 0000000..1aced70 --- /dev/null +++ b/backend/app/engine/tools/18时33分48秒_副本___init__.py @@ -0,0 +1,60 @@ +import os +import yaml +import json +import importlib +from cachetools import cached, LRUCache +from llama_index.core.tools.tool_spec.base import BaseToolSpec +from llama_index.core.tools.function_tool import FunctionTool + + +class ToolType: + LLAMAHUB = "llamahub" + LOCAL = "local" + + +class ToolFactory: + + TOOL_SOURCE_PACKAGE_MAP = { + ToolType.LLAMAHUB: "llama_index.tools", + ToolType.LOCAL: "app.engine.tools", + } + + def load_tools(tool_type: str, tool_name: str, config: dict) -> list[FunctionTool]: + source_package = ToolFactory.TOOL_SOURCE_PACKAGE_MAP[tool_type] + try: + if "ToolSpec" in tool_name: + tool_package, tool_cls_name = tool_name.split(".") + module_name = f"{source_package}.{tool_package}" + module = importlib.import_module(module_name) + tool_class = getattr(module, tool_cls_name) + tool_spec: BaseToolSpec = tool_class(**config) + return tool_spec.to_tool_list() + else: + module = importlib.import_module(f"{source_package}.{tool_name}") + tools = module.get_tools(**config) + if not all(isinstance(tool, FunctionTool) for tool in tools): + raise ValueError( + f"The module {module} does not contain valid tools" + ) + return tools + except ImportError as e: + raise ValueError(f"Failed to import tool {tool_name}: {e}") + except AttributeError as e: + raise ValueError(f"Failed to load tool {tool_name}: {e}") + + @staticmethod + def from_env() -> list[FunctionTool]: + tools = [] + if os.path.exists("config/tools.yaml"): + with open("config/tools.yaml", "r") as f: + tool_configs = yaml.safe_load(f) + if tool_configs != None and len(tool_configs.items()) != 0: + for tool_type, config_entries in tool_configs.items(): + if config_entries == None or len(config_entries.items()) == 0: + continue + + for tool_name, config in config_entries.items(): + tools.extend( + ToolFactory.load_tools(tool_type, tool_name, config) + ) + return tools diff --git a/backend/app/engine/tools/18时33分48秒_副本_img_gen.py b/backend/app/engine/tools/18时33分48秒_副本_img_gen.py new file mode 100644 index 0000000..966e95d --- /dev/null +++ b/backend/app/engine/tools/18时33分48秒_副本_img_gen.py @@ -0,0 +1,108 @@ +import os +import uuid +import logging +import requests +from typing import Optional +from pydantic import BaseModel, Field +from llama_index.core.tools import FunctionTool + +logger = logging.getLogger(__name__) + + +class ImageGeneratorToolOutput(BaseModel): + is_success: bool = Field( + ..., + description="Whether the image generation was successful.", + ) + image_url: Optional[str] = Field( + None, + description="The URL of the generated image.", + ) + error_message: Optional[str] = Field( + None, + description="The error message if the image generation failed.", + ) + + +class ImageGeneratorTool: + _IMG_OUTPUT_FORMAT = "webp" + _IMG_OUTPUT_DIR = "output/tool" + _IMG_GEN_API = "https://api.stability.ai/v2beta/stable-image/generate/core" + + def __init__(self, api_key: str = None): + if not api_key: + api_key = os.getenv("STABILITY_API_KEY") + self._api_key = api_key + self.fileserver_url_prefix = os.getenv("FILESERVER_URL_PREFIX") + if self._api_key is None: + raise ValueError( + "STABILITY_API_KEY key is required to run image generator. Get it here: https://platform.stability.ai/account/keys" + ) + if self.fileserver_url_prefix is None: + raise ValueError("FILESERVER_URL_PREFIX is required.") + + def _prepare_output_dir(self): + """ + Create the output directory if it doesn't exist + """ + if not os.path.exists(self._IMG_OUTPUT_DIR): + os.makedirs(self._IMG_OUTPUT_DIR, exist_ok=True) + + def _save_image(self, image_data: bytes): + self._prepare_output_dir() + filename = f"{uuid.uuid4()}.{self._IMG_OUTPUT_FORMAT}" + output_path = os.path.join(self._IMG_OUTPUT_DIR, filename) + with open(output_path, "wb") as f: + f.write(image_data) + url = f"{os.getenv('FILESERVER_URL_PREFIX')}/{self._IMG_OUTPUT_DIR}/{filename}" + logger.info(f"Saved image to {output_path}.\nURL: {url}") + return url + + def _call_stability_api(self, prompt: str): + headers = { + "authorization": f"Bearer {self._api_key}", + "accept": "image/*", + } + data = { + "prompt": prompt, + "output_format": self._IMG_OUTPUT_FORMAT, + } + + response = requests.post( + self._IMG_GEN_API, + headers=headers, + files={"none": ""}, + data=data, + ) + response.raise_for_status() + + return response + + def generate_image(self, prompt: str) -> ImageGeneratorToolOutput: + """ + Use this tool to generate an image based on the prompt. + Args: + prompt (str): The prompt to generate the image from. + """ + + try: + # Call the Stability API + response = self._call_stability_api(prompt) + + # Save the image and get the URL + image_url = self._save_image(response.content) + + return ImageGeneratorToolOutput( + is_success=True, + image_url=image_url, + ) + except Exception as e: + logger.exception(e, exc_info=True) + return ImageGeneratorToolOutput( + is_success=False, + error_message=str(e), + ) + + +def get_tools(**kwargs): + return [FunctionTool.from_defaults(ImageGeneratorTool(**kwargs).generate_image)] diff --git a/backend/app/engine/tools/18时33分48秒_副本_interpreter.py b/backend/app/engine/tools/18时33分48秒_副本_interpreter.py new file mode 100644 index 0000000..1d2c02c --- /dev/null +++ b/backend/app/engine/tools/18时33分48秒_副本_interpreter.py @@ -0,0 +1,143 @@ +import os +import logging +import base64 +import uuid +from pydantic import BaseModel +from typing import List, Tuple, Dict, Optional +from llama_index.core.tools import FunctionTool +from e2b_code_interpreter import CodeInterpreter +from e2b_code_interpreter.models import Logs + + +logger = logging.getLogger(__name__) + + +class InterpreterExtraResult(BaseModel): + type: str + content: Optional[str] = None + filename: Optional[str] = None + url: Optional[str] = None + + +class E2BToolOutput(BaseModel): + is_error: bool + logs: Logs + results: List[InterpreterExtraResult] = [] + + +class E2BCodeInterpreter: + + output_dir = "output/tool" + + def __init__(self, api_key: str = None): + if api_key is None: + api_key = os.getenv("E2B_API_KEY") + filesever_url_prefix = os.getenv("FILESERVER_URL_PREFIX") + if not api_key: + raise ValueError( + "E2B_API_KEY key is required to run code interpreter. Get it here: https://e2b.dev/docs/getting-started/api-key" + ) + if not filesever_url_prefix: + raise ValueError( + "FILESERVER_URL_PREFIX is required to display file output from sandbox" + ) + + self.filesever_url_prefix = filesever_url_prefix + self.interpreter = CodeInterpreter(api_key=api_key) + + def __del__(self): + self.interpreter.close() + + def get_output_path(self, filename: str) -> str: + # if output directory doesn't exist, create it + if not os.path.exists(self.output_dir): + os.makedirs(self.output_dir, exist_ok=True) + return os.path.join(self.output_dir, filename) + + def save_to_disk(self, base64_data: str, ext: str) -> Dict: + filename = f"{uuid.uuid4()}.{ext}" # generate a unique filename + buffer = base64.b64decode(base64_data) + output_path = self.get_output_path(filename) + + try: + with open(output_path, "wb") as file: + file.write(buffer) + except IOError as e: + logger.error(f"Failed to write to file {output_path}: {str(e)}") + raise e + + logger.info(f"Saved file to {output_path}") + + return { + "outputPath": output_path, + "filename": filename, + } + + def get_file_url(self, filename: str) -> str: + return f"{self.filesever_url_prefix}/{self.output_dir}/{filename}" + + def parse_result(self, result) -> List[InterpreterExtraResult]: + """ + The result could include multiple formats (e.g. png, svg, etc.) but encoded in base64 + We save each result to disk and return saved file metadata (extension, filename, url) + """ + if not result: + return [] + + output = [] + + try: + formats = result.formats() + results = [result[format] for format in formats] + + for ext, data in zip(formats, results): + match ext: + case "png" | "svg" | "jpeg" | "pdf": + result = self.save_to_disk(data, ext) + filename = result["filename"] + output.append( + InterpreterExtraResult( + type=ext, + filename=filename, + url=self.get_file_url(filename), + ) + ) + case _: + output.append( + InterpreterExtraResult( + type=ext, + content=data, + ) + ) + except Exception as error: + logger.exception(error, exc_info=True) + logger.error("Error when parsing output from E2b interpreter tool", error) + + return output + + def interpret(self, code: str) -> E2BToolOutput: + """ + Execute python code in a Jupyter notebook cell, the toll will return result, stdout, stderr, display_data, and error. + + Parameters: + code (str): The python code to be executed in a single cell. + """ + logger.info( + f"\n{'='*50}\n> Running following AI-generated code:\n{code}\n{'='*50}" + ) + exec = self.interpreter.notebook.exec_cell(code) + + if exec.error: + logger.error("Error when executing code", exec.error) + output = E2BToolOutput(is_error=True, logs=exec.logs, results=[]) + else: + if len(exec.results) == 0: + output = E2BToolOutput(is_error=False, logs=exec.logs, results=[]) + else: + results = self.parse_result(exec.results[0]) + output = E2BToolOutput(is_error=False, logs=exec.logs, results=results) + return output + + +def get_tools(**kwargs): + return [FunctionTool.from_defaults(E2BCodeInterpreter(**kwargs).interpret)] diff --git a/backend/app/engine/tools/18时33分48秒_副本_openapi_action.py b/backend/app/engine/tools/18时33分48秒_副本_openapi_action.py new file mode 100644 index 0000000..c19187d --- /dev/null +++ b/backend/app/engine/tools/18时33分48秒_副本_openapi_action.py @@ -0,0 +1,78 @@ +from typing import Dict, List, Tuple +from llama_index.tools.openapi import OpenAPIToolSpec +from llama_index.tools.requests import RequestsToolSpec + + +class OpenAPIActionToolSpec(OpenAPIToolSpec, RequestsToolSpec): + """ + A combination of OpenAPI and Requests tool specs that can parse OpenAPI specs and make requests. + + openapi_uri: str: The file path or URL to the OpenAPI spec. + domain_headers: dict: Whitelist domains and the headers to use. + """ + + spec_functions = OpenAPIToolSpec.spec_functions + RequestsToolSpec.spec_functions + # Cached parsed specs by URI + _specs: Dict[str, Tuple[Dict, List[str]]] = {} + + def __init__(self, openapi_uri: str, domain_headers: dict = None, **kwargs): + if domain_headers is None: + domain_headers = {} + if openapi_uri not in self._specs: + openapi_spec, servers = self._load_openapi_spec(openapi_uri) + self._specs[openapi_uri] = (openapi_spec, servers) + else: + openapi_spec, servers = self._specs[openapi_uri] + + # Add the servers to the domain headers if they are not already present + for server in servers: + if server not in domain_headers: + domain_headers[server] = {} + + OpenAPIToolSpec.__init__(self, spec=openapi_spec) + RequestsToolSpec.__init__(self, domain_headers) + + @staticmethod + def _load_openapi_spec(uri: str) -> Tuple[Dict, List[str]]: + """ + Load an OpenAPI spec from a URI. + + Args: + uri (str): A file path or URL to the OpenAPI spec. + + Returns: + List[Document]: A list of Document objects. + """ + import yaml + from urllib.parse import urlparse + + if uri.startswith("http"): + import requests + + response = requests.get(uri) + if response.status_code != 200: + raise ValueError( + "Could not initialize OpenAPIActionToolSpec: " + f"Failed to load OpenAPI spec from {uri}, status code: {response.status_code}" + ) + spec = yaml.safe_load(response.text) + elif uri.startswith("file"): + filepath = urlparse(uri).path + with open(filepath, "r") as file: + spec = yaml.safe_load(file) + else: + raise ValueError( + "Could not initialize OpenAPIActionToolSpec: Invalid OpenAPI URI provided. " + "Only HTTP and file path are supported." + ) + # Add the servers to the whitelist + try: + servers = [ + urlparse(server["url"]).netloc for server in spec.get("servers", []) + ] + except KeyError as e: + raise ValueError( + "Could not initialize OpenAPIActionToolSpec: Invalid OpenAPI spec provided. " + "Could not get `servers` from the spec." + ) from e + return spec, servers diff --git a/backend/app/engine/tools/18时33分48秒_副本_weather.py b/backend/app/engine/tools/18时33分48秒_副本_weather.py new file mode 100644 index 0000000..c8b6f1b --- /dev/null +++ b/backend/app/engine/tools/18时33分48秒_副本_weather.py @@ -0,0 +1,73 @@ +"""Open Meteo weather map tool spec.""" + +import logging +import requests +import pytz +from llama_index.core.tools import FunctionTool + +logger = logging.getLogger(__name__) + + +class OpenMeteoWeather: + geo_api = "https://geocoding-api.open-meteo.com/v1" + weather_api = "https://api.open-meteo.com/v1" + + @classmethod + def _get_geo_location(cls, location: str) -> dict: + """Get geo location from location name.""" + params = {"name": location, "count": 10, "language": "en", "format": "json"} + response = requests.get(f"{cls.geo_api}/search", params=params) + if response.status_code != 200: + raise Exception(f"Failed to fetch geo location: {response.status_code}") + else: + data = response.json() + result = data["results"][0] + geo_location = { + "id": result["id"], + "name": result["name"], + "latitude": result["latitude"], + "longitude": result["longitude"], + } + return geo_location + + @classmethod + def get_weather_information(cls, location: str) -> dict: + """Use this function to get the weather of any given location. + Note that the weather code should follow WMO Weather interpretation codes (WW): + 0: Clear sky + 1, 2, 3: Mainly clear, partly cloudy, and overcast + 45, 48: Fog and depositing rime fog + 51, 53, 55: Drizzle: Light, moderate, and dense intensity + 56, 57: Freezing Drizzle: Light and dense intensity + 61, 63, 65: Rain: Slight, moderate and heavy intensity + 66, 67: Freezing Rain: Light and heavy intensity + 71, 73, 75: Snow fall: Slight, moderate, and heavy intensity + 77: Snow grains + 80, 81, 82: Rain showers: Slight, moderate, and violent + 85, 86: Snow showers slight and heavy + 95: Thunderstorm: Slight or moderate + 96, 99: Thunderstorm with slight and heavy hail + """ + logger.info( + f"Calling open-meteo api to get weather information of location: {location}" + ) + geo_location = cls._get_geo_location(location) + timezone = pytz.timezone("UTC").zone + params = { + "latitude": geo_location["latitude"], + "longitude": geo_location["longitude"], + "current": "temperature_2m,weather_code", + "hourly": "temperature_2m,weather_code", + "daily": "weather_code", + "timezone": timezone, + } + response = requests.get(f"{cls.weather_api}/forecast", params=params) + if response.status_code != 200: + raise Exception( + f"Failed to fetch weather information: {response.status_code}" + ) + return response.json() + + +def get_tools(**kwargs): + return [FunctionTool.from_defaults(OpenMeteoWeather.get_weather_information)] diff --git a/backend/app/xinference/18时33分46秒_副本___init__.py b/backend/app/xinference/18时33分46秒_副本___init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/xinference/18时33分46秒_副本_base.py b/backend/app/xinference/18时33分46秒_副本_base.py new file mode 100644 index 0000000..f256ec8 --- /dev/null +++ b/backend/app/xinference/18时33分46秒_副本_base.py @@ -0,0 +1,272 @@ +"""Xinference embeddings file.""" + +import logging +from enum import Enum +from http import HTTPStatus +from typing import Any, Dict, List, Optional, Union, Tuple + +from llama_index.core.base.embeddings.base import BaseEmbedding, Embedding, dispatcher +from llama_index.core.bridge.pydantic import PrivateAttr +from llama_index.core.callbacks import CBEventType, EventPayload +from llama_index.core.embeddings.multi_modal_base import MultiModalEmbedding +from llama_index.core.instrumentation.events.rerank import ReRankStartEvent, ReRankEndEvent +from llama_index.core.postprocessor.types import BaseNodePostprocessor +from llama_index.core.schema import ImageType, NodeWithScore, QueryBundle +from pydantic import Field + +logger = logging.getLogger(__name__) + + +EMBED_MAX_INPUT_LENGTH = 2048 +EMBED_MAX_BATCH_SIZE = 1 + + +class XinferenceEmbedding(BaseEmbedding): + """Xinference class for text embedding. + + """ + model_description: Dict[str, Any] = Field( + description="The model description from Xinference." + ) + _generator: Any = PrivateAttr() + _model_uid: str = Field(description="The Xinference model to use.") + _endpoint: str = Field(description="The Xinference endpoint URL to use.") + + def __init__( + self, + model_uid: str, + endpoint: str, + embed_batch_size: int = EMBED_MAX_BATCH_SIZE, + dimensions: Optional[int] = None, + additional_kwargs: Optional[Dict[str, Any]] = None, + api_key: Optional[str] = None, + api_base: Optional[str] = None, + api_version: Optional[str] = None, + max_retries: int = 10, + # timeout: float = 60.0, + # reuse_client: bool = True, + # callback_manager: Optional[CallbackManager] = None, + # default_headers: Optional[Dict[str, str]] = None, + # http_client: Optional[httpx.Client] = None, + # async_http_client: Optional[httpx.AsyncClient] = None, + # num_workers: Optional[int] = None, + **kwargs: Any, + ) -> None: + generator, model_description, embed_batch_size, dimensions = self.load_model( + model_uid, endpoint + ) + self._generator = generator + #self._model_uid = model_uid + #self._endpoint = endpoint + super().__init__( + embed_batch_size=embed_batch_size, + dimensions=dimensions, + #callback_manager=callback_manager, + model_name=model_uid, + additional_kwargs=additional_kwargs, + api_key=api_key, + api_base=api_base, + api_version=api_version, + max_retries=max_retries, + # reuse_client=reuse_client, + # timeout=timeout, + # default_headers=default_headers, + # num_workers=num_workers, + **kwargs, + ) + + def load_model(self, model_uid: str, endpoint: str) -> Tuple[Any, int, dict]: + try: + from xinference.client import RESTfulClient + except ImportError: + raise ImportError( + "Could not import Xinference library." + 'Please install Xinference with `pip install "xinference[all]"`' + ) + + client = RESTfulClient(endpoint) + + try: + assert isinstance(client, RESTfulClient) + except AssertionError: + raise RuntimeError( + "Could not create RESTfulClient instance." + "Please make sure Xinference endpoint is running at the correct port." + ) + + generator = client.get_model(model_uid) + model_description = client.list_models()[model_uid] + + try: + assert generator is not None + assert model_description is not None + except AssertionError: + raise RuntimeError( + "Could not get model from endpoint." + "Please make sure Xinference endpoint is running at the correct port." + ) + + model = model_description["model_name"] + replica = model_description['replica'] + dimensions = model_description['dimensions'] + max_tokens = model_description['max_tokens'] + + return generator, model_description, replica, dimensions + + @classmethod + def class_name(cls) -> str: + return "XinferenceEmbedding" + + def _get_text_embedding(self, text: str) -> Embedding: + """ + Embed the input text synchronously. + + Subclasses should implement this method. Reference get_text_embedding's + docstring for more information. + """ + assert self._generator is not None + + response = self._generator.create_embedding(input=text) + return response['data'][0]['embedding'] + + def _get_query_embedding(self, query: str) -> Embedding: + """ + Embed the input query synchronously. + + Subclasses should implement this method. Reference get_query_embedding's + docstring for more information. + """ + return self._get_text_embedding(query) + + async def _aget_query_embedding(self, query: str) -> Embedding: + """ + Embed the input query asynchronously. + + Subclasses should implement this method. Reference get_query_embedding's + docstring for more information. + """ + return self._get_query_embedding(query) + +class XinferenceRerank(BaseNodePostprocessor): + """Xinference class for rerank. + + """ + model_description: Dict[str, Any] = Field( + description="The model description from Xinference." + ) + _generator: Any = PrivateAttr() + _model_uid: str = Field(description="The Xinference model to use.") + _endpoint: str = Field(description="The Xinference endpoint URL to use.") + model: str = Field(description="Dashscope rerank model name.") + top_n: int = Field(description="Top N nodes to return.") + threshold: float = Field(description="threshold nodes to return.") + + def __init__( + self, + model_uid: str, + endpoint: str, + top_n: int = None, + threshold: float = None, + return_documents: bool = False + ): + _model_uid = model_uid + _endpoint = endpoint + _op_n = top_n + threshold = threshold + generator, model_description = self.load_model( + model_uid, endpoint + ) + self._generator = generator + super().__init__(top_n=top_n, model=model_uid, model_uid=model_uid, threshold = threshold, return_documents=return_documents) + + @classmethod + def class_name(cls) -> str: + return "XinferenceRerank" + + def _postprocess_nodes( + self, + nodes: List[NodeWithScore], + query_bundle: Optional[QueryBundle] = None, + ) -> List[NodeWithScore]: + if query_bundle is None: + raise ValueError("Missing query bundle in extra info.") + if len(nodes) == 0: + return [] + + dispatcher.event( + ReRankStartEvent( + nodes = nodes, + top_n = self.top_n, + query = query_bundle, + model_name = self.model + ) + ) + + with self.callback_manager.event( + CBEventType.RERANKING, + payload={ + EventPayload.NODES: nodes, + EventPayload.MODEL_NAME: self._model_uid, + EventPayload.QUERY_STR: query_bundle.query_str, + EventPayload.TOP_K: self.top_n, + }, + ) as event: + texts = [node.node.get_content() for node in nodes] + response = self._generator.rerank(texts,query_bundle.query_str) + new_nodes = [] + for result in response['results']: + new_node_with_score = NodeWithScore( + node=nodes[result['index']].node, score=result['relevance_score'] + ) + if self.threshold is not None: + if new_node_with_score.score >=self.threshold: + new_nodes.append(new_node_with_score) + + if self.top_n is not None: + if len(new_nodes) > self.top_n: + for index in new_nodes[self.top_n:-1]: + new_nodes.remove(index) + + event.on_end(payload={EventPayload.NODES: new_nodes}) + + dispatcher.event( + ReRankEndEvent( + nodes= new_nodes + ) + ) + return new_nodes + + def load_model(self, model_uid: str, endpoint: str) -> Tuple[Any, int, dict]: + try: + from xinference.client import RESTfulClient + except ImportError: + raise ImportError( + "Could not import Xinference library." + 'Please install Xinference with `pip install "xinference[all]"`' + ) + + client = RESTfulClient(endpoint) + + try: + assert isinstance(client, RESTfulClient) + except AssertionError: + raise RuntimeError( + "Could not create RESTfulClient instance." + "Please make sure Xinference endpoint is running at the correct port." + ) + + generator = client.get_model(model_uid) + model_description = client.list_models()[model_uid] + + try: + assert generator is not None + assert model_description is not None + except AssertionError: + raise RuntimeError( + "Could not get model from endpoint." + "Please make sure Xinference endpoint is running at the correct port." + ) + + model = model_description["model_name"] + + return generator, model_description \ No newline at end of file -- 2.52.0 From b052d373f13f1c1bf7fbdb8e423b2985762eddb8 Mon Sep 17 00:00:00 2001 From: paituo <330435863@qq.com> Date: Mon, 26 Aug 2024 09:54:33 +0800 Subject: [PATCH 05/18] =?UTF-8?q?=E5=88=A0=E9=99=A4=E8=AF=AF=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=E7=9A=84=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/18时33分46秒_副本___init__.py | 0 backend/app/18时33分46秒_副本_llmhub.py | 61 ---- .../app/18时33分46秒_副本_observability.py | 20 -- backend/app/18时33分46秒_副本_settings.py | 235 --------------- backend/app/api/18时33分47秒_副本___init__.py | 0 .../api/routers/18时33分49秒_副本___init__.py | 0 .../app/api/routers/18时33分49秒_副本_chat.py | 150 ---------- .../api/routers/18时33分49秒_副本_events.py | 149 ---------- .../api/routers/18时33分49秒_副本_models.py | 253 ---------------- .../api/routers/18时33分49秒_副本_upload.py | 25 -- .../18时33分49秒_副本_vercel_response.py | 109 ------- .../api/services/18时33分49秒_副本_file.py | 113 -------- .../services/18时33分49秒_副本_llama_cloud.py | 114 -------- .../services/18时33分49秒_副本_suggestion.py | 48 ---- backend/app/engine/18时33分46秒_副本_index.py | 22 -- .../app/engine/18时33分47秒_副本___init__.py | 61 ---- .../app/engine/18时33分47秒_副本_constants.py | 1 - .../app/engine/18时33分47秒_副本_engine.py | 108 ------- .../app/engine/18时33分47秒_副本_generate.py | 94 ------ .../app/engine/18时33分47秒_副本_prompt.py | 93 ------ .../app/engine/18时33分47秒_副本_vectordb.py | 71 ----- .../loaders/18时33分48秒_副本___init__.py | 40 --- .../engine/loaders/18时33分48秒_副本_db.py | 140 --------- .../engine/loaders/18时33分48秒_副本_file.py | 88 ------ .../engine/loaders/18时33分48秒_副本_web.py | 37 --- .../18时33分48秒_副本_CHBM25Retriever.py | 133 --------- .../retriever/18时33分48秒_副本_CHTokener.py | 46 --- .../18时33分49秒_副本_HybridRetriever.py | 67 ----- .../tools/18时33分47秒_副本_duckduckgo.py | 36 --- .../tools/18时33分48秒_副本___init__.py | 60 ---- .../engine/tools/18时33分48秒_副本_img_gen.py | 108 ------- .../tools/18时33分48秒_副本_interpreter.py | 143 --------- .../tools/18时33分48秒_副本_openapi_action.py | 78 ----- .../engine/tools/18时33分48秒_副本_weather.py | 73 ----- .../xinference/18时33分46秒_副本___init__.py | 0 .../app/xinference/18时33分46秒_副本_base.py | 272 ------------------ 36 files changed, 3048 deletions(-) delete mode 100644 backend/app/18时33分46秒_副本___init__.py delete mode 100644 backend/app/18时33分46秒_副本_llmhub.py delete mode 100644 backend/app/18时33分46秒_副本_observability.py delete mode 100644 backend/app/18时33分46秒_副本_settings.py delete mode 100644 backend/app/api/18时33分47秒_副本___init__.py delete mode 100644 backend/app/api/routers/18时33分49秒_副本___init__.py delete mode 100644 backend/app/api/routers/18时33分49秒_副本_chat.py delete mode 100644 backend/app/api/routers/18时33分49秒_副本_events.py delete mode 100644 backend/app/api/routers/18时33分49秒_副本_models.py delete mode 100644 backend/app/api/routers/18时33分49秒_副本_upload.py delete mode 100644 backend/app/api/routers/18时33分49秒_副本_vercel_response.py delete mode 100644 backend/app/api/services/18时33分49秒_副本_file.py delete mode 100644 backend/app/api/services/18时33分49秒_副本_llama_cloud.py delete mode 100644 backend/app/api/services/18时33分49秒_副本_suggestion.py delete mode 100644 backend/app/engine/18时33分46秒_副本_index.py delete mode 100644 backend/app/engine/18时33分47秒_副本___init__.py delete mode 100644 backend/app/engine/18时33分47秒_副本_constants.py delete mode 100644 backend/app/engine/18时33分47秒_副本_engine.py delete mode 100644 backend/app/engine/18时33分47秒_副本_generate.py delete mode 100644 backend/app/engine/18时33分47秒_副本_prompt.py delete mode 100644 backend/app/engine/18时33分47秒_副本_vectordb.py delete mode 100644 backend/app/engine/loaders/18时33分48秒_副本___init__.py delete mode 100644 backend/app/engine/loaders/18时33分48秒_副本_db.py delete mode 100644 backend/app/engine/loaders/18时33分48秒_副本_file.py delete mode 100644 backend/app/engine/loaders/18时33分48秒_副本_web.py delete mode 100644 backend/app/engine/retriever/18时33分48秒_副本_CHBM25Retriever.py delete mode 100644 backend/app/engine/retriever/18时33分48秒_副本_CHTokener.py delete mode 100644 backend/app/engine/retriever/18时33分49秒_副本_HybridRetriever.py delete mode 100644 backend/app/engine/tools/18时33分47秒_副本_duckduckgo.py delete mode 100644 backend/app/engine/tools/18时33分48秒_副本___init__.py delete mode 100644 backend/app/engine/tools/18时33分48秒_副本_img_gen.py delete mode 100644 backend/app/engine/tools/18时33分48秒_副本_interpreter.py delete mode 100644 backend/app/engine/tools/18时33分48秒_副本_openapi_action.py delete mode 100644 backend/app/engine/tools/18时33分48秒_副本_weather.py delete mode 100644 backend/app/xinference/18时33分46秒_副本___init__.py delete mode 100644 backend/app/xinference/18时33分46秒_副本_base.py diff --git a/backend/app/18时33分46秒_副本___init__.py b/backend/app/18时33分46秒_副本___init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/app/18时33分46秒_副本_llmhub.py b/backend/app/18时33分46秒_副本_llmhub.py deleted file mode 100644 index 69e0e32..0000000 --- a/backend/app/18时33分46秒_副本_llmhub.py +++ /dev/null @@ -1,61 +0,0 @@ -from llama_index.embeddings.openai import OpenAIEmbedding -from llama_index.core.settings import Settings -from typing import Dict -import os - -DEFAULT_MODEL = "gpt-3.5-turbo" -DEFAULT_EMBEDDING_MODEL = "text-embedding-3-large" - -class TSIEmbedding(OpenAIEmbedding): - def __init__(self, **kwargs): - super().__init__(**kwargs) - self._query_engine = self._text_engine = self.model_name - -def llm_config_from_env() -> Dict: - from llama_index.core.constants import DEFAULT_TEMPERATURE - - model = os.getenv("MODEL", DEFAULT_MODEL) - temperature = os.getenv("LLM_TEMPERATURE", DEFAULT_TEMPERATURE) - max_tokens = os.getenv("LLM_MAX_TOKENS") - api_key = os.getenv("T_SYSTEMS_LLMHUB_API_KEY") - api_base = os.getenv("T_SYSTEMS_LLMHUB_BASE_URL") - - config = { - "model": model, - "api_key": api_key, - "api_base": api_base, - "temperature": float(temperature), - "max_tokens": int(max_tokens) if max_tokens is not None else None, - } - return config - - -def embedding_config_from_env() -> Dict: - from llama_index.core.constants import DEFAULT_EMBEDDING_DIM - - model = os.getenv("EMBEDDING_MODEL", DEFAULT_EMBEDDING_MODEL) - dimension = os.getenv("EMBEDDING_DIM", DEFAULT_EMBEDDING_DIM) - api_key = os.getenv("T_SYSTEMS_LLMHUB_API_KEY") - api_base = os.getenv("T_SYSTEMS_LLMHUB_BASE_URL") - - config = { - "model_name": model, - "dimension": int(dimension) if dimension is not None else None, - "api_key": api_key, - "api_base": api_base, - } - return config - -def init_llmhub(): - from llama_index.llms.openai_like import OpenAILike - - llm_configs = llm_config_from_env() - embedding_configs = embedding_config_from_env() - - Settings.embed_model = TSIEmbedding(**embedding_configs) - Settings.llm = OpenAILike( - **llm_configs, - is_chat_model=True, - is_function_calling_model=False, - context_window=4096, - ) \ No newline at end of file diff --git a/backend/app/18时33分46秒_副本_observability.py b/backend/app/18时33分46秒_副本_observability.py deleted file mode 100644 index 780ae04..0000000 --- a/backend/app/18时33分46秒_副本_observability.py +++ /dev/null @@ -1,20 +0,0 @@ -import os - -import llama_index.core - -def init_observability(): - - PHOENIX_API_KEY = os.getenv("PHOENIX_API_KEY") - if not PHOENIX_API_KEY: - raise ValueError("PHOENIX_API_KEY environment variable is not set") - os.environ["OTEL_EXPORTER_OTLP_HEADERS"] = f"api_key={PHOENIX_API_KEY}" - PHOENIX_URL = os.getenv("PHOENIX_URL") - llama_index.core.set_global_handler( - "arize_phoenix", endpoint=PHOENIX_URL, eval_params={} - ) - - #debugHandle=[] - # llama_debug = LlamaDebugHandler(print_trace_on_end=True) - # debugHandle.append(llama_debug) - # callback_manager = CallbackManager(debugHandle) - # settings.Settings.callback_manager = callback_manager diff --git a/backend/app/18时33分46秒_副本_settings.py b/backend/app/18时33分46秒_副本_settings.py deleted file mode 100644 index 58333d9..0000000 --- a/backend/app/18时33分46秒_副本_settings.py +++ /dev/null @@ -1,235 +0,0 @@ -import os -from typing import Dict - -from llama_index.core.constants import DEFAULT_TEMPERATURE -from llama_index.core.settings import Settings -from llama_index.llms.xinference import Xinference -from llama_index.llms.xinference.base import DEFAULT_XINFERENCE_TEMP - -from app.xinference.base import XinferenceEmbedding, XinferenceRerank - - -def get_node_postprocessors(): - rerank_enabled = os.getenv("RERANK_ENABLED").title() - if rerank_enabled is None or rerank_enabled == 'False': - return [] - - rerank_model = os.getenv("RERANK_MODEL") - rerank_url = os.getenv("RERANK_BASE_URL") - rerank_top_n = os.getenv("RERANK_TOP_N") - rerank_threshold = os.getenv("RERANK_THRESHOLD") - postprocess = None - if rerank_model is not None: - postprocess = [XinferenceRerank(rerank_model, rerank_url, top_n=rerank_top_n, threshold=rerank_threshold)] - return postprocess - -def init_settings(): - model_provider = os.getenv("MODEL_PROVIDER") - match model_provider: - case "openai": - init_openai() - case "dashscope": - init_dashscope() - case "groq": - init_groq() - case "ollama": - init_ollama() - case "anthropic": - init_anthropic() - case "gemini": - init_gemini() - case "mistral": - init_mistral() - case "azure-openai": - init_azure_openai() - case "t-systems": - from .llmhub import init_llmhub - init_llmhub() - case "xinference": - init_xinference() - case _: - raise ValueError(f"Invalid model provider: {model_provider}") - - Settings.chunk_size = int(os.getenv("CHUNK_SIZE", "1024")) - Settings.chunk_overlap = int(os.getenv("CHUNK_OVERLAP", "20")) - - -def init_ollama(): - # from llama_index.embeddings.ollama import OllamaEmbedding - # from llama_index.llms.ollama.base import DEFAULT_REQUEST_TIMEOUT, Ollama - # - # base_url = os.getenv("OLLAMA_BASE_URL") or "http://127.0.0.1:11434" - # request_timeout = float( - # os.getenv("OLLAMA_REQUEST_TIMEOUT", DEFAULT_REQUEST_TIMEOUT) - # ) - # Settings.embed_model = OllamaEmbedding( - # base_url=base_url, - # model_name=os.getenv("EMBEDDING_MODEL"), - # ) - # Settings.llm = Ollama( - # base_url=base_url, model=os.getenv("MODEL"), request_timeout=request_timeout - # ) - pass - -def init_xinference(): - base_url = os.getenv("BASE_URL") - model = os.getenv("MODEL") - max_tokens = int(os.getenv("LLM_MAX_TOKENS")) if os.getenv("LLM_MAX_TOKENS") is not None else None - temperature = float(os.getenv("LLM_TEMPERATURE", DEFAULT_XINFERENCE_TEMP)) - - Settings.llm = Xinference(model, base_url, temperature, max_tokens) - - embedding_base_url = os.getenv("EMBEDDING_BASE_URL") - embedding_base_url = embedding_base_url if embedding_base_url != None and embedding_base_url != "" else base_url - - embed_model_name = os.getenv("EMBEDDING_MODEL") - dimensions = os.getenv("EMBEDDING_DIM") - dimensions = int(dimensions) if dimensions is not None else None - Settings.embed_model = XinferenceEmbedding(embed_model_name, embedding_base_url, dimensions=dimensions) - -def init_openai(): - from llama_index.core.constants import DEFAULT_TEMPERATURE - from llama_index.embeddings.openai import OpenAIEmbedding - from llama_index.llms.openai import OpenAI - - max_tokens = os.getenv("LLM_MAX_TOKENS") - config = { - "model": os.getenv("MODEL"), - "temperature": float(os.getenv("LLM_TEMPERATURE", DEFAULT_TEMPERATURE)), - "max_tokens": int(max_tokens) if max_tokens is not None else None, - } - Settings.llm = OpenAI(**config) - - dimensions = os.getenv("EMBEDDING_DIM") - config = { - "model": os.getenv("EMBEDDING_MODEL"), - "dimensions": int(dimensions) if dimensions is not None else None, - } - Settings.embed_model = OpenAIEmbedding(**config) - -def init_dashscope(): - from llama_index.llms.dashscope import DashScope,DashScopeGenerationModels - from llama_index.embeddings.dashscope import DashScopeEmbedding,DashScopeBatchTextEmbeddingModels,DashScopeTextEmbeddingType,DashScopeTextEmbeddingModels - - max_tokens = os.getenv("LLM_MAX_TOKENS") - config = { - "model": os.getenv("MODEL"), - "temperature": float(os.getenv("LLM_TEMPERATURE", DEFAULT_TEMPERATURE)), - "max_tokens": int(max_tokens) if max_tokens is not None else None, - } - Settings.llm = llm = DashScope(model_name=DashScopeGenerationModels.QWEN_MAX) - - dimensions = os.getenv("EMBEDDING_DIM") - config = { - "model": os.getenv("EMBEDDING_MODEL"), - "dimensions": int(dimensions) if dimensions is not None else None, - } - Settings.embed_model = DashScopeEmbedding(model_name=DashScopeTextEmbeddingModels.TEXT_EMBEDDING_V2, - text_type=DashScopeTextEmbeddingType.TEXT_TYPE_QUERY) - - -def init_azure_openai(): - # from llama_index.core.constants import DEFAULT_TEMPERATURE - # from llama_index.embeddings.azure_openai import AzureOpenAIEmbedding - # from llama_index.llms.azure_openai import AzureOpenAI - # - # llm_deployment = os.environ["AZURE_OPENAI_LLM_DEPLOYMENT"] - # embedding_deployment = os.environ["AZURE_OPENAI_EMBEDDING_DEPLOYMENT"] - # max_tokens = os.getenv("LLM_MAX_TOKENS") - # temperature = os.getenv("LLM_TEMPERATURE", DEFAULT_TEMPERATURE) - # dimensions = os.getenv("EMBEDDING_DIM") - # - # azure_config = { - # "api_key": os.environ["AZURE_OPENAI_KEY"], - # "azure_endpoint": os.environ["AZURE_OPENAI_ENDPOINT"], - # "api_version": os.getenv("AZURE_OPENAI_API_VERSION") - # or os.getenv("OPENAI_API_VERSION"), - # } - # - # Settings.llm = AzureOpenAI( - # model=os.getenv("MODEL"), - # max_tokens=int(max_tokens) if max_tokens is not None else None, - # temperature=float(temperature), - # deployment_name=llm_deployment, - # **azure_config, - # ) - # - # Settings.embed_model = AzureOpenAIEmbedding( - # model=os.getenv("EMBEDDING_MODEL"), - # dimensions=int(dimensions) if dimensions is not None else None, - # deployment_name=embedding_deployment, - # **azure_config, - # ) - pass - - -def init_fastembed(): - """ - Use Qdrant Fastembed as the local embedding provider. - """ - # from llama_index.embeddings.fastembed import FastEmbedEmbedding - # - # embed_model_map: Dict[str, str] = { - # # Small and multilingual - # "all-MiniLM-L6-v2": "sentence-transformers/all-MiniLM-L6-v2", - # # Large and multilingual - # "paraphrase-multilingual-mpnet-base-v2": "sentence-transformers/paraphrase-multilingual-mpnet-base-v2", # noqa: E501 - # } - # - # # This will download the model automatically if it is not already downloaded - # Settings.embed_model = FastEmbedEmbedding( - # model_name=embed_model_map[os.getenv("EMBEDDING_MODEL")] - # ) - pass - - -def init_groq(): - # from llama_index.llms.groq import Groq - # - # model_map: Dict[str, str] = { - # "llama3-8b": "llama3-8b-8192", - # "llama3-70b": "llama3-70b-8192", - # "mixtral-8x7b": "mixtral-8x7b-32768", - # } - # - # Settings.llm = Groq(model=model_map[os.getenv("MODEL")]) - # # Groq does not provide embeddings, so we use FastEmbed instead - # init_fastembed() - pass - - -def init_anthropic(): - # from llama_index.llms.anthropic import Anthropic - # - # model_map: Dict[str, str] = { - # "claude-3-opus": "claude-3-opus-20240229", - # "claude-3-sonnet": "claude-3-sonnet-20240229", - # "claude-3-haiku": "claude-3-haiku-20240307", - # "claude-2.1": "claude-2.1", - # "claude-instant-1.2": "claude-instant-1.2", - # } - # - # Settings.llm = Anthropic(model=model_map[os.getenv("MODEL")]) - # # Anthropic does not provide embeddings, so we use FastEmbed instead - # init_fastembed() - pass - - -def init_gemini(): - # from llama_index.embeddings.gemini import GeminiEmbedding - # from llama_index.llms.gemini import Gemini - # - # model_name = f"models/{os.getenv('MODEL')}" - # embed_model_name = f"models/{os.getenv('EMBEDDING_MODEL')}" - # - # Settings.llm = Gemini(model=model_name) - # Settings.embed_model = GeminiEmbedding(model_name=embed_model_name) - pass - -def init_mistral(): - # from llama_index.embeddings.mistralai import MistralAIEmbedding - # from llama_index.llms.mistralai import MistralAI - # - # Settings.llm = MistralAI(model=os.getenv("MODEL")) - # Settings.embed_model = MistralAIEmbedding(model_name=os.getenv("EMBEDDING_MODEL")) - pass \ No newline at end of file diff --git a/backend/app/api/18时33分47秒_副本___init__.py b/backend/app/api/18时33分47秒_副本___init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/app/api/routers/18时33分49秒_副本___init__.py b/backend/app/api/routers/18时33分49秒_副本___init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/app/api/routers/18时33分49秒_副本_chat.py b/backend/app/api/routers/18时33分49秒_副本_chat.py deleted file mode 100644 index 6476567..0000000 --- a/backend/app/api/routers/18时33分49秒_副本_chat.py +++ /dev/null @@ -1,150 +0,0 @@ -import logging -import os -from typing import List - -from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, status -from llama_index.core.chat_engine.types import BaseChatEngine, NodeWithScore -from llama_index.core.llms import MessageRole -from llama_index.core.vector_stores.types import MetadataFilter, MetadataFilters - -from app.api.routers.events import EventCallbackHandler -from app.api.routers.models import ( - ChatConfig, - ChatData, - Message, - Result, - SourceNodes, -) -from app.api.routers.vercel_response import VercelStreamResponse -from app.api.services.llama_cloud import LLamaCloudFileService -from app.engine import get_chat_engine - -chat_router = r = APIRouter() - -logger = logging.getLogger("uvicorn") - - -def process_response_nodes( - nodes: List[NodeWithScore], - background_tasks: BackgroundTasks, -): - """ - Start background tasks on the source nodes if needed. - """ - files_to_download = SourceNodes.get_download_files(nodes) - for file in files_to_download: - background_tasks.add_task( - LLamaCloudFileService.download_llamacloud_pipeline_file, file - ) - - -# streaming endpoint - delete if not needed -@r.post("") -async def chat( - request: Request, - data: ChatData, - background_tasks: BackgroundTasks, - chat_engine: BaseChatEngine = Depends(get_chat_engine), -): - try: - last_message_content = data.get_last_message_content() - # 由于基于历史消息的提示词没有调整好,所以暂时屏蔽历史消息 - data.messages.clear() - messages = data.get_history_messages() - - doc_ids = data.get_chat_document_ids() - filters = generate_filters(doc_ids) - params = data.data or {} - logger.info("Creating chat engine with filters", filters.dict()) - chat_engine = get_chat_engine(filters=filters, params=params) - - event_handler = EventCallbackHandler() - chat_engine.callback_manager.handlers.append(event_handler) # type: ignore - - response = await chat_engine.astream_chat(last_message_content, messages) - process_response_nodes(response.source_nodes, background_tasks) - - return VercelStreamResponse(request, event_handler, response, data) - except Exception as e: - logger.exception("Error in chat engine", exc_info=True) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error in chat engine: {e}", - ) from e - - -def generate_filters(doc_ids): - if len(doc_ids) > 0: - filters = MetadataFilters( - filters=[ - MetadataFilter( - key="private", - value=["true"], - operator="nin", # type: ignore - ), - MetadataFilter( - key="doc_id", - value=doc_ids, - operator="in", # type: ignore - ), - ], - condition="or", # type: ignore - ) - else: - filters = MetadataFilters( - # Use the "NIN" - "not in" operator to include all public documents (don't have the private key set) - filters=[ - MetadataFilter( - key="private", - value=["true"], - operator="nin", # type: ignore - ), - ] - ) - - return filters - - -# non-streaming endpoint - delete if not needed -@r.post("/request") -async def chat_request( - data: ChatData, - chat_engine: BaseChatEngine = Depends(get_chat_engine), -) -> Result: - last_message_content = data.get_last_message_content() - messages = data.get_history_messages() - - response = await chat_engine.achat(last_message_content, messages) - return Result( - result=Message(role=MessageRole.ASSISTANT, content=response.response), - nodes=SourceNodes.from_source_nodes(response.source_nodes), - ) - - -@r.get("/config") -async def chat_config() -> ChatConfig: - starter_questions = None - conversation_starters = os.getenv("CONVERSATION_STARTERS") - if conversation_starters and conversation_starters.strip(): - starter_questions = conversation_starters.strip().split("\\n") - return ChatConfig(starter_questions=starter_questions) - - -@r.get("/config/llamacloud") -async def chat_llama_cloud_config(): - projects = LLamaCloudFileService.get_all_projects_with_pipelines() - pipeline = os.getenv("LLAMA_CLOUD_INDEX_NAME") - project = os.getenv("LLAMA_CLOUD_PROJECT_NAME") - pipeline_config = ( - pipeline - and project - and { - "pipeline": pipeline, - "project": project, - } - or None - ) - return { - "projects": projects, - "pipeline": pipeline_config, - } diff --git a/backend/app/api/routers/18时33分49秒_副本_events.py b/backend/app/api/routers/18时33分49秒_副本_events.py deleted file mode 100644 index a1d2ea8..0000000 --- a/backend/app/api/routers/18时33分49秒_副本_events.py +++ /dev/null @@ -1,149 +0,0 @@ -import json -import asyncio -import logging -from typing import AsyncGenerator, Dict, Any, List, Optional -from llama_index.core.callbacks.base import BaseCallbackHandler -from llama_index.core.callbacks.schema import CBEventType -from llama_index.core.tools.types import ToolOutput -from pydantic import BaseModel - - -logger = logging.getLogger(__name__) - - -class CallbackEvent(BaseModel): - event_type: CBEventType - payload: Optional[Dict[str, Any]] = None - event_id: str = "" - - def get_retrieval_message(self) -> dict | None: - if self.payload: - nodes = self.payload.get("nodes") - if nodes: - msg = f"根据查询检索到 {len(nodes)} 源文件" - else: - msg = f"查询检索中: '{self.payload.get('query_str')}'" - return { - "type": "events", - "data": {"title": msg}, - } - else: - return None - - def get_tool_message(self) -> dict | None: - func_call_args = self.payload.get("function_call") - if func_call_args is not None and "tool" in self.payload: - tool = self.payload.get("tool") - return { - "type": "events", - "data": { - "title": f"调用工具 {tool.name} ,参数: {func_call_args}", - }, - } - - def _is_output_serializable(self, output: Any) -> bool: - try: - json.dumps(output) - return True - except TypeError: - return False - - def get_agent_tool_response(self) -> dict | None: - response = self.payload.get("response") - if response is not None: - sources = response.sources - for source in sources: - # Return the tool response here to include the toolCall information - if isinstance(source, ToolOutput): - if self._is_output_serializable(source.raw_output): - output = source.raw_output - else: - output = source.content - - return { - "type": "tools", - "data": { - "toolOutput": { - "output": output, - "isError": source.is_error, - }, - "toolCall": { - "id": None, # There is no tool id in the ToolOutput - "name": source.tool_name, - "input": source.raw_input, - }, - }, - } - - def to_response(self): - try: - match self.event_type: - case "retrieve": - return self.get_retrieval_message() - case "function_call": - return self.get_tool_message() - case "agent_step": - return self.get_agent_tool_response() - case _: - return None - except Exception as e: - logger.error(f"转换回应时间时发生错误,原因: {e}") - return None - - -class EventCallbackHandler(BaseCallbackHandler): - _aqueue: asyncio.Queue - is_done: bool = False - - def __init__( - self, - ): - """Initialize the base callback handler.""" - ignored_events = [ - CBEventType.CHUNKING, - CBEventType.NODE_PARSING, - CBEventType.EMBEDDING, - CBEventType.LLM, - CBEventType.TEMPLATING, - ] - super().__init__(ignored_events, ignored_events) - self._aqueue = asyncio.Queue() - - def on_event_start( - self, - event_type: CBEventType, - payload: Optional[Dict[str, Any]] = None, - event_id: str = "", - **kwargs: Any, - ) -> str: - event = CallbackEvent(event_id=event_id, event_type=event_type, payload=payload) - if event.to_response() is not None: - self._aqueue.put_nowait(event) - - def on_event_end( - self, - event_type: CBEventType, - payload: Optional[Dict[str, Any]] = None, - event_id: str = "", - **kwargs: Any, - ) -> None: - event = CallbackEvent(event_id=event_id, event_type=event_type, payload=payload) - if event.to_response() is not None: - self._aqueue.put_nowait(event) - - def start_trace(self, trace_id: Optional[str] = None) -> None: - """No-op.""" - - def end_trace( - self, - trace_id: Optional[str] = None, - trace_map: Optional[Dict[str, List[str]]] = None, - ) -> None: - """No-op.""" - - async def async_event_gen(self) -> AsyncGenerator[CallbackEvent, None]: - while not self._aqueue.empty() or not self.is_done: - try: - yield await asyncio.wait_for(self._aqueue.get(), timeout=0.1) - except asyncio.TimeoutError: - pass diff --git a/backend/app/api/routers/18时33分49秒_副本_models.py b/backend/app/api/routers/18时33分49秒_副本_models.py deleted file mode 100644 index b064218..0000000 --- a/backend/app/api/routers/18时33分49秒_副本_models.py +++ /dev/null @@ -1,253 +0,0 @@ -import logging -import os -from typing import Any, Dict, List, Literal, Optional, Set - -from llama_index.core.llms import ChatMessage, MessageRole -from llama_index.core.schema import NodeWithScore -from pydantic import BaseModel, Field, validator, field_validator -from pydantic.alias_generators import to_camel - -logger = logging.getLogger("uvicorn") - - -class FileContent(BaseModel): - type: Literal["text", "ref"] - # If the file is pure text then the value is be a string - # otherwise, it's a list of document IDs - value: str | List[str] - - -class File(BaseModel): - id: str - content: FileContent - filename: str - filesize: int - filetype: str - - -class AnnotationFileData(BaseModel): - files: List[File] = Field( - default=[], - description="List of files", - ) - - class Config: - json_schema_extra = { - "example": { - "csvFiles": [ - { - "content": "Name, Age\nAlice, 25\nBob, 30", - "filename": "example.csv", - "filesize": 123, - "id": "123", - "type": "text/csv", - } - ] - } - } - alias_generator = to_camel - - -class Annotation(BaseModel): - type: str - data: AnnotationFileData | List[str] - - def to_content(self) -> str | None: - if self.type == "document_file": - # We only support generating context content for CSV files for now - csv_files = [file for file in self.data.files if file.filetype == "csv"] - if len(csv_files) > 0: - return "Use data from following CSV raw content\n" + "\n".join( - [f"```csv\n{csv_file.content.value}\n```" for csv_file in csv_files] - ) - else: - logger.warning( - f"The annotation {self.type} is not supported for generating context content" - ) - return None - - -class Message(BaseModel): - role: MessageRole - content: str - annotations: List[Annotation] | None = None - - -class ChatData(BaseModel): - messages: List[Message] - data: Any = None - - class Config: - json_schema_extra = { - "example": { - "messages": [ - { - "role": "user", - "content": "What standards for letters exist?", - } - ] - } - } - - @field_validator("messages") - def messages_must_not_be_empty(cls, v): - if len(v) == 0: - raise ValueError("Messages must not be empty") - return v - - def get_last_message_content(self) -> str: - """ - Get the content of the last message along with the data content if available. - Fallback to use data content from previous messages - """ - if len(self.messages) == 0: - raise ValueError("There is not any message in the chat") - last_message = self.messages[-1] - message_content = last_message.content - for message in reversed(self.messages): - if message.role == MessageRole.USER and message.annotations is not None: - annotation_contents = filter( - None, - [annotation.to_content() for annotation in message.annotations], - ) - if not annotation_contents: - continue - annotation_text = "\n".join(annotation_contents) - message_content = f"{message_content}\n{annotation_text}" - break - return message_content - - def get_history_messages(self) -> List[ChatMessage]: - """ - Get the history messages - """ - return [ - ChatMessage(role=message.role, content=message.content) - for message in self.messages[:-1] - ] - - def is_last_message_from_user(self) -> bool: - return self.messages[-1].role == MessageRole.USER - - def get_chat_document_ids(self) -> List[str]: - """ - Get the document IDs from the chat messages - """ - document_ids: List[str] = [] - for message in self.messages: - if message.role == MessageRole.USER and message.annotations is not None: - for annotation in message.annotations: - if ( - annotation.type == "document_file" - and annotation.data.files is not None - ): - for fi in annotation.data.files: - if fi.content.type == "ref": - document_ids += fi.content.value - return list(set(document_ids)) - - -class LlamaCloudFile(BaseModel): - file_name: str - pipeline_id: str - - def __eq__(self, other): - if not isinstance(other, LlamaCloudFile): - return NotImplemented - return ( - self.file_name == other.file_name and self.pipeline_id == other.pipeline_id - ) - - def __hash__(self): - return hash((self.file_name, self.pipeline_id)) - - -class SourceNodes(BaseModel): - id: str - metadata: Dict[str, Any] - score: Optional[float] - text: str - url: Optional[str] - - @classmethod - def from_source_node(cls, source_node: NodeWithScore): - metadata = source_node.node.metadata - url = cls.get_url_from_metadata(metadata) - #text = 'filename' in metadata and metadata['filename'] or source_node.node.node_id - text = source_node.node.text - return cls( - id=source_node.node.node_id, - metadata=metadata, - score=source_node.score, - text=text, # type: ignore - url=url, - ) - - @classmethod - def get_url_from_metadata(cls, metadata: Dict[str, Any]) -> str: - url_prefix = os.getenv("FILESERVER_URL_PREFIX") - if not url_prefix: - logger.warning( - "Warning: FILESERVER_URL_PREFIX not set in environment variables. Can't use file server" - ) - file_name = metadata.get("file_name") - if file_name and url_prefix: - # file_name exists and file server is configured - pipeline_id = metadata.get("pipeline_id") - if pipeline_id and metadata.get("private") is None: - # file is from LlamaCloud and was not ingested locally - file_name = f"{pipeline_id}${file_name}" - return f"{url_prefix}/output/llamacloud/{file_name}" - is_private = metadata.get("private", "false") == "true" - if is_private: - return f"{url_prefix}/output/uploaded/{file_name}" - return f"{url_prefix}/data/{file_name}" - else: - # fallback to URL in metadata (e.g. for websites) - return metadata.get("URL") - - @classmethod - def from_source_nodes(cls, source_nodes: List[NodeWithScore]): - return [cls.from_source_node(node) for node in source_nodes] - - @staticmethod - def get_download_files(nodes: List[NodeWithScore]) -> Set[LlamaCloudFile]: - source_nodes = SourceNodes.from_source_nodes(nodes) - llama_cloud_files = [ - LlamaCloudFile( - file_name=node.metadata.get("file_name"), - pipeline_id=node.metadata.get("pipeline_id"), - ) - for node in source_nodes - if ( - node.metadata.get("private") - is None # Only download files are from LlamaCloud and were not ingested locally - and node.metadata.get("pipeline_id") is not None - and node.metadata.get("file_name") is not None - ) - ] - # Remove duplicates and return - return set(llama_cloud_files) - - -class Result(BaseModel): - result: Message - nodes: List[SourceNodes] - - -class ChatConfig(BaseModel): - starter_questions: Optional[List[str]] = Field( - default=None, - description="List of starter questions", - serialization_alias="starterQuestions", - ) - - class Config: - json_schema_extra = { - "example": { - "starterQuestions": [ - "What standards for letters exist?", - "What are the requirements for a letter to be considered a letter?", - ] - } - } diff --git a/backend/app/api/routers/18时33分49秒_副本_upload.py b/backend/app/api/routers/18时33分49秒_副本_upload.py deleted file mode 100644 index 94f3ce7..0000000 --- a/backend/app/api/routers/18时33分49秒_副本_upload.py +++ /dev/null @@ -1,25 +0,0 @@ -import logging -from typing import List - -from fastapi import APIRouter, HTTPException -from pydantic import BaseModel - -from app.api.services.file import PrivateFileService - -file_upload_router = r = APIRouter() - -logger = logging.getLogger("uvicorn") - - -class FileUploadRequest(BaseModel): - base64: str - - -@r.post("") -def upload_file(request: FileUploadRequest) -> List[str]: - try: - logger.info("Processing file") - return PrivateFileService.process_file(request.base64) - except Exception as e: - logger.error(f"Error processing file: {e}", exc_info=True) - raise HTTPException(status_code=500, detail="Error processing file") diff --git a/backend/app/api/routers/18时33分49秒_副本_vercel_response.py b/backend/app/api/routers/18时33分49秒_副本_vercel_response.py deleted file mode 100644 index 0222a14..0000000 --- a/backend/app/api/routers/18时33分49秒_副本_vercel_response.py +++ /dev/null @@ -1,109 +0,0 @@ -import json - -from aiostream import stream -from fastapi import Request -from fastapi.responses import StreamingResponse -from llama_index.core.chat_engine.types import StreamingAgentChatResponse - -from app.api.routers.events import EventCallbackHandler -from app.api.routers.models import ChatData, Message, SourceNodes -from app.api.services.suggestion import NextQuestionSuggestion - - -class VercelStreamResponse(StreamingResponse): - """ - Class to convert the response from the chat engine to the streaming format expected by Vercel - """ - - TEXT_PREFIX = "0:" - DATA_PREFIX = "8:" - - @classmethod - def convert_text(cls, token: str): - # Escape newlines and double quotes to avoid breaking the stream - token = json.dumps(token) - return f"{cls.TEXT_PREFIX}{token}\n" - - @classmethod - def convert_data(cls, data: dict): - data_str = json.dumps(data) - return f"{cls.DATA_PREFIX}[{data_str}]\n" - - def __init__( - self, - request: Request, - event_handler: EventCallbackHandler, - response: StreamingAgentChatResponse, - chat_data: ChatData, - ): - content = VercelStreamResponse.content_generator( - request, event_handler, response, chat_data - ) - super().__init__(content=content) - - @classmethod - async def content_generator( - cls, - request: Request, - event_handler: EventCallbackHandler, - response: StreamingAgentChatResponse, - chat_data: ChatData, - ): - # Yield the text response - async def _chat_response_generator(): - final_response = "" - async for token in response.async_response_gen(): - final_response += token - yield VercelStreamResponse.convert_text(token) - - # Generate questions that user might interested to - conversation = chat_data.messages + [ - Message(role="assistant", content=final_response) - ] - questions = await NextQuestionSuggestion.suggest_next_questions( - conversation - ) - if len(questions) > 0: - yield VercelStreamResponse.convert_data( - { - "type": "suggested_questions", - "data": questions, - } - ) - - # the text_generator is the leading stream, once it's finished, also finish the event stream - event_handler.is_done = True - - # Yield the source nodes - yield cls.convert_data( - { - "type": "sources", - "data": { - "nodes": [ - SourceNodes.from_source_node(node).dict() - for node in response.source_nodes - ] - }, - } - ) - - # Yield the events from the event handler - async def _event_generator(): - async for event in event_handler.async_event_gen(): - event_response = event.to_response() - if event_response is not None: - yield VercelStreamResponse.convert_data(event_response) - - combine = stream.merge(_chat_response_generator(), _event_generator()) - is_stream_started = False - async with combine.stream() as streamer: - async for output in streamer: - if not is_stream_started: - is_stream_started = True - # Stream a blank message to start the stream - yield VercelStreamResponse.convert_text("") - - yield output - - if await request.is_disconnected(): - break diff --git a/backend/app/api/services/18时33分49秒_副本_file.py b/backend/app/api/services/18时33分49秒_副本_file.py deleted file mode 100644 index a478570..0000000 --- a/backend/app/api/services/18时33分49秒_副本_file.py +++ /dev/null @@ -1,113 +0,0 @@ -import base64 -import mimetypes -import os -from pathlib import Path -from typing import Dict, List -from uuid import uuid4 - -from app.engine.index import get_index -from llama_index.core import VectorStoreIndex -from llama_index.core.ingestion import IngestionPipeline -from llama_index.core.readers.file.base import ( - _try_loading_included_file_formats as get_file_loaders_map, -) -from llama_index.core.readers.file.base import ( - default_file_metadata_func, -) -from llama_index.core.schema import Document -from llama_index.indices.managed.llama_cloud.base import LlamaCloudIndex -from llama_index.readers.file import FlatReader - - -def get_llamaparse_parser(): - from app.engine.loaders import load_configs - from app.engine.loaders.file import FileLoaderConfig, llama_parse_parser - - config = load_configs() - file_loader_config = FileLoaderConfig(**config["file"]) - if file_loader_config.use_llama_parse: - return llama_parse_parser() - else: - return None - - -def default_file_loaders_map(): - default_loaders = get_file_loaders_map() - default_loaders[".txt"] = FlatReader - return default_loaders - - -class PrivateFileService: - PRIVATE_STORE_PATH = "output/uploaded" - - @staticmethod - def preprocess_base64_file(base64_content: str) -> tuple: - header, data = base64_content.split(",", 1) - mime_type = header.split(";")[0].split(":", 1)[1] - extension = mimetypes.guess_extension(mime_type) - # File data as bytes - return base64.b64decode(data), extension - - @staticmethod - def store_and_parse_file(file_data, extension) -> List[Document]: - # Store file to the private directory - os.makedirs(PrivateFileService.PRIVATE_STORE_PATH, exist_ok=True) - - # random file name - file_name = f"{uuid4().hex}{extension}" - file_path = Path(os.path.join(PrivateFileService.PRIVATE_STORE_PATH, file_name)) - - # write file - with open(file_path, "wb") as f: - f.write(file_data) - - # Load file to documents - # If LlamaParse is enabled, use it to parse the file - # Otherwise, use the default file loaders - reader = get_llamaparse_parser() - if reader is None: - reader_cls = default_file_loaders_map().get(extension) - if reader_cls is None: - raise ValueError(f"File extension {extension} is not supported") - reader = reader_cls() - documents = reader.load_data(file_path) - # Add custom metadata - for doc in documents: - doc.metadata["file_name"] = file_name - doc.metadata["private"] = "true" - return documents - - @staticmethod - def process_file(base64_content: str) -> List[str]: - file_data, extension = PrivateFileService.preprocess_base64_file(base64_content) - documents = PrivateFileService.store_and_parse_file(file_data, extension) - - # Only process nodes, no store the index - pipeline = IngestionPipeline() - nodes = pipeline.run(documents=documents) - - # Add the nodes to the index and persist it - current_index = get_index() - - # Insert the documents into the index - if isinstance(current_index, LlamaCloudIndex): - # LlamaCloudIndex is a managed index so we don't need to process the nodes - # just insert the documents - for doc in documents: - current_index.insert(doc) - else: - # Only process nodes, no store the index - pipeline = IngestionPipeline() - nodes = pipeline.run(documents=documents) - - # Add the nodes to the index and persist it - if current_index is None: - current_index = VectorStoreIndex(nodes=nodes) - else: - current_index.insert_nodes(nodes=nodes) - current_index.storage_context.persist( - persist_dir=os.environ.get("STORAGE_DIR", "storage") - ) - - # Return the document ids - return [doc.doc_id for doc in documents] diff --git a/backend/app/api/services/18时33分49秒_副本_llama_cloud.py b/backend/app/api/services/18时33分49秒_副本_llama_cloud.py deleted file mode 100644 index 852ae7c..0000000 --- a/backend/app/api/services/18时33分49秒_副本_llama_cloud.py +++ /dev/null @@ -1,114 +0,0 @@ -import logging -import os -from typing import Any, Dict, List, Optional - -import requests -from app.api.routers.models import LlamaCloudFile - -logger = logging.getLogger("uvicorn") - - -class LLamaCloudFileService: - LLAMA_CLOUD_URL = "https://cloud.llamaindex.ai/api/v1" - LOCAL_STORE_PATH = "output/llamacloud" - - DOWNLOAD_FILE_NAME_TPL = "{pipeline_id}${filename}" - - @classmethod - def get_all_projects(cls) -> List[Dict[str, Any]]: - url = f"{cls.LLAMA_CLOUD_URL}/projects" - return cls._make_request(url) - - @classmethod - def get_all_pipelines(cls) -> List[Dict[str, Any]]: - url = f"{cls.LLAMA_CLOUD_URL}/pipelines" - return cls._make_request(url) - - @classmethod - def get_all_projects_with_pipelines(cls) -> List[Dict[str, Any]]: - try: - projects = cls.get_all_projects() - pipelines = cls.get_all_pipelines() - return [ - { - **project, - "pipelines": [p for p in pipelines if p["project_id"] == project["id"]], - } - for project in projects - ] - except Exception as error: - logger.error(f"Error listing projects and pipelines: {error}") - return [] - - @classmethod - def _get_files(cls, pipeline_id: str) -> List[Dict[str, Any]]: - url = f"{cls.LLAMA_CLOUD_URL}/pipelines/{pipeline_id}/files" - return cls._make_request(url) - - @classmethod - def _get_file_detail(cls, project_id: str, file_id: str) -> Dict[str, Any]: - url = f"{cls.LLAMA_CLOUD_URL}/files/{file_id}/content?project_id={project_id}" - return cls._make_request(url) - - @classmethod - def _download_file(cls, url: str, local_file_path: str): - logger.info(f"Downloading file to {local_file_path}") - # Create directory if it doesn't exist - os.makedirs(cls.LOCAL_STORE_PATH, exist_ok=True) - # Download the file - with requests.get(url, stream=True) as r: - r.raise_for_status() - with open(local_file_path, "wb") as f: - for chunk in r.iter_content(chunk_size=8192): - f.write(chunk) - logger.info("File downloaded successfully") - - @classmethod - def download_llamacloud_pipeline_file( - cls, - file: LlamaCloudFile, - force_download: bool = False, - ): - file_name = file.file_name - pipeline_id = file.pipeline_id - - # Check is the file already exists - downloaded_file_path = cls.get_file_path(file_name, pipeline_id) - if os.path.exists(downloaded_file_path) and not force_download: - logger.debug(f"File {file_name} already exists in local storage") - return - try: - logger.info(f"Downloading file {file_name} for pipeline {pipeline_id}") - files = cls._get_files(pipeline_id) - if not files or not isinstance(files, list): - raise Exception("No files found in LlamaCloud") - for file_entry in files: - if file_entry["name"] == file_name: - file_id = file_entry["file_id"] - project_id = file_entry["project_id"] - file_detail = cls._get_file_detail(project_id, file_id) - cls._download_file(file_detail["url"], downloaded_file_path) - break - except Exception as error: - logger.info(f"Error fetching file from LlamaCloud: {error}") - - @classmethod - def get_file_name(cls, name: str, pipeline_id: str) -> str: - return cls.DOWNLOAD_FILE_NAME_TPL.format(pipeline_id=pipeline_id, filename=name) - - @classmethod - def get_file_path(cls, name: str, pipeline_id: str) -> str: - return os.path.join(cls.LOCAL_STORE_PATH, cls.get_file_name(name, pipeline_id)) - - @staticmethod - def _make_request( - url: str, data=None, headers: Optional[Dict] = None, method: str = "get" - ): - if headers is None: - headers = { - "Accept": "application/json", - "Authorization": f'Bearer {os.getenv("LLAMA_CLOUD_API_KEY")}', - } - response = requests.request(method, url, headers=headers, data=data) - response.raise_for_status() - return response.json() diff --git a/backend/app/api/services/18时33分49秒_副本_suggestion.py b/backend/app/api/services/18时33分49秒_副本_suggestion.py deleted file mode 100644 index 7017307..0000000 --- a/backend/app/api/services/18时33分49秒_副本_suggestion.py +++ /dev/null @@ -1,48 +0,0 @@ -from typing import List - -from app.api.routers.models import Message -from llama_index.core.prompts import PromptTemplate -from llama_index.core.settings import Settings -from pydantic import BaseModel - -NEXT_QUESTIONS_SUGGESTION_PROMPT = PromptTemplate( - "你是一个乐于助人的助手!你的任务是对用户可能会问的下一个问题给出建议。 " - "\n这是对话历史记录" - "\n---------------------\n{conversation}\n---------------------" - "考虑到对话历史记录,仅限于现在知识库已有内容, 请给我 $number_of_questions 个你接下来可能会问题的问题!" -) -N_QUESTION_TO_GENERATE = 3 - - -class NextQuestions(BaseModel): - """A list of questions that user might ask next""" - - questions: List[str] - - -class NextQuestionSuggestion: - @staticmethod - async def suggest_next_questions( - messages: List[Message], - number_of_questions: int = N_QUESTION_TO_GENERATE, - ) -> List[str]: - # Reduce the cost by only using the last two messages - last_user_message = None - last_assistant_message = None - for message in reversed(messages): - if message.role == "user": - last_user_message = f"User: {message.content}" - elif message.role == "assistant": - last_assistant_message = f"Assistant: {message.content}" - if last_user_message and last_assistant_message: - break - conversation: str = f"{last_user_message}\n{last_assistant_message}" - - output: NextQuestions = await Settings.llm.astructured_predict( - NextQuestions, - prompt=NEXT_QUESTIONS_SUGGESTION_PROMPT, - conversation=conversation, - nun_questions=number_of_questions, - ) - - return output.questions diff --git a/backend/app/engine/18时33分46秒_副本_index.py b/backend/app/engine/18时33分46秒_副本_index.py deleted file mode 100644 index b21e695..0000000 --- a/backend/app/engine/18时33分46秒_副本_index.py +++ /dev/null @@ -1,22 +0,0 @@ -import logging -from llama_index.core.indices import VectorStoreIndex -from app.engine.vectordb import get_vector_store - - -logger = logging.getLogger("uvicorn") - -index = None - -def get_index(params=None): - global index - if index is None: - logger.info("Connecting vector store...") - - store = get_vector_store() - # Load the index from the vector store - # If you are using a vector store that doesn't store text, - # you must load the index from both the vector store and the document store - index = VectorStoreIndex.from_vector_store(store) - logger.info("Finished load index from vector store.") - - return index diff --git a/backend/app/engine/18时33分47秒_副本___init__.py b/backend/app/engine/18时33分47秒_副本___init__.py deleted file mode 100644 index 6e2a97a..0000000 --- a/backend/app/engine/18时33分47秒_副本___init__.py +++ /dev/null @@ -1,61 +0,0 @@ -import os - -from llama_index.core.agent import AgentRunner, ReActChatFormatter -from llama_index.core.settings import Settings -from llama_index.core.tools.query_engine import QueryEngineTool - -from app.engine.engine import create_query_engine, create_summary_query_engine -from app.engine.index import get_index -#from app.engine.loaders.db import makeDescriptionByEngine -from app.engine.tools import ToolFactory - - -def get_chat_engine(filters=None, params=None): - system_prompt = os.getenv("SYSTEM_PROMPT") - top_k = int(os.getenv("TOP_K", "3")) - use_reranker = os.getenv("RERANK_ENABLED") - tools = [] - - # 创建SQL查询工具 -# sql_query_engine = create_summary_query_engine(index) - # sql_query_tool = QueryEngineTool.from_defaults(query_engine=sql_query_engine, - # name="zjdata_query_tool", - # description="来源于一个由博微公司电力造价软件编制的造价工程文件。该文件以多张表格的形式存储存储了整个工程的全部数据内容。适用于以详细的自然语言查询表格数据方式查询造价工程各项具体属性、费用的数值。请先使用“zj_query_tool”无法解决才使用本工具" - # ) - #tools.append(sql_query_tool) - - # Add query tool if index exists - index = get_index() - if index is not None: - summary_query_engine = create_summary_query_engine(index,top_k,use_reranker,filters) - summary_query_tool = QueryEngineTool.from_defaults( query_engine=summary_query_engine, name="summary_query_tool", - description="适用于任何需要进行全面总结、概括的要求。", - ) - query_engine = create_query_engine(index,top_k,use_reranker,filters) - query_engine_tool = QueryEngineTool.from_defaults(query_engine=query_engine, name="zj_query_tool", - description="由博微公司编制的关于电力造价知识、电力造价编制软件知识和造价工程文件结构的知识库。适用于查询电力领域、电力造价领域、博微、博微电力、博微造价等业务等内容。如果本知识库没有直接答案但有解决思路的可以返回解决办法后建议使用“zjdata_query_tool”工具。", - ) - - tools.append(summary_query_tool) - tools.append(query_engine_tool) - - # Add additional tools - tools += ToolFactory.from_env() - - prefix_messages = ("""您的设计旨在帮助完成各种任务,从回答问题到提供其他类型分析的摘要。\n\n##工具\n\n你可以访问各种工具。你有责任按照你认为合适的顺序使用这些工具来完成当前的任务。\n这可能需要将任务分解为子任务,并使用不同的工具来完成每个子任务。\n\n你可以访问以下工具:\n{tool_desc}\n\n\n##输出格式\n\n请用与问题相同的语言回答,并使用以下格式:\n\n \nThought: 用户当前的语言是:(user's language)。我需要使用工具来帮助我回答问题。\nAction: 如果使用工具,则为工具名称(one of {tool_names})。\nAction Input: 输入给工具的内容,使用JSON格式表示kwargs(例如{{\"input\": \"hello world\", \"num_beams\": 5}})\n \n\n请始终以Thought开始。\n\n请始终以Thought开始。\n\n请始终以Thought开始。\n\n请始终以Thought开始。\n\n切勿用Markdown代码标记包围你的响应。如果需要,可以在响应中使用代码标记。\n\n请为Action Input使用有效的JSON格式。不要这样做{{\'input\': \'hello world\', \'num_beams\': 5}}。\n\n如果使用此格式,用户将以下面的格式进行回应:\n\n \nObservation: 工具响应\n \n\n你应该继续重复上述格式,直到你有足够的信息来回答问题而无需使用更多工具。此时,你必须使用以下两种格式之一进行回答:\n\n \nThought: 我可以不用任何工具来回答。我将使用用户的语言来回答。\nAnswer: [你的答案(与用户问题相同的语言)]\n \n\n \nThought: 我无法使用提供的工具回答问题。\nAnswer: [你的答案(与用户问题相同的语言)]\n \n\n##如果从工具中得到的回应是Empty Response,那么只需要回答“我不知道”,不需要额外回答别的内容。## 当前对话\n\n以下是当前对话,由人类和助手的消息交替组成。\n""") - react_chat_formatter = ReActChatFormatter.from_defaults(prefix_messages) - agentrunner = AgentRunner.from_llm( - llm=Settings.llm, - tools=tools, - react_chat_formatter=react_chat_formatter, - system_prompt=system_prompt, - verbose=True, - ) - return agentrunner - # create the function calling worker for reasoning - # worker = FunctionCallingAgentWorker.from_tools( - # tools, verbose=True - # ) - # - # # wrap the worker in the top-level planner - # return StructuredPlannerAgent(worker, tools) diff --git a/backend/app/engine/18时33分47秒_副本_constants.py b/backend/app/engine/18时33分47秒_副本_constants.py deleted file mode 100644 index bd93bb8..0000000 --- a/backend/app/engine/18时33分47秒_副本_constants.py +++ /dev/null @@ -1 +0,0 @@ -STORAGE_DIR = "storage" # directory to cache the generated index \ No newline at end of file diff --git a/backend/app/engine/18时33分47秒_副本_engine.py b/backend/app/engine/18时33分47秒_副本_engine.py deleted file mode 100644 index 379275e..0000000 --- a/backend/app/engine/18时33分47秒_副本_engine.py +++ /dev/null @@ -1,108 +0,0 @@ -import os - -from llama_index.core import SummaryIndex, SQLDatabase, VectorStoreIndex -from llama_index.core.indices.struct_store import SQLTableRetrieverQueryEngine -from llama_index.core.objects import SQLTableNodeMapping, ObjectIndex, SQLTableSchema -from llama_index.core.query_engine import RetrieverQueryEngine -from llama_index.core.response_synthesizers import ResponseMode -from llama_index.readers.database import DatabaseReader -from sqlalchemy import create_engine - -from app.engine.prompt import text_qa_template, refine_template, summary_template, simple_template -from app.engine.retriever.HybridRetriever import HybridRetriever -from app.settings import get_node_postprocessors - -def makeDescriptionByEngine(sql_database:SQLDatabase): - reader = DatabaseReader(sql_database) - - table_names = sql_database.get_usable_table_names() - table_schema_objs = [] - for table_name in table_names: - columns = sql_database.get_table_columns(table_name) - if len(columns) > 150: - continue - stats_txt = "" - - if table_name == 'gongchengshuxing': - stats_txt = '该表中有以下属性:' - documents = reader.load_data(query='select name from gongchengshuxing') - for index in range(len(documents) if len(documents) < 30 else 30): - if index == 0: - continue - elif index > 1: - stats_txt += ',' - stats_txt += documents[index].text.split(':')[1] - - tbSchema = (SQLTableSchema(table_name=table_name, context_str=stats_txt)) - table_schema_objs.append(tbSchema) - - return table_schema_objs - -def get_Retriever(index,**kwargs): - strEnableHybrid = os.getenv("HYBRID_ENABLED",'False') - bEnableHybrid = True if strEnableHybrid is not None and strEnableHybrid.title() == 'True' else False - if bEnableHybrid: - alpha = float(os.getenv("HYBRID_ALPHA", "0.5")) - retriever = HybridRetriever(index,alpha = alpha,**kwargs) - else: - retriever = index.as_retriever(**kwargs) - return retriever - - -sql_database = None -sql_obj_index = None - -# Create a summary query engine -def create_summary_query_engine(top_k=3, use_reranker=False, filters=None): - global sql_obj_index - global sql_database - if sql_obj_index is None or sql_database is None: - sqlengine = create_engine(os.getenv("SQL_DATABASE_URL", "")) - sql_database = SQLDatabase(sqlengine) - table_schema_objs = makeDescriptionByEngine(sql_database) - table_node_mapping = SQLTableNodeMapping(sql_database) - - sql_obj_index = ObjectIndex.from_objects( - table_schema_objs, - table_node_mapping, - index_cls=VectorStoreIndex, - ) - - # 创建SQL查询工具 - sql_query_engine = SQLTableRetrieverQueryEngine(sql_database, - sql_obj_index.as_retriever(similarity_top_k=top_k), - verbose=True, - ) - return sql_query_engine - -# Create a summary query engine -def create_summary_query_engine(index, top_k=3, use_reranker=False, filters=None): - summary_index = SummaryIndex(index.vector_store.get_nodes(node_ids=None)) - summary_query_engine = summary_index.as_query_engine( - response_mode=ResponseMode.TREE_SUMMARIZE, - use_async=True, - streaming=True, - ) - return summary_query_engine - -# Create a query engine -def create_query_engine(index, top_k=3, use_reranker=False, filters=None): - # 创建向量检索查询工具 - postprocess = None - if use_reranker: - postprocess = get_node_postprocessors() - - query_engine = RetrieverQueryEngine.from_args( - get_Retriever(index, - similarity_top_k=top_k, - filters=filters), - text_qa_template=text_qa_template, - refine_template=refine_template, - summary_template = summary_template, - simple_template = simple_template, - node_postprocessors=postprocess, - use_async=True, - streaming=True, - ) - - return query_engine \ No newline at end of file diff --git a/backend/app/engine/18时33分47秒_副本_generate.py b/backend/app/engine/18时33分47秒_副本_generate.py deleted file mode 100644 index 87ecfa1..0000000 --- a/backend/app/engine/18时33分47秒_副本_generate.py +++ /dev/null @@ -1,94 +0,0 @@ -from dotenv import load_dotenv - -load_dotenv() - -import logging -import os - -from app.engine.loaders import get_documents -from app.engine.vectordb import get_vector_store -from app.settings import init_settings -from app.engine.retriever.CHBM25Retriever import CHBM25Retriever -from llama_index.core.ingestion import IngestionPipeline -from llama_index.core.node_parser import SentenceSplitter -from llama_index.core.settings import Settings -from llama_index.core.storage import StorageContext -from llama_index.core.storage.docstore import SimpleDocumentStore - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger() - -STORAGE_DIR = os.getenv("STORAGE_DIR", "storage") - - -def get_doc_store(): - - # If the storage directory is there, load the document store from it. - # If not, set up an in-memory document store since we can't load from a directory that doesn't exist. - if os.path.exists(STORAGE_DIR): - return SimpleDocumentStore.from_persist_dir(STORAGE_DIR) - else: - return SimpleDocumentStore() - - -def run_pipeline(docstore, vector_store, documents): - pipeline = IngestionPipeline( - transformations=[ - SentenceSplitter( - chunk_size=Settings.chunk_size, - chunk_overlap=Settings.chunk_overlap, - ), - Settings.embed_model, - ], - docstore=docstore, - docstore_strategy="upserts_and_delete", - vector_store=vector_store, - ) - - # Run the ingestion pipeline and store the results - nodes = pipeline.run(show_progress=True, documents=documents) - - return nodes - - -def persist_storage(docstore, vector_store): - storage_context = StorageContext.from_defaults( - docstore=docstore, - vector_store=vector_store, - ) - storage_context.persist(STORAGE_DIR) - - -def persist_BMRetriever(vector_store): - STORAGE_DIR = os.getenv("BM_RETRIEVER_PATH", "storage_bm") - top_k = int(os.getenv("TOP_K", "3")) - bmRetriver = CHBM25Retriever.from_defaults(similarity_top_k=top_k,nodes=vector_store.get_nodes([])) - bmRetriver.persist(STORAGE_DIR) - - -def generate_datasource(): - init_settings() - logger.info("Generate index for the provided data") - - # Get the stores and documents or create new ones - documents = get_documents() - # Set private=false to mark the document as public (required for filtering) - for doc in documents: - doc.metadata["private"] = "false" - docstore = get_doc_store() - vector_store = get_vector_store() - - # Run the ingestion pipeline - _ = run_pipeline(docstore, vector_store, documents) - - # Build the index and persist storage - persist_storage(docstore, vector_store) - persist_BMRetriever(vector_store) - - logger.info("Finished generating the index") - - -if __name__ == "__main__": - from phoenix.trace import using_project - with using_project(os.getenv("PHOENIX_PROJECT_NAME") + "_generate") as obj: - generate_datasource() diff --git a/backend/app/engine/18时33分47秒_副本_prompt.py b/backend/app/engine/18时33分47秒_副本_prompt.py deleted file mode 100644 index 29a2283..0000000 --- a/backend/app/engine/18时33分47秒_副本_prompt.py +++ /dev/null @@ -1,93 +0,0 @@ -from llama_index.core import PromptTemplate - -text_qa_template_str = ( - "# 角色\n" - "你是一名博微造价工程数据查询助手,专精于电力工程文件中的信息。" - "你的职责是提供有关电力造价、造价编制软件、文件结构及相关数据的精准、客观的回答," - "如同直接从文件中提取的内容。\n" - "知识库中已经导入一个工程的全部数据,请你站在当前工程的角度回答用户关于工程文件的问题。\n" - "例如:询问“此工程”指当前导入的工程。询问“此工程名称”指当前导入的工程的工程名称。\n" - - "## 技能\n" - "### 技能 1: 数据查询与提供\n" - "- 准确回答所有关于电力工程造价的相关问题。\n" - "- 提供具体数据,如成本估算、材料清单、劳动力需求等。\n" - "- 确保提供的信息严格基于工程文档中的记录。\n" - - "### 技能 2: 技术性解释\n" - "- 解释造价工程中的技术术语和概念。\n" - "- 为复杂的工程细节提供清晰易懂的说明。\n" - - "## 约束\n" - "- 仅回答与电力工程造价文件相关的具体问题。\n" - "- 不进行任何超出文件内容的猜测或假设。\n" - "- 所有回答均基于文件内容,采用客观和技术性的语言。\n" - "- 请基于这些信息回答问题。如果无法找到相关信息,请不要额外发散回答,不要回答多余的信息,只需要回答“我不知道这个问题的答案”。\n" - "以下为上下文信息\n" - "---------------------\n" - "{context_str}\n" - "---------------------\n" - "请根据上下文信息而非先前知识回答我的问题或回复我的指令。前面的上下文信息可能有用,也可能没用,你需要从我给出的上下文信息中选出与我的问题最相关的那些,来为你的回答提供依据。回答一定要忠于原文,简洁但不丢信息,不要胡乱编造。如果无法找到相关信息,请不要额外发散回答,不要回答多余的信息,只需要回答“我不知道这个问题的答案”。我的问题或指令是什么语种,你就用什么语种回复。\n" - "如果是表结构或者是数据库的相关内容,只用于推导问题,不需要告诉用户数据库或表结构等物理信息。\n" - - "问题:{query_str}\n" - "你的回复: " -) - - -text_qa_template = PromptTemplate(text_qa_template_str) - -refine_template_str = ( - "这是原本的问题: {query_str}\n" - "我们已经提供了回答: {existing_answer}\n" - "现在我们有机会改进这个回答 " - "使用以下更多上下文(仅当有助于改进回答时使用)\n" - "如果新的上下文对回答没有影响,或者原来的回答已经正确,不要在上次回答的后边再加上多余的补充信息,直接返回原本的回答。\n" - "如果新的上下文对回答没有影响,或者原来的回答已经正确,不要在上次回答的后边再加上多余的补充信息,直接返回原本的回答。\n" - "------------\n" - "{context_msg}\n" - "------------\n" - "如果回答中已经包含有正确答案,不要返回多余的解释等信息,只返回正确答案\n" - "如果是表结构或者是数据库的相关内容,仅用于推导问题,不需要告诉用户数据库或表结构等物理信息。\n" - "改进的回答: " -) - -refine_template = PromptTemplate(refine_template_str) - -summary_template_str = ( - "# 角色\n" - "你是一名博微造价工程数据查询助手,专精于电力工程文件中的信息。" - "你的职责是提供有关电力造价、造价编制软件、文件结构及相关数据的精准、客观的回答," - "如同直接从文件中提取的内容。\n" - - "## 技能\n" - "### 技能 1: 数据查询与提供\n" - "- 准确回答所有关于电力工程造价的相关问题。\n" - "- 提供具体数据,如成本估算、材料清单、劳动力需求等。\n" - "- 确保提供的信息严格基于工程文档中的记录。\n" - - "### 技能 2: 技术性解释\n" - "- 解释造价工程中的技术术语和概念。\n" - "- 为复杂的工程细节提供清晰易懂的说明。\n" - - "## 约束\n" - "- 仅回答与电力工程造价文件相关的具体问题。\n" - "- 不进行任何超出文件内容的猜测或假设。\n" - "- 所有回答均基于文件内容,采用客观和技术性的语言。\n" - "- 请基于这些信息回答问题。如果无法找到相关信息,请不要额外发散回答,不要回答多余的信息,只需要回答“我不知道这个问题的答案”。\n" - "来自多个来源的上下文信息如下。\n" - "---------------------\n" - "{context_str}\n" - "---------------------\n" - "鉴于来自多个来源的信息而非先验知识, " - "回答查询。\n" - "如果是表结构或者是数据库的相关内容,只用于推导问题,不需要告诉用户数据库或表结构等物理信息。\n" - "Query: {query_str}\n" - "Answer: " -) -summary_template = PromptTemplate(summary_template_str) - -simple_template_str = ( - "{query_str}" -) -simple_template = PromptTemplate(simple_template_str) diff --git a/backend/app/engine/18时33分47秒_副本_vectordb.py b/backend/app/engine/18时33分47秒_副本_vectordb.py deleted file mode 100644 index f3f2a7d..0000000 --- a/backend/app/engine/18时33分47秒_副本_vectordb.py +++ /dev/null @@ -1,71 +0,0 @@ -import os -from llama_index.vector_stores.chroma import ChromaVectorStore -from llama_index.vector_stores.qdrant import QdrantVectorStore -from qdrant_client import qdrant_client - -qclient = None - -def get_qdrant_vector_store(): - collection_name = os.getenv("VECTOR_STORE_COLLECTION", "default") - vector_store_path = os.getenv("VECTOR_STORE_PATH") - host=os.getenv("VECTOR_STORE_HOST", "127.0.0.1"), - port=int(os.getenv("VECTOR_STORE_PORT", "6333")), - - if not vector_store_path or not host: - raise ValueError( - "Please provide either VECTOR_STORE_PATH or VECTOR_STORE_HOST and VECTOR_STORE_PORT" - ) - # if VECTOR_STORE_PATH is set, use a local QdrantVectorStore from the path - # otherwise, use a remote QdrantVectorStore - global qclient - if qclient == None: - if vector_store_path: - qclient = qdrant_client.QdrantClient( - path=vector_store_path, - ) - else: - qclient = qdrant_client.QdrantClient( - host=host, - port=port, - ) - - vector_store = QdrantVectorStore(client=qclient, collection_name=collection_name) - return vector_store - -def get_chroma_vector_store(): - collection_name = os.getenv("VECTOR_STORE_COLLECTION", "default") - vector_store_path = os.getenv("VECTOR_STORE_PATH") - # if VECTOR_STORE_PATH is set, use a local ChromaVectorStore from the path - # otherwise, use a remote ChromaVectorStore (ChromaDB Cloud is not supported yet) - if vector_store_path: - store = ChromaVectorStore.from_params( - persist_dir=vector_store_path, collection_name=collection_name, - collection_kwargs={"metadata":{"hnsw:space":"cosine"}}, - ) - else: - if not os.getenv("VECTOR_STORE_HOST") or not os.getenv("VECTOR_STORE_PORT"): - raise ValueError( - "Please provide either VECTOR_STORE_PATH or VECTOR_STORE_HOST and VECTOR_STORE_PORT" - ) - store = ChromaVectorStore.from_params( - host=os.getenv("VECTOR_STORE_HOST"), - port=int(os.getenv("VECTOR_STORE_PORT")), - collection_name=collection_name, - collection_kwargs={"metadata":{"hnsw:space":"cosine"}}, - ) - return store - -def get_vector_store(): - store_type=os.getenv("VECTOR_STORE_TYPE") - - store = None - - match store_type: - case "chroma": - store = get_chroma_vector_store() - case "qdrant": - store = get_qdrant_vector_store() - case _: - raise ValueError(f"Invalid vector store type: {store_type}") - - return store \ No newline at end of file diff --git a/backend/app/engine/loaders/18时33分48秒_副本___init__.py b/backend/app/engine/loaders/18时33分48秒_副本___init__.py deleted file mode 100644 index a220170..0000000 --- a/backend/app/engine/loaders/18时33分48秒_副本___init__.py +++ /dev/null @@ -1,40 +0,0 @@ -import logging - -import yaml -from app.engine.loaders.db import DBLoaderConfig, get_db_documents -from app.engine.loaders.file import FileLoaderConfig, get_file_documents -from app.engine.loaders.web import WebLoaderConfig, get_web_documents - -logger = logging.getLogger(__name__) - - -def load_configs(): - with open("config/loaders.yaml") as f: - configs = yaml.safe_load(f) - return configs - - -def get_documents(): - documents = [] - config = load_configs() - if config is None or len(config.items()) == 0: - return documents - - for loader_type, loader_config in config.items(): - logger.info( - f"Loading documents from loader: {loader_type}, config: {loader_config}" - ) - - loader_config = loader_config or [] - match loader_type: - case "file": - document = get_file_documents(FileLoaderConfig(**loader_config)) - case "web": - document = get_web_documents(WebLoaderConfig(**loader_config)) - case "db": - document = get_db_documents(configs=[DBLoaderConfig(**cfg) for cfg in loader_config]) - case _: - raise ValueError(f"Invalid loader type: {loader_type}") - documents.extend(document) - - return documents diff --git a/backend/app/engine/loaders/18时33分48秒_副本_db.py b/backend/app/engine/loaders/18时33分48秒_副本_db.py deleted file mode 100644 index 4be984d..0000000 --- a/backend/app/engine/loaders/18时33分48秒_副本_db.py +++ /dev/null @@ -1,140 +0,0 @@ -import logging -from typing import Any, List, Optional - -from llama_index.core import SQLDatabase, Document -from llama_index.readers.database import DatabaseReader -from pydantic import BaseModel -from sqlalchemy import create_engine, text -from sqlalchemy.engine import Engine - -logger = logging.getLogger(__name__) - -class CustomDatabaseReader(DatabaseReader): - """Simple Database reader. - - Concatenates each row into Document used by LlamaIndex. - - 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. - - Returns: - DatabaseReader: A DatabaseReader object. - """ - - 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: Any, - **kwargs: 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." - ) - - def load_data(self, query: str, explanation: str) -> List[Document]: - """Query and load data from the Database, returning a list of Documents. - - Args: - query (str): Query parameter to filter tables and rows. - explanation (str): Explanation for the query to be included in the document. - - Returns: - List[Document]: A list of Document objects. - """ - dco_str = explanation + "\n" - - 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)) - - dco_str += ", ".join( - [f"{entry}" for entry in result.keys()] - ) + "\n" - - for item in result.fetchall(): - # Fetch each item - record_str = ", ".join( - [f"{entry}" for col, entry in zip(result.keys(), item)] - ) - dco_str += record_str + "\n" - - doc = Document(text=dco_str) - doc.metadata["name"] = query - doc.metadata["context"] = query - doc.metadata["file_type"] = "application/vnd.ms-excel" - return [doc] - -class DBLoaderConfig(BaseModel): - uri: str - queries: List[dict] - -def get_db_documents(configs: list[DBLoaderConfig]): - docs = [] - - if len(configs) == 0 or configs[0].uri == "": - logger.warning( - f"Failed to load database, error message: uri is empty. Return as empty document list." - ) - return docs - - metadata = { - 'file_type': 'application/booway.document.zj', - } - - for entry in configs: - engine = create_engine(entry.uri) - sql_database = SQLDatabase(engine) - - loader = CustomDatabaseReader(sql_database) - for query_dict in entry.queries: - query = query_dict.get("sql", "") - explanation = query_dict.get("explanation", "") - logger.info(f"Loading data from database with query: {query}") - documents = loader.load_data(query=query, explanation=explanation) - - docs.extend(documents) - return docs diff --git a/backend/app/engine/loaders/18时33分48秒_副本_file.py b/backend/app/engine/loaders/18时33分48秒_副本_file.py deleted file mode 100644 index dc199db..0000000 --- a/backend/app/engine/loaders/18时33分48秒_副本_file.py +++ /dev/null @@ -1,88 +0,0 @@ -import os -import logging -from typing import Dict - -from llama_index.core.readers.base import BaseReader -from llama_index.core.readers.json import JSONReader -from llama_parse import LlamaParse -from pydantic import BaseModel, validator - -logger = logging.getLogger(__name__) - - -class FileLoaderConfig(BaseModel): - data_dir: str = "data" - use_llama_parse: bool = False - - @validator("data_dir") - def data_dir_must_exist(cls, v): - if not os.path.isdir(v): - raise ValueError(f"Directory '{v}' does not exist") - return v - - -def llama_parse_parser(): - if os.getenv("LLAMA_CLOUD_API_KEY") is None: - raise ValueError( - "LLAMA_CLOUD_API_KEY environment variable is not set. " - "Please set it in .env file or in your shell environment then run again!" - ) - parser = LlamaParse( - result_type="markdown", - verbose=True, - language="en", - ignore_errors=False, - ) - return parser - - -def llama_parse_extractor() -> Dict[str, LlamaParse]: - from llama_parse.utils import SUPPORTED_FILE_TYPES - - parser = llama_parse_parser() - return {file_type: parser for file_type in SUPPORTED_FILE_TYPES} - -def llama_local_extractor() -> Dict[str, BaseReader]: - return {".json" : JSONReader(clean_json=False,levels_back=0)} - - -def get_file_documents(config: FileLoaderConfig): - from llama_index.core.readers import SimpleDirectoryReader - - try: - file_extractor = None - if config.use_llama_parse: - # LlamaParse is async first, - # so we need to use nest_asyncio to run it in sync mode - import nest_asyncio - - nest_asyncio.apply() - - file_extractor = llama_parse_extractor() - else: - file_extractor = llama_local_extractor() - - reader = SimpleDirectoryReader( - config.data_dir, - recursive=True, - filename_as_id=True, - raise_on_error=True, - file_extractor=file_extractor, - ) - return reader.load_data() - except Exception as e: - import sys - import traceback - - # Catch the error if the data dir is empty - # and return as empty document list - _, _, exc_traceback = sys.exc_info() - function_name = traceback.extract_tb(exc_traceback)[-1].name - if function_name == "_add_files": - logger.warning( - f"Failed to load file documents, error message: {e} . Return as empty document list." - ) - return [] - else: - # Raise the error if it is not the case of empty data dir - raise e diff --git a/backend/app/engine/loaders/18时33分48秒_副本_web.py b/backend/app/engine/loaders/18时33分48秒_副本_web.py deleted file mode 100644 index e667a69..0000000 --- a/backend/app/engine/loaders/18时33分48秒_副本_web.py +++ /dev/null @@ -1,37 +0,0 @@ -import os -import json -from pydantic import BaseModel, Field - - -class CrawlUrl(BaseModel): - base_url: str - prefix: str - max_depth: int = Field(default=1, ge=0) - - -class WebLoaderConfig(BaseModel): - driver_arguments: list[str] = Field(default=None) - urls: list[CrawlUrl] = [] - - -def get_web_documents(config: WebLoaderConfig): - from llama_index.readers.web import WholeSiteReader - from selenium import webdriver - from selenium.webdriver.chrome.options import Options - - options = Options() - driver_arguments = config.driver_arguments or [] - for arg in driver_arguments: - options.add_argument(arg) - - docs = [] - urls = config.urls or [] - for url in config.urls: - scraper = WholeSiteReader( - prefix=url.prefix, - max_depth=url.max_depth, - driver=webdriver.Chrome(options=options), - ) - docs.extend(scraper.load_data(url.base_url)) - - return docs diff --git a/backend/app/engine/retriever/18时33分48秒_副本_CHBM25Retriever.py b/backend/app/engine/retriever/18时33分48秒_副本_CHBM25Retriever.py deleted file mode 100644 index fa5d5ec..0000000 --- a/backend/app/engine/retriever/18时33分48秒_副本_CHBM25Retriever.py +++ /dev/null @@ -1,133 +0,0 @@ -import json -import logging -import os - -from typing import Any, Callable, Dict, List, Optional, cast - -from llama_index.core.base.base_retriever import BaseRetriever -from llama_index.core.callbacks.base import CallbackManager -from llama_index.core.constants import DEFAULT_SIMILARITY_TOP_K -from llama_index.core.indices.vector_store.base import VectorStoreIndex -from llama_index.core.schema import BaseNode, IndexNode, NodeWithScore, QueryBundle -from llama_index.core.storage.docstore.types import BaseDocumentStore -from llama_index.core.vector_stores.utils import ( - node_to_metadata_dict, - metadata_dict_to_node, -) - -import bm25s -from app.engine.retriever.CHTokener import chTokenize - -CHDEFAULT_PERSIST_ARGS = {"similarity_top_k": "similarity_top_k", "_verbose": "verbose"} - -CHDEFAULT_PERSIST_FILENAME = "retriever.json" - -class CHBM25Retriever(BaseRetriever): - def __init__( - self, - nodes: Optional[List[BaseNode]] = None, - existing_bm25: Optional[bm25s.BM25] = None, - similarity_top_k: int = DEFAULT_SIMILARITY_TOP_K, - callback_manager: Optional[CallbackManager] = None, - objects: Optional[List[IndexNode]] = None, - object_map: Optional[dict] = None, - verbose: bool = False, - ) -> None: - self.similarity_top_k = similarity_top_k - if existing_bm25 is not None: - self.bm25 = existing_bm25 - self.corpus = existing_bm25.corpus - else: - from nltk.corpus import stopwords - if nodes is None: - raise ValueError("Please pass nodes or an existing BM25 object.") - - self.corpus = [node_to_metadata_dict(node) for node in nodes] - - corpus_tokens = chTokenize( - [node.get_content() for node in nodes], - show_progress=verbose, - ) - self.bm25 = bm25s.BM25() - self.bm25.index(corpus_tokens, show_progress=verbose) - super().__init__( - callback_manager=callback_manager, - object_map=object_map, - objects=objects, - verbose=verbose, - ) - - @classmethod - def from_defaults( - cls, - index: Optional[VectorStoreIndex] = None, - nodes: Optional[List[BaseNode]] = None, - docstore: Optional[BaseDocumentStore] = None, - similarity_top_k: int = DEFAULT_SIMILARITY_TOP_K, - verbose: bool = False, - ) -> "CHBM25Retriever": - if sum(bool(val) for val in [index, nodes, docstore]) != 1: - raise ValueError("Please pass exactly one of index, nodes, or docstore.") - - if index is not None: - docstore = index.docstore - - if docstore is not None: - nodes = cast(List[BaseNode], list(docstore.docs.values())) - - assert ( - nodes is not None - ), "Please pass exactly one of index, nodes, or docstore." - - return cls( - nodes=nodes, - similarity_top_k=similarity_top_k, - verbose=verbose, - ) - - def get_persist_args(self) -> Dict[str, Any]: - """Get Persist Args Dict to Save.""" - return { - CHDEFAULT_PERSIST_ARGS[key]: getattr(self, key) - for key in CHDEFAULT_PERSIST_ARGS - if hasattr(self, key) - } - - def persist(self, path: str, **kwargs: Any) -> None: - """Persist the retriever to a directory.""" - self.bm25.save(path, corpus=self.corpus, **kwargs) - with open(os.path.join(path, CHDEFAULT_PERSIST_FILENAME), "w") as f: - json.dump(self.get_persist_args(), f, indent=2) - - @classmethod - def from_persist_dir(cls, path: str, **kwargs: Any) -> "CHBM25Retriever": - """Load the retriever from a directory.""" - bm25 = bm25s.BM25.load(path, load_corpus=True, **kwargs) - with open(os.path.join(path, CHDEFAULT_PERSIST_FILENAME)) as f: - retriever_data = json.load(f) - return cls(existing_bm25=bm25, **retriever_data) - - def _retrieve(self, query_bundle: QueryBundle) -> List[NodeWithScore]: - query = query_bundle.query_str - tokenized_query = chTokenize( - query,show_progress=self._verbose - ) - indexes, scores = self.bm25.retrieve( - tokenized_query, k=self.similarity_top_k, show_progress=self._verbose - ) - - # batched, but only one query - indexes = indexes[0] - scores = scores[0] - - nodes: List[NodeWithScore] = [] - for idx, score in zip(indexes, scores): - # idx can be an int or a dict of the node - if isinstance(idx, dict): - node = metadata_dict_to_node(idx) - else: - node_dict = self.corpus[int(idx)] - node = metadata_dict_to_node(node_dict) - nodes.append(NodeWithScore(node=node, score=float(score))) - - return nodes \ No newline at end of file diff --git a/backend/app/engine/retriever/18时33分48秒_副本_CHTokener.py b/backend/app/engine/retriever/18时33分48秒_副本_CHTokener.py deleted file mode 100644 index 9c5a071..0000000 --- a/backend/app/engine/retriever/18时33分48秒_副本_CHTokener.py +++ /dev/null @@ -1,46 +0,0 @@ -from typing import Any, Dict, List, Union, Callable, NamedTuple -from bm25s.tokenization import * - -try: - from tqdm.auto import tqdm -except ImportError: - - def tqdm(iterable, *args, **kwargs): - return iterable - - -def chinese_tokenizer(text: str) -> List[str]: - import jieba - from nltk.corpus import stopwords - tokens = jieba.lcut(text) - return [token for token in tokens if token not in stopwords.words('chinese')] - -def chTokenize( - texts, - show_progress: bool = True, - leave: bool = False, -) -> Union[List[List[str]], Tokenized]: - if isinstance(texts, str): - texts = [texts] - - corpus_ids = [] - token_to_index = {} - - for text in tqdm( - texts, desc="Split strings", leave=leave, disable=not show_progress - ): - - splitted = chinese_tokenizer(text) - doc_ids = [] - - for token in splitted: - if token not in token_to_index: - token_to_index[token] = len(token_to_index) - - token_id = token_to_index[token] - doc_ids.append(token_id) - - corpus_ids.append(doc_ids) - - return Tokenized(ids=corpus_ids, vocab=token_to_index) - diff --git a/backend/app/engine/retriever/18时33分49秒_副本_HybridRetriever.py b/backend/app/engine/retriever/18时33分49秒_副本_HybridRetriever.py deleted file mode 100644 index 4bf0b8d..0000000 --- a/backend/app/engine/retriever/18时33分49秒_副本_HybridRetriever.py +++ /dev/null @@ -1,67 +0,0 @@ -import os -from typing import Optional, Any, Dict, List - -from llama_index.core.base.base_retriever import BaseRetriever -from llama_index.core.schema import NodeWithScore, QueryBundle - -from app.engine.retriever.CHBM25Retriever import CHBM25Retriever - - -class HybridRetriever(BaseRetriever): - def __init__( - self, - vector_index, - similarity_top_k: int = 2, - out_top_k: Optional[int] = None, - alpha: float = 0.5, - filters = None, - **kwargs: Any, - ) -> None: - super().__init__(**kwargs) - self._vector_index = vector_index - self._embed_model = vector_index._embed_model - self._out_top_k = out_top_k or similarity_top_k - self._vecRetriever = vector_index.as_retriever( - similarity_top_k=similarity_top_k,filters = filters - ) - - STORAGE_DIR = os.getenv("BM_RETRIEVER_PATH", "storage_bm") - if os.path.exists(STORAGE_DIR) and len(os.listdir(STORAGE_DIR)) > 0: - self._bm25Retriever = CHBM25Retriever.from_persist_dir(STORAGE_DIR) - else: - bmRetriver = CHBM25Retriever.from_defaults(similarity_top_k=similarity_top_k,nodes=self._vector_index.vector_store.get_nodes(None)) - bmRetriver.persist(STORAGE_DIR) - self._alpha = alpha - - - - def _retrieve(self, query_bundle: QueryBundle) -> List[NodeWithScore]: - vecNodes:List[NodeWithScore] = self._vecRetriever.retrieve(query_bundle.query_str) - bmNodes:List[NodeWithScore] = self._bm25Retriever.retrieve(query_bundle.query_str) - - bmDic:Dict[str,NodeWithScore] = {} - for node in bmNodes: - bmDic[node.node_id] = node - - result_tups = [] - for i in range(len(vecNodes)): - node = vecNodes[i] - bmScore = 0.0 - if node.node_id in bmDic: - bmScore = bmDic[node.node_id].score - bmDic.pop(node.node_id) - else: - bmScore = 0.0 - full_similarity = (self._alpha * node.score) + ( - (1 - self._alpha) * bmScore - ) - result_tups.append((full_similarity, node)) - - for _,node in bmDic.items(): - full_similarity = (1 - self._alpha) * node.score - result_tups.append((full_similarity, node)) - - result_tups = sorted(result_tups, key=lambda x: x[0], reverse=True) - for full_score, node in result_tups: - node.score = full_score - return [n for _, n in result_tups][:self._out_top_k] \ No newline at end of file diff --git a/backend/app/engine/tools/18时33分47秒_副本_duckduckgo.py b/backend/app/engine/tools/18时33分47秒_副本_duckduckgo.py deleted file mode 100644 index b63612a..0000000 --- a/backend/app/engine/tools/18时33分47秒_副本_duckduckgo.py +++ /dev/null @@ -1,36 +0,0 @@ -from llama_index.core.tools.function_tool import FunctionTool - - -def duckduckgo_search( - query: str, - region: str = "wt-wt", - max_results: int = 10, -): - """ - Use this function to search for any query in DuckDuckGo. - Args: - query (str): The query to search in DuckDuckGo. - region Optional(str): The region to be used for the search in [country-language] convention, ex us-en, uk-en, ru-ru, etc... - max_results Optional(int): The maximum number of results to be returned. Default is 10. - """ - try: - from duckduckgo_search import DDGS - except ImportError: - raise ImportError( - "duckduckgo_search package is required to use this function." - "Please install it by running: `poetry add duckduckgo_search` or `pip install duckduckgo_search`" - ) - - params = { - "keywords": query, - "region": region, - "max_results": max_results, - } - results = [] - with DDGS() as ddg: - results = list(ddg.text(**params)) - return results - - -def get_tools(**kwargs): - return [FunctionTool.from_defaults(duckduckgo_search)] diff --git a/backend/app/engine/tools/18时33分48秒_副本___init__.py b/backend/app/engine/tools/18时33分48秒_副本___init__.py deleted file mode 100644 index 1aced70..0000000 --- a/backend/app/engine/tools/18时33分48秒_副本___init__.py +++ /dev/null @@ -1,60 +0,0 @@ -import os -import yaml -import json -import importlib -from cachetools import cached, LRUCache -from llama_index.core.tools.tool_spec.base import BaseToolSpec -from llama_index.core.tools.function_tool import FunctionTool - - -class ToolType: - LLAMAHUB = "llamahub" - LOCAL = "local" - - -class ToolFactory: - - TOOL_SOURCE_PACKAGE_MAP = { - ToolType.LLAMAHUB: "llama_index.tools", - ToolType.LOCAL: "app.engine.tools", - } - - def load_tools(tool_type: str, tool_name: str, config: dict) -> list[FunctionTool]: - source_package = ToolFactory.TOOL_SOURCE_PACKAGE_MAP[tool_type] - try: - if "ToolSpec" in tool_name: - tool_package, tool_cls_name = tool_name.split(".") - module_name = f"{source_package}.{tool_package}" - module = importlib.import_module(module_name) - tool_class = getattr(module, tool_cls_name) - tool_spec: BaseToolSpec = tool_class(**config) - return tool_spec.to_tool_list() - else: - module = importlib.import_module(f"{source_package}.{tool_name}") - tools = module.get_tools(**config) - if not all(isinstance(tool, FunctionTool) for tool in tools): - raise ValueError( - f"The module {module} does not contain valid tools" - ) - return tools - except ImportError as e: - raise ValueError(f"Failed to import tool {tool_name}: {e}") - except AttributeError as e: - raise ValueError(f"Failed to load tool {tool_name}: {e}") - - @staticmethod - def from_env() -> list[FunctionTool]: - tools = [] - if os.path.exists("config/tools.yaml"): - with open("config/tools.yaml", "r") as f: - tool_configs = yaml.safe_load(f) - if tool_configs != None and len(tool_configs.items()) != 0: - for tool_type, config_entries in tool_configs.items(): - if config_entries == None or len(config_entries.items()) == 0: - continue - - for tool_name, config in config_entries.items(): - tools.extend( - ToolFactory.load_tools(tool_type, tool_name, config) - ) - return tools diff --git a/backend/app/engine/tools/18时33分48秒_副本_img_gen.py b/backend/app/engine/tools/18时33分48秒_副本_img_gen.py deleted file mode 100644 index 966e95d..0000000 --- a/backend/app/engine/tools/18时33分48秒_副本_img_gen.py +++ /dev/null @@ -1,108 +0,0 @@ -import os -import uuid -import logging -import requests -from typing import Optional -from pydantic import BaseModel, Field -from llama_index.core.tools import FunctionTool - -logger = logging.getLogger(__name__) - - -class ImageGeneratorToolOutput(BaseModel): - is_success: bool = Field( - ..., - description="Whether the image generation was successful.", - ) - image_url: Optional[str] = Field( - None, - description="The URL of the generated image.", - ) - error_message: Optional[str] = Field( - None, - description="The error message if the image generation failed.", - ) - - -class ImageGeneratorTool: - _IMG_OUTPUT_FORMAT = "webp" - _IMG_OUTPUT_DIR = "output/tool" - _IMG_GEN_API = "https://api.stability.ai/v2beta/stable-image/generate/core" - - def __init__(self, api_key: str = None): - if not api_key: - api_key = os.getenv("STABILITY_API_KEY") - self._api_key = api_key - self.fileserver_url_prefix = os.getenv("FILESERVER_URL_PREFIX") - if self._api_key is None: - raise ValueError( - "STABILITY_API_KEY key is required to run image generator. Get it here: https://platform.stability.ai/account/keys" - ) - if self.fileserver_url_prefix is None: - raise ValueError("FILESERVER_URL_PREFIX is required.") - - def _prepare_output_dir(self): - """ - Create the output directory if it doesn't exist - """ - if not os.path.exists(self._IMG_OUTPUT_DIR): - os.makedirs(self._IMG_OUTPUT_DIR, exist_ok=True) - - def _save_image(self, image_data: bytes): - self._prepare_output_dir() - filename = f"{uuid.uuid4()}.{self._IMG_OUTPUT_FORMAT}" - output_path = os.path.join(self._IMG_OUTPUT_DIR, filename) - with open(output_path, "wb") as f: - f.write(image_data) - url = f"{os.getenv('FILESERVER_URL_PREFIX')}/{self._IMG_OUTPUT_DIR}/{filename}" - logger.info(f"Saved image to {output_path}.\nURL: {url}") - return url - - def _call_stability_api(self, prompt: str): - headers = { - "authorization": f"Bearer {self._api_key}", - "accept": "image/*", - } - data = { - "prompt": prompt, - "output_format": self._IMG_OUTPUT_FORMAT, - } - - response = requests.post( - self._IMG_GEN_API, - headers=headers, - files={"none": ""}, - data=data, - ) - response.raise_for_status() - - return response - - def generate_image(self, prompt: str) -> ImageGeneratorToolOutput: - """ - Use this tool to generate an image based on the prompt. - Args: - prompt (str): The prompt to generate the image from. - """ - - try: - # Call the Stability API - response = self._call_stability_api(prompt) - - # Save the image and get the URL - image_url = self._save_image(response.content) - - return ImageGeneratorToolOutput( - is_success=True, - image_url=image_url, - ) - except Exception as e: - logger.exception(e, exc_info=True) - return ImageGeneratorToolOutput( - is_success=False, - error_message=str(e), - ) - - -def get_tools(**kwargs): - return [FunctionTool.from_defaults(ImageGeneratorTool(**kwargs).generate_image)] diff --git a/backend/app/engine/tools/18时33分48秒_副本_interpreter.py b/backend/app/engine/tools/18时33分48秒_副本_interpreter.py deleted file mode 100644 index 1d2c02c..0000000 --- a/backend/app/engine/tools/18时33分48秒_副本_interpreter.py +++ /dev/null @@ -1,143 +0,0 @@ -import os -import logging -import base64 -import uuid -from pydantic import BaseModel -from typing import List, Tuple, Dict, Optional -from llama_index.core.tools import FunctionTool -from e2b_code_interpreter import CodeInterpreter -from e2b_code_interpreter.models import Logs - - -logger = logging.getLogger(__name__) - - -class InterpreterExtraResult(BaseModel): - type: str - content: Optional[str] = None - filename: Optional[str] = None - url: Optional[str] = None - - -class E2BToolOutput(BaseModel): - is_error: bool - logs: Logs - results: List[InterpreterExtraResult] = [] - - -class E2BCodeInterpreter: - - output_dir = "output/tool" - - def __init__(self, api_key: str = None): - if api_key is None: - api_key = os.getenv("E2B_API_KEY") - filesever_url_prefix = os.getenv("FILESERVER_URL_PREFIX") - if not api_key: - raise ValueError( - "E2B_API_KEY key is required to run code interpreter. Get it here: https://e2b.dev/docs/getting-started/api-key" - ) - if not filesever_url_prefix: - raise ValueError( - "FILESERVER_URL_PREFIX is required to display file output from sandbox" - ) - - self.filesever_url_prefix = filesever_url_prefix - self.interpreter = CodeInterpreter(api_key=api_key) - - def __del__(self): - self.interpreter.close() - - def get_output_path(self, filename: str) -> str: - # if output directory doesn't exist, create it - if not os.path.exists(self.output_dir): - os.makedirs(self.output_dir, exist_ok=True) - return os.path.join(self.output_dir, filename) - - def save_to_disk(self, base64_data: str, ext: str) -> Dict: - filename = f"{uuid.uuid4()}.{ext}" # generate a unique filename - buffer = base64.b64decode(base64_data) - output_path = self.get_output_path(filename) - - try: - with open(output_path, "wb") as file: - file.write(buffer) - except IOError as e: - logger.error(f"Failed to write to file {output_path}: {str(e)}") - raise e - - logger.info(f"Saved file to {output_path}") - - return { - "outputPath": output_path, - "filename": filename, - } - - def get_file_url(self, filename: str) -> str: - return f"{self.filesever_url_prefix}/{self.output_dir}/{filename}" - - def parse_result(self, result) -> List[InterpreterExtraResult]: - """ - The result could include multiple formats (e.g. png, svg, etc.) but encoded in base64 - We save each result to disk and return saved file metadata (extension, filename, url) - """ - if not result: - return [] - - output = [] - - try: - formats = result.formats() - results = [result[format] for format in formats] - - for ext, data in zip(formats, results): - match ext: - case "png" | "svg" | "jpeg" | "pdf": - result = self.save_to_disk(data, ext) - filename = result["filename"] - output.append( - InterpreterExtraResult( - type=ext, - filename=filename, - url=self.get_file_url(filename), - ) - ) - case _: - output.append( - InterpreterExtraResult( - type=ext, - content=data, - ) - ) - except Exception as error: - logger.exception(error, exc_info=True) - logger.error("Error when parsing output from E2b interpreter tool", error) - - return output - - def interpret(self, code: str) -> E2BToolOutput: - """ - Execute python code in a Jupyter notebook cell, the toll will return result, stdout, stderr, display_data, and error. - - Parameters: - code (str): The python code to be executed in a single cell. - """ - logger.info( - f"\n{'='*50}\n> Running following AI-generated code:\n{code}\n{'='*50}" - ) - exec = self.interpreter.notebook.exec_cell(code) - - if exec.error: - logger.error("Error when executing code", exec.error) - output = E2BToolOutput(is_error=True, logs=exec.logs, results=[]) - else: - if len(exec.results) == 0: - output = E2BToolOutput(is_error=False, logs=exec.logs, results=[]) - else: - results = self.parse_result(exec.results[0]) - output = E2BToolOutput(is_error=False, logs=exec.logs, results=results) - return output - - -def get_tools(**kwargs): - return [FunctionTool.from_defaults(E2BCodeInterpreter(**kwargs).interpret)] diff --git a/backend/app/engine/tools/18时33分48秒_副本_openapi_action.py b/backend/app/engine/tools/18时33分48秒_副本_openapi_action.py deleted file mode 100644 index c19187d..0000000 --- a/backend/app/engine/tools/18时33分48秒_副本_openapi_action.py +++ /dev/null @@ -1,78 +0,0 @@ -from typing import Dict, List, Tuple -from llama_index.tools.openapi import OpenAPIToolSpec -from llama_index.tools.requests import RequestsToolSpec - - -class OpenAPIActionToolSpec(OpenAPIToolSpec, RequestsToolSpec): - """ - A combination of OpenAPI and Requests tool specs that can parse OpenAPI specs and make requests. - - openapi_uri: str: The file path or URL to the OpenAPI spec. - domain_headers: dict: Whitelist domains and the headers to use. - """ - - spec_functions = OpenAPIToolSpec.spec_functions + RequestsToolSpec.spec_functions - # Cached parsed specs by URI - _specs: Dict[str, Tuple[Dict, List[str]]] = {} - - def __init__(self, openapi_uri: str, domain_headers: dict = None, **kwargs): - if domain_headers is None: - domain_headers = {} - if openapi_uri not in self._specs: - openapi_spec, servers = self._load_openapi_spec(openapi_uri) - self._specs[openapi_uri] = (openapi_spec, servers) - else: - openapi_spec, servers = self._specs[openapi_uri] - - # Add the servers to the domain headers if they are not already present - for server in servers: - if server not in domain_headers: - domain_headers[server] = {} - - OpenAPIToolSpec.__init__(self, spec=openapi_spec) - RequestsToolSpec.__init__(self, domain_headers) - - @staticmethod - def _load_openapi_spec(uri: str) -> Tuple[Dict, List[str]]: - """ - Load an OpenAPI spec from a URI. - - Args: - uri (str): A file path or URL to the OpenAPI spec. - - Returns: - List[Document]: A list of Document objects. - """ - import yaml - from urllib.parse import urlparse - - if uri.startswith("http"): - import requests - - response = requests.get(uri) - if response.status_code != 200: - raise ValueError( - "Could not initialize OpenAPIActionToolSpec: " - f"Failed to load OpenAPI spec from {uri}, status code: {response.status_code}" - ) - spec = yaml.safe_load(response.text) - elif uri.startswith("file"): - filepath = urlparse(uri).path - with open(filepath, "r") as file: - spec = yaml.safe_load(file) - else: - raise ValueError( - "Could not initialize OpenAPIActionToolSpec: Invalid OpenAPI URI provided. " - "Only HTTP and file path are supported." - ) - # Add the servers to the whitelist - try: - servers = [ - urlparse(server["url"]).netloc for server in spec.get("servers", []) - ] - except KeyError as e: - raise ValueError( - "Could not initialize OpenAPIActionToolSpec: Invalid OpenAPI spec provided. " - "Could not get `servers` from the spec." - ) from e - return spec, servers diff --git a/backend/app/engine/tools/18时33分48秒_副本_weather.py b/backend/app/engine/tools/18时33分48秒_副本_weather.py deleted file mode 100644 index c8b6f1b..0000000 --- a/backend/app/engine/tools/18时33分48秒_副本_weather.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Open Meteo weather map tool spec.""" - -import logging -import requests -import pytz -from llama_index.core.tools import FunctionTool - -logger = logging.getLogger(__name__) - - -class OpenMeteoWeather: - geo_api = "https://geocoding-api.open-meteo.com/v1" - weather_api = "https://api.open-meteo.com/v1" - - @classmethod - def _get_geo_location(cls, location: str) -> dict: - """Get geo location from location name.""" - params = {"name": location, "count": 10, "language": "en", "format": "json"} - response = requests.get(f"{cls.geo_api}/search", params=params) - if response.status_code != 200: - raise Exception(f"Failed to fetch geo location: {response.status_code}") - else: - data = response.json() - result = data["results"][0] - geo_location = { - "id": result["id"], - "name": result["name"], - "latitude": result["latitude"], - "longitude": result["longitude"], - } - return geo_location - - @classmethod - def get_weather_information(cls, location: str) -> dict: - """Use this function to get the weather of any given location. - Note that the weather code should follow WMO Weather interpretation codes (WW): - 0: Clear sky - 1, 2, 3: Mainly clear, partly cloudy, and overcast - 45, 48: Fog and depositing rime fog - 51, 53, 55: Drizzle: Light, moderate, and dense intensity - 56, 57: Freezing Drizzle: Light and dense intensity - 61, 63, 65: Rain: Slight, moderate and heavy intensity - 66, 67: Freezing Rain: Light and heavy intensity - 71, 73, 75: Snow fall: Slight, moderate, and heavy intensity - 77: Snow grains - 80, 81, 82: Rain showers: Slight, moderate, and violent - 85, 86: Snow showers slight and heavy - 95: Thunderstorm: Slight or moderate - 96, 99: Thunderstorm with slight and heavy hail - """ - logger.info( - f"Calling open-meteo api to get weather information of location: {location}" - ) - geo_location = cls._get_geo_location(location) - timezone = pytz.timezone("UTC").zone - params = { - "latitude": geo_location["latitude"], - "longitude": geo_location["longitude"], - "current": "temperature_2m,weather_code", - "hourly": "temperature_2m,weather_code", - "daily": "weather_code", - "timezone": timezone, - } - response = requests.get(f"{cls.weather_api}/forecast", params=params) - if response.status_code != 200: - raise Exception( - f"Failed to fetch weather information: {response.status_code}" - ) - return response.json() - - -def get_tools(**kwargs): - return [FunctionTool.from_defaults(OpenMeteoWeather.get_weather_information)] diff --git a/backend/app/xinference/18时33分46秒_副本___init__.py b/backend/app/xinference/18时33分46秒_副本___init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/app/xinference/18时33分46秒_副本_base.py b/backend/app/xinference/18时33分46秒_副本_base.py deleted file mode 100644 index f256ec8..0000000 --- a/backend/app/xinference/18时33分46秒_副本_base.py +++ /dev/null @@ -1,272 +0,0 @@ -"""Xinference embeddings file.""" - -import logging -from enum import Enum -from http import HTTPStatus -from typing import Any, Dict, List, Optional, Union, Tuple - -from llama_index.core.base.embeddings.base import BaseEmbedding, Embedding, dispatcher -from llama_index.core.bridge.pydantic import PrivateAttr -from llama_index.core.callbacks import CBEventType, EventPayload -from llama_index.core.embeddings.multi_modal_base import MultiModalEmbedding -from llama_index.core.instrumentation.events.rerank import ReRankStartEvent, ReRankEndEvent -from llama_index.core.postprocessor.types import BaseNodePostprocessor -from llama_index.core.schema import ImageType, NodeWithScore, QueryBundle -from pydantic import Field - -logger = logging.getLogger(__name__) - - -EMBED_MAX_INPUT_LENGTH = 2048 -EMBED_MAX_BATCH_SIZE = 1 - - -class XinferenceEmbedding(BaseEmbedding): - """Xinference class for text embedding. - - """ - model_description: Dict[str, Any] = Field( - description="The model description from Xinference." - ) - _generator: Any = PrivateAttr() - _model_uid: str = Field(description="The Xinference model to use.") - _endpoint: str = Field(description="The Xinference endpoint URL to use.") - - def __init__( - self, - model_uid: str, - endpoint: str, - embed_batch_size: int = EMBED_MAX_BATCH_SIZE, - dimensions: Optional[int] = None, - additional_kwargs: Optional[Dict[str, Any]] = None, - api_key: Optional[str] = None, - api_base: Optional[str] = None, - api_version: Optional[str] = None, - max_retries: int = 10, - # timeout: float = 60.0, - # reuse_client: bool = True, - # callback_manager: Optional[CallbackManager] = None, - # default_headers: Optional[Dict[str, str]] = None, - # http_client: Optional[httpx.Client] = None, - # async_http_client: Optional[httpx.AsyncClient] = None, - # num_workers: Optional[int] = None, - **kwargs: Any, - ) -> None: - generator, model_description, embed_batch_size, dimensions = self.load_model( - model_uid, endpoint - ) - self._generator = generator - #self._model_uid = model_uid - #self._endpoint = endpoint - super().__init__( - embed_batch_size=embed_batch_size, - dimensions=dimensions, - #callback_manager=callback_manager, - model_name=model_uid, - additional_kwargs=additional_kwargs, - api_key=api_key, - api_base=api_base, - api_version=api_version, - max_retries=max_retries, - # reuse_client=reuse_client, - # timeout=timeout, - # default_headers=default_headers, - # num_workers=num_workers, - **kwargs, - ) - - def load_model(self, model_uid: str, endpoint: str) -> Tuple[Any, int, dict]: - try: - from xinference.client import RESTfulClient - except ImportError: - raise ImportError( - "Could not import Xinference library." - 'Please install Xinference with `pip install "xinference[all]"`' - ) - - client = RESTfulClient(endpoint) - - try: - assert isinstance(client, RESTfulClient) - except AssertionError: - raise RuntimeError( - "Could not create RESTfulClient instance." - "Please make sure Xinference endpoint is running at the correct port." - ) - - generator = client.get_model(model_uid) - model_description = client.list_models()[model_uid] - - try: - assert generator is not None - assert model_description is not None - except AssertionError: - raise RuntimeError( - "Could not get model from endpoint." - "Please make sure Xinference endpoint is running at the correct port." - ) - - model = model_description["model_name"] - replica = model_description['replica'] - dimensions = model_description['dimensions'] - max_tokens = model_description['max_tokens'] - - return generator, model_description, replica, dimensions - - @classmethod - def class_name(cls) -> str: - return "XinferenceEmbedding" - - def _get_text_embedding(self, text: str) -> Embedding: - """ - Embed the input text synchronously. - - Subclasses should implement this method. Reference get_text_embedding's - docstring for more information. - """ - assert self._generator is not None - - response = self._generator.create_embedding(input=text) - return response['data'][0]['embedding'] - - def _get_query_embedding(self, query: str) -> Embedding: - """ - Embed the input query synchronously. - - Subclasses should implement this method. Reference get_query_embedding's - docstring for more information. - """ - return self._get_text_embedding(query) - - async def _aget_query_embedding(self, query: str) -> Embedding: - """ - Embed the input query asynchronously. - - Subclasses should implement this method. Reference get_query_embedding's - docstring for more information. - """ - return self._get_query_embedding(query) - -class XinferenceRerank(BaseNodePostprocessor): - """Xinference class for rerank. - - """ - model_description: Dict[str, Any] = Field( - description="The model description from Xinference." - ) - _generator: Any = PrivateAttr() - _model_uid: str = Field(description="The Xinference model to use.") - _endpoint: str = Field(description="The Xinference endpoint URL to use.") - model: str = Field(description="Dashscope rerank model name.") - top_n: int = Field(description="Top N nodes to return.") - threshold: float = Field(description="threshold nodes to return.") - - def __init__( - self, - model_uid: str, - endpoint: str, - top_n: int = None, - threshold: float = None, - return_documents: bool = False - ): - _model_uid = model_uid - _endpoint = endpoint - _op_n = top_n - threshold = threshold - generator, model_description = self.load_model( - model_uid, endpoint - ) - self._generator = generator - super().__init__(top_n=top_n, model=model_uid, model_uid=model_uid, threshold = threshold, return_documents=return_documents) - - @classmethod - def class_name(cls) -> str: - return "XinferenceRerank" - - def _postprocess_nodes( - self, - nodes: List[NodeWithScore], - query_bundle: Optional[QueryBundle] = None, - ) -> List[NodeWithScore]: - if query_bundle is None: - raise ValueError("Missing query bundle in extra info.") - if len(nodes) == 0: - return [] - - dispatcher.event( - ReRankStartEvent( - nodes = nodes, - top_n = self.top_n, - query = query_bundle, - model_name = self.model - ) - ) - - with self.callback_manager.event( - CBEventType.RERANKING, - payload={ - EventPayload.NODES: nodes, - EventPayload.MODEL_NAME: self._model_uid, - EventPayload.QUERY_STR: query_bundle.query_str, - EventPayload.TOP_K: self.top_n, - }, - ) as event: - texts = [node.node.get_content() for node in nodes] - response = self._generator.rerank(texts,query_bundle.query_str) - new_nodes = [] - for result in response['results']: - new_node_with_score = NodeWithScore( - node=nodes[result['index']].node, score=result['relevance_score'] - ) - if self.threshold is not None: - if new_node_with_score.score >=self.threshold: - new_nodes.append(new_node_with_score) - - if self.top_n is not None: - if len(new_nodes) > self.top_n: - for index in new_nodes[self.top_n:-1]: - new_nodes.remove(index) - - event.on_end(payload={EventPayload.NODES: new_nodes}) - - dispatcher.event( - ReRankEndEvent( - nodes= new_nodes - ) - ) - return new_nodes - - def load_model(self, model_uid: str, endpoint: str) -> Tuple[Any, int, dict]: - try: - from xinference.client import RESTfulClient - except ImportError: - raise ImportError( - "Could not import Xinference library." - 'Please install Xinference with `pip install "xinference[all]"`' - ) - - client = RESTfulClient(endpoint) - - try: - assert isinstance(client, RESTfulClient) - except AssertionError: - raise RuntimeError( - "Could not create RESTfulClient instance." - "Please make sure Xinference endpoint is running at the correct port." - ) - - generator = client.get_model(model_uid) - model_description = client.list_models()[model_uid] - - try: - assert generator is not None - assert model_description is not None - except AssertionError: - raise RuntimeError( - "Could not get model from endpoint." - "Please make sure Xinference endpoint is running at the correct port." - ) - - model = model_description["model_name"] - - return generator, model_description \ No newline at end of file -- 2.52.0 From afccaf6eb56fcf0838273b432348b93ce12c30b1 Mon Sep 17 00:00:00 2001 From: wanyaokun <12345678> Date: Mon, 26 Aug 2024 19:57:22 +0800 Subject: [PATCH 06/18] =?UTF-8?q?=E6=96=B0=E5=A2=9EWeb=E5=89=8D=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=E9=80=9A=E4=BF=A1=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/.env.example | 1 + backend/.env.xinference | 1 + backend/app/api/routers/app.py | 69 +++++++ backend/app/api/routers/request/base.py | 127 ++++++++++++ backend/app/api/routers/request/baseConfig.py | 52 +++++ backend/app/api/routers/request/dbOrm.py | 194 ++++++++++++++++++ backend/app/api/routers/request/models.py | 10 + backend/main.py | 3 + 8 files changed, 457 insertions(+) create mode 100644 backend/app/api/routers/app.py create mode 100644 backend/app/api/routers/request/base.py create mode 100644 backend/app/api/routers/request/baseConfig.py create mode 100644 backend/app/api/routers/request/dbOrm.py create mode 100644 backend/app/api/routers/request/models.py diff --git a/backend/.env.example b/backend/.env.example index 37ba235..83e69c1 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -2,6 +2,7 @@ # LLAMA_CLOUD_API_KEY= SQL_DATABASE_URL=mysql+pymysql://zjinfo1:Dy2Bcr53Hm5xRkba@110.42.234.166:3306/zjinfo1 #SQL_DATABASE_URL=mysql+pymysql://zjinfo2:GSKcziSdBixDXwcd@110.42.234.166:3306/zjinfo2 +SQLITE_DATABASE_URL=sqlite:///./source.db DASHSCOPE_API_KEY=sk-02c8540e86d84b7ca0e6f4f51bac6e60 # The provider for the AI models to use. diff --git a/backend/.env.xinference b/backend/.env.xinference index 6dd566f..1dc074c 100644 --- a/backend/.env.xinference +++ b/backend/.env.xinference @@ -2,6 +2,7 @@ # LLAMA_CLOUD_API_KEY= SQL_DATABASE_URL=mysql+pymysql://zjinfo1:Dy2Bcr53Hm5xRkba@110.42.234.166:3306/zjinfo1 #SQL_DATABASE_URL=mysql+pymysql://zjinfo2:GSKcziSdBixDXwcd@110.42.234.166:3306/zjinfo2 +SQLITE_DATABASE_URL=sqlite:///./source.db # The number of similar embeddings to return when retrieving documents. TOP_K=10 diff --git a/backend/app/api/routers/app.py b/backend/app/api/routers/app.py new file mode 100644 index 0000000..e806726 --- /dev/null +++ b/backend/app/api/routers/app.py @@ -0,0 +1,69 @@ +import os +from typing import Dict, List, Any, Optional, cast +from fastapi import APIRouter,Request +from app.api.routers.request.base import userMng,conversations +from app.api.routers.request.models import ChatRequestData + +api_router = r = APIRouter() +v1_router = v = APIRouter() + + +@v.post("/chat-messages") +async def post_conversations(request: Request,data: ChatRequestData): + userMng.findNoExistCreate(data.user) + + conversaObj = conversations() + conversationinfo = conversaObj.get(data.user) + if conversationinfo is None: + conversationinfo = conversaObj.add(data.user, "新建会话") + + return None + +@v.get("/messages") +async def query_messages(user:str, conversation_id:str): + pass + +@v.post("/conversations/{itemid}/name") +async def post_conversations(user:str): + pass + +@v.get("/conversations") +async def query_conversations(user:str): + user_id = '' if user is None else user + userMng.findNoExistCreate(user_id) + + return { + "limit": 20, + "has_more": False, + "data": conversations().gets(user_id) + } + + +@r.get("/conversations") +async def query_conversations(first_id:int = None, limit:int = None, pinned:bool = None): + pass + +#meta查询 +@r.get("/meta") +async def query_meta(): + pass + +#name查询 +@r.get("/name查询") +def query_name(): + with sessionlocal() as session: + name = session.query(NameOrm).first() + + return Name.from_orm(name) + +#parameters查询 +@r.get("/parameters") +async def query_parameters(): + pass + +#msite查询 +@r.get("/site") +async def query_site(): + pass + + diff --git a/backend/app/api/routers/request/base.py b/backend/app/api/routers/request/base.py new file mode 100644 index 0000000..b50333a --- /dev/null +++ b/backend/app/api/routers/request/base.py @@ -0,0 +1,127 @@ +import os +from typing import Dict, List, Any, Optional, cast +import json +from app.api.routers.request.dbOrm import DBManager +from app.api.routers.request.baseConfig import BaseConfig +from datetime import datetime + +dbManage = DBManager() + +class conversations: + def __init__(self) -> None: + self._tableName = 'conversations' + dbManage.createTable(self._tableName) + + def gets(self,user_id:str): + records = dbManage.query(self._tableName,user_id = user_id) + datas = [] + for record in records: + datas.append(record) + + return datas + + def get(self,user_id:str,id:str = ''): + records = dbManage.query(self._tableName,user_id = user_id,id = id) + if len(records) >0: + return records[0] + return None + + def add(self,user_id:str,name:str,id:str = ''): + template = BaseConfig.ConversationCfg + template['id'] = id + template['user_id'] = user_id + template['name'] = name + template['created_at'] = 1724399038 + + dbManage.addRecord(self._tableName,template) + + def delete(self,id:str): + dbManage.delete(self._tableName,id=id) + + def rename(self,id:str): + data = {'name':''} + dbManage.update(self._tableName,data,id=id) + +class user: + def __init__(self) -> None: + self._tableName = 'user' + dbManage.createTable(self._tableName) + + def gets(self): + return dbManage.query(self._tableName) + + def get(self,id:str): + return dbManage.query(self._tableName,id = id) + + def add(self,id:str): + info = { + 'id':id, + 'createtime': datetime.now().strftime("%Y-%m-%d %H:%M:%S") + } + dbManage.addRecord(self._tableName,info) + + def delete(self,id:str): + dbManage.delete(self._tableName,id = id) + +class userMng: + userObj = user() + @classmethod + def findNoExistCreate(cls,user_id:str): + userInfo = cls.userObj.get(user_id) + if userInfo is None: + cls.userObj.add(user_id) + + def remove(cls,user_id:str): + cls.userObj.delete(user_id) + +class parameter: + def __init__(self) -> None: + self._tableName = 'parameters' + dbManage.createTable(self._tableName) + + def get(self,user_id:str): + records = dbManage.query(self._tableName,user_id = user_id) + data = {} + for record in records: + key = record['name'] + value = record['value'] + data[key] = value + + return { + 'opening_statement':data['opening_statement'], + 'suggested_questions':data['suggested_questions'], + 'suggested_questions_after_answer':data['suggested_questions_after_answer'], + 'speech_to_text':data['speech_to_text'], + 'text_to_speech':data['text_to_speech'], + 'retriever_resource':data['retriever_resource'], + 'annotation_reply':data['annotation_reply'], + 'more_like_this':data['more_like_this'], + 'user_input_form':data['user_input_form'], + 'sensitive_word_avoidance':data['sensitive_word_avoidance'], + 'file_upload':data['file_upload'], + 'system_parameters':data['system_parameters'], + 'opening_statement':data['opening_statement'], + } + + def set(self,user_id:str): + dbManage.addRecord(self._tableName,{}) + + def delete(self,user_id:str): + dbManage.delete(self._tableName,user_id = user_id) + +class message: + def __init__(self) -> None: + self._tableName = 'messages' + dbManage.createTable(self._tableName) + + def gets(self,user_id:str): + return dbManage.query(self._tableName,user_id = user_id) + + def add(self,user_id:str): + dbManage.addRecord(self._tableName,{}) + + def delete(self,user_id:str): + dbManage.delete(self._tableName,user_id = user_id) + + + diff --git a/backend/app/api/routers/request/baseConfig.py b/backend/app/api/routers/request/baseConfig.py new file mode 100644 index 0000000..b81f03e --- /dev/null +++ b/backend/app/api/routers/request/baseConfig.py @@ -0,0 +1,52 @@ + +class BaseConfig: + ParamterCfg = { + "opening_statement": "您好,我是配网D3造价软件小助手,您可以问我有关配网造价软件的相关问题!", + "suggested_questions": [], + "suggested_questions_after_answer": { + "enabled": False + }, + "speech_to_text": { + "enabled": False + }, + "text_to_speech": { + "enabled": False, + "language": "", + "voice": "" + }, + "retriever_resource": { + "enabled": True + }, + "annotation_reply": { + "enabled": False + }, + "more_like_this": { + "enabled": False + }, + "user_input_form": [], + "sensitive_word_avoidance": { + "enabled": False + }, + "file_upload": { + "image": { + "enabled": False, + "number_limits": 3, + "transfer_methods": [ + "remote_url" + ] + } + }, + "system_parameters": { + "image_file_size_limit": "10" + } + } + + ConversationCfg = { + "id": "", + 'user_id':'', + "name": "", + "inputs": {}, + "status": "normal", + "introduction": ParamterCfg['opening_statement'], + "created_at":'' + } \ No newline at end of file diff --git a/backend/app/api/routers/request/dbOrm.py b/backend/app/api/routers/request/dbOrm.py new file mode 100644 index 0000000..539a669 --- /dev/null +++ b/backend/app/api/routers/request/dbOrm.py @@ -0,0 +1,194 @@ +import os +from typing import Dict, List, Any, Optional, cast + +from fastapi import APIRouter +from pydantic import BaseModel, Field +from sqlalchemy import create_engine, Column, String, Integer, Boolean, JSON,ForeignKey +from sqlalchemy.orm import sessionmaker, declarative_base,relationship +from sqlalchemy.engine.reflection import Inspector + +Base = declarative_base() + +#orm类 +class ConversationOrm(Base): + __tablename__ = "conversations" + + id = Column(String, primary_key=True) + user_id = Column(String) + name = Column(String) + inputs = Column(JSON) + status = Column(String) + introduction = Column(String) + created_at = Column(Integer) + +class UserOrm(Base): + __tablename__ = "user" + + id = Column(String, primary_key=True) + createtime = Column(String) + +class ParametersOrm(Base): + __tablename__ = "parameters" + + user_id = Column(String,primary_key=True) + name = Column(String) + value = Column(JSON) + +class MessagesOrm(Base): + __tablename__ = "messages" + + id = Column(String,primary_key=True) + user_id = Column(String) + conversation_id = Column(String) + inputs = Column(JSON) + query = Column(String) + answer = Column(JSON) + +#数据结构 +class ConversationModel(BaseModel): + id: str + name: str + inputs: Dict[str, Any] + status: str + introduction: str + created_at: int + + class Config: + #orm_mode = True + from_attributes=True + + @classmethod + def orm(cls): + return ConversationOrm + +class UserModel(BaseModel): + id: str + createtime: str + + class Config: + #orm_mode = True + from_attributes=True + + @classmethod + def orm(cls): + return UserOrm + +class ParametersModel(BaseModel): + user_id : str + name : str + value : Dict[str, Any] + + class Config: + #orm_mode = True + from_attributes=True + + @classmethod + def orm(cls): + return ParametersOrm + +class MessagesModel(BaseModel): + id :str + conversation_id :str + inputs : Dict[str, Any] + query : str + answer : Dict[str, Any] + + class Config: + #orm_mode = True + from_attributes=True + + @classmethod + def orm(cls): + return MessagesOrm + +class DBManager: + def __init__(self) -> None: + DATABASE_URL = os.getenv("SQLITE_DATABASE_URL") + self._engine = create_engine(DATABASE_URL) + self.SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=self._engine) + + def createTable(self,tableName:str): + if self._engine is None: + return + if not self.exist(tableName): + Base.metadata.tables[tableName].create(self._engine) + + def addRecord(self,tableName:str,record:Dict[str,Any]): + ormCls = self._get_orm(tableName) + if ormCls is None: + return + session = self.SessionLocal() + data = ormCls(**record) + session.add(data) + session.commit() + + def addRecords(self,tableName:str,records:List[Dict[str,Any]]): + ormCls = self._get_orm(tableName) + if ormCls is None: + return + datas = [] + session = self.SessionLocal() + for record in records: + datas.append(ormCls(**record)) + session.add(datas) + session.commit() + + def delete(self,tableName:str,**filter): + session = self.SessionLocal() + ormCls = self._get_orm(tableName) + if ormCls is None: + return + records = session.query(ormCls).filter_by(**filter).all() + if records is not None: + session.delete(records) + session.commit() + + def update(self,tableName:str,data:Dict[str,Any],**filter): + session = self.SessionLocal() + ormCls = self._get_orm(tableName) + if ormCls is None: + return + record = session.query(ormCls).filter_by(**filter).first() + if record is not None: + record.update(data) + session.commit() + + def query(self,tableName:str,**filter): + session = self.SessionLocal() + ormCls = self._get_orm(tableName) + if ormCls is None: + return + modelCls = self._get_model(ormCls) + if modelCls is None: + return + + if filter is not None: + records = session.query(ormCls).filter_by(**filter).all() + else: + records = session.query(ormCls).all() + + datas = [] + for record in records: + datas.append(modelCls.from_orm(record)) + return datas + + def exist(self,tableName:str)->bool: + if self._engine is None: + return + inspector = Inspector.from_engine(self._engine) + return inspector.has_table(tableName) + + def _get_orm(self,tableName:str): + subClss = Base.__subclasses__() + for sunCls in subClss: + if sunCls.__tablename__ == tableName: + return sunCls + return None + + def _get_model(self,orm:Any): + subClss = BaseModel.__subclasses__() + for sunCls in subClss: + if 'orm' in sunCls.__dict__ and sunCls.orm() == orm: + return sunCls + return None + diff --git a/backend/app/api/routers/request/models.py b/backend/app/api/routers/request/models.py new file mode 100644 index 0000000..493883c --- /dev/null +++ b/backend/app/api/routers/request/models.py @@ -0,0 +1,10 @@ +from pydantic import BaseModel +from typing import Dict, List, Any, Optional, cast + +class ChatRequestData(BaseModel): + inputs: Dict[str,Any] + query: str + user: str + response_mode: str + files: Any + \ No newline at end of file diff --git a/backend/main.py b/backend/main.py index 0f5e9ad..f4bca3e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -12,6 +12,7 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import RedirectResponse from app.api.routers.chat import chat_router from app.api.routers.upload import file_upload_router +from app.api.routers.app import api_router,v1_router from app.settings import init_settings from app.observability import init_observability from fastapi.staticfiles import StaticFiles @@ -55,6 +56,8 @@ mount_static_files("data", "/api/files/data") mount_static_files("data_output", "/api/files/output") app.include_router(chat_router, prefix="/api/chat") app.include_router(file_upload_router, prefix="/api/chat/upload") +app.include_router(api_router, prefix="/api") +app.include_router(v1_router, prefix="/v1") @app.get("/") async def redirect_to_docs(): -- 2.52.0 From 33b2281b7b9605a8564aa3fd40df65c900ffad80 Mon Sep 17 00:00:00 2001 From: wanyaokun <12345678> Date: Mon, 26 Aug 2024 20:16:58 +0800 Subject: [PATCH 07/18] =?UTF-8?q?=E4=BF=AE=E6=94=B9ID=E4=B8=BA=E7=A9=BA?= =?UTF-8?q?=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/routers/request/base.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backend/app/api/routers/request/base.py b/backend/app/api/routers/request/base.py index b50333a..7723d49 100644 --- a/backend/app/api/routers/request/base.py +++ b/backend/app/api/routers/request/base.py @@ -27,6 +27,9 @@ class conversations: return None def add(self,user_id:str,name:str,id:str = ''): + import uuid + if id == '': + id= str(uuid.uuid4()) template = BaseConfig.ConversationCfg template['id'] = id template['user_id'] = user_id -- 2.52.0 From 7068b058e86f64a1440d7ee4c4b4a15de56cf4bf Mon Sep 17 00:00:00 2001 From: paituo <330435863@qq.com> Date: Tue, 27 Aug 2024 08:40:46 +0800 Subject: [PATCH 08/18] =?UTF-8?q?=E8=B0=83=E6=95=B4=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F=E4=B8=BADOCX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/data/工程造价基础知识.docx | Bin 152064 -> 76460 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/backend/data/工程造价基础知识.docx b/backend/data/工程造价基础知识.docx index b328c09e92c9afad8878f99bb8e4291804b7db88..bd7f91d7c34ce7b166f9ed19a3fcb73a83ab3259 100644 GIT binary patch literal 76460 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�!@Z(8@XOdDFYXePyTka#1|hD7#+_ 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%0AF6>%H}4YC-GLk)|yjQad`|xX$j2dSzZ3 z6QdF5q@(wi2L3v?ci3aY3_sLVNny&-ImMrw*N`(qG_t8_1{<{p!_y`Xj0$BR?d8Y0 zsJ8e{AC;vFJ$|~XLcnfBD2TiD8_o(jP$nug9rWygW0Y6RD6!>Q2784`sZGO>!oO0L z$lhD6@N!b2VN|(S16Ppnj26y7@q~+^-lCwhEJuP%QFPIHNKvQcq#1M6fz_x2oMIjNiDzZMjn%$|1mov7UJ3Q#+F=BuJ>tcL6!g;q?vX zSv=sxfPKxwwx?7{qIsm*?yp%?1NR%pjge>#e+ zA4B!uvy9wqpW-Kru-|X(4e+LQnc(oQvf_v#4WrVE8%bzEH8U{S&OeLI79W&3jOgN{ zc~v4lb6cAA;jl0)B^=2j2&)J~YNz;#fKvCF`{!0c&HCEptwa5aZ@5@6;^(Bnuyv{@ zYEcW}@u)PD=19fa_BS~0Zy((l;QCS#Qc_t_4et|v{lrgIL`;0`sk>7=cUQdl{Mg5z zlrxF0#gE_d)>Ds}6E7gR?Fn;4;JQ$H;Czb7xk4JsIWeB<`J*!zuoS2txF0s(Ws-fpxKsW9K z(HmIM@QvZ8C_fPc0kQcIi2K))pkFtPnViPT{U4$px$**v*!Jwc#>FMhT-br?+qKU= z0SAk)%uHc0u)+?9mPh*u?8dn>zh~!AX6NTR!hM_Y4!y9E8w=y&@A&oM@Rgu=qszu! z&M*Ev+?8*lZ~V!?Xqw*8Q1>w>XQBg{NPG*s+zRd2VQ(p(yrK_5*vkpur_LAG+I;%R zWQNk?3zE#tST}Xx%lJ7N45k6I%a@69jmJ5yN4OStwjz6}%UM$Pd}s24XGB-rMF^OC z-net4l!)A>9GsRev>(tnyh&**%6N73*4fMvCJm~o=j3IQ9fJO9RPs(bvI;7ySdOvM zJXwno7uReC={-7G4-$X3w-`*(D*8#NxoQ;if!~rRu^-Ml=fRU7%rzo%-!5kuw1LpsUE zdDCw%)G+5$XvyvE#EDxtL$hZ>6&TGJ=K3zt1xsjBl3~>FGVgo~6q57sz3{MVFW`63lwGqnDw*`w>;D=y#o{#X^bR z#iu%huZd?=-OZgu_3Nt=bm9{OlaqotrNnd}483^mFR`9m=p&+OZwfV3kY=&2!3T+%Uzc< z^slO?mw}}F9`tOIMnL(o$tywhl!f-^lsnc@nKK)gjy2^x9|&PC4Zr4M^wd(XPOzqz z+LkAEX}&{VwKE&l!W180xwYAQy|W^cfs5JFA&56OlSP<9#1XkYNQmPPlTDHk%ovzk z`)h3yum--)`o1u)CDX3ohgX5QFSdik)EO>k`s5)DH^G!w9z_I(Z@=ZP6yOx-=CN3} zhLiqDU2#mgq$J*(O~D;Qb1;@$VtWq<3msy#{qnVthyQr(=3-(v$dd&s!tUpV@z-`gSqthtPG;ZGQKkW3Y2 zkt=JePqOds-0yoWrAM+KQ@@sc*~S!o6NhrUfImuLC0bJAP|Bjb5(BIbU}@1ND6FSl z7KUCz?YkFHBy1<)@v7dWLSVj8N%+u|z>E(ui5OCsT2DrF!H0V1&Q;Hq@BrfLYzA=# zPnC(DuauSy7C)%z|7f=@{@9PAPj8E8xg32C&+&7u-(Uq>l&0?c>V)k8U6q8@&)s_# z;rz8hXvRHl>QdG#PkZ`&d%+y2*$qe&N>9~Oa+wRLO${Am@;xn3D3UIqgk&%=GJjg` zF9}9_p$mvZd%ZX6S1@!$= z;NEa5s3aXwuAgGi?j-vvork;vLKB@Ra90cI!J$j&1@!gm1+f(n5O9v&_w|+>PEi18P zDLC6r*fNl{Z=lr9SluE@!KW#-_@YcjZvUng5@CRgkG61ZRLb(m$n|6qL99m&(U3kdH5n&2G2fMhsa zbO;^*l{)p<<-Eze;rlgPl)GRN53!KgGtfv4_X-RAMww^pa&Ivr*XxxcxoJh& zi|AC+ifs8+^T*2Vhg$4!eYB~hPI~u|AL)&eABTT8!IuI|e?=2d$C84M@NG3lnvD)D z7XC(zOoxUJ>{2z7Ad&X=2zr+r8-0kC#^#61uVIIyD=-fr-7y!p^18Puvc2^+N?|)~ z--$lKiF3WlipWaDO1=lK#89BIMNdL6Gn3=y{QfH~v4f_a3C|FF*~=$=XsvDPL$n$X zs63*PW+LE1h1Q=Wt`0kSeIY6S?$jxoetM{LY4)m2VQmQ)F>eTNCTX9n2niA@S(PrC z`kX_e1l@>rSZl2Gm4^Xn1-ElpZ{6V43np2W^(Ts-Ms(ZfcB%WH!E|Rn`sf0RLP(zw zu%;(b`|%v}OW^S+?N2YLA^o*C{+-1Cyy`>)%D@#n!OJdUQ=f0Q;7|unjLTcOyXIzP zpm^|L^UA?=LLO)JT2UYQY{Kmmqwg%rm^bNqkS}Vu?J-mhHg?TSphel(m2-t3_#U{H zMKP%f<%Mq=%Rr>vg*~EGg`AZ>ye;Xr{dCoESN zwF)-cuBc|sl$BoJls#I?!vTvV)$8d&Yz8LNCVq=^%I$O3!P=}^8PRFMNbAxVuThC& zr1J%O?Dyps0xTVd6l3iY`$^wtT8e30Ua?4(Sk82c9X9L4^k6=m%P69GPZf<;an7M~ zcullR+|nLLkgpG^ad}=> za-s01-cUVTrrq$doW5Nl`|AgjSIejL39q%=U{KIB3JlnZGF8Q`%9p9S`EZ*^Y_Hyg z!I!T0W7n|XV8IrmMtV7vhD-Zb)-wD#67z3m$@9g#6vVX&Y#X+QMDE*Mf*!7Op}%4(ecsD;%?&RlG34`yXhl@2$$lsPgP zU2@5WwibjlrSbwlHis#b3zjO@1uG+}RiKHeONMy$!x|ync}Tc`;EHd;=bM95!-`c2 zO9t;FJFIg%(*K3~MqZPFzx|tGdOH4Q2R4Sg zRFRl*=!s~f^4)=%;|Q&h0vkgyA5BJ>E@5>X{vHl1YdNtiZ(oP0BZO_>{VM-mr1$s! z5U=VAqtwl5ku8lGxO=;lBidA%VARb!#2v9!Cl$R67_9`Za!k9EGH+l zR~0&A10A?TEb>cHH{P(!Lm1N9l~EdaWD0LbI8k_;9~Ia|?N#rjql`XZKun77)_B!~ z>WpTM6|L%dDpM?9-ji=4ExObbZl`wvc_FdXbV!&rQBejlTtLc$Kz&$=>IfGIYnY0) zh?HD7$t41M9c}=niqF>oPLN0S@K!f(a}fVikjJiA?CA5Aux$l_R|}v0iY)^PKeq19 z%+Z~&6R&w?)o3&PSio+ekvA%l5y;Bh_kWL_96+``tOEVM?@Yo=Q)Ff#EJiGBSgaN5XQDcZj&Q%kit@CxHWu@*d|C?_Tj; zKu9MNZs)@b4J%3bf}8v&#=N~8?2CH~w_}7Z!{XpQ9Zb*R3P4wdB@du8pE7DVn7rFn zYPl;snbY#l$%5v-74uPVvVR}+OU}-!c#lb(W26yo&3GJpUzJN3b=#j>c61O7516S-HG(4_fdp83E_G(aK9wp~l z!cbyHoKKaO2q@RU+|uHq($LnJJm=NtnTZVM-D|gQYp~8YuNSDT=!e5$sGbHoFACS^ z9Q1u%Ze8kqD!OXy>}Q=sBh)$8mPD}xn|I}A6+S5NUD8cb9Cr44qSHP$B{#ehRKS~= z$dq9Ldm;pVj^UN{YnSzE_<8+Z4e7AqGMI2*LQH;J8&0F-rcQyHE2RvkvKU}iyeIHC zNWR}haR5t(Nc^g&)PUbQNB){@g=l~Men4?s!&g)=pLgTBfEW?_@lQJhKMU`lBK%Cz zUmG6&7uJE6E@f)i7Drai+aXW#w8yw``R_fi)up;%XGuY5pizFR(Q@U@bMBE;`9$T~ z(vmW-07bDE3@#PUI+GaNuiE)SUG=EZ&=k9*h;8u#77f=cdktFk+ic#mv6&=?SmV@s ztTJ#jqLHy=67VJ9vlXQ0e*V3Awb)kAC|%>uY`9xYX~3|UZaxMhD|#9 zY4Wvh3dXx*PUl%(3-1K?H@oojmYn496VyfA-wY9vi~E==-ywPnJO#%UYDor$+Xi2 zrTHx52o_O(H{%B%TF0%dZzL=dwV|Mkj>OVzV#w3m=v}-{ zX?V|}a@gz1An_rD3ZE1?X|d}N#w+b)^))4Tm15(oOq3L})joWLb^op7^6YMP@aky&oy0(^ zh7roWG5LYUPt-ciLRGeK6F!#RFPB8ns9&vNP9s57K;jT{a8{fdY+UC(yj)t6uqW)i z0Kc5v-0)sX(TR$>zy|fz^xH!NgLg6=?P!yc|eUGc@KlF`Ay06=V~?hNH@Q>1m>)VHjPC z_p+;sTd#)%=T7D5S}4DsXH1-ttx(nYfZ_Yj$K!C!y}dMcs|WLsr|{9CWf%Lz@|z3Y z5tShuI1gQ(MKSVrQ%7>6GwS=HM#dDOV>KZM33n5O2d}P*I9J#eR+QVjSk>wkE&I~b zJ)aygboy2@6MF4Y;PZLwzM5Ue6Dos|0*N*Yp^5rIu<2^+^fn;D`m;~`O0uk5x%X1P z^Juf4FR#+cQN%e>Zhamj+~J9d0wGz1;EINbWt5IclII2FxdUNX_;=SW7UHxD9Z}Bq z7+B|W6p5cyKJ+S=6|z%?$aQ#M__vhfJnLY*Zi`a6(-9%LKtCdYqk0_QFv$>NBBnfY zf1JuMM7pA?b*iPiPh{b-$-t?MUC~()OGe7r;Rh4e?GK-B^r8e}nPQ>oxff*S(GXxz z_Xm4+cXJV%7#l@t$#~&VS--Qh)_1))A&u*osc(N{y8TiDTb2l3J&e@gd9t^OTR3Z$EL4IFkoOk7?v+$vEt&AWZ6&MT`*03x^oMPp`aAd=mUW1X z969SZ%C+HNFvtAOj7R5y1nJ>##Qf7=51gcbu+;edna?j)YQHuT`2RzpsN2pPieHJY z+np1R%6xft0om|%+pL6fdvCY3d#_P+2;f23={0^%xOwh5&O-~vSXL9rrOZgrEmDMX!OFz1Ehm0$2H|iw9BTTNrAFRn;i(MG zl)!mjR=*?Pdtv@b29Kt8^1eoorwfvy2IKz^EX@DAE!zK`-+%upg8#>LE_pF&2<%?| z*(aKi5yy%C(-TdIQ%ryVi6$X%f(h}pf?rNF@q)X*{X~-oBjQ9;_{5ZKU0f8fP)tcd zSs4b?MpY30r{g{#MZ}3FIS}@nH~)R2>E9=s{^C)s|4k>F{*|(bjST3)qH+_J0&(X| zJ3Ykbycx*M-)_~k%eGG`bRvm2OVgP*Jbl!=v)^itaVv0hvWxR^$Vc{Va#VL&sq0}F z802)|jLE_sh7qo!`ddR?Z=~ILm8h$>?C!ns6MOmbgC=DYkqoXg$4BgyvK60Lx(*qi zH_H5TB*BLH$MeSAGj`*G`D4KaX+qqDN<1DASF}U4`i{^Ro z$|nma%0Xq6@p+p-&I_sqkJaYSJvJ#4USa#=%N@IvQ2U5Dj$N*U#zyLtkXp|Xj(d=y zfg=4gm|ZO&JLd;CV$bWc_EWxMawk_4?L44&Rl@~%k6dTd<>9iom!B5b_M9@@a%j`Enon_0{u42y;F=A#;)8hOHZ5-md;B*?L29OpFIC*V+>k## z`K4l*A-VO-Rf$0u{b?h*gf?!H&lLr{c8DEmP~E5_sFE$>0>bG)+Z;M~pJTeHYh)9-VNq5AvVLw~FJA}KXri|gb0cqXaM$FUEg4{dsGGlbGKPZ%=O z@LgJ#ZNI--3t6T&k4!2Q6Sto2>Tv-3YaiKyxN`^b4~BHO!Zw|qD<-zT9v^KOc&6_S z@?t#yfN8tuOEs7xo|3dbf;~9=rSL#ma9ph-uBWOd%sOmGcEv2l)sLDID*u(m6KO=@ zaWvl5?QZ30KDT^(v9|as?=WmguYCToHU@0Z6(8jaCaUOY>-Sejr;KC73M7xsQ@$@y zzYdvAj(Q^}wmh+c**;iPhN(6Mx1)5NQX5(s-m)BSeuJa?*nlHHIKzy*v9V+5^}yHI zOuIZ1_*weiZ3RD1S;|F6s4qa&Rg?uu7DBGbMXuKoj4_@uZgD{M5fbzN2IszFoPkpl z%JHDFU$In%1XBO61F6l|qwNmOup<`-nEr8&tzOqu`-poKn^EMv%E+oBhf3QH40IQS zcM{L=M48WV!D9d7o9fU?91gA)p~RPuXD(L_ZoCZnloP`cIoE@(uvAQXZI@~5c>49a z&^SdSnNX)=n&x;aw_i^5XDVcQiqAnmakGzaknE;!mw^Bua?(MVQS%-DQBeAzp6s+XW?d`C6+ z`0C>K4IQ6Xr^+FEM?BH=Cl4RXIMutVthVcoD_Im8a3tUL39fiv%#q%ykj{XE`K{}0 z9V1Yzd?o+%<2`2&U8j0UKaLUI+V)BxFAu6vc_*4Jj+p2<13@Ed+%Sp`BU54)6jcA4 zwG(?X8HV<;>|;AGzB(y$GUy~|N3w|(SOoT9YR`^1vtZhPVe2n)rmKx;)tK(39t3uF zy4l(56WlY|O?s9-sDuHS4DZnYX)_G*6R_d%$V5)T%lHN+tzMG$$o)S53^&h-x)<_~ z!RN40I;9^ZFy{%pTl3A(B@Y?H!yg5kW`!*g5ozzZ8BJz0yyzo3(b2V*A3V)4nmU$U zQa9F>6-?AUI?}x9W#{Y+d#{rsCnI5yFh^8IvbM5(h5Bm8sn*N2Go?ehv#w&Jz@{*X{uEOl4Z-S>iy5vU~4jM_BNOfmB&4XR`3Mkq_5I#?9_jDcZHSX2;cN z0M*qugknCryIIuSq=opA!V=*9pmo$VnwDT5;`f+M(;9)zTxYLOzFY!TZoiaT=D1f7 zcRsQFWi!yXKbW`fE@IzE?l5%QpU3JfKU!kE;X|3~iPOV|w`WuDFMJjBM3e5be&hK->FkEtgHE-Y#w3JI#k(KvffBCr@Yd z=OYE@z%e}z&a8c4{_u7L>$3xjKxG=uNNeE>q$LJfSHDIG986&XC--W0+7b_ogir2V zK!^;g@Xwcy?2j4ostaj%B9!v{&lyHJ1(5GfHTF8$VtDbeSxfnL#wK5JgAn%pV_FrpzW)wDXZds@*Ekp zv{$h6xkog`LpGui4g31q)F+n|ADpVbvAc3P;!LZ&&9zKPEOMVB1~rnVE{Ne~W$9k5 zj^~zU>m01gjr3hg6p2T#lfZb=VUBmSY*c+bYZ`7|OT!IJp4ecQwrtxwWYNav-&fEY zjmzy{MmBazGp@|dFvJa#*)Q(^HPADc`Rvoq`=^Jn)GOYl1qD(3$Ls!840b%7XG^kB zlW|McdO2%_mrjuH*@RW~;)CFa~e=d}LH5^C?WIwxesIjzAP_8}qO=4o)L& zJp9r-5tYV>566~VxKj4;{OWsH!};LL>7zNP1+A(o##dcrGs^V~(;g3+Jfxsqu?_6f zV-KYiu;Ux?BEGPY#G66Zs1p+doypa=3wkKEQ#$e_u5~;|=ekVV(?1ODrDsbLm>;%2 zN$=iz`2=fWmN!5;Db`E067w>c z9$j9Z&!dM+w4AcG$ZcQ3`MardN<#bJzkdRaB@puWJzBDL&v4Z5N57Gg*M}rv1ja-C zyax}=(haNcRQrgjp6ydRW1M*zrOf+}ZhG}fP|1|;ct3I3vglM`kEbh>Wj?=$TN?kjlE=cD*->r>>F znkBJYyOyG(3zecyXOWpI7ALyTZl&B#zvgP;eNrT~M$soL<4M};tJ|zwYxBfv^h-fS zlK#vc7x)KRxY9#6*Kz`$NZUYfGTYC|sdYm^Vt4-ulW<{4ToZ@t@3(i>4|%2xt1|8b z=BEr#HY-|@gh8P&PllUUh9_%vHYw3xWXXF)>3sU)!ZA`a;&qMW8w&L^axBaa40BNQ z>kecsQwhaP@hc%uv#o};Mo<&SbF6Buw=MP_?0=A=n|IReoWo?OuoO}jM8 zr{R_B)E#RD<}OU%a3Abc9d2UGsj0$RXM$>V3_mLt}@&Pi{+>1-hTC7bzI%#P4qN!^|Iz>SUClSt<3fXGSv?H$>^1D!AOJKNz5Bd2nn0#r?OgfkoW ztm1k$E@K8w6*o4wK1f2zTMc3_4x2hu{chyLh81(w0nZ`5)_~V52Q7AfW7@8VInx48JNK;UrdI_; zIs@6nO=H~oD#h?Fw`tcKE>di7J4tEz=QcXg)!yI$OOTq)+vKF7Z?M> z6TBdkn1BH()N4&pkdz3@&_C_-CMe-MD-BOFOX?l)G?juUz?8RhZP+rtq{R>c{9-cO~W&79;hD_As0y4R#1%@JwM2 z>&v>oV5|r)^kKWUR*Fh^FBs2h0D~Z4{tKb~_!o)1MLgc`6H8W)zH6mg-^S18aYcP0 ze!hR7V7ASE`cn$?57ScZ0eZxDPt@1TSkt3}kUWCeGSY*D-)T0#zdGiu_R%+?SxG>- z(6rVv4>k0<5l5#Ds;l<%*EG8Cj7taoZ)O`XWre(6m{zjv(lZ@cjv`}b#^!VtDM|^4 zmIYJ^e5jdRPT?M|P-?|lmHN&TzRmO2*;2t-u@ma=dC&xH!WQ#y7ET_k4BhJzIOF`Fi`_5y zdZBxzrgoEiSanr7aw+&muqgHc90eH@^$MmykKGZ@g44|t{AJNk<-$&aOCOw`SbF!? zaeqkDP}dZb#?M7!Re)Arj<-9}zVOMCG8^$aVXF&?G`G!=tG;7~zX0vsyi6?bHmYS= zoI5B3k*NRcKx(K~QZ=$^SzZ@e&uc-)qk&16e~mgHd_LG66V;uymjo#+?W55P=ZXNu zn^!ZeLU>~^Qc=pEWG+Ru(&KV-f0#g0jH}kKjfQi%RGh3^5gJb_y;Ugpc|nC-e90Yu z2=lrtg?fqi{CYX6Mvs&luYx#|6poDApg@Ewp>4w5%7@!kbx#yu(8c#_I})Ry#K`*6 zg*W_jR{s986+%nj9M!tf`fG1KRUxBz_&G4uQY^DXyixd((@bvMty9=zR;S%85 zj(^7-aV+9|JQ`sv(3U*J`|V@>w-sIOD6RWn>yY_cmZH)THvWiDk}<-4&3xes>Q`(g ziOV75s)Xk6$!yg4jVYsvUW>*O{TeUZrQKkqH>@PBALlTmp>TE{NvsgJwvwKdU8A7r z;9I(Yw(=?iI4bUmMZ{&vT+Q9c){CDKdg^Gu5qU-K=@!v9zfbhcRX*Z8yfgN9uD(Kf z6sHk3qCmw6ITk);Z6uXwMHU-CvEE$^Eqt9~n4h0y!j!HPz1r@ZX>rYr{FWu|^0UBN zf?0`p_4iE2?rbJiMl>pOy`6iURzpR%Jc<)nU1ayZ8Q0a$pGLYxUW!=E+Z3=pr^_UB z>#I7aeav(se%itAGL@-a9W>>Iq)zATW5iD46P&J7XY|ZO=qE5}gEFNyBC$?jWMS{oYJ!s3QT} ztzLU~DFClPz~NDyT#}+vVojL>;S6hIPm#BCCu%Y35`Cg@uitEFs7ptL0Bu>ng^%;g z{n>twx-Pm$N`4N^3Er>IHqH`p-;qeHTA@65JTMFsr*kAzA9ao{Pn%U@dN&&s!WZBH zV|}mrO`*oXFp&cH17J38Ta=;s-Gn5Vfrm<*fl`2%KzfTvq`2y(dPfrnqUyVkP%kCK zV(jYPG`U`sk^bqWc2~GD{uu7W9`iT&Uw83{C&mZOC#?o0(n3u&j5t}KNYH%-K zK>7AVyOmjBeJXTlY^Zr-ckXa>mcv^9l|{5=Y^S$pQ!^o^NEeL=V@%C}*Jm+22`UBE4m?$&aX=?|pY`f4vw4g0?jzRkdEwV|VWPktP0w)oCF0DQ8>-2947GN;q* z4GYu~eIYkG%WpRc-cC-zS`aXS9 zpMMW0Rl;Y4s~N+lnkAo40$|au58L=wLDu&oi1@Vz|{JFc@2{d>#{<^FeX+6GQEtn{3 zU3jQ;B40`JuB4_mM8sO#j@aSJi(9sDXhz$mMfAC4Nsy5$eHJ?%E02b9hFzLpSL6AL zXJGEnbdc?SSsBThosUe$rL=lqUbsOJa5X7=O4W&amHf)a^7L{CtW+__zEgpK(9|`p zAjnl~jZt&Qxa}x{zV4~=V0uZFA!=o++x`}O%KF)p;qK1pn|_kE{Fg2Sbunmso}?%bN$ z+|48Bxk76Eye#Ot$hZ4kMuz(DhFcbrf``&^^aSRaLOty#%+0636syToOxp1Z(Q_Aj zLDBa$E{osLBfPASrYQBM+C12)vYz22FIh%HXB)V1(Nx71&oB7COAGV6qyrdYI5 zVuWSxauifues2}2ZdCR@iKvQoSTRz&^Ice`MM=333bQcm^p($}L=#G;n(vd$p2uz|5x{`e>DBTi%At=O7owa7RCQVA4^90UDF~I zV8ru}m==lr_|H&&F)adLH2ynGi>w(CrbS4$>TT^;8xqGin7@>b}nP5NXD zQ}3tx^4fTd=_mBjt3oz0aBfUl$h}9pQP><}2Fswo{`Q()3jVkNrawWNYzpbv)my_( zqgK&|Qbni^`*_0-ACAWm`jI)58QV*!@6AIC3A`Q$6BJ`;k3z5b_eg6@M-O3n#E_=%H&g)q&DnHF{F)2-P4|p$yG8Y`up=ZA zJ#vCzJz`y3>{UbC)Yi+N&=dJzx`fh8lc-vgUfGf(k6b!(T?*w zM7e~nkz%&Q6F+r~A4J;f-j(s`@ch~ke^#YjnG>U$0z=~3zK@c_+ZvG>J-C7h46bSb zUt}KOSgszu8mGCl+-V-a-Q0N??%NtlTOUe&*|F@v{DXoN9u1q;t0F_iQEi4%2Icv* zRv-090zvV^WoIM!qab(eaz|ZyA6pJoO6wdqo-*FNp##%zFNZXn8Eg8*w{%7D@jJRC z>xWB5i%s7}+#WXzrplA$D57&WtEF>Cmtr|at1nZfp{S~Yg#OXb^ZsY?SlN=Evu|b9 z)V!b%hH=5*+}biIu>X&}_kfG)`1-~#sL0}iB?*b30b9g^EQ*L&VoWRqy}0bkf*|0o z%YuMnVHGem!GM4irCBktC6?F~yRl;wd+a5OF~+X32KRryJ6!Lg=J&Mseg4n;d`vzg zcki7!bLPxBXU?2CGgA<6_0tcxPRIA#JXrs*q3e?Y&E#%t2A*jhCn(<5`(;`|p=i9PNVh*N+UperuCM>=KWIlka5LN#!Pn*4sYgrQeak0|MtIH0M5& zgleAOGxPX#+#Q!k1q((5HL+>dWbcRDAFlWA-qm?hP{2+3)zA}19w*G1Q>SyMrW?OG zHKl%!?Vs_nhFXsiND}4u4bXyw{@uGD> z@PK8Jo#S5QwfR0%tEsoRzx%!=+^V`$TG&s&@$$#M$(AdYyZp5*WAtCk!tX>oc4)I< zPC?v!kE&ozas!u7z9{sw-{^WywV*7|to0SE8;Aei-fo0NahpAMJ^_E#Q)Mn`USQs* z$H40qTSIfwW|VVHo;*C;yVcG6UH7O%Zl7Ow#$Px7U>(O7tETzZA8Q|P_s77xjrxDF zJmc>!iv}zjydbQsrrEwCa#a=Qc%E=~ud@3|;Y8^DhT%ntCY6^-y%H zj1_-%jf%_WKMEUO*8g`$bBW)Gzv~qatQR@E+fnDm>+B}Xn*Z%*{Z;F7hF&`6b7^Sb z#7Sf4=Z`yd?8|mmg)3XETXTHpyx#^7___G&HO_}F^js70snvlttykxIgg(F6^!FZd zxocL&-#9m>VUTyyhz6C52kpHVr5S$M{?lRMGh^E>+t>SP#~topCD&W|llzRG@uSOI zZGWO%e==>rp*bI5u9c}T`Rqv9G_7ldqmSuk1 zvJIE1-gRJ5>h~=snlJ76$GO}mK4(Mc-McfZ>!o8$ZuKAh?BY{({>$GTTYq~mX2qk> z17^pM{nrE0j4S(oqgO0T(v?K7xbo8P`|M%jI^me}kheH42%=apH|9ET0 zii$sm`7YeN@Q1C%O{bskl7Hji^b5_G7%YQy0McyHK5cxb!Yu^txx>*S;22EcU(RU>X)&i%x1*8%v`IH=QB>U_wKtfFD@o6pfnA%y{{>LA5PF=P{dN_aD?a{g< zo8Y*o7JXv}2dz19?BKCAiGT8AlS|HjQpb_YxM)+l@a!M9_pHAg*mM8o)&&*E54H-m zK62OFb!VL!JGlww$t{k%&A)Bn+;7|%vwoDyqg%H&W4AQ@+3EMF$QkRuIoNu2Shtm< zL)UI7x^ZJ99Xz+SVBjt~c&@vODk9?CakGzj6A|bT+_iS7@K;@5gcBqU({dvLwK1@{J9%RHW&6Tx%ENMb5SeFBU6~=7RB2(E%~cge&waBfj4%|Sg`aT zPGbM|RHxtXZ##bTMf7i9E~=(=5<=&gP=hG1UvoE`R(oarX5hr#<5>?T~Xa>z9xF{nY|HVvmN^kJY?I^>!5mnk(xI7(+U4G0*YS@zWwo< zTk#(r{W8|+@ZqgD2X*_(MzX7(1Lsm=cJ0Rvr*>M5o}U!G;9Q%FbGDC3zuvmex~V7p zn=}pJt@cHaz(1rwgVz{lftN+ihofulBBP`s)1E3v*9*XzAP% zfRVE#Lwb(uzpL9FA z_Oh*I!GMkJmygYudgGglE+gJ7zBaV$Oig+0FLkE}ot&>Oe zi{4|lEv0>*mM6+q??_qRQ+6==yStYYejIl2(Z%f+L)+BvwBMoU+13;4>%aEWNN24m z@V~jb`rF;Jr;qW|Kf1fCyQXQY7C|#gvqq08S^B)_>ouDvZ+GggU-Hr3?Rg$kd^#R; zyY}+Y(p~2Un77&|xg_bKSVS!RiFtC%4I8N49U{YhKWW*=cwpKP8CR$5$6+hiep)gj z-FeBjZS#8%p1a|)>rOiE>!YFa{35l*z~%!joE1aocADXR{CM~E-9PO9$jtS8f4=8I z*%$lkwoh%4*Jr2nP^TFSX{%o8%F%71wWf@npc;K!H>&;h+x-syeEqN11$#@9zg!eC ze#N0to0qjFwY92lcT!tZss_xvV0}0_D4FMm4u8F+vqLw(Q`6_Q*tjA2;;3%jZBOso zaCcv~F77rbgK`ev)kO-*%2${SR7}YxjNF zIq!qSb2l0s3SSw$*sH9t&i>X>O@|LW;5hxeMHrh%9UNL3aephRgOOX;WO|c27#6OK z?RByKrK;i^2lC8MFI&Vv?eJqnRQ{;^&wg%}-~5-*8C71>FBGgu9a7N$3vEFwL{539ZICr+OON{F{#kc%OP)9d_nI#8df^ zY3ExH^$4He)ah1S|E5*`<~^ou`_C=1#SFQ%`j(%a-^P_k?7VMY+kB#DujrOLO7;%T zd-&t=O&uj|8!xGwJL8*)>u(LEQm|;~1#VNn z5gR^gG5reP&26UH`py^51)m#!Yfx-2ee}NlgK|=Ljf(G5XQRhZN4KA59JDgaYjI-L z(d!9&PQ{k?KK4U9=VlQdW+x2k?`k*4d}kAB+UOp$@BU!r^sN7$fLrrgtL7z?U)w(Y zgYn~DUe~^K>#XTjJh?z7Pq#n9HB%}2)EjZjF8y%z?ODBZ+ZJ~Y4GvEJWSDuE_2YMc z{YBk6d1X^ayDwWZt?efFC!WLC-HCd!e%G3A1NZc7aQ$Gb&YEWrnp!2;)}Ir4%Rl1D zX6>wD0eh={E_EE;(W`Bn(&^r=h$NzUL7C;cZLNHct;pFvKeCzS;qCq9MTHMi@9rR-UenKY{(`O$iAoO0sCS_5m3l*eS+`~V#?vl; zd~jK{p=F=TXcJ`5TEH?I(@(JzWk*7@3Wf!Qg>Xv zPQTP|GSO`Ns6W=->3_m+)wCD;Ms(d;b|pZ|GgeDSnW;U3=3M!dw{Lnb#%#s1@S<}Yi(4J+we3QS ztyx{!go!h3;RAiC`l-}78aD+W&N3PmX3K})Sbpv zoQjQeR&PCZ>G0#jNrToTWJX^s{O+XlwYqDTwR?WD*&TJAef~|#x~uL+R`!hOQhDWK z$lwRg3ndAk&L7(~=i=7vR`b3}f3)ttX7cFIIuCU6w}^50^xOZO#43)jTW0;ipeg(b zXU&FnDf;~%YEted6vP+AwViP|=)2_Z{%udWeKS5-d;jkD3D#A&g1+p!Zo{sxt7m<3 zprzN)l#BNI$IIr|K3l8^ITCwW%`zCzU^a zm9uWeE$)}2W4cCGyN>QUw~gDf#p8NcZ0vjG$LZ-MPEC8>@3OD^kv=DA-%*F%vAtTh z>pxo?u{3zMo9e~P^TV3=y>szn-`v7*K2dHn(D`YpkMy^GPunl)G-GjP{e1WN5v>Ck zCOKdA-Lg>H+N0I7`jcPWjrz;Ytm;Mo1>xHp-p}7$biXvV$ClLmy)ng|2e_%6H@9hh zsD(p#Zm0D7UOkR9@SC2yba=!(r&Z+-I&M6A*md*ToO{X<^ZZsfY8mx>oKlYD@8Y@22~U3Qwr2fSm8!E(>&7cvSsiX=Tesv;p-20mADUWh zSh+5z^NXvUIz9f=J2OAOz$;Xxb@E?OH#>FF>`!M;_ncK^-=Ih2maI+Aj?-@Z9x-oZ zc85djMog47jjFRRen`D(Mp0e*?s24y^XOSv=Fe>j#5cwzc~1N}9Zx2RAQ+ zHu%lWu{ifl-``&~@qRX_%f-O&hMn6y_w=gk8|-_vj!J#D^SM0k?4~Vy93I-;|EW6N z!N3i?8~*kM2LUg(U0SjH{IIi2(*N>KdQ3-6MBG?^CbRjTajreW!d@>z7?ZBX-t*IlD`j*#c97#V!)&nY@Xr)c9-*yr4BYl#xE^ssFN|Bb!1KwI`{x4tMUQFHudY+8>29AW$rNp# z{GnN!&}UO#eHlzqf~&qaaDOyEOef-Ie15&_)sUA~pTEk#@E^Pm;2)xDKIxY9aEM#d z%X*iSZl3@8(sKJ9&z zcRzD&@%x-reyy^f^qv!2W;5VblZ!F5lVCla7Bc6sb^6v-XSY6y*w8;>SJ>EgKW{kM zY;>!L9{u~@Y1laA&nHEG#V;nGc3*wSd3nRggs>9>bKMR$ZF4+yhf9e^(}Rjfq4)RR zU%z+G6xF)T(|sD(eXV}OS@Ik}WDL1+w zIN;D=`P}IXo(#9WET3_2n1R#o?ppBE$TLHOu6*!ls_&lbISYnx@y*|4fk^+p(U1T1 zx87>I&m&)I`}QMNs?xOlIv-K;fn?oU+icNfmxyKqv|f!99SGk9J??$6b`N~0IK zAGANxsO0#7_RG&)X=3K6+urc#(fZl9f?Xe|@|XKWeDmXg)+$B6=*DYe&jnPTJ-Xn; zd1=Li5yOV?e;xFl^jAW({`B1^M^1aJEWCT?#-`jO8z&9)pXU}@aYfepz@`J&&A;jL z^YCX4hrJxRwp0F9YuZb>?Qr#vtp?q(vY6N}dG4igTjwX`w`%7w;imx&zm45wQP_3+ zq9tF%E(yGFw$q<=T951Vu)n5<{F>#i7K2hAUf;ldQf3kHs{O;in=ia@`NBur%8%MB z3jQd&Z1;TZo{?ew+WT~#vVLry<$>QeZK3#{_{R=smj62IEAy)t7mj?{VZYm?*^%aB zz8GTlV}4$p@6B4ptT-L$icRcodW@4xTirQQAJ58lvtfb*Q$vkzJY>@IKi<)Kc; zm*h2!l3!^aUuVh65nCg@my{R%{xbf_Q97$XB_s38H@s}MRbSmt1wPdr`meA}wRtvt`GPzQ=v~|kH$TRmRW=-z*)2JqgU5a}Z zpDB2G`{0tEos(}3Z8f0qRN}bwUDdx`-+b@qWz8EHIGbm)e!KkDOY4hsXZ!PEu7}1u zpKq1l(zZ**qD8|C8dnv5S=pphz0WeQY}wIZQtFA9jj!bPd+EHjo7&6&P>ak?^(Cv8 zUU<0g`VYZFr`uf1v+)Ufmi60k&(r$`9}RFmTzxX+kG!2D8(81^?BeBcWrwPRs|=j2 zTC=I$qmL839}LL2`*+(#m-dd;j!#&6t2n0r&qrmA`)`!~_~Y8Nu-<*lwjSOzqTtM> z=nIp21bo)i`7fsvBcHDQWjC=l}V5G{Bp&pOC8sxtnU!8?sChU2bL}B zwK|`6KJ|UL)_jGlB2VVquRU*dt>3kKAB2qhc*CqHPvt}F&@**Y$E@?RIj_Fn-Xpp5 zy=hJ``aFz zocy`;p7ZrJlFz0N*yf+Ml(^5^)*EP_V0QBK-jUBn@B7pH=$&=vmpZq!4c~CT zx@drQ(y2avEv72xI21eQ-m^PhX1;3j7Y44yPgZn3C~ataJpIPTAEz!@T)p|}*X55k ze3SaeO7}aJr6t;|rj-tVj-MB=+1{t#i<+s(fBTiP@9X_PwmtTLTi?+^Jw{*J`+xrb zrNHe)ySU5@2?Zg2IAjMy1y{4<0(P$TOOCVQycOPx-T3*|e|0TwxlXIZprC@dx3biF zVK6j&{rjJug$xUg57X%0EiTT>g}_2D1h0cMan$K9&Wag$B*Bv4c9Qlr9b zIL_1L{eSeajLH-EfA@dg=sKj3f9Uk29B*5d}LF1$XiT`9Bq)UtGyuy@Nq``a985nHpgxLzytX zIhm0tiEQVT%x-@?ohmc zpp&ZM2roWO&++Z~?7#_xJ@QNmbdBAVWfw41G1v!h_9Qk^r|D6@!UuS=UKHTh#>*va z^rTsV!Z%C8i{je&u-wOmW-`XFRAy&0&Z zdybiGdsh7b=bX_=-E%4wdiiM{c!?0vi*=Q8C3x!+)}6MW)KvMrVsK#3&D}#u%SL8hY7hM zOC3`gPeBe|@(X;!_-tj5P=ybEOAVEYe6$rmFK|!>dfX$tI6-O{l<~RNiWm5BPvZVW zbx6aIG`*k;KusytXlHOzRSmZ@(#V;Y8tba!L8YqUsR~cylYoho@-~igipB5RdBXx* zgrjVGzK23x{t5Aq?_NI(T4OKk2bc#`48IKCB0_QQgN9(O=V@Pe?4(;z63(bzUqAYTY z+DGU~@F7}*{3N;5JMsj)t1V@s^qI*7z6Q+me(ia{2W*B%*Vh{IO{q1ho9d`gK!!z` zhUOC}=gd3NIMRbp$ppouhV(*NxOTpPovlomYzhOqy^oqJPRV5b1N_Wo z=wktHk5II-HWx6gG3vSh1Ln{RvjXRq)W%<=i16Jd3}dFPS})Jge5vy!9f0vOJFtTBwUyM#HZ@ZMG})QdhAFo#_C znPf$jt|F={e4y<-=}Bs^RO*AD>Bk#+2xC07mDB)Cp(CY+D}Ey*rG^k15AhCE66x6u z97%P)O)2P%1~j%XJ-{rG**tU^qp_vIm6MRZdskT|&7dUkfn=9!2ZkrBU3Ac6CnKli zrs?PUjMe}vmoVrVlLQMeI_1@3kyW6&gW_)vz2Ju zf&_~RczaAiw9nHDRobka?7+04OnbBqMINc4kNSgbz;q3A37hBpk{{}m zMK*~<*GN6z*M_&|@fWtzJwYn)-9TZ@pxc%BDWH=7l$|LDocrK^f*U5l29E zQVq&j>RZJRCQbT0FeP&yQ6Y;*uhhw|(&^>MjhgV6S#@}7>Wg3OZBt)$B>08dD}qK6 zYccAC8hdrHuY(R&B$Hm)1+0f98lfDl(RsdEHS(>ETsVuUBL}U4ZAxa6kDv$E%KZ{F zk)tO`4M}-}<+kcddE=lfet7E|a4$=m?3mX`4eN4LboU&QpSx3!I;lkLoQ-W2tWbuR z>3ICQT{ZTDkQ1#Ep)68=trRrU{zNt|fpeb+3fO|8=>LJ*3jLleL4QGFr3OeU>^U3i zF#&#$(77dNaxH?M2MU~@O?GK<0(f>}&JvBgFR&EbkBB{kp~_9@zq?|>yWc!bXj1O&9vhU~F^MT<1Y zav_P*u+5IylmYLIC3Mw=Ho?zi6kD5;|I| z!#JF#7e*{66{G>whV|~rPvyaDRl`fQUJ2#e9c0_O24NQ5N9_~~$P5Z_Pt&tG>@>B| zHjIf)e9@vRz5&m$M4Rnso=dZDdSb0)PiQH7LJOUAdo%7D=WuAfOvOe<)^3)oQSzkx z&P78~*snAl=3{vFq8TylK8!W4Ci4Zsf-vn*W>a8hX_vP@cS^1&pPVpMk){Vny9QM! zGui+u#*+_eT&v>u=aY`eQ;^LsST2G@!h+eJWok!A4R(1R!Gbl7ueGf^W_58oXllkG z;4G;j%O^E}<{y!V;>)$;C)-XE=w(lHf%}T1x-8XGg{N*>aGDzsgxcJlcfDT9+_r13^zg2Me|g+KK#5DyFNTlUSWo0w?D5nFxz& z3BODEli3xmN`Y&>Rm5#T|4h;70zv<=pttr}0TFu@(ji!m3Y zGe)M$L}n=_C)g@zwit0+-8PggN27P9sSzqL2Apgqq)7N>3JY+iXEMgnWEnIqmccZ8fD;j?&* z);*$^`a;7!58TX~$y$=OJiWTRfT67v*{l|p`CtJT4J zf#*=*I@7P1xv|-(z!_#TQ$8`1!CL`1X0>KLmlHXDt?yD__64$lCwL(pba?V2eZgcK zIt!kUW{wiRIH9?tyYFJsHqb-nGLK+x8YGA$o;XHo033;q^*Y9Qi4LQ@)NnReD2>qw zqlRf41YSlPom9Q!?0kCgUOZ&qnYaVkFbvRJXic^f`~*q=P9@G`R*k>L+bUq$V-~2YX3`3CsmZnCG%F7O>#;5N5!_jMuad zaoockQ{au--Y`>s5_4eA2J4q;a$&qK*Q$%{j59&ZJK3xeWwMng65pOFvhR?1na@)` zoc!MCCpMFWKB9JOs%q>B=3~eB$NE*EvYqwytaRrjO7)q2BJO>oakD@Bq?ez|K+$9-Xgf>g!H22&+q|lhF=P;ZuZk!8=f( z!5i>=wz5vLM>u|=1ZyGigiM@Q*AK@Cg_#bX;KfW#_$~{$3;quBPSnh%&@xlG!0gQ? zv|Bi{^BibqAaV%P3b0HZ$itfzQpHzkrz+Y7#ArQ@{fK@rm%Y~U#(dDSgL4Fn#ch&< ziVLY4?oA~zD~Ck|uNtJyRt-rT5_ne++A*IttbpygGGC(&K-2}Tnb%6F*<5zT53}TA z^>MOWZ>6}8O^jI^h?yx8v!a5AR1JrLeHILuZJ7?y@(ih2r(0j*6;*p-F3k0~Tm2%^@8 z4qYzS1fu6)(ZG{9oyRZuFs%76ivq@`xUcyub2s( zBFtj{cdwWUt$n{@CeF8Ax%Vq(Z?BjMobrCf?EQ*a?KL%F#f-L&QXGsB|ArMJ78jYO zhh@R>HpW;9;azIPUm@8_th!rLT$jY~_E!^s&ev95|0Nvzgk zy%n*?*x&PTcpOF8u!y!-ew!#(h?PQYxUCxHh4to#!&$`1VBe#f1_WQ&>t?Gqn`j=K zQgV#q;#Q5P-E34pymMwU#Oq*vmc`-YJz`%dLg)2rI9BBw<;9h-wdDr!l@#l$3UG>j zrk@$iU?2yg3laHgCPO~#1Hv8)eei;;(~8H1>=)L+S(GdyX1x+nx6}ZP1-h_UIw9Vj ztt^?YfCE3o8{wTX)`Na$D-o?%p%7Ndkq26^rK4#-n#s;47HHg}P|8eJ6lP~!y*86| zjr}l6H`XT1D}hDewRA*G#@i6HmB{(_dvlp3?c#g0-x)t3-U6)?R$tkQe_2gTK30`Y z+fh$3j-qq0vd>oj(XUK@A+7@!ElMH=18d1oW)8}j=W8+Hrn+~e7{`Geh_FCeGaox1 z(Fb+12j}DWY+vlE0H+{kbhgjTn%YpNs}U-TG(b7vCPZRnD~%jLQNAoX2N z!?$LRt&QwGR&>t+uUk@NB;!f&uf&k81h+5sH4Cki{gemSv2vh_ zk<0W+>Us+F1xorTR%V4!kDJL5vw_^e8Pwo(tF-39o+{86QpBKiqc7k|#P@rvv*qlq z2fanDh-hFfxJ+D)+OU2AsPM@!N+AlRrDJVcq2|Q(cJkoK zNd;+PdWH6NQ5(Dx%6t*6);0$*Ne?N;%!c-BTF^VmJGceWba3TrYgOQiuPq%J z-+;PIJL4_w*QEH4r#wo}3yc_dDhfM6%w?Sk%Dc2WY7l7auH9FJ7>TcPN-6GQ*pP1m z5ZNeFYyQ~GP*$f`f{>4W1r4V1*D~={pdh2gs_~vG#K5zB;6+iBvUlLK0Z~0d(fdrh zqDA1%7b?Uf;ul&Zsp&WHnxJJ-rh85qaSDS4ZiCJhc8IdK;5Jh&&E9sWx1ifP0nUC67%u}`!G#O(dFS8J;M!NziiaOaGHs)Tr7%!_gLZ;EBKtt7k?{!l8k9i2R@6&rG{U35Y?GcUYUc!J z!&(_1tS)}#hrF`_2m4IO-H~XTcP2^t<;1vzQ>*^$ob`&3A|x?5LZsu$UlF1)TBj zTq&95F)baj*W{nSyM);pnmOV-c2b$i5T}LF(cXX^6)#kXv$~XfClOd7Lcc|1al#9V zGqVe55s8Rtdt+1}BF?0S*q9#Cp1P&J%^l5UfJnBW(KbYn3crOhoBhTw*I2Q|6roEs z0Fxbw6x~a;o5=*fER(i|Ar%VbhvcyRr+~pqz;o-QYUa2jvALs}teK;_xVa->Sn)y& zAQ5T$9b_rCh^$=-wcx9mm*qV?Vbj$jQAplxEf{vwP0W;(e6KH)+>{STpS&FShXQKylN>Mk-;A zVrFoa5t?m4IZ2o&KPXnn(UBXk0F;|K3+Zi|zSQ4V{Xo}89ZIq-)Qa-@U@f)gW=^`C z;UM&uM@?^u@^2R)&_HM#W)|oX;kP$Ms{#u4M--Kc?rpcT>2L@VrBj{ zrIPVi`kFSHn|WRoYl688@qk<_ zzh;c?Ku@q;Tvq%w1#q^cU7VO{?9ca-7t-#6&PAig08736qR&EgsXvXJ|ImvV%}1zY zDuQq9f9Mx0rCmyV{NuvyS)-FS=;Gu124yQBL}NSyUhEC5G_sG3JG{`tdU@ZVGqVuw ziP4}l7tH~~$9@adpv{}L3q4^j1Lwj*NQ&Q|%P>gO+vO?p=tK}BKIt@*yjhs8Fq5?n zNT*oV+I%f=wE&OthL}HHzMb|AT=J_329DVGhVi{G?TrI(6{^RE2wW{dz zt%7#bLhA^5yy=`1Mp;-=VMgvc6tD0BpMf6e5zqh>Vb7-c*c%p*Rce|iNqsm!jQYKA zkgd9H4*r_v7xV$_GdfeFrpMFtpQhedjEV7*FQFY$3n@+=)EB;*@`FF^)$1D+LwmPP zX~HOt)qS@69Q1&N4x082vNS-8_6@?krc+L3I^e&}flTwf8!l$M65RvRz&DGYQlwW{ z?zwQ{u^jw+HvYZ>yJ;kv2mi>q_6D34A(kJiD4^VF`gg<8%k?FvYw;X*Gkt9wGlgDb zIvo4ovD4Cu_E(xxQ{XkXsMQGq&w1)TS2H}{Mc3Nx7WAbn=U$7Kv0qW3ux)^8jkG6{ z#(B?)_Bzz}bEdrxe2(WXMfXV_mnyI;w6Ugwv{p<`MKHtU-F;je7uHf}MaH+N_kk{3 z2^s;CnJjNq1??^P*Yua@PV28|L36Q8W6cXWjUWNr0v886sbJxBB~1i9EyzFW5aj;t zJ`i}>mC8vBSF{3M)AY~ftksP*_{+H2REN9^7Hi$(LWvHwrs<(Mfh#NhCP&n!ka-8NsMH^OkZ02#7T6+Yvik~m+Mm8Ww6TUsYpK7wxLh3*Lq5Ag zw;UDuFxbvybus*6BJEsj(GIeO(@b`T-zASjMs#O7-zH5DT6P@wQ@GIgkOz!q!Z-#> zr0GT7BWfIsf!4+mFip?KM>aNAC@_MuX923n?dkHvBZGX#2SbLe_);x8BwgJ<5k?Yd* zwRzK=<{9EHH8$6PPuoHDXkt0}E@-d(>nGSh#yehAbC=6z@ z&7@jt_>ixTNGb&7t0T4rN_gub(2!KS#=&?R40ho&`mtWaSZU4o){anRE3J7KZ41hm zq_Z@zIap_^Ut;j%dT~x%Uk=!O$+aVl1{$#ft?=D7cA2VgPy^bdr!EF0u{7kZcIghB zHBm#Hdt8b4Kz6YkyIwi{ESqP1H=&7aC6&)XPzd)rfJV zHV$m2XHR>}Ud-H{h5CUBF#A_Eqpha6(y{cU34vFSP|T#E)w|L@G}eB${=hT?WCc9~ zEx~YhQVAHdF#z%*$c9{naRxdQvpQxAVur+a2H?q5o|yDD48eH?IIEzg5*DtU3vu|bB(t5V7I+X%{}(YbeJehrHU?p59VfySbFB_W zoY`Mjf{YfFkrcC)AM`@GR_HBgcT@dlOJ^FqtN-v`(3?yzJE-2)+_nLxy@q)ks}0=3 z=BaGdXz9rQdXf*xCBUfTb&>u|cJW2wW9lnGUt=`F7%Nw?lIC=>XLW=VCs(!OP)B8c zCCzVWg``I2u^;*ntw}ghIoKcIC}_|M1)jVTPy^G>(5{di5ia22d!>Af;;}w01hR+q zVrJP{8Zc6dziGWk56MCaOT(^ArYWEc8GLA7%yumepR0F=NDN{!51s_;PZ6 z4J*xBfSxy{XoW(APb-|t2;Tr7TXilUVx%rOJHW?5g^?aD?Pa7oPF}nh=gcvZQb#dPyaMM__ zdpwM@xME@qc6Xs?a8qZ}Gj2vay3%vB ziH<^PXWsNH#>rm^CzN)ly73L`88Z5Xk|>cbM}3In^`LPTd7-&b4{CFx`kgpL?R0;~ zSKz{0-t{#^NCPyXfd8-bB;b$tosAfDp(l)>7{yp0g92|_r=XC6>JY3$(Kaljx2CX| z?I_ZPS&+zuIuMc7#YlVTUq~Z#4YLeu+exCWhS?m;QO5Zh>55(f?O0u`O~{Y8(C#8_ zQExY@uM_>--N+H|lE8G#@Qv0jYGD0fN@=u4=m(4;7~_RL!Z+4iZd8xNfZ9=OS7WVR zsb28myJ{UvE20as?8qyKK46?tX3WhjLMA1myZ|@onCb|oVVEvA)$TOX80DZsA;<~q z9TC1W)dUWMu`YKNL!;4AjNGvx%e8QoC>qzM#;jNmJtF@BroVBF67 zK$LsdrhlL2X3{8ft+Pp8z{jbkKGqvDBUbH8Zp{tu zHl?2j{Q~bn3Prf!ZoCIY(ch4!h;Zmd$W%*5@fjnfLY-FP?)%+L?*yjRB4|ddP@hzP zH#13x=Rq+yqwsf;2LGDnMbNyQ%_K!Wf|M%M5#g2wmYYRtROS!%of(Xn8wZtB&XU9q zzAO#`vj{UiqH@kD)$rFZFK({fUALiT6gtKiB_!?e7WCKBM14b1HQ)PVAl)crj-S@tuIJLamkK8?0TlcCgRr zB!;C=-|3ScO@y4A>I>#q`1fgz?`YP(AR#A#W>z5r z^`Q;z=^Qlhh>{|~2Feeqv~u(>^aGn`ZJ8-xB|iOowH3Sypf@BfnnPcMx4^Zc_A**= zuQd`{2o2wpZb=BD6FQL}t%QaYHUE|P)Ksj+8ZGP^8bUANe>PKL8d|XNB^uxk%9(nB zF!I=?c&j%Ut#S4QJxCD(%BbA7p?F5jEaGXLc}8oOFhq4A&SPfq*CV+?A^r%h5Mg1o zl1`E*us6&j&Yo#)g)}aE26tdaD(1v5N3x(x{aFsSrp2!*t z7AXO}JwV0Aew>H;Y4kN(p@@&SRCy(^cnnC7xeWdQNA9toNP(+z*tLR=e3XK3&`SRu zErOnNMbbwm?Hut`=g(1+IfMLUO7Di?oB`sAP;+MZai6+JxesdyzVz>FJ?<1 zKkGr@0IR%Rz`1ZhP#0g!fyTl+aGNRrFix0h^vYZvZC)ecQu=|?kCc{CT25&NrInO^qO^+A zYD#M;t);Y%(t1i8C~c&)iBdVG&6KuK+Dd5~rR|h(*Wpe|yD0rkX*Z=kl=f2EM`=H$ z1C$O@s-Sd;(l3+_Q#wMalG0I1$0!}Abb``JN~b8Drt~YNGn8=4_PN|C04NAXLx=HB|O1CKeN$EDFJCyEHx<~0gr3aKAQhG$`F{QsK zJ)!iJ(lbiWDZQZdlG5Llswo-p*I+PkW*l*XCq+dwu5>2O1dr1SlyQ~|Pmn9w4v1t4 z@Bxpz(mchK-|1Hk#|!6WR~ci0aCWT7+3*p%a&$jVjeOx*;BjW3i{n3AdqC6>%8S|( z^DIOHU{r<_3vm->$jXLGHMOhlkqa=nUo154h zOfeH-*#_+L7a$2fnMWz1QM-)e*1I5~t?YlqHcJd_tsQIX0#uwsjoFp+$n9Cc!%PO> zJFr>udbLe}fKiJ@jR~bCnxP71pqLka8H^dw#=>93pu)zpC7T?yWoMbw7(PilX?_$| z{ht&$gh?mi6nC8E{>m589e}|65p1Oozbo^-jdw-N3?3-Qt_G}0F{{u?g;}9cBU^EY zhK7e8>mk^YVmk@YMPEM~6@ifrtq3feZfSU+TQ(gQ4bEbR*H#fCe8o43VcB%pvr;8T zdmRKH0JGBB7=~H;AljoQ(XkaaM553*Xk2YW4vcn~HzF5KN`{@pPO-=EBZ*NbFj~JTE7$EjjCD2XX_+b^Fzz4=-v!g!joch2gM>z8KVJP@n?BiK5+~N zW-KrGre@8U`s>j|*hYZK^0M_^v;t7y)rw`)-}d#;s&fqoSW_+N2gGNB^F`ZPqJdX| zjm&62?07qh0xk6~^?!kNEqbcG25oPERm;BO>4j>iJgHEG&^DmA)-FZKsYBo&s7otl zhx4x};N!sw+H9QxzBlr1(W>#F;S?7{^|E=DnQYfgMD}2v2@&LdCJ7O$Lly0LXci|G zvvS~70Ogf|pGL###O%qM&oFwVaL#CGajAb%*oRT`qtf(^HF={JQ}002*&AmWvsHk- zE%1f$mK^4nGFtxhmhpW$MafH(G53_IQpm4{{LVa!IA-}-U5EoAUv~|i1=;_P{-xDV z9j)XV{k*LHdkUx627E)~yoRj=0|V6D%eV^mD{%{W18<>4I&dZGGG9EwTaCBqNoVeX zu7V5D0zlb`AqfO1(1eF=6kU;fU@6PJ&lSsDakyNGG5!Q|$oG zA$>Gp_h(jumB-N}xU7~cu`x>IhPBiz{t9~8PqzQYt_3?=@umP$03_p&lw z2{LZwWA+$%N$EG{9&|4qpQdxnd!Q4vJodWbra)khIcSnDv>4Q^8{iT~@j&#nQa>OX zQDD+!il7Q(nwR0@W-K*10=KI$Uj}^YNxB#<#eVbMXw5*X>=w@YTWX|8HeZiUe6JHc zIthC}7?iF?D6FJ5BT3hs{HW{~-IAj8;$hVdP~oNpSBlRXOffhCKA>i5KmbK<^d;P} z2G*IVhFwERx^01;yc?~X$mk8jnMDlXrYG#1du+V*;NfsUY|ST8j4NCnxcx&=9b4u4Y*HqGe2wyldLD&^1u(m8jgV+#*M?xDz=lJMk78S z5PMF7>`cjRO!LjhVM0!Z18YSYr!VOn)YxZCC?ZX_ue$PSJ}oe)5@F zwQ~xy3r=TcyiHPoc`*lki&<}bifKpeEc5cCE;}Dnh!}TR-BQEI$nqGvQ#HsjZ$Y3( z_}h(G#a>0|9rSw~?fEvBoz3kQK5Pi?5eo~fmpmo+gRpGPRoXR% zThlBi(C(kn%|q`QZ%9LXu*V^VmEA z_$P-XhuHam_Gfc{p-2yCIkL)YpbIhGY$U+9+WRG}Y43{-ZyIqg@U}$|blB5q+%6hD z26n({{x<1Frt#P=UFfDcv@ZY=r}-nDRBgX;%KJqMaBX z);!y_W@&&o`I#Q~5nyj~1HzxR1$&^&W5Sgmgjr13oRy~EO!x9tO~me_L-CPv6-C73 zTXDiSbPoq^)*Z-WzaHDq7e@QCa6i~S`Dn6oUr-z}g9&Yo@`65LJNlRoNz)^@4gY6| zL!N1wu)zJf&a{`Gw1IL^hJb^U$}8$l7B`J{l>{D(Q~11`{0nJptfSp5d_^}t|EXx^ zxFYN4tgyi5j#9&Eq95)wJe=i4t*Xe!u62~WOKX7z{E94S?E?Op{#s@Ou7co>Q$ji?iFMm0iV$*7Lp=;1*36@HfQYOEoQ5H`zid{r0?B%V^Rfvh%N%Dkf z6KB$g_*+g=jk+0Ba0q+b%lOxs?_jiF+7nN8DLPI)5u>Hpo0=zy0Xz0tiJcT}!{9pq zndR@`jhOJgfjj@GO`G(WAkz_&Fj5*m#lzZc&l9}>}ay74eu_4eUWdMVYx-TZ&4nU7jR@24}-|w znbVzcBEB<&;kSbS9qWO(PlV}8XiC@vjMvz%l@Z3hfanQHu+5S*g*4j8as#K*U{jheN$b*8L{BZjeo=klNRiqzr z+Z)s0nDIT(4WWC0u=f^RL)PZ&bs)^%sw1+Mn7`3T9dgY$^JhGbnGSG3jo5i8^rcWb zO^;p_Msd)G*}T9K)CHFK%e)sDK?$=Otlox&?|@Y)&&00jxbaO_T+N%JZE#qTuz9~0HD_nb-%jnuo8f2&ugvXyO?1C(byBD5XQ zkjo=@DBVzvZ>U{(%kH14%x|wl55mHdXqq_|C*a0+jT-+pcVwqwfMPQ@;}9@BTglkK&Xia5&@qkQ%R&$OFLjO`M)+p{=G7UZ@dr z1E}Cjw4yt;0y+cOGdYoHm`2$tH{CgC+L{_IL$fs}l^CH7yz9}tQSgGXwJ0HWsa`UR zy<)q9Yhw%>&Vzisz#jXx*gCn0k<{Qm)<)^iPv<8`U=MDk+?8gAmWI*XhmP=nnadO* zlOyyco@5_v=3(UnB3j`QY?mkV#Wz^;PqgoXJ~LQ^435RjSF8s#LX0ch-6x^bu4?bd zrtii*?Xly^Db@*64nD-H8vcUI5g#B(i`0M>Tll}>>rT@PdRe4LZ8)ODvVGowsV+`3 z>Y*KpqK2?0UG1*DreHEKg7hcT{lf2e)l?f6cJij_O<{<2cTC7Oj;a_*z$3z_db22k zcI?3eMSEp}Z{`6aI|D(;S(z`Kgdj-UyUH-W6YImcggBcd-TbKLNsxUuIm}W)$b-@kjeNlcz=pyC+EeH%h(~@iydzoIykBRxr zcy1ePF2)adgMGg67~G3@BR@25ZiR+o`<@VMD7*oF#v6i--$&41;kWrh7-NOn1bvEj zo(Wk+zS!b~f36c;%tkYzyhMZC!Yl>8T+BY+9?<}o$!F8Tdt^~dG<0ej%@gVV)S7)< z0z}q}s0pLmp?LBX1tsZ9=P#Wl(Fnh2&SVmSQd_4C8m=z^PG1$3`La@Pj;`=EFl>ap zfD6qb)_{`08Bs`5!vWf7CFB+0ieF(Jy9OQ3w+_I&sXTDU$jC}mM+hY(8Wv+{Ui0N& z;V6`~;oq#6-8k!{noitO9pMtzC+KD#+UpY5HsEHSJ;mG9OMaZizTqUIF)=jHN@h9i z@{%Z;%3Nmp)mH5i#_@X+$HdqhYH|g%r~9Yv0`?^0bQ^uJI;4>rU(ha$rjSZDN?`|? zpqEU0K^UiYhF9qjS0ltkv8UOzvl43~rnXmqI`a-A8;fnBd%JmNZ(gD~RdM_$!N>Kq zKZE@JSgFOD31)EcU1IG8{aqa)LK9^X{UKm0a602cCWZLs%$Ja5U?yu?h;tC)Q(b5c zqhSc`>#fnZ&m7hjQ!eE}|lmwm8W z)M?DdDB~WevsBPy*~++h_Qd4lfttP3mq5$ZJ}tzwS4Fxy8&{uO^GkeqiXD4EJ}Fo} zybpWt$;Xkeli38o5Vf!|CQXkMk@^Ph$?O@~Ohp*-TC(aY}K2pwjY7?ZFbmsS!GdyM=NRjJ1m4s;R_s~fi=f-l(D zxnym?=Tawof>waez61wN=W)W}W+NPwMM-AKW6c2fRBq<4Xvfq1gpDo&Mi^Nvf?AA9 z(gEj>eYNr*>6|lI5#4i~NppQ4=#ujlov_z2L1Mh~d|-GM`OTd9jtSTu2caOPQn8eMhTKq6H27%HId)ay_WloIAIiFcaB%)gW|voacYPJ=*%^9#Lei; z*96R^2G}|{r4g8hXE^f|Lr*s|f9(50jvHKslMJy#%vyP$ZcWF?hg0j|D>|EN6FQL2 z&0IBJpdzE)zD!UKa~&s@DSXt3IshGKD$+bhEqRrmMc5C^+-tso$+9AU|8W?8?*p2fK?7q&zXNAUp0Pejd$mtF=V%2;#5ue z$!*mV4Wt2m177YraVeo@U)Gq?qpzhACn;KufG^AX*Qns6Np$C{D4EiF=C6bg#Hjw*&1Qq#KZR?fHJe zh%+5h5A;8qdpHhl zaS6lPFKAbthdWl`&&GHxAw2QBPIgmY8{*V{Mc_(n{xh0=3R(rdv6yUi&>7YR9u1idmJ`k+$Yn9eW~DQd$XQ`8Br%(tf1Z|G8-mBw^Sqqb_v^)iM#CZFRXuw?v`>kFSG}5u@-@w za1mWluKip)Cm1ks%H%?RE!n1M74z}|-vZ5uC`cQgF`jux&kF2r+@HioSf)36hI{kP z90je4bC<(2poMTBweS`+5v0JAa>vo_=~4sq6@IyL_yu`jl6+6`8TkS6jI(E*`CBQ2 zGGJ-Lx@+e6RS|=anE>mfARp*P)QlBD+kg$^=Q5L}%h_M7h2e?CN>%1pC$2 zKt)T@qV-x|#V%H;S8Z=yYOA7GEm{|<;>+c=NRgs))x5v&b22FAL^2mWy4>b&?#0^(YNx__}H@L8mq_!`$MzOJAIGu z^`A0TV+Vc$o^GF1-6Gk_YBas*Pl{IeLjFkYr_6JVR#paIX39dDEs`;19hF3fU$}tcwjMk%8LIX&Kun`(ln00Ez8r^+0>iqAdZ`|N>Qd+h0!~R4M^E~1hU4d zB_-LkBG0+ut6ZZ`BO&+_Y^eWaM^EypVeM@Gc7=Q(ee257N`#e@Ee~kZ@k+D!;O05U zDv+@L$Ps5*akvycyNB9^weKeuW{KLwD58C@&6%H5K7x4Hy)}CNd}c|u|DvZkPpT*9 z!IL=7S}<|epan-L;w1BwLPbiPSHQOhEodhX1MhhX6|ELbS2}L|^qb%ZRs!U>%skp~ zw~R2?j3eDM;H^`xns-HPxQ~1O>_Td;Oa7tpE^2G*FMV3xEZs74tL_^9;{4LOK?@k+ zd*^;qgw;dv)(jI_PTaU5Ag(oMI7plQhm^)#+QgE_=Gh4CI)QgZQ*G@+l4Klq(hq z)a0surAAfX>g?VR6^~rA3bn-5lJ%N%5O@(%tWO(mXQ^LuSxVz0@^Z8kp`K9-X>;vT zi+zdz+#uV&D3VU_H) ztK+u}U#wM(z9rI9*9WOEC+$AGWIW>%@raRnkU)KuX81bMYAjvm<;;De_ZfA$?q{%G z>U&Q$M?R4gMh)fvXBV2h>08IQMG$f6 zmIc$=3+q(YNY~c$+S~={MI$Opri3jBnPPC9wnbf1N+hgQSv!&Yc)wI5^EzpUCGl0w z8ak_pI{Tw)4Y@Nl{@Cc*^Av|>=C4#U)bkUHL)|6+l3_1uBpO-Z=YQ75s+A*)UUAK3 zSP)Ls*kt-|X+DAx2KsSCKC@mZc2{eC-7-<{i@oDc6 z{bH7U4gJ~C;<)}^?~L9V?7@I`p?&Vg*_MZM^9IMGIb)?4(lAm9l1#`0kZjkhCvFs# znC;-_lz}RLfQwx4(%oS?d?m&Hd41K4j0%`cI8S5`)72Ps<)jYe9{$hO( ztBO&zI{&vhWmlGGzcCYkQslN#L;VPWjAB{^OFt>toq@btgsX8&EDE@f8c&Q88)rz< zrw3xiB!`Ubt!w=QNsQKPDU%Z@i9E#IC%Kq- z)Aa50)ISxCtBK+^vUv$Ny$E^5%RF#E(x4>~3*7cTpq-?MdrmBtHny20&$p1|n`)@Y zCdy57Epson_2M#CA+d-z-D|KHP`znxJ%pq-Td1MYI7Muw~32VuHWXgt73Fo zH)5Rh%b9t;&S;Ik9ML*qHTmp$l-|Zqv`@?tQyLj>PAX(?bBmnrYm2A~zP5e;qHhr8 zDvl z-}On=CBw*hdJ#EirceDN{US4GEtsU*Kn_lbA(t}8qb3I~`Q>;<)^&=EL7!oM#JueO z29v!~?eNuz7lgF^%iD|efxk-46f+G3H`VFfwt%PuB>|+TrJR#GIyqpo$ z)imi=)l0(fW5Ib^eiR?5240kGOfEiwlEI|CHx)nDryU(I&!Yt?npc za#{1*sLc)Q86B5VmljDaW;M0K-P0KPI4URj5f^>y?sQ#5AV`QY8i({slOCscpo^e? zk<;4TALW$D9*mUn;soqdNmK_z#|-xlurL44Dtc-6zh=ux&8&i4bo01+$_Vm_6lE?3N!fFDwt&SNL%4wV1alcdevl ztbsKZA6%V2t+@QC@qO~+(ykAZwdEc4s;8$T*?h0=1kD?HqHFLTad$`y-tyYeZ%u4i z^4Cekrsf##Z(l=e&3Xc46yw&R);Q2v)T943T{wANDfxzaG2b!x_wkxV!S!FBi6ll# zODeprV0w|KTFA%!?De=ngX?S8#%bdLe0!*PT8~D5_mirZa^8{^?8eF?cC-V3FZw1N z?rE*9W8OeZrgb&fqoF}Fd#v>~i`{MhFU|Xhyg-(W8-Ic9I<^O(wbBpDa$oSj;S!-4v-Cq{!>ki;oyiI_c5KCUm#Ma(=HfJs%gFy6(>`7wF<%n7)vtfA z?8?65d`^+*cosQM>ucoIXC+|dJ5>CPGEkOum98Y)ryzb1Xqh|4PAj?~*p;O=H?i=v z5@sL|&xihG zLoQ||1wMAm9r7SKXeV9kV_IFcs~o3Px$7mx$X3Z$v3Bq3DJxe)VgD)I?Xln+3*?)t zQjkA1>ZYyb%d8J={$=Ki$nmVQ!D{BeMV&MhKBd)EXo9f%hH_!^bg6SBdF%be<-*>T zEPX=1(sPFqi`x1T*KUbpPs^Wj-Dr4dmR2nMTxruw=;dL5gEOTx{G2~b71#hCu!VEmo`HVqz_4BW?e+P z1HG|3;NO6615Y5?kVAW$v*fb(=I>6A;H4U`Ngy&IEjYP;sl4(IEXP6&??ZkH^OF_N ztMB~XHD!LkDAOUYpYPAt&u5IF9{u=Y+`@cBXrZg6<7+;{WgVW$ndmK>%V4x+EUaFl zf%ntz-_dx>+=DRyDaSmf{Zq(1HebNM!{k5OnQyVpztK|83u}a#MrXDD-Wlmdy)*EP zrAHbM|LF~8;T|Rl@I-Nb{8XuPv`*bJ%$tby54<0G*P2i4V)+zwZNy7IA%@3(%lG^` z_0T_wZ`=B0@1KMn%6MhB49jEhjPA;3H)#tkuiZ1qX+?J5GS>gnUyxPvONNy(UNKtv z7BoODi2gQs*7Td`!DvBd&ohfn`mBU$r^ridy=F9H9?zeMZY`x2@!fx?|soJ4UB{Ob=m3PH8KHt^(+^MO^9(i-ghQ*|B z_haf2G1ZxYxSrHG^@yAdKhVmfQTNJ&d6?u1cK8YHelKeM#ar@XSuIM>tjv@BSNq3k zSHYC3%seQ-JGQ$@Rvfw9^l{_H)1PQ#=p1~VHTUD`TX~Ww7xu_g%83AIUOir87T%>N zlGCUs)#U6O;%6G0?0)XZ%cUL4J^+1v zpwnd^xB9Zxnk}=x%HXmdjmb?OUCe5u(s`KAPwL^nyUHbYls)HEc5kiuOqEYYk11BZ z^dibqzepQPl|RzX&n3qGnoTLOs&7{t@->K5Z`=+JCLLQa&iT7&)V%K7_~Yon z%Eq()p2>skVd!btJ!4WJXH2;%CYl*&p|sPP!QbP>`)#g51V^}6H^s2-tTh=(u=J81 zvS`j#UuMl-$Up2E#$YHsKY4Q6)7O=b<42G6BfI&dtjp_x`)LsJ3<^ky%>p-_~_)i zI=^rfIvwNJ;p`dGko# z@Oh`4p@$+I!{VU%-DymJo1&d&2VTi8$=KJFBT4lfLwV!r>>|jUrboE3=(&mHls#5% z9?32{^BO2~wDgHFD|%Rt znW?8#^(*zh5~GH0&*qyuELg#w^kz$WIwwQ3GVA}QP4o3SNIOqjIc=vWYvXQox@_`c zPO-YKDIPq1%+T5Ms0T`HEA`JA8XLY?Hi^IsYsb}or4~!rS~B(Jqe}ahZq)X4Grl}( zC~IX{70s+TEElyDS{%H;Z$&mGv63|}jd>>$Rf(NgZyp(rQMMk9m3AZJM2A@~PZhr^ zEqwmtf=@0p*%F()Nj~9SabDdI^M_SN>#=rx&2wJr)%^Dug(pdJJIQ@89}fn6>EEAv zUwPGRAK}KnVb}Z^LiZn7KazU!vCiR!7x>g+Ndebl>zNNKE|C=a4|!uL^2_z*GUXz}jdFeT|3%8xa78;g+nwWv z>gXb+nj4hiZ2iq}hbe^I;gSfvgm6YgLa5HSm1miGmkh%ye$ntPm%^279|fbP@e};$ zJ%vmT_G1|GRTn(c9W+leS(vkx!gF74VihTE56YMilRd7JW}abYHyMr+RBv1qS2=e z3#D5ex8ytDyH{F)tNPggboM+; z7df98ITbhEU;Wn+HW$K<%6P=;CnOoO-}tkJ!Xt)f=6SBye5CW~Y}am2jH2f%ZmQ{{ zuFX%&MG`UzVMx(>t$eg^`e(U=eQy z=J`Urdv<&EIYrK8Zz*X}TYQV}_@|7A`jMM{YuppLIesfmaoC3dJnQeP6Mg8kW4T2viAL*)+I}E0+a3YgTBF zgUkiVWkVLHES{zLTte(M?HiGxwXI2u8S4{b%yIc^YkRN=xe{HX4)Le2uqkH!Zb&)?!RLJy;P4p3p82*!&c6TN<7Ax!!7Z z(ZUm^++TU;Ow;ADD=(S?a%}cq{5#FQWlsj4h_VxG&$c)EC~6YESs%#5*yf*AniNrIX%=&wwROp5#Q(8fD=Ck>619uuB1Iawt+F;3zUz0iq-3{MZi|nOk5uFZA~@AlV4pBM zBen_O9z1<*7l9#x7TmZIJ(+R#h~dG$nwUko%_jMq@6|CEih|8^;7)YHX7lZ*MD zBGIX*r_b``X|8wo9Go>zrnNp@Yr*gpxJav!;RkO>J3w7}4(#iLHHQ7CC`PkvBCFNP z*vF-!u%U3{Y-UCM-P8rdG~;e!ta}Efwwy(Co|c=e?{FO*qNQYIAgyN<2cMMEXH8XG z)!v(u2GO%I+p;^h?!l*M?GZ0*7vG#?_oySRN%kcoV%eL%?V^){kNmj4zaoy%kCCKU ze`Ni?tL$dlRaus(?irM4QE?}Y0Hncn{vPj$k0S3LonuEnVM>QtH5Ek6ga*)2p7fFe z?$;Q`%2npr39-?|M^q&!s!GvlWcQcr7;m0k7`|crleDImJS9J}8n-uFjeEgWf z)7g0#E77CbK8k>OvJRMl3^{4R!Sb+Yzn>1X{#>`VBAScZT)v+_lsJ)=UeHoxy`V+@ zx$aKc0Be^HludvU5ekxbcvEymi=}P3q*VP%U!O5gWkC->zbCq!pZRA65qD_AP*6JT zCe8RTou4RZ?xPsAcx9K*O_R3-J-1dGUEk)C%ZM;wt=lxIR!`^?(H(LRp4_jLb>>hr zr4jEBu2^b*th5*)(Ceaww<)6Ra}(d0_sC2yrSvO#Ks!m`Z#W@)MF}!^*n6zEQt$N7 z^A)G=+3}OFsi~l>Xw@hiZNt-!zs&0J2yaPWBpgc*u9sFys?4~!PU^QQW@>dlXYh~m zx&Hne(mbLWB<*L%uMJ`pn)Zh)tU%|B#+8^QGK->@`5t7}6zcpZ#RG@mUuk24X{`sx z(OB`lixxgIlMxKqR zi0Jre9{HqM+!kRa$ohN684$GLyKLj zR!&Z8d{aF0w)y=^*Ja-)iA|hn)DgQ^>=2dQSW-r5)OgtDqa2p?IyTFoj%%~gn!qFdD(Q2Eh8h)k zv8_n=zE98>9nVw2Y7Y8DN7*#6U8KfW#FK)Z;Z43+IeBcY*)y0IAiZGsr%j$3XZ?9N z)3JRt>EoXBc5*H6K0H5J{F-tnDaL9rmzEBNb4KRm;!ZQLZ~NS0@11;&X)p&Vo`!j6 zn6`=BhvTI2Q(_C-oCt{tE$RK@{?7*-6kQGUqO3>}jA_Vs$K)~|4Ae@-gxHD0SIJ-C z4(&$z9dRivA!w>?6heN9QgT}f@)PSFc@m9^AIE5Oi92|baVI!rNCm%+)>ENhX*6ZO zOBnMvqru?O1BV|yW^CE_CB3J{Zydd~bWll?_eE2Qu<0X>?ppq1M%Ho$&q#)>NvsZ| zQdY1ATC06W@Kv}9bg|r|#?g%u`EQl-fOU)3Kn-w({zeU8<*&`fgUs`+j|!qC<2zaL zKC)E5t98NF`u9D@9~JCN-jFq;-#l4sU^dimSWKDBCQHeY=WM(rCbpM6^KYD=bAPaB zob@}}OW4}*S=hP7(JEy$-^w;a|D8^&(w#v6OuMIV>_Fliz z4fWeJMIBgSLhn4&i&^W5M2R^etOYl@_ctL>^!r5w+X6W+Gtc)?t|8B1zZx*XKj}5gL`Fe7 z>f_l&%VPau{7Ag$sCR7Aio#ZwU3uA6?0aa@#OYP&WlV1$t(WBDvMZzMMeF8xX`sB# z9}h}Wpr%setLJlUyT5lHg;xgOMjl1_Yd4x*y3_M}4C^6Te`Z;+qAb2Q-K2=e3$XZ_ zwa@p;oxdNo@oUAE(n~I82RPoov(Uef@6f7}>$2C>X)UCEP4X<#8ZBae&NA)(XnRX~ z`2p$7N*L0Jyt3nXLTvoV9(nYgPwHpS>mZxGr%sZz25C(mG>mTWZd-$m{6LM64?M7+ ziL(>?s!8d^SB@HxPcF2wwC?p0+$Z)r@epkRC^~-q5xB4sP3~@+&w3yYwDgFO;FKiL)WsDbUhHR?rx}6c^ib&17O2g0cxi@~~ za<@v+{jIezPu*2I$lvseoci~II1Sj7nHA$z^WFkQtk*GZ{=Eg;^Emd@r7d!QTS@qw zXhPy`9Rle1&8QN3r&j?Q=dpGc;YkO`MXH*T^o6WdLu7gr1wJ7 zlw?`%^APQES#h4`{Tt$FKTFElO@KaLk$rT2iFA`ZK}kA~D$yReX^Jvcp8ZKMDpP*G z<8wir4xba7QBVeCL-Ro(hMcuu`Nz#z9B02_MS%!x_h@r%?An!G%|@aw_RZ}L$mY%fnaEtfR?%O#g;0xgl6zL(1gJtdw|+bG9Zpug<@Af_YAdj^4_SSi{s_FTSwqkbkUOd zP|E@3B?pY!wBY7mQ#N}=axo1tEzx9t`VYS6@C9^jVKn%27~#+c_86^l3JuvWTT29fFTN9dkG z#2aMPDe`>6v!5K2j_q$1et|reYxLoH*2zt|Xzn-~p->x)s-)m4&)w#4AbI+y zlZ#JQ&7^4`2vV}Wb5_JxO=_P%b%Bp=fK+3B(WtMeuu~4J_vuIPz(p%0$5PUoicax) z6IhE%zL7n6J39}*Ex7p3-UB|x@VO|b_OcoCr-72qlFMkxl6iyikTKD`ZTq{zovyK~ z;LCNl1an>5_tXXC!o2rL1%1c6(m?dBmVP#fw_#C5kOpkEy!Bn`)!I&C42c)HBtf3F zeXEhgISV-<9A%E`bwyEiKAw8mp|MJ@zjSK$W#wFXD(6)BsQB3}Ga*J*>W@5`_XXNP zxWpaB;sf$G_jMG_Rdqhzjc}FdlW1e!({4-hCvSS!IFj)eM%KqAwNe_{93;00^%uy(^% zZa|H2?ICL}wANF1p)V^t?0&Spx3t{aIt|-v!_SSVPqmn)2zYiDjr~|Y7i)5qD@Mz_ zKXM1Jh~|LUN11sRM>H$$M!Utfax$6^WbkOov6NAIB8=IaY4Mh6T9g^)h0X7zN^{!$ zd+;B)kEPUqNB(vik7y=Xi59@`KMmsH}F;81GD1nQhrQeNtPFyqM=}C@@?PjF7R%P2MRBW)5Dpurl&Do5>ziV6 zwfu5JVe_w&&p4ZZfm5{En|yk;?S;hes`Oe`KZo|XXZ6`>&RYZ3|FYBL%ugDd34$023TVyV+= ziJry$V0XN1X#t!#T)oFyK;E#HXlpDF&h_PFt>xCz;_*bkux}*F@0PW|JEve=lDZ;} zJZBsuWp-`soy(}zyjFdCZNB2rtkzM!oh1z*>xij!TiF2rR=(BmgB2WQN9wxy&RZ#V zSN4`Y##)&1&(Ncvb{qZvwvvrG?nZQLrk6mzHV+eIVL$Tv8hkH@uUP$IE8?wMSs;FT zL+BL`XE9nM@iBWjZptk~&AaW1g|7`Aw15))K|3b5Y?uDImiB8O9ns&fsjze`_M;!) z>CI+k4xdA3Y`hD*+p54GZ1E18a~nnrpAK_kOA9IcM>$((ZW@YqA+(J#=R!V7h;15L zG8b9ryg1`BltYWzvRo~_v7ij&0xJ;NpW@W|Xi?#r9frHn z)HpWLTgzC*d-f8IrSV^m=c((m-z-Tk<1N;~@Q&sc^Me@FK|HW1ZynQ1UVB=-YUxo{ zf7kyV+bda;DXGNr@U3I_WX9U4~s?v9nYVl?b+6ip7I|XYWVnm(1145n~x^^Qf_qrdd1Dq-AgM z<1~Ib#u0scuu+aQS?^~WXzGa(^Yp-vkP@1A__6XnoLqQy3A=A8D*}#RvN?zTLyA04TQmDa_%wO|OO^dKZJ!LDPmF!L zCYDDEoRw$ue%D5J@8nu)Ac1o&QIVmL_g5&2*7=H3%n^O=JK2l1H<3L@`aD}xfxd#3 ze0ak7vU}epwE40FmsoCUWCiFHA2qDBZm@LO;c=MD^!%+ONR|9q3}ie_+06lOTC`=g ziegAUcq06oz*g>7$s~EFtD#k5I&qp)v#~WIi=b_AA{9EwIa7>Km!oJ6|v>bH4P4SAG z^2O-q*CP=DBjme&HB$83Zxk_J`LBjwv~`-S{radN{CZdtG(FABOg+ArL(lN3(cj=? z?22|y@pCgXgPp-yABZmia%y@J+ykFu=3S(~?y1blc<0%L=|xy-(EHfDEIP8$rq9b( zJUl%STH8Ae=S^t~b(K1_c+U&6sb}t3ik2SVjY_$U$kt~WbIa$Ru5;tWlB2ZKZI&Vu zA~6lq)f1&DXx?)JuG4s-c1UJlPwi5wU21KekL`wf^T@SJ@tvJrKC7%@$r+;#sCs|) zHJ8njFH5+e?{#{Y=NQ-7bxL-a2c8)pXzi(gvZWQ$U(d{ou8?I1a&o(K znWXRCQO*I{&o?j6N{a7O6DMuFNbyB481+C|r+Dwt2T01lvGV=d{(Ny^*T!`voE=cb zb#~TRT=nAbjfq1MjtNSQV5*aa-* zV}@hBVU+C}tXu6c{LXfiR6ZH)BtnC`&U#s|>l*DY&}xlgBlC zk~QL%dhI@^PAss`k~&g=Q3Va5<~C|)K8jYf_KZH-(LSIMyb9S3{=}6OMSn+Xw1@@p z8Fm7Fe|F!>Kaa4rLEh42KEL6bp`^ysH8P$qe@p(ygZQ*1zc1=HbW%y4qG3l{g+=ej z2c~b?kg0fb<6m}I{8{+`qG7X|cBB80PmXNF=_Fs2W8FCy|3+RB=mmYt{6Sqp{oiGY zKy$nEJQ2yCF#!8SOWp2uJAAw?MN7%_d#o2XUv;cY*ky>89@-C(4!brse=p2ib}56H|5?-7TL993ldRCk|mTInE_nw`>Y-|w0V+j+*MgJ^y{|H)cVqVa$?n(9fr zh0~|C%6}B!#@=OVG%rur#=;wS_3l%O-7+{2A7j#F?{WV=V}J8B$S-5s+3v3+ZY*QH zA#KphqS1E6Q{=?+*&#*8MAoj+haWF$&S_}V{u(Qn@XRBkmZkJbRge6cMccD^0uKXX zAHly9W9AhQ%};3^>K)UEKsv=0QOC$4`zr^J~Za3u2uh%oI6ZN7fuoe3nP-98!0RkmJ6Q>uA_5z z3U>(~3Y-73=8=1UylD2M(eZEh-9-f*bw;0rN#(~Mf4n>M{I9v8=eKj&LgD!dZmiBP zIM2D(^u%Fjo`0kYnV>=@1cju4Us4IXM{0WKOhoTCr$dm%RA{+fSl!9FhlGcPM+KMS zT%xc=*eX=08%z^63!7g1Iak0zJ0~-O`-1S^6^`xfTuc}#Tq4XosEz)uhqJj9=vgH^Bs?tiIoP?r!ZcxqaE&lixJS4b9MVSr z)fA74jxbl~eVB8Z!g<2^!X{y}(DU#%`nM+fL(g8qk?GEL5e5n;2^)@Z zZlloQNavD-SAVbyWQUXS9vdEx25=QW&*`*()&;!Uv=G= zOM{NFZ;IZ+THzI;PjAfug?EKb!e(KMaNN-v2ZUak;se56!sfSLc;dmI-da~Rd0gIk zC-?P@pq0@2U!_fc-h8^w(C}`dr}}R%AubFP?iTJ5o)Z2jjOpWCk#N0GE37=mxt|GJ z-g@bY)jwTUS3S8j@BEXG-9dj2K5wqt-rIS6fw6q?_2N21!%f0wVU)UWp>Vfwk8rQB zQg~fh2eKp+3RNO=weXv_RrYqIzz+N!pMHo?+P=7YXq4ZT~8rb7$KbB z-?SM=XM)Y>2Y9~zz{3>H!bIF}|ICkzml3bzWAPH^rj;gEr{ z6$;yhkA-e0w)Ouni&O8P7Ueoa!>wLS!YRV3{5?f~g^|J~!mZzyZ-7vEYFq#RSMUGOaEuVU zM0{TuAWRe{30DbAgulK*G^ZxLxtwm-f?6v^UAiAy{8Q|x_3>5OM{Nu5i8ZjIc)7D7-6NGD|ulp^g5nhyKuW zsc%uxAWwyo#p^g5nhyKv>RAGoPN0=+D5bhKn6@Dx9u9l5Z zm?2yvw9&uy&>wpKLU=%UQ23qjxbTv&R_Ogb(O%dhY!!~H(Hv1w8~D$Ae^^_2dH(+M z|Np!)^Oqw#?fonNi0p2uJN@=-L*DxnE%j4TN!6u7%Zb7yVT-U;IOIC%{Df15A;Q%{ zg|J$9NNDT-t*8G(&v&7zFnEr1eL}skNZ2B56=L64zZV`B9u?Z?-+Jf|J+}!T3X|qa z{ujE;lO9N@5~_t|!g67w@UGBC|JFl)=-K6Z(Oc*%94m|wiiGQhT4B5Ju`po1>_9>r z{aX+Hq32D)QemC&rf^WLWB}oO;X>h7VVTfjfz|>DZS-$F^oO29gfoQKg>}N58#E6T z8tR-|BD^lF6FSyQ2OzZ3zxB``dY&SjD%>lq6n-YG6QE)`mq3NHvR3DrxSs}WWRcM98t4}~XhlrOMQdQ%(!Z%ymJpl7+TRJc`G zcC-4uuu0e~^js=ifN+CQFT5?ZjsLA@{D+?J2!*$3-X}aDJScR$RkEJ2NN5n&32zEX zKWw9aYwG{dv!gIkm?YE)bA%1TM&X5J8W)7L<(d}?H9}keZ$14Vdd?9JxlKBMVUw^~ zn0UL|gRuO^k^zNrD>U{C8F#eNzcuxL=-E@aO1N6+c>jP$-NM)(LM4ML$s+5Yq2z zqkn6nKlD6OnD`&+@4~&pN@4Kb(hCR`!ZhJ+;T_?)pSIDzHPIh>4iNg@Bi+AnhH#cJ zQ>YTU{U7Omgd$;_@Q~0p{Xp8^YN+O%O6zI8G=<*BkdSR(> zt5Eby@q6JJVU6&y@Tt)A0qKK;NE^*Yi-+94=5X(2-Pb4fG-A3G=vgb=BrFwP z5ndHG3hxS=gw4Vh;kS=yjv#D$t;zN~^6dUpd#d3t;)(q?y0{eR`MB_; z@RYDk_)xgyQTcfY<-+B{T;Y1*L1DG_K~-_RA{+H*eaa=8;$eABB4Pj`mN>w!XlwTct%(wJTGVr*n|Iv zim}^rd=WjOHKnotdiUOI$)!Te7lfCDJO4{^pm4Wvk8rQBQuvwhu<)ob?02I5D^L9V zhx4XgcF6_Jqdq`&`|aPpYN_)jAML;f?85`^tv%nhI^o?fpr2*s?_)pvs*Zs!1$qt_ zs)ZUM=`q=WghfJw@H62T!rQ_-LdE0K4+@*!c;*q-e|^>WfB9$ju4w#)aAp>bzp#J% z$-eY|XxaV=`Thwf34?_Ph1J4#;bWobNsR---NHRW+x)Nf=zpQ-lfRejClo#77D|{-rXGC+Mw{U|{FSOCW z_0S)Bt`%MpYM&L2g?EGv!ZzVU;p#OS_l0+bO`y&G(|YVbqG$SZvhxako|hkhP$SF{ zlKxw=pwL%1R@fqJ724?Edip=~eDVd2|3c=AY6HS=h2IH>Nv;Yi_YLKmT{&`szrd|l`veA?L9c$EJ36uu$!622+)7LFFU-}c+T zH9%k9D)ju5>R;F-Y!(KumH(e`gHSI#EIcaoeOY6H!2F-}UssJCcG|IY>|Ol6Dn0g} zsRM}e#ZU*}R~km|{xfviE)`lnBdih1Uy-g~s1mA$cZE$t?9a0O3)6%dLX`LKg6zAv z^i^pIZTV_f@Vc;0h`lO$3%3g^gbl(*q4#T&5rj7Sw;uTqdY&VkCtN7R zg(bpG!l>6J0}3mIJB3$-R|V03)m@8gCKqK7>9>o{9=X(-`kUGJLkpp8_TAFHzxNV< zQR(dcOM8F9r9jVKZ>VnzcMJCjUDiof6P5|fg-ya{;p>0V`X8aG|4$j0bH+Z{|M#AY zFXxG`QoVmUCEoj2w6&k;StN`TW(rlpox)wh8sT~2k8esw5Vi{2gf{-)diX!|9Qc-O zKf*F$xv)vtES&PTe8Ge*!d4-+UUGp@FIXG6e){G4=MU=Rn*aXUQtg-VeM-DL3-aFk z-B;xamkKQx2@eXZh2HN-#uH`=Rl=$NSH6Hkl~64#5*mcS{`;dFW=$L&{|DxOUk0VZ z5xbqsKEJvA`|tjJmkKQ(7oHRbZ&05Xdj5}O0^t?mRpDddQ{nE7k`IIrh0WUkVdanN zXH6PC?6g?(?%(X}-@S&v+U0kt&~m$Q)BlsrS6D4PBqY5ny`L~Z7%1clBZNglgV5Ih zaV7q0Q+My9jQYQ5`K0ia(DSeA_d-lKP8cGbAq*FCg$iMs@Qm=)=>Jg<8}w(*=423P zKU3(R{r$aDc+V}=r9#U!LeeJnccHhCDNGWs66OeVg--?do}SyR=LGBj^|K~kx_{!o z?Y(4Q&J)r4`|9a$qq+M$`eT<0Ek_6=g=v42eph&2ctPm3MeRX&TX;u!;C;!7LfibW z_00dE=ZC^}VdhrJ`9h5_M|elrAe{Ag=?H~8g}a0{`nMkXL(kR1L(o;&EF8H_IzZt< zAud!1(}Zh;nL-==TMzxA=ZFu)|Al*mdxZ->6y1es!VKYK;Zxy|k7VN)+UVbU=np-I z3B!dk+ocN@>V-u@#{X(gAoLQNXg+;vv)TQ>+DSW^YfNS$=VzY3vqbmhNR?leuefBF zB!3-YWU}2dou9xfID`7w{cj$_7uAo=KVfr&l-bMe;&ve{X{kUXk z+a+E0{UfC7J5qXpuA+5!`TKocbnhXbucOo+zTupvh~e+g;h}TCkDmN0dU8kf-_t$>p0m?!6 zyCOUc_MDEMHXZLv`FGjk#n$$~CN${nOUnO>&+M``Tu122-e2tZ#U5Ym1I6A??Dxc; zKqe-F*(Uw3p#hPLee!#+Rk1;hR??D@jJAnXOg{vYfS(MJE)Nq=a|ejn`Z z!5$Fo`@mig>%)(vA^dmZ$bY*M@Qr~`R`wNc99HiiT6#6Z{k1``Mji$?!KTJaq1|=t)lWWXG`-RD0`w>Ae!Z zQ<|VRz6#HF-c^2gX{Xo12VgRu2pEcmD3;v%SbkM!6iXo)_TA5lCAA#?I-I!SqIjP?s_i# zT^$~_=)ZZYjToUef~!O1jHSvbeZL&^0{l79MsF4;x?9 zvTS|huwAi~9rlgwl_cudN0?Lk-BC z;7k26u=<}y&AtAj&+ql&rU!VZ<$D7=$bKSxmY>g`f7k#0ENOgIp7nWuR8r9zVV$s9 zaP7r~gbX1j3=xJ2V}waUl~6D2?meHb|C^y_KTkUU?(fA_^?z$jbo#sFm*|See_zwF zPP#I-SG#t-+V$_HnV|fl4tK+}mSU{@qb@iv(Oq#~a{I}`#PixGT`i10FF{mj`RnCQ zAF`$KHofn5j1wK)+GQ)||8o4dYw`+mg1KJYT{h(X#-Edq9Z$7)6HB|)m(M+=Tym&G zG;b+;Pp~Ty*?WRyV)AXjnEQ0-?e1?b<}c`Pio4pCxM}*o*nQVc49@s}a_~&9D;y%Z zIw3dmMu#9DnJ#E>tECOIo5j9B{?Cx5#Txs2*>CauV z>PscVj$gqpUOyMDAJD44Rko`KI{^?49*q!Dp@|HP1_%uS+JPYQ;~HrJr2!yn5Rqeu z9>UClSpzG{SxwA}QdSkRvW(S5tT17f0iNb~8{=_`*CAeoGgUPznyV62Jg4Hh?+GfR zQxV-9LB(`)1r^m@FU%KYpmr*vmj!r|R!;b;~Q-Jwl@RU8NX4O>Ib}q{u z>W*^Xa(&&gZm>Jqo#rle*Q#m%$~}?z`^3XD(lfr6(LJMg#=xElvBX%r*a5NRSf^NO ztaI$(*x|7wVhN3jjR!U+H>NbEHFjz2+SsG<+l@mSM>SsBSlGC(F_M8Vy z$c%0oNwJQxlvrBqpx9xt^jMPecXDGv<6j!r|HHXTI&k>`zYx1ftnzf;--Y}56HmF_ zed$}98x?&o;WV+OJ+x!q?oqCOuitUO|Iz;vsuEn9E&zVC8Gr2m_R)3vIDL7&;D6U8 zws(KY>EN~<*V#Szopkp?VWyjU*tgs>+5O!1cE`Cn6(@?h401F2o$PuPo#tMgb-L?t z%6D9^D@VE+mlwKc4<7FZoIT#ny!L9>c-D0;Vd5P3?8JJv?9l&ks|xOMm-kueULOB| z%j>kt9d_(P?$F*3yZgTJm|J+p8h2Li^KRr-&%5+?Yh9P4UUeI;`inc^n{T=2F8I64 zxa99aiD!RfU9T(M3&rHOiC?XxP6?#fSz|NITR{#ttCP7z6%JMdcm{BMnoHPRqrb!5 zft!2j?*RwAckU1FTb%s2)Y_A)u6yLuzfRq`X@$>TzxI`Le*J?59ghFgvSsbo|M!3G z>Dh$x?8hGO{m7EXwhgHG=4GR|?fk6k>=HLi7?3oQv>9RqnH6O^yLOvnI`8b-Z_d=; z1iABiMy=BwzeJ6{yazR5nmiA24{5p{NOPk4@vhTmn)xI0qCPla-AV4Wb~KF#E>UAD t&@O+%G~qA8Tf*g+YP=9$6sYP$&unO1ygc}Sh5g?+L Date: Tue, 27 Aug 2024 08:43:00 +0800 Subject: [PATCH 09/18] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=AF=B9=E6=8E=A5DIFY?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=E6=94=AF=E6=8C=81=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/routers/app.py | 291 +++++++++++++++++++--- backend/app/api/routers/request/base.py | 9 +- backend/app/api/routers/request/dbOrm.py | 9 +- backend/app/api/routers/request/models.py | 6 +- backend/main.py | 4 +- 5 files changed, 266 insertions(+), 53 deletions(-) diff --git a/backend/app/api/routers/app.py b/backend/app/api/routers/app.py index e806726..101740a 100644 --- a/backend/app/api/routers/app.py +++ b/backend/app/api/routers/app.py @@ -1,23 +1,264 @@ -import os -from typing import Dict, List, Any, Optional, cast -from fastapi import APIRouter,Request -from app.api.routers.request.base import userMng,conversations -from app.api.routers.request.models import ChatRequestData +import asyncio +import json +import logging +from typing import Dict, List, Any, Optional, AsyncGenerator + +from aiostream import stream +from fastapi import APIRouter, Request +from fastapi.responses import StreamingResponse +from llama_index.core import BaseCallbackHandler +from llama_index.core.base.llms.types import ChatMessage +from llama_index.core.callbacks import CBEventType +from llama_index.core.chat_engine.types import StreamingAgentChatResponse +from llama_index.core.tools import ToolOutput +from pydantic import BaseModel + +from app.api.routers.events import EventCallbackHandler +from app.api.routers.request.base import userMng, conversations +from app.api.routers.request.models import ChatRequestData +from app.engine import get_chat_engine + +logger = logging.getLogger("uvicorn") -api_router = r = APIRouter() v1_router = v = APIRouter() +class ChatCallbackEvent(BaseModel): + event_type: CBEventType + payload: Optional[Dict[str, Any]] = None + event_id: str = "" + + def get_retrieval_message(self) -> dict | None: + if self.payload: + nodes = self.payload.get("nodes") + if nodes: + msg = f"根据查询检索到 {len(nodes)} 源文件" + else: + msg = f"查询检索中: '{self.payload.get('query_str')}'" + return { + "type": "events", + "data": {"title": msg}, + } + else: + return None + + def get_tool_message(self) -> dict | None: + func_call_args = self.payload.get("function_call") + if func_call_args is not None and "tool" in self.payload: + tool = self.payload.get("tool") + return { + "type": "events", + "data": { + "title": f"调用工具 {tool.name} ,参数: {func_call_args}", + }, + } + + def _is_output_serializable(self, output: Any) -> bool: + try: + json.dumps(output) + return True + except TypeError: + return False + + def get_agent_tool_response(self) -> dict | None: + response = self.payload.get("response") + if response is not None: + sources = response.sources + for source in sources: + # Return the tool response here to include the toolCall information + if isinstance(source, ToolOutput): + if self._is_output_serializable(source.raw_output): + output = source.raw_output + else: + output = source.content + + return { + "type": "tools", + "data": { + "toolOutput": { + "output": output, + "isError": source.is_error, + }, + "toolCall": { + "id": None, # There is no tool id in the ToolOutput + "name": source.tool_name, + "input": source.raw_input, + }, + }, + } + + def to_response(self): + try: + match self.event_type: + case "retrieve": + return self.get_retrieval_message() + case "function_call": + return self.get_tool_message() + case "agent_step": + return self.get_agent_tool_response() + case _: + return None + except Exception as e: + logger.error(f"转换回应时间时发生错误,原因: {e}") + return None + + +class ChatEventCallbackHandler(BaseCallbackHandler): + _aqueue: asyncio.Queue + is_done: bool = False + + def __init__( + self, + ): + """Initialize the base callback handler.""" + ignored_events = [ + CBEventType.CHUNKING, + CBEventType.NODE_PARSING, + CBEventType.EMBEDDING, + CBEventType.LLM, + CBEventType.TEMPLATING, + ] + super().__init__(ignored_events, ignored_events) + self._aqueue = asyncio.Queue() + + def on_event_start( + self, + event_type: CBEventType, + payload: Optional[Dict[str, Any]] = None, + event_id: str = "", + **kwargs: Any, + ) -> str: + event = ChatCallbackEvent(event_id=event_id, event_type=event_type, payload=payload) + if event.to_response() is not None: + self._aqueue.put_nowait(event) + + def on_event_end( + self, + event_type: CBEventType, + payload: Optional[Dict[str, Any]] = None, + event_id: str = "", + **kwargs: Any, + ) -> None: + event = ChatCallbackEvent(event_id=event_id, event_type=event_type, payload=payload) + if event.to_response() is not None: + self._aqueue.put_nowait(event) + + def start_trace(self, trace_id: Optional[str] = None) -> None: + """No-op.""" + + def end_trace( + self, + trace_id: Optional[str] = None, + trace_map: Optional[Dict[str, List[str]]] = None, + ) -> None: + """No-op.""" + + async def async_event_gen(self) -> AsyncGenerator[ChatCallbackEvent, None]: + while not self._aqueue.empty() or not self.is_done: + try: + yield await asyncio.wait_for(self._aqueue.get(), timeout=0.1) + except asyncio.TimeoutError: + pass + +class ChatStreamResponse(StreamingResponse): + TEXT_PREFIX = "data:" + DATA_PREFIX = "data:" + + @classmethod + def convert_text(cls, token: str): + # Escape newlines and double quotes to avoid breaking the stream + token = json.dumps(token) + + #return f"data: {{"event": "message", "conversation_id": "80d85523-de92-4b9d-aca0-c48a5eacb068", "message_id": "16a06b1b-a89b-49c0-bc15-123bd999f6d6", "created_at": 1724406492, "task_id": "802f3064-030d-42ac-a882-0e1293712d04", "id": "16a06b1b-a89b-49c0-bc15-123bd999f6d6", "answer": "{token}"}}" + return "" + + @classmethod + def convert_data(cls, data: dict): + data_str = json.dumps(data) + return f"{cls.DATA_PREFIX}{data_str}\n" + + def __init__( + self, + request: Request, + event_handler: EventCallbackHandler, + response: StreamingAgentChatResponse, + data: ChatRequestData + ): + content = ChatStreamResponse.content_generator( + request, event_handler, response, data + ) + super().__init__(content=content) + + @classmethod + async def content_generator( + cls, + request: Request, + event_handler: EventCallbackHandler, + response: StreamingAgentChatResponse, + data: ChatRequestData + ): + # Yield the text response + async def _chat_response_generator(): + final_response = "" + async for token in response.async_response_gen(): + final_response += token + yield ChatStreamResponse.convert_text(token) + + + # 存储消息历史 + #message = Message(data.conversation_id, data.query, answer=final_response) + #messageManager.addmessage(message) + + # the text_generator is the leading stream, once it's finished, also finish the event stream + event_handler.is_done = True + + # Yield the events from the event handler + async def _event_generator(): + async for event in event_handler.async_event_gen(): + event_response = event.to_response() + if event_response is not None: + yield ChatStreamResponse.convert_data(event_response) + + combine = stream.merge(_chat_response_generator(), _event_generator()) + is_stream_started = False + async with combine.stream() as streamer: + async for output in streamer: + if not is_stream_started: + is_stream_started = True + # Stream a blank message to start the stream + yield ChatStreamResponse.convert_text("") + + yield output + + if await request.is_disconnected(): + break + @v.post("/chat-messages") -async def post_conversations(request: Request,data: ChatRequestData): +async def post_conversations(request: Request, data: ChatRequestData): userMng.findNoExistCreate(data.user) conversaObj = conversations() - conversationinfo = conversaObj.get(data.user) + conversationinfo = conversaObj.get(data.user, data.conversation_id) if conversationinfo is None: - conversationinfo = conversaObj.add(data.user, "新建会话") + conversationinfo = conversaObj.add(data.user, "新建会话", data.conversation_id) - return None + # 生成聊天参数 + last_message_content = ChatMessage.from_str(data.query) + filters = None + params = data.inputs or {} + + # 获取聊天引擎对象 + chat_engine = get_chat_engine(filters=filters, params=params) + + # 启动聊天事件监听 + event_handler = EventCallbackHandler() + chat_engine.callback_manager.handlers.append(event_handler) # type: ignore + + # 执行异步聊天 + response = await chat_engine.astream_chat(data.query) + + # 返回异步消息回应 + return ChatStreamResponse(request, event_handler, response, data) @v.get("/messages") async def query_messages(user:str, conversation_id:str): @@ -37,33 +278,3 @@ async def query_conversations(user:str): "has_more": False, "data": conversations().gets(user_id) } - - -@r.get("/conversations") -async def query_conversations(first_id:int = None, limit:int = None, pinned:bool = None): - pass - -#meta查询 -@r.get("/meta") -async def query_meta(): - pass - -#name查询 -@r.get("/name查询") -def query_name(): - with sessionlocal() as session: - name = session.query(NameOrm).first() - - return Name.from_orm(name) - -#parameters查询 -@r.get("/parameters") -async def query_parameters(): - pass - -#msite查询 -@r.get("/site") -async def query_site(): - pass - - diff --git a/backend/app/api/routers/request/base.py b/backend/app/api/routers/request/base.py index 7723d49..00f86b1 100644 --- a/backend/app/api/routers/request/base.py +++ b/backend/app/api/routers/request/base.py @@ -1,10 +1,8 @@ -import os -from typing import Dict, List, Any, Optional, cast -import json -from app.api.routers.request.dbOrm import DBManager -from app.api.routers.request.baseConfig import BaseConfig from datetime import datetime +from app.api.routers.request.baseConfig import BaseConfig +from app.api.routers.request.dbOrm import DBManager + dbManage = DBManager() class conversations: @@ -31,6 +29,7 @@ class conversations: if id == '': id= str(uuid.uuid4()) template = BaseConfig.ConversationCfg + template['id'] = id template['user_id'] = user_id template['name'] = name diff --git a/backend/app/api/routers/request/dbOrm.py b/backend/app/api/routers/request/dbOrm.py index 539a669..bb7c1be 100644 --- a/backend/app/api/routers/request/dbOrm.py +++ b/backend/app/api/routers/request/dbOrm.py @@ -1,11 +1,10 @@ import os -from typing import Dict, List, Any, Optional, cast +from typing import Dict, List, Any -from fastapi import APIRouter -from pydantic import BaseModel, Field -from sqlalchemy import create_engine, Column, String, Integer, Boolean, JSON,ForeignKey -from sqlalchemy.orm import sessionmaker, declarative_base,relationship +from pydantic import BaseModel +from sqlalchemy import create_engine, Column, String, Integer, JSON from sqlalchemy.engine.reflection import Inspector +from sqlalchemy.orm import sessionmaker, declarative_base Base = declarative_base() diff --git a/backend/app/api/routers/request/models.py b/backend/app/api/routers/request/models.py index 493883c..43b06d4 100644 --- a/backend/app/api/routers/request/models.py +++ b/backend/app/api/routers/request/models.py @@ -1,5 +1,8 @@ + +from typing import Dict, Any + from pydantic import BaseModel -from typing import Dict, List, Any, Optional, cast + class ChatRequestData(BaseModel): inputs: Dict[str,Any] @@ -7,4 +10,5 @@ class ChatRequestData(BaseModel): user: str response_mode: str files: Any + conversation_id: str = None \ No newline at end of file diff --git a/backend/main.py b/backend/main.py index f4bca3e..dd12002 100644 --- a/backend/main.py +++ b/backend/main.py @@ -12,7 +12,7 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import RedirectResponse from app.api.routers.chat import chat_router from app.api.routers.upload import file_upload_router -from app.api.routers.app import api_router,v1_router +from app.api.routers.app import v1_router from app.settings import init_settings from app.observability import init_observability from fastapi.staticfiles import StaticFiles @@ -56,7 +56,7 @@ mount_static_files("data", "/api/files/data") mount_static_files("data_output", "/api/files/output") app.include_router(chat_router, prefix="/api/chat") app.include_router(file_upload_router, prefix="/api/chat/upload") -app.include_router(api_router, prefix="/api") + app.include_router(v1_router, prefix="/v1") @app.get("/") -- 2.52.0 From 07a3b2a14709bd79dbb25108e3e872b9740fe068 Mon Sep 17 00:00:00 2001 From: wanyaokun <12345678> Date: Tue, 27 Aug 2024 17:48:38 +0800 Subject: [PATCH 10/18] =?UTF-8?q?=E4=BF=AE=E6=94=B9POST=E5=92=8CGet?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/routers/app.py | 151 ++++++++++++++++-- backend/app/api/routers/request/base.py | 30 ++-- backend/app/api/routers/request/baseConfig.py | 10 ++ backend/app/api/routers/request/dbOrm.py | 4 +- backend/app/api/routers/request/models.py | 1 - 5 files changed, 168 insertions(+), 28 deletions(-) diff --git a/backend/app/api/routers/app.py b/backend/app/api/routers/app.py index 101740a..a058c93 100644 --- a/backend/app/api/routers/app.py +++ b/backend/app/api/routers/app.py @@ -14,14 +14,16 @@ from llama_index.core.tools import ToolOutput from pydantic import BaseModel from app.api.routers.events import EventCallbackHandler -from app.api.routers.request.base import userMng, conversations +from app.api.routers.request.base import userMng, conversations,message from app.api.routers.request.models import ChatRequestData from app.engine import get_chat_engine +import uuid logger = logging.getLogger("uvicorn") v1_router = v = APIRouter() +default_conversation_id = '82e8417f-2c3b-4bb5-ab22-2ad318bbd29a' class ChatCallbackEvent(BaseModel): event_type: CBEventType @@ -102,7 +104,6 @@ class ChatCallbackEvent(BaseModel): logger.error(f"转换回应时间时发生错误,原因: {e}") return None - class ChatEventCallbackHandler(BaseCallbackHandler): _aqueue: asyncio.Queue is_done: bool = False @@ -160,6 +161,84 @@ class ChatEventCallbackHandler(BaseCallbackHandler): except asyncio.TimeoutError: pass +class IDManager: + def createID(self): + return { + "message_id" : str(uuid.uuid4()), + 'task_id':str(uuid.uuid4()), + 'workflow_run_id': str(uuid.uuid4()), + "workflow_id": str(uuid.uuid4()) + } + +class DifyChatResponseEvent(BaseModel): + event: str + conversation_id: str + message_id: str + created_at: int = 1724406492 + task_id: str + +class Workflow_started_DifyChatResponseEvent(DifyChatResponseEvent): + workflow_run_id:str + data:Dict[str,Any] + def __init__(self,**args): + args['data'] = { + "id": args['workflow_run_id'], + "workflow_id": args['workflow_id'], + "sequence_number": 1709, + "inputs": { + "sys.query": args['query'], + "sys.files": [], + "sys.conversation_id": args['conversation_id'], + "sys.user_id": args['use_id'] + }, + "created_at": 1724406492 + } + args['event'] = 'workflow_started' + super().__init__(**args) + +class Workflow_finished_DifyChatResponseEvent(DifyChatResponseEvent): + workflow_run_id:str + data:Dict[str,Any] + def __init__(self,**args): + args['event'] = 'workflow_finished' + args['data'] = { + "id": args['workflow_run_id'], + "workflow_id": args['workflow_id'], + "sequence_number": 1709, + "status": "succeeded", + "outputs": { + "answer": args['response'] + }, + "error": '', + "elapsed_time": 36.03764106379822, + "total_tokens": 11707, + "total_steps": 10, + "created_by": { + "id": str(uuid.uuid4()), + "user": args['use_id'] + }, + "created_at": 1724406492, + "finished_at": 1724406528, + "files": [] + } + super().__init__(**args) + +class Message_DifyChatResponseEvent(DifyChatResponseEvent): + id:str + answer:str + def __init__(self,**args): + args['id'] = args['message_id'] + args['event'] = 'message' + super().__init__(**args) + +class MessageEnd_DifyChatResponseEvent(DifyChatResponseEvent): + id:str + metadata:Dict[str,Any] = {} + def __init__(self,**args): + args['id'] = args['message_id'] + args['event'] = 'message_end' + super().__init__(**args) + class ChatStreamResponse(StreamingResponse): TEXT_PREFIX = "data:" DATA_PREFIX = "data:" @@ -177,10 +256,15 @@ class ChatStreamResponse(StreamingResponse): data_str = json.dumps(data) return f"{cls.DATA_PREFIX}{data_str}\n" + @classmethod + def convert_event(cls, event: DifyChatResponseEvent): + data_str = json.dumps(event.dict()) + return f"{cls.DATA_PREFIX}{data_str}\n" + def __init__( self, request: Request, - event_handler: EventCallbackHandler, + event_handler: ChatEventCallbackHandler, response: StreamingAgentChatResponse, data: ChatRequestData ): @@ -193,24 +277,38 @@ class ChatStreamResponse(StreamingResponse): async def content_generator( cls, request: Request, - event_handler: EventCallbackHandler, + event_handler: ChatEventCallbackHandler, response: StreamingAgentChatResponse, data: ChatRequestData ): + ids = IDManager().createID() # Yield the text response async def _chat_response_generator(): final_response = "" async for token in response.async_response_gen(): final_response += token - yield ChatStreamResponse.convert_text(token) - + args = ids + args['answer'] = token + args['conversation_id'] = data.conversation_id + event = Message_DifyChatResponseEvent(**args) + yield ChatStreamResponse.convert_event(event) + #yield ChatStreamResponse.convert_text(token) # 存储消息历史 - #message = Message(data.conversation_id, data.query, answer=final_response) - #messageManager.addmessage(message) + message().add(user_id=data.user,conversation_id=data.conversation_id,query=data.query,answer=final_response) # the text_generator is the leading stream, once it's finished, also finish the event stream event_handler.is_done = True + # 发送工作流结束事件 + args = ids + args['response'] = final_response + args['conversation_id'] = data.conversation_id + wf_event = Workflow_finished_DifyChatResponseEvent(**args) + yield ChatStreamResponse.convert_event(wf_event) + + msgEnt_event = MessageEnd_DifyChatResponseEvent(**ids) + yield ChatStreamResponse.convert_event(msgEnt_event) + # Yield the events from the event handler async def _event_generator(): @@ -225,8 +323,18 @@ class ChatStreamResponse(StreamingResponse): async for output in streamer: if not is_stream_started: is_stream_started = True + + # 发送工作流开始事件 + args = ids + args['use_id'] = data.user + args['query'] = data.query + args['conversation_id'] = data.conversation_id + wf_event = Workflow_started_DifyChatResponseEvent(**args) + yield ChatStreamResponse.convert_event(wf_event) + # Stream a blank message to start the stream - yield ChatStreamResponse.convert_text("") + # 发送一个空消息事件 + #yield ChatStreamResponse.convert_text("") yield output @@ -236,7 +344,8 @@ class ChatStreamResponse(StreamingResponse): @v.post("/chat-messages") async def post_conversations(request: Request, data: ChatRequestData): userMng.findNoExistCreate(data.user) - + data.conversation_id = default_conversation_id if data.conversation_id is None else data.conversation_id + conversaObj = conversations() conversationinfo = conversaObj.get(data.user, data.conversation_id) if conversationinfo is None: @@ -251,7 +360,7 @@ async def post_conversations(request: Request, data: ChatRequestData): chat_engine = get_chat_engine(filters=filters, params=params) # 启动聊天事件监听 - event_handler = EventCallbackHandler() + event_handler = ChatEventCallbackHandler() chat_engine.callback_manager.handlers.append(event_handler) # type: ignore # 执行异步聊天 @@ -262,8 +371,26 @@ async def post_conversations(request: Request, data: ChatRequestData): @v.get("/messages") async def query_messages(user:str, conversation_id:str): - pass + conversation_id = default_conversation_id if conversation_id is None else conversation_id + datas = [] + records = message().gets(user,conversation_id) + for record in records: + res = record.dict() + res["message_files"] = [] + res["feedback"] = '' + res["retriever_resources"] = [] + res["created_at"] = 1723444905 + res["agent_thoughts"] = [] + res["status"] = "normal" + res["error"] = '' + datas.append(res) + return { + "limit": 20, + "has_more": False, + "data": datas + } + @v.post("/conversations/{itemid}/name") async def post_conversations(user:str): pass diff --git a/backend/app/api/routers/request/base.py b/backend/app/api/routers/request/base.py index 00f86b1..997971d 100644 --- a/backend/app/api/routers/request/base.py +++ b/backend/app/api/routers/request/base.py @@ -1,5 +1,5 @@ from datetime import datetime - +import uuid from app.api.routers.request.baseConfig import BaseConfig from app.api.routers.request.dbOrm import DBManager @@ -19,22 +19,17 @@ class conversations: return datas def get(self,user_id:str,id:str = ''): - records = dbManage.query(self._tableName,user_id = user_id,id = id) + records = dbManage.query(self._tableName,user_id = user_id,id=id) if len(records) >0: return records[0] return None def add(self,user_id:str,name:str,id:str = ''): - import uuid - if id == '': - id= str(uuid.uuid4()) template = BaseConfig.ConversationCfg - template['id'] = id template['user_id'] = user_id template['name'] = name template['created_at'] = 1724399038 - dbManage.addRecord(self._tableName,template) def delete(self,id:str): @@ -70,7 +65,7 @@ class userMng: @classmethod def findNoExistCreate(cls,user_id:str): userInfo = cls.userObj.get(user_id) - if userInfo is None: + if len(userInfo) == 0: cls.userObj.add(user_id) def remove(cls,user_id:str): @@ -116,14 +111,23 @@ class message: self._tableName = 'messages' dbManage.createTable(self._tableName) - def gets(self,user_id:str): - return dbManage.query(self._tableName,user_id = user_id) + def gets(self,user_id:str,conversation_id:str): + records = dbManage.query(self._tableName,user_id = user_id,conversation_id = conversation_id) + datas = [] + for record in records: + datas.append(record) + return datas - def add(self,user_id:str): - dbManage.addRecord(self._tableName,{}) + def add(self,user_id:str,conversation_id:str,query:str,answer:str): + template = BaseConfig.MessageCfg + template['id'] = str(uuid.uuid4()) + template['user_id'] = user_id + template['conversation_id'] = conversation_id + template['query'] = query + template['answer'] = answer + dbManage.addRecord(self._tableName,template) def delete(self,user_id:str): dbManage.delete(self._tableName,user_id = user_id) - diff --git a/backend/app/api/routers/request/baseConfig.py b/backend/app/api/routers/request/baseConfig.py index b81f03e..7dce858 100644 --- a/backend/app/api/routers/request/baseConfig.py +++ b/backend/app/api/routers/request/baseConfig.py @@ -49,4 +49,14 @@ class BaseConfig: "status": "normal", "introduction": ParamterCfg['opening_statement'], "created_at":'' + } + + + MessageCfg = { + "id": "", + 'user_id':'', + "conversation_id": "", + "inputs": {}, + "query": "", + "answer": "" } \ No newline at end of file diff --git a/backend/app/api/routers/request/dbOrm.py b/backend/app/api/routers/request/dbOrm.py index bb7c1be..81c2968 100644 --- a/backend/app/api/routers/request/dbOrm.py +++ b/backend/app/api/routers/request/dbOrm.py @@ -41,7 +41,7 @@ class MessagesOrm(Base): conversation_id = Column(String) inputs = Column(JSON) query = Column(String) - answer = Column(JSON) + answer = Column(String) #数据结构 class ConversationModel(BaseModel): @@ -90,7 +90,7 @@ class MessagesModel(BaseModel): conversation_id :str inputs : Dict[str, Any] query : str - answer : Dict[str, Any] + answer : str class Config: #orm_mode = True diff --git a/backend/app/api/routers/request/models.py b/backend/app/api/routers/request/models.py index 43b06d4..d15d809 100644 --- a/backend/app/api/routers/request/models.py +++ b/backend/app/api/routers/request/models.py @@ -11,4 +11,3 @@ class ChatRequestData(BaseModel): response_mode: str files: Any conversation_id: str = None - \ No newline at end of file -- 2.52.0 From 56459c164e798e82b15c699763ebf4a28e950bf9 Mon Sep 17 00:00:00 2001 From: paituo <330435863@qq.com> Date: Wed, 28 Aug 2024 08:04:01 +0800 Subject: [PATCH 11/18] =?UTF-8?q?=E9=85=8D=E7=BD=AE=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0UTF8=E7=BC=96=E7=A0=81=E6=A0=BC=E5=BC=8F?= =?UTF-8?q?=E6=94=AF=E6=8C=81=EF=BC=8C=E4=BB=A5=E5=85=8D=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=E4=B8=AD=E6=96=87=E6=97=B6=E5=87=BA=E7=8E=B0=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/engine/loaders/__init__.py | 2 +- backend/app/engine/tools/__init__.py | 11 +++++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/backend/app/engine/loaders/__init__.py b/backend/app/engine/loaders/__init__.py index a220170..6a906ca 100644 --- a/backend/app/engine/loaders/__init__.py +++ b/backend/app/engine/loaders/__init__.py @@ -9,7 +9,7 @@ logger = logging.getLogger(__name__) def load_configs(): - with open("config/loaders.yaml") as f: + with open("config/loaders.yaml",encoding='UTF-8') as f: configs = yaml.safe_load(f) return configs diff --git a/backend/app/engine/tools/__init__.py b/backend/app/engine/tools/__init__.py index 1aced70..054308e 100644 --- a/backend/app/engine/tools/__init__.py +++ b/backend/app/engine/tools/__init__.py @@ -1,10 +1,9 @@ -import os -import yaml -import json import importlib -from cachetools import cached, LRUCache -from llama_index.core.tools.tool_spec.base import BaseToolSpec +import os + +import yaml from llama_index.core.tools.function_tool import FunctionTool +from llama_index.core.tools.tool_spec.base import BaseToolSpec class ToolType: @@ -46,7 +45,7 @@ class ToolFactory: def from_env() -> list[FunctionTool]: tools = [] if os.path.exists("config/tools.yaml"): - with open("config/tools.yaml", "r") as f: + with open("config/tools.yaml", "r", encoding='UTF-8') as f: tool_configs = yaml.safe_load(f) if tool_configs != None and len(tool_configs.items()) != 0: for tool_type, config_entries in tool_configs.items(): -- 2.52.0 From b008ad9766f8df021b272a54ca38f5079f8995cf Mon Sep 17 00:00:00 2001 From: chentianrui Date: Wed, 28 Aug 2024 09:39:57 +0800 Subject: [PATCH 12/18] =?UTF-8?q?=E6=9B=B4=E6=94=B9=E4=BA=86=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E8=AF=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/engine/loaders/__init__.py | 26 +++---- backend/app/engine/loaders/db.py | 94 +++++++------------------- backend/app/engine/prompt.py | 12 ++-- 3 files changed, 44 insertions(+), 88 deletions(-) diff --git a/backend/app/engine/loaders/__init__.py b/backend/app/engine/loaders/__init__.py index a220170..241f68a 100644 --- a/backend/app/engine/loaders/__init__.py +++ b/backend/app/engine/loaders/__init__.py @@ -1,24 +1,26 @@ import logging - -import yaml -from app.engine.loaders.db import DBLoaderConfig, get_db_documents +# import yaml from app.engine.loaders.file import FileLoaderConfig, get_file_documents from app.engine.loaders.web import WebLoaderConfig, get_web_documents logger = logging.getLogger(__name__) - -def load_configs(): - with open("config/loaders.yaml") as f: - configs = yaml.safe_load(f) - return configs +# 注释掉 load_configs 函数 +# def load_configs(): +# with open("config/loaders.yaml") as f: +# configs = yaml.safe_load(f) +# return configs def get_documents(): documents = [] - config = load_configs() - if config is None or len(config.items()) == 0: - return documents + # 注释掉对 load_configs 的调用 + # config = load_configs() + # if config is None or len(config.items()) == 0: + # return documents + + # 使用一个空的 config 替代原有的配置加载逻辑 + config = {} for loader_type, loader_config in config.items(): logger.info( @@ -31,8 +33,6 @@ def get_documents(): document = get_file_documents(FileLoaderConfig(**loader_config)) case "web": document = get_web_documents(WebLoaderConfig(**loader_config)) - case "db": - document = get_db_documents(configs=[DBLoaderConfig(**cfg) for cfg in loader_config]) case _: raise ValueError(f"Invalid loader type: {loader_type}") documents.extend(document) diff --git a/backend/app/engine/loaders/db.py b/backend/app/engine/loaders/db.py index d6310e2..0289fb5 100644 --- a/backend/app/engine/loaders/db.py +++ b/backend/app/engine/loaders/db.py @@ -1,18 +1,12 @@ import logging from typing import Any, List, Optional -from llama_index.core import SQLDatabase, Document -from llama_index.core.objects import SQLTableSchema -from llama_index.core.readers.base import BaseReader -from llama_index.readers.database import DatabaseReader +from llama_index.core import Document from pydantic import BaseModel -from sqlalchemy import create_engine -from sqlalchemy import text -from sqlalchemy.engine import Engine logger = logging.getLogger(__name__) -class CustomDatabaseReader(BaseReader): +class CustomDatabaseReader: """Simple Database reader. Concatenates each row into Document used by LlamaIndex. @@ -45,8 +39,8 @@ class CustomDatabaseReader(BaseReader): def __init__( self, - sql_database: Optional[SQLDatabase] = None, - engine: Optional[Engine] = None, + sql_database: Optional[Any] = None, + engine: Optional[Any] = None, uri: Optional[str] = None, scheme: Optional[str] = None, host: Optional[str] = None, @@ -58,50 +52,24 @@ class CustomDatabaseReader(BaseReader): **kwargs: 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." - ) + # Setting the database-related properties to None + self.sql_database = None + self.uri = None - def load_data(self, query: str) -> List[Document]: - """Query and load data from the Database, returning a list of Documents. + def load_data(self, query: str, explanation: str) -> List[Document]: + """Simulate loading data without a database connection. Args: - query (str): Query parameter to filter tables and rows. + query (str): Query parameter (not used). + explanation (str): Explanation to be included in the document. Returns: List[Document]: A list of Document objects. """ - dco_str = "" - 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)) - - dco_str = ", ".join( - [f"{entry}" for entry in result.keys()] - ) - - for item in result.fetchall(): - # fetch each item - record_str = ", ".join( - [f"{entry}" for col, entry in zip(result.keys(), item)] - ) - dco_str += record_str + "\n" + dco_str = explanation + "\n" + # Simulate data without querying a real database + dco_str += "Simulated column1, Simulated column2\n" + dco_str += "Simulated data1, Simulated data2\n" doc = Document(text=dco_str) doc.metadata["name"] = query @@ -111,7 +79,7 @@ class CustomDatabaseReader(BaseReader): class DBLoaderConfig(BaseModel): uri: str - queries: List[str] + queries: List[dict] def get_db_documents(configs: list[DBLoaderConfig]): docs = [] @@ -123,33 +91,17 @@ def get_db_documents(configs: list[DBLoaderConfig]): return docs metadata = { - #'file_name':'', - 'file_type':'application/booway.document.zj', - #'file_path':'', - #'file_size':'', - #'creation_date':'', - #'last_modified_date':'', + 'file_type': 'application/booway.document.zj', } - #from llama_index.readers.database import DatabaseReader for entry in configs: - engine = create_engine(entry.uri) - sql_database = SQLDatabase(engine) - - # table_schema_objs = makeDescriptionByEngine(sql_database) - # table_node_mapping = SQLTableNodeMapping(sql_database) - # - # nodes = table_node_mapping.to_nodes(table_schema_objs) - # for node in nodes: - # node.metadata.update(metadata) - # - # docs.extend(nodes) - - queries = entry.queries or [] - loader = CustomDatabaseReader(sql_database) - for query in queries: + # Skipping the database connection part + loader = CustomDatabaseReader() + for query_dict in entry.queries: + query = query_dict.get("sql", "") + explanation = query_dict.get("explanation", "") logger.info(f"Loading data from database with query: {query}") - documents = loader.load_data(query=query) + documents = loader.load_data(query=query, explanation=explanation) docs.extend(documents) return docs diff --git a/backend/app/engine/prompt.py b/backend/app/engine/prompt.py index 101b6bf..29a2283 100644 --- a/backend/app/engine/prompt.py +++ b/backend/app/engine/prompt.py @@ -5,6 +5,8 @@ text_qa_template_str = ( "你是一名博微造价工程数据查询助手,专精于电力工程文件中的信息。" "你的职责是提供有关电力造价、造价编制软件、文件结构及相关数据的精准、客观的回答," "如同直接从文件中提取的内容。\n" + "知识库中已经导入一个工程的全部数据,请你站在当前工程的角度回答用户关于工程文件的问题。\n" + "例如:询问“此工程”指当前导入的工程。询问“此工程名称”指当前导入的工程的工程名称。\n" "## 技能\n" "### 技能 1: 数据查询与提供\n" @@ -39,15 +41,17 @@ refine_template_str = ( "这是原本的问题: {query_str}\n" "我们已经提供了回答: {existing_answer}\n" "现在我们有机会改进这个回答 " - "使用以下更多上下文(仅当需要用时)\n" + "使用以下更多上下文(仅当有助于改进回答时使用)\n" + "如果新的上下文对回答没有影响,或者原来的回答已经正确,不要在上次回答的后边再加上多余的补充信息,直接返回原本的回答。\n" + "如果新的上下文对回答没有影响,或者原来的回答已经正确,不要在上次回答的后边再加上多余的补充信息,直接返回原本的回答。\n" "------------\n" "{context_msg}\n" "------------\n" - "根据新的上下文, 请改进原来的回答。" - "如果新的上下文没有用, 直接返回原本的回答。\n" - "如果是表结构或者是数据库的相关内容,只用于推导问题,不需要告诉用户数据库或表结构等物理信息。\n" + "如果回答中已经包含有正确答案,不要返回多余的解释等信息,只返回正确答案\n" + "如果是表结构或者是数据库的相关内容,仅用于推导问题,不需要告诉用户数据库或表结构等物理信息。\n" "改进的回答: " ) + refine_template = PromptTemplate(refine_template_str) summary_template_str = ( -- 2.52.0 From 0f7c900c1e916bf4c5ef916623ea5cd6decc1fdb Mon Sep 17 00:00:00 2001 From: chentianrui Date: Wed, 28 Aug 2024 09:42:12 +0800 Subject: [PATCH 13/18] =?UTF-8?q?=E6=9B=B4=E6=94=B9=E4=BA=86=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E8=AF=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/engine/prompt.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/app/engine/prompt.py b/backend/app/engine/prompt.py index 29a2283..5871562 100644 --- a/backend/app/engine/prompt.py +++ b/backend/app/engine/prompt.py @@ -42,8 +42,10 @@ refine_template_str = ( "我们已经提供了回答: {existing_answer}\n" "现在我们有机会改进这个回答 " "使用以下更多上下文(仅当有助于改进回答时使用)\n" + "你需要仔细的判断新的上下文的信息与原本问题必须一个字都不差,如果有一点差别,那就不能改变我现有的回答。\n" + "在判断回答是否正确的时候,你应该仔细对比新的上下文中包含的信息是否与原本的问题一字不差,如果一字不差,才能当作新的正确回答。\n" "如果新的上下文对回答没有影响,或者原来的回答已经正确,不要在上次回答的后边再加上多余的补充信息,直接返回原本的回答。\n" - "如果新的上下文对回答没有影响,或者原来的回答已经正确,不要在上次回答的后边再加上多余的补充信息,直接返回原本的回答。\n" + "判断一下如果原回答正确,且在新的上下文仍然包含正确的回答,请将新的回答与原回答一起返回。\n" "------------\n" "{context_msg}\n" "------------\n" -- 2.52.0 From 8a5facb5b693a3cf308a508a2396ed841da2200a Mon Sep 17 00:00:00 2001 From: chentianrui Date: Wed, 28 Aug 2024 09:45:01 +0800 Subject: [PATCH 14/18] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E4=BA=86=E5=88=A4?= =?UTF-8?q?=E6=96=AD=E6=98=AF=E5=90=A6=E4=BD=BF=E7=94=A8=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/engine/loaders/__init__.py | 51 ++++++++-------- backend/app/engine/loaders/db.py | 81 +++++++++++++++++++------- backend/config/loaders.yaml | 18 ++---- 3 files changed, 91 insertions(+), 59 deletions(-) diff --git a/backend/app/engine/loaders/__init__.py b/backend/app/engine/loaders/__init__.py index 241f68a..4f585b4 100644 --- a/backend/app/engine/loaders/__init__.py +++ b/backend/app/engine/loaders/__init__.py @@ -1,40 +1,41 @@ import logging -# import yaml +import yaml +from app.engine.loaders.db import DBLoaderConfig, get_db_documents from app.engine.loaders.file import FileLoaderConfig, get_file_documents from app.engine.loaders.web import WebLoaderConfig, get_web_documents logger = logging.getLogger(__name__) -# 注释掉 load_configs 函数 -# def load_configs(): -# with open("config/loaders.yaml") as f: -# configs = yaml.safe_load(f) -# return configs + +def load_configs(): + with open("config/loaders.yaml") as f: + configs = yaml.safe_load(f) + return configs def get_documents(): documents = [] - # 注释掉对 load_configs 的调用 - # config = load_configs() - # if config is None or len(config.items()) == 0: - # return documents + config = load_configs() - # 使用一个空的 config 替代原有的配置加载逻辑 - config = {} + if config is None or len(config.items()) == 0: + return documents for loader_type, loader_config in config.items(): - logger.info( - f"Loading documents from loader: {loader_type}, config: {loader_config}" - ) + if loader_config.get('enable', True): # 检查 enable 字段 + logger.info( + f"Loading documents from loader: {loader_type}, config: {loader_config}" + ) - loader_config = loader_config or [] - match loader_type: - case "file": - document = get_file_documents(FileLoaderConfig(**loader_config)) - case "web": - document = get_web_documents(WebLoaderConfig(**loader_config)) - case _: - raise ValueError(f"Invalid loader type: {loader_type}") - documents.extend(document) + loader_config = loader_config or [] + match loader_type: + case "file": + document = get_file_documents(FileLoaderConfig(**loader_config)) + case "web": + document = get_web_documents(WebLoaderConfig(**loader_config)) + case "db": + document = get_db_documents(configs=[DBLoaderConfig(**cfg) for cfg in loader_config]) + case _: + raise ValueError(f"Invalid loader type: {loader_type}") + documents.extend(document) - return documents + return documents \ No newline at end of file diff --git a/backend/app/engine/loaders/db.py b/backend/app/engine/loaders/db.py index 0289fb5..00c0381 100644 --- a/backend/app/engine/loaders/db.py +++ b/backend/app/engine/loaders/db.py @@ -1,12 +1,15 @@ import logging from typing import Any, List, Optional -from llama_index.core import Document +from llama_index.core import SQLDatabase, Document +from llama_index.readers.database import DatabaseReader from pydantic import BaseModel +from sqlalchemy import create_engine, text +from sqlalchemy.engine import Engine logger = logging.getLogger(__name__) -class CustomDatabaseReader: +class CustomDatabaseReader(DatabaseReader): """Simple Database reader. Concatenates each row into Document used by LlamaIndex. @@ -39,8 +42,8 @@ class CustomDatabaseReader: def __init__( self, - sql_database: Optional[Any] = None, - engine: Optional[Any] = None, + sql_database: Optional[SQLDatabase] = None, + engine: Optional[Engine] = None, uri: Optional[str] = None, scheme: Optional[str] = None, host: Optional[str] = None, @@ -52,24 +55,51 @@ class CustomDatabaseReader: **kwargs: Any, ) -> None: """Initialize with parameters.""" - # Setting the database-related properties to None - self.sql_database = None - self.uri = None + 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." + ) - def load_data(self, query: str, explanation: str) -> List[Document]: - """Simulate loading data without a database connection. + def load_data(self, query: str) -> List[Document]: + """Query and load data from the Database, returning a list of Documents. Args: - query (str): Query parameter (not used). - explanation (str): Explanation to be included in the document. + query (str): Query parameter to filter tables and rows. Returns: List[Document]: A list of Document objects. """ - dco_str = explanation + "\n" - # Simulate data without querying a real database - dco_str += "Simulated column1, Simulated column2\n" - dco_str += "Simulated data1, Simulated data2\n" + dco_str = "" + + 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)) + + dco_str += ", ".join( + [f"{entry}" for entry in result.keys()] + ) + "\n" + + for item in result.fetchall(): + # Fetch each item + record_str = ", ".join( + [f"{entry}" for col, entry in zip(result.keys(), item)] + ) + dco_str += record_str + "\n" doc = Document(text=dco_str) doc.metadata["name"] = query @@ -81,10 +111,10 @@ class DBLoaderConfig(BaseModel): uri: str queries: List[dict] -def get_db_documents(configs: list[DBLoaderConfig]): +def get_db_documents(configs: List[DBLoaderConfig]) -> List[Document]: docs = [] - if len(configs) == 0 or configs[0].uri == "": + if not configs or not configs[0].uri: logger.warning( f"Failed to load database, error message: uri is empty. Return as empty document list." ) @@ -95,13 +125,20 @@ def get_db_documents(configs: list[DBLoaderConfig]): } for entry in configs: - # Skipping the database connection part - loader = CustomDatabaseReader() + engine = create_engine(entry.uri) + sql_database = SQLDatabase(engine) + + loader = CustomDatabaseReader(sql_database) for query_dict in entry.queries: query = query_dict.get("sql", "") explanation = query_dict.get("explanation", "") logger.info(f"Loading data from database with query: {query}") - documents = loader.load_data(query=query, explanation=explanation) + documents = loader.load_data(query=query) - docs.extend(documents) - return docs + # 添加解释到元数据中 + for doc in documents: + doc.metadata["explanation"] = explanation + doc.metadata.update(metadata) # 更新或添加额外的元数据 + docs.append(doc) + + return docs \ No newline at end of file diff --git a/backend/config/loaders.yaml b/backend/config/loaders.yaml index c69c13e..9844715 100644 --- a/backend/config/loaders.yaml +++ b/backend/config/loaders.yaml @@ -1,4 +1,5 @@ file: + enable: true # 添加 enable 字段 # use_llama_parse: Use LlamaParse if `true`. Needs a `LLAMA_CLOUD_API_KEY` from https://cloud.llamaindex.ai set as environment variable use_llama_parse: false @@ -7,27 +8,20 @@ db: # uri: The URI for the database. E.g.: mysql+pymysql://user:password@localhost:3306/db or postgresql+psycopg2://user:password@localhost:5432/db # query: The query to fetch data from the database. E.g.: SELECT * FROM table - uri: mysql+pymysql://zjinfo1:Dy2Bcr53Hm5xRkba@110.42.234.166:3306/zjinfo1 - #- uri: mysql+pymysql://zjinfo:Y6EAjEEdSYmskA8B@110.42.234.166:3306/zjinfo -# - uri: mysql+pymysql://zjinfo2:GSKcziSdBixDXwcd@110.42.234.166:3306/zjinfo2 + enable: true # 添加 enable 字段 queries: - - sql: select * from ProjectProperties limit 30; + - sql: select * from ProjectProperties; explanation: "工程属性表数据,层级关系包含在博微电力造价工程文件格式_ProjectProperties.json文件中。" - - sql: select Id, ParentId, Level, Name, Code, Amount, Amount_Total from TotalCalculateTable; explanation: "总算表数据,层级关系包含在博微电力造价工程文件格式_TotalCalculateTable.json文件中。" - - - sql: select Id, ParentId, Level, SerialNumber, Name, Quantity, Rate, Sum_Price from ProjectDivision where Level = 3 and ProfessionalType = '线路' limit 50; + - sql: select Id, ParentId, Level, SerialNumber, Name, Quantity, Rate, Sum_Price from ProjectDivision where ProfessionalType = '线路'; explanation: "专业类型为线路的项目划分表数据,层级关系包含在博微电力造价工程文件格式_ProjectDivision.json文件中。" - - - sql: select Id, ParentId, Level, SerialNumber, Name, Quantity, Rate, Sum_Price from ProjectDivision where Level = 3 and ProfessionalType = '余物清理' limit 50; + - sql: select Id, ParentId, Level, SerialNumber, Name, Quantity, Rate, Sum_Price from ProjectDivision where ProfessionalType = '余物清理'; explanation: "专业类型为余物清理的项目划分表数据,层级关系包含在博微电力造价工程文件格式_ProjectDivision.json文件中。" - - - sql: select Id, ParentId, Level, SerialNumber, Name, Quantity, Rate, Sum_Price from ProjectDivision where Level = 3 and ProfessionalType = '拆除线路' limit 50; + - sql: select Id, ParentId, Level, SerialNumber, Name, Quantity, Rate, Sum_Price from ProjectDivision where ProfessionalType = '拆除线路'; explanation: "专业类型为拆除线路的项目划分表数据,层级关系包含在博微电力造价工程文件格式_ProjectDivision.json文件中。" - - sql: select Id, ParentId, Level, Name, Code, Rate, Amount from OtherFee; explanation: "其他费用表数据,层级关系包含在博微电力造价工程文件格式_OtherFee.json文件中" - #web: # driver_arguments: # # The arguments to pass to the webdriver. E.g.: add --headless to run in headless mode -- 2.52.0 From d1242d2080410b27e9f96c732e7d8455c056f696 Mon Sep 17 00:00:00 2001 From: chentianrui Date: Wed, 28 Aug 2024 14:46:13 +0800 Subject: [PATCH 15/18] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E4=BA=86=E4=BB=8E?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=BA=93=E4=B8=AD=E6=9F=A5=E6=89=BE=E5=8F=96?= =?UTF-8?q?=E8=B4=B9=E8=A1=A8=E5=92=8C=E5=B7=A5=E7=A8=8B=E9=87=8F=E8=A1=A8?= =?UTF-8?q?=EF=BC=8C=E6=96=B0=E5=8A=A0=E4=BA=86=E4=B8=80=E4=B8=AA=E6=A0=91?= =?UTF-8?q?=E7=8A=B6=E6=90=9C=E7=B4=A2=E6=80=BB=E7=BB=93=E6=90=9C=E7=B4=A2?= =?UTF-8?q?=E5=BC=95=E6=93=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/engine/__init__.py | 8 ++++++- backend/app/engine/engine.py | 3 ++- backend/config/loaders.yaml | 21 ++++++++++++++++++ .../data/博微电力造价工程业务数据说明.docx | Bin 14451 -> 13692 bytes 4 files changed, 30 insertions(+), 2 deletions(-) diff --git a/backend/app/engine/__init__.py b/backend/app/engine/__init__.py index 6e2a97a..4ee1c9c 100644 --- a/backend/app/engine/__init__.py +++ b/backend/app/engine/__init__.py @@ -31,13 +31,19 @@ def get_chat_engine(filters=None, params=None): summary_query_tool = QueryEngineTool.from_defaults( query_engine=summary_query_engine, name="summary_query_tool", description="适用于任何需要进行全面总结、概括的要求。", ) - query_engine = create_query_engine(index,top_k,use_reranker,filters) + query_engine = create_query_engine(index,top_k,use_reranker,filters,response_mode = "COMPACT") query_engine_tool = QueryEngineTool.from_defaults(query_engine=query_engine, name="zj_query_tool", description="由博微公司编制的关于电力造价知识、电力造价编制软件知识和造价工程文件结构的知识库。适用于查询电力领域、电力造价领域、博微、博微电力、博微造价等业务等内容。如果本知识库没有直接答案但有解决思路的可以返回解决办法后建议使用“zjdata_query_tool”工具。", ) + + query_engine = create_query_engine(index,top_k,use_reranker,filters,response_mode = "TREE_SUMMARIZE") + query_engine_tool_1 = QueryEngineTool.from_defaults(query_engine=query_engine, name="zj_query_tool_1", + description="由博微公司编制的关于电力造价知识、电力造价编制软件知识和造价工程文件结构的知识库。适用于查询电力领域、电力造价领域、博微、博微电力、博微造价等业务等内容。如果本知识库没有直接答案但有解决思路的可以返回解决办法后,且在询问工程中单位的具体数值,例如用量,费率,合计,金额等的时候建议使用“zj_query_tool_1”工具。", + ) tools.append(summary_query_tool) tools.append(query_engine_tool) + tools.append(query_engine_tool_1) # Add additional tools tools += ToolFactory.from_env() diff --git a/backend/app/engine/engine.py b/backend/app/engine/engine.py index 379275e..4bbd993 100644 --- a/backend/app/engine/engine.py +++ b/backend/app/engine/engine.py @@ -86,7 +86,7 @@ def create_summary_query_engine(index, top_k=3, use_reranker=False, filters=None return summary_query_engine # Create a query engine -def create_query_engine(index, top_k=3, use_reranker=False, filters=None): +def create_query_engine(index, top_k=3, use_reranker=False, filters=None, response_mode=None): # 创建向量检索查询工具 postprocess = None if use_reranker: @@ -103,6 +103,7 @@ def create_query_engine(index, top_k=3, use_reranker=False, filters=None): node_postprocessors=postprocess, use_async=True, streaming=True, + ResponseMode = response_mode ) return query_engine \ No newline at end of file diff --git a/backend/config/loaders.yaml b/backend/config/loaders.yaml index 9844715..723cc3a 100644 --- a/backend/config/loaders.yaml +++ b/backend/config/loaders.yaml @@ -12,16 +12,37 @@ db: queries: - sql: select * from ProjectProperties; explanation: "工程属性表数据,层级关系包含在博微电力造价工程文件格式_ProjectProperties.json文件中。" + - sql: select Id, ParentId, Level, Name, Code, Amount, Amount_Total from TotalCalculateTable; explanation: "总算表数据,层级关系包含在博微电力造价工程文件格式_TotalCalculateTable.json文件中。" + - sql: select Id, ParentId, Level, SerialNumber, Name, Quantity, Rate, Sum_Price from ProjectDivision where ProfessionalType = '线路'; explanation: "专业类型为线路的项目划分表数据,层级关系包含在博微电力造价工程文件格式_ProjectDivision.json文件中。" - sql: select Id, ParentId, Level, SerialNumber, Name, Quantity, Rate, Sum_Price from ProjectDivision where ProfessionalType = '余物清理'; explanation: "专业类型为余物清理的项目划分表数据,层级关系包含在博微电力造价工程文件格式_ProjectDivision.json文件中。" - sql: select Id, ParentId, Level, SerialNumber, Name, Quantity, Rate, Sum_Price from ProjectDivision where ProfessionalType = '拆除线路'; explanation: "专业类型为拆除线路的项目划分表数据,层级关系包含在博微电力造价工程文件格式_ProjectDivision.json文件中。" + - sql: select Id, ParentId, Level, Name, Code, Rate, Amount from OtherFee; explanation: "其他费用表数据,层级关系包含在博微电力造价工程文件格式_OtherFee.json文件中" + + - sql: select Name, Code, Calculation_Formula, Rate, from FeeCollectionTable where FeeCollection_Table_Name = '线路取费表' + explanation: "取费表名称为线路取费表的取费表数据,层级关系包含在博微电力造价工程文件格式_FeeCollectionTable.json文件中" + - sql: select Name, Code, Calculation_Formula, Rate, from FeeCollectionTable where FeeCollection_Table_Name = '线路取费表(调试工程)aa' + explanation: "取费表名称为线路取费表的取费表数据,层级关系包含在博微电力造价工程文件格式_FeeCollectionTable.json文件中" + - sql: select Name, Code, Calculation_Formula, Rate, from FeeCollectionTable where FeeCollection_Table_Name = '大型土石方取费表' + explanation: "取费表名称为线路取费表的取费表数据,层级关系包含在博微电力造价工程文件格式_FeeCollectionTable.json文件中" + - sql: select Name, Code, Calculation_Formula, Rate, from FeeCollectionTable where FeeCollection_Table_Name = '线路取费表(余物清理)' + explanation: "取费表名称为线路取费表的取费表数据,层级关系包含在博微电力造价工程文件格式_FeeCollectionTable.json文件中" + - sql: select Name, Code, Calculation_Formula, Rate, from FeeCollectionTable where FeeCollection_Table_Name = '线路取费表(余物清理)(1)' + explanation: "取费表名称为线路取费表的取费表数据,层级关系包含在博微电力造价工程文件格式_FeeCollectionTable.json文件中" + - sql: select Name, Code, Calculation_Formula, Rate, from FeeCollectionTable where FeeCollection_Table_Name = '线路取费表(拆除)' + explanation: "取费表名称为线路取费表的取费表数据,层级关系包含在博微电力造价工程文件格式_FeeCollectionTable.json文件中" + + - sql: select Name, Code, Calculation_Formula, Rate, from ProjectQuantities where Professional_Type = '线路' + explanation: "专业类型为线路的工程量表数据,层级关系包含在博微电力造价工程文件格式_ProjectQuantities.json文件中" + - sql: select Name, Code, Calculation_Formula, Rate, from ProjectQuantities where Professional_Type = '余物清理' + explanation: "专业类型为线路的工程量表数据,层级关系包含在博微电力造价工程文件格式_ProjectQuantities.json文件中" #web: # driver_arguments: # # The arguments to pass to the webdriver. E.g.: add --headless to run in headless mode diff --git a/backend/data/博微电力造价工程业务数据说明.docx b/backend/data/博微电力造价工程业务数据说明.docx index 425772fc2d814390b163a4c7eca93c73622daf2a..670ce04d062e8f88944bd8e0d4d4be00875cd8ba 100644 GIT binary patch delta 10848 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 Umkbr-y_kj$4FYL={73Kq0U`Dg?f?J) delta 11510 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 -- 2.52.0 From 327bba75d5bfea4ed7df7165f0a4d47d8f52abf2 Mon Sep 17 00:00:00 2001 From: chentianrui Date: Wed, 28 Aug 2024 17:24:55 +0800 Subject: [PATCH 16/18] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E4=BA=86=E8=AF=AD?= =?UTF-8?q?=E5=8F=A5=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/config/loaders.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/config/loaders.yaml b/backend/config/loaders.yaml index 723cc3a..af5d2fe 100644 --- a/backend/config/loaders.yaml +++ b/backend/config/loaders.yaml @@ -42,7 +42,7 @@ db: - sql: select Name, Code, Calculation_Formula, Rate, from ProjectQuantities where Professional_Type = '线路' explanation: "专业类型为线路的工程量表数据,层级关系包含在博微电力造价工程文件格式_ProjectQuantities.json文件中" - sql: select Name, Code, Calculation_Formula, Rate, from ProjectQuantities where Professional_Type = '余物清理' - explanation: "专业类型为线路的工程量表数据,层级关系包含在博微电力造价工程文件格式_ProjectQuantities.json文件中" + explanation: "专业类型为余物清理的工程量表数据,层级关系包含在博微电力造价工程文件格式_ProjectQuantities.json文件中" #web: # driver_arguments: # # The arguments to pass to the webdriver. E.g.: add --headless to run in headless mode -- 2.52.0 From a7c79df339c0d64cdb1b5c01bf7dd6c0c7531efc Mon Sep 17 00:00:00 2001 From: wanyaokun <12345678> Date: Wed, 28 Aug 2024 17:35:28 +0800 Subject: [PATCH 17/18] =?UTF-8?q?=E4=BF=AE=E6=94=B9web=E8=AF=B7=E6=B1=82?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/routers/app.py | 81 +++++++++++++++++++++-- backend/app/api/routers/request/base.py | 30 +++------ backend/app/api/routers/request/dbOrm.py | 22 ++++-- backend/app/api/routers/request/models.py | 4 +- 4 files changed, 107 insertions(+), 30 deletions(-) diff --git a/backend/app/api/routers/app.py b/backend/app/api/routers/app.py index a058c93..c6a60a6 100644 --- a/backend/app/api/routers/app.py +++ b/backend/app/api/routers/app.py @@ -12,15 +12,14 @@ from llama_index.core.callbacks import CBEventType from llama_index.core.chat_engine.types import StreamingAgentChatResponse from llama_index.core.tools import ToolOutput from pydantic import BaseModel - -from app.api.routers.events import EventCallbackHandler -from app.api.routers.request.base import userMng, conversations,message -from app.api.routers.request.models import ChatRequestData +from app.api.routers.request.base import userMng, conversations,message,parameter +from app.api.routers.request.models import ChatRequestData,ChatFileUploadRequest from app.engine import get_chat_engine import uuid logger = logging.getLogger("uvicorn") +api_router = r = APIRouter() v1_router = v = APIRouter() default_conversation_id = '82e8417f-2c3b-4bb5-ab22-2ad318bbd29a' @@ -341,6 +340,8 @@ class ChatStreamResponse(StreamingResponse): if await request.is_disconnected(): break + + @v.post("/chat-messages") async def post_conversations(request: Request, data: ChatRequestData): userMng.findNoExistCreate(data.user) @@ -392,8 +393,26 @@ async def query_messages(user:str, conversation_id:str): } @v.post("/conversations/{itemid}/name") -async def post_conversations(user:str): - pass +async def post_conversations(request: Request,itemid:str,params:Dict[str,Any]): + consaObj = conversations() + consaObj.rename(itemid,'知识问答') + cond = { + 'id':itemid, + 'user_id':params['user'] + } + results = consaObj.query(**cond) + if len(results) > 0: + res = results[0] + return { + "id": res['id'], + "name": res['name'], + "inputs": res['inputs'], + "status": res['status'], + "introduction": res['introduction'], + "created_at": res['created_at'], + #"工程位置" + } + return 'null' @v.get("/conversations") async def query_conversations(user:str): @@ -405,3 +424,53 @@ async def query_conversations(user:str): "has_more": False, "data": conversations().gets(user_id) } + +@v.get("/parameters") +async def query_parameters(user:str): + params = parameter().get(user) + if len(params) == 0: + params = { + "opening_statement": "您好,我是配网D3造价软件小助手,您可以问我有关配网造价软件的相关问题!", + "suggested_questions": [], + "suggested_questions_after_answer": { + "enabled": False + }, + "speech_to_text": { + "enabled": False + }, + "text_to_speech": { + "enabled": False, + "language": "", + "voice": "" + }, + "retriever_resource": { + "enabled": True + }, + "annotation_reply": { + "enabled": False + }, + "more_like_this": { + "enabled": False + }, + "user_input_form": [], + "sensitive_word_avoidance": { + "enabled": False + }, + "file_upload": { + "image": { + "enabled": False, + "number_limits": 3, + "transfer_methods": [ + "remote_url" + ] + } + }, + "system_parameters": { + "image_file_size_limit": "10" + } + } + return params + +@r.post("") +def upload_file(request: ChatFileUploadRequest) -> List[str]: + pass \ No newline at end of file diff --git a/backend/app/api/routers/request/base.py b/backend/app/api/routers/request/base.py index 997971d..b7b2ec8 100644 --- a/backend/app/api/routers/request/base.py +++ b/backend/app/api/routers/request/base.py @@ -35,10 +35,17 @@ class conversations: def delete(self,id:str): dbManage.delete(self._tableName,id=id) - def rename(self,id:str): - data = {'name':''} + def rename(self,id:str,name:str): + data = {'name':name} dbManage.update(self._tableName,data,id=id) + def query(self,**condition): + results = [] + records = dbManage.query(self._tableName,**condition) + for record in records: + results.append(record.dict()) + return results + class user: def __init__(self) -> None: self._tableName = 'user' @@ -83,23 +90,8 @@ class parameter: key = record['name'] value = record['value'] data[key] = value - - return { - 'opening_statement':data['opening_statement'], - 'suggested_questions':data['suggested_questions'], - 'suggested_questions_after_answer':data['suggested_questions_after_answer'], - 'speech_to_text':data['speech_to_text'], - 'text_to_speech':data['text_to_speech'], - 'retriever_resource':data['retriever_resource'], - 'annotation_reply':data['annotation_reply'], - 'more_like_this':data['more_like_this'], - 'user_input_form':data['user_input_form'], - 'sensitive_word_avoidance':data['sensitive_word_avoidance'], - 'file_upload':data['file_upload'], - 'system_parameters':data['system_parameters'], - 'opening_statement':data['opening_statement'], - } - + return data + def set(self,user_id:str): dbManage.addRecord(self._tableName,{}) diff --git a/backend/app/api/routers/request/dbOrm.py b/backend/app/api/routers/request/dbOrm.py index 81c2968..796b90c 100644 --- a/backend/app/api/routers/request/dbOrm.py +++ b/backend/app/api/routers/request/dbOrm.py @@ -20,6 +20,14 @@ class ConversationOrm(Base): introduction = Column(String) created_at = Column(Integer) + def update(self,data:Dict[str,Any]): + if 'name' in data: + self.name = data['name'] + + + + + class UserOrm(Base): __tablename__ = "user" @@ -143,14 +151,20 @@ class DBManager: session.commit() def update(self,tableName:str,data:Dict[str,Any],**filter): + if not self.exist(tableName): + return session = self.SessionLocal() ormCls = self._get_orm(tableName) if ormCls is None: return - record = session.query(ormCls).filter_by(**filter).first() - if record is not None: - record.update(data) - session.commit() + if len(filter) > 0: + records = session.query(ormCls).filter_by(**filter).all() + else: + records = session.query(ormCls).all() + for record in records: + if record is not None: + record.update(data) + session.commit() def query(self,tableName:str,**filter): session = self.SessionLocal() diff --git a/backend/app/api/routers/request/models.py b/backend/app/api/routers/request/models.py index d15d809..d76af75 100644 --- a/backend/app/api/routers/request/models.py +++ b/backend/app/api/routers/request/models.py @@ -1,6 +1,5 @@ from typing import Dict, Any - from pydantic import BaseModel @@ -11,3 +10,6 @@ class ChatRequestData(BaseModel): response_mode: str files: Any conversation_id: str = None + +class ChatFileUploadRequest(BaseModel): + base64: str \ No newline at end of file -- 2.52.0 From 131d6ef1d178b94c37ba277eb892f1941fa37eec Mon Sep 17 00:00:00 2001 From: paituo <330435863@qq.com> Date: Thu, 29 Aug 2024 08:26:59 +0800 Subject: [PATCH 18/18] =?UTF-8?q?=E5=AE=8C=E5=96=84=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=EF=BC=8C=E5=AE=9E=E7=8E=B0=E5=AF=B9DIFY=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E6=B5=81=E4=BC=A0=E8=BE=93=E7=9A=84=E6=94=AF?= =?UTF-8?q?=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/routers/app.py | 57 +++++++++++++++---------- backend/app/api/routers/request/base.py | 6 +-- 2 files changed, 37 insertions(+), 26 deletions(-) diff --git a/backend/app/api/routers/app.py b/backend/app/api/routers/app.py index c6a60a6..cb53bd2 100644 --- a/backend/app/api/routers/app.py +++ b/backend/app/api/routers/app.py @@ -1,6 +1,7 @@ import asyncio import json import logging +import time from typing import Dict, List, Any, Optional, AsyncGenerator from aiostream import stream @@ -22,8 +23,6 @@ logger = logging.getLogger("uvicorn") api_router = r = APIRouter() v1_router = v = APIRouter() -default_conversation_id = '82e8417f-2c3b-4bb5-ab22-2ad318bbd29a' - class ChatCallbackEvent(BaseModel): event_type: CBEventType payload: Optional[Dict[str, Any]] = None @@ -112,11 +111,11 @@ class ChatEventCallbackHandler(BaseCallbackHandler): ): """Initialize the base callback handler.""" ignored_events = [ - CBEventType.CHUNKING, - CBEventType.NODE_PARSING, - CBEventType.EMBEDDING, - CBEventType.LLM, - CBEventType.TEMPLATING, + # CBEventType.CHUNKING, + # CBEventType.NODE_PARSING, + # CBEventType.EMBEDDING, + # CBEventType.LLM, + # CBEventType.TEMPLATING, ] super().__init__(ignored_events, ignored_events) self._aqueue = asyncio.Queue() @@ -128,6 +127,8 @@ class ChatEventCallbackHandler(BaseCallbackHandler): event_id: str = "", **kwargs: Any, ) -> str: + logger.info("event_start:{} type:{} payload:{}\n".format(event_id, event_type, payload)) + event = ChatCallbackEvent(event_id=event_id, event_type=event_type, payload=payload) if event.to_response() is not None: self._aqueue.put_nowait(event) @@ -139,12 +140,14 @@ class ChatEventCallbackHandler(BaseCallbackHandler): event_id: str = "", **kwargs: Any, ) -> None: + logger.info("event_end:{} type:{} payload:{}\n".format(event_id, event_type, payload)) event = ChatCallbackEvent(event_id=event_id, event_type=event_type, payload=payload) if event.to_response() is not None: self._aqueue.put_nowait(event) def start_trace(self, trace_id: Optional[str] = None) -> None: """No-op.""" + logger.info("trace_start:{}\n".format(trace_id)) def end_trace( self, @@ -152,6 +155,7 @@ class ChatEventCallbackHandler(BaseCallbackHandler): trace_map: Optional[Dict[str, List[str]]] = None, ) -> None: """No-op.""" + logger.info("trace_end:{} trace_map:{}\n".format(trace_id, trace_map)) async def async_event_gen(self) -> AsyncGenerator[ChatCallbackEvent, None]: while not self._aqueue.empty() or not self.is_done: @@ -173,7 +177,7 @@ class DifyChatResponseEvent(BaseModel): event: str conversation_id: str message_id: str - created_at: int = 1724406492 + created_at: int = int(time.time()) task_id: str class Workflow_started_DifyChatResponseEvent(DifyChatResponseEvent): @@ -190,7 +194,7 @@ class Workflow_started_DifyChatResponseEvent(DifyChatResponseEvent): "sys.conversation_id": args['conversation_id'], "sys.user_id": args['use_id'] }, - "created_at": 1724406492 + "created_at": int(time.time()) } args['event'] = 'workflow_started' super().__init__(**args) @@ -216,8 +220,8 @@ class Workflow_finished_DifyChatResponseEvent(DifyChatResponseEvent): "id": str(uuid.uuid4()), "user": args['use_id'] }, - "created_at": 1724406492, - "finished_at": 1724406528, + "created_at": int(time.time()), + "finished_at": int(time.time()), "files": [] } super().__init__(**args) @@ -239,26 +243,26 @@ class MessageEnd_DifyChatResponseEvent(DifyChatResponseEvent): super().__init__(**args) class ChatStreamResponse(StreamingResponse): - TEXT_PREFIX = "data:" - DATA_PREFIX = "data:" + TEXT_PREFIX = "data: " + DATA_PREFIX = "data: " @classmethod def convert_text(cls, token: str): # Escape newlines and double quotes to avoid breaking the stream - token = json.dumps(token) + #token = json.dumps(token) #return f"data: {{"event": "message", "conversation_id": "80d85523-de92-4b9d-aca0-c48a5eacb068", "message_id": "16a06b1b-a89b-49c0-bc15-123bd999f6d6", "created_at": 1724406492, "task_id": "802f3064-030d-42ac-a882-0e1293712d04", "id": "16a06b1b-a89b-49c0-bc15-123bd999f6d6", "answer": "{token}"}}" - return "" + return "\n" @classmethod def convert_data(cls, data: dict): data_str = json.dumps(data) - return f"{cls.DATA_PREFIX}{data_str}\n" + return f"{cls.DATA_PREFIX}{data_str}\n\n" @classmethod def convert_event(cls, event: DifyChatResponseEvent): data_str = json.dumps(event.dict()) - return f"{cls.DATA_PREFIX}{data_str}\n" + return f"{cls.DATA_PREFIX}{data_str}\n\n" def __init__( self, @@ -314,7 +318,7 @@ class ChatStreamResponse(StreamingResponse): async for event in event_handler.async_event_gen(): event_response = event.to_response() if event_response is not None: - yield ChatStreamResponse.convert_data(event_response) + yield ChatStreamResponse.convert_text("") combine = stream.merge(_chat_response_generator(), _event_generator()) is_stream_started = False @@ -345,12 +349,12 @@ class ChatStreamResponse(StreamingResponse): @v.post("/chat-messages") async def post_conversations(request: Request, data: ChatRequestData): userMng.findNoExistCreate(data.user) - data.conversation_id = default_conversation_id if data.conversation_id is None else data.conversation_id + data.conversation_id = data.conversation_id if data.conversation_id else str(uuid.uuid4()) conversaObj = conversations() - conversationinfo = conversaObj.get(data.user, data.conversation_id) + conversationinfo = conversaObj.get(data.conversation_id) if conversationinfo is None: - conversationinfo = conversaObj.add(data.user, "新建会话", data.conversation_id) + conversationinfo = conversaObj.add(data.conversation_id, data.user, "新建会话") # 生成聊天参数 last_message_content = ChatMessage.from_str(data.query) @@ -372,9 +376,16 @@ async def post_conversations(request: Request, data: ChatRequestData): @v.get("/messages") async def query_messages(user:str, conversation_id:str): - conversation_id = default_conversation_id if conversation_id is None else conversation_id + #conversation_id = default_conversation_id if conversation_id is None else conversation_id datas = [] records = message().gets(user,conversation_id) + if records is None: + return { + "limit": 20, + "has_more": False, + "data": [] + } + for record in records: res = record.dict() res["message_files"] = [] @@ -415,7 +426,7 @@ async def post_conversations(request: Request,itemid:str,params:Dict[str,Any]): return 'null' @v.get("/conversations") -async def query_conversations(user:str): +async def query_conversations(user:str, first_id:str = None, limit:str = None, pinned:str = None): user_id = '' if user is None else user userMng.findNoExistCreate(user_id) diff --git a/backend/app/api/routers/request/base.py b/backend/app/api/routers/request/base.py index b7b2ec8..bb90305 100644 --- a/backend/app/api/routers/request/base.py +++ b/backend/app/api/routers/request/base.py @@ -18,13 +18,13 @@ class conversations: return datas - def get(self,user_id:str,id:str = ''): - records = dbManage.query(self._tableName,user_id = user_id,id=id) + def get(self, id:str): + records = dbManage.query(self._tableName, id=id) if len(records) >0: return records[0] return None - def add(self,user_id:str,name:str,id:str = ''): + def add(self,id:str, user_id:str, name:str): template = BaseConfig.ConversationCfg template['id'] = id template['user_id'] = user_id -- 2.52.0