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 .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."""
|
||||
|
|
|
|||
|
|
@ -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">📋</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">📋</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 || [];
|
||||
|
|
|
|||
Loading…
Reference in New Issue