Add direct BLM handler for Link Building build stage

Replace Claude-based link building with direct subprocess calls to
Big-Link-Man CLI. The build stage now runs ingest-cora + generate-batch
deterministically without LLM involvement.

- New clickup_runner/blm.py: BLM subprocess runner (ported from old linkbuilding.py)
- New handler="blm" in skill_map for Link Building build stage
- Add BLMConfig to config.py (blm_dir, cora_inbox, timeout)
- Add task-specific fields (Keyword, CLIFlags, etc.) to build_prompt
- Strip YAML frontmatter from skill files in read_skill_file
- Skip orphaned AutoCora results with no state DB entry

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
clickup-runner
PeninsulaInd 2026-04-09 15:22:55 -05:00
parent e51570b804
commit f4e642240c
7 changed files with 480 additions and 6 deletions

View File

@ -15,6 +15,7 @@ from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from .autocora import archive_result, scan_results, submit_job from .autocora import archive_result, scan_results, submit_job
from .blm import find_cora_xlsx, run_generate, run_ingest
from .claude_runner import ( from .claude_runner import (
RunResult, RunResult,
build_prompt, build_prompt,
@ -204,6 +205,8 @@ def poll_cycle(
if route.handler == "autocora": if route.handler == "autocora":
_dispatch_autocora(client, cfg, db, task, route, run_id) _dispatch_autocora(client, cfg, db, task, route, run_id)
elif route.handler == "blm":
_dispatch_blm(client, cfg, db, task, route, run_id)
else: else:
_dispatch_claude(client, cfg, db, task, route, run_id) _dispatch_claude(client, cfg, db, task, route, run_id)
@ -434,6 +437,120 @@ def _dispatch_autocora(
) )
def _dispatch_blm(
client: ClickUpClient,
cfg: Config,
db: StateDB,
task: ClickUpTask,
route: SkillRoute,
run_id: int,
):
"""Run BLM ingest-cora + generate-batch directly (no Claude needed)."""
keyword = task.get_field_value("Keyword") or ""
url = task.get_field_value("IMSURL") or ""
cli_flags = task.get_field_value("CLIFlags") or ""
bp_ratio = task.get_field_value("BrandedPlusRatio") or ""
custom_anchors = task.get_field_value("CustomAnchors") or ""
if not keyword:
_handle_dispatch_error(
client, cfg, db, task, run_id,
error="Missing Keyword field",
fix="Set the Keyword custom field, then re-check Delegate to Claude.",
)
return
# 1. Set status to AI Working
client.update_task_status(task.id, cfg.clickup.ai_working_status)
# 2. Find the Cora xlsx
xlsx_path = find_cora_xlsx(keyword, cfg.blm.cora_inbox)
if not xlsx_path:
_handle_dispatch_error(
client, cfg, db, task, run_id,
error="No Cora xlsx found for keyword '%s' in %s"
% (keyword, cfg.blm.cora_inbox),
fix="Check that the Cora report exists in %s, then re-check Delegate to Claude."
% cfg.blm.cora_inbox,
)
return
log.info("Found Cora xlsx: %s", xlsx_path)
client.add_comment(task.id, "Starting BLM pipeline for '%s'.\nCora file: %s" % (keyword, xlsx_path))
# 3. Run ingest-cora
log.info("Running ingest-cora for task %s (keyword=%s)", task.id, keyword)
ingest = run_ingest(
xlsx_path=xlsx_path,
keyword=keyword,
money_site_url=url,
blm_dir=cfg.blm.blm_dir,
timeout=cfg.blm.timeout_seconds,
branded_plus_ratio=bp_ratio,
custom_anchors=custom_anchors,
cli_flags=cli_flags,
)
if not ingest.success:
_handle_dispatch_error(
client, cfg, db, task, run_id,
error="ingest-cora failed: %s" % ingest.error,
fix="Check BLM logs, fix the issue, then re-check Delegate to Claude.",
)
return
log.info(
"ingest-cora OK: project=%s (ID=%s), job_file=%s",
ingest.project_name, ingest.project_id, ingest.job_file,
)
# 4. Run generate-batch
log.info("Running generate-batch for task %s (job=%s)", task.id, ingest.job_file)
gen = run_generate(
job_file=ingest.job_file,
blm_dir=cfg.blm.blm_dir,
timeout=cfg.blm.timeout_seconds,
)
if not gen.success:
_handle_dispatch_error(
client, cfg, db, task, run_id,
error="generate-batch failed: %s" % gen.error,
fix="Check BLM logs, fix the issue, then re-check Delegate to Claude.",
)
return
log.info("generate-batch OK: job moved to %s", gen.job_moved_to)
# 5. Advance stage + post summary
client.set_stage(
task.id, task.list_id, route.next_stage, cfg.clickup.stage_field_name
)
client.update_task_status(task.id, route.next_status)
summary = (
"BLM pipeline completed for '%s'.\n\n"
"- Project: %s (ID: %s)\n"
"- Keyword: %s\n"
"- Job file: %s"
) % (keyword, ingest.project_name, ingest.project_id,
ingest.main_keyword, gen.job_moved_to or ingest.job_file)
client.add_comment(task.id, summary)
# 6. Clear error + uncheck delegate
client.set_checkbox(
task.id, task.list_id, cfg.clickup.error_field_name, False
)
client.set_checkbox(
task.id, task.list_id, cfg.clickup.delegate_field_name, False
)
db.log_run_finish(run_id, "completed", result="BLM pipeline done")
notify(cfg, "BLM done: %s" % keyword, "Task %s completed" % task.id)
log.info("BLM pipeline completed for task %s (keyword=%s)", task.id, keyword)
def _download_attachments( def _download_attachments(
client: ClickUpClient, client: ClickUpClient,
task: ClickUpTask, task: ClickUpTask,

View File

@ -0,0 +1,262 @@
"""Big-Link-Man CLI runner.
Runs ingest-cora and generate-batch via BLM's own venv Python.
Ported from cheddahbot/tools/linkbuilding.py for headless use.
"""
from __future__ import annotations
import logging
import os
import re
import subprocess
from dataclasses import dataclass
from pathlib import Path
log = logging.getLogger(__name__)
@dataclass
class IngestResult:
"""Parsed output from ingest-cora."""
success: bool
project_id: str = ""
project_name: str = ""
main_keyword: str = ""
job_file: str = ""
error: str = ""
@dataclass
class GenerateResult:
"""Parsed output from generate-batch."""
success: bool
job_moved_to: str = ""
error: str = ""
def _resolve_venv_python(blm_dir: str) -> Path:
"""Find BLM's venv Python executable."""
venv_python = Path(blm_dir) / ".venv" / "Scripts" / "python.exe"
if not venv_python.exists():
# Fallback for Linux/Mac
venv_python = Path(blm_dir) / ".venv" / "bin" / "python"
if not venv_python.exists():
raise FileNotFoundError(
"No .venv found in %s. BLM must have its own venv." % blm_dir
)
return venv_python
def _run_blm(
args: list[str], blm_dir: str, timeout: int = 1800
) -> subprocess.CompletedProcess:
"""Run a BLM CLI command with credential injection."""
venv_python = _resolve_venv_python(blm_dir)
cmd = [str(venv_python), "main.py"] + args
# Inject credentials from env vars
username = os.getenv("BLM_USERNAME", "")
password = os.getenv("BLM_PASSWORD", "")
if username and "-u" not in args and "--username" not in args:
cmd.extend(["-u", username])
if password and "-p" not in args and "--password" not in args:
cmd.extend(["-p", password])
log.info("BLM command: %s (cwd=%s)", " ".join(cmd), blm_dir)
result = subprocess.run(
cmd,
cwd=blm_dir,
capture_output=True,
text=True,
timeout=timeout,
)
log.info("BLM exit code: %d", result.returncode)
if result.stdout:
log.debug("BLM stdout: %s", result.stdout[:2000])
if result.stderr:
log.debug("BLM stderr: %s", result.stderr[:2000])
return result
def find_cora_xlsx(keyword: str, cora_inbox: str) -> str | None:
"""Find the Cora xlsx in the inbox directory by keyword match.
Looks for files whose name (slugified) matches the keyword.
Returns the full path or None.
"""
inbox = Path(cora_inbox)
if not inbox.exists():
log.warning("Cora inbox not found: %s", cora_inbox)
return None
# Slugify keyword for matching: lowercase, spaces -> underscores
slug = keyword.lower().strip().replace(" ", "_")
slug = re.sub(r"[^a-z0-9_]", "", slug)
# Look for exact match first, then prefix match
for xlsx in sorted(inbox.glob("*.xlsx"), key=lambda p: p.stat().st_mtime, reverse=True):
name_lower = xlsx.stem.lower()
if name_lower == slug:
return str(xlsx)
# Prefix match (keyword slug is prefix of filename)
for xlsx in sorted(inbox.glob("*.xlsx"), key=lambda p: p.stat().st_mtime, reverse=True):
name_lower = xlsx.stem.lower()
if name_lower.startswith(slug):
return str(xlsx)
log.warning("No xlsx matching '%s' in %s", keyword, cora_inbox)
return None
def build_ingest_args(
xlsx_path: str,
project_name: str,
money_site_url: str = "",
branded_plus_ratio: str = "",
custom_anchors: str = "",
cli_flags: str = "",
) -> list[str]:
"""Build the ingest-cora CLI argument list."""
args = ["ingest-cora", "-f", xlsx_path, "-n", project_name]
if money_site_url:
args.extend(["-m", money_site_url])
if branded_plus_ratio:
try:
bp = float(branded_plus_ratio)
if bp != 0.7:
args.extend(["-bp", str(bp)])
except (ValueError, TypeError):
pass
if custom_anchors:
args.extend(["-a", custom_anchors])
if cli_flags:
args.extend(cli_flags.strip().split())
return args
def parse_ingest_output(stdout: str) -> IngestResult:
"""Parse ingest-cora stdout."""
result = IngestResult(success=False)
for line in stdout.splitlines():
line = line.strip()
m = re.match(r"^Success: Project '(.+)' created \(ID: (\d+)\)$", line)
if m:
result.project_name = m.group(1)
result.project_id = m.group(2)
result.success = True
continue
m = re.match(r"^Job file created: (.+)$", line)
if m:
result.job_file = m.group(1).strip()
continue
m = re.match(r"^Main Keyword: (.+)$", line)
if m:
result.main_keyword = m.group(1).strip()
continue
return result
def parse_generate_output(stdout: str) -> GenerateResult:
"""Parse generate-batch stdout."""
result = GenerateResult(success=False)
for line in stdout.splitlines():
line = line.strip()
m = re.match(r"^Job file moved to: (.+)$", line)
if m:
result.job_moved_to = m.group(1).strip()
result.success = True
continue
return result
def run_ingest(
xlsx_path: str,
keyword: str,
money_site_url: str,
blm_dir: str,
timeout: int = 1800,
branded_plus_ratio: str = "",
custom_anchors: str = "",
cli_flags: str = "",
) -> IngestResult:
"""Run ingest-cora and return parsed result."""
args = build_ingest_args(
xlsx_path=xlsx_path,
project_name=keyword,
money_site_url=money_site_url,
branded_plus_ratio=branded_plus_ratio,
custom_anchors=custom_anchors,
cli_flags=cli_flags,
)
try:
proc = _run_blm(args, blm_dir, timeout=timeout)
except subprocess.TimeoutExpired:
return IngestResult(
success=False,
error="ingest-cora timed out after %d seconds" % timeout,
)
except FileNotFoundError as e:
return IngestResult(success=False, error=str(e))
if proc.returncode != 0:
return IngestResult(
success=False,
error="ingest-cora failed (exit code %d).\nstdout: %s\nstderr: %s"
% (proc.returncode, proc.stdout[-500:], proc.stderr[-500:]),
)
parsed = parse_ingest_output(proc.stdout)
if not parsed.job_file:
return IngestResult(
success=False,
error="ingest-cora produced no job file.\nstdout: %s" % proc.stdout[-500:],
)
return parsed
def run_generate(
job_file: str,
blm_dir: str,
timeout: int = 1800,
) -> GenerateResult:
"""Run generate-batch and return parsed result."""
job_path = Path(blm_dir) / job_file if not Path(job_file).is_absolute() else Path(job_file)
args = ["generate-batch", "-j", str(job_path), "--continue-on-error"]
try:
proc = _run_blm(args, blm_dir, timeout=timeout)
except subprocess.TimeoutExpired:
return GenerateResult(
success=False,
error="generate-batch timed out after %d seconds" % timeout,
)
except FileNotFoundError as e:
return GenerateResult(success=False, error=str(e))
if proc.returncode != 0:
return GenerateResult(
success=False,
error="generate-batch failed (exit code %d).\nstdout: %s\nstderr: %s"
% (proc.returncode, proc.stdout[-500:], proc.stderr[-500:]),
)
return parse_generate_output(proc.stdout)

View File

@ -73,6 +73,27 @@ def build_prompt(
if task.url: if task.url:
ctx_lines.append("ClickUp Task: %s" % task.url) ctx_lines.append("ClickUp Task: %s" % task.url)
# Task-type-specific fields
keyword = task.get_field_value("Keyword")
if keyword:
ctx_lines.append("Keyword: %s" % keyword)
cli_flags = task.get_field_value("CLIFlags")
if cli_flags:
ctx_lines.append("CLIFlags: %s" % cli_flags)
bp_ratio = task.get_field_value("BrandedPlusRatio")
if bp_ratio:
ctx_lines.append("BrandedPlusRatio: %s" % bp_ratio)
custom_anchors = task.get_field_value("CustomAnchors")
if custom_anchors:
ctx_lines.append("CustomAnchors: %s" % custom_anchors)
pr_topic = task.get_field_value("PR Topic")
if pr_topic:
ctx_lines.append("PR Topic: %s" % pr_topic)
if task.description: if task.description:
ctx_lines.append("") ctx_lines.append("")
ctx_lines.append("### Description") ctx_lines.append("### Description")

View File

@ -40,6 +40,13 @@ class AutoCoraConfig:
poll_interval_seconds: int = 120 poll_interval_seconds: int = 120
@dataclass
class BLMConfig:
blm_dir: str = "E:/dev/Big-Link-Man"
cora_inbox: str = "//PennQnap1/SHARE1/cora-inbox"
timeout_seconds: int = 1800 # 30 minutes
@dataclass @dataclass
class NASConfig: class NASConfig:
generated_dir: str = "//PennQnap1/SHARE1/generated" generated_dir: str = "//PennQnap1/SHARE1/generated"
@ -65,6 +72,7 @@ class NtfyConfig:
class Config: class Config:
clickup: ClickUpConfig = field(default_factory=ClickUpConfig) clickup: ClickUpConfig = field(default_factory=ClickUpConfig)
autocora: AutoCoraConfig = field(default_factory=AutoCoraConfig) autocora: AutoCoraConfig = field(default_factory=AutoCoraConfig)
blm: BLMConfig = field(default_factory=BLMConfig)
nas: NASConfig = field(default_factory=NASConfig) nas: NASConfig = field(default_factory=NASConfig)
runner: RunnerConfig = field(default_factory=RunnerConfig) runner: RunnerConfig = field(default_factory=RunnerConfig)
ntfy: NtfyConfig = field(default_factory=NtfyConfig) ntfy: NtfyConfig = field(default_factory=NtfyConfig)
@ -93,7 +101,7 @@ def load_config(yaml_path: Path | None = None) -> Config:
with open(yaml_path) as f: with open(yaml_path) as f:
data = yaml.safe_load(f) or {} data = yaml.safe_load(f) or {}
for section_name in ("clickup", "autocora", "nas", "runner", "ntfy"): for section_name in ("clickup", "autocora", "blm", "nas", "runner", "ntfy"):
if section_name in data and isinstance(data[section_name], dict): if section_name in data and isinstance(data[section_name], dict):
_apply_section(getattr(cfg, section_name), data[section_name]) _apply_section(getattr(cfg, section_name), data[section_name])

View File

@ -96,11 +96,9 @@ SKILL_MAP: dict[str, dict[str, SkillRoute]] = {
next_status="review", next_status="review",
), ),
"build": SkillRoute( "build": SkillRoute(
skill_file="linkbuilding.md", handler="blm",
next_stage="final", next_stage="final",
next_status="review", next_status="review",
tools=_LINK_TOOLS,
max_turns=15,
), ),
}, },
} }

View File

@ -0,0 +1,69 @@
# Link Building -- Build Stage
Run the Big-Link-Man (BLM) CLI to ingest a Cora report and generate tiered backlink content.
## What You Have
- A Cora `.xlsx` report on the NAS at `//PennQnap1/SHARE1/Cora72-for-macro/`
- The task's keyword, target URL, and optional CLI flags in the Task Context below
## Steps
### 1. Find the Cora .xlsx File
Search `//PennQnap1/SHARE1/Cora72-for-macro/` for a file matching the task keyword. The filename is a slugified version of the keyword (e.g., "gearbox oil sight glass" -> `gearbox_oil_sight_glass.xlsx`). Use glob/ls to find it. If multiple matches exist, pick the most recent.
### 2. Run ingest-cora
Run the BLM CLI using its own venv Python (NOT the system Python or CheddahBot's venv):
```bash
E:/dev/Big-Link-Man/.venv/Scripts/python.exe main.py ingest-cora \
-f "<path_to_xlsx>" \
-n "<keyword>" \
-m "<target_url>" \
<additional_cli_flags>
```
- Working directory: `E:/dev/Big-Link-Man`
- Always pass `-m` with the target URL (prevents interactive prompts)
- If BrandedPlusRatio is provided and not 0.7, add `-bp <value>`
- If CustomAnchors is provided, add `-a "<anchors>"`
- If CLIFlags is provided, append them as-is (e.g. `--tier1-count 6`)
- BLM credentials are in env vars `BLM_USERNAME` and `BLM_PASSWORD` -- pass them as `-u` and `-p`
**Expected output:** Look for lines like:
- `Success: Project 'name' created (ID: 42)`
- `Job file created: jobs/some-file.json`
If exit code is non-zero, stop and report the error including stdout and stderr.
### 3. Run generate-batch
Using the job file path from step 2:
```bash
E:/dev/Big-Link-Man/.venv/Scripts/python.exe main.py generate-batch \
-j "<job_file_path>" \
--continue-on-error
```
- Working directory: `E:/dev/Big-Link-Man`
- Always include `--continue-on-error`
- Pass `-u` and `-p` credentials from env vars
- This step can take several minutes
If exit code is non-zero, stop and report the error.
### 4. Collect Output
After generate-batch completes, the generated content files are in Big-Link-Man's output directory. Copy the relevant output files to the current working directory so they get uploaded to ClickUp.
Look in `E:/dev/Big-Link-Man/output/` for the project folder matching the keyword. Copy all `.md`, `.txt`, and `.html` files from there to the current working directory.
## Important
- NEVER use `uv run` or CheddahBot's Python -- always use BLM's venv at `E:/dev/Big-Link-Man/.venv/Scripts/python.exe`
- If the venv doesn't exist, stop and report the error
- Do not modify any BLM source code
- Do not create subdirectories in the working directory

View File

@ -64,8 +64,7 @@ class TestGetRoute:
def test_link_building_build(self): def test_link_building_build(self):
route = get_route("Link Building", "build") route = get_route("Link Building", "build")
assert route is not None assert route is not None
assert route.handler == "claude" assert route.handler == "blm"
assert route.skill_file == "linkbuilding.md"
def test_unknown_task_type_returns_none(self): def test_unknown_task_type_returns_none(self):
assert get_route("Banana Farming", "draft") is None assert get_route("Banana Farming", "draft") is None