igerasimov commited on
Commit
1b0659e
·
1 Parent(s): 8a6cd0c

deploy multi-topic parallel langgraph pipeline

Browse files
Files changed (3) hide show
  1. app.py +163 -0
  2. gcmd_hierarchy.json +0 -0
  3. requirements.txt +6 -0
app.py ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ import re
4
+ import gradio as gr
5
+ from typing import List, TypedDict, Annotated
6
+ from pydantic import BaseModel, Field
7
+ from langgraph.graph import StateGraph, START, END
8
+ from langchain_core.prompts import ChatPromptTemplate
9
+ from langchain_openai import ChatOpenAI
10
+
11
+ # ==========================================================
12
+ # 1. LOAD DATA & BUILD LOOKUP INDICES
13
+ # ==========================================================
14
+ # Hugging Face will look for this file in the root directory
15
+ with open("gcmd_hierarchy.json", "r") as f:
16
+ gcmd_data = json.load(f)
17
+
18
+ def build_gcmd_indices(gcmd_json):
19
+ topic_list = []
20
+ sub_tree_indices = {}
21
+ topics = gcmd_json.get("children", [])
22
+
23
+ for topic_node in topics:
24
+ topic_name = topic_node.get("name", "").upper()
25
+ topic_list.append(topic_name)
26
+ collected_paths = []
27
+
28
+ def recurse_sub_tree(node, current_path=""):
29
+ node_name = node.get("name", "")
30
+ node_path = f"{current_path} > {node_name}" if current_path else node_name
31
+ if "Variable" in node.get("level", "") or not node.get("children"):
32
+ definition = node.get("definition", "")
33
+ if definition:
34
+ collected_paths.append(f"{node_path} (Definition: {definition})")
35
+ else:
36
+ collected_paths.append(node_path)
37
+ for child in node.get("children", []):
38
+ recurse_sub_tree(child, node_path)
39
+
40
+ for term_node in topic_node.get("children", []):
41
+ recurse_sub_tree(term_node, current_path="")
42
+
43
+ sub_tree_indices[topic_name] = "\n".join([f"- {path}" for path in collected_paths])
44
+ return topic_list, sub_tree_indices
45
+
46
+ VALID_TOPICS, SUB_TREE_LOOKUP = build_gcmd_indices(gcmd_data)
47
+
48
+ # ==========================================================
49
+ # 2. LANGGRAPH MULTI-TOPIC DEFINITION
50
+ # ==========================================================
51
+ def merge_lists(left: list, right: list) -> list:
52
+ return list(set((left or []) + (right or [])))
53
+
54
+ class MultiTopicState(TypedDict):
55
+ title: str
56
+ abstract: str
57
+ chosen_topics: List[str]
58
+ predicted_keywords: Annotated[List[str], merge_lists]
59
+ invalid_keywords: Annotated[List[str], merge_lists]
60
+
61
+ class TopicsChoice(BaseModel):
62
+ topics: List[str] = Field(description="List of matching topic areas.")
63
+
64
+ def route_multi_topic(state: MultiTopicState):
65
+ llm = ChatOpenAI(model="gpt-4o-mini", temperature=0) # gpt-4o-mini keeps costs low for public demos
66
+ structured_llm = llm.with_structured_output(TopicsChoice)
67
+ prompt = ChatPromptTemplate.from_messages([
68
+ ("system", f"You are an expert science cataloger. Identify ALL relevant major topic areas. Choose ONLY from: {', '.join(VALID_TOPICS)}"),
69
+ ("user", "Title: {title}\nAbstract: {abstract}\n\nSelect all relevant Topics.")
70
+ ])
71
+ result = structured_llm.invoke(prompt.format(title=state["title"], abstract=state["abstract"]))
72
+ valid_selected = [t.upper().strip() for t in result.topics if t.upper().strip() in VALID_TOPICS]
73
+ if not valid_selected:
74
+ valid_selected = ["FALLBACK"]
75
+ return {"chosen_topics": valid_selected}
76
+
77
+ def classify_individual_topic(topic_name: str):
78
+ def node_runner(state: MultiTopicState):
79
+ llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
80
+ target_sub_tree = SUB_TREE_LOOKUP.get(topic_name, "")
81
+ prompt = ChatPromptTemplate.from_messages([
82
+ ("system", f"You are a specialist in {topic_name} data mapping. Extract exact keyword pathways present in the list."),
83
+ ("user", "Title: {title}\nAbstract: {abstract}\n\nValid Paths:\n{sub_tree}\n\nReturn exact matching entries as a comma-separated list.")
84
+ ])
85
+ response = llm.invoke(prompt.format(title=state["title"], abstract=state["abstract"], sub_tree=target_sub_tree))
86
+ raw_keywords = [k.strip() for k in response.content.split(",") if k.strip()]
87
+
88
+ valid_set = set()
89
+ for line in target_sub_tree.split("\n"):
90
+ if line.strip():
91
+ path = line.replace("- ", "").strip()
92
+ path = re.sub(r"\s*\(Definition:.*?\)$", "", path).strip()
93
+ valid_set.add(path)
94
+
95
+ valid_kws = [kw for kw in raw_keywords if kw in valid_set]
96
+ invalid_kws = [kw for kw in raw_keywords if kw not in valid_set]
97
+ return {"predicted_keywords": valid_kws, "invalid_keywords": invalid_kws}
98
+ return node_runner
99
+
100
+ def parallel_router(state: MultiTopicState) -> List[str]:
101
+ return [f"classify_{topic.lower()}" for topic in state["chosen_topics"]]
102
+
103
+ # Compile the Workflow
104
+ workflow = StateGraph(MultiTopicState)
105
+ workflow.add_node("top_router", route_multi_topic)
106
+ for topic in VALID_TOPICS:
107
+ workflow.add_node(f"classify_{topic.lower()}", classify_individual_topic(topic))
108
+ workflow.add_node("classify_fallback", lambda state: {"predicted_keywords": []})
109
+
110
+ workflow.add_edge(START, "top_router")
111
+ workflow.add_conditional_edges(
112
+ "top_router",
113
+ parallel_router,
114
+ {f"classify_{t.lower()}": f"classify_{t.lower()}" for t in VALID_TOPICS} | {"classify_fallback": "classify_fallback"}
115
+ )
116
+ for topic in VALID_TOPICS:
117
+ workflow.add_edge(f"classify_{topic.lower()}", END)
118
+ workflow.add_edge("classify_fallback", END)
119
+
120
+ app = workflow.compile()
121
+
122
+ # ==========================================================
123
+ # 3. GRADIO USER INTERFACE FOR DEMONSTRATION
124
+ # ==========================================================
125
+ def run_agent_classifier(title, abstract):
126
+ if not title or not abstract:
127
+ return "Please fill out both Title and Abstract fields.", "N/A"
128
+
129
+ inputs = {"title": title, "abstract": abstract}
130
+ output = app.invoke(inputs)
131
+
132
+ topics_str = ", ".join(output.get("chosen_topics", []))
133
+ keywords_list = output.get("predicted_keywords", [])
134
+
135
+ if not keywords_list:
136
+ keywords_str = "No explicit keywords mapped."
137
+ else:
138
+ keywords_str = "\n".join([f"• {kw}" for kw in keywords_list])
139
+
140
+ return topics_str, keywords_str
141
+
142
+ # Build out layout blocks
143
+ demo = gr.Interface(
144
+ fn=run_agent_classifier,
145
+ inputs=[
146
+ gr.Textbox(label="Journal Article Title", placeholder="Enter article title here...", lines=1),
147
+ gr.Textbox(label="Abstract / Body Text", placeholder="Paste abstract description here...", lines=5)
148
+ ],
149
+ outputs=[
150
+ gr.Textbox(label="Routed Multi-Topic Domains"),
151
+ gr.Textbox(label="Verified GCMD Keywords Extracted")
152
+ ],
153
+ title="GCMD Science Keyword Classifier Agent",
154
+ description="Proof of Concept using LangGraph and LangChain. Routes articles concurrently across science domains and runs isolated self-validation routines.",
155
+ examples=[
156
+ ["El Niño Southern Oscillation Shifting Evaporation Tracks", "Rising sea surface temperatures across equatorial waters directly trigger increased low-level cloud formation and accelerated surface winds."]
157
+ ]
158
+ )
159
+
160
+ # Launch configuration compatible with Hugging Face Space runtime rules
161
+ if __name__ == "__main__":
162
+ demo.launch()
163
+
gcmd_hierarchy.json ADDED
The diff for this file is too large to render. See raw diff
 
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ langchain-core
2
+ langchain-openai
3
+ langgraph
4
+ pydantic
5
+ gradio
6
+