diff --git a/.gitattributes b/.gitattributes index a6344aac8c09253b3b630fb776ae94478aa0275b..deb2a4c08dc14682b243cfa240c456762247c0bd 100644 --- a/.gitattributes +++ b/.gitattributes @@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text *.zip filter=lfs diff=lfs merge=lfs -text *.zst filter=lfs diff=lfs merge=lfs -text *tfevents* filter=lfs diff=lfs merge=lfs -text +data/vector_store/chroma.sqlite3 filter=lfs diff=lfs merge=lfs -text diff --git a/README.md b/README.md index f2dff62b7bb31c92f883888c1f7856d74441f2fd..5cd47bf24dc3a5d9921de89fdbf587dede0dcf33 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,105 @@ --- -title: EmpathemePotionBot -emoji: 🚀 -colorFrom: red -colorTo: red -sdk: docker -app_port: 8501 -tags: -- streamlit +title: EmpathemeBot +emoji: 🤖 +colorFrom: indigo +colorTo: purple +sdk: streamlit +sdk_version: 1.32.0 +app_file: app.py pinned: false -short_description: Streamlit template space +license: mit --- -# Welcome to Streamlit! +# 🤖 EmpathemeBot -Edit `/src/streamlit_app.py` to customize this app to your heart's desire. :heart: +**KurageSan®による英語学習サポートAIチャットボット** -If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community -forums](https://discuss.streamlit.io). +## 概要 + +EmpathemeBotは、Potionベースの質問応答システムを搭載した英語学習サポートボットです。 +会話履歴を保持しながら、文脈に沿った質問応答を提供します。 + +## 機能 + +- 📚 **RAGベースの質問応答**: ベクトルストアを使用した高精度な回答 +- 💬 **会話履歴の保持**: セッション内で文脈を保持した対話 +- 🎨 **洗練されたUI**: LINE風の吹き出しスタイルのチャットインターフェース +- 🔑 **APIキー管理**: OpenAI APIキーをセキュアに管理 + +## 使い方 + +1. **APIキーの設定** + - 左上の「>」ボタンをクリックしてサイドバーを開く + - OpenAI APIキーを入力(`sk-...`形式) + - Enterキーを押して設定を保存 + +2. **質問を入力** + - 下部のチャット入力欄に質問を入力 + - Enterキーを押して送信 + +3. **新しいチャットを開始** + - サイドバーの「新しいチャット」ボタンをクリック + +## Hugging Face Spacesへのデプロイ + +このアプリケーションはHugging Face Spaces上で動作するように最適化されています。 + +### デプロイ手順 + +1. **Hugging Faceアカウントの作成** + - [Hugging Face](https://huggingface.co/)でアカウントを作成 + +2. **新しいSpaceの作成** + - Hugging Faceダッシュボードで「New Space」をクリック + - Space名を入力(例:`empathemebot`) + - SDKとして「Streamlit」を選択 + - Visibilityを選択(Public/Private) + +3. **ファイルのアップロード** + ``` + your-space/ + ├── app.py # メインアプリケーション + ├── requirements.txt # 依存関係(requirements_hf.txtの内容) + ├── README.md # このファイル + ├── src/ # ソースコード + │ ├── qa/ + │ │ ├── chain.py + │ │ └── prompt.py + │ ├── vector/ + │ │ └── ... + │ └── ... + └── data/ # ベクトルストア(オプション) + └── vector_store/ + ``` + +4. **環境変数の設定(オプション)** + - Settings → Repository secretsで`OPENAI_API_KEY`を設定 + - または、ユーザーが直接UIから入力 + +5. **デプロイの確認** + - 自動的にビルドが開始されます + - ビルドが完了すると、アプリケーションが利用可能になります + +## 技術スタック + +- **フロントエンド**: Streamlit +- **LLMフレームワーク**: LangChain +- **ベクトルDB**: ChromaDB +- **LLM**: OpenAI GPT-4 + +## ライセンス + +MIT License + +## 開発者 + +Empatheme開発チーム + +## サポート + +問題が発生した場合は、[Issues](https://github.com/your-username/empathemebot/issues)でお知らせください。 + +--- + +**Note**: このアプリケーションを使用するには、OpenAI APIキーが必要です。 +APIキーは[OpenAIのダッシュボード](https://platform.openai.com/api-keys)から取得できます。 diff --git a/app.py b/app.py new file mode 100644 index 0000000000000000000000000000000000000000..deab4dc1c09b7596bd91440fd0ebd1567d26dc71 --- /dev/null +++ b/app.py @@ -0,0 +1,563 @@ +""" +EmpathemeBot - Hugging Face Spaces用統合版Streamlitアプリ +""" + +import html +import logging +import re +import time +import uuid +from datetime import datetime +from typing import List, Dict, Optional +from pathlib import Path +import sys + +import streamlit as st +from dotenv import load_dotenv +import os + +# 環境変数の読み込み +load_dotenv() + +# srcディレクトリをパスに追加 +sys.path.append(str(Path(__file__).parent)) + +# QAチェーンのインポート +from src.qa.chain import QAChain + +# ロギング設定 +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +# 設定 +st.set_page_config( + page_title="EmpathemeBot QA System", + page_icon="🤖", + layout="wide", + initial_sidebar_state="collapsed", + menu_items={} +) + +class EmpathemeBotUI: + """Hugging Face Spaces用統合版EmpathemeBot UIクラス""" + + def __init__(self): + # セッション状態の初期化 + if 'session_id' not in st.session_state: + st.session_state.session_id = str(uuid.uuid4()) + if 'messages' not in st.session_state: + st.session_state.messages = [] + if 'qa_chain' not in st.session_state: + st.session_state.qa_chain = None + if 'api_key' not in st.session_state: + # Hugging Face Secretsから取得を試みる + st.session_state.api_key = os.getenv("OPENAI_API_KEY", "") + if 'last_activity' not in st.session_state: + st.session_state.last_activity = datetime.now() + if 'vector_store_initialized' not in st.session_state: + st.session_state.vector_store_initialized = False + + def initialize_qa_chain(self, api_key: str) -> bool: + """ + QAChainを初期化 + + Args: + api_key: OpenAI APIキー + + Returns: + 初期化成功の場合True + """ + try: + # 環境変数にAPIキーを設定 + os.environ["OPENAI_API_KEY"] = api_key + + # ベクトルストアのパスを確認 + vector_store_path = Path("data/vector_store") + + # QAChainの初期化 + if st.session_state.qa_chain is None: + logger.info("QAChainを初期化中...") + + # ベクトルストアが存在しない場合の処理 + if not vector_store_path.exists(): + st.warning("ベクトルストアが見つかりません。デモモードで実行します。") + # デモモード用の簡易実装 + st.session_state.qa_chain = self.create_demo_chain() + else: + st.session_state.qa_chain = QAChain( + persist_dir=str(vector_store_path), + verbose=False, + max_history_turns=10, + max_history_chars=10000 + ) + st.session_state.vector_store_initialized = True + logger.info("QAChain初期化完了") + return True + + except Exception as e: + logger.error(f"QAChain初期化エラー: {e}") + st.error(f"初期化エラー: {str(e)}") + return False + + return True + + def create_demo_chain(self): + """ + デモ用の簡易QAチェーンを作成(ベクトルストアなし) + """ + class DemoQAChain: + def __init__(self): + self.conversation_history = [] + + def ask_with_history(self, question: str): + # デモ用の簡単な応答 + self.conversation_history.append(f"Q: {question}") + + # OpenAI APIを直接使用して応答を生成 + try: + from langchain_openai import ChatOpenAI + from langchain.schema import HumanMessage, SystemMessage + + llm = ChatOpenAI( + model_name="gpt-4o-mini", + temperature=0.7 + ) + + messages = [ + SystemMessage(content="あなたは英語学習をサポートするKurageSan®という親切なアシスタントです。"), + HumanMessage(content=question) + ] + + response = llm.invoke(messages) + answer = response.content + + except Exception as e: + answer = f"申し訳ございません。現在デモモードで動作しており、詳細な回答ができません。エラー: {str(e)}" + + self.conversation_history.append(f"A: {answer}") + return answer, [] + + def clear_history(self): + self.conversation_history = [] + + def get_history(self): + return "\n".join(self.conversation_history) + + return DemoQAChain() + + def ask_question(self, question: str) -> Optional[Dict]: + """ + 質問を処理して回答を取得 + + Args: + question: ユーザーの質問 + + Returns: + 回答データ + """ + try: + if st.session_state.qa_chain is None: + st.error("システムが初期化されていません。") + return None + + # 質問処理 + logger.info(f"質問処理開始: {question[:100]}...") + answer, source_docs = st.session_state.qa_chain.ask_with_history(question) + + # ソースURLを抽出(重複除去) + source_urls = [] + for doc in source_docs: + url = doc.metadata.get('source_url', '') + if url and url not in source_urls: + source_urls.append(url) + + result = { + "answer": answer, + "source_count": len(source_docs), + "source_urls": source_urls + } + + logger.info(f"回答生成成功: {len(source_docs)}件のソース参照") + return result + + except Exception as e: + logger.error(f"エラー発生: {e}") + st.error(f"予期しないエラーが発生しました: {str(e)}") + return None + + def clear_history(self): + """会話履歴をクリア""" + try: + if st.session_state.qa_chain: + st.session_state.qa_chain.clear_history() + st.session_state.messages = [] + st.success("会話履歴をクリアしました") + logger.info("履歴クリア成功") + except Exception as e: + logger.error(f"履歴クリアエラー: {e}") + st.error("エラーが発生しました") + + def create_new_session(self): + """新しいセッションIDを生成""" + st.session_state.session_id = str(uuid.uuid4()) + st.session_state.messages = [] + if st.session_state.qa_chain: + st.session_state.qa_chain.clear_history() + logger.info(f"新しいセッション作成: {st.session_state.session_id}") + +def main(): + """メイン関数""" + + # カスタムCSS + st.markdown(""" + + """, unsafe_allow_html=True) + + # UIインスタンス作成 + bot = EmpathemeBotUI() + + # サイドバー設定 + with st.sidebar: + st.markdown("## 設定") + + # APIキー入力欄 + st.markdown("### OpenAI API キー") + api_key = st.text_input( + "APIキーを入力(必須)", + value=st.session_state.api_key, + type="password", + placeholder="sk-...", + help="OpenAI APIキーを入力してください。このフィールドは必須です。" + ) + + # APIキーが変更された場合、セッション状態を更新 + if api_key != st.session_state.api_key: + st.session_state.api_key = api_key + if api_key: + # QAChainを初期化 + if bot.initialize_qa_chain(api_key): + st.success("✅ APIキーが設定されました") + else: + st.error("❌ 初期化に失敗しました") + else: + st.warning("⚠️ APIキーが未入力です") + + st.markdown("---") + + # コントロールボタン + st.markdown("### コントロール") + if st.button("新しいチャット", use_container_width=True): + bot.create_new_session() + st.rerun() + + if st.button("履歴クリア", use_container_width=True): + bot.clear_history() + st.rerun() + + st.markdown("---") + + # ステータス + st.markdown("### ステータス") + if st.session_state.vector_store_initialized: + st.success("システム準備完了") + else: + st.info("システム待機中") + + # メインヘッダー + st.markdown( + """ +
+

🤖 EmpathemeBot

+

Potionベースの質問応答システム

+
+ """, + unsafe_allow_html=True + ) + + # APIキー未入力時の警告メッセージ + if not st.session_state.api_key: + st.markdown( + """ +
+

APIキーの入力が必要です

+

+ EmpathemeBotを使用するには、OpenAI APIキーが必要です。 +

+
    +
  1. 左上の「>」ボタンをクリックしてサイドバーを開く
  2. +
  3. 「OpenAI API キー」セクションにAPIキー(sk-...)を入力
  4. +
  5. Enterキーを押してAPIキーを設定
  6. +
+

+ APIキーは OpenAIのダッシュボード から取得できます。 +

+
+ """, + unsafe_allow_html=True + ) + st.stop() + + # APIキーがあるがQAChainが初期化されていない場合 + if st.session_state.api_key and st.session_state.qa_chain is None: + if bot.initialize_qa_chain(st.session_state.api_key): + st.rerun() + + # ウェルカムメッセージ(初回のみ) + if len(st.session_state.messages) == 0: + st.markdown( + """ +
+

こんにちは、KurageSan®だよ!何か英語学習に関して困っていることはありますか?

+
+ """, + unsafe_allow_html=True + ) + + # チャット履歴の表示 + for message in st.session_state.messages: + if message["role"] == "user": + # ユーザーメッセージ(右側) + st.markdown( + f""" +
+
+
{html.escape(message['content'])}
+
+ {message.get('timestamp', '')} +
+
+
+ """, + unsafe_allow_html=True + ) + else: + # アシスタントメッセージ(左側) + st.markdown( + f""" +
+
+
{html.escape(message['content'])}
+
+ {message.get('timestamp', '')} +
+
+
+ """, + unsafe_allow_html=True + ) + + # チャット入力 + if prompt := st.chat_input("質問を入力してください...", key="chat_input"): + # タイムスタンプを追加 + timestamp = datetime.now().strftime("%H:%M") + + # ユーザーメッセージを追加 + st.session_state.messages.append({ + "role": "user", + "content": prompt, + "timestamp": timestamp + }) + + # ユーザーメッセージを表示 + st.markdown( + f""" +
+
+
{html.escape(prompt)}
+
+ {timestamp} +
+
+
+ """, + unsafe_allow_html=True + ) + + # アシスタントの応答を生成 + with st.spinner("考えています..."): + response_timestamp = datetime.now().strftime("%H:%M") + response_data = bot.ask_question(prompt) + + if response_data: + answer = response_data["answer"] + + # メッセージ履歴に追加 + st.session_state.messages.append({ + "role": "assistant", + "content": answer, + "timestamp": response_timestamp, + "metadata": { + "source_count": response_data.get("source_count", 0) + } + }) + + # アシスタントメッセージを表示 + st.markdown( + f""" +
+
+
{html.escape(answer)}
+
+ {response_timestamp} +
+
+
+ """, + unsafe_allow_html=True + ) + else: + # エラーの場合 + error_message = "申し訳ございません。回答の生成に失敗しました。もう一度お試しください。" + + st.session_state.messages.append({ + "role": "assistant", + "content": error_message, + "timestamp": response_timestamp + }) + + # エラーメッセージを表示 + st.markdown( + f""" +
+
+
{html.escape(error_message)}
+
+ {response_timestamp} +
+
+
+ """, + unsafe_allow_html=True + ) + + # アクティビティを更新 + st.session_state.last_activity = datetime.now() + + # フッター + st.markdown( + f""" +
+ EmpathemeBot · セッション: {st.session_state.session_id[:8]} +
+ """, + unsafe_allow_html=True + ) + +if __name__ == "__main__": + main() diff --git a/data/.DS_Store b/data/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..8db360213a4eaf4007e8ee2ee4a4ef80b7c11ea7 Binary files /dev/null and b/data/.DS_Store differ diff --git a/data/vector_store/.gitkeep b/data/vector_store/.gitkeep new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/data/vector_store/5108300c-ddfb-4f8b-9c77-5f4004790ec3/data_level0.bin b/data/vector_store/5108300c-ddfb-4f8b-9c77-5f4004790ec3/data_level0.bin new file mode 100644 index 0000000000000000000000000000000000000000..aca4811feb9ddc1cd70456bdb9bdff8692f2c6fc --- /dev/null +++ b/data/vector_store/5108300c-ddfb-4f8b-9c77-5f4004790ec3/data_level0.bin @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bb76ae0f6ca830a9a048b0cf53962a78c88c5c7fcda63fc846077d3456eb3890 +size 62840000 diff --git a/data/vector_store/5108300c-ddfb-4f8b-9c77-5f4004790ec3/header.bin b/data/vector_store/5108300c-ddfb-4f8b-9c77-5f4004790ec3/header.bin new file mode 100644 index 0000000000000000000000000000000000000000..1b9d2e782a1941b2151a696323425d63b6bbfebf --- /dev/null +++ b/data/vector_store/5108300c-ddfb-4f8b-9c77-5f4004790ec3/header.bin @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ec666c9828420c69fc6b597461d8c18487becec1527c7d1cff9b898cbb393c2d +size 100 diff --git a/data/vector_store/5108300c-ddfb-4f8b-9c77-5f4004790ec3/length.bin b/data/vector_store/5108300c-ddfb-4f8b-9c77-5f4004790ec3/length.bin new file mode 100644 index 0000000000000000000000000000000000000000..4cf2d45966d321ca3fb35c3ff5656012e2613dfb --- /dev/null +++ b/data/vector_store/5108300c-ddfb-4f8b-9c77-5f4004790ec3/length.bin @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fbeb5cb0c36f6258be7779f5e16bfa212d9750a0183f6eaf196473ad5293babc +size 40000 diff --git a/data/vector_store/5108300c-ddfb-4f8b-9c77-5f4004790ec3/link_lists.bin b/data/vector_store/5108300c-ddfb-4f8b-9c77-5f4004790ec3/link_lists.bin new file mode 100644 index 0000000000000000000000000000000000000000..fc8e42b32efb9a9bf3ae0234a18a948e499490f8 --- /dev/null +++ b/data/vector_store/5108300c-ddfb-4f8b-9c77-5f4004790ec3/link_lists.bin @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 +size 0 diff --git a/data/vector_store/chroma.sqlite3 b/data/vector_store/chroma.sqlite3 new file mode 100644 index 0000000000000000000000000000000000000000..e0910c404a0dd1a9bf80481fffbe6cb6a89b97fe --- /dev/null +++ b/data/vector_store/chroma.sqlite3 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cabab722f170e66bfea7089f24a34969058525cb56bbcfbe7a8cbb63f23fb518 +size 5783552 diff --git a/requirements.txt b/requirements.txt index 28d994e22f8dd432b51df193562052e315ad95f7..d1d8e9d1b41a5ceb6852450ecfacbcc1b156301c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,21 @@ -altair -pandas -streamlit \ No newline at end of file +# Requirements for Hugging Face Spaces deployment +# FastAPI and uvicorn removed since we're using unified Streamlit app + +aiohttp>=3.9 +beautifulsoup4>=4.12 +chromadb>=0.5.0 +langchain==0.3.7 +langchain-chroma>=0.1.0 +langchain-community==0.3.5 +langchain-core +langchain-openai==0.2.5 +langchain-text-splitters>=0.0.1 +langgraph>0.2.27 +lxml>=5.2 +markdownify>=0.13 +numpy>=1.24.0 +python-dotenv +readability-lxml>=0.8 +requests>=2.31.0 +streamlit>=1.32.0 +tqdm>=4.66 diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/processing/__init__.py b/src/processing/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..ec6ecf3cc2b0b24d04f9b88d1162569a83c84fcc --- /dev/null +++ b/src/processing/__init__.py @@ -0,0 +1,20 @@ +"""RAG (Retrieval-Augmented Generation) System + +This module provides tools for building and managing RAG systems, +including document processing, metadata generation, and batch processing. +""" + +__version__ = "0.1.0" + +from src.processing.batch_processor import BatchProcessor +from src.processing.document_processor import DocumentProcessor +from src.processing.metadata_utils import generate_file_name, extract_title +from src.processing.scrape_to_metadata import ScrapeToMetadata + +__all__ = [ + "BatchProcessor", + "DocumentProcessor", + "ScrapeToMetadata", + "extract_title", + "generate_file_name", +] diff --git a/src/processing/__pycache__/__init__.cpython-312.pyc b/src/processing/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a02a4747995522b60f2c68fd1df6e5c8fb07c388 Binary files /dev/null and b/src/processing/__pycache__/__init__.cpython-312.pyc differ diff --git a/src/processing/__pycache__/batch_processor.cpython-312.pyc b/src/processing/__pycache__/batch_processor.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0d5284ca32b01e32705139641bbbd140c1c72254 Binary files /dev/null and b/src/processing/__pycache__/batch_processor.cpython-312.pyc differ diff --git a/src/processing/__pycache__/chunk_documents.cpython-312.pyc b/src/processing/__pycache__/chunk_documents.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ddc38b557101dbcbe7a7ba3d147d1a4f9f0e5b3c Binary files /dev/null and b/src/processing/__pycache__/chunk_documents.cpython-312.pyc differ diff --git a/src/processing/__pycache__/cli.cpython-312.pyc b/src/processing/__pycache__/cli.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..34f4586514b71349946791e67873fbbfd4480eb0 Binary files /dev/null and b/src/processing/__pycache__/cli.cpython-312.pyc differ diff --git a/src/processing/__pycache__/document_processor.cpython-312.pyc b/src/processing/__pycache__/document_processor.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2cd0aa0bbe47a3f6a796cf4257f8b946df02237d Binary files /dev/null and b/src/processing/__pycache__/document_processor.cpython-312.pyc differ diff --git a/src/processing/__pycache__/metadata_utils.cpython-312.pyc b/src/processing/__pycache__/metadata_utils.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..dc00e9f7cc91e91ea1d3de6ff5b174d0622bc8b2 Binary files /dev/null and b/src/processing/__pycache__/metadata_utils.cpython-312.pyc differ diff --git a/src/processing/__pycache__/scrape_to_metadata.cpython-312.pyc b/src/processing/__pycache__/scrape_to_metadata.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b3635a8aee5113d756a74c89094b8d313dbe6c22 Binary files /dev/null and b/src/processing/__pycache__/scrape_to_metadata.cpython-312.pyc differ diff --git a/src/processing/batch_processor.py b/src/processing/batch_processor.py new file mode 100644 index 0000000000000000000000000000000000000000..09424375163e0c69bc479e3c84f8e8274715ea01 --- /dev/null +++ b/src/processing/batch_processor.py @@ -0,0 +1,182 @@ +"""バッチ処理とプログレス管理""" + +import asyncio +import json +import logging +from pathlib import Path +from typing import List, Dict, Any, Optional + +from tqdm import tqdm + +from src.processing.document_processor import DocumentProcessor +from src.scraping.exceptions import ArticleNotFoundError, FetchError + +logger = logging.getLogger(__name__) + +class BatchProcessor: + """バッチ処理とプログレス管理""" + + def __init__(self, wait_time: float = 1.0): + """ + Args: + wait_time: リクエスト間の待機時間(秒) + """ + self.wait_time = wait_time + self.document_processor = DocumentProcessor() + + async def process_urls_batch( + self, + urls: List[str], + start_id: int = 1, + mode: str = "memory", + show_progress: bool = True, + save_dir: Optional[Path] = None, + verbose: bool = False + ) -> List[Dict[str, Any]]: + """ + 複数URLをバッチ処理してメタデータを生成 + + Args: + urls: 処理するURLのリスト + start_id: 開始ID + mode: "memory" または "save" + show_progress: プログレス表示の有無 + save_dir: saveモード時のMarkdownファイル保存先ディレクトリ + verbose: 詳細ログを表示するか + + Returns: + 生成されたドキュメントメタデータのリスト + """ + documents = [] + success_count = 0 + skip_count = 0 + fail_count = 0 + + total = len(urls) + end_id = start_id + total - 1 + + # saveモードの場合、保存先ディレクトリを設定 + if mode == "save": + if save_dir is None: + save_dir = Path("data/raw") + save_dir.mkdir(parents=True, exist_ok=True) + logger.info(f"保存先ディレクトリ: {save_dir}") + + logger.info(f"スクレイピング開始: ID {start_id} から {end_id} まで(計{total}件)") + logger.info(f"モード: {'メモリー保管' if mode == 'memory' else 'ファイル保存'}") + logger.info(f"待機時間: {self.wait_time}秒\n") + + # プログレスバーの作成(単一行で更新) + pbar = None + if show_progress: + pbar = tqdm( + total=total, + desc="処理中", + leave=True, + ncols=80, + bar_format='{desc}: {percentage:3.0f}%|{bar}| {n_fmt}/{total_fmt} [{elapsed}<{remaining}, {postfix}]' + ) + + try: + for i, url in enumerate(urls): + current_id = start_id + i + + try: + # saveモードの場合はsave_dirを渡す + if mode == "save": + document = await self.document_processor.process_url(url, current_id, save_dir) + else: + document = await self.document_processor.process_url(url, current_id) + + documents.append(document) + success_count += 1 + + # verboseモードの場合は詳細ログも表示 + if verbose: + # プログレスバーを一時的にクリアして詳細を表示 + if pbar: + pbar.clear() + if mode == "save": + logger.info(f" ✓ {url}: 保存完了 ({document['metadata']['file_name']})") + else: + logger.info(f" ✓ {url}: 処理完了 ({document['metadata']['file_name']})") + if pbar: + pbar.refresh() + + except ArticleNotFoundError: + skip_count += 1 + + # verboseモードの場合は詳細ログも表示 + if verbose: + if pbar: + pbar.clear() + logger.warning(f" ⊘ {url}: 記事が見つかりません") + if pbar: + pbar.refresh() + + except FetchError as e: + fail_count += 1 + + # verboseモードの場合は詳細ログも表示 + if verbose: + if pbar: + pbar.clear() + logger.error(f" ✗ {url}: 取得エラー: {str(e)}") + if pbar: + pbar.refresh() + + except Exception as e: + fail_count += 1 + + # verboseモードの場合は詳細ログも表示 + if verbose: + if pbar: + pbar.clear() + logger.error(f" ✗ {url}: エラー: {str(e)}") + if pbar: + pbar.refresh() + + # プログレスバーを更新 + if pbar: + pbar.set_postfix({ + '成功': success_count, + 'スキップ': skip_count, + '失敗': fail_count + }) + pbar.update(1) + + # 次のリクエストまで待機(最後のURLでは待機しない) + if i < len(urls) - 1: + await asyncio.sleep(self.wait_time) + finally: + if pbar: + pbar.close() + + # サマリー表示 + logger.info("\n" + "=" * 50) + logger.info("処理結果サマリー") + logger.info("=" * 50) + logger.info(f"合計: {total}件") + logger.info(f"成功: {success_count}件") + logger.info(f"スキップ(記事なし): {skip_count}件") + logger.info(f"失敗: {fail_count}件") + + if mode == "save" and success_count > 0: + logger.info(f"\nMarkdownファイル保存先: {save_dir}") + + return documents + + def save_metadata(self, documents: List[Dict[str, Any]], output_path: Path): + """メタデータをJSON形式で保存 + + Args: + documents: 保存するドキュメントメタデータのリスト + output_path: 出力ファイルパス + """ + output_path.parent.mkdir(parents=True, exist_ok=True) + + with open(output_path, 'w', encoding='utf-8') as f: + json.dump(documents, f, ensure_ascii=False, indent=2) + + logger.info(f"\nメタデータを保存しました: {output_path}") + logger.info(f"保存件数: {len(documents)}件") diff --git a/src/processing/chunk_documents.py b/src/processing/chunk_documents.py new file mode 100644 index 0000000000000000000000000000000000000000..4219335be9e83b0ba4690944cf7bef0ca83b9557 --- /dev/null +++ b/src/processing/chunk_documents.py @@ -0,0 +1,54 @@ +import argparse +import json +import logging + +from langchain_core.documents import Document +from langchain_text_splitters import CharacterTextSplitter + +logger = logging.getLogger(__name__) + +def chunk_documents(input_file_path: str, output_file_path: str): + """ + JSONファイルをロードし、ドキュメントをチャンクに分割して、新しいJSONファイルに保存します。 + """ + # テキスト分割を初期化します + text_splitter = CharacterTextSplitter(chunk_size=1000, chunk_overlap=50) + + # 入力JSONファイルからドキュメントを読み込みます + with open(input_file_path, 'r', encoding='utf-8') as f: + documents_data = json.load(f) + + chunked_documents = [] + for doc_data in documents_data: + page_content = doc_data.get("page_content", "") + metadata = doc_data.get("metadata", {}) + + # page_contentをチャンクに分割します + splits_texts = text_splitter.split_text(page_content) + + # 分割ごとに新しいドキュメントチャンクを作成します + for i, text in enumerate(splits_texts): + chunk_metadata = metadata.copy() + chunk_metadata['chunk_index'] = i + + chunked_doc = { + "metadata": chunk_metadata, + "page_content": text + } + chunked_documents.append(chunked_doc) + + # チャンク化されたドキュメントを出力JSONファイルに保存します + with open(output_file_path, 'w', encoding='utf-8') as f: + json.dump(chunked_documents, f, ensure_ascii=False, indent=2) + + logger.info(f"{len(documents_data)}個のドキュメントを{len(chunked_documents)}個のチャンクに正常に分割しました。") + logger.info(f"出力は {output_file_path} に保存されました") + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="JSONファイルからドキュメントをチャンク化します。") + parser.add_argument("input_file", help="入力JSONファイルのパス (例: documents.json)。") + parser.add_argument("output_file", help="出力JSONファイルのパス (例: chunks.json)。") + + args = parser.parse_args() + + chunk_documents(args.input_file, args.output_file) diff --git a/src/processing/document_processor.py b/src/processing/document_processor.py new file mode 100644 index 0000000000000000000000000000000000000000..d2c704c9477edc687819d6e9d466d4d59e990b22 --- /dev/null +++ b/src/processing/document_processor.py @@ -0,0 +1,46 @@ +"""単一URLのスクレイピング→メタデータ生成処理""" + +from pathlib import Path +from typing import Dict, Any, Optional + +from src.processing.metadata_utils import extract_title, generate_file_name +from src.scraping import io +from src.scraping import pipeline as scraping_pipeline + +class DocumentProcessor: + """単一URLのスクレイピング→メタデータ生成処理""" + + async def process_url(self, url: str, index: int, save_dir: Optional[Path] = None) -> Dict[str, Any]: + """ + URLから直接メタデータを生成 + save_dirが指定されている場合はMarkdownファイルも保存 + + Args: + url: 処理対象のURL + index: URLのインデックス番号 + save_dir: Markdownファイルの保存先ディレクトリ(Noneの場合は保存しない) + + Returns: + LangChain形式のドキュメントメタデータ + """ + # スクレイピング処理(集中化されたパイプライン関数を利用) + md_content = await scraping_pipeline.to_markdown_from_url(url) + + # save_dirが指定されている場合はファイル保存 + if save_dir: + saved_path = io.save_markdown(md_content, save_dir, url) + file_name = saved_path.name + else: + file_name = generate_file_name(url, index) + + # メタデータ生成(LangChain形式) + document = { + "metadata": { + "title": extract_title(md_content), + "file_name": file_name, + "source_url": url, + }, + "page_content": md_content + } + + return document diff --git a/src/processing/metadata_utils.py b/src/processing/metadata_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..b859c71e88a0b6495424832292891f14812d820d --- /dev/null +++ b/src/processing/metadata_utils.py @@ -0,0 +1,38 @@ +"""メタデータ生成のユーティリティ関数""" + +from urllib.parse import urlparse + + +def generate_file_name(url: str, index: int) -> str: + """URLからファイル名を生成(potion_XXX.md形式) + + Args: + url: 処理対象のURL + index: URLのインデックス番号 + + Returns: + 生成されたファイル名(例: potion_001.md) + """ + # URLからIDを抽出するか、インデックスを使用 + url_parts = urlparse(url).path.strip('/').split('/') + if url_parts and url_parts[-1].isdigit(): + doc_id = url_parts[-1].zfill(3) + else: + doc_id = str(index).zfill(3) + return f"potion_{doc_id}.md" + + +def extract_title(content: str) -> str: + """Markdownコンテンツからタイトルを抽出 + + Args: + content: Markdownコンテンツ + + Returns: + 抽出されたタイトル(見つからない場合は"Untitled") + """ + lines = content.split('\n') + for line in lines: + if line.startswith('# '): + return line.replace('# ', '').strip() + return "Untitled" diff --git a/src/processing/scrape_to_metadata.py b/src/processing/scrape_to_metadata.py new file mode 100644 index 0000000000000000000000000000000000000000..117aeb7926d60e7c9e6780d3409fb7c3d412b04a --- /dev/null +++ b/src/processing/scrape_to_metadata.py @@ -0,0 +1,55 @@ +from pathlib import Path +from typing import List, Dict, Any, Optional + +from src.processing.batch_processor import BatchProcessor + +class ScrapeToMetadata: + """スクレイピング→メタデータ生成を一気通貫で処理するファサードクラス""" + + def __init__(self, wait_time: float = 1.0): + """ + Args: + wait_time: リクエスト間の待機時間(秒) + """ + self.batch_processor = BatchProcessor(wait_time) + + async def process_urls_batch( + self, + urls: List[str], + start_id: int = 1, + mode: str = "memory", + show_progress: bool = True, + save_dir: Optional[Path] = None, + verbose: bool = False + ) -> List[Dict[str, Any]]: + """ + 複数URLをバッチ処理してメタデータを生成 + + Args: + urls: 処理するURLのリスト + start_id: 開始ID + mode: "memory" または "save" + show_progress: プログレス表示の有無 + save_dir: saveモード時のMarkdownファイル保存先ディレクトリ + verbose: 詳細ログを表示するか + + Returns: + 生成されたドキュメントメタデータのリスト + """ + return await self.batch_processor.process_urls_batch( + urls=urls, + start_id=start_id, + mode=mode, + show_progress=show_progress, + save_dir=save_dir, + verbose=verbose + ) + + def save_metadata(self, documents: List[Dict[str, Any]], output_path: Path): + """メタデータをJSON形式で保存 + + Args: + documents: 保存するドキュメントメタデータのリスト + output_path: 出力ファイルパス + """ + self.batch_processor.save_metadata(documents, output_path) diff --git a/src/qa/__init__.py b/src/qa/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..b5346ae162724111ff1cb996606c89447a253c61 --- /dev/null +++ b/src/qa/__init__.py @@ -0,0 +1,3 @@ +from .chain import QAChain, ask_question + +__all__ = ['QAChain', 'ask_question'] diff --git a/src/qa/__pycache__/__init__.cpython-312.pyc b/src/qa/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cef7c9bfbe0db5642bfccbdb412eb962708e6db5 Binary files /dev/null and b/src/qa/__pycache__/__init__.cpython-312.pyc differ diff --git a/src/qa/__pycache__/chain.cpython-312.pyc b/src/qa/__pycache__/chain.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6c88d8bb6bace91db2fb0cfba4cf3430ba00cbc2 Binary files /dev/null and b/src/qa/__pycache__/chain.cpython-312.pyc differ diff --git a/src/qa/__pycache__/prompt.cpython-312.pyc b/src/qa/__pycache__/prompt.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d94f8094e3ba8ace5d9d105531ae41c3429931bf Binary files /dev/null and b/src/qa/__pycache__/prompt.cpython-312.pyc differ diff --git a/src/qa/chain.py b/src/qa/chain.py new file mode 100644 index 0000000000000000000000000000000000000000..aef678fa611b1175c01e7f840ca2f5af8973ad25 --- /dev/null +++ b/src/qa/chain.py @@ -0,0 +1,341 @@ +""" +RAGベースの質問応答用QA Chainモジュール +""" + +import os +import logging +from pathlib import Path +from typing import List, Optional, Tuple + +from dotenv import load_dotenv +from langchain_chroma import Chroma +from langchain_core.documents import Document +from langchain_core.output_parsers import StrOutputParser +from langchain_core.prompts import ChatPromptTemplate +from langchain_core.runnables import RunnableLambda, RunnablePassthrough +from langchain_openai import OpenAIEmbeddings, ChatOpenAI + +from .prompt import QA_TEMPLATE, CHARACTER_TEMPLATE, QA_TEMPLATE_WITH_HISTORY + +logger = logging.getLogger(__name__) + + +class QAChain: + """RAGベースの質問応答用QA Chain""" + + def __init__( + self, + persist_dir: str = "data/vector_store", + model_name: str = "text-embedding-3-small", + k: int = 5, + verbose: bool = False, + llm_model: str = "gpt-5-nano", + llm_temperature: float = 0.3, + llm_max_tokens: Optional[int] = None, + max_history_turns: int = 10, + max_history_chars: int = 10000 + ): + """ + 永続化されたベクトルストアを使ってQA Chainを初期化 + + Args: + persist_dir: ベクトルストアの保存ディレクトリ + model_name: 埋め込みモデル名 + k: 検索する文書の数 + verbose: 詳細ログの出力 + llm_model: 使用するLLMモデル名 + llm_temperature: LLMの温度パラメーター(0-2) + llm_max_tokens: 最大トークン数(Noneで自動) + max_history_turns: 保持する最大会話ターン数(デフォルト: 10) + max_history_chars: 履歴の最大文字数(デフォルト: 10000) + """ + self.persist_dir = persist_dir + self.model_name = model_name + self.k = k + self.verbose = verbose + self.llm_model = llm_model + self.llm_temperature = llm_temperature + self.llm_max_tokens = llm_max_tokens + self.max_history_turns = max_history_turns + self.max_history_chars = max_history_chars + self.conversation_history = [] # 配列形式で管理 + + # 環境変数の読み込み(より堅牢な実装) + try: + load_dotenv(dotenv_path=".env") + api_key = os.getenv("OPENAI_API_KEY") + if not api_key: + raise ValueError("OPENAI_API_KEYが.envファイルに見つかりません") + except FileNotFoundError: + if self.verbose: + logger.warning(".envファイルが見つかりません。環境変数から読み込みを試みます。") + api_key = os.getenv("OPENAI_API_KEY") + if not api_key: + raise ValueError("OPENAI_API_KEYが環境変数に設定されていません") + except Exception as e: + logger.error(f"環境変数の読み込み中にエラーが発生しました: {e}") + raise + + self.db = None + self.retriever = None + self.rag_chain = None + self.model_with_history = None + self._setup_chain() + + def _load_vector_store(self) -> Chroma: + """永続化されたベクトルストアを読み込む""" + persist_path = Path(self.persist_dir) + if not persist_path.exists(): + raise FileNotFoundError( + f"ベクトルストアが見つかりません: {self.persist_dir}\n" + "まず次のコマンドでベクトルストアを作成してください: python -m src.cli vector build" + ) + embeddings = OpenAIEmbeddings(model=self.model_name) + db = Chroma( + persist_directory=self.persist_dir, + embedding_function=embeddings + ) + if self.verbose: + logger.info(f"ベクトルストアを{self.persist_dir}から読み込みました") + return db + + @staticmethod + def _format_docs_with_metadata(docs: List[Document]) -> str: + """文書をメタデータ付きでコンテキスト用に整形""" + return '\n\n'.join( + f"[出典: {doc.metadata.get('title', 'Unknown Title')} - チャンク {doc.metadata.get('chunk_index', 'N/A')}]" + f"\nURL: {doc.metadata.get('source_url', 'Unknown Source')}" + f"\n内容: {doc.page_content}\n---" + for doc in docs + ) + + def _setup_chain(self): + """RAGチェーン全体をセットアップ""" + try: + self.db = self._load_vector_store() + self.retriever = self.db.as_retriever( + search_type='similarity', + search_kwargs={'k': self.k} + ) + + # LLMのパラメーターを動的に構成 + llm_params = { + 'model': self.llm_model, + } + if self.llm_model == 'gpt-5-nano': + llm_params['temperature'] = 1.0 + llm_params['reasoning_effort'] = 'minimal' + llm_params['verbosity'] = 'low' + else: + # その他のモデルでは指定された temperature を使用 + if self.llm_temperature != 0.3: + llm_params['temperature'] = self.llm_temperature + + if self.llm_max_tokens is not None: + llm_params['max_tokens'] = self.llm_max_tokens + + model = ChatOpenAI(**llm_params) + format_docs = RunnableLambda(self._format_docs_with_metadata) + qa_prompt = ChatPromptTemplate.from_template(QA_TEMPLATE) + + # 通常のRAGチェーン + self.rag_chain = { + 'context': self.retriever | format_docs, + 'question': RunnablePassthrough(), + } | qa_prompt | model | StrOutputParser() + + # モデルを保存 + self.model = model + self.model_with_history = model + self.format_docs_with_history = format_docs + + if self.verbose: + logger.info("永続化ベクトルストアを使ってRAGチェーンを作成しました!") + except Exception as e: + logger.error(f"QAチェーンのセットアップ中にエラー: {e}") + raise + + def ask(self, question: str) -> Tuple[str, List[Document]]: + """ + 質問を投げて、回答と参照文書を取得 + """ + if not self.model: + raise RuntimeError("モデルが正しく初期化されていません") + try: + # 文書を検索して整形 + source_docs = self.retriever.invoke(question) + context = self._format_docs_with_metadata(source_docs) + + # URLリストを作成(重複を除去) + source_urls = [] + for doc in source_docs: + url = doc.metadata.get('source_url', '') + if url and url not in source_urls: + source_urls.append(url) + urls_text = '\n'.join(f"- {url}" for url in source_urls) + + # プロンプトを構築して実行 + prompt_input = { + 'context': context, + 'question': question, + 'source_urls': urls_text + } + qa_prompt = ChatPromptTemplate.from_template(QA_TEMPLATE) + + # プロンプトテンプレートを適用して回答を生成 + messages = qa_prompt.invoke(prompt_input) + answer = self.model.invoke(messages).content + + return answer, source_docs + except Exception as e: + logger.error(f"質問処理中にエラー: {e}") + raise + + def _manage_history_window(self): + """ + Sliding Windowを使用して履歴を管理 + 最大ターン数と最大文字数の両方を考慮 + """ + # ターン数の制限 + if len(self.conversation_history) > self.max_history_turns: + self.conversation_history = self.conversation_history[-self.max_history_turns:] + + # 文字数の制限(古い会話から削除) + total_chars = sum(len(turn) for turn in self.conversation_history) + while total_chars > self.max_history_chars and len(self.conversation_history) > 1: + removed = self.conversation_history.pop(0) + total_chars -= len(removed) + if self.verbose: + logger.info(f"履歴が制限を超えたため、古い会話を削除しました(削除文字数: {len(removed)})") + + def _format_history_text(self) -> str: + """ + 会話履歴配列を文字列に整形 + """ + if not self.conversation_history: + return "まだ会話履歴はありません" + return "\n".join(self.conversation_history) + + def ask_with_history(self, question: str, retry_count: int = 0) -> Tuple[str, List[Document]]: + """ + 対話履歴を考慮した質問応答(Sliding Window機能付き) + + Args: + question: 質問内容 + retry_count: リトライ回数(内部使用) + """ + if not self.model_with_history: + raise RuntimeError("履歴付きモデルが正しく初期化されていません") + + try: + # 履歴を文字列形式に変換 + history_text = self._format_history_text() + + # 文書を検索して整形 + source_docs = self.retriever.invoke(question) + context = self._format_docs_with_metadata(source_docs) + + # URLリストを作成(重複を除去) + source_urls = [] + for doc in source_docs: + url = doc.metadata.get('source_url', '') + if url and url not in source_urls: + source_urls.append(url) + urls_text = '\n'.join(f"- {url}" for url in source_urls) + + # プロンプトを手動で構築して実行 + prompt_input = { + 'context': context, + 'question': question, + 'conversation_history': history_text, + 'source_urls': urls_text + } + qa_prompt_with_history = ChatPromptTemplate.from_messages([ + ("system", CHARACTER_TEMPLATE), + ("system", QA_TEMPLATE_WITH_HISTORY), + ]) + + # プロンプトテンプレートを適用して回答を生成 + messages = qa_prompt_with_history.invoke(prompt_input) + answer = self.model_with_history.invoke(messages).content + + # 新しい会話を履歴に追加 + new_turn = f"ユーザー: {question}\nKurageSan®: {answer}" + self.conversation_history.append(new_turn) + + # Sliding Windowを適用 + self._manage_history_window() + + if self.verbose: + total_chars = sum(len(turn) for turn in self.conversation_history) + logger.info(f"履歴付き質問処理完了。ターン数: {len(self.conversation_history)}, 合計文字数: {total_chars}") + + return answer, source_docs + + except Exception as e: + # トークン制限エラーの処理 + error_message = str(e).lower() + if retry_count < 2 and ('maximum context length' in error_message or + 'token' in error_message and 'limit' in error_message): + logger.warning(f"トークン制限エラーが発生しました。履歴を削減して再試行します(試行回数: {retry_count + 1})") + + # 履歴を半分に削減 + if len(self.conversation_history) > 1: + old_size = len(self.conversation_history) + self.conversation_history = self.conversation_history[old_size//2:] + logger.info(f"会話履歴を削減: {old_size} -> {len(self.conversation_history)} ターン") + else: + # 履歴が1つ以下の場合はクリア + self.conversation_history = [] + logger.info("会話履歴を完全にクリアしました") + + # リトライ + return self.ask_with_history(question, retry_count + 1) + + logger.error(f"履歴付き質問処理中にエラー: {e}") + raise + + def clear_history(self): + """ + 対話履歴をクリア + """ + self.conversation_history = [] + if self.verbose: + logger.info("対話履歴をクリアしました") + + def get_history(self) -> str: + """ + 現在の対話履歴を取得(デバッグ用) + """ + return self._format_history_text() + + def search_similar(self, query: str, k: int = 5) -> List[Document]: + """ + 類似文書を検索 + """ + if not self.retriever: + raise RuntimeError("リトリーバーが正しく初期化されていません") + self.retriever.search_kwargs['k'] = k + return self.retriever.invoke(query) + + +def ask_question(question: str, persist_dir: str = "data/vector_store") -> None: + """ + 質問を投げて、回答と参照情報を表示(後方互換用関数) + """ + try: + qa_chain = QAChain(persist_dir=persist_dir, verbose=True) + answer, source_docs = qa_chain.ask(question) + + print(f"\n{'='*50}") + print(f"質問: {question}") + print(f"{'='*50}") + print(f"\n回答:\n{answer}") + + print(f"\n参考にした文書 ({len(source_docs)}件):") + for i, doc in enumerate(source_docs, 1): + print(f"\n{i}. {doc.metadata.get('title', 'No Title')}") + print(f" URL: {doc.metadata.get('source_url', 'N/A')}") + print(f" チャンク: {doc.metadata.get('chunk_index', 'N/A')}") + except Exception as e: + print(f"エラー: {e}") diff --git a/src/qa/prompt.py b/src/qa/prompt.py new file mode 100644 index 0000000000000000000000000000000000000000..c4afbcde27cfb5be92ac448fbe7e8f9cc07a9007 --- /dev/null +++ b/src/qa/prompt.py @@ -0,0 +1,51 @@ +QA_TEMPLATE = ''' +以下の文脈情報を参考にして、質問に対して正確で有用な回答を提供してください。 + +【参考URL一覧】 +以下のURLから情報を取得しています。回答で情報を引用する際は、必ずこれらのURLを明記してください: +{source_urls} + +文脈情報: +{context} + +質問: {question} + +回答: +''' + +CHARACTER_TEMPLATE = ''' +"敬語"ではなく、フレンドリーな口調で答えてください。”敬語”は必ず使わないでください。 +あなたはフレンドリーで優しいです。 +あなたの名前は「KurageSan®」です。 +必ずわかりやすく答えてください。絵文字は使わないでください。 +ユーザーが読みやすい文章で答えてください。 +あなたには、「https://ja.empatheme.org/potion/」の情報に基づいて英語の発音や、練習、英語学習、英語学習に対する向き合い方に関する質問に答える役割があります。 + +【あなたの対話スタイル】 +- ユーザーの学習状況や背景を理解しようと努める +- 質問の背後にある本当のニーズや困りごとを察知する +- 共感的で励ましのある態度を保つ +- 実践的で具体的なアドバイスを心がける +- 練習メニューは作らなくて良いです。 + +【スタイルの厳守ルール】 +- 出力は【最大5文 or 150字以内】のどちらか短い方 +- 箇条書きは【最大3項目まで】 +- 不要な見出し・冗長な導入は禁止 +''' + +QA_TEMPLATE_WITH_HISTORY = ''' +以下の文脈情報を参考にして、質問に対して正確で有用な回答を提供してください。 +回答に、それに関する場合は、最も重要な文書のURLを記載してください。 +回答時に添付するとユーザーがためになりそうな場合は、そのpotionのリンクを貼ってください。 + +文脈情報: +{context} + +質問: {question} + +過去の会話履歴: +{conversation_history} + +回答: +''' diff --git a/src/scraping/__init__.py b/src/scraping/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..26de1a2703338110e5d2b5dc24ff4f848a31475a --- /dev/null +++ b/src/scraping/__init__.py @@ -0,0 +1,15 @@ +from src.scraping.convert import to_markdown +from src.scraping.extract import extract_main_html +from src.scraping.fetch import fetch_html +from src.scraping.io import save_markdown +from src.scraping.pipeline import run +from src.scraping.textutil import compact_blank_lines + +__all__ = [ + "fetch_html", + "extract_main_html", + "to_markdown", + "compact_blank_lines", + "save_markdown", + "run", +] diff --git a/src/scraping/__pycache__/__init__.cpython-312.pyc b/src/scraping/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0a1866d4e9cfc660fc9fd17f52cab76e5955f02f Binary files /dev/null and b/src/scraping/__pycache__/__init__.cpython-312.pyc differ diff --git a/src/scraping/__pycache__/batch.cpython-312.pyc b/src/scraping/__pycache__/batch.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1b1515db9f6a87c6f4692f61a3886ea11d958037 Binary files /dev/null and b/src/scraping/__pycache__/batch.cpython-312.pyc differ diff --git a/src/scraping/__pycache__/cli.cpython-312.pyc b/src/scraping/__pycache__/cli.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..da70462c9a7d2e58bc2fe1480a514117f8d961d2 Binary files /dev/null and b/src/scraping/__pycache__/cli.cpython-312.pyc differ diff --git a/src/scraping/__pycache__/convert.cpython-312.pyc b/src/scraping/__pycache__/convert.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..92cc5de6df1c4c10e7dd9a427ffa26a96deef0f0 Binary files /dev/null and b/src/scraping/__pycache__/convert.cpython-312.pyc differ diff --git a/src/scraping/__pycache__/exceptions.cpython-312.pyc b/src/scraping/__pycache__/exceptions.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0f9fcbf4da4e0909a5a77441eaf356486e460a05 Binary files /dev/null and b/src/scraping/__pycache__/exceptions.cpython-312.pyc differ diff --git a/src/scraping/__pycache__/extract.cpython-312.pyc b/src/scraping/__pycache__/extract.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4a6cfc5fabbeb9e0dc1aa76512c163a3ff9c1812 Binary files /dev/null and b/src/scraping/__pycache__/extract.cpython-312.pyc differ diff --git a/src/scraping/__pycache__/fetch.cpython-312.pyc b/src/scraping/__pycache__/fetch.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ada5d38c0dc5a34f8bab5ff9e2d6c91946b7b7e1 Binary files /dev/null and b/src/scraping/__pycache__/fetch.cpython-312.pyc differ diff --git a/src/scraping/__pycache__/io.cpython-312.pyc b/src/scraping/__pycache__/io.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9e95c72bfc590bd2926128b2dbc6f0e7f5adedb2 Binary files /dev/null and b/src/scraping/__pycache__/io.cpython-312.pyc differ diff --git a/src/scraping/__pycache__/pipeline.cpython-312.pyc b/src/scraping/__pycache__/pipeline.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c08c4b2a3105d760e4977c7aae73291452bbcbf8 Binary files /dev/null and b/src/scraping/__pycache__/pipeline.cpython-312.pyc differ diff --git a/src/scraping/__pycache__/textutil.cpython-312.pyc b/src/scraping/__pycache__/textutil.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bdf593c4952ffb06633297dd3e90d3f0056e6327 Binary files /dev/null and b/src/scraping/__pycache__/textutil.cpython-312.pyc differ diff --git a/src/scraping/batch.py b/src/scraping/batch.py new file mode 100644 index 0000000000000000000000000000000000000000..45057538a2e7692387023484ffd1a1e659dce958 --- /dev/null +++ b/src/scraping/batch.py @@ -0,0 +1,165 @@ +""" +バッチスクレイピング処理モジュール +""" + +import asyncio +import logging +import sys +from enum import Enum +from pathlib import Path +from typing import List, Tuple, Literal, Optional + +from tqdm import tqdm + +from src.scraping.exceptions import ArticleNotFoundError, FetchError +from src.scraping.pipeline import run as run_pipeline + +# ロガーの設定 +logging.basicConfig(level=logging.INFO, format='%(message)s') +logger = logging.getLogger(__name__) + + +class ScrapeStatus(Enum): + """スクレイピング結果のステータス""" + SUCCESS = "success" + SKIPPED = "skipped" # 記事が存在しない + FAILED = "failed" # その他のエラー + + +async def scrape_single_page(url: str, out_dir: Path) -> Tuple[str, ScrapeStatus, str]: + """ + 単一ページのスクレイピング + + Returns: + (url, status, message) のタプル + """ + try: + path = await run_pipeline(url, out_dir) + return (url, ScrapeStatus.SUCCESS, f"保存完了: {path}") + except ArticleNotFoundError: + return (url, ScrapeStatus.SKIPPED, "記事が見つかりません") + except FetchError as e: + return (url, ScrapeStatus.FAILED, f"取得エラー: {str(e)}") + except Exception as e: + return (url, ScrapeStatus.FAILED, f"エラー: {str(e)}") + + +async def batch_scrape( + start_id: int, + end_id: int, + out_dir: Path, + delay: float = 1.0, + base_url: str = "https://ja.empatheme.org/potion", + verbose: bool = False +) -> List[Tuple[str, ScrapeStatus, str]]: + """ + 指定範囲のIDでバッチスクレイピング実行 + + Args: + start_id: 開始ID + end_id: 終了ID(含む) + out_dir: 出力ディレクトリ + delay: 各リクエスト間の待機時間(秒) + base_url: ベースURL + verbose: 詳細ログを表示するか + + Returns: + 各URLの処理結果のリスト + """ + results = [] + total = end_id - start_id + 1 + + logger.info(f"スクレイピング開始: ID {start_id} から {end_id} まで(計{total}件)") + logger.info(f"出力先: {out_dir}") + logger.info(f"待機時間: {delay}秒\n") + + # カウンター初期化 + success_count = 0 + skipped_count = 0 + failed_count = 0 + + # プログレスバーの作成(単一行で更新) + pbar = tqdm( + total=total, + desc="処理中", + leave=True, + ncols=80, + bar_format='{desc}: {percentage:3.0f}%|{bar}| {n_fmt}/{total_fmt} [{elapsed}<{remaining}, {postfix}]' + ) + + try: + for page_id in range(start_id, end_id + 1): + url = f"{base_url}/{page_id:03d}/" + + # スクレイピング実行 + result = await scrape_single_page(url, out_dir) + results.append(result) + + # カウンター更新 + url, status, message = result + if status == ScrapeStatus.SUCCESS: + success_count += 1 + elif status == ScrapeStatus.SKIPPED: + skipped_count += 1 + else: # FAILED + failed_count += 1 + + # プログレスバーの説明を更新 + pbar.set_postfix({ + '成功': success_count, + 'スキップ': skipped_count, + '失敗': failed_count + }) + + # verboseモードの場合は詳細ログも表示 + if verbose: + # プログレスバーを一時的にクリアして詳細を表示 + pbar.clear() + if status == ScrapeStatus.SUCCESS: + print(f" ✓ {url}: {message}") + elif status == ScrapeStatus.SKIPPED: + print(f" ⊘ {url}: {message}") + else: # FAILED + print(f" ✗ {url}: {message}") + pbar.refresh() + + # プログレスバーを進める + pbar.update(1) + + # 最後のページでなければ待機 + if page_id < end_id: + await asyncio.sleep(delay) + finally: + pbar.close() + + return results + + +def print_summary(results: List[Tuple[str, ScrapeStatus, str]]) -> None: + """処理結果のサマリーを表示""" + total = len(results) + success_count = sum(1 for _, status, _ in results if status == ScrapeStatus.SUCCESS) + skipped_count = sum(1 for _, status, _ in results if status == ScrapeStatus.SKIPPED) + failed_count = sum(1 for _, status, _ in results if status == ScrapeStatus.FAILED) + + logger.info("\n" + "="*50) + logger.info("処理結果サマリー") + logger.info("="*50) + logger.info(f"合計: {total}件") + logger.info(f"成功: {success_count}件") + logger.info(f"スキップ(記事なし): {skipped_count}件") + logger.info(f"失敗: {failed_count}件") + + # スキップしたURL(記事が存在しない)の表示 + if skipped_count > 0: + logger.info("\nスキップしたURL(記事が存在しない):") + for url, status, message in results: + if status == ScrapeStatus.SKIPPED: + logger.info(f" ⊘ {url}") + + # 失敗したURLの詳細表示 + if failed_count > 0: + logger.info("\n失敗したURL:") + for url, status, message in results: + if status == ScrapeStatus.FAILED: + logger.info(f" ✗ {url}: {message}") diff --git a/src/scraping/convert.py b/src/scraping/convert.py new file mode 100644 index 0000000000000000000000000000000000000000..7bdab6e9296d58a52b34ef050d7d2a430d244f44 --- /dev/null +++ b/src/scraping/convert.py @@ -0,0 +1,4 @@ +from markdownify import markdownify as md + +def to_markdown(html: str) -> str: + return md(html, heading_style="ATX") diff --git a/src/scraping/exceptions.py b/src/scraping/exceptions.py new file mode 100644 index 0000000000000000000000000000000000000000..fd9fa03fa0ab7b855552e2d5aeae34840a3a85ef --- /dev/null +++ b/src/scraping/exceptions.py @@ -0,0 +1,11 @@ +""" +スクレイピング処理用のカスタム例外 +""" + +class ArticleNotFoundError(Exception): + """記事が存在しない場合の例外(404エラー)""" + pass + +class FetchError(Exception): + """その他のフェッチエラー""" + pass diff --git a/src/scraping/extract.py b/src/scraping/extract.py new file mode 100644 index 0000000000000000000000000000000000000000..0ce59457f586ad4341013f4d4710ecb38eca9990 --- /dev/null +++ b/src/scraping/extract.py @@ -0,0 +1,55 @@ +from bs4 import BeautifulSoup +from readability import Document + +def extract_main_html(html: str) -> str: + soup = BeautifulSoup(html, "lxml") + + main = soup.select_one("main#content") or soup + for sel in [ + "header", "footer", "nav", "aside", + ".breadcrumbs", ".ast-breadcrumbs", ".yoast-breadcrumbs", + ".site-header", ".site-footer", + ".widget", ".sidebar", ".post-navigation", ".navigation", + ".comments-area", ".comment-respond", ".entry-footer", ".entry-meta", + ".jp-relatedposts", ".related-posts", ".sharedaddy", ".share-buttons", + ".wp-block-search", ".search-form", + 'a[href*="facebook.com/sharer"]', 'a[href*="twitter.com/share"]', + ]: + for el in main.select(sel): + el.decompose() + + article = main.select_one("article") or main + h1 = article.select_one("h1.entry-title, h1") + content = ( + article.select_one(".entry-content") + or article.select_one(".nv-content-wrap") + or article.select_one(".post-content") + or article.select_one(".single-content") + or article.select_one(".content") + ) + + if content: + for sel in [ + ".sharedaddy", ".share", ".sns", ".advertisement", ".adsbygoogle", + ".post-navigation", ".entry-footer", ".toc_container", ".table-of-contents", + ".jp-relatedposts", ".related-posts" + ]: + for el in content.select(sel): + el.decompose() + + parts = [] + if h1: + parts.append(str(h1)) + parts.append(str(content)) + return "".join(parts) + + global_content = soup.select_one(".entry-content") + if global_content: + parts = [] + if h1: + parts.append(str(h1)) + parts.append(str(global_content)) + return "".join(parts) + + doc = Document(html) + return f"

{doc.short_title()}

{doc.summary()}" diff --git a/src/scraping/fetch.py b/src/scraping/fetch.py new file mode 100644 index 0000000000000000000000000000000000000000..769f3fb7e0f4a81b7fdfd3aaed90850651d55c8a --- /dev/null +++ b/src/scraping/fetch.py @@ -0,0 +1,39 @@ +import asyncio +from typing import Optional + +import aiohttp + +from src.scraping.exceptions import ArticleNotFoundError, FetchError + +async def fetch_html( + url: str, + timeout_s: float = 20.0, + user_agent: Optional[str] = None, +) -> str: + """ + HTMLを取得する + + Raises: + ArticleNotFoundError: 404エラーの場合 + FetchError: その他のネットワークエラーの場合 + """ + headers = { + "User-Agent": user_agent or ( + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/124.0 Safari/537.36" + ) + } + timeout = aiohttp.ClientTimeout(total=timeout_s) + try: + async with aiohttp.ClientSession(timeout=timeout, headers=headers) as session: + async with session.get(url, allow_redirects=True) as resp: + if resp.status == 404: + raise ArticleNotFoundError(f"記事が見つかりません: {url}") + resp.raise_for_status() + return await resp.text() + except ArticleNotFoundError: + raise + except aiohttp.ClientResponseError as e: + raise FetchError(f"HTTPエラー {e.status}: {url}") + except (aiohttp.ClientError, aiohttp.http_exceptions.HttpProcessingError, asyncio.TimeoutError) as e: + raise FetchError(f"ネットワークエラー: {url} - {str(e)}") diff --git a/src/scraping/io.py b/src/scraping/io.py new file mode 100644 index 0000000000000000000000000000000000000000..a811f2627a7f1c7e1e5a39ad1290e5ebb3d9caca --- /dev/null +++ b/src/scraping/io.py @@ -0,0 +1,30 @@ +import hashlib +import re +from pathlib import Path +from urllib.parse import urlparse + +def _slug_from_url(url: str) -> str: + """ + URLから安全なファイル名を生成。例: https://ja.empatheme.org/potion/108/ → potion_108.md + """ + p = urlparse(url) + parts = [part for part in p.path.strip("/").split("/") if part] + if len(parts) >= 2: + base = f"{parts[-2]}_{parts[-1]}" + elif parts: + base = parts[-1] + else: + base = "index" + # サニタイズ: ファイル名に使えない文字をアンダースコアに置換 + base = re.sub(r'[^\w\-_.]', '_', base) + return f"{base}.md" + +def ensure_dir(path: Path) -> None: + path.mkdir(parents=True, exist_ok=True) + +def save_markdown(md_text: str, out_dir: Path, url: str) -> Path: + ensure_dir(out_dir) + filename = _slug_from_url(url) + out_path = out_dir / filename + out_path.write_text(md_text, encoding="utf-8") + return out_path diff --git a/src/scraping/pipeline.py b/src/scraping/pipeline.py new file mode 100644 index 0000000000000000000000000000000000000000..4e1a3ecfa3574475c75384336e3c1e31d3200c27 --- /dev/null +++ b/src/scraping/pipeline.py @@ -0,0 +1,46 @@ +from pathlib import Path + +from src.scraping.convert import to_markdown +from src.scraping.exceptions import ArticleNotFoundError, FetchError +from src.scraping.extract import extract_main_html +from src.scraping.fetch import fetch_html +from src.scraping.io import save_markdown +from src.scraping.textutil import compact_blank_lines + +async def run(url: str, out_dir: Path) -> Path: + """ + URLを取得→本文抽出→Markdown化→空行圧縮→保存 までを実行。 + 戻り値は保存先パス。 + + Raises: + ArticleNotFoundError: 記事が存在しない場合 + FetchError: ネットワークエラーの場合 + """ + # HTMLを取得(例外が発生する可能性あり) + html = await fetch_html(url, timeout_s=25.0) + + # 本文抽出→Markdown化→空行圧縮→保存 + main_html = extract_main_html(html) + md_out = to_markdown(main_html) + md_out = compact_blank_lines(md_out) + return save_markdown(md_out, out_dir, url) + +async def to_markdown_from_url(url: str, timeout_s: float = 25.0) -> str: + """ + URLを取得→本文抽出→Markdown化→空行圧縮 までを実行してMarkdown文字列を返す。 + + Args: + url: 取得対象URL + timeout_s: 取得タイムアウト(秒) + + Returns: + Markdown化された本文文字列 + + Raises: + ArticleNotFoundError: 記事が存在しない場合 + FetchError: ネットワークエラーの場合 + """ + html = await fetch_html(url, timeout_s=timeout_s) + main_html = extract_main_html(html) + md_out = to_markdown(main_html) + return compact_blank_lines(md_out) diff --git a/src/scraping/textutil.py b/src/scraping/textutil.py new file mode 100644 index 0000000000000000000000000000000000000000..2cc05b9641491bd34c8be6955d5bf62167c15545 --- /dev/null +++ b/src/scraping/textutil.py @@ -0,0 +1,10 @@ +def compact_blank_lines(text: str) -> str: + lines = [line.rstrip() for line in text.splitlines()] + out, prev_blank = [], False + for ln in lines: + blank = (ln.strip() == "") + if blank and prev_blank: + continue + out.append("" if blank else ln) + prev_blank = blank + return "\n".join(out) diff --git a/src/vector/__init__.py b/src/vector/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..ffa069887ede837951d5ac719263363fe68ee1e2 --- /dev/null +++ b/src/vector/__init__.py @@ -0,0 +1,14 @@ +# リファクタリングされた各モジュールから主要な関数をインポート +from .embedding_storage import save_embeddings, load_embeddings +from .loader import load_chunks_from_json, display_document_info +from .vector_builder import build_vector_store, search_vector_store + +# パッケージレベルで利用可能にする +__all__ = [ + 'load_chunks_from_json', + 'display_document_info', + 'save_embeddings', + 'load_embeddings', + 'build_vector_store', + 'search_vector_store' +] diff --git a/src/vector/__pycache__/__init__.cpython-312.pyc b/src/vector/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a5a6b33bb7a355fe88948d4f9037c0c0d42c0c52 Binary files /dev/null and b/src/vector/__pycache__/__init__.cpython-312.pyc differ diff --git a/src/vector/__pycache__/embedding_storage.cpython-312.pyc b/src/vector/__pycache__/embedding_storage.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ed7226c7d683832eb0b3a4eb44b9deecb1286139 Binary files /dev/null and b/src/vector/__pycache__/embedding_storage.cpython-312.pyc differ diff --git a/src/vector/__pycache__/embeddings.cpython-312.pyc b/src/vector/__pycache__/embeddings.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..456d7253a89e53e1d78225805545534753f49ebc Binary files /dev/null and b/src/vector/__pycache__/embeddings.cpython-312.pyc differ diff --git a/src/vector/__pycache__/loader.cpython-312.pyc b/src/vector/__pycache__/loader.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d8518ff3d3aadb3ca4f09eb26df271297a7382b0 Binary files /dev/null and b/src/vector/__pycache__/loader.cpython-312.pyc differ diff --git a/src/vector/__pycache__/store_builder.cpython-312.pyc b/src/vector/__pycache__/store_builder.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..207d69814db457f7734450fc05a88d1402986cfa Binary files /dev/null and b/src/vector/__pycache__/store_builder.cpython-312.pyc differ diff --git a/src/vector/__pycache__/vector_builder.cpython-312.pyc b/src/vector/__pycache__/vector_builder.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..65c2875651087709c0e7b4a0b21963da0470c873 Binary files /dev/null and b/src/vector/__pycache__/vector_builder.cpython-312.pyc differ diff --git a/src/vector/embedding_storage.py b/src/vector/embedding_storage.py new file mode 100644 index 0000000000000000000000000000000000000000..6a0714b400ac64d17ff77be9155cc9b6ed6bc0fe --- /dev/null +++ b/src/vector/embedding_storage.py @@ -0,0 +1,134 @@ +""" +埋め込み(embedding)の保存・読み込みユーティリティ +""" + +import json +import logging +import os +from datetime import datetime +from pathlib import Path +from typing import List, Dict, Tuple + +import numpy as np +from langchain_core.documents import Document + +logger = logging.getLogger(__name__) + + +def save_embeddings( + documents: List[Document], + vectors: List[List[float]], + model_name: str, + embeddings_dir: str = 'data/embeddings' +) -> str: + """ + 埋め込みとメタデータをJSONファイルに保存する + + Args: + documents: Documentオブジェクトのリスト + vectors: 埋め込みベクトルのリスト + model_name: 使用した埋め込みモデル名 + embeddings_dir: 埋め込みを保存するディレクトリ + + Returns: + 保存した埋め込みファイルのパス + """ + # ディレクトリがなければ作成 + Path(embeddings_dir).mkdir(parents=True, exist_ok=True) + + # タイムスタンプ生成(ファイル名用) + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + + # 埋め込みデータの準備 + embeddings_data = { + "model": model_name, + "timestamp": datetime.now().isoformat(), + "total_documents": len(documents), + "embeddings": [] + } + + # Documentとベクトルを結合 + for i, (doc, vector) in enumerate(zip(documents, vectors)): + chunk_id = f"doc_{i}_chunk_{doc.metadata.get('chunk_index', 0)}" + embeddings_data["embeddings"].append({ + "chunk_id": chunk_id, + "metadata": doc.metadata, + "content": doc.page_content, + "vector": vector # リストとして保存(JSONシリアライズ可能) + }) + + # JSONファイルに保存 + output_file = os.path.join(embeddings_dir, f"embeddings_{timestamp}.json") + with open(output_file, 'w', encoding='utf-8') as f: + json.dump(embeddings_data, f, ensure_ascii=False, indent=2) + + # 最新の埋め込みへのシンボリックリンクを作成または更新 + latest_link = os.path.join(embeddings_dir, "latest.json") + if os.path.exists(latest_link): + os.remove(latest_link) + try: + os.symlink(os.path.basename(output_file), latest_link) + except OSError: + # シンボリックリンク作成に失敗した場合(例: Windows)、パスをテキストファイルに保存 + with open(os.path.join(embeddings_dir, "latest.txt"), 'w') as f: + f.write(output_file) + + logger.info(f"Embeddings saved to: {output_file}") + logger.info(f"Total embeddings saved: {len(vectors)}") + + return output_file + + +def load_embeddings( + embeddings_file: str = None, + embeddings_dir: str = 'data/embeddings' +) -> Tuple[List[Document], np.ndarray, str]: + """ + 保存済みのJSONファイルから埋め込みを読み込む + + Args: + embeddings_file: 読み込む埋め込みファイルのパス。Noneの場合は最新を読み込む + embeddings_dir: 埋め込みが保存されているディレクトリ + + Returns: + (documents, ベクトル(numpy配列), モデル名) のタプル + """ + # ファイル指定がなければ最新を読み込む + if embeddings_file is None: + latest_link = os.path.join(embeddings_dir, "latest.json") + if os.path.exists(latest_link): + embeddings_file = latest_link + else: + # latest.txtからパスを取得 + latest_txt = os.path.join(embeddings_dir, "latest.txt") + if os.path.exists(latest_txt): + with open(latest_txt, 'r') as f: + embeddings_file = f.read().strip() + else: + raise FileNotFoundError(f"{embeddings_dir} に埋め込みファイルが見つかりません") + + # JSONから埋め込みを読み込む + with open(embeddings_file, 'r', encoding='utf-8') as f: + embeddings_data = json.load(f) + + # Documentとベクトルを復元 + documents = [] + vectors = [] + + for item in embeddings_data["embeddings"]: + # Documentオブジェクトを作成 + doc = Document( + page_content=item["content"], + metadata=item["metadata"] + ) + documents.append(doc) + vectors.append(item["vector"]) + + # ベクトルをnumpy配列に変換 + vectors_np = np.array(vectors) + + logger.info(f"Loaded {len(documents)} embeddings from: {embeddings_file}") + logger.info(f"Model used: {embeddings_data['model']}") + logger.info(f"Created at: {embeddings_data['timestamp']}") + + return documents, vectors_np, embeddings_data["model"] diff --git a/src/vector/loader.py b/src/vector/loader.py new file mode 100644 index 0000000000000000000000000000000000000000..92cfb7e296fd6c3acfb6d9574e54bac81ba729d2 --- /dev/null +++ b/src/vector/loader.py @@ -0,0 +1,91 @@ +""" +ベクトルストア操作用のドキュメント読み込みユーティリティ。 +""" + +import json +import logging +from typing import List + +from langchain_core.documents import Document + +logger = logging.getLogger(__name__) + + +def load_chunks_from_json(json_file_path: str) -> List[Document]: + """ + JSONファイルからドキュメントチャンクを読み込み、LangChainのDocumentに変換する。 + + 期待されるJSONフォーマット: + [ + { + "metadata": { + "title": "...", + "file_name": "...", + "source_url": "...", + "chunk_index": 0 + }, + "page_content": "..." + }, + ... + ] + """ + try: + with open(json_file_path, 'r', encoding='utf-8') as f: + chunks_data = json.load(f) + + documents = [] + for chunk in chunks_data: + # メタデータ付きでDocumentを作成 + doc = Document( + page_content=chunk['page_content'], + metadata=chunk['metadata'] + ) + documents.append(doc) + + logger.info(f"{len(documents)}個のドキュメントチャンクを正常に読み込みました") + return documents + + except FileNotFoundError: + logger.error(f"エラー: {json_file_path} が見つかりません!") + logger.error("カレントディレクトリにchunks.jsonが存在するか確認してください。") + return [] + except json.JSONDecodeError as e: + logger.error(f"エラー: {json_file_path} のJSON形式が不正です") + logger.error(f"JSONエラー: {e}") + return [] + except Exception as e: + logger.error(f"{json_file_path} の読み込み中にエラーが発生しました: {e}") + return [] + + +def display_document_info(docs: List[Document], max_display: int = 5) -> None: + """ + 読み込んだドキュメントの情報を表示する。 + + Args: + docs: 表示するドキュメントのリスト + max_display: 表示する最大ドキュメント数(デフォルト: 5) + """ + logger.info(f"\n=== ドキュメント情報 ===") + logger.info(f"総ドキュメント数: {len(docs)}") + + if not docs: + logger.info("ドキュメントが見つかりませんでした。") + return + + # 最大max_display件まで表示 + for i, doc in enumerate(docs[:max_display]): + meta = doc.metadata + logger.info(f"\n--- ドキュメント {i+1} ---") + logger.info(f"タイトル: {meta.get('title', 'N/A')}") + logger.info(f"ファイル名: {meta.get('file_name', 'N/A')}") + logger.info(f"URL: {meta.get('source_url', 'N/A')}") + logger.info(f"チャンク番号: {meta.get('chunk_index', 'N/A')}") + + # 内容のプレビュー(最初の100文字) + preview = doc.page_content[:100].replace('\n', ' ') + logger.info(f"内容プレビュー: {preview}{'...' if len(doc.page_content) > 100 else ''}") + + # 残りのドキュメント数を表示 + if len(docs) > max_display: + logger.info(f"\n...他 {len(docs) - max_display} 件省略") diff --git a/src/vector/vector_builder.py b/src/vector/vector_builder.py new file mode 100644 index 0000000000000000000000000000000000000000..a1eb1347c7c806389636eec33863101b4fe6809e --- /dev/null +++ b/src/vector/vector_builder.py @@ -0,0 +1,113 @@ +""" +ベクトルストア構築ユーティリティ +""" + +import logging +import os +from typing import List, Optional + +from dotenv import load_dotenv +from langchain_chroma import Chroma +from langchain_core.documents import Document +from langchain_openai import OpenAIEmbeddings + +from .embedding_storage import save_embeddings +from .loader import load_chunks_from_json, display_document_info + +logger = logging.getLogger(__name__) + +# .envファイルからAPIキーを読み込む +load_dotenv(dotenv_path=".env") + + +def build_vector_store( + docs_path: str = 'data/processed/chunks.json', + persist_dir: str = 'data/vector_store', + model_name: str = 'text-embedding-3-small', + save_embeddings_to_file: bool = True, + embeddings_dir: str = 'data/embeddings' +) -> None: + """ + ドキュメントチャンクからベクトルストアを構築し、必要に応じて埋め込みも保存する + + Args: + docs_path: ドキュメントチャンクが格納されたJSONファイルのパス + persist_dir: ベクトルストアを保存するディレクトリ + model_name: 使用するOpenAI埋め込みモデル名 + save_embeddings_to_file: 埋め込みを別ファイルに保存するかどうか + embeddings_dir: 埋め込みを保存するディレクトリ + """ + logger.debug("OPENAI_API_KEY set: %s", bool(os.getenv("OPENAI_API_KEY"))) + logger.info("全てのインポートが成功しました") + + # チャンクJSONからドキュメントを読み込む + docs_list = load_chunks_from_json(docs_path) + + # ドキュメント情報を表示 + display_document_info(docs_list) + + # OpenAI埋め込みを初期化 + embeddings = OpenAIEmbeddings(model=model_name) + logger.info(f"埋め込みモデル初期化済み: {model_name}") + + # ドキュメントが存在する場合のみベクトルストアを作成 + if docs_list: + # 全ドキュメントの埋め込みを生成 + texts = [doc.page_content for doc in docs_list] + logger.info(f"{len(texts)}件のドキュメントに対して埋め込みを生成中...") + vectors = embeddings.embed_documents(texts) + + # 埋め込みをファイルに保存する場合 + if save_embeddings_to_file: + embeddings_file = save_embeddings( + documents=docs_list, + vectors=vectors, + model_name=model_name, + embeddings_dir=embeddings_dir + ) + logger.info(f"埋め込みを保存しました: {embeddings_file}") + + # 生成した埋め込みでChromaベクトルストアを作成 + # Chromaは内部でストレージを管理するためfrom_documentsを利用 + db = Chroma.from_documents(docs_list, embeddings, persist_directory=persist_dir) + logger.info(f"ベクトルストアを作成し保存しました: {persist_dir}") + logger.info(f"{len(docs_list)}件のドキュメントチャンクをインデックス化しました") + else: + logger.error("インデックス化するドキュメントがありません。chunks.jsonファイルを確認してください。") + raise ValueError("入力ファイルにドキュメントが見つかりません") + + +def search_vector_store( + query: str, + persist_dir: str = 'data/vector_store', + k: int = 5, + model_name: str = 'text-embedding-3-small' +) -> List[Document]: + """ + ベクトルストアから関連ドキュメントを検索する + + Args: + query: 検索クエリ + persist_dir: ベクトルストアが保存されているディレクトリ + k: 取得するドキュメント数 + model_name: 使用するOpenAI埋め込みモデル名 + + Returns: + 関連ドキュメントのリスト + """ + # OpenAI埋め込みを初期化 + embeddings = OpenAIEmbeddings(model=model_name) + + # 保存済みベクトルストアを読み込む + db = Chroma(persist_directory=persist_dir, embedding_function=embeddings) + + # レトリーバーを作成 + retriever = db.as_retriever( + search_type='similarity', + search_kwargs={'k': k} + ) + + # 関連ドキュメントを取得 + docs = retriever.invoke(query) + + return docs