spagestic commited on
Commit
fc6d1ba
·
1 Parent(s): 35be521

fix empty research findings

Browse files
tests/test_research_findings.py ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # tests/test_research_findings.py
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import unittest
6
+ from unittest.mock import patch
7
+
8
+ from langchain_core.messages import AIMessage, ToolMessage
9
+
10
+ from ui.agent.graph.nodes.helpers import extract_assistant_text, research_tool_calls
11
+ from ui.agent.synthesis import synthesize_finding_from_tool_messages
12
+
13
+
14
+ class ExtractAssistantTextTests(unittest.TestCase):
15
+ def test_plain_string_content(self) -> None:
16
+ message = AIMessage(content="## Eligibility\nYou may qualify.")
17
+ self.assertEqual(extract_assistant_text(message), "## Eligibility\nYou may qualify.")
18
+
19
+ def test_list_content_blocks(self) -> None:
20
+ message = AIMessage(
21
+ content=[
22
+ {"type": "text", "text": "Line one"},
23
+ {"type": "text", "text": "Line two"},
24
+ ]
25
+ )
26
+ self.assertEqual(extract_assistant_text(message), "Line one\nLine two")
27
+
28
+ def test_reasoning_only_message(self) -> None:
29
+ message = AIMessage(content="", additional_kwargs={"reasoning_content": "Final report"})
30
+ self.assertEqual(extract_assistant_text(message), "Final report")
31
+
32
+ def test_strips_think_wrappers(self) -> None:
33
+ open_tag = "<" + "think" + ">"
34
+ close_tag = "</" + "think" + ">"
35
+ message = AIMessage(
36
+ content=(
37
+ f"{open_tag}internal reasoning{close_tag}\n\n"
38
+ "## Findings\nCanada Express Entry."
39
+ )
40
+ )
41
+ extracted = extract_assistant_text(message)
42
+ self.assertIn("## Findings", extracted)
43
+ self.assertNotIn("internal reasoning", extracted)
44
+
45
+ def test_prefers_content_over_reasoning(self) -> None:
46
+ message = AIMessage(
47
+ content="Visible answer",
48
+ additional_kwargs={"reasoning_content": "Hidden reasoning"},
49
+ )
50
+ self.assertEqual(extract_assistant_text(message), "Visible answer")
51
+
52
+
53
+ class ResearchToolCallsTests(unittest.TestCase):
54
+ def test_parses_tool_calls_from_reasoning(self) -> None:
55
+ reasoning = 'search_immigration_info{"query": "Canada Express Entry"}'
56
+ message = AIMessage(content="", additional_kwargs={"reasoning_content": reasoning})
57
+ calls = research_tool_calls(message)
58
+ self.assertEqual(len(calls), 1)
59
+ self.assertEqual(calls[0][0], "search_immigration_info")
60
+ self.assertEqual(calls[0][1]["query"], "Canada Express Entry")
61
+
62
+
63
+ class SynthesizeFindingTests(unittest.TestCase):
64
+ def test_builds_fallback_from_search_results(self) -> None:
65
+ todo = {"id": 1, "country": "Canada", "methods": "Express Entry"}
66
+ payload = {
67
+ "query": "Canada Express Entry",
68
+ "num_results": 1,
69
+ "results": [
70
+ {
71
+ "title": "Express Entry",
72
+ "url": "https://www.canada.ca/en/immigration/express-entry.html",
73
+ "highlights": ["Skilled workers may apply through Express Entry."],
74
+ }
75
+ ],
76
+ }
77
+ tool_messages = [ToolMessage(content=json.dumps(payload), tool_call_id="abc")]
78
+ summary = synthesize_finding_from_tool_messages(todo, tool_messages)
79
+ self.assertIn("Canada", summary)
80
+ self.assertIn("Express Entry", summary)
81
+ self.assertIn("canada.ca", summary)
82
+ self.assertNotEqual(summary, "")
83
+
84
+ def test_returns_empty_without_tool_evidence(self) -> None:
85
+ todo = {"id": 1, "country": "Canada", "methods": "Express Entry"}
86
+ self.assertEqual(
87
+ synthesize_finding_from_tool_messages(todo, []),
88
+ "",
89
+ )
90
+
91
+
92
+ class ResearcherFallbackTests(unittest.TestCase):
93
+ @patch("ui.agent.graph.nodes.researcher.build_llm")
94
+ @patch("ui.agent.graph.nodes.researcher.get_stream_writer")
95
+ @patch("ui.agent.graph.nodes.researcher.execute_tool_call")
96
+ def test_uses_tool_fallback_when_llm_summary_empty(
97
+ self,
98
+ mock_execute_tool_call: unittest.mock.Mock,
99
+ mock_get_stream_writer: unittest.mock.Mock,
100
+ mock_build_llm: unittest.mock.Mock,
101
+ ) -> None:
102
+ from ui.agent.graph.nodes.researcher import researcher_node
103
+
104
+ mock_get_stream_writer.return_value = lambda _event: None
105
+ mock_execute_tool_call.return_value = json.dumps(
106
+ {
107
+ "query": "Germany Blue Card",
108
+ "num_results": 1,
109
+ "results": [
110
+ {
111
+ "title": "EU Blue Card",
112
+ "url": "https://www.make-it-in-germany.com/en/visa-residence/eu-blue-card",
113
+ "highlights": ["The EU Blue Card is for qualified professionals."],
114
+ }
115
+ ],
116
+ }
117
+ )
118
+
119
+ search_response = AIMessage(
120
+ content="",
121
+ tool_calls=[
122
+ {
123
+ "name": "search_immigration_info",
124
+ "args": {"query": "Germany Blue Card"},
125
+ "id": "call-1",
126
+ }
127
+ ],
128
+ )
129
+ empty_summary = AIMessage(content="")
130
+
131
+ mock_llm = unittest.mock.Mock()
132
+ mock_bound = unittest.mock.Mock()
133
+ mock_bound.invoke.side_effect = [search_response, empty_summary]
134
+ mock_llm.bind_tools.return_value = mock_bound
135
+ mock_build_llm.return_value = mock_llm
136
+
137
+ task = {
138
+ "todo": {"id": 2, "country": "Germany", "methods": "EU Blue Card"},
139
+ "profile_summary": "Software engineer from India.",
140
+ }
141
+ config = {"configurable": {"hf_token": "test-token"}}
142
+
143
+ result = researcher_node(task, config)
144
+
145
+ summary = result["findings"][0]["summary"]
146
+ self.assertNotEqual(summary, "No findings could be produced for this to-do.")
147
+ self.assertIn("Germany", summary)
148
+
149
+
150
+ if __name__ == "__main__":
151
+ unittest.main()
ui/agent/graph/nodes/config.py CHANGED
@@ -19,6 +19,7 @@ MAX_TODOS = 4
19
  MAX_RESEARCH_ROUNDS = 4
20
  TOOL_RESULT_LIMIT = 4000
21
  FINDING_SUMMARY_LIMIT = 2500
 
22
  PLANNER_MAX_TOKENS = 900
23
  PLANNER_TEMPERATURE = 0.2
24
  CONSOLIDATOR_MAX_TOKENS = 3000
 
19
  MAX_RESEARCH_ROUNDS = 4
20
  TOOL_RESULT_LIMIT = 4000
21
  FINDING_SUMMARY_LIMIT = 2500
22
+ RESEARCHER_FINAL_MAX_TOKENS = FINDING_SUMMARY_LIMIT
23
  PLANNER_MAX_TOKENS = 900
24
  PLANNER_TEMPERATURE = 0.2
25
  CONSOLIDATOR_MAX_TOKENS = 3000
ui/agent/graph/nodes/helpers.py CHANGED
@@ -365,6 +365,88 @@ def normalize_plan(
365
  }
366
 
367
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
368
  def research_tool_calls(
369
  response: AIMessage,
370
  ) -> list[tuple[str, dict[str, Any], str]]:
@@ -375,7 +457,10 @@ def research_tool_calls(
375
  for tool_call in response.tool_calls
376
  ]
377
 
378
- parsed_calls = parse_text_tool_calls(str(response.content or ""))
 
 
 
379
  if not parsed_calls:
380
  return []
381
 
 
365
  }
366
 
367
 
368
+ def _think_tags() -> tuple[str, str]:
369
+ return "<" + "think" + ">", "</" + "think" + ">"
370
+
371
+
372
+ _THINK_OPEN, _THINK_CLOSE = _think_tags()
373
+ _THINK_BLOCK_PATTERN = re.compile(
374
+ re.escape(_THINK_OPEN) + r".*?" + re.escape(_THINK_CLOSE),
375
+ re.I | re.DOTALL,
376
+ )
377
+ _THINK_INNER_PATTERN = re.compile(
378
+ re.escape(_THINK_OPEN) + r"(.*?)" + re.escape(_THINK_CLOSE),
379
+ re.I | re.DOTALL,
380
+ )
381
+
382
+
383
+ def _flatten_content_blocks(content: Any) -> str:
384
+ if content is None:
385
+ return ""
386
+ if isinstance(content, str):
387
+ return content
388
+ if isinstance(content, list):
389
+ parts: list[str] = []
390
+ for block in content:
391
+ if isinstance(block, str):
392
+ parts.append(block)
393
+ continue
394
+ if isinstance(block, dict):
395
+ if block.get("type") == "text":
396
+ parts.append(str(block.get("text") or ""))
397
+ elif "text" in block:
398
+ parts.append(str(block["text"]))
399
+ return "\n".join(part for part in parts if part)
400
+ return str(content)
401
+
402
+
403
+ def _extract_reasoning(message: AIMessage) -> str:
404
+ reasoning = getattr(message, "reasoning", None)
405
+ if reasoning:
406
+ return str(reasoning).strip()
407
+
408
+ additional = getattr(message, "additional_kwargs", None) or {}
409
+ for key in ("reasoning_content", "reasoning", "reasoning_text"):
410
+ value = additional.get(key)
411
+ if value:
412
+ return str(value).strip()
413
+
414
+ metadata = getattr(message, "response_metadata", None) or {}
415
+ for key in ("reasoning_content", "reasoning"):
416
+ value = metadata.get(key)
417
+ if value:
418
+ return str(value).strip()
419
+ return ""
420
+
421
+
422
+ def _strip_think_blocks(text: str) -> str:
423
+ if not text.strip():
424
+ return ""
425
+ outside = _THINK_BLOCK_PATTERN.sub("", text).strip()
426
+ if outside:
427
+ return outside
428
+ inner_parts = _THINK_INNER_PATTERN.findall(text)
429
+ if inner_parts:
430
+ return "\n".join(part.strip() for part in inner_parts if part.strip())
431
+ return text.strip()
432
+
433
+
434
+ def assistant_text_sources(message: AIMessage) -> tuple[str, str]:
435
+ content_text = _flatten_content_blocks(message.content).strip()
436
+ reasoning_text = _extract_reasoning(message)
437
+ return content_text, reasoning_text
438
+
439
+
440
+ def extract_assistant_text(message: AIMessage) -> str:
441
+ """Extract user-visible assistant text from content, blocks, or reasoning."""
442
+ content_text, reasoning_text = assistant_text_sources(message)
443
+ for candidate in (content_text, reasoning_text):
444
+ stripped = _strip_think_blocks(candidate)
445
+ if stripped:
446
+ return stripped
447
+ return ""
448
+
449
+
450
  def research_tool_calls(
451
  response: AIMessage,
452
  ) -> list[tuple[str, dict[str, Any], str]]:
 
457
  for tool_call in response.tool_calls
458
  ]
459
 
460
+ content_text, reasoning_text = assistant_text_sources(response)
461
+ parsed_calls = parse_text_tool_calls(content_text) or parse_text_tool_calls(
462
+ reasoning_text
463
+ )
464
  if not parsed_calls:
465
  return []
466
 
ui/agent/graph/nodes/researcher.py CHANGED
@@ -2,6 +2,8 @@
2
  from __future__ import annotations
3
 
4
  import json
 
 
5
  import time
6
  import uuid
7
  from typing import Any
@@ -10,6 +12,7 @@ from langchain_core.messages import AIMessage, ToolMessage
10
  from langchain_core.runnables import RunnableConfig
11
  from langgraph.config import get_stream_writer
12
 
 
13
  from ...tools import (
14
  _done_tool_message,
15
  _pending_tool_message,
@@ -23,12 +26,27 @@ from ..state import Finding, ResearchTask, TodoItem
23
  from .config import (
24
  FINDING_SUMMARY_LIMIT,
25
  MAX_RESEARCH_ROUNDS,
 
26
  RESEARCH_TOOL_SCHEMAS,
27
  TOOL_RESULT_LIMIT,
28
  )
29
- from .helpers import format_todo_label, research_tool_calls, todo_research_brief
 
 
 
 
 
 
30
  from .prompts import RESEARCHER_SYSTEM_PROMPT
31
 
 
 
 
 
 
 
 
 
32
 
33
  def _research_task_metadata(todo: TodoItem) -> dict[str, Any]:
34
  return {
@@ -82,6 +100,18 @@ def execute_tool_call(
82
  return result
83
 
84
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  def researcher_node(task: ResearchTask, config: RunnableConfig) -> dict[str, Any]:
86
  writer = get_stream_writer()
87
  todo = task["todo"]
@@ -102,15 +132,23 @@ def researcher_node(task: ResearchTask, config: RunnableConfig) -> dict[str, Any
102
  summary = ""
103
  for round_index in range(MAX_RESEARCH_ROUNDS):
104
  response: AIMessage = llm.invoke(messages)
 
105
  tool_calls = research_tool_calls(response)
 
 
 
 
 
 
 
 
 
 
106
  if not tool_calls:
107
- summary = str(response.content or "").strip()
108
  break
109
 
110
- if response.tool_calls:
111
- messages.append(response)
112
- else:
113
- messages.append(AIMessage(content=""))
114
 
115
  for tool_name, tool_args, tool_call_id in tool_calls:
116
  result = execute_tool_call(writer, todo, tool_name, tool_args)
@@ -126,17 +164,39 @@ def researcher_node(task: ResearchTask, config: RunnableConfig) -> dict[str, Any
126
  {
127
  "role": "user",
128
  "content": (
129
- "Stop researching now. Write your findings report based "
130
- "on the information gathered so far, labeling anything "
131
- "unverified."
132
  ),
133
  }
134
  )
135
- final = build_llm(config).invoke(messages)
136
- summary = str(final.content or "").strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
 
138
  if not summary:
139
  summary = "No findings could be produced for this to-do."
 
 
 
 
 
 
 
140
  summary = truncate(summary, FINDING_SUMMARY_LIMIT)
141
 
142
  todo_label = format_todo_label(todo)
 
2
  from __future__ import annotations
3
 
4
  import json
5
+ import logging
6
+ import os
7
  import time
8
  import uuid
9
  from typing import Any
 
12
  from langchain_core.runnables import RunnableConfig
13
  from langgraph.config import get_stream_writer
14
 
15
+ from ...synthesis import synthesize_finding_from_tool_messages
16
  from ...tools import (
17
  _done_tool_message,
18
  _pending_tool_message,
 
26
  from .config import (
27
  FINDING_SUMMARY_LIMIT,
28
  MAX_RESEARCH_ROUNDS,
29
+ RESEARCHER_FINAL_MAX_TOKENS,
30
  RESEARCH_TOOL_SCHEMAS,
31
  TOOL_RESULT_LIMIT,
32
  )
33
+ from .helpers import (
34
+ assistant_text_sources,
35
+ extract_assistant_text,
36
+ format_todo_label,
37
+ research_tool_calls,
38
+ todo_research_brief,
39
+ )
40
  from .prompts import RESEARCHER_SYSTEM_PROMPT
41
 
42
+ logger = logging.getLogger(__name__)
43
+ DEBUG_RESEARCHER = os.environ.get("BORDERLESS_DEBUG_RESEARCHER") == "1"
44
+
45
+
46
+ def _debug_researcher(**fields: Any) -> None:
47
+ if DEBUG_RESEARCHER:
48
+ logger.info("researcher debug: %s", fields)
49
+
50
 
51
  def _research_task_metadata(todo: TodoItem) -> dict[str, Any]:
52
  return {
 
100
  return result
101
 
102
 
103
+ def _append_assistant_message(messages: list[Any], response: AIMessage) -> None:
104
+ if response.tool_calls:
105
+ messages.append(response)
106
+ return
107
+
108
+ assistant_text = extract_assistant_text(response)
109
+ if assistant_text:
110
+ messages.append(AIMessage(content=assistant_text))
111
+ else:
112
+ messages.append(response)
113
+
114
+
115
  def researcher_node(task: ResearchTask, config: RunnableConfig) -> dict[str, Any]:
116
  writer = get_stream_writer()
117
  todo = task["todo"]
 
132
  summary = ""
133
  for round_index in range(MAX_RESEARCH_ROUNDS):
134
  response: AIMessage = llm.invoke(messages)
135
+ content_text, reasoning_text = assistant_text_sources(response)
136
  tool_calls = research_tool_calls(response)
137
+
138
+ _debug_researcher(
139
+ round_index=round_index,
140
+ has_native_tool_calls=bool(response.tool_calls),
141
+ parsed_text_tool_calls=len(tool_calls) if not response.tool_calls else 0,
142
+ content_len=len(content_text),
143
+ reasoning_len=len(reasoning_text),
144
+ content_preview=content_text[:120],
145
+ )
146
+
147
  if not tool_calls:
148
+ summary = extract_assistant_text(response)
149
  break
150
 
151
+ _append_assistant_message(messages, response)
 
 
 
152
 
153
  for tool_name, tool_args, tool_call_id in tool_calls:
154
  result = execute_tool_call(writer, todo, tool_name, tool_args)
 
164
  {
165
  "role": "user",
166
  "content": (
167
+ "Respond with the markdown findings report in your main "
168
+ "answer text. Do not call tools. Label anything unverified."
 
169
  ),
170
  }
171
  )
172
+ final = build_llm(
173
+ config,
174
+ max_tokens=RESEARCHER_FINAL_MAX_TOKENS,
175
+ ).invoke(messages)
176
+ summary = extract_assistant_text(final)
177
+ final_content, final_reasoning = assistant_text_sources(final)
178
+ _debug_researcher(
179
+ round_index="final",
180
+ content_len=len(final_content),
181
+ reasoning_len=len(final_reasoning),
182
+ final_summary_len=len(summary),
183
+ )
184
+
185
+ tool_messages = [message for message in messages if isinstance(message, ToolMessage)]
186
+ fallback_used = False
187
+ if not summary and tool_messages:
188
+ summary = synthesize_finding_from_tool_messages(todo, tool_messages)
189
+ fallback_used = bool(summary)
190
 
191
  if not summary:
192
  summary = "No findings could be produced for this to-do."
193
+
194
+ _debug_researcher(
195
+ fallback_used=fallback_used,
196
+ final_summary_len=len(summary),
197
+ tool_message_count=len(tool_messages),
198
+ )
199
+
200
  summary = truncate(summary, FINDING_SUMMARY_LIMIT)
201
 
202
  todo_label = format_todo_label(todo)
ui/agent/synthesis.py CHANGED
@@ -1,6 +1,7 @@
1
  # ui/agent/synthesis.py
2
  from __future__ import annotations
3
 
 
4
  import re
5
  from typing import Any, TypedDict
6
 
@@ -349,3 +350,122 @@ def findings_from_ui_messages(ui_messages: list[Any]) -> list[Finding]:
349
  )
350
  todo_id += 1
351
  return findings
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  # ui/agent/synthesis.py
2
  from __future__ import annotations
3
 
4
+ import json
5
  import re
6
  from typing import Any, TypedDict
7
 
 
350
  )
351
  todo_id += 1
352
  return findings
353
+
354
+
355
+ def _parse_tool_payload(content: str) -> dict[str, Any]:
356
+ try:
357
+ parsed = json.loads(content)
358
+ except json.JSONDecodeError:
359
+ return {"text": content}
360
+ return parsed if isinstance(parsed, dict) else {"text": content}
361
+
362
+
363
+ def synthesize_finding_from_tool_messages(
364
+ todo: TodoItem,
365
+ tool_messages: list[Any],
366
+ ) -> str:
367
+ """Build a markdown finding from tool results when the LLM summary is empty."""
368
+ country = todo["country"]
369
+ methods = todo["methods"]
370
+
371
+ search_snippets: list[str] = []
372
+ scraped_snippets: list[str] = []
373
+ profile_notes: list[str] = []
374
+ urls: list[str] = []
375
+
376
+ for message in tool_messages:
377
+ content = getattr(message, "content", message)
378
+ payload = _parse_tool_payload(str(content or ""))
379
+ if payload.get("error") and len(payload) <= 3:
380
+ continue
381
+
382
+ for result in payload.get("results") or []:
383
+ if not isinstance(result, dict):
384
+ continue
385
+ url = str(result.get("url") or "")
386
+ if url:
387
+ urls.append(url)
388
+ title = str(result.get("title") or url or "Search result")
389
+ highlights = result.get("highlights") or []
390
+ summary = str(result.get("summary") or "")
391
+ snippet = "\n".join(str(item) for item in highlights[:2])
392
+ if not snippet:
393
+ snippet = summary
394
+ if snippet:
395
+ search_snippets.append(f"- **{title}**: {snippet[:500]}")
396
+
397
+ markdown = str(payload.get("markdown") or "")
398
+ if markdown:
399
+ title = str(payload.get("title") or payload.get("url") or "Official page")
400
+ url = str(payload.get("source_url") or payload.get("url") or "")
401
+ if url:
402
+ urls.append(url)
403
+ scraped_snippets.append(f"### {title}\n{markdown[:1500]}")
404
+
405
+ for page in payload.get("pages") or []:
406
+ if not isinstance(page, dict):
407
+ continue
408
+ page_markdown = str(page.get("markdown") or "")
409
+ if not page_markdown:
410
+ continue
411
+ title = str(page.get("title") or page.get("url") or "Page")
412
+ url = str(page.get("source_url") or page.get("url") or "")
413
+ if url:
414
+ urls.append(url)
415
+ scraped_snippets.append(f"### {title}\n{page_markdown[:800]}")
416
+
417
+ for profile in payload.get("countries") or []:
418
+ if not isinstance(profile, dict):
419
+ continue
420
+ name = str(profile.get("name") or country)
421
+ domains = profile.get("official_immigration_domains") or []
422
+ domain_text = ", ".join(str(domain) for domain in domains[:5])
423
+ if domain_text:
424
+ profile_notes.append(f"{name}: official domains include {domain_text}")
425
+
426
+ if not search_snippets and not scraped_snippets and not profile_notes:
427
+ return ""
428
+
429
+ raw_parts = [
430
+ f"# Research Findings: {country} — {methods}",
431
+ "",
432
+ "*Compiled from tool results. Verify all details on official sources.*",
433
+ "",
434
+ "## Visa Pathway",
435
+ methods,
436
+ "",
437
+ ]
438
+ if search_snippets:
439
+ raw_parts.extend(["## Search Results", "\n".join(search_snippets[:8]), ""])
440
+ if scraped_snippets:
441
+ raw_parts.extend(
442
+ ["## Official Page Excerpts", "\n\n".join(scraped_snippets[:3]), ""]
443
+ )
444
+ if profile_notes:
445
+ raw_parts.extend(["## Country Context", "\n".join(profile_notes), ""])
446
+
447
+ unique_urls = list(dict.fromkeys(url for url in urls if url))
448
+ if unique_urls:
449
+ raw_parts.extend(
450
+ ["## Official Sources", "\n".join(f"- {url}" for url in unique_urls[:10])]
451
+ )
452
+
453
+ raw_text = "\n".join(raw_parts)
454
+ sections = build_country_report_sections(
455
+ raw_text,
456
+ country=country,
457
+ pathway=methods,
458
+ )
459
+
460
+ return (
461
+ f"# {country} — {methods}\n\n"
462
+ "*Automated fallback report from research tool outputs. "
463
+ "Verify all details on official immigration websites.*\n\n"
464
+ f"## Eligibility\n{sections['why_recommended']}\n\n"
465
+ f"## Visa Pathway\n{sections['feasible_methods']}\n\n"
466
+ f"## Required Documents\n{sections['requirements']}\n\n"
467
+ f"## Steps\n{sections['procedure']}\n\n"
468
+ f"## Timeline, Costs, and Risks\n{sections['duration_costs_risks']}\n\n"
469
+ f"## Official Sources\n{sections['official_sources']}\n\n"
470
+ f"## Raw Research Notes\n{raw_text[:2000]}"
471
+ )