diff --git a/clickup_runner/__main__.py b/clickup_runner/__main__.py index 0e2780b..30495a5 100644 --- a/clickup_runner/__main__.py +++ b/clickup_runner/__main__.py @@ -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( diff --git a/clickup_runner/claude_runner.py b/clickup_runner/claude_runner.py index 8aa5bf3..8fef05c 100644 --- a/clickup_runner/claude_runner.py +++ b/clickup_runner/claude_runner.py @@ -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: diff --git a/clickup_runner/clickup_client.py b/clickup_runner/clickup_client.py index 246de4a..15381f0 100644 --- a/clickup_runner/clickup_client.py +++ b/clickup_runner/clickup_client.py @@ -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: diff --git a/skills/content_draft.md b/skills/content_draft.md index 1b0884e..e4a3830 100644 --- a/skills/content_draft.md +++ b/skills/content_draft.md @@ -82,34 +82,14 @@ After the main content, write the FOQ section from the outline: - Mark the section: `` and `` - 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