import os import gradio as gr from pypdf import PdfReader from sentence_transformers import SentenceTransformer import chromadb from chromadb.utils import embedding_functions import ollama # Ollamaライブラリをインポート from langchain_text_splitters import RecursiveCharacterTextSplitter import uuid # For generating unique IDs for chunks from dotenv import load_dotenv # from pdfminer.high_level import extract_text as pdfminer_extract_text # LLMクライアントのインポート from openai import OpenAI import anthropic import google.generativeai as genai import sys print(f"Python executable: {sys.executable}") print(f"Python version: {sys.version}") print(f"Python version info: {sys.version_info}") print(f"--------------------------") # .envファイルから環境変数を読み込む load_dotenv() # --- APIキーの取得 --- OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY") GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY") # --- Ollamaクライアントの初期化 --- client_ollama = ollama.Client() OLLAMA_MODEL_NAME = "llama3.2" # OLLAMA_MODEL_NAME = "llama3:8b-instruct-q4_0" client_openai = None OPENAI_MODEL_NAME = "gpt-4o-mini" if OPENAI_API_KEY: try: client_openai = OpenAI(api_key=OPENAI_API_KEY) print(f"OpenAIクライアントを初期化しました (モデル: {OPENAI_MODEL_NAME})。") except Exception as e: print(f"OpenAIクライアントの初期化に失敗しました: {e}") client_openai = None else: print("OPENAI_API_KEYが設定されていません。OpenAIモデルは利用できません。") client_anthropic = None ANTHROPIC_MODEL_NAME = "claude-3-haiku-20240307" if ANTHROPIC_API_KEY: try: client_anthropic = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY) print(f"Anthropicクライアントを初期化しました (モデル: {ANTHROPIC_MODEL_NAME})。") except Exception as e: print(f"Anthropicクライアントの初期化に失敗しました: {e}") client_anthropic = None else: print("ANTHROPIC_API_KEYが設定されていません。Anthropicモデルは利用できません。") client_gemini = None GOOGLE_MODEL_NAME = "gemini-2.5-flash" if GOOGLE_API_KEY: try: genai.configure(api_key=GOOGLE_API_KEY) client_gemini = genai.GenerativeModel(GOOGLE_MODEL_NAME) print(f"Google Geminiクライアントを初期化しました (モデル: {GOOGLE_MODEL_NAME})。") except Exception as e: print(f"Google Geminiクライアントの初期化に失敗しました: {e}") client_gemini = None else: print("GOOGLE_API_KEYが設定されていません。Google Geminiモデルは利用できません。") # --- 埋め込みモデルの初期化 --- # 重複定義を削除し、1回のみ初期化 embedding_model = SentenceTransformer('pkshatech/GLuCoSE-base-ja') # 日本語対応の埋め込みモデル # --- ChromaDBのカスタム埋め込み関数 --- # 重複定義を削除し、1回のみ定義 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クライアントとコレクションの初期化 --- # インメモリモードで動作させ、アプリケーション起動時にコレクションをリセットします。 # グローバル変数としてクライアントを保持 client = chromadb.Client() collection_name = "pdf_documents_collection" # アプリケーション起動時にコレクションが存在すれば削除し、新しく作成する # (インメモリDBはセッションごとにリセットされるため、これは初回起動時のみ意味を持つ) try: client.delete_collection(name=collection_name) print(f"既存のChromaDBコレクション '{collection_name}' を削除しました。") except Exception as e: # コレクションが存在しない場合はエラーになるので無視。デバッグ用にメッセージは出力。 print(f"ChromaDBコレクション '{collection_name}' の削除に失敗しました (存在しないか、その他のエラー): {e}") pass collection = client.get_or_create_collection(name=collection_name, embedding_function=sbert_ef) print(f"ChromaDBコレクション '{collection_name}' を初期化しました。") 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ファイルからテキストを抽出する""" print(f"Attempting to extract text from: {pdf_file_path}") try: reader = PdfReader(pdf_file_path) text = "" if not reader.pages: print(f" PDF '{os.path.basename(pdf_file_path)}' contains no pages.") return "ERROR: PDFにページが含まれていません。" # プレフィックスを追加 for i, page in enumerate(reader.pages): page_text = page.extract_text() if page_text: text += page_text + "\n" # print(f" Page {i+1} extracted text (first 100 chars): {page_text[:100].replace('\n', ' ')}...") cleaned_page_text = page_text[:100].replace('\n', ' ') print(f" Page {i+1} extracted text (first 100 chars): {cleaned_page_text}...") else: print(f" Page {i+1} extracted no text.") if not text.strip(): print(" No text extracted from any page.") return "ERROR: PDFからテキストを抽出できませんでした。画像ベースのPDFかもしれません。" # プレフィックスを追加 print(f" Total text extracted (length: {len(text)}).") return text except Exception as e: print(f" Error during PDF reading: {e}") return f"ERROR: PDFの読み込み中にエラーが発生しました: {e}" # プレフィックスを追加 def get_llm_response(selected_llm, query, context, source_code_to_check): """選択されたLLMを使用して質問に回答する""" system_prompt = "あなたは提供されたコンテキスト(ソースコードチェックリスト)とレビュー対象のソースコードに基づいて、ソースコードをチェックし、その結果を返す有益なアシスタントです。チェックリストの項目ごとにソースコードを評価し、具体的な指摘と改善案を提示してください。コンテキストに情報がない場合は、「提供された情報からは回答できません。」と答えてください。" user_content = f"ソースコードチェックリスト:\n{context}\n\nレビュー対象のソースコード:\n```\n{source_code_to_check}\n```\n\nレビュー指示: {query}\n\nチェック結果:" try: if selected_llm == "Ollama": if not client_ollama: return "Ollamaクライアントが初期化されていません。" messages = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_content} ] print(f"Ollamaモデル '{OLLAMA_MODEL_NAME}' にリクエストを送信中...") response = client_ollama.chat( model=OLLAMA_MODEL_NAME, messages=messages, options={ "temperature": 0.7, "num_predict": 2000 } ) if 'message' in response and 'content' in response['message']: return response['message']['content'].strip() else: return f"Ollamaからの応答形式が不正です: {response}" elif selected_llm == "GPT": if not client_openai: return "OpenAI APIキーが設定されていないため、GPTモデルは利用できません。" messages = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_content} ] print(f"GPTモデル '{OPENAI_MODEL_NAME}' にリクエストを送信中...") response = client_openai.chat.completions.create( model=OPENAI_MODEL_NAME, messages=messages, temperature=0.5, max_tokens=2000 ) return response.choices[0].message.content.strip() elif selected_llm == "Anthropic": if not client_anthropic: return "Anthropic APIキーが設定されていないため、Anthropicモデルは利用できません。" messages = [ {"role": "user", "content": user_content} ] print(f"Anthropicモデル '{ANTHROPIC_MODEL_NAME}' にリクエストを送信中...") response = client_anthropic.messages.create( model=ANTHROPIC_MODEL_NAME, max_tokens=2000, temperature=0.5, system=system_prompt, # Anthropicはsystemプロンプトを直接引数で渡す messages=messages ) return response.content[0].text.strip() elif selected_llm == "Google Gemini": if not client_gemini: return "Google APIキーが設定されていないため、Geminiモデルは利用できません。" # Geminiのsystem instructionはまだベータ版で、messagesと併用できない場合があるため、 # system_promptをuser_contentの先頭に結合する形式にする。 # --- システムプロンプトの調整 (後述の2.プロンプト調整も参照) --- system_prompt = "あなたは提供されたコンテキスト(ソースコードチェックリスト)とレビュー対象のソースコードに基づいて、ソースコードのレビューを行うアシスタントです。チェックリストの項目ごとにソースコードを評価し、潜在的な問題点や改善の機会を提案してください。コンテキストに情報がない場合は、「提供された情報からは回答できません。」と答えてください。" # --- ユーザープロンプトの調整 (後述の2.プロンプト調整も参照) --- user_content = f"ソースコードチェックリスト:\n{context}\n\nレビュー対象のソースコード:\n```\n{source_code_to_check}\n```\n\nレビュー指示: {query}\n\nチェック結果:" full_user_content = f"{system_prompt}\n\n{user_content}" messages = [ {"role": "user", "parts": [full_user_content]} ] print(f"Google Geminiモデル '{GOOGLE_MODEL_NAME}' にリクエストを送信中...") try: response = client_gemini.generate_content( messages, generation_config=genai.types.GenerationConfig( temperature=0.5, # まずは0.5で試す。必要なら0.7などに上げる max_output_tokens=2000 ) ) # --- エラーハンドリングの強化 --- # response.text を呼び出す前に、応答の候補と終了理由を確認 if response.candidates: candidate = response.candidates[0] # finish_reason が SAFETY (genai.types.HarmCategory.SAFETY) の場合、安全ポリシーによりブロックされた可能性が高い if candidate.finish_reason == genai.types.HarmCategory.SAFETY: safety_ratings = candidate.safety_ratings safety_details = ", ".join([f"{sr.category.name}: {sr.probability.name}" for sr in safety_ratings]) print(f"Gemini response blocked due to safety policy. Details: {safety_details}") return f"Google Geminiからの応答が安全ポリシーによりブロックされました。詳細: {safety_details}" # 正常なコンテンツがあるか確認 elif candidate.content and candidate.content.parts: return response.text.strip() else: # コンテンツがないが、finish_reasonがSAFETY以外の場合 print(f"Gemini response has no content parts. Finish reason: {candidate.finish_reason.name}") return f"Google Geminiからの応答にコンテンツが含まれていません。終了理由: {candidate.finish_reason.name}" else: # 候補自体がない場合 print(f"Gemini response has no candidates. Raw response: {response}") return f"Google Geminiからの応答に候補がありませんでした。生の応答: {response}" except Exception as e: # generate_content 自体でエラーが発生した場合 print(f"Google Gemini API呼び出し中にエラーが発生しました: {e}") return f"Google Gemini API呼び出し中にエラーが発生しました: {e}" else: return "無効なLLMが選択されました。" except Exception as e: print(f"LLM ({selected_llm}) の呼び出し中にエラーが発生しました: {e}") return f"LLM ({selected_llm}) の呼び出し中にエラーが発生しました: {e}" def upload_pdf_and_process(pdf_files): """複数のPDFファイルをアップロードし、テキストを抽出し、ChromaDBに登録する""" if not pdf_files: print("No PDF files uploaded.") return "PDFファイルがアップロードされていません。", gr.update(interactive=False), gr.update(interactive=False) processed_files_count = 0 total_chunks_added = 0 all_status_messages = [] 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}' を処理中...") print(f"Processing PDF: {file_name} (Temporary Path: {pdf_path})") # 1. PDFからテキストを抽出 raw_text = extract_text_from_pdf(pdf_path) # --- デバッグ用追加コード (前回のデバッグで追加したものは残しておくと良いでしょう) --- print(f"DEBUG: raw_text received from extract_text_from_pdf (length: {len(raw_text)})") # print(f"DEBUG: raw_text starts with: '{raw_text[:100].replace(newline_char, ' ')}'") print(f"DEBUG: 'エラー' in raw_text: {'エラー' in raw_text}") print(f"DEBUG: '抽出できませんでした' in raw_text: {'抽出できませんでした' in raw_text}") print(f"DEBUG: 'PDFにページが含まれていません' in raw_text: {'PDFにページが含まれていません' in raw_text}") # --- デバッグ情報ここまで --- # エラープレフィックスでチェックするように変更 if raw_text.startswith("ERROR:"): # ここを変更 all_status_messages.append(raw_text) print(f"Error during text extraction from {file_name}: {raw_text}") # ログメッセージも変更 continue # 次のファイルへ # --- デバッグ用追加コード --- print(f"\n--- Raw text extracted from {file_name} (length: {len(raw_text)}, first 500 chars) ---") print(raw_text[:500]) print(f"--- End of raw text from {file_name} ---\n") # 2. テキストをチャンクに分割 chunks = text_splitter.split_text(raw_text) if not chunks: all_status_messages.append(f"'{file_name}' から有効なテキストチャンクを抽出できませんでした。") print(f"No valid chunks extracted from {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))] 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)}個のチャンクがデータベースに登録されました。") print(f"Finished processing {file_name}. Added {len(chunks)} chunks.") except Exception as e: all_status_messages.append(f"PDFファイル '{os.path.basename(pdf_file.name)}' 処理中に予期せぬエラーが発生しました: {e}") print(f"Unexpected error during processing {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), gr.update(interactive=True) def answer_question(question, source_code, selected_llm): """ChromaDBから関連情報を取得し、選択されたLLMで質問に回答する""" if not question and not source_code: return "質問またはレビュー対象のソースコードを入力してください。", "" if collection.count() == 0: return "PDFがまだアップロードされていないか、処理されていません。まずPDFをアップロードしてください。", "" try: print(f"Searching ChromaDB for question: {question}") results = collection.query( query_texts=[question], n_results=8 ) context_chunks = results['documents'][0] if results['documents'] else [] if not context_chunks: print("No relevant context chunks found in ChromaDB.") return "関連する情報が見つかりませんでした。質問を明確にするか、別のPDFを試してください。", "" context = "\n\n".join(context_chunks) print(f"Retrieved context (first 500 chars):\n{context[:500]}...") answer = get_llm_response(selected_llm, question, context, source_code) return answer, context except Exception as e: print(f"質問応答中に予期せぬエラーが発生しました: {e}") return f"質問応答中に予期せぬエラーが発生しました: {e}", "" # --- Gradio UIの構築 --- with gr.Blocks() as gradioUI: gr.Markdown( f""" # PDF Q&A with Local LLM (Ollama: {OLLAMA_MODEL_NAME}) and Vector Database PDFファイルとしてソースコードチェックリストをアップロードし、レビューしたいソースコードを入力してください。 **複数のPDFファイルを同時にアップロードできます。** 利用するLLMを選択してください。 """ ) with gr.Row(): with gr.Column(): 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(): # LLM選択コンポーネント llm_options = ["Ollama"] if client_openai: llm_options.append("GPT") if client_anthropic: llm_options.append("Anthropic") if client_gemini: llm_options.append("Google Gemini") llm_choice = gr.Radio( llm_options, label="使用するLLMを選択", value=llm_options[0] if llm_options else None, # 利用可能な最初のLLMをデフォルトにする interactive=True ) source_code_input = gr.Code( label="レビュー対象のソースコード (ここにソースコードを貼り付けてください)", value="", language="python", interactive=False, # PDFアップロード後に有効化 lines=15 ) question_input = gr.Textbox(label="レビュー指示(例: セキュリティの観点からレビュー)", placeholder="特定の観点からのレビュー指示を入力してください(任意)。", interactive=False) # PDFアップロード後に有効化 review_button = gr.Button("レビュー開始") answer_output = gr.Markdown(label="レビュー結果") retrieved_context_output = gr.Textbox(label="取得されたチェックリスト項目", interactive=False, lines=10) pdf_input.upload( upload_pdf_and_process, inputs=[pdf_input], outputs=[upload_status, question_input, source_code_input] ) review_button.click( answer_question, inputs=[question_input, source_code_input, llm_choice], outputs=[answer_output, retrieved_context_output] ) # gradioUI.launch(server_name="localhost", server_port=7860) gradioUI.launch(server_name="0.0.0.0", server_port=7860)