From 3d706dc422d3911cdfc7d34b7b78f91cca2b033c Mon Sep 17 00:00:00 2001 From: kimbwook Date: Mon, 18 Nov 2024 21:38:10 +0900 Subject: [PATCH 01/55] just commit --- autorag/vectordb/couchbase.py | 162 ++++++++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 autorag/vectordb/couchbase.py diff --git a/autorag/vectordb/couchbase.py b/autorag/vectordb/couchbase.py new file mode 100644 index 000000000..cbfda3bc2 --- /dev/null +++ b/autorag/vectordb/couchbase.py @@ -0,0 +1,162 @@ +import logging + + +from typing import List, Optional, Tuple + +from autorag.vectordb import BaseVectorStore + +logger = logging.getLogger("AutoRAG") + + +class Couchbase(BaseVectorStore): + def __init__( + self, + embedding_model: str, + collection_name: str, + embedding_batch: int = 100, + similarity_metric: str = "cosine", + client_type: str = "docker", + host: str = "localhost", + port: int = 8080, + grpc_port: int = 50051, + url: Optional[str] = None, + api_key: Optional[str] = None, + text_key: str = "content", + ): + super().__init__(embedding_model, similarity_metric, embedding_batch) + + self.text_key = text_key + + if client_type == "docker": + self.client = weaviate.connect_to_local( + host=host, + port=port, + grpc_port=grpc_port, + ) + elif client_type == "cloud": + self.client = weaviate.connect_to_weaviate_cloud( + cluster_url=url, + auth_credentials=Auth.api_key(api_key), + ) + else: + raise ValueError( + f"client_type {client_type} is not supported\n" + "supported client types are: docker, cloud" + ) + if similarity_metric == "cosine": + distance_metric = wvc.config.VectorDistances.COSINE + elif similarity_metric == "ip": + distance_metric = wvc.config.VectorDistances.DOT + elif similarity_metric == "l2": + distance_metric = wvc.config.VectorDistances.L2_SQUARED + else: + raise ValueError( + f"similarity_metric {similarity_metric} is not supported\n" + "supported similarity metrics are: cosine, ip, l2" + ) + + if not self.client.collections.exists(collection_name): + self.client.collections.create( + collection_name, + properties=[ + Property( + name="content", data_type=DataType.TEXT, skip_vectorization=True + ), + ], + vectorizer_config=wvc.config.Configure.Vectorizer.none(), + vector_index_config=wvc.config.Configure.VectorIndex.hnsw( # hnsw, flat, dynamic, + distance_metric=distance_metric + ), + ) + self.collection = self.client.collections.get(collection_name) + self.collection_name = collection_name + + async def add(self, ids: List[str], texts: List[str]): + texts = self.truncated_inputs(texts) + text_embeddings = await self.embedding.aget_text_embedding_batch(texts) + + with self.client.batch.dynamic() as batch: + for i, text in enumerate(texts): + data_properties = {self.text_key: text} + + batch.add_object( + collection=self.collection_name, + properties=data_properties, + uuid=ids[i], + vector=text_embeddings[i], + ) + + failed_objs = self.client.batch.failed_objects + for obj in failed_objs: + err_message = ( + f"Failed to add object: {obj.original_uuid}\nReason: {obj.message}" + ) + + logger.error(err_message) + + async def fetch(self, ids: List[str]) -> List[List[float]]: + # Fetch vectors by IDs + results = self.collection.query.fetch_objects( + filters=wvc.query.Filter.by_property("_id").contains_any(ids), + include_vector=True, + ) + id_vector_dict = { + str(object.uuid): object.vector["default"] for object in results.objects + } + result = [id_vector_dict[_id] for _id in ids] + return result + + async def is_exist(self, ids: List[str]) -> List[bool]: + fetched_result = self.collection.query.fetch_objects( + filters=wvc.query.Filter.by_property("_id").contains_any(ids), + ) + existed_ids = [str(result.uuid) for result in fetched_result.objects] + return list(map(lambda x: x in existed_ids, ids)) + + async def query( + self, queries: List[str], top_k: int, **kwargs + ) -> Tuple[List[List[str]], List[List[float]]]: + queries = self.truncated_inputs(queries) + query_embeddings: List[ + List[float] + ] = await self.embedding.aget_text_embedding_batch(queries) + + ids, scores = [], [] + for query_embedding in query_embeddings: + response = self.collection.query.near_vector( + near_vector=query_embedding, + limit=top_k, + return_metadata=MetadataQuery(distance=True), + ) + + ids.append([o.uuid for o in response.objects]) + scores.append( + [ + distance_to_score(o.metadata.distance, self.similarity_metric) + for o in response.objects + ] + ) + + return ids, scores + + async def delete(self, ids: List[str]): + filter = wvc.query.Filter.by_id().contains_any(ids) + self.collection.data.delete_many(where=filter) + + def delete_collection(self): + # Delete the collection + self.client.collections.delete(self.collection_name) + + +def distance_to_score(distance: float, similarity_metric) -> float: + if similarity_metric == "cosine": + return 1 - distance + elif similarity_metric == "ip": + return -distance + elif similarity_metric == "l2": + return -distance + else: + raise ValueError( + f"similarity_metric {similarity_metric} is not supported\n" + "supported similarity metrics are: cosine, ip, l2" + ) From deb5d659a8fb8665553349bfc3343fdf6be601ac Mon Sep 17 00:00:00 2001 From: kimbwook Date: Mon, 18 Nov 2024 23:19:18 +0900 Subject: [PATCH 02/55] just commit --- autorag/vectordb/couchbase.py | 67 ++++++++--------------------------- 1 file changed, 15 insertions(+), 52 deletions(-) diff --git a/autorag/vectordb/couchbase.py b/autorag/vectordb/couchbase.py index cbfda3bc2..3ba4e87c9 100644 --- a/autorag/vectordb/couchbase.py +++ b/autorag/vectordb/couchbase.py @@ -1,7 +1,13 @@ import logging +from datetime import timedelta -from typing import List, Optional, Tuple + +from couchbase.auth import PasswordAuthenticator +from couchbase.cluster import Cluster +from couchbase.options import ClusterOptions + +from typing import List, Tuple from autorag.vectordb import BaseVectorStore @@ -15,61 +21,18 @@ def __init__( collection_name: str, embedding_batch: int = 100, similarity_metric: str = "cosine", - client_type: str = "docker", - host: str = "localhost", - port: int = 8080, - grpc_port: int = 50051, - url: Optional[str] = None, - api_key: Optional[str] = None, - text_key: str = "content", + connection_string: str = "couchbase://localhost", + username: str = "", + password: str = "", + batch_size: int = 100, ): super().__init__(embedding_model, similarity_metric, embedding_batch) - self.text_key = text_key - - if client_type == "docker": - self.client = weaviate.connect_to_local( - host=host, - port=port, - grpc_port=grpc_port, - ) - elif client_type == "cloud": - self.client = weaviate.connect_to_weaviate_cloud( - cluster_url=url, - auth_credentials=Auth.api_key(api_key), - ) - else: - raise ValueError( - f"client_type {client_type} is not supported\n" - "supported client types are: docker, cloud" - ) - if similarity_metric == "cosine": - distance_metric = wvc.config.VectorDistances.COSINE - elif similarity_metric == "ip": - distance_metric = wvc.config.VectorDistances.DOT - elif similarity_metric == "l2": - distance_metric = wvc.config.VectorDistances.L2_SQUARED - else: - raise ValueError( - f"similarity_metric {similarity_metric} is not supported\n" - "supported similarity metrics are: cosine, ip, l2" - ) + auth = PasswordAuthenticator(username, password) + self.cluster = Cluster(connection_string, ClusterOptions(auth)) - if not self.client.collections.exists(collection_name): - self.client.collections.create( - collection_name, - properties=[ - Property( - name="content", data_type=DataType.TEXT, skip_vectorization=True - ), - ], - vectorizer_config=wvc.config.Configure.Vectorizer.none(), - vector_index_config=wvc.config.Configure.VectorIndex.hnsw( # hnsw, flat, dynamic, - distance_metric=distance_metric - ), - ) - self.collection = self.client.collections.get(collection_name) - self.collection_name = collection_name + # Wait until the cluster is ready for use. + self.cluster.wait_until_ready(timedelta(seconds=5)) async def add(self, ids: List[str], texts: List[str]): texts = self.truncated_inputs(texts) From b92172d4a168300d95f0995cc418b78332d65aae Mon Sep 17 00:00:00 2001 From: jeffrey Date: Tue, 19 Nov 2024 15:05:30 +0800 Subject: [PATCH 03/55] add the root directory --- .dockerignore | 4 ++-- .gitignore | 4 ++-- README.md | 2 +- Dockerfile.base => autorag/Dockerfile.base | 2 +- Dockerfile.gpu => autorag/Dockerfile.gpu | 0 autorag/{ => autorag}/VERSION | 0 autorag/{ => autorag}/__init__.py | 0 autorag/{ => autorag}/chunker.py | 0 autorag/{ => autorag}/cli.py | 0 autorag/{ => autorag}/dashboard.py | 0 autorag/{ => autorag}/data/__init__.py | 0 autorag/{ => autorag}/data/chunk/__init__.py | 0 autorag/{ => autorag}/data/chunk/base.py | 0 autorag/{ => autorag}/data/chunk/langchain_chunk.py | 0 autorag/{ => autorag}/data/chunk/llama_index_chunk.py | 0 autorag/{ => autorag}/data/chunk/run.py | 0 autorag/{ => autorag}/data/legacy/__init__.py | 0 autorag/{ => autorag}/data/legacy/corpus/__init__.py | 0 autorag/{ => autorag}/data/legacy/corpus/langchain.py | 0 autorag/{ => autorag}/data/legacy/corpus/llama_index.py | 0 autorag/{ => autorag}/data/legacy/qacreation/__init__.py | 0 autorag/{ => autorag}/data/legacy/qacreation/base.py | 0 .../{ => autorag}/data/legacy/qacreation/llama_index.py | 0 .../data/legacy/qacreation/llama_index_default_prompt.txt | 0 autorag/{ => autorag}/data/legacy/qacreation/ragas.py | 0 autorag/{ => autorag}/data/legacy/qacreation/simple.py | 0 autorag/{ => autorag}/data/parse/__init__.py | 0 autorag/{ => autorag}/data/parse/base.py | 0 autorag/{ => autorag}/data/parse/clova.py | 0 autorag/{ => autorag}/data/parse/langchain_parse.py | 0 autorag/{ => autorag}/data/parse/llamaparse.py | 0 autorag/{ => autorag}/data/parse/run.py | 0 autorag/{ => autorag}/data/parse/table_hybrid_parse.py | 0 autorag/{ => autorag}/data/qa/__init__.py | 0 autorag/{ => autorag}/data/qa/evolve/__init__.py | 0 .../data/qa/evolve/llama_index_query_evolve.py | 0 .../{ => autorag}/data/qa/evolve/openai_query_evolve.py | 0 autorag/{ => autorag}/data/qa/evolve/prompt.py | 0 autorag/{ => autorag}/data/qa/extract_evidence.py | 0 autorag/{ => autorag}/data/qa/filter/__init__.py | 0 autorag/{ => autorag}/data/qa/filter/dontknow.py | 0 .../{ => autorag}/data/qa/filter/passage_dependency.py | 0 autorag/{ => autorag}/data/qa/filter/prompt.py | 0 autorag/{ => autorag}/data/qa/generation_gt/__init__.py | 0 autorag/{ => autorag}/data/qa/generation_gt/base.py | 0 .../data/qa/generation_gt/llama_index_gen_gt.py | 0 .../{ => autorag}/data/qa/generation_gt/openai_gen_gt.py | 0 autorag/{ => autorag}/data/qa/generation_gt/prompt.py | 0 autorag/{ => autorag}/data/qa/query/__init__.py | 0 autorag/{ => autorag}/data/qa/query/llama_gen_query.py | 0 autorag/{ => autorag}/data/qa/query/openai_gen_query.py | 0 autorag/{ => autorag}/data/qa/query/prompt.py | 0 autorag/{ => autorag}/data/qa/sample.py | 0 autorag/{ => autorag}/data/qa/schema.py | 0 autorag/{ => autorag}/data/utils/__init__.py | 0 autorag/{ => autorag}/data/utils/util.py | 0 autorag/{ => autorag}/deploy/__init__.py | 0 autorag/{ => autorag}/deploy/api.py | 0 autorag/{ => autorag}/deploy/base.py | 0 autorag/{ => autorag}/deploy/gradio.py | 0 autorag/{ => autorag}/deploy/swagger.yml | 0 autorag/{ => autorag}/evaluation/__init__.py | 0 autorag/{ => autorag}/evaluation/generation.py | 0 autorag/{ => autorag}/evaluation/metric/__init__.py | 0 .../{ => autorag}/evaluation/metric/deepeval_prompt.py | 0 .../evaluation/metric/g_eval_prompts/coh_detailed.txt | 2 +- .../evaluation/metric/g_eval_prompts/con_detailed.txt | 8 ++++---- .../evaluation/metric/g_eval_prompts/flu_detailed.txt | 2 +- .../evaluation/metric/g_eval_prompts/rel_detailed.txt | 2 +- autorag/{ => autorag}/evaluation/metric/generation.py | 0 autorag/{ => autorag}/evaluation/metric/retrieval.py | 0 .../{ => autorag}/evaluation/metric/retrieval_contents.py | 0 autorag/{ => autorag}/evaluation/metric/util.py | 0 autorag/{ => autorag}/evaluation/retrieval.py | 0 autorag/{ => autorag}/evaluation/retrieval_contents.py | 0 autorag/{ => autorag}/evaluation/util.py | 0 autorag/{ => autorag}/evaluator.py | 0 autorag/{ => autorag}/node_line.py | 0 autorag/{ => autorag}/nodes/__init__.py | 0 autorag/{ => autorag}/nodes/generator/__init__.py | 0 autorag/{ => autorag}/nodes/generator/base.py | 0 autorag/{ => autorag}/nodes/generator/llama_index_llm.py | 0 autorag/{ => autorag}/nodes/generator/openai_llm.py | 0 autorag/{ => autorag}/nodes/generator/run.py | 0 autorag/{ => autorag}/nodes/generator/vllm.py | 0 autorag/{ => autorag}/nodes/passageaugmenter/__init__.py | 0 autorag/{ => autorag}/nodes/passageaugmenter/base.py | 0 .../nodes/passageaugmenter/pass_passage_augmenter.py | 0 .../nodes/passageaugmenter/prev_next_augmenter.py | 0 autorag/{ => autorag}/nodes/passageaugmenter/run.py | 0 autorag/{ => autorag}/nodes/passagecompressor/__init__.py | 0 autorag/{ => autorag}/nodes/passagecompressor/base.py | 0 .../nodes/passagecompressor/longllmlingua.py | 0 .../nodes/passagecompressor/pass_compressor.py | 0 autorag/{ => autorag}/nodes/passagecompressor/refine.py | 0 autorag/{ => autorag}/nodes/passagecompressor/run.py | 0 .../nodes/passagecompressor/tree_summarize.py | 0 autorag/{ => autorag}/nodes/passagefilter/__init__.py | 0 autorag/{ => autorag}/nodes/passagefilter/base.py | 0 .../nodes/passagefilter/pass_passage_filter.py | 0 .../nodes/passagefilter/percentile_cutoff.py | 0 autorag/{ => autorag}/nodes/passagefilter/recency.py | 0 autorag/{ => autorag}/nodes/passagefilter/run.py | 0 .../nodes/passagefilter/similarity_percentile_cutoff.py | 0 .../nodes/passagefilter/similarity_threshold_cutoff.py | 0 .../{ => autorag}/nodes/passagefilter/threshold_cutoff.py | 0 autorag/{ => autorag}/nodes/passagereranker/__init__.py | 0 autorag/{ => autorag}/nodes/passagereranker/base.py | 0 autorag/{ => autorag}/nodes/passagereranker/cohere.py | 0 autorag/{ => autorag}/nodes/passagereranker/colbert.py | 0 .../{ => autorag}/nodes/passagereranker/flag_embedding.py | 0 .../nodes/passagereranker/flag_embedding_llm.py | 0 autorag/{ => autorag}/nodes/passagereranker/flashrank.py | 0 autorag/{ => autorag}/nodes/passagereranker/jina.py | 0 autorag/{ => autorag}/nodes/passagereranker/koreranker.py | 0 .../{ => autorag}/nodes/passagereranker/mixedbreadai.py | 0 autorag/{ => autorag}/nodes/passagereranker/monot5.py | 0 autorag/{ => autorag}/nodes/passagereranker/openvino.py | 0 .../{ => autorag}/nodes/passagereranker/pass_reranker.py | 0 autorag/{ => autorag}/nodes/passagereranker/rankgpt.py | 0 autorag/{ => autorag}/nodes/passagereranker/run.py | 0 .../nodes/passagereranker/sentence_transformer.py | 0 .../{ => autorag}/nodes/passagereranker/tart/__init__.py | 0 .../nodes/passagereranker/tart/modeling_enc_t5.py | 0 autorag/{ => autorag}/nodes/passagereranker/tart/tart.py | 0 .../nodes/passagereranker/tart/tokenization_enc_t5.py | 0 .../{ => autorag}/nodes/passagereranker/time_reranker.py | 0 autorag/{ => autorag}/nodes/passagereranker/upr.py | 0 autorag/{ => autorag}/nodes/passagereranker/voyageai.py | 0 autorag/{ => autorag}/nodes/promptmaker/__init__.py | 0 autorag/{ => autorag}/nodes/promptmaker/base.py | 0 autorag/{ => autorag}/nodes/promptmaker/fstring.py | 0 .../nodes/promptmaker/long_context_reorder.py | 0 autorag/{ => autorag}/nodes/promptmaker/run.py | 0 .../{ => autorag}/nodes/promptmaker/window_replacement.py | 0 autorag/{ => autorag}/nodes/queryexpansion/__init__.py | 0 autorag/{ => autorag}/nodes/queryexpansion/base.py | 0 autorag/{ => autorag}/nodes/queryexpansion/hyde.py | 0 .../nodes/queryexpansion/multi_query_expansion.py | 0 .../nodes/queryexpansion/pass_query_expansion.py | 0 .../{ => autorag}/nodes/queryexpansion/query_decompose.py | 0 autorag/{ => autorag}/nodes/queryexpansion/run.py | 0 autorag/{ => autorag}/nodes/retrieval/__init__.py | 0 autorag/{ => autorag}/nodes/retrieval/base.py | 0 autorag/{ => autorag}/nodes/retrieval/bm25.py | 0 autorag/{ => autorag}/nodes/retrieval/hybrid_cc.py | 0 autorag/{ => autorag}/nodes/retrieval/hybrid_rrf.py | 0 autorag/{ => autorag}/nodes/retrieval/run.py | 0 autorag/{ => autorag}/nodes/retrieval/vectordb.py | 0 autorag/{ => autorag}/nodes/util.py | 0 autorag/{ => autorag}/parser.py | 0 autorag/{ => autorag}/schema/__init__.py | 0 autorag/{ => autorag}/schema/base.py | 0 autorag/{ => autorag}/schema/metricinput.py | 0 autorag/{ => autorag}/schema/module.py | 0 autorag/{ => autorag}/schema/node.py | 0 autorag/{ => autorag}/strategy.py | 0 autorag/{ => autorag}/support.py | 0 autorag/{ => autorag}/utils/__init__.py | 0 autorag/{ => autorag}/utils/preprocess.py | 0 autorag/{ => autorag}/utils/util.py | 0 autorag/{ => autorag}/validator.py | 0 autorag/{ => autorag}/vectordb/__init__.py | 0 autorag/{ => autorag}/vectordb/base.py | 0 autorag/{ => autorag}/vectordb/chroma.py | 0 autorag/{ => autorag}/vectordb/couchbase.py | 0 autorag/{ => autorag}/vectordb/milvus.py | 0 autorag/{ => autorag}/vectordb/pinecone.py | 0 autorag/{ => autorag}/vectordb/weaviate.py | 0 autorag/{ => autorag}/web.py | 0 build_and_push.sh => autorag/build_and_push.sh | 0 docker-compose.yml => autorag/docker-compose.yml | 0 {projects => autorag/projects}/tutorial_1/config.yaml | 0 pyproject.toml => autorag/pyproject.toml | 0 requirements.txt => autorag/requirements.txt | 0 .../sample_config}/chunk/chunk_full.yaml | 0 .../sample_config}/chunk/chunk_ko.yaml | 0 .../sample_config}/chunk/simple_chunk.yaml | 0 .../sample_config}/parse/parse_full.yaml | 0 .../sample_config}/parse/parse_hybird.yaml | 0 .../sample_config}/parse/parse_ko.yaml | 0 .../sample_config}/parse/parse_multimodal.yaml | 0 .../sample_config}/parse/parse_ocr.yaml | 0 .../sample_config}/parse/simple_parse.yaml | 0 .../sample_config}/rag/english/gpu/compact_local.yaml | 0 .../sample_config}/rag/english/gpu/compact_openai.yaml | 0 .../sample_config}/rag/english/gpu/full.yaml | 0 .../sample_config}/rag/english/gpu/half.yaml | 0 .../sample_config}/rag/english/gpu_api/compact.yaml | 0 .../sample_config}/rag/english/gpu_api/full.yaml | 0 .../sample_config}/rag/english/gpu_api/half.yaml | 0 .../sample_config}/rag/english/non_gpu/compact.yaml | 0 .../sample_config}/rag/english/non_gpu/full.yaml | 0 .../sample_config}/rag/english/non_gpu/half.yaml | 0 .../rag/english/non_gpu/simple_bedrock.yaml | 0 .../sample_config}/rag/english/non_gpu/simple_local.yaml | 0 .../sample_config}/rag/english/non_gpu/simple_ollama.yaml | 0 .../sample_config}/rag/english/non_gpu/simple_openai.yaml | 0 .../sample_config}/rag/extracted_sample.yaml | 0 {sample_config => autorag/sample_config}/rag/full.yaml | 0 .../sample_config}/rag/korean/gpu/compact_korean.yaml | 0 .../sample_config}/rag/korean/gpu/full_korean.yaml | 0 .../sample_config}/rag/korean/gpu/half_korean.yaml | 0 .../sample_config}/rag/korean/gpu_api/compact_korean.yaml | 0 .../sample_config}/rag/korean/gpu_api/full_korean.yaml | 0 .../sample_config}/rag/korean/gpu_api/half_korean.yaml | 0 .../sample_config}/rag/korean/non_gpu/compact_korean.yaml | 0 .../sample_config}/rag/korean/non_gpu/full_korean.yaml | 0 .../sample_config}/rag/korean/non_gpu/half_korean.yaml | 0 .../sample_config}/rag/korean/non_gpu/simple_korean.yaml | 0 {sample_dataset => autorag/sample_dataset}/README.md | 2 +- .../sample_dataset}/eli5/load_eli5_dataset.py | 0 .../sample_dataset}/hotpotqa/load_hotpotqa_dataset.py | 0 .../sample_dataset}/msmarco/load_msmarco_dataset.py | 0 .../sample_dataset}/triviaqa/load_triviaqa_dataset.py | 0 215 files changed, 14 insertions(+), 14 deletions(-) rename Dockerfile.base => autorag/Dockerfile.base (97%) rename Dockerfile.gpu => autorag/Dockerfile.gpu (100%) rename autorag/{ => autorag}/VERSION (100%) rename autorag/{ => autorag}/__init__.py (100%) rename autorag/{ => autorag}/chunker.py (100%) rename autorag/{ => autorag}/cli.py (100%) rename autorag/{ => autorag}/dashboard.py (100%) rename autorag/{ => autorag}/data/__init__.py (100%) rename autorag/{ => autorag}/data/chunk/__init__.py (100%) rename autorag/{ => autorag}/data/chunk/base.py (100%) rename autorag/{ => autorag}/data/chunk/langchain_chunk.py (100%) rename autorag/{ => autorag}/data/chunk/llama_index_chunk.py (100%) rename autorag/{ => autorag}/data/chunk/run.py (100%) rename autorag/{ => autorag}/data/legacy/__init__.py (100%) rename autorag/{ => autorag}/data/legacy/corpus/__init__.py (100%) rename autorag/{ => autorag}/data/legacy/corpus/langchain.py (100%) rename autorag/{ => autorag}/data/legacy/corpus/llama_index.py (100%) rename autorag/{ => autorag}/data/legacy/qacreation/__init__.py (100%) rename autorag/{ => autorag}/data/legacy/qacreation/base.py (100%) rename autorag/{ => autorag}/data/legacy/qacreation/llama_index.py (100%) rename autorag/{ => autorag}/data/legacy/qacreation/llama_index_default_prompt.txt (100%) rename autorag/{ => autorag}/data/legacy/qacreation/ragas.py (100%) rename autorag/{ => autorag}/data/legacy/qacreation/simple.py (100%) rename autorag/{ => autorag}/data/parse/__init__.py (100%) rename autorag/{ => autorag}/data/parse/base.py (100%) rename autorag/{ => autorag}/data/parse/clova.py (100%) rename autorag/{ => autorag}/data/parse/langchain_parse.py (100%) rename autorag/{ => autorag}/data/parse/llamaparse.py (100%) rename autorag/{ => autorag}/data/parse/run.py (100%) rename autorag/{ => autorag}/data/parse/table_hybrid_parse.py (100%) rename autorag/{ => autorag}/data/qa/__init__.py (100%) rename autorag/{ => autorag}/data/qa/evolve/__init__.py (100%) rename autorag/{ => autorag}/data/qa/evolve/llama_index_query_evolve.py (100%) rename autorag/{ => autorag}/data/qa/evolve/openai_query_evolve.py (100%) rename autorag/{ => autorag}/data/qa/evolve/prompt.py (100%) rename autorag/{ => autorag}/data/qa/extract_evidence.py (100%) rename autorag/{ => autorag}/data/qa/filter/__init__.py (100%) rename autorag/{ => autorag}/data/qa/filter/dontknow.py (100%) rename autorag/{ => autorag}/data/qa/filter/passage_dependency.py (100%) rename autorag/{ => autorag}/data/qa/filter/prompt.py (100%) rename autorag/{ => autorag}/data/qa/generation_gt/__init__.py (100%) rename autorag/{ => autorag}/data/qa/generation_gt/base.py (100%) rename autorag/{ => autorag}/data/qa/generation_gt/llama_index_gen_gt.py (100%) rename autorag/{ => autorag}/data/qa/generation_gt/openai_gen_gt.py (100%) rename autorag/{ => autorag}/data/qa/generation_gt/prompt.py (100%) rename autorag/{ => autorag}/data/qa/query/__init__.py (100%) rename autorag/{ => autorag}/data/qa/query/llama_gen_query.py (100%) rename autorag/{ => autorag}/data/qa/query/openai_gen_query.py (100%) rename autorag/{ => autorag}/data/qa/query/prompt.py (100%) rename autorag/{ => autorag}/data/qa/sample.py (100%) rename autorag/{ => autorag}/data/qa/schema.py (100%) rename autorag/{ => autorag}/data/utils/__init__.py (100%) rename autorag/{ => autorag}/data/utils/util.py (100%) rename autorag/{ => autorag}/deploy/__init__.py (100%) rename autorag/{ => autorag}/deploy/api.py (100%) rename autorag/{ => autorag}/deploy/base.py (100%) rename autorag/{ => autorag}/deploy/gradio.py (100%) rename autorag/{ => autorag}/deploy/swagger.yml (100%) rename autorag/{ => autorag}/evaluation/__init__.py (100%) rename autorag/{ => autorag}/evaluation/generation.py (100%) rename autorag/{ => autorag}/evaluation/metric/__init__.py (100%) rename autorag/{ => autorag}/evaluation/metric/deepeval_prompt.py (100%) rename autorag/{ => autorag}/evaluation/metric/g_eval_prompts/coh_detailed.txt (98%) rename autorag/{ => autorag}/evaluation/metric/g_eval_prompts/con_detailed.txt (94%) rename autorag/{ => autorag}/evaluation/metric/g_eval_prompts/flu_detailed.txt (97%) rename autorag/{ => autorag}/evaluation/metric/g_eval_prompts/rel_detailed.txt (98%) rename autorag/{ => autorag}/evaluation/metric/generation.py (100%) rename autorag/{ => autorag}/evaluation/metric/retrieval.py (100%) rename autorag/{ => autorag}/evaluation/metric/retrieval_contents.py (100%) rename autorag/{ => autorag}/evaluation/metric/util.py (100%) rename autorag/{ => autorag}/evaluation/retrieval.py (100%) rename autorag/{ => autorag}/evaluation/retrieval_contents.py (100%) rename autorag/{ => autorag}/evaluation/util.py (100%) rename autorag/{ => autorag}/evaluator.py (100%) rename autorag/{ => autorag}/node_line.py (100%) rename autorag/{ => autorag}/nodes/__init__.py (100%) rename autorag/{ => autorag}/nodes/generator/__init__.py (100%) rename autorag/{ => autorag}/nodes/generator/base.py (100%) rename autorag/{ => autorag}/nodes/generator/llama_index_llm.py (100%) rename autorag/{ => autorag}/nodes/generator/openai_llm.py (100%) rename autorag/{ => autorag}/nodes/generator/run.py (100%) rename autorag/{ => autorag}/nodes/generator/vllm.py (100%) rename autorag/{ => autorag}/nodes/passageaugmenter/__init__.py (100%) rename autorag/{ => autorag}/nodes/passageaugmenter/base.py (100%) rename autorag/{ => autorag}/nodes/passageaugmenter/pass_passage_augmenter.py (100%) rename autorag/{ => autorag}/nodes/passageaugmenter/prev_next_augmenter.py (100%) rename autorag/{ => autorag}/nodes/passageaugmenter/run.py (100%) rename autorag/{ => autorag}/nodes/passagecompressor/__init__.py (100%) rename autorag/{ => autorag}/nodes/passagecompressor/base.py (100%) rename autorag/{ => autorag}/nodes/passagecompressor/longllmlingua.py (100%) rename autorag/{ => autorag}/nodes/passagecompressor/pass_compressor.py (100%) rename autorag/{ => autorag}/nodes/passagecompressor/refine.py (100%) rename autorag/{ => autorag}/nodes/passagecompressor/run.py (100%) rename autorag/{ => autorag}/nodes/passagecompressor/tree_summarize.py (100%) rename autorag/{ => autorag}/nodes/passagefilter/__init__.py (100%) rename autorag/{ => autorag}/nodes/passagefilter/base.py (100%) rename autorag/{ => autorag}/nodes/passagefilter/pass_passage_filter.py (100%) rename autorag/{ => autorag}/nodes/passagefilter/percentile_cutoff.py (100%) rename autorag/{ => autorag}/nodes/passagefilter/recency.py (100%) rename autorag/{ => autorag}/nodes/passagefilter/run.py (100%) rename autorag/{ => autorag}/nodes/passagefilter/similarity_percentile_cutoff.py (100%) rename autorag/{ => autorag}/nodes/passagefilter/similarity_threshold_cutoff.py (100%) rename autorag/{ => autorag}/nodes/passagefilter/threshold_cutoff.py (100%) rename autorag/{ => autorag}/nodes/passagereranker/__init__.py (100%) rename autorag/{ => autorag}/nodes/passagereranker/base.py (100%) rename autorag/{ => autorag}/nodes/passagereranker/cohere.py (100%) rename autorag/{ => autorag}/nodes/passagereranker/colbert.py (100%) rename autorag/{ => autorag}/nodes/passagereranker/flag_embedding.py (100%) rename autorag/{ => autorag}/nodes/passagereranker/flag_embedding_llm.py (100%) rename autorag/{ => autorag}/nodes/passagereranker/flashrank.py (100%) rename autorag/{ => autorag}/nodes/passagereranker/jina.py (100%) rename autorag/{ => autorag}/nodes/passagereranker/koreranker.py (100%) rename autorag/{ => autorag}/nodes/passagereranker/mixedbreadai.py (100%) rename autorag/{ => autorag}/nodes/passagereranker/monot5.py (100%) rename autorag/{ => autorag}/nodes/passagereranker/openvino.py (100%) rename autorag/{ => autorag}/nodes/passagereranker/pass_reranker.py (100%) rename autorag/{ => autorag}/nodes/passagereranker/rankgpt.py (100%) rename autorag/{ => autorag}/nodes/passagereranker/run.py (100%) rename autorag/{ => autorag}/nodes/passagereranker/sentence_transformer.py (100%) rename autorag/{ => autorag}/nodes/passagereranker/tart/__init__.py (100%) rename autorag/{ => autorag}/nodes/passagereranker/tart/modeling_enc_t5.py (100%) rename autorag/{ => autorag}/nodes/passagereranker/tart/tart.py (100%) rename autorag/{ => autorag}/nodes/passagereranker/tart/tokenization_enc_t5.py (100%) rename autorag/{ => autorag}/nodes/passagereranker/time_reranker.py (100%) rename autorag/{ => autorag}/nodes/passagereranker/upr.py (100%) rename autorag/{ => autorag}/nodes/passagereranker/voyageai.py (100%) rename autorag/{ => autorag}/nodes/promptmaker/__init__.py (100%) rename autorag/{ => autorag}/nodes/promptmaker/base.py (100%) rename autorag/{ => autorag}/nodes/promptmaker/fstring.py (100%) rename autorag/{ => autorag}/nodes/promptmaker/long_context_reorder.py (100%) rename autorag/{ => autorag}/nodes/promptmaker/run.py (100%) rename autorag/{ => autorag}/nodes/promptmaker/window_replacement.py (100%) rename autorag/{ => autorag}/nodes/queryexpansion/__init__.py (100%) rename autorag/{ => autorag}/nodes/queryexpansion/base.py (100%) rename autorag/{ => autorag}/nodes/queryexpansion/hyde.py (100%) rename autorag/{ => autorag}/nodes/queryexpansion/multi_query_expansion.py (100%) rename autorag/{ => autorag}/nodes/queryexpansion/pass_query_expansion.py (100%) rename autorag/{ => autorag}/nodes/queryexpansion/query_decompose.py (100%) rename autorag/{ => autorag}/nodes/queryexpansion/run.py (100%) rename autorag/{ => autorag}/nodes/retrieval/__init__.py (100%) rename autorag/{ => autorag}/nodes/retrieval/base.py (100%) rename autorag/{ => autorag}/nodes/retrieval/bm25.py (100%) rename autorag/{ => autorag}/nodes/retrieval/hybrid_cc.py (100%) rename autorag/{ => autorag}/nodes/retrieval/hybrid_rrf.py (100%) rename autorag/{ => autorag}/nodes/retrieval/run.py (100%) rename autorag/{ => autorag}/nodes/retrieval/vectordb.py (100%) rename autorag/{ => autorag}/nodes/util.py (100%) rename autorag/{ => autorag}/parser.py (100%) rename autorag/{ => autorag}/schema/__init__.py (100%) rename autorag/{ => autorag}/schema/base.py (100%) rename autorag/{ => autorag}/schema/metricinput.py (100%) rename autorag/{ => autorag}/schema/module.py (100%) rename autorag/{ => autorag}/schema/node.py (100%) rename autorag/{ => autorag}/strategy.py (100%) rename autorag/{ => autorag}/support.py (100%) rename autorag/{ => autorag}/utils/__init__.py (100%) rename autorag/{ => autorag}/utils/preprocess.py (100%) rename autorag/{ => autorag}/utils/util.py (100%) rename autorag/{ => autorag}/validator.py (100%) rename autorag/{ => autorag}/vectordb/__init__.py (100%) rename autorag/{ => autorag}/vectordb/base.py (100%) rename autorag/{ => autorag}/vectordb/chroma.py (100%) rename autorag/{ => autorag}/vectordb/couchbase.py (100%) rename autorag/{ => autorag}/vectordb/milvus.py (100%) rename autorag/{ => autorag}/vectordb/pinecone.py (100%) rename autorag/{ => autorag}/vectordb/weaviate.py (100%) rename autorag/{ => autorag}/web.py (100%) rename build_and_push.sh => autorag/build_and_push.sh (100%) rename docker-compose.yml => autorag/docker-compose.yml (100%) rename {projects => autorag/projects}/tutorial_1/config.yaml (100%) rename pyproject.toml => autorag/pyproject.toml (100%) rename requirements.txt => autorag/requirements.txt (100%) rename {sample_config => autorag/sample_config}/chunk/chunk_full.yaml (100%) rename {sample_config => autorag/sample_config}/chunk/chunk_ko.yaml (100%) rename {sample_config => autorag/sample_config}/chunk/simple_chunk.yaml (100%) rename {sample_config => autorag/sample_config}/parse/parse_full.yaml (100%) rename {sample_config => autorag/sample_config}/parse/parse_hybird.yaml (100%) rename {sample_config => autorag/sample_config}/parse/parse_ko.yaml (100%) rename {sample_config => autorag/sample_config}/parse/parse_multimodal.yaml (100%) rename {sample_config => autorag/sample_config}/parse/parse_ocr.yaml (100%) rename {sample_config => autorag/sample_config}/parse/simple_parse.yaml (100%) rename {sample_config => autorag/sample_config}/rag/english/gpu/compact_local.yaml (100%) rename {sample_config => autorag/sample_config}/rag/english/gpu/compact_openai.yaml (100%) rename {sample_config => autorag/sample_config}/rag/english/gpu/full.yaml (100%) rename {sample_config => autorag/sample_config}/rag/english/gpu/half.yaml (100%) rename {sample_config => autorag/sample_config}/rag/english/gpu_api/compact.yaml (100%) rename {sample_config => autorag/sample_config}/rag/english/gpu_api/full.yaml (100%) rename {sample_config => autorag/sample_config}/rag/english/gpu_api/half.yaml (100%) rename {sample_config => autorag/sample_config}/rag/english/non_gpu/compact.yaml (100%) rename {sample_config => autorag/sample_config}/rag/english/non_gpu/full.yaml (100%) rename {sample_config => autorag/sample_config}/rag/english/non_gpu/half.yaml (100%) rename {sample_config => autorag/sample_config}/rag/english/non_gpu/simple_bedrock.yaml (100%) rename {sample_config => autorag/sample_config}/rag/english/non_gpu/simple_local.yaml (100%) rename {sample_config => autorag/sample_config}/rag/english/non_gpu/simple_ollama.yaml (100%) rename {sample_config => autorag/sample_config}/rag/english/non_gpu/simple_openai.yaml (100%) rename {sample_config => autorag/sample_config}/rag/extracted_sample.yaml (100%) rename {sample_config => autorag/sample_config}/rag/full.yaml (100%) rename {sample_config => autorag/sample_config}/rag/korean/gpu/compact_korean.yaml (100%) rename {sample_config => autorag/sample_config}/rag/korean/gpu/full_korean.yaml (100%) rename {sample_config => autorag/sample_config}/rag/korean/gpu/half_korean.yaml (100%) rename {sample_config => autorag/sample_config}/rag/korean/gpu_api/compact_korean.yaml (100%) rename {sample_config => autorag/sample_config}/rag/korean/gpu_api/full_korean.yaml (100%) rename {sample_config => autorag/sample_config}/rag/korean/gpu_api/half_korean.yaml (100%) rename {sample_config => autorag/sample_config}/rag/korean/non_gpu/compact_korean.yaml (100%) rename {sample_config => autorag/sample_config}/rag/korean/non_gpu/full_korean.yaml (100%) rename {sample_config => autorag/sample_config}/rag/korean/non_gpu/half_korean.yaml (100%) rename {sample_config => autorag/sample_config}/rag/korean/non_gpu/simple_korean.yaml (100%) rename {sample_dataset => autorag/sample_dataset}/README.md (96%) rename {sample_dataset => autorag/sample_dataset}/eli5/load_eli5_dataset.py (100%) rename {sample_dataset => autorag/sample_dataset}/hotpotqa/load_hotpotqa_dataset.py (100%) rename {sample_dataset => autorag/sample_dataset}/msmarco/load_msmarco_dataset.py (100%) rename {sample_dataset => autorag/sample_dataset}/triviaqa/load_triviaqa_dataset.py (100%) diff --git a/.dockerignore b/.dockerignore index d7ae30111..92d100458 100644 --- a/.dockerignore +++ b/.dockerignore @@ -161,5 +161,5 @@ cython_debug/ .idea/ pytest.ini .DS_Store -projects/tutorial_1 -!projects/tutorial_1/config.yaml +autorag/projects/tutorial_1 +!autorag/projects/tutorial_1/config.yaml diff --git a/.gitignore b/.gitignore index 6ec7d4887..7b325c116 100644 --- a/.gitignore +++ b/.gitignore @@ -160,5 +160,5 @@ cython_debug/ .idea/ pytest.ini .DS_Store -projects/tutorial_1 -!projects/tutorial_1/config.yaml +autorag/projects/tutorial_1 +!autorag/projects/tutorial_1/config.yaml diff --git a/README.md b/README.md index 9e524bbd5..bdcb5a600 100644 --- a/README.md +++ b/README.md @@ -279,7 +279,7 @@ First, you need to set the config YAML file for your RAG optimization. We highly recommend using pre-made config YAML files for starter. -- [Get Sample YAML](./sample_config/rag) +- [Get Sample YAML](autorag/sample_config/rag) - [Sample YAML Guide](https://docs.auto-rag.com/optimization/sample_config.html) - [Make Custom YAML Guide](https://docs.auto-rag.com/optimization/custom_config.html) diff --git a/Dockerfile.base b/autorag/Dockerfile.base similarity index 97% rename from Dockerfile.base rename to autorag/Dockerfile.base index 748dd6d71..aed8fd31c 100644 --- a/Dockerfile.base +++ b/autorag/Dockerfile.base @@ -68,4 +68,4 @@ RUN apt-get update && \ pip install --no-cache-dir "AutoRAG[parse]" && \ rm -rf /var/lib/apt/lists/* && \ rm -rf /root/.cache/pip/* -ENTRYPOINT ["python", "-m", "autorag.cli"] \ No newline at end of file +ENTRYPOINT ["python", "-m", "autorag.cli"] diff --git a/Dockerfile.gpu b/autorag/Dockerfile.gpu similarity index 100% rename from Dockerfile.gpu rename to autorag/Dockerfile.gpu diff --git a/autorag/VERSION b/autorag/autorag/VERSION similarity index 100% rename from autorag/VERSION rename to autorag/autorag/VERSION diff --git a/autorag/__init__.py b/autorag/autorag/__init__.py similarity index 100% rename from autorag/__init__.py rename to autorag/autorag/__init__.py diff --git a/autorag/chunker.py b/autorag/autorag/chunker.py similarity index 100% rename from autorag/chunker.py rename to autorag/autorag/chunker.py diff --git a/autorag/cli.py b/autorag/autorag/cli.py similarity index 100% rename from autorag/cli.py rename to autorag/autorag/cli.py diff --git a/autorag/dashboard.py b/autorag/autorag/dashboard.py similarity index 100% rename from autorag/dashboard.py rename to autorag/autorag/dashboard.py diff --git a/autorag/data/__init__.py b/autorag/autorag/data/__init__.py similarity index 100% rename from autorag/data/__init__.py rename to autorag/autorag/data/__init__.py diff --git a/autorag/data/chunk/__init__.py b/autorag/autorag/data/chunk/__init__.py similarity index 100% rename from autorag/data/chunk/__init__.py rename to autorag/autorag/data/chunk/__init__.py diff --git a/autorag/data/chunk/base.py b/autorag/autorag/data/chunk/base.py similarity index 100% rename from autorag/data/chunk/base.py rename to autorag/autorag/data/chunk/base.py diff --git a/autorag/data/chunk/langchain_chunk.py b/autorag/autorag/data/chunk/langchain_chunk.py similarity index 100% rename from autorag/data/chunk/langchain_chunk.py rename to autorag/autorag/data/chunk/langchain_chunk.py diff --git a/autorag/data/chunk/llama_index_chunk.py b/autorag/autorag/data/chunk/llama_index_chunk.py similarity index 100% rename from autorag/data/chunk/llama_index_chunk.py rename to autorag/autorag/data/chunk/llama_index_chunk.py diff --git a/autorag/data/chunk/run.py b/autorag/autorag/data/chunk/run.py similarity index 100% rename from autorag/data/chunk/run.py rename to autorag/autorag/data/chunk/run.py diff --git a/autorag/data/legacy/__init__.py b/autorag/autorag/data/legacy/__init__.py similarity index 100% rename from autorag/data/legacy/__init__.py rename to autorag/autorag/data/legacy/__init__.py diff --git a/autorag/data/legacy/corpus/__init__.py b/autorag/autorag/data/legacy/corpus/__init__.py similarity index 100% rename from autorag/data/legacy/corpus/__init__.py rename to autorag/autorag/data/legacy/corpus/__init__.py diff --git a/autorag/data/legacy/corpus/langchain.py b/autorag/autorag/data/legacy/corpus/langchain.py similarity index 100% rename from autorag/data/legacy/corpus/langchain.py rename to autorag/autorag/data/legacy/corpus/langchain.py diff --git a/autorag/data/legacy/corpus/llama_index.py b/autorag/autorag/data/legacy/corpus/llama_index.py similarity index 100% rename from autorag/data/legacy/corpus/llama_index.py rename to autorag/autorag/data/legacy/corpus/llama_index.py diff --git a/autorag/data/legacy/qacreation/__init__.py b/autorag/autorag/data/legacy/qacreation/__init__.py similarity index 100% rename from autorag/data/legacy/qacreation/__init__.py rename to autorag/autorag/data/legacy/qacreation/__init__.py diff --git a/autorag/data/legacy/qacreation/base.py b/autorag/autorag/data/legacy/qacreation/base.py similarity index 100% rename from autorag/data/legacy/qacreation/base.py rename to autorag/autorag/data/legacy/qacreation/base.py diff --git a/autorag/data/legacy/qacreation/llama_index.py b/autorag/autorag/data/legacy/qacreation/llama_index.py similarity index 100% rename from autorag/data/legacy/qacreation/llama_index.py rename to autorag/autorag/data/legacy/qacreation/llama_index.py diff --git a/autorag/data/legacy/qacreation/llama_index_default_prompt.txt b/autorag/autorag/data/legacy/qacreation/llama_index_default_prompt.txt similarity index 100% rename from autorag/data/legacy/qacreation/llama_index_default_prompt.txt rename to autorag/autorag/data/legacy/qacreation/llama_index_default_prompt.txt diff --git a/autorag/data/legacy/qacreation/ragas.py b/autorag/autorag/data/legacy/qacreation/ragas.py similarity index 100% rename from autorag/data/legacy/qacreation/ragas.py rename to autorag/autorag/data/legacy/qacreation/ragas.py diff --git a/autorag/data/legacy/qacreation/simple.py b/autorag/autorag/data/legacy/qacreation/simple.py similarity index 100% rename from autorag/data/legacy/qacreation/simple.py rename to autorag/autorag/data/legacy/qacreation/simple.py diff --git a/autorag/data/parse/__init__.py b/autorag/autorag/data/parse/__init__.py similarity index 100% rename from autorag/data/parse/__init__.py rename to autorag/autorag/data/parse/__init__.py diff --git a/autorag/data/parse/base.py b/autorag/autorag/data/parse/base.py similarity index 100% rename from autorag/data/parse/base.py rename to autorag/autorag/data/parse/base.py diff --git a/autorag/data/parse/clova.py b/autorag/autorag/data/parse/clova.py similarity index 100% rename from autorag/data/parse/clova.py rename to autorag/autorag/data/parse/clova.py diff --git a/autorag/data/parse/langchain_parse.py b/autorag/autorag/data/parse/langchain_parse.py similarity index 100% rename from autorag/data/parse/langchain_parse.py rename to autorag/autorag/data/parse/langchain_parse.py diff --git a/autorag/data/parse/llamaparse.py b/autorag/autorag/data/parse/llamaparse.py similarity index 100% rename from autorag/data/parse/llamaparse.py rename to autorag/autorag/data/parse/llamaparse.py diff --git a/autorag/data/parse/run.py b/autorag/autorag/data/parse/run.py similarity index 100% rename from autorag/data/parse/run.py rename to autorag/autorag/data/parse/run.py diff --git a/autorag/data/parse/table_hybrid_parse.py b/autorag/autorag/data/parse/table_hybrid_parse.py similarity index 100% rename from autorag/data/parse/table_hybrid_parse.py rename to autorag/autorag/data/parse/table_hybrid_parse.py diff --git a/autorag/data/qa/__init__.py b/autorag/autorag/data/qa/__init__.py similarity index 100% rename from autorag/data/qa/__init__.py rename to autorag/autorag/data/qa/__init__.py diff --git a/autorag/data/qa/evolve/__init__.py b/autorag/autorag/data/qa/evolve/__init__.py similarity index 100% rename from autorag/data/qa/evolve/__init__.py rename to autorag/autorag/data/qa/evolve/__init__.py diff --git a/autorag/data/qa/evolve/llama_index_query_evolve.py b/autorag/autorag/data/qa/evolve/llama_index_query_evolve.py similarity index 100% rename from autorag/data/qa/evolve/llama_index_query_evolve.py rename to autorag/autorag/data/qa/evolve/llama_index_query_evolve.py diff --git a/autorag/data/qa/evolve/openai_query_evolve.py b/autorag/autorag/data/qa/evolve/openai_query_evolve.py similarity index 100% rename from autorag/data/qa/evolve/openai_query_evolve.py rename to autorag/autorag/data/qa/evolve/openai_query_evolve.py diff --git a/autorag/data/qa/evolve/prompt.py b/autorag/autorag/data/qa/evolve/prompt.py similarity index 100% rename from autorag/data/qa/evolve/prompt.py rename to autorag/autorag/data/qa/evolve/prompt.py diff --git a/autorag/data/qa/extract_evidence.py b/autorag/autorag/data/qa/extract_evidence.py similarity index 100% rename from autorag/data/qa/extract_evidence.py rename to autorag/autorag/data/qa/extract_evidence.py diff --git a/autorag/data/qa/filter/__init__.py b/autorag/autorag/data/qa/filter/__init__.py similarity index 100% rename from autorag/data/qa/filter/__init__.py rename to autorag/autorag/data/qa/filter/__init__.py diff --git a/autorag/data/qa/filter/dontknow.py b/autorag/autorag/data/qa/filter/dontknow.py similarity index 100% rename from autorag/data/qa/filter/dontknow.py rename to autorag/autorag/data/qa/filter/dontknow.py diff --git a/autorag/data/qa/filter/passage_dependency.py b/autorag/autorag/data/qa/filter/passage_dependency.py similarity index 100% rename from autorag/data/qa/filter/passage_dependency.py rename to autorag/autorag/data/qa/filter/passage_dependency.py diff --git a/autorag/data/qa/filter/prompt.py b/autorag/autorag/data/qa/filter/prompt.py similarity index 100% rename from autorag/data/qa/filter/prompt.py rename to autorag/autorag/data/qa/filter/prompt.py diff --git a/autorag/data/qa/generation_gt/__init__.py b/autorag/autorag/data/qa/generation_gt/__init__.py similarity index 100% rename from autorag/data/qa/generation_gt/__init__.py rename to autorag/autorag/data/qa/generation_gt/__init__.py diff --git a/autorag/data/qa/generation_gt/base.py b/autorag/autorag/data/qa/generation_gt/base.py similarity index 100% rename from autorag/data/qa/generation_gt/base.py rename to autorag/autorag/data/qa/generation_gt/base.py diff --git a/autorag/data/qa/generation_gt/llama_index_gen_gt.py b/autorag/autorag/data/qa/generation_gt/llama_index_gen_gt.py similarity index 100% rename from autorag/data/qa/generation_gt/llama_index_gen_gt.py rename to autorag/autorag/data/qa/generation_gt/llama_index_gen_gt.py diff --git a/autorag/data/qa/generation_gt/openai_gen_gt.py b/autorag/autorag/data/qa/generation_gt/openai_gen_gt.py similarity index 100% rename from autorag/data/qa/generation_gt/openai_gen_gt.py rename to autorag/autorag/data/qa/generation_gt/openai_gen_gt.py diff --git a/autorag/data/qa/generation_gt/prompt.py b/autorag/autorag/data/qa/generation_gt/prompt.py similarity index 100% rename from autorag/data/qa/generation_gt/prompt.py rename to autorag/autorag/data/qa/generation_gt/prompt.py diff --git a/autorag/data/qa/query/__init__.py b/autorag/autorag/data/qa/query/__init__.py similarity index 100% rename from autorag/data/qa/query/__init__.py rename to autorag/autorag/data/qa/query/__init__.py diff --git a/autorag/data/qa/query/llama_gen_query.py b/autorag/autorag/data/qa/query/llama_gen_query.py similarity index 100% rename from autorag/data/qa/query/llama_gen_query.py rename to autorag/autorag/data/qa/query/llama_gen_query.py diff --git a/autorag/data/qa/query/openai_gen_query.py b/autorag/autorag/data/qa/query/openai_gen_query.py similarity index 100% rename from autorag/data/qa/query/openai_gen_query.py rename to autorag/autorag/data/qa/query/openai_gen_query.py diff --git a/autorag/data/qa/query/prompt.py b/autorag/autorag/data/qa/query/prompt.py similarity index 100% rename from autorag/data/qa/query/prompt.py rename to autorag/autorag/data/qa/query/prompt.py diff --git a/autorag/data/qa/sample.py b/autorag/autorag/data/qa/sample.py similarity index 100% rename from autorag/data/qa/sample.py rename to autorag/autorag/data/qa/sample.py diff --git a/autorag/data/qa/schema.py b/autorag/autorag/data/qa/schema.py similarity index 100% rename from autorag/data/qa/schema.py rename to autorag/autorag/data/qa/schema.py diff --git a/autorag/data/utils/__init__.py b/autorag/autorag/data/utils/__init__.py similarity index 100% rename from autorag/data/utils/__init__.py rename to autorag/autorag/data/utils/__init__.py diff --git a/autorag/data/utils/util.py b/autorag/autorag/data/utils/util.py similarity index 100% rename from autorag/data/utils/util.py rename to autorag/autorag/data/utils/util.py diff --git a/autorag/deploy/__init__.py b/autorag/autorag/deploy/__init__.py similarity index 100% rename from autorag/deploy/__init__.py rename to autorag/autorag/deploy/__init__.py diff --git a/autorag/deploy/api.py b/autorag/autorag/deploy/api.py similarity index 100% rename from autorag/deploy/api.py rename to autorag/autorag/deploy/api.py diff --git a/autorag/deploy/base.py b/autorag/autorag/deploy/base.py similarity index 100% rename from autorag/deploy/base.py rename to autorag/autorag/deploy/base.py diff --git a/autorag/deploy/gradio.py b/autorag/autorag/deploy/gradio.py similarity index 100% rename from autorag/deploy/gradio.py rename to autorag/autorag/deploy/gradio.py diff --git a/autorag/deploy/swagger.yml b/autorag/autorag/deploy/swagger.yml similarity index 100% rename from autorag/deploy/swagger.yml rename to autorag/autorag/deploy/swagger.yml diff --git a/autorag/evaluation/__init__.py b/autorag/autorag/evaluation/__init__.py similarity index 100% rename from autorag/evaluation/__init__.py rename to autorag/autorag/evaluation/__init__.py diff --git a/autorag/evaluation/generation.py b/autorag/autorag/evaluation/generation.py similarity index 100% rename from autorag/evaluation/generation.py rename to autorag/autorag/evaluation/generation.py diff --git a/autorag/evaluation/metric/__init__.py b/autorag/autorag/evaluation/metric/__init__.py similarity index 100% rename from autorag/evaluation/metric/__init__.py rename to autorag/autorag/evaluation/metric/__init__.py diff --git a/autorag/evaluation/metric/deepeval_prompt.py b/autorag/autorag/evaluation/metric/deepeval_prompt.py similarity index 100% rename from autorag/evaluation/metric/deepeval_prompt.py rename to autorag/autorag/evaluation/metric/deepeval_prompt.py diff --git a/autorag/evaluation/metric/g_eval_prompts/coh_detailed.txt b/autorag/autorag/evaluation/metric/g_eval_prompts/coh_detailed.txt similarity index 98% rename from autorag/evaluation/metric/g_eval_prompts/coh_detailed.txt rename to autorag/autorag/evaluation/metric/g_eval_prompts/coh_detailed.txt index 896447373..6aa2df4aa 100644 --- a/autorag/evaluation/metric/g_eval_prompts/coh_detailed.txt +++ b/autorag/autorag/evaluation/metric/g_eval_prompts/coh_detailed.txt @@ -29,4 +29,4 @@ Summary: Evaluation Form (scores ONLY): -- Coherence: \ No newline at end of file +- Coherence: diff --git a/autorag/evaluation/metric/g_eval_prompts/con_detailed.txt b/autorag/autorag/evaluation/metric/g_eval_prompts/con_detailed.txt similarity index 94% rename from autorag/evaluation/metric/g_eval_prompts/con_detailed.txt rename to autorag/autorag/evaluation/metric/g_eval_prompts/con_detailed.txt index 1ad6ab404..104153bfb 100644 --- a/autorag/evaluation/metric/g_eval_prompts/con_detailed.txt +++ b/autorag/autorag/evaluation/metric/g_eval_prompts/con_detailed.txt @@ -7,7 +7,7 @@ Please make sure you read and understand these instructions carefully. Please ke Evaluation Criteria: -Consistency (1-5) - the factual alignment between the summary and the summarized source. A factually consistent summary contains only statements that are entailed by the source document. Annotators were also asked to penalize summaries that contained hallucinated facts. +Consistency (1-5) - the factual alignment between the summary and the summarized source. A factually consistent summary contains only statements that are entailed by the source document. Annotators were also asked to penalize summaries that contained hallucinated facts. Evaluation Steps: @@ -19,15 +19,15 @@ Evaluation Steps: Example: -Source Text: +Source Text: {{Document}} -Summary: +Summary: {{Summary}} Evaluation Form (scores ONLY): -- Consistency: \ No newline at end of file +- Consistency: diff --git a/autorag/evaluation/metric/g_eval_prompts/flu_detailed.txt b/autorag/autorag/evaluation/metric/g_eval_prompts/flu_detailed.txt similarity index 97% rename from autorag/evaluation/metric/g_eval_prompts/flu_detailed.txt rename to autorag/autorag/evaluation/metric/g_eval_prompts/flu_detailed.txt index 72f76e931..8ed51a329 100644 --- a/autorag/evaluation/metric/g_eval_prompts/flu_detailed.txt +++ b/autorag/autorag/evaluation/metric/g_eval_prompts/flu_detailed.txt @@ -23,4 +23,4 @@ Summary: Evaluation Form (scores ONLY): -- Fluency (1-3): \ No newline at end of file +- Fluency (1-3): diff --git a/autorag/evaluation/metric/g_eval_prompts/rel_detailed.txt b/autorag/autorag/evaluation/metric/g_eval_prompts/rel_detailed.txt similarity index 98% rename from autorag/evaluation/metric/g_eval_prompts/rel_detailed.txt rename to autorag/autorag/evaluation/metric/g_eval_prompts/rel_detailed.txt index 3f5a650c6..b7b4330de 100644 --- a/autorag/evaluation/metric/g_eval_prompts/rel_detailed.txt +++ b/autorag/autorag/evaluation/metric/g_eval_prompts/rel_detailed.txt @@ -30,4 +30,4 @@ Summary: Evaluation Form (scores ONLY): -- Relevance: \ No newline at end of file +- Relevance: diff --git a/autorag/evaluation/metric/generation.py b/autorag/autorag/evaluation/metric/generation.py similarity index 100% rename from autorag/evaluation/metric/generation.py rename to autorag/autorag/evaluation/metric/generation.py diff --git a/autorag/evaluation/metric/retrieval.py b/autorag/autorag/evaluation/metric/retrieval.py similarity index 100% rename from autorag/evaluation/metric/retrieval.py rename to autorag/autorag/evaluation/metric/retrieval.py diff --git a/autorag/evaluation/metric/retrieval_contents.py b/autorag/autorag/evaluation/metric/retrieval_contents.py similarity index 100% rename from autorag/evaluation/metric/retrieval_contents.py rename to autorag/autorag/evaluation/metric/retrieval_contents.py diff --git a/autorag/evaluation/metric/util.py b/autorag/autorag/evaluation/metric/util.py similarity index 100% rename from autorag/evaluation/metric/util.py rename to autorag/autorag/evaluation/metric/util.py diff --git a/autorag/evaluation/retrieval.py b/autorag/autorag/evaluation/retrieval.py similarity index 100% rename from autorag/evaluation/retrieval.py rename to autorag/autorag/evaluation/retrieval.py diff --git a/autorag/evaluation/retrieval_contents.py b/autorag/autorag/evaluation/retrieval_contents.py similarity index 100% rename from autorag/evaluation/retrieval_contents.py rename to autorag/autorag/evaluation/retrieval_contents.py diff --git a/autorag/evaluation/util.py b/autorag/autorag/evaluation/util.py similarity index 100% rename from autorag/evaluation/util.py rename to autorag/autorag/evaluation/util.py diff --git a/autorag/evaluator.py b/autorag/autorag/evaluator.py similarity index 100% rename from autorag/evaluator.py rename to autorag/autorag/evaluator.py diff --git a/autorag/node_line.py b/autorag/autorag/node_line.py similarity index 100% rename from autorag/node_line.py rename to autorag/autorag/node_line.py diff --git a/autorag/nodes/__init__.py b/autorag/autorag/nodes/__init__.py similarity index 100% rename from autorag/nodes/__init__.py rename to autorag/autorag/nodes/__init__.py diff --git a/autorag/nodes/generator/__init__.py b/autorag/autorag/nodes/generator/__init__.py similarity index 100% rename from autorag/nodes/generator/__init__.py rename to autorag/autorag/nodes/generator/__init__.py diff --git a/autorag/nodes/generator/base.py b/autorag/autorag/nodes/generator/base.py similarity index 100% rename from autorag/nodes/generator/base.py rename to autorag/autorag/nodes/generator/base.py diff --git a/autorag/nodes/generator/llama_index_llm.py b/autorag/autorag/nodes/generator/llama_index_llm.py similarity index 100% rename from autorag/nodes/generator/llama_index_llm.py rename to autorag/autorag/nodes/generator/llama_index_llm.py diff --git a/autorag/nodes/generator/openai_llm.py b/autorag/autorag/nodes/generator/openai_llm.py similarity index 100% rename from autorag/nodes/generator/openai_llm.py rename to autorag/autorag/nodes/generator/openai_llm.py diff --git a/autorag/nodes/generator/run.py b/autorag/autorag/nodes/generator/run.py similarity index 100% rename from autorag/nodes/generator/run.py rename to autorag/autorag/nodes/generator/run.py diff --git a/autorag/nodes/generator/vllm.py b/autorag/autorag/nodes/generator/vllm.py similarity index 100% rename from autorag/nodes/generator/vllm.py rename to autorag/autorag/nodes/generator/vllm.py diff --git a/autorag/nodes/passageaugmenter/__init__.py b/autorag/autorag/nodes/passageaugmenter/__init__.py similarity index 100% rename from autorag/nodes/passageaugmenter/__init__.py rename to autorag/autorag/nodes/passageaugmenter/__init__.py diff --git a/autorag/nodes/passageaugmenter/base.py b/autorag/autorag/nodes/passageaugmenter/base.py similarity index 100% rename from autorag/nodes/passageaugmenter/base.py rename to autorag/autorag/nodes/passageaugmenter/base.py diff --git a/autorag/nodes/passageaugmenter/pass_passage_augmenter.py b/autorag/autorag/nodes/passageaugmenter/pass_passage_augmenter.py similarity index 100% rename from autorag/nodes/passageaugmenter/pass_passage_augmenter.py rename to autorag/autorag/nodes/passageaugmenter/pass_passage_augmenter.py diff --git a/autorag/nodes/passageaugmenter/prev_next_augmenter.py b/autorag/autorag/nodes/passageaugmenter/prev_next_augmenter.py similarity index 100% rename from autorag/nodes/passageaugmenter/prev_next_augmenter.py rename to autorag/autorag/nodes/passageaugmenter/prev_next_augmenter.py diff --git a/autorag/nodes/passageaugmenter/run.py b/autorag/autorag/nodes/passageaugmenter/run.py similarity index 100% rename from autorag/nodes/passageaugmenter/run.py rename to autorag/autorag/nodes/passageaugmenter/run.py diff --git a/autorag/nodes/passagecompressor/__init__.py b/autorag/autorag/nodes/passagecompressor/__init__.py similarity index 100% rename from autorag/nodes/passagecompressor/__init__.py rename to autorag/autorag/nodes/passagecompressor/__init__.py diff --git a/autorag/nodes/passagecompressor/base.py b/autorag/autorag/nodes/passagecompressor/base.py similarity index 100% rename from autorag/nodes/passagecompressor/base.py rename to autorag/autorag/nodes/passagecompressor/base.py diff --git a/autorag/nodes/passagecompressor/longllmlingua.py b/autorag/autorag/nodes/passagecompressor/longllmlingua.py similarity index 100% rename from autorag/nodes/passagecompressor/longllmlingua.py rename to autorag/autorag/nodes/passagecompressor/longllmlingua.py diff --git a/autorag/nodes/passagecompressor/pass_compressor.py b/autorag/autorag/nodes/passagecompressor/pass_compressor.py similarity index 100% rename from autorag/nodes/passagecompressor/pass_compressor.py rename to autorag/autorag/nodes/passagecompressor/pass_compressor.py diff --git a/autorag/nodes/passagecompressor/refine.py b/autorag/autorag/nodes/passagecompressor/refine.py similarity index 100% rename from autorag/nodes/passagecompressor/refine.py rename to autorag/autorag/nodes/passagecompressor/refine.py diff --git a/autorag/nodes/passagecompressor/run.py b/autorag/autorag/nodes/passagecompressor/run.py similarity index 100% rename from autorag/nodes/passagecompressor/run.py rename to autorag/autorag/nodes/passagecompressor/run.py diff --git a/autorag/nodes/passagecompressor/tree_summarize.py b/autorag/autorag/nodes/passagecompressor/tree_summarize.py similarity index 100% rename from autorag/nodes/passagecompressor/tree_summarize.py rename to autorag/autorag/nodes/passagecompressor/tree_summarize.py diff --git a/autorag/nodes/passagefilter/__init__.py b/autorag/autorag/nodes/passagefilter/__init__.py similarity index 100% rename from autorag/nodes/passagefilter/__init__.py rename to autorag/autorag/nodes/passagefilter/__init__.py diff --git a/autorag/nodes/passagefilter/base.py b/autorag/autorag/nodes/passagefilter/base.py similarity index 100% rename from autorag/nodes/passagefilter/base.py rename to autorag/autorag/nodes/passagefilter/base.py diff --git a/autorag/nodes/passagefilter/pass_passage_filter.py b/autorag/autorag/nodes/passagefilter/pass_passage_filter.py similarity index 100% rename from autorag/nodes/passagefilter/pass_passage_filter.py rename to autorag/autorag/nodes/passagefilter/pass_passage_filter.py diff --git a/autorag/nodes/passagefilter/percentile_cutoff.py b/autorag/autorag/nodes/passagefilter/percentile_cutoff.py similarity index 100% rename from autorag/nodes/passagefilter/percentile_cutoff.py rename to autorag/autorag/nodes/passagefilter/percentile_cutoff.py diff --git a/autorag/nodes/passagefilter/recency.py b/autorag/autorag/nodes/passagefilter/recency.py similarity index 100% rename from autorag/nodes/passagefilter/recency.py rename to autorag/autorag/nodes/passagefilter/recency.py diff --git a/autorag/nodes/passagefilter/run.py b/autorag/autorag/nodes/passagefilter/run.py similarity index 100% rename from autorag/nodes/passagefilter/run.py rename to autorag/autorag/nodes/passagefilter/run.py diff --git a/autorag/nodes/passagefilter/similarity_percentile_cutoff.py b/autorag/autorag/nodes/passagefilter/similarity_percentile_cutoff.py similarity index 100% rename from autorag/nodes/passagefilter/similarity_percentile_cutoff.py rename to autorag/autorag/nodes/passagefilter/similarity_percentile_cutoff.py diff --git a/autorag/nodes/passagefilter/similarity_threshold_cutoff.py b/autorag/autorag/nodes/passagefilter/similarity_threshold_cutoff.py similarity index 100% rename from autorag/nodes/passagefilter/similarity_threshold_cutoff.py rename to autorag/autorag/nodes/passagefilter/similarity_threshold_cutoff.py diff --git a/autorag/nodes/passagefilter/threshold_cutoff.py b/autorag/autorag/nodes/passagefilter/threshold_cutoff.py similarity index 100% rename from autorag/nodes/passagefilter/threshold_cutoff.py rename to autorag/autorag/nodes/passagefilter/threshold_cutoff.py diff --git a/autorag/nodes/passagereranker/__init__.py b/autorag/autorag/nodes/passagereranker/__init__.py similarity index 100% rename from autorag/nodes/passagereranker/__init__.py rename to autorag/autorag/nodes/passagereranker/__init__.py diff --git a/autorag/nodes/passagereranker/base.py b/autorag/autorag/nodes/passagereranker/base.py similarity index 100% rename from autorag/nodes/passagereranker/base.py rename to autorag/autorag/nodes/passagereranker/base.py diff --git a/autorag/nodes/passagereranker/cohere.py b/autorag/autorag/nodes/passagereranker/cohere.py similarity index 100% rename from autorag/nodes/passagereranker/cohere.py rename to autorag/autorag/nodes/passagereranker/cohere.py diff --git a/autorag/nodes/passagereranker/colbert.py b/autorag/autorag/nodes/passagereranker/colbert.py similarity index 100% rename from autorag/nodes/passagereranker/colbert.py rename to autorag/autorag/nodes/passagereranker/colbert.py diff --git a/autorag/nodes/passagereranker/flag_embedding.py b/autorag/autorag/nodes/passagereranker/flag_embedding.py similarity index 100% rename from autorag/nodes/passagereranker/flag_embedding.py rename to autorag/autorag/nodes/passagereranker/flag_embedding.py diff --git a/autorag/nodes/passagereranker/flag_embedding_llm.py b/autorag/autorag/nodes/passagereranker/flag_embedding_llm.py similarity index 100% rename from autorag/nodes/passagereranker/flag_embedding_llm.py rename to autorag/autorag/nodes/passagereranker/flag_embedding_llm.py diff --git a/autorag/nodes/passagereranker/flashrank.py b/autorag/autorag/nodes/passagereranker/flashrank.py similarity index 100% rename from autorag/nodes/passagereranker/flashrank.py rename to autorag/autorag/nodes/passagereranker/flashrank.py diff --git a/autorag/nodes/passagereranker/jina.py b/autorag/autorag/nodes/passagereranker/jina.py similarity index 100% rename from autorag/nodes/passagereranker/jina.py rename to autorag/autorag/nodes/passagereranker/jina.py diff --git a/autorag/nodes/passagereranker/koreranker.py b/autorag/autorag/nodes/passagereranker/koreranker.py similarity index 100% rename from autorag/nodes/passagereranker/koreranker.py rename to autorag/autorag/nodes/passagereranker/koreranker.py diff --git a/autorag/nodes/passagereranker/mixedbreadai.py b/autorag/autorag/nodes/passagereranker/mixedbreadai.py similarity index 100% rename from autorag/nodes/passagereranker/mixedbreadai.py rename to autorag/autorag/nodes/passagereranker/mixedbreadai.py diff --git a/autorag/nodes/passagereranker/monot5.py b/autorag/autorag/nodes/passagereranker/monot5.py similarity index 100% rename from autorag/nodes/passagereranker/monot5.py rename to autorag/autorag/nodes/passagereranker/monot5.py diff --git a/autorag/nodes/passagereranker/openvino.py b/autorag/autorag/nodes/passagereranker/openvino.py similarity index 100% rename from autorag/nodes/passagereranker/openvino.py rename to autorag/autorag/nodes/passagereranker/openvino.py diff --git a/autorag/nodes/passagereranker/pass_reranker.py b/autorag/autorag/nodes/passagereranker/pass_reranker.py similarity index 100% rename from autorag/nodes/passagereranker/pass_reranker.py rename to autorag/autorag/nodes/passagereranker/pass_reranker.py diff --git a/autorag/nodes/passagereranker/rankgpt.py b/autorag/autorag/nodes/passagereranker/rankgpt.py similarity index 100% rename from autorag/nodes/passagereranker/rankgpt.py rename to autorag/autorag/nodes/passagereranker/rankgpt.py diff --git a/autorag/nodes/passagereranker/run.py b/autorag/autorag/nodes/passagereranker/run.py similarity index 100% rename from autorag/nodes/passagereranker/run.py rename to autorag/autorag/nodes/passagereranker/run.py diff --git a/autorag/nodes/passagereranker/sentence_transformer.py b/autorag/autorag/nodes/passagereranker/sentence_transformer.py similarity index 100% rename from autorag/nodes/passagereranker/sentence_transformer.py rename to autorag/autorag/nodes/passagereranker/sentence_transformer.py diff --git a/autorag/nodes/passagereranker/tart/__init__.py b/autorag/autorag/nodes/passagereranker/tart/__init__.py similarity index 100% rename from autorag/nodes/passagereranker/tart/__init__.py rename to autorag/autorag/nodes/passagereranker/tart/__init__.py diff --git a/autorag/nodes/passagereranker/tart/modeling_enc_t5.py b/autorag/autorag/nodes/passagereranker/tart/modeling_enc_t5.py similarity index 100% rename from autorag/nodes/passagereranker/tart/modeling_enc_t5.py rename to autorag/autorag/nodes/passagereranker/tart/modeling_enc_t5.py diff --git a/autorag/nodes/passagereranker/tart/tart.py b/autorag/autorag/nodes/passagereranker/tart/tart.py similarity index 100% rename from autorag/nodes/passagereranker/tart/tart.py rename to autorag/autorag/nodes/passagereranker/tart/tart.py diff --git a/autorag/nodes/passagereranker/tart/tokenization_enc_t5.py b/autorag/autorag/nodes/passagereranker/tart/tokenization_enc_t5.py similarity index 100% rename from autorag/nodes/passagereranker/tart/tokenization_enc_t5.py rename to autorag/autorag/nodes/passagereranker/tart/tokenization_enc_t5.py diff --git a/autorag/nodes/passagereranker/time_reranker.py b/autorag/autorag/nodes/passagereranker/time_reranker.py similarity index 100% rename from autorag/nodes/passagereranker/time_reranker.py rename to autorag/autorag/nodes/passagereranker/time_reranker.py diff --git a/autorag/nodes/passagereranker/upr.py b/autorag/autorag/nodes/passagereranker/upr.py similarity index 100% rename from autorag/nodes/passagereranker/upr.py rename to autorag/autorag/nodes/passagereranker/upr.py diff --git a/autorag/nodes/passagereranker/voyageai.py b/autorag/autorag/nodes/passagereranker/voyageai.py similarity index 100% rename from autorag/nodes/passagereranker/voyageai.py rename to autorag/autorag/nodes/passagereranker/voyageai.py diff --git a/autorag/nodes/promptmaker/__init__.py b/autorag/autorag/nodes/promptmaker/__init__.py similarity index 100% rename from autorag/nodes/promptmaker/__init__.py rename to autorag/autorag/nodes/promptmaker/__init__.py diff --git a/autorag/nodes/promptmaker/base.py b/autorag/autorag/nodes/promptmaker/base.py similarity index 100% rename from autorag/nodes/promptmaker/base.py rename to autorag/autorag/nodes/promptmaker/base.py diff --git a/autorag/nodes/promptmaker/fstring.py b/autorag/autorag/nodes/promptmaker/fstring.py similarity index 100% rename from autorag/nodes/promptmaker/fstring.py rename to autorag/autorag/nodes/promptmaker/fstring.py diff --git a/autorag/nodes/promptmaker/long_context_reorder.py b/autorag/autorag/nodes/promptmaker/long_context_reorder.py similarity index 100% rename from autorag/nodes/promptmaker/long_context_reorder.py rename to autorag/autorag/nodes/promptmaker/long_context_reorder.py diff --git a/autorag/nodes/promptmaker/run.py b/autorag/autorag/nodes/promptmaker/run.py similarity index 100% rename from autorag/nodes/promptmaker/run.py rename to autorag/autorag/nodes/promptmaker/run.py diff --git a/autorag/nodes/promptmaker/window_replacement.py b/autorag/autorag/nodes/promptmaker/window_replacement.py similarity index 100% rename from autorag/nodes/promptmaker/window_replacement.py rename to autorag/autorag/nodes/promptmaker/window_replacement.py diff --git a/autorag/nodes/queryexpansion/__init__.py b/autorag/autorag/nodes/queryexpansion/__init__.py similarity index 100% rename from autorag/nodes/queryexpansion/__init__.py rename to autorag/autorag/nodes/queryexpansion/__init__.py diff --git a/autorag/nodes/queryexpansion/base.py b/autorag/autorag/nodes/queryexpansion/base.py similarity index 100% rename from autorag/nodes/queryexpansion/base.py rename to autorag/autorag/nodes/queryexpansion/base.py diff --git a/autorag/nodes/queryexpansion/hyde.py b/autorag/autorag/nodes/queryexpansion/hyde.py similarity index 100% rename from autorag/nodes/queryexpansion/hyde.py rename to autorag/autorag/nodes/queryexpansion/hyde.py diff --git a/autorag/nodes/queryexpansion/multi_query_expansion.py b/autorag/autorag/nodes/queryexpansion/multi_query_expansion.py similarity index 100% rename from autorag/nodes/queryexpansion/multi_query_expansion.py rename to autorag/autorag/nodes/queryexpansion/multi_query_expansion.py diff --git a/autorag/nodes/queryexpansion/pass_query_expansion.py b/autorag/autorag/nodes/queryexpansion/pass_query_expansion.py similarity index 100% rename from autorag/nodes/queryexpansion/pass_query_expansion.py rename to autorag/autorag/nodes/queryexpansion/pass_query_expansion.py diff --git a/autorag/nodes/queryexpansion/query_decompose.py b/autorag/autorag/nodes/queryexpansion/query_decompose.py similarity index 100% rename from autorag/nodes/queryexpansion/query_decompose.py rename to autorag/autorag/nodes/queryexpansion/query_decompose.py diff --git a/autorag/nodes/queryexpansion/run.py b/autorag/autorag/nodes/queryexpansion/run.py similarity index 100% rename from autorag/nodes/queryexpansion/run.py rename to autorag/autorag/nodes/queryexpansion/run.py diff --git a/autorag/nodes/retrieval/__init__.py b/autorag/autorag/nodes/retrieval/__init__.py similarity index 100% rename from autorag/nodes/retrieval/__init__.py rename to autorag/autorag/nodes/retrieval/__init__.py diff --git a/autorag/nodes/retrieval/base.py b/autorag/autorag/nodes/retrieval/base.py similarity index 100% rename from autorag/nodes/retrieval/base.py rename to autorag/autorag/nodes/retrieval/base.py diff --git a/autorag/nodes/retrieval/bm25.py b/autorag/autorag/nodes/retrieval/bm25.py similarity index 100% rename from autorag/nodes/retrieval/bm25.py rename to autorag/autorag/nodes/retrieval/bm25.py diff --git a/autorag/nodes/retrieval/hybrid_cc.py b/autorag/autorag/nodes/retrieval/hybrid_cc.py similarity index 100% rename from autorag/nodes/retrieval/hybrid_cc.py rename to autorag/autorag/nodes/retrieval/hybrid_cc.py diff --git a/autorag/nodes/retrieval/hybrid_rrf.py b/autorag/autorag/nodes/retrieval/hybrid_rrf.py similarity index 100% rename from autorag/nodes/retrieval/hybrid_rrf.py rename to autorag/autorag/nodes/retrieval/hybrid_rrf.py diff --git a/autorag/nodes/retrieval/run.py b/autorag/autorag/nodes/retrieval/run.py similarity index 100% rename from autorag/nodes/retrieval/run.py rename to autorag/autorag/nodes/retrieval/run.py diff --git a/autorag/nodes/retrieval/vectordb.py b/autorag/autorag/nodes/retrieval/vectordb.py similarity index 100% rename from autorag/nodes/retrieval/vectordb.py rename to autorag/autorag/nodes/retrieval/vectordb.py diff --git a/autorag/nodes/util.py b/autorag/autorag/nodes/util.py similarity index 100% rename from autorag/nodes/util.py rename to autorag/autorag/nodes/util.py diff --git a/autorag/parser.py b/autorag/autorag/parser.py similarity index 100% rename from autorag/parser.py rename to autorag/autorag/parser.py diff --git a/autorag/schema/__init__.py b/autorag/autorag/schema/__init__.py similarity index 100% rename from autorag/schema/__init__.py rename to autorag/autorag/schema/__init__.py diff --git a/autorag/schema/base.py b/autorag/autorag/schema/base.py similarity index 100% rename from autorag/schema/base.py rename to autorag/autorag/schema/base.py diff --git a/autorag/schema/metricinput.py b/autorag/autorag/schema/metricinput.py similarity index 100% rename from autorag/schema/metricinput.py rename to autorag/autorag/schema/metricinput.py diff --git a/autorag/schema/module.py b/autorag/autorag/schema/module.py similarity index 100% rename from autorag/schema/module.py rename to autorag/autorag/schema/module.py diff --git a/autorag/schema/node.py b/autorag/autorag/schema/node.py similarity index 100% rename from autorag/schema/node.py rename to autorag/autorag/schema/node.py diff --git a/autorag/strategy.py b/autorag/autorag/strategy.py similarity index 100% rename from autorag/strategy.py rename to autorag/autorag/strategy.py diff --git a/autorag/support.py b/autorag/autorag/support.py similarity index 100% rename from autorag/support.py rename to autorag/autorag/support.py diff --git a/autorag/utils/__init__.py b/autorag/autorag/utils/__init__.py similarity index 100% rename from autorag/utils/__init__.py rename to autorag/autorag/utils/__init__.py diff --git a/autorag/utils/preprocess.py b/autorag/autorag/utils/preprocess.py similarity index 100% rename from autorag/utils/preprocess.py rename to autorag/autorag/utils/preprocess.py diff --git a/autorag/utils/util.py b/autorag/autorag/utils/util.py similarity index 100% rename from autorag/utils/util.py rename to autorag/autorag/utils/util.py diff --git a/autorag/validator.py b/autorag/autorag/validator.py similarity index 100% rename from autorag/validator.py rename to autorag/autorag/validator.py diff --git a/autorag/vectordb/__init__.py b/autorag/autorag/vectordb/__init__.py similarity index 100% rename from autorag/vectordb/__init__.py rename to autorag/autorag/vectordb/__init__.py diff --git a/autorag/vectordb/base.py b/autorag/autorag/vectordb/base.py similarity index 100% rename from autorag/vectordb/base.py rename to autorag/autorag/vectordb/base.py diff --git a/autorag/vectordb/chroma.py b/autorag/autorag/vectordb/chroma.py similarity index 100% rename from autorag/vectordb/chroma.py rename to autorag/autorag/vectordb/chroma.py diff --git a/autorag/vectordb/couchbase.py b/autorag/autorag/vectordb/couchbase.py similarity index 100% rename from autorag/vectordb/couchbase.py rename to autorag/autorag/vectordb/couchbase.py diff --git a/autorag/vectordb/milvus.py b/autorag/autorag/vectordb/milvus.py similarity index 100% rename from autorag/vectordb/milvus.py rename to autorag/autorag/vectordb/milvus.py diff --git a/autorag/vectordb/pinecone.py b/autorag/autorag/vectordb/pinecone.py similarity index 100% rename from autorag/vectordb/pinecone.py rename to autorag/autorag/vectordb/pinecone.py diff --git a/autorag/vectordb/weaviate.py b/autorag/autorag/vectordb/weaviate.py similarity index 100% rename from autorag/vectordb/weaviate.py rename to autorag/autorag/vectordb/weaviate.py diff --git a/autorag/web.py b/autorag/autorag/web.py similarity index 100% rename from autorag/web.py rename to autorag/autorag/web.py diff --git a/build_and_push.sh b/autorag/build_and_push.sh similarity index 100% rename from build_and_push.sh rename to autorag/build_and_push.sh diff --git a/docker-compose.yml b/autorag/docker-compose.yml similarity index 100% rename from docker-compose.yml rename to autorag/docker-compose.yml diff --git a/projects/tutorial_1/config.yaml b/autorag/projects/tutorial_1/config.yaml similarity index 100% rename from projects/tutorial_1/config.yaml rename to autorag/projects/tutorial_1/config.yaml diff --git a/pyproject.toml b/autorag/pyproject.toml similarity index 100% rename from pyproject.toml rename to autorag/pyproject.toml diff --git a/requirements.txt b/autorag/requirements.txt similarity index 100% rename from requirements.txt rename to autorag/requirements.txt diff --git a/sample_config/chunk/chunk_full.yaml b/autorag/sample_config/chunk/chunk_full.yaml similarity index 100% rename from sample_config/chunk/chunk_full.yaml rename to autorag/sample_config/chunk/chunk_full.yaml diff --git a/sample_config/chunk/chunk_ko.yaml b/autorag/sample_config/chunk/chunk_ko.yaml similarity index 100% rename from sample_config/chunk/chunk_ko.yaml rename to autorag/sample_config/chunk/chunk_ko.yaml diff --git a/sample_config/chunk/simple_chunk.yaml b/autorag/sample_config/chunk/simple_chunk.yaml similarity index 100% rename from sample_config/chunk/simple_chunk.yaml rename to autorag/sample_config/chunk/simple_chunk.yaml diff --git a/sample_config/parse/parse_full.yaml b/autorag/sample_config/parse/parse_full.yaml similarity index 100% rename from sample_config/parse/parse_full.yaml rename to autorag/sample_config/parse/parse_full.yaml diff --git a/sample_config/parse/parse_hybird.yaml b/autorag/sample_config/parse/parse_hybird.yaml similarity index 100% rename from sample_config/parse/parse_hybird.yaml rename to autorag/sample_config/parse/parse_hybird.yaml diff --git a/sample_config/parse/parse_ko.yaml b/autorag/sample_config/parse/parse_ko.yaml similarity index 100% rename from sample_config/parse/parse_ko.yaml rename to autorag/sample_config/parse/parse_ko.yaml diff --git a/sample_config/parse/parse_multimodal.yaml b/autorag/sample_config/parse/parse_multimodal.yaml similarity index 100% rename from sample_config/parse/parse_multimodal.yaml rename to autorag/sample_config/parse/parse_multimodal.yaml diff --git a/sample_config/parse/parse_ocr.yaml b/autorag/sample_config/parse/parse_ocr.yaml similarity index 100% rename from sample_config/parse/parse_ocr.yaml rename to autorag/sample_config/parse/parse_ocr.yaml diff --git a/sample_config/parse/simple_parse.yaml b/autorag/sample_config/parse/simple_parse.yaml similarity index 100% rename from sample_config/parse/simple_parse.yaml rename to autorag/sample_config/parse/simple_parse.yaml diff --git a/sample_config/rag/english/gpu/compact_local.yaml b/autorag/sample_config/rag/english/gpu/compact_local.yaml similarity index 100% rename from sample_config/rag/english/gpu/compact_local.yaml rename to autorag/sample_config/rag/english/gpu/compact_local.yaml diff --git a/sample_config/rag/english/gpu/compact_openai.yaml b/autorag/sample_config/rag/english/gpu/compact_openai.yaml similarity index 100% rename from sample_config/rag/english/gpu/compact_openai.yaml rename to autorag/sample_config/rag/english/gpu/compact_openai.yaml diff --git a/sample_config/rag/english/gpu/full.yaml b/autorag/sample_config/rag/english/gpu/full.yaml similarity index 100% rename from sample_config/rag/english/gpu/full.yaml rename to autorag/sample_config/rag/english/gpu/full.yaml diff --git a/sample_config/rag/english/gpu/half.yaml b/autorag/sample_config/rag/english/gpu/half.yaml similarity index 100% rename from sample_config/rag/english/gpu/half.yaml rename to autorag/sample_config/rag/english/gpu/half.yaml diff --git a/sample_config/rag/english/gpu_api/compact.yaml b/autorag/sample_config/rag/english/gpu_api/compact.yaml similarity index 100% rename from sample_config/rag/english/gpu_api/compact.yaml rename to autorag/sample_config/rag/english/gpu_api/compact.yaml diff --git a/sample_config/rag/english/gpu_api/full.yaml b/autorag/sample_config/rag/english/gpu_api/full.yaml similarity index 100% rename from sample_config/rag/english/gpu_api/full.yaml rename to autorag/sample_config/rag/english/gpu_api/full.yaml diff --git a/sample_config/rag/english/gpu_api/half.yaml b/autorag/sample_config/rag/english/gpu_api/half.yaml similarity index 100% rename from sample_config/rag/english/gpu_api/half.yaml rename to autorag/sample_config/rag/english/gpu_api/half.yaml diff --git a/sample_config/rag/english/non_gpu/compact.yaml b/autorag/sample_config/rag/english/non_gpu/compact.yaml similarity index 100% rename from sample_config/rag/english/non_gpu/compact.yaml rename to autorag/sample_config/rag/english/non_gpu/compact.yaml diff --git a/sample_config/rag/english/non_gpu/full.yaml b/autorag/sample_config/rag/english/non_gpu/full.yaml similarity index 100% rename from sample_config/rag/english/non_gpu/full.yaml rename to autorag/sample_config/rag/english/non_gpu/full.yaml diff --git a/sample_config/rag/english/non_gpu/half.yaml b/autorag/sample_config/rag/english/non_gpu/half.yaml similarity index 100% rename from sample_config/rag/english/non_gpu/half.yaml rename to autorag/sample_config/rag/english/non_gpu/half.yaml diff --git a/sample_config/rag/english/non_gpu/simple_bedrock.yaml b/autorag/sample_config/rag/english/non_gpu/simple_bedrock.yaml similarity index 100% rename from sample_config/rag/english/non_gpu/simple_bedrock.yaml rename to autorag/sample_config/rag/english/non_gpu/simple_bedrock.yaml diff --git a/sample_config/rag/english/non_gpu/simple_local.yaml b/autorag/sample_config/rag/english/non_gpu/simple_local.yaml similarity index 100% rename from sample_config/rag/english/non_gpu/simple_local.yaml rename to autorag/sample_config/rag/english/non_gpu/simple_local.yaml diff --git a/sample_config/rag/english/non_gpu/simple_ollama.yaml b/autorag/sample_config/rag/english/non_gpu/simple_ollama.yaml similarity index 100% rename from sample_config/rag/english/non_gpu/simple_ollama.yaml rename to autorag/sample_config/rag/english/non_gpu/simple_ollama.yaml diff --git a/sample_config/rag/english/non_gpu/simple_openai.yaml b/autorag/sample_config/rag/english/non_gpu/simple_openai.yaml similarity index 100% rename from sample_config/rag/english/non_gpu/simple_openai.yaml rename to autorag/sample_config/rag/english/non_gpu/simple_openai.yaml diff --git a/sample_config/rag/extracted_sample.yaml b/autorag/sample_config/rag/extracted_sample.yaml similarity index 100% rename from sample_config/rag/extracted_sample.yaml rename to autorag/sample_config/rag/extracted_sample.yaml diff --git a/sample_config/rag/full.yaml b/autorag/sample_config/rag/full.yaml similarity index 100% rename from sample_config/rag/full.yaml rename to autorag/sample_config/rag/full.yaml diff --git a/sample_config/rag/korean/gpu/compact_korean.yaml b/autorag/sample_config/rag/korean/gpu/compact_korean.yaml similarity index 100% rename from sample_config/rag/korean/gpu/compact_korean.yaml rename to autorag/sample_config/rag/korean/gpu/compact_korean.yaml diff --git a/sample_config/rag/korean/gpu/full_korean.yaml b/autorag/sample_config/rag/korean/gpu/full_korean.yaml similarity index 100% rename from sample_config/rag/korean/gpu/full_korean.yaml rename to autorag/sample_config/rag/korean/gpu/full_korean.yaml diff --git a/sample_config/rag/korean/gpu/half_korean.yaml b/autorag/sample_config/rag/korean/gpu/half_korean.yaml similarity index 100% rename from sample_config/rag/korean/gpu/half_korean.yaml rename to autorag/sample_config/rag/korean/gpu/half_korean.yaml diff --git a/sample_config/rag/korean/gpu_api/compact_korean.yaml b/autorag/sample_config/rag/korean/gpu_api/compact_korean.yaml similarity index 100% rename from sample_config/rag/korean/gpu_api/compact_korean.yaml rename to autorag/sample_config/rag/korean/gpu_api/compact_korean.yaml diff --git a/sample_config/rag/korean/gpu_api/full_korean.yaml b/autorag/sample_config/rag/korean/gpu_api/full_korean.yaml similarity index 100% rename from sample_config/rag/korean/gpu_api/full_korean.yaml rename to autorag/sample_config/rag/korean/gpu_api/full_korean.yaml diff --git a/sample_config/rag/korean/gpu_api/half_korean.yaml b/autorag/sample_config/rag/korean/gpu_api/half_korean.yaml similarity index 100% rename from sample_config/rag/korean/gpu_api/half_korean.yaml rename to autorag/sample_config/rag/korean/gpu_api/half_korean.yaml diff --git a/sample_config/rag/korean/non_gpu/compact_korean.yaml b/autorag/sample_config/rag/korean/non_gpu/compact_korean.yaml similarity index 100% rename from sample_config/rag/korean/non_gpu/compact_korean.yaml rename to autorag/sample_config/rag/korean/non_gpu/compact_korean.yaml diff --git a/sample_config/rag/korean/non_gpu/full_korean.yaml b/autorag/sample_config/rag/korean/non_gpu/full_korean.yaml similarity index 100% rename from sample_config/rag/korean/non_gpu/full_korean.yaml rename to autorag/sample_config/rag/korean/non_gpu/full_korean.yaml diff --git a/sample_config/rag/korean/non_gpu/half_korean.yaml b/autorag/sample_config/rag/korean/non_gpu/half_korean.yaml similarity index 100% rename from sample_config/rag/korean/non_gpu/half_korean.yaml rename to autorag/sample_config/rag/korean/non_gpu/half_korean.yaml diff --git a/sample_config/rag/korean/non_gpu/simple_korean.yaml b/autorag/sample_config/rag/korean/non_gpu/simple_korean.yaml similarity index 100% rename from sample_config/rag/korean/non_gpu/simple_korean.yaml rename to autorag/sample_config/rag/korean/non_gpu/simple_korean.yaml diff --git a/sample_dataset/README.md b/autorag/sample_dataset/README.md similarity index 96% rename from sample_dataset/README.md rename to autorag/sample_dataset/README.md index afdaa32ca..c249ad788 100644 --- a/sample_dataset/README.md +++ b/autorag/sample_dataset/README.md @@ -1,6 +1,6 @@ # sample_dataset handling -The sample_dataset folder does not includes a `qa.parquet`, `corpus.parquet` file that is significantly large and cannot be uploaded directly to Git due to size limitations. +The sample_dataset folder does not includes a `qa.parquet`, `corpus.parquet` file that is significantly large and cannot be uploaded directly to Git due to size limitations. To prepare and use datasets available in the sample_dataset folder, specifically `triviaqa`, `hotpotqa`, `msmarco` and `eli5`, you can follow the outlined methods below. diff --git a/sample_dataset/eli5/load_eli5_dataset.py b/autorag/sample_dataset/eli5/load_eli5_dataset.py similarity index 100% rename from sample_dataset/eli5/load_eli5_dataset.py rename to autorag/sample_dataset/eli5/load_eli5_dataset.py diff --git a/sample_dataset/hotpotqa/load_hotpotqa_dataset.py b/autorag/sample_dataset/hotpotqa/load_hotpotqa_dataset.py similarity index 100% rename from sample_dataset/hotpotqa/load_hotpotqa_dataset.py rename to autorag/sample_dataset/hotpotqa/load_hotpotqa_dataset.py diff --git a/sample_dataset/msmarco/load_msmarco_dataset.py b/autorag/sample_dataset/msmarco/load_msmarco_dataset.py similarity index 100% rename from sample_dataset/msmarco/load_msmarco_dataset.py rename to autorag/sample_dataset/msmarco/load_msmarco_dataset.py diff --git a/sample_dataset/triviaqa/load_triviaqa_dataset.py b/autorag/sample_dataset/triviaqa/load_triviaqa_dataset.py similarity index 100% rename from sample_dataset/triviaqa/load_triviaqa_dataset.py rename to autorag/sample_dataset/triviaqa/load_triviaqa_dataset.py From 356c2533c67100fe93c002b8d655ad1c2a8c5ca1 Mon Sep 17 00:00:00 2001 From: jeffrey Date: Tue, 19 Nov 2024 15:12:31 +0800 Subject: [PATCH 04/55] .gitignore in the autorag source folder --- .dockerignore => autorag/.dockerignore | 6 +++--- .gitignore => autorag/.gitignore | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) rename .dockerignore => autorag/.dockerignore (97%) rename .gitignore => autorag/.gitignore (97%) diff --git a/.dockerignore b/autorag/.dockerignore similarity index 97% rename from .dockerignore rename to autorag/.dockerignore index 92d100458..a97ff2cbd 100644 --- a/.dockerignore +++ b/autorag/.dockerignore @@ -120,7 +120,7 @@ celerybeat.pid *.sage.py # Environments -.env +../.env .venv env/ venv/ @@ -161,5 +161,5 @@ cython_debug/ .idea/ pytest.ini .DS_Store -autorag/projects/tutorial_1 -!autorag/projects/tutorial_1/config.yaml +projects/tutorial_1 +!projects/tutorial_1/config.yaml diff --git a/.gitignore b/autorag/.gitignore similarity index 97% rename from .gitignore rename to autorag/.gitignore index 7b325c116..c0fa6e820 100644 --- a/.gitignore +++ b/autorag/.gitignore @@ -120,7 +120,7 @@ celerybeat.pid *.sage.py # Environments -.env +../.env .venv env/ venv/ @@ -160,5 +160,5 @@ cython_debug/ .idea/ pytest.ini .DS_Store -autorag/projects/tutorial_1 -!autorag/projects/tutorial_1/config.yaml +projects/tutorial_1 +!projects/tutorial_1/config.yaml From bc085e486b234602746b8141e2e66f14306a0717 Mon Sep 17 00:00:00 2001 From: jeffrey Date: Tue, 19 Nov 2024 15:17:14 +0800 Subject: [PATCH 05/55] edit github actions --- .github/workflows/docker-push.yml | 4 ++++ .github/workflows/gpu-docker-push.yml | 4 ++++ .github/workflows/publish.yml | 4 ++++ .github/workflows/test.yml | 2 +- 4 files changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docker-push.yml b/.github/workflows/docker-push.yml index 34a2b4c29..9903cc4b8 100644 --- a/.github/workflows/docker-push.yml +++ b/.github/workflows/docker-push.yml @@ -4,6 +4,10 @@ on: push: branches: [ "main" ] +defaults: + run: + working-directory: ./autorag + env: DOCKER_REPO: "autoraghq/autorag" diff --git a/.github/workflows/gpu-docker-push.yml b/.github/workflows/gpu-docker-push.yml index 203f74911..d1bba0447 100644 --- a/.github/workflows/gpu-docker-push.yml +++ b/.github/workflows/gpu-docker-push.yml @@ -4,6 +4,10 @@ on: push: branches: [ "main" ] +defaults: + run: + working-directory: ./autorag + env: DOCKER_REPO: "autoraghq/autorag" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 37693f1e6..a3126829d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -5,6 +5,10 @@ on: branches: - main +defaults: + run: + working-directory: ./autorag + jobs: pypi-publish: name: upload release to PyPI diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index db491017d..7c6ba4548 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -40,7 +40,7 @@ jobs: sudo apt-get install tesseract-ocr - name: Install AutoRAG run: | - pip install -e '.[ko,dev,parse,ja]' + pip install -e './autorag[ko,dev,parse,ja]' - name: Install dependencies run: | pip install -r tests/requirements.txt From a9043fb85af68edd84b763a7c9f712020d98b8e7 Mon Sep 17 00:00:00 2001 From: jeffrey Date: Tue, 19 Nov 2024 15:18:44 +0800 Subject: [PATCH 06/55] fix .env .gitignore --- autorag/.dockerignore | 2 +- autorag/.gitignore | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/autorag/.dockerignore b/autorag/.dockerignore index a97ff2cbd..d7ae30111 100644 --- a/autorag/.dockerignore +++ b/autorag/.dockerignore @@ -120,7 +120,7 @@ celerybeat.pid *.sage.py # Environments -../.env +.env .venv env/ venv/ diff --git a/autorag/.gitignore b/autorag/.gitignore index c0fa6e820..6ec7d4887 100644 --- a/autorag/.gitignore +++ b/autorag/.gitignore @@ -120,7 +120,7 @@ celerybeat.pid *.sage.py # Environments -../.env +.env .venv env/ venv/ From 9a07a44c42edd1d138c82f4093c29cc6eba21b7b Mon Sep 17 00:00:00 2001 From: jeffrey Date: Tue, 19 Nov 2024 15:19:32 +0800 Subject: [PATCH 07/55] add root .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..81e3a94a2 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +pytest.ini From e2c08fc0b936762f2c401d802e5b4f48bfefa13d Mon Sep 17 00:00:00 2001 From: jeffrey Date: Tue, 19 Nov 2024 15:34:56 +0800 Subject: [PATCH 08/55] set PYTHONPATH at test.yml --- .github/workflows/test.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7c6ba4548..0d6c8943d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -54,6 +54,8 @@ jobs: python3 -c "import nltk; nltk.download('averaged_perceptron_tagger_eng')" - name: delete tests package run: python3 tests/delete_tests.py - - name: Run tests + - name: Run AutoRAG tests + env: + PYTHONPATH: ${PYTHONPATH}:./autorag run: | python3 -m pytest -o log_cli=true --log-cli-level=INFO -n auto tests/ From 4ba97d3a289d1d739350cbb899ecb2c5485aa791 Mon Sep 17 00:00:00 2001 From: jeffrey Date: Tue, 19 Nov 2024 15:52:21 +0800 Subject: [PATCH 09/55] change the name of the test_base.py --- tests/autorag/schema/test_base.py | 38 ----------- tests/autorag/schema/test_base_schema.py | 38 +++++++++++ tests/autorag/vectordb/test_base.py | 70 -------------------- tests/autorag/vectordb/test_base_vectordb.py | 70 ++++++++++++++++++++ 4 files changed, 108 insertions(+), 108 deletions(-) delete mode 100644 tests/autorag/schema/test_base.py create mode 100644 tests/autorag/schema/test_base_schema.py delete mode 100644 tests/autorag/vectordb/test_base.py create mode 100644 tests/autorag/vectordb/test_base_vectordb.py diff --git a/tests/autorag/schema/test_base.py b/tests/autorag/schema/test_base.py deleted file mode 100644 index 6f0a3c17b..000000000 --- a/tests/autorag/schema/test_base.py +++ /dev/null @@ -1,38 +0,0 @@ -from pathlib import Path -from typing import Union - -import pandas as pd - -from autorag.schema import BaseModule - - -class TestModule(BaseModule): - def __init__(self, project_dir: Union[str, Path], *args, **kwargs): - self.param1 = kwargs.pop("param1", None) - self.param2 = self.cast_to_init(project_dir, *args, **kwargs) - - def pure(self, previous_result: pd.DataFrame, *args, **kwargs): - param3 = self.cast_to_run(previous_result, *args, **kwargs) - return param3, self._pure(*args, **kwargs) - - def _pure(self, *args, **kwargs): - return [ - kwargs.pop("param1", None), - kwargs.pop("param2", None), - kwargs.pop("param3", None), - kwargs, - ] - - def cast_to_init(self, project_dir: Union[str, Path], *args, **kwargs): - return kwargs.pop("param2", None) - - def cast_to_run(self, previous_result: pd.DataFrame, *args, **kwargs): - return kwargs.pop("param3", None) - - -def test_base_module_kwargs_pop(): - param3, result_lst = TestModule.run_evaluator( - "pseudo", previous_result=pd.DataFrame(), param1=1, param2=2, param3=3, param4=4 - ) - assert param3 == 3 - assert result_lst == [1, 2, 3, {"param4": 4}] diff --git a/tests/autorag/schema/test_base_schema.py b/tests/autorag/schema/test_base_schema.py new file mode 100644 index 000000000..07ae87d9b --- /dev/null +++ b/tests/autorag/schema/test_base_schema.py @@ -0,0 +1,38 @@ +from pathlib import Path +from typing import Union + +import pandas as pd + +from autorag.schema import BaseModule + + +class TestModule(BaseModule): + def __init__(self, project_dir: Union[str, Path], *args, **kwargs): + self.param1 = kwargs.pop("param1", None) + self.param2 = self.cast_to_init(project_dir, *args, **kwargs) + + def pure(self, previous_result: pd.DataFrame, *args, **kwargs): + param3 = self.cast_to_run(previous_result, *args, **kwargs) + return param3, self._pure(*args, **kwargs) + + def _pure(self, *args, **kwargs): + return [ + kwargs.pop("param1", None), + kwargs.pop("param2", None), + kwargs.pop("param3", None), + kwargs, + ] + + def cast_to_init(self, project_dir: Union[str, Path], *args, **kwargs): + return kwargs.pop("param2", None) + + def cast_to_run(self, previous_result: pd.DataFrame, *args, **kwargs): + return kwargs.pop("param3", None) + + +def test_base_module_kwargs_pop(): + param3, result_lst = TestModule.run_evaluator( + "pseudo", previous_result=pd.DataFrame(), param1=1, param2=2, param3=3, param4=4 + ) + assert param3 == 3 + assert result_lst == [1, 2, 3, {"param4": 4}] diff --git a/tests/autorag/vectordb/test_base.py b/tests/autorag/vectordb/test_base.py deleted file mode 100644 index e3aff4ea9..000000000 --- a/tests/autorag/vectordb/test_base.py +++ /dev/null @@ -1,70 +0,0 @@ -import os.path -import pathlib -import tempfile - -from llama_index.core import MockEmbedding -from llama_index.embeddings.openai import OpenAIEmbedding - -from autorag.vectordb import ( - load_vectordb, - load_vectordb_from_yaml, - load_all_vectordb_from_yaml, -) -from autorag.vectordb.chroma import Chroma - - -root_path = pathlib.PurePath(os.path.dirname(os.path.realpath(__file__))).parent.parent -resource_dir = os.path.join(root_path, "resources") - - -def test_load_vectordb(): - db = load_vectordb( - "chroma", - client_type="ephemeral", - collection_name="jax1", - embedding_model="mock", - ) - assert isinstance(db, Chroma) - assert db.collection.name == "jax1" - - -def test_load_vectordb_from_yaml(): - yaml_path = os.path.join(resource_dir, "simple_mock.yaml") - with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as project_dir: - os.environ["PROJECT_DIR"] = project_dir - default_vectordb = load_vectordb_from_yaml(yaml_path, "default", project_dir) - assert isinstance(default_vectordb, Chroma) - assert default_vectordb.collection.name == "openai" - assert isinstance(default_vectordb.embedding, OpenAIEmbedding) - - chroma_default_vectordb = load_vectordb_from_yaml( - yaml_path, "chroma_default", project_dir - ) - assert isinstance(chroma_default_vectordb, Chroma) - assert chroma_default_vectordb.collection.name == "openai" - assert isinstance(chroma_default_vectordb.embedding, MockEmbedding) - - chroma_large_vectordb = load_vectordb_from_yaml( - yaml_path, "chroma_large", project_dir - ) - assert isinstance(chroma_large_vectordb, Chroma) - assert chroma_large_vectordb.collection.name == "openai_embed_3_large" - assert isinstance(chroma_large_vectordb.embedding, MockEmbedding) - - -def test_load_all_vectordb_from_yaml(): - yaml_path = os.path.join(resource_dir, "simple_mock.yaml") - with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as project_dir: - os.environ["PROJECT_DIR"] = project_dir - vectordb_list = load_all_vectordb_from_yaml(yaml_path, project_dir) - assert len(vectordb_list) == 2 - - chroma_default_vectordb = vectordb_list[0] - assert isinstance(chroma_default_vectordb, Chroma) - assert chroma_default_vectordb.collection.name == "openai" - assert isinstance(chroma_default_vectordb.embedding, MockEmbedding) - - chroma_large_vectordb = vectordb_list[1] - assert isinstance(chroma_large_vectordb, Chroma) - assert chroma_large_vectordb.collection.name == "openai_embed_3_large" - assert isinstance(chroma_large_vectordb.embedding, MockEmbedding) diff --git a/tests/autorag/vectordb/test_base_vectordb.py b/tests/autorag/vectordb/test_base_vectordb.py new file mode 100644 index 000000000..e51044040 --- /dev/null +++ b/tests/autorag/vectordb/test_base_vectordb.py @@ -0,0 +1,70 @@ +import os.path +import pathlib +import tempfile + +from llama_index.core import MockEmbedding +from llama_index.embeddings.openai import OpenAIEmbedding + +from autorag.vectordb import ( + load_vectordb, + load_vectordb_from_yaml, + load_all_vectordb_from_yaml, +) +from autorag.vectordb.chroma import Chroma + + +root_path = pathlib.PurePath(os.path.dirname(os.path.realpath(__file__))).parent.parent +resource_dir = os.path.join(root_path, "resources") + + +def test_load_vectordb(): + db = load_vectordb( + "chroma", + client_type="ephemeral", + collection_name="jax1", + embedding_model="mock", + ) + assert isinstance(db, Chroma) + assert db.collection.name == "jax1" + + +def test_load_vectordb_from_yaml(): + yaml_path = os.path.join(resource_dir, "simple_mock.yaml") + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as project_dir: + os.environ["PROJECT_DIR"] = project_dir + default_vectordb = load_vectordb_from_yaml(yaml_path, "default", project_dir) + assert isinstance(default_vectordb, Chroma) + assert default_vectordb.collection.name == "openai" + assert isinstance(default_vectordb.embedding, OpenAIEmbedding) + + chroma_default_vectordb = load_vectordb_from_yaml( + yaml_path, "chroma_default", project_dir + ) + assert isinstance(chroma_default_vectordb, Chroma) + assert chroma_default_vectordb.collection.name == "openai" + assert isinstance(chroma_default_vectordb.embedding, MockEmbedding) + + chroma_large_vectordb = load_vectordb_from_yaml( + yaml_path, "chroma_large", project_dir + ) + assert isinstance(chroma_large_vectordb, Chroma) + assert chroma_large_vectordb.collection.name == "openai_embed_3_large" + assert isinstance(chroma_large_vectordb.embedding, MockEmbedding) + + +def test_load_all_vectordb_from_yaml(): + yaml_path = os.path.join(resource_dir, "simple_mock.yaml") + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as project_dir: + os.environ["PROJECT_DIR"] = project_dir + vectordb_list = load_all_vectordb_from_yaml(yaml_path, project_dir) + assert len(vectordb_list) == 2 + + chroma_default_vectordb = vectordb_list[0] + assert isinstance(chroma_default_vectordb, Chroma) + assert chroma_default_vectordb.collection.name == "openai" + assert isinstance(chroma_default_vectordb.embedding, MockEmbedding) + + chroma_large_vectordb = vectordb_list[1] + assert isinstance(chroma_large_vectordb, Chroma) + assert chroma_large_vectordb.collection.name == "openai_embed_3_large" + assert isinstance(chroma_large_vectordb.embedding, MockEmbedding) From d9fa4b2d2ae75ffa541b67801c3dedea0ee7c946 Mon Sep 17 00:00:00 2001 From: jeffrey Date: Tue, 19 Nov 2024 17:10:42 +0800 Subject: [PATCH 10/55] change the VERSION path at docs/conf.py --- docs/source/conf.py | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 24f239912..24d6b6514 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -10,29 +10,29 @@ copyright = "2024, Marker-Inc" author = "Marker-Inc" -with open("../../autorag/VERSION") as f: - version = f.read().strip() +with open("../../autorag/autorag/VERSION") as f: + version = f.read().strip() # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration extensions = [ - "sphinx.ext.autodoc", - "sphinx.ext.extlinks", - "sphinx.ext.intersphinx", - "sphinx.ext.mathjax", - "sphinx.ext.todo", - "sphinx.ext.viewcode", - "myst_parser", - "sphinx_copybutton", - "sphinx_design", - "sphinx_inline_tabs", - "sphinxcontrib.googleanalytics", - "sphinx_sitemap", + "sphinx.ext.autodoc", + "sphinx.ext.extlinks", + "sphinx.ext.intersphinx", + "sphinx.ext.mathjax", + "sphinx.ext.todo", + "sphinx.ext.viewcode", + "myst_parser", + "sphinx_copybutton", + "sphinx_design", + "sphinx_inline_tabs", + "sphinxcontrib.googleanalytics", + "sphinx_sitemap", ] source_suffix = { - ".rst": "restructuredtext", - ".md": "markdown", + ".rst": "restructuredtext", + ".md": "markdown", } templates_path = ["_templates"] From 9d90aca1f28234afc177ee76bb785248f0274590 Mon Sep 17 00:00:00 2001 From: jeffrey Date: Tue, 19 Nov 2024 17:27:33 +0800 Subject: [PATCH 11/55] Add api to repository --- api/.gitignore | 164 +++++ api/README.md | 14 + api/app.py | 955 ++++++++++++++++++++++++++ api/docs/README.md | 99 +++ api/docs/openapi.js | 1179 ++++++++++++++++++++++++++++++++ api/docs/openapi.yaml | 748 ++++++++++++++++++++ api/docs/openapi3_0.json | 1179 ++++++++++++++++++++++++++++++++ api/docs/swagger-api.html | 37 + api/requirements.txt | 6 + api/src/__init__.py | 0 api/src/auth.py | 39 ++ api/src/evaluate_history.py | 71 ++ api/src/qa_create.py | 119 ++++ api/src/run.py | 111 +++ api/src/schema.py | 158 +++++ api/src/trial_config.py | 105 +++ api/src/validate.py | 57 ++ tests/api/test.http | 194 ++++++ tests/api/test_app.py | 371 ++++++++++ tests/api/test_trial_config.py | 111 +++ 20 files changed, 5717 insertions(+) create mode 100644 api/.gitignore create mode 100644 api/README.md create mode 100644 api/app.py create mode 100644 api/docs/README.md create mode 100644 api/docs/openapi.js create mode 100644 api/docs/openapi.yaml create mode 100644 api/docs/openapi3_0.json create mode 100644 api/docs/swagger-api.html create mode 100644 api/requirements.txt create mode 100644 api/src/__init__.py create mode 100644 api/src/auth.py create mode 100644 api/src/evaluate_history.py create mode 100644 api/src/qa_create.py create mode 100644 api/src/run.py create mode 100644 api/src/schema.py create mode 100644 api/src/trial_config.py create mode 100644 api/src/validate.py create mode 100644 tests/api/test.http create mode 100644 tests/api/test_app.py create mode 100644 tests/api/test_trial_config.py diff --git a/api/.gitignore b/api/.gitignore new file mode 100644 index 000000000..78a5d0a2d --- /dev/null +++ b/api/.gitignore @@ -0,0 +1,164 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +.idea/ +.DS_Store +projects diff --git a/api/README.md b/api/README.md new file mode 100644 index 000000000..31b96848d --- /dev/null +++ b/api/README.md @@ -0,0 +1,14 @@ +# AutoRAG API Server + +Quart API server for running AutoRAG and various data creations. + +## Installation + +```bash +pip install -r requirements.txt +``` + +And run +```bash +python3 app.py +``` diff --git a/api/app.py b/api/app.py new file mode 100644 index 000000000..a02d1b214 --- /dev/null +++ b/api/app.py @@ -0,0 +1,955 @@ +import asyncio +import os +import signal +import tempfile +import concurrent.futures +import uuid +from datetime import datetime +from pathlib import Path +from typing import Callable, Dict, Optional + +import pandas as pd +import yaml +from pydantic import ValidationError +from quart import Quart, request, jsonify +from quart_cors import cors # Import quart_cors to enable CORS +from quart_uploads import UploadSet, configure_uploads + +from src.auth import require_auth +from src.evaluate_history import get_new_trial_dir +from src.run import ( + run_parser_start_parsing, + run_chunker_start_chunking, + run_qa_creation, + run_start_trial, + run_validate, + run_dashboard, + run_chat, +) +from src.schema import ( + ChunkRequest, + ParseRequest, + EnvVariableRequest, + QACreationRequest, + Project, + Task, + Status, + TaskType, + TrialCreateRequest, + Trial, + TrialConfig, +) + +import nest_asyncio + +from src.trial_config import PandasTrialDB +from src.validate import project_exists, trial_exists + + +nest_asyncio.apply() + +app = Quart(__name__) +app = cors( + app, + allow_origin=["http://localhost:3000"], # 구체적인 origin 지정 + allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allow_headers=["Content-Type", "Authorization"], + allow_credentials=True, + max_age=3600, +) +print("CORS enabled for http://localhost:3000") + +# Global variables to manage tasks +tasks = {} # task_id -> task_info # This will be the temporal DB for task infos +task_futures = {} # task_id -> future (for forceful termination) +task_queue = asyncio.Queue() +current_task_id = None # ID of the currently running task +lock = asyncio.Lock() # To manage access to shared variables + +ROOT_DIR = os.path.dirname(os.path.realpath(__file__)) +WORK_DIR = os.path.join(ROOT_DIR, "projects") + + +# Function to create a task +async def create_task(task_id: str, task: Task, func: Callable, *args) -> None: + tasks[task_id] = { + "function": func, + "args": args, + "error": None, + "task": task, + } + await task_queue.put(task_id) + + +async def task_runner(): + global current_task_id + loop = asyncio.get_running_loop() + executor = concurrent.futures.ProcessPoolExecutor() + try: + while True: + task_id = await task_queue.get() + async with lock: + current_task_id = task_id + tasks[task_id]["task"].status = Status.IN_PROGRESS + + try: + # Get function and arguments from task info + func = tasks[task_id]["function"] + args = tasks[task_id].get("args", ()) + + # Run the function in a separate process + future = loop.run_in_executor( + executor, + func, + *args, + ) + task_futures[task_id] = future + + await future + # Use future Results + if func.__name__ == run_dashboard.__name__: + tasks[task_id]["report_pid"] = future.result() + elif func.__name__ == run_chat.__name__: + tasks[task_id]["chat_pid"] = future.result() + + # Update status on completion + async with lock: + print(f"Task {task_id} is completed") + tasks[task_id]["task"].status = Status.COMPLETED + current_task_id = None + except asyncio.CancelledError: + tasks[task_id]["task"].status = Status.TERMINATED + print(f"Task {task_id} has been forcefully terminated.") + except Exception as e: + # Handle errors + async with lock: + tasks[task_id]["task"].status = Status.FAILED + tasks[task_id]["error"] = str(e) + current_task_id = None + print(f"Task {task_id} failed with error: {e}") + + finally: + task_queue.task_done() + task_futures.pop(task_id, None) + finally: + executor.shutdown() + + +async def cancel_task(task_id: str) -> None: + async with lock: + future = task_futures.get(task_id) + if future and not future.done(): + try: + # Attempt to kill the associated process directly + future.cancel() + except Exception as e: + tasks[task_id]["task"].status = Status.FAILED + tasks[task_id]["error"] = f"Failed to terminate: {str(e)}" + print(f"Task {task_id} failed to terminate with error: {e}") + else: + print(f"Task {task_id} is not running or already completed.") + + +@app.before_serving +async def startup(): + # Start the background task when the app starts + app.add_background_task(task_runner) + + +# Project creation endpoint +@app.route("/projects", methods=["POST"]) +@require_auth() +async def create_project(): + data = await request.get_json() + + # Validate required fields + if not data or "name" not in data: + return jsonify({"error": "Name is required"}), 400 + + description = data.get("description", "") + + # Create a new project + new_project_dir = os.path.join(WORK_DIR, data["name"]) + if not os.path.exists(new_project_dir): + os.makedirs(new_project_dir) + os.makedirs(os.path.join(new_project_dir, "parse")) + os.makedirs(os.path.join(new_project_dir, "chunk")) + os.makedirs(os.path.join(new_project_dir, "qa")) + os.makedirs(os.path.join(new_project_dir, "project")) + os.makedirs(os.path.join(new_project_dir, "config")) + # Make trial_config.csv file + _ = PandasTrialDB(os.path.join(new_project_dir, "trial_config.csv")) + else: + return jsonify({"error": f'Project name already exists: {data["name"]}'}), 400 + + # save at 'description.txt' file + with open(os.path.join(new_project_dir, "description.txt"), "w") as f: + f.write(description) + + response = Project( + id=data["name"], + name=data["name"], + description=description, + created_at=datetime.now(), + status="active", + metadata={}, + ) + return jsonify(response.model_dump()), 201 + + +async def get_project_directories(): + """Get all project directories from WORK_DIR.""" + directories = [] + + # List all directories in WORK_DIR + for item in Path(WORK_DIR).iterdir(): + if item.is_dir(): + directories.append( + { + "name": item.name, + "status": "active", # All projects are currently active + "path": str(item), + "last_modified_datetime": datetime.fromtimestamp( + item.stat().st_mtime + ), + "created_datetime": datetime.fromtimestamp(item.stat().st_ctime), + } + ) + + directories.sort(key=lambda x: x["last_modified_datetime"], reverse=True) + return directories + + +@app.route("/projects", methods=["GET"]) +async def list_projects(): + """List all projects with pagination. It returns the last modified projects first.""" + # Get query parameters with defaults + page = request.args.get("page", 1, type=int) + limit = request.args.get("limit", 10, type=int) + status = request.args.get("status", "active") + + # Validate pagination parameters + if page < 1: + page = 1 + if limit < 1: + limit = 10 + + # Get all projects + projects = await get_project_directories() + + # Filter by status if provided (though all are active) + if status: + projects = [p for p in projects if p["status"] == status] + + # Calculate pagination + total = len(projects) + start_idx = (page - 1) * limit + end_idx = start_idx + limit + + # Get paginated data + paginated_projects = projects[start_idx:end_idx] + + # Get descriptions from paginated data + def get_project_description(project_name): + description_path = os.path.join(WORK_DIR, project_name, "description.txt") + try: + with open(description_path, "r") as f: + return f.read() + except FileNotFoundError: + # 파일이 없으면 빈 description.txt 파일 생성 + with open(description_path, "w") as f: + f.write(f"## {project_name}") + return "" + + projects = [ + Project( + id=p["name"], + name=p["name"], + description=get_project_description(p["name"]), + created_at=p["created_datetime"], + status=p["status"], + metadata={}, + ) + for p in paginated_projects + ] + + return jsonify( + { + "total": total, + "data": list(map(lambda p: p.model_dump(), projects)), + } + ), 200 + + +@app.route("/projects//trials", methods=["GET"]) +@project_exists(WORK_DIR) +async def get_trial_lists(project_id: str): + trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") + trial_config_db = PandasTrialDB(trial_config_path) + trial_ids = trial_config_db.get_all_config_ids() + return jsonify( + { + "total": len(trial_ids), + "data": list( + map(lambda x: trial_config_db.get_trial(x).model_dump(), trial_ids) + ), + } + ) + + +@app.route("/projects//trials", methods=["POST"]) +@project_exists(WORK_DIR) +async def create_new_trial(project_id: str): + trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") + + data = await request.get_json() + try: + creation_request = TrialCreateRequest(**data) + except ValidationError as e: + return jsonify( + { + "error": f"Invalid request format : {e}", + } + ), 400 + + trial_id = str(uuid.uuid4()) + + request_dict = creation_request.model_dump() + if request_dict["config"] is not None: + config_path = os.path.join( + WORK_DIR, project_id, "config", f"{str(uuid.uuid4())}.yaml" + ) + with open(config_path, "w") as f: + yaml.safe_dump(request_dict["config"], f) + else: + config_path = None + request_dict["trial_id"] = trial_id + request_dict["project_id"] = project_id + request_dict["config_path"] = config_path + request_dict["metadata"] = {} + request_dict.pop("config") + name = request_dict.pop("name") + + new_trial_config = TrialConfig(**request_dict) + new_trial = Trial( + id=trial_id, + project_id=project_id, + config=new_trial_config, + name=name, + status=Status.NOT_STARTED, + created_at=datetime.now(), + ) + trial_config_db = PandasTrialDB(trial_config_path) + trial_config_db.set_trial(new_trial) + return jsonify(new_trial.model_dump()), 202 + + +@app.route("/projects//upload", methods=["POST"]) +@project_exists(WORK_DIR) +async def upload_files(project_id: str): + # Setting upload + raw_data_path = os.path.join(WORK_DIR, project_id, "raw_data") + files = UploadSet() + files.default_dest = raw_data_path + configure_uploads(app, files) + try: + filename = await files.save((await request.files)["file"]) + + if not filename: + return jsonify({"error": "No files were uploaded"}), 400 + + return jsonify( + { + "message": "Files uploaded successfully", + "filePaths": os.path.join(raw_data_path, filename), + } + ), 200 + + except Exception as e: + return jsonify( + {"error": f"An error occurred while uploading files: {str(e)}"} + ), 500 + + +@app.route( + "/projects//trials//parse", methods=["POST"] +) +@project_exists(WORK_DIR) +@trial_exists(WORK_DIR) +async def start_parsing(project_id: str, trial_id: str): + try: + # Get JSON data from request and validate with Pydantic + data = await request.get_json() + parse_request = ParseRequest(**data) + + # Get the directory containing datasets + dataset_dir = os.path.join(WORK_DIR, project_id, "parse", parse_request.name) + if not os.path.exists(dataset_dir): + os.makedirs(dataset_dir) + else: + return jsonify( + {"error": f"Parse dataset name already exists: {parse_request.name}"} + ), 400 + + with tempfile.NamedTemporaryFile(suffix=".yaml", delete=False) as yaml_tempfile: + with open(yaml_tempfile.name, "w") as w: + yaml.safe_dump(parse_request.config, w) + yaml_path = yaml_tempfile.name + + task_id = str(uuid.uuid4()) + response = Task( + id=task_id, + project_id=project_id, + trial_id=trial_id, + name=parse_request.name, + config_yaml=parse_request.config, + status=Status.IN_PROGRESS, + type=TaskType.PARSE, + created_at=datetime.now(), + save_path=dataset_dir, + ) + await create_task( + task_id, + response, + run_parser_start_parsing, + os.path.join(WORK_DIR, project_id, "raw_data", "*.pdf"), + dataset_dir, + yaml_path, + ) + + # Update to trial + trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") + trial_config_db = PandasTrialDB(trial_config_path) + previous_config = trial_config_db.get_trial_config(trial_id) + new_config = previous_config.model_copy(deep=True) + new_config.raw_path = os.path.join( + dataset_dir, "0.parquet" + ) # TODO: deal with multiple parse config later + trial_config_db.set_trial_config(trial_id, new_config) + + return jsonify(response.model_dump()), 202 + + except ValueError as ve: + # Handle Pydantic validation errors + return jsonify({"error": f"Validation error: {str(ve)}"}), 400 + + except Exception as e: + return jsonify({"error": f"An error occurred: {str(e)}"}), 500 + + +@app.route( + "/projects//trials//chunk", methods=["POST"] +) +@project_exists(WORK_DIR) +@trial_exists(WORK_DIR) +async def start_chunking(project_id: str, trial_id: str): + try: + # Get JSON data from request and validate with Pydantic + data = await request.get_json() + chunk_request = ChunkRequest(**data) + + # Get the directory containing datasets + dataset_dir = os.path.join(WORK_DIR, project_id, "chunk", chunk_request.name) + if not os.path.exists(dataset_dir): + os.makedirs(dataset_dir) + else: + return jsonify( + {"error": f"Parse dataset name already exists: {chunk_request.name}"} + ), 400 + + trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") + trial_config_db = PandasTrialDB(trial_config_path) + previous_config = trial_config_db.get_trial_config(trial_id) + + raw_filepath = previous_config.raw_path + if raw_filepath is None or not raw_filepath or not os.path.exists(raw_filepath): + return jsonify({"error": "Raw data path not found"}), 400 + + with tempfile.NamedTemporaryFile(suffix=".yaml", delete=False) as yaml_tempfile: + with open(yaml_tempfile.name, "w") as w: + yaml.safe_dump(chunk_request.config, w) + yaml_path = yaml_tempfile.name + + task_id = str(uuid.uuid4()) + response = Task( + id=task_id, + project_id=project_id, + trial_id=trial_id, + name=chunk_request.name, + config_yaml=chunk_request.config, + status=Status.IN_PROGRESS, + type=TaskType.CHUNK, + created_at=datetime.now(), + save_path=dataset_dir, + ) + await create_task( + task_id, + response, + run_chunker_start_chunking, + raw_filepath, + dataset_dir, + yaml_path, + ) + + # Update to trial + new_config: TrialConfig = previous_config.model_copy(deep=True) + new_config.corpus_path = os.path.join( + dataset_dir, "0.parquet" + ) # TODO: deal with multiple chunk config later + trial_config_db.set_trial_config(trial_id, new_config) + + return jsonify(response.model_dump()), 202 + + except ValueError as ve: + # Handle Pydantic validation errors + return jsonify({"error": f"Validation error: {str(ve)}"}), 400 + + except Exception as e: + return jsonify({"error": f"An error occurred: {str(e)}"}), 500 + + +@app.route( + "/projects//trials//qa", methods=["POST"] +) +@project_exists(WORK_DIR) +@trial_exists(WORK_DIR) +async def create_qa(project_id: str, trial_id: str): + data = await request.get_json() + try: + qa_creation_request = QACreationRequest(**data) + dataset_dir = os.path.join(WORK_DIR, project_id, "qa") + + if not os.path.exists(dataset_dir): + os.makedirs(dataset_dir) + + save_path = os.path.join(dataset_dir, f"{qa_creation_request.name}.parquet") + + if os.path.exists(save_path): + return jsonify( + {"error": f"QA dataset name already exists: {qa_creation_request.name}"} + ), 400 + + trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") + trial_config_db = PandasTrialDB(trial_config_path) + previous_config = trial_config_db.get_trial_config(trial_id) + + corpus_filepath = previous_config.corpus_path + if ( + corpus_filepath is None + or not corpus_filepath + or not os.path.exists(corpus_filepath) + ): + return jsonify({"error": "Corpus data path not found"}), 400 + + task_id = str(uuid.uuid4()) + response = Task( + id=task_id, + project_id=project_id, + trial_id=trial_id, + name=qa_creation_request.name, + config_yaml={"preset": qa_creation_request.preset}, + status=Status.IN_PROGRESS, + type=TaskType.QA, + created_at=datetime.now(), + save_path=save_path, + ) + await create_task( + task_id, + response, + run_qa_creation, + qa_creation_request, + corpus_filepath, + dataset_dir, + ) + + # Update qa path + new_config: TrialConfig = previous_config.model_copy(deep=True) + new_config.qa_path = save_path + trial_config_db.set_trial_config(trial_id, new_config) + + return jsonify(response.model_dump()), 202 + + except Exception as e: + return jsonify( + {"status": "error", "message": f"Failed at creation of QA: {str(e)}"} + ), 400 + + +@app.route( + "/projects//trials//config", methods=["GET"] +) +@project_exists(WORK_DIR) +@trial_exists(WORK_DIR) +async def get_trial_config(project_id: str, trial_id: str): + trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") + trial_config_db = PandasTrialDB(trial_config_path) + trial_config = trial_config_db.get_trial_config(trial_id) + return jsonify(trial_config.model_dump()), 200 + + +@app.route( + "/projects//trials//config", methods=["POST"] +) +@project_exists(WORK_DIR) +@trial_exists(WORK_DIR) +async def set_trial_config(project_id: str, trial_id: str): + trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") + trial_config_db = PandasTrialDB(trial_config_path) + previous_config = trial_config_db.get_trial_config(trial_id) + new_config = previous_config.model_copy(deep=True) + data = await request.get_json() + if data.get("raw_path", None) is not None: + new_config.raw_path = data["raw_path"] + if data.get("corpus_path", None) is not None: + new_config.corpus_path = data["corpus_path"] + if data.get("qa_path", None) is not None: + new_config.qa_path = data["qa_path"] + if data.get("config", None) is not None: + new_config_path = os.path.join( + WORK_DIR, project_id, "config", f"{str(uuid.uuid4())}.yaml" + ) + with open(new_config_path, "w") as f: + yaml.safe_dump(data["config"], f) + new_config.config_path = new_config_path + if data.get("metadata", None) is not None: + new_config.metadata = data["metadata"] + + trial_config_db.set_trial_config(trial_id, new_config) + return jsonify(new_config.model_dump()), 201 + + +@app.route( + "/projects//trials//validate", methods=["POST"] +) +@project_exists(WORK_DIR) +async def start_validate(project_id: str, trial_id: str): + trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") + trial_config_db = PandasTrialDB(trial_config_path) + trial_config = trial_config_db.get_trial_config(trial_id) + + task_id = str(uuid.uuid4()) + with open(trial_config.config_path, "r") as f: + config_yaml = yaml.safe_load(f) + response = Task( + id=task_id, + project_id=project_id, + trial_id=trial_id, + name=f"{trial_id}/validation", + config_yaml=config_yaml, + status=Status.IN_PROGRESS, + type=TaskType.VALIDATE, + created_at=datetime.now(), + ) + await create_task( + task_id, + response, + run_validate, + trial_config.qa_path, + trial_config.corpus_path, + trial_config.config_path, + ) + + return jsonify(response.model_dump()), 202 + + +@app.route( + "/projects//trials//evaluate", methods=["POST"] +) +@project_exists(WORK_DIR) +async def start_evaluate(project_id: str, trial_id: str): + evaluate_history_path = os.path.join(WORK_DIR, project_id, "evaluate_history.csv") + if not os.path.exists(evaluate_history_path): + evaluate_history_df = pd.DataFrame( + columns=["trial_id", "save_dir", "corpus_path", "qa_path", "config_path"] + ) # save_dir is to autorag trial directory + evaluate_history_df.to_csv(evaluate_history_path, index=False) + else: + evaluate_history_df = pd.read_csv(evaluate_history_path) + + trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") + trial_config_db = PandasTrialDB(trial_config_path) + trial = trial_config_db.get_trial(trial_id) + evaluate_dir = os.path.join(WORK_DIR, project_id, "project") + + # Update the trial progress to IN_PROGRESS + updated_trial = trial.model_copy(deep=True) + updated_trial.status = Status.IN_PROGRESS + trial_config_db.set_trial(updated_trial) + + data = await request.get_json() + skip_validation = data.get("skip_validation", False) + full_ingest = data.get("full_ingest", True) + + new_trial_dir = get_new_trial_dir(evaluate_history_df, trial.config, evaluate_dir) + if os.path.exists(new_trial_dir): + return jsonify( + { + "trial_dir": new_trial_dir, + "error": "Exact same evaluation already run. " + "Skipping but return the directory where the evaluation result is saved.", + } + ), 409 + + new_row = pd.DataFrame( + [ + { + "trial_id": trial_id, + "save_dir": new_trial_dir, + "corpus_path": trial.config.corpus_path, + "qa_path": trial.config.qa_path, + "config_path": trial.config.config_path, + } + ] + ) + evaluate_history_df = pd.concat([evaluate_history_df, new_row], ignore_index=True) + evaluate_history_df.reset_index(drop=True, inplace=True) + evaluate_history_df.to_csv(evaluate_history_path, index=False) + + task_id = str(uuid.uuid4()) + with open(trial.config.config_path, "r") as f: + config_yaml = yaml.safe_load(f) + task = Task( + id=task_id, + project_id=project_id, + trial_id=trial_id, + name=f"{trial_id}/evaluation", + config_yaml=config_yaml, + status=Status.IN_PROGRESS, + type=TaskType.EVALUATE, + created_at=datetime.now(), + save_path=new_trial_dir, + ) + await create_task( + task_id, + task, + run_start_trial, + trial.config.qa_path, + trial.config.corpus_path, + os.path.dirname(new_trial_dir), + trial.config.config_path, + skip_validation, + full_ingest, + trial_id, + trial_config_path, + ) + + return jsonify(task.model_dump()), 202 + + +@app.route( + "/projects//trials//report/open", + methods=["GET"], +) +async def open_dashboard(project_id: str, trial_id: str): + """ + Get a preparation task or run status for chat open. + + Args: + project_id (str): The project ID + trial_id (str): The trial ID + + Returns: + JSON response with task status or error message + """ + try: + # Get the trial and search for the corresponding save_path + evaluate_history_path = os.path.join( + WORK_DIR, project_id, "evaluate_history.csv" + ) + if not os.path.exists(evaluate_history_path): + return jsonify({"error": "You need to run evaluation first"}), 400 + + evaluate_history_df = pd.read_csv(evaluate_history_path) + trial_raw = evaluate_history_df[evaluate_history_df["trial_id"] == trial_id] + if trial_raw.empty or len(trial_raw) < 1: + return jsonify({"error": "Trial ID not found"}), 404 + if len(trial_raw) >= 2: + return jsonify({"error": "Duplicated trial ID found"}), 400 + + trial_dir = trial_raw.iloc[0]["save_dir"] + if not os.path.exists(trial_dir): + return jsonify({"error": "Trial directory not found"}), 404 + if not os.path.isdir(trial_dir): + return jsonify({"error": "Trial directory is not a directory"}), 500 + + task_id = str(uuid.uuid4()) + response = Task( + id=task_id, + project_id=project_id, + trial_id=trial_id, + status=Status.IN_PROGRESS, + type=TaskType.REPORT, + created_at=datetime.now(), + ) + await create_task(task_id, response, run_dashboard, trial_dir) + + trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") + trial_config_db = PandasTrialDB(trial_config_path) + trial = trial_config_db.get_trial(trial_id) + new_trial = trial.model_copy(deep=True) + new_trial.report_task_id = task_id + trial_config_db.set_trial(new_trial) + + return jsonify(response.model_dump()), 202 + + except Exception as e: + return jsonify({"error": f"Internal server error: {str(e)}"}), 500 + + +@app.route( + "/projects//trials//report/close", + methods=["GET"], +) +async def close_dashboard(project_id: str, trial_id: str): + trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") + trial_config_db = PandasTrialDB(trial_config_path) + trial = trial_config_db.get_trial(trial_id) + report_pid = tasks[trial.report_task_id]["report_pid"] + os.killpg(os.getpgid(report_pid), signal.SIGTERM) + + new_trial = trial.model_copy(deep=True) + + original_task = tasks[trial.report_task_id]["task"] + original_task.status = Status.TERMINATED + new_trial.report_task_id = None + trial_config_db.set_trial(new_trial) + + return jsonify(original_task.model_dump()), 200 + + +@app.route( + "/projects//trials//chat/open", methods=["GET"] +) +async def open_chat_server(project_id: str, trial_id: str): + try: + # Get the trial and search for the corresponding save_path + evaluate_history_path = os.path.join( + WORK_DIR, project_id, "evaluate_history.csv" + ) + if not os.path.exists(evaluate_history_path): + return jsonify({"error": "You need to run evaluation first"}), 400 + + evaluate_history_df = pd.read_csv(evaluate_history_path) + trial_raw = evaluate_history_df[evaluate_history_df["trial_id"] == trial_id] + if trial_raw.empty or len(trial_raw) < 1: + return jsonify({"error": "Trial ID not found"}), 404 + if len(trial_raw) >= 2: + return jsonify({"error": "Duplicated trial ID found"}), 400 + + trial_dir = trial_raw.iloc[0]["save_dir"] + if not os.path.exists(trial_dir): + return jsonify({"error": "Trial directory not found"}), 404 + if not os.path.isdir(trial_dir): + return jsonify({"error": "Trial directory is not a directory"}), 500 + + task_id = str(uuid.uuid4()) + response = Task( + id=task_id, + project_id=project_id, + trial_id=trial_id, + status=Status.IN_PROGRESS, + type=TaskType.CHAT, + created_at=datetime.now(), + ) + await create_task(task_id, response, run_chat, trial_dir) + + trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") + trial_config_db = PandasTrialDB(trial_config_path) + trial = trial_config_db.get_trial(trial_id) + new_trial = trial.model_copy(deep=True) + new_trial.chat_task_id = task_id + trial_config_db.set_trial(new_trial) + + return jsonify(response.model_dump()), 202 + + except Exception as e: + return jsonify({"error": f"Internal server error: {str(e)}"}), 500 + + +@app.route( + "/projects//trials//chat/close", methods=["GET"] +) +async def close_chat_server(project_id: str, trial_id: str): + trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") + trial_config_db = PandasTrialDB(trial_config_path) + trial = trial_config_db.get_trial(trial_id) + chat_pid = tasks[trial.chat_task_id]["chat_pid"] + os.killpg(os.getpgid(chat_pid), signal.SIGTERM) + + new_trial = trial.model_copy(deep=True) + + original_task = tasks[trial.chat_task_id]["task"] + original_task.status = Status.TERMINATED + new_trial.chat_task_id = None + trial_config_db.set_trial(new_trial) + + return jsonify(original_task.model_dump()), 200 + + +@app.route("/projects//tasks/", methods=["GET"]) +@project_exists(WORK_DIR) +async def get_task(project_id: str, task_id: str): + if not os.path.exists(os.path.join(WORK_DIR, project_id)): + return jsonify({"error": f"Project name does not exist: {project_id}"}), 404 + task: Optional[Dict] = tasks.get(task_id, None) + if task is None: + return jsonify({"error": f"Task ID does not exist: {task_id}"}), 404 + response = task["task"] + return jsonify(response.model_dump()), 200 + + +@app.route("/env", methods=["POST"]) +async def set_environment_variable(): + # Get JSON data from request + data = await request.get_json() + + try: + # Validate request data using Pydantic model + env_var = EnvVariableRequest(**data) + + if os.getenv(env_var.key, None) is None: + # Set the environment variable + os.environ[env_var.key] = env_var.value + return jsonify({}), 200 + else: + os.environ[env_var.key] = env_var.value + return jsonify({}), 201 + + except Exception as e: + return jsonify( + { + "status": "error", + "message": f"Failed to set environment variable: {str(e)}", + } + ), 400 + + +@app.route("/env/", methods=["GET"]) +async def get_environment_variable(key: str): + """ + Get environment variable by key. + + Args: + key (str): The environment variable key to lookup + + Returns: + Tuple containing response dictionary and status code + """ + try: + value = os.environ.get(key) + + if value is None: + return {"error": f"Environment variable '{key}' not found"}, 404 + + return {"key": key, "value": value}, 200 + + except Exception as e: + return {"error": f"Internal server error: {str(e)}"}, 500 + + +if __name__ == "__main__": + from dotenv import load_dotenv + + load_dotenv() + + app.run() diff --git a/api/docs/README.md b/api/docs/README.md new file mode 100644 index 000000000..ccb8bfe51 --- /dev/null +++ b/api/docs/README.md @@ -0,0 +1,99 @@ +# AutoRAG Workflow API Documentation + +This API provides a complete workflow for AutoRAG operations, from project creation to evaluation. The API follows RESTful principles and uses JSON for request/response payloads. + +## Authentication + +The API uses Bearer token authentication. Include the token in the Authorization header: + +## Core Components + +### Project +- Represents a RAG workflow project +- Contains metadata, status, and configuration +- Unique identifier: `proj_*` + +### Task +- Represents individual workflow operations +- Types: parse, chunk, qa, validate, evaluate +- Contains status, configuration, and results +- Tracks execution state and errors + +## API Endpoints + +### Project Management +- `POST /projects` + - Create a new project + - Required: `name` + - Returns: Project object + +### Workflow Operations + +#### 1. Parsing +- `POST /projects/{project_id}/parse/start` + - Start document parsing + - Required: + - `glob_path`: File pattern to match + - `config`: Parsing configuration + - `name`: Operation name + +#### 2. Chunking +- `POST /projects/{project_id}/chunk/start` + - Process parsed documents into chunks + - Required: + - `raw_filepath`: Path to parsed data + - `config`: Chunking configuration + - `name`: Operation name + +#### 3. QA Generation +- `POST /projects/{project_id}/qa/start` + - Generate QA pairs + - Required: + - `corpus_filepath`: Path to chunked data + - Optional: + - `qa_num`: Number of QA pairs + - `preset`: [basic, simple, advanced] + - `llm_config`: LLM configuration + +#### 4. Validation +- `POST /projects/{project_id}/validate/start` + - Validate generated QA pairs + - Required: + - `config_yaml`: Validation configuration + +#### 5. Evaluation +- `POST /projects/{project_id}/evaluate/start` + - Evaluate RAG performance + - Required: + - `config_yaml`: Evaluation configuration + - Optional: + - `skip_validation`: Skip validation step (default: true) + +### Task Monitoring +- `GET /projects/{project_id}/task/{task_id}` + - Monitor task status + - Returns: Task object with current status + +## Task States +- `not_started`: Task is created but not running +- `in_progress`: Task is currently executing +- `completed`: Task finished successfully +- `failed`: Task failed with error + +## Log Levels +- `info`: General information +- `warning`: Warning messages +- `error`: Error messages + +## Typical Workflow Sequence +1. Create a project +2. Start parsing documents +3. Process chunks +4. Generate QA pairs +5. Validate results +6. Run evaluation +7. Monitor tasks through the task endpoint + +## Response Formats + +All successful responses return either a Project or Task object. Error responses include appropriate HTTP status codes and error messages. diff --git a/api/docs/openapi.js b/api/docs/openapi.js new file mode 100644 index 000000000..0abd153bc --- /dev/null +++ b/api/docs/openapi.js @@ -0,0 +1,1179 @@ +var spec = { + "openapi": "3.0.0", + "info": { + "title": "AutoRAG API", + "description": "API for AutoRAG with Preparation and Run workflow", + "version": "1.0.1" + }, + "components": { + "schemas": { + "Project": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "proj_123" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "status": { + "type": "string", + "enum": [ + "active", + "archived" + ] + } + } + }, + "TrialConfig": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "project_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "config_yaml": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "is_default": { + "type": "boolean" + }, + "metadata": { + "type": "object" + } + } + }, + "Task": { + "type": "object", + "required": [ + "id", + "project_id", + "status", + "type" + ], + "properties": { + "id": { + "type": "string", + "description": "The task id" + }, + "project_id": { + "type": "string" + }, + "trial_id": { + "type": "string" + }, + "name": { + "type": "string", + "description": "The name of the task" + }, + "config_yaml": { + "type": "object", + "description": "YAML configuration. Format is dictionary, not path of the YAML file." + }, + "status": { + "type": "string", + "enum": [ + "not_started", + "in_progress", + "completed", + "failed" + ] + }, + "error_message": { + "type": "string", + "description": "Error message if the task failed" + }, + "type": { + "type": "string", + "enum": [ + "parse", + "chunk", + "qa", + "validate", + "evaluate" + ], + "description": "Type of the task - preparation tasks (parse, chunk, qa) or run tasks (validate, evaluate)" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "save_path": { + "type": "string", + "description": "Path where the task results are saved. It will be directory or file." + } + } + }, + "Trial": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "project_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "preparation_id": { + "type": "string", + "description": "Reference to completed preparation data" + }, + "config_yaml": { + "type": "string", + "description": "YAML configuration for trial" + }, + "status": { + "type": "string", + "enum": [ + "not_started", + "in_progress", + "completed", + "failed" + ] + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + }, + "Run": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "trial_id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "validation", + "eval" + ] + }, + "status": { + "type": "string", + "enum": [ + "not_started", + "in_progress", + "completed", + "failed" + ] + }, + "result": { + "type": "object" + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + } + } + }, + "paths": { + "/projects": { + "post": { + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Create a new project", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Project created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project" + } + } + } + }, + "400": { + "description": "Project name already exists" + }, + "401": { + "description": "Unauthorized - Invalid or missing token" + }, + "403": { + "description": "Forbidden - Token does not have sufficient permissions" + } + } + }, + "get": { + "summary": "List all projects", + "parameters": [ + { + "in": "query", + "name": "page", + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer", + "default": 10 + } + }, + { + "in": "query", + "name": "status", + "schema": { + "type": "string", + "enum": [ + "active", + "archived" + ] + } + } + ], + "responses": { + "200": { + "description": "List of projects", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "total": { + "type": "integer" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Project" + } + } + } + } + } + } + } + } + } + }, + "/projects/{project_id}/trials": { + "get": { + "summary": "Get list trials", + "parameters": [ + { + "name": "project_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer", + "default": 10 + } + }, + { + "in": "query", + "name": "status", + "schema": { + "type": "string", + "enum": [ + "active", + "archived" + ] + } + } + ], + "responses": { + "200": { + "description": "List of trials", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "total": { + "type": "integer" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Trial" + } + } + } + } + } + } + } + } + }, + "post": { + "summary": "Creat trial", + "parameters": [ + { + "name": "project_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "glob_path", + "name" + ], + "properties": { + "glob_path": { + "type": "string", + "description": "Path pattern to match files" + }, + "name": { + "type": "string", + "description": "Name for this preparation task" + }, + "config": { + "type": "object", + "properties": { + "parse": { + "type": "object" + } + } + } + } + } + } + } + }, + "responses": { + "202": { + "description": "Parsing started", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Task" + } + } + } + } + } + } + }, + "/projects/{project_id}/trials/{trial_id}/parse": { + "post": { + "summary": "Start parsing preparation", + "parameters": [ + { + "name": "project_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "trial_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "glob_path", + "name" + ], + "properties": { + "glob_path": { + "type": "string", + "description": "Path pattern to match files" + }, + "name": { + "type": "string", + "description": "Name for this preparation task" + }, + "config": { + "type": "object", + "properties": { + "parse": { + "type": "object" + } + } + } + } + } + } + } + }, + "responses": { + "202": { + "description": "Parsing started", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Task" + } + } + } + } + } + } + }, + "/projects/{project_id}/trials/{trial_id}/chunk": { + "post": { + "summary": "Start chunking preparation", + "parameters": [ + { + "name": "project_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "trial_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "trial_id", + "name", + "config" + ], + "properties": { + "trial_id": { + "type": "string", + "description": "Trial ID from parsing step" + }, + "name": { + "type": "string" + }, + "config": { + "type": "object", + "properties": { + "chunk": { + "type": "object" + } + } + } + } + } + } + } + }, + "responses": { + "202": { + "description": "Chunking started", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Task" + } + } + } + } + } + } + }, + "/projects/{project_id}/trials/{trial_id}/qa": { + "post": { + "summary": "Start QA generation preparation", + "parameters": [ + { + "name": "project_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "trial_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "trial_id", + "name", + "qa_num", + "preset", + "llm_config" + ], + "properties": { + "trial_id": { + "type": "string", + "description": "Trial ID from chunking step" + }, + "name": { + "type": "string" + }, + "qa_num": { + "type": "integer" + }, + "preset": { + "type": "string", + "enum": [ + "basic", + "simple", + "advanced" + ] + }, + "llm_config": { + "type": "object", + "properties": { + "llm_name": { + "type": "string" + }, + "llm_params": { + "type": "object" + } + } + } + } + } + } + } + }, + "responses": { + "202": { + "description": "QA generation started", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Task" + } + } + } + } + } + } + }, + "/projects/{project_id}/trials/{trial_id}/config": { + "get": { + "summary": "Get config of trial", + "parameters": [ + { + "name": "project_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "trial_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "202": { + "description": "Parsing started", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TrialConfig" + } + } + } + } + } + }, + "post": { + "summary": "Set config of trial", + "parameters": [ + { + "name": "project_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "trial_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "glob_path", + "name" + ], + "properties": { + "glob_path": { + "type": "string", + "description": "Path pattern to match files" + }, + "name": { + "type": "string", + "description": "Name for this preparation task" + }, + "config": { + "type": "object", + "properties": { + "parse": { + "type": "object" + } + } + } + } + } + } + } + }, + "responses": { + "202": { + "description": "Parsing started", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TrialConfig" + } + } + } + } + } + } + }, + "/projects/{project_id}/trials/{trial_id}/clone": { + "post": { + "summary": "Clone validation", + "parameters": [ + { + "name": "project_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "trial_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "config", + "qa_path", + "corpus_path" + ], + "properties": { + "config": { + "type": "object", + "description": "YAML configuration. Format is dictionary, not path of the YAML file" + }, + "qa_path": { + "type": "string", + "description": "Path to the QA data" + }, + "corpus_path": { + "type": "string", + "description": "Path to the corpus data" + } + } + } + } + } + }, + "responses": { + "202": { + "description": "Validation started", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Task" + } + } + } + } + } + } + }, + "/projects/{project_id}/trials/{trial_id}/validate": { + "post": { + "summary": "Start validation", + "parameters": [ + { + "name": "project_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "trial_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "config", + "qa_path", + "corpus_path" + ], + "properties": { + "config": { + "type": "object", + "description": "YAML configuration. Format is dictionary, not path of the YAML file" + }, + "qa_path": { + "type": "string", + "description": "Path to the QA data" + }, + "corpus_path": { + "type": "string", + "description": "Path to the corpus data" + } + } + } + } + } + }, + "responses": { + "202": { + "description": "Validation started", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Task" + } + } + } + } + } + } + }, + "/projects/{project_id}/trials/{trial_id}/evaluate": { + "post": { + "summary": "Start evaluation", + "parameters": [ + { + "name": "project_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "trial_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "config_yaml", + "qa_path", + "corpus_path" + ], + "properties": { + "config_yaml": { + "type": "object", + "description": "YAML configuration. Format is dictionary, not path of the YAML file" + }, + "skip_validation": { + "type": "boolean", + "description": "Skip validation step", + "default": true + }, + "qa_path": { + "type": "string", + "description": "Path to the QA data" + }, + "corpus_path": { + "type": "string", + "description": "Path to the corpus data" + } + } + } + } + } + }, + "responses": { + "202": { + "description": "Evaluation started", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Task" + }, + { + "type": "object", + "properties": { + "trial_id": { + "type": "string" + } + } + } + ] + } + } + } + } + } + } + }, + "/projects/{project_id}/trials/{trial_id}/report/open": { + "get": { + "summary": "Get preparation task or run status", + "parameters": [ + { + "name": "project_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "trial_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "202": { + "description": "Evaluation started", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Task" + }, + { + "type": "object", + "properties": { + "trial_id": { + "type": "string" + } + } + } + ] + } + } + } + } + } + } + }, + "/projects/{project_id}/trials/{trial_id}/report/close": { + "get": { + "summary": "Get preparation task or run status", + "parameters": [ + { + "name": "project_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "trial_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "202": { + "description": "Evaluation started", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Task" + }, + { + "type": "object", + "properties": { + "trial_id": { + "type": "string" + } + } + } + ] + } + } + } + } + } + } + }, + "/projects/{project_id}/trials/{trial_id}/chat/open": { + "get": { + "summary": "Get preparation task or run status", + "parameters": [ + { + "name": "project_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "trial_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "202": { + "description": "Evaluation started", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Task" + }, + { + "type": "object", + "properties": { + "trial_id": { + "type": "string" + } + } + } + ] + } + } + } + } + } + } + }, + "/projects/{project_id}/trials/{trial_id}/chat/close": { + "get": { + "summary": "Get preparation task or run status", + "parameters": [ + { + "name": "project_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "trial_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "202": { + "description": "Evaluation started", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Task" + }, + { + "type": "object", + "properties": { + "trial_id": { + "type": "string" + } + } + } + ] + } + } + } + } + } + } + }, + "/projects/{project_id}/tasks/{task_id}": { + "get": { + "summary": "Get preparation task or run status", + "parameters": [ + { + "name": "project_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "task_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Task status", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/Task" + } + ] + } + } + } + } + } + } + } + } +} diff --git a/api/docs/openapi.yaml b/api/docs/openapi.yaml new file mode 100644 index 000000000..fe8d469cb --- /dev/null +++ b/api/docs/openapi.yaml @@ -0,0 +1,748 @@ +openapi: 3.0.0 +info: + title: AutoRAG API + description: API for AutoRAG with Preparation and Run workflow + version: 1.0.1 + +components: + schemas: + Project: + type: object + properties: + id: + type: string + example: "proj_123" + name: + type: string + description: + type: string + created_at: + type: string + format: date-time + status: + type: string + enum: [active, archived] + + TrialConfig: + type: object + properties: + id: + type: string + project_id: + type: string + raw_path: + type: string + corpus_path: + type: string + qa_path: + type: string + config_path: + type: string + metadata: + type: object + Task: + type: object + required: + - id + - project_id + - status + - type + properties: + id: + type: string + description: The task id + project_id: + type: string + trial_id: + type: string + name: + type: string + description: The name of the task + config_yaml: + type: object + description: YAML configuration. Format is dictionary, not path of the YAML file. + status: + type: string + enum: [not_started, in_progress, completed, failed, terminated] + error_message: + type: string + description: Error message if the task failed + type: + type: string + enum: [parse, chunk, qa, validate, evaluate, dashboard, chat] + description: Type of the task - preparation tasks (parse, chunk, qa) or run tasks (validate, evaluate) + created_at: + type: string + format: date-time + save_path: + type: string + description: Path where the task results are saved. It will be directory or file. + Trial: + type: object + properties: + id: + type: string + project_id: + type: string + config: + $ref: '#/components/schemas/TrialConfig' + name: + type: string + status: + type: string + enum: [not_started, in_progress, completed, failed] + created_at: + type: string + format: date-time + + Run: + type: object + properties: + id: + type: string + trial_id: + type: string + type: + type: string + enum: [validation, eval] + status: + type: string + enum: [not_started, in_progress, completed, failed] + result: + type: object + created_at: + type: string + format: date-time + +paths: + + /projects: + post: + security: + - bearerAuth: [ ] + summary: Create a new project + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - name + properties: + name: + type: string + description: + type: string + responses: + '201': + description: Project created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Project' + '400': + description: Project name already exists + '401': + description: Unauthorized - Invalid or missing token + '403': + description: Forbidden - Token does not have sufficient permissions + get: + summary: List all projects + parameters: + - in: query + name: page + schema: + type: integer + default: 1 + - in: query + name: limit + schema: + type: integer + default: 10 + - in: query + name: status + schema: + type: string + enum: [active, archived] + responses: + '200': + description: List of projects + content: + application/json: + schema: + type: object + properties: + total: + type: integer + data: + type: array + items: + $ref: '#/components/schemas/Project' + + /projects/{project_id}/trials: + get: + summary: Get list trials + parameters: + - name: project_id + in: path + required: true + schema: + type: string + - in: query + name: page + schema: + type: integer + default: 1 + - in: query + name: limit + schema: + type: integer + default: 10 + - in: query + name: status + schema: + type: string + enum: [active, archived] + responses: + '200': + description: List of trials + content: + application/json: + schema: + type: object + properties: + total: + type: integer + data: + type: array + items: + $ref: '#/components/schemas/Trial' + post: + summary: Create trial + parameters: + - name: project_id + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + description: Name for this preparation task + raw_path: + type: string + description: Parsed data to use in the trial + corpus_path: + type: string + description: Corpus data to use in the trial + qa_path: + type: string + description: QA data to use in the trial + config: + type: object + properties: + parse: + type: object + responses: + '202': + description: New Trial made + content: + application/json: + schema: + $ref: '#/components/schemas/Trial' + '409': + description: Duplicate trial name + '400': + description: Invalid request format + + /projects/{project_id}/upload: + post: + summary: Upload files to a project + operationId: uploadFiles + parameters: + - name: project_id + in: path + required: true + description: The ID of the project to which files are being uploaded. + schema: + type: string + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + files: + type: array + items: + type: string + format: binary + description: The files to upload. + responses: + '200': + description: Files uploaded successfully. + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: Files uploaded successfully. + filePaths: + type: array + items: + type: string + example: /path/to/uploaded/file.txt + '400': + description: Bad Request - No files were uploaded. + content: + application/json: + schema: + type: object + properties: + error: + type: string + example: No files were uploaded. + '500': + description: Internal Server Error - An error occurred while uploading files. + content: + application/json: + schema: + type: object + properties: + error: + type: string + + /projects/{project_id}/trials/{trial_id}/parse: + post: + summary: Start parsing preparation + parameters: + - name: project_id + in: path + required: true + schema: + type: string + - name: trial_id + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - name + - config + properties: + name: + type: string + description: Name for this preparation task + config: + type: object + description: The parse configuration. Must be dict. "parse.yaml" file. + properties: + parse: + type: object + responses: + '202': + description: Parsing started + content: + application/json: + schema: + $ref: '#/components/schemas/Task' + + /projects/{project_id}/trials/{trial_id}/chunk: + post: + summary: Start chunking preparation + parameters: + - name: project_id + in: path + required: true + schema: + type: string + - name: trial_id + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - name + - config + properties: + name: + type: string + config: + type: object + properties: + chunk: + type: object + responses: + '202': + description: Chunking started + content: + application/json: + schema: + $ref: '#/components/schemas/Task' + + /projects/{project_id}/trials/{trial_id}/qa: + post: + summary: Start QA generation preparation + parameters: + - name: project_id + in: path + required: true + schema: + type: string + - name: trial_id + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - name + - qa_num + - preset + - llm_config + properties: + name: + type: string + qa_num: + type: integer + preset: + type: string + enum: [basic, simple, advanced] + lang: + type: string + enum: [en, ko, ja] + default: en + llm_config: + type: object + properties: + llm_name: + type: string + llm_params: + type: object + responses: + '202': + description: QA generation started + content: + application/json: + schema: + $ref: '#/components/schemas/Task' + + /projects/{project_id}/trials/{trial_id}/config: + get: + summary: Get config of trial + parameters: + - name: project_id + in: path + required: true + schema: + type: string + - name: trial_id + in: path + required: true + schema: + type: string + + responses: + '200': + description: Trial config returned + content: + application/json: + schema: + $ref: '#/components/schemas/TrialConfig' + post: + summary: Set config of trial + parameters: + - name: project_id + in: path + required: true + schema: + type: string + - name: trial_id + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + raw_path: + type: string + corpus_path: + type: string + qa_path: + type: string + config: + type: object + metadata: + type: object + responses: + '201': + description: Trial modified + content: + application/json: + schema: + $ref: '#/components/schemas/TrialConfig' + + + /projects/{project_id}/trials/{trial_id}/clone: + post: + summary: Clone validation + parameters: + - name: project_id + in: path + required: true + schema: + type: string + - name: trial_id + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - config + - qa_path + - corpus_path + properties: + config: + type: object + description: YAML configuration. Format is dictionary, not path of the YAML file + qa_path: + type: string + description: Path to the QA data + corpus_path: + type: string + description: Path to the corpus data + + + + + + responses: + '202': + description: Validation started + content: + application/json: + schema: + $ref: '#/components/schemas/Task' + /projects/{project_id}/trials/{trial_id}/validate: + post: + summary: Start validation + parameters: + - name: project_id + in: path + required: true + schema: + type: string + - name: trial_id + in: path + required: true + schema: + type: string + + responses: + '202': + description: Validation started + content: + application/json: + schema: + $ref: '#/components/schemas/Task' + + /projects/{project_id}/trials/{trial_id}/evaluate: + post: + summary: Start evaluation + parameters: + - name: project_id + in: path + required: true + schema: + type: string + - name: trial_id + in: path + required: true + schema: + type: string + + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + skip_validation: + type: boolean + default: false + full_ingest: + type: boolean + default: true + responses: + '202': + description: Evaluation started + content: + application/json: + schema: + $ref: '#/components/schemas/Task' + '409': + description: Duplicate evaluation request. + content: + application/json: + schema: + type: object + properties: + trial_dir: + type: string + description: Directory where the duplicated evaluation results are saved. Access here to get the results. + error: + type: string + + /projects/{project_id}/trials/{trial_id}/report/open: + get: + summary: Get preparation task or run status + parameters: + - name: project_id + in: path + required: true + schema: + type: string + - name: trial_id + in: path + required: true + schema: + type: string + responses: + '202': + description: Report open + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/Task' + - type: object + properties: + trial_id: + type: string + + /projects/{project_id}/trials/{trial_id}/report/close: + get: + summary: Get preparation task or run status + parameters: + - name: project_id + in: path + required: true + schema: + type: string + - name: trial_id + in: path + required: true + schema: + type: string + responses: + '200': + description: Report closed + content: + application/json: + schema: + $ref: '#/components/schemas/Task' + + /projects/{project_id}/trials/{trial_id}/chat/open: + get: + summary: Get preparation task or run status + parameters: + - name: project_id + in: path + required: true + schema: + type: string + - name: trial_id + in: path + required: true + schema: + type: string + responses: + '202': + description: Chat open. The port is 8501. + content: + application/json: + schema: + $ref: '#/components/schemas/Task' + + /projects/{project_id}/trials/{trial_id}/chat/close: + get: + summary: Get preparation task or run status + parameters: + - name: project_id + in: path + required: true + schema: + type: string + - name: trial_id + in: path + required: true + schema: + type: string + responses: + '200': + description: Chat closed + content: + application/json: + schema: + $ref: '#/components/schemas/Task' + /projects/{project_id}/tasks/{task_id}: + get: + summary: Get preparation task or run status + parameters: + - name: project_id + in: path + required: true + schema: + type: string + - name: task_id + in: path + required: true + schema: + type: string + responses: + '200': + description: Task status + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/Task' diff --git a/api/docs/openapi3_0.json b/api/docs/openapi3_0.json new file mode 100644 index 000000000..0c76e85e4 --- /dev/null +++ b/api/docs/openapi3_0.json @@ -0,0 +1,1179 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "AutoRAG API", + "description": "API for AutoRAG with Preparation and Run workflow", + "version": "1.0.1" + }, + "components": { + "schemas": { + "Project": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "proj_123" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "status": { + "type": "string", + "enum": [ + "active", + "archived" + ] + } + } + }, + "TrialConfig": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "project_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "config_yaml": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "is_default": { + "type": "boolean" + }, + "metadata": { + "type": "object" + } + } + }, + "Task": { + "type": "object", + "required": [ + "id", + "project_id", + "status", + "type" + ], + "properties": { + "id": { + "type": "string", + "description": "The task id" + }, + "project_id": { + "type": "string" + }, + "trial_id": { + "type": "string" + }, + "name": { + "type": "string", + "description": "The name of the task" + }, + "config_yaml": { + "type": "object", + "description": "YAML configuration. Format is dictionary, not path of the YAML file." + }, + "status": { + "type": "string", + "enum": [ + "not_started", + "in_progress", + "completed", + "failed" + ] + }, + "error_message": { + "type": "string", + "description": "Error message if the task failed" + }, + "type": { + "type": "string", + "enum": [ + "parse", + "chunk", + "qa", + "validate", + "evaluate" + ], + "description": "Type of the task - preparation tasks (parse, chunk, qa) or run tasks (validate, evaluate)" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "save_path": { + "type": "string", + "description": "Path where the task results are saved. It will be directory or file." + } + } + }, + "Trial": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "project_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "preparation_id": { + "type": "string", + "description": "Reference to completed preparation data" + }, + "config_yaml": { + "type": "string", + "description": "YAML configuration for trial" + }, + "status": { + "type": "string", + "enum": [ + "not_started", + "in_progress", + "completed", + "failed" + ] + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + }, + "Run": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "trial_id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "validation", + "eval" + ] + }, + "status": { + "type": "string", + "enum": [ + "not_started", + "in_progress", + "completed", + "failed" + ] + }, + "result": { + "type": "object" + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + } + } + }, + "paths": { + "/projects": { + "post": { + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Create a new project", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Project created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project" + } + } + } + }, + "400": { + "description": "Project name already exists" + }, + "401": { + "description": "Unauthorized - Invalid or missing token" + }, + "403": { + "description": "Forbidden - Token does not have sufficient permissions" + } + } + }, + "get": { + "summary": "List all projects", + "parameters": [ + { + "in": "query", + "name": "page", + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer", + "default": 10 + } + }, + { + "in": "query", + "name": "status", + "schema": { + "type": "string", + "enum": [ + "active", + "archived" + ] + } + } + ], + "responses": { + "200": { + "description": "List of projects", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "total": { + "type": "integer" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Project" + } + } + } + } + } + } + } + } + } + }, + "/projects/{project_id}/trials": { + "get": { + "summary": "Get list trials", + "parameters": [ + { + "name": "project_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer", + "default": 10 + } + }, + { + "in": "query", + "name": "status", + "schema": { + "type": "string", + "enum": [ + "active", + "archived" + ] + } + } + ], + "responses": { + "200": { + "description": "List of trials", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "total": { + "type": "integer" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Trial" + } + } + } + } + } + } + } + } + }, + "post": { + "summary": "Creat trial", + "parameters": [ + { + "name": "project_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "glob_path", + "name" + ], + "properties": { + "glob_path": { + "type": "string", + "description": "Path pattern to match files" + }, + "name": { + "type": "string", + "description": "Name for this preparation task" + }, + "config": { + "type": "object", + "properties": { + "parse": { + "type": "object" + } + } + } + } + } + } + } + }, + "responses": { + "202": { + "description": "Parsing started", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Task" + } + } + } + } + } + } + }, + "/projects/{project_id}/trials/{trial_id}/parse": { + "post": { + "summary": "Start parsing preparation", + "parameters": [ + { + "name": "project_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "trial_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "glob_path", + "name" + ], + "properties": { + "glob_path": { + "type": "string", + "description": "Path pattern to match files" + }, + "name": { + "type": "string", + "description": "Name for this preparation task" + }, + "config": { + "type": "object", + "properties": { + "parse": { + "type": "object" + } + } + } + } + } + } + } + }, + "responses": { + "202": { + "description": "Parsing started", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Task" + } + } + } + } + } + } + }, + "/projects/{project_id}/trials/{trial_id}/chunk": { + "post": { + "summary": "Start chunking preparation", + "parameters": [ + { + "name": "project_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "trial_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "trial_id", + "name", + "config" + ], + "properties": { + "trial_id": { + "type": "string", + "description": "Trial ID from parsing step" + }, + "name": { + "type": "string" + }, + "config": { + "type": "object", + "properties": { + "chunk": { + "type": "object" + } + } + } + } + } + } + } + }, + "responses": { + "202": { + "description": "Chunking started", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Task" + } + } + } + } + } + } + }, + "/projects/{project_id}/trials/{trial_id}/qa": { + "post": { + "summary": "Start QA generation preparation", + "parameters": [ + { + "name": "project_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "trial_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "trial_id", + "name", + "qa_num", + "preset", + "llm_config" + ], + "properties": { + "trial_id": { + "type": "string", + "description": "Trial ID from chunking step" + }, + "name": { + "type": "string" + }, + "qa_num": { + "type": "integer" + }, + "preset": { + "type": "string", + "enum": [ + "basic", + "simple", + "advanced" + ] + }, + "llm_config": { + "type": "object", + "properties": { + "llm_name": { + "type": "string" + }, + "llm_params": { + "type": "object" + } + } + } + } + } + } + } + }, + "responses": { + "202": { + "description": "QA generation started", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Task" + } + } + } + } + } + } + }, + "/projects/{project_id}/trials/{trial_id}/config": { + "get": { + "summary": "Get config of trial", + "parameters": [ + { + "name": "project_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "trial_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "202": { + "description": "Parsing started", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TrialConfig" + } + } + } + } + } + }, + "post": { + "summary": "Set config of trial", + "parameters": [ + { + "name": "project_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "trial_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "glob_path", + "name" + ], + "properties": { + "glob_path": { + "type": "string", + "description": "Path pattern to match files" + }, + "name": { + "type": "string", + "description": "Name for this preparation task" + }, + "config": { + "type": "object", + "properties": { + "parse": { + "type": "object" + } + } + } + } + } + } + } + }, + "responses": { + "202": { + "description": "Parsing started", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TrialConfig" + } + } + } + } + } + } + }, + "/projects/{project_id}/trials/{trial_id}/clone": { + "post": { + "summary": "Clone validation", + "parameters": [ + { + "name": "project_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "trial_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "config", + "qa_path", + "corpus_path" + ], + "properties": { + "config": { + "type": "object", + "description": "YAML configuration. Format is dictionary, not path of the YAML file" + }, + "qa_path": { + "type": "string", + "description": "Path to the QA data" + }, + "corpus_path": { + "type": "string", + "description": "Path to the corpus data" + } + } + } + } + } + }, + "responses": { + "202": { + "description": "Validation started", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Task" + } + } + } + } + } + } + }, + "/projects/{project_id}/trials/{trial_id}/validate": { + "post": { + "summary": "Start validation", + "parameters": [ + { + "name": "project_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "trial_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "config", + "qa_path", + "corpus_path" + ], + "properties": { + "config": { + "type": "object", + "description": "YAML configuration. Format is dictionary, not path of the YAML file" + }, + "qa_path": { + "type": "string", + "description": "Path to the QA data" + }, + "corpus_path": { + "type": "string", + "description": "Path to the corpus data" + } + } + } + } + } + }, + "responses": { + "202": { + "description": "Validation started", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Task" + } + } + } + } + } + } + }, + "/projects/{project_id}/trials/{trial_id}/evaluate": { + "post": { + "summary": "Start evaluation", + "parameters": [ + { + "name": "project_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "trial_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "config_yaml", + "qa_path", + "corpus_path" + ], + "properties": { + "config_yaml": { + "type": "object", + "description": "YAML configuration. Format is dictionary, not path of the YAML file" + }, + "skip_validation": { + "type": "boolean", + "description": "Skip validation step", + "default": true + }, + "qa_path": { + "type": "string", + "description": "Path to the QA data" + }, + "corpus_path": { + "type": "string", + "description": "Path to the corpus data" + } + } + } + } + } + }, + "responses": { + "202": { + "description": "Evaluation started", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Task" + }, + { + "type": "object", + "properties": { + "trial_id": { + "type": "string" + } + } + } + ] + } + } + } + } + } + } + }, + "/projects/{project_id}/trials/{trial_id}/report/open": { + "get": { + "summary": "Get preparation task or run status", + "parameters": [ + { + "name": "project_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "trial_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "202": { + "description": "Evaluation started", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Task" + }, + { + "type": "object", + "properties": { + "trial_id": { + "type": "string" + } + } + } + ] + } + } + } + } + } + } + }, + "/projects/{project_id}/trials/{trial_id}/report/close": { + "get": { + "summary": "Get preparation task or run status", + "parameters": [ + { + "name": "project_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "trial_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "202": { + "description": "Evaluation started", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Task" + }, + { + "type": "object", + "properties": { + "trial_id": { + "type": "string" + } + } + } + ] + } + } + } + } + } + } + }, + "/projects/{project_id}/trials/{trial_id}/chat/open": { + "get": { + "summary": "Get preparation task or run status", + "parameters": [ + { + "name": "project_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "trial_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "202": { + "description": "Evaluation started", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Task" + }, + { + "type": "object", + "properties": { + "trial_id": { + "type": "string" + } + } + } + ] + } + } + } + } + } + } + }, + "/projects/{project_id}/trials/{trial_id}/chat/close": { + "get": { + "summary": "Get preparation task or run status", + "parameters": [ + { + "name": "project_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "trial_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "202": { + "description": "Evaluation started", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Task" + }, + { + "type": "object", + "properties": { + "trial_id": { + "type": "string" + } + } + } + ] + } + } + } + } + } + } + }, + "/projects/{project_id}/tasks/{task_id}": { + "get": { + "summary": "Get preparation task or run status", + "parameters": [ + { + "name": "project_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "task_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Task status", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/Task" + } + ] + } + } + } + } + } + } + } + } +} diff --git a/api/docs/swagger-api.html b/api/docs/swagger-api.html new file mode 100644 index 000000000..f5886e3b1 --- /dev/null +++ b/api/docs/swagger-api.html @@ -0,0 +1,37 @@ + + + + + + + + +
+ + + + + + diff --git a/api/requirements.txt b/api/requirements.txt new file mode 100644 index 000000000..78b3a81af --- /dev/null +++ b/api/requirements.txt @@ -0,0 +1,6 @@ +AutoRAG[parse,api] +quart-schema +jwt +quart-cors +Werkzeug +quart-uploads diff --git a/api/src/__init__.py b/api/src/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/api/src/auth.py b/api/src/auth.py new file mode 100644 index 000000000..bf4efbb3e --- /dev/null +++ b/api/src/auth.py @@ -0,0 +1,39 @@ +# Authentication decorator +from functools import wraps + +import jwt +from quart import request, jsonify + + +def require_auth(): + def decorator(f): + @wraps(f) + async def decorated_function(*args, **kwargs): + auth_header = request.headers.get("Authorization") + + if not auth_header: + return jsonify({"error": "No authorization header"}), 401 + + try: + token_type, token = auth_header.split() + if token_type.lower() != "bearer": + return jsonify({"error": "Invalid token type"}), 401 + + # Verify token (implement your token verification logic here) + # This is a simple example - adjust according to your needs + # TODO: Make JWT auth server at remote location (AutoRAG private) + + # Check permissions (implement your permission logic here) + if token != "good": + return jsonify({"error": "Insufficient permissions"}), 403 + + except jwt.InvalidTokenError: + return jsonify({"error": "Invalid token"}), 401 + except Exception as e: + return jsonify({"error": str(e)}), 401 + + return await f(*args, **kwargs) + + return decorated_function + + return decorator diff --git a/api/src/evaluate_history.py b/api/src/evaluate_history.py new file mode 100644 index 000000000..effab7984 --- /dev/null +++ b/api/src/evaluate_history.py @@ -0,0 +1,71 @@ +import os +import shutil +import uuid +import json +from datetime import datetime + +import pandas as pd + +from src.schema import TrialConfig + + +def get_new_trial_dir( + history_df: pd.DataFrame, trial_config: TrialConfig, project_dir: str +): + trial_rows = history_df[history_df["trial_id"] == trial_config.trial_id] + duplicate_corpus_rows = trial_rows[ + trial_rows["corpus_path"] == trial_config.corpus_path + ] + if len(duplicate_corpus_rows) == 0: # If corpus data changed + # Changed Corpus - ingest again (Make new directory - new save_dir) + new_dir_name = f"{trial_config.trial_id}-{str(uuid.uuid4())}" + os.makedirs(os.path.join(project_dir, new_dir_name)) + return os.path.join(project_dir, new_dir_name, "0") # New trial folder + duplicate_qa_rows = duplicate_corpus_rows[ + trial_rows["qa_path"] == trial_config.qa_path + ] + if len(duplicate_qa_rows) == 0: # If qa data changed + # swap qa data from the existing project directory + existing_project_dir = os.path.dirname(duplicate_qa_rows.iloc[0]["save_path"]) + shutil.copy( + trial_config.qa_path, + os.path.join(existing_project_dir, "data", "qa.parquet"), + ) + duplicate_config_rows = duplicate_qa_rows[ + trial_rows["config_path"] == trial_config.config_path + ] + if len(duplicate_config_rows) > 0: + duplicate_row_save_paths = duplicate_config_rows["save_dir"].unique().tolist() + return duplicate_row_save_paths[0] + # Get the next trial folder + existing_project_dir = os.path.dirname(duplicate_qa_rows.iloc[0]["save_path"]) + latest_trial_name = get_latest_trial( + os.path.join(existing_project_dir, "trial.json") + ) + new_trial_name = str(int(latest_trial_name) + 1) + return os.path.join(existing_project_dir, new_trial_name) + + +def get_latest_trial(file_path): + try: + # Load JSON file + with open(file_path, "r") as f: + trials = json.load(f) + + # Convert start_time to datetime objects and find the latest trial + latest_trial = max( + trials, + key=lambda x: datetime.strptime(x["start_time"], "%Y-%m-%d %H:%M:%S"), + ) + + return latest_trial["trial_name"] + + except FileNotFoundError: + print("Error: trial.json file not found") + return None + except json.JSONDecodeError: + print("Error: Invalid JSON format") + return None + except Exception as e: + print(f"Error: {str(e)}") + return None diff --git a/api/src/qa_create.py b/api/src/qa_create.py new file mode 100644 index 000000000..24ae92051 --- /dev/null +++ b/api/src/qa_create.py @@ -0,0 +1,119 @@ +import pandas as pd +from autorag.data.qa.filter.passage_dependency import ( + passage_dependency_filter_llama_index, +) +from autorag.data.qa.query.llama_gen_query import factoid_query_gen +from autorag.data.qa.sample import random_single_hop +from autorag.data.qa.schema import Corpus, QA +from autorag.data.qa.generation_gt.llama_index_gen_gt import ( + make_basic_gen_gt, + make_concise_gen_gt, +) +from autorag.data.qa.filter.dontknow import dontknow_filter_rule_based +from llama_index.core.base.llms.base import BaseLLM +from autorag.data.qa.evolve.llama_index_query_evolve import reasoning_evolve_ragas +from autorag.data.qa.evolve.llama_index_query_evolve import compress_ragas + + +def default_create( + corpus_df, llm: BaseLLM, n: int = 100, lang: str = "en", batch_size: int = 32 +) -> QA: + corpus_instance = Corpus(corpus_df) + if len(corpus_instance.data) < n: + n = len(corpus_instance.data) + sampled_corpus = corpus_instance.sample(random_single_hop, n=n) + mapped_corpus = sampled_corpus.map(lambda df: df.reset_index(drop=True)) + retrieval_gt_contents = mapped_corpus.make_retrieval_gt_contents() + query_generated = retrieval_gt_contents.batch_apply( + factoid_query_gen, llm=llm, lang=lang, batch_size=batch_size + ) + basic_answers = query_generated.batch_apply( + make_basic_gen_gt, llm=llm, lang=lang, batch_size=batch_size + ) + concise_answers = basic_answers.batch_apply( + make_concise_gen_gt, llm=llm, lang=lang, batch_size=batch_size + ) + filtered_answers = concise_answers.filter(dontknow_filter_rule_based, lang=lang) + initial_qa = filtered_answers.batch_filter( + passage_dependency_filter_llama_index, llm=llm, lang=lang, batch_size=batch_size + ) + return initial_qa + + +def fast_create( + corpus_df, llm: BaseLLM, n: int = 100, lang: str = "en", batch_size: int = 32 +) -> QA: + corpus_instance = Corpus(corpus_df) + if len(corpus_instance.data) < n: + n = len(corpus_instance.data) + + sampled_corpus = corpus_instance.sample(random_single_hop, n=n) + mapped_corpus = sampled_corpus.map(lambda df: df.reset_index(drop=True)) + + retrieval_gt_contents = mapped_corpus.make_retrieval_gt_contents() + + query_generated = retrieval_gt_contents.batch_apply( + factoid_query_gen, llm=llm, lang=lang, batch_size=batch_size + ) + + basic_answers = query_generated.batch_apply( + make_basic_gen_gt, llm=llm, lang=lang, batch_size=batch_size + ) + + concise_answers = basic_answers.batch_apply( + make_concise_gen_gt, llm=llm, lang=lang, batch_size=batch_size + ) + + initial_qa = concise_answers + + return initial_qa + + +def advanced_create( + corpus_df, llm: BaseLLM, n: int = 100, lang: str = "en", batch_size: int = 32 +) -> QA: + """ + Mix hard and easy question. + """ + corpus_instance = Corpus(corpus_df) + if len(corpus_instance.data) < n: + n = len(corpus_instance.data) + sampled_corpus = corpus_instance.sample(random_single_hop, n=n) + mapped_corpus = sampled_corpus.map(lambda df: df.reset_index(drop=True)) + retrieval_gt_contents = mapped_corpus.make_retrieval_gt_contents() + query_generated = retrieval_gt_contents.batch_apply( + factoid_query_gen, llm=llm, lang=lang, batch_size=batch_size + ) + basic_answers = query_generated.batch_apply( + make_basic_gen_gt, llm=llm, lang=lang, batch_size=batch_size + ) + concise_answers = basic_answers.batch_apply( + make_concise_gen_gt, llm=llm, lang=lang, batch_size=batch_size + ) + filtered_answers = concise_answers.filter(dontknow_filter_rule_based, lang=lang) + initial_qa = filtered_answers.batch_filter( + passage_dependency_filter_llama_index, llm=llm, lang=lang, batch_size=batch_size + ) + cut_idx = n // 2 + reasoning_qa = initial_qa.map(lambda df: df.iloc[:cut_idx]).batch_apply( + reasoning_evolve_ragas, + llm=llm, + lang=lang, + batch_size=batch_size, + ) + compressed_qa = ( + initial_qa.map(lambda df: df.iloc[cut_idx:]) + .map(lambda df: df.reset_index(drop=True)) + .batch_apply( + compress_ragas, + llm=llm, + lang=lang, + batch_size=batch_size, + ) + ) + final_qa = QA( + pd.concat([reasoning_qa.data, compressed_qa.data], ignore_index=True), + linked_corpus=corpus_instance, + ) + + return final_qa diff --git a/api/src/run.py b/api/src/run.py new file mode 100644 index 000000000..c53605f72 --- /dev/null +++ b/api/src/run.py @@ -0,0 +1,111 @@ +import os +import subprocess +from typing import Optional + +import pandas as pd +from autorag import generator_models +from autorag.chunker import Chunker +from autorag.data.qa.schema import QA +from autorag.evaluator import Evaluator +from autorag.parser import Parser +from autorag.validator import Validator + +from src.qa_create import default_create, fast_create, advanced_create +from src.schema import QACreationRequest, Status +from src.trial_config import PandasTrialDB + + +def run_parser_start_parsing(data_path_glob, project_dir, yaml_path): + # Import Parser here if it's defined in another module + parser = Parser(data_path_glob=data_path_glob, project_dir=project_dir) + parser.start_parsing(yaml_path) + + +def run_chunker_start_chunking(raw_path, project_dir, yaml_path): + # Import Parser here if it's defined in another module + chunker = Chunker.from_parquet(raw_path, project_dir=project_dir) + chunker.start_chunking(yaml_path) + + +def run_qa_creation( + qa_creation_request: QACreationRequest, corpus_filepath: str, dataset_dir: str +): + corpus_df = pd.read_parquet(corpus_filepath, engine="pyarrow") + llm = generator_models[qa_creation_request.llm_config.llm_name]( + **qa_creation_request.llm_config.llm_params + ) + + if qa_creation_request.preset == "basic": + qa: QA = default_create( + corpus_df, + llm, + qa_creation_request.qa_num, + qa_creation_request.lang, + batch_size=8, + ) + elif qa_creation_request.preset == "simple": + qa: QA = fast_create( + corpus_df, + llm, + qa_creation_request.qa_num, + qa_creation_request.lang, + batch_size=8, + ) + elif qa_creation_request.preset == "advanced": + qa: QA = advanced_create( + corpus_df, + llm, + qa_creation_request.qa_num, + qa_creation_request.lang, + batch_size=8, + ) + else: + raise ValueError(f"Input not supported Preset {qa_creation_request.preset}") + + # dataset_dir will be folder ${PROJECT_DIR}/qa/ + qa.to_parquet( + os.path.join(dataset_dir, f"{qa_creation_request.name}.parquet"), + corpus_filepath, + ) + + +def run_start_trial( + qa_path: str, + corpus_path: str, + project_dir: str, + yaml_path: str, + skip_validation: bool = True, + full_ingest: bool = True, + trial_id: Optional[str] = None, + trial_config_path: Optional[str] = None, +): + evaluator = Evaluator(qa_path, corpus_path, project_dir=project_dir) + evaluator.start_trial( + yaml_path, skip_validation=skip_validation, full_ingest=full_ingest + ) + if trial_id is not None and trial_config_path is not None: + # Update trial status + trial_config_db = PandasTrialDB(trial_config_path) + trial = trial_config_db.get_trial(trial_id) + new_trial = trial.model_copy(deep=True) + new_trial.status = Status.COMPLETED + trial_config_db.set_trial(new_trial) + + +def run_validate(qa_path: str, corpus_path: str, yaml_path: str): + validator = Validator(qa_path, corpus_path) + validator.validate(yaml_path) + + +def run_dashboard(trial_dir: str): + process = subprocess.Popen( + ["autorag", "dashboard", "--trial_dir", trial_dir], start_new_session=True + ) + return process.pid + + +def run_chat(trial_dir: str): + process = subprocess.Popen( + ["autorag", "run_web", "--trial_path", trial_dir], start_new_session=True + ) + return process.pid diff --git a/api/src/schema.py b/api/src/schema.py new file mode 100644 index 000000000..2f6c7e735 --- /dev/null +++ b/api/src/schema.py @@ -0,0 +1,158 @@ +from datetime import datetime +from enum import Enum +from typing import Dict, Literal, Any, Optional + +import numpy as np +from pydantic import BaseModel, Field, field_validator + + +class TrialCreateRequest(BaseModel): + name: Optional[str] = Field(None, description="The name of the trial") + raw_path: Optional[str] = Field(None, description="The path to the raw data") + corpus_path: Optional[str] = Field(None, description="The path to the corpus data") + qa_path: Optional[str] = Field(None, description="The path to the QA data") + config: Optional[Dict] = Field( + None, description="The trial configuration dictionary" + ) + + +class ParseRequest(BaseModel): + config: Dict = Field( + ..., description="Dictionary contains parse YAML configuration" + ) + name: str = Field(..., description="Name of the parse target dataset") + + +class ChunkRequest(BaseModel): + config: Dict = Field( + ..., description="Dictionary contains chunk YAML configuration" + ) + name: str = Field(..., description="Name of the chunk target dataset") + + +class QACreationPresetEnum(str, Enum): + BASIC = "basic" + SIMPLE = "simple" + ADVANCED = "advanced" + + +class LLMConfig(BaseModel): + llm_name: str = Field(description="Name of the LLM model") + llm_params: dict = Field(description="Parameters for the LLM model", default={}) + + +class SupportLanguageEnum(str, Enum): + ENGLISH = "en" + KOREAN = "ko" + JAPANESE = "ja" + + +class QACreationRequest(BaseModel): + preset: QACreationPresetEnum + name: str = Field(..., description="Name of the QA dataset") + qa_num: int + llm_config: LLMConfig = Field(description="LLM configuration settings") + lang: SupportLanguageEnum = Field( + default=SupportLanguageEnum.ENGLISH, description="Language of the QA dataset" + ) + + +class EnvVariableRequest(BaseModel): + key: str + value: str + + +class Project(BaseModel): + id: str + name: str + description: str + created_at: datetime + status: Literal["active", "archived"] + metadata: Dict[str, Any] + + class Config: + json_schema_extra = { + "example": { + "id": "proj_123", + "name": "My Project", + "description": "A sample project", + "created_at": "2024-02-11T12:00:00Z", + "status": "active", + "metadata": {}, + } + } + + +class Status(str, Enum): + NOT_STARTED = "not_started" + IN_PROGRESS = "in_progress" + COMPLETED = "completed" + FAILED = "failed" + TERMINATED = "terminated" + + +class TaskType(str, Enum): + PARSE = "parse" + CHUNK = "chunk" + QA = "qa" + VALIDATE = "validate" + EVALUATE = "evaluate" + REPORT = "report" + CHAT = "chat" + + +class Task(BaseModel): + id: str = Field(description="The task id") + project_id: str + trial_id: str = Field(description="The trial id", default="") + name: Optional[str] = Field(None, description="The name of the task") + config_yaml: Optional[Dict] = Field( + None, + description="YAML configuration. Format is dictionary, not path of the YAML file.", + ) + status: Status + error_message: Optional[str] = Field( + None, description="Error message if the task failed" + ) + type: TaskType + created_at: Optional[datetime] = None + save_path: Optional[str] = Field( + None, + description="Path where the task results are saved. It will be directory or file.", + ) + + +class TrialConfig(BaseModel): + trial_id: str + project_id: str + raw_path: Optional[str] + corpus_path: Optional[str] + qa_path: Optional[str] + config_path: Optional[str] + metadata: Dict = {} # Using Dict as the default empty dict for metadata + + class Config: + arbitrary_types_allowed = True + + +class Trial(BaseModel): + id: str + project_id: str + config: Optional[TrialConfig] = Field( + description="The trial configuration", default=None + ) + name: str + status: Status + created_at: datetime + report_task_id: Optional[str] = Field( + None, description="The report task id for forcing shutdown of the task" + ) + chat_task_id: Optional[str] = Field( + None, description="The chat task id for forcing shutdown of the task" + ) + + @field_validator("report_task_id", "chat_task_id", mode="before") + def replace_nan_with_none(cls, v): + if isinstance(v, float) and np.isnan(v): + return None + return v diff --git a/api/src/trial_config.py b/api/src/trial_config.py new file mode 100644 index 000000000..9fd6c0acf --- /dev/null +++ b/api/src/trial_config.py @@ -0,0 +1,105 @@ +import os +from abc import ABCMeta, abstractmethod +from typing import Optional, List + +import pandas as pd + +from src.schema import TrialConfig, Trial + + +class BaseTrialDB(metaclass=ABCMeta): + @abstractmethod + def set_trial(self, trial: Trial): + pass + + @abstractmethod + def get_trial(self, trial_id: str) -> Optional[Trial]: + pass + + @abstractmethod + def set_trial_config(self, trial_id: str, config: TrialConfig): + pass + + @abstractmethod + def get_trial_config(self, trial_id: str) -> Optional[TrialConfig]: + pass + + @abstractmethod + def get_all_config_ids(self) -> List[str]: + pass + + +class PandasTrialDB(BaseTrialDB): + def __init__(self, df_path: str): + self.columns = [ + "id", + "project_id", + "config", + "name", + "status", + "created_at", + "report_task_id", + "chat_task_id", + ] + self.df_path = df_path + if not os.path.exists(df_path): + df = pd.DataFrame(columns=self.columns) + df.to_csv(df_path, index=False) + else: + try: + df = pd.read_csv(df_path) + except Exception: + df = pd.DataFrame(columns=self.columns) + self.df = df + + def set_trial(self, trial: Trial): + new_row = pd.DataFrame( + { + "id": [trial.id], + "project_id": [trial.project_id], + "config": [trial.config.model_dump_json()], + "name": [trial.name], + "status": [trial.status], + "created_at": [trial.created_at], + "report_task_id": [trial.report_task_id], + "chat_task_id": [trial.chat_task_id], + } + ) + if len(self.df.loc[self.df["id"] == trial.id]) > 0: + self.df = self.df.loc[self.df["id"] != trial.id] + self.df = pd.concat([self.df, new_row]) + self.df.to_csv(self.df_path, index=False) + + def get_trial(self, trial_id: str) -> Optional[Trial]: + matches = self.df[self.df["id"] == trial_id] + if len(matches) < 1: + return None + row = matches.iloc[0] + if row.empty: + return None + return Trial( + id=row["id"], + project_id=row["project_id"], + config=TrialConfig.model_validate_json(row["config"]), + name=row["name"], + status=row["status"], + created_at=row["created_at"], + report_task_id=row["report_task_id"], + chat_task_id=row["chat_task_id"], + ) + + def set_trial_config(self, trial_id: str, config: TrialConfig): + config_dict = config.model_dump_json() + self.df.loc[self.df["id"] == trial_id, "config"] = config_dict + self.df.to_csv(self.df_path, index=False) + + def get_trial_config(self, trial_id: str) -> Optional[TrialConfig]: + config_dict = self.df.loc[self.df["id"] == trial_id]["config"].tolist() + if len(config_dict) < 1: + return None + + config = TrialConfig.model_validate_json(config_dict[0]) + return config + + def get_all_config_ids(self) -> List[str]: + return self.df["id"].tolist() diff --git a/api/src/validate.py b/api/src/validate.py new file mode 100644 index 000000000..c7f4f1b71 --- /dev/null +++ b/api/src/validate.py @@ -0,0 +1,57 @@ +import os +from functools import wraps + +from quart import jsonify + +from src.schema import Trial +from src.trial_config import PandasTrialDB + + +def project_exists(work_dir): + def decorator(func): + @wraps(func) + async def wrapper(*args, **kwargs): + # Get project_id from request arguments + project_id = kwargs.get("project_id") + + if not project_id: + return jsonify({"error": "project_id is required"}), 400 + + # Check if project directory exists + project_path = os.path.join(work_dir, project_id) + if not os.path.exists(project_path): + return jsonify( + {"error": f"Project with id {project_id} does not exist"} + ), 404 + + # If everything is okay, proceed with the endpoint function + return await func(*args, **kwargs) + + return wrapper + + return decorator + + +def trial_exists(work_dir: str): + def decorator(func): + @wraps(func) + async def wrapper(*args, **kwargs): + project_id = kwargs.get("project_id") + trial_id = kwargs.get("trial_id") + + if not trial_id: + return jsonify({"error": "trial_id is required"}), 400 + + trial_config_path = os.path.join(work_dir, project_id, "trial_config.csv") + trial_config_db = PandasTrialDB(trial_config_path) + trial = trial_config_db.get_trial(trial_id) + if trial is None or not isinstance(trial, Trial): + return jsonify( + {"error": f"Trial with id {trial_id} does not exist"} + ), 404 + + return await func(*args, **kwargs) + + return wrapper + + return decorator diff --git a/tests/api/test.http b/tests/api/test.http new file mode 100644 index 000000000..213ce20fc --- /dev/null +++ b/tests/api/test.http @@ -0,0 +1,194 @@ +### +POST http://localhost:5000/projects +Authorization: Bearer good +Content-Type: application/json + +{ + "name": "Project-1", + "description": "Description of project 1" +} +> {% client.global.set("project_id", response.body.id); %} +<> 2024-11-03T134659.201.json +<> 2024-11-03T134638.401.json + +### +POST http://localhost:5000/projects/{{project_id}}/trials +Content-Type: application/json + +{ + "name": "Trial-1" +} + +> {% client.global.set("trial_id", response.body.id); %} + +### +GET http://localhost:5000/projects/{{project_id}}/trials + +### +POST http://localhost:5000/projects/{{project_id}}/trials/{{trial_id}}/parse +Content-Type: application/json + +{ + "config": { + "modules": [ + {"module_type": "langchain_parse", "parse_method": ["pdfminer"]} + ] + }, + "name": "test2" +} + +> {% client.global.set("parse_task_id", response.body.id); %} +<> 2024-11-03T134938.202.json + +### + +GET http://localhost:5000/projects/{{project_id}}/tasks/{{parse_task_id}} + + +### +POST http://localhost:5000/projects/{{project_id}}/trials/{{trial_id}}/chunk +Content-Type: application/json + +{ + "config": { + "modules": [ + {"module_type": "llama_index_chunk", "chunk_method": ["Token"]} + ] + }, + "name": "test2" +} + +> {% client.global.set("chunk_task_id", response.body.id); %} + +### +GET http://localhost:5000/projects/{{project_id}}/tasks/{{chunk_task_id}} + +### +POST http://localhost:5000/projects/{{project_id}}/trials/{{trial_id}}/qa +Content-Type: application/json + +{ + "preset": "simple", + "name": "test3", + "qa_num": 5, + "llm_config": { + "llm_name": "mock" + }, + "lang": "ko" +} + +> {% client.global.set("qa_task_id", response.body.id); %} + +### +GET http://localhost:5000/projects/{{project_id}}/tasks/{{qa_task_id}} + +### +POST http://localhost:5000/projects/{{project_id}}/trials/{{trial_id}}/config +Content-Type: application/json + +{ + "config": { + "node_lines": [ + { + "node_line_name": "retrieve_node_line", + "nodes": [ + { + "node_type": "retrieval", + "strategy": { + "metrics": [ + "retrieval_f1", + "retrieval_recall", + "retrieval_precision" + ] + }, + "top_k": 3, + "modules": [ + { + "module_type": "vectordb", + "vectordb": "default" + } + ] + } + ] + }, + { + "node_line_name": "post_retrieve_node_line", + "nodes": [ + { + "node_type": "prompt_maker", + "strategy": { + "metrics": [ + "bleu", + "meteor", + "rouge" + ] + }, + "modules": [ + { + "module_type": "fstring", + "prompt": "Read the passages and answer the given question. \n Question: {query} \n Passage: {retrieved_contents} \n Answer : " + } + ] + }, + { + "node_type": "generator", + "strategy": { + "metrics": [ + "bleu", + "rouge" + ] + }, + "modules": [ + { + "module_type": "llama_index_llm", + "llm": "openai", + "model": [ + "gpt-4o-mini" + ] + } + ] + } + ] + } + ] + } +} + +### +GET http://localhost:5000/projects/{{project_id}}/trials/{{trial_id}}/config + +### +POST http://localhost:5000/projects/{{project_id}}/trials/{{trial_id}}/validate +Content-Type: application/json + +{} + +> {% client.global.set("validate_id", response.body.id); %} + +### +GET http://localhost:5000/projects/{{project_id}}/tasks/{{validate_id}} + +### +POST http://localhost:5000/projects/{{project_id}}/trials/{{trial_id}}/evaluate +Content-Type: application/json + +{ + "full_ingest": true, + "skip_validation": true +} + +> {% client.global.set("evaluate_id", response.body.id); %} +### +GET http://localhost:5000/projects/{{project_id}}/tasks/{{evaluate_id}} + +### +GET http://localhost:5000/projects/{{project_id}}/trials/{{trial_id}}/report/open + +### +GET http://localhost:5000/projects/{{project_id}}/trials/{{trial_id}}/report/close + +### +GET http://localhost:5000/projects/{{project_id}}/trials/{{trial_id}}/chat/open + +### +GET http://localhost:5000/projects/{{project_id}}/trials/{{trial_id}}/chat/close diff --git a/tests/api/test_app.py b/tests/api/test_app.py new file mode 100644 index 000000000..421e6b2ba --- /dev/null +++ b/tests/api/test_app.py @@ -0,0 +1,371 @@ +import os +import pathlib +import shutil +import uuid +from datetime import datetime + +import pytest +import yaml + +from app import app, WORK_DIR +from src.schema import TrialConfig, Trial, Status +from src.trial_config import PandasTrialDB + +tests_dir = os.path.dirname(os.path.realpath(__file__)) +root_dir = pathlib.PurePath(tests_dir).parent + + +@pytest.fixture +def client_for_test(): + yield app.test_client() + + +@pytest.fixture +def new_project_test_client(): + yield app.test_client() + shutil.rmtree(os.path.join(WORK_DIR, "test_project")) + + +@pytest.fixture +def chunk_client(): + yield app.test_client() + + +@pytest.mark.asyncio +async def test_create_project_success(new_project_test_client): + # Make request + response = await new_project_test_client.post( + "/projects", + json={"name": "test_project", "description": "A test project"}, + headers={"Authorization": "Bearer good", "Content-Type": "application/json"}, + ) + + # Assert response + data = await response.get_json() + assert response.status_code == 201 + assert data["name"] == "test_project" + assert data["description"] == "A test project" + assert data["status"] == "active" + assert "created_at" in data + assert data["id"] == "test_project" + assert "metadata" in data + + assert os.path.exists(os.path.join(WORK_DIR, "test_project")) + assert os.path.exists(os.path.join(WORK_DIR, "test_project", "parse")) + assert os.path.exists(os.path.join(WORK_DIR, "test_project", "chunk")) + assert os.path.exists(os.path.join(WORK_DIR, "test_project", "qa")) + assert os.path.exists(os.path.join(WORK_DIR, "test_project", "project")) + assert os.path.exists(os.path.join(WORK_DIR, "test_project", "config")) + assert os.path.exists(os.path.join(WORK_DIR, "test_project", "trial_config.csv")) + assert os.path.exists(os.path.join(WORK_DIR, "test_project", "description.txt")) + + with open(os.path.join(WORK_DIR, "test_project", "description.txt"), "r") as f: + assert f.read() == "A test project" + + # Test GET of projects + response = await new_project_test_client.get( + "/projects?page=1&limit=10&status=active" + ) + + # Assert Response + data = await response.get_json() + assert response.status_code == 200 + assert data["total"] >= 1 + assert len(data["data"]) >= 1 + assert data["data"][0]["name"] == "test_project" + assert data["data"][0]["status"] == "active" + assert data["data"][0]["description"] == "A test project" + + with open( + os.path.join(root_dir, "tests", "resources", "parsed_data", "baseball_1.pdf"), + "rb", + ) as f1, open( + os.path.join( + root_dir, "tests", "resources", "parsed_data", "korean_texts_two_page.pdf" + ), + "rb", + ) as f2: + # Prepare the data for upload + data = [ + ("file", (os.path.basename(f1.name), f1)), + ("file", (os.path.basename(f2.name), f2)), + ] + + response = await new_project_test_client.post( + "/projects/test_project/upload", files=data + ) + + assert response.status_code == 200 + data = await response.get_json() + assert data["message"] == "Files uploaded successfully" + assert "filePaths" in data + assert len(data["filePaths"]) == 2 + assert os.path.dirname(data["filePaths"][0]).endswith("raw_data") + assert os.path.exists(data["filePaths"][0]) + + # duplicate project + response = await new_project_test_client.post( + "/projects", + json={"name": "test_project", "description": "A test project one more time"}, + headers={"Authorization": "Bearer good", "Content-Type": "application/json"}, + ) + + assert response.status_code == 400 + data = await response.get_json() + assert "error" in data + assert data["error"] == "Project name already exists: test_project" + + # Missing name at request + response = await new_project_test_client.post( + "/projects", + json={"description": "A test project"}, + headers={"Authorization": "Bearer good", "Content-Type": "application/json"}, + ) + assert response.status_code == 400 + data = await response.get_json() + assert "error" in data + assert data["error"] == "Name is required" + + # Missing auth header + response = await new_project_test_client.post( + "/projects", + json={"name": "test_project", "description": "A test project"}, + headers={"Content-Type": "application/json"}, + ) + assert response.status_code == 401 + + # Invalid token + response = await new_project_test_client.post( + "/projects", + json={"name": "test_project", "description": "A test project"}, + headers={"Authorization": "Bearer bad", "Content-Type": "application/json"}, + ) + assert response.status_code == 403 + + +@pytest.fixture +def get_trial_list_client(): + yield app.test_client() + shutil.rmtree(os.path.join(WORK_DIR, "test_project_get_trial_lists")) + + +@pytest.mark.asyncio +async def test_get_trial_lists(get_trial_list_client): + project_id = "test_project_get_trial_lists" + trial_id = str(uuid.uuid4()) + trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") + os.makedirs(os.path.join(WORK_DIR, project_id), exist_ok=True) + + # Create a trial config file + trial_config = TrialConfig( + trial_id=trial_id, + project_id=project_id, + raw_path="/path/to/raw", + corpus_path="/path/to/corpus", + qa_path="/path/to/qa", + config_path="/path/to/config", + ) + trial = Trial( + id=trial_id, + project_id=project_id, + config=trial_config, + name="Test Trial", + status="not_started", + created_at=datetime.now(), + ) + trial_config_db = PandasTrialDB(trial_config_path) + trial_config_db.set_trial(trial) + + response = await get_trial_list_client.get(f"/projects/{project_id}/trials") + data = await response.get_json() + + assert response.status_code == 200 + assert data["total"] == 1 + assert data["data"][0]["id"] == trial_id + assert data["data"][0]["config"]["project_id"] == project_id + + # Test @project_exists + + +@pytest.fixture +def create_new_trial_client(): + yield app.test_client() + shutil.rmtree(os.path.join(WORK_DIR, "test_project_create_new_trial")) + + +@pytest.mark.asyncio +async def test_create_new_trial(create_new_trial_client): + project_id = "test_project_create_new_trial" + os.makedirs(os.path.join(WORK_DIR, project_id), exist_ok=True) + trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") + + trial_create_request = { + "name": "New Trial", + "raw_path": None, + "corpus_path": None, + "qa_path": None, + "config": None, + } + + response = await create_new_trial_client.post( + f"/projects/{project_id}/trials", json=trial_create_request + ) + data = await response.get_json() + + assert response.status_code == 202 + assert data["name"] == "New Trial" + assert data["project_id"] == project_id + assert "id" in data + + # Verify the trial was added to the CSV + trial_config_db = PandasTrialDB(trial_config_path) + trial_ids = trial_config_db.get_all_config_ids() + assert len(trial_ids) == 1 + assert trial_ids[0] == data["id"] + + +@pytest.fixture +def trial_config_client(): + client = app.test_client() + yield client + shutil.rmtree(os.path.join(WORK_DIR, "test_project")) + + +@pytest.mark.asyncio +async def test_get_trial_config(trial_config_client): + project_id = "test_project" + trial_id = "test_trial" + response = await trial_config_client.post( + "/projects", + json={"name": project_id, "description": "A test project"}, + headers={"Authorization": "Bearer good", "Content-Type": "application/json"}, + ) + assert response.status_code == 201 + os.makedirs(os.path.join(WORK_DIR, project_id), exist_ok=True) + trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") + trial_config_db = PandasTrialDB(trial_config_path) + trial_config = TrialConfig( + trial_id=trial_id, + project_id=project_id, + raw_path="raw_path", + corpus_path="corpus_path", + qa_path="qa_path", + config_path="config_path", + metadata={"key": "value"}, + ) + trial = Trial( + id=trial_id, + project_id=project_id, + config=trial_config, + name="Test Trial", + status=Status.NOT_STARTED, + created_at=datetime.now(), + ) + trial_config_db.set_trial(trial) + + response = await trial_config_client.get( + f"/projects/{project_id}/trials/{trial_id}/config" + ) + data = await response.get_json() + + assert response.status_code == 200 + assert data["trial_id"] == trial_id + assert data["project_id"] == project_id + assert data["raw_path"] == "raw_path" + assert data["corpus_path"] == "corpus_path" + assert data["qa_path"] == "qa_path" + assert data["config_path"] == "config_path" + assert data["metadata"] == {"key": "value"} + + +@pytest.mark.asyncio +async def test_set_trial_config(trial_config_client): + project_id = "test_project" + trial_id = "test_trial" + response = await trial_config_client.post( + "/projects", + json={"name": project_id, "description": "A test project"}, + headers={"Authorization": "Bearer good", "Content-Type": "application/json"}, + ) + assert response.status_code == 201 + + os.makedirs(os.path.join(WORK_DIR, project_id), exist_ok=True) + trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") + trial_config_db = PandasTrialDB(trial_config_path) + trial_config = TrialConfig( + trial_id=trial_id, + project_id=project_id, + raw_path="raw_path", + corpus_path="corpus_path", + qa_path="qa_path", + config_path="config_path", + metadata={"key": "value"}, + ) + trial = Trial( + id=trial_id, + project_id=project_id, + config=trial_config, + name="Test Trial", + status=Status.NOT_STARTED, + created_at=datetime.now(), + ) + trial_config_db.set_trial(trial) + + new_config_data = { + "raw_path": "new_raw_path", + "corpus_path": "new_corpus_path", + "qa_path": "new_qa_path", + "config": {"jax": "children"}, + "metadata": {"new_key": "new_value"}, + } + + response = await trial_config_client.post( + f"/projects/{project_id}/trials/{trial_id}/config", json=new_config_data + ) + data = await response.get_json() + + assert response.status_code == 201 + assert data["trial_id"] == trial_id + assert data["project_id"] == project_id + assert data["raw_path"] == "new_raw_path" + assert data["corpus_path"] == "new_corpus_path" + assert data["qa_path"] == "new_qa_path" + assert data["config_path"] + with open(data["config_path"]) as f: + assert yaml.safe_load(f) == {"jax": "children"} + assert data["metadata"] == {"new_key": "new_value"} + + +@pytest.mark.asyncio +async def test_set_env_variable(client_for_test): + os.environ.pop("test_key", None) + response = await client_for_test.post( + "/env", + json={ + "key": "test_key", + "value": "test_value", + }, + ) + assert response.status_code == 200 + assert os.getenv("test_key") == "test_value" + response = await client_for_test.post( + "/env", + json={ + "key": "test_key", + "value": "test_value2", + }, + ) + assert response.status_code == 201 + assert os.getenv("test_key") == "test_value2" + + +@pytest.mark.asyncio +async def test_get_env_variable(client_for_test): + os.environ["test_key"] = "test_value" + response = await client_for_test.get("/env/test_key") + assert response.status_code == 200 + + response = await client_for_test.get("/env/non_existent_key") + assert response.status_code == 404 + data = await response.get_json() + assert data["error"] == "Environment variable 'non_existent_key' not found" diff --git a/tests/api/test_trial_config.py b/tests/api/test_trial_config.py new file mode 100644 index 000000000..ce43025e8 --- /dev/null +++ b/tests/api/test_trial_config.py @@ -0,0 +1,111 @@ +import os +import tempfile +import pandas as pd +import pytest + +from src.trial_config import PandasTrialDB +from src.schema import Trial, TrialConfig +from datetime import datetime + + +@pytest.fixture +def temp_csv_path(): + with tempfile.NamedTemporaryFile(suffix=".csv", delete=False) as tmp_file: + temp_path = tmp_file.name + yield temp_path + if os.path.exists(temp_path): + os.remove(temp_path) + + +@pytest.fixture +def sample_trial(): + return Trial( + id="test_trial_1", + project_id="test_project", + config=TrialConfig( + trial_id="test_trial_1", + project_id="test_project", + raw_path="/path/to/raw", + corpus_path="/path/to/corpus", + qa_path="/path/to/qa", + config_path="/path/to/config", + ), + name="Test Trial", + status="not_started", + created_at=datetime.now(), + ) + + +def test_set_trial(temp_csv_path, sample_trial): + trial_db = PandasTrialDB(temp_csv_path) + trial_db.set_trial(sample_trial) + df = pd.read_csv(temp_csv_path) + assert len(df) == 1 + assert df.iloc[0]["id"] == sample_trial.id + assert df.iloc[0]["project_id"] == sample_trial.project_id + + +def test_get_trial_existing(temp_csv_path, sample_trial): + trial_db = PandasTrialDB(temp_csv_path) + trial_db.set_trial(sample_trial) + retrieved_trial = trial_db.get_trial(sample_trial.id) + assert retrieved_trial is not None + assert retrieved_trial.id == sample_trial.id + assert retrieved_trial.project_id == sample_trial.project_id + assert isinstance(retrieved_trial.config, TrialConfig) + assert retrieved_trial.config == sample_trial.config + + retrieved_trial_config = trial_db.get_trial_config(sample_trial.id) + assert retrieved_trial_config is not None + assert isinstance(retrieved_trial_config, TrialConfig) + assert retrieved_trial_config == sample_trial.config + + +def test_get_trial_nonexistent(temp_csv_path): + trial_db = PandasTrialDB(temp_csv_path) + retrieved_trial = trial_db.get_trial("nonexistent_id") + assert retrieved_trial is None + + +def test_set_trial_config(temp_csv_path, sample_trial): + trial_db = PandasTrialDB(temp_csv_path) + trial_db.set_trial(sample_trial) + new_config = TrialConfig( + trial_id="test_trial_1", + project_id="test_project", + raw_path="/new/path/to/raw", + corpus_path="/new/path/to/corpus", + qa_path="/new/path/to/qa", + config_path="/new/path/to/config", + ) + trial_db.set_trial_config(sample_trial.id, new_config) + retrieved_config = trial_db.get_trial_config(sample_trial.id) + assert retrieved_config is not None + assert retrieved_config == new_config + + retrieved_trial = trial_db.get_trial(sample_trial.id) + assert retrieved_trial.id == sample_trial.id + assert retrieved_trial.config == new_config + + +def test_get_trial_config_existing(temp_csv_path, sample_trial): + trial_db = PandasTrialDB(temp_csv_path) + trial_db.set_trial(sample_trial) + retrieved_config = trial_db.get_trial_config(sample_trial.id) + assert retrieved_config is not None + assert retrieved_config.trial_id == sample_trial.config.trial_id + assert retrieved_config == sample_trial.config + + +def test_get_trial_config_nonexistent(temp_csv_path): + trial_db = PandasTrialDB(temp_csv_path) + retrieved_config = trial_db.get_trial_config("nonexistent_id") + assert retrieved_config is None + + +def test_get_all_config_ids(temp_csv_path, sample_trial): + trial_db = PandasTrialDB(temp_csv_path) + trial_db.set_trial(sample_trial) + config_ids = trial_db.get_all_config_ids() + assert len(config_ids) == 1 + assert config_ids[0] == sample_trial.id From 6f12102743af7c13574b94f6f2489eaabeaf9f76 Mon Sep 17 00:00:00 2001 From: jeffrey Date: Tue, 19 Nov 2024 17:33:42 +0800 Subject: [PATCH 12/55] Add api to repository --- .github/workflows/test.yml | 10 +++++++++- tests/api/test_app.py | 27 --------------------------- 2 files changed, 9 insertions(+), 28 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0d6c8943d..85d44cebc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -58,4 +58,12 @@ jobs: env: PYTHONPATH: ${PYTHONPATH}:./autorag run: | - python3 -m pytest -o log_cli=true --log-cli-level=INFO -n auto tests/ + python3 -m pytest -o log_cli=true --log-cli-level=INFO -n auto tests/autorag + - name: Install Quart Dependencies + run: | + pip install -r api/requirements.txt + - name: Run AutoRAG API server tests + env: + PYTHONPATH: ${PYTHONPATH}:./api + run: | + python3 -m pytest -o log_cli=true --log-cli-level=INFO -n auto tests/api diff --git a/tests/api/test_app.py b/tests/api/test_app.py index 421e6b2ba..92de5cbb0 100644 --- a/tests/api/test_app.py +++ b/tests/api/test_app.py @@ -76,33 +76,6 @@ async def test_create_project_success(new_project_test_client): assert data["data"][0]["status"] == "active" assert data["data"][0]["description"] == "A test project" - with open( - os.path.join(root_dir, "tests", "resources", "parsed_data", "baseball_1.pdf"), - "rb", - ) as f1, open( - os.path.join( - root_dir, "tests", "resources", "parsed_data", "korean_texts_two_page.pdf" - ), - "rb", - ) as f2: - # Prepare the data for upload - data = [ - ("file", (os.path.basename(f1.name), f1)), - ("file", (os.path.basename(f2.name), f2)), - ] - - response = await new_project_test_client.post( - "/projects/test_project/upload", files=data - ) - - assert response.status_code == 200 - data = await response.get_json() - assert data["message"] == "Files uploaded successfully" - assert "filePaths" in data - assert len(data["filePaths"]) == 2 - assert os.path.dirname(data["filePaths"][0]).endswith("raw_data") - assert os.path.exists(data["filePaths"][0]) - # duplicate project response = await new_project_test_client.post( "/projects", From 8ff9c86cd0501337c285eb29d90cbbdcd7ef5d3f Mon Sep 17 00:00:00 2001 From: jeffrey Date: Tue, 19 Nov 2024 17:43:02 +0800 Subject: [PATCH 13/55] add autorag at pythonpath --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 85d44cebc..e8027b4e4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -64,6 +64,6 @@ jobs: pip install -r api/requirements.txt - name: Run AutoRAG API server tests env: - PYTHONPATH: ${PYTHONPATH}:./api + PYTHONPATH: ${PYTHONPATH}:./api:./autorag run: | python3 -m pytest -o log_cli=true --log-cli-level=INFO -n auto tests/api From b6c4232fb245cf259814546fcd5a1462f81100b0 Mon Sep 17 00:00:00 2001 From: jeffrey Date: Tue, 19 Nov 2024 17:52:04 +0800 Subject: [PATCH 14/55] edit gitignore for tracking projects folder --- api/.gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/api/.gitignore b/api/.gitignore index 78a5d0a2d..7eeaf681b 100644 --- a/api/.gitignore +++ b/api/.gitignore @@ -162,3 +162,4 @@ cython_debug/ .idea/ .DS_Store projects +!projects/README.md From f32cec35edacba4a4190e628332953c7acdea458 Mon Sep 17 00:00:00 2001 From: jeffrey Date: Tue, 19 Nov 2024 17:52:44 +0800 Subject: [PATCH 15/55] add README.md at projects folder for tracking projects folder --- api/projects/README.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 api/projects/README.md diff --git a/api/projects/README.md b/api/projects/README.md new file mode 100644 index 000000000..9329ef4fd --- /dev/null +++ b/api/projects/README.md @@ -0,0 +1 @@ +This is a folder that saves all results from the API server From e9d5666cede1748ce02cf4611575f76476f7e12f Mon Sep 17 00:00:00 2001 From: jeffrey Date: Tue, 19 Nov 2024 18:01:30 +0800 Subject: [PATCH 16/55] add autorag-frontend as git submodule --- .gitmodules | 3 +++ autorag-frontend | 1 + 2 files changed, 4 insertions(+) create mode 100644 .gitmodules create mode 160000 autorag-frontend diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..8c62292ce --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "autorag-frontend"] + path = autorag-frontend + url = https://github.com/Auto-RAG/autorag-frontend.git diff --git a/autorag-frontend b/autorag-frontend new file mode 160000 index 000000000..6ace603a7 --- /dev/null +++ b/autorag-frontend @@ -0,0 +1 @@ +Subproject commit 6ace603a7acc4444702a29d3afd95b0e8ded7542 From 7d2b8cb63f81c46f46fe79da7795346133114363 Mon Sep 17 00:00:00 2001 From: jeffrey Date: Tue, 19 Nov 2024 18:02:38 +0800 Subject: [PATCH 17/55] Do not run API test at github actions --- .github/workflows/test.yml | 8 -------- 1 file changed, 8 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e8027b4e4..de76bb56b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -59,11 +59,3 @@ jobs: PYTHONPATH: ${PYTHONPATH}:./autorag run: | python3 -m pytest -o log_cli=true --log-cli-level=INFO -n auto tests/autorag - - name: Install Quart Dependencies - run: | - pip install -r api/requirements.txt - - name: Run AutoRAG API server tests - env: - PYTHONPATH: ${PYTHONPATH}:./api:./autorag - run: | - python3 -m pytest -o log_cli=true --log-cli-level=INFO -n auto tests/api From 51354ed6e7ce4551b6b8d592b0cac126f6c5bd7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=8D=EC=8A=B9=EC=9A=B0?= Date: Tue, 19 Nov 2024 21:00:20 +0900 Subject: [PATCH 18/55] rename: update file path from api/projects/README.md to projects/README.md --- {api/projects => projects}/README.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename {api/projects => projects}/README.md (100%) diff --git a/api/projects/README.md b/projects/README.md similarity index 100% rename from api/projects/README.md rename to projects/README.md From 1dd72fb461959e9dc5b5c2820c2ff0461602049a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=8D=EC=8A=B9=EC=9A=B0?= Date: Tue, 19 Nov 2024 21:00:33 +0900 Subject: [PATCH 19/55] =?UTF-8?q?=F0=9F=9A=91=20fix:=20Update=20.gitignore?= =?UTF-8?q?=20and=20add=20.dockerignore=20and=20Dockerfile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added various entries to ignore specific files and directories in both the root directory's .gitignore and the api directory's .dockerignore. Additionally, included a Dockerfile for building a Python 3.10-slim-based API image with specified dependencies and runtime configurations. A docker-compose.yml file was introduced to define services and networks for frontend and API components. --- .gitignore | 163 ++++++++++++++++++++++++++++++++++++++++++++- api/.dockerignore | 89 +++++++++++++++++++++++++ api/Dockerfile | 22 ++++++ docker-compose.yml | 37 ++++++++++ 4 files changed, 310 insertions(+), 1 deletion(-) create mode 100644 api/.dockerignore create mode 100644 api/Dockerfile create mode 100644 docker-compose.yml diff --git a/.gitignore b/.gitignore index 81e3a94a2..82f927558 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,162 @@ -pytest.ini +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ diff --git a/api/.dockerignore b/api/.dockerignore new file mode 100644 index 000000000..b7be024ec --- /dev/null +++ b/api/.dockerignore @@ -0,0 +1,89 @@ +# Git +.git +.gitignore +.gitattributes + + +# CI +.codeclimate.yml +.travis.yml +.taskcluster.yml + +# Docker +docker-compose.yml +Dockerfile +.docker +.dockerignore + +# Byte-compiled / optimized / DLL files +**/__pycache__/ +**/*.py[cod] + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.cache +nosetests.xml +coverage.xml + +# Translations +*.mo +*.pot + +# Django stuff: +*.log + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Virtual environment +.env +.venv/ +venv/ + +# PyCharm +.idea + +# Python mode for VIM +.ropeproject +**/.ropeproject + +# Vim swap files +**/*.swp + +# VS Code +.vscode/ \ No newline at end of file diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 000000000..a8389aefe --- /dev/null +++ b/api/Dockerfile @@ -0,0 +1,22 @@ +FROM python:3.10-slim + +WORKDIR /app + +# Install system dependencies and pip +RUN apt-get update && apt-get install -y \ + python3-pip \ + build-essential \ + && rm -rf /var/lib/apt/lists/* + +# Upgrade pip +RUN python -m pip install --upgrade pip + +# Install Python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY . . + +EXPOSE ${PORT:-5000} +CMD ["sh", "-c", "hypercorn app:app --bind 0.0.0.0:${PORT:-5000} --reload"] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..6a4eb623b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,37 @@ +services: + frontend: + build: + context: ./autorag-frontend + dockerfile: Dockerfile + working_dir: /app/autorag-frontend + volumes: + - ./autorag-frontend:/app/autorag-frontend + - /app/autorag-frontend/node_modules + ports: + - "3000:3000" + environment: + - NEXT_PUBLIC_API_URL=http://localhost:5001 + - NODE_ENV=development + command: sh -c "yarn install && yarn dev" + networks: + - app-network + + api: + build: + context: ./api + dockerfile: Dockerfile + volumes: + - ./api:/app/api + ports: + - "5000:5001" + environment: + - PYTHONUNBUFFERED=1 + - PORT=5000 + - HOST=0.0.0.0 + command: hypercorn app:app --bind 0.0.0.0:5000 --reload + networks: + - app-network + +networks: + app-network: + driver: bridge \ No newline at end of file From c7c0f9b23f9af98d4da22eda44a26476fad8d47d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=8D=EC=8A=B9=EC=9A=B0?= Date: Tue, 19 Nov 2024 21:16:56 +0900 Subject: [PATCH 20/55] =?UTF-8?q?=F0=9F=93=9D=20docs:=20remove=20AutoRAG?= =?UTF-8?q?=20Workflow=20API=20documentation=20and=20related=20resources.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/docs/README.md | 99 ---- api/docs/openapi.js | 1179 ------------------------------------- api/docs/openapi.yaml | 748 ----------------------- api/docs/openapi3_0.json | 1179 ------------------------------------- api/docs/swagger-api.html | 37 -- 5 files changed, 3242 deletions(-) delete mode 100644 api/docs/README.md delete mode 100644 api/docs/openapi.js delete mode 100644 api/docs/openapi.yaml delete mode 100644 api/docs/openapi3_0.json delete mode 100644 api/docs/swagger-api.html diff --git a/api/docs/README.md b/api/docs/README.md deleted file mode 100644 index ccb8bfe51..000000000 --- a/api/docs/README.md +++ /dev/null @@ -1,99 +0,0 @@ -# AutoRAG Workflow API Documentation - -This API provides a complete workflow for AutoRAG operations, from project creation to evaluation. The API follows RESTful principles and uses JSON for request/response payloads. - -## Authentication - -The API uses Bearer token authentication. Include the token in the Authorization header: - -## Core Components - -### Project -- Represents a RAG workflow project -- Contains metadata, status, and configuration -- Unique identifier: `proj_*` - -### Task -- Represents individual workflow operations -- Types: parse, chunk, qa, validate, evaluate -- Contains status, configuration, and results -- Tracks execution state and errors - -## API Endpoints - -### Project Management -- `POST /projects` - - Create a new project - - Required: `name` - - Returns: Project object - -### Workflow Operations - -#### 1. Parsing -- `POST /projects/{project_id}/parse/start` - - Start document parsing - - Required: - - `glob_path`: File pattern to match - - `config`: Parsing configuration - - `name`: Operation name - -#### 2. Chunking -- `POST /projects/{project_id}/chunk/start` - - Process parsed documents into chunks - - Required: - - `raw_filepath`: Path to parsed data - - `config`: Chunking configuration - - `name`: Operation name - -#### 3. QA Generation -- `POST /projects/{project_id}/qa/start` - - Generate QA pairs - - Required: - - `corpus_filepath`: Path to chunked data - - Optional: - - `qa_num`: Number of QA pairs - - `preset`: [basic, simple, advanced] - - `llm_config`: LLM configuration - -#### 4. Validation -- `POST /projects/{project_id}/validate/start` - - Validate generated QA pairs - - Required: - - `config_yaml`: Validation configuration - -#### 5. Evaluation -- `POST /projects/{project_id}/evaluate/start` - - Evaluate RAG performance - - Required: - - `config_yaml`: Evaluation configuration - - Optional: - - `skip_validation`: Skip validation step (default: true) - -### Task Monitoring -- `GET /projects/{project_id}/task/{task_id}` - - Monitor task status - - Returns: Task object with current status - -## Task States -- `not_started`: Task is created but not running -- `in_progress`: Task is currently executing -- `completed`: Task finished successfully -- `failed`: Task failed with error - -## Log Levels -- `info`: General information -- `warning`: Warning messages -- `error`: Error messages - -## Typical Workflow Sequence -1. Create a project -2. Start parsing documents -3. Process chunks -4. Generate QA pairs -5. Validate results -6. Run evaluation -7. Monitor tasks through the task endpoint - -## Response Formats - -All successful responses return either a Project or Task object. Error responses include appropriate HTTP status codes and error messages. diff --git a/api/docs/openapi.js b/api/docs/openapi.js deleted file mode 100644 index 0abd153bc..000000000 --- a/api/docs/openapi.js +++ /dev/null @@ -1,1179 +0,0 @@ -var spec = { - "openapi": "3.0.0", - "info": { - "title": "AutoRAG API", - "description": "API for AutoRAG with Preparation and Run workflow", - "version": "1.0.1" - }, - "components": { - "schemas": { - "Project": { - "type": "object", - "properties": { - "id": { - "type": "string", - "example": "proj_123" - }, - "name": { - "type": "string" - }, - "description": { - "type": "string" - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "status": { - "type": "string", - "enum": [ - "active", - "archived" - ] - } - } - }, - "TrialConfig": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "project_id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "config_yaml": { - "type": "string" - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "is_default": { - "type": "boolean" - }, - "metadata": { - "type": "object" - } - } - }, - "Task": { - "type": "object", - "required": [ - "id", - "project_id", - "status", - "type" - ], - "properties": { - "id": { - "type": "string", - "description": "The task id" - }, - "project_id": { - "type": "string" - }, - "trial_id": { - "type": "string" - }, - "name": { - "type": "string", - "description": "The name of the task" - }, - "config_yaml": { - "type": "object", - "description": "YAML configuration. Format is dictionary, not path of the YAML file." - }, - "status": { - "type": "string", - "enum": [ - "not_started", - "in_progress", - "completed", - "failed" - ] - }, - "error_message": { - "type": "string", - "description": "Error message if the task failed" - }, - "type": { - "type": "string", - "enum": [ - "parse", - "chunk", - "qa", - "validate", - "evaluate" - ], - "description": "Type of the task - preparation tasks (parse, chunk, qa) or run tasks (validate, evaluate)" - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "save_path": { - "type": "string", - "description": "Path where the task results are saved. It will be directory or file." - } - } - }, - "Trial": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "project_id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "preparation_id": { - "type": "string", - "description": "Reference to completed preparation data" - }, - "config_yaml": { - "type": "string", - "description": "YAML configuration for trial" - }, - "status": { - "type": "string", - "enum": [ - "not_started", - "in_progress", - "completed", - "failed" - ] - }, - "created_at": { - "type": "string", - "format": "date-time" - } - } - }, - "Run": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "trial_id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": [ - "validation", - "eval" - ] - }, - "status": { - "type": "string", - "enum": [ - "not_started", - "in_progress", - "completed", - "failed" - ] - }, - "result": { - "type": "object" - }, - "created_at": { - "type": "string", - "format": "date-time" - } - } - } - } - }, - "paths": { - "/projects": { - "post": { - "security": [ - { - "bearerAuth": [] - } - ], - "summary": "Create a new project", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "name" - ], - "properties": { - "name": { - "type": "string" - }, - "description": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Project created successfully", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Project" - } - } - } - }, - "400": { - "description": "Project name already exists" - }, - "401": { - "description": "Unauthorized - Invalid or missing token" - }, - "403": { - "description": "Forbidden - Token does not have sufficient permissions" - } - } - }, - "get": { - "summary": "List all projects", - "parameters": [ - { - "in": "query", - "name": "page", - "schema": { - "type": "integer", - "default": 1 - } - }, - { - "in": "query", - "name": "limit", - "schema": { - "type": "integer", - "default": 10 - } - }, - { - "in": "query", - "name": "status", - "schema": { - "type": "string", - "enum": [ - "active", - "archived" - ] - } - } - ], - "responses": { - "200": { - "description": "List of projects", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "total": { - "type": "integer" - }, - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Project" - } - } - } - } - } - } - } - } - } - }, - "/projects/{project_id}/trials": { - "get": { - "summary": "Get list trials", - "parameters": [ - { - "name": "project_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "page", - "schema": { - "type": "integer", - "default": 1 - } - }, - { - "in": "query", - "name": "limit", - "schema": { - "type": "integer", - "default": 10 - } - }, - { - "in": "query", - "name": "status", - "schema": { - "type": "string", - "enum": [ - "active", - "archived" - ] - } - } - ], - "responses": { - "200": { - "description": "List of trials", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "total": { - "type": "integer" - }, - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Trial" - } - } - } - } - } - } - } - } - }, - "post": { - "summary": "Creat trial", - "parameters": [ - { - "name": "project_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "glob_path", - "name" - ], - "properties": { - "glob_path": { - "type": "string", - "description": "Path pattern to match files" - }, - "name": { - "type": "string", - "description": "Name for this preparation task" - }, - "config": { - "type": "object", - "properties": { - "parse": { - "type": "object" - } - } - } - } - } - } - } - }, - "responses": { - "202": { - "description": "Parsing started", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Task" - } - } - } - } - } - } - }, - "/projects/{project_id}/trials/{trial_id}/parse": { - "post": { - "summary": "Start parsing preparation", - "parameters": [ - { - "name": "project_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "trial_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "glob_path", - "name" - ], - "properties": { - "glob_path": { - "type": "string", - "description": "Path pattern to match files" - }, - "name": { - "type": "string", - "description": "Name for this preparation task" - }, - "config": { - "type": "object", - "properties": { - "parse": { - "type": "object" - } - } - } - } - } - } - } - }, - "responses": { - "202": { - "description": "Parsing started", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Task" - } - } - } - } - } - } - }, - "/projects/{project_id}/trials/{trial_id}/chunk": { - "post": { - "summary": "Start chunking preparation", - "parameters": [ - { - "name": "project_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "trial_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "trial_id", - "name", - "config" - ], - "properties": { - "trial_id": { - "type": "string", - "description": "Trial ID from parsing step" - }, - "name": { - "type": "string" - }, - "config": { - "type": "object", - "properties": { - "chunk": { - "type": "object" - } - } - } - } - } - } - } - }, - "responses": { - "202": { - "description": "Chunking started", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Task" - } - } - } - } - } - } - }, - "/projects/{project_id}/trials/{trial_id}/qa": { - "post": { - "summary": "Start QA generation preparation", - "parameters": [ - { - "name": "project_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "trial_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "trial_id", - "name", - "qa_num", - "preset", - "llm_config" - ], - "properties": { - "trial_id": { - "type": "string", - "description": "Trial ID from chunking step" - }, - "name": { - "type": "string" - }, - "qa_num": { - "type": "integer" - }, - "preset": { - "type": "string", - "enum": [ - "basic", - "simple", - "advanced" - ] - }, - "llm_config": { - "type": "object", - "properties": { - "llm_name": { - "type": "string" - }, - "llm_params": { - "type": "object" - } - } - } - } - } - } - } - }, - "responses": { - "202": { - "description": "QA generation started", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Task" - } - } - } - } - } - } - }, - "/projects/{project_id}/trials/{trial_id}/config": { - "get": { - "summary": "Get config of trial", - "parameters": [ - { - "name": "project_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "trial_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "202": { - "description": "Parsing started", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TrialConfig" - } - } - } - } - } - }, - "post": { - "summary": "Set config of trial", - "parameters": [ - { - "name": "project_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "trial_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "glob_path", - "name" - ], - "properties": { - "glob_path": { - "type": "string", - "description": "Path pattern to match files" - }, - "name": { - "type": "string", - "description": "Name for this preparation task" - }, - "config": { - "type": "object", - "properties": { - "parse": { - "type": "object" - } - } - } - } - } - } - } - }, - "responses": { - "202": { - "description": "Parsing started", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TrialConfig" - } - } - } - } - } - } - }, - "/projects/{project_id}/trials/{trial_id}/clone": { - "post": { - "summary": "Clone validation", - "parameters": [ - { - "name": "project_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "trial_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "config", - "qa_path", - "corpus_path" - ], - "properties": { - "config": { - "type": "object", - "description": "YAML configuration. Format is dictionary, not path of the YAML file" - }, - "qa_path": { - "type": "string", - "description": "Path to the QA data" - }, - "corpus_path": { - "type": "string", - "description": "Path to the corpus data" - } - } - } - } - } - }, - "responses": { - "202": { - "description": "Validation started", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Task" - } - } - } - } - } - } - }, - "/projects/{project_id}/trials/{trial_id}/validate": { - "post": { - "summary": "Start validation", - "parameters": [ - { - "name": "project_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "trial_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "config", - "qa_path", - "corpus_path" - ], - "properties": { - "config": { - "type": "object", - "description": "YAML configuration. Format is dictionary, not path of the YAML file" - }, - "qa_path": { - "type": "string", - "description": "Path to the QA data" - }, - "corpus_path": { - "type": "string", - "description": "Path to the corpus data" - } - } - } - } - } - }, - "responses": { - "202": { - "description": "Validation started", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Task" - } - } - } - } - } - } - }, - "/projects/{project_id}/trials/{trial_id}/evaluate": { - "post": { - "summary": "Start evaluation", - "parameters": [ - { - "name": "project_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "trial_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "config_yaml", - "qa_path", - "corpus_path" - ], - "properties": { - "config_yaml": { - "type": "object", - "description": "YAML configuration. Format is dictionary, not path of the YAML file" - }, - "skip_validation": { - "type": "boolean", - "description": "Skip validation step", - "default": true - }, - "qa_path": { - "type": "string", - "description": "Path to the QA data" - }, - "corpus_path": { - "type": "string", - "description": "Path to the corpus data" - } - } - } - } - } - }, - "responses": { - "202": { - "description": "Evaluation started", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/Task" - }, - { - "type": "object", - "properties": { - "trial_id": { - "type": "string" - } - } - } - ] - } - } - } - } - } - } - }, - "/projects/{project_id}/trials/{trial_id}/report/open": { - "get": { - "summary": "Get preparation task or run status", - "parameters": [ - { - "name": "project_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "trial_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "202": { - "description": "Evaluation started", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/Task" - }, - { - "type": "object", - "properties": { - "trial_id": { - "type": "string" - } - } - } - ] - } - } - } - } - } - } - }, - "/projects/{project_id}/trials/{trial_id}/report/close": { - "get": { - "summary": "Get preparation task or run status", - "parameters": [ - { - "name": "project_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "trial_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "202": { - "description": "Evaluation started", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/Task" - }, - { - "type": "object", - "properties": { - "trial_id": { - "type": "string" - } - } - } - ] - } - } - } - } - } - } - }, - "/projects/{project_id}/trials/{trial_id}/chat/open": { - "get": { - "summary": "Get preparation task or run status", - "parameters": [ - { - "name": "project_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "trial_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "202": { - "description": "Evaluation started", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/Task" - }, - { - "type": "object", - "properties": { - "trial_id": { - "type": "string" - } - } - } - ] - } - } - } - } - } - } - }, - "/projects/{project_id}/trials/{trial_id}/chat/close": { - "get": { - "summary": "Get preparation task or run status", - "parameters": [ - { - "name": "project_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "trial_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "202": { - "description": "Evaluation started", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/Task" - }, - { - "type": "object", - "properties": { - "trial_id": { - "type": "string" - } - } - } - ] - } - } - } - } - } - } - }, - "/projects/{project_id}/tasks/{task_id}": { - "get": { - "summary": "Get preparation task or run status", - "parameters": [ - { - "name": "project_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "task_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Task status", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/Task" - } - ] - } - } - } - } - } - } - } - } -} diff --git a/api/docs/openapi.yaml b/api/docs/openapi.yaml deleted file mode 100644 index fe8d469cb..000000000 --- a/api/docs/openapi.yaml +++ /dev/null @@ -1,748 +0,0 @@ -openapi: 3.0.0 -info: - title: AutoRAG API - description: API for AutoRAG with Preparation and Run workflow - version: 1.0.1 - -components: - schemas: - Project: - type: object - properties: - id: - type: string - example: "proj_123" - name: - type: string - description: - type: string - created_at: - type: string - format: date-time - status: - type: string - enum: [active, archived] - - TrialConfig: - type: object - properties: - id: - type: string - project_id: - type: string - raw_path: - type: string - corpus_path: - type: string - qa_path: - type: string - config_path: - type: string - metadata: - type: object - Task: - type: object - required: - - id - - project_id - - status - - type - properties: - id: - type: string - description: The task id - project_id: - type: string - trial_id: - type: string - name: - type: string - description: The name of the task - config_yaml: - type: object - description: YAML configuration. Format is dictionary, not path of the YAML file. - status: - type: string - enum: [not_started, in_progress, completed, failed, terminated] - error_message: - type: string - description: Error message if the task failed - type: - type: string - enum: [parse, chunk, qa, validate, evaluate, dashboard, chat] - description: Type of the task - preparation tasks (parse, chunk, qa) or run tasks (validate, evaluate) - created_at: - type: string - format: date-time - save_path: - type: string - description: Path where the task results are saved. It will be directory or file. - Trial: - type: object - properties: - id: - type: string - project_id: - type: string - config: - $ref: '#/components/schemas/TrialConfig' - name: - type: string - status: - type: string - enum: [not_started, in_progress, completed, failed] - created_at: - type: string - format: date-time - - Run: - type: object - properties: - id: - type: string - trial_id: - type: string - type: - type: string - enum: [validation, eval] - status: - type: string - enum: [not_started, in_progress, completed, failed] - result: - type: object - created_at: - type: string - format: date-time - -paths: - - /projects: - post: - security: - - bearerAuth: [ ] - summary: Create a new project - requestBody: - required: true - content: - application/json: - schema: - type: object - required: - - name - properties: - name: - type: string - description: - type: string - responses: - '201': - description: Project created successfully - content: - application/json: - schema: - $ref: '#/components/schemas/Project' - '400': - description: Project name already exists - '401': - description: Unauthorized - Invalid or missing token - '403': - description: Forbidden - Token does not have sufficient permissions - get: - summary: List all projects - parameters: - - in: query - name: page - schema: - type: integer - default: 1 - - in: query - name: limit - schema: - type: integer - default: 10 - - in: query - name: status - schema: - type: string - enum: [active, archived] - responses: - '200': - description: List of projects - content: - application/json: - schema: - type: object - properties: - total: - type: integer - data: - type: array - items: - $ref: '#/components/schemas/Project' - - /projects/{project_id}/trials: - get: - summary: Get list trials - parameters: - - name: project_id - in: path - required: true - schema: - type: string - - in: query - name: page - schema: - type: integer - default: 1 - - in: query - name: limit - schema: - type: integer - default: 10 - - in: query - name: status - schema: - type: string - enum: [active, archived] - responses: - '200': - description: List of trials - content: - application/json: - schema: - type: object - properties: - total: - type: integer - data: - type: array - items: - $ref: '#/components/schemas/Trial' - post: - summary: Create trial - parameters: - - name: project_id - in: path - required: true - schema: - type: string - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - name: - type: string - description: Name for this preparation task - raw_path: - type: string - description: Parsed data to use in the trial - corpus_path: - type: string - description: Corpus data to use in the trial - qa_path: - type: string - description: QA data to use in the trial - config: - type: object - properties: - parse: - type: object - responses: - '202': - description: New Trial made - content: - application/json: - schema: - $ref: '#/components/schemas/Trial' - '409': - description: Duplicate trial name - '400': - description: Invalid request format - - /projects/{project_id}/upload: - post: - summary: Upload files to a project - operationId: uploadFiles - parameters: - - name: project_id - in: path - required: true - description: The ID of the project to which files are being uploaded. - schema: - type: string - requestBody: - required: true - content: - multipart/form-data: - schema: - type: object - properties: - files: - type: array - items: - type: string - format: binary - description: The files to upload. - responses: - '200': - description: Files uploaded successfully. - content: - application/json: - schema: - type: object - properties: - message: - type: string - example: Files uploaded successfully. - filePaths: - type: array - items: - type: string - example: /path/to/uploaded/file.txt - '400': - description: Bad Request - No files were uploaded. - content: - application/json: - schema: - type: object - properties: - error: - type: string - example: No files were uploaded. - '500': - description: Internal Server Error - An error occurred while uploading files. - content: - application/json: - schema: - type: object - properties: - error: - type: string - - /projects/{project_id}/trials/{trial_id}/parse: - post: - summary: Start parsing preparation - parameters: - - name: project_id - in: path - required: true - schema: - type: string - - name: trial_id - in: path - required: true - schema: - type: string - requestBody: - required: true - content: - application/json: - schema: - type: object - required: - - name - - config - properties: - name: - type: string - description: Name for this preparation task - config: - type: object - description: The parse configuration. Must be dict. "parse.yaml" file. - properties: - parse: - type: object - responses: - '202': - description: Parsing started - content: - application/json: - schema: - $ref: '#/components/schemas/Task' - - /projects/{project_id}/trials/{trial_id}/chunk: - post: - summary: Start chunking preparation - parameters: - - name: project_id - in: path - required: true - schema: - type: string - - name: trial_id - in: path - required: true - schema: - type: string - requestBody: - required: true - content: - application/json: - schema: - type: object - required: - - name - - config - properties: - name: - type: string - config: - type: object - properties: - chunk: - type: object - responses: - '202': - description: Chunking started - content: - application/json: - schema: - $ref: '#/components/schemas/Task' - - /projects/{project_id}/trials/{trial_id}/qa: - post: - summary: Start QA generation preparation - parameters: - - name: project_id - in: path - required: true - schema: - type: string - - name: trial_id - in: path - required: true - schema: - type: string - requestBody: - required: true - content: - application/json: - schema: - type: object - required: - - name - - qa_num - - preset - - llm_config - properties: - name: - type: string - qa_num: - type: integer - preset: - type: string - enum: [basic, simple, advanced] - lang: - type: string - enum: [en, ko, ja] - default: en - llm_config: - type: object - properties: - llm_name: - type: string - llm_params: - type: object - responses: - '202': - description: QA generation started - content: - application/json: - schema: - $ref: '#/components/schemas/Task' - - /projects/{project_id}/trials/{trial_id}/config: - get: - summary: Get config of trial - parameters: - - name: project_id - in: path - required: true - schema: - type: string - - name: trial_id - in: path - required: true - schema: - type: string - - responses: - '200': - description: Trial config returned - content: - application/json: - schema: - $ref: '#/components/schemas/TrialConfig' - post: - summary: Set config of trial - parameters: - - name: project_id - in: path - required: true - schema: - type: string - - name: trial_id - in: path - required: true - schema: - type: string - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - raw_path: - type: string - corpus_path: - type: string - qa_path: - type: string - config: - type: object - metadata: - type: object - responses: - '201': - description: Trial modified - content: - application/json: - schema: - $ref: '#/components/schemas/TrialConfig' - - - /projects/{project_id}/trials/{trial_id}/clone: - post: - summary: Clone validation - parameters: - - name: project_id - in: path - required: true - schema: - type: string - - name: trial_id - in: path - required: true - schema: - type: string - requestBody: - required: true - content: - application/json: - schema: - type: object - required: - - config - - qa_path - - corpus_path - properties: - config: - type: object - description: YAML configuration. Format is dictionary, not path of the YAML file - qa_path: - type: string - description: Path to the QA data - corpus_path: - type: string - description: Path to the corpus data - - - - - - responses: - '202': - description: Validation started - content: - application/json: - schema: - $ref: '#/components/schemas/Task' - /projects/{project_id}/trials/{trial_id}/validate: - post: - summary: Start validation - parameters: - - name: project_id - in: path - required: true - schema: - type: string - - name: trial_id - in: path - required: true - schema: - type: string - - responses: - '202': - description: Validation started - content: - application/json: - schema: - $ref: '#/components/schemas/Task' - - /projects/{project_id}/trials/{trial_id}/evaluate: - post: - summary: Start evaluation - parameters: - - name: project_id - in: path - required: true - schema: - type: string - - name: trial_id - in: path - required: true - schema: - type: string - - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - skip_validation: - type: boolean - default: false - full_ingest: - type: boolean - default: true - responses: - '202': - description: Evaluation started - content: - application/json: - schema: - $ref: '#/components/schemas/Task' - '409': - description: Duplicate evaluation request. - content: - application/json: - schema: - type: object - properties: - trial_dir: - type: string - description: Directory where the duplicated evaluation results are saved. Access here to get the results. - error: - type: string - - /projects/{project_id}/trials/{trial_id}/report/open: - get: - summary: Get preparation task or run status - parameters: - - name: project_id - in: path - required: true - schema: - type: string - - name: trial_id - in: path - required: true - schema: - type: string - responses: - '202': - description: Report open - content: - application/json: - schema: - allOf: - - $ref: '#/components/schemas/Task' - - type: object - properties: - trial_id: - type: string - - /projects/{project_id}/trials/{trial_id}/report/close: - get: - summary: Get preparation task or run status - parameters: - - name: project_id - in: path - required: true - schema: - type: string - - name: trial_id - in: path - required: true - schema: - type: string - responses: - '200': - description: Report closed - content: - application/json: - schema: - $ref: '#/components/schemas/Task' - - /projects/{project_id}/trials/{trial_id}/chat/open: - get: - summary: Get preparation task or run status - parameters: - - name: project_id - in: path - required: true - schema: - type: string - - name: trial_id - in: path - required: true - schema: - type: string - responses: - '202': - description: Chat open. The port is 8501. - content: - application/json: - schema: - $ref: '#/components/schemas/Task' - - /projects/{project_id}/trials/{trial_id}/chat/close: - get: - summary: Get preparation task or run status - parameters: - - name: project_id - in: path - required: true - schema: - type: string - - name: trial_id - in: path - required: true - schema: - type: string - responses: - '200': - description: Chat closed - content: - application/json: - schema: - $ref: '#/components/schemas/Task' - /projects/{project_id}/tasks/{task_id}: - get: - summary: Get preparation task or run status - parameters: - - name: project_id - in: path - required: true - schema: - type: string - - name: task_id - in: path - required: true - schema: - type: string - responses: - '200': - description: Task status - content: - application/json: - schema: - oneOf: - - $ref: '#/components/schemas/Task' diff --git a/api/docs/openapi3_0.json b/api/docs/openapi3_0.json deleted file mode 100644 index 0c76e85e4..000000000 --- a/api/docs/openapi3_0.json +++ /dev/null @@ -1,1179 +0,0 @@ -{ - "openapi": "3.0.0", - "info": { - "title": "AutoRAG API", - "description": "API for AutoRAG with Preparation and Run workflow", - "version": "1.0.1" - }, - "components": { - "schemas": { - "Project": { - "type": "object", - "properties": { - "id": { - "type": "string", - "example": "proj_123" - }, - "name": { - "type": "string" - }, - "description": { - "type": "string" - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "status": { - "type": "string", - "enum": [ - "active", - "archived" - ] - } - } - }, - "TrialConfig": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "project_id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "config_yaml": { - "type": "string" - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "is_default": { - "type": "boolean" - }, - "metadata": { - "type": "object" - } - } - }, - "Task": { - "type": "object", - "required": [ - "id", - "project_id", - "status", - "type" - ], - "properties": { - "id": { - "type": "string", - "description": "The task id" - }, - "project_id": { - "type": "string" - }, - "trial_id": { - "type": "string" - }, - "name": { - "type": "string", - "description": "The name of the task" - }, - "config_yaml": { - "type": "object", - "description": "YAML configuration. Format is dictionary, not path of the YAML file." - }, - "status": { - "type": "string", - "enum": [ - "not_started", - "in_progress", - "completed", - "failed" - ] - }, - "error_message": { - "type": "string", - "description": "Error message if the task failed" - }, - "type": { - "type": "string", - "enum": [ - "parse", - "chunk", - "qa", - "validate", - "evaluate" - ], - "description": "Type of the task - preparation tasks (parse, chunk, qa) or run tasks (validate, evaluate)" - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "save_path": { - "type": "string", - "description": "Path where the task results are saved. It will be directory or file." - } - } - }, - "Trial": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "project_id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "preparation_id": { - "type": "string", - "description": "Reference to completed preparation data" - }, - "config_yaml": { - "type": "string", - "description": "YAML configuration for trial" - }, - "status": { - "type": "string", - "enum": [ - "not_started", - "in_progress", - "completed", - "failed" - ] - }, - "created_at": { - "type": "string", - "format": "date-time" - } - } - }, - "Run": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "trial_id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": [ - "validation", - "eval" - ] - }, - "status": { - "type": "string", - "enum": [ - "not_started", - "in_progress", - "completed", - "failed" - ] - }, - "result": { - "type": "object" - }, - "created_at": { - "type": "string", - "format": "date-time" - } - } - } - } - }, - "paths": { - "/projects": { - "post": { - "security": [ - { - "bearerAuth": [] - } - ], - "summary": "Create a new project", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "name" - ], - "properties": { - "name": { - "type": "string" - }, - "description": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Project created successfully", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Project" - } - } - } - }, - "400": { - "description": "Project name already exists" - }, - "401": { - "description": "Unauthorized - Invalid or missing token" - }, - "403": { - "description": "Forbidden - Token does not have sufficient permissions" - } - } - }, - "get": { - "summary": "List all projects", - "parameters": [ - { - "in": "query", - "name": "page", - "schema": { - "type": "integer", - "default": 1 - } - }, - { - "in": "query", - "name": "limit", - "schema": { - "type": "integer", - "default": 10 - } - }, - { - "in": "query", - "name": "status", - "schema": { - "type": "string", - "enum": [ - "active", - "archived" - ] - } - } - ], - "responses": { - "200": { - "description": "List of projects", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "total": { - "type": "integer" - }, - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Project" - } - } - } - } - } - } - } - } - } - }, - "/projects/{project_id}/trials": { - "get": { - "summary": "Get list trials", - "parameters": [ - { - "name": "project_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "page", - "schema": { - "type": "integer", - "default": 1 - } - }, - { - "in": "query", - "name": "limit", - "schema": { - "type": "integer", - "default": 10 - } - }, - { - "in": "query", - "name": "status", - "schema": { - "type": "string", - "enum": [ - "active", - "archived" - ] - } - } - ], - "responses": { - "200": { - "description": "List of trials", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "total": { - "type": "integer" - }, - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Trial" - } - } - } - } - } - } - } - } - }, - "post": { - "summary": "Creat trial", - "parameters": [ - { - "name": "project_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "glob_path", - "name" - ], - "properties": { - "glob_path": { - "type": "string", - "description": "Path pattern to match files" - }, - "name": { - "type": "string", - "description": "Name for this preparation task" - }, - "config": { - "type": "object", - "properties": { - "parse": { - "type": "object" - } - } - } - } - } - } - } - }, - "responses": { - "202": { - "description": "Parsing started", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Task" - } - } - } - } - } - } - }, - "/projects/{project_id}/trials/{trial_id}/parse": { - "post": { - "summary": "Start parsing preparation", - "parameters": [ - { - "name": "project_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "trial_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "glob_path", - "name" - ], - "properties": { - "glob_path": { - "type": "string", - "description": "Path pattern to match files" - }, - "name": { - "type": "string", - "description": "Name for this preparation task" - }, - "config": { - "type": "object", - "properties": { - "parse": { - "type": "object" - } - } - } - } - } - } - } - }, - "responses": { - "202": { - "description": "Parsing started", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Task" - } - } - } - } - } - } - }, - "/projects/{project_id}/trials/{trial_id}/chunk": { - "post": { - "summary": "Start chunking preparation", - "parameters": [ - { - "name": "project_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "trial_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "trial_id", - "name", - "config" - ], - "properties": { - "trial_id": { - "type": "string", - "description": "Trial ID from parsing step" - }, - "name": { - "type": "string" - }, - "config": { - "type": "object", - "properties": { - "chunk": { - "type": "object" - } - } - } - } - } - } - } - }, - "responses": { - "202": { - "description": "Chunking started", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Task" - } - } - } - } - } - } - }, - "/projects/{project_id}/trials/{trial_id}/qa": { - "post": { - "summary": "Start QA generation preparation", - "parameters": [ - { - "name": "project_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "trial_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "trial_id", - "name", - "qa_num", - "preset", - "llm_config" - ], - "properties": { - "trial_id": { - "type": "string", - "description": "Trial ID from chunking step" - }, - "name": { - "type": "string" - }, - "qa_num": { - "type": "integer" - }, - "preset": { - "type": "string", - "enum": [ - "basic", - "simple", - "advanced" - ] - }, - "llm_config": { - "type": "object", - "properties": { - "llm_name": { - "type": "string" - }, - "llm_params": { - "type": "object" - } - } - } - } - } - } - } - }, - "responses": { - "202": { - "description": "QA generation started", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Task" - } - } - } - } - } - } - }, - "/projects/{project_id}/trials/{trial_id}/config": { - "get": { - "summary": "Get config of trial", - "parameters": [ - { - "name": "project_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "trial_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "202": { - "description": "Parsing started", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TrialConfig" - } - } - } - } - } - }, - "post": { - "summary": "Set config of trial", - "parameters": [ - { - "name": "project_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "trial_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "glob_path", - "name" - ], - "properties": { - "glob_path": { - "type": "string", - "description": "Path pattern to match files" - }, - "name": { - "type": "string", - "description": "Name for this preparation task" - }, - "config": { - "type": "object", - "properties": { - "parse": { - "type": "object" - } - } - } - } - } - } - } - }, - "responses": { - "202": { - "description": "Parsing started", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TrialConfig" - } - } - } - } - } - } - }, - "/projects/{project_id}/trials/{trial_id}/clone": { - "post": { - "summary": "Clone validation", - "parameters": [ - { - "name": "project_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "trial_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "config", - "qa_path", - "corpus_path" - ], - "properties": { - "config": { - "type": "object", - "description": "YAML configuration. Format is dictionary, not path of the YAML file" - }, - "qa_path": { - "type": "string", - "description": "Path to the QA data" - }, - "corpus_path": { - "type": "string", - "description": "Path to the corpus data" - } - } - } - } - } - }, - "responses": { - "202": { - "description": "Validation started", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Task" - } - } - } - } - } - } - }, - "/projects/{project_id}/trials/{trial_id}/validate": { - "post": { - "summary": "Start validation", - "parameters": [ - { - "name": "project_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "trial_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "config", - "qa_path", - "corpus_path" - ], - "properties": { - "config": { - "type": "object", - "description": "YAML configuration. Format is dictionary, not path of the YAML file" - }, - "qa_path": { - "type": "string", - "description": "Path to the QA data" - }, - "corpus_path": { - "type": "string", - "description": "Path to the corpus data" - } - } - } - } - } - }, - "responses": { - "202": { - "description": "Validation started", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Task" - } - } - } - } - } - } - }, - "/projects/{project_id}/trials/{trial_id}/evaluate": { - "post": { - "summary": "Start evaluation", - "parameters": [ - { - "name": "project_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "trial_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "config_yaml", - "qa_path", - "corpus_path" - ], - "properties": { - "config_yaml": { - "type": "object", - "description": "YAML configuration. Format is dictionary, not path of the YAML file" - }, - "skip_validation": { - "type": "boolean", - "description": "Skip validation step", - "default": true - }, - "qa_path": { - "type": "string", - "description": "Path to the QA data" - }, - "corpus_path": { - "type": "string", - "description": "Path to the corpus data" - } - } - } - } - } - }, - "responses": { - "202": { - "description": "Evaluation started", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/Task" - }, - { - "type": "object", - "properties": { - "trial_id": { - "type": "string" - } - } - } - ] - } - } - } - } - } - } - }, - "/projects/{project_id}/trials/{trial_id}/report/open": { - "get": { - "summary": "Get preparation task or run status", - "parameters": [ - { - "name": "project_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "trial_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "202": { - "description": "Evaluation started", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/Task" - }, - { - "type": "object", - "properties": { - "trial_id": { - "type": "string" - } - } - } - ] - } - } - } - } - } - } - }, - "/projects/{project_id}/trials/{trial_id}/report/close": { - "get": { - "summary": "Get preparation task or run status", - "parameters": [ - { - "name": "project_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "trial_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "202": { - "description": "Evaluation started", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/Task" - }, - { - "type": "object", - "properties": { - "trial_id": { - "type": "string" - } - } - } - ] - } - } - } - } - } - } - }, - "/projects/{project_id}/trials/{trial_id}/chat/open": { - "get": { - "summary": "Get preparation task or run status", - "parameters": [ - { - "name": "project_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "trial_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "202": { - "description": "Evaluation started", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/Task" - }, - { - "type": "object", - "properties": { - "trial_id": { - "type": "string" - } - } - } - ] - } - } - } - } - } - } - }, - "/projects/{project_id}/trials/{trial_id}/chat/close": { - "get": { - "summary": "Get preparation task or run status", - "parameters": [ - { - "name": "project_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "trial_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "202": { - "description": "Evaluation started", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/Task" - }, - { - "type": "object", - "properties": { - "trial_id": { - "type": "string" - } - } - } - ] - } - } - } - } - } - } - }, - "/projects/{project_id}/tasks/{task_id}": { - "get": { - "summary": "Get preparation task or run status", - "parameters": [ - { - "name": "project_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "task_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Task status", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/Task" - } - ] - } - } - } - } - } - } - } - } -} diff --git a/api/docs/swagger-api.html b/api/docs/swagger-api.html deleted file mode 100644 index f5886e3b1..000000000 --- a/api/docs/swagger-api.html +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - -
- - - - - - From 55249eafec3edbf81d83aa77816dab1fc7ecda6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=8D=EC=8A=B9=EC=9A=B0?= Date: Tue, 19 Nov 2024 21:17:39 +0900 Subject: [PATCH 21/55] =?UTF-8?q?=E2=9C=A8=20feat:=20Add=20description=20f?= =?UTF-8?q?or=20tutorial=5F1=20project?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../0.parquet | Bin 0 -> 18344 bytes .../chunk_config.yaml | 6 ++++++ .../summary.csv | 2 ++ ...g_8c42b9e6-490d-4971-bb9e-705b36b7a3a2.yaml | 3 +++ projects/tutorial_1/description.txt | 1 + projects/tutorial_1/evaluate_history.csv | 1 + .../0.parquet | Bin 0 -> 13968 bytes .../parse_config.yaml | 3 +++ .../summary.csv | 2 ++ ...c42b9e6-490d-4971-bb9e-705b36b7a3a2.parquet | Bin 0 -> 39418 bytes projects/tutorial_1/raw_data/baseball_1.pdf | Bin 0 -> 107748 bytes .../raw_data/korean_texts_two_page.pdf | Bin 0 -> 442018 bytes projects/tutorial_1/summary.csv | 2 ++ projects/tutorial_1/trial.json | 6 ++++++ projects/tutorial_1/trial_config.csv | 2 ++ 15 files changed, 28 insertions(+) create mode 100644 projects/tutorial_1/chunk/chunk_8c42b9e6-490d-4971-bb9e-705b36b7a3a2/0.parquet create mode 100644 projects/tutorial_1/chunk/chunk_8c42b9e6-490d-4971-bb9e-705b36b7a3a2/chunk_config.yaml create mode 100644 projects/tutorial_1/chunk/chunk_8c42b9e6-490d-4971-bb9e-705b36b7a3a2/summary.csv create mode 100644 projects/tutorial_1/configs/parse_config_8c42b9e6-490d-4971-bb9e-705b36b7a3a2.yaml create mode 100644 projects/tutorial_1/description.txt create mode 100644 projects/tutorial_1/evaluate_history.csv create mode 100644 projects/tutorial_1/parse/parse_8c42b9e6-490d-4971-bb9e-705b36b7a3a2/0.parquet create mode 100644 projects/tutorial_1/parse/parse_8c42b9e6-490d-4971-bb9e-705b36b7a3a2/parse_config.yaml create mode 100644 projects/tutorial_1/parse/parse_8c42b9e6-490d-4971-bb9e-705b36b7a3a2/summary.csv create mode 100644 projects/tutorial_1/qa/qa_8c42b9e6-490d-4971-bb9e-705b36b7a3a2.parquet create mode 100644 projects/tutorial_1/raw_data/baseball_1.pdf create mode 100644 projects/tutorial_1/raw_data/korean_texts_two_page.pdf create mode 100644 projects/tutorial_1/summary.csv create mode 100644 projects/tutorial_1/trial.json create mode 100644 projects/tutorial_1/trial_config.csv diff --git a/projects/tutorial_1/chunk/chunk_8c42b9e6-490d-4971-bb9e-705b36b7a3a2/0.parquet b/projects/tutorial_1/chunk/chunk_8c42b9e6-490d-4971-bb9e-705b36b7a3a2/0.parquet new file mode 100644 index 0000000000000000000000000000000000000000..adc380695f5b5c145fbca1b10542260438b4f187 GIT binary patch literal 18344 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 pTQLtkMzwV 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 S@x|BVACxo`p*8St(f%LPs2aHd literal 0 HcmV?d00001 diff --git a/projects/tutorial_1/parse/parse_8c42b9e6-490d-4971-bb9e-705b36b7a3a2/parse_config.yaml b/projects/tutorial_1/parse/parse_8c42b9e6-490d-4971-bb9e-705b36b7a3a2/parse_config.yaml new file mode 100644 index 000000000..9dab3cc6f --- /dev/null +++ b/projects/tutorial_1/parse/parse_8c42b9e6-490d-4971-bb9e-705b36b7a3a2/parse_config.yaml @@ -0,0 +1,3 @@ +modules: +- module_type: langchain_parse + parse_method: pdfminer diff --git a/projects/tutorial_1/parse/parse_8c42b9e6-490d-4971-bb9e-705b36b7a3a2/summary.csv b/projects/tutorial_1/parse/parse_8c42b9e6-490d-4971-bb9e-705b36b7a3a2/summary.csv new file mode 100644 index 000000000..e72d930e2 --- /dev/null +++ b/projects/tutorial_1/parse/parse_8c42b9e6-490d-4971-bb9e-705b36b7a3a2/summary.csv @@ -0,0 +1,2 @@ +filename,module_name,module_params,execution_time +0.parquet,langchain_parse,{'parse_method': 'pdfminer'},5.022722005844116 diff --git a/projects/tutorial_1/qa/qa_8c42b9e6-490d-4971-bb9e-705b36b7a3a2.parquet b/projects/tutorial_1/qa/qa_8c42b9e6-490d-4971-bb9e-705b36b7a3a2.parquet new file mode 100644 index 0000000000000000000000000000000000000000..c61ca6908e5c7556e23bcb00e846f9d4d1cf3c10 GIT binary patch literal 39418 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

pJuyx6?eWB-cus2K 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

evcUNn3K!j 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

` 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 Date: Tue, 19 Nov 2024 21:17:51 +0900 Subject: [PATCH 22/55] =?UTF-8?q?=F0=9F=94=A7=20chore:=20update=20.gitigno?= =?UTF-8?q?re=20to=20exclude=20.DS=5FStore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 82f927558..ab74e15bc 100644 --- a/.gitignore +++ b/.gitignore @@ -160,3 +160,4 @@ cython_debug/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ +.DS_Store From 191e499106e98002d6529f3d56abe09850ed2665 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=8D=EC=8A=B9=EC=9A=B0?= Date: Tue, 19 Nov 2024 21:18:42 +0900 Subject: [PATCH 23/55] =?UTF-8?q?=F0=9F=9A=91=20fix:=20Update=20project=20?= =?UTF-8?q?naming=20convention=20in=20README=20and=20adjust=20requirements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit updates the project naming convention in the README file from "AutoRAG API Server" to "AutoRAG-API" for consistency. Additionally, it modifies the version requirement in the `requirements.txt` file for AutoRAG to be greater than or equal to 0.3.8 to ensure compatibility with the latest features. --- api/README.md | 14 +- api/app.py | 1759 +++++++++++++++++++++++------------------- api/requirements.txt | 2 +- 3 files changed, 965 insertions(+), 810 deletions(-) diff --git a/api/README.md b/api/README.md index 31b96848d..9b29d17fc 100644 --- a/api/README.md +++ b/api/README.md @@ -1,14 +1,2 @@ -# AutoRAG API Server - +# AutoRAG-API Quart API server for running AutoRAG and various data creations. - -## Installation - -```bash -pip install -r requirements.txt -``` - -And run -```bash -python3 app.py -``` diff --git a/api/app.py b/api/app.py index a02d1b214..b708b9752 100644 --- a/api/app.py +++ b/api/app.py @@ -7,56 +7,57 @@ from datetime import datetime from pathlib import Path from typing import Callable, Dict, Optional +import os +from typing import List, Optional +from quart import jsonify, request +from pydantic import BaseModel +import aiofiles +import aiofiles.os import pandas as pd import yaml from pydantic import ValidationError from quart import Quart, request, jsonify from quart_cors import cors # Import quart_cors to enable CORS -from quart_uploads import UploadSet, configure_uploads - from src.auth import require_auth from src.evaluate_history import get_new_trial_dir -from src.run import ( - run_parser_start_parsing, - run_chunker_start_chunking, - run_qa_creation, - run_start_trial, - run_validate, - run_dashboard, - run_chat, -) -from src.schema import ( - ChunkRequest, - ParseRequest, - EnvVariableRequest, - QACreationRequest, - Project, - Task, - Status, - TaskType, - TrialCreateRequest, - Trial, - TrialConfig, -) +from src.run import run_parser_start_parsing, run_chunker_start_chunking, run_qa_creation, run_start_trial, \ + run_validate, run_dashboard, run_chat +from src.schema import ChunkRequest, ParseRequest, EnvVariableRequest, QACreationRequest, Project, Task, Status, \ + TaskType, TrialCreateRequest, Trial, TrialConfig import nest_asyncio from src.trial_config import PandasTrialDB from src.validate import project_exists, trial_exists +from werkzeug.utils import secure_filename + +import subprocess + +import logging nest_asyncio.apply() app = Quart(__name__) -app = cors( - app, - allow_origin=["http://localhost:3000"], # 구체적인 origin 지정 + +# 로깅 설정 +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' +) +logger = logging.getLogger(__name__) + +# CORS 설정 +app = cors(app, + allow_origin=["http://localhost:3000"], allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], allow_headers=["Content-Type", "Authorization"], allow_credentials=True, - max_age=3600, + max_age=3600 ) + print("CORS enabled for http://localhost:3000") # Global variables to manage tasks @@ -71,885 +72,1051 @@ # Function to create a task -async def create_task(task_id: str, task: Task, func: Callable, *args) -> None: - tasks[task_id] = { - "function": func, - "args": args, - "error": None, - "task": task, - } - await task_queue.put(task_id) - +async def create_task(task_id: str, task: Task, func, *args): + """비동기 작업을 생성하고 관리하는 함수""" + tasks[task_id] = { + "task": task, + "future": asyncio.create_task(run_background_task(task_id, func, *args)) + } + +async def run_background_task(task_id: str, func, *args): + """백그라운드 작업을 실행하는 함수""" + task_info = tasks[task_id] + task = task_info["task"] + + try: + loop = asyncio.get_event_loop() + logger.info(f"Executing {func.__name__} with args: {args}") + + def execute(): + return func(*args) # 인자를 그대로 언패킹하여 전달 + + result = await loop.run_in_executor(None, execute) + task.status = Status.COMPLETED + return result + except Exception as e: + logger.error(f"Task {task_id} failed with error: {func.__name__}({args}) {e}") + task.status = Status.FAILED + task.error = str(e) + raise async def task_runner(): - global current_task_id - loop = asyncio.get_running_loop() - executor = concurrent.futures.ProcessPoolExecutor() - try: - while True: - task_id = await task_queue.get() - async with lock: - current_task_id = task_id - tasks[task_id]["task"].status = Status.IN_PROGRESS - - try: - # Get function and arguments from task info - func = tasks[task_id]["function"] - args = tasks[task_id].get("args", ()) - - # Run the function in a separate process - future = loop.run_in_executor( - executor, - func, - *args, - ) - task_futures[task_id] = future - - await future - # Use future Results - if func.__name__ == run_dashboard.__name__: - tasks[task_id]["report_pid"] = future.result() - elif func.__name__ == run_chat.__name__: - tasks[task_id]["chat_pid"] = future.result() - - # Update status on completion - async with lock: - print(f"Task {task_id} is completed") - tasks[task_id]["task"].status = Status.COMPLETED - current_task_id = None - except asyncio.CancelledError: - tasks[task_id]["task"].status = Status.TERMINATED - print(f"Task {task_id} has been forcefully terminated.") - except Exception as e: - # Handle errors - async with lock: - tasks[task_id]["task"].status = Status.FAILED - tasks[task_id]["error"] = str(e) - current_task_id = None - print(f"Task {task_id} failed with error: {e}") - - finally: - task_queue.task_done() - task_futures.pop(task_id, None) - finally: - executor.shutdown() - + global current_task_id + loop = asyncio.get_running_loop() + executor = concurrent.futures.ProcessPoolExecutor() + try: + while True: + task_id = await task_queue.get() + async with lock: + current_task_id = task_id + tasks[task_id]['task'].status = Status.IN_PROGRESS + + try: + # Get function and arguments from task info + func = tasks[task_id]['function'] + args = tasks[task_id].get('args', ()) + + # Run the function in a separate process + future = loop.run_in_executor( + executor, + func, + *args, + ) + task_futures[task_id] = future + + await future + if func.__name__ == run_dashboard.__name__: + tasks[task_id]["report_pid"] = future.result() + elif func.__name__ == run_chat.__name__: + tasks[task_id]["chat_pid"] = future.result() + + # Update status on completion + async with lock: + print(f"Task {task_id} is completed") + tasks[task_id]['task'].status = Status.COMPLETED + current_task_id = None + except asyncio.CancelledError: + tasks[task_id]['task'].status = Status.TERMINATED + print(f"Task {task_id} has been forcefully terminated.") + except Exception as e: + # Handle errors + async with lock: + tasks[task_id]['task'].status = Status.FAILED + tasks[task_id]['error'] = str(e) + current_task_id = None + print(f"Task {task_id} failed with error: task_runner {e}") + print(e) + + finally: + task_queue.task_done() + task_futures.pop(task_id, None) + finally: + executor.shutdown() async def cancel_task(task_id: str) -> None: - async with lock: - future = task_futures.get(task_id) - if future and not future.done(): - try: - # Attempt to kill the associated process directly - future.cancel() - except Exception as e: - tasks[task_id]["task"].status = Status.FAILED - tasks[task_id]["error"] = f"Failed to terminate: {str(e)}" - print(f"Task {task_id} failed to terminate with error: {e}") - else: - print(f"Task {task_id} is not running or already completed.") - + async with lock: + future = task_futures.get(task_id) + if future and not future.done(): + try: + # Attempt to kill the associated process directly + future.cancel() + except Exception as e: + tasks[task_id]['task'].status = Status.FAILED + tasks[task_id]['error'] = f"Failed to terminate: {str(e)}" + print(f"Task {task_id} failed to terminate with error: {e}") + else: + print(f"Task {task_id} is not running or already completed.") @app.before_serving async def startup(): - # Start the background task when the app starts - app.add_background_task(task_runner) - + # Start the background task when the app starts + app.add_background_task(task_runner) # Project creation endpoint -@app.route("/projects", methods=["POST"]) +@app.route('/projects', methods=['POST']) @require_auth() async def create_project(): - data = await request.get_json() - - # Validate required fields - if not data or "name" not in data: - return jsonify({"error": "Name is required"}), 400 - - description = data.get("description", "") - - # Create a new project - new_project_dir = os.path.join(WORK_DIR, data["name"]) - if not os.path.exists(new_project_dir): - os.makedirs(new_project_dir) - os.makedirs(os.path.join(new_project_dir, "parse")) - os.makedirs(os.path.join(new_project_dir, "chunk")) - os.makedirs(os.path.join(new_project_dir, "qa")) - os.makedirs(os.path.join(new_project_dir, "project")) - os.makedirs(os.path.join(new_project_dir, "config")) - # Make trial_config.csv file - _ = PandasTrialDB(os.path.join(new_project_dir, "trial_config.csv")) - else: - return jsonify({"error": f'Project name already exists: {data["name"]}'}), 400 - - # save at 'description.txt' file - with open(os.path.join(new_project_dir, "description.txt"), "w") as f: - f.write(description) - - response = Project( - id=data["name"], - name=data["name"], - description=description, - created_at=datetime.now(), - status="active", - metadata={}, - ) - return jsonify(response.model_dump()), 201 + data = await request.get_json() + + # Validate required fields + if not data or 'name' not in data: + return jsonify({'error': 'Name is required'}), 400 + + description = data.get("description", "") + + # Create a new project + new_project_dir = os.path.join(WORK_DIR, data['name']) + if not os.path.exists(new_project_dir): + os.makedirs(new_project_dir) + os.makedirs(os.path.join(new_project_dir, "parse")) + os.makedirs(os.path.join(new_project_dir, "chunk")) + os.makedirs(os.path.join(new_project_dir, "qa")) + os.makedirs(os.path.join(new_project_dir, "project")) + os.makedirs(os.path.join(new_project_dir, "config")) + # Make trial_config.csv file + _ = PandasTrialDB(os.path.join(new_project_dir, "trial_config.csv")) + else: + return jsonify({ + 'error': f'Project name already exists: {data["name"]}' + }), 400 + + # save at 'description.txt' file + with open(os.path.join(new_project_dir, "description.txt"), "w") as f: + f.write(description) + + response = Project( + id=data["name"], + name=data["name"], + description=description, + created_at=datetime.now(), + status="active", + metadata={}, + ) + return jsonify(response.model_dump()), 201 async def get_project_directories(): - """Get all project directories from WORK_DIR.""" - directories = [] - - # List all directories in WORK_DIR - for item in Path(WORK_DIR).iterdir(): - if item.is_dir(): - directories.append( - { - "name": item.name, - "status": "active", # All projects are currently active - "path": str(item), - "last_modified_datetime": datetime.fromtimestamp( - item.stat().st_mtime - ), - "created_datetime": datetime.fromtimestamp(item.stat().st_ctime), - } - ) - - directories.sort(key=lambda x: x["last_modified_datetime"], reverse=True) - return directories - - -@app.route("/projects", methods=["GET"]) -async def list_projects(): - """List all projects with pagination. It returns the last modified projects first.""" - # Get query parameters with defaults - page = request.args.get("page", 1, type=int) - limit = request.args.get("limit", 10, type=int) - status = request.args.get("status", "active") - - # Validate pagination parameters - if page < 1: - page = 1 - if limit < 1: - limit = 10 - - # Get all projects - projects = await get_project_directories() - - # Filter by status if provided (though all are active) - if status: - projects = [p for p in projects if p["status"] == status] - - # Calculate pagination - total = len(projects) - start_idx = (page - 1) * limit - end_idx = start_idx + limit - - # Get paginated data - paginated_projects = projects[start_idx:end_idx] - - # Get descriptions from paginated data - def get_project_description(project_name): - description_path = os.path.join(WORK_DIR, project_name, "description.txt") - try: - with open(description_path, "r") as f: - return f.read() - except FileNotFoundError: - # 파일이 없으면 빈 description.txt 파일 생성 - with open(description_path, "w") as f: - f.write(f"## {project_name}") - return "" - - projects = [ - Project( - id=p["name"], - name=p["name"], - description=get_project_description(p["name"]), - created_at=p["created_datetime"], - status=p["status"], - metadata={}, - ) - for p in paginated_projects - ] + """Get all project directories from WORK_DIR.""" + directories = [] + + # List all directories in WORK_DIR + for item in Path(WORK_DIR).iterdir(): + if item.is_dir(): + directories.append({ + "name": item.name, + "status": "active", # All projects are currently active + "path": str(item), + "last_modified_datetime": datetime.fromtimestamp(item.stat().st_mtime), + "created_datetime": datetime.fromtimestamp(item.stat().st_ctime), + }) + + directories.sort(key=lambda x: x['last_modified_datetime'], reverse=True) + return directories - return jsonify( - { - "total": total, - "data": list(map(lambda p: p.model_dump(), projects)), - } - ), 200 + +@app.route('/projects', methods=['GET']) +async def list_projects(): + """List all projects with pagination. It returns the last modified projects first.""" + # Get query parameters with defaults + page = request.args.get('page', 1, type=int) + limit = request.args.get('limit', 10, type=int) + status = request.args.get('status', 'active') + + # Validate pagination parameters + if page < 1: + page = 1 + if limit < 1: + limit = 10 + + # Get all projects + projects = await get_project_directories() + + # Filter by status if provided (though all are active) + if status: + projects = [p for p in projects if p['status'] == status] + + # Calculate pagination + total = len(projects) + start_idx = (page - 1) * limit + end_idx = start_idx + limit + + # Get paginated data + paginated_projects = projects[start_idx:end_idx] + + # Get descriptions from paginated data + def get_project_description(project_name): + description_path = os.path.join(WORK_DIR, project_name, "description.txt") + try: + with open(description_path, "r") as f: + return f.read() + except FileNotFoundError: + # 파일이 없으면 빈 description.txt 파일 생성 + with open(description_path, "w") as f: + f.write(f"## {project_name}") + return "" + + projects = [Project( + id=p["name"], + name=p["name"], + description=get_project_description(p["name"]), + created_at=p["created_datetime"], + status=p["status"], + metadata={}, + ) for p in paginated_projects] + + return jsonify({ + "total": total, + "data": list(map(lambda p: p.model_dump(), projects)), + }), 200 @app.route("/projects//trials", methods=["GET"]) @project_exists(WORK_DIR) async def get_trial_lists(project_id: str): - trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") - trial_config_db = PandasTrialDB(trial_config_path) - trial_ids = trial_config_db.get_all_config_ids() - return jsonify( - { - "total": len(trial_ids), - "data": list( - map(lambda x: trial_config_db.get_trial(x).model_dump(), trial_ids) - ), - } + trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") + trial_config_db = PandasTrialDB(trial_config_path) + trial_ids = trial_config_db.get_all_trial_ids() + return jsonify({ + "total": len(trial_ids), + "data": list(map(lambda x: trial_config_db.get_trial(x).model_dump(), + trial_ids)), + }) + +class FileNode(BaseModel): + name: str + type: str # 'directory' or 'file' + children: Optional[List['FileNode']] = None + +FileNode.model_rebuild() + +async def scan_directory(path: str) -> FileNode: + """비동기적으로 디렉토리를 스캔하여 파일 트리 구조를 생성합니다.""" + basename = os.path.basename(path) + + # 파일인지 확인 + if os.path.isfile(path): + return FileNode( + name=basename, + type='file' + ) + + # 디렉토리인 경우 + children = [] + try: + # Convert scandir result to list for async iteration + entries = await aiofiles.os.scandir(path) + for item in entries: + item_path = os.path.join(path, item.name) + # 숨김 파일 제외 + if not item.name.startswith('.'): + children.append(await scan_directory(item_path)) + except PermissionError: + pass + + return FileNode( + name=basename, + type='directory', + children=sorted(children, key=lambda x: (x.type == 'file', x.name)) ) - @app.route("/projects//trials", methods=["POST"]) @project_exists(WORK_DIR) async def create_new_trial(project_id: str): - trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") - - data = await request.get_json() - try: - creation_request = TrialCreateRequest(**data) - except ValidationError as e: - return jsonify( - { - "error": f"Invalid request format : {e}", - } - ), 400 - - trial_id = str(uuid.uuid4()) - - request_dict = creation_request.model_dump() - if request_dict["config"] is not None: - config_path = os.path.join( - WORK_DIR, project_id, "config", f"{str(uuid.uuid4())}.yaml" - ) - with open(config_path, "w") as f: - yaml.safe_dump(request_dict["config"], f) - else: - config_path = None - request_dict["trial_id"] = trial_id - request_dict["project_id"] = project_id - request_dict["config_path"] = config_path - request_dict["metadata"] = {} - request_dict.pop("config") - name = request_dict.pop("name") - - new_trial_config = TrialConfig(**request_dict) - new_trial = Trial( - id=trial_id, + trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") + + data = await request.get_json() + try: + creation_request = TrialCreateRequest(**data) + except ValidationError as e: + return jsonify({ + "error": f"Invalid request format : {e}", + }), 400 + trial_id = str(uuid.uuid4()) + request_dict = creation_request.model_dump() + if request_dict["config"] is not None: + config_path = os.path.join(WORK_DIR, project_id, "config", f"{str(uuid.uuid4())}.yaml") + async with aiofiles.open(config_path, 'w') as f: + await f.write(yaml.safe_dump(request_dict["config"])) + else: + config_path = None + + request_dict["trial_id"] = trial_id + request_dict["project_id"] = project_id + request_dict["config_path"] = config_path + request_dict["metadata"] = {} + request_dict.pop("config") + name = request_dict.pop("name") + + new_trial_config = TrialConfig(**request_dict) + new_trial = Trial( + id=trial_id, project_id=project_id, config=new_trial_config, name=name, status=Status.NOT_STARTED, created_at=datetime.now(), - ) - trial_config_db = PandasTrialDB(trial_config_path) - trial_config_db.set_trial(new_trial) - return jsonify(new_trial.model_dump()), 202 + ) + trial_config_db = PandasTrialDB(trial_config_path) + trial_config_db.set_trial(new_trial) + return jsonify(new_trial.model_dump()), 202 -@app.route("/projects//upload", methods=["POST"]) +@app.route("/projects//trials/", methods=["GET"]) @project_exists(WORK_DIR) -async def upload_files(project_id: str): - # Setting upload - raw_data_path = os.path.join(WORK_DIR, project_id, "raw_data") - files = UploadSet() - files.default_dest = raw_data_path - configure_uploads(app, files) - try: - filename = await files.save((await request.files)["file"]) +@trial_exists(WORK_DIR) +async def get_trial(project_id: str, trial_id: str): + trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") + trial_config_db = PandasTrialDB(trial_config_path) + return jsonify(trial_config_db.get_trial(trial_id).model_dump()), 200 - if not filename: - return jsonify({"error": "No files were uploaded"}), 400 +@app.route("/projects//artifacts/files", methods=["GET"]) +@project_exists(WORK_DIR) +async def get_artifact_file(project_id: str): + """특정 파일의 내용을 비동기적으로 반환합니다.""" + file_path = request.args.get('path') + if not file_path: + return jsonify({ + "error": "File path is required" + }), 400 - return jsonify( - { - "message": "Files uploaded successfully", - "filePaths": os.path.join(raw_data_path, filename), - } - ), 200 + try: + full_path = os.path.join(WORK_DIR, project_id, file_path) + + # 경로 검증 (디렉토리 트래버설 방지) + if not os.path.normpath(full_path).startswith(os.path.normpath(os.path.join(WORK_DIR, project_id))): + return jsonify({ + "error": "Invalid file path" + }), 403 + + # 비동기로 파일 재 여부 확인 + if not await aiofiles.os.path.exists(full_path): + return jsonify({ + "error": "File not found" + }), 404 + + if not await aiofiles.os.path.isfile(full_path): + return jsonify({ + "error": "Path is not a file" + }), 400 + + # 파일 크기 체크 + stats = await aiofiles.os.stat(full_path) + if stats.st_size > 10 * 1024 * 1024: # 10MB 제한 + return jsonify({ + "error": "File too large" + }), 400 + + # 파일 확장자 체크 + _, ext = os.path.splitext(full_path) + allowed_extensions = {'.txt', '.yaml', '.yml', '.json', '.py', '.md'} + if ext.lower() not in allowed_extensions: + return jsonify({ + "error": "File type not supported" + }), 400 + + # 비동기로 파일 읽기 + async with aiofiles.open(full_path, 'r', encoding='utf-8') as f: + content = await f.read() + + return jsonify({ + "content": content, + "path": file_path, + "size": stats.st_size, + "last_modified": stats.st_mtime + }), 200 except Exception as e: - return jsonify( - {"error": f"An error occurred while uploading files: {str(e)}"} - ), 500 - + return jsonify({ + "error": f"Failed to read file: {str(e)}" + }), 500 -@app.route( - "/projects//trials//parse", methods=["POST"] -) +@app.route("/projects//upload", methods=["POST"]) +@project_exists(WORK_DIR) +@trial_exists(WORK_DIR) +async def upload_files(project_id: str, trial_id: str): + try: + # Get the files from the request + files = await request.files.getlist('files') + + if not files: + return jsonify({ + 'error': 'No files were uploaded' + }), 400 + + # Create upload directory if it doesn't exist + upload_dir = os.path.join(WORK_DIR, project_id, "uploads", trial_id) + os.makedirs(upload_dir, exist_ok=True) + + uploaded_paths = [] + + # Save each file + for file in files: + if file.filename: + # Secure the filename + filename = secure_filename(file.filename) + file_path = os.path.join(upload_dir, filename) + + # Save the file + await file.save(file_path) + uploaded_paths.append(file_path) + + return jsonify({ + 'message': 'Files uploaded successfully', + 'filePaths': uploaded_paths + }), 200 + + except Exception as e: + return jsonify({ + 'error': f'An error occurred while uploading files: {str(e)}' + }), 500 + +@app.route("/projects//trials//parse", methods=["POST"]) @project_exists(WORK_DIR) @trial_exists(WORK_DIR) async def start_parsing(project_id: str, trial_id: str): + yaml_path = None try: - # Get JSON data from request and validate with Pydantic data = await request.get_json() parse_request = ParseRequest(**data) - # Get the directory containing datasets - dataset_dir = os.path.join(WORK_DIR, project_id, "parse", parse_request.name) + # 프로젝트 디렉토리 구조 설정 + project_dir = os.path.join(WORK_DIR, project_id) + raw_data_path = os.path.join(project_dir, "raw_data") + dataset_dir = os.path.join(project_dir, "parse", parse_request.name) + config_dir = os.path.join(project_dir, "configs") + + logger.info(f"Project directory: {project_dir}") + logger.info(f"Raw data path: {raw_data_path}") + + # data_path_glob 생성 - raw_data 디렉토리 내의 모든 파일을 포함 + data_path_glob = os.path.join(raw_data_path, "*.pdf") + logger.info(f"Data path glob: {data_path_glob}") + + # 필요한 디렉토리 생성 + os.makedirs(config_dir, exist_ok=True) if not os.path.exists(dataset_dir): os.makedirs(dataset_dir) else: - return jsonify( - {"error": f"Parse dataset name already exists: {parse_request.name}"} - ), 400 + return jsonify({ + 'error': f'Parse dataset name already exists: {parse_request.name}' + }), 400 + + # 파일 검색 + files = [] + for root, _, filenames in os.walk(raw_data_path): + for filename in filenames: + if not filename.startswith('.'): + files.append(os.path.join(root, filename)) + + if not files: + return jsonify({ + 'error': f'No valid files found in {raw_data_path}. Please upload some files first.' + }), 400 + + logger.info(f"Found files: {files}") + + # YAML 설정 파일 생성 + yaml_config = { + "modules": [{ + "module_type": "langchain_parse", + "parse_method": "pdfminer", + }] + } + + yaml_path = os.path.join(config_dir, f"parse_config_{trial_id}.yaml") + with open(yaml_path, 'w', encoding='utf-8') as f: + yaml.safe_dump(yaml_config, f, encoding='utf-8', allow_unicode=True) - with tempfile.NamedTemporaryFile(suffix=".yaml", delete=False) as yaml_tempfile: - with open(yaml_tempfile.name, "w") as w: - yaml.safe_dump(parse_request.config, w) - yaml_path = yaml_tempfile.name + logger.info(f"Created config file at {yaml_path} with content: {yaml_config}") + # Task 생성 및 실행 task_id = str(uuid.uuid4()) - response = Task( + save_path = os.path.join(WORK_DIR, project_id, "parse", f"parse_{trial_id}") + task = Task( id=task_id, project_id=project_id, trial_id=trial_id, name=parse_request.name, - config_yaml=parse_request.config, status=Status.IN_PROGRESS, type=TaskType.PARSE, created_at=datetime.now(), - save_path=dataset_dir, - ) - await create_task( - task_id, - response, - run_parser_start_parsing, - os.path.join(WORK_DIR, project_id, "raw_data", "*.pdf"), - dataset_dir, - yaml_path, + save_path=save_path, ) - - # Update to trial - trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") - trial_config_db = PandasTrialDB(trial_config_path) - previous_config = trial_config_db.get_trial_config(trial_id) - new_config = previous_config.model_copy(deep=True) - new_config.raw_path = os.path.join( - dataset_dir, "0.parquet" - ) # TODO: deal with multiple parse config later - trial_config_db.set_trial_config(trial_id, new_config) - - return jsonify(response.model_dump()), 202 - - except ValueError as ve: - # Handle Pydantic validation errors - return jsonify({"error": f"Validation error: {str(ve)}"}), 400 + + logger.info(f"Creating task with data_path_glob: {data_path_glob}, project_dir: {save_path}, yaml_path: {yaml_path}") + await create_task(task_id, task, run_parser_start_parsing, data_path_glob, save_path, yaml_path) + return jsonify(task.model_dump()), 202 except Exception as e: - return jsonify({"error": f"An error occurred: {str(e)}"}), 500 - - -@app.route( - "/projects//trials//chunk", methods=["POST"] -) + logger.error(f"Error in parse: {str(e)}", exc_info=True) + if yaml_path and os.path.exists(yaml_path): + try: + os.remove(yaml_path) + except Exception as cleanup_error: + logger.error(f"Error cleaning up yaml file: {cleanup_error}") + return jsonify({ + 'error': f'An error occurred: {str(e)}' + }), 500 + +@app.route("/projects//trials//chunk", methods=["POST"]) @project_exists(WORK_DIR) @trial_exists(WORK_DIR) async def start_chunking(project_id: str, trial_id: str): - try: - # Get JSON data from request and validate with Pydantic - data = await request.get_json() - chunk_request = ChunkRequest(**data) - - # Get the directory containing datasets - dataset_dir = os.path.join(WORK_DIR, project_id, "chunk", chunk_request.name) - if not os.path.exists(dataset_dir): - os.makedirs(dataset_dir) - else: - return jsonify( - {"error": f"Parse dataset name already exists: {chunk_request.name}"} - ), 400 - - trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") - trial_config_db = PandasTrialDB(trial_config_path) - previous_config = trial_config_db.get_trial_config(trial_id) - - raw_filepath = previous_config.raw_path - if raw_filepath is None or not raw_filepath or not os.path.exists(raw_filepath): - return jsonify({"error": "Raw data path not found"}), 400 - - with tempfile.NamedTemporaryFile(suffix=".yaml", delete=False) as yaml_tempfile: - with open(yaml_tempfile.name, "w") as w: - yaml.safe_dump(chunk_request.config, w) - yaml_path = yaml_tempfile.name - - task_id = str(uuid.uuid4()) - response = Task( - id=task_id, - project_id=project_id, - trial_id=trial_id, - name=chunk_request.name, - config_yaml=chunk_request.config, - status=Status.IN_PROGRESS, - type=TaskType.CHUNK, - created_at=datetime.now(), - save_path=dataset_dir, - ) - await create_task( - task_id, - response, - run_chunker_start_chunking, - raw_filepath, - dataset_dir, - yaml_path, - ) - - # Update to trial - new_config: TrialConfig = previous_config.model_copy(deep=True) - new_config.corpus_path = os.path.join( - dataset_dir, "0.parquet" - ) # TODO: deal with multiple chunk config later - trial_config_db.set_trial_config(trial_id, new_config) - - return jsonify(response.model_dump()), 202 - - except ValueError as ve: - # Handle Pydantic validation errors - return jsonify({"error": f"Validation error: {str(ve)}"}), 400 - - except Exception as e: - return jsonify({"error": f"An error occurred: {str(e)}"}), 500 - - -@app.route( - "/projects//trials//qa", methods=["POST"] -) + try: + # Get JSON data from request and validate with Pydantic + data = await request.get_json() + chunk_request = ChunkRequest(**data) + + trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") + trial_config_db = PandasTrialDB(trial_config_path) + previous_config = trial_config_db.get_trial_config(trial_id) + + + parsed_data_path = os.path.join(WORK_DIR, project_id, "parse", f"parse_{previous_config.id}/0.parquet") + # parsed_data_path 확인 + print(f"parsed_data_path: {parsed_data_path}") + if not parsed_data_path or not os.path.exists(parsed_data_path): + return jsonify({ + "error": "Parsed data path not found. Please run parse first." + }), 400 + + # Get the directory containing datasets + dataset_dir = os.path.join(WORK_DIR, project_id, "chunk", chunk_request.name) + if not os.path.exists(dataset_dir): + os.makedirs(dataset_dir) + + with tempfile.NamedTemporaryFile(suffix=".yaml", delete=False) as yaml_tempfile: + with open(yaml_tempfile.name, "w") as w: + yaml.safe_dump(chunk_request.config, w) + yaml_path = yaml_tempfile.name + + task_id = str(uuid.uuid4()) + response = Task( + id=task_id, + project_id=project_id, + trial_id=trial_id, + name=chunk_request.name, + config_yaml=chunk_request.config, + status=Status.IN_PROGRESS, + type=TaskType.CHUNK, + created_at=datetime.now(), + + save_path=dataset_dir, + ) + await create_task(task_id, response, run_chunker_start_chunking, + parsed_data_path, # raw_filepath 대신 parsed_data_path 사용 + dataset_dir, yaml_path) + + # Update trial config + new_config = previous_config.model_copy(deep=True) + new_config.corpus_path = os.path.join(dataset_dir, "0.parquet") + trial_config_db.set_trial_config(trial_id, new_config) + + + return jsonify(response.model_dump()), 202 + + except ValueError as ve: + print(f"ValueError in chunk: {ve}") + logger.error(f"ValueError in chunk: {ve}", exc_info=True) + + # Handle Pydantic validation errors + return jsonify({ + 'error': f'Validation error: {str(ve)}' + }), 400 + + except Exception as e: + print(f"Error in chunk: {e}") + logger.error(f"Error in chunk: {e}", exc_info=True) + return jsonify({ + 'error': f'An error occurred: {str(e)}' + }), 500 + + +@app.route("/projects//trials//qa", methods=["POST"]) @project_exists(WORK_DIR) @trial_exists(WORK_DIR) async def create_qa(project_id: str, trial_id: str): - data = await request.get_json() - try: - qa_creation_request = QACreationRequest(**data) - dataset_dir = os.path.join(WORK_DIR, project_id, "qa") - - if not os.path.exists(dataset_dir): - os.makedirs(dataset_dir) - - save_path = os.path.join(dataset_dir, f"{qa_creation_request.name}.parquet") - - if os.path.exists(save_path): - return jsonify( - {"error": f"QA dataset name already exists: {qa_creation_request.name}"} - ), 400 - - trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") - trial_config_db = PandasTrialDB(trial_config_path) - previous_config = trial_config_db.get_trial_config(trial_id) - - corpus_filepath = previous_config.corpus_path - if ( - corpus_filepath is None - or not corpus_filepath - or not os.path.exists(corpus_filepath) - ): - return jsonify({"error": "Corpus data path not found"}), 400 - - task_id = str(uuid.uuid4()) - response = Task( - id=task_id, - project_id=project_id, - trial_id=trial_id, - name=qa_creation_request.name, - config_yaml={"preset": qa_creation_request.preset}, - status=Status.IN_PROGRESS, - type=TaskType.QA, - created_at=datetime.now(), - save_path=save_path, - ) - await create_task( - task_id, - response, - run_qa_creation, - qa_creation_request, - corpus_filepath, - dataset_dir, - ) - - # Update qa path - new_config: TrialConfig = previous_config.model_copy(deep=True) - new_config.qa_path = save_path - trial_config_db.set_trial_config(trial_id, new_config) - - return jsonify(response.model_dump()), 202 - - except Exception as e: - return jsonify( - {"status": "error", "message": f"Failed at creation of QA: {str(e)}"} - ), 400 - - -@app.route( - "/projects//trials//config", methods=["GET"] -) + data = await request.get_json() + try: + qa_creation_request = QACreationRequest(**data) + dataset_dir = os.path.join(WORK_DIR, project_id, "qa") + + if not os.path.exists(dataset_dir): + os.makedirs(dataset_dir) + + save_path = os.path.join(dataset_dir, f"{qa_creation_request.name}.parquet") + + if os.path.exists(save_path): + return jsonify({ + 'error': f'QA dataset name already exists: {qa_creation_request.name}' + }), 400 + + trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") + trial_config_db = PandasTrialDB(trial_config_path) + previous_config = trial_config_db.get_trial_config(trial_id) + + corpus_filepath = os.path.join(WORK_DIR, project_id, "chunk", f"chunk_{previous_config.id}/0.parquet") + # corpus_filepath = previous_config.corpus_path + print(f"previous_config: {previous_config}") + print(f"corpus_filepath: {corpus_filepath}") + + if corpus_filepath is None or not corpus_filepath or not os.path.exists(corpus_filepath): + return jsonify({ + "error": "Corpus data path not found" + }), 400 + + task_id = str(uuid.uuid4()) + response = Task( + id=task_id, + project_id=project_id, + trial_id=trial_id, + name=qa_creation_request.name, + config_yaml={"preset": qa_creation_request.preset}, + status=Status.IN_PROGRESS, + type=TaskType.QA, + created_at=datetime.now(), + save_path=save_path, + ) + await create_task(task_id, response, run_qa_creation, qa_creation_request, corpus_filepath, dataset_dir) + + # Update qa path + new_config: TrialConfig = previous_config.model_copy(deep=True) + new_config.qa_path = save_path + trial_config_db.set_trial_config(trial_id, new_config) + + + return jsonify(response.model_dump()), 202 + + except Exception as e: + return jsonify({ + "status": "error", + "message": f"Failed at creation of QA: {str(e)}" + }), 400 + + +@app.route("/projects//trials//config", methods=["GET"]) @project_exists(WORK_DIR) @trial_exists(WORK_DIR) async def get_trial_config(project_id: str, trial_id: str): - trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") - trial_config_db = PandasTrialDB(trial_config_path) - trial_config = trial_config_db.get_trial_config(trial_id) - return jsonify(trial_config.model_dump()), 200 - + trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") + trial_config_db = PandasTrialDB(trial_config_path) + trial_config = trial_config_db.get_trial_config(trial_id) + return jsonify(trial_config.model_dump()), 200 -@app.route( - "/projects//trials//config", methods=["POST"] -) +@app.route("/projects//trials//config", methods=["POST"]) @project_exists(WORK_DIR) @trial_exists(WORK_DIR) async def set_trial_config(project_id: str, trial_id: str): - trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") - trial_config_db = PandasTrialDB(trial_config_path) - previous_config = trial_config_db.get_trial_config(trial_id) - new_config = previous_config.model_copy(deep=True) - data = await request.get_json() - if data.get("raw_path", None) is not None: - new_config.raw_path = data["raw_path"] - if data.get("corpus_path", None) is not None: - new_config.corpus_path = data["corpus_path"] - if data.get("qa_path", None) is not None: - new_config.qa_path = data["qa_path"] - if data.get("config", None) is not None: - new_config_path = os.path.join( - WORK_DIR, project_id, "config", f"{str(uuid.uuid4())}.yaml" - ) - with open(new_config_path, "w") as f: - yaml.safe_dump(data["config"], f) - new_config.config_path = new_config_path - if data.get("metadata", None) is not None: - new_config.metadata = data["metadata"] - - trial_config_db.set_trial_config(trial_id, new_config) - return jsonify(new_config.model_dump()), 201 - - -@app.route( - "/projects//trials//validate", methods=["POST"] -) + trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") + trial_config_db = PandasTrialDB(trial_config_path) + previous_config = trial_config_db.get_trial_config(trial_id) + new_config = previous_config.model_copy(deep=True) + data = await request.get_json() + if data.get("raw_path", None) is not None: + new_config.raw_path = data["raw_path"] + if data.get("corpus_path", None) is not None: + new_config.corpus_path = data["corpus_path"] + if data.get("qa_path", None) is not None: + new_config.qa_path = data["qa_path"] + if data.get("config", None) is not None: + new_config_path = os.path.join(WORK_DIR, project_id, "config", f"{str(uuid.uuid4())}.yaml") + with open(new_config_path, "w") as f: + yaml.safe_dump(data["config"], f) + new_config.config_path = new_config_path + if data.get("metadata", None) is not None: + new_config.metadata = data["metadata"] + + trial_config_db.set_trial_config(trial_id, new_config) + return jsonify(new_config.model_dump()), 201 + +@app.route("/projects//trials//validate", methods=["POST"]) @project_exists(WORK_DIR) async def start_validate(project_id: str, trial_id: str): - trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") - trial_config_db = PandasTrialDB(trial_config_path) - trial_config = trial_config_db.get_trial_config(trial_id) - - task_id = str(uuid.uuid4()) - with open(trial_config.config_path, "r") as f: - config_yaml = yaml.safe_load(f) - response = Task( - id=task_id, - project_id=project_id, - trial_id=trial_id, - name=f"{trial_id}/validation", - config_yaml=config_yaml, - status=Status.IN_PROGRESS, - type=TaskType.VALIDATE, - created_at=datetime.now(), - ) - await create_task( - task_id, - response, - run_validate, - trial_config.qa_path, - trial_config.corpus_path, - trial_config.config_path, - ) - - return jsonify(response.model_dump()), 202 - - -@app.route( - "/projects//trials//evaluate", methods=["POST"] -) + trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") + trial_config_db = PandasTrialDB(trial_config_path) + trial_config = trial_config_db.get_trial_config(trial_id) + + task_id = str(uuid.uuid4()) + with open(trial_config.config_path, "r") as f: + config_yaml = yaml.safe_load(f) + response = Task( + id=task_id, + project_id=project_id, + trial_id=trial_id, + name=f"{trial_id}/validation", + config_yaml=config_yaml, + status=Status.IN_PROGRESS, + type=TaskType.VALIDATE, + created_at=datetime.now(), + ) + await create_task(task_id, response, + run_validate, trial_config.qa_path, trial_config.corpus_path, + trial_config.config_path) + + return jsonify(response.model_dump()), 202 + + +@app.route("/projects//trials//evaluate", methods=["POST"]) @project_exists(WORK_DIR) async def start_evaluate(project_id: str, trial_id: str): - evaluate_history_path = os.path.join(WORK_DIR, project_id, "evaluate_history.csv") - if not os.path.exists(evaluate_history_path): - evaluate_history_df = pd.DataFrame( - columns=["trial_id", "save_dir", "corpus_path", "qa_path", "config_path"] - ) # save_dir is to autorag trial directory - evaluate_history_df.to_csv(evaluate_history_path, index=False) - else: - evaluate_history_df = pd.read_csv(evaluate_history_path) - - trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") - trial_config_db = PandasTrialDB(trial_config_path) - trial = trial_config_db.get_trial(trial_id) - evaluate_dir = os.path.join(WORK_DIR, project_id, "project") - - # Update the trial progress to IN_PROGRESS - updated_trial = trial.model_copy(deep=True) - updated_trial.status = Status.IN_PROGRESS - trial_config_db.set_trial(updated_trial) - - data = await request.get_json() - skip_validation = data.get("skip_validation", False) - full_ingest = data.get("full_ingest", True) - - new_trial_dir = get_new_trial_dir(evaluate_history_df, trial.config, evaluate_dir) - if os.path.exists(new_trial_dir): - return jsonify( - { - "trial_dir": new_trial_dir, - "error": "Exact same evaluation already run. " - "Skipping but return the directory where the evaluation result is saved.", - } - ), 409 - - new_row = pd.DataFrame( - [ - { - "trial_id": trial_id, - "save_dir": new_trial_dir, - "corpus_path": trial.config.corpus_path, - "qa_path": trial.config.qa_path, - "config_path": trial.config.config_path, - } - ] - ) - evaluate_history_df = pd.concat([evaluate_history_df, new_row], ignore_index=True) - evaluate_history_df.reset_index(drop=True, inplace=True) - evaluate_history_df.to_csv(evaluate_history_path, index=False) - - task_id = str(uuid.uuid4()) - with open(trial.config.config_path, "r") as f: - config_yaml = yaml.safe_load(f) - task = Task( - id=task_id, - project_id=project_id, - trial_id=trial_id, - name=f"{trial_id}/evaluation", - config_yaml=config_yaml, - status=Status.IN_PROGRESS, - type=TaskType.EVALUATE, - created_at=datetime.now(), - save_path=new_trial_dir, - ) - await create_task( - task_id, - task, - run_start_trial, - trial.config.qa_path, - trial.config.corpus_path, - os.path.dirname(new_trial_dir), - trial.config.config_path, - skip_validation, - full_ingest, - trial_id, - trial_config_path, - ) - - return jsonify(task.model_dump()), 202 - - -@app.route( - "/projects//trials//report/open", - methods=["GET"], -) + evaluate_history_path = os.path.join(WORK_DIR, project_id, "evaluate_history.csv") + if not os.path.exists(evaluate_history_path): + evaluate_history_df = pd.DataFrame(columns=["trial_id", "save_dir", "corpus_path", "qa_path", "config_path"]) # save_dir is to autorag trial directory + evaluate_history_df.to_csv(evaluate_history_path, index=False) + else: + evaluate_history_df = pd.read_csv(evaluate_history_path) + + trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") + trial_config_db = PandasTrialDB(trial_config_path) + trial = trial_config_db.get_trial(trial_id) + trials_dir = os.path.join(WORK_DIR, project_id, "trials") + + data = await request.get_json() + skip_validation = data.get("skip_validation", False) + full_ingest = data.get("full_ingest", True) + + new_trial_dir = get_new_trial_dir(evaluate_history_df, trial.config, trials_dir) + if os.path.exists(new_trial_dir): + return jsonify({ + "trial_dir": new_trial_dir, + "error": "Exact same evaluation already run. " + "Skipping but return the directory where the evaluation result is saved." + }), 409 + task_id = str(uuid.uuid4()) + + new_row = pd.DataFrame([{ + "task_id": task_id, + "trial_id": trial_id, + "save_dir": new_trial_dir, + "corpus_path": trial.config.corpus_path, + "qa_path": trial.config.qa_path, + "config_path": trial.config.config_path, + "created_at": datetime.now(), + }]) + evaluate_history_df = pd.concat([evaluate_history_df, new_row], ignore_index=True) + evaluate_history_df.reset_index(drop=True, inplace=True) + evaluate_history_df.to_csv(evaluate_history_path, index=False) + + with open(trial.config.config_path, "r") as f: + config_yaml = yaml.safe_load(f) + task = Task( + id=task_id, + project_id=project_id, + trial_id=trial_id, + name=f"{trial_id}/evaluation", + config_yaml=config_yaml, + status=Status.IN_PROGRESS, + type=TaskType.EVALUATE, + created_at=datetime.now(), + save_path=new_trial_dir, + ) + await create_task(task_id, task, + run_start_trial, trial.config.qa_path, trial.config.corpus_path, + os.path.dirname(new_trial_dir), + trial.config.config_path, skip_validation, full_ingest) + + task.model_dump() + return jsonify(task.model_dump()), 202 + + +@app.route('/projects//trials//report/open', methods=['GET']) async def open_dashboard(project_id: str, trial_id: str): - """ - Get a preparation task or run status for chat open. - - Args: - project_id (str): The project ID - trial_id (str): The trial ID - - Returns: - JSON response with task status or error message - """ - try: - # Get the trial and search for the corresponding save_path - evaluate_history_path = os.path.join( - WORK_DIR, project_id, "evaluate_history.csv" - ) - if not os.path.exists(evaluate_history_path): - return jsonify({"error": "You need to run evaluation first"}), 400 - - evaluate_history_df = pd.read_csv(evaluate_history_path) - trial_raw = evaluate_history_df[evaluate_history_df["trial_id"] == trial_id] - if trial_raw.empty or len(trial_raw) < 1: - return jsonify({"error": "Trial ID not found"}), 404 - if len(trial_raw) >= 2: - return jsonify({"error": "Duplicated trial ID found"}), 400 - - trial_dir = trial_raw.iloc[0]["save_dir"] - if not os.path.exists(trial_dir): - return jsonify({"error": "Trial directory not found"}), 404 - if not os.path.isdir(trial_dir): - return jsonify({"error": "Trial directory is not a directory"}), 500 - - task_id = str(uuid.uuid4()) - response = Task( - id=task_id, - project_id=project_id, - trial_id=trial_id, - status=Status.IN_PROGRESS, - type=TaskType.REPORT, - created_at=datetime.now(), - ) - await create_task(task_id, response, run_dashboard, trial_dir) - - trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") - trial_config_db = PandasTrialDB(trial_config_path) - trial = trial_config_db.get_trial(trial_id) - new_trial = trial.model_copy(deep=True) - new_trial.report_task_id = task_id - trial_config_db.set_trial(new_trial) - - return jsonify(response.model_dump()), 202 - - except Exception as e: - return jsonify({"error": f"Internal server error: {str(e)}"}), 500 - - -@app.route( - "/projects//trials//report/close", - methods=["GET"], -) + """ + Get a preparation task or run status for chat open. + + Args: + project_id (str): The project ID + trial_id (str): The trial ID + + Returns: + JSON response with task status or error message + """ + try: + # Get the trial and search for the corresponding save_path + evaluate_history_path = os.path.join(WORK_DIR, project_id, "evaluate_history.csv") + if not os.path.exists(evaluate_history_path): + return jsonify({"error": "You need to run evaluation first"}), 400 + + evaluate_history_df = pd.read_csv(evaluate_history_path) + trial_raw = evaluate_history_df[evaluate_history_df["trial_id"] == trial_id] + if trial_raw.empty or len(trial_raw) < 1: + return jsonify({"error": "Trial ID not found"}), 404 + if len(trial_raw) >= 2: + return jsonify({"error": "Duplicated trial ID found"}), 400 + + trial_dir = trial_raw.iloc[0]["save_dir"] + if not os.path.exists(trial_dir): + return jsonify({"error": "Trial directory not found"}), 404 + if not os.path.isdir(trial_dir): + return jsonify({"error": "Trial directory is not a directory"}), 500 + + task_id = str(uuid.uuid4()) + response = Task( + id=task_id, + project_id=project_id, + trial_id=trial_id, + status=Status.IN_PROGRESS, + type=TaskType.REPORT, + created_at=datetime.now(), + ) + await create_task(task_id, response, run_dashboard, trial_dir) + + trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") + trial_config_db = PandasTrialDB(trial_config_path) + trial = trial_config_db.get_trial(trial_id) + new_trial = trial.model_copy(deep=True) + new_trial.report_task_id = task_id + trial_config_db.set_trial(new_trial) + + return jsonify(response.model_dump()), 202 + + except Exception as e: + return jsonify({'error': f'Internal server error: {str(e)}'}), 500 + + +@app.route('/projects//trials//report/close', methods=['GET']) async def close_dashboard(project_id: str, trial_id: str): - trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") - trial_config_db = PandasTrialDB(trial_config_path) - trial = trial_config_db.get_trial(trial_id) - report_pid = tasks[trial.report_task_id]["report_pid"] - os.killpg(os.getpgid(report_pid), signal.SIGTERM) + trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") + trial_config_db = PandasTrialDB(trial_config_path) + trial = trial_config_db.get_trial(trial_id) + report_pid = tasks[trial.report_task_id]["report_pid"] + os.killpg(os.getpgid(report_pid), signal.SIGTERM) - new_trial = trial.model_copy(deep=True) + new_trial = trial.model_copy(deep=True) - original_task = tasks[trial.report_task_id]["task"] - original_task.status = Status.TERMINATED - new_trial.report_task_id = None - trial_config_db.set_trial(new_trial) + original_task = tasks[trial.report_task_id]["task"] + original_task.status = Status.TERMINATED + new_trial.report_task_id = None + trial_config_db.set_trial(new_trial) - return jsonify(original_task.model_dump()), 200 + return jsonify(original_task.model_dump()), 200 -@app.route( - "/projects//trials//chat/open", methods=["GET"] -) +@app.route('/projects//trials//chat/open', methods=['GET']) async def open_chat_server(project_id: str, trial_id: str): + try: + # Get the trial and search for the corresponding save_path + evaluate_history_path = os.path.join(WORK_DIR, project_id, "evaluate_history.csv") + if not os.path.exists(evaluate_history_path): + return jsonify({"error": "You need to run evaluation first"}), 400 + + evaluate_history_df = pd.read_csv(evaluate_history_path) + trial_raw = evaluate_history_df[evaluate_history_df["trial_id"] == trial_id] + if trial_raw.empty or len(trial_raw) < 1: + return jsonify({"error": "Trial ID not found"}), 404 + if len(trial_raw) >= 2: + return jsonify({"error": "Duplicated trial ID found"}), 400 + + trial_dir = trial_raw.iloc[0]["save_dir"] + if not os.path.exists(trial_dir): + return jsonify({"error": "Trial directory not found"}), 404 + if not os.path.isdir(trial_dir): + return jsonify({"error": "Trial directory is not a directory"}), 500 + + task_id = str(uuid.uuid4()) + response = Task( + id=task_id, + project_id=project_id, + trial_id=trial_id, + status=Status.IN_PROGRESS, + type=TaskType.CHAT, + created_at=datetime.now(), + ) + await create_task(task_id, response, run_chat, trial_dir) + + trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") + trial_config_db = PandasTrialDB(trial_config_path) + trial = trial_config_db.get_trial(trial_id) + new_trial = trial.model_copy(deep=True) + new_trial.chat_task_id = task_id + trial_config_db.set_trial(new_trial) + + + return jsonify(response.model_dump()), 202 + + except Exception as e: + return jsonify({'error': f'Internal server error: {str(e)}'}), 500 + + +@app.route("/projects//artifacts", methods=["GET"]) +@project_exists(WORK_DIR) +async def get_project_artifacts(project_id: str): + """프로젝트 아티팩트 디렉토리 구조를 비동기적으로 반환합니다.""" try: - # Get the trial and search for the corresponding save_path - evaluate_history_path = os.path.join( - WORK_DIR, project_id, "evaluate_history.csv" - ) - if not os.path.exists(evaluate_history_path): - return jsonify({"error": "You need to run evaluation first"}), 400 - - evaluate_history_df = pd.read_csv(evaluate_history_path) - trial_raw = evaluate_history_df[evaluate_history_df["trial_id"] == trial_id] - if trial_raw.empty or len(trial_raw) < 1: - return jsonify({"error": "Trial ID not found"}), 404 - if len(trial_raw) >= 2: - return jsonify({"error": "Duplicated trial ID found"}), 400 - - trial_dir = trial_raw.iloc[0]["save_dir"] - if not os.path.exists(trial_dir): - return jsonify({"error": "Trial directory not found"}), 404 - if not os.path.isdir(trial_dir): - return jsonify({"error": "Trial directory is not a directory"}), 500 - - task_id = str(uuid.uuid4()) - response = Task( - id=task_id, - project_id=project_id, - trial_id=trial_id, - status=Status.IN_PROGRESS, - type=TaskType.CHAT, - created_at=datetime.now(), - ) - await create_task(task_id, response, run_chat, trial_dir) - - trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") - trial_config_db = PandasTrialDB(trial_config_path) - trial = trial_config_db.get_trial(trial_id) - new_trial = trial.model_copy(deep=True) - new_trial.chat_task_id = task_id - trial_config_db.set_trial(new_trial) - - return jsonify(response.model_dump()), 202 - + project_path = os.path.join(WORK_DIR, project_id) + + # 특정 디렉토리만 스캔 (예: index 디렉토리) + index_path = os.path.join(project_path, "raw_data") + print(index_path) + # 비동기로 디렉토리 존재 여부 확인 + if await aiofiles.os.path.exists(index_path): + file_tree = await scan_directory(index_path) + return jsonify(file_tree.model_dump()), 200 + else: + return jsonify({ + "error": "Artifacts directory not found" + }), 404 except Exception as e: - return jsonify({"error": f"Internal server error: {str(e)}"}), 500 - - -@app.route( - "/projects//trials//chat/close", methods=["GET"] -) + print(e) + return jsonify({ + "error": f"Failed to scan artifacts: {str(e)}" + }), 500 + +@app.route('/projects//trials//chat/close', methods=['GET']) async def close_chat_server(project_id: str, trial_id: str): - trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") - trial_config_db = PandasTrialDB(trial_config_path) - trial = trial_config_db.get_trial(trial_id) - chat_pid = tasks[trial.chat_task_id]["chat_pid"] - os.killpg(os.getpgid(chat_pid), signal.SIGTERM) - - new_trial = trial.model_copy(deep=True) + trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") + trial_config_db = PandasTrialDB(trial_config_path) + trial = trial_config_db.get_trial(trial_id) + chat_pid = tasks[trial.chat_task_id]["chat_pid"] + os.killpg(os.getpgid(chat_pid), signal.SIGTERM) - original_task = tasks[trial.chat_task_id]["task"] - original_task.status = Status.TERMINATED - new_trial.chat_task_id = None - trial_config_db.set_trial(new_trial) + new_trial = trial.model_copy(deep=True) - return jsonify(original_task.model_dump()), 200 + original_task = tasks[trial.chat_task_id]["task"] + original_task.status = Status.TERMINATED + new_trial.chat_task_id = None + trial_config_db.set_trial(new_trial) + return jsonify(original_task.model_dump()), 200 -@app.route("/projects//tasks/", methods=["GET"]) +@app.route('/projects//tasks', methods=['GET']) +@project_exists(WORK_DIR) +async def get_tasks(project_id: str): + if not os.path.exists(os.path.join(WORK_DIR, project_id)): + return jsonify({ + 'error': f'Project name does not exist: {project_id}' + }), 404 + + evaluate_history_path = os.path.join(WORK_DIR, project_id, "evaluate_history.csv") + if not os.path.exists(evaluate_history_path): + evaluate_history_df = pd.DataFrame(columns=["trial_id", "save_dir", "corpus_path", "qa_path", "config_path"]) # save_dir is to autorag trial directory + evaluate_history_df.to_csv(evaluate_history_path, index=False) + else: + evaluate_history_df = pd.read_csv(evaluate_history_path) + + # Replace NaN values with None before converting to dict + evaluate_history_df = evaluate_history_df.where(pd.notna(evaluate_history_df), -1) + + return jsonify({ + "total": len(evaluate_history_df), + "data": evaluate_history_df.to_dict(orient='records') # Convert DataFrame to list of dictionaries + }), 200 + +@app.route('/projects//tasks/', methods=['GET']) @project_exists(WORK_DIR) async def get_task(project_id: str, task_id: str): - if not os.path.exists(os.path.join(WORK_DIR, project_id)): - return jsonify({"error": f"Project name does not exist: {project_id}"}), 404 - task: Optional[Dict] = tasks.get(task_id, None) - if task is None: - return jsonify({"error": f"Task ID does not exist: {task_id}"}), 404 - response = task["task"] - return jsonify(response.model_dump()), 200 + if not os.path.exists(os.path.join(WORK_DIR, project_id)): + return jsonify({ + 'error': f'Project name does not exist: {project_id}' + }), 404 + task: Optional[Dict] = tasks.get(task_id, None) + if task is None: + return jsonify({ + 'error': f'Task ID does not exist: {task_id}' + }), 404 + response = task["task"] + return jsonify(response.model_dump()), 200 @app.route("/env", methods=["POST"]) async def set_environment_variable(): - # Get JSON data from request - data = await request.get_json() + # Get JSON data from request + data = await request.get_json() - try: - # Validate request data using Pydantic model - env_var = EnvVariableRequest(**data) + try: + # Validate request data using Pydantic model + env_var = EnvVariableRequest(**data) - if os.getenv(env_var.key, None) is None: - # Set the environment variable - os.environ[env_var.key] = env_var.value - return jsonify({}), 200 - else: - os.environ[env_var.key] = env_var.value - return jsonify({}), 201 + if os.getenv(env_var.key, None) is None: + # Set the environment variable + os.environ[env_var.key] = env_var.value + return jsonify({}), 200 + else: + os.environ[env_var.key] = env_var.value + return jsonify({}), 201 - except Exception as e: - return jsonify( - { - "status": "error", - "message": f"Failed to set environment variable: {str(e)}", - } - ), 400 + except Exception as e: + return jsonify({ + "status": "error", + "message": f"Failed to set environment variable: {str(e)}" + }), 400 @app.route("/env/", methods=["GET"]) async def get_environment_variable(key: str): - """ - Get environment variable by key. + """ + Get environment variable by key. - Args: - key (str): The environment variable key to lookup + Args: + key (str): The environment variable key to lookup - Returns: - Tuple containing response dictionary and status code - """ - try: - value = os.environ.get(key) + Returns: + Tuple containing response dictionary and status code + """ + try: + value = os.environ.get(key) - if value is None: - return {"error": f"Environment variable '{key}' not found"}, 404 + if value is None: + return {"error": f"Environment variable '{key}' not found"}, 404 - return {"key": key, "value": value}, 200 + return { + "key": key, + "value": value + }, 200 - except Exception as e: - return {"error": f"Internal server error: {str(e)}"}, 500 + except Exception as e: + return {"error": f"Internal server error: {str(e)}"}, 500 if __name__ == "__main__": - from dotenv import load_dotenv - - load_dotenv() - - app.run() + import hypercorn.asyncio + import asyncio + from hypercorn.config import Config + + config = Config() + + # 환경 변수에서 포트를 가져오거나 기본값 5000 사용 + port = int(os.getenv('PORT', 5000)) + host = os.getenv('HOST', '127.0.0.1') + + config.bind = [f"{host}:{port}"] + config.use_reloader = True + + # 특정 디렉토리만 감시 + base_dir = os.path.dirname(os.path.abspath(__file__)) + config.reload_dirs = [ + os.path.join(base_dir, "src"), + os.path.join(base_dir, "app.py") + ] + + # 워커 설정 + config.workers = 1 + config.worker_class = "asyncio" + + # 로그 설정 + config.accesslog = "-" + config.errorlog = "-" + config.loglevel = "INFO" + + logger.info("Starting server with CORS enabled for http://localhost:3000") + asyncio.run(hypercorn.asyncio.serve(app, config)) \ No newline at end of file diff --git a/api/requirements.txt b/api/requirements.txt index 78b3a81af..59b9817df 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -1,4 +1,4 @@ -AutoRAG[parse,api] +AutoRAG[parse,api]>=0.3.8 quart-schema jwt quart-cors From 0b6bfc3020b19b1313a786919186532e574c5315 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=8D=EC=8A=B9=EC=9A=B0?= Date: Tue, 19 Nov 2024 21:18:49 +0900 Subject: [PATCH 24/55] =?UTF-8?q?=F0=9F=9A=91=20fix:=20Update=20ports=20an?= =?UTF-8?q?d=20environment=20variables=20in=20docker-compose.yml=20to=20us?= =?UTF-8?q?e=20port=205001=20instead=20of=205000?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 6a4eb623b..3f041fb74 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,13 +22,14 @@ services: dockerfile: Dockerfile volumes: - ./api:/app/api + - ./projects:/app/projects ports: - - "5000:5001" + - "5001:5001" environment: - PYTHONUNBUFFERED=1 - - PORT=5000 + - PORT=5001 - HOST=0.0.0.0 - command: hypercorn app:app --bind 0.0.0.0:5000 --reload + command: hypercorn app:app --bind 0.0.0.0:5001 --reload networks: - app-network From 270638d0b335e309bd8166c200d99c2f0d749cb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=8D=EC=8A=B9=EC=9A=B0?= Date: Tue, 19 Nov 2024 21:21:41 +0900 Subject: [PATCH 25/55] =?UTF-8?q?=F0=9F=9A=91=20fix:=20Update=20schema.py?= =?UTF-8?q?=20with=20corrected=20field=20indentation=20and=20added=20'path?= =?UTF-8?q?'=20field=20in=20ParseRequest=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/src/schema.py | 62 +++++++++++++++++------------------------------ 1 file changed, 22 insertions(+), 40 deletions(-) diff --git a/api/src/schema.py b/api/src/schema.py index 2f6c7e735..b4f7a5097 100644 --- a/api/src/schema.py +++ b/api/src/schema.py @@ -11,22 +11,17 @@ class TrialCreateRequest(BaseModel): raw_path: Optional[str] = Field(None, description="The path to the raw data") corpus_path: Optional[str] = Field(None, description="The path to the corpus data") qa_path: Optional[str] = Field(None, description="The path to the QA data") - config: Optional[Dict] = Field( - None, description="The trial configuration dictionary" - ) + config: Optional[Dict] = Field(None, description="The trial configuration dictionary") class ParseRequest(BaseModel): - config: Dict = Field( - ..., description="Dictionary contains parse YAML configuration" - ) + config: Dict = Field(..., + description="Dictionary contains parse YAML configuration") name: str = Field(..., description="Name of the parse target dataset") - + path: str # 추가: 파싱할 파일 경로 class ChunkRequest(BaseModel): - config: Dict = Field( - ..., description="Dictionary contains chunk YAML configuration" - ) + config: Dict = Field(..., description="Dictionary contains chunk YAML configuration") name: str = Field(..., description="Name of the chunk target dataset") @@ -35,33 +30,29 @@ class QACreationPresetEnum(str, Enum): SIMPLE = "simple" ADVANCED = "advanced" - class LLMConfig(BaseModel): llm_name: str = Field(description="Name of the LLM model") - llm_params: dict = Field(description="Parameters for the LLM model", default={}) - + llm_params: dict = Field(description="Parameters for the LLM model", + default={}) class SupportLanguageEnum(str, Enum): ENGLISH = "en" KOREAN = "ko" JAPANESE = "ja" - class QACreationRequest(BaseModel): preset: QACreationPresetEnum name: str = Field(..., description="Name of the QA dataset") qa_num: int - llm_config: LLMConfig = Field(description="LLM configuration settings") - lang: SupportLanguageEnum = Field( - default=SupportLanguageEnum.ENGLISH, description="Language of the QA dataset" + llm_config: LLMConfig = Field( + description="LLM configuration settings" ) - + lang: SupportLanguageEnum = Field(default=SupportLanguageEnum.ENGLISH, description="Language of the QA dataset") class EnvVariableRequest(BaseModel): key: str value: str - class Project(BaseModel): id: str name: str @@ -78,11 +69,10 @@ class Config: "description": "A sample project", "created_at": "2024-02-11T12:00:00Z", "status": "active", - "metadata": {}, + "metadata": {} } } - class Status(str, Enum): NOT_STARTED = "not_started" IN_PROGRESS = "in_progress" @@ -90,7 +80,6 @@ class Status(str, Enum): FAILED = "failed" TERMINATED = "terminated" - class TaskType(str, Enum): PARSE = "parse" CHUNK = "chunk" @@ -100,7 +89,6 @@ class TaskType(str, Enum): REPORT = "report" CHAT = "chat" - class Task(BaseModel): id: str = Field(description="The task id") project_id: str @@ -108,20 +96,17 @@ class Task(BaseModel): name: Optional[str] = Field(None, description="The name of the task") config_yaml: Optional[Dict] = Field( None, - description="YAML configuration. Format is dictionary, not path of the YAML file.", + description="YAML configuration. Format is dictionary, not path of the YAML file." ) status: Status - error_message: Optional[str] = Field( - None, description="Error message if the task failed" - ) + error_message: Optional[str] = Field(None, description="Error message if the task failed") type: TaskType created_at: Optional[datetime] = None save_path: Optional[str] = Field( None, - description="Path where the task results are saved. It will be directory or file.", + description="Path where the task results are saved. It will be directory or file." ) - class TrialConfig(BaseModel): trial_id: str project_id: str @@ -134,24 +119,21 @@ class TrialConfig(BaseModel): class Config: arbitrary_types_allowed = True - class Trial(BaseModel): id: str project_id: str - config: Optional[TrialConfig] = Field( - description="The trial configuration", default=None - ) + config: Optional[TrialConfig] = Field(description="The trial configuration", + default=None) name: str status: Status created_at: datetime - report_task_id: Optional[str] = Field( - None, description="The report task id for forcing shutdown of the task" - ) - chat_task_id: Optional[str] = Field( - None, description="The chat task id for forcing shutdown of the task" - ) + report_task_id: Optional[str] = Field(None, description="The report task id for forcing shutdown of the task") + chat_task_id: Optional[str] = Field(None, description="The chat task id for forcing shutdown of the task") - @field_validator("report_task_id", "chat_task_id", mode="before") + corpus_path: Optional[str] = None + qa_path: Optional[str] = None + + @field_validator('report_task_id', 'chat_task_id', mode="before") def replace_nan_with_none(cls, v): if isinstance(v, float) and np.isnan(v): return None From 60cda5bb4f1fed8e1624c94b58acc041da17a7d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=8D=EC=8A=B9=EC=9A=B0?= Date: Tue, 19 Nov 2024 21:21:48 +0900 Subject: [PATCH 26/55] =?UTF-8?q?=F0=9F=9A=91=20fix:=20Fix=20indentation?= =?UTF-8?q?=20in=20validate.py=20for=20decorator=20functions.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/src/validate.py | 90 ++++++++++++++++++++++----------------------- 1 file changed, 45 insertions(+), 45 deletions(-) diff --git a/api/src/validate.py b/api/src/validate.py index c7f4f1b71..4f187bce1 100644 --- a/api/src/validate.py +++ b/api/src/validate.py @@ -8,50 +8,50 @@ def project_exists(work_dir): - def decorator(func): - @wraps(func) - async def wrapper(*args, **kwargs): - # Get project_id from request arguments - project_id = kwargs.get("project_id") - - if not project_id: - return jsonify({"error": "project_id is required"}), 400 - - # Check if project directory exists - project_path = os.path.join(work_dir, project_id) - if not os.path.exists(project_path): - return jsonify( - {"error": f"Project with id {project_id} does not exist"} - ), 404 - - # If everything is okay, proceed with the endpoint function - return await func(*args, **kwargs) - - return wrapper - - return decorator - + def decorator(func): + @wraps(func) + async def wrapper(*args, **kwargs): + # Get project_id from request arguments + project_id = kwargs.get('project_id') + + if not project_id: + return jsonify({ + 'error': 'project_id is required' + }), 400 + + # Check if project directory exists + project_path = os.path.join(work_dir, project_id) + if not os.path.exists(project_path): + return jsonify({ + 'error': f'Project with id {project_id} does not exist' + }), 404 + + # If everything is okay, proceed with the endpoint function + return await func(*args, **kwargs) + + return wrapper + return decorator def trial_exists(work_dir: str): - def decorator(func): - @wraps(func) - async def wrapper(*args, **kwargs): - project_id = kwargs.get("project_id") - trial_id = kwargs.get("trial_id") - - if not trial_id: - return jsonify({"error": "trial_id is required"}), 400 - - trial_config_path = os.path.join(work_dir, project_id, "trial_config.csv") - trial_config_db = PandasTrialDB(trial_config_path) - trial = trial_config_db.get_trial(trial_id) - if trial is None or not isinstance(trial, Trial): - return jsonify( - {"error": f"Trial with id {trial_id} does not exist"} - ), 404 - - return await func(*args, **kwargs) - - return wrapper - - return decorator + def decorator(func): + @wraps(func) + async def wrapper(*args, **kwargs): + project_id = kwargs.get("project_id") + trial_id = kwargs.get("trial_id") + + if not trial_id: + return jsonify({ + 'error': 'trial_id is required' + }), 400 + + trial_config_path = os.path.join(work_dir, project_id, "trial_config.csv") + trial_config_db = PandasTrialDB(trial_config_path) + trial = trial_config_db.get_trial(trial_id) + if trial is None or not isinstance(trial, Trial): + return jsonify({ + 'error': f'Trial with id {trial_id} does not exist' + }), 404 + + return await func(*args, **kwargs) + return wrapper + return decorator From 62a9eb66e9ff085e82b08602016f2c8edee51d4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=8D=EC=8A=B9=EC=9A=B0?= Date: Tue, 19 Nov 2024 21:21:56 +0900 Subject: [PATCH 27/55] =?UTF-8?q?=F0=9F=9A=91=20fix:=20refactor=20authenti?= =?UTF-8?q?cation=20decorator=20in=20auth.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/src/auth.py | 46 +++++++++++++++++++++++----------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/api/src/auth.py b/api/src/auth.py index bf4efbb3e..394a57d8f 100644 --- a/api/src/auth.py +++ b/api/src/auth.py @@ -6,34 +6,34 @@ def require_auth(): - def decorator(f): - @wraps(f) - async def decorated_function(*args, **kwargs): - auth_header = request.headers.get("Authorization") + def decorator(f): + @wraps(f) + async def decorated_function(*args, **kwargs): + auth_header = request.headers.get('Authorization') - if not auth_header: - return jsonify({"error": "No authorization header"}), 401 + if not auth_header: + return jsonify({'error': 'No authorization header'}), 401 - try: - token_type, token = auth_header.split() - if token_type.lower() != "bearer": - return jsonify({"error": "Invalid token type"}), 401 + try: + token_type, token = auth_header.split() + if token_type.lower() != 'bearer': + return jsonify({'error': 'Invalid token type'}), 401 - # Verify token (implement your token verification logic here) - # This is a simple example - adjust according to your needs - # TODO: Make JWT auth server at remote location (AutoRAG private) + # Verify token (implement your token verification logic here) + # This is a simple example - adjust according to your needs + # TODO: Make JWT auth server at remote location (AutoRAG private) - # Check permissions (implement your permission logic here) - if token != "good": - return jsonify({"error": "Insufficient permissions"}), 403 + # Check permissions (implement your permission logic here) + if token != 'good': + return jsonify({'error': 'Insufficient permissions'}), 403 - except jwt.InvalidTokenError: - return jsonify({"error": "Invalid token"}), 401 - except Exception as e: - return jsonify({"error": str(e)}), 401 + except jwt.InvalidTokenError: + return jsonify({'error': 'Invalid token'}), 401 + except Exception as e: + return jsonify({'error': str(e)}), 401 - return await f(*args, **kwargs) + return await f(*args, **kwargs) - return decorated_function + return decorated_function - return decorator + return decorator From c46758042e34ea978dabc0473c01f68066697ef4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=8D=EC=8A=B9=EC=9A=B0?= Date: Tue, 19 Nov 2024 21:22:02 +0900 Subject: [PATCH 28/55] =?UTF-8?q?=F0=9F=9A=91=20fix:=20Correct=20get=5Fnew?= =?UTF-8?q?=5Ftrial=5Fdir=20parameter=20naming=20and=20handle=20trial=20di?= =?UTF-8?q?rectory=20creation=20accurately?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/src/evaluate_history.py | 103 ++++++++++++++++-------------------- 1 file changed, 46 insertions(+), 57 deletions(-) diff --git a/api/src/evaluate_history.py b/api/src/evaluate_history.py index effab7984..d259361c5 100644 --- a/api/src/evaluate_history.py +++ b/api/src/evaluate_history.py @@ -9,63 +9,52 @@ from src.schema import TrialConfig -def get_new_trial_dir( - history_df: pd.DataFrame, trial_config: TrialConfig, project_dir: str -): - trial_rows = history_df[history_df["trial_id"] == trial_config.trial_id] - duplicate_corpus_rows = trial_rows[ - trial_rows["corpus_path"] == trial_config.corpus_path - ] - if len(duplicate_corpus_rows) == 0: # If corpus data changed - # Changed Corpus - ingest again (Make new directory - new save_dir) - new_dir_name = f"{trial_config.trial_id}-{str(uuid.uuid4())}" - os.makedirs(os.path.join(project_dir, new_dir_name)) - return os.path.join(project_dir, new_dir_name, "0") # New trial folder - duplicate_qa_rows = duplicate_corpus_rows[ - trial_rows["qa_path"] == trial_config.qa_path - ] - if len(duplicate_qa_rows) == 0: # If qa data changed - # swap qa data from the existing project directory - existing_project_dir = os.path.dirname(duplicate_qa_rows.iloc[0]["save_path"]) - shutil.copy( - trial_config.qa_path, - os.path.join(existing_project_dir, "data", "qa.parquet"), - ) - duplicate_config_rows = duplicate_qa_rows[ - trial_rows["config_path"] == trial_config.config_path - ] - if len(duplicate_config_rows) > 0: - duplicate_row_save_paths = duplicate_config_rows["save_dir"].unique().tolist() - return duplicate_row_save_paths[0] - # Get the next trial folder - existing_project_dir = os.path.dirname(duplicate_qa_rows.iloc[0]["save_path"]) - latest_trial_name = get_latest_trial( - os.path.join(existing_project_dir, "trial.json") - ) - new_trial_name = str(int(latest_trial_name) + 1) - return os.path.join(existing_project_dir, new_trial_name) +def get_new_trial_dir(history_df: pd.DataFrame, trial_config: TrialConfig, + trials_dir: str): + trial_rows = history_df[history_df['trial_id'] == trial_config.trial_id] + duplicate_corpus_rows = trial_rows[trial_rows['corpus_path'] == trial_config.corpus_path] + if len(duplicate_corpus_rows) == 0: # If corpus data changed + # Changed Corpus - ingest again (Make new directory - new save_dir) + new_dir_name = f"{trial_config.trial_id}/{str(uuid.uuid4())}" + os.makedirs(os.path.join(project_dir, new_dir_name)) + return os.path.join(project_dir, new_dir_name, "0") # New trial folder + duplicate_qa_rows = duplicate_corpus_rows[trial_rows['qa_path'] == trial_config.qa_path] + if len(duplicate_qa_rows) == 0: # If qa data changed + # swap qa data from the existing project directory + existing_project_dir = os.path.dirname(duplicate_qa_rows.iloc[0]['save_path']) + shutil.copy(trial_config.qa_path, os.path.join(existing_project_dir, "data", "qa.parquet")) + duplicate_config_rows = duplicate_qa_rows[trial_rows['config_path'] == trial_config.config_path] + if len(duplicate_config_rows) > 0: + duplicate_row_save_paths = duplicate_config_rows['save_dir'].unique().tolist() + return duplicate_row_save_paths[0] + # Get the next trial folder + existing_project_dir = os.path.dirname(duplicate_qa_rows.iloc[0]['save_path']) + latest_trial_name = get_latest_trial(os.path.join(existing_project_dir, "trial.json")) + new_trial_name = str(int(latest_trial_name) + 1) + return os.path.join(existing_project_dir, new_trial_name) -def get_latest_trial(file_path): - try: - # Load JSON file - with open(file_path, "r") as f: - trials = json.load(f) - - # Convert start_time to datetime objects and find the latest trial - latest_trial = max( - trials, - key=lambda x: datetime.strptime(x["start_time"], "%Y-%m-%d %H:%M:%S"), - ) - return latest_trial["trial_name"] - - except FileNotFoundError: - print("Error: trial.json file not found") - return None - except json.JSONDecodeError: - print("Error: Invalid JSON format") - return None - except Exception as e: - print(f"Error: {str(e)}") - return None +def get_latest_trial(file_path): + try: + # Load JSON file + with open(file_path, 'r') as f: + trials = json.load(f) + + # Convert start_time to datetime objects and find the latest trial + latest_trial = max( + trials, + key=lambda x: datetime.strptime(x['start_time'], "%Y-%m-%d %H:%M:%S") + ) + + return latest_trial['trial_name'] + + except FileNotFoundError: + print("Error: trial.json file not found") + return None + except json.JSONDecodeError: + print("Error: Invalid JSON format") + return None + except Exception as e: + print(f"Error: {str(e)}") + return None From dea7c09e5e1114427beaf6dd2f54f49ebc27e5d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=8D=EC=8A=B9=EC=9A=B0?= Date: Tue, 19 Nov 2024 21:22:08 +0900 Subject: [PATCH 29/55] =?UTF-8?q?=F0=9F=9A=91=20fix:=20Corrected=20import?= =?UTF-8?q?=20formatting=20in=20qa=5Fcreate.py=20and=20standardized=20func?= =?UTF-8?q?tion=20indentation.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/src/qa_create.py | 153 +++++++++++++++++-------------------------- 1 file changed, 60 insertions(+), 93 deletions(-) diff --git a/api/src/qa_create.py b/api/src/qa_create.py index 24ae92051..8696854c6 100644 --- a/api/src/qa_create.py +++ b/api/src/qa_create.py @@ -1,7 +1,5 @@ import pandas as pd -from autorag.data.qa.filter.passage_dependency import ( - passage_dependency_filter_llama_index, -) +from autorag.data.qa.filter.passage_dependency import passage_dependency_filter_llama_index from autorag.data.qa.query.llama_gen_query import factoid_query_gen from autorag.data.qa.sample import random_single_hop from autorag.data.qa.schema import Corpus, QA @@ -15,105 +13,74 @@ from autorag.data.qa.evolve.llama_index_query_evolve import compress_ragas -def default_create( - corpus_df, llm: BaseLLM, n: int = 100, lang: str = "en", batch_size: int = 32 -) -> QA: - corpus_instance = Corpus(corpus_df) - if len(corpus_instance.data) < n: - n = len(corpus_instance.data) - sampled_corpus = corpus_instance.sample(random_single_hop, n=n) - mapped_corpus = sampled_corpus.map(lambda df: df.reset_index(drop=True)) - retrieval_gt_contents = mapped_corpus.make_retrieval_gt_contents() - query_generated = retrieval_gt_contents.batch_apply( - factoid_query_gen, llm=llm, lang=lang, batch_size=batch_size - ) - basic_answers = query_generated.batch_apply( - make_basic_gen_gt, llm=llm, lang=lang, batch_size=batch_size - ) - concise_answers = basic_answers.batch_apply( - make_concise_gen_gt, llm=llm, lang=lang, batch_size=batch_size - ) - filtered_answers = concise_answers.filter(dontknow_filter_rule_based, lang=lang) - initial_qa = filtered_answers.batch_filter( - passage_dependency_filter_llama_index, llm=llm, lang=lang, batch_size=batch_size - ) - return initial_qa +def default_create(corpus_df, llm: BaseLLM, n: int = 100, lang: str = "en", + batch_size: int = 32) -> QA: + corpus_instance = Corpus(corpus_df) + if len(corpus_instance.data) < n: + n = len(corpus_instance.data) + sampled_corpus = corpus_instance.sample(random_single_hop, n=n) + mapped_corpus = sampled_corpus.map(lambda df: df.reset_index(drop=True)) + retrieval_gt_contents = mapped_corpus.make_retrieval_gt_contents() + query_generated = retrieval_gt_contents.batch_apply(factoid_query_gen, llm=llm, lang=lang, batch_size=batch_size) + basic_answers = query_generated.batch_apply(make_basic_gen_gt, llm=llm, lang=lang, batch_size=batch_size) + concise_answers = basic_answers.batch_apply(make_concise_gen_gt, llm=llm, lang=lang, batch_size=batch_size) + filtered_answers = concise_answers.filter(dontknow_filter_rule_based, lang=lang) + initial_qa = filtered_answers.batch_filter(passage_dependency_filter_llama_index, llm=llm, lang=lang, batch_size=batch_size) + return initial_qa -def fast_create( - corpus_df, llm: BaseLLM, n: int = 100, lang: str = "en", batch_size: int = 32 -) -> QA: - corpus_instance = Corpus(corpus_df) - if len(corpus_instance.data) < n: - n = len(corpus_instance.data) +def fast_create(corpus_df, llm: BaseLLM, n: int = 100, lang: str = "en", + batch_size: int = 32) -> QA: + corpus_instance = Corpus(corpus_df) + if len(corpus_instance.data) < n: + n = len(corpus_instance.data) - sampled_corpus = corpus_instance.sample(random_single_hop, n=n) - mapped_corpus = sampled_corpus.map(lambda df: df.reset_index(drop=True)) + sampled_corpus = corpus_instance.sample(random_single_hop, n=n) + mapped_corpus = sampled_corpus.map(lambda df: df.reset_index(drop=True)) - retrieval_gt_contents = mapped_corpus.make_retrieval_gt_contents() + retrieval_gt_contents = mapped_corpus.make_retrieval_gt_contents() - query_generated = retrieval_gt_contents.batch_apply( - factoid_query_gen, llm=llm, lang=lang, batch_size=batch_size - ) + query_generated = retrieval_gt_contents.batch_apply(factoid_query_gen, llm=llm, lang=lang, batch_size=batch_size) - basic_answers = query_generated.batch_apply( - make_basic_gen_gt, llm=llm, lang=lang, batch_size=batch_size - ) + basic_answers = query_generated.batch_apply(make_basic_gen_gt, llm=llm, lang=lang, batch_size=batch_size) - concise_answers = basic_answers.batch_apply( - make_concise_gen_gt, llm=llm, lang=lang, batch_size=batch_size - ) + concise_answers = basic_answers.batch_apply(make_concise_gen_gt, llm=llm, lang=lang, batch_size=batch_size) - initial_qa = concise_answers + initial_qa = concise_answers - return initial_qa + return initial_qa -def advanced_create( - corpus_df, llm: BaseLLM, n: int = 100, lang: str = "en", batch_size: int = 32 -) -> QA: - """ - Mix hard and easy question. - """ - corpus_instance = Corpus(corpus_df) - if len(corpus_instance.data) < n: - n = len(corpus_instance.data) - sampled_corpus = corpus_instance.sample(random_single_hop, n=n) - mapped_corpus = sampled_corpus.map(lambda df: df.reset_index(drop=True)) - retrieval_gt_contents = mapped_corpus.make_retrieval_gt_contents() - query_generated = retrieval_gt_contents.batch_apply( - factoid_query_gen, llm=llm, lang=lang, batch_size=batch_size - ) - basic_answers = query_generated.batch_apply( - make_basic_gen_gt, llm=llm, lang=lang, batch_size=batch_size - ) - concise_answers = basic_answers.batch_apply( - make_concise_gen_gt, llm=llm, lang=lang, batch_size=batch_size - ) - filtered_answers = concise_answers.filter(dontknow_filter_rule_based, lang=lang) - initial_qa = filtered_answers.batch_filter( - passage_dependency_filter_llama_index, llm=llm, lang=lang, batch_size=batch_size - ) - cut_idx = n // 2 - reasoning_qa = initial_qa.map(lambda df: df.iloc[:cut_idx]).batch_apply( - reasoning_evolve_ragas, - llm=llm, - lang=lang, - batch_size=batch_size, - ) - compressed_qa = ( - initial_qa.map(lambda df: df.iloc[cut_idx:]) - .map(lambda df: df.reset_index(drop=True)) - .batch_apply( - compress_ragas, - llm=llm, - lang=lang, - batch_size=batch_size, - ) - ) - final_qa = QA( - pd.concat([reasoning_qa.data, compressed_qa.data], ignore_index=True), - linked_corpus=corpus_instance, - ) +def advanced_create(corpus_df, llm: BaseLLM, n: int = 100, lang: str = "en", + batch_size: int = 32) -> QA: + """ + Mix hard and easy question. + """ + corpus_instance = Corpus(corpus_df) + if len(corpus_instance.data) < n: + n = len(corpus_instance.data) + sampled_corpus = corpus_instance.sample(random_single_hop, n=n) + mapped_corpus = sampled_corpus.map(lambda df: df.reset_index(drop=True)) + retrieval_gt_contents = mapped_corpus.make_retrieval_gt_contents() + query_generated = retrieval_gt_contents.batch_apply(factoid_query_gen, llm=llm, lang=lang, batch_size=batch_size) + basic_answers = query_generated.batch_apply(make_basic_gen_gt, llm=llm, lang=lang, batch_size=batch_size) + concise_answers = basic_answers.batch_apply(make_concise_gen_gt, llm=llm, lang=lang, batch_size=batch_size) + filtered_answers = concise_answers.filter(dontknow_filter_rule_based, lang=lang) + initial_qa = filtered_answers.batch_filter(passage_dependency_filter_llama_index, llm=llm, lang=lang, batch_size=batch_size) + cut_idx = n // 2 + reasoning_qa = initial_qa.map(lambda df: df.iloc[:cut_idx]).batch_apply( + reasoning_evolve_ragas, + llm=llm, + lang=lang, + batch_size=batch_size, + ) + compressed_qa = initial_qa.map(lambda df: df.iloc[cut_idx:]).map(lambda df: df.reset_index(drop=True)).batch_apply( + compress_ragas, + llm=llm, + lang=lang, + batch_size=batch_size, + ) + final_qa = QA(pd.concat([reasoning_qa.data, compressed_qa.data], ignore_index=True), + linked_corpus=corpus_instance) - return final_qa + return final_qa From 0473cb37253caafb19940fc844367ab398593137 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=8D=EC=8A=B9=EC=9A=B0?= Date: Tue, 19 Nov 2024 21:22:13 +0900 Subject: [PATCH 30/55] =?UTF-8?q?=E2=9C=A8=20feat:=20Add=20dashboard=20mod?= =?UTF-8?q?ule=20to=20autorag=20package=20and=20implement=20async=20parser?= =?UTF-8?q?=20function?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/src/run.py | 170 ++++++++++++++++++++++++------------------------- 1 file changed, 82 insertions(+), 88 deletions(-) diff --git a/api/src/run.py b/api/src/run.py index c53605f72..b0e7ddacf 100644 --- a/api/src/run.py +++ b/api/src/run.py @@ -1,9 +1,8 @@ import os import subprocess -from typing import Optional import pandas as pd -from autorag import generator_models +from autorag import generator_models, dashboard from autorag.chunker import Chunker from autorag.data.qa.schema import QA from autorag.evaluator import Evaluator @@ -11,101 +10,96 @@ from autorag.validator import Validator from src.qa_create import default_create, fast_create, advanced_create -from src.schema import QACreationRequest, Status -from src.trial_config import PandasTrialDB - +from src.schema import QACreationRequest + +# async def run_parser_start_parsing(raw_data_path: str, dataset_dir: str, yaml_path: str): +# try: +# # 경로들이 실제로 존재하는지 확인 +# if not os.path.exists(raw_data_path): +# raise ValueError(f"Raw data path does not exist: {raw_data_path}") +# if not os.path.exists(dataset_dir): +# os.makedirs(dataset_dir) + +# # 명령어 구성 +# cmd = [ +# "python", "-m", "autorag.cli", +# "parse", +# "--input-path", str(raw_data_path), # 문자열로 명시적 변환 +# "--output-path", str(dataset_dir), # 문자열로 명시적 변환 +# "--config-path", str(yaml_path) # 문자열로 명시적 변환 +# ] + +# # subprocess로 실행 +# process = await asyncio.create_subprocess_exec( +# *cmd, +# stdout=asyncio.subprocess.PIPE, +# stderr=asyncio.subprocess.PIPE +# ) + +# # 결과 대기 +# stdout, stderr = await process.communicate() + +# # 에러 체크 +# if process.returncode != 0: +# error_msg = stderr.decode() if stderr else "Unknown error" +# raise RuntimeError(f"Parser failed: {error_msg}") + +# print(f"Parser completed successfully") +# return True + +# except Exception as e: +# print(f"Error in parser: {str(e)}") +# raise def run_parser_start_parsing(data_path_glob, project_dir, yaml_path): - # Import Parser here if it's defined in another module - parser = Parser(data_path_glob=data_path_glob, project_dir=project_dir) - parser.start_parsing(yaml_path) - + # Import Parser here if it's defined in another module + parser = Parser(data_path_glob=data_path_glob, project_dir=project_dir) + print(f"Parser started with data_path_glob: {data_path_glob}, project_dir: {project_dir}, yaml_path: {yaml_path}") + parser.start_parsing(yaml_path) + print(f"Parser completed") def run_chunker_start_chunking(raw_path, project_dir, yaml_path): - # Import Parser here if it's defined in another module - chunker = Chunker.from_parquet(raw_path, project_dir=project_dir) - chunker.start_chunking(yaml_path) - - -def run_qa_creation( - qa_creation_request: QACreationRequest, corpus_filepath: str, dataset_dir: str -): - corpus_df = pd.read_parquet(corpus_filepath, engine="pyarrow") - llm = generator_models[qa_creation_request.llm_config.llm_name]( - **qa_creation_request.llm_config.llm_params - ) - - if qa_creation_request.preset == "basic": - qa: QA = default_create( - corpus_df, - llm, - qa_creation_request.qa_num, - qa_creation_request.lang, - batch_size=8, - ) - elif qa_creation_request.preset == "simple": - qa: QA = fast_create( - corpus_df, - llm, - qa_creation_request.qa_num, - qa_creation_request.lang, - batch_size=8, - ) - elif qa_creation_request.preset == "advanced": - qa: QA = advanced_create( - corpus_df, - llm, - qa_creation_request.qa_num, - qa_creation_request.lang, - batch_size=8, - ) - else: - raise ValueError(f"Input not supported Preset {qa_creation_request.preset}") - - # dataset_dir will be folder ${PROJECT_DIR}/qa/ - qa.to_parquet( - os.path.join(dataset_dir, f"{qa_creation_request.name}.parquet"), - corpus_filepath, - ) - - -def run_start_trial( - qa_path: str, - corpus_path: str, - project_dir: str, - yaml_path: str, - skip_validation: bool = True, - full_ingest: bool = True, - trial_id: Optional[str] = None, - trial_config_path: Optional[str] = None, -): - evaluator = Evaluator(qa_path, corpus_path, project_dir=project_dir) - evaluator.start_trial( - yaml_path, skip_validation=skip_validation, full_ingest=full_ingest - ) - if trial_id is not None and trial_config_path is not None: - # Update trial status - trial_config_db = PandasTrialDB(trial_config_path) - trial = trial_config_db.get_trial(trial_id) - new_trial = trial.model_copy(deep=True) - new_trial.status = Status.COMPLETED - trial_config_db.set_trial(new_trial) + # Import Parser here if it's defined in another module + chunker = Chunker.from_parquet(raw_path, project_dir=project_dir) + chunker.start_chunking(yaml_path) +def run_qa_creation(qa_creation_request: QACreationRequest, corpus_filepath: str, dataset_dir: str): + corpus_df = pd.read_parquet(corpus_filepath, engine="pyarrow") + llm = generator_models[qa_creation_request.llm_config.llm_name]( + **qa_creation_request.llm_config.llm_params + ) -def run_validate(qa_path: str, corpus_path: str, yaml_path: str): - validator = Validator(qa_path, corpus_path) - validator.validate(yaml_path) + if qa_creation_request.preset == "basic": + qa: QA = default_create(corpus_df, llm, qa_creation_request.qa_num, qa_creation_request.lang, batch_size=8) + elif qa_creation_request.preset == "simple": + qa: QA = fast_create(corpus_df, llm, qa_creation_request.qa_num, qa_creation_request.lang, batch_size=8) + elif qa_creation_request.preset == "advanced": + qa: QA = advanced_create(corpus_df, llm, qa_creation_request.qa_num, qa_creation_request.lang, batch_size=8) + else: + raise ValueError(f"Input not supported Preset {qa_creation_request.preset}") + + # dataset_dir will be folder ${PROJECT_DIR}/qa/ + qa.to_parquet(os.path.join(dataset_dir, f"{qa_creation_request.name}.parquet"), + corpus_filepath) +def run_start_trial(qa_path: str, corpus_path: str, project_dir: str, yaml_path: str, + skip_validation: bool = True, full_ingest: bool = True): + evaluator = Evaluator(qa_path, corpus_path, project_dir=project_dir) + evaluator.start_trial(yaml_path, skip_validation=skip_validation, full_ingest=full_ingest) + + +def run_validate(qa_path: str, corpus_path: str, yaml_path: str): + validator = Validator(qa_path, corpus_path) + validator.validate(yaml_path) + def run_dashboard(trial_dir: str): - process = subprocess.Popen( - ["autorag", "dashboard", "--trial_dir", trial_dir], start_new_session=True - ) - return process.pid + process = subprocess.Popen(["autorag", "dashboard", "--trial_dir", trial_dir], + start_new_session=True) + return process.pid def run_chat(trial_dir: str): - process = subprocess.Popen( - ["autorag", "run_web", "--trial_path", trial_dir], start_new_session=True - ) - return process.pid + process = subprocess.Popen(["autorag", "run_web", "--trial_path", trial_dir], + start_new_session=True) + return process.pid From 5359b4d5d1568730d309566a9a748d7d6118efb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=8D=EC=8A=B9=EC=9A=B0?= Date: Tue, 19 Nov 2024 21:22:19 +0900 Subject: [PATCH 31/55] =?UTF-8?q?=F0=9F=9A=91=20fix:=20Refactor=20PandasTr?= =?UTF-8?q?ialDB=20to=20handle=20trial=20operations=20more=20efficiently?= =?UTF-8?q?=20and=20improve=20error=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/src/trial_config.py | 197 +++++++++++++++++++--------------------- 1 file changed, 93 insertions(+), 104 deletions(-) diff --git a/api/src/trial_config.py b/api/src/trial_config.py index 9fd6c0acf..475b95385 100644 --- a/api/src/trial_config.py +++ b/api/src/trial_config.py @@ -1,105 +1,94 @@ -import os -from abc import ABCMeta, abstractmethod -from typing import Optional, List - import pandas as pd - -from src.schema import TrialConfig, Trial - - -class BaseTrialDB(metaclass=ABCMeta): - @abstractmethod - def set_trial(self, trial: Trial): - pass - - @abstractmethod - def get_trial(self, trial_id: str) -> Optional[Trial]: - pass - - @abstractmethod - def set_trial_config(self, trial_id: str, config: TrialConfig): - pass - - @abstractmethod - def get_trial_config(self, trial_id: str) -> Optional[TrialConfig]: - pass - - @abstractmethod - def get_all_config_ids(self) -> List[str]: - pass - - -class PandasTrialDB(BaseTrialDB): - def __init__(self, df_path: str): - self.columns = [ - "id", - "project_id", - "config", - "name", - "status", - "created_at", - "report_task_id", - "chat_task_id", - ] - self.df_path = df_path - if not os.path.exists(df_path): - df = pd.DataFrame(columns=self.columns) - df.to_csv(df_path, index=False) - else: - try: - df = pd.read_csv(df_path) - except Exception: - df = pd.DataFrame(columns=self.columns) - self.df = df - - def set_trial(self, trial: Trial): - new_row = pd.DataFrame( - { - "id": [trial.id], - "project_id": [trial.project_id], - "config": [trial.config.model_dump_json()], - "name": [trial.name], - "status": [trial.status], - "created_at": [trial.created_at], - "report_task_id": [trial.report_task_id], - "chat_task_id": [trial.chat_task_id], - } - ) - if len(self.df.loc[self.df["id"] == trial.id]) > 0: - self.df = self.df.loc[self.df["id"] != trial.id] - self.df = pd.concat([self.df, new_row]) - self.df.to_csv(self.df_path, index=False) - - def get_trial(self, trial_id: str) -> Optional[Trial]: - matches = self.df[self.df["id"] == trial_id] - if len(matches) < 1: - return None - row = matches.iloc[0] - if row.empty: - return None - return Trial( - id=row["id"], - project_id=row["project_id"], - config=TrialConfig.model_validate_json(row["config"]), - name=row["name"], - status=row["status"], - created_at=row["created_at"], - report_task_id=row["report_task_id"], - chat_task_id=row["chat_task_id"], - ) - - def set_trial_config(self, trial_id: str, config: TrialConfig): - config_dict = config.model_dump_json() - self.df.loc[self.df["id"] == trial_id, "config"] = config_dict - self.df.to_csv(self.df_path, index=False) - - def get_trial_config(self, trial_id: str) -> Optional[TrialConfig]: - config_dict = self.df.loc[self.df["id"] == trial_id]["config"].tolist() - if len(config_dict) < 1: - return None - - config = TrialConfig.model_validate_json(config_dict[0]) - return config - - def get_all_config_ids(self) -> List[str]: - return self.df["id"].tolist() +import json +from typing import Optional, List +from src.schema import Trial, TrialConfig +from datetime import datetime + +class DateTimeEncoder(json.JSONEncoder): + def default(self, obj): + if isinstance(obj, datetime): + return obj.isoformat() + return super().default(obj) + +class PandasTrialDB: + def __init__(self, path: str): + self.path = path + self.columns = [ + 'id', + 'project_id', + 'config', + 'name', + 'status', + 'created_at', + 'report_task_id', + 'chat_task_id' + ] + try: + self.df = pd.read_csv(self.path) + for col in self.columns: + if col not in self.df.columns: + self.df[col] = None + except FileNotFoundError: + self.df = pd.DataFrame(columns=self.columns) + self.df.to_csv(self.path, index=False) + + def get_trial(self, trial_id: str) -> Optional[Trial]: + try: + trial_row = self.df[self.df['id'] == trial_id].iloc[0] + trial_dict = trial_row.to_dict() + + # config 문자열을 딕셔너리로 변환 + if isinstance(trial_dict['config'], str): + config_dict = json.loads(trial_dict['config']) + # config 필드만 따로 처리 + if 'config' in config_dict: + trial_dict['config'] = TrialConfig(**config_dict['config']) + else: + trial_dict['config'] = None + + # created_at을 datetime으로 변환 + if isinstance(trial_dict['created_at'], str): + trial_dict['created_at'] = datetime.fromisoformat(trial_dict['created_at']) + + return Trial(**trial_dict) + except (IndexError, KeyError, json.JSONDecodeError): + return None + + def set_trial(self, trial: Trial): + try: + row_data = { + 'id': trial.id, + 'project_id': trial.project_id, + 'config': json.dumps(trial.model_dump(), cls=DateTimeEncoder), + 'name': trial.name, + 'status': trial.status, + 'created_at': trial.created_at.isoformat() if trial.created_at else None, + 'report_task_id': None, + 'chat_task_id': None + } + + if self.df.empty or trial.id not in self.df['id'].values: + # 새 행 추가 + new_row = pd.DataFrame([row_data], columns=self.columns) + self.df = pd.concat([self.df, new_row], ignore_index=True) + else: + # 기존 행 업데이트 + idx = self.df.index[self.df['id'] == trial.id][0] + for col in self.columns: + self.df.at[idx, col] = row_data[col] + + # 변경사항 저장 + self.df.to_csv(self.path, index=False) + except Exception as e: + print(f"Error in set_trial: {e}") + print(f"Row data: {row_data}") + raise + + def get_trial_config(self, trial_id: str) -> Optional[Trial]: + return self.get_trial(trial_id) + + def set_trial_config(self, trial_id: str, config: Trial): + return self.set_trial(config) + + def get_all_trial_ids(self) -> List[str]: + return self.df['id'].tolist() From b0b1970f16ea559cdb920fe8e00969eff9719e46 Mon Sep 17 00:00:00 2001 From: jeffrey Date: Tue, 19 Nov 2024 22:29:30 +0800 Subject: [PATCH 32/55] move upload file endpoint --- api/app.py | 1752 +++++++++++++++++++++++++++------------------------- 1 file changed, 919 insertions(+), 833 deletions(-) diff --git a/api/app.py b/api/app.py index b708b9752..aafa4f564 100644 --- a/api/app.py +++ b/api/app.py @@ -6,9 +6,8 @@ import uuid from datetime import datetime from pathlib import Path -from typing import Callable, Dict, Optional -import os -from typing import List, Optional +from typing import Dict, Optional +from typing import List from quart import jsonify, request from pydantic import BaseModel import aiofiles @@ -17,23 +16,40 @@ import pandas as pd import yaml from pydantic import ValidationError -from quart import Quart, request, jsonify +from quart import Quart from quart_cors import cors # Import quart_cors to enable CORS +from quart_uploads import UploadSet, configure_uploads + from src.auth import require_auth from src.evaluate_history import get_new_trial_dir -from src.run import run_parser_start_parsing, run_chunker_start_chunking, run_qa_creation, run_start_trial, \ - run_validate, run_dashboard, run_chat -from src.schema import ChunkRequest, ParseRequest, EnvVariableRequest, QACreationRequest, Project, Task, Status, \ - TaskType, TrialCreateRequest, Trial, TrialConfig +from src.run import ( + run_parser_start_parsing, + run_chunker_start_chunking, + run_qa_creation, + run_start_trial, + run_validate, + run_dashboard, + run_chat, +) +from src.schema import ( + ChunkRequest, + ParseRequest, + EnvVariableRequest, + QACreationRequest, + Project, + Task, + Status, + TaskType, + TrialCreateRequest, + Trial, + TrialConfig, +) import nest_asyncio from src.trial_config import PandasTrialDB from src.validate import project_exists, trial_exists -from werkzeug.utils import secure_filename - -import subprocess import logging @@ -44,18 +60,19 @@ # 로깅 설정 logging.basicConfig( level=logging.INFO, - format='%(asctime)s - %(levelname)s - %(message)s', - datefmt='%Y-%m-%d %H:%M:%S' + format="%(asctime)s - %(levelname)s - %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", ) logger = logging.getLogger(__name__) # CORS 설정 -app = cors(app, +app = cors( + app, allow_origin=["http://localhost:3000"], allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], allow_headers=["Content-Type", "Authorization"], allow_credentials=True, - max_age=3600 + max_age=3600, ) print("CORS enabled for http://localhost:3000") @@ -73,253 +90,268 @@ # Function to create a task async def create_task(task_id: str, task: Task, func, *args): - """비동기 작업을 생성하고 관리하는 함수""" - tasks[task_id] = { - "task": task, - "future": asyncio.create_task(run_background_task(task_id, func, *args)) - } + """비동기 작업을 생성하고 관리하는 함수""" + tasks[task_id] = { + "task": task, + "future": asyncio.create_task(run_background_task(task_id, func, *args)), + } + async def run_background_task(task_id: str, func, *args): - """백그라운드 작업을 실행하는 함수""" - task_info = tasks[task_id] - task = task_info["task"] - - try: - loop = asyncio.get_event_loop() - logger.info(f"Executing {func.__name__} with args: {args}") - - def execute(): - return func(*args) # 인자를 그대로 언패킹하여 전달 - - result = await loop.run_in_executor(None, execute) - task.status = Status.COMPLETED - return result - except Exception as e: - logger.error(f"Task {task_id} failed with error: {func.__name__}({args}) {e}") - task.status = Status.FAILED - task.error = str(e) - raise + """백그라운드 작업을 실행하는 함수""" + task_info = tasks[task_id] + task = task_info["task"] + + try: + loop = asyncio.get_event_loop() + logger.info(f"Executing {func.__name__} with args: {args}") + + def execute(): + return func(*args) # 인자를 그대로 언패킹하여 전달 + + result = await loop.run_in_executor(None, execute) + task.status = Status.COMPLETED + return result + except Exception as e: + logger.error(f"Task {task_id} failed with error: {func.__name__}({args}) {e}") + task.status = Status.FAILED + task.error = str(e) + raise + async def task_runner(): - global current_task_id - loop = asyncio.get_running_loop() - executor = concurrent.futures.ProcessPoolExecutor() - try: - while True: - task_id = await task_queue.get() - async with lock: - current_task_id = task_id - tasks[task_id]['task'].status = Status.IN_PROGRESS - - try: - # Get function and arguments from task info - func = tasks[task_id]['function'] - args = tasks[task_id].get('args', ()) - - # Run the function in a separate process - future = loop.run_in_executor( - executor, - func, - *args, - ) - task_futures[task_id] = future - - await future - if func.__name__ == run_dashboard.__name__: - tasks[task_id]["report_pid"] = future.result() - elif func.__name__ == run_chat.__name__: - tasks[task_id]["chat_pid"] = future.result() - - # Update status on completion - async with lock: - print(f"Task {task_id} is completed") - tasks[task_id]['task'].status = Status.COMPLETED - current_task_id = None - except asyncio.CancelledError: - tasks[task_id]['task'].status = Status.TERMINATED - print(f"Task {task_id} has been forcefully terminated.") - except Exception as e: - # Handle errors - async with lock: - tasks[task_id]['task'].status = Status.FAILED - tasks[task_id]['error'] = str(e) - current_task_id = None - print(f"Task {task_id} failed with error: task_runner {e}") - print(e) - - finally: - task_queue.task_done() - task_futures.pop(task_id, None) - finally: - executor.shutdown() + global current_task_id + loop = asyncio.get_running_loop() + executor = concurrent.futures.ProcessPoolExecutor() + try: + while True: + task_id = await task_queue.get() + async with lock: + current_task_id = task_id + tasks[task_id]["task"].status = Status.IN_PROGRESS + + try: + # Get function and arguments from task info + func = tasks[task_id]["function"] + args = tasks[task_id].get("args", ()) + + # Run the function in a separate process + future = loop.run_in_executor( + executor, + func, + *args, + ) + task_futures[task_id] = future + + await future + if func.__name__ == run_dashboard.__name__: + tasks[task_id]["report_pid"] = future.result() + elif func.__name__ == run_chat.__name__: + tasks[task_id]["chat_pid"] = future.result() + + # Update status on completion + async with lock: + print(f"Task {task_id} is completed") + tasks[task_id]["task"].status = Status.COMPLETED + current_task_id = None + except asyncio.CancelledError: + tasks[task_id]["task"].status = Status.TERMINATED + print(f"Task {task_id} has been forcefully terminated.") + except Exception as e: + # Handle errors + async with lock: + tasks[task_id]["task"].status = Status.FAILED + tasks[task_id]["error"] = str(e) + current_task_id = None + print(f"Task {task_id} failed with error: task_runner {e}") + print(e) + + finally: + task_queue.task_done() + task_futures.pop(task_id, None) + finally: + executor.shutdown() + async def cancel_task(task_id: str) -> None: - async with lock: - future = task_futures.get(task_id) - if future and not future.done(): - try: - # Attempt to kill the associated process directly - future.cancel() - except Exception as e: - tasks[task_id]['task'].status = Status.FAILED - tasks[task_id]['error'] = f"Failed to terminate: {str(e)}" - print(f"Task {task_id} failed to terminate with error: {e}") - else: - print(f"Task {task_id} is not running or already completed.") + async with lock: + future = task_futures.get(task_id) + if future and not future.done(): + try: + # Attempt to kill the associated process directly + future.cancel() + except Exception as e: + tasks[task_id]["task"].status = Status.FAILED + tasks[task_id]["error"] = f"Failed to terminate: {str(e)}" + print(f"Task {task_id} failed to terminate with error: {e}") + else: + print(f"Task {task_id} is not running or already completed.") + @app.before_serving async def startup(): - # Start the background task when the app starts - app.add_background_task(task_runner) + # Start the background task when the app starts + app.add_background_task(task_runner) + # Project creation endpoint -@app.route('/projects', methods=['POST']) +@app.route("/projects", methods=["POST"]) @require_auth() async def create_project(): - data = await request.get_json() - - # Validate required fields - if not data or 'name' not in data: - return jsonify({'error': 'Name is required'}), 400 - - description = data.get("description", "") - - # Create a new project - new_project_dir = os.path.join(WORK_DIR, data['name']) - if not os.path.exists(new_project_dir): - os.makedirs(new_project_dir) - os.makedirs(os.path.join(new_project_dir, "parse")) - os.makedirs(os.path.join(new_project_dir, "chunk")) - os.makedirs(os.path.join(new_project_dir, "qa")) - os.makedirs(os.path.join(new_project_dir, "project")) - os.makedirs(os.path.join(new_project_dir, "config")) - # Make trial_config.csv file - _ = PandasTrialDB(os.path.join(new_project_dir, "trial_config.csv")) - else: - return jsonify({ - 'error': f'Project name already exists: {data["name"]}' - }), 400 - - # save at 'description.txt' file - with open(os.path.join(new_project_dir, "description.txt"), "w") as f: - f.write(description) - - response = Project( - id=data["name"], - name=data["name"], - description=description, - created_at=datetime.now(), - status="active", - metadata={}, - ) - return jsonify(response.model_dump()), 201 + data = await request.get_json() + + # Validate required fields + if not data or "name" not in data: + return jsonify({"error": "Name is required"}), 400 + + description = data.get("description", "") + + # Create a new project + new_project_dir = os.path.join(WORK_DIR, data["name"]) + if not os.path.exists(new_project_dir): + os.makedirs(new_project_dir) + os.makedirs(os.path.join(new_project_dir, "parse")) + os.makedirs(os.path.join(new_project_dir, "chunk")) + os.makedirs(os.path.join(new_project_dir, "qa")) + os.makedirs(os.path.join(new_project_dir, "project")) + os.makedirs(os.path.join(new_project_dir, "config")) + # Make trial_config.csv file + _ = PandasTrialDB(os.path.join(new_project_dir, "trial_config.csv")) + else: + return jsonify({"error": f'Project name already exists: {data["name"]}'}), 400 + + # save at 'description.txt' file + with open(os.path.join(new_project_dir, "description.txt"), "w") as f: + f.write(description) + + response = Project( + id=data["name"], + name=data["name"], + description=description, + created_at=datetime.now(), + status="active", + metadata={}, + ) + return jsonify(response.model_dump()), 201 async def get_project_directories(): - """Get all project directories from WORK_DIR.""" - directories = [] - - # List all directories in WORK_DIR - for item in Path(WORK_DIR).iterdir(): - if item.is_dir(): - directories.append({ - "name": item.name, - "status": "active", # All projects are currently active - "path": str(item), - "last_modified_datetime": datetime.fromtimestamp(item.stat().st_mtime), - "created_datetime": datetime.fromtimestamp(item.stat().st_ctime), - }) - - directories.sort(key=lambda x: x['last_modified_datetime'], reverse=True) - return directories - - -@app.route('/projects', methods=['GET']) + """Get all project directories from WORK_DIR.""" + directories = [] + + # List all directories in WORK_DIR + for item in Path(WORK_DIR).iterdir(): + if item.is_dir(): + directories.append( + { + "name": item.name, + "status": "active", # All projects are currently active + "path": str(item), + "last_modified_datetime": datetime.fromtimestamp( + item.stat().st_mtime + ), + "created_datetime": datetime.fromtimestamp(item.stat().st_ctime), + } + ) + + directories.sort(key=lambda x: x["last_modified_datetime"], reverse=True) + return directories + + +@app.route("/projects", methods=["GET"]) async def list_projects(): - """List all projects with pagination. It returns the last modified projects first.""" - # Get query parameters with defaults - page = request.args.get('page', 1, type=int) - limit = request.args.get('limit', 10, type=int) - status = request.args.get('status', 'active') - - # Validate pagination parameters - if page < 1: - page = 1 - if limit < 1: - limit = 10 - - # Get all projects - projects = await get_project_directories() - - # Filter by status if provided (though all are active) - if status: - projects = [p for p in projects if p['status'] == status] - - # Calculate pagination - total = len(projects) - start_idx = (page - 1) * limit - end_idx = start_idx + limit - - # Get paginated data - paginated_projects = projects[start_idx:end_idx] - - # Get descriptions from paginated data - def get_project_description(project_name): - description_path = os.path.join(WORK_DIR, project_name, "description.txt") - try: - with open(description_path, "r") as f: - return f.read() - except FileNotFoundError: - # 파일이 없으면 빈 description.txt 파일 생성 - with open(description_path, "w") as f: - f.write(f"## {project_name}") - return "" - - projects = [Project( - id=p["name"], - name=p["name"], - description=get_project_description(p["name"]), - created_at=p["created_datetime"], - status=p["status"], - metadata={}, - ) for p in paginated_projects] - - return jsonify({ - "total": total, - "data": list(map(lambda p: p.model_dump(), projects)), - }), 200 + """List all projects with pagination. It returns the last modified projects first.""" + # Get query parameters with defaults + page = request.args.get("page", 1, type=int) + limit = request.args.get("limit", 10, type=int) + status = request.args.get("status", "active") + + # Validate pagination parameters + if page < 1: + page = 1 + if limit < 1: + limit = 10 + + # Get all projects + projects = await get_project_directories() + + # Filter by status if provided (though all are active) + if status: + projects = [p for p in projects if p["status"] == status] + + # Calculate pagination + total = len(projects) + start_idx = (page - 1) * limit + end_idx = start_idx + limit + + # Get paginated data + paginated_projects = projects[start_idx:end_idx] + + # Get descriptions from paginated data + def get_project_description(project_name): + description_path = os.path.join(WORK_DIR, project_name, "description.txt") + try: + with open(description_path, "r") as f: + return f.read() + except FileNotFoundError: + # 파일이 없으면 빈 description.txt 파일 생성 + with open(description_path, "w") as f: + f.write(f"## {project_name}") + return "" + + projects = [ + Project( + id=p["name"], + name=p["name"], + description=get_project_description(p["name"]), + created_at=p["created_datetime"], + status=p["status"], + metadata={}, + ) + for p in paginated_projects + ] + + return jsonify( + { + "total": total, + "data": list(map(lambda p: p.model_dump(), projects)), + } + ), 200 @app.route("/projects//trials", methods=["GET"]) @project_exists(WORK_DIR) async def get_trial_lists(project_id: str): - trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") - trial_config_db = PandasTrialDB(trial_config_path) - trial_ids = trial_config_db.get_all_trial_ids() - return jsonify({ - "total": len(trial_ids), - "data": list(map(lambda x: trial_config_db.get_trial(x).model_dump(), - trial_ids)), - }) + trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") + trial_config_db = PandasTrialDB(trial_config_path) + trial_ids = trial_config_db.get_all_trial_ids() + return jsonify( + { + "total": len(trial_ids), + "data": list( + map(lambda x: trial_config_db.get_trial(x).model_dump(), trial_ids) + ), + } + ) + class FileNode(BaseModel): name: str type: str # 'directory' or 'file' - children: Optional[List['FileNode']] = None + children: Optional[List["FileNode"]] = None + FileNode.model_rebuild() + async def scan_directory(path: str) -> FileNode: """비동기적으로 디렉토리를 스캔하여 파일 트리 구조를 생성합니다.""" basename = os.path.basename(path) - + # 파일인지 확인 if os.path.isfile(path): - return FileNode( - name=basename, - type='file' - ) - + return FileNode(name=basename, type="file") + # 디렉토리인 경우 children = [] try: @@ -328,169 +360,155 @@ async def scan_directory(path: str) -> FileNode: for item in entries: item_path = os.path.join(path, item.name) # 숨김 파일 제외 - if not item.name.startswith('.'): + if not item.name.startswith("."): children.append(await scan_directory(item_path)) except PermissionError: pass - + return FileNode( name=basename, - type='directory', - children=sorted(children, key=lambda x: (x.type == 'file', x.name)) + type="directory", + children=sorted(children, key=lambda x: (x.type == "file", x.name)), ) + @app.route("/projects//trials", methods=["POST"]) @project_exists(WORK_DIR) async def create_new_trial(project_id: str): - trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") - - data = await request.get_json() - try: - creation_request = TrialCreateRequest(**data) - except ValidationError as e: - return jsonify({ - "error": f"Invalid request format : {e}", - }), 400 - trial_id = str(uuid.uuid4()) - request_dict = creation_request.model_dump() - if request_dict["config"] is not None: - config_path = os.path.join(WORK_DIR, project_id, "config", f"{str(uuid.uuid4())}.yaml") - async with aiofiles.open(config_path, 'w') as f: - await f.write(yaml.safe_dump(request_dict["config"])) - else: - config_path = None - - request_dict["trial_id"] = trial_id - request_dict["project_id"] = project_id - request_dict["config_path"] = config_path - request_dict["metadata"] = {} - request_dict.pop("config") - name = request_dict.pop("name") - - new_trial_config = TrialConfig(**request_dict) - new_trial = Trial( - id=trial_id, + trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") + + data = await request.get_json() + try: + creation_request = TrialCreateRequest(**data) + except ValidationError as e: + return jsonify( + { + "error": f"Invalid request format : {e}", + } + ), 400 + trial_id = str(uuid.uuid4()) + request_dict = creation_request.model_dump() + if request_dict["config"] is not None: + config_path = os.path.join( + WORK_DIR, project_id, "config", f"{str(uuid.uuid4())}.yaml" + ) + async with aiofiles.open(config_path, "w") as f: + await f.write(yaml.safe_dump(request_dict["config"])) + else: + config_path = None + + request_dict["trial_id"] = trial_id + request_dict["project_id"] = project_id + request_dict["config_path"] = config_path + request_dict["metadata"] = {} + request_dict.pop("config") + name = request_dict.pop("name") + + new_trial_config = TrialConfig(**request_dict) + new_trial = Trial( + id=trial_id, project_id=project_id, config=new_trial_config, name=name, status=Status.NOT_STARTED, created_at=datetime.now(), - ) - trial_config_db = PandasTrialDB(trial_config_path) - trial_config_db.set_trial(new_trial) - return jsonify(new_trial.model_dump()), 202 + ) + trial_config_db = PandasTrialDB(trial_config_path) + trial_config_db.set_trial(new_trial) + return jsonify(new_trial.model_dump()), 202 @app.route("/projects//trials/", methods=["GET"]) @project_exists(WORK_DIR) @trial_exists(WORK_DIR) async def get_trial(project_id: str, trial_id: str): - trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") - trial_config_db = PandasTrialDB(trial_config_path) - return jsonify(trial_config_db.get_trial(trial_id).model_dump()), 200 + trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") + trial_config_db = PandasTrialDB(trial_config_path) + return jsonify(trial_config_db.get_trial(trial_id).model_dump()), 200 + @app.route("/projects//artifacts/files", methods=["GET"]) @project_exists(WORK_DIR) async def get_artifact_file(project_id: str): """특정 파일의 내용을 비동기적으로 반환합니다.""" - file_path = request.args.get('path') + file_path = request.args.get("path") if not file_path: - return jsonify({ - "error": "File path is required" - }), 400 + return jsonify({"error": "File path is required"}), 400 try: full_path = os.path.join(WORK_DIR, project_id, file_path) - + # 경로 검증 (디렉토리 트래버설 방지) - if not os.path.normpath(full_path).startswith(os.path.normpath(os.path.join(WORK_DIR, project_id))): - return jsonify({ - "error": "Invalid file path" - }), 403 + if not os.path.normpath(full_path).startswith( + os.path.normpath(os.path.join(WORK_DIR, project_id)) + ): + return jsonify({"error": "Invalid file path"}), 403 # 비동기로 파일 재 여부 확인 if not await aiofiles.os.path.exists(full_path): - return jsonify({ - "error": "File not found" - }), 404 + return jsonify({"error": "File not found"}), 404 if not await aiofiles.os.path.isfile(full_path): - return jsonify({ - "error": "Path is not a file" - }), 400 + return jsonify({"error": "Path is not a file"}), 400 # 파일 크기 체크 stats = await aiofiles.os.stat(full_path) if stats.st_size > 10 * 1024 * 1024: # 10MB 제한 - return jsonify({ - "error": "File too large" - }), 400 + return jsonify({"error": "File too large"}), 400 # 파일 확장자 체크 _, ext = os.path.splitext(full_path) - allowed_extensions = {'.txt', '.yaml', '.yml', '.json', '.py', '.md'} + allowed_extensions = {".txt", ".yaml", ".yml", ".json", ".py", ".md"} if ext.lower() not in allowed_extensions: - return jsonify({ - "error": "File type not supported" - }), 400 + return jsonify({"error": "File type not supported"}), 400 # 비동기로 파일 읽기 - async with aiofiles.open(full_path, 'r', encoding='utf-8') as f: + async with aiofiles.open(full_path, "r", encoding="utf-8") as f: content = await f.read() - return jsonify({ - "content": content, - "path": file_path, - "size": stats.st_size, - "last_modified": stats.st_mtime - }), 200 + return jsonify( + { + "content": content, + "path": file_path, + "size": stats.st_size, + "last_modified": stats.st_mtime, + } + ), 200 except Exception as e: - return jsonify({ - "error": f"Failed to read file: {str(e)}" - }), 500 + return jsonify({"error": f"Failed to read file: {str(e)}"}), 500 + @app.route("/projects//upload", methods=["POST"]) @project_exists(WORK_DIR) -@trial_exists(WORK_DIR) -async def upload_files(project_id: str, trial_id: str): - try: - # Get the files from the request - files = await request.files.getlist('files') - - if not files: - return jsonify({ - 'error': 'No files were uploaded' - }), 400 - - # Create upload directory if it doesn't exist - upload_dir = os.path.join(WORK_DIR, project_id, "uploads", trial_id) - os.makedirs(upload_dir, exist_ok=True) - - uploaded_paths = [] - - # Save each file - for file in files: - if file.filename: - # Secure the filename - filename = secure_filename(file.filename) - file_path = os.path.join(upload_dir, filename) - - # Save the file - await file.save(file_path) - uploaded_paths.append(file_path) - - return jsonify({ - 'message': 'Files uploaded successfully', - 'filePaths': uploaded_paths - }), 200 - - except Exception as e: - return jsonify({ - 'error': f'An error occurred while uploading files: {str(e)}' - }), 500 - -@app.route("/projects//trials//parse", methods=["POST"]) +async def upload_files(project_id: str): + # Setting upload + raw_data_path = os.path.join(WORK_DIR, project_id, "raw_data") + files = UploadSet() + files.default_dest = raw_data_path + configure_uploads(app, files) + try: + filename = await files.save((await request.files)["file"]) + + if not filename: + return jsonify({"error": "No files were uploaded"}), 400 + + return jsonify( + { + "message": "Files uploaded successfully", + "filePaths": os.path.join(raw_data_path, filename), + } + ), 200 + + except Exception as e: + return jsonify( + {"error": f"An error occurred while uploading files: {str(e)}"} + ), 500 + + +@app.route( + "/projects//trials//parse", methods=["POST"] +) @project_exists(WORK_DIR) @trial_exists(WORK_DIR) async def start_parsing(project_id: str, trial_id: str): @@ -507,45 +525,49 @@ async def start_parsing(project_id: str, trial_id: str): logger.info(f"Project directory: {project_dir}") logger.info(f"Raw data path: {raw_data_path}") - + # data_path_glob 생성 - raw_data 디렉토리 내의 모든 파일을 포함 data_path_glob = os.path.join(raw_data_path, "*.pdf") logger.info(f"Data path glob: {data_path_glob}") - + # 필요한 디렉토리 생성 os.makedirs(config_dir, exist_ok=True) if not os.path.exists(dataset_dir): os.makedirs(dataset_dir) else: - return jsonify({ - 'error': f'Parse dataset name already exists: {parse_request.name}' - }), 400 - + return jsonify( + {"error": f"Parse dataset name already exists: {parse_request.name}"} + ), 400 + # 파일 검색 files = [] for root, _, filenames in os.walk(raw_data_path): for filename in filenames: - if not filename.startswith('.'): + if not filename.startswith("."): files.append(os.path.join(root, filename)) - + if not files: - return jsonify({ - 'error': f'No valid files found in {raw_data_path}. Please upload some files first.' - }), 400 + return jsonify( + { + "error": f"No valid files found in {raw_data_path}. Please upload some files first." + } + ), 400 logger.info(f"Found files: {files}") # YAML 설정 파일 생성 yaml_config = { - "modules": [{ - "module_type": "langchain_parse", - "parse_method": "pdfminer", - }] + "modules": [ + { + "module_type": "langchain_parse", + "parse_method": "pdfminer", + } + ] } - + yaml_path = os.path.join(config_dir, f"parse_config_{trial_id}.yaml") - with open(yaml_path, 'w', encoding='utf-8') as f: - yaml.safe_dump(yaml_config, f, encoding='utf-8', allow_unicode=True) + with open(yaml_path, "w", encoding="utf-8") as f: + yaml.safe_dump(yaml_config, f, encoding="utf-8", allow_unicode=True) logger.info(f"Created config file at {yaml_path} with content: {yaml_config}") @@ -562,9 +584,18 @@ async def start_parsing(project_id: str, trial_id: str): created_at=datetime.now(), save_path=save_path, ) - - logger.info(f"Creating task with data_path_glob: {data_path_glob}, project_dir: {save_path}, yaml_path: {yaml_path}") - await create_task(task_id, task, run_parser_start_parsing, data_path_glob, save_path, yaml_path) + + logger.info( + f"Creating task with data_path_glob: {data_path_glob}, project_dir: {save_path}, yaml_path: {yaml_path}" + ) + await create_task( + task_id, + task, + run_parser_start_parsing, + data_path_glob, + save_path, + yaml_path, + ) return jsonify(task.model_dump()), 202 except Exception as e: @@ -574,388 +605,443 @@ async def start_parsing(project_id: str, trial_id: str): os.remove(yaml_path) except Exception as cleanup_error: logger.error(f"Error cleaning up yaml file: {cleanup_error}") - return jsonify({ - 'error': f'An error occurred: {str(e)}' - }), 500 + return jsonify({"error": f"An error occurred: {str(e)}"}), 500 + -@app.route("/projects//trials//chunk", methods=["POST"]) +@app.route( + "/projects//trials//chunk", methods=["POST"] +) @project_exists(WORK_DIR) @trial_exists(WORK_DIR) async def start_chunking(project_id: str, trial_id: str): - try: - # Get JSON data from request and validate with Pydantic - data = await request.get_json() - chunk_request = ChunkRequest(**data) - - trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") - trial_config_db = PandasTrialDB(trial_config_path) - previous_config = trial_config_db.get_trial_config(trial_id) - - - parsed_data_path = os.path.join(WORK_DIR, project_id, "parse", f"parse_{previous_config.id}/0.parquet") - # parsed_data_path 확인 - print(f"parsed_data_path: {parsed_data_path}") - if not parsed_data_path or not os.path.exists(parsed_data_path): - return jsonify({ - "error": "Parsed data path not found. Please run parse first." - }), 400 - - # Get the directory containing datasets - dataset_dir = os.path.join(WORK_DIR, project_id, "chunk", chunk_request.name) - if not os.path.exists(dataset_dir): - os.makedirs(dataset_dir) - - with tempfile.NamedTemporaryFile(suffix=".yaml", delete=False) as yaml_tempfile: - with open(yaml_tempfile.name, "w") as w: - yaml.safe_dump(chunk_request.config, w) - yaml_path = yaml_tempfile.name - - task_id = str(uuid.uuid4()) - response = Task( - id=task_id, - project_id=project_id, - trial_id=trial_id, - name=chunk_request.name, - config_yaml=chunk_request.config, - status=Status.IN_PROGRESS, - type=TaskType.CHUNK, - created_at=datetime.now(), - - save_path=dataset_dir, - ) - await create_task(task_id, response, run_chunker_start_chunking, - parsed_data_path, # raw_filepath 대신 parsed_data_path 사용 - dataset_dir, yaml_path) - - # Update trial config - new_config = previous_config.model_copy(deep=True) - new_config.corpus_path = os.path.join(dataset_dir, "0.parquet") - trial_config_db.set_trial_config(trial_id, new_config) - - - return jsonify(response.model_dump()), 202 - - except ValueError as ve: - print(f"ValueError in chunk: {ve}") - logger.error(f"ValueError in chunk: {ve}", exc_info=True) - - # Handle Pydantic validation errors - return jsonify({ - 'error': f'Validation error: {str(ve)}' - }), 400 - - except Exception as e: - print(f"Error in chunk: {e}") - logger.error(f"Error in chunk: {e}", exc_info=True) - return jsonify({ - 'error': f'An error occurred: {str(e)}' - }), 500 - - -@app.route("/projects//trials//qa", methods=["POST"]) + try: + # Get JSON data from request and validate with Pydantic + data = await request.get_json() + chunk_request = ChunkRequest(**data) + + trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") + trial_config_db = PandasTrialDB(trial_config_path) + previous_config = trial_config_db.get_trial_config(trial_id) + + parsed_data_path = os.path.join( + WORK_DIR, project_id, "parse", f"parse_{previous_config.id}/0.parquet" + ) + # parsed_data_path 확인 + print(f"parsed_data_path: {parsed_data_path}") + if not parsed_data_path or not os.path.exists(parsed_data_path): + return jsonify( + {"error": "Parsed data path not found. Please run parse first."} + ), 400 + + # Get the directory containing datasets + dataset_dir = os.path.join(WORK_DIR, project_id, "chunk", chunk_request.name) + if not os.path.exists(dataset_dir): + os.makedirs(dataset_dir) + + with tempfile.NamedTemporaryFile(suffix=".yaml", delete=False) as yaml_tempfile: + with open(yaml_tempfile.name, "w") as w: + yaml.safe_dump(chunk_request.config, w) + yaml_path = yaml_tempfile.name + + task_id = str(uuid.uuid4()) + response = Task( + id=task_id, + project_id=project_id, + trial_id=trial_id, + name=chunk_request.name, + config_yaml=chunk_request.config, + status=Status.IN_PROGRESS, + type=TaskType.CHUNK, + created_at=datetime.now(), + save_path=dataset_dir, + ) + await create_task( + task_id, + response, + run_chunker_start_chunking, + parsed_data_path, # raw_filepath 대신 parsed_data_path 사용 + dataset_dir, + yaml_path, + ) + + # Update trial config + new_config = previous_config.model_copy(deep=True) + new_config.corpus_path = os.path.join(dataset_dir, "0.parquet") + trial_config_db.set_trial_config(trial_id, new_config) + + return jsonify(response.model_dump()), 202 + + except ValueError as ve: + print(f"ValueError in chunk: {ve}") + logger.error(f"ValueError in chunk: {ve}", exc_info=True) + + # Handle Pydantic validation errors + return jsonify({"error": f"Validation error: {str(ve)}"}), 400 + + except Exception as e: + print(f"Error in chunk: {e}") + logger.error(f"Error in chunk: {e}", exc_info=True) + return jsonify({"error": f"An error occurred: {str(e)}"}), 500 + + +@app.route( + "/projects//trials//qa", methods=["POST"] +) @project_exists(WORK_DIR) @trial_exists(WORK_DIR) async def create_qa(project_id: str, trial_id: str): - data = await request.get_json() - try: - qa_creation_request = QACreationRequest(**data) - dataset_dir = os.path.join(WORK_DIR, project_id, "qa") - - if not os.path.exists(dataset_dir): - os.makedirs(dataset_dir) - - save_path = os.path.join(dataset_dir, f"{qa_creation_request.name}.parquet") - - if os.path.exists(save_path): - return jsonify({ - 'error': f'QA dataset name already exists: {qa_creation_request.name}' - }), 400 - - trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") - trial_config_db = PandasTrialDB(trial_config_path) - previous_config = trial_config_db.get_trial_config(trial_id) - - corpus_filepath = os.path.join(WORK_DIR, project_id, "chunk", f"chunk_{previous_config.id}/0.parquet") - # corpus_filepath = previous_config.corpus_path - print(f"previous_config: {previous_config}") - print(f"corpus_filepath: {corpus_filepath}") - - if corpus_filepath is None or not corpus_filepath or not os.path.exists(corpus_filepath): - return jsonify({ - "error": "Corpus data path not found" - }), 400 - - task_id = str(uuid.uuid4()) - response = Task( - id=task_id, - project_id=project_id, - trial_id=trial_id, - name=qa_creation_request.name, - config_yaml={"preset": qa_creation_request.preset}, - status=Status.IN_PROGRESS, - type=TaskType.QA, - created_at=datetime.now(), - save_path=save_path, - ) - await create_task(task_id, response, run_qa_creation, qa_creation_request, corpus_filepath, dataset_dir) - - # Update qa path - new_config: TrialConfig = previous_config.model_copy(deep=True) - new_config.qa_path = save_path - trial_config_db.set_trial_config(trial_id, new_config) - - - return jsonify(response.model_dump()), 202 - - except Exception as e: - return jsonify({ - "status": "error", - "message": f"Failed at creation of QA: {str(e)}" - }), 400 - - -@app.route("/projects//trials//config", methods=["GET"]) + data = await request.get_json() + try: + qa_creation_request = QACreationRequest(**data) + dataset_dir = os.path.join(WORK_DIR, project_id, "qa") + + if not os.path.exists(dataset_dir): + os.makedirs(dataset_dir) + + save_path = os.path.join(dataset_dir, f"{qa_creation_request.name}.parquet") + + if os.path.exists(save_path): + return jsonify( + {"error": f"QA dataset name already exists: {qa_creation_request.name}"} + ), 400 + + trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") + trial_config_db = PandasTrialDB(trial_config_path) + previous_config = trial_config_db.get_trial_config(trial_id) + + corpus_filepath = os.path.join( + WORK_DIR, project_id, "chunk", f"chunk_{previous_config.id}/0.parquet" + ) + # corpus_filepath = previous_config.corpus_path + print(f"previous_config: {previous_config}") + print(f"corpus_filepath: {corpus_filepath}") + + if ( + corpus_filepath is None + or not corpus_filepath + or not os.path.exists(corpus_filepath) + ): + return jsonify({"error": "Corpus data path not found"}), 400 + + task_id = str(uuid.uuid4()) + response = Task( + id=task_id, + project_id=project_id, + trial_id=trial_id, + name=qa_creation_request.name, + config_yaml={"preset": qa_creation_request.preset}, + status=Status.IN_PROGRESS, + type=TaskType.QA, + created_at=datetime.now(), + save_path=save_path, + ) + await create_task( + task_id, + response, + run_qa_creation, + qa_creation_request, + corpus_filepath, + dataset_dir, + ) + + # Update qa path + new_config: TrialConfig = previous_config.model_copy(deep=True) + new_config.qa_path = save_path + trial_config_db.set_trial_config(trial_id, new_config) + + return jsonify(response.model_dump()), 202 + + except Exception as e: + return jsonify( + {"status": "error", "message": f"Failed at creation of QA: {str(e)}"} + ), 400 + + +@app.route( + "/projects//trials//config", methods=["GET"] +) @project_exists(WORK_DIR) @trial_exists(WORK_DIR) async def get_trial_config(project_id: str, trial_id: str): - trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") - trial_config_db = PandasTrialDB(trial_config_path) - trial_config = trial_config_db.get_trial_config(trial_id) - return jsonify(trial_config.model_dump()), 200 + trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") + trial_config_db = PandasTrialDB(trial_config_path) + trial_config = trial_config_db.get_trial_config(trial_id) + return jsonify(trial_config.model_dump()), 200 + -@app.route("/projects//trials//config", methods=["POST"]) +@app.route( + "/projects//trials//config", methods=["POST"] +) @project_exists(WORK_DIR) @trial_exists(WORK_DIR) async def set_trial_config(project_id: str, trial_id: str): - trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") - trial_config_db = PandasTrialDB(trial_config_path) - previous_config = trial_config_db.get_trial_config(trial_id) - new_config = previous_config.model_copy(deep=True) - data = await request.get_json() - if data.get("raw_path", None) is not None: - new_config.raw_path = data["raw_path"] - if data.get("corpus_path", None) is not None: - new_config.corpus_path = data["corpus_path"] - if data.get("qa_path", None) is not None: - new_config.qa_path = data["qa_path"] - if data.get("config", None) is not None: - new_config_path = os.path.join(WORK_DIR, project_id, "config", f"{str(uuid.uuid4())}.yaml") - with open(new_config_path, "w") as f: - yaml.safe_dump(data["config"], f) - new_config.config_path = new_config_path - if data.get("metadata", None) is not None: - new_config.metadata = data["metadata"] - - trial_config_db.set_trial_config(trial_id, new_config) - return jsonify(new_config.model_dump()), 201 - -@app.route("/projects//trials//validate", methods=["POST"]) + trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") + trial_config_db = PandasTrialDB(trial_config_path) + previous_config = trial_config_db.get_trial_config(trial_id) + new_config = previous_config.model_copy(deep=True) + data = await request.get_json() + if data.get("raw_path", None) is not None: + new_config.raw_path = data["raw_path"] + if data.get("corpus_path", None) is not None: + new_config.corpus_path = data["corpus_path"] + if data.get("qa_path", None) is not None: + new_config.qa_path = data["qa_path"] + if data.get("config", None) is not None: + new_config_path = os.path.join( + WORK_DIR, project_id, "config", f"{str(uuid.uuid4())}.yaml" + ) + with open(new_config_path, "w") as f: + yaml.safe_dump(data["config"], f) + new_config.config_path = new_config_path + if data.get("metadata", None) is not None: + new_config.metadata = data["metadata"] + + trial_config_db.set_trial_config(trial_id, new_config) + return jsonify(new_config.model_dump()), 201 + + +@app.route( + "/projects//trials//validate", methods=["POST"] +) @project_exists(WORK_DIR) async def start_validate(project_id: str, trial_id: str): - trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") - trial_config_db = PandasTrialDB(trial_config_path) - trial_config = trial_config_db.get_trial_config(trial_id) - - task_id = str(uuid.uuid4()) - with open(trial_config.config_path, "r") as f: - config_yaml = yaml.safe_load(f) - response = Task( - id=task_id, - project_id=project_id, - trial_id=trial_id, - name=f"{trial_id}/validation", - config_yaml=config_yaml, - status=Status.IN_PROGRESS, - type=TaskType.VALIDATE, - created_at=datetime.now(), - ) - await create_task(task_id, response, - run_validate, trial_config.qa_path, trial_config.corpus_path, - trial_config.config_path) - - return jsonify(response.model_dump()), 202 - - -@app.route("/projects//trials//evaluate", methods=["POST"]) + trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") + trial_config_db = PandasTrialDB(trial_config_path) + trial_config = trial_config_db.get_trial_config(trial_id) + + task_id = str(uuid.uuid4()) + with open(trial_config.config_path, "r") as f: + config_yaml = yaml.safe_load(f) + response = Task( + id=task_id, + project_id=project_id, + trial_id=trial_id, + name=f"{trial_id}/validation", + config_yaml=config_yaml, + status=Status.IN_PROGRESS, + type=TaskType.VALIDATE, + created_at=datetime.now(), + ) + await create_task( + task_id, + response, + run_validate, + trial_config.qa_path, + trial_config.corpus_path, + trial_config.config_path, + ) + + return jsonify(response.model_dump()), 202 + + +@app.route( + "/projects//trials//evaluate", methods=["POST"] +) @project_exists(WORK_DIR) async def start_evaluate(project_id: str, trial_id: str): - evaluate_history_path = os.path.join(WORK_DIR, project_id, "evaluate_history.csv") - if not os.path.exists(evaluate_history_path): - evaluate_history_df = pd.DataFrame(columns=["trial_id", "save_dir", "corpus_path", "qa_path", "config_path"]) # save_dir is to autorag trial directory - evaluate_history_df.to_csv(evaluate_history_path, index=False) - else: - evaluate_history_df = pd.read_csv(evaluate_history_path) - - trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") - trial_config_db = PandasTrialDB(trial_config_path) - trial = trial_config_db.get_trial(trial_id) - trials_dir = os.path.join(WORK_DIR, project_id, "trials") - - data = await request.get_json() - skip_validation = data.get("skip_validation", False) - full_ingest = data.get("full_ingest", True) - - new_trial_dir = get_new_trial_dir(evaluate_history_df, trial.config, trials_dir) - if os.path.exists(new_trial_dir): - return jsonify({ - "trial_dir": new_trial_dir, - "error": "Exact same evaluation already run. " - "Skipping but return the directory where the evaluation result is saved." - }), 409 - task_id = str(uuid.uuid4()) - - new_row = pd.DataFrame([{ - "task_id": task_id, - "trial_id": trial_id, - "save_dir": new_trial_dir, - "corpus_path": trial.config.corpus_path, - "qa_path": trial.config.qa_path, - "config_path": trial.config.config_path, - "created_at": datetime.now(), - }]) - evaluate_history_df = pd.concat([evaluate_history_df, new_row], ignore_index=True) - evaluate_history_df.reset_index(drop=True, inplace=True) - evaluate_history_df.to_csv(evaluate_history_path, index=False) - - with open(trial.config.config_path, "r") as f: - config_yaml = yaml.safe_load(f) - task = Task( - id=task_id, - project_id=project_id, - trial_id=trial_id, - name=f"{trial_id}/evaluation", - config_yaml=config_yaml, - status=Status.IN_PROGRESS, - type=TaskType.EVALUATE, - created_at=datetime.now(), - save_path=new_trial_dir, - ) - await create_task(task_id, task, - run_start_trial, trial.config.qa_path, trial.config.corpus_path, - os.path.dirname(new_trial_dir), - trial.config.config_path, skip_validation, full_ingest) - - task.model_dump() - return jsonify(task.model_dump()), 202 - - -@app.route('/projects//trials//report/open', methods=['GET']) + evaluate_history_path = os.path.join(WORK_DIR, project_id, "evaluate_history.csv") + if not os.path.exists(evaluate_history_path): + evaluate_history_df = pd.DataFrame( + columns=["trial_id", "save_dir", "corpus_path", "qa_path", "config_path"] + ) # save_dir is to autorag trial directory + evaluate_history_df.to_csv(evaluate_history_path, index=False) + else: + evaluate_history_df = pd.read_csv(evaluate_history_path) + + trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") + trial_config_db = PandasTrialDB(trial_config_path) + trial = trial_config_db.get_trial(trial_id) + trials_dir = os.path.join(WORK_DIR, project_id, "trials") + + data = await request.get_json() + skip_validation = data.get("skip_validation", False) + full_ingest = data.get("full_ingest", True) + + new_trial_dir = get_new_trial_dir(evaluate_history_df, trial.config, trials_dir) + if os.path.exists(new_trial_dir): + return jsonify( + { + "trial_dir": new_trial_dir, + "error": "Exact same evaluation already run. " + "Skipping but return the directory where the evaluation result is saved.", + } + ), 409 + task_id = str(uuid.uuid4()) + + new_row = pd.DataFrame( + [ + { + "task_id": task_id, + "trial_id": trial_id, + "save_dir": new_trial_dir, + "corpus_path": trial.config.corpus_path, + "qa_path": trial.config.qa_path, + "config_path": trial.config.config_path, + "created_at": datetime.now(), + } + ] + ) + evaluate_history_df = pd.concat([evaluate_history_df, new_row], ignore_index=True) + evaluate_history_df.reset_index(drop=True, inplace=True) + evaluate_history_df.to_csv(evaluate_history_path, index=False) + + with open(trial.config.config_path, "r") as f: + config_yaml = yaml.safe_load(f) + task = Task( + id=task_id, + project_id=project_id, + trial_id=trial_id, + name=f"{trial_id}/evaluation", + config_yaml=config_yaml, + status=Status.IN_PROGRESS, + type=TaskType.EVALUATE, + created_at=datetime.now(), + save_path=new_trial_dir, + ) + await create_task( + task_id, + task, + run_start_trial, + trial.config.qa_path, + trial.config.corpus_path, + os.path.dirname(new_trial_dir), + trial.config.config_path, + skip_validation, + full_ingest, + ) + + task.model_dump() + return jsonify(task.model_dump()), 202 + + +@app.route( + "/projects//trials//report/open", + methods=["GET"], +) async def open_dashboard(project_id: str, trial_id: str): - """ - Get a preparation task or run status for chat open. - - Args: - project_id (str): The project ID - trial_id (str): The trial ID - - Returns: - JSON response with task status or error message - """ - try: - # Get the trial and search for the corresponding save_path - evaluate_history_path = os.path.join(WORK_DIR, project_id, "evaluate_history.csv") - if not os.path.exists(evaluate_history_path): - return jsonify({"error": "You need to run evaluation first"}), 400 - - evaluate_history_df = pd.read_csv(evaluate_history_path) - trial_raw = evaluate_history_df[evaluate_history_df["trial_id"] == trial_id] - if trial_raw.empty or len(trial_raw) < 1: - return jsonify({"error": "Trial ID not found"}), 404 - if len(trial_raw) >= 2: - return jsonify({"error": "Duplicated trial ID found"}), 400 - - trial_dir = trial_raw.iloc[0]["save_dir"] - if not os.path.exists(trial_dir): - return jsonify({"error": "Trial directory not found"}), 404 - if not os.path.isdir(trial_dir): - return jsonify({"error": "Trial directory is not a directory"}), 500 - - task_id = str(uuid.uuid4()) - response = Task( - id=task_id, - project_id=project_id, - trial_id=trial_id, - status=Status.IN_PROGRESS, - type=TaskType.REPORT, - created_at=datetime.now(), - ) - await create_task(task_id, response, run_dashboard, trial_dir) - - trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") - trial_config_db = PandasTrialDB(trial_config_path) - trial = trial_config_db.get_trial(trial_id) - new_trial = trial.model_copy(deep=True) - new_trial.report_task_id = task_id - trial_config_db.set_trial(new_trial) - - return jsonify(response.model_dump()), 202 - - except Exception as e: - return jsonify({'error': f'Internal server error: {str(e)}'}), 500 - - -@app.route('/projects//trials//report/close', methods=['GET']) + """ + Get a preparation task or run status for chat open. + + Args: + project_id (str): The project ID + trial_id (str): The trial ID + + Returns: + JSON response with task status or error message + """ + try: + # Get the trial and search for the corresponding save_path + evaluate_history_path = os.path.join( + WORK_DIR, project_id, "evaluate_history.csv" + ) + if not os.path.exists(evaluate_history_path): + return jsonify({"error": "You need to run evaluation first"}), 400 + + evaluate_history_df = pd.read_csv(evaluate_history_path) + trial_raw = evaluate_history_df[evaluate_history_df["trial_id"] == trial_id] + if trial_raw.empty or len(trial_raw) < 1: + return jsonify({"error": "Trial ID not found"}), 404 + if len(trial_raw) >= 2: + return jsonify({"error": "Duplicated trial ID found"}), 400 + + trial_dir = trial_raw.iloc[0]["save_dir"] + if not os.path.exists(trial_dir): + return jsonify({"error": "Trial directory not found"}), 404 + if not os.path.isdir(trial_dir): + return jsonify({"error": "Trial directory is not a directory"}), 500 + + task_id = str(uuid.uuid4()) + response = Task( + id=task_id, + project_id=project_id, + trial_id=trial_id, + status=Status.IN_PROGRESS, + type=TaskType.REPORT, + created_at=datetime.now(), + ) + await create_task(task_id, response, run_dashboard, trial_dir) + + trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") + trial_config_db = PandasTrialDB(trial_config_path) + trial = trial_config_db.get_trial(trial_id) + new_trial = trial.model_copy(deep=True) + new_trial.report_task_id = task_id + trial_config_db.set_trial(new_trial) + + return jsonify(response.model_dump()), 202 + + except Exception as e: + return jsonify({"error": f"Internal server error: {str(e)}"}), 500 + + +@app.route( + "/projects//trials//report/close", + methods=["GET"], +) async def close_dashboard(project_id: str, trial_id: str): - trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") - trial_config_db = PandasTrialDB(trial_config_path) - trial = trial_config_db.get_trial(trial_id) - report_pid = tasks[trial.report_task_id]["report_pid"] - os.killpg(os.getpgid(report_pid), signal.SIGTERM) + trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") + trial_config_db = PandasTrialDB(trial_config_path) + trial = trial_config_db.get_trial(trial_id) + report_pid = tasks[trial.report_task_id]["report_pid"] + os.killpg(os.getpgid(report_pid), signal.SIGTERM) - new_trial = trial.model_copy(deep=True) + new_trial = trial.model_copy(deep=True) - original_task = tasks[trial.report_task_id]["task"] - original_task.status = Status.TERMINATED - new_trial.report_task_id = None - trial_config_db.set_trial(new_trial) + original_task = tasks[trial.report_task_id]["task"] + original_task.status = Status.TERMINATED + new_trial.report_task_id = None + trial_config_db.set_trial(new_trial) - return jsonify(original_task.model_dump()), 200 + return jsonify(original_task.model_dump()), 200 -@app.route('/projects//trials//chat/open', methods=['GET']) +@app.route( + "/projects//trials//chat/open", methods=["GET"] +) async def open_chat_server(project_id: str, trial_id: str): - try: - # Get the trial and search for the corresponding save_path - evaluate_history_path = os.path.join(WORK_DIR, project_id, "evaluate_history.csv") - if not os.path.exists(evaluate_history_path): - return jsonify({"error": "You need to run evaluation first"}), 400 - - evaluate_history_df = pd.read_csv(evaluate_history_path) - trial_raw = evaluate_history_df[evaluate_history_df["trial_id"] == trial_id] - if trial_raw.empty or len(trial_raw) < 1: - return jsonify({"error": "Trial ID not found"}), 404 - if len(trial_raw) >= 2: - return jsonify({"error": "Duplicated trial ID found"}), 400 - - trial_dir = trial_raw.iloc[0]["save_dir"] - if not os.path.exists(trial_dir): - return jsonify({"error": "Trial directory not found"}), 404 - if not os.path.isdir(trial_dir): - return jsonify({"error": "Trial directory is not a directory"}), 500 - - task_id = str(uuid.uuid4()) - response = Task( - id=task_id, - project_id=project_id, - trial_id=trial_id, - status=Status.IN_PROGRESS, - type=TaskType.CHAT, - created_at=datetime.now(), - ) - await create_task(task_id, response, run_chat, trial_dir) - - trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") - trial_config_db = PandasTrialDB(trial_config_path) - trial = trial_config_db.get_trial(trial_id) - new_trial = trial.model_copy(deep=True) - new_trial.chat_task_id = task_id - trial_config_db.set_trial(new_trial) - - - return jsonify(response.model_dump()), 202 - - except Exception as e: - return jsonify({'error': f'Internal server error: {str(e)}'}), 500 + try: + # Get the trial and search for the corresponding save_path + evaluate_history_path = os.path.join( + WORK_DIR, project_id, "evaluate_history.csv" + ) + if not os.path.exists(evaluate_history_path): + return jsonify({"error": "You need to run evaluation first"}), 400 + + evaluate_history_df = pd.read_csv(evaluate_history_path) + trial_raw = evaluate_history_df[evaluate_history_df["trial_id"] == trial_id] + if trial_raw.empty or len(trial_raw) < 1: + return jsonify({"error": "Trial ID not found"}), 404 + if len(trial_raw) >= 2: + return jsonify({"error": "Duplicated trial ID found"}), 400 + + trial_dir = trial_raw.iloc[0]["save_dir"] + if not os.path.exists(trial_dir): + return jsonify({"error": "Trial directory not found"}), 404 + if not os.path.isdir(trial_dir): + return jsonify({"error": "Trial directory is not a directory"}), 500 + + task_id = str(uuid.uuid4()) + response = Task( + id=task_id, + project_id=project_id, + trial_id=trial_id, + status=Status.IN_PROGRESS, + type=TaskType.CHAT, + created_at=datetime.now(), + ) + await create_task(task_id, response, run_chat, trial_dir) + + trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") + trial_config_db = PandasTrialDB(trial_config_path) + trial = trial_config_db.get_trial(trial_id) + new_trial = trial.model_copy(deep=True) + new_trial.chat_task_id = task_id + trial_config_db.set_trial(new_trial) + + return jsonify(response.model_dump()), 202 + + except Exception as e: + return jsonify({"error": f"Internal server error: {str(e)}"}), 500 @app.route("/projects//artifacts", methods=["GET"]) @@ -964,7 +1050,7 @@ async def get_project_artifacts(project_id: str): """프로젝트 아티팩트 디렉토리 구조를 비동기적으로 반환합니다.""" try: project_path = os.path.join(WORK_DIR, project_id) - + # 특정 디렉토리만 스캔 (예: index 디렉토리) index_path = os.path.join(project_path, "raw_data") print(index_path) @@ -973,150 +1059,150 @@ async def get_project_artifacts(project_id: str): file_tree = await scan_directory(index_path) return jsonify(file_tree.model_dump()), 200 else: - return jsonify({ - "error": "Artifacts directory not found" - }), 404 + return jsonify({"error": "Artifacts directory not found"}), 404 except Exception as e: print(e) - return jsonify({ - "error": f"Failed to scan artifacts: {str(e)}" - }), 500 - -@app.route('/projects//trials//chat/close', methods=['GET']) + return jsonify({"error": f"Failed to scan artifacts: {str(e)}"}), 500 + + +@app.route( + "/projects//trials//chat/close", methods=["GET"] +) async def close_chat_server(project_id: str, trial_id: str): - trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") - trial_config_db = PandasTrialDB(trial_config_path) - trial = trial_config_db.get_trial(trial_id) - chat_pid = tasks[trial.chat_task_id]["chat_pid"] - os.killpg(os.getpgid(chat_pid), signal.SIGTERM) + trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") + trial_config_db = PandasTrialDB(trial_config_path) + trial = trial_config_db.get_trial(trial_id) + chat_pid = tasks[trial.chat_task_id]["chat_pid"] + os.killpg(os.getpgid(chat_pid), signal.SIGTERM) + + new_trial = trial.model_copy(deep=True) - new_trial = trial.model_copy(deep=True) + original_task = tasks[trial.chat_task_id]["task"] + original_task.status = Status.TERMINATED + new_trial.chat_task_id = None + trial_config_db.set_trial(new_trial) - original_task = tasks[trial.chat_task_id]["task"] - original_task.status = Status.TERMINATED - new_trial.chat_task_id = None - trial_config_db.set_trial(new_trial) + return jsonify(original_task.model_dump()), 200 - return jsonify(original_task.model_dump()), 200 -@app.route('/projects//tasks', methods=['GET']) +@app.route("/projects//tasks", methods=["GET"]) @project_exists(WORK_DIR) async def get_tasks(project_id: str): - if not os.path.exists(os.path.join(WORK_DIR, project_id)): - return jsonify({ - 'error': f'Project name does not exist: {project_id}' - }), 404 - - evaluate_history_path = os.path.join(WORK_DIR, project_id, "evaluate_history.csv") - if not os.path.exists(evaluate_history_path): - evaluate_history_df = pd.DataFrame(columns=["trial_id", "save_dir", "corpus_path", "qa_path", "config_path"]) # save_dir is to autorag trial directory - evaluate_history_df.to_csv(evaluate_history_path, index=False) - else: - evaluate_history_df = pd.read_csv(evaluate_history_path) - - # Replace NaN values with None before converting to dict - evaluate_history_df = evaluate_history_df.where(pd.notna(evaluate_history_df), -1) - - return jsonify({ - "total": len(evaluate_history_df), - "data": evaluate_history_df.to_dict(orient='records') # Convert DataFrame to list of dictionaries - }), 200 - -@app.route('/projects//tasks/', methods=['GET']) + if not os.path.exists(os.path.join(WORK_DIR, project_id)): + return jsonify({"error": f"Project name does not exist: {project_id}"}), 404 + + evaluate_history_path = os.path.join(WORK_DIR, project_id, "evaluate_history.csv") + if not os.path.exists(evaluate_history_path): + evaluate_history_df = pd.DataFrame( + columns=["trial_id", "save_dir", "corpus_path", "qa_path", "config_path"] + ) # save_dir is to autorag trial directory + evaluate_history_df.to_csv(evaluate_history_path, index=False) + else: + evaluate_history_df = pd.read_csv(evaluate_history_path) + + # Replace NaN values with None before converting to dict + evaluate_history_df = evaluate_history_df.where(pd.notna(evaluate_history_df), -1) + + return jsonify( + { + "total": len(evaluate_history_df), + "data": evaluate_history_df.to_dict( + orient="records" + ), # Convert DataFrame to list of dictionaries + } + ), 200 + + +@app.route("/projects//tasks/", methods=["GET"]) @project_exists(WORK_DIR) async def get_task(project_id: str, task_id: str): - if not os.path.exists(os.path.join(WORK_DIR, project_id)): - return jsonify({ - 'error': f'Project name does not exist: {project_id}' - }), 404 - task: Optional[Dict] = tasks.get(task_id, None) - if task is None: - return jsonify({ - 'error': f'Task ID does not exist: {task_id}' - }), 404 - response = task["task"] - return jsonify(response.model_dump()), 200 + if not os.path.exists(os.path.join(WORK_DIR, project_id)): + return jsonify({"error": f"Project name does not exist: {project_id}"}), 404 + task: Optional[Dict] = tasks.get(task_id, None) + if task is None: + return jsonify({"error": f"Task ID does not exist: {task_id}"}), 404 + response = task["task"] + return jsonify(response.model_dump()), 200 @app.route("/env", methods=["POST"]) async def set_environment_variable(): - # Get JSON data from request - data = await request.get_json() + # Get JSON data from request + data = await request.get_json() - try: - # Validate request data using Pydantic model - env_var = EnvVariableRequest(**data) + try: + # Validate request data using Pydantic model + env_var = EnvVariableRequest(**data) - if os.getenv(env_var.key, None) is None: - # Set the environment variable - os.environ[env_var.key] = env_var.value - return jsonify({}), 200 - else: - os.environ[env_var.key] = env_var.value - return jsonify({}), 201 + if os.getenv(env_var.key, None) is None: + # Set the environment variable + os.environ[env_var.key] = env_var.value + return jsonify({}), 200 + else: + os.environ[env_var.key] = env_var.value + return jsonify({}), 201 - except Exception as e: - return jsonify({ - "status": "error", - "message": f"Failed to set environment variable: {str(e)}" - }), 400 + except Exception as e: + return jsonify( + { + "status": "error", + "message": f"Failed to set environment variable: {str(e)}", + } + ), 400 @app.route("/env/", methods=["GET"]) async def get_environment_variable(key: str): - """ - Get environment variable by key. + """ + Get environment variable by key. - Args: - key (str): The environment variable key to lookup + Args: + key (str): The environment variable key to lookup - Returns: - Tuple containing response dictionary and status code - """ - try: - value = os.environ.get(key) + Returns: + Tuple containing response dictionary and status code + """ + try: + value = os.environ.get(key) - if value is None: - return {"error": f"Environment variable '{key}' not found"}, 404 + if value is None: + return {"error": f"Environment variable '{key}' not found"}, 404 - return { - "key": key, - "value": value - }, 200 + return {"key": key, "value": value}, 200 - except Exception as e: - return {"error": f"Internal server error: {str(e)}"}, 500 + except Exception as e: + return {"error": f"Internal server error: {str(e)}"}, 500 if __name__ == "__main__": - import hypercorn.asyncio - import asyncio - from hypercorn.config import Config - - config = Config() - - # 환경 변수에서 포트를 가져오거나 기본값 5000 사용 - port = int(os.getenv('PORT', 5000)) - host = os.getenv('HOST', '127.0.0.1') - - config.bind = [f"{host}:{port}"] - config.use_reloader = True - - # 특정 디렉토리만 감시 - base_dir = os.path.dirname(os.path.abspath(__file__)) - config.reload_dirs = [ - os.path.join(base_dir, "src"), - os.path.join(base_dir, "app.py") - ] - - # 워커 설정 - config.workers = 1 - config.worker_class = "asyncio" - - # 로그 설정 - config.accesslog = "-" - config.errorlog = "-" - config.loglevel = "INFO" - - logger.info("Starting server with CORS enabled for http://localhost:3000") - asyncio.run(hypercorn.asyncio.serve(app, config)) \ No newline at end of file + import hypercorn.asyncio + import asyncio + from hypercorn.config import Config + + config = Config() + + # 환경 변수에서 포트를 가져오거나 기본값 5000 사용 + port = int(os.getenv("PORT", 5000)) + host = os.getenv("HOST", "127.0.0.1") + + config.bind = [f"{host}:{port}"] + config.use_reloader = True + + # 특정 디렉토리만 감시 + base_dir = os.path.dirname(os.path.abspath(__file__)) + config.reload_dirs = [ + os.path.join(base_dir, "src"), + os.path.join(base_dir, "app.py"), + ] + + # 워커 설정 + config.workers = 1 + config.worker_class = "asyncio" + + # 로그 설정 + config.accesslog = "-" + config.errorlog = "-" + config.loglevel = "INFO" + + logger.info("Starting server with CORS enabled for http://localhost:3000") + asyncio.run(hypercorn.asyncio.serve(app, config)) From cc5600648df9e559aef970929cdeea27ffd64bcf Mon Sep 17 00:00:00 2001 From: jeffrey Date: Tue, 19 Nov 2024 22:38:59 +0800 Subject: [PATCH 33/55] turn evaluate_history.py workable again --- api/src/evaluate_history.py | 103 ++++++++++++++++++++---------------- 1 file changed, 57 insertions(+), 46 deletions(-) diff --git a/api/src/evaluate_history.py b/api/src/evaluate_history.py index d259361c5..effab7984 100644 --- a/api/src/evaluate_history.py +++ b/api/src/evaluate_history.py @@ -9,52 +9,63 @@ from src.schema import TrialConfig -def get_new_trial_dir(history_df: pd.DataFrame, trial_config: TrialConfig, - trials_dir: str): - trial_rows = history_df[history_df['trial_id'] == trial_config.trial_id] - duplicate_corpus_rows = trial_rows[trial_rows['corpus_path'] == trial_config.corpus_path] - if len(duplicate_corpus_rows) == 0: # If corpus data changed - # Changed Corpus - ingest again (Make new directory - new save_dir) - new_dir_name = f"{trial_config.trial_id}/{str(uuid.uuid4())}" - os.makedirs(os.path.join(project_dir, new_dir_name)) - return os.path.join(project_dir, new_dir_name, "0") # New trial folder - duplicate_qa_rows = duplicate_corpus_rows[trial_rows['qa_path'] == trial_config.qa_path] - if len(duplicate_qa_rows) == 0: # If qa data changed - # swap qa data from the existing project directory - existing_project_dir = os.path.dirname(duplicate_qa_rows.iloc[0]['save_path']) - shutil.copy(trial_config.qa_path, os.path.join(existing_project_dir, "data", "qa.parquet")) - duplicate_config_rows = duplicate_qa_rows[trial_rows['config_path'] == trial_config.config_path] - if len(duplicate_config_rows) > 0: - duplicate_row_save_paths = duplicate_config_rows['save_dir'].unique().tolist() - return duplicate_row_save_paths[0] - # Get the next trial folder - existing_project_dir = os.path.dirname(duplicate_qa_rows.iloc[0]['save_path']) - latest_trial_name = get_latest_trial(os.path.join(existing_project_dir, "trial.json")) - new_trial_name = str(int(latest_trial_name) + 1) - return os.path.join(existing_project_dir, new_trial_name) - +def get_new_trial_dir( + history_df: pd.DataFrame, trial_config: TrialConfig, project_dir: str +): + trial_rows = history_df[history_df["trial_id"] == trial_config.trial_id] + duplicate_corpus_rows = trial_rows[ + trial_rows["corpus_path"] == trial_config.corpus_path + ] + if len(duplicate_corpus_rows) == 0: # If corpus data changed + # Changed Corpus - ingest again (Make new directory - new save_dir) + new_dir_name = f"{trial_config.trial_id}-{str(uuid.uuid4())}" + os.makedirs(os.path.join(project_dir, new_dir_name)) + return os.path.join(project_dir, new_dir_name, "0") # New trial folder + duplicate_qa_rows = duplicate_corpus_rows[ + trial_rows["qa_path"] == trial_config.qa_path + ] + if len(duplicate_qa_rows) == 0: # If qa data changed + # swap qa data from the existing project directory + existing_project_dir = os.path.dirname(duplicate_qa_rows.iloc[0]["save_path"]) + shutil.copy( + trial_config.qa_path, + os.path.join(existing_project_dir, "data", "qa.parquet"), + ) + duplicate_config_rows = duplicate_qa_rows[ + trial_rows["config_path"] == trial_config.config_path + ] + if len(duplicate_config_rows) > 0: + duplicate_row_save_paths = duplicate_config_rows["save_dir"].unique().tolist() + return duplicate_row_save_paths[0] + # Get the next trial folder + existing_project_dir = os.path.dirname(duplicate_qa_rows.iloc[0]["save_path"]) + latest_trial_name = get_latest_trial( + os.path.join(existing_project_dir, "trial.json") + ) + new_trial_name = str(int(latest_trial_name) + 1) + return os.path.join(existing_project_dir, new_trial_name) def get_latest_trial(file_path): - try: - # Load JSON file - with open(file_path, 'r') as f: - trials = json.load(f) - - # Convert start_time to datetime objects and find the latest trial - latest_trial = max( - trials, - key=lambda x: datetime.strptime(x['start_time'], "%Y-%m-%d %H:%M:%S") - ) - - return latest_trial['trial_name'] - - except FileNotFoundError: - print("Error: trial.json file not found") - return None - except json.JSONDecodeError: - print("Error: Invalid JSON format") - return None - except Exception as e: - print(f"Error: {str(e)}") - return None + try: + # Load JSON file + with open(file_path, "r") as f: + trials = json.load(f) + + # Convert start_time to datetime objects and find the latest trial + latest_trial = max( + trials, + key=lambda x: datetime.strptime(x["start_time"], "%Y-%m-%d %H:%M:%S"), + ) + + return latest_trial["trial_name"] + + except FileNotFoundError: + print("Error: trial.json file not found") + return None + except json.JSONDecodeError: + print("Error: Invalid JSON format") + return None + except Exception as e: + print(f"Error: {str(e)}") + return None From 06284b2a3602ed9d3114e15d6e5868815bb5176e Mon Sep 17 00:00:00 2001 From: jeffrey Date: Tue, 19 Nov 2024 22:42:20 +0800 Subject: [PATCH 34/55] just reformat and edit ignore files --- .gitignore | 3 ++- api/.dockerignore | 3 ++- api/Dockerfile | 2 +- docker-compose.yml | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index ab74e15bc..664b23e07 100644 --- a/.gitignore +++ b/.gitignore @@ -159,5 +159,6 @@ cython_debug/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +.idea/ .DS_Store +pytest.ini diff --git a/api/.dockerignore b/api/.dockerignore index b7be024ec..8173cce3f 100644 --- a/api/.dockerignore +++ b/api/.dockerignore @@ -86,4 +86,5 @@ venv/ **/*.swp # VS Code -.vscode/ \ No newline at end of file +.vscode/ +projects/ diff --git a/api/Dockerfile b/api/Dockerfile index a8389aefe..9e72a07d6 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -19,4 +19,4 @@ RUN pip install --no-cache-dir -r requirements.txt COPY . . EXPOSE ${PORT:-5000} -CMD ["sh", "-c", "hypercorn app:app --bind 0.0.0.0:${PORT:-5000} --reload"] \ No newline at end of file +CMD ["sh", "-c", "hypercorn app:app --bind 0.0.0.0:${PORT:-5000} --reload"] diff --git a/docker-compose.yml b/docker-compose.yml index 3f041fb74..13c444921 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,4 +35,4 @@ services: networks: app-network: - driver: bridge \ No newline at end of file + driver: bridge From e9a546c913c5af51d2e6031ba8af0d078c50a64d Mon Sep 17 00:00:00 2001 From: jeffrey Date: Wed, 20 Nov 2024 13:54:22 +0800 Subject: [PATCH 35/55] working with uvicorn now --- api/Dockerfile | 2 +- api/app.py | 58 ++++++++++++++++++-------------------------- api/requirements.txt | 1 + autorag-frontend | 2 +- docker-compose.yml | 2 +- 5 files changed, 28 insertions(+), 37 deletions(-) diff --git a/api/Dockerfile b/api/Dockerfile index 9e72a07d6..481a3e7a4 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -19,4 +19,4 @@ RUN pip install --no-cache-dir -r requirements.txt COPY . . EXPOSE ${PORT:-5000} -CMD ["sh", "-c", "hypercorn app:app --bind 0.0.0.0:${PORT:-5000} --reload"] +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "${PORT:-5000}", "--reload", "--loop", "asyncio"] diff --git a/api/app.py b/api/app.py index aafa4f564..3c9e0b663 100644 --- a/api/app.py +++ b/api/app.py @@ -6,8 +6,11 @@ import uuid from datetime import datetime from pathlib import Path -from typing import Dict, Optional +from typing import Dict, Optional, Callable from typing import List + +import click +import uvicorn from quart import jsonify, request from pydantic import BaseModel import aiofiles @@ -89,12 +92,22 @@ # Function to create a task -async def create_task(task_id: str, task: Task, func, *args): - """비동기 작업을 생성하고 관리하는 함수""" +# async def create_task(task_id: str, task: Task, func, *args): +# """비동기 작업을 생성하고 관리하는 함수""" +# tasks[task_id] = { +# "task": task, +# "future": asyncio.create_task(run_background_task(task_id, func, *args)), +# } + + +async def create_task(task_id: str, task: Task, func: Callable, *args) -> None: tasks[task_id] = { + "function": func, + "args": args, + "error": None, "task": task, - "future": asyncio.create_task(run_background_task(task_id, func, *args)), } + await task_queue.put(task_id) async def run_background_task(task_id: str, func, *args): @@ -1174,35 +1187,12 @@ async def get_environment_variable(key: str): return {"error": f"Internal server error: {str(e)}"}, 500 -if __name__ == "__main__": - import hypercorn.asyncio - import asyncio - from hypercorn.config import Config - - config = Config() - - # 환경 변수에서 포트를 가져오거나 기본값 5000 사용 - port = int(os.getenv("PORT", 5000)) - host = os.getenv("HOST", "127.0.0.1") +@click.command() +@click.option("--host", type=str, default="127.0.0.1", help="Host IP address") +@click.option("--port", type=int, default=5000, help="Port number") +def main(host: str = "127.0.0.1", port: int = 5000): + uvicorn.run("app:app", host=host, port=port, reload=True, loop="asyncio") - config.bind = [f"{host}:{port}"] - config.use_reloader = True - # 특정 디렉토리만 감시 - base_dir = os.path.dirname(os.path.abspath(__file__)) - config.reload_dirs = [ - os.path.join(base_dir, "src"), - os.path.join(base_dir, "app.py"), - ] - - # 워커 설정 - config.workers = 1 - config.worker_class = "asyncio" - - # 로그 설정 - config.accesslog = "-" - config.errorlog = "-" - config.loglevel = "INFO" - - logger.info("Starting server with CORS enabled for http://localhost:3000") - asyncio.run(hypercorn.asyncio.serve(app, config)) +if __name__ == "__main__": + main() diff --git a/api/requirements.txt b/api/requirements.txt index 59b9817df..11659b2fe 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -4,3 +4,4 @@ jwt quart-cors Werkzeug quart-uploads +uvicorn diff --git a/autorag-frontend b/autorag-frontend index 6ace603a7..61c4b0b02 160000 --- a/autorag-frontend +++ b/autorag-frontend @@ -1 +1 @@ -Subproject commit 6ace603a7acc4444702a29d3afd95b0e8ded7542 +Subproject commit 61c4b0b0273658bcebc2bb5cecd35bc771af7348 diff --git a/docker-compose.yml b/docker-compose.yml index 13c444921..5b45622bf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,7 +29,7 @@ services: - PYTHONUNBUFFERED=1 - PORT=5001 - HOST=0.0.0.0 - command: hypercorn app:app --bind 0.0.0.0:5001 --reload + command: uvicorn app:app --host 0.0.0.0 --port 5001 --reload --loop asyncio networks: - app-network From 24db0d79d0c6e18b2933eff5c965da7cb7a6b185 Mon Sep 17 00:00:00 2001 From: "Jeffrey (Dongkyu) Kim" Date: Sat, 23 Nov 2024 20:54:04 +0800 Subject: [PATCH 36/55] Add env variable to locate the project folder and resolve new pydantic version issues (#971) Co-authored-by: jeffrey --- api/app.py | 9 ++++++--- api/requirements.txt | 4 ++-- autorag/autorag/__init__.py | 2 +- autorag/requirements.txt | 1 + docker-compose.yml | 1 + projects/tutorial_1/trial_config.csv | 3 ++- 6 files changed, 13 insertions(+), 7 deletions(-) diff --git a/api/app.py b/api/app.py index 3c9e0b663..47b644c85 100644 --- a/api/app.py +++ b/api/app.py @@ -87,9 +87,12 @@ current_task_id = None # ID of the currently running task lock = asyncio.Lock() # To manage access to shared variables -ROOT_DIR = os.path.dirname(os.path.realpath(__file__)) -WORK_DIR = os.path.join(ROOT_DIR, "projects") - +ROOT_DIR = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) +WORK_DIR = os.getenv("AUTORAG_WORK_DIR", None) +if WORK_DIR is None: + WORK_DIR = os.path.join(ROOT_DIR, "projects") +if not os.path.exists(WORK_DIR): + os.makedirs(WORK_DIR) # Function to create a task # async def create_task(task_id: str, task: Task, func, *args): diff --git a/api/requirements.txt b/api/requirements.txt index 11659b2fe..77b0d8ea0 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -1,5 +1,5 @@ -AutoRAG[parse,api]>=0.3.8 -quart-schema +AutoRAG[parse]>=0.3.10 +pydantic<2.10.0 jwt quart-cors Werkzeug diff --git a/autorag/autorag/__init__.py b/autorag/autorag/__init__.py index 3c861aafc..21b019a1a 100644 --- a/autorag/autorag/__init__.py +++ b/autorag/autorag/__init__.py @@ -5,7 +5,7 @@ from random import random from typing import List, Any -from llama_index.core import MockEmbedding +from llama_index.core.embeddings.mock_embed_model import MockEmbedding from llama_index.core.base.llms.types import CompletionResponse from llama_index.core.llms.mock import MockLLM from llama_index.llms.bedrock import Bedrock diff --git a/autorag/requirements.txt b/autorag/requirements.txt index f4d7de202..c00f1649a 100644 --- a/autorag/requirements.txt +++ b/autorag/requirements.txt @@ -1,3 +1,4 @@ +pydantic<2.10.0 # incompatible with llama index numpy<2.0.0 # temporal not using numpy 2.0.0 pandas>=2.1.0 tqdm diff --git a/docker-compose.yml b/docker-compose.yml index 5b45622bf..13097713f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,6 +29,7 @@ services: - PYTHONUNBUFFERED=1 - PORT=5001 - HOST=0.0.0.0 + - AUTORAG_WORK_DIR=/app/projects command: uvicorn app:app --host 0.0.0.0 --port 5001 --reload --loop asyncio networks: - app-network diff --git a/projects/tutorial_1/trial_config.csv b/projects/tutorial_1/trial_config.csv index b37c17b0a..1d5ae6e73 100644 --- a/projects/tutorial_1/trial_config.csv +++ b/projects/tutorial_1/trial_config.csv @@ -1,2 +1,3 @@ id,project_id,config,name,status,created_at,report_task_id,chat_task_id -8c42b9e6-490d-4971-bb9e-705b36b7a3a2,tutorial_1,"{""id"": ""8c42b9e6-490d-4971-bb9e-705b36b7a3a2"", ""project_id"": ""tutorial_1"", ""config"": {""trial_id"": ""8c42b9e6-490d-4971-bb9e-705b36b7a3a2"", ""project_id"": ""tutorial_1"", ""raw_path"": null, ""corpus_path"": null, ""qa_path"": null, ""config_path"": null, ""metadata"": {}}, ""name"": ""Trial_20241117_1111"", ""status"": ""not_started"", ""created_at"": ""2024-11-17T11:11:15.348589"", ""report_task_id"": null, ""chat_task_id"": null, ""corpus_path"": null, ""qa_path"": ""/Users/martin/Development/org_autorag/SaaS-AutoRAG-API/projects/tutorial_1/qa/qa_8c42b9e6-490d-4971-bb9e-705b36b7a3a2.parquet""}",Trial_20241117_1111,not_started,2024-11-17T11:11:15.348589,, \ No newline at end of file +8c42b9e6-490d-4971-bb9e-705b36b7a3a2,tutorial_1,"{""id"": ""8c42b9e6-490d-4971-bb9e-705b36b7a3a2"", ""project_id"": ""tutorial_1"", ""config"": {""trial_id"": ""8c42b9e6-490d-4971-bb9e-705b36b7a3a2"", ""project_id"": ""tutorial_1"", ""raw_path"": null, ""corpus_path"": null, ""qa_path"": null, ""config_path"": null, ""metadata"": {}}, ""name"": ""Trial_20241117_1111"", ""status"": ""not_started"", ""created_at"": ""2024-11-17T11:11:15.348589"", ""report_task_id"": null, ""chat_task_id"": null, ""corpus_path"": null, ""qa_path"": ""/Users/martin/Development/org_autorag/SaaS-AutoRAG-API/projects/tutorial_1/qa/qa_8c42b9e6-490d-4971-bb9e-705b36b7a3a2.parquet""}",Trial_20241117_1111,not_started,2024-11-17T11:11:15.348589,, +119f5ac8-a39d-47f7-aa87-e98dbed16931,tutorial_1,"{""id"": ""119f5ac8-a39d-47f7-aa87-e98dbed16931"", ""project_id"": ""tutorial_1"", ""config"": {""trial_id"": ""119f5ac8-a39d-47f7-aa87-e98dbed16931"", ""project_id"": ""tutorial_1"", ""raw_path"": null, ""corpus_path"": null, ""qa_path"": null, ""config_path"": null, ""metadata"": {}}, ""name"": ""Trial_20241121_1618"", ""status"": ""not_started"", ""created_at"": ""2024-11-21T08:18:09.443575"", ""report_task_id"": null, ""chat_task_id"": null, ""corpus_path"": null, ""qa_path"": ""/app/projects/tutorial_1/qa/qa_119f5ac8-a39d-47f7-aa87-e98dbed16931.parquet""}",Trial_20241121_1618,not_started,2024-11-21T08:18:09.443575,, From 61c2cb8998e39245b41e6edb861f2fa8544bba5b Mon Sep 17 00:00:00 2001 From: "Jeffrey (Dongkyu) Kim" Date: Sat, 23 Nov 2024 21:11:33 +0800 Subject: [PATCH 37/55] Add env variable endpoints for managing env variable (#975) * add delete endpoint and change to .env based operations * add api endpoint for gathering all env settings * load env variable when start each task * change GET /env to return everything (key & values) --------- Co-authored-by: jeffrey --- api/app.py | 39 ++++++++++++++++++++++++- tests/api/test_app.py | 66 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 101 insertions(+), 4 deletions(-) diff --git a/api/app.py b/api/app.py index 47b644c85..a06210e8e 100644 --- a/api/app.py +++ b/api/app.py @@ -55,6 +55,7 @@ import logging +from dotenv import load_dotenv, dotenv_values, set_key, unset_key nest_asyncio.apply() @@ -87,12 +88,15 @@ current_task_id = None # ID of the currently running task lock = asyncio.Lock() # To manage access to shared variables + ROOT_DIR = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) WORK_DIR = os.getenv("AUTORAG_WORK_DIR", None) if WORK_DIR is None: WORK_DIR = os.path.join(ROOT_DIR, "projects") if not os.path.exists(WORK_DIR): os.makedirs(WORK_DIR) +ENV_FILEPATH = os.path.join(os.path.dirname(os.path.realpath(__file__)), ".env") +load_dotenv(ENV_FILEPATH) # Function to create a task # async def create_task(task_id: str, task: Task, func, *args): @@ -151,6 +155,9 @@ async def task_runner(): func = tasks[task_id]["function"] args = tasks[task_id].get("args", ()) + # Load env variable before running a task + load_dotenv(ENV_FILEPATH) + # Run the function in a separate process future = loop.run_in_executor( executor, @@ -1145,6 +1152,10 @@ async def get_task(project_id: str, task_id: str): async def set_environment_variable(): # Get JSON data from request data = await request.get_json() + is_exist_env = load_dotenv(ENV_FILEPATH) + if not is_exist_env: + with open(ENV_FILEPATH, "w") as f: + f.write("") try: # Validate request data using Pydantic model @@ -1153,9 +1164,11 @@ async def set_environment_variable(): if os.getenv(env_var.key, None) is None: # Set the environment variable os.environ[env_var.key] = env_var.value + set_key(ENV_FILEPATH, env_var.key, env_var.value) return jsonify({}), 200 else: os.environ[env_var.key] = env_var.value + set_key(ENV_FILEPATH, env_var.key, env_var.value) return jsonify({}), 201 except Exception as e: @@ -1179,7 +1192,7 @@ async def get_environment_variable(key: str): Tuple containing response dictionary and status code """ try: - value = os.environ.get(key) + value = dotenv_values(ENV_FILEPATH).get(key, None) if value is None: return {"error": f"Environment variable '{key}' not found"}, 404 @@ -1190,6 +1203,30 @@ async def get_environment_variable(key: str): return {"error": f"Internal server error: {str(e)}"}, 500 +@app.route("/env", methods=["GET"]) +async def get_all_env_keys(): + try: + envs = dotenv_values(ENV_FILEPATH) + return jsonify(dict(envs)), 200 + except Exception as e: + return {"error": f"Internal server error: {str(e)}"}, 500 + + +@app.route("/env/", methods=["DELETE"]) +async def delete_environment_variable(key: str): + try: + value = dotenv_values(ENV_FILEPATH).get(key, None) + if value is None: + return {"error": f"Environment variable '{key}' not found"}, 404 + + unset_key(ENV_FILEPATH, key) + + return {}, 200 + + except Exception as e: + return {"error": f"Internal server error: {str(e)}"}, 500 + + @click.command() @click.option("--host", type=str, default="127.0.0.1", help="Host IP address") @click.option("--port", type=int, default=5000, help="Port number") diff --git a/tests/api/test_app.py b/tests/api/test_app.py index 92de5cbb0..6c7abeb7f 100644 --- a/tests/api/test_app.py +++ b/tests/api/test_app.py @@ -331,14 +331,74 @@ async def test_set_env_variable(client_for_test): assert response.status_code == 201 assert os.getenv("test_key") == "test_value2" + response = await client_for_test.delete( + "/env/test_key", + ) + assert response.status_code == 200 + @pytest.mark.asyncio async def test_get_env_variable(client_for_test): - os.environ["test_key"] = "test_value" + response = await client_for_test.post( + "/env", + json={ + "key": "test_key", + "value": "test_value", + }, + ) + assert response.status_code == 200 or response.status_code == 201 + response = await client_for_test.get("/env/test_key") assert response.status_code == 200 + data = await response.get_json() + assert data["value"] == "test_value" - response = await client_for_test.get("/env/non_existent_key") + response = await client_for_test.delete( + "/env/test_key", + ) + assert response.status_code == 200 + + response = await client_for_test.get("/env/test_key") assert response.status_code == 404 data = await response.get_json() - assert data["error"] == "Environment variable 'non_existent_key' not found" + assert data["error"] == "Environment variable 'test_key' not found" + + +@pytest.mark.asyncio +async def test_get_all_env_keys(client_for_test): + response = await client_for_test.post( + "/env", + json={ + "key": "test_key", + "value": "test_value", + }, + ) + assert response.status_code == 200 or response.status_code == 201 + + response = await client_for_test.post( + "/env", + json={ + "key": "test_key2", + "value": "test_value2", + }, + ) + assert response.status_code == 200 or response.status_code == 201 + + response = await client_for_test.get("/env") + assert response.status_code == 200 + data = await response.get_json() + assert "test_key" in list(data.keys()) + assert "test_key2" in list(data.keys()) + assert data["test_key"] == "test_value" + assert data["test_key2"] == "test_value2" + assert len(data) >= 2 + + response = await client_for_test.delete( + "/env/test_key", + ) + assert response.status_code == 200 + + response = await client_for_test.delete( + "/env/test_key2", + ) + assert response.status_code == 200 From 23260f4efcdbccbcc73aea5b9a2b8c88d2e6b7d3 Mon Sep 17 00:00:00 2001 From: "Jeffrey (Dongkyu) Kim" Date: Sun, 24 Nov 2024 11:18:18 +0800 Subject: [PATCH 38/55] upload multiple files at once using key 'files' (#981) Co-authored-by: jeffrey --- api/app.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/api/app.py b/api/app.py index a06210e8e..df42041b9 100644 --- a/api/app.py +++ b/api/app.py @@ -510,16 +510,25 @@ async def upload_files(project_id: str): files = UploadSet() files.default_dest = raw_data_path configure_uploads(app, files) + # List to hold paths of uploaded files + uploaded_file_paths = [] + try: - filename = await files.save((await request.files)["file"]) + # Get all files from the request + uploaded_files = (await request.files).getlist("files") - if not filename: + if not uploaded_files: return jsonify({"error": "No files were uploaded"}), 400 + # Iterate over each file and save it + for uploaded_file in uploaded_files: + filename = await files.save(uploaded_file) + uploaded_file_paths.append(os.path.join(raw_data_path, filename)) + return jsonify( { "message": "Files uploaded successfully", - "filePaths": os.path.join(raw_data_path, filename), + "filePaths": uploaded_file_paths, } ), 200 From 7565f7c1721ad80014acbc0ab87f2e6008588cf0 Mon Sep 17 00:00:00 2001 From: Seungwoo hong <1100974+hongsw@users.noreply.github.com> Date: Mon, 25 Nov 2024 14:35:36 +0900 Subject: [PATCH 39/55] [API] fix validate and evaluation api config, set_trial_config #984 (#987) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: refactor SQL Trial DB from Pandas Trial DB, and Test code * 🚑 fix: Set correct WORK_DIR based on environment variable - Updated the logic in app.py to properly set the `WORK_DIR` based on the environment variable `AUTORAG_API_ENV`. If the environment is 'dev', the `WORK_DIR` will be located at `"../projects"`, otherwise, it will be set to `"projects"`. Additionally, the `.env` file path is now correctly constructed using the determined `WORK_DIR` value. * 🚑 fix: Update method to use model_validate_json in trial_dict['config'] assignment and update set_trial_config for trial_id with TrialConfig model dump JSON. Add get_all_config_ids and get_all_trial_ids SQL query functions. * ✨ feat: Add CORS headers and handle OPTIONS requests This commit introduces the addition of CORS headers in every response and explicit handling of OPTIONS requests in the API server. Includes setting Access-Control-Allow-Origin, Access-Control-Allow-Credentials, Access-Control-Allow-Headers, and Access-Control-Allow-Methods based on the request origin. * ✅ test: add test file for project creation with setup and cleanup fixtures, including logging configurations, environment setup, client creation, and project directory validation * 🚑 fix: Remove unnecessary commented-out properties in Trial class * 🚑 fix: Set correct WORK_DIR based on environment variable AUTORAG_WORK_DIR * ♻️ refactor: Update code in app.py and schema.py for better handling of working directory and model configuration. Fix deprecated usage in test_app.py and enhance testing in test_trial_config.py. * 📝 docs: update README with instructions for running using Docker Compose and monitoring options. * ✨ feat: start parsing documents task with improved import handling This commit introduces changes to the document parsing task initiation. The import statement for `parse_documents` has been updated within the file. Additionally, the logic for initiating the parsing process has been streamlined and improved for better performance and handling of imports. * ✅ test: add tests for project database operations such as initializing DB, setting/getting trials, updating trial configurations, and retrieving trial information by project or ID. * ♻️ refactor: Improve database initialization in SQLiteProjectDB - Refactored the `_init_db` method to enhance database initialization. - Added logging and enhanced debugging statements for better clarity. - Now checks for the existence of the database file and its directory before initializing. - If the database file does not exist, it creates the necessary directory and tables. - Adjusted permissions for directories (777) and the database file (666) accordingly. * 🚑 fix: correct chunking and parsing tasks in trial_tasks.py * 🔧 chore: Update imports and debug logging level in app.py - Updated import statement in app.py to include chunk_documents from trial_tasks module. - Changed the logging level from INFO to DEBUG for more detailed logging information. * ♻️ refactor: refactor parsing endpoint and improve error handling - Refactored the parsing endpoint to handle configuration data retrieval more efficiently. - Improved error handling to provide more informative error messages in case of missing data or failed tasks. * 🚑 fix: Correct chunked data path and task handling in start_chunking function * ✨ feat: Configure not to use uvloop, apply nest_asyncio, and correct import in app.py - Avoid using uvloop by setting asyncio event loop policy to DefaultEventLoopPolicy(). - Apply nest_asyncio after that to prevent conflicts. - Change the import in app.py from `from database.project_db import SQLiteProjectDB` to the correct import. refactor: Update Celery configuration in celery_app.py - Adjust broker and backend URLs to use 'redis://redis:6379/0'. - Modify the timezone to 'Asia/Seoul' for better synchronization. * 🚑 fix: Install system dependencies and pip, adjust Dockerfile for API service - Removed unnecessary comments related to installing pip as it's clear from the command itself - Added installation of 'watchfiles', setting PYTHONPATH and PYTHONUNBUFFERED environment variables - Created a directory for celery beat schedule and added an entrypoint script - Adjusted permissions for the entrypoint script and removed Windows line endings - Updated entrypoint to /entrypoint.sh in the API service section - Added environment variables for watching files, setting time zone, log level, and disabling Python output buffering * 🔧 chore: update subproject commit reference in autorag-frontend * 🔧 chore: add test_projects to .gitignore * add new lines and fix .env.dev * fix chunk_documents --------- Co-authored-by: Seungwoo hong Co-authored-by: jeffrey --- .gitignore | 2 + api/.env.dev | 2 + api/.env.dev.example | 0 api/Dockerfile | 19 +- api/README.md | 30 ++ api/app.py | 493 ++++++++++++++------------------- api/celery_app.py | 17 ++ api/database/project_db.py | 178 ++++++++++++ api/entrypoint.sh | 15 + api/requirements.txt | 3 + api/src/schema.py | 89 ++++-- api/src/trial_config.py | 94 ------- api/src/validate.py | 83 +++--- api/tasks/base.py | 60 ++++ api/tasks/processing.py | 21 ++ api/tasks/trial_tasks.py | 152 ++++++++++ api/tests/test_app.py | 304 ++++++++++++++++++++ api/tests/test_project_db.py | 180 ++++++++++++ api/utils/task_utils.py | 34 +++ autorag-frontend | 2 +- docker-compose.yml | 57 ++-- tests/api/test_app.py | 20 +- tests/api/test_trial_config.py | 111 -------- 23 files changed, 1371 insertions(+), 595 deletions(-) create mode 100644 api/.env.dev create mode 100644 api/.env.dev.example create mode 100644 api/celery_app.py create mode 100644 api/database/project_db.py create mode 100644 api/entrypoint.sh delete mode 100644 api/src/trial_config.py create mode 100644 api/tasks/base.py create mode 100644 api/tasks/processing.py create mode 100644 api/tasks/trial_tasks.py create mode 100644 api/tests/test_app.py create mode 100644 api/tests/test_project_db.py create mode 100644 api/utils/task_utils.py delete mode 100644 tests/api/test_trial_config.py diff --git a/.gitignore b/.gitignore index 664b23e07..c102e07fd 100644 --- a/.gitignore +++ b/.gitignore @@ -162,3 +162,5 @@ cython_debug/ .idea/ .DS_Store pytest.ini +projects +test_projects diff --git a/api/.env.dev b/api/.env.dev new file mode 100644 index 000000000..efbbc09d8 --- /dev/null +++ b/api/.env.dev @@ -0,0 +1,2 @@ +AUTORAG_API_ENV=dev +AUTORAG_WORK_DIR=../projects diff --git a/api/.env.dev.example b/api/.env.dev.example new file mode 100644 index 000000000..e69de29bb diff --git a/api/Dockerfile b/api/Dockerfile index 481a3e7a4..fe57661ae 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -2,7 +2,7 @@ FROM python:3.10-slim WORKDIR /app -# Install system dependencies and pip +# Install system dependencies RUN apt-get update && apt-get install -y \ python3-pip \ build-essential \ @@ -14,9 +14,18 @@ RUN python -m pip install --upgrade pip # Install Python dependencies COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt +RUN pip install watchfiles +ENV PYTHONPATH=/app +ENV PYTHONUNBUFFERED=1 +# # Copy application code +# COPY . . -# Copy application code -COPY . . +# Create directory for celery beat schedule +RUN mkdir -p /app/celerybeat -EXPOSE ${PORT:-5000} -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "${PORT:-5000}", "--reload", "--loop", "asyncio"] +# Add entrypoint script +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh && \ + sed -i 's/\r$//' /entrypoint.sh # Remove Windows line endings + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/api/README.md b/api/README.md index 9b29d17fc..6b9f66900 100644 --- a/api/README.md +++ b/api/README.md @@ -1,2 +1,32 @@ # AutoRAG-API Quart API server for running AutoRAG and various data creations. + +## 2.사용 방법: +Docker Compose로 전체 스택 실행: +``` +docker-compose up -d +``` +### 2. 모니터링: +Flower UI: http://localhost:5555 +Redis Commander: http://localhost:8081 + +### Test: +``` +python -m pytest tests/test_trial_config.py -v +python -m pytest tests/test_app.py -v + + + +``` + +### 4. 프로젝트 생성 +``` +cd .. +docker-compose up +``` + + +``` +# cd api +python -m celery -A celery_app worker --loglevel=INFO +``` \ No newline at end of file diff --git a/api/app.py b/api/app.py index df42041b9..b5e792fe7 100644 --- a/api/app.py +++ b/api/app.py @@ -1,24 +1,25 @@ import asyncio import os import signal -import tempfile import concurrent.futures import uuid from datetime import datetime from pathlib import Path from typing import Dict, Optional, Callable from typing import List +import logging +import nest_asyncio import click import uvicorn -from quart import jsonify, request +from quart import jsonify, request, make_response from pydantic import BaseModel import aiofiles import aiofiles.os +from dotenv import load_dotenv, dotenv_values, set_key, unset_key import pandas as pd import yaml -from pydantic import ValidationError from quart import Quart from quart_cors import cors # Import quart_cors to enable CORS from quart_uploads import UploadSet, configure_uploads @@ -26,44 +27,39 @@ from src.auth import require_auth from src.evaluate_history import get_new_trial_dir from src.run import ( - run_parser_start_parsing, - run_chunker_start_chunking, run_qa_creation, run_start_trial, - run_validate, run_dashboard, run_chat, ) from src.schema import ( ChunkRequest, - ParseRequest, EnvVariableRequest, QACreationRequest, Project, Task, Status, TaskType, - TrialCreateRequest, Trial, TrialConfig, ) -import nest_asyncio - -from src.trial_config import PandasTrialDB from src.validate import project_exists, trial_exists +from database.project_db import SQLiteProjectDB # 올바른 임포트로 변경 +from tasks.trial_tasks import parse_documents, chunk_documents # 수정된 임포트 +from celery.result import AsyncResult +# uvloop을 사용하지 않도록 설정 +asyncio.set_event_loop_policy(asyncio.DefaultEventLoopPolicy()) -import logging -from dotenv import load_dotenv, dotenv_values, set_key, unset_key - +# 그 다음에 nest_asyncio 적용 nest_asyncio.apply() app = Quart(__name__) # 로깅 설정 logging.basicConfig( - level=logging.INFO, + level=logging.DEBUG, format="%(asctime)s - %(levelname)s - %(message)s", datefmt="%Y-%m-%d %H:%M:%S", ) @@ -88,23 +84,60 @@ current_task_id = None # ID of the currently running task lock = asyncio.Lock() # To manage access to shared variables - -ROOT_DIR = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) -WORK_DIR = os.getenv("AUTORAG_WORK_DIR", None) -if WORK_DIR is None: +ROOT_DIR = os.path.dirname(os.path.realpath(__file__)) +ENV = os.getenv("AUTORAG_API_ENV", "dev") +if ENV == "dev": + WORK_DIR = os.path.join(ROOT_DIR, "../projects") +else: # production WORK_DIR = os.path.join(ROOT_DIR, "projects") -if not os.path.exists(WORK_DIR): - os.makedirs(WORK_DIR) -ENV_FILEPATH = os.path.join(os.path.dirname(os.path.realpath(__file__)), ".env") +if "AUTORAG_WORK_DIR" in os.environ: + WORK_DIR = os.getenv("AUTORAG_WORK_DIR") + +if "AUTORAG_WORK_DIR" in os.environ: + WORK_DIR = os.path.join(ROOT_DIR, os.getenv("AUTORAG_WORK_DIR")) + +ENV_FILEPATH = os.path.join(ROOT_DIR, f".env.{ENV}") +# 환경에 따른 WORK_DIR 설정 + load_dotenv(ENV_FILEPATH) -# Function to create a task -# async def create_task(task_id: str, task: Task, func, *args): -# """비동기 작업을 생성하고 관리하는 함수""" -# tasks[task_id] = { -# "task": task, -# "future": asyncio.create_task(run_background_task(task_id, func, *args)), -# } +print(f"ENV_FILEPATH: {ENV_FILEPATH}") +print(f"WORK_DIR: {WORK_DIR}") +print(f"AUTORAG_API_ENV: {ENV}") + +print("--------------------------------") +print("### Server start") +print("--------------------------------") + + +# Ensure CORS headers are present in every response +@app.after_request +def add_cors_headers(response): + origin = request.headers.get("Origin") + if origin == "http://localhost:3000": + response.headers["Access-Control-Allow-Origin"] = origin + response.headers["Access-Control-Allow-Credentials"] = "true" + response.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization" + response.headers["Access-Control-Allow-Methods"] = ( + "GET, POST, PUT, DELETE, PATCH, OPTIONS" + ) + return response + + +# Handle OPTIONS requests explicitly +@app.route("/", methods=["OPTIONS"]) +@app.route("/", methods=["OPTIONS"]) +async def options_handler(path=""): + response = await make_response("") + origin = request.headers.get("Origin") + if origin == "http://localhost:3000": + response.headers["Access-Control-Allow-Origin"] = origin + response.headers["Access-Control-Allow-Credentials"] = "true" + response.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization" + response.headers["Access-Control-Allow-Methods"] = ( + "GET, POST, PUT, DELETE, PATCH, OPTIONS" + ) + return response async def create_task(task_id: str, task: Task, func: Callable, *args) -> None: @@ -155,6 +188,9 @@ async def task_runner(): func = tasks[task_id]["function"] args = tasks[task_id].get("args", ()) + print(f"args: {args}") + print(f"func: {func}") + # Load env variable before running a task load_dotenv(ENV_FILEPATH) @@ -228,7 +264,8 @@ async def create_project(): return jsonify({"error": "Name is required"}), 400 description = data.get("description", "") - + print(f"Set WORK_DIR environment variable to: {os.environ['AUTORAG_WORK_DIR']}") + WORK_DIR = os.environ["AUTORAG_WORK_DIR"] # Create a new project new_project_dir = os.path.join(WORK_DIR, data["name"]) if not os.path.exists(new_project_dir): @@ -238,8 +275,9 @@ async def create_project(): os.makedirs(os.path.join(new_project_dir, "qa")) os.makedirs(os.path.join(new_project_dir, "project")) os.makedirs(os.path.join(new_project_dir, "config")) - # Make trial_config.csv file - _ = PandasTrialDB(os.path.join(new_project_dir, "trial_config.csv")) + os.makedirs(os.path.join(new_project_dir, "raw_data")) + # SQLiteProjectDB 인스턴스 생성 + _ = SQLiteProjectDB(data["name"]) else: return jsonify({"error": f'Project name already exists: {data["name"]}'}), 400 @@ -345,16 +383,17 @@ def get_project_description(project_name): @app.route("/projects//trials", methods=["GET"]) @project_exists(WORK_DIR) async def get_trial_lists(project_id: str): - trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") - trial_config_db = PandasTrialDB(trial_config_path) - trial_ids = trial_config_db.get_all_trial_ids() + project_db = SQLiteProjectDB(project_id) + + page = request.args.get("page", 1, type=int) + limit = request.args.get("limit", 10, type=int) + offset = (page - 1) * limit + + trials = project_db.get_trials_by_project(project_id, limit=limit, offset=offset) + total_trials = len(project_db.get_all_trial_ids(project_id)) + return jsonify( - { - "total": len(trial_ids), - "data": list( - map(lambda x: trial_config_db.get_trial(x).model_dump(), trial_ids) - ), - } + {"total": total_trials, "data": [trial.model_dump() for trial in trials]} ) @@ -397,57 +436,41 @@ async def scan_directory(path: str) -> FileNode: @app.route("/projects//trials", methods=["POST"]) @project_exists(WORK_DIR) -async def create_new_trial(project_id: str): - trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") +async def create_trial(project_id: str): + project_db = SQLiteProjectDB(project_id) data = await request.get_json() - try: - creation_request = TrialCreateRequest(**data) - except ValidationError as e: - return jsonify( - { - "error": f"Invalid request format : {e}", - } - ), 400 - trial_id = str(uuid.uuid4()) - request_dict = creation_request.model_dump() - if request_dict["config"] is not None: - config_path = os.path.join( - WORK_DIR, project_id, "config", f"{str(uuid.uuid4())}.yaml" - ) - async with aiofiles.open(config_path, "w") as f: - await f.write(yaml.safe_dump(request_dict["config"])) - else: - config_path = None - - request_dict["trial_id"] = trial_id - request_dict["project_id"] = project_id - request_dict["config_path"] = config_path - request_dict["metadata"] = {} - request_dict.pop("config") - name = request_dict.pop("name") - - new_trial_config = TrialConfig(**request_dict) - new_trial = Trial( - id=trial_id, - project_id=project_id, - config=new_trial_config, - name=name, - status=Status.NOT_STARTED, + data["project_id"] = project_id + trial = Trial( + **data, created_at=datetime.now(), + status=Status.IN_PROGRESS, + id=str(uuid.uuid4()), ) - trial_config_db = PandasTrialDB(trial_config_path) - trial_config_db.set_trial(new_trial) - return jsonify(new_trial.model_dump()), 202 + + project_db.set_trial(trial) + return jsonify(trial.model_dump()) @app.route("/projects//trials/", methods=["GET"]) @project_exists(WORK_DIR) -@trial_exists(WORK_DIR) async def get_trial(project_id: str, trial_id: str): - trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") - trial_config_db = PandasTrialDB(trial_config_path) - return jsonify(trial_config_db.get_trial(trial_id).model_dump()), 200 + project_db = SQLiteProjectDB(project_id) + + trial = project_db.get_trial(trial_id) + if not trial: + return jsonify({"error": "Trial not found"}), 404 + + return jsonify(trial.model_dump()) + + +@app.route("/projects//trials/", methods=["DELETE"]) +@project_exists(WORK_DIR) +async def delete_trial(project_id: str, trial_id: str): + project_db = SQLiteProjectDB(project_id) + + project_db.delete_trial(trial_id) + return jsonify({"message": "Trial deleted successfully"}) @app.route("/projects//artifacts/files", methods=["GET"]) @@ -538,106 +561,68 @@ async def upload_files(project_id: str): ), 500 -@app.route( - "/projects//trials//parse", methods=["POST"] -) -@project_exists(WORK_DIR) -@trial_exists(WORK_DIR) -async def start_parsing(project_id: str, trial_id: str): - yaml_path = None +@app.route("/projects//trials//parse", methods=["POST"]) +async def parse_documents_endpoint(project_id, trial_id): + task_id = "" try: + # POST body에서 config 받기 data = await request.get_json() - parse_request = ParseRequest(**data) - - # 프로젝트 디렉토리 구조 설정 - project_dir = os.path.join(WORK_DIR, project_id) - raw_data_path = os.path.join(project_dir, "raw_data") - dataset_dir = os.path.join(project_dir, "parse", parse_request.name) - config_dir = os.path.join(project_dir, "configs") - - logger.info(f"Project directory: {project_dir}") - logger.info(f"Raw data path: {raw_data_path}") - - # data_path_glob 생성 - raw_data 디렉토리 내의 모든 파일을 포함 - data_path_glob = os.path.join(raw_data_path, "*.pdf") - logger.info(f"Data path glob: {data_path_glob}") - - # 필요한 디렉토리 생성 - os.makedirs(config_dir, exist_ok=True) - if not os.path.exists(dataset_dir): - os.makedirs(dataset_dir) - else: - return jsonify( - {"error": f"Parse dataset name already exists: {parse_request.name}"} - ), 400 - - # 파일 검색 - files = [] - for root, _, filenames in os.walk(raw_data_path): - for filename in filenames: - if not filename.startswith("."): - files.append(os.path.join(root, filename)) - - if not files: - return jsonify( - { - "error": f"No valid files found in {raw_data_path}. Please upload some files first." - } - ), 400 - - logger.info(f"Found files: {files}") + if not data or "config" not in data: + return jsonify({"error": "Config is required in request body"}), 400 - # YAML 설정 파일 생성 - yaml_config = { - "modules": [ - { - "module_type": "langchain_parse", - "parse_method": "pdfminer", - } - ] - } + config = data["config"] - yaml_path = os.path.join(config_dir, f"parse_config_{trial_id}.yaml") - with open(yaml_path, "w", encoding="utf-8") as f: - yaml.safe_dump(yaml_config, f, encoding="utf-8", allow_unicode=True) + # Trial 객체 가져오기 + project_db = SQLiteProjectDB(project_id) + trial = project_db.get_trial(trial_id) + if not trial: + return jsonify({"error": f"Trial not found: {trial_id}"}), 404 - logger.info(f"Created config file at {yaml_path} with content: {yaml_config}") + print(f"trial: {trial}") + print(f"project_id: {project_id}") + print(f"trial_id: {trial_id}") + print(f"config: {config}") - # Task 생성 및 실행 - task_id = str(uuid.uuid4()) - save_path = os.path.join(WORK_DIR, project_id, "parse", f"parse_{trial_id}") - task = Task( - id=task_id, + # Celery task 시작 + task = parse_documents.delay( project_id=project_id, trial_id=trial_id, - name=parse_request.name, - status=Status.IN_PROGRESS, - type=TaskType.PARSE, - created_at=datetime.now(), - save_path=save_path, + config_str=yaml.dump(config), # POST body의 config 사용 ) + task_id = task.id + print(f"task: {task}") - logger.info( - f"Creating task with data_path_glob: {data_path_glob}, project_dir: {save_path}, yaml_path: {yaml_path}" - ) - await create_task( - task_id, - task, - run_parser_start_parsing, - data_path_glob, - save_path, - yaml_path, - ) - return jsonify(task.model_dump()), 202 + # Trial 상태 업데이트 + trial.status = Status.IN_PROGRESS + print(f"trial: {trial}") + print(f"task: {task}") + trial.parse_task_id = task.id + project_db.set_trial(trial) + return jsonify({"task_id": task.id, "status": "started"}) except Exception as e: - logger.error(f"Error in parse: {str(e)}", exc_info=True) - if yaml_path and os.path.exists(yaml_path): - try: - os.remove(yaml_path) - except Exception as cleanup_error: - logger.error(f"Error cleaning up yaml file: {cleanup_error}") - return jsonify({"error": f"An error occurred: {str(e)}"}), 500 + logger.error(f"Error starting parse task: {str(e)}", exc_info=True) + return jsonify({"task_id": task_id, "status": "FAILURE", "error": str(e)}), 500 + + +@app.route("/projects//tasks/", methods=["GET"]) +async def get_task_status(project_id: str, task_id: str): + print(f"project_id: {project_id}") + if task_id == "undefined": + return jsonify({"status": "FAILURE", "error": "Task ID is undefined"}), 200 + else: + # celery 상태 확인 + task = AsyncResult(task_id) + print(f"task: {task.status}") + try: + return jsonify( + { + "status": task.status, + "error": str(task.result) if task.failed() else None, + } + ), 200 + except Exception as e: + return jsonify({"status": "FAILURE", "error": str(e)}), 500 @app.route( @@ -650,70 +635,33 @@ async def start_chunking(project_id: str, trial_id: str): # Get JSON data from request and validate with Pydantic data = await request.get_json() chunk_request = ChunkRequest(**data) + config = chunk_request.config - trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") - trial_config_db = PandasTrialDB(trial_config_path) - previous_config = trial_config_db.get_trial_config(trial_id) - - parsed_data_path = os.path.join( - WORK_DIR, project_id, "parse", f"parse_{previous_config.id}/0.parquet" - ) - # parsed_data_path 확인 - print(f"parsed_data_path: {parsed_data_path}") - if not parsed_data_path or not os.path.exists(parsed_data_path): - return jsonify( - {"error": "Parsed data path not found. Please run parse first."} - ), 400 + project_db = SQLiteProjectDB(project_id) + trial = project_db.get_trial(trial_id) + if not trial: + return jsonify({"error": f"Trial not found: {trial_id}"}), 404 - # Get the directory containing datasets - dataset_dir = os.path.join(WORK_DIR, project_id, "chunk", chunk_request.name) - if not os.path.exists(dataset_dir): - os.makedirs(dataset_dir) - - with tempfile.NamedTemporaryFile(suffix=".yaml", delete=False) as yaml_tempfile: - with open(yaml_tempfile.name, "w") as w: - yaml.safe_dump(chunk_request.config, w) - yaml_path = yaml_tempfile.name - - task_id = str(uuid.uuid4()) - response = Task( - id=task_id, + # Celery task 시작 + task = chunk_documents.delay( project_id=project_id, trial_id=trial_id, - name=chunk_request.name, - config_yaml=chunk_request.config, - status=Status.IN_PROGRESS, - type=TaskType.CHUNK, - created_at=datetime.now(), - save_path=dataset_dir, - ) - await create_task( - task_id, - response, - run_chunker_start_chunking, - parsed_data_path, # raw_filepath 대신 parsed_data_path 사용 - dataset_dir, - yaml_path, + config_str=yaml.dump(config), # POST body의 config 사용 ) + task_id = task.id + print(f"task: {task}") - # Update trial config - new_config = previous_config.model_copy(deep=True) - new_config.corpus_path = os.path.join(dataset_dir, "0.parquet") - trial_config_db.set_trial_config(trial_id, new_config) - - return jsonify(response.model_dump()), 202 - - except ValueError as ve: - print(f"ValueError in chunk: {ve}") - logger.error(f"ValueError in chunk: {ve}", exc_info=True) - - # Handle Pydantic validation errors - return jsonify({"error": f"Validation error: {str(ve)}"}), 400 + # Trial 상태 업데이트 + trial.status = Status.IN_PROGRESS + print(f"trial: {trial}") + print(f"task: {task}") + trial.parse_task_id = task.id + project_db.set_trial(trial) + return jsonify({"task_id": task.id, "status": "started"}) except Exception as e: - print(f"Error in chunk: {e}") - logger.error(f"Error in chunk: {e}", exc_info=True) - return jsonify({"error": f"An error occurred: {str(e)}"}), 500 + logger.error(f"Error starting parse task: {str(e)}", exc_info=True) + return jsonify({"task_id": task_id, "status": "FAILURE", "error": str(e)}), 500 @app.route( @@ -737,9 +685,9 @@ async def create_qa(project_id: str, trial_id: str): {"error": f"QA dataset name already exists: {qa_creation_request.name}"} ), 400 - trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") - trial_config_db = PandasTrialDB(trial_config_path) - previous_config = trial_config_db.get_trial_config(trial_id) + trial_config_path = os.path.join(WORK_DIR, project_id, "trials.db") + trial_config_db = SQLiteProjectDB(trial_config_path) + previous_config = trial_config_db.get_trial(trial_id).config corpus_filepath = os.path.join( WORK_DIR, project_id, "chunk", f"chunk_{previous_config.id}/0.parquet" @@ -795,10 +743,11 @@ async def create_qa(project_id: str, trial_id: str): @project_exists(WORK_DIR) @trial_exists(WORK_DIR) async def get_trial_config(project_id: str, trial_id: str): - trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") - trial_config_db = PandasTrialDB(trial_config_path) - trial_config = trial_config_db.get_trial_config(trial_id) - return jsonify(trial_config.model_dump()), 200 + project_db = SQLiteProjectDB(project_id) + trial = project_db.get_trial(trial_id) + if not trial: + return jsonify({"error": "Trial not found"}), 404 + return jsonify(trial.config.model_dump()), 200 @app.route( @@ -807,29 +756,16 @@ async def get_trial_config(project_id: str, trial_id: str): @project_exists(WORK_DIR) @trial_exists(WORK_DIR) async def set_trial_config(project_id: str, trial_id: str): - trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") - trial_config_db = PandasTrialDB(trial_config_path) - previous_config = trial_config_db.get_trial_config(trial_id) - new_config = previous_config.model_copy(deep=True) + project_db = SQLiteProjectDB(project_id) + trial = project_db.get_trial(trial_id) + if not trial: + return jsonify({"error": "Trial not found"}), 404 + data = await request.get_json() - if data.get("raw_path", None) is not None: - new_config.raw_path = data["raw_path"] - if data.get("corpus_path", None) is not None: - new_config.corpus_path = data["corpus_path"] - if data.get("qa_path", None) is not None: - new_config.qa_path = data["qa_path"] if data.get("config", None) is not None: - new_config_path = os.path.join( - WORK_DIR, project_id, "config", f"{str(uuid.uuid4())}.yaml" - ) - with open(new_config_path, "w") as f: - yaml.safe_dump(data["config"], f) - new_config.config_path = new_config_path - if data.get("metadata", None) is not None: - new_config.metadata = data["metadata"] + project_db.set_trial_config(trial_id, TrialConfig(**data["config"])) - trial_config_db.set_trial_config(trial_id, new_config) - return jsonify(new_config.model_dump()), 201 + return jsonify({"message": "Config updated successfully"}), 200 @app.route( @@ -837,19 +773,16 @@ async def set_trial_config(project_id: str, trial_id: str): ) @project_exists(WORK_DIR) async def start_validate(project_id: str, trial_id: str): - trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") - trial_config_db = PandasTrialDB(trial_config_path) - trial_config = trial_config_db.get_trial_config(trial_id) - + trial_config_path = os.path.join(WORK_DIR, project_id, "trials.db") + trial_config_db = SQLiteProjectDB(trial_config_path) + trial = trial_config_db.get_trial(trial_id) task_id = str(uuid.uuid4()) - with open(trial_config.config_path, "r") as f: - config_yaml = yaml.safe_load(f) response = Task( id=task_id, project_id=project_id, trial_id=trial_id, name=f"{trial_id}/validation", - config_yaml=config_yaml, + config_yaml=trial.config, status=Status.IN_PROGRESS, type=TaskType.VALIDATE, created_at=datetime.now(), @@ -857,13 +790,13 @@ async def start_validate(project_id: str, trial_id: str): await create_task( task_id, response, - run_validate, - trial_config.qa_path, - trial_config.corpus_path, - trial_config.config_path, + TaskType.VALIDATE, + trial.config.qa_path, + trial.config.corpus_path, + trial.config.config_path, ) - return jsonify(response.model_dump()), 202 + return jsonify(response), 200 @app.route( @@ -880,8 +813,10 @@ async def start_evaluate(project_id: str, trial_id: str): else: evaluate_history_df = pd.read_csv(evaluate_history_path) - trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") - trial_config_db = PandasTrialDB(trial_config_path) + trial_config_path = os.path.join(WORK_DIR, project_id, "trials.db") + trial_config_db = SQLiteProjectDB(trial_config_path) + previous_config = trial_config_db.get_trial(trial_id).config + print("previous_config: ", previous_config) trial = trial_config_db.get_trial(trial_id) trials_dir = os.path.join(WORK_DIR, project_id, "trials") @@ -906,9 +841,9 @@ async def start_evaluate(project_id: str, trial_id: str): "task_id": task_id, "trial_id": trial_id, "save_dir": new_trial_dir, - "corpus_path": trial.config.corpus_path, - "qa_path": trial.config.qa_path, - "config_path": trial.config.config_path, + "corpus_path": previous_config.corpus_path, + "qa_path": previous_config.qa_path, + "config_path": previous_config.config_path, "created_at": datetime.now(), } ] @@ -993,8 +928,8 @@ async def open_dashboard(project_id: str, trial_id: str): ) await create_task(task_id, response, run_dashboard, trial_dir) - trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") - trial_config_db = PandasTrialDB(trial_config_path) + trial_config_path = os.path.join(WORK_DIR, project_id, "trials.db") + trial_config_db = SQLiteProjectDB(trial_config_path) trial = trial_config_db.get_trial(trial_id) new_trial = trial.model_copy(deep=True) new_trial.report_task_id = task_id @@ -1011,8 +946,8 @@ async def open_dashboard(project_id: str, trial_id: str): methods=["GET"], ) async def close_dashboard(project_id: str, trial_id: str): - trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") - trial_config_db = PandasTrialDB(trial_config_path) + trial_config_path = os.path.join(WORK_DIR, project_id, "trials.db") + trial_config_db = SQLiteProjectDB(trial_config_path) trial = trial_config_db.get_trial(trial_id) report_pid = tasks[trial.report_task_id]["report_pid"] os.killpg(os.getpgid(report_pid), signal.SIGTERM) @@ -1063,8 +998,8 @@ async def open_chat_server(project_id: str, trial_id: str): ) await create_task(task_id, response, run_chat, trial_dir) - trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") - trial_config_db = PandasTrialDB(trial_config_path) + trial_config_path = os.path.join(WORK_DIR, project_id, "trials.db") + trial_config_db = SQLiteProjectDB(trial_config_path) trial = trial_config_db.get_trial(trial_id) new_trial = trial.model_copy(deep=True) new_trial.chat_task_id = task_id @@ -1101,8 +1036,8 @@ async def get_project_artifacts(project_id: str): "/projects//trials//chat/close", methods=["GET"] ) async def close_chat_server(project_id: str, trial_id: str): - trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") - trial_config_db = PandasTrialDB(trial_config_path) + trial_config_path = os.path.join(WORK_DIR, project_id, "trials.db") + trial_config_db = SQLiteProjectDB(trial_config_path) trial = trial_config_db.get_trial(trial_id) chat_pid = tasks[trial.chat_task_id]["chat_pid"] os.killpg(os.getpgid(chat_pid), signal.SIGTERM) diff --git a/api/celery_app.py b/api/celery_app.py new file mode 100644 index 000000000..f404b7dac --- /dev/null +++ b/api/celery_app.py @@ -0,0 +1,17 @@ +from celery import Celery + +app = Celery('autorag', + broker='redis://redis:6379/0', + backend='redis://redis:6379/0', + include=['tasks.trial_tasks']) + +# Celery 설정 +app.conf.update( + broker_url='redis://redis:6379/0', + result_backend='redis://redis:6379/0', + task_serializer='json', + accept_content=['json'], + result_serializer='json', + timezone='Asia/Seoul', + enable_utc=True, +) diff --git a/api/database/project_db.py b/api/database/project_db.py new file mode 100644 index 000000000..2d4bd06fb --- /dev/null +++ b/api/database/project_db.py @@ -0,0 +1,178 @@ +import sqlite3 +import json +from typing import Optional, List +from datetime import datetime +from src.schema import Trial, TrialConfig + +import os +from pathlib import Path + +class SQLiteProjectDB: + def __init__(self, project_id: str): + print(f"Initializing SQLiteProjectDB for project_id: {project_id}") + self.project_id = project_id + self.db_path = self._get_db_path() + self._init_db() + + def _get_db_path(self) -> str: + """프로젝트 ID로부터 DB 경로 생성""" + # 1. 기본 작업 디렉토리 설정 + work_dir = os.getenv('WORK_DIR', '/app/projects') + + # 2. 절대 경로로 변환 (상대 경로 해결) + work_dir = os.path.abspath(work_dir) + + # 3. 최종 DB 파일 경로 생성 + db_path = os.path.join(work_dir, self.project_id, "project.db") + + # 디버깅을 위한 로그 + print(f"WORK_DIR (raw): {os.getenv('WORK_DIR', '/app/projects')}") + print(f"WORK_DIR (abs): {work_dir}") + print(f"Project ID: {self.project_id}") + print(f"Final DB path: {db_path}") + + return db_path + + def _init_db(self): + """DB 초기화 (필요한 경우 디렉토리 및 테이블 생성)""" + db_exists = os.path.exists(self.db_path) + db_dir = os.path.dirname(self.db_path) + + print(f"DB Path: {self.db_path}") + print(f"DB Directory: {db_dir}") + print(f"DB exists: {db_exists}") + print(f"Directory exists: {os.path.exists(db_dir)}") + + # DB 파일이 없을 때만 초기화 작업 수행 + if not db_exists: + # 디렉토리가 없을 때만 생성 + if not os.path.exists(db_dir): + print(f"Creating directory: {db_dir}") + os.makedirs(db_dir) + # 디렉토리 권한 설정 (777) + os.chmod(db_dir, 0o777) + + try: + print(f"Creating database: {self.db_path}") + with sqlite3.connect(self.db_path) as conn: + print("Successfully connected to database") + conn.execute(""" + CREATE TABLE IF NOT EXISTS trials ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + name TEXT, + status TEXT, + config JSON, + created_at TEXT, + report_task_id TEXT, + chat_task_id TEXT, + parse_task_id TEXT, + chunk_task_id TEXT + ) + """) + conn.execute("CREATE INDEX IF NOT EXISTS idx_project_id ON trials(project_id)") + + # DB 파일 권한 설정 (666) + os.chmod(self.db_path, 0o666) + except Exception as e: + print(f"Error creating database: {str(e)}") + print(f"Current working directory: {os.getcwd()}") + raise + + def get_trial(self, trial_id: str) -> Optional[Trial]: + """특정 trial 조회""" + with sqlite3.connect(self.db_path) as conn: + conn.row_factory = sqlite3.Row + cursor = conn.execute("SELECT * FROM trials WHERE id = ?", (trial_id,)) + row = cursor.fetchone() + + if row: + trial_dict = dict(row) + if trial_dict['config']: + trial_dict['config'] = TrialConfig.model_validate_json(trial_dict['config']) + if trial_dict['created_at']: + trial_dict['created_at'] = datetime.fromisoformat(trial_dict['created_at']) + return Trial(**trial_dict) + return None + + def set_trial(self, trial: Trial): + """trial 저장 또는 업데이트""" + with sqlite3.connect(self.db_path) as conn: + conn.execute(""" + INSERT OR REPLACE INTO trials + (id, project_id, name, status, config, created_at, report_task_id, chat_task_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, ( + trial.id, + trial.project_id, + trial.name, + trial.status, + trial.config.model_dump_json() if trial.config else None, + trial.created_at.isoformat() if trial.created_at else None, + trial.report_task_id, + trial.chat_task_id + )) + + def set_trial_config(self, trial_id: str, config: TrialConfig): + """trial config 업데이트""" + with sqlite3.connect(self.db_path) as conn: + conn.execute(""" + UPDATE trials + SET config = ? + WHERE id = ? + """, (config.model_dump_json(), trial_id)) + + def get_all_config_ids(self) -> List[str]: + """모든 trial의 config ID 목록 조회""" + with sqlite3.connect(self.db_path) as conn: + cursor = conn.execute(""" + SELECT DISTINCT id + FROM trials + WHERE config IS NOT NULL + ORDER BY created_at DESC + """) + return [row[0] for row in cursor.fetchall()] + + def get_all_trial_ids(self, project_id: Optional[str] = None) -> List[str]: + """모든 trial ID 조회 (프로젝트별 필터링 가능)""" + with sqlite3.connect(self.db_path) as conn: + if project_id: + cursor = conn.execute(""" + SELECT id + FROM trials + WHERE project_id = ? + ORDER BY created_at DESC + """, (project_id,)) + else: + cursor = conn.execute(""" + SELECT id + FROM trials + ORDER BY created_at DESC + """) + return [row[0] for row in cursor.fetchall()] + + def delete_trial(self, trial_id: str): + """trial 삭제""" + with sqlite3.connect(self.db_path) as conn: + conn.execute("DELETE FROM trials WHERE id = ?", (trial_id,)) + + def get_trials_by_project(self, project_id: str, limit: int = 10, offset: int = 0) -> List[Trial]: + """프로젝트별 trial 목록 조회 (페이지네이션)""" + with sqlite3.connect(self.db_path) as conn: + conn.row_factory = sqlite3.Row + cursor = conn.execute(""" + SELECT * FROM trials + WHERE project_id = ? + ORDER BY created_at DESC + LIMIT ? OFFSET ? + """, (project_id, limit, offset)) + + trials = [] + for row in cursor.fetchall(): + trial_dict = dict(row) + if trial_dict['config']: + trial_dict['config'] = TrialConfig(**json.loads(trial_dict['config'])) + if trial_dict['created_at']: + trial_dict['created_at'] = datetime.fromisoformat(trial_dict['created_at']) + trials.append(Trial(**trial_dict)) + return trials diff --git a/api/entrypoint.sh b/api/entrypoint.sh new file mode 100644 index 000000000..31f9019e3 --- /dev/null +++ b/api/entrypoint.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +# Start API server with standard event loop +uvicorn app:app --host 0.0.0.0 --port 5000 --loop asyncio --reload & +# --reload-dir /app/api & + +# Start Celery worker +# multiprocessing을 사용하지 않으므로 daemon 프로세스 문제를 피할 수 있음 +python -m celery -A celery_app worker --loglevel=INFO --pool=solo # --concurrency=1 + +# Wait for any process to exit +wait -n + +# Exit with status of process that exited first +exit $? diff --git a/api/requirements.txt b/api/requirements.txt index 77b0d8ea0..74c986d9c 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -5,3 +5,6 @@ quart-cors Werkzeug quart-uploads uvicorn +redis==4.5.1 +celery==5.2.7 +flower==1.2.0 diff --git a/api/src/schema.py b/api/src/schema.py index b4f7a5097..ef7184c95 100644 --- a/api/src/schema.py +++ b/api/src/schema.py @@ -3,7 +3,7 @@ from typing import Dict, Literal, Any, Optional import numpy as np -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, Field, field_validator, ConfigDict class TrialCreateRequest(BaseModel): @@ -54,15 +54,10 @@ class EnvVariableRequest(BaseModel): value: str class Project(BaseModel): - id: str - name: str - description: str - created_at: datetime - status: Literal["active", "archived"] - metadata: Dict[str, Any] - - class Config: - json_schema_extra = { + model_config = ConfigDict( + from_attributes=True, + validate_assignment=True, + json_schema_extra={ "example": { "id": "proj_123", "name": "My Project", @@ -72,13 +67,22 @@ class Config: "metadata": {} } } + ) + + id: str + name: str + description: str + created_at: datetime + status: Literal["active", "archived"] + metadata: Dict[str, Any] class Status(str, Enum): - NOT_STARTED = "not_started" - IN_PROGRESS = "in_progress" - COMPLETED = "completed" - FAILED = "failed" - TERMINATED = "terminated" + NOT_STARTED = 'not_started' + IN_PROGRESS = 'in_progress' + PARSING = 'parsing' + COMPLETED = 'completed' + FAILED = 'failed' + TERMINATED = 'terminated' class TaskType(str, Enum): PARSE = "parse" @@ -108,28 +112,35 @@ class Task(BaseModel): ) class TrialConfig(BaseModel): + model_config = ConfigDict( + from_attributes=True, + validate_assignment=True + ) + trial_id: str project_id: str - raw_path: Optional[str] - corpus_path: Optional[str] - qa_path: Optional[str] - config_path: Optional[str] - metadata: Dict = {} # Using Dict as the default empty dict for metadata - - class Config: - arbitrary_types_allowed = True + raw_path: str + corpus_path: Optional[str] = None + qa_path: Optional[str] = None + config_path: Optional[str] = None + metadata: Optional[dict] = {} class Trial(BaseModel): + model_config = ConfigDict( + from_attributes=True, + validate_assignment=True + ) + id: str project_id: str - config: Optional[TrialConfig] = Field(description="The trial configuration", - default=None) + config: Optional[TrialConfig] = None name: str status: Status created_at: datetime report_task_id: Optional[str] = Field(None, description="The report task id for forcing shutdown of the task") chat_task_id: Optional[str] = Field(None, description="The chat task id for forcing shutdown of the task") - + parse_task_id: Optional[str] = Field(None, description="The parse task id") # Celery task id + chunk_task_id: Optional[str] = Field(None, description="The chunk task id") # Celery task id corpus_path: Optional[str] = None qa_path: Optional[str] = None @@ -138,3 +149,29 @@ def replace_nan_with_none(cls, v): if isinstance(v, float) and np.isnan(v): return None return v + + # 경로 유효성 검사 메서드 추가 + def validate_paths(self) -> bool: + """ + 모든 필수 경로가 유효한지 검사 + """ + import os + return all([ + os.path.exists(self.corpus_path), + os.path.exists(self.qa_path), + os.path.exists(self.config_path) + ]) + + # 경로 생성 메서드 추가 + def create_directories(self) -> None: + """ + 필요한 디렉토리 구조 생성 + """ + import os + paths = [ + os.path.dirname(self.corpus_path), + os.path.dirname(self.qa_path), + os.path.dirname(self.config_path) + ] + for path in paths: + os.makedirs(path, exist_ok=True) diff --git a/api/src/trial_config.py b/api/src/trial_config.py deleted file mode 100644 index 475b95385..000000000 --- a/api/src/trial_config.py +++ /dev/null @@ -1,94 +0,0 @@ -import pandas as pd -import json -from typing import Optional, List -from src.schema import Trial, TrialConfig -from datetime import datetime - -class DateTimeEncoder(json.JSONEncoder): - def default(self, obj): - if isinstance(obj, datetime): - return obj.isoformat() - return super().default(obj) - -class PandasTrialDB: - def __init__(self, path: str): - self.path = path - self.columns = [ - 'id', - 'project_id', - 'config', - 'name', - 'status', - 'created_at', - 'report_task_id', - 'chat_task_id' - ] - try: - self.df = pd.read_csv(self.path) - for col in self.columns: - if col not in self.df.columns: - self.df[col] = None - except FileNotFoundError: - self.df = pd.DataFrame(columns=self.columns) - self.df.to_csv(self.path, index=False) - - def get_trial(self, trial_id: str) -> Optional[Trial]: - try: - trial_row = self.df[self.df['id'] == trial_id].iloc[0] - trial_dict = trial_row.to_dict() - - # config 문자열을 딕셔너리로 변환 - if isinstance(trial_dict['config'], str): - config_dict = json.loads(trial_dict['config']) - # config 필드만 따로 처리 - if 'config' in config_dict: - trial_dict['config'] = TrialConfig(**config_dict['config']) - else: - trial_dict['config'] = None - - # created_at을 datetime으로 변환 - if isinstance(trial_dict['created_at'], str): - trial_dict['created_at'] = datetime.fromisoformat(trial_dict['created_at']) - - return Trial(**trial_dict) - except (IndexError, KeyError, json.JSONDecodeError): - return None - - def set_trial(self, trial: Trial): - try: - row_data = { - 'id': trial.id, - 'project_id': trial.project_id, - 'config': json.dumps(trial.model_dump(), cls=DateTimeEncoder), - 'name': trial.name, - 'status': trial.status, - 'created_at': trial.created_at.isoformat() if trial.created_at else None, - 'report_task_id': None, - 'chat_task_id': None - } - - if self.df.empty or trial.id not in self.df['id'].values: - # 새 행 추가 - new_row = pd.DataFrame([row_data], columns=self.columns) - self.df = pd.concat([self.df, new_row], ignore_index=True) - else: - # 기존 행 업데이트 - idx = self.df.index[self.df['id'] == trial.id][0] - for col in self.columns: - self.df.at[idx, col] = row_data[col] - - # 변경사항 저장 - self.df.to_csv(self.path, index=False) - except Exception as e: - print(f"Error in set_trial: {e}") - print(f"Row data: {row_data}") - raise - - def get_trial_config(self, trial_id: str) -> Optional[Trial]: - return self.get_trial(trial_id) - - def set_trial_config(self, trial_id: str, config: Trial): - return self.set_trial(config) - - def get_all_trial_ids(self) -> List[str]: - return self.df['id'].tolist() diff --git a/api/src/validate.py b/api/src/validate.py index 4f187bce1..46ba16df0 100644 --- a/api/src/validate.py +++ b/api/src/validate.py @@ -1,57 +1,48 @@ import os from functools import wraps - from quart import jsonify from src.schema import Trial -from src.trial_config import PandasTrialDB - - -def project_exists(work_dir): - def decorator(func): - @wraps(func) - async def wrapper(*args, **kwargs): - # Get project_id from request arguments - project_id = kwargs.get('project_id') +from database.project_db import SQLiteProjectDB - if not project_id: - return jsonify({ - 'error': 'project_id is required' - }), 400 +def project_exists(base_dir: str): + def decorator(f): + @wraps(f) + async def decorated_function(*args, **kwargs): + project_id = kwargs.get('project_id') + if not project_id: + return jsonify({"error": "Project ID is required"}), 400 - # Check if project directory exists - project_path = os.path.join(work_dir, project_id) - if not os.path.exists(project_path): - return jsonify({ - 'error': f'Project with id {project_id} does not exist' - }), 404 + project_dir = os.path.join(base_dir, project_id) + if not os.path.exists(project_dir): + return jsonify({"error": "Project not found"}), 404 - # If everything is okay, proceed with the endpoint function - return await func(*args, **kwargs) + # SQLiteProjectDB 초기화 + project_db = SQLiteProjectDB(project_id) - return wrapper - return decorator + return await f(*args, **kwargs) + return decorated_function + return decorator def trial_exists(work_dir: str): - def decorator(func): - @wraps(func) - async def wrapper(*args, **kwargs): - project_id = kwargs.get("project_id") - trial_id = kwargs.get("trial_id") - - if not trial_id: - return jsonify({ - 'error': 'trial_id is required' - }), 400 - - trial_config_path = os.path.join(work_dir, project_id, "trial_config.csv") - trial_config_db = PandasTrialDB(trial_config_path) - trial = trial_config_db.get_trial(trial_id) - if trial is None or not isinstance(trial, Trial): - return jsonify({ - 'error': f'Trial with id {trial_id} does not exist' - }), 404 - - return await func(*args, **kwargs) - return wrapper - return decorator + def decorator(func): + @wraps(func) + async def wrapper(*args, **kwargs): + project_id = kwargs.get("project_id") + trial_id = kwargs.get("trial_id") + + if not trial_id: + return jsonify({ + 'error': 'trial_id is required' + }), 400 + + project_db = SQLiteProjectDB(project_id) + trial = project_db.get_trial(trial_id) + if trial is None or not isinstance(trial, Trial): + return jsonify({ + 'error': f'Trial with id {trial_id} does not exist' + }), 404 + + return await func(*args, **kwargs) + return wrapper + return decorator diff --git a/api/tasks/base.py b/api/tasks/base.py new file mode 100644 index 000000000..dda174200 --- /dev/null +++ b/api/tasks/base.py @@ -0,0 +1,60 @@ +from celery import Task +from typing import Dict, Any +from database.project_db import SQLiteProjectDB +import os +from src.schema import Status # Import 추가 + +class TrialTask(Task): + """Trial 관련 모든 task의 기본 클래스""" + + def update_state_and_db(self, trial_id: str, project_id: str, + status: str, progress: int, + task_type: str, parse_task_id: str = None, chunk_task_id: str = None, + info: Dict[str, Any] = None): + """Task 상태와 DB를 함께 업데이트""" + # Redis에 상태 업데이트 (Celery) + self.update_state( + state='PROGRESS', + meta={ + 'trial_id': trial_id, + 'task_type': task_type, + 'current': progress, + 'parse_task_id': parse_task_id, + 'chunk_task_id': chunk_task_id, + 'total': 100, + 'status': status, + 'info': info or {} + } + ) + + # 상태 매핑 추가 + status_map = { + 'PENDING': Status.IN_PROGRESS, + 'STARTED': Status.IN_PROGRESS, + 'SUCCESS': Status.COMPLETED, + 'FAILURE': Status.FAILED, + 'chunking': Status.IN_PROGRESS, + 'parsing': Status.IN_PROGRESS + } + trial_status = status_map.get(status, Status.FAILED) + + # SQLite DB 업데이트 + project_db = SQLiteProjectDB(project_id) + trial = project_db.get_trial(trial_id) + if trial: + trial.status = trial_status # 매핑된 상태 사용 + if task_type == 'parse': + trial.parse_task_id = self.request.id + elif task_type == 'chunk': + trial.chunk_task_id = self.request.id + project_db.set_trial(trial) + + def on_failure(self, exc, task_id, args, kwargs, einfo): + """Handle task failure""" + super().on_failure(exc, task_id, args, kwargs, einfo) + # Add your error handling logic here + + def on_success(self, retval, task_id, args, kwargs): + """Handle task success""" + super().on_success(retval, task_id, args, kwargs) + # Add your success handling logic here \ No newline at end of file diff --git a/api/tasks/processing.py b/api/tasks/processing.py new file mode 100644 index 000000000..d14494699 --- /dev/null +++ b/api/tasks/processing.py @@ -0,0 +1,21 @@ +from .base import ProgressTask +from celery import shared_task +import time + +@shared_task(bind=True, base=ProgressTask) +def process_documents(self, documents): + """Example task with progress tracking""" + total = len(documents) + + for i, doc in enumerate(documents, 1): + # Process document + time.sleep(1) # Simulate work + + # Update progress + self.update_progress( + current=i, + total=total, + info={'current_doc': doc['id']} + ) + + return {'processed': total} \ No newline at end of file diff --git a/api/tasks/trial_tasks.py b/api/tasks/trial_tasks.py new file mode 100644 index 000000000..c8ffa3b50 --- /dev/null +++ b/api/tasks/trial_tasks.py @@ -0,0 +1,152 @@ +import os +from celery import shared_task +from .base import TrialTask +from src.schema import ( + Status, +) +import logging +import yaml +from src.run import run_parser_start_parsing, run_chunker_start_chunking + +# 로깅 설정 +logging.basicConfig( + level=logging.DEBUG, + format="%(asctime)s - %(levelname)s - %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", +) +logger = logging.getLogger(__name__) +ROOT_DIR = "/app" +ENV = os.getenv("AUTORAG_API_ENV", "dev") + +# WORK_DIR 설정 +if "AUTORAG_WORK_DIR" in os.environ: + # 환경변수로 지정된 경우 해당 경로 사용 + WORK_DIR = os.getenv("AUTORAG_WORK_DIR") +else: + # 환경변수가 없는 경우 기본값 사용 + WORK_DIR = os.path.join(ROOT_DIR, "projects") + + +@shared_task(bind=True, base=TrialTask) +def chunk_documents(self, project_id: str, trial_id: str, config_str: str): + """ + Task for the chunk documents + + :param project_id: The project id of the trial + :param trial_id: The id of the trial + :param config_str: Configuration string for chunking + :return: The result of the chunking (Maybe None?) + """ + try: + self.update_state_and_db( + trial_id=trial_id, + project_id=project_id, + status="chunking", + progress=0, + task_type="chunk", + ) + + # 청킹 작업 수행 + logger.info("Chunking documents") + + project_dir = os.path.join(WORK_DIR, project_id) + config_dir = os.path.join(project_dir, "config") + parsed_data_path = os.path.join( + project_dir, "parse", f"parse_{trial_id}", "0.parquet" + ) + chunked_data_dir = os.path.join(project_dir, "chunk", f"chunk_{trial_id}") + os.makedirs(config_dir, exist_ok=True) + config_dir = os.path.join(project_dir, "config") + + # config_str을 파이썬 딕셔너리로 변환 후 다시 YAML로 저장 + if isinstance(config_str, str): + config_dict = yaml.safe_load(config_str) + else: + config_dict = config_str + + # YAML 파일 형식 확인 + if "modules" not in config_dict: + config_dict = {"modules": config_dict} + + logger.debug(f"Chunking config_dict: {config_dict}") + # YAML 파일 저장 + yaml_path = os.path.join(config_dir, f"chunk_config_{trial_id}.yaml") + with open(yaml_path, "w", encoding="utf-8") as f: + yaml.safe_dump(config_dict, f, allow_unicode=True) + + result = run_chunker_start_chunking( + parsed_data_path, chunked_data_dir, yaml_path + ) + + self.update_state_and_db( + trial_id=trial_id, + project_id=project_id, + status=Status.COMPLETED, + progress=100, + task_type="chunk", + ) + return result + except Exception as e: + self.update_state_and_db( + trial_id=trial_id, + project_id=project_id, + status=Status.FAILED, + progress=0, + task_type="chunk", + info={"error": str(e)}, + ) + raise + + +@shared_task(bind=True, base=TrialTask) +def parse_documents(self, project_id: str, trial_id: str, config_str: str): + try: + self.update_state_and_db( + trial_id=trial_id, + project_id=project_id, + status=Status.IN_PROGRESS, + progress=0, + task_type="parse", + ) + + project_dir = os.path.join(WORK_DIR, project_id) + raw_data_path = os.path.join(project_dir, "raw_data", "*.pdf") + config_dir = os.path.join(project_dir, "config") + parsed_data_path = os.path.join(project_dir, "parse", f"parse_{trial_id}") + os.makedirs(config_dir, exist_ok=True) + + # config_str을 파이썬 딕셔너리로 변환 후 다시 YAML로 저장 + if isinstance(config_str, str): + config_dict = yaml.safe_load(config_str) + else: + config_dict = config_str + + # YAML 파일 형식 확인 + if "modules" not in config_dict: + config_dict = {"modules": config_dict} + + # YAML 파일 저장 + yaml_path = os.path.join(config_dir, f"parse_config_{trial_id}.yaml") + with open(yaml_path, "w", encoding="utf-8") as f: + yaml.safe_dump(config_dict, f, allow_unicode=True) + + result = run_parser_start_parsing(raw_data_path, parsed_data_path, yaml_path) + + self.update_state_and_db( + trial_id=trial_id, + project_id=project_id, + status=Status.COMPLETED, + progress=100, + task_type="parse", + ) + return result + except Exception as e: + self.update_state_and_db( + trial_id=trial_id, + project_id=project_id, + status=Status.FAILED, + progress=0, + task_type="parse", + info={"error": str(e)}, + ) + raise diff --git a/api/tests/test_app.py b/api/tests/test_app.py new file mode 100644 index 000000000..113f546fd --- /dev/null +++ b/api/tests/test_app.py @@ -0,0 +1,304 @@ +import pytest +import logging +import tempfile +import shutil +import os +from pathlib import Path +from datetime import datetime +import warnings +from pydantic import warnings as pydantic_warnings +import uuid + +from app import app as quart_app, WORK_DIR + +# Pydantic v2 deprecation 경고 무시 +warnings.filterwarnings("ignore", category=pydantic_warnings.PydanticDeprecatedSince20) + +# 로깅 설정 +logging.basicConfig(level=logging.DEBUG) +logger = logging.getLogger(__name__) + +TEST_HEADERS = { + 'Authorization': 'Bearer good' +} + +# 전역 변수로 프로젝트 데이터 설정 +project_data = { + "name": f"test_project_{datetime.now().strftime('%Y%m%d%H%M%S')}", + "description": "Test Project", + "metadata": {} +} +project_data_name = project_data["name"] +@pytest.fixture(scope="session") +def app_config(): + """Create test configuration for the entire session""" + print("\n=== Setting up test session ===") + + # 테스트용 WORK_DIR 설정 + original_work_dir = os.environ.get('AUTORAG_WORK_DIR') + test_work_dir = str(Path(__file__).resolve().parent.parent.parent / "test_projects") + + print(f"Original WORK_DIR: {original_work_dir}") + print(f"Test WORK_DIR: {test_work_dir}") + + # 테스트 디렉토리 생성 + print(f"Creating test directory at: {test_work_dir}") + os.makedirs(test_work_dir, exist_ok=True) + + # 환경변수 설정 + os.environ['AUTORAG_WORK_DIR'] = test_work_dir + print(f"Set WORK_DIR environment variable to: {os.environ['AUTORAG_WORK_DIR']}") + + yield { + 'original_work_dir': original_work_dir, + 'test_work_dir': test_work_dir + } + + print("\n=== Cleaning up test session ===") + # if os.path.exists(test_work_dir): + # print(f"Removing test directory: {test_work_dir}") + # shutil.rmtree(test_work_dir) + + # 환경변수 복구 + if original_work_dir: + os.environ['AUTORAG_WORK_DIR'] = original_work_dir + else: + os.environ.pop('AUTORAG_WORK_DIR', None) + print("=== Session cleanup complete ===\n") + +@pytest.fixture +async def app(app_config): + """Create a test app instance""" + app = quart_app + + # WORK_DIR 업데이트 + global AUTORAG_WORK_DIR + AUTORAG_WORK_DIR = app_config['test_work_dir'] + app.config['AUTORAG_WORK_DIR'] = app_config['test_work_dir'] + app.config['ENV'] = 'test' + + return app + +@pytest.fixture +async def test_client(app): + """Create a test client""" + logger.info("Creating test client") + return app.test_client() + +@pytest.mark.asyncio +async def test_create_project(test_client, app): + """Test project creation endpoint""" + logger.info(f"Testing project creation with data: {project_data}") + logger.info(f"Using WORK_DIR: {app.config['AUTORAG_WORK_DIR']}") + + response = await test_client.post('/projects', + json=project_data, + headers=TEST_HEADERS) + + status_code = response.status_code + response_data = await response.get_json() + logger.info(f"Response status: {status_code}") + logger.info(f"Response data: {response_data}") + + assert status_code == 201 + + # 프로젝트 디렉토리 경로 확인 + project_dir = Path(app.config['AUTORAG_WORK_DIR']) / project_data_name + logger.info(f"Checking project directory at: {project_dir}") + + assert project_dir.exists(), f"Project directory does not exist: {project_dir}" + for subdir in ['parse', 'chunk', 'qa', 'project', 'config', 'raw_data']: + assert (project_dir / subdir).exists(), f"Subdirectory {subdir} does not exist" + assert (project_dir / 'trials.db').exists(), "trials.db file does not exist" + + # description.txt 파일 검증 + desc_file = project_dir / 'description.txt' + assert desc_file.exists(), "description.txt file does not exist" + content = desc_file.read_text() + assert content == project_data['description'], f"Expected description: {project_data['description']}, got: {content}" + +@pytest.mark.asyncio +@pytest.mark.depends(on=['test_create_project']) # 의존성 표시 +async def test_create_trial_with_tasks(test_client, app): + """Test trial creation with subsequent tasks""" + # 기존 프로젝트 사용 + logger.info(f"Using existing project: {project_data_name}") + + # Trial 생성 + trial_data = { + "name": "test_trial", + "config": { + "trial_id": "test_trial", + "project_id": project_data["name"], + "raw_path": "./raw_data/*.pdf" + }, + "metadata": {} + } + + logger.info(f"Creating trial with data: {trial_data}") + trial_response = await test_client.post( + f'/projects/{project_data_name}/trials', + json=trial_data, + headers=TEST_HEADERS + ) + + assert trial_response.status_code == 200 + trial_result = await trial_response.get_json() + trial_id = trial_result['id'] + + # Parse Task 생성 + parse_data = { + "name": f"parse_{trial_id}", + "path": "./raw_data/*.pdf", + "config": { + "modules": [{ + "module_type": "langchain_parse", + "parse_method": ["pdfminer"] + }] + } + } + + logger.info(f"Creating parse task with data: {parse_data}") + parse_response = await test_client.post( + f'/projects/{project_data["name"]}/trials/{trial_id}/parse', + json=parse_data, + headers=TEST_HEADERS + ) + + assert parse_response.status_code == 200 + parse_result = await parse_response.get_json() + assert parse_result['status'] == 'in_progress' + +@pytest.mark.asyncio +async def test_create_trial(test_client, app): + """Test trial creation endpoint""" + project_data_name = project_data["name"] + logger.info(f"Creating test project with data: {project_data_name}") + project_response = await test_client.post('/projects', + json=project_data, + headers=TEST_HEADERS) + + logger.info(f"Project response: {project_response}") + assert project_response.status_code == 201 + + # Trial 생성 - 간단한 데이터만 포함 + trial_data = { + "name": "test_trial" + } + + logger.info(f"Creating trial with data: {trial_data}") + logger.info(f"Project path: {app.config['AUTORAG_WORK_DIR']}/{project_data_name}") + + response = await test_client.post( + f'/projects/{project_data["name"]}/trials', + json=trial_data, + headers=TEST_HEADERS + ) + + status_code = response.status_code + try: + response_data = await response.get_json() + logger.info(f"Response data: {response_data}") + except Exception as e: + logger.error(f"Failed to get response JSON: {e}") + response_data = await response.get_data() + logger.error(f"Raw response data: {response_data}") + + logger.info(f"Response status: {status_code}") + + assert status_code == 200, f"Expected 200, got {status_code}. Response: {response_data}" + + # Trial 데이터 검증 + assert response_data['name'] == trial_data['name'] + assert response_data['project_id'] == project_data_name + assert response_data['status'] == 'in_progress' + assert 'id' in response_data + assert 'created_at' in response_data + + # Trial DB 파일 검증 + trial_db_path = Path(app.config['AUTORAG_WORK_DIR']) / project_data_name / 'trials.db' + assert trial_db_path.exists(), f"Trial database does not exist at {trial_db_path}" + +@pytest.mark.asyncio +async def test_create_trial_invalid_project(test_client): + """Test trial creation with non-existent project""" + trial_data = { + "name": "test_trial", + "config": { + "model": "gpt-3.5-turbo", + "temperature": 0.7 + } + } + + logger.info("Testing trial creation with invalid project") + response = await test_client.post( + '/projects/nonexistent_project/trials', + json=trial_data, + headers=TEST_HEADERS + ) + + status_code = response.status_code + response_data = await response.get_json() + logger.info(f"Response status: {status_code}") + logger.info(f"Response data: {response_data}") + + assert status_code == 404 + +@pytest.mark.asyncio +async def test_get_trial(test_client): + """Test getting trial details""" + project_data_name = project_data["name"] + + await test_client.post('/projects', json={"name": project_data_name}, headers=TEST_HEADERS) + + trial_response = await test_client.post( + f'/projects/{project_data_name}/trials', + json={"name": "test_trial"}, + headers=TEST_HEADERS + ) + trial_data = await trial_response.get_json() + trial_id = trial_data['id'] + + response = await test_client.get(f'/projects/{project_data_name}/trials/{trial_id}', headers=TEST_HEADERS) + assert response.status_code == 200 + + data = await response.get_json() + assert data['id'] == trial_id + assert data['name'] == "test_trial" + +@pytest.mark.asyncio +async def test_delete_trial(test_client): + """Test trial deletion""" + project_data_name = project_data_name + await test_client.post('/projects', json={"name": project_data_name}, headers=TEST_HEADERS) + trial_response = await test_client.post( + f'/projects/{project_data_name}/trials', + json={"name": "test_trial"}, + headers=TEST_HEADERS + ) + trial_data = await trial_response.get_json() + trial_id = trial_data['id'] + + response = await test_client.delete(f'/projects/{project_data_name}/trials/{trial_id}', headers=TEST_HEADERS) + assert response.status_code == 200 + + get_response = await test_client.get(f'/projects/{project_data_name}/trials/{trial_id}', headers=TEST_HEADERS) + assert get_response.status_code == 404 + +@pytest.mark.asyncio +async def test_environment_variables(test_client): + """Test environment variable operations""" + env_data = {"key": "TEST_KEY", "value": "test_value"} + response = await test_client.post('/env', json=env_data, headers=TEST_HEADERS) + assert response.status_code in [200, 201] + + response = await test_client.get(f'/env/{env_data["key"]}', headers=TEST_HEADERS) + assert response.status_code == 200 + data = await response.get_json() + assert data['value'] == env_data['value'] + + response = await test_client.delete(f'/env/{env_data["key"]}', headers=TEST_HEADERS) + assert response.status_code == 200 + + response = await test_client.get(f'/env/{env_data["key"]}', headers=TEST_HEADERS) + assert response.status_code == 404 \ No newline at end of file diff --git a/api/tests/test_project_db.py b/api/tests/test_project_db.py new file mode 100644 index 000000000..f0f059b2b --- /dev/null +++ b/api/tests/test_project_db.py @@ -0,0 +1,180 @@ +import os +import tempfile +import pytest +from datetime import datetime + +from database.project_db import SQLiteProjectDB +from src.schema import Trial, TrialConfig + + +@pytest.fixture +def temp_project_dir(): + """임시 프로젝트 디렉토리 생성""" + with tempfile.TemporaryDirectory() as temp_dir: + os.environ['WORK_DIR'] = temp_dir + yield temp_dir + + +@pytest.fixture +def project_db(temp_project_dir): + """테스트용 프로젝트 DB 인스턴스 생성""" + return SQLiteProjectDB("test_project") + + +@pytest.fixture +def sample_trial(): + """테스트용 Trial 객체 생성""" + return Trial( + id="test_trial_1", + project_id="test_project", + config=TrialConfig( + trial_id="test_trial_1", + project_id="test_project", + raw_path="/path/to/raw", + corpus_path="/path/to/corpus", + qa_path="/path/to/qa", + config_path="/path/to/config", + ), + name="Test Trial", + status="not_started", + created_at=datetime.now(), + report_task_id="report_123", + chat_task_id="chat_123" + ) + + +def test_db_initialization(temp_project_dir): + """DB 초기화 테스트""" + print("\n[테스트] DB 초기화") + db = SQLiteProjectDB("test_project") + db_path = os.path.join(temp_project_dir, "test_project", "project.db") + print(f"- DB 파일 생성 확인: {db_path}") + assert os.path.exists(db_path) + + +def test_set_and_get_trial(project_db, sample_trial): + """Trial 저장 및 조회 테스트""" + print("\n[테스트] Trial 저장 및 조회") + print(f"- Trial 저장: ID={sample_trial.id}") + project_db.set_trial(sample_trial) + + print("- Trial 조회 및 데이터 검증") + retrieved_trial = project_db.get_trial(sample_trial.id) + + print("\n[Config 검증]") + print(f"원본 Config: {sample_trial.config.model_dump()}") + print(f"조회된 Config: {retrieved_trial.config.model_dump()}") + + assert retrieved_trial is not None, "Trial이 성공적으로 조회되어야 함" + assert retrieved_trial.id == sample_trial.id, "Trial ID가 일치해야 함" + assert retrieved_trial.config.model_dump() == sample_trial.config.model_dump(), "Config 데이터가 일치해야 함" + print("- 검증 완료: 모든 필드가 일치함") + + +def test_get_nonexistent_trial(project_db): + """존재하지 않는 Trial 조회 테스트""" + print("\n[테스트] 존재하지 않는 Trial 조회") + nonexistent_id = "nonexistent_id" + print(f"- 존재하지 않는 ID로 조회: {nonexistent_id}") + retrieved_trial = project_db.get_trial(nonexistent_id) + assert retrieved_trial is None, "존재하지 않는 Trial은 None을 반환해야 함" + print("- 검증 완료: None 반환 확인") + + +def test_set_trial_config(project_db, sample_trial): + """Trial config 업데이트 테스트""" + print("\n[테스트] Trial 설정 업데이트") + print(f"- 기존 Trial 저장: ID={sample_trial.id}") + project_db.set_trial(sample_trial) + + print("- 새로운 설정으로 업데이트") + new_config = TrialConfig( + trial_id="test_trial_1", + project_id="test_project", + raw_path="/new/path/to/raw", + corpus_path="/new/path/to/corpus", + qa_path="/new/path/to/qa", + config_path="/new/path/to/config", + ) + + project_db.set_trial_config(sample_trial.id, new_config) + retrieved_trial = project_db.get_trial(sample_trial.id) + assert retrieved_trial.config.model_dump() == new_config.model_dump() + print("- 검증 완료: 설정 업데이트 확인") + + +def test_get_trials_by_project(project_db, sample_trial): + """프로젝트별 trial 목록 조회 테스트""" + print("\n[테스트] 프로젝트별 Trial 목록 조회") + print("- 첫 번째 Trial 저장") + project_db.set_trial(sample_trial) + + print("- 두 번째 Trial 생성 및 저장") + second_trial = Trial( + id="test_trial_2", + project_id="test_project", + name="Test Trial 2", + status="completed", + created_at=datetime.now() + ) + project_db.set_trial(second_trial) + + print("- 페이지네이션 테스트 (limit=1)") + trials = project_db.get_trials_by_project("test_project", limit=1, offset=0) + assert len(trials) == 1, "한 개의 Trial만 반환되어야 함" + + print("- 전체 Trial 조회 테스트") + all_trials = project_db.get_trials_by_project("test_project", limit=10, offset=0) + assert len(all_trials) == 2, "두 개의 Trial이 반환되어야 함" + print(f"- 검증 완료: 총 {len(all_trials)}개의 Trial 확인") + + +def test_get_all_config_ids(project_db, sample_trial): + """모든 config ID 조회 테스트""" + project_db.set_trial(sample_trial) + + # config가 없는 trial 추가 + trial_without_config = Trial( + id="test_trial_2", + project_id="test_project", + name="Test Trial 2", + status="not_started", + created_at=datetime.now() + ) + project_db.set_trial(trial_without_config) + + config_ids = project_db.get_all_config_ids() + assert len(config_ids) == 1 + assert config_ids[0] == sample_trial.id + + +def test_delete_trial(project_db, sample_trial): + """Trial 삭제 테스트""" + project_db.set_trial(sample_trial) + project_db.delete_trial(sample_trial.id) + assert project_db.get_trial(sample_trial.id) is None + + +def test_get_all_trial_ids(project_db, sample_trial): + """모든 trial ID 조회 테스트""" + project_db.set_trial(sample_trial) + + # 다른 프로젝트의 trial 추가 + other_trial = Trial( + id="other_trial", + project_id="other_project", + name="Other Trial", + status="not_started", + created_at=datetime.now() + ) + project_db.set_trial(other_trial) + + # 특정 프로젝트의 trial ID 조회 + project_trials = project_db.get_all_trial_ids(project_id="test_project") + assert len(project_trials) == 1 + assert project_trials[0] == sample_trial.id + + # 모든 trial ID 조회 + all_trials = project_db.get_all_trial_ids() + assert len(all_trials) == 2 + assert set(all_trials) == {sample_trial.id, other_trial.id} diff --git a/api/utils/task_utils.py b/api/utils/task_utils.py new file mode 100644 index 000000000..70566c0d9 --- /dev/null +++ b/api/utils/task_utils.py @@ -0,0 +1,34 @@ +from celery.result import AsyncResult +from typing import Dict, Any +import logging + +logger = logging.getLogger(__name__) + +def get_task_info(task_id: str) -> Dict[str, Any]: + """Get detailed information about a task""" + result = AsyncResult(task_id) + + response = { + "task_id": task_id, + "status": result.status, + "success": result.successful() if result.ready() else None, + } + + if result.ready(): + if result.successful(): + response["result"] = result.get() + else: + response["error"] = str(result.result) + + if hasattr(result, 'info'): + response["progress"] = result.info + + return response + +def cleanup_old_tasks(days: int = 7): + """Clean up tasks older than specified days""" + try: + # Implementation depends on your storage method + pass + except Exception as e: + logger.error(f"Error cleaning up old tasks: {e}") \ No newline at end of file diff --git a/autorag-frontend b/autorag-frontend index 61c4b0b02..043bceb0e 160000 --- a/autorag-frontend +++ b/autorag-frontend @@ -1 +1 @@ -Subproject commit 61c4b0b0273658bcebc2bb5cecd35bc771af7348 +Subproject commit 043bceb0ebeb0d1937770d40d4061adced02d0c4 diff --git a/docker-compose.yml b/docker-compose.yml index 13097713f..67a50d019 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,20 +1,10 @@ +version: '3' + services: - frontend: - build: - context: ./autorag-frontend - dockerfile: Dockerfile - working_dir: /app/autorag-frontend - volumes: - - ./autorag-frontend:/app/autorag-frontend - - /app/autorag-frontend/node_modules + redis: + image: redis:latest ports: - - "3000:3000" - environment: - - NEXT_PUBLIC_API_URL=http://localhost:5001 - - NODE_ENV=development - command: sh -c "yarn install && yarn dev" - networks: - - app-network + - "6379:6379" api: build: @@ -23,16 +13,37 @@ services: volumes: - ./api:/app/api - ./projects:/app/projects + working_dir: /app/api ports: - - "5001:5001" + - "5000:5000" environment: - - PYTHONUNBUFFERED=1 - - PORT=5001 - - HOST=0.0.0.0 - - AUTORAG_WORK_DIR=/app/projects - command: uvicorn app:app --host 0.0.0.0 --port 5001 --reload --loop asyncio - networks: - - app-network + - WATCHFILES_FORCE_POLLING=true # Docker on Windows/macOS를 위한 설정 + - TZ=Asia/Seoul + - PYTHONPATH=/app/api + - CELERY_BROKER_URL=redis://redis:6379/0 + - CELERY_RESULT_BACKEND=redis://redis:6379/0 + - LOG_LEVEL=DEBUG # 로그 레벨 설정 + - PYTHONUNBUFFERED=1 # Python 출력 버퍼링 비활성화 + + depends_on: + - redis + + flower: + image: mher/flower + command: celery flower --broker=redis://redis:6379/0 --port=5555 + ports: + - "5555:5555" + environment: + - TZ=Asia/Seoul + - FLOWER_BROKER_API=redis://redis:6379/0 + - CELERY_BROKER_URL=redis://redis:6379/0 + - CELERY_RESULT_BACKEND=redis://redis:6379/0 + depends_on: + - redis + - api + +volumes: + redis_data: networks: app-network: diff --git a/tests/api/test_app.py b/tests/api/test_app.py index 6c7abeb7f..9e08127bc 100644 --- a/tests/api/test_app.py +++ b/tests/api/test_app.py @@ -9,7 +9,7 @@ from app import app, WORK_DIR from src.schema import TrialConfig, Trial, Status -from src.trial_config import PandasTrialDB +from src.trial_config import SQLiteTrialDB tests_dir = os.path.dirname(os.path.realpath(__file__)) root_dir = pathlib.PurePath(tests_dir).parent @@ -56,7 +56,7 @@ async def test_create_project_success(new_project_test_client): assert os.path.exists(os.path.join(WORK_DIR, "test_project", "qa")) assert os.path.exists(os.path.join(WORK_DIR, "test_project", "project")) assert os.path.exists(os.path.join(WORK_DIR, "test_project", "config")) - assert os.path.exists(os.path.join(WORK_DIR, "test_project", "trial_config.csv")) + assert os.path.exists(os.path.join(WORK_DIR, "test_project", "trials.db")) assert os.path.exists(os.path.join(WORK_DIR, "test_project", "description.txt")) with open(os.path.join(WORK_DIR, "test_project", "description.txt"), "r") as f: @@ -126,7 +126,7 @@ def get_trial_list_client(): async def test_get_trial_lists(get_trial_list_client): project_id = "test_project_get_trial_lists" trial_id = str(uuid.uuid4()) - trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") + trial_config_path = os.path.join(WORK_DIR, project_id, "trials.db") os.makedirs(os.path.join(WORK_DIR, project_id), exist_ok=True) # Create a trial config file @@ -146,7 +146,7 @@ async def test_get_trial_lists(get_trial_list_client): status="not_started", created_at=datetime.now(), ) - trial_config_db = PandasTrialDB(trial_config_path) + trial_config_db = SQLiteTrialDB(trial_config_path) trial_config_db.set_trial(trial) response = await get_trial_list_client.get(f"/projects/{project_id}/trials") @@ -170,7 +170,7 @@ def create_new_trial_client(): async def test_create_new_trial(create_new_trial_client): project_id = "test_project_create_new_trial" os.makedirs(os.path.join(WORK_DIR, project_id), exist_ok=True) - trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") + trial_config_path = os.path.join(WORK_DIR, project_id, "trials.db") trial_create_request = { "name": "New Trial", @@ -191,7 +191,7 @@ async def test_create_new_trial(create_new_trial_client): assert "id" in data # Verify the trial was added to the CSV - trial_config_db = PandasTrialDB(trial_config_path) + trial_config_db = SQLiteTrialDB(trial_config_path) trial_ids = trial_config_db.get_all_config_ids() assert len(trial_ids) == 1 assert trial_ids[0] == data["id"] @@ -215,8 +215,8 @@ async def test_get_trial_config(trial_config_client): ) assert response.status_code == 201 os.makedirs(os.path.join(WORK_DIR, project_id), exist_ok=True) - trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") - trial_config_db = PandasTrialDB(trial_config_path) + trial_config_path = os.path.join(WORK_DIR, project_id, "trials.db") + trial_config_db = SQLiteTrialDB(trial_config_path) trial_config = TrialConfig( trial_id=trial_id, project_id=project_id, @@ -263,8 +263,8 @@ async def test_set_trial_config(trial_config_client): assert response.status_code == 201 os.makedirs(os.path.join(WORK_DIR, project_id), exist_ok=True) - trial_config_path = os.path.join(WORK_DIR, project_id, "trial_config.csv") - trial_config_db = PandasTrialDB(trial_config_path) + trial_config_path = os.path.join(WORK_DIR, project_id, "trials.db") + trial_config_db = SQLiteTrialDB(trial_config_path) trial_config = TrialConfig( trial_id=trial_id, project_id=project_id, diff --git a/tests/api/test_trial_config.py b/tests/api/test_trial_config.py deleted file mode 100644 index ce43025e8..000000000 --- a/tests/api/test_trial_config.py +++ /dev/null @@ -1,111 +0,0 @@ -import os -import tempfile -import pandas as pd -import pytest - -from src.trial_config import PandasTrialDB -from src.schema import Trial, TrialConfig -from datetime import datetime - - -@pytest.fixture -def temp_csv_path(): - with tempfile.NamedTemporaryFile(suffix=".csv", delete=False) as tmp_file: - temp_path = tmp_file.name - yield temp_path - if os.path.exists(temp_path): - os.remove(temp_path) - - -@pytest.fixture -def sample_trial(): - return Trial( - id="test_trial_1", - project_id="test_project", - config=TrialConfig( - trial_id="test_trial_1", - project_id="test_project", - raw_path="/path/to/raw", - corpus_path="/path/to/corpus", - qa_path="/path/to/qa", - config_path="/path/to/config", - ), - name="Test Trial", - status="not_started", - created_at=datetime.now(), - ) - - -def test_set_trial(temp_csv_path, sample_trial): - trial_db = PandasTrialDB(temp_csv_path) - trial_db.set_trial(sample_trial) - df = pd.read_csv(temp_csv_path) - assert len(df) == 1 - assert df.iloc[0]["id"] == sample_trial.id - assert df.iloc[0]["project_id"] == sample_trial.project_id - - -def test_get_trial_existing(temp_csv_path, sample_trial): - trial_db = PandasTrialDB(temp_csv_path) - trial_db.set_trial(sample_trial) - retrieved_trial = trial_db.get_trial(sample_trial.id) - assert retrieved_trial is not None - assert retrieved_trial.id == sample_trial.id - assert retrieved_trial.project_id == sample_trial.project_id - assert isinstance(retrieved_trial.config, TrialConfig) - assert retrieved_trial.config == sample_trial.config - - retrieved_trial_config = trial_db.get_trial_config(sample_trial.id) - assert retrieved_trial_config is not None - assert isinstance(retrieved_trial_config, TrialConfig) - assert retrieved_trial_config == sample_trial.config - - -def test_get_trial_nonexistent(temp_csv_path): - trial_db = PandasTrialDB(temp_csv_path) - retrieved_trial = trial_db.get_trial("nonexistent_id") - assert retrieved_trial is None - - -def test_set_trial_config(temp_csv_path, sample_trial): - trial_db = PandasTrialDB(temp_csv_path) - trial_db.set_trial(sample_trial) - new_config = TrialConfig( - trial_id="test_trial_1", - project_id="test_project", - raw_path="/new/path/to/raw", - corpus_path="/new/path/to/corpus", - qa_path="/new/path/to/qa", - config_path="/new/path/to/config", - ) - trial_db.set_trial_config(sample_trial.id, new_config) - retrieved_config = trial_db.get_trial_config(sample_trial.id) - assert retrieved_config is not None - assert retrieved_config == new_config - - retrieved_trial = trial_db.get_trial(sample_trial.id) - assert retrieved_trial.id == sample_trial.id - assert retrieved_trial.config == new_config - - -def test_get_trial_config_existing(temp_csv_path, sample_trial): - trial_db = PandasTrialDB(temp_csv_path) - trial_db.set_trial(sample_trial) - retrieved_config = trial_db.get_trial_config(sample_trial.id) - assert retrieved_config is not None - assert retrieved_config.trial_id == sample_trial.config.trial_id - assert retrieved_config == sample_trial.config - - -def test_get_trial_config_nonexistent(temp_csv_path): - trial_db = PandasTrialDB(temp_csv_path) - retrieved_config = trial_db.get_trial_config("nonexistent_id") - assert retrieved_config is None - - -def test_get_all_config_ids(temp_csv_path, sample_trial): - trial_db = PandasTrialDB(temp_csv_path) - trial_db.set_trial(sample_trial) - config_ids = trial_db.get_all_config_ids() - assert len(config_ids) == 1 - assert config_ids[0] == sample_trial.id From 0558ab2a4d8641ffa171395cfdc81ea9e9df1932 Mon Sep 17 00:00:00 2001 From: "Jeffrey (Dongkyu) Kim" Date: Mon, 25 Nov 2024 13:38:59 +0800 Subject: [PATCH 40/55] Make the default timezone at the API server to UTC (#992) * Change all datetime.now() to the timezone UTC * properly working UTC timezone in the API server --------- Co-authored-by: jeffrey --- api/app.py | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/api/app.py b/api/app.py index b5e792fe7..90d4d3832 100644 --- a/api/app.py +++ b/api/app.py @@ -3,7 +3,7 @@ import signal import concurrent.futures import uuid -from datetime import datetime +from datetime import datetime, timezone from pathlib import Path from typing import Dict, Optional, Callable from typing import List @@ -289,7 +289,7 @@ async def create_project(): id=data["name"], name=data["name"], description=description, - created_at=datetime.now(), + created_at=datetime.now(tz=timezone.utc), status="active", metadata={}, ) @@ -309,9 +309,13 @@ async def get_project_directories(): "status": "active", # All projects are currently active "path": str(item), "last_modified_datetime": datetime.fromtimestamp( - item.stat().st_mtime + item.stat().st_mtime, + tz=timezone.utc, + ), + "created_datetime": datetime.fromtimestamp( + item.stat().st_ctime, + tz=timezone.utc, ), - "created_datetime": datetime.fromtimestamp(item.stat().st_ctime), } ) @@ -443,7 +447,7 @@ async def create_trial(project_id: str): data["project_id"] = project_id trial = Trial( **data, - created_at=datetime.now(), + created_at=datetime.now(tz=timezone.utc), status=Status.IN_PROGRESS, id=str(uuid.uuid4()), ) @@ -712,7 +716,7 @@ async def create_qa(project_id: str, trial_id: str): config_yaml={"preset": qa_creation_request.preset}, status=Status.IN_PROGRESS, type=TaskType.QA, - created_at=datetime.now(), + created_at=datetime.now(tz=timezone.utc), save_path=save_path, ) await create_task( @@ -785,7 +789,7 @@ async def start_validate(project_id: str, trial_id: str): config_yaml=trial.config, status=Status.IN_PROGRESS, type=TaskType.VALIDATE, - created_at=datetime.now(), + created_at=datetime.now(tz=timezone.utc), ) await create_task( task_id, @@ -844,7 +848,7 @@ async def start_evaluate(project_id: str, trial_id: str): "corpus_path": previous_config.corpus_path, "qa_path": previous_config.qa_path, "config_path": previous_config.config_path, - "created_at": datetime.now(), + "created_at": datetime.now(tz=timezone.utc), } ] ) @@ -862,7 +866,7 @@ async def start_evaluate(project_id: str, trial_id: str): config_yaml=config_yaml, status=Status.IN_PROGRESS, type=TaskType.EVALUATE, - created_at=datetime.now(), + created_at=datetime.now(tz=timezone.utc), save_path=new_trial_dir, ) await create_task( @@ -924,7 +928,7 @@ async def open_dashboard(project_id: str, trial_id: str): trial_id=trial_id, status=Status.IN_PROGRESS, type=TaskType.REPORT, - created_at=datetime.now(), + created_at=datetime.now(tz=timezone.utc), ) await create_task(task_id, response, run_dashboard, trial_dir) @@ -994,7 +998,7 @@ async def open_chat_server(project_id: str, trial_id: str): trial_id=trial_id, status=Status.IN_PROGRESS, type=TaskType.CHAT, - created_at=datetime.now(), + created_at=datetime.now(tz=timezone.utc), ) await create_task(task_id, response, run_chat, trial_dir) From f4c664bf7b710a7c20a25946a8e46ab28d617089 Mon Sep 17 00:00:00 2001 From: Seungwoo hong <1100974+hongsw@users.noreply.github.com> Date: Mon, 25 Nov 2024 20:12:23 +0900 Subject: [PATCH 41/55] =?UTF-8?q?=E2=9C=A8=20feat:=20Add=20QA=20document?= =?UTF-8?q?=20generation=20task=20in=20trial=5Ftasks.py=20and=20schema.py?= =?UTF-8?q?=20(#1005)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ feat: Add QA document generation task in trial_tasks.py and schema.py - Added a new field `qa_task_id` in the Trial schema to store the QA task ID. - Introduced `generate_qa_documents` shared task in `trial_tasks.py` for creating QA documents. - Updated imports and added `QACreationRequest` in `trial_tasks.py`. - Included function `run_qa_creation` in `generate_qa_documents` task for generating QA documents with status tracking and database updates. * 🚑 fix: Return full trial config in get_trial_config Adjusts the return statement in `get_trial_config` to return the complete trial configuration instead of just the model dump. * 🔧 chore: update subproject commit in autorag-frontend to 1434e797 --------- Co-authored-by: Seungwoo hong --- api/app.py | 82 +++++++++++----------------------------- api/src/run.py | 57 +++++++++------------------- api/src/schema.py | 1 + api/tasks/base.py | 3 +- api/tasks/trial_tasks.py | 54 +++++++++++++++++++++++++- autorag-frontend | 2 +- 6 files changed, 98 insertions(+), 101 deletions(-) diff --git a/api/app.py b/api/app.py index 90d4d3832..a59d49ce8 100644 --- a/api/app.py +++ b/api/app.py @@ -46,7 +46,7 @@ from src.validate import project_exists, trial_exists from database.project_db import SQLiteProjectDB # 올바른 임포트로 변경 -from tasks.trial_tasks import parse_documents, chunk_documents # 수정된 임포트 +from tasks.trial_tasks import generate_qa_documents, parse_documents, chunk_documents # 수정된 임포트 from celery.result import AsyncResult # uvloop을 사용하지 않도록 설정 @@ -674,71 +674,35 @@ async def start_chunking(project_id: str, trial_id: str): @project_exists(WORK_DIR) @trial_exists(WORK_DIR) async def create_qa(project_id: str, trial_id: str): - data = await request.get_json() try: - qa_creation_request = QACreationRequest(**data) - dataset_dir = os.path.join(WORK_DIR, project_id, "qa") - - if not os.path.exists(dataset_dir): - os.makedirs(dataset_dir) - - save_path = os.path.join(dataset_dir, f"{qa_creation_request.name}.parquet") - - if os.path.exists(save_path): - return jsonify( - {"error": f"QA dataset name already exists: {qa_creation_request.name}"} - ), 400 - - trial_config_path = os.path.join(WORK_DIR, project_id, "trials.db") - trial_config_db = SQLiteProjectDB(trial_config_path) - previous_config = trial_config_db.get_trial(trial_id).config - - corpus_filepath = os.path.join( - WORK_DIR, project_id, "chunk", f"chunk_{previous_config.id}/0.parquet" - ) - # corpus_filepath = previous_config.corpus_path - print(f"previous_config: {previous_config}") - print(f"corpus_filepath: {corpus_filepath}") - - if ( - corpus_filepath is None - or not corpus_filepath - or not os.path.exists(corpus_filepath) - ): - return jsonify({"error": "Corpus data path not found"}), 400 + # Get JSON data from request and validate with Pydantic + data = await request.get_json() + + project_db = SQLiteProjectDB(project_id) + trial = project_db.get_trial(trial_id) + if not trial: + return jsonify({"error": f"Trial not found: {trial_id}"}), 404 - task_id = str(uuid.uuid4()) - response = Task( - id=task_id, + # Celery task 시작 + task = generate_qa_documents.delay( project_id=project_id, trial_id=trial_id, - name=qa_creation_request.name, - config_yaml={"preset": qa_creation_request.preset}, - status=Status.IN_PROGRESS, - type=TaskType.QA, - created_at=datetime.now(tz=timezone.utc), - save_path=save_path, - ) - await create_task( - task_id, - response, - run_qa_creation, - qa_creation_request, - corpus_filepath, - dataset_dir, + data=data, # POST body 전체를 전달 ) + task_id = task.id + print(f"task: {task}") - # Update qa path - new_config: TrialConfig = previous_config.model_copy(deep=True) - new_config.qa_path = save_path - trial_config_db.set_trial_config(trial_id, new_config) - - return jsonify(response.model_dump()), 202 + # Trial 상태 업데이트 + trial.status = Status.IN_PROGRESS + print(f"trial: {trial}") + print(f"task: {task}") + trial.qa_task_id = task.id # parse_task_id 대신 qa_task_id 사용 + project_db.set_trial(trial) + return jsonify({"task_id": task.id, "status": "started"}) except Exception as e: - return jsonify( - {"status": "error", "message": f"Failed at creation of QA: {str(e)}"} - ), 400 + logger.error(f"Error starting QA generation task: {str(e)}", exc_info=True) + return jsonify({"task_id": task_id, "status": "FAILURE", "error": str(e)}), 500 @app.route( @@ -751,7 +715,7 @@ async def get_trial_config(project_id: str, trial_id: str): trial = project_db.get_trial(trial_id) if not trial: return jsonify({"error": "Trial not found"}), 404 - return jsonify(trial.config.model_dump()), 200 + return jsonify(trial.config), 200 @app.route( diff --git a/api/src/run.py b/api/src/run.py index b0e7ddacf..6605279b2 100644 --- a/api/src/run.py +++ b/api/src/run.py @@ -12,45 +12,6 @@ from src.qa_create import default_create, fast_create, advanced_create from src.schema import QACreationRequest -# async def run_parser_start_parsing(raw_data_path: str, dataset_dir: str, yaml_path: str): -# try: -# # 경로들이 실제로 존재하는지 확인 -# if not os.path.exists(raw_data_path): -# raise ValueError(f"Raw data path does not exist: {raw_data_path}") -# if not os.path.exists(dataset_dir): -# os.makedirs(dataset_dir) - -# # 명령어 구성 -# cmd = [ -# "python", "-m", "autorag.cli", -# "parse", -# "--input-path", str(raw_data_path), # 문자열로 명시적 변환 -# "--output-path", str(dataset_dir), # 문자열로 명시적 변환 -# "--config-path", str(yaml_path) # 문자열로 명시적 변환 -# ] - -# # subprocess로 실행 -# process = await asyncio.create_subprocess_exec( -# *cmd, -# stdout=asyncio.subprocess.PIPE, -# stderr=asyncio.subprocess.PIPE -# ) - -# # 결과 대기 -# stdout, stderr = await process.communicate() - -# # 에러 체크 -# if process.returncode != 0: -# error_msg = stderr.decode() if stderr else "Unknown error" -# raise RuntimeError(f"Parser failed: {error_msg}") - -# print(f"Parser completed successfully") -# return True - -# except Exception as e: -# print(f"Error in parser: {str(e)}") -# raise - def run_parser_start_parsing(data_path_glob, project_dir, yaml_path): # Import Parser here if it's defined in another module parser = Parser(data_path_glob=data_path_glob, project_dir=project_dir) @@ -64,6 +25,24 @@ def run_chunker_start_chunking(raw_path, project_dir, yaml_path): chunker.start_chunking(yaml_path) def run_qa_creation(qa_creation_request: QACreationRequest, corpus_filepath: str, dataset_dir: str): + """Create QA pairs from a corpus using specified LLM and preset configuration. + + Args: + qa_creation_request (QACreationRequest): Configuration object containing: + - preset: Type of QA generation ("basic", "simple", or "advanced") + - llm_config: LLM configuration (name and parameters) + - qa_num: Number of QA pairs to generate + - lang: Target language for QA pairs + - name: Output filename prefix + corpus_filepath (str): Path to the input corpus parquet file + dataset_dir (str): Directory where the generated QA pairs will be saved + + Raises: + ValueError: If an unsupported preset is specified + + Returns: + None: Saves the generated QA pairs to a parquet file in dataset_dir + """ corpus_df = pd.read_parquet(corpus_filepath, engine="pyarrow") llm = generator_models[qa_creation_request.llm_config.llm_name]( **qa_creation_request.llm_config.llm_params diff --git a/api/src/schema.py b/api/src/schema.py index ef7184c95..eee246e0d 100644 --- a/api/src/schema.py +++ b/api/src/schema.py @@ -141,6 +141,7 @@ class Trial(BaseModel): chat_task_id: Optional[str] = Field(None, description="The chat task id for forcing shutdown of the task") parse_task_id: Optional[str] = Field(None, description="The parse task id") # Celery task id chunk_task_id: Optional[str] = Field(None, description="The chunk task id") # Celery task id + qa_task_id: Optional[str] = Field(None, description="The QA task id") # Celery task id corpus_path: Optional[str] = None qa_path: Optional[str] = None diff --git a/api/tasks/base.py b/api/tasks/base.py index dda174200..fba4b25eb 100644 --- a/api/tasks/base.py +++ b/api/tasks/base.py @@ -34,7 +34,8 @@ def update_state_and_db(self, trial_id: str, project_id: str, 'SUCCESS': Status.COMPLETED, 'FAILURE': Status.FAILED, 'chunking': Status.IN_PROGRESS, - 'parsing': Status.IN_PROGRESS + 'parsing': Status.IN_PROGRESS, + 'generating_qa_docs': Status.IN_PROGRESS } trial_status = status_map.get(status, Status.FAILED) diff --git a/api/tasks/trial_tasks.py b/api/tasks/trial_tasks.py index c8ffa3b50..3073ab06b 100644 --- a/api/tasks/trial_tasks.py +++ b/api/tasks/trial_tasks.py @@ -2,11 +2,12 @@ from celery import shared_task from .base import TrialTask from src.schema import ( + QACreationRequest, Status, ) import logging import yaml -from src.run import run_parser_start_parsing, run_chunker_start_chunking +from src.run import run_parser_start_parsing, run_chunker_start_chunking, run_qa_creation # 로깅 설정 logging.basicConfig( @@ -98,6 +99,57 @@ def chunk_documents(self, project_id: str, trial_id: str, config_str: str): raise + +@shared_task(bind=True, base=TrialTask) +def generate_qa_documents(self, project_id: str, trial_id: str, data: dict): + try: + self.update_state_and_db( + trial_id=trial_id, + project_id=project_id, + status="generating_qa_docs", + progress=0, + task_type="qa_docs", + ) + + # QA 생성 작업 수행 + logger.info("Generating QA documents") + + project_dir = os.path.join(WORK_DIR, project_id) + config_dir = os.path.join(project_dir, "config") + corpus_filepath = os.path.join( + project_dir, "chunk", f"chunk_{trial_id}", "0.parquet" + ) + dataset_dir = os.path.join(project_dir, "qa", f"qa_{trial_id}") + + # 필요한 모든 디렉토리 생성 + os.makedirs(config_dir, exist_ok=True) + os.makedirs(dataset_dir, exist_ok=True) # dataset_dir도 생성 + + qa_creation_request = QACreationRequest(**data) + result = run_qa_creation( + qa_creation_request, corpus_filepath, dataset_dir + ) + + self.update_state_and_db( + trial_id=trial_id, + project_id=project_id, + status=Status.COMPLETED, + progress=100, + task_type="qa_docs" + ) + return result + except Exception as e: + self.update_state_and_db( + trial_id=trial_id, + project_id=project_id, + status=Status.FAILED, + progress=0, + task_type="qa_docs", + info={"error": str(e)}, + ) + raise + + @shared_task(bind=True, base=TrialTask) def parse_documents(self, project_id: str, trial_id: str, config_str: str): try: diff --git a/autorag-frontend b/autorag-frontend index 043bceb0e..1434e7974 160000 --- a/autorag-frontend +++ b/autorag-frontend @@ -1 +1 @@ -Subproject commit 043bceb0ebeb0d1937770d40d4061adced02d0c4 +Subproject commit 1434e7974629fec05e660b60a9c8cd016c128f25 From cd530bd74810562efc27ec2a662ef2329ea45de1 Mon Sep 17 00:00:00 2001 From: "Jeffrey (Dongkyu) Kim" Date: Tue, 26 Nov 2024 15:58:58 +0800 Subject: [PATCH 42/55] Change the api port to 8000 (#1007) --- api/app.py | 14 ++++++++------ api/entrypoint.sh | 2 +- autorag-frontend | 2 +- docker-compose.yml | 4 +++- tests/api/test.http | 38 +++++++++++++++++++------------------- 5 files changed, 32 insertions(+), 28 deletions(-) diff --git a/api/app.py b/api/app.py index a59d49ce8..617276b66 100644 --- a/api/app.py +++ b/api/app.py @@ -27,7 +27,6 @@ from src.auth import require_auth from src.evaluate_history import get_new_trial_dir from src.run import ( - run_qa_creation, run_start_trial, run_dashboard, run_chat, @@ -35,7 +34,6 @@ from src.schema import ( ChunkRequest, EnvVariableRequest, - QACreationRequest, Project, Task, Status, @@ -46,7 +44,11 @@ from src.validate import project_exists, trial_exists from database.project_db import SQLiteProjectDB # 올바른 임포트로 변경 -from tasks.trial_tasks import generate_qa_documents, parse_documents, chunk_documents # 수정된 임포트 +from tasks.trial_tasks import ( + generate_qa_documents, + parse_documents, + chunk_documents, +) # 수정된 임포트 from celery.result import AsyncResult # uvloop을 사용하지 않도록 설정 @@ -677,7 +679,7 @@ async def create_qa(project_id: str, trial_id: str): try: # Get JSON data from request and validate with Pydantic data = await request.get_json() - + project_db = SQLiteProjectDB(project_id) trial = project_db.get_trial(trial_id) if not trial: @@ -1141,8 +1143,8 @@ async def delete_environment_variable(key: str): @click.command() @click.option("--host", type=str, default="127.0.0.1", help="Host IP address") -@click.option("--port", type=int, default=5000, help="Port number") -def main(host: str = "127.0.0.1", port: int = 5000): +@click.option("--port", type=int, default=8000, help="Port number") +def main(host: str = "127.0.0.1", port: int = 8000): uvicorn.run("app:app", host=host, port=port, reload=True, loop="asyncio") diff --git a/api/entrypoint.sh b/api/entrypoint.sh index 31f9019e3..f1b5da7da 100644 --- a/api/entrypoint.sh +++ b/api/entrypoint.sh @@ -1,7 +1,7 @@ #!/bin/bash # Start API server with standard event loop -uvicorn app:app --host 0.0.0.0 --port 5000 --loop asyncio --reload & +uvicorn app:app --host 0.0.0.0 --port 8000 --loop asyncio --reload & # --reload-dir /app/api & # Start Celery worker diff --git a/autorag-frontend b/autorag-frontend index 1434e7974..7c7bb2862 160000 --- a/autorag-frontend +++ b/autorag-frontend @@ -1 +1 @@ -Subproject commit 1434e7974629fec05e660b60a9c8cd016c128f25 +Subproject commit 7c7bb2862676e69b2524c4dc68290b5160727f62 diff --git a/docker-compose.yml b/docker-compose.yml index 67a50d019..44eac6ab3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,7 +15,9 @@ services: - ./projects:/app/projects working_dir: /app/api ports: - - "5000:5000" + - "8000:8000" + - "7690:7690" # for panel report + - "8501:8501" # for streamlit chat environment: - WATCHFILES_FORCE_POLLING=true # Docker on Windows/macOS를 위한 설정 - TZ=Asia/Seoul diff --git a/tests/api/test.http b/tests/api/test.http index 213ce20fc..e29c24ae9 100644 --- a/tests/api/test.http +++ b/tests/api/test.http @@ -1,5 +1,5 @@ ### -POST http://localhost:5000/projects +POST http://localhost:8000/projects Authorization: Bearer good Content-Type: application/json @@ -12,7 +12,7 @@ Content-Type: application/json <> 2024-11-03T134638.401.json ### -POST http://localhost:5000/projects/{{project_id}}/trials +POST http://localhost:8000/projects/{{project_id}}/trials Content-Type: application/json { @@ -22,10 +22,10 @@ Content-Type: application/json > {% client.global.set("trial_id", response.body.id); %} ### -GET http://localhost:5000/projects/{{project_id}}/trials +GET http://localhost:8000/projects/{{project_id}}/trials ### -POST http://localhost:5000/projects/{{project_id}}/trials/{{trial_id}}/parse +POST http://localhost:8000/projects/{{project_id}}/trials/{{trial_id}}/parse Content-Type: application/json { @@ -42,11 +42,11 @@ Content-Type: application/json ### -GET http://localhost:5000/projects/{{project_id}}/tasks/{{parse_task_id}} +GET http://localhost:8000/projects/{{project_id}}/tasks/{{parse_task_id}} ### -POST http://localhost:5000/projects/{{project_id}}/trials/{{trial_id}}/chunk +POST http://localhost:8000/projects/{{project_id}}/trials/{{trial_id}}/chunk Content-Type: application/json { @@ -61,10 +61,10 @@ Content-Type: application/json > {% client.global.set("chunk_task_id", response.body.id); %} ### -GET http://localhost:5000/projects/{{project_id}}/tasks/{{chunk_task_id}} +GET http://localhost:8000/projects/{{project_id}}/tasks/{{chunk_task_id}} ### -POST http://localhost:5000/projects/{{project_id}}/trials/{{trial_id}}/qa +POST http://localhost:8000/projects/{{project_id}}/trials/{{trial_id}}/qa Content-Type: application/json { @@ -80,10 +80,10 @@ Content-Type: application/json > {% client.global.set("qa_task_id", response.body.id); %} ### -GET http://localhost:5000/projects/{{project_id}}/tasks/{{qa_task_id}} +GET http://localhost:8000/projects/{{project_id}}/tasks/{{qa_task_id}} ### -POST http://localhost:5000/projects/{{project_id}}/trials/{{trial_id}}/config +POST http://localhost:8000/projects/{{project_id}}/trials/{{trial_id}}/config Content-Type: application/json { @@ -155,10 +155,10 @@ Content-Type: application/json } ### -GET http://localhost:5000/projects/{{project_id}}/trials/{{trial_id}}/config +GET http://localhost:8000/projects/{{project_id}}/trials/{{trial_id}}/config ### -POST http://localhost:5000/projects/{{project_id}}/trials/{{trial_id}}/validate +POST http://localhost:8000/projects/{{project_id}}/trials/{{trial_id}}/validate Content-Type: application/json {} @@ -166,10 +166,10 @@ Content-Type: application/json > {% client.global.set("validate_id", response.body.id); %} ### -GET http://localhost:5000/projects/{{project_id}}/tasks/{{validate_id}} +GET http://localhost:8000/projects/{{project_id}}/tasks/{{validate_id}} ### -POST http://localhost:5000/projects/{{project_id}}/trials/{{trial_id}}/evaluate +POST http://localhost:8000/projects/{{project_id}}/trials/{{trial_id}}/evaluate Content-Type: application/json { @@ -179,16 +179,16 @@ Content-Type: application/json > {% client.global.set("evaluate_id", response.body.id); %} ### -GET http://localhost:5000/projects/{{project_id}}/tasks/{{evaluate_id}} +GET http://localhost:8000/projects/{{project_id}}/tasks/{{evaluate_id}} ### -GET http://localhost:5000/projects/{{project_id}}/trials/{{trial_id}}/report/open +GET http://localhost:8000/projects/{{project_id}}/trials/{{trial_id}}/report/open ### -GET http://localhost:5000/projects/{{project_id}}/trials/{{trial_id}}/report/close +GET http://localhost:8000/projects/{{project_id}}/trials/{{trial_id}}/report/close ### -GET http://localhost:5000/projects/{{project_id}}/trials/{{trial_id}}/chat/open +GET http://localhost:8000/projects/{{project_id}}/trials/{{trial_id}}/chat/open ### -GET http://localhost:5000/projects/{{project_id}}/trials/{{trial_id}}/chat/close +GET http://localhost:8000/projects/{{project_id}}/trials/{{trial_id}}/chat/close From 9611073c7fc13047ff3492cc014a5722952499a3 Mon Sep 17 00:00:00 2001 From: "Jeffrey (Dongkyu) Kim" Date: Tue, 26 Nov 2024 17:07:36 +0800 Subject: [PATCH 43/55] artifacts/content GET endpoint for sending raw_data files (#1008) * Change the WORK_DIR setting * send file directly --- api/app.py | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/api/app.py b/api/app.py index 617276b66..53ced3def 100644 --- a/api/app.py +++ b/api/app.py @@ -12,7 +12,7 @@ import click import uvicorn -from quart import jsonify, request, make_response +from quart import jsonify, request, make_response, send_file from pydantic import BaseModel import aiofiles import aiofiles.os @@ -86,18 +86,12 @@ current_task_id = None # ID of the currently running task lock = asyncio.Lock() # To manage access to shared variables -ROOT_DIR = os.path.dirname(os.path.realpath(__file__)) +ROOT_DIR = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) ENV = os.getenv("AUTORAG_API_ENV", "dev") -if ENV == "dev": - WORK_DIR = os.path.join(ROOT_DIR, "../projects") -else: # production - WORK_DIR = os.path.join(ROOT_DIR, "projects") +WORK_DIR = os.path.join(ROOT_DIR, "projects") if "AUTORAG_WORK_DIR" in os.environ: WORK_DIR = os.getenv("AUTORAG_WORK_DIR") -if "AUTORAG_WORK_DIR" in os.environ: - WORK_DIR = os.path.join(ROOT_DIR, os.getenv("AUTORAG_WORK_DIR")) - ENV_FILEPATH = os.path.join(ROOT_DIR, f".env.{ENV}") # 환경에 따른 WORK_DIR 설정 @@ -1002,6 +996,19 @@ async def get_project_artifacts(project_id: str): return jsonify({"error": f"Failed to scan artifacts: {str(e)}"}), 500 +@app.route("/projects//artifacts/content") +@project_exists(WORK_DIR) +async def get_artifact_content(project_id: str): + try: + requested_filename = request.args.get("filename") + target_path = os.path.join(WORK_DIR, project_id, "raw_data", requested_filename) + if not os.path.exists(target_path): + return jsonify({"error": "File not found"}), 404 + return await send_file(target_path, as_attachment=True), 200 + except Exception as e: + return jsonify({"error": f"Failed to load artifacts: {str(e)}"}), 500 + + @app.route( "/projects//trials//chat/close", methods=["GET"] ) From 21e9577b4d0b6af8f3031633d5c0ad2670ff1f90 Mon Sep 17 00:00:00 2001 From: "Jeffrey (Dongkyu) Kim" Date: Thu, 28 Nov 2024 14:49:36 +0800 Subject: [PATCH 44/55] Change the API server that qa, chunk, and qa contains to the project_id. (#1011) * get all parsed documents and the parse is not relevant to the trial_id now * add get chunk list at the API server * chunk document at project view * /parse POST with parse_name * QA creation endpoint --- api/app.py | 162 +++++++++++++++--------- api/src/schema.py | 117 ++++++++++------- api/tasks/base.py | 69 +++++----- api/tasks/trial_tasks.py | 104 ++++++++++----- autorag/projects/tutorial_1/config.yaml | 37 ------ 5 files changed, 278 insertions(+), 211 deletions(-) delete mode 100644 autorag/projects/tutorial_1/config.yaml diff --git a/api/app.py b/api/app.py index 53ced3def..c79aacd82 100644 --- a/api/app.py +++ b/api/app.py @@ -4,6 +4,7 @@ import concurrent.futures import uuid from datetime import datetime, timezone +from glob import glob from pathlib import Path from typing import Dict, Optional, Callable from typing import List @@ -40,6 +41,7 @@ TaskType, Trial, TrialConfig, + QACreationRequest, ) from src.validate import project_exists, trial_exists @@ -561,45 +563,87 @@ async def upload_files(project_id: str): ), 500 -@app.route("/projects//trials//parse", methods=["POST"]) -async def parse_documents_endpoint(project_id, trial_id): +@app.route("/projects//parse", methods=["GET"]) +@project_exists(WORK_DIR) +async def get_parse_documents(project_id): + parse_files = glob(os.path.join(WORK_DIR, project_id, "parse", "**", "*.parquet")) + if len(parse_files) <= 0: + return jsonify({"error": "No parse files found"}), 404 + # get its summary.csv files + summary_csv_files = [ + os.path.join(os.path.dirname(parse_filepath), "summary.csv") + for parse_filepath in parse_files + ] + result_dict_list = [ + { + "parse_filepath": parse_filepath, + "parse_name": os.path.basename(os.path.dirname(parse_filepath)), + "module_name": pd.read_csv(summary_csv_file).iloc[0]["module_name"], + "module_params": pd.read_csv(summary_csv_file).iloc[0]["module_params"], + } + for parse_filepath, summary_csv_file in zip(parse_files, summary_csv_files) + ] + return jsonify(result_dict_list), 200 + + +@app.route("/projects//chunk", methods=["GET"]) +@project_exists(WORK_DIR) +async def get_chunk_documents(project_id): + chunk_files = glob(os.path.join(WORK_DIR, project_id, "chunk", "**", "*.parquet")) + if len(chunk_files) <= 0: + return jsonify({"error": "No chunk files found"}), 404 + + summary_csv_files = [ + os.path.join(os.path.dirname(parse_filepath), "summary.csv") + for parse_filepath in chunk_files + ] + chunk_dict_list = [ + { + "chunk_filepath": chunk_filepath, + "chunk_name": os.path.basename(os.path.dirname(chunk_filepath)), + "module_name": pd.read_csv(summary_csv_file).iloc[0]["module_name"], + "module_params": pd.read_csv(summary_csv_file).iloc[0]["module_params"], + } + for chunk_filepath, summary_csv_file in zip(chunk_files, summary_csv_files) + ] + return jsonify(chunk_dict_list), 200 + + +@app.route("/projects//parse", methods=["POST"]) +@project_exists(WORK_DIR) +async def parse_documents_endpoint(project_id): + """ + The request body + + - name: The name of the parse task + - config: The configuration for parsing + - extension: string. + Default is "pdf". + You can parse all extensions using "*" + """ task_id = "" try: - # POST body에서 config 받기 data = await request.get_json() if not data or "config" not in data: return jsonify({"error": "Config is required in request body"}), 400 config = data["config"] + target_extension = data["extension"] + parse_name = data["name"] - # Trial 객체 가져오기 - project_db = SQLiteProjectDB(project_id) - trial = project_db.get_trial(trial_id) - if not trial: - return jsonify({"error": f"Trial not found: {trial_id}"}), 404 + parse_dir = os.path.join(WORK_DIR, project_id, "parse") - print(f"trial: {trial}") - print(f"project_id: {project_id}") - print(f"trial_id: {trial_id}") - print(f"config: {config}") + if os.path.exists(os.path.join(parse_dir, parse_name)): + return {"error": "Parse name already exists"}, 400 - # Celery task 시작 task = parse_documents.delay( project_id=project_id, - trial_id=trial_id, - config_str=yaml.dump(config), # POST body의 config 사용 + config_str=yaml.dump(config), + parse_name=parse_name, + glob_path=f"*.{target_extension}", ) task_id = task.id - print(f"task: {task}") - - # Trial 상태 업데이트 - trial.status = Status.IN_PROGRESS - print(f"trial: {trial}") - print(f"task: {task}") - trial.parse_task_id = task.id - project_db.set_trial(trial) - - return jsonify({"task_id": task.id, "status": "started"}) + return jsonify({"task_id": task_id, "status": "started"}) except Exception as e: logger.error(f"Error starting parse task: {str(e)}", exc_info=True) return jsonify({"task_id": task_id, "status": "FAILURE", "error": str(e)}), 500 @@ -625,76 +669,68 @@ async def get_task_status(project_id: str, task_id: str): return jsonify({"status": "FAILURE", "error": str(e)}), 500 -@app.route( - "/projects//trials//chunk", methods=["POST"] -) +@app.route("/projects//chunk", methods=["POST"]) @project_exists(WORK_DIR) -@trial_exists(WORK_DIR) -async def start_chunking(project_id: str, trial_id: str): +async def start_chunking(project_id: str): + task_id = None try: # Get JSON data from request and validate with Pydantic data = await request.get_json() chunk_request = ChunkRequest(**data) config = chunk_request.config - project_db = SQLiteProjectDB(project_id) - trial = project_db.get_trial(trial_id) - if not trial: - return jsonify({"error": f"Trial not found: {trial_id}"}), 404 + if os.path.exists( + os.path.join(WORK_DIR, project_id, "chunk", chunk_request.name) + ): + return jsonify({"error": "Chunk name already exists"}), 400 # Celery task 시작 task = chunk_documents.delay( project_id=project_id, - trial_id=trial_id, - config_str=yaml.dump(config), # POST body의 config 사용 + config_str=yaml.dump(config), + parsed_data_path=os.path.join( + WORK_DIR, project_id, "parse", chunk_request.parsed_name + ), + chunk_name=chunk_request.name, ) task_id = task.id print(f"task: {task}") - # Trial 상태 업데이트 - trial.status = Status.IN_PROGRESS - print(f"trial: {trial}") - print(f"task: {task}") - trial.parse_task_id = task.id - project_db.set_trial(trial) - return jsonify({"task_id": task.id, "status": "started"}) except Exception as e: logger.error(f"Error starting parse task: {str(e)}", exc_info=True) return jsonify({"task_id": task_id, "status": "FAILURE", "error": str(e)}), 500 -@app.route( - "/projects//trials//qa", methods=["POST"] -) +@app.route("/projects//qa", methods=["POST"]) @project_exists(WORK_DIR) -@trial_exists(WORK_DIR) -async def create_qa(project_id: str, trial_id: str): +async def create_qa(project_id: str): + task_id = None try: # Get JSON data from request and validate with Pydantic data = await request.get_json() + qa_creation_request = QACreationRequest(**data) + # Check the corpus_filepath is existed + corpus_filepath = os.path.join( + WORK_DIR, project_id, "chunk", qa_creation_request.chunked_name, "0.parquet" + ) + if not os.path.exists(corpus_filepath): + return jsonify({"error": "corpus_filepath does not exist"}), 401 - project_db = SQLiteProjectDB(project_id) - trial = project_db.get_trial(trial_id) - if not trial: - return jsonify({"error": f"Trial not found: {trial_id}"}), 404 + if os.path.exists( + os.path.join( + WORK_DIR, project_id, "qa", f"{qa_creation_request.qa_name}.parquet" + ) + ): + return jsonify({"error": "QA name already exists"}), 400 - # Celery task 시작 + # Start Celery task task = generate_qa_documents.delay( project_id=project_id, - trial_id=trial_id, - data=data, # POST body 전체를 전달 + request_data=qa_creation_request, ) task_id = task.id print(f"task: {task}") - - # Trial 상태 업데이트 - trial.status = Status.IN_PROGRESS - print(f"trial: {trial}") - print(f"task: {task}") - trial.qa_task_id = task.id # parse_task_id 대신 qa_task_id 사용 - project_db.set_trial(trial) - return jsonify({"task_id": task.id, "status": "started"}) except Exception as e: logger.error(f"Error starting QA generation task: {str(e)}", exc_info=True) diff --git a/api/src/schema.py b/api/src/schema.py index eee246e0d..30ec7476f 100644 --- a/api/src/schema.py +++ b/api/src/schema.py @@ -11,18 +11,25 @@ class TrialCreateRequest(BaseModel): raw_path: Optional[str] = Field(None, description="The path to the raw data") corpus_path: Optional[str] = Field(None, description="The path to the corpus data") qa_path: Optional[str] = Field(None, description="The path to the QA data") - config: Optional[Dict] = Field(None, description="The trial configuration dictionary") + config: Optional[Dict] = Field( + None, description="The trial configuration dictionary" + ) class ParseRequest(BaseModel): - config: Dict = Field(..., - description="Dictionary contains parse YAML configuration") + config: Dict = Field( + ..., description="Dictionary contains parse YAML configuration" + ) name: str = Field(..., description="Name of the parse target dataset") path: str # 추가: 파싱할 파일 경로 + class ChunkRequest(BaseModel): - config: Dict = Field(..., description="Dictionary contains chunk YAML configuration") + config: Dict = Field( + ..., description="Dictionary contains chunk YAML configuration" + ) name: str = Field(..., description="Name of the chunk target dataset") + parsed_name: str = Field(..., description="The name of the parsed data") class QACreationPresetEnum(str, Enum): @@ -30,29 +37,34 @@ class QACreationPresetEnum(str, Enum): SIMPLE = "simple" ADVANCED = "advanced" + class LLMConfig(BaseModel): llm_name: str = Field(description="Name of the LLM model") - llm_params: dict = Field(description="Parameters for the LLM model", - default={}) + llm_params: dict = Field(description="Parameters for the LLM model", default={}) + class SupportLanguageEnum(str, Enum): ENGLISH = "en" KOREAN = "ko" JAPANESE = "ja" + class QACreationRequest(BaseModel): preset: QACreationPresetEnum name: str = Field(..., description="Name of the QA dataset") + chunked_name: str = Field(..., description="The name of the chunked data") qa_num: int - llm_config: LLMConfig = Field( - description="LLM configuration settings" + llm_config: LLMConfig = Field(description="LLM configuration settings") + lang: SupportLanguageEnum = Field( + default=SupportLanguageEnum.ENGLISH, description="Language of the QA dataset" ) - lang: SupportLanguageEnum = Field(default=SupportLanguageEnum.ENGLISH, description="Language of the QA dataset") + class EnvVariableRequest(BaseModel): key: str value: str + class Project(BaseModel): model_config = ConfigDict( from_attributes=True, @@ -64,11 +76,11 @@ class Project(BaseModel): "description": "A sample project", "created_at": "2024-02-11T12:00:00Z", "status": "active", - "metadata": {} + "metadata": {}, } - } + }, ) - + id: str name: str description: str @@ -76,13 +88,15 @@ class Project(BaseModel): status: Literal["active", "archived"] metadata: Dict[str, Any] + class Status(str, Enum): - NOT_STARTED = 'not_started' - IN_PROGRESS = 'in_progress' - PARSING = 'parsing' - COMPLETED = 'completed' - FAILED = 'failed' - TERMINATED = 'terminated' + NOT_STARTED = "not_started" + IN_PROGRESS = "in_progress" + PARSING = "parsing" + COMPLETED = "completed" + FAILED = "failed" + TERMINATED = "terminated" + class TaskType(str, Enum): PARSE = "parse" @@ -93,6 +107,7 @@ class TaskType(str, Enum): REPORT = "report" CHAT = "chat" + class Task(BaseModel): id: str = Field(description="The task id") project_id: str @@ -100,23 +115,23 @@ class Task(BaseModel): name: Optional[str] = Field(None, description="The name of the task") config_yaml: Optional[Dict] = Field( None, - description="YAML configuration. Format is dictionary, not path of the YAML file." + description="YAML configuration. Format is dictionary, not path of the YAML file.", ) status: Status - error_message: Optional[str] = Field(None, description="Error message if the task failed") + error_message: Optional[str] = Field( + None, description="Error message if the task failed" + ) type: TaskType created_at: Optional[datetime] = None save_path: Optional[str] = Field( None, - description="Path where the task results are saved. It will be directory or file." + description="Path where the task results are saved. It will be directory or file.", ) + class TrialConfig(BaseModel): - model_config = ConfigDict( - from_attributes=True, - validate_assignment=True - ) - + model_config = ConfigDict(from_attributes=True, validate_assignment=True) + trial_id: str project_id: str raw_path: str @@ -125,43 +140,54 @@ class TrialConfig(BaseModel): config_path: Optional[str] = None metadata: Optional[dict] = {} + class Trial(BaseModel): - model_config = ConfigDict( - from_attributes=True, - validate_assignment=True - ) - + model_config = ConfigDict(from_attributes=True, validate_assignment=True) + id: str project_id: str config: Optional[TrialConfig] = None name: str status: Status created_at: datetime - report_task_id: Optional[str] = Field(None, description="The report task id for forcing shutdown of the task") - chat_task_id: Optional[str] = Field(None, description="The chat task id for forcing shutdown of the task") - parse_task_id: Optional[str] = Field(None, description="The parse task id") # Celery task id - chunk_task_id: Optional[str] = Field(None, description="The chunk task id") # Celery task id - qa_task_id: Optional[str] = Field(None, description="The QA task id") # Celery task id + report_task_id: Optional[str] = Field( + None, description="The report task id for forcing shutdown of the task" + ) + chat_task_id: Optional[str] = Field( + None, description="The chat task id for forcing shutdown of the task" + ) + parse_task_id: Optional[str] = Field( + None, description="The parse task id" + ) # Celery task id + chunk_task_id: Optional[str] = Field( + None, description="The chunk task id" + ) # Celery task id + qa_task_id: Optional[str] = Field( + None, description="The QA task id" + ) # Celery task id corpus_path: Optional[str] = None qa_path: Optional[str] = None - - @field_validator('report_task_id', 'chat_task_id', mode="before") + + @field_validator("report_task_id", "chat_task_id", mode="before") def replace_nan_with_none(cls, v): if isinstance(v, float) and np.isnan(v): return None return v - + # 경로 유효성 검사 메서드 추가 def validate_paths(self) -> bool: """ 모든 필수 경로가 유효한지 검사 """ import os - return all([ - os.path.exists(self.corpus_path), - os.path.exists(self.qa_path), - os.path.exists(self.config_path) - ]) + + return all( + [ + os.path.exists(self.corpus_path), + os.path.exists(self.qa_path), + os.path.exists(self.config_path), + ] + ) # 경로 생성 메서드 추가 def create_directories(self) -> None: @@ -169,10 +195,11 @@ def create_directories(self) -> None: 필요한 디렉토리 구조 생성 """ import os + paths = [ os.path.dirname(self.corpus_path), os.path.dirname(self.qa_path), - os.path.dirname(self.config_path) + os.path.dirname(self.config_path), ] for path in paths: os.makedirs(path, exist_ok=True) diff --git a/api/tasks/base.py b/api/tasks/base.py index fba4b25eb..eb8e82a38 100644 --- a/api/tasks/base.py +++ b/api/tasks/base.py @@ -1,61 +1,68 @@ from celery import Task from typing import Dict, Any from database.project_db import SQLiteProjectDB -import os -from src.schema import Status # Import 추가 +from src.schema import Status + class TrialTask(Task): """Trial 관련 모든 task의 기본 클래스""" - - def update_state_and_db(self, trial_id: str, project_id: str, - status: str, progress: int, - task_type: str, parse_task_id: str = None, chunk_task_id: str = None, - info: Dict[str, Any] = None): + + def update_state_and_db( + self, + trial_id: str, + project_id: str, + status: str, + progress: int, + task_type: str, + parse_task_id: str = None, + chunk_task_id: str = None, + info: Dict[str, Any] = None, + ): """Task 상태와 DB를 함께 업데이트""" # Redis에 상태 업데이트 (Celery) self.update_state( - state='PROGRESS', + state="PROGRESS", meta={ - 'trial_id': trial_id, - 'task_type': task_type, - 'current': progress, - 'parse_task_id': parse_task_id, - 'chunk_task_id': chunk_task_id, - 'total': 100, - 'status': status, - 'info': info or {} - } + "trial_id": trial_id, + "task_type": task_type, + "current": progress, + "parse_task_id": parse_task_id, + "chunk_task_id": chunk_task_id, + "total": 100, + "status": status, + "info": info or {}, + }, ) - + # 상태 매핑 추가 status_map = { - 'PENDING': Status.IN_PROGRESS, - 'STARTED': Status.IN_PROGRESS, - 'SUCCESS': Status.COMPLETED, - 'FAILURE': Status.FAILED, - 'chunking': Status.IN_PROGRESS, - 'parsing': Status.IN_PROGRESS, - 'generating_qa_docs': Status.IN_PROGRESS + "PENDING": Status.IN_PROGRESS, + "STARTED": Status.IN_PROGRESS, + "SUCCESS": Status.COMPLETED, + "FAILURE": Status.FAILED, + "chunking": Status.IN_PROGRESS, + "parsing": Status.IN_PROGRESS, + "generating_qa_docs": Status.IN_PROGRESS, } trial_status = status_map.get(status, Status.FAILED) - + # SQLite DB 업데이트 project_db = SQLiteProjectDB(project_id) trial = project_db.get_trial(trial_id) if trial: trial.status = trial_status # 매핑된 상태 사용 - if task_type == 'parse': + if task_type == "parse": trial.parse_task_id = self.request.id - elif task_type == 'chunk': + elif task_type == "chunk": trial.chunk_task_id = self.request.id project_db.set_trial(trial) - + def on_failure(self, exc, task_id, args, kwargs, einfo): """Handle task failure""" super().on_failure(exc, task_id, args, kwargs, einfo) # Add your error handling logic here - + def on_success(self, retval, task_id, args, kwargs): """Handle task success""" super().on_success(retval, task_id, args, kwargs) - # Add your success handling logic here \ No newline at end of file + # Add your success handling logic here diff --git a/api/tasks/trial_tasks.py b/api/tasks/trial_tasks.py index 3073ab06b..aaa7e7e44 100644 --- a/api/tasks/trial_tasks.py +++ b/api/tasks/trial_tasks.py @@ -1,4 +1,5 @@ import os + from celery import shared_task from .base import TrialTask from src.schema import ( @@ -7,7 +8,11 @@ ) import logging import yaml -from src.run import run_parser_start_parsing, run_chunker_start_chunking, run_qa_creation +from src.run import ( + run_parser_start_parsing, + run_chunker_start_chunking, + run_qa_creation, +) # 로깅 설정 logging.basicConfig( @@ -29,18 +34,23 @@ @shared_task(bind=True, base=TrialTask) -def chunk_documents(self, project_id: str, trial_id: str, config_str: str): +def chunk_documents( + self, project_id: str, config_str: str, parsed_data_path: str, chunk_name: str +): """ Task for the chunk documents :param project_id: The project id of the trial - :param trial_id: The id of the trial :param config_str: Configuration string for chunking - :return: The result of the chunking (Maybe None?) + :param parsed_data_path: The path of the parsed data + :param chunk_name: The name of the chunk """ + if not os.path.exists(parsed_data_path): + raise ValueError(f"parsed_data_path does not exist: {parsed_data_path}") + try: self.update_state_and_db( - trial_id=trial_id, + trial_id="", project_id=project_id, status="chunking", progress=0, @@ -52,13 +62,22 @@ def chunk_documents(self, project_id: str, trial_id: str, config_str: str): project_dir = os.path.join(WORK_DIR, project_id) config_dir = os.path.join(project_dir, "config") - parsed_data_path = os.path.join( - project_dir, "parse", f"parse_{trial_id}", "0.parquet" - ) - chunked_data_dir = os.path.join(project_dir, "chunk", f"chunk_{trial_id}") + chunked_data_dir = os.path.join(project_dir, "chunk", chunk_name) os.makedirs(config_dir, exist_ok=True) config_dir = os.path.join(project_dir, "config") + os.makedirs(chunked_data_dir, exist_ok=False) + except Exception as e: + self.update_state_and_db( + trial_id="", + project_id=project_id, + status=Status.FAILED, + progress=0, + task_type="chunk", + info={"error": str(e)}, + ) + raise + try: # config_str을 파이썬 딕셔너리로 변환 후 다시 YAML로 저장 if isinstance(config_str, str): config_dict = yaml.safe_load(config_str) @@ -71,7 +90,7 @@ def chunk_documents(self, project_id: str, trial_id: str, config_str: str): logger.debug(f"Chunking config_dict: {config_dict}") # YAML 파일 저장 - yaml_path = os.path.join(config_dir, f"chunk_config_{trial_id}.yaml") + yaml_path = os.path.join(config_dir, f"chunk_config_{chunk_name}.yaml") with open(yaml_path, "w", encoding="utf-8") as f: yaml.safe_dump(config_dict, f, allow_unicode=True) @@ -80,7 +99,7 @@ def chunk_documents(self, project_id: str, trial_id: str, config_str: str): ) self.update_state_and_db( - trial_id=trial_id, + trial_id="", project_id=project_id, status=Status.COMPLETED, progress=100, @@ -89,22 +108,23 @@ def chunk_documents(self, project_id: str, trial_id: str, config_str: str): return result except Exception as e: self.update_state_and_db( - trial_id=trial_id, + trial_id="", project_id=project_id, status=Status.FAILED, progress=0, task_type="chunk", info={"error": str(e)}, ) + if os.path.exists(chunked_data_dir): + os.rmdir(chunked_data_dir) raise - @shared_task(bind=True, base=TrialTask) -def generate_qa_documents(self, project_id: str, trial_id: str, data: dict): +def generate_qa_documents(self, project_id: str, request_data: QACreationRequest): try: self.update_state_and_db( - trial_id=trial_id, + trial_id="", project_id=project_id, status="generating_qa_docs", progress=0, @@ -117,30 +137,29 @@ def generate_qa_documents(self, project_id: str, trial_id: str, data: dict): project_dir = os.path.join(WORK_DIR, project_id) config_dir = os.path.join(project_dir, "config") corpus_filepath = os.path.join( - project_dir, "chunk", f"chunk_{trial_id}", "0.parquet" + project_dir, "chunk", request_data.chunked_name, "0.parquet" ) - dataset_dir = os.path.join(project_dir, "qa", f"qa_{trial_id}") - - # 필요한 모든 디렉토리 생성 + if not os.path.exists(corpus_filepath): + raise ValueError(f"corpus_filepath does not exist: {corpus_filepath}") + + dataset_dir = os.path.join(project_dir, "qa") + os.makedirs(config_dir, exist_ok=True) - os.makedirs(dataset_dir, exist_ok=True) # dataset_dir도 생성 + if not os.path.exists(dataset_dir): + os.makedirs(dataset_dir, exist_ok=False) - qa_creation_request = QACreationRequest(**data) - result = run_qa_creation( - qa_creation_request, corpus_filepath, dataset_dir - ) + run_qa_creation(request_data, corpus_filepath, dataset_dir) self.update_state_and_db( - trial_id=trial_id, + trial_id="", project_id=project_id, status=Status.COMPLETED, progress=100, - task_type="qa_docs" + task_type="qa_docs", ) - return result except Exception as e: self.update_state_and_db( - trial_id=trial_id, + trial_id="", project_id=project_id, status=Status.FAILED, progress=0, @@ -151,10 +170,12 @@ def generate_qa_documents(self, project_id: str, trial_id: str, data: dict): @shared_task(bind=True, base=TrialTask) -def parse_documents(self, project_id: str, trial_id: str, config_str: str): +def parse_documents( + self, project_id: str, config_str: str, parse_name: str, glob_path: str = "*.*" +): try: self.update_state_and_db( - trial_id=trial_id, + trial_id="", project_id=project_id, status=Status.IN_PROGRESS, progress=0, @@ -162,11 +183,22 @@ def parse_documents(self, project_id: str, trial_id: str, config_str: str): ) project_dir = os.path.join(WORK_DIR, project_id) - raw_data_path = os.path.join(project_dir, "raw_data", "*.pdf") + raw_data_path = os.path.join(project_dir, "raw_data", glob_path) config_dir = os.path.join(project_dir, "config") - parsed_data_path = os.path.join(project_dir, "parse", f"parse_{trial_id}") + parsed_data_path = os.path.join(project_dir, "parse", parse_name) os.makedirs(config_dir, exist_ok=True) + os.makedirs(parsed_data_path, exist_ok=False) + except Exception as e: + self.update_state_and_db( + trial_id="", + project_id=project_id, + status=Status.FAILED, + progress=0, + task_type="parse", + info={"error": str(e)}, + ) + try: # config_str을 파이썬 딕셔너리로 변환 후 다시 YAML로 저장 if isinstance(config_str, str): config_dict = yaml.safe_load(config_str) @@ -178,14 +210,14 @@ def parse_documents(self, project_id: str, trial_id: str, config_str: str): config_dict = {"modules": config_dict} # YAML 파일 저장 - yaml_path = os.path.join(config_dir, f"parse_config_{trial_id}.yaml") + yaml_path = os.path.join(config_dir, f"parse_config_{parse_name}.yaml") with open(yaml_path, "w", encoding="utf-8") as f: yaml.safe_dump(config_dict, f, allow_unicode=True) result = run_parser_start_parsing(raw_data_path, parsed_data_path, yaml_path) self.update_state_and_db( - trial_id=trial_id, + trial_id="", project_id=project_id, status=Status.COMPLETED, progress=100, @@ -194,11 +226,13 @@ def parse_documents(self, project_id: str, trial_id: str, config_str: str): return result except Exception as e: self.update_state_and_db( - trial_id=trial_id, + trial_id="", project_id=project_id, status=Status.FAILED, progress=0, task_type="parse", info={"error": str(e)}, ) + if os.path.exists(parsed_data_path): + os.rmdir(parsed_data_path) raise diff --git a/autorag/projects/tutorial_1/config.yaml b/autorag/projects/tutorial_1/config.yaml deleted file mode 100644 index 44dd26c2e..000000000 --- a/autorag/projects/tutorial_1/config.yaml +++ /dev/null @@ -1,37 +0,0 @@ - -node_lines: -- node_line_name: retrieve_node_line - nodes: - - node_type: retrieval - strategy: - metrics: [retrieval_f1, retrieval_recall, retrieval_ndcg, retrieval_mrr] - top_k: 3 - modules: - - module_type: vectordb - embedding_model: openai - - module_type: bm25 - - module_type: hybrid_rrf - weight_range: (4,80) -- node_line_name: post_retrieve_node_line - nodes: - - node_type: prompt_maker - strategy: - metrics: - - metric_name: meteor - - metric_name: rouge - - metric_name: sem_score - embedding_model: openai - modules: - - module_type: fstring - prompt: "Read the passages and answer the given question. \n Question: {query} \n Passage: {retrieved_contents} \n Answer : " - - node_type: generator - strategy: - metrics: - - metric_name: meteor - - metric_name: rouge - - metric_name: sem_score - embedding_model: openai - modules: - - module_type: openai_llm - llm: gpt-4o-mini - batch: 16 # If you have low tier at OpenAI, decrease this. From 4a0cdfd841069fc18b548fe5089d7f1f8df1fef8 Mon Sep 17 00:00:00 2001 From: "Jeffrey (Dongkyu) Kim" Date: Sun, 1 Dec 2024 17:03:25 +0800 Subject: [PATCH 45/55] Working API with SQL DB (#1016) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Refactor start_evaluate api endpoint * if there is no .env, make one * make to one api endpoint that retrieve file content /artifacts/content * add /artifacts/content delete operation to delete the file * upload korean filenames * working parse with frontend * working QA! * validation 정상화 shout! * checkpoint (working but no result at evaluation) * Fix problem that trial_tasks.py cannot load the env * Finally success!!!! Working evaluate and validate --- api/Dockerfile | 15 +- api/app.py | 272 ++++++++++++++--------------------- api/database/project_db.py | 137 +++++++++++------- api/requirements.txt | 2 +- api/src/evaluate_history.py | 41 ++++-- api/src/run.py | 161 +++++++++++++-------- api/src/schema.py | 23 +-- api/src/validate.py | 48 +++---- api/tasks/trial_tasks.py | 170 ++++++++++++++++++++-- api/tests/test_project_db.py | 62 +++++--- autorag-frontend | 2 +- 11 files changed, 573 insertions(+), 360 deletions(-) diff --git a/api/Dockerfile b/api/Dockerfile index fe57661ae..754b9c4aa 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -2,10 +2,17 @@ FROM python:3.10-slim WORKDIR /app -# Install system dependencies +# Install system dependencies + parsing dependencies RUN apt-get update && apt-get install -y \ python3-pip \ build-essential \ + libmagic-dev \ + libgl1-mesa-dev \ + libglib2.0-0 \ + poppler-utils \ + tesseract-ocr \ + tesseract-ocr-eng \ + tesseract-ocr-kor \ && rm -rf /var/lib/apt/lists/* # Upgrade pip @@ -14,7 +21,11 @@ RUN python -m pip install --upgrade pip # Install Python dependencies COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -RUN pip install watchfiles +RUN pip install watchfiles pdf2image bert_score +# Install NLTK and download model +RUN pip install nltk && \ + python3 -c "import nltk; nltk.download('punkt_tab')" && \ + python3 -c "import nltk; nltk.download('averaged_perceptron_tagger_eng')" ENV PYTHONPATH=/app ENV PYTHONUNBUFFERED=1 # # Copy application code diff --git a/api/app.py b/api/app.py index c79aacd82..26e325244 100644 --- a/api/app.py +++ b/api/app.py @@ -1,4 +1,5 @@ import asyncio +import json import os import signal import concurrent.futures @@ -28,7 +29,6 @@ from src.auth import require_auth from src.evaluate_history import get_new_trial_dir from src.run import ( - run_start_trial, run_dashboard, run_chat, ) @@ -50,6 +50,8 @@ generate_qa_documents, parse_documents, chunk_documents, + start_validate, + start_evaluate, ) # 수정된 임포트 from celery.result import AsyncResult @@ -95,6 +97,10 @@ WORK_DIR = os.getenv("AUTORAG_WORK_DIR") ENV_FILEPATH = os.path.join(ROOT_DIR, f".env.{ENV}") +if not os.path.exists(ENV_FILEPATH): + # add empty new .env file + with open(ENV_FILEPATH, "w") as f: + f.write("") # 환경에 따른 WORK_DIR 설정 load_dotenv(ENV_FILEPATH) @@ -262,8 +268,6 @@ async def create_project(): return jsonify({"error": "Name is required"}), 400 description = data.get("description", "") - print(f"Set WORK_DIR environment variable to: {os.environ['AUTORAG_WORK_DIR']}") - WORK_DIR = os.environ["AUTORAG_WORK_DIR"] # Create a new project new_project_dir = os.path.join(WORK_DIR, data["name"]) if not os.path.exists(new_project_dir): @@ -443,13 +447,14 @@ async def create_trial(project_id: str): data = await request.get_json() data["project_id"] = project_id + new_trial_id = str(uuid.uuid4()) trial = Trial( **data, created_at=datetime.now(tz=timezone.utc), status=Status.IN_PROGRESS, - id=str(uuid.uuid4()), + id=new_trial_id, ) - + trial.config.trial_id = new_trial_id project_db.set_trial(trial) return jsonify(trial.model_dump()) @@ -475,58 +480,6 @@ async def delete_trial(project_id: str, trial_id: str): return jsonify({"message": "Trial deleted successfully"}) -@app.route("/projects//artifacts/files", methods=["GET"]) -@project_exists(WORK_DIR) -async def get_artifact_file(project_id: str): - """특정 파일의 내용을 비동기적으로 반환합니다.""" - file_path = request.args.get("path") - if not file_path: - return jsonify({"error": "File path is required"}), 400 - - try: - full_path = os.path.join(WORK_DIR, project_id, file_path) - - # 경로 검증 (디렉토리 트래버설 방지) - if not os.path.normpath(full_path).startswith( - os.path.normpath(os.path.join(WORK_DIR, project_id)) - ): - return jsonify({"error": "Invalid file path"}), 403 - - # 비동기로 파일 재 여부 확인 - if not await aiofiles.os.path.exists(full_path): - return jsonify({"error": "File not found"}), 404 - - if not await aiofiles.os.path.isfile(full_path): - return jsonify({"error": "Path is not a file"}), 400 - - # 파일 크기 체크 - stats = await aiofiles.os.stat(full_path) - if stats.st_size > 10 * 1024 * 1024: # 10MB 제한 - return jsonify({"error": "File too large"}), 400 - - # 파일 확장자 체크 - _, ext = os.path.splitext(full_path) - allowed_extensions = {".txt", ".yaml", ".yml", ".json", ".py", ".md"} - if ext.lower() not in allowed_extensions: - return jsonify({"error": "File type not supported"}), 400 - - # 비동기로 파일 읽기 - async with aiofiles.open(full_path, "r", encoding="utf-8") as f: - content = await f.read() - - return jsonify( - { - "content": content, - "path": file_path, - "size": stats.st_size, - "last_modified": stats.st_mtime, - } - ), 200 - - except Exception as e: - return jsonify({"error": f"Failed to read file: {str(e)}"}), 500 - - @app.route("/projects//upload", methods=["POST"]) @project_exists(WORK_DIR) async def upload_files(project_id: str): @@ -541,13 +494,17 @@ async def upload_files(project_id: str): try: # Get all files from the request uploaded_files = (await request.files).getlist("files") + uploaded_file_names = json.loads((await request.form).get("filenames")) if not uploaded_files: return jsonify({"error": "No files were uploaded"}), 400 + if len(uploaded_files) != len(uploaded_file_names): + return jsonify({"error": "Number of files and filenames do not match"}), 400 + # Iterate over each file and save it - for uploaded_file in uploaded_files: - filename = await files.save(uploaded_file) + for uploaded_file, filename in zip(uploaded_files, uploaded_file_names): + filename = await files.save(uploaded_file, name=filename) uploaded_file_paths.append(os.path.join(raw_data_path, filename)) return jsonify( @@ -688,9 +645,7 @@ async def start_chunking(project_id: str): task = chunk_documents.delay( project_id=project_id, config_str=yaml.dump(config), - parsed_data_path=os.path.join( - WORK_DIR, project_id, "parse", chunk_request.parsed_name - ), + parse_name=chunk_request.parsed_name, chunk_name=chunk_request.name, ) task_id = task.id @@ -719,7 +674,7 @@ async def create_qa(project_id: str): if os.path.exists( os.path.join( - WORK_DIR, project_id, "qa", f"{qa_creation_request.qa_name}.parquet" + WORK_DIR, project_id, "qa", f"{qa_creation_request.name}.parquet" ) ): return jsonify({"error": "QA name already exists"}), 400 @@ -727,7 +682,7 @@ async def create_qa(project_id: str): # Start Celery task task = generate_qa_documents.delay( project_id=project_id, - request_data=qa_creation_request, + request_data=qa_creation_request.model_dump(), ) task_id = task.id print(f"task: {task}") @@ -741,7 +696,7 @@ async def create_qa(project_id: str): "/projects//trials//config", methods=["GET"] ) @project_exists(WORK_DIR) -@trial_exists(WORK_DIR) +@trial_exists async def get_trial_config(project_id: str, trial_id: str): project_db = SQLiteProjectDB(project_id) trial = project_db.get_trial(trial_id) @@ -754,7 +709,7 @@ async def get_trial_config(project_id: str, trial_id: str): "/projects//trials//config", methods=["POST"] ) @project_exists(WORK_DIR) -@trial_exists(WORK_DIR) +@trial_exists async def set_trial_config(project_id: str, trial_id: str): project_db = SQLiteProjectDB(project_id) trial = project_db.get_trial(trial_id) @@ -772,113 +727,84 @@ async def set_trial_config(project_id: str, trial_id: str): "/projects//trials//validate", methods=["POST"] ) @project_exists(WORK_DIR) -async def start_validate(project_id: str, trial_id: str): - trial_config_path = os.path.join(WORK_DIR, project_id, "trials.db") - trial_config_db = SQLiteProjectDB(trial_config_path) - trial = trial_config_db.get_trial(trial_id) - task_id = str(uuid.uuid4()) - response = Task( - id=task_id, - project_id=project_id, - trial_id=trial_id, - name=f"{trial_id}/validation", - config_yaml=trial.config, - status=Status.IN_PROGRESS, - type=TaskType.VALIDATE, - created_at=datetime.now(tz=timezone.utc), - ) - await create_task( - task_id, - response, - TaskType.VALIDATE, - trial.config.qa_path, - trial.config.corpus_path, - trial.config.config_path, - ) - - return jsonify(response), 200 +@trial_exists +async def run_validate(project_id: str, trial_id: str): + try: + trial_config_db = SQLiteProjectDB(project_id) + trial_config: TrialConfig = trial_config_db.get_trial(trial_id).config + task = start_validate.delay( + project_id=project_id, + trial_id=trial_id, + corpus_name=trial_config.corpus_name, + qa_name=trial_config.qa_name, + yaml_config=trial_config.config, + ) + return jsonify({"task_id": task.id, "status": "Started"}), 200 + except Exception as e: + return jsonify({"error": f"Internal server error: {str(e)}"}), 500 @app.route( "/projects//trials//evaluate", methods=["POST"] ) @project_exists(WORK_DIR) -async def start_evaluate(project_id: str, trial_id: str): - evaluate_history_path = os.path.join(WORK_DIR, project_id, "evaluate_history.csv") - if not os.path.exists(evaluate_history_path): - evaluate_history_df = pd.DataFrame( - columns=["trial_id", "save_dir", "corpus_path", "qa_path", "config_path"] - ) # save_dir is to autorag trial directory - evaluate_history_df.to_csv(evaluate_history_path, index=False) - else: - evaluate_history_df = pd.read_csv(evaluate_history_path) - - trial_config_path = os.path.join(WORK_DIR, project_id, "trials.db") - trial_config_db = SQLiteProjectDB(trial_config_path) - previous_config = trial_config_db.get_trial(trial_id).config - print("previous_config: ", previous_config) - trial = trial_config_db.get_trial(trial_id) - trials_dir = os.path.join(WORK_DIR, project_id, "trials") +@trial_exists +async def run_evaluate(project_id: str, trial_id: str): + try: + trial_config_db = SQLiteProjectDB(project_id) + new_config = trial_config_db.get_trial(trial_id).config + if ( + new_config.corpus_name is None + or new_config.qa_name is None + or new_config.config is None + ): + return jsonify({"error": "All Corpus, QA, and config must be set"}), 400 + project_dir = os.path.join(WORK_DIR, project_id, "project") - data = await request.get_json() - skip_validation = data.get("skip_validation", False) - full_ingest = data.get("full_ingest", True) + data = await request.get_json() + skip_validation = data.get("skip_validation", False) + full_ingest = data.get("full_ingest", True) + + trial_configs = trial_config_db.get_all_configs() + print(f"trial config length : {len(trial_configs)}") + print(f"DB configs list: {list(map(lambda x: x.trial_id, trial_configs))}") + original_trial_configs = [ + config for config in trial_configs if config.trial_id != trial_id + ] + print(f"original_trial_configs length : {len(original_trial_configs)}") + new_trial_dir = get_new_trial_dir( + original_trial_configs, new_config, project_dir + ) + print(f"new_trial_dir: {new_trial_dir}") - new_trial_dir = get_new_trial_dir(evaluate_history_df, trial.config, trials_dir) - if os.path.exists(new_trial_dir): - return jsonify( - { - "trial_dir": new_trial_dir, - "error": "Exact same evaluation already run. " - "Skipping but return the directory where the evaluation result is saved.", - } - ), 409 - task_id = str(uuid.uuid4()) + new_config.save_dir = new_trial_dir + trial_config_db.set_trial_config(trial_id, new_config) - new_row = pd.DataFrame( - [ - { - "task_id": task_id, - "trial_id": trial_id, - "save_dir": new_trial_dir, - "corpus_path": previous_config.corpus_path, - "qa_path": previous_config.qa_path, - "config_path": previous_config.config_path, - "created_at": datetime.now(tz=timezone.utc), - } - ] - ) - evaluate_history_df = pd.concat([evaluate_history_df, new_row], ignore_index=True) - evaluate_history_df.reset_index(drop=True, inplace=True) - evaluate_history_df.to_csv(evaluate_history_path, index=False) - - with open(trial.config.config_path, "r") as f: - config_yaml = yaml.safe_load(f) - task = Task( - id=task_id, - project_id=project_id, - trial_id=trial_id, - name=f"{trial_id}/evaluation", - config_yaml=config_yaml, - status=Status.IN_PROGRESS, - type=TaskType.EVALUATE, - created_at=datetime.now(tz=timezone.utc), - save_path=new_trial_dir, - ) - await create_task( - task_id, - task, - run_start_trial, - trial.config.qa_path, - trial.config.corpus_path, - os.path.dirname(new_trial_dir), - trial.config.config_path, - skip_validation, - full_ingest, - ) + if os.path.exists(new_trial_dir): + return jsonify( + { + "trial_dir": new_trial_dir, + "error": "Exact same evaluation already run. " + "Skipping but return the directory where the evaluation result is saved.", + } + ), 409 + new_project_dir = os.path.dirname(new_trial_dir) + if not os.path.exists(new_project_dir): + os.makedirs(new_project_dir) - task.model_dump() - return jsonify(task.model_dump()), 202 + task = start_evaluate.delay( + project_id=project_id, + trial_id=trial_id, + corpus_name=new_config.corpus_name, + qa_name=new_config.qa_name, + yaml_config=new_config.config, + project_dir=new_project_dir, + skip_validation=skip_validation, + full_ingest=full_ingest, + ) + return jsonify({"task_id": task.id, "status": "started"}), 202 + except Exception as e: + return jsonify({"error": f"Internal server error: {str(e)}"}), 500 @app.route( @@ -1032,7 +958,7 @@ async def get_project_artifacts(project_id: str): return jsonify({"error": f"Failed to scan artifacts: {str(e)}"}), 500 -@app.route("/projects//artifacts/content") +@app.route("/projects//artifacts/content", methods=["GET"]) @project_exists(WORK_DIR) async def get_artifact_content(project_id: str): try: @@ -1040,11 +966,29 @@ async def get_artifact_content(project_id: str): target_path = os.path.join(WORK_DIR, project_id, "raw_data", requested_filename) if not os.path.exists(target_path): return jsonify({"error": "File not found"}), 404 + # 파일 크기 체크 + stats = await aiofiles.os.stat(target_path) + if stats.st_size > 10 * 1024 * 1024: # 10MB 제한 + return jsonify({"error": "File too large"}), 400 return await send_file(target_path, as_attachment=True), 200 except Exception as e: return jsonify({"error": f"Failed to load artifacts: {str(e)}"}), 500 +@app.route("/projects//artifacts/content", methods=["DELETE"]) +@project_exists(WORK_DIR) +async def delete_artifact(project_id: str): + try: + requested_filename = request.args.get("filename") + target_path = os.path.join(WORK_DIR, project_id, "raw_data", requested_filename) + if not os.path.exists(target_path): + return jsonify({"error": "File not found"}), 404 + os.remove(target_path) + return jsonify({"message": "File deleted successfully"}), 200 + except Exception as e: + return jsonify({"error": f"Failed to delete artifact: {str(e)}"}), 500 + + @app.route( "/projects//trials//chat/close", methods=["GET"] ) diff --git a/api/database/project_db.py b/api/database/project_db.py index 2d4bd06fb..6c7fbe04c 100644 --- a/api/database/project_db.py +++ b/api/database/project_db.py @@ -5,7 +5,7 @@ from src.schema import Trial, TrialConfig import os -from pathlib import Path + class SQLiteProjectDB: def __init__(self, project_id: str): @@ -17,32 +17,32 @@ def __init__(self, project_id: str): def _get_db_path(self) -> str: """프로젝트 ID로부터 DB 경로 생성""" # 1. 기본 작업 디렉토리 설정 - work_dir = os.getenv('WORK_DIR', '/app/projects') - + work_dir = os.getenv("WORK_DIR", "/app/projects") + # 2. 절대 경로로 변환 (상대 경로 해결) work_dir = os.path.abspath(work_dir) - + # 3. 최종 DB 파일 경로 생성 db_path = os.path.join(work_dir, self.project_id, "project.db") - + # 디버깅을 위한 로그 print(f"WORK_DIR (raw): {os.getenv('WORK_DIR', '/app/projects')}") print(f"WORK_DIR (abs): {work_dir}") print(f"Project ID: {self.project_id}") print(f"Final DB path: {db_path}") - + return db_path def _init_db(self): """DB 초기화 (필요한 경우 디렉토리 및 테이블 생성)""" db_exists = os.path.exists(self.db_path) db_dir = os.path.dirname(self.db_path) - + print(f"DB Path: {self.db_path}") print(f"DB Directory: {db_dir}") print(f"DB exists: {db_exists}") print(f"Directory exists: {os.path.exists(db_dir)}") - + # DB 파일이 없을 때만 초기화 작업 수행 if not db_exists: # 디렉토리가 없을 때만 생성 @@ -51,7 +51,7 @@ def _init_db(self): os.makedirs(db_dir) # 디렉토리 권한 설정 (777) os.chmod(db_dir, 0o777) - + try: print(f"Creating database: {self.db_path}") with sqlite3.connect(self.db_path) as conn: @@ -65,13 +65,13 @@ def _init_db(self): config JSON, created_at TEXT, report_task_id TEXT, - chat_task_id TEXT, - parse_task_id TEXT, - chunk_task_id TEXT + chat_task_id TEXT ) """) - conn.execute("CREATE INDEX IF NOT EXISTS idx_project_id ON trials(project_id)") - + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_project_id ON trials(project_id)" + ) + # DB 파일 권한 설정 (666) os.chmod(self.db_path, 0o666) except Exception as e: @@ -85,67 +85,93 @@ def get_trial(self, trial_id: str) -> Optional[Trial]: conn.row_factory = sqlite3.Row cursor = conn.execute("SELECT * FROM trials WHERE id = ?", (trial_id,)) row = cursor.fetchone() - + if row: trial_dict = dict(row) - if trial_dict['config']: - trial_dict['config'] = TrialConfig.model_validate_json(trial_dict['config']) - if trial_dict['created_at']: - trial_dict['created_at'] = datetime.fromisoformat(trial_dict['created_at']) + if trial_dict["config"]: + trial_dict["config"] = TrialConfig.model_validate_json( + trial_dict["config"] + ) + if trial_dict["created_at"]: + trial_dict["created_at"] = datetime.fromisoformat( + trial_dict["created_at"] + ) return Trial(**trial_dict) return None def set_trial(self, trial: Trial): """trial 저장 또는 업데이트""" with sqlite3.connect(self.db_path) as conn: - conn.execute(""" - INSERT OR REPLACE INTO trials + conn.execute( + """ + INSERT OR REPLACE INTO trials (id, project_id, name, status, config, created_at, report_task_id, chat_task_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - """, ( - trial.id, - trial.project_id, - trial.name, - trial.status, - trial.config.model_dump_json() if trial.config else None, - trial.created_at.isoformat() if trial.created_at else None, - trial.report_task_id, - trial.chat_task_id - )) + """, + ( + trial.id, + trial.project_id, + trial.name, + trial.status, + trial.config.model_dump_json() if trial.config else None, + trial.created_at.isoformat() if trial.created_at else None, + trial.report_task_id, + trial.chat_task_id, + ), + ) def set_trial_config(self, trial_id: str, config: TrialConfig): """trial config 업데이트""" with sqlite3.connect(self.db_path) as conn: - conn.execute(""" - UPDATE trials + conn.execute( + """ + UPDATE trials SET config = ? WHERE id = ? - """, (config.model_dump_json(), trial_id)) + """, + (config.model_dump_json(), trial_id), + ) def get_all_config_ids(self) -> List[str]: """모든 trial의 config ID 목록 조회""" with sqlite3.connect(self.db_path) as conn: cursor = conn.execute(""" - SELECT DISTINCT id - FROM trials + SELECT DISTINCT id + FROM trials WHERE config IS NOT NULL ORDER BY created_at DESC """) return [row[0] for row in cursor.fetchall()] + def get_all_configs(self) -> List[TrialConfig]: + """모든 trial의 config 목록 조회""" + with sqlite3.connect(self.db_path) as conn: + cursor = conn.execute(""" + SELECT config + FROM trials + WHERE config IS NOT NULL + ORDER BY created_at DESC + """) + return [ + TrialConfig.model_validate_json(row[0]) for row in cursor.fetchall() + ] + def get_all_trial_ids(self, project_id: Optional[str] = None) -> List[str]: """모든 trial ID 조회 (프로젝트별 필터링 가능)""" with sqlite3.connect(self.db_path) as conn: if project_id: - cursor = conn.execute(""" - SELECT id - FROM trials + cursor = conn.execute( + """ + SELECT id + FROM trials WHERE project_id = ? ORDER BY created_at DESC - """, (project_id,)) + """, + (project_id,), + ) else: cursor = conn.execute(""" - SELECT id + SELECT id FROM trials ORDER BY created_at DESC """) @@ -156,23 +182,32 @@ def delete_trial(self, trial_id: str): with sqlite3.connect(self.db_path) as conn: conn.execute("DELETE FROM trials WHERE id = ?", (trial_id,)) - def get_trials_by_project(self, project_id: str, limit: int = 10, offset: int = 0) -> List[Trial]: + def get_trials_by_project( + self, project_id: str, limit: int = 10, offset: int = 0 + ) -> List[Trial]: """프로젝트별 trial 목록 조회 (페이지네이션)""" with sqlite3.connect(self.db_path) as conn: conn.row_factory = sqlite3.Row - cursor = conn.execute(""" - SELECT * FROM trials - WHERE project_id = ? + cursor = conn.execute( + """ + SELECT * FROM trials + WHERE project_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ? - """, (project_id, limit, offset)) - + """, + (project_id, limit, offset), + ) + trials = [] for row in cursor.fetchall(): trial_dict = dict(row) - if trial_dict['config']: - trial_dict['config'] = TrialConfig(**json.loads(trial_dict['config'])) - if trial_dict['created_at']: - trial_dict['created_at'] = datetime.fromisoformat(trial_dict['created_at']) + if trial_dict["config"]: + trial_dict["config"] = TrialConfig( + **json.loads(trial_dict["config"]) + ) + if trial_dict["created_at"]: + trial_dict["created_at"] = datetime.fromisoformat( + trial_dict["created_at"] + ) trials.append(Trial(**trial_dict)) return trials diff --git a/api/requirements.txt b/api/requirements.txt index 74c986d9c..07eab817d 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -1,4 +1,4 @@ -AutoRAG[parse]>=0.3.10 +AutoRAG[parse]>=0.3.11 pydantic<2.10.0 jwt quart-cors diff --git a/api/src/evaluate_history.py b/api/src/evaluate_history.py index effab7984..e7a3b71ac 100644 --- a/api/src/evaluate_history.py +++ b/api/src/evaluate_history.py @@ -3,6 +3,7 @@ import uuid import json from datetime import datetime +from typing import List import pandas as pd @@ -10,35 +11,55 @@ def get_new_trial_dir( - history_df: pd.DataFrame, trial_config: TrialConfig, project_dir: str + past_trial_configs: List[TrialConfig], + new_trial_config: TrialConfig, + project_dir: str, ): - trial_rows = history_df[history_df["trial_id"] == trial_config.trial_id] - duplicate_corpus_rows = trial_rows[ - trial_rows["corpus_path"] == trial_config.corpus_path + def trial_configs_to_dataframe(trial_configs: List[TrialConfig]) -> pd.DataFrame: + # Convert list of TrialConfig to list of dictionaries + trial_configs_dicts = [ + trial_config.model_dump() for trial_config in trial_configs + ] + # Create DataFrame from list of dictionaries + df = pd.DataFrame(trial_configs_dicts) + return df + + if len(past_trial_configs) == 0: + new_dir_name = f"{new_trial_config.trial_id}-{str(uuid.uuid4())}" + os.makedirs(os.path.join(project_dir, new_dir_name)) + return os.path.join(project_dir, new_dir_name, "0") # New trial folder + + history_df = trial_configs_to_dataframe(past_trial_configs) + duplicate_corpus_rows = history_df[ + history_df["corpus_name"] == new_trial_config.corpus_name ] + print(f"Duplicate corpus rows: {duplicate_corpus_rows}") if len(duplicate_corpus_rows) == 0: # If corpus data changed # Changed Corpus - ingest again (Make new directory - new save_dir) - new_dir_name = f"{trial_config.trial_id}-{str(uuid.uuid4())}" + new_dir_name = f"{new_trial_config.trial_id}-{str(uuid.uuid4())}" os.makedirs(os.path.join(project_dir, new_dir_name)) return os.path.join(project_dir, new_dir_name, "0") # New trial folder duplicate_qa_rows = duplicate_corpus_rows[ - trial_rows["qa_path"] == trial_config.qa_path + duplicate_corpus_rows["qa_name"] == new_trial_config.qa_name ] + print(f"Duplicate qa rows: {duplicate_qa_rows}") if len(duplicate_qa_rows) == 0: # If qa data changed # swap qa data from the existing project directory - existing_project_dir = os.path.dirname(duplicate_qa_rows.iloc[0]["save_path"]) + existing_project_dir = os.path.dirname(duplicate_qa_rows.iloc[0]["save_dir"]) shutil.copy( - trial_config.qa_path, + new_trial_config.qa_path, os.path.join(existing_project_dir, "data", "qa.parquet"), ) duplicate_config_rows = duplicate_qa_rows[ - trial_rows["config_path"] == trial_config.config_path + duplicate_qa_rows["config"] == new_trial_config.config ] + print(f"Duplicate config rows: {duplicate_config_rows}") if len(duplicate_config_rows) > 0: duplicate_row_save_paths = duplicate_config_rows["save_dir"].unique().tolist() + print(f"Duplicate row save paths: {duplicate_row_save_paths}") return duplicate_row_save_paths[0] # Get the next trial folder - existing_project_dir = os.path.dirname(duplicate_qa_rows.iloc[0]["save_path"]) + existing_project_dir = os.path.dirname(duplicate_qa_rows.iloc[0]["save_dir"]) latest_trial_name = get_latest_trial( os.path.join(existing_project_dir, "trial.json") ) diff --git a/api/src/run.py b/api/src/run.py index 6605279b2..8103d4338 100644 --- a/api/src/run.py +++ b/api/src/run.py @@ -2,7 +2,7 @@ import subprocess import pandas as pd -from autorag import generator_models, dashboard +from autorag import generator_models from autorag.chunker import Chunker from autorag.data.qa.schema import QA from autorag.evaluator import Evaluator @@ -12,73 +12,114 @@ from src.qa_create import default_create, fast_create, advanced_create from src.schema import QACreationRequest + def run_parser_start_parsing(data_path_glob, project_dir, yaml_path): - # Import Parser here if it's defined in another module - parser = Parser(data_path_glob=data_path_glob, project_dir=project_dir) - print(f"Parser started with data_path_glob: {data_path_glob}, project_dir: {project_dir}, yaml_path: {yaml_path}") - parser.start_parsing(yaml_path) - print(f"Parser completed") + # Import Parser here if it's defined in another module + parser = Parser(data_path_glob=data_path_glob, project_dir=project_dir) + print( + f"Parser started with data_path_glob: {data_path_glob}, project_dir: {project_dir}, yaml_path: {yaml_path}" + ) + parser.start_parsing(yaml_path, all_files=True) + print("Parser completed") + def run_chunker_start_chunking(raw_path, project_dir, yaml_path): - # Import Parser here if it's defined in another module - chunker = Chunker.from_parquet(raw_path, project_dir=project_dir) - chunker.start_chunking(yaml_path) - -def run_qa_creation(qa_creation_request: QACreationRequest, corpus_filepath: str, dataset_dir: str): - """Create QA pairs from a corpus using specified LLM and preset configuration. - - Args: - qa_creation_request (QACreationRequest): Configuration object containing: - - preset: Type of QA generation ("basic", "simple", or "advanced") - - llm_config: LLM configuration (name and parameters) - - qa_num: Number of QA pairs to generate - - lang: Target language for QA pairs - - name: Output filename prefix - corpus_filepath (str): Path to the input corpus parquet file - dataset_dir (str): Directory where the generated QA pairs will be saved - - Raises: - ValueError: If an unsupported preset is specified - - Returns: - None: Saves the generated QA pairs to a parquet file in dataset_dir - """ - corpus_df = pd.read_parquet(corpus_filepath, engine="pyarrow") - llm = generator_models[qa_creation_request.llm_config.llm_name]( - **qa_creation_request.llm_config.llm_params - ) - - if qa_creation_request.preset == "basic": - qa: QA = default_create(corpus_df, llm, qa_creation_request.qa_num, qa_creation_request.lang, batch_size=8) - elif qa_creation_request.preset == "simple": - qa: QA = fast_create(corpus_df, llm, qa_creation_request.qa_num, qa_creation_request.lang, batch_size=8) - elif qa_creation_request.preset == "advanced": - qa: QA = advanced_create(corpus_df, llm, qa_creation_request.qa_num, qa_creation_request.lang, batch_size=8) - else: - raise ValueError(f"Input not supported Preset {qa_creation_request.preset}") - - # dataset_dir will be folder ${PROJECT_DIR}/qa/ - qa.to_parquet(os.path.join(dataset_dir, f"{qa_creation_request.name}.parquet"), - corpus_filepath) - - -def run_start_trial(qa_path: str, corpus_path: str, project_dir: str, yaml_path: str, - skip_validation: bool = True, full_ingest: bool = True): - evaluator = Evaluator(qa_path, corpus_path, project_dir=project_dir) - evaluator.start_trial(yaml_path, skip_validation=skip_validation, full_ingest=full_ingest) + # Import Parser here if it's defined in another module + chunker = Chunker.from_parquet(raw_path, project_dir=project_dir) + chunker.start_chunking(yaml_path) + + +def run_qa_creation( + qa_creation_request: QACreationRequest, corpus_filepath: str, dataset_dir: str +): + """Create QA pairs from a corpus using specified LLM and preset configuration. + + Args: + qa_creation_request (QACreationRequest): Configuration object containing: + - preset: Type of QA generation ("basic", "simple", or "advanced") + - llm_config: LLM configuration (name and parameters) + - qa_num: Number of QA pairs to generate + - lang: Target language for QA pairs + - name: Output filename prefix + corpus_filepath (str): Path to the input corpus parquet file + dataset_dir (str): Directory where the generated QA pairs will be saved + + Raises: + ValueError: If an unsupported preset is specified + + Returns: + None: Saves the generated QA pairs to a parquet file in dataset_dir + """ + corpus_df = pd.read_parquet(corpus_filepath, engine="pyarrow") + llm = generator_models[qa_creation_request.llm_config.llm_name]( + **qa_creation_request.llm_config.llm_params + ) + + if qa_creation_request.preset == "basic": + qa: QA = default_create( + corpus_df, + llm, + qa_creation_request.qa_num, + qa_creation_request.lang, + batch_size=8, + ) + elif qa_creation_request.preset == "simple": + qa: QA = fast_create( + corpus_df, + llm, + qa_creation_request.qa_num, + qa_creation_request.lang, + batch_size=8, + ) + elif qa_creation_request.preset == "advanced": + qa: QA = advanced_create( + corpus_df, + llm, + qa_creation_request.qa_num, + qa_creation_request.lang, + batch_size=8, + ) + else: + raise ValueError(f"Input not supported Preset {qa_creation_request.preset}") + + print(f"Generated QA jax : {qa.data}") + print(f"QA jax shape : {qa.data.shape}") + print(f"QA jax length : {len(qa.data)}") + # dataset_dir will be folder ${PROJECT_DIR}/qa/ + qa.to_parquet( + os.path.join(dataset_dir, f"{qa_creation_request.name}.parquet"), + corpus_filepath, + ) + + +def run_start_trial( + qa_path: str, + corpus_path: str, + project_dir: str, + yaml_path: str, + skip_validation: bool = True, + full_ingest: bool = True, +): + evaluator = Evaluator(qa_path, corpus_path, project_dir=project_dir) + evaluator.start_trial( + yaml_path, skip_validation=skip_validation, full_ingest=full_ingest + ) def run_validate(qa_path: str, corpus_path: str, yaml_path: str): - validator = Validator(qa_path, corpus_path) - validator.validate(yaml_path) + validator = Validator(qa_path, corpus_path) + validator.validate(yaml_path) + def run_dashboard(trial_dir: str): - process = subprocess.Popen(["autorag", "dashboard", "--trial_dir", trial_dir], - start_new_session=True) - return process.pid + process = subprocess.Popen( + ["autorag", "dashboard", "--trial_dir", trial_dir], start_new_session=True + ) + return process.pid def run_chat(trial_dir: str): - process = subprocess.Popen(["autorag", "run_web", "--trial_path", trial_dir], - start_new_session=True) - return process.pid + process = subprocess.Popen( + ["autorag", "run_web", "--trial_path", trial_dir], start_new_session=True + ) + return process.pid diff --git a/api/src/schema.py b/api/src/schema.py index 30ec7476f..12f54b35f 100644 --- a/api/src/schema.py +++ b/api/src/schema.py @@ -132,12 +132,14 @@ class Task(BaseModel): class TrialConfig(BaseModel): model_config = ConfigDict(from_attributes=True, validate_assignment=True) - trial_id: str + trial_id: Optional[str] = Field(None, description="The trial id") project_id: str - raw_path: str - corpus_path: Optional[str] = None - qa_path: Optional[str] = None - config_path: Optional[str] = None + save_dir: Optional[str] = Field( + None, description="The directory that trial result is stored." + ) + corpus_name: Optional[str] = None + qa_name: Optional[str] = None + config: Optional[dict] = None metadata: Optional[dict] = {} @@ -156,17 +158,6 @@ class Trial(BaseModel): chat_task_id: Optional[str] = Field( None, description="The chat task id for forcing shutdown of the task" ) - parse_task_id: Optional[str] = Field( - None, description="The parse task id" - ) # Celery task id - chunk_task_id: Optional[str] = Field( - None, description="The chunk task id" - ) # Celery task id - qa_task_id: Optional[str] = Field( - None, description="The QA task id" - ) # Celery task id - corpus_path: Optional[str] = None - qa_path: Optional[str] = None @field_validator("report_task_id", "chat_task_id", mode="before") def replace_nan_with_none(cls, v): diff --git a/api/src/validate.py b/api/src/validate.py index 46ba16df0..597a5b445 100644 --- a/api/src/validate.py +++ b/api/src/validate.py @@ -5,11 +5,12 @@ from src.schema import Trial from database.project_db import SQLiteProjectDB + def project_exists(base_dir: str): def decorator(f): @wraps(f) async def decorated_function(*args, **kwargs): - project_id = kwargs.get('project_id') + project_id = kwargs.get("project_id") if not project_id: return jsonify({"error": "Project ID is required"}), 400 @@ -17,32 +18,27 @@ async def decorated_function(*args, **kwargs): if not os.path.exists(project_dir): return jsonify({"error": "Project not found"}), 404 - # SQLiteProjectDB 초기화 - project_db = SQLiteProjectDB(project_id) - return await f(*args, **kwargs) + return decorated_function - return decorator -def trial_exists(work_dir: str): - def decorator(func): - @wraps(func) - async def wrapper(*args, **kwargs): - project_id = kwargs.get("project_id") - trial_id = kwargs.get("trial_id") - - if not trial_id: - return jsonify({ - 'error': 'trial_id is required' - }), 400 - - project_db = SQLiteProjectDB(project_id) - trial = project_db.get_trial(trial_id) - if trial is None or not isinstance(trial, Trial): - return jsonify({ - 'error': f'Trial with id {trial_id} does not exist' - }), 404 - - return await func(*args, **kwargs) - return wrapper return decorator + + +def trial_exists(func): + @wraps(func) + async def wrapper(*args, **kwargs): + project_id = kwargs.get("project_id") + trial_id = kwargs.get("trial_id") + + if not trial_id: + return jsonify({"error": "trial_id is required"}), 400 + + project_db = SQLiteProjectDB(project_id) + trial = project_db.get_trial(trial_id) + if trial is None or not isinstance(trial, Trial): + return jsonify({"error": f"Trial with id {trial_id} does not exist"}), 404 + + return await func(*args, **kwargs) + + return wrapper diff --git a/api/tasks/trial_tasks.py b/api/tasks/trial_tasks.py index aaa7e7e44..ce85362c9 100644 --- a/api/tasks/trial_tasks.py +++ b/api/tasks/trial_tasks.py @@ -1,6 +1,13 @@ import os +import shutil +import tempfile +from typing import Dict, Any +import pandas as pd from celery import shared_task +from dotenv import load_dotenv + +from database.project_db import SQLiteProjectDB from .base import TrialTask from src.schema import ( QACreationRequest, @@ -12,6 +19,8 @@ run_parser_start_parsing, run_chunker_start_chunking, run_qa_creation, + run_start_trial, + run_validate, ) # 로깅 설정 @@ -32,19 +41,31 @@ # 환경변수가 없는 경우 기본값 사용 WORK_DIR = os.path.join(ROOT_DIR, "projects") +ENV_FILEPATH = os.path.join(ROOT_DIR, f".env.{ENV}") +if not os.path.exists(ENV_FILEPATH): + # add empty new .env file + with open(ENV_FILEPATH, "w") as f: + f.write("") + +load_dotenv(ENV_FILEPATH) + @shared_task(bind=True, base=TrialTask) def chunk_documents( - self, project_id: str, config_str: str, parsed_data_path: str, chunk_name: str + self, project_id: str, config_str: str, parse_name: str, chunk_name: str ): """ Task for the chunk documents :param project_id: The project id of the trial :param config_str: Configuration string for chunking - :param parsed_data_path: The path of the parsed data + :param parse_name: The name of the parsed data :param chunk_name: The name of the chunk """ + load_dotenv(ENV_FILEPATH) + parsed_data_path = os.path.join( + WORK_DIR, project_id, "parse", parse_name, "0.parquet" + ) if not os.path.exists(parsed_data_path): raise ValueError(f"parsed_data_path does not exist: {parsed_data_path}") @@ -121,7 +142,17 @@ def chunk_documents( @shared_task(bind=True, base=TrialTask) -def generate_qa_documents(self, project_id: str, request_data: QACreationRequest): +def generate_qa_documents(self, project_id: str, request_data: Dict[str, Any]): + """ + Task for generating QA documents + + :param self: TrialTask self + :param project_id: The project_id + :param request_data: The request_data will be the model_dump of the QACreationRequest + """ + load_dotenv(ENV_FILEPATH) + qa_creation_request = QACreationRequest(**request_data) + print(f"qa_creation_request : {qa_creation_request}") try: self.update_state_and_db( trial_id="", @@ -135,20 +166,18 @@ def generate_qa_documents(self, project_id: str, request_data: QACreationRequest logger.info("Generating QA documents") project_dir = os.path.join(WORK_DIR, project_id) - config_dir = os.path.join(project_dir, "config") corpus_filepath = os.path.join( - project_dir, "chunk", request_data.chunked_name, "0.parquet" + project_dir, "chunk", qa_creation_request.chunked_name, "0.parquet" ) if not os.path.exists(corpus_filepath): raise ValueError(f"corpus_filepath does not exist: {corpus_filepath}") dataset_dir = os.path.join(project_dir, "qa") - os.makedirs(config_dir, exist_ok=True) if not os.path.exists(dataset_dir): os.makedirs(dataset_dir, exist_ok=False) - run_qa_creation(request_data, corpus_filepath, dataset_dir) + run_qa_creation(qa_creation_request, corpus_filepath, dataset_dir) self.update_state_and_db( trial_id="", @@ -173,6 +202,7 @@ def generate_qa_documents(self, project_id: str, request_data: QACreationRequest def parse_documents( self, project_id: str, config_str: str, parse_name: str, glob_path: str = "*.*" ): + load_dotenv(ENV_FILEPATH) try: self.update_state_and_db( trial_id="", @@ -234,5 +264,129 @@ def parse_documents( info={"error": str(e)}, ) if os.path.exists(parsed_data_path): - os.rmdir(parsed_data_path) + shutil.rmtree(parsed_data_path) + raise + + +@shared_task(bind=True, base=TrialTask) +def start_validate( + self, + project_id: str, + trial_id: str, + corpus_name: str, + qa_name: str, + yaml_config: dict, +): + load_dotenv(ENV_FILEPATH) + try: + self.update_state_and_db( + trial_id=trial_id, + project_id=project_id, + status=Status.IN_PROGRESS, + progress=0, + task_type="validate", + ) + + # Run the validation + with tempfile.NamedTemporaryFile(suffix=".yaml") as yaml_filepath: + with open(yaml_filepath.name, "w") as f: + yaml.safe_dump(yaml_config, f) + + corpus_df = pd.read_parquet( + os.path.join(WORK_DIR, project_id, "chunk", corpus_name, "0.parquet"), + engine="pyarrow", + ) + print(f"corpus_df columns : {corpus_df.columns}") + qa_df = pd.read_parquet( + os.path.join(WORK_DIR, project_id, "qa", f"{qa_name}.parquet"), + engine="pyarrow", + ) + print(f"qa_df columns : {qa_df.columns}") + print(f"qa length : {len(qa_df)}") + run_validate( + qa_path=os.path.join(WORK_DIR, project_id, "qa", f"{qa_name}.parquet"), + corpus_path=os.path.join( + WORK_DIR, project_id, "chunk", corpus_name, "0.parquet" + ), + yaml_path=yaml_filepath.name, + ) + + self.update_state_and_db( + trial_id=trial_id, + project_id=project_id, + status=Status.COMPLETED, + progress=100, + task_type="validate", + ) + + except Exception as e: + self.update_state_and_db( + trial_id=trial_id, + project_id=project_id, + status=Status.FAILED, + progress=0, + task_type="validate", + info={"error": str(e)}, + ) + raise + + +@shared_task(bind=True, base=TrialTask) +def start_evaluate( + self, + project_id: str, + trial_id: str, + corpus_name: str, + qa_name: str, + yaml_config: dict, + project_dir: str, + skip_validation: bool = True, + full_ingest: bool = True, +): + load_dotenv(ENV_FILEPATH) + try: + self.update_state_and_db( + trial_id=trial_id, + project_id=project_id, + status=Status.IN_PROGRESS, + progress=0, + task_type="evaluate", + ) + # Run the evaluation + with tempfile.NamedTemporaryFile(suffix=".yaml") as yaml_filepath: + with open(yaml_filepath.name, "w") as f: + yaml.safe_dump(yaml_config, f) + run_start_trial( + qa_path=os.path.join(WORK_DIR, project_id, "qa", f"{qa_name}.parquet"), + corpus_path=os.path.join( + WORK_DIR, project_id, "chunk", corpus_name, "0.parquet" + ), + project_dir=project_dir, + yaml_path=yaml_filepath.name, + skip_validation=skip_validation, + full_ingest=full_ingest, + ) + + trial_config_db = SQLiteProjectDB(project_id) + trial = trial_config_db.get_trial(trial_id) + trial.status = Status.COMPLETED + trial_config_db.set_trial(trial) + + self.update_state_and_db( + trial_id=trial_id, + project_id=project_id, + status=Status.COMPLETED, + progress=100, + task_type="evaluate", + ) + + except Exception as e: + self.update_state_and_db( + trial_id=trial_id, + project_id=project_id, + status=Status.FAILED, + progress=0, + task_type="evaluate", + info={"error": str(e)}, + ) raise diff --git a/api/tests/test_project_db.py b/api/tests/test_project_db.py index f0f059b2b..398d27c06 100644 --- a/api/tests/test_project_db.py +++ b/api/tests/test_project_db.py @@ -11,7 +11,7 @@ def temp_project_dir(): """임시 프로젝트 디렉토리 생성""" with tempfile.TemporaryDirectory() as temp_dir: - os.environ['WORK_DIR'] = temp_dir + os.environ["WORK_DIR"] = temp_dir yield temp_dir @@ -30,7 +30,7 @@ def sample_trial(): config=TrialConfig( trial_id="test_trial_1", project_id="test_project", - raw_path="/path/to/raw", + save_dir="/path/to/save", corpus_path="/path/to/corpus", qa_path="/path/to/qa", config_path="/path/to/config", @@ -39,14 +39,13 @@ def sample_trial(): status="not_started", created_at=datetime.now(), report_task_id="report_123", - chat_task_id="chat_123" + chat_task_id="chat_123", ) def test_db_initialization(temp_project_dir): """DB 초기화 테스트""" print("\n[테스트] DB 초기화") - db = SQLiteProjectDB("test_project") db_path = os.path.join(temp_project_dir, "test_project", "project.db") print(f"- DB 파일 생성 확인: {db_path}") assert os.path.exists(db_path) @@ -57,17 +56,19 @@ def test_set_and_get_trial(project_db, sample_trial): print("\n[테스트] Trial 저장 및 조회") print(f"- Trial 저장: ID={sample_trial.id}") project_db.set_trial(sample_trial) - + print("- Trial 조회 및 데이터 검증") retrieved_trial = project_db.get_trial(sample_trial.id) - + print("\n[Config 검증]") print(f"원본 Config: {sample_trial.config.model_dump()}") print(f"조회된 Config: {retrieved_trial.config.model_dump()}") - + assert retrieved_trial is not None, "Trial이 성공적으로 조회되어야 함" assert retrieved_trial.id == sample_trial.id, "Trial ID가 일치해야 함" - assert retrieved_trial.config.model_dump() == sample_trial.config.model_dump(), "Config 데이터가 일치해야 함" + assert ( + retrieved_trial.config.model_dump() == sample_trial.config.model_dump() + ), "Config 데이터가 일치해야 함" print("- 검증 완료: 모든 필드가 일치함") @@ -86,7 +87,7 @@ def test_set_trial_config(project_db, sample_trial): print("\n[테스트] Trial 설정 업데이트") print(f"- 기존 Trial 저장: ID={sample_trial.id}") project_db.set_trial(sample_trial) - + print("- 새로운 설정으로 업데이트") new_config = TrialConfig( trial_id="test_trial_1", @@ -96,7 +97,7 @@ def test_set_trial_config(project_db, sample_trial): qa_path="/new/path/to/qa", config_path="/new/path/to/config", ) - + project_db.set_trial_config(sample_trial.id, new_config) retrieved_trial = project_db.get_trial(sample_trial.id) assert retrieved_trial.config.model_dump() == new_config.model_dump() @@ -108,21 +109,21 @@ def test_get_trials_by_project(project_db, sample_trial): print("\n[테스트] 프로젝트별 Trial 목록 조회") print("- 첫 번째 Trial 저장") project_db.set_trial(sample_trial) - + print("- 두 번째 Trial 생성 및 저장") second_trial = Trial( id="test_trial_2", project_id="test_project", name="Test Trial 2", status="completed", - created_at=datetime.now() + created_at=datetime.now(), ) project_db.set_trial(second_trial) - + print("- 페이지네이션 테스트 (limit=1)") trials = project_db.get_trials_by_project("test_project", limit=1, offset=0) assert len(trials) == 1, "한 개의 Trial만 반환되어야 함" - + print("- 전체 Trial 조회 테스트") all_trials = project_db.get_trials_by_project("test_project", limit=10, offset=0) assert len(all_trials) == 2, "두 개의 Trial이 반환되어야 함" @@ -132,22 +133,41 @@ def test_get_trials_by_project(project_db, sample_trial): def test_get_all_config_ids(project_db, sample_trial): """모든 config ID 조회 테스트""" project_db.set_trial(sample_trial) - + # config가 없는 trial 추가 trial_without_config = Trial( id="test_trial_2", project_id="test_project", name="Test Trial 2", status="not_started", - created_at=datetime.now() + created_at=datetime.now(), ) project_db.set_trial(trial_without_config) - + config_ids = project_db.get_all_config_ids() assert len(config_ids) == 1 assert config_ids[0] == sample_trial.id +def test_get_all_configs(project_db, sample_trial): + project_db.set_trial(sample_trial) + + # config가 없는 trial 추가 + trial_without_config = Trial( + id="test_trial_2", + project_id="test_project", + name="Test Trial 2", + status="not_started", + created_at=datetime.now(), + ) + project_db.set_trial(trial_without_config) + + configs = project_db.get_all_configs() + assert len(configs) == 1 + assert all([isinstance(config, TrialConfig) for config in configs]) + assert configs[0].corpus_path == "/path/to/corpus" + + def test_delete_trial(project_db, sample_trial): """Trial 삭제 테스트""" project_db.set_trial(sample_trial) @@ -158,22 +178,22 @@ def test_delete_trial(project_db, sample_trial): def test_get_all_trial_ids(project_db, sample_trial): """모든 trial ID 조회 테스트""" project_db.set_trial(sample_trial) - + # 다른 프로젝트의 trial 추가 other_trial = Trial( id="other_trial", project_id="other_project", name="Other Trial", status="not_started", - created_at=datetime.now() + created_at=datetime.now(), ) project_db.set_trial(other_trial) - + # 특정 프로젝트의 trial ID 조회 project_trials = project_db.get_all_trial_ids(project_id="test_project") assert len(project_trials) == 1 assert project_trials[0] == sample_trial.id - + # 모든 trial ID 조회 all_trials = project_db.get_all_trial_ids() assert len(all_trials) == 2 diff --git a/autorag-frontend b/autorag-frontend index 7c7bb2862..832987d14 160000 --- a/autorag-frontend +++ b/autorag-frontend @@ -1 +1 @@ -Subproject commit 7c7bb2862676e69b2524c4dc68290b5160727f62 +Subproject commit 832987d148555aa788ffa8181bd6e8f59468f6bb From 1759488ed666c93e7da96ca0c852e59111301298 Mon Sep 17 00:00:00 2001 From: "Jeffrey (Dongkyu) Kim" Date: Sun, 1 Dec 2024 20:39:43 +0800 Subject: [PATCH 46/55] API server refactor to celery with report, streamlit, and qaurt api server with streaming (#1021) * working running dashboard * working running and closing report * working and closing the chat streamlit server * working and closing the external api server port to 8100 --- api/app.py | 353 ++++++++++--------------------------- api/database/project_db.py | 8 +- api/src/run.py | 8 + api/src/schema.py | 1 + api/tasks/base.py | 19 +- api/tasks/trial_tasks.py | 136 +++++++++++++- docker-compose.yml | 1 + 7 files changed, 244 insertions(+), 282 deletions(-) diff --git a/api/app.py b/api/app.py index 26e325244..daf7b4e13 100644 --- a/api/app.py +++ b/api/app.py @@ -1,59 +1,53 @@ import asyncio import json +import logging import os import signal -import concurrent.futures import uuid from datetime import datetime, timezone from glob import glob from pathlib import Path -from typing import Dict, Optional, Callable +from typing import Optional from typing import List -import logging -import nest_asyncio -import click -import uvicorn -from quart import jsonify, request, make_response, send_file -from pydantic import BaseModel import aiofiles import aiofiles.os -from dotenv import load_dotenv, dotenv_values, set_key, unset_key - +import click +import nest_asyncio import pandas as pd +import uvicorn import yaml +from celery.result import AsyncResult +from dotenv import load_dotenv, dotenv_values, set_key, unset_key +from pydantic import BaseModel from quart import Quart +from quart import jsonify, request, make_response, send_file from quart_cors import cors # Import quart_cors to enable CORS from quart_uploads import UploadSet, configure_uploads +from database.project_db import SQLiteProjectDB # 올바른 임포트로 변경 from src.auth import require_auth from src.evaluate_history import get_new_trial_dir -from src.run import ( - run_dashboard, - run_chat, -) from src.schema import ( ChunkRequest, EnvVariableRequest, Project, - Task, Status, - TaskType, Trial, TrialConfig, QACreationRequest, ) - from src.validate import project_exists, trial_exists -from database.project_db import SQLiteProjectDB # 올바른 임포트로 변경 from tasks.trial_tasks import ( generate_qa_documents, parse_documents, chunk_documents, start_validate, start_evaluate, + start_dashboard, + start_chat_server, + start_api_server, ) # 수정된 임포트 -from celery.result import AsyncResult # uvloop을 사용하지 않도록 설정 asyncio.set_event_loop_policy(asyncio.DefaultEventLoopPolicy()) @@ -83,13 +77,6 @@ print("CORS enabled for http://localhost:3000") -# Global variables to manage tasks -tasks = {} # task_id -> task_info # This will be the temporal DB for task infos -task_futures = {} # task_id -> future (for forceful termination) -task_queue = asyncio.Queue() -current_task_id = None # ID of the currently running task -lock = asyncio.Lock() # To manage access to shared variables - ROOT_DIR = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) ENV = os.getenv("AUTORAG_API_ENV", "dev") WORK_DIR = os.path.join(ROOT_DIR, "projects") @@ -144,119 +131,6 @@ async def options_handler(path=""): return response -async def create_task(task_id: str, task: Task, func: Callable, *args) -> None: - tasks[task_id] = { - "function": func, - "args": args, - "error": None, - "task": task, - } - await task_queue.put(task_id) - - -async def run_background_task(task_id: str, func, *args): - """백그라운드 작업을 실행하는 함수""" - task_info = tasks[task_id] - task = task_info["task"] - - try: - loop = asyncio.get_event_loop() - logger.info(f"Executing {func.__name__} with args: {args}") - - def execute(): - return func(*args) # 인자를 그대로 언패킹하여 전달 - - result = await loop.run_in_executor(None, execute) - task.status = Status.COMPLETED - return result - except Exception as e: - logger.error(f"Task {task_id} failed with error: {func.__name__}({args}) {e}") - task.status = Status.FAILED - task.error = str(e) - raise - - -async def task_runner(): - global current_task_id - loop = asyncio.get_running_loop() - executor = concurrent.futures.ProcessPoolExecutor() - try: - while True: - task_id = await task_queue.get() - async with lock: - current_task_id = task_id - tasks[task_id]["task"].status = Status.IN_PROGRESS - - try: - # Get function and arguments from task info - func = tasks[task_id]["function"] - args = tasks[task_id].get("args", ()) - - print(f"args: {args}") - print(f"func: {func}") - - # Load env variable before running a task - load_dotenv(ENV_FILEPATH) - - # Run the function in a separate process - future = loop.run_in_executor( - executor, - func, - *args, - ) - task_futures[task_id] = future - - await future - if func.__name__ == run_dashboard.__name__: - tasks[task_id]["report_pid"] = future.result() - elif func.__name__ == run_chat.__name__: - tasks[task_id]["chat_pid"] = future.result() - - # Update status on completion - async with lock: - print(f"Task {task_id} is completed") - tasks[task_id]["task"].status = Status.COMPLETED - current_task_id = None - except asyncio.CancelledError: - tasks[task_id]["task"].status = Status.TERMINATED - print(f"Task {task_id} has been forcefully terminated.") - except Exception as e: - # Handle errors - async with lock: - tasks[task_id]["task"].status = Status.FAILED - tasks[task_id]["error"] = str(e) - current_task_id = None - print(f"Task {task_id} failed with error: task_runner {e}") - print(e) - - finally: - task_queue.task_done() - task_futures.pop(task_id, None) - finally: - executor.shutdown() - - -async def cancel_task(task_id: str) -> None: - async with lock: - future = task_futures.get(task_id) - if future and not future.done(): - try: - # Attempt to kill the associated process directly - future.cancel() - except Exception as e: - tasks[task_id]["task"].status = Status.FAILED - tasks[task_id]["error"] = f"Failed to terminate: {str(e)}" - print(f"Task {task_id} failed to terminate with error: {e}") - else: - print(f"Task {task_id} is not running or already completed.") - - -@app.before_serving -async def startup(): - # Start the background task when the app starts - app.add_background_task(task_runner) - - # Project creation endpoint @app.route("/projects", methods=["POST"]) @require_auth() @@ -823,45 +697,22 @@ async def open_dashboard(project_id: str, trial_id: str): JSON response with task status or error message """ try: - # Get the trial and search for the corresponding save_path - evaluate_history_path = os.path.join( - WORK_DIR, project_id, "evaluate_history.csv" - ) - if not os.path.exists(evaluate_history_path): - return jsonify({"error": "You need to run evaluation first"}), 400 - - evaluate_history_df = pd.read_csv(evaluate_history_path) - trial_raw = evaluate_history_df[evaluate_history_df["trial_id"] == trial_id] - if trial_raw.empty or len(trial_raw) < 1: - return jsonify({"error": "Trial ID not found"}), 404 - if len(trial_raw) >= 2: - return jsonify({"error": "Duplicated trial ID found"}), 400 - - trial_dir = trial_raw.iloc[0]["save_dir"] - if not os.path.exists(trial_dir): + db = SQLiteProjectDB(project_id) + trial = db.get_trial(trial_id) + + if trial.config.save_dir is None or not os.path.exists(trial.config.save_dir): return jsonify({"error": "Trial directory not found"}), 404 - if not os.path.isdir(trial_dir): - return jsonify({"error": "Trial directory is not a directory"}), 500 - task_id = str(uuid.uuid4()) - response = Task( - id=task_id, + if trial.report_task_id is not None: + return jsonify({"error": "Report already running"}), 409 + + task = start_dashboard.delay( project_id=project_id, trial_id=trial_id, - status=Status.IN_PROGRESS, - type=TaskType.REPORT, - created_at=datetime.now(tz=timezone.utc), + trial_dir=trial.config.save_dir, ) - await create_task(task_id, response, run_dashboard, trial_dir) - trial_config_path = os.path.join(WORK_DIR, project_id, "trials.db") - trial_config_db = SQLiteProjectDB(trial_config_path) - trial = trial_config_db.get_trial(trial_id) - new_trial = trial.model_copy(deep=True) - new_trial.report_task_id = task_id - trial_config_db.set_trial(new_trial) - - return jsonify(response.model_dump()), 202 + return jsonify({"task_id": task.id, "status": "running"}), 202 except Exception as e: return jsonify({"error": f"Internal server error: {str(e)}"}), 500 @@ -872,20 +723,19 @@ async def open_dashboard(project_id: str, trial_id: str): methods=["GET"], ) async def close_dashboard(project_id: str, trial_id: str): - trial_config_path = os.path.join(WORK_DIR, project_id, "trials.db") - trial_config_db = SQLiteProjectDB(trial_config_path) - trial = trial_config_db.get_trial(trial_id) - report_pid = tasks[trial.report_task_id]["report_pid"] - os.killpg(os.getpgid(report_pid), signal.SIGTERM) + db = SQLiteProjectDB(project_id) + trial = db.get_trial(trial_id) - new_trial = trial.model_copy(deep=True) + if trial.report_task_id is None: + return jsonify({"error": "The report already closed"}), 409 + + os.killpg(os.getpgid(int(trial.report_task_id)), signal.SIGTERM) - original_task = tasks[trial.report_task_id]["task"] - original_task.status = Status.TERMINATED + new_trial = trial.model_copy(deep=True) new_trial.report_task_id = None - trial_config_db.set_trial(new_trial) + db.set_trial(new_trial) - return jsonify(original_task.model_dump()), 200 + return jsonify({"task_id": trial.report_task_id, "status": "terminated"}), 200 @app.route( @@ -893,45 +743,22 @@ async def close_dashboard(project_id: str, trial_id: str): ) async def open_chat_server(project_id: str, trial_id: str): try: - # Get the trial and search for the corresponding save_path - evaluate_history_path = os.path.join( - WORK_DIR, project_id, "evaluate_history.csv" - ) - if not os.path.exists(evaluate_history_path): - return jsonify({"error": "You need to run evaluation first"}), 400 - - evaluate_history_df = pd.read_csv(evaluate_history_path) - trial_raw = evaluate_history_df[evaluate_history_df["trial_id"] == trial_id] - if trial_raw.empty or len(trial_raw) < 1: - return jsonify({"error": "Trial ID not found"}), 404 - if len(trial_raw) >= 2: - return jsonify({"error": "Duplicated trial ID found"}), 400 - - trial_dir = trial_raw.iloc[0]["save_dir"] - if not os.path.exists(trial_dir): + db = SQLiteProjectDB(project_id) + trial = db.get_trial(trial_id) + + if trial.config.save_dir is None or not os.path.exists(trial.config.save_dir): return jsonify({"error": "Trial directory not found"}), 404 - if not os.path.isdir(trial_dir): - return jsonify({"error": "Trial directory is not a directory"}), 500 - task_id = str(uuid.uuid4()) - response = Task( - id=task_id, + if trial.chat_task_id is not None: + return jsonify({"error": "Report already running"}), 409 + + task = start_chat_server.delay( project_id=project_id, trial_id=trial_id, - status=Status.IN_PROGRESS, - type=TaskType.CHAT, - created_at=datetime.now(tz=timezone.utc), + trial_dir=trial.config.save_dir, ) - await create_task(task_id, response, run_chat, trial_dir) - - trial_config_path = os.path.join(WORK_DIR, project_id, "trials.db") - trial_config_db = SQLiteProjectDB(trial_config_path) - trial = trial_config_db.get_trial(trial_id) - new_trial = trial.model_copy(deep=True) - new_trial.chat_task_id = task_id - trial_config_db.set_trial(new_trial) - return jsonify(response.model_dump()), 202 + return jsonify({"task_id": task.id, "status": "running"}), 202 except Exception as e: return jsonify({"error": f"Internal server error: {str(e)}"}), 500 @@ -993,60 +820,72 @@ async def delete_artifact(project_id: str): "/projects//trials//chat/close", methods=["GET"] ) async def close_chat_server(project_id: str, trial_id: str): - trial_config_path = os.path.join(WORK_DIR, project_id, "trials.db") - trial_config_db = SQLiteProjectDB(trial_config_path) - trial = trial_config_db.get_trial(trial_id) - chat_pid = tasks[trial.chat_task_id]["chat_pid"] - os.killpg(os.getpgid(chat_pid), signal.SIGTERM) + db = SQLiteProjectDB(project_id) + trial = db.get_trial(trial_id) - new_trial = trial.model_copy(deep=True) + if trial.chat_task_id is None: + return jsonify({"error": "The chat server already closed"}), 409 + + try: + os.killpg(os.getpgid(int(trial.chat_task_id)), signal.SIGTERM) + except Exception as e: + logger.debug(f"Error while closing chat server: {str(e)}") - original_task = tasks[trial.chat_task_id]["task"] - original_task.status = Status.TERMINATED + new_trial = trial.model_copy(deep=True) new_trial.chat_task_id = None - trial_config_db.set_trial(new_trial) + db.set_trial(new_trial) - return jsonify(original_task.model_dump()), 200 + return jsonify({"task_id": trial.chat_task_id, "status": "terminated"}), 200 -@app.route("/projects//tasks", methods=["GET"]) +@app.route( + "/projects//trials//api/open", methods=["GET"] +) @project_exists(WORK_DIR) -async def get_tasks(project_id: str): - if not os.path.exists(os.path.join(WORK_DIR, project_id)): - return jsonify({"error": f"Project name does not exist: {project_id}"}), 404 - - evaluate_history_path = os.path.join(WORK_DIR, project_id, "evaluate_history.csv") - if not os.path.exists(evaluate_history_path): - evaluate_history_df = pd.DataFrame( - columns=["trial_id", "save_dir", "corpus_path", "qa_path", "config_path"] - ) # save_dir is to autorag trial directory - evaluate_history_df.to_csv(evaluate_history_path, index=False) - else: - evaluate_history_df = pd.read_csv(evaluate_history_path) +@trial_exists +async def open_api_server(project_id: str, trial_id: str): + try: + db = SQLiteProjectDB(project_id) + trial = db.get_trial(trial_id) - # Replace NaN values with None before converting to dict - evaluate_history_df = evaluate_history_df.where(pd.notna(evaluate_history_df), -1) + if trial.api_pid is not None: + return jsonify({"error": "API server already running"}), 409 - return jsonify( - { - "total": len(evaluate_history_df), - "data": evaluate_history_df.to_dict( - orient="records" - ), # Convert DataFrame to list of dictionaries - } - ), 200 + if trial.config.save_dir is None or not os.path.exists(trial.config.save_dir): + return jsonify({"error": "Trial directory not found"}), 404 + task = start_api_server.delay( + project_id=project_id, trial_id=trial_id, trial_dir=trial.config.save_dir + ) -@app.route("/projects//tasks/", methods=["GET"]) + return jsonify({"task_id": task.id, "status": "running"}), 202 + + except Exception as e: + return jsonify({"error": f"Internal server error: {str(e)}"}), 500 + + +@app.route( + "/projects//trials//api/close", methods=["GET"] +) @project_exists(WORK_DIR) -async def get_task(project_id: str, task_id: str): - if not os.path.exists(os.path.join(WORK_DIR, project_id)): - return jsonify({"error": f"Project name does not exist: {project_id}"}), 404 - task: Optional[Dict] = tasks.get(task_id, None) - if task is None: - return jsonify({"error": f"Task ID does not exist: {task_id}"}), 404 - response = task["task"] - return jsonify(response.model_dump()), 200 +@trial_exists +async def close_api_server(project_id: str, trial_id: str): + db = SQLiteProjectDB(project_id) + trial = db.get_trial(trial_id) + + if trial.api_pid is None: + return jsonify({"error": "The api server already closed"}), 409 + + try: + os.killpg(os.getpgid(int(trial.api_pid)), signal.SIGTERM) + except Exception as e: + logger.debug(f"Error while closing api server: {str(e)}") + + new_trial = trial.model_copy(deep=True) + new_trial.api_pid = None + db.set_trial(new_trial) + + return jsonify({"task_id": trial.api_pid, "status": "terminated"}), 200 @app.route("/env", methods=["POST"]) diff --git a/api/database/project_db.py b/api/database/project_db.py index 6c7fbe04c..4b115e353 100644 --- a/api/database/project_db.py +++ b/api/database/project_db.py @@ -65,7 +65,8 @@ def _init_db(self): config JSON, created_at TEXT, report_task_id TEXT, - chat_task_id TEXT + chat_task_id TEXT, + api_pid NUMERIC ) """) conn.execute( @@ -105,8 +106,8 @@ def set_trial(self, trial: Trial): conn.execute( """ INSERT OR REPLACE INTO trials - (id, project_id, name, status, config, created_at, report_task_id, chat_task_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + (id, project_id, name, status, config, created_at, report_task_id, chat_task_id, api_pid) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( trial.id, @@ -117,6 +118,7 @@ def set_trial(self, trial: Trial): trial.created_at.isoformat() if trial.created_at else None, trial.report_task_id, trial.chat_task_id, + trial.api_pid, ), ) diff --git a/api/src/run.py b/api/src/run.py index 8103d4338..5b3949bdf 100644 --- a/api/src/run.py +++ b/api/src/run.py @@ -123,3 +123,11 @@ def run_chat(trial_dir: str): ["autorag", "run_web", "--trial_path", trial_dir], start_new_session=True ) return process.pid + + +def run_api_server(trial_dir: str): + process = subprocess.Popen( + ["autorag", "run_api", "--port", "8100", "--trial_dir", trial_dir], + start_new_session=True, + ) + return process.pid diff --git a/api/src/schema.py b/api/src/schema.py index 12f54b35f..73943bcf0 100644 --- a/api/src/schema.py +++ b/api/src/schema.py @@ -158,6 +158,7 @@ class Trial(BaseModel): chat_task_id: Optional[str] = Field( None, description="The chat task id for forcing shutdown of the task" ) + api_pid: Optional[int] = Field(None, description="The process id of the API server") @field_validator("report_task_id", "chat_task_id", mode="before") def replace_nan_with_none(cls, v): diff --git a/api/tasks/base.py b/api/tasks/base.py index eb8e82a38..19c1d99ef 100644 --- a/api/tasks/base.py +++ b/api/tasks/base.py @@ -34,27 +34,12 @@ def update_state_and_db( }, ) - # 상태 매핑 추가 - status_map = { - "PENDING": Status.IN_PROGRESS, - "STARTED": Status.IN_PROGRESS, - "SUCCESS": Status.COMPLETED, - "FAILURE": Status.FAILED, - "chunking": Status.IN_PROGRESS, - "parsing": Status.IN_PROGRESS, - "generating_qa_docs": Status.IN_PROGRESS, - } - trial_status = status_map.get(status, Status.FAILED) - # SQLite DB 업데이트 project_db = SQLiteProjectDB(project_id) trial = project_db.get_trial(trial_id) if trial: - trial.status = trial_status # 매핑된 상태 사용 - if task_type == "parse": - trial.parse_task_id = self.request.id - elif task_type == "chunk": - trial.chunk_task_id = self.request.id + if task_type == "evaluate" and status == Status.COMPLETED: + trial.status = Status.COMPLETED project_db.set_trial(trial) def on_failure(self, exc, task_id, args, kwargs, einfo): diff --git a/api/tasks/trial_tasks.py b/api/tasks/trial_tasks.py index ce85362c9..b7cf0e94f 100644 --- a/api/tasks/trial_tasks.py +++ b/api/tasks/trial_tasks.py @@ -21,6 +21,9 @@ run_qa_creation, run_start_trial, run_validate, + run_dashboard, + run_chat, + run_api_server, ) # 로깅 설정 @@ -367,11 +370,6 @@ def start_evaluate( full_ingest=full_ingest, ) - trial_config_db = SQLiteProjectDB(project_id) - trial = trial_config_db.get_trial(trial_id) - trial.status = Status.COMPLETED - trial_config_db.set_trial(trial) - self.update_state_and_db( trial_id=trial_id, project_id=project_id, @@ -390,3 +388,131 @@ def start_evaluate( info={"error": str(e)}, ) raise + + +@shared_task(bind=True, base=TrialTask) +def start_dashboard(self, project_id: str, trial_id: str, trial_dir: str): + load_dotenv(ENV_FILEPATH) + try: + self.update_state_and_db( + trial_id=trial_id, + project_id=project_id, + status=Status.IN_PROGRESS, + progress=0, + task_type="report", + ) + + # Run the dashboard + report_pid = run_dashboard(trial_dir) + + print(f"report_pid : {report_pid}") + + db = SQLiteProjectDB(project_id) + trial = db.get_trial(trial_id) + new_trial = trial.model_copy(deep=True) + + new_trial.report_task_id = str(report_pid) + db.set_trial(new_trial) + + self.update_state_and_db( + trial_id=trial_id, + project_id=project_id, + status=Status.COMPLETED, + progress=100, + task_type="report", + ) + + except Exception as e: + self.update_state_and_db( + trial_id=trial_id, + project_id=project_id, + status=Status.FAILED, + progress=0, + task_type="report", + info={"error": str(e)}, + ) + raise + + +@shared_task(bind=True, base=TrialTask) +def start_chat_server(self, project_id: str, trial_id: str, trial_dir: str): + load_dotenv(ENV_FILEPATH) + try: + self.update_state_and_db( + trial_id=trial_id, + project_id=project_id, + status=Status.IN_PROGRESS, + progress=0, + task_type="chat", + ) + + # Run the dashboard + chat_pid = run_chat(trial_dir) + + db = SQLiteProjectDB(project_id) + trial = db.get_trial(trial_id) + new_trial = trial.model_copy(deep=True) + + new_trial.chat_task_id = str(chat_pid) + db.set_trial(new_trial) + + self.update_state_and_db( + trial_id=trial_id, + project_id=project_id, + status=Status.COMPLETED, + progress=100, + task_type="chat", + ) + + except Exception as e: + self.update_state_and_db( + trial_id=trial_id, + project_id=project_id, + status=Status.FAILED, + progress=0, + task_type="chat", + info={"error": str(e)}, + ) + raise + + +@shared_task(bind=True, base=TrialTask) +def start_api_server(self, project_id: str, trial_id: str, trial_dir: str): + load_dotenv(ENV_FILEPATH) + try: + self.update_state_and_db( + trial_id=trial_id, + project_id=project_id, + status=Status.IN_PROGRESS, + progress=0, + task_type="api", + ) + + # Run the dashboard + api_pid = run_api_server(trial_dir) + + db = SQLiteProjectDB(project_id) + trial = db.get_trial(trial_id) + new_trial = trial.model_copy(deep=True) + + new_trial.api_pid = api_pid + db.set_trial(new_trial) + + self.update_state_and_db( + trial_id=trial_id, + project_id=project_id, + status=Status.COMPLETED, + progress=100, + task_type="api", + ) + + except Exception as e: + self.update_state_and_db( + trial_id=trial_id, + project_id=project_id, + status=Status.FAILED, + progress=0, + task_type="api", + info={"error": str(e)}, + ) + raise diff --git a/docker-compose.yml b/docker-compose.yml index 44eac6ab3..d3577d45c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,6 +18,7 @@ services: - "8000:8000" - "7690:7690" # for panel report - "8501:8501" # for streamlit chat + - "8100:8100" # for chat API server environment: - WATCHFILES_FORCE_POLLING=true # Docker on Windows/macOS를 위한 설정 - TZ=Asia/Seoul From c0482272adf7aea315a5b5b50f88b09fb94fea96 Mon Sep 17 00:00:00 2001 From: "Jeffrey (Dongkyu) Kim" Date: Mon, 2 Dec 2024 23:10:01 +0800 Subject: [PATCH 47/55] add ko version at requirements.txt (#1026) --- api/requirements.txt | 1 + autorag-frontend | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/api/requirements.txt b/api/requirements.txt index 07eab817d..5d1cea4d2 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -1,4 +1,5 @@ AutoRAG[parse]>=0.3.11 +AutoRAG[ko]>=0.3.11 pydantic<2.10.0 jwt quart-cors diff --git a/autorag-frontend b/autorag-frontend index 832987d14..c3b18385d 160000 --- a/autorag-frontend +++ b/autorag-frontend @@ -1 +1 @@ -Subproject commit 832987d148555aa788ffa8181bd6e8f59468f6bb +Subproject commit c3b18385d6cd426c7c9b28f3910059c8f1a63b10 From 42df1fcd983b099ae7319b0bd42e0f37c023eaf8 Mon Sep 17 00:00:00 2001 From: "Jeffrey (Dongkyu) Kim" Date: Tue, 3 Dec 2024 09:40:35 +0800 Subject: [PATCH 48/55] Update frontend --- autorag-frontend | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/autorag-frontend b/autorag-frontend index c3b18385d..e7a5d48d4 160000 --- a/autorag-frontend +++ b/autorag-frontend @@ -1 +1 @@ -Subproject commit c3b18385d6cd426c7c9b28f3910059c8f1a63b10 +Subproject commit e7a5d48d4ba3c0a85e71742bb7a953436e2dd758 From 23f11eef6f6def91119cda6af2019890c95d41eb Mon Sep 17 00:00:00 2001 From: "Jeffrey (Dongkyu) Kim" Date: Tue, 3 Dec 2024 14:47:53 +0800 Subject: [PATCH 49/55] update for api compatibility --- api/requirements.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/requirements.txt b/api/requirements.txt index 5d1cea4d2..e31612577 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -1,5 +1,5 @@ -AutoRAG[parse]>=0.3.11 -AutoRAG[ko]>=0.3.11 +AutoRAG[parse]==0.3.11rc2 +AutoRAG[ko]==0.3.11rc2 pydantic<2.10.0 jwt quart-cors From 02ff52a16684fefdc488b7c51c3e3dd98a02a030 Mon Sep 17 00:00:00 2001 From: "Jeffrey (Dongkyu) Kim" Date: Sun, 8 Dec 2024 11:01:07 +0900 Subject: [PATCH 50/55] update autorag-frontend --- autorag-frontend | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/autorag-frontend b/autorag-frontend index e7a5d48d4..c9c356c1b 160000 --- a/autorag-frontend +++ b/autorag-frontend @@ -1 +1 @@ -Subproject commit e7a5d48d4ba3c0a85e71742bb7a953436e2dd758 +Subproject commit c9c356c1b8867a16c82cd2f3c5fd46b5ae6484e1 From 9f272575992fae9927c429a42566b78862cf1344 Mon Sep 17 00:00:00 2001 From: "Jeffrey (Dongkyu) Kim" Date: Sun, 8 Dec 2024 17:42:10 +0900 Subject: [PATCH 51/55] Add parsed data get endpoint (#1041) * add parsed file get endpoint * Add an "all_files" endpoint. --- api/app.py | 37 +++++++++++++++++++++++++++++++++++++ api/src/run.py | 4 ++-- api/tasks/trial_tasks.py | 11 +++++++++-- 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/api/app.py b/api/app.py index daf7b4e13..1ef599081 100644 --- a/api/app.py +++ b/api/app.py @@ -417,6 +417,41 @@ async def get_parse_documents(project_id): return jsonify(result_dict_list), 200 +@app.route("/projects//parse/", methods=["GET"]) +@project_exists(WORK_DIR) +async def get_parsed_file(project_id: str, parsed_name: str): + parsed_folder = os.path.join(WORK_DIR, project_id, "parse") + raw_df = pd.read_parquet( + os.path.join(parsed_folder, parsed_name, "0.parquet"), engine="pyarrow" + ) + requested_filename = request.args.get("filename", type=str) + requested_page = request.args.get("page", -1, type=int) + + if requested_filename is None: + return jsonify({"error": "Filename is required"}), 400 + + if requested_page < -1: + return jsonify({"error": "Invalid page number"}), 400 + + requested_filepath = os.path.join( + WORK_DIR, project_id, "raw_data", requested_filename + ) + + raw_row = raw_df.loc[raw_df["path"] == requested_filepath].loc[ + raw_df["page"] == requested_page + ] + if len(raw_row) <= 0: + raw_row = raw_df.loc[raw_df["path"] == requested_filepath].loc[ + raw_df["page"] == -1 + ] + if len(raw_row) <= 0: + return jsonify({"error": "No matching document found"}), 404 + + result_dict = raw_row.iloc[0].to_dict() + + return jsonify(result_dict), 200 + + @app.route("/projects//chunk", methods=["GET"]) @project_exists(WORK_DIR) async def get_chunk_documents(project_id): @@ -461,6 +496,7 @@ async def parse_documents_endpoint(project_id): config = data["config"] target_extension = data["extension"] parse_name = data["name"] + all_files: bool = data.get("all_files", True) parse_dir = os.path.join(WORK_DIR, project_id, "parse") @@ -472,6 +508,7 @@ async def parse_documents_endpoint(project_id): config_str=yaml.dump(config), parse_name=parse_name, glob_path=f"*.{target_extension}", + all_files=all_files, ) task_id = task.id return jsonify({"task_id": task_id, "status": "started"}) diff --git a/api/src/run.py b/api/src/run.py index 5b3949bdf..ec31a8895 100644 --- a/api/src/run.py +++ b/api/src/run.py @@ -13,13 +13,13 @@ from src.schema import QACreationRequest -def run_parser_start_parsing(data_path_glob, project_dir, yaml_path): +def run_parser_start_parsing(data_path_glob, project_dir, yaml_path, all_files: bool): # Import Parser here if it's defined in another module parser = Parser(data_path_glob=data_path_glob, project_dir=project_dir) print( f"Parser started with data_path_glob: {data_path_glob}, project_dir: {project_dir}, yaml_path: {yaml_path}" ) - parser.start_parsing(yaml_path, all_files=True) + parser.start_parsing(yaml_path, all_files=all_files) print("Parser completed") diff --git a/api/tasks/trial_tasks.py b/api/tasks/trial_tasks.py index b7cf0e94f..e93f32184 100644 --- a/api/tasks/trial_tasks.py +++ b/api/tasks/trial_tasks.py @@ -203,7 +203,12 @@ def generate_qa_documents(self, project_id: str, request_data: Dict[str, Any]): @shared_task(bind=True, base=TrialTask) def parse_documents( - self, project_id: str, config_str: str, parse_name: str, glob_path: str = "*.*" + self, + project_id: str, + config_str: str, + parse_name: str, + glob_path: str = "*.*", + all_files: bool = True, ): load_dotenv(ENV_FILEPATH) try: @@ -247,7 +252,9 @@ def parse_documents( with open(yaml_path, "w", encoding="utf-8") as f: yaml.safe_dump(config_dict, f, allow_unicode=True) - result = run_parser_start_parsing(raw_data_path, parsed_data_path, yaml_path) + result = run_parser_start_parsing( + raw_data_path, parsed_data_path, yaml_path, all_files + ) self.update_state_and_db( trial_id="", From dc5432f34a197069fc170aa5387cd5ce2dd6add6 Mon Sep 17 00:00:00 2001 From: "Jeffrey (Dongkyu) Kim" Date: Mon, 9 Dec 2024 14:22:20 +0900 Subject: [PATCH 52/55] update AutoRAG version to 0.3.11rc3 --- api/requirements.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/requirements.txt b/api/requirements.txt index e31612577..6d32c08b4 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -1,5 +1,5 @@ -AutoRAG[parse]==0.3.11rc2 -AutoRAG[ko]==0.3.11rc2 +AutoRAG[parse]==0.3.11rc3 +AutoRAG[ko]==0.3.11rc3 pydantic<2.10.0 jwt quart-cors From f34b7d990be04f1e00cf09fc45d33ba6766c3c32 Mon Sep 17 00:00:00 2001 From: "Jeffrey (Dongkyu) Kim" Date: Mon, 9 Dec 2024 15:26:40 +0900 Subject: [PATCH 53/55] update AutoRAG version to 0.3.12 --- api/app.py | 3 ++- api/requirements.txt | 4 ++-- api/tasks/trial_tasks.py | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/api/app.py b/api/app.py index 1ef599081..5bb88d3a8 100644 --- a/api/app.py +++ b/api/app.py @@ -422,7 +422,8 @@ async def get_parse_documents(project_id): async def get_parsed_file(project_id: str, parsed_name: str): parsed_folder = os.path.join(WORK_DIR, project_id, "parse") raw_df = pd.read_parquet( - os.path.join(parsed_folder, parsed_name, "0.parquet"), engine="pyarrow" + os.path.join(parsed_folder, parsed_name, "parsed_result.parquet"), + engine="pyarrow", ) requested_filename = request.args.get("filename", type=str) requested_page = request.args.get("page", -1, type=int) diff --git a/api/requirements.txt b/api/requirements.txt index 6d32c08b4..9b123b856 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -1,5 +1,5 @@ -AutoRAG[parse]==0.3.11rc3 -AutoRAG[ko]==0.3.11rc3 +AutoRAG[parse]==0.3.12 +AutoRAG[ko]==0.3.12 pydantic<2.10.0 jwt quart-cors diff --git a/api/tasks/trial_tasks.py b/api/tasks/trial_tasks.py index e93f32184..fc1a054cd 100644 --- a/api/tasks/trial_tasks.py +++ b/api/tasks/trial_tasks.py @@ -67,7 +67,7 @@ def chunk_documents( """ load_dotenv(ENV_FILEPATH) parsed_data_path = os.path.join( - WORK_DIR, project_id, "parse", parse_name, "0.parquet" + WORK_DIR, project_id, "parse", parse_name, "parsed_result.parquet" ) if not os.path.exists(parsed_data_path): raise ValueError(f"parsed_data_path does not exist: {parsed_data_path}") From 91cdc6c4ce72966b9656d2055aab26bd97273611 Mon Sep 17 00:00:00 2001 From: "Jeffrey (Dongkyu) Kim" Date: Mon, 9 Dec 2024 15:49:23 +0900 Subject: [PATCH 54/55] update autorag frontend to the latest --- autorag-frontend | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/autorag-frontend b/autorag-frontend index c9c356c1b..5380d13f5 160000 --- a/autorag-frontend +++ b/autorag-frontend @@ -1 +1 @@ -Subproject commit c9c356c1b8867a16c82cd2f3c5fd46b5ae6484e1 +Subproject commit 5380d13f5579e968f602b43ec95ea71d82cdccf6 From 27d9bdb0814665d6b1a32c2593a258e1e05f3ece Mon Sep 17 00:00:00 2001 From: "Jeffrey (Dongkyu) Kim" Date: Tue, 17 Dec 2024 21:18:41 +0900 Subject: [PATCH 55/55] Enable the file extensions (data, html, etc.) (#1053) * change to the dynamic root directory * enable uploading html and data file extensions --- api/app.py | 5 ++++- api/tasks/trial_tasks.py | 3 ++- tests/api/test_app.py | 11 ++++++----- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/api/app.py b/api/app.py index 5bb88d3a8..c524bd5d8 100644 --- a/api/app.py +++ b/api/app.py @@ -24,6 +24,7 @@ from quart import jsonify, request, make_response, send_file from quart_cors import cors # Import quart_cors to enable CORS from quart_uploads import UploadSet, configure_uploads +from quart_uploads.file_ext import FileExtensions as fe from database.project_db import SQLiteProjectDB # 올바른 임포트로 변경 from src.auth import require_auth @@ -359,7 +360,9 @@ async def delete_trial(project_id: str, trial_id: str): async def upload_files(project_id: str): # Setting upload raw_data_path = os.path.join(WORK_DIR, project_id, "raw_data") - files = UploadSet() + files = UploadSet( + extensions=fe.Text + fe.Documents + fe.Data + fe.Scripts + ("html",) + ) files.default_dest = raw_data_path configure_uploads(app, files) # List to hold paths of uploaded files diff --git a/api/tasks/trial_tasks.py b/api/tasks/trial_tasks.py index fc1a054cd..88651a524 100644 --- a/api/tasks/trial_tasks.py +++ b/api/tasks/trial_tasks.py @@ -1,4 +1,5 @@ import os +import pathlib import shutil import tempfile from typing import Dict, Any @@ -33,7 +34,7 @@ datefmt="%Y-%m-%d %H:%M:%S", ) logger = logging.getLogger(__name__) -ROOT_DIR = "/app" +ROOT_DIR = pathlib.PurePath(os.path.dirname(os.path.realpath(__file__))).parent.parent ENV = os.getenv("AUTORAG_API_ENV", "dev") # WORK_DIR 설정 diff --git a/tests/api/test_app.py b/tests/api/test_app.py index 9e08127bc..dd0dc0a19 100644 --- a/tests/api/test_app.py +++ b/tests/api/test_app.py @@ -8,11 +8,12 @@ import yaml from app import app, WORK_DIR +from database.project_db import SQLiteProjectDB from src.schema import TrialConfig, Trial, Status -from src.trial_config import SQLiteTrialDB tests_dir = os.path.dirname(os.path.realpath(__file__)) root_dir = pathlib.PurePath(tests_dir).parent +resources_dir = os.path.join(root_dir, "resources") @pytest.fixture @@ -146,7 +147,7 @@ async def test_get_trial_lists(get_trial_list_client): status="not_started", created_at=datetime.now(), ) - trial_config_db = SQLiteTrialDB(trial_config_path) + trial_config_db = SQLiteProjectDB(trial_config_path) trial_config_db.set_trial(trial) response = await get_trial_list_client.get(f"/projects/{project_id}/trials") @@ -191,7 +192,7 @@ async def test_create_new_trial(create_new_trial_client): assert "id" in data # Verify the trial was added to the CSV - trial_config_db = SQLiteTrialDB(trial_config_path) + trial_config_db = SQLiteProjectDB(trial_config_path) trial_ids = trial_config_db.get_all_config_ids() assert len(trial_ids) == 1 assert trial_ids[0] == data["id"] @@ -216,7 +217,7 @@ async def test_get_trial_config(trial_config_client): assert response.status_code == 201 os.makedirs(os.path.join(WORK_DIR, project_id), exist_ok=True) trial_config_path = os.path.join(WORK_DIR, project_id, "trials.db") - trial_config_db = SQLiteTrialDB(trial_config_path) + trial_config_db = SQLiteProjectDB(trial_config_path) trial_config = TrialConfig( trial_id=trial_id, project_id=project_id, @@ -264,7 +265,7 @@ async def test_set_trial_config(trial_config_client): os.makedirs(os.path.join(WORK_DIR, project_id), exist_ok=True) trial_config_path = os.path.join(WORK_DIR, project_id, "trials.db") - trial_config_db = SQLiteTrialDB(trial_config_path) + trial_config_db = SQLiteProjectDB(trial_config_path) trial_config = TrialConfig( trial_id=trial_id, project_id=project_id,