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 <noreply@anthropic.com>
fix/customer-field-migration
PeninsulaInd 2026-03-03 15:49:49 -06:00
parent 236b64c11c
commit 813dd4cb01
3 changed files with 851 additions and 12 deletions

View File

@ -20,6 +20,7 @@ log = logging.getLogger(__name__)
_ROOT_DIR = Path(__file__).resolve().parent.parent.parent _ROOT_DIR = Path(__file__).resolve().parent.parent.parent
_DATA_DIR = _ROOT_DIR / "data" _DATA_DIR = _ROOT_DIR / "data"
_LOCAL_CONTENT_DIR = _DATA_DIR / "generated" / "content" _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" EXEC_TOOLS = "Bash,Read,Edit,Write,Glob,Grep,WebSearch,WebFetch"
@ -351,6 +352,369 @@ def _build_phase2_prompt(
return "\n".join(parts) 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 `<!-- HIDDEN TEST BLOCK -->` 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 `<div style=\"display:none\">` 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 # Main tool
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -418,6 +782,22 @@ def create_content(
capabilities_default = config.content.company_capabilities_default if config else "" 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: if phase == 1:
return _run_phase1( return _run_phase1(
agent=agent, agent=agent,

View File

@ -63,6 +63,7 @@ clickup:
"On Page Optimization": "On Page Optimization":
tool: "create_content" tool: "create_content"
auto_execute: false auto_execute: false
required_fields: [keyword, url]
field_mapping: field_mapping:
url: "IMSURL" url: "IMSURL"
keyword: "Keyword" keyword: "Keyword"

View File

@ -2,16 +2,21 @@
from __future__ import annotations from __future__ import annotations
import json
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from cheddahbot.config import Config, ContentConfig from cheddahbot.config import Config, ContentConfig
from cheddahbot.tools.content_creation import ( from cheddahbot.tools.content_creation import (
_build_optimization_prompt,
_build_phase1_prompt, _build_phase1_prompt,
_build_phase2_prompt, _build_phase2_prompt,
_finalize_optimization,
_find_cora_report, _find_cora_report,
_run_optimization,
_save_content, _save_content,
_slugify, _slugify,
_sync_clickup_optimization_complete,
continue_content, continue_content,
create_content, create_content,
) )
@ -234,10 +239,9 @@ class TestCreateContentPhase1:
def test_requires_context(self): def test_requires_context(self):
assert create_content(keyword="kw", url="http://x", ctx=None).startswith("Error:") 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) ctx = self._make_ctx(tmp_db, tmp_path)
result = create_content( result = create_content(
url="https://example.com/services",
keyword="plumbing services", keyword="plumbing services",
ctx=ctx, ctx=ctx,
) )
@ -250,7 +254,6 @@ class TestCreateContentPhase1:
def test_phase1_saves_outline_file(self, tmp_db, tmp_path): def test_phase1_saves_outline_file(self, tmp_db, tmp_path):
ctx = self._make_ctx(tmp_db, tmp_path) ctx = self._make_ctx(tmp_db, tmp_path)
create_content( create_content(
url="https://example.com",
keyword="plumbing services", keyword="plumbing services",
ctx=ctx, ctx=ctx,
) )
@ -266,7 +269,6 @@ class TestCreateContentPhase1:
mock_get_client.return_value = mock_client mock_get_client.return_value = mock_client
ctx = self._make_ctx(tmp_db, tmp_path) ctx = self._make_ctx(tmp_db, tmp_path)
create_content( create_content(
url="https://example.com",
keyword="plumbing services", keyword="plumbing services",
ctx=ctx, ctx=ctx,
) )
@ -280,7 +282,6 @@ class TestCreateContentPhase1:
def test_phase1_includes_clickup_sync_marker(self, tmp_db, tmp_path): def test_phase1_includes_clickup_sync_marker(self, tmp_db, tmp_path):
ctx = self._make_ctx(tmp_db, tmp_path) ctx = self._make_ctx(tmp_db, tmp_path)
result = create_content( result = create_content(
url="https://example.com",
keyword="test keyword", keyword="test keyword",
ctx=ctx, ctx=ctx,
) )
@ -329,7 +330,6 @@ class TestCreateContentPhase2:
mock_get_client.return_value = self._make_phase2_client(outline_path) mock_get_client.return_value = self._make_phase2_client(outline_path)
result = create_content( result = create_content(
url="https://example.com/plumbing",
keyword="plumbing services", keyword="plumbing services",
ctx=ctx, ctx=ctx,
) )
@ -341,7 +341,6 @@ class TestCreateContentPhase2:
mock_get_client.return_value = self._make_phase2_client(outline_path) mock_get_client.return_value = self._make_phase2_client(outline_path)
create_content( create_content(
url="https://example.com/plumbing",
keyword="plumbing services", keyword="plumbing services",
ctx=ctx, ctx=ctx,
) )
@ -355,7 +354,6 @@ class TestCreateContentPhase2:
mock_get_client.return_value = self._make_phase2_client(outline_path) mock_get_client.return_value = self._make_phase2_client(outline_path)
create_content( create_content(
url="https://example.com/plumbing",
keyword="plumbing services", keyword="plumbing services",
ctx=ctx, ctx=ctx,
) )
@ -370,7 +368,6 @@ class TestCreateContentPhase2:
mock_get_client.return_value = mock_client mock_get_client.return_value = mock_client
create_content( create_content(
url="https://example.com/plumbing",
keyword="plumbing services", keyword="plumbing services",
ctx=ctx, ctx=ctx,
) )
@ -384,7 +381,6 @@ class TestCreateContentPhase2:
mock_get_client.return_value = self._make_phase2_client(outline_path) mock_get_client.return_value = self._make_phase2_client(outline_path)
result = create_content( result = create_content(
url="https://example.com/plumbing",
keyword="plumbing services", keyword="plumbing services",
ctx=ctx, ctx=ctx,
) )
@ -459,7 +455,6 @@ class TestErrorPropagation:
"clickup_task_id": "task_err", "clickup_task_id": "task_err",
} }
result = create_content( result = create_content(
url="https://example.com",
keyword="test", keyword="test",
ctx=ctx, ctx=ctx,
) )
@ -483,10 +478,473 @@ class TestErrorPropagation:
"clickup_task_id": "task_err2", "clickup_task_id": "task_err2",
} }
result = create_content( result = create_content(
url="https://example.com",
keyword="test", keyword="test",
ctx=ctx, ctx=ctx,
) )
assert result.startswith("Error:") assert result.startswith("Error:")
# Verify ClickUp was notified of the failure # Verify ClickUp was notified of the failure
mock_client.update_task_status.assert_any_call("task_err2", "error") 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("<div>block</div>")
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("<div>block</div>")
(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("<div>block</div>")
(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("<div>block</div>")
(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("<div>block</div>")
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("<div>block</div>")
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