From 813dd4cb01490b8369eb3e938eb116cf4cafe09c Mon Sep 17 00:00:00 2001 From: PeninsulaInd Date: Tue, 3 Mar 2026 15:49:49 -0600 Subject: [PATCH] Wire Phase 3 test block pipeline into automated optimization flow When a ClickUp task with a URL arrives at create_content, route it to the new optimization pipeline instead of the outline-gate Phase 1/Phase 2 flow. The pipeline runs 8 steps via the execution brain (scrape, deficit analysis, entity filtering, template writing, test block generation, readability rewrite, validation, surgical instructions doc) and uploads deliverables (test_block.html, optimization_instructions.md, validation_report.json) directly to ClickUp as attachments. Co-Authored-By: Claude Opus 4.6 --- cheddahbot/tools/content_creation.py | 380 +++++++++++++++++++++ config.yaml | 1 + tests/test_content_creation.py | 482 ++++++++++++++++++++++++++- 3 files changed, 851 insertions(+), 12 deletions(-) diff --git a/cheddahbot/tools/content_creation.py b/cheddahbot/tools/content_creation.py index c737a6f..38d8f3f 100644 --- a/cheddahbot/tools/content_creation.py +++ b/cheddahbot/tools/content_creation.py @@ -20,6 +20,7 @@ log = logging.getLogger(__name__) _ROOT_DIR = Path(__file__).resolve().parent.parent.parent _DATA_DIR = _ROOT_DIR / "data" _LOCAL_CONTENT_DIR = _DATA_DIR / "generated" / "content" +_SCRIPTS_DIR = _ROOT_DIR / ".claude" / "skills" / "content-researcher" / "scripts" EXEC_TOOLS = "Bash,Read,Edit,Write,Glob,Grep,WebSearch,WebFetch" @@ -351,6 +352,369 @@ def _build_phase2_prompt( return "\n".join(parts) +def _build_optimization_prompt( + url: str, + keyword: str, + cora_path: str, + work_dir: str, + scripts_dir: str, + is_service_page: bool = False, + capabilities_default: str = "", +) -> str: + """Build the execution brain prompt for the Phase 3 optimization pipeline. + + Produces 8 sequential steps that scrape the existing page, run deficit + analysis, generate a test block, and create an optimization instructions + document. All script commands use absolute paths so the CLI can execute + them without any skill context. + """ + parts = [ + f"You are running an automated on-page optimization pipeline for " + f"'{keyword}' on {url}.\n\n" + f"Working directory: {work_dir}\n" + f"Cora report: {cora_path}\n" + f"Scripts directory: {scripts_dir}\n\n" + f"Execute the following steps IN ORDER. Each step depends on the " + f"previous step's output files. Do NOT skip steps.\n", + + # Step 1 — Scrape existing page + f"\n## Step 1 — Scrape Existing Page\n\n" + f"Run the competitor scraper to fetch the current page content:\n\n" + f"```bash\n" + f'uv run --with requests,beautifulsoup4 python "{scripts_dir}/competitor_scraper.py" ' + f'"{url}" --output-dir "{work_dir}" --format text\n' + f"```\n\n" + f"This produces `existing_content.md` (or a text file named after the URL) " + f"in the working directory. If the output file is not named `existing_content.md`, " + f"rename it to `existing_content.md`.", + + # Step 2 — Deficit analysis + f"\n## Step 2 — Test Block Prep (Deficit Analysis)\n\n" + f"Run the deficit analysis against the Cora report:\n\n" + f"```bash\n" + f'cd "{scripts_dir}" && uv run --with openpyxl python test_block_prep.py ' + f'"{work_dir}/existing_content.md" "{cora_path}" --format json ' + f'> "{work_dir}/prep_data.json"\n' + f"```\n\n" + f"This produces `prep_data.json` with word count deficits, missing entities, " + f"density targets, and template generation instructions.", + + # Step 3 — Filter entities (LLM step) + f"\n## Step 3 — Filter Missing Entities for Topical Relevance\n\n" + f'Read `{work_dir}/prep_data.json` and extract the `missing_entities` list. ' + f"Filter this list to keep ONLY entities that are topically relevant to " + f"'{keyword}' and the page content. Remove generic/off-topic entities.\n\n" + f"Write one entity per line to `{work_dir}/filtered_entities.txt`.\n\n" + f"Be aggressive about filtering — only keep entities that a subject-matter " + f"expert would expect to see on a page about '{keyword}'.", + + # Step 4 — Write templates (LLM step) + f"\n## Step 4 — Write Heading + Body Templates\n\n" + f"Using the deficit data from `{work_dir}/prep_data.json` and the filtered " + f"entities from `{work_dir}/filtered_entities.txt`, write:\n\n" + f"1. H2 and H3 headings that incorporate target entities\n" + f"2. Body sentence templates with `{{N}}` placeholder slots where entity " + f"terms will be inserted programmatically\n\n" + f"Format: Each template is a heading line followed by body sentences. " + f"Each body sentence should have 1-3 `{{N}}` slots (numbered sequentially " + f"starting from 1 within each sentence).\n\n" + f"Write the output to `{work_dir}/templates.txt`.\n\n" + f"Example format:\n" + f"```\n" + f"## Heading About {{1}} and {{2}}\n" + f"Sentence with {{1}} integrated naturally. Another point about {{2}} " + f"that provides value.\n" + f"```", + + # Step 5 — Generate test block (script) + f"\n## Step 5 — Generate Test Block\n\n" + f"Run the test block generator to fill template slots and produce the " + f"HTML test block:\n\n" + f"```bash\n" + f'cd "{scripts_dir}" && uv run --with openpyxl python test_block_generator.py ' + f'"{work_dir}/templates.txt" "{work_dir}/prep_data.json" "{cora_path}" ' + f'--entities-file "{work_dir}/filtered_entities.txt" ' + f'--output-dir "{work_dir}"\n' + f"```\n\n" + f"This produces `test_block.md`, `test_block.html`, and `test_block_stats.json` " + f"in the working directory.", + + # Step 6 — Rewrite for readability (LLM step) + f"\n## Step 6 — Rewrite Body Sentences for Readability\n\n" + f"Read `{work_dir}/test_block.md`. Rewrite each body sentence to improve " + f"readability and natural flow while preserving:\n" + f"- ALL entity strings exactly as they appear (do not paraphrase entity terms)\n" + f"- The overall heading structure\n" + f"- The `` markers\n\n" + f"Write the improved version back to `{work_dir}/test_block.md`.\n" + f"Then regenerate the HTML version at `{work_dir}/test_block.html` with the " + f"content wrapped in `
` tags.", + + # Step 7 — Validate (script) + f"\n## Step 7 — Validate Test Block\n\n" + f"Run the before/after validation:\n\n" + f"```bash\n" + f'cd "{scripts_dir}" && uv run --with openpyxl python test_block_validate.py ' + f'"{work_dir}/existing_content.md" "{work_dir}/test_block.md" "{cora_path}" ' + f'--format json --output "{work_dir}/validation_report.json"\n' + f"```\n\n" + f"This produces `validation_report.json` with before/after metrics comparison.", + + # Step 8 — Generate optimization instructions (LLM step) + f"\n## Step 8 — Generate Optimization Instructions\n\n" + f"Read the following files:\n" + f"- `{work_dir}/existing_content.md` (current page)\n" + f"- `{work_dir}/prep_data.json` (deficit analysis)\n" + f"- `{work_dir}/validation_report.json` (before/after metrics)\n" + f"- `{work_dir}/test_block.md` (generated test block)\n\n" + f"Generate `{work_dir}/optimization_instructions.md` — a surgical playbook " + f"for the human editor with these sections:\n\n" + f"1. **Executive Summary** — one-paragraph overview of optimization opportunity\n" + f"2. **Heading Changes** — specific H1/H2/H3 modifications with before→after\n" + f"3. **Sections to Expand** — which sections need more content and what to add\n" + f"4. **Entity Integration Points** — exact locations to weave in missing entities\n" + f"5. **Meta Tag Updates** — title tag and meta description recommendations\n" + f"6. **Content Gaps** — topics covered by competitors but missing from this page\n" + f"7. **Priority Ranking** — rank all changes by expected SEO impact (high/medium/low)\n\n" + f"Be specific and actionable. Reference exact headings and paragraphs from " + f"the existing content. Do NOT rewrite the full page — this is a surgical guide.", + ] + + if is_service_page: + parts.append( + f'\nNOTE: This is a **service page**. Company capabilities: ' + f'"{capabilities_default}"\n' + f"Do NOT make specific claims about services, certifications, or " + f"licenses not found on the existing page." + ) + + return "\n".join(parts) + + +# --------------------------------------------------------------------------- +# Optimization pipeline (Phase 3 — test block + surgical instructions) +# --------------------------------------------------------------------------- + + +def _run_optimization( + *, + agent, + config, + ctx: dict | None, + task_id: str, + url: str, + keyword: str, + cora_path: str, + is_service_page: bool = False, + capabilities_default: str = "", +) -> str: + """Run the Phase 3 optimization pipeline. + + Requires a Cora report. Creates an isolated working directory, calls the + execution brain with the 8-step optimization prompt, then finalizes by + collecting deliverables and syncing ClickUp. + """ + if not cora_path: + msg = ( + f"Error: No Cora report found for keyword '{keyword}'. " + f"A Cora report is required for the optimization pipeline. " + f"Please upload a Cora .xlsx report to the content Cora inbox." + ) + log.error(msg) + if task_id: + _sync_clickup_fail(ctx, task_id, msg) + return msg + + slug = _slugify(keyword) or "unknown" + work_dir = _LOCAL_CONTENT_DIR / slug / f"optimization-{task_id or 'manual'}" + work_dir.mkdir(parents=True, exist_ok=True) + + scripts_dir = str(_SCRIPTS_DIR) + + # ClickUp: move to automation underway + if task_id: + _sync_clickup_start(ctx, task_id) + + prompt = _build_optimization_prompt( + url=url, + keyword=keyword, + cora_path=cora_path, + work_dir=str(work_dir), + scripts_dir=scripts_dir, + is_service_page=is_service_page, + capabilities_default=capabilities_default, + ) + + log.info( + "Optimization pipeline — running for '%s' (%s), work_dir=%s", + keyword, url, work_dir, + ) + try: + exec_result = agent.execute_task( + prompt, + tools=EXEC_TOOLS, + skip_permissions=True, + ) + except Exception as e: + error_msg = f"Optimization pipeline execution failed: {e}" + log.error(error_msg) + if task_id: + _sync_clickup_fail(ctx, task_id, str(e)) + return f"Error: {error_msg}" + + if exec_result.startswith("Error:"): + if task_id: + _sync_clickup_fail(ctx, task_id, exec_result) + return exec_result + + return _finalize_optimization( + ctx=ctx, + config=config, + task_id=task_id, + keyword=keyword, + url=url, + work_dir=work_dir, + exec_result=exec_result, + ) + + +def _finalize_optimization( + *, + ctx: dict | None, + config, + task_id: str, + keyword: str, + url: str, + work_dir: Path, + exec_result: str, +) -> str: + """Collect deliverables from the working directory and sync ClickUp. + + Required files: test_block.html, optimization_instructions.md. + Optional: validation_report.json. + """ + required = ["test_block.html", "optimization_instructions.md"] + missing = [f for f in required if not (work_dir / f).exists()] + if missing: + error_msg = ( + f"Optimization pipeline finished but required deliverables are " + f"missing: {', '.join(missing)}. Working directory: {work_dir}" + ) + log.error(error_msg) + if task_id: + _sync_clickup_fail(ctx, task_id, error_msg) + return f"Error: {error_msg}" + + # Collect all deliverable paths + deliverable_names = [ + "test_block.html", + "optimization_instructions.md", + "validation_report.json", + ] + found_files: dict[str, Path] = {} + for name in deliverable_names: + fpath = work_dir / name + if fpath.exists(): + found_files[name] = fpath + + # Copy deliverables to network path (if configured) + slug = _slugify(keyword) or "unknown" + if config and config.content.outline_dir: + net_dir = Path(config.content.outline_dir) / slug + try: + net_dir.mkdir(parents=True, exist_ok=True) + for name, fpath in found_files.items(): + dest = net_dir / name + dest.write_bytes(fpath.read_bytes()) + log.info("Copied %s → %s", fpath, dest) + except OSError as e: + log.warning("Could not copy deliverables to network path %s: %s", net_dir, e) + + # Sync ClickUp + if task_id: + _sync_clickup_optimization_complete( + ctx=ctx, + config=config, + task_id=task_id, + keyword=keyword, + url=url, + found_files=found_files, + work_dir=work_dir, + ) + + file_list = "\n".join(f"- `{p}`" for p in found_files.values()) + return ( + f"## Optimization Complete\n\n" + f"**Keyword:** {keyword}\n" + f"**URL:** {url}\n" + f"**Deliverables:**\n{file_list}\n\n" + f"---\n\n{exec_result}\n\n" + f"## ClickUp Sync\nOptimization complete. Status: internal review." + ) + + +def _sync_clickup_optimization_complete( + *, + ctx: dict | None, + config, + task_id: str, + keyword: str, + url: str, + found_files: dict[str, Path], + work_dir: Path, +) -> None: + """Upload optimization deliverables to ClickUp and set status.""" + if not task_id or not ctx: + return + client = _get_clickup_client(ctx) + if not client: + return + try: + # Upload attachments + for name, fpath in found_files.items(): + try: + client.upload_attachment(task_id, fpath) + log.info("Uploaded %s to ClickUp task %s", name, task_id) + except Exception as e: + log.warning("Failed to upload %s: %s", name, e) + + # Build comment with validation summary + comment_parts = [ + f"✅ Optimization pipeline complete for '{keyword}'.\n", + f"**URL:** {url}\n", + "**Deliverables attached:**", + ] + for name in found_files: + comment_parts.append(f"- {name}") + + # Include validation summary if available + val_path = work_dir / "validation_report.json" + if val_path.exists(): + try: + import json + val_data = json.loads(val_path.read_text(encoding="utf-8")) + summary = val_data.get("summary", "") + if summary: + comment_parts.append(f"\n**Validation Summary:**\n{summary}") + except Exception: + pass + + comment_parts.append( + "\n**Next Steps:**\n" + "1. Review `optimization_instructions.md` for surgical changes\n" + "2. Deploy `test_block.html` hidden div to the page\n" + "3. Monitor rankings for 2-4 weeks\n" + "4. Apply surgical changes from the instructions doc" + ) + + client.add_comment(task_id, "\n".join(comment_parts)) + client.update_task_status(task_id, config.clickup.review_status) + except Exception as e: + log.warning("Failed to sync optimization complete for %s: %s", task_id, e) + finally: + client.close() + + # --------------------------------------------------------------------------- # Main tool # --------------------------------------------------------------------------- @@ -418,6 +782,22 @@ def create_content( capabilities_default = config.content.company_capabilities_default if config else "" + # Optimization path: URL present → run Phase 3 test block pipeline + # (skips the outline gate entirely) + if url: + return _run_optimization( + agent=agent, + config=config, + ctx=ctx, + task_id=task_id, + url=url, + keyword=keyword, + cora_path=cora_path, + is_service_page=is_service_page, + capabilities_default=capabilities_default, + ) + + # New content path: Phase 1 (outline) → human review → Phase 2 (write) if phase == 1: return _run_phase1( agent=agent, diff --git a/config.yaml b/config.yaml index 9ffbf2d..a00b48f 100644 --- a/config.yaml +++ b/config.yaml @@ -63,6 +63,7 @@ clickup: "On Page Optimization": tool: "create_content" auto_execute: false + required_fields: [keyword, url] field_mapping: url: "IMSURL" keyword: "Keyword" diff --git a/tests/test_content_creation.py b/tests/test_content_creation.py index 041ea2b..a073aea 100644 --- a/tests/test_content_creation.py +++ b/tests/test_content_creation.py @@ -2,16 +2,21 @@ from __future__ import annotations +import json from pathlib import Path from unittest.mock import MagicMock, patch from cheddahbot.config import Config, ContentConfig from cheddahbot.tools.content_creation import ( + _build_optimization_prompt, _build_phase1_prompt, _build_phase2_prompt, + _finalize_optimization, _find_cora_report, + _run_optimization, _save_content, _slugify, + _sync_clickup_optimization_complete, continue_content, create_content, ) @@ -234,10 +239,9 @@ class TestCreateContentPhase1: def test_requires_context(self): assert create_content(keyword="kw", url="http://x", ctx=None).startswith("Error:") - def test_phase1_runs_without_prior_state(self, tmp_db, tmp_path): + def test_phase1_runs_for_new_content(self, tmp_db, tmp_path): ctx = self._make_ctx(tmp_db, tmp_path) result = create_content( - url="https://example.com/services", keyword="plumbing services", ctx=ctx, ) @@ -250,7 +254,6 @@ class TestCreateContentPhase1: def test_phase1_saves_outline_file(self, tmp_db, tmp_path): ctx = self._make_ctx(tmp_db, tmp_path) create_content( - url="https://example.com", keyword="plumbing services", ctx=ctx, ) @@ -266,7 +269,6 @@ class TestCreateContentPhase1: mock_get_client.return_value = mock_client ctx = self._make_ctx(tmp_db, tmp_path) create_content( - url="https://example.com", keyword="plumbing services", ctx=ctx, ) @@ -280,7 +282,6 @@ class TestCreateContentPhase1: def test_phase1_includes_clickup_sync_marker(self, tmp_db, tmp_path): ctx = self._make_ctx(tmp_db, tmp_path) result = create_content( - url="https://example.com", keyword="test keyword", ctx=ctx, ) @@ -329,7 +330,6 @@ class TestCreateContentPhase2: mock_get_client.return_value = self._make_phase2_client(outline_path) result = create_content( - url="https://example.com/plumbing", keyword="plumbing services", ctx=ctx, ) @@ -341,7 +341,6 @@ class TestCreateContentPhase2: mock_get_client.return_value = self._make_phase2_client(outline_path) create_content( - url="https://example.com/plumbing", keyword="plumbing services", ctx=ctx, ) @@ -355,7 +354,6 @@ class TestCreateContentPhase2: mock_get_client.return_value = self._make_phase2_client(outline_path) create_content( - url="https://example.com/plumbing", keyword="plumbing services", ctx=ctx, ) @@ -370,7 +368,6 @@ class TestCreateContentPhase2: mock_get_client.return_value = mock_client create_content( - url="https://example.com/plumbing", keyword="plumbing services", ctx=ctx, ) @@ -384,7 +381,6 @@ class TestCreateContentPhase2: mock_get_client.return_value = self._make_phase2_client(outline_path) result = create_content( - url="https://example.com/plumbing", keyword="plumbing services", ctx=ctx, ) @@ -459,7 +455,6 @@ class TestErrorPropagation: "clickup_task_id": "task_err", } result = create_content( - url="https://example.com", keyword="test", ctx=ctx, ) @@ -483,10 +478,473 @@ class TestErrorPropagation: "clickup_task_id": "task_err2", } result = create_content( - url="https://example.com", keyword="test", ctx=ctx, ) assert result.startswith("Error:") # Verify ClickUp was notified of the failure mock_client.update_task_status.assert_any_call("task_err2", "error") + + +# --------------------------------------------------------------------------- +# _build_optimization_prompt +# --------------------------------------------------------------------------- + + +class TestBuildOptimizationPrompt: + def test_contains_url_and_keyword(self): + prompt = _build_optimization_prompt( + url="https://example.com/plumbing", + keyword="plumbing services", + cora_path="Z:/cora/report.xlsx", + work_dir="/tmp/work", + scripts_dir="/scripts", + ) + assert "https://example.com/plumbing" in prompt + assert "plumbing services" in prompt + + def test_contains_cora_path(self): + prompt = _build_optimization_prompt( + url="https://example.com", + keyword="kw", + cora_path="Z:/cora/report.xlsx", + work_dir="/tmp/work", + scripts_dir="/scripts", + ) + assert "Z:/cora/report.xlsx" in prompt + + def test_contains_all_script_commands(self): + prompt = _build_optimization_prompt( + url="https://example.com", + keyword="kw", + cora_path="Z:/cora/report.xlsx", + work_dir="/tmp/work", + scripts_dir="/scripts", + ) + assert "competitor_scraper.py" in prompt + assert "test_block_prep.py" in prompt + assert "test_block_generator.py" in prompt + assert "test_block_validate.py" in prompt + + def test_contains_step8_instructions(self): + prompt = _build_optimization_prompt( + url="https://example.com", + keyword="kw", + cora_path="Z:/cora/report.xlsx", + work_dir="/tmp/work", + scripts_dir="/scripts", + ) + assert "optimization_instructions.md" in prompt + assert "Heading Changes" in prompt + assert "Entity Integration Points" in prompt + assert "Meta Tag Updates" in prompt + assert "Priority Ranking" in prompt + + def test_service_page_note(self): + prompt = _build_optimization_prompt( + url="https://example.com", + keyword="kw", + cora_path="Z:/cora/report.xlsx", + work_dir="/tmp/work", + scripts_dir="/scripts", + is_service_page=True, + capabilities_default="Check website.", + ) + assert "service page" in prompt + assert "Check website." in prompt + + def test_no_service_page_note_by_default(self): + prompt = _build_optimization_prompt( + url="https://example.com", + keyword="kw", + cora_path="Z:/cora/report.xlsx", + work_dir="/tmp/work", + scripts_dir="/scripts", + ) + assert "service page" not in prompt.lower().split("step")[0] + + def test_all_eight_steps_present(self): + prompt = _build_optimization_prompt( + url="https://example.com", + keyword="kw", + cora_path="Z:/cora/report.xlsx", + work_dir="/tmp/work", + scripts_dir="/scripts", + ) + for step_num in range(1, 9): + assert f"Step {step_num}" in prompt + + +# --------------------------------------------------------------------------- +# _run_optimization +# --------------------------------------------------------------------------- + + +class TestRunOptimization: + def _make_ctx(self, tmp_db, tmp_path): + cfg = Config() + cfg.content = ContentConfig(outline_dir=str(tmp_path / "outlines")) + agent = MagicMock() + agent.execute_task.return_value = "Optimization complete" + return { + "agent": agent, + "config": cfg, + "db": tmp_db, + "clickup_task_id": "opt_task_1", + } + + def test_fails_without_cora_report(self, tmp_db, tmp_path): + ctx = self._make_ctx(tmp_db, tmp_path) + result = _run_optimization( + agent=ctx["agent"], + config=ctx["config"], + ctx=ctx, + task_id="opt_task_1", + url="https://example.com", + keyword="plumbing services", + cora_path="", + ) + assert "Error:" in result + assert "Cora report" in result + + @patch("cheddahbot.tools.content_creation._sync_clickup_fail") + def test_syncs_clickup_on_missing_cora(self, mock_fail, tmp_db, tmp_path): + ctx = self._make_ctx(tmp_db, tmp_path) + _run_optimization( + agent=ctx["agent"], + config=ctx["config"], + ctx=ctx, + task_id="opt_task_1", + url="https://example.com", + keyword="plumbing services", + cora_path="", + ) + mock_fail.assert_called_once() + assert mock_fail.call_args[0][1] == "opt_task_1" + + @patch("cheddahbot.tools.content_creation._finalize_optimization") + @patch("cheddahbot.tools.content_creation._sync_clickup_start") + def test_creates_work_dir_and_calls_execute( + self, mock_start, mock_finalize, tmp_db, tmp_path + ): + ctx = self._make_ctx(tmp_db, tmp_path) + mock_finalize.return_value = "finalized" + with patch( + "cheddahbot.tools.content_creation._LOCAL_CONTENT_DIR", + tmp_path / "content", + ): + result = _run_optimization( + agent=ctx["agent"], + config=ctx["config"], + ctx=ctx, + task_id="opt_task_1", + url="https://example.com/plumbing", + keyword="plumbing services", + cora_path="Z:/cora/report.xlsx", + ) + ctx["agent"].execute_task.assert_called_once() + mock_start.assert_called_once_with(ctx, "opt_task_1") + mock_finalize.assert_called_once() + assert result == "finalized" + + @patch("cheddahbot.tools.content_creation._sync_clickup_fail") + @patch("cheddahbot.tools.content_creation._sync_clickup_start") + def test_syncs_clickup_on_execution_error( + self, mock_start, mock_fail, tmp_db, tmp_path + ): + ctx = self._make_ctx(tmp_db, tmp_path) + ctx["agent"].execute_task.side_effect = RuntimeError("CLI crashed") + with patch( + "cheddahbot.tools.content_creation._LOCAL_CONTENT_DIR", + tmp_path / "content", + ): + result = _run_optimization( + agent=ctx["agent"], + config=ctx["config"], + ctx=ctx, + task_id="opt_task_1", + url="https://example.com", + keyword="plumbing services", + cora_path="Z:/cora/report.xlsx", + ) + assert "Error:" in result + mock_fail.assert_called_once() + + +# --------------------------------------------------------------------------- +# _finalize_optimization +# --------------------------------------------------------------------------- + + +class TestFinalizeOptimization: + def _make_config(self, outline_dir: str = "") -> Config: + cfg = Config() + cfg.content = ContentConfig(outline_dir=outline_dir) + return cfg + + def test_errors_on_missing_test_block(self, tmp_path): + work_dir = tmp_path / "work" + work_dir.mkdir() + # Only create instructions, not test_block.html + (work_dir / "optimization_instructions.md").write_text("instructions") + cfg = self._make_config() + result = _finalize_optimization( + ctx=None, + config=cfg, + task_id="", + keyword="kw", + url="https://example.com", + work_dir=work_dir, + exec_result="done", + ) + assert "Error:" in result + assert "test_block.html" in result + + def test_errors_on_missing_instructions(self, tmp_path): + work_dir = tmp_path / "work" + work_dir.mkdir() + # Only create test_block, not instructions + (work_dir / "test_block.html").write_text("
block
") + cfg = self._make_config() + result = _finalize_optimization( + ctx=None, + config=cfg, + task_id="", + keyword="kw", + url="https://example.com", + work_dir=work_dir, + exec_result="done", + ) + assert "Error:" in result + assert "optimization_instructions.md" in result + + def test_succeeds_with_required_files(self, tmp_path): + work_dir = tmp_path / "work" + work_dir.mkdir() + (work_dir / "test_block.html").write_text("
block
") + (work_dir / "optimization_instructions.md").write_text("# Instructions") + cfg = self._make_config() + result = _finalize_optimization( + ctx=None, + config=cfg, + task_id="", + keyword="plumbing services", + url="https://example.com", + work_dir=work_dir, + exec_result="all done", + ) + assert "Optimization Complete" in result + assert "plumbing services" in result + assert "test_block.html" in result + + def test_copies_to_network_path(self, tmp_path): + work_dir = tmp_path / "work" + work_dir.mkdir() + (work_dir / "test_block.html").write_text("
block
") + (work_dir / "optimization_instructions.md").write_text("# Instructions") + net_dir = tmp_path / "network" + cfg = self._make_config(str(net_dir)) + _finalize_optimization( + ctx=None, + config=cfg, + task_id="", + keyword="plumbing services", + url="https://example.com", + work_dir=work_dir, + exec_result="done", + ) + assert (net_dir / "plumbing-services" / "test_block.html").exists() + assert (net_dir / "plumbing-services" / "optimization_instructions.md").exists() + + @patch("cheddahbot.tools.content_creation._sync_clickup_optimization_complete") + def test_syncs_clickup_when_task_id_present(self, mock_sync, tmp_path, tmp_db): + work_dir = tmp_path / "work" + work_dir.mkdir() + (work_dir / "test_block.html").write_text("
block
") + (work_dir / "optimization_instructions.md").write_text("# Instructions") + cfg = self._make_config() + ctx = {"config": cfg, "db": tmp_db} + _finalize_optimization( + ctx=ctx, + config=cfg, + task_id="task_fin", + keyword="kw", + url="https://example.com", + work_dir=work_dir, + exec_result="done", + ) + mock_sync.assert_called_once() + call_kwargs = mock_sync.call_args.kwargs + assert call_kwargs["task_id"] == "task_fin" + assert "test_block.html" in call_kwargs["found_files"] + assert "optimization_instructions.md" in call_kwargs["found_files"] + + +# --------------------------------------------------------------------------- +# _sync_clickup_optimization_complete +# --------------------------------------------------------------------------- + + +class TestSyncClickupOptimizationComplete: + @patch("cheddahbot.tools.content_creation._get_clickup_client") + def test_uploads_files_and_posts_comment(self, mock_get_client, tmp_path): + mock_client = MagicMock() + mock_get_client.return_value = mock_client + + work_dir = tmp_path / "work" + work_dir.mkdir() + tb_path = work_dir / "test_block.html" + tb_path.write_text("
block
") + inst_path = work_dir / "optimization_instructions.md" + inst_path.write_text("# Instructions") + val_path = work_dir / "validation_report.json" + val_path.write_text(json.dumps({"summary": "All metrics improved."})) + + cfg = Config() + ctx = {"config": cfg} + found_files = { + "test_block.html": tb_path, + "optimization_instructions.md": inst_path, + "validation_report.json": val_path, + } + _sync_clickup_optimization_complete( + ctx=ctx, + config=cfg, + task_id="task_sync", + keyword="plumbing", + url="https://example.com", + found_files=found_files, + work_dir=work_dir, + ) + # 3 file uploads + assert mock_client.upload_attachment.call_count == 3 + # Comment posted + mock_client.add_comment.assert_called_once() + comment = mock_client.add_comment.call_args[0][1] + assert "plumbing" in comment + assert "All metrics improved." in comment + assert "Next Steps" in comment + # Status set to internal review + mock_client.update_task_status.assert_called_once_with( + "task_sync", cfg.clickup.review_status + ) + + @patch("cheddahbot.tools.content_creation._get_clickup_client") + def test_handles_no_validation_report(self, mock_get_client, tmp_path): + mock_client = MagicMock() + mock_get_client.return_value = mock_client + + work_dir = tmp_path / "work" + work_dir.mkdir() + tb_path = work_dir / "test_block.html" + tb_path.write_text("
block
") + inst_path = work_dir / "optimization_instructions.md" + inst_path.write_text("# Instructions") + + cfg = Config() + ctx = {"config": cfg} + found_files = { + "test_block.html": tb_path, + "optimization_instructions.md": inst_path, + } + _sync_clickup_optimization_complete( + ctx=ctx, + config=cfg, + task_id="task_sync2", + keyword="kw", + url="https://example.com", + found_files=found_files, + work_dir=work_dir, + ) + # 2 uploads (no validation_report.json) + assert mock_client.upload_attachment.call_count == 2 + mock_client.add_comment.assert_called_once() + + def test_noop_without_task_id(self, tmp_path): + """No ClickUp sync when task_id is empty.""" + work_dir = tmp_path / "work" + work_dir.mkdir() + cfg = Config() + # Should not raise + _sync_clickup_optimization_complete( + ctx={"config": cfg}, + config=cfg, + task_id="", + keyword="kw", + url="https://example.com", + found_files={}, + work_dir=work_dir, + ) + + +# --------------------------------------------------------------------------- +# create_content — Routing (URL → optimization vs new content → phases) +# --------------------------------------------------------------------------- + + +class TestCreateContentRouting: + @patch("cheddahbot.tools.content_creation._run_optimization") + def test_url_present_routes_to_optimization(self, mock_opt, tmp_db, tmp_path): + """When URL is present, create_content should call _run_optimization.""" + mock_opt.return_value = "## Optimization Complete" + cfg = Config() + cfg.content = ContentConfig(outline_dir=str(tmp_path / "outlines")) + ctx = { + "agent": MagicMock(), + "config": cfg, + "db": tmp_db, + "clickup_task_id": "routing_test", + } + result = create_content( + keyword="plumbing services", + url="https://example.com/plumbing", + ctx=ctx, + ) + mock_opt.assert_called_once() + assert result == "## Optimization Complete" + + @patch("cheddahbot.tools.content_creation._run_optimization") + def test_no_url_does_not_route_to_optimization(self, mock_opt, tmp_db, tmp_path): + """When URL is empty, create_content should NOT call _run_optimization.""" + cfg = Config() + cfg.content = ContentConfig(outline_dir=str(tmp_path / "outlines")) + agent = MagicMock() + agent.execute_task.return_value = "## Outline" + ctx = { + "agent": agent, + "config": cfg, + "db": tmp_db, + "clickup_task_id": "", + } + result = create_content( + keyword="new keyword", + url="", + ctx=ctx, + ) + mock_opt.assert_not_called() + assert "Phase 1 Complete" in result + + @patch("cheddahbot.tools.content_creation._run_optimization") + def test_new_content_still_calls_phase1(self, mock_opt, tmp_db, tmp_path): + """Regression: new content (no URL) still goes through _run_phase1.""" + cfg = Config() + cfg.content = ContentConfig(outline_dir=str(tmp_path / "outlines")) + agent = MagicMock() + agent.execute_task.return_value = "## Generated Outline\nContent..." + ctx = { + "agent": agent, + "config": cfg, + "db": tmp_db, + "clickup_task_id": "", + } + create_content( + keyword="new topic", + url="", + ctx=ctx, + ) + mock_opt.assert_not_called() + agent.execute_task.assert_called_once() + # Verify it's the phase 1 prompt (new content path) + call_args = agent.execute_task.call_args + prompt = call_args.args[0] if call_args.args else call_args.kwargs.get("prompt", "") + assert "new content creation project" in prompt