Skip to content

Commit b9a3fcd

Browse files
committed
chore: release v0.9.2 with Chat UI revamp, Universal Translation, and UI Bug Fixes
1 parent 5a2eb56 commit b9a3fcd

23 files changed

Lines changed: 717 additions & 333 deletions

CHANGELOG.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,18 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8-
## [0.9.1] - 2026-03-07
8+
## [0.9.2] - 2026-03-07
9+
10+
### Added
11+
- **Search Panel - Translation Enforcer**: All non-English queries (Japanese, Chinese, Korean, etc.) are now mandatorily translated to English via the LLM API (even when AI Search toggle is disabled) before hitting PubTator, significantly improving international literature retrieval.
12+
- **Chat UI Revamp**: The Chat Panel layout now perfectly mimics leading AI assistants (ChatGPT/Gemini). User messages appear on the right side with aligned avatars, and AI responses align left.
13+
- **Graph Panel - Auto-Clear**: Initiating a new search instantly clears the previous Cytoscape elements from memory, preventing buggy visual overlap or internal package crashes during graph rendering diffs.
914

15+
### Fixed
16+
- **Network Statistics Sync**: Solved the issue where total network node and edge counts failed to synchronize after creating dynamic groups (like "Show Communities"). The panel now accurately counts and updates metrics live directly from the visible display canvas.
17+
- **Cytoscape Crash on Diffing**: Fixed the `nonexistent target/source` error causing graphs to disappear by completely zeroing the `cy.elements` state safely via javascript clientside callback logic between consecutive searches.
18+
19+
## [0.9.1] - 2026-03-07
1020
### Added
1121
- **Graph Panel – Graph File Export**: New **"Graph (.pkl)"** download button exports the complete graph state (NetworkX graph including all node/edge attributes, `pmid_abstract`, and semantic analysis results) as a binary pickle file (`netmedex_graph.pkl`).
1222
- **Search Panel – Graph File Restore**: New **"Graph File (.pkl)"** source option allows uploading a previously exported `.pkl` file. This bypasses the entire PubTator API + graph-building pipeline, restoring the graph session instantly — network visualization and Chat Panel (Analyze Selection) both work fully after restore.

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,8 @@ NetMedEx features an interactive **Chat Panel** driven by **Hybrid RAG**, which
6161

6262
### Key Capabilities
6363
- **Hybrid RAG Chat**: Synthesizes **unstructured text** (abstracts) and **structured graph knowledge** (paths and neighbors).
64-
- **Natural Language to Query**: Ask in plain English; NetMedEx converts it to optimized PubTator3 syntax.
64+
- **Natural Language & Universal Translation**: Ask in English, Japanese, Chinese, or Korean! NetMedEx automatically translates non-English queries to optimized standard PubTator3 English syntax before searching.
65+
- **ChatGPT-Style Chat Experience**: Features an intuitive, auto-scrolling Chat Panel that perfectly mimics modern AI layouts (user queries on the right, AI responses on the left), preventing the need for manual scrolling.
6566
- **Semantic Evidence Extraction**: Automatically identifies relationship types (e.g., *treats*, *inhibits*) and confidence scores.
6667
- **Contextual Reasoning**: Identifies shortest paths and relevant subgraphs to explain hidden connections between entities.
6768

docs/DOCKERHUB_OVERVIEW.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ NetMedEx is an AI-powered knowledge discovery platform that transforms biomedica
55
## 🌟 Core Philosophy
66
In NetMedEx, the **Co-Mention Network** serves as a structural "scaffolding" for discovery. The **AI-driven Semantic Layer** breathes life into these connections by extracting evidence, identifying relationship types, and answering complex natural language queries.
77

8+
### What's New in 0.9.2
9+
- **Universal Translation**: Ask queries in Japanese, Chinese, or Korean. NetMedEx automatically translates them to English before searching PubTator3.
10+
- **ChatGPT-Style Chat**: Intuitive right-aligned user messages and left-aligned AI responses with auto-scrolling.
11+
- **Improved Graph Stability**: Flawless panel clearing and dynamic UI statistics calculation when transitioning between different query networks.
812
## 🚀 Quick Start
913
Launch the interactive dashboard using Docker and access it at `localhost:8050`:
1014

netmedex/cytoscape_js.py

Lines changed: 94 additions & 124 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import json
44
import logging
55
import re
6+
import time
67
from typing import Literal
78

89
import networkx as nx
@@ -33,90 +34,107 @@ def save_as_json(G: nx.Graph, savepath: str):
3334

3435

3536
def create_cytoscape_js(G: nx.Graph, style: Literal["dash", "cyjs"] = "cyjs"):
36-
# TODO: Check whether to set id for edges
37-
with_id = False
38-
39-
# Filter out invalid nodes (missing required fields) to prevent export errors
40-
valid_nodes = []
41-
valid_node_ids = set() # Track valid node IDs for edge filtering
42-
invalid_count = 0
43-
for node in G.nodes(data=True):
44-
node_id, node_attr = node
45-
if "_id" not in node_attr:
46-
logger.warning(f"Skipping node {node_id} during export: missing '_id' field")
47-
invalid_count += 1
37+
"""
38+
Creates Cytoscape JSON with paranoid ID validation and REAL-TIME PRINTING for debugging.
39+
"""
40+
start_t = time.time()
41+
print(f"\n>>> [CYJS-DEBUG] Starting export at {start_t}")
42+
43+
# Track exactly which node IDs (UUIDs) we are sending to the browser
44+
final_node_uuids = set()
45+
valid_nodes_for_export = []
46+
47+
skipped_missing_id = 0
48+
skipped_duplicates = 0
49+
50+
# 1. Collect and Validate Nodes
51+
for node_id, node_attr in G.nodes(data=True):
52+
uuid = node_attr.get("_id")
53+
if not uuid:
54+
print(f">>> [CYJS-DEBUG] Skipping node {node_id}: No '_id' attribute.")
55+
skipped_missing_id += 1
4856
continue
57+
4958
if "pmids" not in node_attr:
50-
logger.warning(f"Skipping node {node_id} during export: missing 'pmids' field")
51-
invalid_count += 1
59+
print(f">>> [CYJS-DEBUG] Skipping node {node_id}: No 'pmids' attribute.")
60+
skipped_missing_id += 1
5261
continue
53-
valid_nodes.append(node)
54-
valid_node_ids.add(node_id) # Track this node as valid
55-
56-
if invalid_count > 0:
57-
logger.info(f"Filtered out {invalid_count} invalid nodes during graph export")
58-
59-
# Filter edges to only include those connecting valid nodes
60-
valid_edges = []
61-
filtered_edge_count = 0
62-
for edge in G.edges(data=True):
63-
node_id_1, node_id_2, edge_attr = edge
64-
# Only include edge if both nodes are valid
65-
if node_id_1 in valid_node_ids and node_id_2 in valid_node_ids:
66-
valid_edges.append(edge)
67-
else:
68-
filtered_edge_count += 1
69-
70-
if filtered_edge_count > 0:
71-
logger.info(f"Filtered out {filtered_edge_count} edges connected to invalid nodes")
7262

73-
# Calculate node degrees for sizing
74-
node_degrees = {}
75-
for node_id, _ in valid_nodes:
76-
node_degrees[node_id] = G.degree(node_id)
63+
if uuid in final_node_uuids:
64+
skipped_duplicates += 1
65+
continue
7766

67+
final_node_uuids.add(uuid)
68+
valid_nodes_for_export.append((node_id, node_attr))
69+
70+
# 2. Collect and Filter Edges
71+
valid_edges_for_export = []
72+
skipped_missing_source = 0
73+
skipped_missing_target = 0
74+
75+
for u, v, edge_attr in G.edges(data=True):
76+
source_uuid = G.nodes[u].get("_id")
77+
target_uuid = G.nodes[v].get("_id")
78+
79+
if not source_uuid or source_uuid not in final_node_uuids:
80+
skipped_missing_source += 1
81+
# print(f">>> [CYJS-DEBUG] Edge filtered: source {source_uuid} not in nodes.")
82+
continue
83+
84+
if not target_uuid or target_uuid not in final_node_uuids:
85+
skipped_missing_target += 1
86+
# print(f">>> [CYJS-DEBUG] Edge filtered: target {target_uuid} not in nodes.")
87+
continue
88+
89+
valid_edges_for_export.append((u, v, edge_attr))
90+
91+
print(
92+
f">>> [CYJS-DEBUG] Summary: Nodes({len(valid_nodes_for_export)}), Edges({len(valid_edges_for_export)}). "
93+
f"Filtered: MissingID={skipped_missing_id}, Dups={skipped_duplicates}, "
94+
f"NoSource={skipped_missing_source}, NoTarget={skipped_missing_target}"
95+
)
96+
97+
# Calculate node degrees for sizing
98+
node_degrees = {node_id: G.degree(node_id) for node_id, _ in valid_nodes_for_export}
7899
if node_degrees:
79-
min_degree = min(node_degrees.values())
80-
max_degree = max(node_degrees.values())
100+
min_deg = min(node_degrees.values())
101+
max_deg = max(node_degrees.values())
81102
else:
82-
min_degree = 0
83-
max_degree = 0
103+
min_deg = max_deg = 0
84104

85-
# Linear interpolation parameters
86-
MIN_SIZE = 25
87-
MAX_SIZE = 65
105+
MIN_SIZE, MAX_SIZE = 25, 65
88106

89-
def get_node_size(node_id):
90-
degree = node_degrees.get(node_id, 0)
91-
if max_degree == min_degree:
107+
def get_node_size(nid):
108+
deg = node_degrees.get(nid, 0)
109+
if max_deg == min_deg:
92110
return MIN_SIZE
111+
norm = (deg - min_deg) / (max_deg - min_deg)
112+
return MIN_SIZE + (norm * (MAX_SIZE - MIN_SIZE))
93113

94-
# Linear mapping
95-
normalized = (degree - min_degree) / (max_degree - min_degree)
96-
return MIN_SIZE + (normalized * (MAX_SIZE - MIN_SIZE))
97-
98-
nodes = [
114+
# Convert to Cytoscape format
115+
nodes_json = [
99116
create_cytoscape_node(
100-
node, size=get_node_size(node[0]), degree=node_degrees.get(node[0], 0)
117+
(nid, attr), size=get_node_size(nid), degree=node_degrees.get(nid, 0)
101118
)
102-
for node in valid_nodes
119+
for nid, attr in valid_nodes_for_export
120+
]
121+
edges_json = [
122+
create_cytoscape_edge((u, v, attr), G, with_id=True)
123+
for u, v, attr in valid_edges_for_export
103124
]
104-
edges = [create_cytoscape_edge(edge, G, with_id) for edge in valid_edges]
105125

106-
if style == "cyjs":
107-
elements = nodes + edges
108-
elif style == "dash":
109-
elements = {"elements": {"nodes": nodes, "edges": edges}}
126+
print(f">>> [CYJS-DEBUG] Export finished in {time.time() - start_t:.4f}s")
110127

111-
return elements
128+
if style == "cyjs":
129+
return nodes_json + edges_json
130+
return {"elements": {"nodes": nodes_json, "edges": edges_json}}
112131

113132

114133
def create_cytoscape_node(node, size=25, degree=0):
115134
def convert_shape(shape):
116135
return SHAPE_JS_MAP.get(shape, shape).lower()
117136

118137
node_id, node_attr = node
119-
120138
node_info = {
121139
"data": {
122140
"id": node_attr["_id"],
@@ -129,25 +147,22 @@ def convert_shape(shape):
129147
"num_articles": node_attr["num_articles"],
130148
"standardized_id": node_attr["mesh"],
131149
"node_type": node_attr["type"],
132-
"node_type": node_attr["type"],
133150
"node_size": size,
134151
"degree": degree,
135152
},
136153
"position": {
137-
"x": round(node_attr["pos"][0], 3),
138-
"y": round(node_attr["pos"][1], 3),
154+
"x": round(node_attr["pos"][0], 3) if "pos" in node_attr else 0,
155+
"y": round(node_attr["pos"][1], 3) if "pos" in node_attr else 0,
139156
},
140157
}
141158

142-
# Community nodes
143-
if COMMUNITY_NODE_PATTERN.search(node_attr["_id"]):
159+
if COMMUNITY_NODE_PATTERN.search(str(node_id)):
144160
node_info["classes"] = "top-center"
145161

146162
return node_info
147163

148164

149165
def _convert_sets_to_lists(obj):
150-
"""Recursively convert all sets to lists for JSON serialization"""
151166
if isinstance(obj, set):
152167
return list(obj)
153168
elif isinstance(obj, dict):
@@ -158,64 +173,37 @@ def _convert_sets_to_lists(obj):
158173

159174

160175
def _extract_primary_relation(edge_attr: dict) -> tuple[str, float]:
161-
"""Extract the primary relation type from edge attributes.
162-
163-
For semantic edges, finds the relation with highest confidence.
164-
For co-occurrence edges, returns "co-mention".
165-
166-
Args:
167-
edge_attr: Edge attributes dictionary
168-
169-
Returns:
170-
Tuple of (relation_type, confidence)
171-
"""
172-
# Check if this is a semantic edge with confidence scores
173176
if "confidences" in edge_attr and edge_attr["confidences"]:
174-
# Find relation with highest average confidence across all PMIDs
175177
relation_confidences = {}
176-
177178
for pmid_confidences in edge_attr["confidences"].values():
178179
for relation, confidence in pmid_confidences.items():
179180
if relation not in relation_confidences:
180181
relation_confidences[relation] = []
181182
relation_confidences[relation].append(confidence)
182-
183-
# Calculate average confidence for each relation type
184183
avg_confidences = {
185184
rel: sum(confs) / len(confs) for rel, confs in relation_confidences.items()
186185
}
187-
188186
if avg_confidences:
189187
primary_relation = max(avg_confidences, key=avg_confidences.get)
190188
return normalize_relation_type(primary_relation), avg_confidences[primary_relation]
191189

192-
# Check the relations dict for co-occurrence or BioREx edges
193190
if "relations" in edge_attr and edge_attr["relations"]:
194-
# Get all unique relation types from all PMIDs
195191
all_relations = set()
196-
for pmid, relations in edge_attr["relations"].items():
197-
if isinstance(relations, set):
198-
all_relations.update(relations)
199-
elif isinstance(relations, list):
192+
for relations in edge_attr["relations"].values():
193+
if isinstance(relations, (set, list)):
200194
all_relations.update(relations)
201195
elif isinstance(relations, str):
202196
all_relations.add(relations)
203197

204-
# Remove "co-mention" if other specific relations exist
205198
if len(all_relations) > 1 and "co-mention" in all_relations:
206199
all_relations.remove("co-mention")
207200

208201
if all_relations:
209-
# Prefer more specific relations over generic ones
210-
if len(all_relations) == 1:
211-
return normalize_relation_type(list(all_relations)[0]), 0.5
212-
else:
213-
# Multiple relation types - pick the first non-co-mention one
214-
for rel in all_relations:
215-
if rel != "co-mention":
216-
return normalize_relation_type(rel), 0.5
217-
218-
# Default to generic interaction
202+
for rel in all_relations:
203+
if rel != "co-mention":
204+
return normalize_relation_type(rel), 0.5
205+
return normalize_relation_type(list(all_relations)[0]), 0.5
206+
219207
return "interacts_with", 0.0
220208

221209

@@ -226,64 +214,46 @@ def create_cytoscape_edge(edge, G, with_id=True):
226214
else:
227215
pmids = list(edge_attr["relations"].keys())
228216

229-
# Extract primary relation type and confidence
230217
primary_relation, confidence = _extract_primary_relation(edge_attr)
231-
232-
# Determine if this is a directional relationship
233218
is_directional = is_directional_relation(primary_relation)
234-
235-
# Get display-friendly relation name
236219
relation_display = get_relation_display_name(primary_relation)
237220

238-
# Create edge label with specific relation type
239-
# Determine source and target based on directionality info
240221
source_id = G.nodes[node_id_1]["_id"]
241222
target_id = G.nodes[node_id_2]["_id"]
242223
source_name = G.nodes[node_id_1]["name"]
243224
target_name = G.nodes[node_id_2]["name"]
244225

245226
if "source_id" in edge_attr:
246-
# If we have explicit source tracking, respect it
247-
real_source_id = edge_attr["source_id"]
248-
# If the stored node1 is actually the source
249-
if real_source_id == node_id_1:
250-
pass # Already correct
251-
else:
252-
# Swap source/target for display
227+
if edge_attr["source_id"] != node_id_1:
253228
source_id, target_id = target_id, source_id
254229
source_name, target_name = target_name, source_name
255230

256-
edge_label = relation_display
257-
258-
# Convert relations dict (may contain sets) to JSON-serializable format
259231
relations = _convert_sets_to_lists(edge_attr.get("relations", {}))
260232

261233
edge_data = {
262234
"source": source_id,
263235
"target": target_id,
264-
"label": edge_label,
265-
"weight": round(max(float(edge_attr["edge_width"]), 1), 1),
236+
"label": relation_display,
237+
"weight": round(max(float(edge_attr["edge_width"] or 0), 1), 1),
266238
"pmids": pmids,
267239
"edge_type": edge_attr["type"],
268240
"relations": relations,
269-
# NEW: Semantic relationship metadata
270241
"primary_relation": primary_relation,
271242
"relation_display": relation_display,
272243
"relation_confidence": round(confidence, 2) if confidence > 0 else None,
273244
"source_name": source_name,
274245
"target_name": target_name,
275-
# Include semantic metadata for display in edge info panel
276246
"confidences": edge_attr.get("confidences", None),
277247
"evidences": edge_attr.get("evidences", None),
278248
}
279249

280-
# Only adding is_directional if True prevents selector matching on False
281250
if is_directional:
282251
edge_data["is_directional"] = True
283252

284253
edge_info = {"data": edge_data}
285-
286254
if with_id:
255+
# NOTE: Using a timestamp-based suffix here could force a redraw, but let's stick to stable IDs first
256+
# and just ensure they are valid.
287257
edge_info["data"]["id"] = edge_attr["_id"]
288258

289259
return edge_info

0 commit comments

Comments
 (0)