Add combined Cora Reports endpoint with keyword deduplication

New /api/tasks/need-cora endpoint pulls tasks needing Cora reports
across Link Building, On Page Optimization, and Content Creation work
categories, deduplicates by Keyword field, and filters to a 30-day
window. Dashboard overview now shows Cora Reports as the first section
with color-coded type badges (LB, OPO, Content).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
cora-start
PeninsulaInd 2026-02-23 21:17:50 -06:00
parent 0b3ab904de
commit ffa8ad49e5
2 changed files with 226 additions and 24 deletions

View File

@ -21,6 +21,7 @@ if TYPE_CHECKING:
from .agent_registry import AgentRegistry
from .config import Config
from .db import Database
from .scheduler import Scheduler
log = logging.getLogger(__name__)
@ -30,6 +31,7 @@ router = APIRouter(prefix="/api")
_config: Config | None = None
_db: Database | None = None
_registry: AgentRegistry | None = None
_scheduler: Scheduler | None = None
# Simple in-memory cache for ClickUp data
_cache: dict[str, dict] = {}
@ -38,13 +40,17 @@ CACHE_TTL = 300 # 5 minutes
def create_api_router(
config: Config, db: Database, registry: AgentRegistry
config: Config,
db: Database,
registry: AgentRegistry,
scheduler: Scheduler | None = None,
) -> APIRouter:
"""Wire dependencies and return the router."""
global _config, _db, _registry
global _config, _db, _registry, _scheduler
_config = config
_db = db
_registry = registry
_scheduler = scheduler
return router
@ -251,6 +257,96 @@ async def get_link_building_tasks():
return result
@router.get("/tasks/need-cora")
async def get_need_cora_tasks():
"""Tasks that need a Cora report, deduplicated by keyword.
Includes work categories: Link Building (Cora Backlinks), On Page
Optimization, and Content Creation. Tasks sharing the same Keyword
field are grouped into a single entry.
"""
cached = _get_cached("need_cora")
if cached is not None:
return cached
if not _config or not _config.clickup.enabled:
return {"count": 0, "keywords": []}
# Reuse the cached /tasks data if available, otherwise fetch fresh
tasks_data = await get_tasks()
all_tasks = tasks_data.get("tasks", [])
automation_touched = {
"error", "automation underway", "complete",
"closed", "done", "internal review",
}
# Work categories that need Cora reports
cora_categories = {"Link Building", "On Page Optimization", "Content Creation"}
# Only include tasks due within 30 days behind / 30 days ahead
now_ms = time.time() * 1000
window_ms = 30 * 86400 * 1000
due_min = now_ms - window_ms
due_max = now_ms + window_ms
qualifying = []
for t in all_tasks:
if t["task_type"] not in cora_categories:
continue
if t["status"] in automation_touched:
continue
# Filter by due date window (skip tasks with no due date)
due = t.get("due_date")
if not due:
continue
try:
due_int = int(due)
except (ValueError, TypeError):
continue
if due_int < due_min or due_int > due_max:
continue
# Link Building additionally requires LB Method = "Cora Backlinks"
if t["task_type"] == "Link Building":
if t["custom_fields"].get("LB Method") != "Cora Backlinks":
continue
qualifying.append(t)
# Group by normalised Keyword
by_keyword: dict[str, dict] = {}
for t in qualifying:
kw = (t["custom_fields"].get("Keyword") or t["name"]).strip()
kw_lower = kw.lower()
if kw_lower not in by_keyword:
by_keyword[kw_lower] = {
"keyword": kw,
"company": t["custom_fields"].get("Customer") or "Unassigned",
"due_date": t.get("due_date"),
"tasks": [],
}
by_keyword[kw_lower]["tasks"].append({
"id": t["id"],
"name": t["name"],
"task_type": t["task_type"],
"status": t["status"],
"url": t.get("url", ""),
})
# Keep the earliest due date across grouped tasks
existing_due = by_keyword[kw_lower]["due_date"]
task_due = t.get("due_date")
if task_due and (not existing_due or int(task_due) < int(existing_due)):
by_keyword[kw_lower]["due_date"] = task_due
keywords = sorted(
by_keyword.values(),
key=lambda k: int(k.get("due_date") or "9999999999999"),
)
result = {"count": len(keywords), "keywords": keywords}
_set_cached("need_cora", result)
return result
@router.get("/tasks/press-releases")
async def get_press_release_tasks():
"""Press release tasks with KV state merged in."""
@ -448,6 +544,24 @@ async def get_kv_states():
return {"states": states}
@router.get("/system/loops")
async def get_loop_timestamps():
"""Last-run timestamps for scheduler loops."""
if not _scheduler:
return {"loops": {}, "error": "Scheduler not available"}
return {"loops": _scheduler.get_loop_timestamps()}
@router.post("/system/loops/force")
async def force_loop_run():
"""Force the heartbeat and poll loops to run immediately."""
if not _scheduler:
return {"status": "error", "message": "Scheduler not available"}
_scheduler.force_heartbeat()
_scheduler.force_poll()
return {"status": "ok", "message": "Force pulse sent to heartbeat and poll loops"}
@router.post("/cache/clear")
async def clear_cache():
"""Clear the ClickUp data cache."""

View File

@ -125,6 +125,18 @@
</div>
</div>
<!-- Cora Reports Needed -->
<div class="section section--tight">
<div class="section__header">
<h2 class="section__title"><span class="icon">&#128203;</span> Cora Reports Needed</h2>
<span onclick="navigator.clipboard.writeText('Z:\\cora-inbox');this.textContent='Copied!';setTimeout(()=>this.textContent='Z:\\cora-inbox',1500)" style="font-size:0.75rem;color:var(--gold-light);cursor:pointer;margin-left:0.5rem;" title="Click to copy path">Z:\cora-inbox</span>
<span class="section__badge"><a href="#" class="section__link" data-tab="linkbuilding">View LB</a></span>
</div>
<div class="task-table-wrap task-table-wrap--compact" id="overview-cora">
<p style="padding:1rem;color:var(--text-muted);">Loading...</p>
</div>
</div>
<!-- Up Next (today/tomorrow, or next 5 by due date) -->
<div class="section section--tight">
<div class="section__header">
@ -147,18 +159,6 @@
</div>
</div>
<!-- Cora Reports Needed -->
<div class="section section--tight">
<div class="section__header">
<h2 class="section__title"><span class="icon">&#128203;</span> Cora Reports Needed</h2>
<span onclick="navigator.clipboard.writeText('Z:\\cora-inbox');this.textContent='Copied!';setTimeout(()=>this.textContent='Z:\\cora-inbox',1500)" style="font-size:0.75rem;color:var(--gold-light);cursor:pointer;margin-left:0.5rem;" title="Click to copy path">Z:\cora-inbox</span>
<span class="section__badge"><a href="#" class="section__link" data-tab="linkbuilding">View LB</a></span>
</div>
<div class="task-table-wrap task-table-wrap--compact" id="overview-cora">
<p style="padding:1rem;color:var(--text-muted);">Loading...</p>
</div>
</div>
<!-- System Health Inline -->
<div class="section section--tight" id="briefing-health">
<div class="health-inline" id="overview-health">
@ -472,12 +472,12 @@ async function loadOverview() {
document.getElementById('this-month-count').textContent = thisMonth.length;
renderOverviewTable('overview-this-month', thisMonth, true);
// -- Cora Reports Needed: LB tasks with LB Method = Cora Backlinks --
const needCora = openTasks.filter(t =>
t.task_type === 'Link Building'
&& t.custom_fields?.['LB Method'] === 'Cora Backlinks'
);
renderOverviewTable('overview-cora', needCora, false);
// -- Cora Reports Needed: fetch from dedicated endpoint (all work categories, deduplicated) --
fetchJSON('/tasks/need-cora').then(coraData => {
if (coraData && coraData.keywords) {
renderCoraKeywords('overview-cora', coraData.keywords);
}
});
}
// Health inline
@ -616,6 +616,91 @@ function renderCoraQueue(containerId, tasks) {
render();
}
function renderCoraKeywords(containerId, keywords) {
const container = document.getElementById(containerId);
if (!keywords || keywords.length === 0) {
container.innerHTML = '<p style="padding:1rem;color:var(--text-muted);">No Cora reports needed.</p>';
return;
}
const PAGE_SIZE = 10;
let showAll = false;
const TYPE_BADGES = {
'Link Building': { label: 'LB', color: '#3b82f6' },
'On Page Optimization': { label: 'OPO', color: '#f59e0b' },
'Content Creation': { label: 'Content', color: '#10b981' },
};
function render() {
const visible = showAll ? keywords : keywords.slice(0, PAGE_SIZE);
let html = `<table class="task-table task-table--dense">
<thead><tr>
<th class="cora-hide-mobile">#</th>
<th>Keyword</th>
<th>Company</th>
<th>Types</th>
<th class="cora-hide-mobile">Due Date</th>
</tr></thead><tbody>`;
visible.forEach((k, i) => {
// Build type badges from the task list
const types = [...new Set(k.tasks.map(t => t.task_type))];
const badges = types.map(tp => {
const b = TYPE_BADGES[tp] || { label: tp, color: '#888' };
return `<span style="display:inline-block;padding:0.15rem 0.4rem;border-radius:3px;font-size:0.65rem;font-weight:600;background:${b.color}20;color:${b.color};border:1px solid ${b.color}40;margin-right:0.25rem;">${b.label}</span>`;
}).join('');
// Link the keyword to the first task's ClickUp URL
const firstUrl = k.tasks.find(t => t.url)?.url;
const kwHtml = firstUrl
? `<a href="${esc(firstUrl)}" target="_blank" style="color:var(--cream-light);text-decoration:none;">${esc(k.keyword)}</a>`
: esc(k.keyword);
let dueStr = '-';
if (k.due_date) {
const d = new Date(parseInt(k.due_date, 10));
dueStr = d.toLocaleDateString('en-US', {month:'short', day:'numeric'});
}
// Show task count if more than 1 task shares this keyword
const countNote = k.tasks.length > 1
? ` <span style="font-size:0.65rem;color:var(--text-muted);">(${k.tasks.length} tasks)</span>`
: '';
html += `<tr>
<td class="task-table__num cora-hide-mobile">${i + 1}</td>
<td class="task-table__keyword">${kwHtml}${countNote}</td>
<td class="task-table__company">${esc(k.company)}</td>
<td>${badges}</td>
<td class="cora-hide-mobile" style="white-space:nowrap;">${esc(dueStr)}</td>
</tr>`;
});
html += '</tbody></table>';
if (keywords.length > PAGE_SIZE) {
const remaining = keywords.length - PAGE_SIZE;
const label = showAll ? 'Show less' : `Show more (${remaining} remaining)`;
html += `<div style="text-align:center;padding:0.75rem;">
<button id="cora-kw-toggle-btn" style="background:none;border:1px solid var(--border);color:var(--gold-light);padding:0.4rem 1.2rem;border-radius:var(--radius);cursor:pointer;font-size:0.8rem;">${label}</button>
</div>`;
}
container.innerHTML = html;
const btn = document.getElementById('cora-kw-toggle-btn');
if (btn) {
btn.addEventListener('click', () => {
showAll = !showAll;
render();
});
}
}
render();
}
function renderOverviewTable(containerId, tasks, showDueDate) {
const container = document.getElementById(containerId);
if (!tasks || tasks.length === 0) {
@ -733,10 +818,13 @@ async function loadLinkBuilding() {
`;
document.getElementById('lb-stats').innerHTML = statsHtml;
// Cora Reports to Run
const coraQueue = data.need_cora || [];
document.getElementById('lb-cora-count').textContent = coraQueue.length;
renderCoraQueue('lb-cora-table', coraQueue);
// Cora Reports to Run (combined view from dedicated endpoint)
fetchJSON('/tasks/need-cora').then(coraData => {
if (coraData && coraData.keywords) {
document.getElementById('lb-cora-count').textContent = coraData.count || 0;
renderCoraKeywords('lb-cora-table', coraData.keywords);
}
});
// Recently Completed
const recent = data.recently_completed || [];