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
parent
0b3ab904de
commit
ffa8ad49e5
|
|
@ -21,6 +21,7 @@ if TYPE_CHECKING:
|
||||||
from .agent_registry import AgentRegistry
|
from .agent_registry import AgentRegistry
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from .db import Database
|
from .db import Database
|
||||||
|
from .scheduler import Scheduler
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -30,6 +31,7 @@ router = APIRouter(prefix="/api")
|
||||||
_config: Config | None = None
|
_config: Config | None = None
|
||||||
_db: Database | None = None
|
_db: Database | None = None
|
||||||
_registry: AgentRegistry | None = None
|
_registry: AgentRegistry | None = None
|
||||||
|
_scheduler: Scheduler | None = None
|
||||||
|
|
||||||
# Simple in-memory cache for ClickUp data
|
# Simple in-memory cache for ClickUp data
|
||||||
_cache: dict[str, dict] = {}
|
_cache: dict[str, dict] = {}
|
||||||
|
|
@ -38,13 +40,17 @@ CACHE_TTL = 300 # 5 minutes
|
||||||
|
|
||||||
|
|
||||||
def create_api_router(
|
def create_api_router(
|
||||||
config: Config, db: Database, registry: AgentRegistry
|
config: Config,
|
||||||
|
db: Database,
|
||||||
|
registry: AgentRegistry,
|
||||||
|
scheduler: Scheduler | None = None,
|
||||||
) -> APIRouter:
|
) -> APIRouter:
|
||||||
"""Wire dependencies and return the router."""
|
"""Wire dependencies and return the router."""
|
||||||
global _config, _db, _registry
|
global _config, _db, _registry, _scheduler
|
||||||
_config = config
|
_config = config
|
||||||
_db = db
|
_db = db
|
||||||
_registry = registry
|
_registry = registry
|
||||||
|
_scheduler = scheduler
|
||||||
return router
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -251,6 +257,96 @@ async def get_link_building_tasks():
|
||||||
return result
|
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")
|
@router.get("/tasks/press-releases")
|
||||||
async def get_press_release_tasks():
|
async def get_press_release_tasks():
|
||||||
"""Press release tasks with KV state merged in."""
|
"""Press release tasks with KV state merged in."""
|
||||||
|
|
@ -448,6 +544,24 @@ async def get_kv_states():
|
||||||
return {"states": 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")
|
@router.post("/cache/clear")
|
||||||
async def clear_cache():
|
async def clear_cache():
|
||||||
"""Clear the ClickUp data cache."""
|
"""Clear the ClickUp data cache."""
|
||||||
|
|
|
||||||
|
|
@ -125,6 +125,18 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Cora Reports Needed -->
|
||||||
|
<div class="section section--tight">
|
||||||
|
<div class="section__header">
|
||||||
|
<h2 class="section__title"><span class="icon">📋</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) -->
|
<!-- Up Next (today/tomorrow, or next 5 by due date) -->
|
||||||
<div class="section section--tight">
|
<div class="section section--tight">
|
||||||
<div class="section__header">
|
<div class="section__header">
|
||||||
|
|
@ -147,18 +159,6 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Cora Reports Needed -->
|
|
||||||
<div class="section section--tight">
|
|
||||||
<div class="section__header">
|
|
||||||
<h2 class="section__title"><span class="icon">📋</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 -->
|
<!-- System Health Inline -->
|
||||||
<div class="section section--tight" id="briefing-health">
|
<div class="section section--tight" id="briefing-health">
|
||||||
<div class="health-inline" id="overview-health">
|
<div class="health-inline" id="overview-health">
|
||||||
|
|
@ -472,12 +472,12 @@ async function loadOverview() {
|
||||||
document.getElementById('this-month-count').textContent = thisMonth.length;
|
document.getElementById('this-month-count').textContent = thisMonth.length;
|
||||||
renderOverviewTable('overview-this-month', thisMonth, true);
|
renderOverviewTable('overview-this-month', thisMonth, true);
|
||||||
|
|
||||||
// -- Cora Reports Needed: LB tasks with LB Method = Cora Backlinks --
|
// -- Cora Reports Needed: fetch from dedicated endpoint (all work categories, deduplicated) --
|
||||||
const needCora = openTasks.filter(t =>
|
fetchJSON('/tasks/need-cora').then(coraData => {
|
||||||
t.task_type === 'Link Building'
|
if (coraData && coraData.keywords) {
|
||||||
&& t.custom_fields?.['LB Method'] === 'Cora Backlinks'
|
renderCoraKeywords('overview-cora', coraData.keywords);
|
||||||
);
|
}
|
||||||
renderOverviewTable('overview-cora', needCora, false);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Health inline
|
// Health inline
|
||||||
|
|
@ -616,6 +616,91 @@ function renderCoraQueue(containerId, tasks) {
|
||||||
render();
|
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) {
|
function renderOverviewTable(containerId, tasks, showDueDate) {
|
||||||
const container = document.getElementById(containerId);
|
const container = document.getElementById(containerId);
|
||||||
if (!tasks || tasks.length === 0) {
|
if (!tasks || tasks.length === 0) {
|
||||||
|
|
@ -733,10 +818,13 @@ async function loadLinkBuilding() {
|
||||||
`;
|
`;
|
||||||
document.getElementById('lb-stats').innerHTML = statsHtml;
|
document.getElementById('lb-stats').innerHTML = statsHtml;
|
||||||
|
|
||||||
// Cora Reports to Run
|
// Cora Reports to Run (combined view from dedicated endpoint)
|
||||||
const coraQueue = data.need_cora || [];
|
fetchJSON('/tasks/need-cora').then(coraData => {
|
||||||
document.getElementById('lb-cora-count').textContent = coraQueue.length;
|
if (coraData && coraData.keywords) {
|
||||||
renderCoraQueue('lb-cora-table', coraQueue);
|
document.getElementById('lb-cora-count').textContent = coraData.count || 0;
|
||||||
|
renderCoraKeywords('lb-cora-table', coraData.keywords);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Recently Completed
|
// Recently Completed
|
||||||
const recent = data.recently_completed || [];
|
const recent = data.recently_completed || [];
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue