diff --git a/.env.template b/.env.template index 4271213..ad70c30 100644 --- a/.env.template +++ b/.env.template @@ -13,3 +13,6 @@ CSV_LOADER_DATA_DIR_PATH="./data" # Qdrant Settings QDRANT_URL="http://localhost:6333" + +# Elasticsearch Settings +ELASTICSEARCH_URL="http://localhost:9200" diff --git a/data/docs_kabuto.md b/data/docs_kabuto.md new file mode 100644 index 0000000..00fef29 --- /dev/null +++ b/data/docs_kabuto.md @@ -0,0 +1,71 @@ +# KABUTOシステム仕様書 + +## 第1章 KABUTOシステム概要 + +本仕様書は、独自技術に基づき開発された高次元演算処理システム「KABUTO」のシステム構成、動作原理、および特殊機能について記述する。KABUTOは、従来のコンピューティングパラダイムとは一線を画す、生体情報および精神波形との同期を基盤としたシステムであり、その動作は多岐にわたる特殊なプロトコルによって制御される。システムの各モジュールは、固有の名称と機能を持ち、相互に複雑な作用を及ぼし合う。 + +--- + +## 第2章 ハードウェア・インターフェース + +### 2.1 電源・起動システム + +KABUTOの電源ランプは、通常の動作状態を以下のパターンで表示する。待機状態では赤色点滅、稼働状態では青色点滅が繰り返される。このパターンにない緑色の点滅は、システムが「**禅モード**」に移行していることを示す。禅モードでは、パフォーマンスが最小限に抑えられ、バックグラウンドでのシステム自己修復が実行される。このモードからの強制離脱は、KABUTO本体に設置された「**龍玉**」ボタンを5秒以上長押しすることで可能となる。また、電源OFF後も微細な電流が流れ続ける「**夢の海**」と呼ばれる待機モードが存在し、これにより次回の起動高速化が図られる。この際、本体から微かな波の音が聞こえるが、これは正常な動作である。 + +### 2.2 映像出力システム + +KABUTOの映像出力モジュール「**玉手箱**」は、接続されたディスプレイの固有情報(「魂」)を認識し、適切なカラー表示を行う。この認識に失敗した場合、画面は白黒表示となる。この事象は、接続ケーブルの抜き差し、または特殊な両端同形状ケーブルの接続向きを逆にして再接続することで解消される場合がある。 + +### 2.3 冷却システム + +システム冷却は、特殊な流体冷却機構「**風魔の術**」によって制御される。特定の演算処理を伴うアプリケーション「鬼灯」の実行中は、冷却システムが一時的に停止し、無音となる。これは内部エネルギーの流れを最適化するための仕様であり、異常ではない。ただし、この無音状態が15分以上継続する場合は、過熱の可能性があるため、アプリケーションの強制終了およびシステムの再起動が推奨される。 + +--- + +## 第3章 ソフトウェア・オペレーション + +### 3.1 起動プロトコル + +KABUTOの起動シーケンスは、「**シノビ・プロトコル**」に基づき実行される。このプロトコルの初期化フェーズで、内部クロックと接続された外部周辺機器のクロックとの同期に失敗した場合、画面全体が紫色に点滅し、システムがフリーズする。この問題は、KABUTO本体の再起動前に、すべての周辺機器を5秒以上電源から抜くことで、外部クロックのキャッシュがクリアされ、同期が正常に行われる可能性が高まる。 + +### 3.2 セキュリティ・認証システム + +ログイン認証は、パスワード入力に加え、セキュリティモジュール「**百鬼夜行**」によるログイン試行者の「**精神波形**」解析を伴う。パスワードが正しく入力された場合でも、精神波形が登録されたユーザーと一致しないと判断された場合、「**虚無**」エラーが表示され、ログインは拒否される。これは疲労やストレスによる精神状態の変化が原因である可能性があり、深呼吸による精神状態の安定化が解決策となる。 + +### 3.3 ネットワーク・通信 + +ネットワークモジュール「**隠れ蓑**」は、外部からの不必要なスキャンを回避するため、デフォルトでステルスモードで動作する。これにより、ネットワーク上の他のデバイスから認識されない状態となる。KABUTOをネットワーク上で可視化させるには、ネットワーク設定画面から「**忍びの道**」オプションを有効にする必要がある。 + +### 3.4 演算コア + +KABUTOの演算コア「**稲荷**」は、膨大な演算処理能力を持つ。しかし、システム負荷が極端に高い場合、演算結果の一部が視覚的なノイズとして画面の隅に不規則な光の点(「**狐火**」)として現れることがある。これはシステム負荷の過大を示す兆候であり、不要なバックグラウンドアプリケーションを終了させるか、システムを再起動することで解消される。 + +--- + +## 第4章 ユーザー・インターフェース機能 + +### 4.1 ファイルシステム + +ファイルシステム「**竜宮城**」は、特定の文字を「**時空の歪み**」として認識し、文字コードの誤読が発生することがある。これにより、漢字で表示されている特定のフォルダ名が、ひらがなとして読み取られる現象が発生する。この問題を解消するには、システム設定にて「**読み方優先モード**」を一時的に無効化する必要がある。 + +### 4.2 入力・AIアシスタント + +内蔵AIアシスタント「**ヤタガラス**」は、ユーザーの入力内容を予測し、システムの基幹言語である「**KABUTO語**」に最適化しようとする機能を持つ。これにより、特定の文字が自動的にカタカナに変換される現象が発生する。この機能を一時的に停止するには、入力中に`Ctrl`キーと`Shift`キーを同時に押しながら「KABUTO」と入力する。 + +### 4.3 スクリーンショット機能 + +スクリーンショット機能「**万華鏡**」は、ユーザーの「**視点**」を認識し、見ている領域のみを円形に切り取る「**集中モード**」を自動で適用する場合がある。画面全体を保存したい場合は、スクリーンショット実行時にマウスカーソルを画面の隅に移動させてから操作する必要がある。 + +### 4.4 映像処理フィルター + +映像処理モジュール「**葛飾北斎**」は、動画形式を自動で判断し、独自の「**浮世絵**」フィルターを適用する場合がある。この機能が有効化されると、映像が「水墨画」のようにモノクロで、筆で描かれたような動きになる。この機能を無効にするには、動画再生中に右クリックメニューから「葛飾北斎」オプションをオフにする。 + +### 4.5 システム・パーソナライゼーション + +高度なAI機能「**天照**」は、ユーザーの無意識下での情報を収集し、デスクトップの背景画像をユーザーの最後に見た「夢」の内容に変更する機能を持つ。この機能を停止するには、システム設定内の「**天照の予知夢**」オプションを無効化する。ただし、この設定の無効化は、KABUTOの全体的なパフォーマンスの一部低下を招く可能性がある。 + +--- + +## 第5章 バグ・特殊現象 + +ごく稀に、システムクロックが過去の時間に一時的に戻ることで発生する「**天狗のいたずら**」と呼ばれる現象が発生する。この現象では、デスクトップ上のアイコンがすべて逆さまに表示される。この状態はパフォーマンスには影響しないが、龍玉ボタンを音量を最大にした状態で3回連続で押すことで、システムクロックが強制的に再設定され、現象が解消される。 diff --git a/data/docs_kabuto.pdf b/data/docs_kabuto.pdf new file mode 100644 index 0000000..a8abc7d Binary files /dev/null and b/data/docs_kabuto.pdf differ diff --git a/docker-compose.yml b/docker-compose.yml index 530f195..359ca95 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,3 +6,15 @@ services: - "6333:6333" # Dashboard: http://localhost:6333/dashboard volumes: - ./assets/qdrant_data:/qdrant/storage + elasticsearch: + build: + dockerfile: elasticsearch.Dockerfile + container_name: elasticsearch + ports: + - "9200:9200" + - "9300:9300" + environment: + - discovery.type=single-node + - xpack.security.enabled=false + volumes: + - ./assets/es_data:/usr/share/elasticsearch/data diff --git a/docs/index.md b/docs/index.md index 5076c17..ac9f46c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -3,6 +3,9 @@ ## Operations ```bash +# Start Docker containers +docker compose up -d + # Delete collection from Qdrant uv run python -m template_langgraph.tasks.delete_qdrant_collection @@ -12,8 +15,15 @@ uv run python -m template_langgraph.tasks.add_documents_to_qdrant # Search Qdrant uv run python -m template_langgraph.tasks.search_documents_on_qdrant +# Add documents to Elasticsearch +uv run python -m template_langgraph.tasks.add_documents_to_elasticsearch + +# Search Elasticsearch +uv run python -m template_langgraph.tasks.search_documents_on_elasticsearch + # Run Kabuto Helpdesk Agent uv run python -m template_langgraph.tasks.run_kabuto_helpdesk_agent "KABUTOの起動時に、画面全体が紫色に点滅し、システムがフリーズします。" +uv run python -m template_langgraph.tasks.run_kabuto_helpdesk_agent "KABUTOのマニュアルから禅モードに関する情報を教えて下さい" ``` ## References diff --git a/elasticsearch.Dockerfile b/elasticsearch.Dockerfile new file mode 100644 index 0000000..508e39f --- /dev/null +++ b/elasticsearch.Dockerfile @@ -0,0 +1,4 @@ +FROM docker.elastic.co/elasticsearch/elasticsearch:9.1.0 + +RUN bin/elasticsearch-plugin install analysis-kuromoji +RUN bin/elasticsearch-plugin install analysis-icu diff --git a/pyproject.toml b/pyproject.toml index 185cc78..ce53f6d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,12 +5,14 @@ description = "A GitHub template repository for Python" readme = "README.md" requires-python = ">=3.10" dependencies = [ + "elasticsearch>=9.1.0", "langchain-community>=0.3.27", "langchain-openai>=0.3.28", "langchain-text-splitters>=0.3.9", "langgraph>=0.6.2", "openai>=1.98.0", "pydantic-settings>=2.9.1", + "pypdf>=5.9.0", "python-dotenv>=1.1.0", "qdrant-client>=1.15.1", "typer>=0.16.0", diff --git a/template_langgraph/agents/kabuto_helpdesk_agent.py b/template_langgraph/agents/kabuto_helpdesk_agent.py index b985da8..2ee05c0 100644 --- a/template_langgraph/agents/kabuto_helpdesk_agent.py +++ b/template_langgraph/agents/kabuto_helpdesk_agent.py @@ -2,6 +2,7 @@ from template_langgraph.llms.azure_openais import AzureOpenAiWrapper from template_langgraph.loggers import get_logger +from template_langgraph.tools.elasticsearch_tool import search_elasticsearch from template_langgraph.tools.qdrants import search_qdrant logger = get_logger(__name__) @@ -10,7 +11,12 @@ class KabutoHelpdeskAgent: def __init__(self, tools=None): if tools is None: - tools = [search_qdrant] # Default tool for searching Qdrant + # Default tool for searching Qdrant + tools = [ + search_qdrant, + search_elasticsearch, + # Add other tools as needed + ] self.agent = create_react_agent( model=AzureOpenAiWrapper().chat_model, tools=tools, diff --git a/template_langgraph/tasks/add_documents_to_elasticsearch.py b/template_langgraph/tasks/add_documents_to_elasticsearch.py new file mode 100644 index 0000000..6c762f1 --- /dev/null +++ b/template_langgraph/tasks/add_documents_to_elasticsearch.py @@ -0,0 +1,35 @@ +import logging + +from template_langgraph.loggers import get_logger +from template_langgraph.tools.elasticsearch_tool import ElasticsearchClientWrapper +from template_langgraph.tools.pdf_loaders import PdfLoaderWrapper + +logger = get_logger(__name__) +logger.setLevel(logging.DEBUG) +COLLECTION_NAME = "docs_kabuto" + +if __name__ == "__main__": + # Create Elasticsearch index + es = ElasticsearchClientWrapper() + logger.info(f"Creating Elasticsearch index: {COLLECTION_NAME}") + result = es.create_index( + index_name=COLLECTION_NAME, + ) + if result: + logger.info(f"Created Elasticsearch index: {COLLECTION_NAME}") + else: + logger.warning(f"Index {COLLECTION_NAME} already exists.") + + # Load documents from PDF files + documents = PdfLoaderWrapper().load_pdf_docs() + logger.info(f"Loaded {len(documents)} documents from PDF.") + + # Add documents to Elasticsearch index + result = es.add_documents( + index_name=COLLECTION_NAME, + documents=documents, + ) + if result: + logger.info(f"Added {len(documents)} documents to Elasticsearch index: {COLLECTION_NAME}") + else: + logger.error(f"Failed to add documents to Elasticsearch index: {COLLECTION_NAME}") diff --git a/template_langgraph/tasks/search_documents_on_elasticsearch.py b/template_langgraph/tasks/search_documents_on_elasticsearch.py new file mode 100644 index 0000000..392f166 --- /dev/null +++ b/template_langgraph/tasks/search_documents_on_elasticsearch.py @@ -0,0 +1,23 @@ +import logging + +from template_langgraph.loggers import get_logger +from template_langgraph.tools.elasticsearch_tool import ElasticsearchClientWrapper + +logger = get_logger(__name__) +logger.setLevel(logging.INFO) +COLLECTION_NAME = "docs_kabuto" + +if __name__ == "__main__": + query = "禅モード" + es = ElasticsearchClientWrapper() + + results = es.search( + index_name=COLLECTION_NAME, + query=query, + ) + logger.info(f"Found {len(results)} results for the question: {query}") + for i, result in enumerate(results, start=1): + logger.info(f"Result {i}:") + logger.info(f"File Name: {result.metadata['source']}") + logger.info(f"Content: {result.page_content}") + logger.info("-" * 40) diff --git a/template_langgraph/tools/elasticsearch_tool.py b/template_langgraph/tools/elasticsearch_tool.py new file mode 100644 index 0000000..0691ba3 --- /dev/null +++ b/template_langgraph/tools/elasticsearch_tool.py @@ -0,0 +1,172 @@ +import os +from functools import lru_cache + +from elasticsearch import Elasticsearch, helpers +from langchain.tools import tool +from langchain_core.documents import Document +from pydantic import BaseModel, Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + elasticsearch_url: str = "http://localhost:9200" + + model_config = SettingsConfigDict( + env_file=".env", + env_ignore_empty=True, + extra="ignore", + ) + + +@lru_cache +def get_elasticsearch_settings() -> Settings: + """Get Elasticsearch settings.""" + return Settings() + + +class ElasticsearchClientWrapper: + def __init__( + self, + settings: Settings = None, + ): + if settings is None: + settings = get_elasticsearch_settings() + print(f"Elasticsearch URL: {settings.elasticsearch_url}") + self.client = Elasticsearch( + settings.elasticsearch_url, + ) + self.mapping = { + # ドキュメントのマッピング設定を定義 + "mappings": { + # ドキュメント内の各フィールドのプロパティを定義 + "properties": { + # 'content' フィールドを定義 + "content": { + # 'content' は全文検索用のフィールド + "type": "text", # テキスト検索用のフィールド + # 日本語用のカスタムアナライザー 'kuromoji_analyzer' を使用 + "analyzer": "kuromoji_analyzer", # 日本語用のアナライザーを指定 + } + }, + }, + # インデックスの設定(アナライザーなど)を定義 + "settings": { + # インデックスの分析設定 + "analysis": { + # 使用するアナライザーを定義 + "analyzer": { + # 'kuromoji_analyzer' というカスタムアナライザーを定義 + "kuromoji_analyzer": { + # カスタムアナライザーであることを指定 + "type": "custom", + # ICU正規化(文字の正規化処理)を適用 + "char_filter": ["icu_normalizer"], + # Kuromojiトークナイザー(形態素解析用)を使用 + "tokenizer": "kuromoji_tokenizer", + # トークンに対するフィルタのリストを定義 + "filter": [ + # 動詞や形容詞の基本形に変換 + "kuromoji_baseform", + # 品詞に基づいたフィルタリング + "kuromoji_part_of_speech", + # 日本語のストップワード(不要な単語)を除去 + "ja_stop", + # 数字の正規化を行う + "kuromoji_number", + # 日本語の語幹(ルート形)を抽出 + "kuromoji_stemmer", + ], + } + } + } + }, + } + + def create_index(self, index_name: str) -> bool: + """Create an index in Elasticsearch.""" + if not self.client.indices.exists(index=index_name): + result = self.client.indices.create(index=index_name, body=self.mapping) + if result: + return True + return False + + def add_documents( + self, + index_name: str, + documents: list[Document], + ) -> bool: + """Add documents to an Elasticsearch index.""" + actions = [ + { + "_index": index_name, + "_source": { + "filename": os.path.basename(doc.metadata.get("source", "unknown")), + "content": doc.page_content, + }, + } + for doc in documents + ] + success, _ = helpers.bulk(self.client, actions) + return success > 0 + + def search( + self, + index_name: str, + query: str, + max_results: int = 10, + ) -> list[Document]: + """Search documents in an Elasticsearch index.""" + search_query = { + "query": { + "match": { + "content": query, + } + }, + "size": max_results, + } + response = self.client.search( + index=index_name, + body=search_query, + ) + return [ + Document( + page_content=hit["_source"]["content"], + metadata={ + "source": hit["_source"]["filename"], + }, + ) + for hit in response["hits"]["hits"] + ] + + +class ElasticsearchInput(BaseModel): + keywords: str = Field(description="Keywords to search") + + +class ElasticsearchOutput(BaseModel): + file_name: str = Field(description="The file name") + content: str = Field(description="The content of the file") + + +@tool(args_schema=ElasticsearchInput) +def search_elasticsearch( + keywords: str, +) -> list[ElasticsearchOutput]: + """ + 空想上のシステム「KABUTO」のマニュアルから、関連する情報を取得します。 + """ + wrapper = ElasticsearchClientWrapper() + results = wrapper.search( + index_name="docs_kabuto", + query=keywords, + max_results=3, + ) + outputs = [] + for result in results: + outputs.append( + ElasticsearchOutput( + file_name=result.metadata["source"], + content=result.page_content, + ), + ) + return outputs diff --git a/template_langgraph/tools/pdf_loaders.py b/template_langgraph/tools/pdf_loaders.py new file mode 100644 index 0000000..1c56aa2 --- /dev/null +++ b/template_langgraph/tools/pdf_loaders.py @@ -0,0 +1,55 @@ +import os +from functools import lru_cache +from glob import glob + +from langchain_community.document_loaders import PyPDFLoader +from langchain_core.documents import Document +from langchain_text_splitters import RecursiveCharacterTextSplitter +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + pdf_loader_data_dir_path: str = "./data" + + model_config = SettingsConfigDict( + env_file=".env", + env_ignore_empty=True, + extra="ignore", + ) + + +@lru_cache +def get_pdf_loader_settings() -> Settings: + """Get pdf loader settings.""" + return Settings() + + +class PdfLoaderWrapper: + def __init__( + self, + settings: Settings = None, + ): + if settings is None: + settings = get_pdf_loader_settings() + self.settings = settings + + def load_pdf_docs(self) -> list[Document]: + """Load pdf documents from the specified directory.""" + pdf_path = glob( + os.path.join(self.settings.pdf_loader_data_dir_path, "**", "*.pdf"), + recursive=True, + ) + docs = [] + + text_splitter = RecursiveCharacterTextSplitter( + chunk_size=300, + chunk_overlap=20, + length_function=len, + is_separator_regex=False, + ) + for path in pdf_path: + loader = PyPDFLoader(path) + pages = loader.load_and_split(text_splitter) + docs.extend(pages) + + return docs diff --git a/uv.lock b/uv.lock index d931ab7..c260758 100644 --- a/uv.lock +++ b/uv.lock @@ -664,6 +664,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, ] +[[package]] +name = "elastic-transport" +version = "9.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/1f/2d1a1790df2b75e1e1eb90d8a3fe066a47ef95e34430657447e549cc274c/elastic_transport-9.1.0.tar.gz", hash = "sha256:1590e44a25b0fe208107d5e8d7dea15c070525f3ac9baafbe4cb659cd14f073d", size = 76483, upload-time = "2025-07-24T16:41:31.017Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/5d/dd5a919dd887fe20a91f18faf5b4345ee3a058e483d2aa84cef0f2567e17/elastic_transport-9.1.0-py3-none-any.whl", hash = "sha256:369fa56874c74daae4ea10cbf40636d139f38f42bec0e006b9cd45a168ee7fce", size = 65142, upload-time = "2025-07-24T16:41:29.648Z" }, +] + +[[package]] +name = "elasticsearch" +version = "9.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "elastic-transport" }, + { name = "python-dateutil" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/1e/22097cdcdfcdd3bdb417e339d97e7cdb4618f480a0abb8410065d0b08415/elasticsearch-9.1.0.tar.gz", hash = "sha256:764d2f724eac94f6bf9903e4feae07112643a9efcfdc5c868c1b69bd48c52e09", size = 848897, upload-time = "2025-07-30T08:54:52.253Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/90/922dcce6273efe7663ad10ed01f122cd09ecf1845855f4b27741c432920f/elasticsearch-9.1.0-py3-none-any.whl", hash = "sha256:96bb473dc70dbd94c37c4b05a0d2511af95d1b2db1657796f42546ef631cbbe4", size = 929547, upload-time = "2025-07-30T08:54:47.949Z" }, +] + [[package]] name = "exceptiongroup" version = "1.3.0" @@ -2722,6 +2749,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e4/06/43084e6cbd4b3bc0e80f6be743b2e79fbc6eed8de9ad8c629939fa55d972/pymdown_extensions-10.16.1-py3-none-any.whl", hash = "sha256:d6ba157a6c03146a7fb122b2b9a121300056384eafeec9c9f9e584adfdb2a32d", size = 266178, upload-time = "2025-07-28T16:19:31.401Z" }, ] +[[package]] +name = "pypdf" +version = "5.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/89/3a/584b97a228950ed85aec97c811c68473d9b8d149e6a8c155668287cf1a28/pypdf-5.9.0.tar.gz", hash = "sha256:30f67a614d558e495e1fbb157ba58c1de91ffc1718f5e0dfeb82a029233890a1", size = 5035118, upload-time = "2025-07-27T14:04:52.364Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/d9/6cff57c80a6963e7dd183bf09e9f21604a77716644b1e580e97b259f7612/pypdf-5.9.0-py3-none-any.whl", hash = "sha256:be10a4c54202f46d9daceaa8788be07aa8cd5ea8c25c529c50dd509206382c35", size = 313193, upload-time = "2025-07-27T14:04:50.53Z" }, +] + [[package]] name = "pytest" version = "8.4.1" @@ -3389,12 +3428,14 @@ name = "template-langgraph" version = "0.0.1" source = { editable = "." } dependencies = [ + { name = "elasticsearch" }, { name = "langchain-community" }, { name = "langchain-openai" }, { name = "langchain-text-splitters" }, { name = "langgraph" }, { name = "openai" }, { name = "pydantic-settings" }, + { name = "pypdf" }, { name = "python-dotenv" }, { name = "qdrant-client" }, { name = "typer" }, @@ -3416,12 +3457,14 @@ docs = [ [package.metadata] requires-dist = [ + { name = "elasticsearch", specifier = ">=9.1.0" }, { name = "langchain-community", specifier = ">=0.3.27" }, { name = "langchain-openai", specifier = ">=0.3.28" }, { name = "langchain-text-splitters", specifier = ">=0.3.9" }, { name = "langgraph", specifier = ">=0.6.2" }, { name = "openai", specifier = ">=1.98.0" }, { name = "pydantic-settings", specifier = ">=2.9.1" }, + { name = "pypdf", specifier = ">=5.9.0" }, { name = "python-dotenv", specifier = ">=1.1.0" }, { name = "qdrant-client", specifier = ">=1.15.1" }, { name = "typer", specifier = ">=0.16.0" },