KevinIsInCoding Claude Sonnet 4.6 commited on
Commit
eb6d0dc
·
unverified ·
1 Parent(s): b3c8b62

FEAT: Add multi-language support (EN / ZH / ES / HI) (#2)

Browse files

- New translations.py with UI string dicts and language directives for all 4 languages
- Language dropdown in UI header — switching resets the session in the chosen language
- Intake and research agents prepend a language directive to their system prompts so
Claude responds entirely in the selected language (Chinese, Spanish, Hindi, English)
- PatientProfile.lang field carries language through to the research agent
- All hardcoded English UI strings replaced with UI[lang] lookups

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

Files changed (3) hide show
  1. app.py +69 -26
  2. clinical_trials_guru.py +3 -0
  3. translations.py +82 -0
app.py CHANGED
@@ -20,20 +20,22 @@ from clinical_trials_guru import (
20
  geocode_zip,
21
  search_trials_api,
22
  )
 
23
 
24
  load_dotenv()
25
 
26
 
27
  def _intake_turn(
28
- user_text: str, messages: list
29
  ) -> tuple[str, list, PatientProfile | None]:
30
  today = datetime.date.today().strftime("%B %d, %Y")
31
  messages = messages + [{"role": "user", "content": user_text}]
32
  client = anthropic.Anthropic()
 
33
  response = client.messages.create(
34
  model=INTAKE_MODEL,
35
  max_tokens=1024,
36
- system=f"Today's date is {today}.\n\n" + INTAKE_SYSTEM,
37
  tools=[SUBMIT_PROFILE_TOOL],
38
  messages=messages,
39
  )
@@ -63,13 +65,15 @@ def _intake_turn(
63
  radius_miles=data.get("radius_miles", 100),
64
  phases=data.get("phases") or [],
65
  include_eap=data.get("include_eap", False),
 
66
  )
67
- return text or "Got it — searching for trials now…", messages, profile
68
 
69
  return text, messages, None
70
 
71
 
72
  def _run_research(profile: PatientProfile) -> str:
 
73
  client = anthropic.Anthropic()
74
  messages: list[anthropic.types.MessageParam] = [
75
  {
@@ -84,7 +88,7 @@ def _run_research(profile: PatientProfile) -> str:
84
  response = client.messages.create(
85
  model=RESEARCH_MODEL,
86
  max_tokens=8096,
87
- system=RESEARCH_SYSTEM,
88
  tools=[SEARCH_TRIALS_TOOL],
89
  messages=messages,
90
  )
@@ -92,7 +96,7 @@ def _run_research(profile: PatientProfile) -> str:
92
  if response.stop_reason == "end_turn":
93
  return next(
94
  (b.text for b in response.content if b.type == "text"),
95
- "No analysis produced.",
96
  )
97
  tool_results: list[anthropic.types.ToolResultBlockParam] = []
98
  for block in response.content:
@@ -112,7 +116,7 @@ def _run_research(profile: PatientProfile) -> str:
112
  content = json.dumps(ranked)
113
  is_error = False
114
  except Exception as exc:
115
- content = f"API request failed: {exc}"
116
  is_error = True
117
  tool_results.append(
118
  {
@@ -125,11 +129,9 @@ def _run_research(profile: PatientProfile) -> str:
125
  messages.append({"role": "user", "content": tool_results})
126
 
127
 
128
- def initialize():
129
- text, msgs, _ = _intake_turn("Please begin.", [])
130
  chat = [{"role": "assistant", "content": text}]
131
- # Strip the seed "Please begin." turn so subsequent user messages append cleanly.
132
- # msgs already includes both the seed user turn and the assistant turn; keep it.
133
  return chat, msgs, None, "intake"
134
 
135
 
@@ -139,27 +141,27 @@ def respond(
139
  intake_msgs: list,
140
  profile,
141
  phase: str,
 
142
  ) -> Generator:
143
  if not user_msg.strip() or phase == "done":
144
  yield chat_history, intake_msgs, profile, phase, gr.update(), gr.update()
145
  return
146
 
 
147
  chat_history = chat_history + [{"role": "user", "content": user_msg}]
148
  yield chat_history, intake_msgs, profile, phase, gr.update(value=""), gr.update()
149
 
150
- assistant_text, updated_msgs, new_profile = _intake_turn(user_msg, intake_msgs)
151
 
152
  if new_profile:
153
- status = (assistant_text + "\n\n" if assistant_text else "") + (
154
- "*Searching ClinicalTrials.gov — this may take a minute…*"
155
- )
156
  chat_history = chat_history + [{"role": "assistant", "content": status}]
157
  yield (
158
  chat_history,
159
  updated_msgs,
160
  new_profile,
161
  "researching",
162
- gr.update(interactive=False, placeholder="Searching…"),
163
  gr.update(visible=False),
164
  )
165
 
@@ -170,7 +172,7 @@ def respond(
170
  updated_msgs,
171
  new_profile,
172
  "done",
173
- gr.update(interactive=False, placeholder="Search complete."),
174
  gr.update(visible=True),
175
  )
176
  else:
@@ -185,43 +187,84 @@ def respond(
185
  )
186
 
187
 
188
- with gr.Blocks(title="Beacon — Clinical Trial Finder") as demo:
189
- gr.Markdown("# 🔦 Beacon — Rare Disease Clinical Trial Finder")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
 
191
  chatbot = gr.Chatbot(height=550, show_label=False)
192
  with gr.Row():
193
  msg_box = gr.Textbox(
194
- placeholder="Type your message and press Enter…",
195
  show_label=False,
196
  scale=9,
197
  autofocus=True,
198
  )
199
- send_btn = gr.Button("Send", scale=1, variant="primary")
200
- new_search_btn = gr.Button("New Search", visible=False, variant="secondary")
201
 
202
  # State
203
  intake_msgs_state = gr.State([])
204
  profile_state = gr.State(None)
205
  phase_state = gr.State("intake")
 
206
 
207
- outputs = [chatbot, intake_msgs_state, profile_state, phase_state, msg_box, new_search_btn]
 
208
 
209
  demo.load(
210
- initialize,
211
  outputs=[chatbot, intake_msgs_state, profile_state, phase_state],
212
  )
213
 
214
- msg_box.submit(respond, [msg_box, chatbot, intake_msgs_state, profile_state, phase_state], outputs)
215
- send_btn.click(respond, [msg_box, chatbot, intake_msgs_state, profile_state, phase_state], outputs)
216
 
217
  new_search_btn.click(
218
  initialize,
 
219
  outputs=[chatbot, intake_msgs_state, profile_state, phase_state],
220
  ).then(
221
- lambda: (gr.update(interactive=True, placeholder="Type your message and press Enter…"), gr.update(visible=False)),
 
 
 
 
222
  outputs=[msg_box, new_search_btn],
223
  )
224
 
 
 
 
 
 
 
 
 
 
 
225
 
226
  if __name__ == "__main__":
227
  demo.launch()
 
20
  geocode_zip,
21
  search_trials_api,
22
  )
23
+ from translations import LANGUAGE_DIRECTIVE, LANGUAGES, UI
24
 
25
  load_dotenv()
26
 
27
 
28
  def _intake_turn(
29
+ user_text: str, messages: list, lang: str = "en"
30
  ) -> tuple[str, list, PatientProfile | None]:
31
  today = datetime.date.today().strftime("%B %d, %Y")
32
  messages = messages + [{"role": "user", "content": user_text}]
33
  client = anthropic.Anthropic()
34
+ system = f"Today's date is {today}.\n\n" + LANGUAGE_DIRECTIVE[lang] + INTAKE_SYSTEM
35
  response = client.messages.create(
36
  model=INTAKE_MODEL,
37
  max_tokens=1024,
38
+ system=system,
39
  tools=[SUBMIT_PROFILE_TOOL],
40
  messages=messages,
41
  )
 
65
  radius_miles=data.get("radius_miles", 100),
66
  phases=data.get("phases") or [],
67
  include_eap=data.get("include_eap", False),
68
+ lang=lang,
69
  )
70
+ return text or UI[lang]["got_it"], messages, profile
71
 
72
  return text, messages, None
73
 
74
 
75
  def _run_research(profile: PatientProfile) -> str:
76
+ lang = profile.lang
77
  client = anthropic.Anthropic()
78
  messages: list[anthropic.types.MessageParam] = [
79
  {
 
88
  response = client.messages.create(
89
  model=RESEARCH_MODEL,
90
  max_tokens=8096,
91
+ system=LANGUAGE_DIRECTIVE[lang] + RESEARCH_SYSTEM,
92
  tools=[SEARCH_TRIALS_TOOL],
93
  messages=messages,
94
  )
 
96
  if response.stop_reason == "end_turn":
97
  return next(
98
  (b.text for b in response.content if b.type == "text"),
99
+ UI[lang]["no_analysis"],
100
  )
101
  tool_results: list[anthropic.types.ToolResultBlockParam] = []
102
  for block in response.content:
 
116
  content = json.dumps(ranked)
117
  is_error = False
118
  except Exception as exc:
119
+ content = UI[lang]["api_error"].format(exc=exc)
120
  is_error = True
121
  tool_results.append(
122
  {
 
129
  messages.append({"role": "user", "content": tool_results})
130
 
131
 
132
+ def initialize(lang: str = "en"):
133
+ text, msgs, _ = _intake_turn("Please begin.", [], lang=lang)
134
  chat = [{"role": "assistant", "content": text}]
 
 
135
  return chat, msgs, None, "intake"
136
 
137
 
 
141
  intake_msgs: list,
142
  profile,
143
  phase: str,
144
+ lang: str,
145
  ) -> Generator:
146
  if not user_msg.strip() or phase == "done":
147
  yield chat_history, intake_msgs, profile, phase, gr.update(), gr.update()
148
  return
149
 
150
+ t = UI[lang]
151
  chat_history = chat_history + [{"role": "user", "content": user_msg}]
152
  yield chat_history, intake_msgs, profile, phase, gr.update(value=""), gr.update()
153
 
154
+ assistant_text, updated_msgs, new_profile = _intake_turn(user_msg, intake_msgs, lang=lang)
155
 
156
  if new_profile:
157
+ status = (assistant_text + "\n\n" if assistant_text else "") + t["status_searching"]
 
 
158
  chat_history = chat_history + [{"role": "assistant", "content": status}]
159
  yield (
160
  chat_history,
161
  updated_msgs,
162
  new_profile,
163
  "researching",
164
+ gr.update(interactive=False, placeholder=t["searching"]),
165
  gr.update(visible=False),
166
  )
167
 
 
172
  updated_msgs,
173
  new_profile,
174
  "done",
175
+ gr.update(interactive=False, placeholder=t["search_complete"]),
176
  gr.update(visible=True),
177
  )
178
  else:
 
187
  )
188
 
189
 
190
+ def change_language(lang: str):
191
+ t = UI[lang]
192
+ chat, msgs, _, phase = initialize(lang)
193
+ return (
194
+ chat, msgs, None, phase,
195
+ gr.update(value=t["heading"]),
196
+ gr.update(placeholder=t["placeholder"], interactive=True),
197
+ gr.update(value=t["send"]),
198
+ gr.update(value=t["new_search"], visible=False),
199
+ lang,
200
+ )
201
+
202
+
203
+ with gr.Blocks(title=UI["en"]["page_title"]) as demo:
204
+ heading_md = gr.Markdown(UI["en"]["heading"])
205
+
206
+ with gr.Row():
207
+ gr.Markdown("") # spacer
208
+ lang_dropdown = gr.Dropdown(
209
+ choices=[(label, code) for code, label in LANGUAGES.items()],
210
+ value="en",
211
+ show_label=False,
212
+ scale=1,
213
+ min_width=140,
214
+ container=False,
215
+ )
216
 
217
  chatbot = gr.Chatbot(height=550, show_label=False)
218
  with gr.Row():
219
  msg_box = gr.Textbox(
220
+ placeholder=UI["en"]["placeholder"],
221
  show_label=False,
222
  scale=9,
223
  autofocus=True,
224
  )
225
+ send_btn = gr.Button(UI["en"]["send"], scale=1, variant="primary")
226
+ new_search_btn = gr.Button(UI["en"]["new_search"], visible=False, variant="secondary")
227
 
228
  # State
229
  intake_msgs_state = gr.State([])
230
  profile_state = gr.State(None)
231
  phase_state = gr.State("intake")
232
+ lang_state = gr.State("en")
233
 
234
+ respond_inputs = [msg_box, chatbot, intake_msgs_state, profile_state, phase_state, lang_state]
235
+ respond_outputs = [chatbot, intake_msgs_state, profile_state, phase_state, msg_box, new_search_btn]
236
 
237
  demo.load(
238
+ lambda: initialize("en"),
239
  outputs=[chatbot, intake_msgs_state, profile_state, phase_state],
240
  )
241
 
242
+ msg_box.submit(respond, respond_inputs, respond_outputs)
243
+ send_btn.click(respond, respond_inputs, respond_outputs)
244
 
245
  new_search_btn.click(
246
  initialize,
247
+ inputs=[lang_state],
248
  outputs=[chatbot, intake_msgs_state, profile_state, phase_state],
249
  ).then(
250
+ lambda lang: (
251
+ gr.update(interactive=True, placeholder=UI[lang]["placeholder"]),
252
+ gr.update(visible=False),
253
+ ),
254
+ inputs=[lang_state],
255
  outputs=[msg_box, new_search_btn],
256
  )
257
 
258
+ lang_dropdown.change(
259
+ change_language,
260
+ inputs=[lang_dropdown],
261
+ outputs=[
262
+ chatbot, intake_msgs_state, profile_state, phase_state,
263
+ heading_md, msg_box, send_btn, new_search_btn,
264
+ lang_state,
265
+ ],
266
+ )
267
+
268
 
269
  if __name__ == "__main__":
270
  demo.launch()
clinical_trials_guru.py CHANGED
@@ -6,6 +6,8 @@ import time
6
  from dataclasses import dataclass, field
7
  from typing import Optional, TypedDict
8
 
 
 
9
  import anthropic
10
  import httpx
11
  from rich import box
@@ -231,6 +233,7 @@ class PatientProfile:
231
  radius_miles: int = 100
232
  phases: list[str] = field(default_factory=list)
233
  include_eap: bool = False
 
234
 
235
  def summary(self) -> str:
236
  lines = [
 
6
  from dataclasses import dataclass, field
7
  from typing import Optional, TypedDict
8
 
9
+ from translations import LANGUAGE_DIRECTIVE
10
+
11
  import anthropic
12
  import httpx
13
  from rich import box
 
233
  radius_miles: int = 100
234
  phases: list[str] = field(default_factory=list)
235
  include_eap: bool = False
236
+ lang: str = "en"
237
 
238
  def summary(self) -> str:
239
  lines = [
translations.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ LANGUAGES: dict[str, str] = {
4
+ "en": "English",
5
+ "zh": "中文",
6
+ "es": "Español",
7
+ "hi": "हिन्दी",
8
+ }
9
+
10
+ UI: dict[str, dict[str, str]] = {
11
+ "en": {
12
+ "page_title": "Beacon — Clinical Trial Finder",
13
+ "heading": "# 🔦 Beacon — Rare Disease Clinical Trial Finder",
14
+ "placeholder": "Type your message and press Enter…",
15
+ "send": "Send",
16
+ "new_search": "New Search",
17
+ "searching": "Searching…",
18
+ "search_complete": "Search complete.",
19
+ "status_searching": "*Searching ClinicalTrials.gov — this may take a minute…*",
20
+ "no_analysis": "No analysis produced.",
21
+ "api_error": "API request failed: {exc}",
22
+ "got_it": "Got it — searching for trials now…",
23
+ },
24
+ "zh": {
25
+ "page_title": "Beacon — 临床试验查找器",
26
+ "heading": "# 🔦 Beacon — 罕见病临床试验查找器",
27
+ "placeholder": "输入消息并按回车…",
28
+ "send": "发送",
29
+ "new_search": "新搜索",
30
+ "searching": "搜索中…",
31
+ "search_complete": "搜索完成。",
32
+ "status_searching": "*正在搜索 ClinicalTrials.gov,请稍候…*",
33
+ "no_analysis": "未生成分析结果。",
34
+ "api_error": "API 请求失败:{exc}",
35
+ "got_it": "好的,正在为您搜索临床试验…",
36
+ },
37
+ "es": {
38
+ "page_title": "Beacon — Buscador de Ensayos Clínicos",
39
+ "heading": "# 🔦 Beacon — Buscador de Ensayos Clínicos para Enfermedades Raras",
40
+ "placeholder": "Escribe tu mensaje y presiona Enter…",
41
+ "send": "Enviar",
42
+ "new_search": "Nueva Búsqueda",
43
+ "searching": "Buscando…",
44
+ "search_complete": "Búsqueda completada.",
45
+ "status_searching": "*Buscando en ClinicalTrials.gov, esto puede tomar un minuto…*",
46
+ "no_analysis": "No se produjo ningún análisis.",
47
+ "api_error": "Solicitud de API fallida: {exc}",
48
+ "got_it": "Entendido, buscando ensayos ahora…",
49
+ },
50
+ "hi": {
51
+ "page_title": "Beacon — क्लिनिकल ट्रायल खोजक",
52
+ "heading": "# 🔦 Beacon — दुर्लभ रोग क्लिनिकल ट्रायल खोजक",
53
+ "placeholder": "अपना संदेश टाइप करें और Enter दबाएं…",
54
+ "send": "भेजें",
55
+ "new_search": "नई खोज",
56
+ "searching": "खोज रहे हैं…",
57
+ "search_complete": "खोज पूर्ण।",
58
+ "status_searching": "*ClinicalTrials.gov पर खोज हो रही है, एक मिनट लग सकता है…*",
59
+ "no_analysis": "कोई विश्लेषण नहीं बना।",
60
+ "api_error": "API अनुरोध विफल: {exc}",
61
+ "got_it": "समझ गया — अभी ट्रायल खोज रहे हैं…",
62
+ },
63
+ }
64
+
65
+ LANGUAGE_DIRECTIVE: dict[str, str] = {
66
+ "en": "",
67
+ "zh": (
68
+ "IMPORTANT: You must respond ONLY in Simplified Chinese (简体中文). "
69
+ "Every word you write to the patient must be in Chinese. "
70
+ "Standard medical abbreviations (ALSFRS-R, FVC, SOD1, etc.) may remain in their original form.\n\n"
71
+ ),
72
+ "es": (
73
+ "IMPORTANTE: Debes responder ÚNICAMENTE en español. "
74
+ "Cada palabra que escribas al paciente debe estar en español. "
75
+ "Las abreviaturas médicas estándar (ALSFRS-R, FVC, SOD1, etc.) pueden mantenerse en su forma original.\n\n"
76
+ ),
77
+ "hi": (
78
+ "महत्वपूर्ण: आपको केवल हिन्दी में उत्तर देना है। "
79
+ "रोगी को लिखी गई हर बात हिन्दी में होनी चाहिए। "
80
+ "मानक चिकित्सा संक्षेप (ALSFRS-R, FVC, SOD1, आदि) अपने मूल रूप में रह सकते हैं।\n\n"
81
+ ),
82
+ }