diff --git a/hugegraph-llm/README.md b/hugegraph-llm/README.md index 4c2a2010c..528463c2e 100644 --- a/hugegraph-llm/README.md +++ b/hugegraph-llm/README.md @@ -58,6 +58,7 @@ graph systems and large language models. 7. After running the web demo, the config file `.env` will be automatically generated at the path `hugegraph-llm/.env`. Additionally, a prompt-related configuration file `config_prompt.yaml` will also be generated at the path `hugegraph-llm/src/hugegraph_llm/resources/demo/config_prompt.yaml`. You can modify the content on the web page, and it will be automatically saved to the configuration file after the corresponding feature is triggered. You can also modify the file directly without restarting the web application; refresh the page to load your latest changes. + The `.env` file is used for setting various environment variables. For a detailed list of configurable environment variables and their descriptions, please see the "Environment Variables for Configuration" section below. (Optional)To regenerate the config file, you can use `config.generate` with `-u` or `--update`. ```bash python -m hugegraph_llm.config.generate --update @@ -74,6 +75,19 @@ graph systems and large language models. > [!TIP] > You can also refer to our [quick-start](https://github.com/apache/incubator-hugegraph-ai/blob/main/hugegraph-llm/quick_start.md) doc to understand how to use it & the basic query logic 🚧 +## Environment Variables for Configuration + +The application can be configured using environment variables, typically set in the `.env` file located in the `hugegraph-llm` directory. This file is automatically generated after running the web demo for the first time. + +Below is a list of variables that can be configured: + +- **`RAG_QUERY_MAX_LENGTH`**: + - **Description**: Sets the maximum character length for user queries submitted to the RAG (Retrieval Augmented Generation) functionalities, including the interactive demo and APIs. Queries exceeding this length will be rejected by the system. + - **Default Value**: `50` + - **Example**: If set to `100`, queries longer than 100 characters will be rejected. + +Many other configuration options related to LLM providers (OpenAI, LiteLLM, Ollama, Qianfan), embedding models, and rerankers are also managed via environment variables in the `.env` file. These are typically set through the web UI and saved to the `.env` file, or can be manually edited. Refer to the `LLMConfig` class in `hugegraph_llm.config.llm_config.py` for a comprehensive list of all possible environment variables. + ## 4 Examples ### 4.1 Build a knowledge graph in HugeGraph through LLM diff --git a/hugegraph-llm/src/hugegraph_llm/api/models/rag_requests.py b/hugegraph-llm/src/hugegraph_llm/api/models/rag_requests.py index 3170e702e..94985e480 100644 --- a/hugegraph-llm/src/hugegraph_llm/api/models/rag_requests.py +++ b/hugegraph-llm/src/hugegraph_llm/api/models/rag_requests.py @@ -18,9 +18,9 @@ from typing import Optional, Literal from fastapi import Query -from pydantic import BaseModel +from pydantic import BaseModel, validator -from hugegraph_llm.config import prompt +from hugegraph_llm.config import prompt, LLMConfig class GraphConfigRequest(BaseModel): @@ -62,6 +62,14 @@ class RAGRequest(BaseModel): description="Prompt for the Text2Gremlin query.", ) + @validator('query') + def check_query_length(cls, value): + llm_config = LLMConfig() + max_len = int(llm_config.rag_query_max_length) + if len(value) > max_len: + raise ValueError(f"Query exceeds maximum allowed length of {max_len} characters.") + return value + # TODO: import the default value of prompt.* dynamically class GraphRAGRequest(BaseModel): @@ -88,6 +96,14 @@ class GraphRAGRequest(BaseModel): description="Prompt for the Text2Gremlin query.", ) + @validator('query') + def check_query_length(cls, value): + llm_config = LLMConfig() + max_len = int(llm_config.rag_query_max_length) + if len(value) > max_len: + raise ValueError(f"Query exceeds maximum allowed length of {max_len} characters.") + return value + class LLMConfigRequest(BaseModel): llm_type: str diff --git a/hugegraph-llm/src/hugegraph_llm/config/llm_config.py b/hugegraph-llm/src/hugegraph_llm/config/llm_config.py index a9b4b2d43..5d8a58546 100644 --- a/hugegraph-llm/src/hugegraph_llm/config/llm_config.py +++ b/hugegraph-llm/src/hugegraph_llm/config/llm_config.py @@ -30,6 +30,7 @@ class LLMConfig(BaseConfig): text2gql_llm_type: Literal["openai", "litellm", "ollama/local", "qianfan_wenxin"] = "openai" embedding_type: Optional[Literal["openai", "litellm", "ollama/local", "qianfan_wenxin"]] = "openai" reranker_type: Optional[Literal["cohere", "siliconflow"]] = None + rag_query_max_length: int = os.environ.get("RAG_QUERY_MAX_LENGTH", 50) # 1. OpenAI settings openai_chat_api_base: Optional[str] = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1") openai_chat_api_key: Optional[str] = os.environ.get("OPENAI_API_KEY") diff --git a/hugegraph-llm/src/hugegraph_llm/demo/rag_demo/rag_block.py b/hugegraph-llm/src/hugegraph_llm/demo/rag_demo/rag_block.py index cc6bb44ea..48e83da26 100644 --- a/hugegraph-llm/src/hugegraph_llm/demo/rag_demo/rag_block.py +++ b/hugegraph-llm/src/hugegraph_llm/demo/rag_demo/rag_block.py @@ -24,7 +24,7 @@ import pandas as pd from gradio.utils import NamedString -from hugegraph_llm.config import resource_path, prompt, huge_settings, llm_settings +from hugegraph_llm.config import resource_path, prompt, huge_settings, llm_settings, LLMConfig from hugegraph_llm.operators.graph_rag_task import RAGPipeline from hugegraph_llm.utils.decorators import with_task_id from hugegraph_llm.operators.llm_op.answer_synthesize import AnswerSynthesize @@ -49,6 +49,16 @@ def rag_answer( vector_dis_threshold=0.9, topk_per_keyword=1, ) -> Tuple: + llm_config = LLMConfig() + max_len = int(llm_config.rag_query_max_length) + query_text = text + if len(query_text) > max_len: + log.warning( + f"Input query exceeds maximum length of {max_len} characters. Query: '{query_text[:100]}...'" + ) + gr.Warning(f"Query is too long! Maximum allowed length is {max_len} characters.") + return "", "", "", "" + """ Generate an answer using the RAG (Retrieval-Augmented Generation) pipeline. 1. Initialize the RAGPipeline. @@ -160,6 +170,17 @@ async def rag_answer_streaming( gremlin_tmpl_num: Optional[int] = -1, gremlin_prompt: Optional[str] = None, ) -> AsyncGenerator[Tuple[str, str, str, str], None]: + llm_config = LLMConfig() + max_len = int(llm_config.rag_query_max_length) + query_text = text + if len(query_text) > max_len: + log.warning( + f"Input query exceeds maximum length of {max_len} characters. Query: '{query_text[:100]}...'" + ) + gr.Warning(f"Query is too long! Maximum allowed length is {max_len} characters.") + yield "", "", "", "" + return + """ Generate an answer using the RAG (Retrieval-Augmented Generation) pipeline. 1. Initialize the RAGPipeline. diff --git a/hugegraph-llm/src/hugegraph_llm/demo/rag_demo/text2gremlin_block.py b/hugegraph-llm/src/hugegraph_llm/demo/rag_demo/text2gremlin_block.py index 0fcc7f7ce..aee23cb05 100644 --- a/hugegraph-llm/src/hugegraph_llm/demo/rag_demo/text2gremlin_block.py +++ b/hugegraph-llm/src/hugegraph_llm/demo/rag_demo/text2gremlin_block.py @@ -22,7 +22,7 @@ import gradio as gr import pandas as pd -from hugegraph_llm.config import prompt, resource_path, huge_settings +from hugegraph_llm.config import prompt, resource_path, huge_settings, LLMConfig from hugegraph_llm.models.embeddings.init_embedding import Embeddings from hugegraph_llm.models.llms.init_llm import LLMs from hugegraph_llm.operators.graph_rag_task import RAGPipeline @@ -194,6 +194,14 @@ def graph_rag_recall( topk_per_keyword: int, get_vertex_only: bool = False, ) -> dict: + llm_config = LLMConfig() + max_len = int(llm_config.rag_query_max_length) + if len(query) > max_len: + log.warning( + f"Input query for graph_rag_recall exceeds maximum length of {max_len} characters. Query: '{query[:100]}...'" + ) + return {"error": f"Query is too long! Maximum allowed length is {max_len} characters.", "graph_result": []} + store_schema(prompt.text2gql_graph_schema, query, gremlin_prompt) rag = RAGPipeline() rag.extract_keywords().keywords_to_vid( diff --git a/hugegraph-llm/src/tests/api/test_rag_api.py b/hugegraph-llm/src/tests/api/test_rag_api.py new file mode 100644 index 000000000..f00396680 --- /dev/null +++ b/hugegraph-llm/src/tests/api/test_rag_api.py @@ -0,0 +1,167 @@ +import unittest +from unittest.mock import patch, MagicMock + +from fastapi import APIRouter, HTTPException, status + +from hugegraph_llm.config.llm_config import LLMConfig +from hugegraph_llm.api.rag_api import rag_http_api +from hugegraph_llm.api.models.rag_requests import RAGRequest, GraphRAGRequest + + +class TestRagApiQueryValidation(unittest.TestCase): + + def setUp(self): + self.router = APIRouter() + self.mock_rag_answer_func = MagicMock() + self.mock_graph_rag_recall_func = MagicMock() + self.mock_apply_graph_conf = MagicMock() + self.mock_apply_llm_conf = MagicMock() + self.mock_apply_embedding_conf = MagicMock() + self.mock_apply_reranker_conf = MagicMock() + + rag_http_api( + self.router, + self.mock_rag_answer_func, + self.mock_graph_rag_recall_func, + self.mock_apply_graph_conf, + self.mock_apply_llm_conf, + self.mock_apply_embedding_conf, + self.mock_apply_reranker_conf, + ) + + def get_endpoint_function(self, path: str): + for route in self.router.routes: + if route.path == path: + return route.endpoint + raise ValueError(f"Route {path} not found") + + @patch('hugegraph_llm.api.models.rag_requests.LLMConfig') + def test_rag_answer_api_query_too_long(self, mock_llm_config_pydantic): + rag_answer_api_endpoint = self.get_endpoint_function("/rag") + + mock_llm_config_instance = mock_llm_config_pydantic.return_value + mock_llm_config_instance.rag_query_max_length = 10 + + # This will be validated by Pydantic before the endpoint logic is hit + with self.assertRaises(HTTPException) as cm: + # Directly instantiating RAGRequest with invalid data won't raise HTTPException here, + # FastAPI does this when processing the request. + # To simulate FastAPI's behavior, we assume the endpoint is called with data + # that *would* cause Pydantic to fail during request body parsing. + # The actual RAGRequest instantiation happens inside FastAPI's request handling. + # For a unit test, we are directly calling the endpoint function. + # Pydantic validation for path/query/body parameters is typically handled by + # FastAPI's request parsing layer *before* the endpoint function is called. + # However, if the endpoint function itself receives the raw request model and + # Pydantic validation happens upon model instantiation *within* the endpoint, + # then the test structure is fine. Given the current structure of FastAPI, + # the validation for RAGRequest happens *before* rag_answer_api is called. + # This test simulates the state *after* FastAPI has parsed and validated, + # and if validation failed, it would have raised HTTPException(422). + # Since we are calling the function directly, we must ensure the Pydantic model + # itself raises an error that FastAPI would catch and convert to 422. + # Let's assume the endpoint *receives* an already validated model or the validation + # is part of the endpoint for this test to make sense as written. + # The instructions imply Pydantic validation in the model will lead to a 422. + # This means FastAPI's handling of Pydantic's ValueError. + # We will construct the request, and the endpoint call will internally trigger validation + # if the model is instantiated there, or FastAPI handles it if passed as type hint. + # For this test to be accurate to FastAPI behavior for request body validation: + # We should not expect to catch HTTPException directly from RAGRequest instantiation here. + # Instead, the endpoint call should be the one raising it due to FastAPI's processing. + # The current test structure where `rag_answer_api_endpoint(req)` is called is correct + # if we assume FastAPI passes a validated model or the model instantiation happens inside. + # Given Pydantic validator raises ValueError, FastAPI converts this to HTTP 422. + + # Simulate calling the endpoint which would trigger Pydantic validation via FastAPI + # For the purpose of this unit test, we'll assume the Pydantic model validation + # error (ValueError) is caught by FastAPI and results in an HTTPException(422). + # Since we call the endpoint function directly, we need to simulate this. + # The most direct way to test the Pydantic validator itself is to instantiate the model. + try: + RAGRequest(query="This is a very long query that exceeds the limit.") + except ValueError as e: # Pydantic validator raises ValueError + # FastAPI would catch this and convert it to HTTPException 422 + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e)) + + + self.assertEqual(cm.exception.status_code, status.HTTP_422_UNPROCESSABLE_ENTITY) + error_detail_str = str(cm.exception.detail) + self.assertIn("Query exceeds maximum allowed length", error_detail_str) + # Ensure the actual function was not called + self.mock_rag_answer_func.assert_not_called() + + @patch('hugegraph_llm.api.models.rag_requests.LLMConfig') + def test_rag_answer_api_query_within_limit(self, mock_llm_config_pydantic): + rag_answer_api_endpoint = self.get_endpoint_function("/rag") + + mock_llm_config_instance = mock_llm_config_pydantic.return_value + mock_llm_config_instance.rag_query_max_length = 50 + + # Provide default values for all required fields of RAGRequest + # Pydantic model will use the mocked LLMConfig during instantiation + req = RAGRequest( + query="Short query.", + raw_answer=True # ensure at least one answer type is requested + ) + + # Define return value for the mocked function + # Assuming it returns a tuple of 4 strings based on previous test structure + self.mock_rag_answer_func.return_value = ("raw_res", "vector_res", "graph_res", "gv_res") + + response = rag_answer_api_endpoint(req) + + self.mock_rag_answer_func.assert_called_once() + # Check if the response is structured as expected + # Based on rag_api.py, it returns a dict including the query and results for requested answer types + self.assertIn("query", response) + self.assertEqual(response["query"], "Short query.") + self.assertIn("raw_answer", response) # since req.raw_answer = True + self.assertEqual(response["raw_answer"], "raw_res") + + @patch('hugegraph_llm.api.models.rag_requests.LLMConfig') + def test_graph_rag_recall_api_query_too_long(self, mock_llm_config_pydantic): + graph_rag_recall_api_endpoint = self.get_endpoint_function("/rag/graph") + + mock_llm_config_instance = mock_llm_config_pydantic.return_value + mock_llm_config_instance.rag_query_max_length = 10 + + with self.assertRaises(HTTPException) as cm: + # Similar to the above, simulating FastAPI's handling of Pydantic ValueError + try: + GraphRAGRequest(query="This is a very long query for graph recall that exceeds limit.") + except ValueError as e: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e)) + + + self.assertEqual(cm.exception.status_code, status.HTTP_422_UNPROCESSABLE_ENTITY) + error_detail_str = str(cm.exception.detail) + self.assertIn("query", error_detail_str) + self.assertIn("Query exceeds maximum allowed length", error_detail_str) + self.mock_graph_rag_recall_func.assert_not_called() + + @patch('hugegraph_llm.api.models.rag_requests.LLMConfig') + def test_graph_rag_recall_api_query_within_limit(self, mock_llm_config_pydantic): + graph_rag_recall_api_endpoint = self.get_endpoint_function("/rag/graph") + + mock_llm_config_instance = mock_llm_config_pydantic.return_value + mock_llm_config_instance.rag_query_max_length = 50 + + req = GraphRAGRequest(query="Short graph query.") + + expected_recall_result = {"keywords": ["short", "graph", "query"], "match_vids": ["id1"]} + self.mock_graph_rag_recall_func.return_value = expected_recall_result + + response = graph_rag_recall_api_endpoint(req) + + self.mock_graph_rag_recall_func.assert_called_once() + # Based on rag_api.py, the response is {"graph_recall": user_result} + self.assertIn("graph_recall", response) + # The user_result filters only specific keys, ensure they are present + self.assertIn("keywords", response["graph_recall"]) + self.assertEqual(response["graph_recall"]["keywords"], expected_recall_result["keywords"]) + self.assertIn("match_vids", response["graph_recall"]) + self.assertEqual(response["graph_recall"]["match_vids"], expected_recall_result["match_vids"]) + +if __name__ == '__main__': + unittest.main() diff --git a/hugegraph-llm/src/tests/demo/rag_demo/test_rag_block.py b/hugegraph-llm/src/tests/demo/rag_demo/test_rag_block.py new file mode 100644 index 000000000..802869b53 --- /dev/null +++ b/hugegraph-llm/src/tests/demo/rag_demo/test_rag_block.py @@ -0,0 +1,143 @@ +import unittest +from unittest.mock import patch, MagicMock, AsyncMock + +import gradio as gr # Import for type hinting, will be mocked + +from hugegraph_llm.config.llm_config import LLMConfig +from hugegraph_llm.demo.rag_demo.rag_block import rag_answer, rag_answer_streaming + + +class TestRagBlockQueryValidation(unittest.TestCase): + + def _get_common_rag_args(self): + return { + "raw_answer": False, + "vector_only_answer": False, + "graph_only_answer": True, + "graph_vector_answer": False, + "graph_ratio": 0.6, + "rerank_method": "bleu", + "near_neighbor_first": False, + "custom_related_information": "custom_info", + "answer_prompt": "answer_prompt_template", + "keywords_extract_prompt": "keywords_extract_template", + "gremlin_tmpl_num": -1, + "gremlin_prompt": "gremlin_prompt_template", + } + + @patch('hugegraph_llm.demo.rag_demo.rag_block.log') + @patch('hugegraph_llm.demo.rag_demo.rag_block.gr.Warning') + @patch('hugegraph_llm.demo.rag_demo.rag_block.LLMConfig') + def test_rag_answer_query_too_long(self, mock_llm_config, mock_gr_warning, mock_log): + mock_llm_config_instance = mock_llm_config.return_value + mock_llm_config_instance.rag_query_max_length = 10 + + query_text = "This is a very long query that exceeds the limit." + args = self._get_common_rag_args() + + result = rag_answer(text=query_text, **args) + + mock_gr_warning.assert_called_once_with( + "Query is too long! Maximum allowed length is 10 characters." + ) + mock_log.warning.assert_called_once() + self.assertEqual(result, ("", "", "", "")) + + @patch('hugegraph_llm.demo.rag_demo.rag_block.RAGPipeline') + @patch('hugegraph_llm.demo.rag_demo.rag_block.log') # Mock log to avoid side effects if any part of RAGPipeline call logs + @patch('hugegraph_llm.demo.rag_demo.rag_block.gr.Warning') + @patch('hugegraph_llm.demo.rag_demo.rag_block.LLMConfig') + def test_rag_answer_query_within_limit(self, mock_llm_config, mock_gr_warning, mock_log, mock_rag_pipeline): + mock_llm_config_instance = mock_llm_config.return_value + mock_llm_config_instance.rag_query_max_length = 50 + + # Configure mock RAGPipeline + mock_pipeline_instance = mock_rag_pipeline.return_value + mock_pipeline_instance.run = MagicMock(return_value={ + "raw_answer": "raw", + "vector_only_answer": "vector", + "graph_only_answer": "graph", + "graph_vector_answer": "graph_vector" + }) + + query_text = "Short query." + args = self._get_common_rag_args() + + # Call the function + result = rag_answer(text=query_text, **args) + + # Assertions + mock_gr_warning.assert_not_called() + # Check if RAGPipeline was instantiated and its methods called as expected + mock_rag_pipeline.assert_called_once() + mock_pipeline_instance.run.assert_called_once() + # Check returned values based on mocked RAGPipeline + self.assertEqual(result, ("raw", "vector", "graph", "graph_vector")) + + + @patch('hugegraph_llm.demo.rag_demo.rag_block.log') + @patch('hugegraph_llm.demo.rag_demo.rag_block.gr.Warning') + @patch('hugegraph_llm.demo.rag_demo.rag_block.LLMConfig') + async def test_rag_answer_streaming_query_too_long(self, mock_llm_config, mock_gr_warning, mock_log): + mock_llm_config_instance = mock_llm_config.return_value + mock_llm_config_instance.rag_query_max_length = 10 + + query_text = "This is a very long query that exceeds the limit for streaming." + args = self._get_common_rag_args() + + results_collected = [] + async for res_tuple in rag_answer_streaming(text=query_text, **args): + results_collected.append(res_tuple) + + mock_gr_warning.assert_called_once_with( + "Query is too long! Maximum allowed length is 10 characters." + ) + mock_log.warning.assert_called_once() + self.assertEqual(len(results_collected), 1) + self.assertEqual(results_collected[0], ("", "", "", "")) + + @patch('hugegraph_llm.demo.rag_demo.rag_block.AnswerSynthesize') + @patch('hugegraph_llm.demo.rag_demo.rag_block.RAGPipeline') + @patch('hugegraph_llm.demo.rag_demo.rag_block.log') + @patch('hugegraph_llm.demo.rag_demo.rag_block.gr.Warning') + @patch('hugegraph_llm.demo.rag_demo.rag_block.LLMConfig') + async def test_rag_answer_streaming_query_within_limit( + self, mock_llm_config, mock_gr_warning, mock_log, mock_rag_pipeline, mock_answer_synthesize + ): + mock_llm_config_instance = mock_llm_config.return_value + mock_llm_config_instance.rag_query_max_length = 50 + + # Configure mock RAGPipeline + mock_pipeline_instance = mock_rag_pipeline.return_value + mock_pipeline_instance.run = MagicMock(return_value={"some_context_key": "some_value"}) # RAGPipeline.run is not async + + # Configure mock AnswerSynthesize + mock_synthesize_instance = mock_answer_synthesize.return_value + # Make run_streaming an async generator mock + async def mock_streaming_results(*args, **kwargs): + yield { + "raw_answer": "s_raw", + "vector_only_answer": "s_vector", + "graph_only_answer": "s_graph", + "graph_vector_answer": "s_graph_vector" + } + mock_synthesize_instance.run_streaming = mock_streaming_results + + query_text = "Short query." + args = self._get_common_rag_args() + + results_collected = [] + async for res_tuple in rag_answer_streaming(text=query_text, **args): + results_collected.append(res_tuple) + + mock_gr_warning.assert_not_called() + mock_rag_pipeline.assert_called_once() + mock_pipeline_instance.run.assert_called_once() + mock_answer_synthesize.assert_called_once() + # mock_synthesize_instance.run_streaming.assert_called_once() # This is harder to check for async generator directly + + self.assertEqual(len(results_collected), 1) + self.assertEqual(results_collected[0], ("s_raw", "s_vector", "s_graph", "s_graph_vector")) + +if __name__ == '__main__': + unittest.main()