Download ClickUp attachments to work dir before running Claude
- Added ClickUpClient.download_attachment() for fetching attachment files - Runner now downloads all task attachments to the temp work dir before dispatching to claude -p. Claude reads local files instead of URLs. - build_prompt() now lists local filenames, not ClickUp attachment URLs - _collect_output_files() excludes pre-existing attachments so they don't get re-uploaded back to ClickUp - Removed optimizer script steps from content_draft.md -- the outline stage already handles Cora parsing, draft just follows the outline Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>clickup-runner
parent
7679467bf8
commit
bba634a63e
|
|
@ -9,8 +9,10 @@ from __future__ import annotations
|
|||
import logging
|
||||
import signal
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from .autocora import archive_result, scan_results, submit_job
|
||||
from .claude_runner import (
|
||||
|
|
@ -436,6 +438,37 @@ def _dispatch_autocora(
|
|||
)
|
||||
|
||||
|
||||
def _download_attachments(
|
||||
client: ClickUpClient,
|
||||
task: ClickUpTask,
|
||||
work_dir: Path,
|
||||
) -> list[str]:
|
||||
"""Download all task attachments to the work directory.
|
||||
|
||||
Returns list of local filenames (not full paths) that were downloaded.
|
||||
"""
|
||||
downloaded = []
|
||||
for att in task.attachments:
|
||||
title = att.get("title", "")
|
||||
url = att.get("url", "")
|
||||
if not title or not url:
|
||||
continue
|
||||
|
||||
dest = work_dir / title
|
||||
if client.download_attachment(url, dest):
|
||||
downloaded.append(title)
|
||||
else:
|
||||
log.warning("Skipping attachment %s -- download failed", title)
|
||||
|
||||
if downloaded:
|
||||
log.info(
|
||||
"Downloaded %d attachment(s) to %s: %s",
|
||||
len(downloaded), work_dir, ", ".join(downloaded),
|
||||
)
|
||||
|
||||
return downloaded
|
||||
|
||||
|
||||
def _dispatch_claude(
|
||||
client: ClickUpClient,
|
||||
cfg: Config,
|
||||
|
|
@ -460,20 +493,20 @@ def _dispatch_claude(
|
|||
)
|
||||
return
|
||||
|
||||
# 3. Gather .xlsx attachment URLs for the prompt
|
||||
xlsx_urls = [
|
||||
a.get("url", "")
|
||||
for a in task.attachments
|
||||
if a.get("title", "").lower().endswith(".xlsx")
|
||||
or a.get("url", "").lower().endswith(".xlsx")
|
||||
]
|
||||
# 3. Create work dir and download all attachments into it
|
||||
work_dir = Path(tempfile.mkdtemp(prefix="clickup_runner_"))
|
||||
downloaded_files = _download_attachments(client, task, work_dir)
|
||||
|
||||
# 4. Build prompt
|
||||
prompt = build_prompt(task, route, skill_content, xlsx_urls or None)
|
||||
# 4. Build prompt (reference local filenames, not URLs)
|
||||
prompt = build_prompt(task, route, skill_content, downloaded_files)
|
||||
|
||||
# 5. Run Claude
|
||||
log.info("Starting Claude for task %s (%s)", task.id, task.name)
|
||||
result = run_claude(prompt, route, cfg)
|
||||
result = run_claude(
|
||||
prompt, route, cfg,
|
||||
work_dir=work_dir,
|
||||
exclude_files=set(downloaded_files),
|
||||
)
|
||||
|
||||
if not result.success:
|
||||
_handle_dispatch_error(
|
||||
|
|
|
|||
|
|
@ -36,13 +36,17 @@ def build_prompt(
|
|||
task: ClickUpTask,
|
||||
route: SkillRoute,
|
||||
skill_content: str,
|
||||
xlsx_urls: list[str] | None = None,
|
||||
attachment_filenames: list[str] | None = None,
|
||||
) -> str:
|
||||
"""Assemble the prompt sent to `claude -p`.
|
||||
|
||||
Structure:
|
||||
1. Skill file content (system-level instructions)
|
||||
2. Task context block (name, description, customer, URL, attachments)
|
||||
|
||||
Args:
|
||||
attachment_filenames: Local filenames (not paths) of attachments
|
||||
already downloaded to the working directory.
|
||||
"""
|
||||
parts: list[str] = []
|
||||
|
||||
|
|
@ -74,11 +78,11 @@ def build_prompt(
|
|||
ctx_lines.append("### Description")
|
||||
ctx_lines.append(task.description.strip())
|
||||
|
||||
if xlsx_urls:
|
||||
if attachment_filenames:
|
||||
ctx_lines.append("")
|
||||
ctx_lines.append("### Attached Cora Reports (.xlsx)")
|
||||
for url in xlsx_urls:
|
||||
ctx_lines.append("- %s" % url)
|
||||
ctx_lines.append("### Attached Files (in working directory)")
|
||||
for fname in attachment_filenames:
|
||||
ctx_lines.append("- %s" % fname)
|
||||
|
||||
# Tell Claude where to write output
|
||||
ctx_lines.append("")
|
||||
|
|
@ -94,11 +98,20 @@ def build_prompt(
|
|||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
def _collect_output_files(work_dir: Path) -> list[Path]:
|
||||
"""Return all files Claude created in the working directory."""
|
||||
def _collect_output_files(
|
||||
work_dir: Path,
|
||||
exclude: set[str] | None = None,
|
||||
) -> list[Path]:
|
||||
"""Return all files Claude created in the working directory.
|
||||
|
||||
Args:
|
||||
exclude: Set of filenames to skip (e.g. downloaded attachments
|
||||
that were in the dir before Claude ran).
|
||||
"""
|
||||
if not work_dir.exists():
|
||||
return []
|
||||
files = [f for f in work_dir.iterdir() if f.is_file()]
|
||||
exclude = exclude or set()
|
||||
files = [f for f in work_dir.iterdir() if f.is_file() and f.name not in exclude]
|
||||
# Sort for deterministic ordering
|
||||
files.sort(key=lambda p: p.name)
|
||||
return files
|
||||
|
|
@ -109,6 +122,7 @@ def run_claude(
|
|||
route: SkillRoute,
|
||||
cfg: Config,
|
||||
work_dir: Path | None = None,
|
||||
exclude_files: set[str] | None = None,
|
||||
) -> RunResult:
|
||||
"""Run `claude -p` as a subprocess and return the result.
|
||||
|
||||
|
|
@ -118,6 +132,8 @@ def run_claude(
|
|||
cfg: Runner config (timeout, etc.).
|
||||
work_dir: Directory for Claude to write files into.
|
||||
If None, a temp directory is created.
|
||||
exclude_files: Filenames to exclude from output_files
|
||||
(e.g. pre-existing attachments).
|
||||
"""
|
||||
created_tmp = False
|
||||
if work_dir is None:
|
||||
|
|
@ -169,11 +185,11 @@ def run_claude(
|
|||
output=stdout,
|
||||
error="Claude exited with code %d: %s"
|
||||
% (result.returncode, stderr[:2000]),
|
||||
output_files=_collect_output_files(work_dir),
|
||||
output_files=_collect_output_files(work_dir, exclude_files),
|
||||
work_dir=work_dir,
|
||||
)
|
||||
|
||||
output_files = _collect_output_files(work_dir)
|
||||
output_files = _collect_output_files(work_dir, exclude_files)
|
||||
log.info(
|
||||
"Claude completed successfully. %d output file(s).", len(output_files)
|
||||
)
|
||||
|
|
@ -195,7 +211,7 @@ def run_claude(
|
|||
output="",
|
||||
error="Claude timed out after %d seconds"
|
||||
% cfg.runner.claude_timeout_seconds,
|
||||
output_files=_collect_output_files(work_dir),
|
||||
output_files=_collect_output_files(work_dir, exclude_files),
|
||||
work_dir=work_dir,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
|
|
|
|||
|
|
@ -272,6 +272,26 @@ class ClickUpClient:
|
|||
)
|
||||
return []
|
||||
|
||||
def download_attachment(self, url: str, dest: Path) -> bool:
|
||||
"""Download a ClickUp attachment to a local file.
|
||||
|
||||
ClickUp attachment URLs are pre-signed S3 URLs that don't need
|
||||
auth headers, so we use a plain httpx request (not the API client).
|
||||
|
||||
Returns True on success, False on failure.
|
||||
"""
|
||||
try:
|
||||
with httpx.stream("GET", url, follow_redirects=True, timeout=60.0) as resp:
|
||||
resp.raise_for_status()
|
||||
with open(dest, "wb") as f:
|
||||
for chunk in resp.iter_bytes(chunk_size=8192):
|
||||
f.write(chunk)
|
||||
log.info("Downloaded attachment to %s", dest)
|
||||
return True
|
||||
except Exception as e:
|
||||
log.warning("Failed to download attachment from %s: %s", url, e)
|
||||
return False
|
||||
|
||||
def get_custom_fields(self, list_id: str) -> list[dict]:
|
||||
"""Get custom field definitions for a list."""
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -82,34 +82,14 @@ After the main content, write the FOQ section from the outline:
|
|||
- Mark the section: `<!-- FOQ SECTION START -->` and `<!-- FOQ SECTION END -->`
|
||||
- FOQs are excluded from the main word count
|
||||
|
||||
## Step 4: Verify Against Cora
|
||||
## Step 4: Self-Check
|
||||
|
||||
Run the optimizer scripts against your draft to check coverage.
|
||||
Before writing the output file, review your own draft:
|
||||
|
||||
### 4a. Entity Check
|
||||
|
||||
```bash
|
||||
cd .claude/skills/content-researcher/scripts && uv run --with openpyxl python entity_optimizer.py "{draft_path}" "{cora_xlsx_path}" --format json --top-n 30
|
||||
```
|
||||
|
||||
Review the output:
|
||||
- Are all "Must mention" entities present? If any are missing, add them.
|
||||
- Are any entities at 0 that should have at least 1 mention? Fix.
|
||||
- Do NOT chase high deficit numbers for outlier entities.
|
||||
|
||||
### 4b. LSI Check
|
||||
|
||||
```bash
|
||||
cd .claude/skills/content-researcher/scripts && uv run --with openpyxl python lsi_optimizer.py "{draft_path}" "{cora_xlsx_path}" --format json --top-n 30
|
||||
```
|
||||
|
||||
Review the output:
|
||||
- Are the top 20 LSI terms from the outline present? If any key ones are missing, weave them in.
|
||||
- Do NOT force LSI terms where they don't fit.
|
||||
|
||||
### 4c. Word Count Check
|
||||
|
||||
Count the words in your draft (excluding FOQ section). Compare against the Cora target. If you're over by more than 10%, trim. If under by more than 10%, expand the thinnest sections.
|
||||
- Count words per section (excluding FOQ). Compare against the outline's per-section targets. If any section is over by more than 10%, trim. If under by more than 10%, expand.
|
||||
- Scan for each entity in the "Must mention" list from the Writer's Reference. If any are missing, add them.
|
||||
- Scan for the top LSI terms. If key ones are absent, weave them in where natural.
|
||||
- Re-read for keyword stuffing -- if any paragraph sounds forced, rewrite it.
|
||||
|
||||
## Step 5: Generate Meta Tags
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue