File size: 5,539 Bytes
a44c13d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
import os
from pathlib import Path
from typing import List, Dict, Any

import gradio as gr
from langchain_community.document_loaders import TextLoader, PyPDFLoader, Docx2txtLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_huggingface import HuggingFaceEmbeddings
from langchain_community.vectorstores import FAISS
from langchain_core.prompts import PromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough
from langchain_openai import ChatOpenAI


# LLM 設定(強烈建議使用環境變數)
llm = ChatOpenAI(
    base_url=os.getenv("LITELLM_BASE_URL"),
    api_key=os.getenv("OPENAI_API_KEY"),
    model=os.getenv("LLM_MODEL", "azure-gpt-4.1"),  # 改用更常見的預設模型
    temperature=0.3,
)

# Embedding 模型(中文效果很好的小模型)
embeddings = HuggingFaceEmbeddings(model_name="BAAI/bge-small-zh-v1.5")


def format_docs(docs):
    return "\n\n".join(doc.page_content for doc in docs)


prompt = PromptTemplate.from_template(
    """你是一個有幫助且誠實的助手,請根據以下提供的上下文來回答問題。
如果上下文不足以回答,請直接說「根據提供的文件,我無法回答這個問題。」,不要編造答案。

上下文:
{context}

問題:{question}
回答:"""
)


# 上傳並建立向量庫
def upload_and_build_db(files: List[Any], vectorstore_state: FAISS | None):
    if not files:
        return "請上傳至少一個文件。", None, vectorstore_state

    docs = []
    for file in files:
        # Gradio 上傳的 file 是 tempfile.NamedTemporaryFile 物件,有 .name 屬性
        file_path = file.name

        try:
            if file_path.lower().endswith(".pdf"):
                loader = PyPDFLoader(file_path)
            elif file_path.lower().endswith(".docx"):
                loader = Docx2txtLoader(file_path)
            else:
                loader = TextLoader(file_path, encoding="utf-8")
            
            loaded_docs = loader.load()
            docs.extend(loaded_docs)
        except Exception as e:
            return f"載入檔案失敗:{os.path.basename(file_path)},錯誤:{str(e)}", None, vectorstore_state

    if not docs:
        return "沒有成功載入任何文件內容。", None, vectorstore_state

    # 分塊
    text_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)
    splits = text_splitter.split_documents(docs)

    # 建立新向量庫
    new_vectorstore = FAISS.from_documents(splits, embeddings)

    success_msg = f"成功載入 {len(docs)} 個文件,共 {len(splits)} 個區塊,已建立專屬知識庫!現在可以開始提問。"
    return success_msg, None, new_vectorstore


# RAG 回答(使用新版 Gradio messages 格式)
def rag_answer(question: str, history: List[Dict], vectorstore_state: FAISS | None):
    if vectorstore_state is None:
        new_message = {"role": "assistant", "content": "請先上傳文件並點擊「建立知識庫」。"}
        return "", history + [new_message]

    retriever = vectorstore_state.as_retriever(search_kwargs={"k": 4})

    chain = (
        {"context": retriever | format_docs, "question": RunnablePassthrough()}
        | prompt
        | llm
        | StrOutputParser()
    )

    try:
        response = chain.invoke(question)
    except Exception as e:
        response = f"回答時發生錯誤:{str(e)}"

    # 新格式:加入 user 和 assistant 兩條訊息
    new_history = history + [
        {"role": "user", "content": question},
        {"role": "assistant", "content": response}
    ]

    return "", new_history


# 清除聊天記錄
def clear_chat():
    return "", []


# Gradio 介面
with gr.Blocks(title="個人 RAG 問答系統", theme=gr.themes.Soft()) as demo:
    gr.Markdown("# 📚 個人 RAG 問答系統\n上傳你的 TXT、PDF、DOCX 文件,建立專屬知識庫,然後向它提問!")

    vectorstore_state = gr.State(None)

    with gr.Row():
        file_input = gr.File(
            label="上傳文件(支援 .txt、.pdf、.docx,可多檔)",
            file_count="multiple",
            type="filepath"
        )

    with gr.Row():
        build_btn = gr.Button("建立知識庫", variant="primary", scale=1)
        clear_btn = gr.Button("清除對話", variant="secondary", scale=1)

    status = gr.Textbox(label="狀態訊息", interactive=False)

    # 使用新版 messages 格式,明確初始化為空列表
    chatbot = gr.Chatbot(
        height=500,
        value=[],
        label="對話紀錄",
        avatar_images=("https://em-content.zobj.net/source/apple/391/man-technologist_1f468-200d-1f4bb.png",
                        "https://em-content.zobj.net/source/apple/391/robot_1f916.png")
    )

    msg = gr.Textbox(
        label="你的問題",
        placeholder="在這裡輸入問題,按 Enter 送出...",
        scale=7
    )

    # 事件綁定
    build_btn.click(
        fn=upload_and_build_db,
        inputs=[file_input, vectorstore_state],
        outputs=[status, file_input, vectorstore_state]  # 清空檔案列表避免重複上傳
    )

    msg.submit(
        fn=rag_answer,
        inputs=[msg, chatbot, vectorstore_state],
        outputs=[msg, chatbot]
    )

    clear_btn.click(
        fn=clear_chat,
        inputs=None,
        outputs=[msg, chatbot]
    )

# Hugging Face Spaces 建議加上 share=True 產生公開連結(本地測試可關閉)
demo.launch()