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
parent
e51570b804
commit
f4e642240c
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue