spagestic commited on
Commit
a5da148
·
1 Parent(s): dbdb569

Richer Globe Research Popups is implemented

Browse files
assets/globe.css CHANGED
@@ -143,13 +143,173 @@
143
  }
144
 
145
  .globe-marker-popup-empty,
146
- .globe-marker-popup-hint {
 
 
 
 
147
  margin: 0;
148
  font-size: 0.82rem;
149
  line-height: 1.4;
150
  color: #9ca3af;
151
  }
152
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
  .globe-marker-popup-hint {
154
  margin-top: 10px;
155
  font-size: 0.75rem;
 
143
  }
144
 
145
  .globe-marker-popup-empty,
146
+ .globe-marker-popup-hint,
147
+ .globe-marker-popup-waiting,
148
+ .globe-marker-popup-incomplete,
149
+ .globe-marker-popup-preview,
150
+ .globe-marker-popup-activity {
151
  margin: 0;
152
  font-size: 0.82rem;
153
  line-height: 1.4;
154
  color: #9ca3af;
155
  }
156
 
157
+ .globe-marker-popup-status {
158
+ margin: 0 0 6px;
159
+ font-size: 0.72rem;
160
+ font-weight: 600;
161
+ letter-spacing: 0.05em;
162
+ text-transform: uppercase;
163
+ color: #60a5fa;
164
+ }
165
+
166
+ .globe-marker-popup--researching .globe-marker-popup-status {
167
+ color: #60a5fa;
168
+ }
169
+
170
+ .globe-marker-popup--complete .globe-marker-popup-status {
171
+ color: #34d399;
172
+ }
173
+
174
+ .globe-marker-popup-activity {
175
+ margin-bottom: 8px;
176
+ color: #e5e7eb;
177
+ }
178
+
179
+ .globe-marker-popup-preview {
180
+ margin-bottom: 10px;
181
+ color: #e5e7eb;
182
+ }
183
+
184
+ .globe-marker-popup-activities {
185
+ margin: 0 0 10px;
186
+ padding-left: 1rem;
187
+ font-size: 0.78rem;
188
+ color: #cbd5e1;
189
+ }
190
+
191
+ .globe-marker-popup-view-details {
192
+ display: inline-flex;
193
+ align-items: center;
194
+ justify-content: center;
195
+ margin: 0 0 10px;
196
+ padding: 8px 12px;
197
+ border: 1px solid rgba(245, 158, 11, 0.45);
198
+ border-radius: 999px;
199
+ background: rgba(245, 158, 11, 0.12);
200
+ color: #fbbf24;
201
+ font-size: 0.82rem;
202
+ font-weight: 600;
203
+ cursor: pointer;
204
+ transition: background 0.15s ease, border-color 0.15s ease;
205
+ }
206
+
207
+ .globe-marker-popup-view-details:hover {
208
+ background: rgba(245, 158, 11, 0.22);
209
+ border-color: rgba(251, 191, 36, 0.75);
210
+ }
211
+
212
+ .globe-detail-backdrop {
213
+ position: fixed;
214
+ inset: 0;
215
+ z-index: 10000;
216
+ display: flex;
217
+ align-items: center;
218
+ justify-content: center;
219
+ padding: 24px;
220
+ background: rgba(2, 6, 23, 0.72);
221
+ backdrop-filter: blur(6px);
222
+ }
223
+
224
+ .globe-detail-backdrop[hidden] {
225
+ display: none;
226
+ }
227
+
228
+ .globe-detail-dialog {
229
+ width: min(720px, calc(100vw - 32px));
230
+ max-height: min(82vh, 760px);
231
+ display: flex;
232
+ flex-direction: column;
233
+ border: 1px solid rgba(245, 158, 11, 0.28);
234
+ border-radius: 16px;
235
+ background: #0f172a;
236
+ box-shadow: 0 24px 64px rgba(0, 0, 0, 0.55);
237
+ overflow: hidden;
238
+ }
239
+
240
+ .globe-detail-dialog-header {
241
+ display: flex;
242
+ align-items: flex-start;
243
+ justify-content: space-between;
244
+ gap: 12px;
245
+ padding: 18px 20px 14px;
246
+ border-bottom: 1px solid rgba(148, 163, 184, 0.18);
247
+ }
248
+
249
+ .globe-detail-dialog-heading {
250
+ min-width: 0;
251
+ }
252
+
253
+ .globe-detail-dialog-title {
254
+ margin: 0;
255
+ font-size: 1.2rem;
256
+ line-height: 1.3;
257
+ color: #f9fafb;
258
+ }
259
+
260
+ .globe-detail-dialog-pathway {
261
+ margin: 6px 0 0;
262
+ color: #fbbf24;
263
+ font-size: 0.92rem;
264
+ line-height: 1.35;
265
+ }
266
+
267
+ .globe-detail-dialog-close {
268
+ flex: 0 0 auto;
269
+ width: 36px;
270
+ height: 36px;
271
+ border: 0;
272
+ border-radius: 999px;
273
+ background: rgba(148, 163, 184, 0.12);
274
+ color: #e5e7eb;
275
+ font-size: 1.4rem;
276
+ line-height: 1;
277
+ cursor: pointer;
278
+ }
279
+
280
+ .globe-detail-dialog-close:hover {
281
+ background: rgba(148, 163, 184, 0.22);
282
+ }
283
+
284
+ .globe-detail-dialog-body {
285
+ padding: 16px 20px 20px;
286
+ overflow-y: auto;
287
+ color: #e5e7eb;
288
+ font-size: 0.9rem;
289
+ line-height: 1.55;
290
+ }
291
+
292
+ .globe-detail-dialog-body h3 {
293
+ margin: 1.1em 0 0.45em;
294
+ font-size: 0.95rem;
295
+ color: #fbbf24;
296
+ }
297
+
298
+ .globe-detail-dialog-body h3:first-child {
299
+ margin-top: 0;
300
+ }
301
+
302
+ .globe-detail-dialog-body p,
303
+ .globe-detail-dialog-body ul,
304
+ .globe-detail-dialog-body ol {
305
+ margin: 0.45em 0;
306
+ }
307
+
308
+ .globe-detail-dialog-body a {
309
+ color: #fbbf24;
310
+ word-break: break-word;
311
+ }
312
+
313
  .globe-marker-popup-hint {
314
  margin-top: 10px;
315
  font-size: 0.75rem;
assets/globe.js CHANGED
@@ -1,8 +1,5 @@
1
  /* assets/globe.js */
2
- import {
3
- plainTextSummary,
4
- renderMarkdownHtml,
5
- } from "/assets/markdown.js?v=1";
6
 
7
  const MAPLIBRE_VERSION = "5.24.0";
8
  const MAPLIBRE_BASE = `https://unpkg.com/maplibre-gl@${MAPLIBRE_VERSION}/dist`;
@@ -122,6 +119,15 @@ function mergeGlobeOptions(...sources) {
122
  const HIGHLIGHT_SOURCE = "borderless-highlights";
123
  const HIGHLIGHT_LAYER = "borderless-highlight-circles";
124
 
 
 
 
 
 
 
 
 
 
125
  window.BorderlessGlobe = {
126
  map: null,
127
  markers: [],
@@ -131,6 +137,10 @@ window.BorderlessGlobe = {
131
  pendingState: null,
132
  rootElement: null,
133
  countryDetails: {},
 
 
 
 
134
 
135
  shell() {
136
  return this.rootElement?.querySelector(".globe-shell") ?? null;
@@ -160,78 +170,204 @@ window.BorderlessGlobe = {
160
  }
161
  },
162
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  buildPopupElement(marker) {
164
- const detail = marker.detail || this.countryDetails[marker.iso2] || null;
 
165
  const container = document.createElement("div");
166
- container.className = "globe-marker-popup";
167
 
168
  const title = document.createElement("h4");
169
  title.className = "globe-marker-popup-title";
170
  title.textContent = detail?.country || marker.name || marker.iso2 || "Country";
171
  container.appendChild(title);
172
 
173
- const pathway = detail?.pathway || marker.label;
174
- if (pathway) {
175
  const pathwayEl = document.createElement("p");
176
  pathwayEl.className = "globe-marker-popup-pathway";
177
- pathwayEl.textContent = pathway;
178
  container.appendChild(pathwayEl);
179
  }
180
 
181
- if (detail?.summary) {
182
- const body = document.createElement("div");
183
- body.className = "globe-marker-popup-body markdown-body";
184
- body.dataset.markdown = detail.summary;
185
- body.textContent = plainTextSummary(detail.summary, 420);
186
- container.appendChild(body);
187
- } else if (marker.label) {
188
- const fallback = document.createElement("p");
189
- fallback.className = "globe-marker-popup-empty";
190
- fallback.textContent = marker.label;
191
- container.appendChild(fallback);
192
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
193
 
194
- if (detail?.sources?.length) {
195
- const sources = document.createElement("div");
196
- sources.className = "globe-marker-popup-sources";
197
- const heading = document.createElement("strong");
198
- heading.textContent = "Official sources";
199
- sources.appendChild(heading);
200
- const list = document.createElement("ul");
201
- for (const url of detail.sources.slice(0, 4)) {
202
- const item = document.createElement("li");
203
- const link = document.createElement("a");
204
- link.href = url;
205
- link.target = "_blank";
206
- link.rel = "noopener noreferrer";
207
- link.textContent = url.replace(/^https?:\/\//, "").slice(0, 72);
208
- item.appendChild(link);
209
- list.appendChild(item);
210
  }
211
- sources.appendChild(list);
212
- container.appendChild(sources);
 
 
 
 
 
 
 
 
 
 
213
  }
214
 
215
  const hint = document.createElement("p");
216
  hint.className = "globe-marker-popup-hint";
217
- hint.textContent = "Research summary — verify on official immigration sites.";
 
 
 
 
 
 
 
 
218
  container.appendChild(hint);
219
 
220
  return container;
221
  },
222
 
223
- async hydratePopupMarkdown(popup) {
224
- const body = popup?.getElement()?.querySelector("[data-markdown]");
225
- if (!body) return;
226
- const markdown = body.dataset.markdown;
227
- if (!markdown) return;
228
- body.innerHTML = await renderMarkdownHtml(markdown);
229
- for (const link of body.querySelectorAll("a")) {
230
- link.target = "_blank";
231
- link.rel = "noopener noreferrer";
232
- }
233
- },
234
-
235
  createMarkerPopup(marker) {
236
  const popupNode = this.buildPopupElement(marker);
237
  const popup = new maplibregl.Popup({
@@ -242,9 +378,6 @@ window.BorderlessGlobe = {
242
  className: "borderless-country-popup",
243
  }).setDOMContent(popupNode);
244
 
245
- popup.on("open", () => {
246
- this.hydratePopupMarkdown(popup);
247
- });
248
  return popup;
249
  },
250
 
@@ -274,10 +407,7 @@ window.BorderlessGlobe = {
274
  for (const record of this.markerRecords) {
275
  const mergedMarker = {
276
  ...record.marker,
277
- detail:
278
- record.marker.detail ||
279
- this.countryDetails[record.marker.iso2] ||
280
- null,
281
  };
282
  record.marker = mergedMarker;
283
  const popupNode = this.buildPopupElement(mergedMarker);
 
1
  /* assets/globe.js */
2
+ import { renderMarkdownHtml } from "/assets/markdown.js?v=1";
 
 
 
3
 
4
  const MAPLIBRE_VERSION = "5.24.0";
5
  const MAPLIBRE_BASE = `https://unpkg.com/maplibre-gl@${MAPLIBRE_VERSION}/dist`;
 
119
  const HIGHLIGHT_SOURCE = "borderless-highlights";
120
  const HIGHLIGHT_LAYER = "borderless-highlight-circles";
121
 
122
+ const DETAIL_SECTIONS = [
123
+ ["why_recommended", "Why This Country Is Recommended"],
124
+ ["feasible_methods", "Feasible Migration Methods"],
125
+ ["procedure", "Step-by-Step Procedure"],
126
+ ["requirements", "Requirements And Documents"],
127
+ ["duration_costs_risks", "Duration, Costs, And Risks"],
128
+ ["official_sources", "Official Sources"],
129
+ ];
130
+
131
  window.BorderlessGlobe = {
132
  map: null,
133
  markers: [],
 
137
  pendingState: null,
138
  rootElement: null,
139
  countryDetails: {},
140
+ detailModalBackdrop: null,
141
+ detailModalTitle: null,
142
+ detailModalPathway: null,
143
+ detailModalBody: null,
144
 
145
  shell() {
146
  return this.rootElement?.querySelector(".globe-shell") ?? null;
 
170
  }
171
  },
172
 
173
+ resolveMarkerDetail(marker) {
174
+ if (marker.detail) return marker.detail;
175
+ if (marker.iso2 && this.countryDetails[marker.iso2]) {
176
+ return this.countryDetails[marker.iso2];
177
+ }
178
+ if (marker.name && this.countryDetails[marker.name]) {
179
+ return this.countryDetails[marker.name];
180
+ }
181
+ return null;
182
+ },
183
+
184
+ ensureDetailModal() {
185
+ if (this.detailModalBackdrop) return;
186
+
187
+ const backdrop = document.createElement("div");
188
+ backdrop.className = "globe-detail-backdrop";
189
+ backdrop.hidden = true;
190
+
191
+ const dialog = document.createElement("div");
192
+ dialog.className = "globe-detail-dialog";
193
+ dialog.setAttribute("role", "dialog");
194
+ dialog.setAttribute("aria-modal", "true");
195
+
196
+ const header = document.createElement("div");
197
+ header.className = "globe-detail-dialog-header";
198
+
199
+ const titleWrap = document.createElement("div");
200
+ titleWrap.className = "globe-detail-dialog-heading";
201
+
202
+ const titleEl = document.createElement("h2");
203
+ titleEl.className = "globe-detail-dialog-title";
204
+
205
+ const pathwayEl = document.createElement("p");
206
+ pathwayEl.className = "globe-detail-dialog-pathway";
207
+
208
+ titleWrap.appendChild(titleEl);
209
+ titleWrap.appendChild(pathwayEl);
210
+
211
+ const closeBtn = document.createElement("button");
212
+ closeBtn.type = "button";
213
+ closeBtn.className = "globe-detail-dialog-close";
214
+ closeBtn.setAttribute("aria-label", "Close country details");
215
+ closeBtn.textContent = "×";
216
+ closeBtn.addEventListener("click", () => this.closeCountryDetailModal());
217
+
218
+ header.appendChild(titleWrap);
219
+ header.appendChild(closeBtn);
220
+
221
+ const body = document.createElement("div");
222
+ body.className = "globe-detail-dialog-body markdown-body";
223
+
224
+ dialog.appendChild(header);
225
+ dialog.appendChild(body);
226
+ backdrop.appendChild(dialog);
227
+
228
+ backdrop.addEventListener("click", (event) => {
229
+ if (event.target === backdrop) {
230
+ this.closeCountryDetailModal();
231
+ }
232
+ });
233
+
234
+ this._detailModalKeyHandler = (event) => {
235
+ if (event.key === "Escape" && !backdrop.hidden) {
236
+ this.closeCountryDetailModal();
237
+ }
238
+ };
239
+ document.addEventListener("keydown", this._detailModalKeyHandler);
240
+
241
+ document.body.appendChild(backdrop);
242
+ this.detailModalBackdrop = backdrop;
243
+ this.detailModalTitle = titleEl;
244
+ this.detailModalPathway = pathwayEl;
245
+ this.detailModalBody = body;
246
+ },
247
+
248
+ async openCountryDetailModal(detail) {
249
+ if (!detail?.sections) return;
250
+ this.ensureDetailModal();
251
+
252
+ this.detailModalTitle.textContent = detail.country || "Country details";
253
+ this.detailModalPathway.textContent = detail.pathway || "";
254
+ this.detailModalPathway.hidden = !detail.pathway;
255
+
256
+ const parts = [];
257
+ for (const [key, heading] of DETAIL_SECTIONS) {
258
+ const content = detail.sections[key];
259
+ if (content) {
260
+ parts.push(`### ${heading}\n\n${content}`);
261
+ }
262
+ }
263
+
264
+ const markdown = parts.join("\n\n");
265
+ this.detailModalBody.innerHTML = await renderMarkdownHtml(markdown);
266
+ for (const link of this.detailModalBody.querySelectorAll("a")) {
267
+ link.target = "_blank";
268
+ link.rel = "noopener noreferrer";
269
+ }
270
+
271
+ this.detailModalBackdrop.hidden = false;
272
+ },
273
+
274
+ closeCountryDetailModal() {
275
+ if (this.detailModalBackdrop) {
276
+ this.detailModalBackdrop.hidden = true;
277
+ }
278
+ },
279
+
280
  buildPopupElement(marker) {
281
+ const detail = this.resolveMarkerDetail(marker);
282
+ const status = detail?.status || "pending";
283
  const container = document.createElement("div");
284
+ container.className = `globe-marker-popup globe-marker-popup--${status}`;
285
 
286
  const title = document.createElement("h4");
287
  title.className = "globe-marker-popup-title";
288
  title.textContent = detail?.country || marker.name || marker.iso2 || "Country";
289
  container.appendChild(title);
290
 
291
+ if (detail?.pathway) {
 
292
  const pathwayEl = document.createElement("p");
293
  pathwayEl.className = "globe-marker-popup-pathway";
294
+ pathwayEl.textContent = detail.pathway;
295
  container.appendChild(pathwayEl);
296
  }
297
 
298
+ if (status === "researching") {
299
+ const statusBadge = document.createElement("p");
300
+ statusBadge.className = "globe-marker-popup-status";
301
+ statusBadge.textContent = "Research in progress";
302
+ container.appendChild(statusBadge);
303
+
304
+ const activity = document.createElement("p");
305
+ activity.className = "globe-marker-popup-activity";
306
+ activity.textContent = `Researching: ${
307
+ detail?.current_activity || "Gathering official immigration sources…"
308
+ }`;
309
+ container.appendChild(activity);
310
+
311
+ if (detail?.activities?.length > 1) {
312
+ const list = document.createElement("ul");
313
+ list.className = "globe-marker-popup-activities";
314
+ for (const item of detail.activities.slice(-3)) {
315
+ const listItem = document.createElement("li");
316
+ listItem.textContent = item;
317
+ list.appendChild(listItem);
318
+ }
319
+ container.appendChild(list);
320
+ }
321
+ } else if (status === "complete") {
322
+ if (detail?.preview) {
323
+ const preview = document.createElement("p");
324
+ preview.className = "globe-marker-popup-preview";
325
+ preview.textContent = detail.preview;
326
+ container.appendChild(preview);
327
+ }
328
 
329
+ if (detail?.sections && Object.keys(detail.sections).length) {
330
+ const viewBtn = document.createElement("button");
331
+ viewBtn.type = "button";
332
+ viewBtn.className = "globe-marker-popup-view-details";
333
+ viewBtn.textContent = "View details";
334
+ viewBtn.addEventListener("click", (event) => {
335
+ event.preventDefault();
336
+ event.stopPropagation();
337
+ void this.openCountryDetailModal(detail);
338
+ });
339
+ container.appendChild(viewBtn);
 
 
 
 
 
340
  }
341
+ } else if (status === "incomplete") {
342
+ const incomplete = document.createElement("p");
343
+ incomplete.className = "globe-marker-popup-incomplete";
344
+ incomplete.textContent =
345
+ detail?.preview ||
346
+ "Research did not produce verified findings for this country.";
347
+ container.appendChild(incomplete);
348
+ } else {
349
+ const waiting = document.createElement("p");
350
+ waiting.className = "globe-marker-popup-waiting";
351
+ waiting.textContent = "Waiting for research to start…";
352
+ container.appendChild(waiting);
353
  }
354
 
355
  const hint = document.createElement("p");
356
  hint.className = "globe-marker-popup-hint";
357
+ if (status === "researching") {
358
+ hint.textContent = "Live tool activity from the country researcher.";
359
+ } else if (status === "complete") {
360
+ hint.textContent = "Verify key requirements on official immigration sites.";
361
+ } else if (status === "incomplete") {
362
+ hint.textContent = "Open the parallel research panel in chat for tool logs.";
363
+ } else {
364
+ hint.textContent = "Details appear as each country research completes.";
365
+ }
366
  container.appendChild(hint);
367
 
368
  return container;
369
  },
370
 
 
 
 
 
 
 
 
 
 
 
 
 
371
  createMarkerPopup(marker) {
372
  const popupNode = this.buildPopupElement(marker);
373
  const popup = new maplibregl.Popup({
 
378
  className: "borderless-country-popup",
379
  }).setDOMContent(popupNode);
380
 
 
 
 
381
  return popup;
382
  },
383
 
 
407
  for (const record of this.markerRecords) {
408
  const mergedMarker = {
409
  ...record.marker,
410
+ detail: this.resolveMarkerDetail(record.marker),
 
 
 
411
  };
412
  record.marker = mergedMarker;
413
  const popupNode = this.buildPopupElement(mergedMarker);
ui/agent/graph/respond.py CHANGED
@@ -8,7 +8,7 @@ import gradio as gr
8
  from gradio import ChatMessage
9
 
10
  from ui.globe_commands import apply_update_globe
11
- from ui.globe_details import attach_finding_to_globe
12
 
13
  from ..messages import history_to_api_messages, multimodal_input_to_api_content
14
  from ..respond import (
@@ -36,6 +36,22 @@ class _UiState:
36
  self.globe_state = globe_state
37
  self._pending_by_id: dict[str, int] = {}
38
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  def handle(self, event: dict[str, Any]) -> bool:
40
  kind = event.get("type")
41
  if kind == "thinking":
@@ -124,6 +140,7 @@ class _UiState:
124
  )
125
  )
126
  self._pending_by_id[str(event.get("id"))] = len(self.ui_messages) - 1
 
127
  return True
128
 
129
  if kind == "tool_end":
@@ -143,6 +160,7 @@ class _UiState:
143
  self.ui_messages[index] = message
144
  else:
145
  self.ui_messages.append(message)
 
146
  return True
147
 
148
  if kind == "finding":
 
8
  from gradio import ChatMessage
9
 
10
  from ui.globe_commands import apply_update_globe
11
+ from ui.globe_details import attach_finding_to_globe, attach_research_progress_to_globe
12
 
13
  from ..messages import history_to_api_messages, multimodal_input_to_api_content
14
  from ..respond import (
 
36
  self.globe_state = globe_state
37
  self._pending_by_id: dict[str, int] = {}
38
 
39
+ def _attach_research_progress(self, event: dict[str, Any]) -> None:
40
+ research_task = event.get("research_task")
41
+ if not research_task:
42
+ return
43
+ todo_title = str(research_task.get("title") or "").strip()
44
+ if not todo_title:
45
+ return
46
+ log = event.get("log") or {}
47
+ self.globe_state = attach_research_progress_to_globe(
48
+ self.globe_state,
49
+ todo_title=todo_title,
50
+ tool_name=str(log.get("tool") or ""),
51
+ args=dict(log.get("arguments") or {}),
52
+ message=str(event.get("message") or ""),
53
+ )
54
+
55
  def handle(self, event: dict[str, Any]) -> bool:
56
  kind = event.get("type")
57
  if kind == "thinking":
 
140
  )
141
  )
142
  self._pending_by_id[str(event.get("id"))] = len(self.ui_messages) - 1
143
+ self._attach_research_progress(event)
144
  return True
145
 
146
  if kind == "tool_end":
 
160
  self.ui_messages[index] = message
161
  else:
162
  self.ui_messages.append(message)
163
+ self._attach_research_progress(event)
164
  return True
165
 
166
  if kind == "finding":
ui/agent/synthesis.py CHANGED
@@ -99,6 +99,65 @@ def _needs_verification(value: str | None, *, fallback: str) -> str:
99
  return f"{fallback} *(Needs verification on official immigration sources.)*"
100
 
101
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  def build_structured_final_answer(
103
  *,
104
  profile_summary: str,
 
99
  return f"{fallback} *(Needs verification on official immigration sources.)*"
100
 
101
 
102
+ def build_country_report_sections(
103
+ summary: str,
104
+ *,
105
+ country: str,
106
+ pathway: str,
107
+ ) -> dict[str, str]:
108
+ """Structured sections for a single country report."""
109
+ pathway_text = _extract_section(summary, "pathway") or pathway
110
+ timeline = _extract_section(summary, "timeline")
111
+ costs = _extract_section(summary, "costs")
112
+ risks = _extract_section(summary, "risks")
113
+ urls = _extract_urls(summary)
114
+
115
+ duration_parts = []
116
+ if timeline:
117
+ duration_parts.append(f"**Timeline:** {timeline}")
118
+ if costs:
119
+ duration_parts.append(f"**Costs:** {costs}")
120
+ if risks:
121
+ duration_parts.append(f"**Risks:** {risks}")
122
+ duration_block = "\n\n".join(duration_parts) if duration_parts else ""
123
+
124
+ sources_block = "\n".join(f"- {url}" for url in urls[:8]) if urls else ""
125
+
126
+ return {
127
+ "why_recommended": _needs_verification(
128
+ _extract_section(summary, "eligibility"),
129
+ fallback=f"Potential fit for skilled migration to {country} via {pathway}.",
130
+ ),
131
+ "feasible_methods": _needs_verification(
132
+ pathway_text,
133
+ fallback=pathway,
134
+ ),
135
+ "procedure": _needs_verification(
136
+ _extract_steps(summary),
137
+ fallback=(
138
+ f"Confirm the official application sequence on the immigration "
139
+ f"authority website for {country}."
140
+ ),
141
+ ),
142
+ "requirements": _needs_verification(
143
+ _extract_section(summary, "documents"),
144
+ fallback=(
145
+ "Passport, education credentials, employment records, language "
146
+ f"test results (if required), and proof of funds — verify exact "
147
+ f"list for {pathway}."
148
+ ),
149
+ ),
150
+ "duration_costs_risks": _needs_verification(
151
+ duration_block or None,
152
+ fallback="Typical processing time, fees, and risks not verified in research.",
153
+ ),
154
+ "official_sources": _needs_verification(
155
+ sources_block or None,
156
+ fallback="Check the country's official immigration authority website.",
157
+ ),
158
+ }
159
+
160
+
161
  def build_structured_final_answer(
162
  *,
163
  profile_summary: str,
ui/globe_details.py CHANGED
@@ -2,10 +2,13 @@
2
  from __future__ import annotations
3
 
4
  import re
 
5
  from typing import Any
6
 
7
  from apis.rest_countries import lookup_country
8
 
 
 
9
  _COUNTRY_NAME_TO_ISO2 = {
10
  "canada": "CA",
11
  "germany": "DE",
@@ -21,6 +24,18 @@ _COUNTRY_NAME_TO_ISO2 = {
21
  "usa": "US",
22
  }
23
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  _URL_PATTERN = re.compile(r"https?://[^\s\)\]>\"']+")
25
 
26
 
@@ -34,6 +49,13 @@ def split_country_title(title: str) -> tuple[str, str]:
34
  return title.strip(), "Skilled migration pathway"
35
 
36
 
 
 
 
 
 
 
 
37
  def _extract_urls(text: str) -> list[str]:
38
  seen: set[str] = set()
39
  urls: list[str] = []
@@ -65,38 +87,73 @@ def iso2_from_todo_title(title: str, marker_isos: set[str] | None = None) -> str
65
  return iso2_from_country_name(country, marker_isos)
66
 
67
 
68
- def build_country_detail(todo_title: str, summary: str) -> dict[str, Any]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  country, pathway = split_country_title(todo_title)
70
  return {
71
  "country": country,
72
  "title": todo_title,
73
  "pathway": pathway,
74
- "summary": summary.strip(),
75
- "sources": _extract_urls(summary),
 
 
 
 
 
76
  }
77
 
78
 
79
- def attach_finding_to_globe(
80
- state: dict[str, Any],
81
- *,
82
  todo_title: str,
83
- summary: str,
84
- ) -> dict[str, Any]:
85
- """Merge researcher findings into globe_state for marker popups."""
86
- marker_isos = {str(marker.get("iso2", "")).upper() for marker in state.get("markers", [])}
87
- marker_isos.discard("")
88
-
89
  iso2 = iso2_from_todo_title(todo_title, marker_isos or None)
90
- detail = build_country_detail(todo_title, summary)
91
  if iso2:
92
  detail["iso2"] = iso2
93
  info = lookup_country(iso2)
94
  if info:
95
  detail["country"] = str(info.get("name") or detail["country"])
 
 
96
 
 
 
 
 
 
 
 
 
97
  country_details = dict(state.get("country_details") or {})
98
- key = iso2 or detail["country"]
99
- country_details[key] = detail
100
 
101
  markers = []
102
  for marker in state.get("markers", []):
@@ -111,3 +168,116 @@ def attach_finding_to_globe(
111
  "country_details": country_details,
112
  "details_version": int(state.get("details_version", 0)) + 1,
113
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  from __future__ import annotations
3
 
4
  import re
5
+ import time
6
  from typing import Any
7
 
8
  from apis.rest_countries import lookup_country
9
 
10
+ from ui.agent.synthesis import build_country_report_sections
11
+
12
  _COUNTRY_NAME_TO_ISO2 = {
13
  "canada": "CA",
14
  "germany": "DE",
 
24
  "usa": "US",
25
  }
26
 
27
+ _INCOMPLETE_SUMMARIES = {
28
+ "no findings could be produced for this to-do.",
29
+ "no detailed findings were captured for this country yet.",
30
+ }
31
+
32
+ _TOOL_ACTIVITY_LABELS = {
33
+ "search_immigration_info": "Searching immigration sources",
34
+ "get_country_profile": "Loading country profile",
35
+ "scrape_web_page": "Reading official page",
36
+ "crawl_web_site": "Crawling official site",
37
+ }
38
+
39
  _URL_PATTERN = re.compile(r"https?://[^\s\)\]>\"']+")
40
 
41
 
 
49
  return title.strip(), "Skilled migration pathway"
50
 
51
 
52
+ def is_incomplete_summary(summary: str) -> bool:
53
+ text = summary.strip().lower()
54
+ if not text:
55
+ return True
56
+ return text in _INCOMPLETE_SUMMARIES
57
+
58
+
59
  def _extract_urls(text: str) -> list[str]:
60
  seen: set[str] = set()
61
  urls: list[str] = []
 
87
  return iso2_from_country_name(country, marker_isos)
88
 
89
 
90
+ def _preview_text(text: str, limit: int = 220) -> str:
91
+ cleaned = re.sub(r"[#>*`_\-[\]()]", " ", text)
92
+ cleaned = re.sub(r"\s+", " ", cleaned).strip()
93
+ if len(cleaned) <= limit:
94
+ return cleaned
95
+ return f"{cleaned[: limit - 1]}…"
96
+
97
+
98
+ def _activity_from_tool(tool_name: str, args: dict[str, Any], message: str) -> str:
99
+ label = _TOOL_ACTIVITY_LABELS.get(tool_name, f"Running {tool_name}")
100
+ if tool_name == "search_immigration_info":
101
+ query = str(args.get("query") or "").strip()
102
+ if query:
103
+ return f"{label}: {query[:160]}"
104
+ if tool_name == "scrape_web_page":
105
+ url = str(args.get("url") or "").strip()
106
+ if url:
107
+ return f"{label}: {url[:120]}"
108
+ if tool_name == "crawl_web_site":
109
+ url = str(args.get("url") or "").strip()
110
+ if url:
111
+ return f"{label}: {url[:120]}"
112
+ if message.strip():
113
+ return message.strip()[:220]
114
+ return label
115
+
116
+
117
+ def _base_detail(todo_title: str) -> dict[str, Any]:
118
  country, pathway = split_country_title(todo_title)
119
  return {
120
  "country": country,
121
  "title": todo_title,
122
  "pathway": pathway,
123
+ "summary": "",
124
+ "preview": "",
125
+ "sources": [],
126
+ "activities": [],
127
+ "current_activity": "",
128
+ "sections": {},
129
+ "updated_at": int(time.time()),
130
  }
131
 
132
 
133
+ def _enrich_detail_with_iso2(
134
+ detail: dict[str, Any],
 
135
  todo_title: str,
136
+ marker_isos: set[str],
137
+ ) -> tuple[str | None, dict[str, Any]]:
 
 
 
 
138
  iso2 = iso2_from_todo_title(todo_title, marker_isos or None)
 
139
  if iso2:
140
  detail["iso2"] = iso2
141
  info = lookup_country(iso2)
142
  if info:
143
  detail["country"] = str(info.get("name") or detail["country"])
144
+ key = iso2 or str(detail["country"])
145
+ return iso2, detail if iso2 else detail | {"iso2": None, "key": key}
146
 
147
+
148
+ def _merge_detail_into_state(
149
+ state: dict[str, Any],
150
+ *,
151
+ iso2: str | None,
152
+ country_key: str,
153
+ detail: dict[str, Any],
154
+ ) -> dict[str, Any]:
155
  country_details = dict(state.get("country_details") or {})
156
+ country_details[country_key] = detail
 
157
 
158
  markers = []
159
  for marker in state.get("markers", []):
 
168
  "country_details": country_details,
169
  "details_version": int(state.get("details_version", 0)) + 1,
170
  }
171
+
172
+
173
+ def build_research_progress_detail(
174
+ todo_title: str,
175
+ *,
176
+ tool_name: str,
177
+ args: dict[str, Any],
178
+ message: str,
179
+ existing: dict[str, Any] | None = None,
180
+ ) -> dict[str, Any]:
181
+ detail = dict(existing or _base_detail(todo_title))
182
+ activity = _activity_from_tool(tool_name, args, message)
183
+ activities = list(detail.get("activities") or [])
184
+ if activity and (not activities or activities[-1] != activity):
185
+ activities.append(activity)
186
+ activities = activities[-6:]
187
+
188
+ detail.update(
189
+ {
190
+ "status": "researching",
191
+ "current_activity": activity,
192
+ "activities": activities,
193
+ "preview": activity,
194
+ "updated_at": int(time.time()),
195
+ }
196
+ )
197
+ return detail
198
+
199
+
200
+ def build_country_detail(todo_title: str, summary: str) -> dict[str, Any]:
201
+ country, pathway = split_country_title(todo_title)
202
+ detail = _base_detail(todo_title)
203
+
204
+ if is_incomplete_summary(summary):
205
+ detail.update(
206
+ {
207
+ "status": "incomplete",
208
+ "preview": (
209
+ "Research could not produce verified findings for this country. "
210
+ "Check the parallel research panel for tool activity."
211
+ ),
212
+ "sections": build_country_report_sections("", country=country, pathway=pathway),
213
+ }
214
+ )
215
+ return detail
216
+
217
+ sections = build_country_report_sections(summary, country=country, pathway=pathway)
218
+ detail.update(
219
+ {
220
+ "status": "complete",
221
+ "summary": summary.strip(),
222
+ "preview": _preview_text(summary),
223
+ "sources": _extract_urls(summary),
224
+ "sections": sections,
225
+ "current_activity": "Research complete",
226
+ }
227
+ )
228
+ return detail
229
+
230
+
231
+ def attach_research_progress_to_globe(
232
+ state: dict[str, Any],
233
+ *,
234
+ todo_title: str,
235
+ tool_name: str,
236
+ args: dict[str, Any],
237
+ message: str,
238
+ ) -> dict[str, Any]:
239
+ marker_isos = {str(marker.get("iso2", "")).upper() for marker in state.get("markers", [])}
240
+ marker_isos.discard("")
241
+
242
+ country_details = dict(state.get("country_details") or {})
243
+ iso2 = iso2_from_todo_title(todo_title, marker_isos or None)
244
+ country, _ = split_country_title(todo_title)
245
+ key = iso2 or country
246
+ existing = country_details.get(key)
247
+ if existing and existing.get("status") == "complete":
248
+ return state
249
+
250
+ detail = build_research_progress_detail(
251
+ todo_title,
252
+ tool_name=tool_name,
253
+ args=args,
254
+ message=message,
255
+ existing=existing,
256
+ )
257
+ iso2, detail = _enrich_detail_with_iso2(detail, todo_title, marker_isos)
258
+ return _merge_detail_into_state(
259
+ state,
260
+ iso2=iso2,
261
+ country_key=iso2 or detail["country"],
262
+ detail=detail,
263
+ )
264
+
265
+
266
+ def attach_finding_to_globe(
267
+ state: dict[str, Any],
268
+ *,
269
+ todo_title: str,
270
+ summary: str,
271
+ ) -> dict[str, Any]:
272
+ """Merge researcher findings into globe_state for marker popups."""
273
+ marker_isos = {str(marker.get("iso2", "")).upper() for marker in state.get("markers", [])}
274
+ marker_isos.discard("")
275
+
276
+ detail = build_country_detail(todo_title, summary)
277
+ iso2, detail = _enrich_detail_with_iso2(detail, todo_title, marker_isos)
278
+ return _merge_detail_into_state(
279
+ state,
280
+ iso2=iso2,
281
+ country_key=iso2 or detail["country"],
282
+ detail=detail,
283
+ )