diff --git a/parallel_web_tools/cli/commands.py b/parallel_web_tools/cli/commands.py index 1c26ac0..c96d36c 100644 --- a/parallel_web_tools/cli/commands.py +++ b/parallel_web_tools/cli/commands.py @@ -1555,9 +1555,19 @@ def research(): @click.option("--no-wait", is_flag=True, help="Return immediately after creating task (don't poll)") @click.option("--dry-run", is_flag=True, help="Show what would be executed without making API calls") @click.option( - "-o", "--output", "output_file", type=click.Path(), help="Save results (creates {name}.json and {name}.md)" + "--text", + "use_text", + is_flag=True, + help="Use text schema — returns markdown report instead of structured JSON", +) +@click.option( + "-o", + "--output", + "output_path", + type=click.Path(), + default=None, + help="Base path for output files (default: auto-generated from run ID)", ) -@click.option("--json", "output_json", is_flag=True, help="Output JSON to stdout") def research_run( query: str | None, input_file: str | None, @@ -1566,22 +1576,30 @@ def research_run( poll_interval: int, no_wait: bool, dry_run: bool, - output_file: str | None, - output_json: bool, + use_text: bool, + output_path: str | None, ): """Run deep research on a question or topic. QUERY is the research question (max 15,000 chars). Alternatively, use --input-file or pass "-" as QUERY to read from stdin. + By default, results use auto schema (structured JSON) and save to a .json file. + Use --text for a markdown report (saves both .json and .md). Use -o to set a custom + base filename; otherwise the run ID is used. + Examples: parallel-cli research run "What are the latest developments in quantum computing?" - parallel-cli research run -f question.txt --processor ultra -o report + parallel-cli research run --text "Market analysis of HVAC industry" -o report + + parallel-cli research run -f question.txt --processor ultra --text - echo "My research question" | parallel-cli research run - --json + parallel-cli research run "My question" -o my-report """ + output_schema = "text" if use_text else "auto" + # Read from stdin if "-" is passed if query == "-": query = click.get_text_stream("stdin").read().strip() @@ -1603,44 +1621,35 @@ def research_run( "query": query[:200] + "..." if len(query) > 200 else query, "query_length": len(query), "processor": processor, + "output_schema": output_schema, "expected_latency": RESEARCH_PROCESSORS[processor], } - if output_json: - print(json.dumps(dry_run_data, indent=2)) - else: - console.print("[bold]Dry run — no API calls will be made[/bold]\n") - console.print(f" [bold]Query:[/bold] {dry_run_data['query']}") - console.print(f" [bold]Length:[/bold] {len(query)} chars") - console.print(f" [bold]Processor:[/bold] {processor}") - console.print(f" [bold]Latency:[/bold] {RESEARCH_PROCESSORS[processor]}") + console.print("[bold]Dry run — no API calls will be made[/bold]\n") + console.print(f" [bold]Query:[/bold] {dry_run_data['query']}") + console.print(f" [bold]Length:[/bold] {len(query)} chars") + console.print(f" [bold]Processor:[/bold] {processor}") + console.print(f" [bold]Schema:[/bold] {output_schema}") + console.print(f" [bold]Latency:[/bold] {RESEARCH_PROCESSORS[processor]}") return try: if no_wait: # Create task and return immediately - if not output_json: - console.print(f"[dim]Creating research task with processor: {processor}...[/dim]") - result = create_research_task(query, processor=processor, source="cli") - - if not output_json: - console.print(f"\n[bold green]Task created: {result['run_id']}[/bold green]") - console.print(f"Track progress: {result['result_url']}") - console.print("\n[dim]Use 'parallel-cli research status ' to check status[/dim]") - console.print("[dim]Use 'parallel-cli research poll ' to wait for results[/dim]") + console.print(f"[dim]Creating research task with processor: {processor}...[/dim]") + result = create_research_task(query, processor=processor, source="cli", output_schema=output_schema) - if output_json: - print(json.dumps(result, indent=2)) + console.print(f"\n[bold green]Task created: {result['run_id']}[/bold green]") + console.print(f"Track progress: {result['result_url']}") + console.print("\n[dim]Use 'parallel-cli research status ' to check status[/dim]") + console.print("[dim]Use 'parallel-cli research poll ' to wait for results[/dim]") else: # Run and wait for results - if not output_json: - console.print(f"[bold cyan]Starting deep research with processor: {processor}[/bold cyan]") - console.print(f"[dim]This may take {RESEARCH_PROCESSORS[processor]}[/dim]\n") + console.print(f"[bold cyan]Starting deep research with processor: {processor}[/bold cyan]") + console.print(f"[dim]This may take {RESEARCH_PROCESSORS[processor]}[/dim]\n") start_time = time.time() def on_status(status: str, run_id: str): - if output_json: - return elapsed = time.time() - start_time mins, secs = divmod(int(elapsed), 60) elapsed_str = f"{mins}m{secs:02d}s" if mins else f"{secs}s" @@ -1659,22 +1668,19 @@ def on_status(status: str, run_id: str): poll_interval=poll_interval, on_status=on_status, source="cli", + output_schema=output_schema, ) - _output_research_result(result, output_file, output_json) + _save_and_display_research(result, output_schema, output_path) except TimeoutError as e: - if output_json: - error_data = {"error": {"message": str(e), "type": "TimeoutError"}} - print(json.dumps(error_data, indent=2)) - else: - console.print(f"[bold yellow]Timeout: {e}[/bold yellow]") - console.print("[dim]The task is still running. Use 'parallel-cli research poll ' to resume.[/dim]") + console.print(f"[bold yellow]Timeout: {e}[/bold yellow]") + console.print("[dim]The task is still running. Use 'parallel-cli research poll ' to resume.[/dim]") sys.exit(EXIT_TIMEOUT) except RuntimeError as e: - _handle_error(e, output_json=output_json) + _handle_error(e) except Exception as e: - _handle_error(e, output_json=output_json) + _handle_error(e) @research.command(name="status") @@ -1783,6 +1789,82 @@ def research_processors(output_json: bool): console.print("\n[dim]Use --processor/-p to select a processor[/dim]") +def _save_and_display_research( + result: dict, + output_schema: str, + output_path: str | None, +): + """Save research result to file(s) and display the executive summary. + + For auto schema (default): saves {base}.json with full response. + For text schema (--text): saves {base}.json with full response AND + {base}.md with just the markdown content. + + Args: + result: The research result dict from the API. + output_schema: "auto" or "text" — determines file format. + output_path: Base path for output files. None means auto-generate from run_id. + """ + from pathlib import Path + + output = result.get("output", {}) + run_id = result.get("run_id", "research") + + # Resolve base path (strip any extension) + if output_path is None: + base_path = Path(run_id) + else: + base_path = Path(output_path) + if base_path.suffix: + base_path = base_path.with_suffix("") + + # Always write the full response as JSON + json_path = base_path.with_suffix(".json") + output_data = { + "run_id": run_id, + "result_url": result.get("result_url"), + "status": result.get("status"), + "output": output, + } + + # For text schema, also write markdown content to .md + if output_schema == "text": + md_path = base_path.with_suffix(".md") + content = output.get("content") if isinstance(output, dict) else output + content_text = _content_to_markdown(content) if content else "" + + if content_text: + with open(md_path, "w") as f: + f.write(content_text) + console.print(f"[green]Content saved to:[/green] {md_path}") + + # Replace content in JSON with reference to the .md file + output_data = output_data.copy() + output_data["output"] = output.copy() if isinstance(output, dict) else {"raw": output} + output_data["output"]["content_file"] = md_path.name + output_data["output"].pop("content", None) + + with open(json_path, "w") as f: + json.dump(output_data, f, indent=2, default=str) + console.print(f"[green]Metadata saved to:[/green] {json_path}") + + # Always display summary to console + console.print("\n[bold green]Research Complete![/bold green]") + console.print(f"[dim]Task: {run_id}[/dim]") + console.print(f"[dim]URL: {result.get('result_url')}[/dim]\n") + + # Show executive summary + content = output.get("content") if isinstance(output, dict) else None + summary = _extract_executive_summary(content) if content else None + + if summary: + from rich.markdown import Markdown + from rich.panel import Panel + + console.print(Panel(Markdown(summary), title="Executive Summary", border_style="cyan")) + console.print() + + def _extract_executive_summary(content: Any) -> str | None: """Extract the executive summary from research content. diff --git a/parallel_web_tools/core/__init__.py b/parallel_web_tools/core/__init__.py index 2be24c2..69c4d23 100644 --- a/parallel_web_tools/core/__init__.py +++ b/parallel_web_tools/core/__init__.py @@ -49,6 +49,7 @@ ) from parallel_web_tools.core.research import ( RESEARCH_PROCESSORS, + OutputSchemaType, create_research_task, get_research_result, get_research_status, @@ -123,6 +124,7 @@ "run_enrichment_from_dict", # Research "RESEARCH_PROCESSORS", + "OutputSchemaType", "create_research_task", "get_research_result", "get_research_status", diff --git a/parallel_web_tools/core/research.py b/parallel_web_tools/core/research.py index 5ff78e3..c9a64d0 100644 --- a/parallel_web_tools/core/research.py +++ b/parallel_web_tools/core/research.py @@ -9,12 +9,15 @@ from __future__ import annotations from collections.abc import Callable -from typing import Any +from typing import Any, Literal from parallel_web_tools.core.auth import create_client from parallel_web_tools.core.polling import poll_until from parallel_web_tools.core.user_agent import ClientSource +# Output schema types supported for deep research +OutputSchemaType = Literal["auto", "text"] + # Base URL for viewing results PLATFORM_BASE = "https://platform.parallel.ai" @@ -69,11 +72,24 @@ def _serialize_output(output: Any) -> dict[str, Any]: return {"raw": str(output)} +def _build_task_spec(output_schema: OutputSchemaType) -> Any: + """Build task_spec kwargs for the SDK based on output schema type. + + Returns None for auto schema (SDK default), or a TaskSpecParam for text. + """ + if output_schema == "text": + from parallel.types import TaskSpecParam, TextSchemaParam + + return TaskSpecParam(output_schema=TextSchemaParam(type="text")) + return None + + def create_research_task( query: str, processor: str = "pro-fast", api_key: str | None = None, source: ClientSource = "python", + output_schema: OutputSchemaType = "auto", ) -> dict[str, Any]: """Create a deep research task without waiting for results. @@ -82,16 +98,22 @@ def create_research_task( processor: Processor tier (see RESEARCH_PROCESSORS). api_key: Optional API key. source: Client source identifier for User-Agent. + output_schema: Output schema type - "auto" for structured JSON, "text" for markdown. Returns: Dict with run_id, result_url, and other task metadata. """ client = create_client(api_key, source) - task = client.task_run.create( - input=query[:15000], - processor=processor, - ) + create_kwargs: dict[str, Any] = { + "input": query[:15000], + "processor": processor, + } + task_spec = _build_task_spec(output_schema) + if task_spec is not None: + create_kwargs["task_spec"] = task_spec + + task = client.task_run.create(**create_kwargs) return { "run_id": task.run_id, @@ -226,6 +248,7 @@ def run_research( poll_interval: int = 45, on_status: Callable[[str, str], None] | None = None, source: ClientSource = "python", + output_schema: OutputSchemaType = "auto", ) -> dict[str, Any]: """Run deep research and wait for results. @@ -240,6 +263,7 @@ def run_research( poll_interval: Seconds between status checks (default: 45). on_status: Optional callback called with (status, run_id) on each poll. source: Client source identifier for User-Agent. + output_schema: Output schema type - "auto" for structured JSON, "text" for markdown. Returns: Dict with content and metadata. @@ -250,10 +274,15 @@ def run_research( """ client = create_client(api_key, source) - task = client.task_run.create( - input=query[:15000], - processor=processor, - ) + create_kwargs: dict[str, Any] = { + "input": query[:15000], + "processor": processor, + } + task_spec = _build_task_spec(output_schema) + if task_spec is not None: + create_kwargs["task_spec"] = task_spec + + task = client.task_run.create(**create_kwargs) run_id = task.run_id result_url = f"{PLATFORM_BASE}/play/deep-research/{run_id}" diff --git a/tests/test_research.py b/tests/test_research.py index 065cbeb..0b15017 100644 --- a/tests/test_research.py +++ b/tests/test_research.py @@ -9,6 +9,7 @@ from parallel_web_tools.cli.commands import _extract_executive_summary, main from parallel_web_tools.core.research import ( RESEARCH_PROCESSORS, + _build_task_spec, _serialize_output, create_research_task, get_research_result, @@ -66,6 +67,44 @@ def test_create_task_truncates_query(self, mock_parallel_client): call_args = mock_parallel_client.task_run.create.call_args assert len(call_args.kwargs["input"]) == 15000 + def test_create_task_auto_schema_no_task_spec(self, mock_parallel_client): + """Should not pass task_spec for auto schema (default).""" + mock_task = mock.MagicMock() + mock_task.run_id = "trun_123" + mock_parallel_client.task_run.create.return_value = mock_task + + create_research_task("What is AI?", output_schema="auto") + + call_args = mock_parallel_client.task_run.create.call_args + assert "task_spec" not in call_args.kwargs + + def test_create_task_text_schema(self, mock_parallel_client): + """Should pass task_spec with text schema when output_schema='text'.""" + mock_task = mock.MagicMock() + mock_task.run_id = "trun_123" + mock_parallel_client.task_run.create.return_value = mock_task + + create_research_task("What is AI?", output_schema="text") + + call_args = mock_parallel_client.task_run.create.call_args + assert "task_spec" in call_args.kwargs + task_spec = call_args.kwargs["task_spec"] + assert task_spec["output_schema"]["type"] == "text" + + +class TestBuildTaskSpec: + """Tests for _build_task_spec helper.""" + + def test_auto_returns_none(self): + """Should return None for auto schema.""" + assert _build_task_spec("auto") is None + + def test_text_returns_task_spec(self): + """Should return TaskSpecParam with TextSchemaParam for text schema.""" + result = _build_task_spec("text") + assert result is not None + assert result["output_schema"]["type"] == "text" + class TestGetResearchStatus: """Tests for get_research_status function.""" @@ -172,6 +211,52 @@ def test_run_research_failed(self, mock_parallel_client): with pytest.raises(RuntimeError, match="failed"): run_research("What is AI?", poll_interval=1) + def test_run_research_text_schema(self, mock_parallel_client): + """Should pass task_spec with text schema to SDK.""" + mock_task = mock.MagicMock() + mock_task.run_id = "trun_text" + mock_parallel_client.task_run.create.return_value = mock_task + + mock_status = mock.MagicMock() + mock_status.status = "completed" + mock_parallel_client.task_run.retrieve.return_value = mock_status + + mock_output = mock.MagicMock() + mock_output.model_dump.return_value = {"content": {"text": "Markdown report"}} + mock_result = mock.MagicMock() + mock_result.output = mock_output + mock_parallel_client.task_run.result.return_value = mock_result + + with mock.patch("parallel_web_tools.core.polling.time.sleep"): + result = run_research("What is AI?", poll_interval=1, timeout=10, output_schema="text") + + assert result["status"] == "completed" + call_args = mock_parallel_client.task_run.create.call_args + assert "task_spec" in call_args.kwargs + assert call_args.kwargs["task_spec"]["output_schema"]["type"] == "text" + + def test_run_research_auto_schema_no_task_spec(self, mock_parallel_client): + """Should not pass task_spec for auto schema.""" + mock_task = mock.MagicMock() + mock_task.run_id = "trun_auto" + mock_parallel_client.task_run.create.return_value = mock_task + + mock_status = mock.MagicMock() + mock_status.status = "completed" + mock_parallel_client.task_run.retrieve.return_value = mock_status + + mock_output = mock.MagicMock() + mock_output.model_dump.return_value = {"content": {"text": "JSON result"}} + mock_result = mock.MagicMock() + mock_result.output = mock_output + mock_parallel_client.task_run.result.return_value = mock_result + + with mock.patch("parallel_web_tools.core.polling.time.sleep"): + run_research("What is AI?", poll_interval=1, timeout=10, output_schema="auto") + + call_args = mock_parallel_client.task_run.create.call_args + assert "task_spec" not in call_args.kwargs + def test_run_research_on_status_callback(self, mock_parallel_client): """Should call on_status callback during polling.""" mock_task = mock.MagicMock() @@ -278,6 +363,7 @@ def test_research_run_help(self, runner): assert "--processor" in result.output assert "--timeout" in result.output assert "--no-wait" in result.output + assert "--text" in result.output assert "--output" in result.output def test_research_run_no_query(self, runner): @@ -320,34 +406,10 @@ def test_research_run_no_wait(self, runner): assert "trun_123" in result.output mock_create.assert_called_once() - def test_research_run_json_output(self, runner): - """Should output JSON with --json flag.""" - with mock.patch("parallel_web_tools.cli.commands.create_research_task") as mock_create: - mock_create.return_value = { - "run_id": "trun_123", - "result_url": "https://platform.parallel.ai/play/deep-research/trun_123", - "status": "pending", - } - - result = runner.invoke(main, ["research", "run", "What is AI?", "--no-wait", "--json"]) - - assert result.exit_code == 0 - # Find the JSON in the output - lines = result.output.strip().split("\n") - json_lines = [] - in_json = False - for line in lines: - if line.strip().startswith("{"): - in_json = True - if in_json: - json_lines.append(line) - if in_json and line.strip().startswith("}"): - break - output = json.loads("\n".join(json_lines)) - assert output["run_id"] == "trun_123" - - def test_research_run_with_wait(self, runner): + def test_research_run_with_wait(self, runner, tmp_path, monkeypatch): """Should poll and return results without --no-wait.""" + monkeypatch.chdir(tmp_path) + with mock.patch("parallel_web_tools.cli.commands.run_research") as mock_run: mock_run.return_value = { "run_id": "trun_123", @@ -362,6 +424,77 @@ def test_research_run_with_wait(self, runner): assert "Research Complete" in result.output mock_run.assert_called_once() + def test_research_run_text_flag(self, runner, tmp_path, monkeypatch): + """Should pass output_schema='text' when --text is used.""" + monkeypatch.chdir(tmp_path) + + with mock.patch("parallel_web_tools.cli.commands.run_research") as mock_run: + mock_run.return_value = { + "run_id": "trun_text", + "result_url": "https://platform.parallel.ai/play/deep-research/trun_text", + "status": "completed", + "output": { + "content": "# Markdown Report\n\nThis is a markdown report with enough text to be meaningful.\n\n## Section\n\nBody." + }, + } + + result = runner.invoke(main, ["research", "run", "What is AI?", "--text", "--poll-interval", "1"]) + + assert result.exit_code == 0 + mock_run.assert_called_once() + call_kwargs = mock_run.call_args.kwargs + assert call_kwargs["output_schema"] == "text" + + def test_research_run_default_auto_schema(self, runner, tmp_path, monkeypatch): + """Should pass output_schema='auto' by default (no --text).""" + monkeypatch.chdir(tmp_path) + + with mock.patch("parallel_web_tools.cli.commands.run_research") as mock_run: + mock_run.return_value = { + "run_id": "trun_auto", + "result_url": "https://platform.parallel.ai/play/deep-research/trun_auto", + "status": "completed", + "output": {"content": {"text": "Structured JSON result"}}, + } + + result = runner.invoke(main, ["research", "run", "What is AI?", "--poll-interval", "1"]) + + assert result.exit_code == 0 + call_kwargs = mock_run.call_args.kwargs + assert call_kwargs["output_schema"] == "auto" + + def test_research_run_text_no_wait(self, runner): + """Should pass output_schema when using --text with --no-wait.""" + with mock.patch("parallel_web_tools.cli.commands.create_research_task") as mock_create: + mock_create.return_value = { + "run_id": "trun_text_nw", + "result_url": "https://platform.parallel.ai/play/deep-research/trun_text_nw", + "status": "pending", + } + + result = runner.invoke(main, ["research", "run", "What is AI?", "--text", "--no-wait"]) + + assert result.exit_code == 0 + mock_create.assert_called_once() + call_kwargs = mock_create.call_args.kwargs + assert call_kwargs["output_schema"] == "text" + + def test_research_run_text_in_help(self, runner): + """Should show --text flag in help.""" + result = runner.invoke(main, ["research", "run", "--help"]) + assert result.exit_code == 0 + assert "--text" in result.output + + def test_research_run_dry_run_shows_schema(self, runner): + """Should show output_schema in dry run output.""" + result = runner.invoke(main, ["research", "run", "What is AI?", "--dry-run", "--text"]) + assert result.exit_code == 0 + assert "text" in result.output + + result = runner.invoke(main, ["research", "run", "What is AI?", "--dry-run"]) + assert result.exit_code == 0 + assert "auto" in result.output + class TestResearchStatusCommand: """Tests for the research status command.""" @@ -444,9 +577,8 @@ def test_research_processors(self, runner): class TestResearchOutputFile: """Tests for saving research results to files.""" - def test_research_save_to_file_with_content(self, runner, tmp_path): - """Should save content to separate markdown file.""" - output_base = tmp_path / "report" + def test_default_saves_json_only(self, runner, tmp_path): + """Default (auto schema) should save only .json.""" json_file = tmp_path / "report.json" md_file = tmp_path / "report.md" @@ -455,154 +587,113 @@ def test_research_save_to_file_with_content(self, runner, tmp_path): "run_id": "trun_123", "result_url": "https://platform.parallel.ai/play/deep-research/trun_123", "status": "completed", - "output": {"content": {"text": "# Research findings\n\nThis is the report."}, "basis": []}, + "output": {"content": {"market_size": "10B"}, "basis": []}, } result = runner.invoke( main, - ["research", "run", "What is AI?", "-o", str(output_base), "--poll-interval", "1"], + ["research", "run", "What is AI?", "-o", str(tmp_path / "report"), "--poll-interval", "1"], ) assert result.exit_code == 0 - - # Check JSON file has output with content_file reference assert json_file.exists() + assert not md_file.exists() + data = json.loads(json_file.read_text()) assert data["run_id"] == "trun_123" - assert data["status"] == "completed" - assert "output" in data - assert "content" not in data["output"] - assert data["output"]["content_file"] == "report.md" - assert data["output"]["basis"] == [] - - # Check markdown file has content - assert md_file.exists() - assert md_file.read_text() == "# Research findings\n\nThis is the report." + assert data["output"]["content"]["market_size"] == "10B" - def test_research_save_to_file_strips_extension(self, runner, tmp_path): - """Should strip extension from output path and create both files.""" - output_with_ext = tmp_path / "report.json" + def test_text_saves_json_and_md(self, runner, tmp_path): + """--text should save both .json (with content_file ref) and .md.""" json_file = tmp_path / "report.json" md_file = tmp_path / "report.md" with mock.patch("parallel_web_tools.cli.commands.run_research") as mock_run: mock_run.return_value = { - "run_id": "trun_ext", - "result_url": "https://platform.parallel.ai/play/deep-research/trun_ext", + "run_id": "trun_text", + "result_url": "https://platform.parallel.ai/play/deep-research/trun_text", "status": "completed", - "output": {"content": "Content here"}, + "output": {"content": "# Report\n\nFindings here.", "basis": [{"field": "content"}]}, } result = runner.invoke( main, - ["research", "run", "Question?", "-o", str(output_with_ext), "--poll-interval", "1"], + ["research", "run", "Question?", "--text", "-o", str(tmp_path / "report"), "--poll-interval", "1"], ) assert result.exit_code == 0 + + # Both files exist assert json_file.exists() assert md_file.exists() - def test_research_save_to_file_string_content(self, runner, tmp_path): - """Should handle string content directly.""" - output_base = tmp_path / "report" + # .md has the content + assert md_file.read_text() == "# Report\n\nFindings here." + + # .json references .md and doesn't duplicate content + data = json.loads(json_file.read_text()) + assert data["output"]["content_file"] == "report.md" + assert "content" not in data["output"] + assert data["output"]["basis"] == [{"field": "content"}] + + def test_output_strips_extension_from_path(self, runner, tmp_path): + """-o with extension should still produce correct files.""" json_file = tmp_path / "report.json" md_file = tmp_path / "report.md" with mock.patch("parallel_web_tools.cli.commands.run_research") as mock_run: mock_run.return_value = { - "run_id": "trun_456", - "result_url": "https://platform.parallel.ai/play/deep-research/trun_456", + "run_id": "trun_ext", + "result_url": "https://platform.parallel.ai/play/deep-research/trun_ext", "status": "completed", - "output": {"content": "Plain string content"}, + "output": {"content": "Content here"}, } result = runner.invoke( main, - ["research", "run", "Question?", "-o", str(output_base), "--poll-interval", "1"], + ["research", "run", "Question?", "--text", "-o", str(md_file), "--poll-interval", "1"], ) assert result.exit_code == 0 - - # Check markdown file has content + assert json_file.exists() assert md_file.exists() - assert md_file.read_text() == "Plain string content" - - # Check JSON references markdown file - data = json.loads(json_file.read_text()) - assert data["output"]["content_file"] == "report.md" - def test_research_save_to_file_no_content(self, runner, tmp_path): - """Should handle output without content field.""" - output_base = tmp_path / "report" - json_file = tmp_path / "report.json" - md_file = tmp_path / "report.md" + def test_auto_generate_filename_from_run_id(self, runner, tmp_path, monkeypatch): + """Should auto-generate filename from run_id when no -o given.""" + monkeypatch.chdir(tmp_path) with mock.patch("parallel_web_tools.cli.commands.run_research") as mock_run: mock_run.return_value = { - "run_id": "trun_789", - "result_url": "https://platform.parallel.ai/play/deep-research/trun_789", + "run_id": "trun_abc", + "result_url": "https://platform.parallel.ai/play/deep-research/trun_abc", "status": "completed", - "output": {"other_field": "some value"}, + "output": {"content": {"text": "Result"}}, } - result = runner.invoke( - main, - ["research", "run", "Question?", "-o", str(output_base), "--poll-interval", "1"], - ) + # Default (auto schema, no -o) + result = runner.invoke(main, ["research", "run", "Question?", "--poll-interval", "1"]) assert result.exit_code == 0 + assert (tmp_path / "trun_abc.json").exists() + assert not (tmp_path / "trun_abc.md").exists() - # No markdown file should be created - assert not md_file.exists() - - # JSON should have original output - data = json.loads(json_file.read_text()) - assert data["output"]["other_field"] == "some value" - assert "content_file" not in data["output"] - - def test_research_save_to_file_structured_content(self, runner, tmp_path): - """Should convert structured dict content to markdown.""" - output_base = tmp_path / "report" - json_file = tmp_path / "report.json" - md_file = tmp_path / "report.md" + def test_auto_generate_filename_text(self, runner, tmp_path, monkeypatch): + """Should auto-generate both files from run_id for --text.""" + monkeypatch.chdir(tmp_path) with mock.patch("parallel_web_tools.cli.commands.run_research") as mock_run: mock_run.return_value = { - "run_id": "trun_structured", - "result_url": "https://platform.parallel.ai/play/deep-research/trun_structured", + "run_id": "trun_xyz", + "result_url": "https://platform.parallel.ai/play/deep-research/trun_xyz", "status": "completed", - "output": { - "content": { - "summary": "This is the summary.", - "key_findings": ["Finding 1", "Finding 2"], - "detailed_analysis": {"section_one": "Details here."}, - }, - "basis": [], - }, + "output": {"content": "Markdown content here"}, } - result = runner.invoke( - main, - ["research", "run", "Question?", "-o", str(output_base), "--poll-interval", "1"], - ) + result = runner.invoke(main, ["research", "run", "Question?", "--text", "--poll-interval", "1"]) assert result.exit_code == 0 - - # Markdown file should be created - assert md_file.exists() - md_content = md_file.read_text() - - # Check markdown has sections - assert "# Summary" in md_content - assert "This is the summary." in md_content - assert "# Key Findings" in md_content - assert "- Finding 1" in md_content - assert "# Detailed Analysis" in md_content - - # JSON should reference markdown file - data = json.loads(json_file.read_text()) - assert data["output"]["content_file"] == "report.md" - assert "content" not in data["output"] + assert (tmp_path / "trun_xyz.json").exists() + assert (tmp_path / "trun_xyz.md").exists() class TestSerializeOutput: @@ -757,10 +848,12 @@ def test_non_string_non_dict(self): class TestResearchOutputExecutiveSummary: - """Tests that the executive summary is printed to console.""" + """Tests that the executive summary is always printed to console.""" - def test_research_run_prints_executive_summary(self, runner): + def test_research_run_prints_executive_summary(self, runner, tmp_path, monkeypatch): """Should print executive summary when research completes.""" + monkeypatch.chdir(tmp_path) + with mock.patch("parallel_web_tools.cli.commands.run_research") as mock_run: mock_run.return_value = { "run_id": "trun_123", @@ -771,7 +864,7 @@ def test_research_run_prints_executive_summary(self, runner): }, } - result = runner.invoke(main, ["research", "run", "What is AI?", "--poll-interval", "1"]) + result = runner.invoke(main, ["research", "run", "What is AI?", "--text", "--poll-interval", "1"]) assert result.exit_code == 0 assert "Research Complete" in result.output @@ -796,8 +889,10 @@ def test_research_poll_prints_executive_summary(self, runner): assert "Executive Summary" in result.output assert "substantial executive summary" in result.output - def test_no_summary_when_content_missing(self, runner): + def test_no_summary_when_content_missing(self, runner, tmp_path, monkeypatch): """Should not crash when content is missing.""" + monkeypatch.chdir(tmp_path) + with mock.patch("parallel_web_tools.cli.commands.run_research") as mock_run: mock_run.return_value = { "run_id": "trun_789", @@ -812,19 +907,21 @@ def test_no_summary_when_content_missing(self, runner): assert "Research Complete" in result.output assert "Executive Summary" not in result.output - def test_no_summary_for_json_output(self, runner): - """Should not print summary panel when --json is used.""" + def test_summary_shown_with_auto_schema(self, runner, tmp_path, monkeypatch): + """Should print summary for auto schema (structured content).""" + monkeypatch.chdir(tmp_path) + with mock.patch("parallel_web_tools.cli.commands.run_research") as mock_run: mock_run.return_value = { "run_id": "trun_json", "result_url": "https://platform.parallel.ai/play/deep-research/trun_json", "status": "completed", "output": { - "content": "# Report\n\nThis is a long executive summary for testing.\n\n## Section\n\nBody." + "content": {"summary": "This is a structured summary for testing the executive summary display."} }, } - result = runner.invoke(main, ["research", "run", "What is AI?", "--poll-interval", "1", "--json"]) + result = runner.invoke(main, ["research", "run", "What is AI?", "--poll-interval", "1"]) assert result.exit_code == 0 - assert "Executive Summary" not in result.output + assert "Executive Summary" in result.output