import os import time import json import gradio as gr from neo4j import GraphDatabase from langchain_community.graphs import Neo4jGraph from langchain.chains import GraphCypherQAChain from langchain_openai import ChatOpenAI from langchain_core.prompts import PromptTemplate from langchain_community.utilities import WikipediaAPIWrapper from langchain_community.tools import WikipediaQueryRun from pyvis.network import Network import html import tempfile BIOCHAR_QUERY_TEMPLATES = """ Biochar remediation query patterns: 1. Pollutant <- treats - Biochar MATCH (b:Biochar)-[r:TREATS]->(p:Pollutant) WHERE toLower(p.name) CONTAINS toLower($keyword) OR $keyword IN coalesce(p.aliases, []) RETURN b.name, type(r), p.name 2. Biochar -> prepared_by -> PreparationMethod MATCH (b:Biochar)-[:PREPARED_BY]->(m:PreparationMethod) WHERE toLower(b.name) CONTAINS toLower($keyword) OR $keyword IN coalesce(b.aliases, []) RETURN b.name, m.name 3. Biochar -> has_property -> Property MATCH (b:Biochar)-[:HAS_PROPERTY]->(prop:Property) WHERE toLower(b.name) CONTAINS toLower($keyword) OR $keyword IN coalesce(b.aliases, []) RETURN b.name, prop.name 4. Biochar / Pollutant -> removes_via -> Mechanism MATCH (n)-[:REMOVES_VIA]->(m:Mechanism) WHERE (n:Biochar OR n:Pollutant) RETURN n.name, labels(n), m.name 5. Biochar -> derived_from -> Feedstock MATCH (b:Biochar)-[:DERIVED_FROM]->(f:Feedstock) RETURN b.name, f.name 6. Biochar / ApplicationScenario -> applied_in -> EnvironmentMedium MATCH (n)-[:APPLIED_IN]->(env:EnvironmentMedium) WHERE n:Biochar OR n:ApplicationScenario RETURN n.name, env.name 7. Biochar / PreparationMethod / ApplicationScenario -> performs_under -> Condition MATCH (n)-[:PERFORMS_UNDER]->(c:Condition) RETURN n.name, labels(n), c.name 8. Biochar / Property -> characterized_by -> CharacterizationMethod MATCH (n)-[:CHARACTERIZED_BY]->(cm:CharacterizationMethod) WHERE n:Biochar OR n:Property RETURN n.name, cm.name """ BIOCHAR_CYPHER_TEMPLATE = """You are an expert Neo4j Cypher generator for a biochar environmental remediation knowledge graph. Task: - Generate a read-only Cypher query that answers the user's question. - Use only the relationship types and node labels present in the provided schema. - Prefer precise, compact queries over broad graph expansion. Domain priorities: - Most important paths: Biochar-[:TREATS]->Pollutant Biochar-[:PREPARED_BY]->PreparationMethod Biochar-[:HAS_PROPERTY]->Property Biochar-[:REMOVES_VIA]->Mechanism Biochar-[:DERIVED_FROM]->Feedstock Biochar-[:APPLIED_IN]->EnvironmentMedium Biochar-[:PERFORMS_UNDER]->Condition Biochar|Property-[:CHARACTERIZED_BY]->CharacterizationMethod - `Chunk` nodes are evidence containers. Use them only when the question asks for evidence, source text, or supporting context. - Prefer matching on `name`; when helpful, also consider `aliases`. - When the user asks "which", "what", "list", or "show", return distinct names and a small number of relevant supporting fields. - When the user asks about mechanisms, prioritize `REMOVES_VIA`. - When the user asks about synthesis or modification, prioritize `PREPARED_BY` and `DERIVED_FROM`. - When the user asks about material characteristics, prioritize `HAS_PROPERTY`. - When the user asks about environmental application, prioritize `APPLIED_IN` and `PERFORMS_UNDER`. Query rules: - Return at most 15 rows unless the user explicitly asks for more. - Use `DISTINCT` whenever duplicates are likely. - Do not write, delete, merge, call procedures, or use APOC. - Do not invent labels or relationships. - If multiple hop paths are needed, keep them biologically and chemically meaningful. - If the question names a specific pollutant or biochar, filter with `toLower(n.name) CONTAINS toLower("...")` and optionally `ANY(alias IN coalesce(n.aliases, []) WHERE toLower(alias) CONTAINS toLower("..."))`. Helpful query patterns: {query_templates} Schema: {schema} Question: {question} Return only the Cypher query text. """ def extract_visualization_keywords(message, api_key, base_url): llm_kwargs = {"model": "gpt-4o-mini", "temperature": 0, "openai_api_key": api_key} if base_url and base_url.strip(): llm_kwargs["base_url"] = base_url.strip() llm = ChatOpenAI(**llm_kwargs) prompt = f"""你要为知识图谱可视化提取检索关键词。 用户问题可能是中文、英文或中英混合。请尽量返回图谱中最可能存在的实体关键词候选,优先返回英文学术名称;如果原问题里有中文术语,请同时保留中文候选。 候选可以来自这些类别: - 污染物 - 生物炭材料 - 原料 - 制备方法 - 性质 - 机理 要求: 1. 返回 1 到 3 个候选短语。 2. 优先返回文献和知识图谱中最可能使用的标准英文名称。 3. 如果问题是中文,尽量给出对应英文术语。 4. 只返回 JSON,不要解释。 输出格式: {{"keywords": ["candidate 1", "candidate 2"]}} 问题:{message} """ content = llm.invoke(prompt).content.strip() try: payload = json.loads(content) keywords = payload.get("keywords") or [] keywords = [str(k).strip() for k in keywords if str(k).strip()] except Exception: keywords = [content] keywords.append(message.strip()) normalized = [] seen = set() for keyword in keywords: if keyword and keyword not in seen: seen.add(keyword) normalized.append(keyword) return normalized[:4] def rewrite_question_for_graph_search(message, api_key, base_url): llm_kwargs = {"model": "gpt-4o-mini", "temperature": 0, "openai_api_key": api_key} if base_url and base_url.strip(): llm_kwargs["base_url"] = base_url.strip() llm = ChatOpenAI(**llm_kwargs) prompt = f"""你要把用户问题改写成更适合英文知识图谱检索的问句。 要求: 1. 保留原问题语义,不要扩展没提到的事实。 2. 如果原问题是中文,请优先改写为简洁、标准的英文科研问句。 3. 把中文术语改成更可能出现在知识图谱节点里的英文表达。 4. 保留核心实体,例如污染物、生物炭材料、原料、制备方法、性质、机理。 5. 只返回改写后的单句问句,不要解释。 示例: - 哪些生物炭可以处理镉、铅或砷等污染物? -> Which biochars can treat pollutants such as cadmium, lead, or arsenic? - 稻壳生物炭常见的制备方法和关键条件有哪些? -> What are the common preparation methods and key conditions for rice husk biochar? 原问题:{message} """ return llm.invoke(prompt).content.strip() def extract_wikipedia_keyword(message, api_key, base_url): llm_kwargs = {"model": "gpt-4o-mini", "temperature": 0, "openai_api_key": api_key} if base_url and base_url.strip(): llm_kwargs["base_url"] = base_url.strip() llm = ChatOpenAI(**llm_kwargs) prompt = f"""请为 Wikipedia 检索提取一个最具体的实体关键词。 要求: 1. 如果原问题是中文,尽量输出对应的标准英文术语。 2. 只输出一个词或一个短语。 3. 不要输出宽泛类别词,如 Pollutant、Property、Material。 4. 不要解释。 问题:{message} """ return llm.invoke(prompt).content.strip() # ========================================== # 1. Read Database Credentials # ========================================== NEO4J_URI = os.environ.get("NEO4J_URI", "") NEO4J_USERNAME = os.environ.get("NEO4J_USERNAME", "") NEO4J_PASSWORD = os.environ.get("NEO4J_PASSWORD", "") DEFAULT_BASE_URL = "" NEO4J_DATABASE_NAME = "f8c5f809" # ========================================== # 2. Database Initialization (LangChain RAG) # ========================================== try: graph = Neo4jGraph( url=NEO4J_URI, username=NEO4J_USERNAME, password=NEO4J_PASSWORD, database=NEO4J_DATABASE_NAME ) print("✅ LangChain Graph RAG connected successfully!") except Exception as e: print(f"❌ LangChain Graph RAG connection failed: {e}") graph = None # Native driver for visualization neo4j_driver = GraphDatabase.driver(NEO4J_URI, auth=(NEO4J_USERNAME, NEO4J_PASSWORD)) # ========================================== # 3. Dynamic Graph Chain Initialization # ========================================== def get_graph_chain(api_key: str, base_url: str): # 动态组装大模型参数,支持自定义 Base URL llm_kwargs = { "model": "gpt-4o-mini", "temperature": 0, "openai_api_key": api_key } if base_url and base_url.strip(): llm_kwargs["base_url"] = base_url.strip() llm = ChatOpenAI(**llm_kwargs) cypher_prompt = PromptTemplate( template=BIOCHAR_CYPHER_TEMPLATE, input_variables=["schema", "question"], partial_variables={"query_templates": BIOCHAR_QUERY_TEMPLATES} ) qa_template = """你是一名严谨的生物炭环境修复、环境工程与材料学专家。 请严格基于图数据库返回的 [Context] 作答。 [关键规则]: 1. 尽最大努力使用给定 Context。即使上下文稍微凌乱,也要尽量提取其中与生物炭、污染物、原料、制备方法、性质、机理、环境介质相关的信息。 2. 回答要围绕生物炭环境修复展开,尽量区分处理对象、制备方式、材料性质和修复机理。 3. 除非上下文明确把某设备当作表征方法,否则不要把纯实验辅助设备写进答案。 4. 只有当 Context 完全为空时,才允许输出完全等于 "Not found"。只要有任何数据,就必须组织出有用答案。 5. 最终请用专业、自然、清晰的中文回答。 Context: {context} Question: {question} 中文专业回答:""" qa_prompt = PromptTemplate(template=qa_template, input_variables=["context", "question"]) kg_rag_chain = GraphCypherQAChain.from_llm( llm=llm, graph=graph, verbose=True, qa_prompt=qa_prompt, cypher_prompt=cypher_prompt, top_k=15, allow_dangerous_requests=True ) return kg_rag_chain # ========================================== # 4. 🕸️ Core Visualization Function: Generate Pyvis HTML # ========================================== def generate_vis_subgraph_html(message, api_key, base_url): if not neo4j_driver: return "⚠️ 图数据库未连接" if not api_key or not api_key.startswith("sk-"): return "
💡 当前尝试匹配的关键词为:{', '.join(keywords)}。这可能意味着这些关键词未命中图谱中的节点名称或别名,或相关节点尚未建立一阶关系。
" net = Network(height='600px', width='100%', bgcolor='#ffffff', font_color='#333333', notebook=False) for node_id, node_data in nodes.items(): net.add_node(node_data['id'], label=node_data['label'], title=node_data['title'], color=node_data['color']) for edge in edges: net.add_edge(edge[0], edge[1], label=edge[2], width=1, color='#DDDDDD') net.toggle_physics(True) net.set_options(""" var options = { "interaction": { "hover": true, "hoverConnectedEdges": true } } """) path = tempfile.mktemp(suffix='.html') net.save_graph(path) with open(path, 'r', encoding='utf-8') as f: html_content = f.read() escaped_html = html.escape(html_content) return f'' # ========================================== # 5. Q&A Function (Graph Priority + Wiki Fallback) # ========================================== def answer_question(message, history, api_key, base_url): history.append({"role": "user", "content": message}) if not api_key or not api_key.startswith("sk-"): history.append({"role": "assistant", "content": "⚠️ 请先在上方填写有效的 LLM API Key。"}) yield history return if graph is None: history.append({"role": "assistant", "content": "⚠️ 图数据库连接失败,请检查环境变量配置。"}) yield history return try: chain = get_graph_chain(api_key, base_url) graph_query = rewrite_question_for_graph_search(message, api_key, base_url) history.append({"role": "assistant", "content": "🧠 正在优先检索生物炭修复知识图谱,请稍候..."}) yield history response = chain.invoke({"query": graph_query}) final_answer = response["result"] if "Not found" in final_answer: history[-1]["content"] = "🧠 图谱中暂未命中,正在补充检索 Wikipedia..." yield history keyword = extract_wikipedia_keyword(message, api_key, base_url) wiki_wrapper = WikipediaAPIWrapper(lang="en", top_k_results=1, doc_content_chars_max=800) wiki_tool = WikipediaQueryRun(api_wrapper=wiki_wrapper) wiki_result = wiki_tool.run(keyword) if "No good Wikipedia Search Result" in wiki_result or not wiki_result.strip(): final_answer = f"图谱中没有找到相关信息,Wikipedia 中也没有检索到 [{keyword}] 的合适结果。" else: final_answer = f"**图谱中未找到该实体。以下是 Wikipedia 中关于 [{keyword}] 的基础说明:**\n\n{wiki_result}" streamed_text = "" for char in final_answer: streamed_text += char history[-1]["content"] = streamed_text yield history time.sleep(0.005) except Exception as e: history[-1]["content"] = f"处理过程中出现系统错误:{str(e)}" yield history def get_schema(): if graph is None: return "⚠️ 图数据库未连接" live_schema = str(graph.schema or "").strip() if not live_schema: return "⚠️ 当前未读取到实时 schema。可能原因包括:图数据库中尚无数据、连接已建立但 schema introspection 暂未返回结果,或当前库尚未完成导入。" return live_schema # ========================================== # 6. UI Construction (Professional Sans-serif Theme) # ========================================== custom_theme = gr.themes.Soft( font=[gr.themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui", "sans-serif"], text_size=gr.themes.sizes.text_md ) custom_css = """ :root { --paper: #f6f2e8; --ink: #1e2a22; --muted: #5d665e; --sand: #e9dfc8; --clay: #7c5c3b; --leaf: #365f48; --water: #d9e9e8; --panel: rgba(255, 252, 246, 0.92); } .gradio-container { background: radial-gradient(circle at top right, rgba(54, 95, 72, 0.12), transparent 26%), radial-gradient(circle at left center, rgba(124, 92, 59, 0.10), transparent 24%), linear-gradient(180deg, #f7f4ee 0%, #efe5d4 100%); } .app-shell { max-width: 1440px; margin: 0 auto; } .hero-banner { background: linear-gradient(135deg, rgba(250, 248, 243, 0.96), rgba(232, 223, 203, 0.92)); border: 1px solid rgba(124, 92, 59, 0.18); border-radius: 28px; padding: 28px 30px; box-shadow: 0 18px 40px rgba(67, 49, 28, 0.10); } .hero-kicker { display: inline-block; padding: 6px 12px; border-radius: 999px; background: rgba(54, 95, 72, 0.10); color: var(--leaf); font-size: 12px; letter-spacing: 0.08em; text-transform: uppercase; font-weight: 700; } .hero-title { margin: 14px 0 10px; font-size: 42px; line-height: 1.08; color: var(--ink); font-weight: 800; } .hero-subtitle { margin: 0; max-width: 860px; color: var(--muted); font-size: 16px; line-height: 1.75; } .metric-strip { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 18px; } .metric-card { border-radius: 18px; padding: 16px 18px; background: rgba(255,255,255,0.55); border: 1px solid rgba(54, 95, 72, 0.12); } .metric-label { font-size: 12px; color: #6a7067; text-transform: uppercase; letter-spacing: 0.08em; } .metric-value { margin-top: 8px; font-size: 20px; font-weight: 700; color: #223228; } .workspace-row { gap: 20px; } .side-panel, .main-panel { background: var(--panel); border: 1px solid rgba(54, 95, 72, 0.14); border-radius: 26px; box-shadow: 0 16px 36px rgba(44, 34, 20, 0.08); } .side-panel { padding: 20px; } .main-panel { padding: 22px; } .panel-title { font-size: 14px; color: #617064; letter-spacing: 0.08em; text-transform: uppercase; margin-bottom: 8px; } .panel-headline { font-size: 26px; color: var(--ink); font-weight: 800; margin-bottom: 8px; } .panel-copy { color: var(--muted); line-height: 1.7; font-size: 14px; margin-bottom: 16px; } .gradio-container .gr-button-primary { background: linear-gradient(135deg, #355f49, #4e7b60); border: none !important; } .gradio-container .gr-button-secondary { border-color: rgba(54, 95, 72, 0.18) !important; } .viz-intro { padding: 14px 16px; border-radius: 16px; background: rgba(233, 223, 200, 0.55); color: #58452f; font-size: 13px; line-height: 1.7; margin-bottom: 10px; } .path-card { margin-top: 18px; padding: 18px; border-radius: 22px; background: radial-gradient(circle at top right, rgba(54, 95, 72, 0.10), transparent 38%), linear-gradient(160deg, rgba(241, 235, 221, 0.95), rgba(232, 220, 196, 0.92)); border: 1px solid rgba(124, 92, 59, 0.14); } .path-title { font-size: 12px; color: #72685a; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 8px; } .path-headline { font-size: 22px; font-weight: 800; color: #26342a; margin-bottom: 10px; } .path-copy { font-size: 14px; line-height: 1.75; color: #57584f; margin-bottom: 14px; } .path-steps { display: grid; gap: 10px; } .path-step { padding: 12px 14px; border-radius: 16px; background: rgba(255,255,255,0.52); border: 1px solid rgba(54, 95, 72, 0.10); } .path-step strong { display: block; color: #304036; margin-bottom: 4px; font-size: 14px; } .path-step span { color: #61655b; font-size: 13px; line-height: 1.65; } @media (max-width: 900px) { .hero-title { font-size: 34px; } .metric-strip { grid-template-columns: 1fr; } } """ with gr.Blocks(theme=custom_theme, css=custom_css) as demo: with gr.Column(elem_classes=["app-shell"]): gr.HTML(""" """) with gr.Row(elem_classes=["workspace-row"]): with gr.Column(scale=4, elem_classes=["side-panel"]): gr.HTML("""系统会在这里绘制与问题关联最紧密的动态子图。
", height="640px", render=True ) with gr.Tab("提问建议"): gr.Markdown( "\n".join( [ "1. 问污染物:哪些生物炭可以处理镉、砷、磷酸盐?", "2. 问材料:某种改性生物炭的制备方法、原料和性质是什么?", "3. 问机理:某类污染物去除可能涉及哪些机制?", "4. 问条件:某种处理效果在什么 pH、温度或投加量下表现更好?", "5. 问表征:哪些方法常用于分析生物炭结构与表面性质?", ] ) ) # ========================================== # Wiring logic (现在包含了 base_url_input) # ========================================== answer_event = msg_input.submit( fn=answer_question, inputs=[msg_input, chatbot_ui, api_key_input, base_url_input], outputs=[chatbot_ui] ) answer_event.then( fn=generate_vis_subgraph_html, inputs=[msg_input, api_key_input, base_url_input], outputs=[html_vis_output] ) answer_event.then(lambda: "", outputs=[msg_input]) btn_event = submit_btn.click( fn=answer_question, inputs=[msg_input, chatbot_ui, api_key_input, base_url_input], outputs=[chatbot_ui] ) btn_event.then( fn=generate_vis_subgraph_html, inputs=[msg_input, api_key_input, base_url_input], outputs=[html_vis_output] ) btn_event.then(lambda: "", outputs=[msg_input]) clear_btn.click(lambda: [], outputs=chatbot_ui, queue=False) clear_btn.click(lambda: "