File size: 10,303 Bytes
34d7f0c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
## 今後の対応予定
## 1. 複数のファイルの読み込み
## 2. OllamaやLlamaCppなどのローカルモデル対応

import os
import gradio as gr
from pypdf import PdfReader
from sentence_transformers import SentenceTransformer
import chromadb
from chromadb.utils import embedding_functions
from openai import OpenAI, OpenAIError
from langchain_text_splitters import RecursiveCharacterTextSplitter
import uuid # For generating unique IDs for chunks

openai_api_key = os.environ.get("OPENAI_API_KEY")
if not openai_api_key:
    raise ValueError("OPENAI_API_KEY環境変数が設定されていません。Hugging Face SpacesのSecretsで設定してください。")

clientai = OpenAI(api_key=openai_api_key)
# embedding_model = SentenceTransformer('all-MiniLM-L6-v2')
embedding_model = SentenceTransformer('pkshatech/GLuCoSE-base-ja')

# ChromaDBのカスタム埋め込み関数
class SBERTEmbeddingFunction(embedding_functions.EmbeddingFunction):
    def __init__(self, model):
        self.model = model
    def __call__(self, texts):
        # sentence-transformersモデルはnumpy配列を返すため、tolist()でPythonリストに変換
        return self.model.encode(texts).tolist()

sbert_ef = SBERTEmbeddingFunction(embedding_model)

# --- ChromaDBクライアントとコレクションの初期化 ---
# インメモリモードで動作させ、新しいPDFがアップロードされるたびにコレクションをリセットします。
# グローバル変数としてクライアントを保持
client = chromadb.Client()
collection_name = "pdf_documents_collection"
# コレクションが存在すれば削除し、新しく作成する(新しいPDFごとにリセットするため)
# (Hugging Face SpacesのインメモリDBはセッションごとにリセットされるため、これは初回起動時のみ意味を持つ)
try:
    client.delete_collection(name=collection_name)
except:
    pass # コレクションが存在しない場合はエラーになるので無視
collection = client.get_or_create_collection(name=collection_name, embedding_function=sbert_ef)

text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000,      # チャンクの最大文字数
    chunk_overlap=150,   # チャンク間のオーバーラップ文字数
    length_function=len, # 文字数で長さを計算
    separators=["\n\n", "\n", " ", ""] # 分割の優先順位
)

# --- ヘルパー関数 ---
def extract_text_from_pdf(pdf_file_path):
    """PDFファイルからテキストを抽出する"""
    try:
        reader = PdfReader(pdf_file_path)
        text = ""
        for page in reader.pages:
            text += page.extract_text() + "\n"
        return text
    except Exception as e:
        return f"PDFの読み込み中にエラーが発生しました: {e}"

def get_gpt_response(query, context):
    """GPTモデルを使用して質問に回答する"""
    messages = [
        {"role": "system", "content": "あなたは提供されたコンテキストに基づいて質問に答える有益なアシスタントです。コンテキストに情報がない場合は、「提供された情報からは回答できません。」と答えてください。コンテキスト内の情報のみを使用し、自身の知識で補完しないでください。"},
        {"role": "user", "content": f"コンテキスト:\n{context}\n\n質問: {query}\n\n回答:"}
    ]
    try:
        response = clientai.chat.completions.create(
            model="gpt-4o-mini",
            messages=messages,
            max_tokens=900, # 回答の最大トークン数
            temperature=0.5, # 創造性の度合い
        )
        return response.choices[0].message.content.strip()
    except OpenAIError as e:
        return f"OpenAI APIエラー: {e}"
    except Exception as e:
        return f"GPTモデルの呼び出し中に予期せぬエラーが発生しました: {e}"

def upload_pdf_and_process(pdf_files): # 引数を複数ファイル対応に変更
    """複数のPDFファイルをアップロードし、テキストを抽出し、ChromaDBに登録する"""
    if not pdf_files: # リストが空かどうかでチェック
        return "PDFファイルがアップロードされていません。", gr.update(interactive=False)

    processed_files_count = 0
    total_chunks_added = 0
    all_status_messages = []

    # コレクションをクリアする処理は削除。既存のコレクションに追加していく。
    # グローバル変数 collection は既に初期化されているものを使用。

    for pdf_file in pdf_files:
        try:
            pdf_path = pdf_file.name
            file_name = os.path.basename(pdf_path)
            all_status_messages.append(f"PDFファイル '{file_name}' を処理中...")

            # 1. PDFからテキストを抽出
            raw_text = extract_text_from_pdf(pdf_path)
            if "エラー" in raw_text:
                all_status_messages.append(raw_text)
                continue # 次のファイルへ

            # 2. テキストをチャンクに分割
            chunks = text_splitter.split_text(raw_text)
            if not chunks:
                all_status_messages.append(f"'{file_name}' から有効なテキストチャンクを抽出できませんでした。")
                continue # 次のファイルへ

            # 3. チャンクをChromaDBに登録
            documents = chunks
            # メタデータにファイル名を追加
            metadatas = [{"source": file_name, "chunk_index": i} for i in range(len(chunks))]
            ids = [str(uuid.uuid4()) for _ in range(len(chunks))] # 各チャンクにユニークなIDを付与

            collection.add(
                documents=documents,
                metadatas=metadatas,
                ids=ids
            )
            processed_files_count += 1
            total_chunks_added += len(chunks)
            all_status_messages.append(f"PDFファイル '{file_name}' の処理が完了しました。{len(chunks)}個のチャンクがデータベースに登録されました。")

        except Exception as e:
            all_status_messages.append(f"PDFファイル '{os.path.basename(pdf_file.name)}' 処理中に予期せぬエラーが発生しました: {e}")
            continue # 次のファイルへ

    final_status_message = f"{processed_files_count}個のPDFファイルの処理が完了しました。合計{total_chunks_added}個のチャンクがデータベースに登録されました。質問を入力してください。\n\n" + "\n".join(all_status_messages)
    return final_status_message, gr.update(interactive=True) # 質問入力欄を有効化

def answer_question(question):
    """ChromaDBから関連情報を取得し、GPTモデルで質問に回答する"""
    if not question:
        return "質問を入力してください。","" # コンテキストも返すように変更

    if collection.count() == 0:
        return "PDFがまだアップロードされていないか、処理されていません。まずPDFをアップロードしてください。"

    try:
        # 1. 質問を埋め込みに変換し、ChromaDBを検索
        # ChromaDBのqueryメソッドは、埋め込みまたはテキストを受け取ります。
        # ここではテキストを直接渡して、ChromaDBのembedding_functionに処理させます。
        results = collection.query(
            query_texts=[question],
            n_results=8 # 上位5つの関連チャンクを取得
        )

        # 2. 検索結果からコンテキストを構築
        context_chunks = results['documents'][0] if results['documents'] else []
        if not context_chunks:
            return "関連する情報が見つかりませんでした。質問を明確にするか、別のPDFを試してください。"

        context = "\n\n".join(context_chunks)

        # 3. GPTモデルで回答を生成
        answer = get_gpt_response(question, context)
        return answer, context # 回答とコンテキストを両方返す
    except Exception as e:
        return f"質問応答中に予期せぬエラーが発生しました: {e}"

# --- Gradio UIの構築 ---
with gr.Blocks() as gradioUI:
    gr.Markdown(
        """

        # PDF Q&A with GPT-4o-mini and Vector Database

        PDFファイルをアップロードし、その内容について質問してください。

        **複数のPDFファイルを同時にアップロードできます。**

        "gpt-4o-mini"を使用しています。

        """
    )

    with gr.Row():
        with gr.Column():
            # file_count="multiple" を追加して複数ファイル対応にする
            pdf_input = gr.File(label="PDFドキュメントをアップロード", file_types=[".pdf"], file_count="multiple")
            upload_status = gr.Textbox(label="ステータス", interactive=False, value="PDFをアップロードしてください。", lines=5)

        with gr.Column():
            question_input = gr.Textbox(label="質問を入力", placeholder="このドキュメントは何について書かれていますか?", interactive=False)
            answer_output = gr.Markdown(label="回答")
            # 動作確認用コンポーネントを追加
            retrieved_context_output = gr.Textbox(label="取得されたコンテキスト", interactive=False, lines=10)

    # コンポーネント定義後にイベントリスナーを設定する
    # PDFアップロード時に処理を実行
    pdf_input.upload(
        upload_pdf_and_process,
        inputs=[pdf_input],
        outputs=[upload_status, question_input] # question_input が定義済みなのでエラーにならない
    )

    # 質問入力時に処理を実行
    question_input.submit(
        answer_question,
        inputs=[question_input],
        outputs=[answer_output, retrieved_context_output] # 2つの出力を受け取るように変更
    )

gradioUI.launch()