File size: 16,598 Bytes
0eaa714
 
d080187
9979c4a
 
 
 
 
 
 
 
56477ca
4029e21
 
e503f9a
 
9979c4a
4029e21
e503f9a
 
4029e21
 
9979c4a
 
 
 
 
 
 
 
 
 
 
e503f9a
4029e21
e503f9a
4029e21
d732366
 
 
 
 
4029e21
 
 
 
e503f9a
d732366
4029e21
 
e503f9a
 
4029e21
 
9979c4a
 
 
4029e21
9979c4a
 
 
 
 
 
 
 
 
 
 
 
 
 
e503f9a
4029e21
e503f9a
4029e21
 
 
 
e503f9a
4029e21
e503f9a
4029e21
 
 
e503f9a
4029e21
 
 
 
e503f9a
 
 
 
 
 
4029e21
 
 
9979c4a
4029e21
9979c4a
4029e21
9979c4a
 
4029e21
 
 
e503f9a
9979c4a
 
01c85f9
9979c4a
 
 
 
 
4029e21
 
 
 
 
 
 
 
9979c4a
 
4029e21
9979c4a
 
e503f9a
 
 
9979c4a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4029e21
 
9979c4a
 
 
 
 
 
 
 
 
e503f9a
9979c4a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e503f9a
9979c4a
 
 
e503f9a
 
 
 
 
5e292c8
4029e21
9979c4a
 
 
 
 
 
 
 
4029e21
9979c4a
 
 
 
 
 
4029e21
9979c4a
 
e503f9a
9979c4a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e503f9a
9979c4a
 
 
 
 
 
1955e7d
9979c4a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e503f9a
d080187
e503f9a
9979c4a
 
 
 
 
 
 
 
 
 
 
 
 
 
1955e7d
 
56477ca
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
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
import os
import gradio as gr
import logging
from typing import List, Dict, Any

# 配置日志
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)

# 从环境变量中获取 API Key
def get_api_key() -> str:
    api_key = os.environ.get("OPENAI_API_KEY")
    if not api_key:
        logger.error("环境变量中未找到 OPENAI_API_KEY")
        raise ValueError("环境变量中未找到 OPENAI_API_KEY,请先设置。")
    return api_key

# 初始化 LLM
def initialize_llm(api_key: str):
    try:
        from langchain_openai.chat_models import ChatOpenAI
        logger.info("初始化 LLM...")
        return ChatOpenAI(
            temperature=0,
            model="gpt-4o-mini",
            api_key=api_key
        )
    except Exception as e:
        logger.error(f"初始化 LLM 失败: {str(e)}")
        raise

# 定义 Prompt 模板
def initialize_prompt_template():
    from langchain_core.prompts import PromptTemplate
    template_text = """请根据以下 context 回答问题,答案请使用 Markdown 格式输出。
如果 context 存在latex表达式,请正确书写。
如果 context 中包含图表链接,请在回答中原封不动地加入图表,并在每个包含"/images"的链接前添加前缀 "https://huggingface.co/spaces/zliang/palynogeology/resolve/main"。
Question: {question}
Context: {context}"""
    return PromptTemplate(
        input_variables=["question", "context"],
        template=template_text
    )


# 将检索到的文档内容格式化为字符串
def format_docs(docs) -> str:
    return "\n\n".join(doc.page_content for doc in docs)

# 加载本地 FAISS 向量库
def initialize_vectorstore(api_key: str):
    try:
        from langchain_openai import OpenAIEmbeddings
        from langchain_community.vectorstores import FAISS

        logger.info("初始化向量存储...")
        embed = OpenAIEmbeddings(
            model="text-embedding-3-small",
            api_key=api_key
        )
        
        if not os.path.exists("faiss_db"):
            logger.error("未找到 'faiss_db' 目录")
            raise FileNotFoundError("未找到 'faiss_db' 目录,请先构建向量库。")
            
        return FAISS.load_local("faiss_db", embed, allow_dangerous_deserialization=True)
    except Exception as e:
        logger.error(f"初始化向量存储失败: {str(e)}")
        raise

# 构建检索器
def initialize_retriever(db):
    return db.as_retriever(
        search_type="mmr", 
        search_kwargs={"score_threshold": 0.5, "k": 3}
    )

# 构造问答链
def initialize_qa_chain(llm, prompt, retriever):
    from langchain_core.runnables import RunnablePassthrough
    from langchain_core.output_parsers import StrOutputParser

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

# 根据用户输入的问题调用问答链
def answer_question(qa_chain, question: str) -> str:
    try:
        logger.info(f"处理问题: {question}")
        answer = qa_chain.invoke(question)
        return answer
    except Exception as e:
        logger.error(f"调用问答链时出错: {str(e)}", exc_info=True)
        return "⚠️ 出错了,请稍后重试。如果问题持续存在,请联系管理员。"

# 返回《孢粉地质学》书籍简介
def get_book_introduction() -> str:
    introduction = """
# 《孢粉地质学》

![](https://huggingface.co/spaces/zliang/palynogeology/resolve/main/images/cover.jpg)

## 简介
《孢粉地质学》是一本系统介绍孢粉与孢子在地质学中应用的重要专著。本书由国内顶尖专家编撰,融合了最新的研究成果和实践经验。

## 内容亮点
- **孢粉与孢子的形成与保存**  
  阐述孢粉在沉积环境中的生成过程及保存机制。
- **鉴定与分类方法**  
  详细介绍如何通过形态学、化学特征对孢粉进行鉴定与分类。
- **地层对比与古环境重建**  
  探讨孢粉在地层划分、沉积环境重建、古气候研究等方面的应用。
- **案例分析与实践应用**  
  配合丰富的实例解析,为读者提供理论与实践相结合的指导。

## 适读人群
本书适合地质学、古生物学及相关领域的研究人员和学生参考,不仅为学术研究提供了坚实的理论基础,同时也为野外勘查和实际应用提供了实用工具。

👉 欢迎在"书籍问答"标签页中提问,获取更多关于书中内容的详细解读!
"""
    return introduction

# 定义常见问题列表
def get_faq_list() -> List[Dict[str, str]]:
    return [
        {"question": "孢粉是什么?", "category": "基础概念"},
        {"question": "如何采集孢粉样本?", "category": "实验方法"},
        {"question": "孢粉分析的主要步骤有哪些?", "category": "实验方法"},
        {"question": "如何鉴定孢粉的年代?", "category": "年代学"},
        {"question": "孢粉数据如何应用于地层对比?", "category": "地层学"},
        {"question": "常见的孢粉类型有哪些?", "category": "分类学"},
        {"question": "孢粉如何指示古气候变化?", "category": "古环境"},
        {"question": "孢粉研究在石油勘探中的应用", "category": "应用领域"},
        {"question": "孢粉与孢子有什么区别?", "category": "基础概念"},
        {"question": "电子显微镜在孢粉研究中的应用", "category": "技术方法"}
    ]

# 构建自定义 Gradio 界面
def create_custom_ui(qa_chain):
    # 主题配置
    theme = gr.Theme(
        primary_hue="green",
        secondary_hue="emerald",
        neutral_hue="gray",
        font=[gr.themes.GoogleFont("Source Sans Pro"), "system-ui", "sans-serif"],
    )
    
    # 自定义CSS
    custom_css = """
    .container {max-width: 1000px; margin: auto;}
    .header-logo {text-align: center; padding: 20px 0; margin-bottom: 20px;}
    .header-title {font-size: 2.5rem; font-weight: 700; margin: 0.5rem 0;}
    .header-subtitle {font-size: 1.25rem; color: #555; margin-bottom: 1rem;}
    .footer {text-align: center; margin-top: 40px; padding: 20px 0; font-size: 0.9rem; color: #666;}
    .card {border-radius: 10px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); padding: 20px; margin-bottom: 20px; background-color: white;}
    .faq-item {cursor: pointer; padding: 10px; border-radius: 5px; margin: 5px 0; transition: all 0.2s;}
    .faq-item:hover {background-color: #f0f9f0;}
    .category-tag {display: inline-block; font-size: 0.8rem; padding: 2px 8px; border-radius: 12px; background-color: #e0f2e0; color: #2e7d32; margin-left: 10px;}
    .loading-indicator {display: flex; justify-content: center; align-items: center; height: 100px;}
    .sample-questions-header {font-weight: 600; margin: 15px 0 10px 0;}
    .search-box {margin-bottom: 15px;}
    
    /* 自定义滚动条 */
    ::-webkit-scrollbar {width: 8px; height: 8px;}
    ::-webkit-scrollbar-track {background: #f1f1f1; border-radius: 10px;}
    ::-webkit-scrollbar-thumb {background: #c1e0c1; border-radius: 10px;}
    ::-webkit-scrollbar-thumb:hover {background: #8bc48b;}
    
    /* 响应式调整 */
    @media (max-width: 768px) {
        .header-title {font-size: 2rem;}
        .header-subtitle {font-size: 1rem;}
    }
    """
    
    with gr.Blocks(theme=theme, css=custom_css) as demo:
        # 注入 KaTeX 样式表以支持LaTeX公式渲染
        gr.HTML(
            '<link rel="stylesheet" '
            'href="https://cdn.jsdelivr.net/npm/katex@0.16.8/dist/katex.min.css" '
            'integrity="sha384-GvrOXuhMATgEsSwCs4smul74iXGOixntILdUW9XmUC6+HX0sLNAK3q71HotJqlAn" '
            'crossorigin="anonymous">'
        )
        
        # 应用标题和介绍
        with gr.Row(elem_classes="header-logo"):
            gr.HTML("""
            <div style="text-align: center;">
                <h1 class="header-title">《孢粉地质学》数字化知识库</h1>
                <p class="header-subtitle">探索孢粉地质学的奥秘,获取专业知识解答</p>
            </div>
            """)
        
        # 主内容区域
        with gr.Tabs() as tabs:
            # 书籍简介标签页
            with gr.TabItem("📚 书籍简介", id="intro"):
                with gr.Box(elem_classes="card"):
                    gr.Markdown(get_book_introduction())
            
            # 书籍问答标签页
            with gr.TabItem("❓ 知识问答", id="qa"):
                with gr.Row():
                    # 左侧:问答区域
                    with gr.Column(scale=7):
                        with gr.Box(elem_classes="card"):
                            gr.Markdown("### 📝 提问区")
                            with gr.Row():
                                question_input = gr.Textbox(
                                    lines=3, 
                                    placeholder="请输入您关于孢粉地质学的问题...", 
                                    label="问题",
                                    elem_classes="search-box"
                                )
                            
                            with gr.Row():
                                submit_button = gr.Button("提交问题", variant="primary")
                                clear_button = gr.Button("清空", variant="secondary")
                            
                            with gr.Box():
                                with gr.Row():
                                    status_indicator = gr.Markdown("准备就绪,等待提问...")
                        
                        with gr.Box(elem_classes="card", visible=False) as answer_card:
                            gr.Markdown("### 🔍 回答结果")
                            answer_output = gr.Markdown(
                                label="回答", 
                                elem_id="answer-output",
                                latex_delimiters=[
                                    {"left": "$", "right": "$", "display": False},
                                    {"left": "$$", "right": "$$", "display": True}
                                ]
                            )
                    
                    # 右侧:常见问题
                    with gr.Column(scale=3):
                        with gr.Box(elem_classes="card"):
                            gr.Markdown("### 📋 常见问题")
                            
                            # 分类筛选下拉框
                            category_filter = gr.Dropdown(
                                ["全部", "基础概念", "实验方法", "分类学", "年代学", "地层学", "古环境", "应用领域", "技术方法"],
                                value="全部",
                                label="按类别筛选"
                            )
                            
                            faq_container = gr.HTML()  # 用于显示FAQ的容器
                            
                            # 更新FAQ显示的函数
                            def update_faq_display(category):
                                faq_list = get_faq_list()
                                html = "<div class='faq-list'>"
                                
                                for item in faq_list:
                                    if category == "全部" or item["category"] == category:
                                        html += f"""
                                        <div class='faq-item' onclick='document.querySelector("[data-testid=textbox]").value = this.getAttribute("data-question"); document.querySelector("[data-testid=button]").click()' data-question='{item["question"]}'>
                                            {item["question"]}
                                            <span class='category-tag'>{item["category"]}</span>
                                        </div>
                                        """
                                
                                html += "</div>"
                                return html
                            
                            # 绑定更新事件
                            category_filter.change(update_faq_display, inputs=[category_filter], outputs=[faq_container])
            
            # 使用指南标签页
            with gr.TabItem("📖 使用指南", id="guide"):
                with gr.Box(elem_classes="card"):
                    gr.Markdown("""
                    # 使用指南
                    
                    ## 🔍 如何有效提问
                    
                    为了获得最准确的回答,建议您:
                    
                    1. **使用专业术语** - 尽量使用孢粉学和地质学的专业术语
                    2. **具体明确** - 问题越具体,回答越精准
                    3. **一次一问** - 每次提交一个问题,而不是多个问题组合
                    4. **参考示例** - 可以参考右侧的常见问题示例
                    
                    ## 📊 功能介绍
                    
                    本平台提供以下功能:
                    
                    - **书籍内容检索** - 快速获取《孢粉地质学》中的知识点
                    - **专业问题解答** - 解答孢粉学相关的各类专业问题
                    - **图例与公式** - 支持显示专业图例和数学公式
                    - **常见问题库** - 提供常见问题的快速访问
                    
                    ## ⚠️ 注意事项
                    
                    - 本平台不替代专业人士的建议
                    - 复杂图表可能需要等待较长时间加载
                    - 若遇到技术问题,请刷新页面或稍后再试
                    """)
        
        # 页脚
        with gr.Row(elem_classes="footer"):
            gr.HTML("""
            <div>
                <p>© 2025 孢粉地质学数字平台 | 由 GPT-4o 提供支持</p>
                <p>如有问题或建议,请联系我们</p>
            </div>
            """)
        
        # 函数:处理问题提交
        def process_question(question):
            if not question or question.strip() == "":
                return ("请输入有效的问题!", gr.update(visible=False))
            
            status = "🔍 正在检索相关知识..."
            yield (status, gr.update(visible=False))
            
            try:
                # 调用问答链获取答案
                answer = answer_question(qa_chain, question)
                status = "✅ 回答已生成"
                
                # 显示答案卡片
                return (status, gr.update(visible=True, value=answer))
            except Exception as e:
                logger.error(f"处理问题时出错: {str(e)}")
                status = "❌ 出错了:" + str(e)
                return (status, gr.update(visible=False))
        
        # 函数:清空输入和结果
        def clear_input():
            return "", "准备就绪,等待提问...", gr.update(visible=False)
        
        # 事件绑定
        submit_button.click(
            process_question, 
            inputs=[question_input], 
            outputs=[status_indicator, answer_card]
        )
        
        clear_button.click(
            clear_input, 
            inputs=[], 
            outputs=[question_input, status_indicator, answer_card]
        )
        
        # 初始化FAQ显示
        demo.load(
            update_faq_display, 
            inputs=[category_filter], 
            outputs=[faq_container]
        )
    
    return demo

def main():
    try:
        logger.info("启动应用...")
        api_key = get_api_key()
        llm = initialize_llm(api_key)
        prompt = initialize_prompt_template()
        db = initialize_vectorstore(api_key)
        retriever = initialize_retriever(db)
        qa_chain = initialize_qa_chain(llm, prompt, retriever)
        app = create_custom_ui(qa_chain)
        app.launch(share=False)
    except Exception as e:
        logger.critical(f"应用启动失败: {str(e)}", exc_info=True)
        print(f"错误: {str(e)}")
        print("请检查日志以获取详细信息。")

if __name__ == "__main__":
    main()