backboard-cmo 0.1.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- backboard_cmo-0.1.1/.cursorignore +3 -0
- backboard_cmo-0.1.1/.env.example +5 -0
- backboard_cmo-0.1.1/.gitignore +6 -0
- backboard_cmo-0.1.1/PKG-INFO +12 -0
- backboard_cmo-0.1.1/README.md +216 -0
- backboard_cmo-0.1.1/app/__init__.py +1 -0
- backboard_cmo-0.1.1/app/main.py +119 -0
- backboard_cmo-0.1.1/app/orchestrator.py +323 -0
- backboard_cmo-0.1.1/app/schemas.py +84 -0
- backboard_cmo-0.1.1/pyproject.toml +34 -0
- backboard_cmo-0.1.1/scripts/create_assistants.py +46 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: backboard-cmo
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: AI CMO Terminal — Backboard agents for traffic and users
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: backboard-sdk>=1.5.0
|
|
7
|
+
Requires-Dist: fastapi>=0.115.0
|
|
8
|
+
Requires-Dist: httpx>=0.27.0
|
|
9
|
+
Requires-Dist: pydantic>=2.0
|
|
10
|
+
Requires-Dist: python-dotenv>=1.0
|
|
11
|
+
Requires-Dist: sse-starlette>=2.0
|
|
12
|
+
Requires-Dist: uvicorn[standard]>=0.32.0
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="web/public/openokara.png" alt="Backboard CMO" width="480" />
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<h1 align="center">Backboard CMO</h1>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
<strong>Drop in a URL. Get a Chief Marketing Officer.</strong>
|
|
9
|
+
<br />
|
|
10
|
+
AI agents that audit your site, map your competitors, and find your brand voice — in seconds.
|
|
11
|
+
</p>
|
|
12
|
+
|
|
13
|
+
<p align="center">
|
|
14
|
+
<a href="https://backboard.io"><img src="https://img.shields.io/badge/Powered%20by-Backboard.io-6366f1?style=flat-square" alt="Powered by Backboard" /></a>
|
|
15
|
+
<img src="https://img.shields.io/badge/Python-3.11%2B-3776AB?style=flat-square&logo=python&logoColor=white" alt="Python 3.11+" />
|
|
16
|
+
<img src="https://img.shields.io/badge/Next.js-15-black?style=flat-square&logo=next.js" alt="Next.js 15" />
|
|
17
|
+
<img src="https://img.shields.io/badge/FastAPI-0.115-009688?style=flat-square&logo=fastapi&logoColor=white" alt="FastAPI" />
|
|
18
|
+
<img src="https://img.shields.io/badge/license-MIT-brightgreen?style=flat-square" alt="MIT License" />
|
|
19
|
+
</p>
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## What is this?
|
|
24
|
+
|
|
25
|
+
Hiring a CMO costs $200k/year. Good ones take months to ramp up.
|
|
26
|
+
|
|
27
|
+
**Backboard CMO does it in 60 seconds.**
|
|
28
|
+
|
|
29
|
+
Type in any URL. Four AI agents fire up in parallel — they read your site, search the web, and hand back:
|
|
30
|
+
|
|
31
|
+
- A **site audit** with real scores (performance, SEO, accessibility, best practices)
|
|
32
|
+
- A **competitor landscape** with categories and pricing
|
|
33
|
+
- A **brand voice profile** so your copy sounds like *you*
|
|
34
|
+
- A ranked list of **SEO fixes** with step-by-step instructions
|
|
35
|
+
- An **AI chat** interface so you can keep drilling into next steps
|
|
36
|
+
|
|
37
|
+
No prompt engineering. No setup. Just a URL.
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## Demo
|
|
42
|
+
|
|
43
|
+
> Type a URL → watch the terminal run → read your marketing brief
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
> Checking what content and documents you have...
|
|
47
|
+
> Content and documents summarized.
|
|
48
|
+
> Now let me check out your competition...
|
|
49
|
+
> Searching: yoursite.com competitors alternative
|
|
50
|
+
> Evaluating competitor's positioning strategy...
|
|
51
|
+
> Competitor analysis complete.
|
|
52
|
+
> Now let me figure out your brand voice...
|
|
53
|
+
> Brand voice guide ready
|
|
54
|
+
> Running website audit...
|
|
55
|
+
> Scanning page structure and metadata...
|
|
56
|
+
> Page speed and core web vitals measured
|
|
57
|
+
> Found 7 SEO optimization opportunities (score: 64/100)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## Features
|
|
63
|
+
|
|
64
|
+
| | |
|
|
65
|
+
|---|---|
|
|
66
|
+
| **Website Audit** | Performance, SEO, Accessibility, and Best Practices scores with specific failing checks and how-to-fix guides |
|
|
67
|
+
| **Competitor Intel** | Direct vs. secondary competitors with pricing — sourced live from the web |
|
|
68
|
+
| **Brand Voice** | 2–3 sentence brand profile your team can actually use |
|
|
69
|
+
| **SEO Fix Queue** | Ranked issues (Critical → High → Medium) with numbered remediation steps |
|
|
70
|
+
| **AI CMO Chat** | Ask follow-up questions about your audit — it remembers context |
|
|
71
|
+
| **Live Terminal** | Real-time SSE stream so you see agents working, not a spinner |
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## Stack
|
|
76
|
+
|
|
77
|
+
| Layer | Tech |
|
|
78
|
+
|---|---|
|
|
79
|
+
| AI Agents | [Backboard.io](https://backboard.io) — threads, assistants, web search |
|
|
80
|
+
| API | FastAPI + Uvicorn, Server-Sent Events |
|
|
81
|
+
| Frontend | Next.js 15, Tailwind CSS |
|
|
82
|
+
| Runtime | Python 3.11+ via `uv`, Node 18+ |
|
|
83
|
+
| Schemas | Pydantic v2 |
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## Quick Start
|
|
88
|
+
|
|
89
|
+
### 1. Clone & install
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
git clone https://github.com/Backboard-io/openokara.git
|
|
93
|
+
cd backboard-cmo
|
|
94
|
+
|
|
95
|
+
# Python deps
|
|
96
|
+
uv sync
|
|
97
|
+
|
|
98
|
+
# Frontend deps
|
|
99
|
+
cd web && npm install && cd ..
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### 2. Configure `.env`
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
cp .env.example .env
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
```env
|
|
109
|
+
BACKBOARD_API_KEY=your_key_here
|
|
110
|
+
|
|
111
|
+
# Assistant IDs — created once, reused forever (never delete these)
|
|
112
|
+
BACKBOARD_ASSISTANT_CONTENT=asst_...
|
|
113
|
+
BACKBOARD_ASSISTANT_COMPETITOR=asst_...
|
|
114
|
+
BACKBOARD_ASSISTANT_BRAND=asst_...
|
|
115
|
+
BACKBOARD_ASSISTANT_AUDIT=asst_...
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
> Get a free Backboard API key at [backboard.io](https://backboard.io). Create your four assistants once and paste the IDs here.
|
|
119
|
+
|
|
120
|
+
### 3. Run
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
./start.sh
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
API → `http://localhost:8000`
|
|
127
|
+
App → `http://localhost:3000`
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## How It Works
|
|
132
|
+
|
|
133
|
+
```
|
|
134
|
+
User enters URL
|
|
135
|
+
│
|
|
136
|
+
▼
|
|
137
|
+
FastAPI /runs ──────────────────────────────────────────────┐
|
|
138
|
+
│ │
|
|
139
|
+
│ SSE stream → browser terminal │
|
|
140
|
+
▼ │
|
|
141
|
+
┌─────────────────────────────────────────────────────────┐ │
|
|
142
|
+
│ Orchestrator Pipeline │ │
|
|
143
|
+
│ │ │
|
|
144
|
+
│ 1. Content Agent → summarize site + docs │ │
|
|
145
|
+
│ 2. Competitor Agent → find rivals, pricing │ │
|
|
146
|
+
│ 3. Brand Agent → extract voice & tone │ │
|
|
147
|
+
│ 4. Audit Agent → scores + SEO checks + fixes │ │
|
|
148
|
+
└─────────────────────────────────────────────────────────┘ │
|
|
149
|
+
│ │
|
|
150
|
+
└──────────────── RunStatus (Pydantic) ─────────────────┘
|
|
151
|
+
│
|
|
152
|
+
GET /runs/{id}
|
|
153
|
+
│
|
|
154
|
+
Next.js dashboard
|
|
155
|
+
(audit · competitors · chat)
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Each agent is a [Backboard](https://backboard.io) assistant with live web search enabled — no stale training data, no hallucinated competitors.
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
## Project Structure
|
|
163
|
+
|
|
164
|
+
```
|
|
165
|
+
backboard-cmo/
|
|
166
|
+
├── app/
|
|
167
|
+
│ ├── main.py # FastAPI routes + SSE
|
|
168
|
+
│ ├── orchestrator.py # Agent pipeline
|
|
169
|
+
│ └── schemas.py # Pydantic models
|
|
170
|
+
├── web/
|
|
171
|
+
│ ├── src/app/ # Next.js pages
|
|
172
|
+
│ └── src/components/ # UI components
|
|
173
|
+
├── scripts/ # Smoke tests
|
|
174
|
+
├── pyproject.toml # uv/hatch config
|
|
175
|
+
└── start.sh # Clean start script
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
## API
|
|
181
|
+
|
|
182
|
+
```http
|
|
183
|
+
POST /runs body: { website_url } → { run_id }
|
|
184
|
+
GET /runs/{id} → RunStatus JSON
|
|
185
|
+
GET /runs/{id}/stream → SSE terminal lines
|
|
186
|
+
POST /runs/{id}/chat body: { message } → { reply }
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
---
|
|
190
|
+
|
|
191
|
+
## Contributing
|
|
192
|
+
|
|
193
|
+
Pull requests welcome. Please keep it surgical — one concern per PR.
|
|
194
|
+
|
|
195
|
+
- Backend logic lives in `app/` only. No logic in the frontend.
|
|
196
|
+
- Pydantic models for all data shapes.
|
|
197
|
+
- `uv` for Python deps, `npm` for frontend.
|
|
198
|
+
- Never delete Backboard assistants — they carry persistent data.
|
|
199
|
+
|
|
200
|
+
---
|
|
201
|
+
|
|
202
|
+
## Built with
|
|
203
|
+
|
|
204
|
+
- [Backboard.io](https://backboard.io) — AI agent infrastructure
|
|
205
|
+
- [FastAPI](https://fastapi.tiangolo.com)
|
|
206
|
+
- [Next.js](https://nextjs.org)
|
|
207
|
+
- [Tailwind CSS](https://tailwindcss.com)
|
|
208
|
+
- [shadcn/ui](https://ui.shadcn.com)
|
|
209
|
+
|
|
210
|
+
---
|
|
211
|
+
|
|
212
|
+
<p align="center">
|
|
213
|
+
Made with ☕ and too many competitor analyses.
|
|
214
|
+
<br />
|
|
215
|
+
<a href="https://backboard.io">backboard.io</a>
|
|
216
|
+
</p>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# AI CMO Terminal API
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""FastAPI app: runs API and SSE stream. All logic in API."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import uuid
|
|
6
|
+
from contextlib import asynccontextmanager
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
from dotenv import load_dotenv
|
|
10
|
+
load_dotenv()
|
|
11
|
+
except ImportError:
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
from fastapi import FastAPI, HTTPException
|
|
15
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
16
|
+
from sse_starlette.sse import EventSourceResponse
|
|
17
|
+
|
|
18
|
+
from app import orchestrator
|
|
19
|
+
from pydantic import BaseModel as PydanticBaseModel
|
|
20
|
+
|
|
21
|
+
from app.schemas import AnalyticsMetric, ChatMessage, FeedItem, RunCreate, RunResponse, RunStatus
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@asynccontextmanager
|
|
25
|
+
async def lifespan(app: FastAPI):
|
|
26
|
+
yield
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
app = FastAPI(title="AI CMO Terminal API", lifespan=lifespan)
|
|
30
|
+
|
|
31
|
+
app.add_middleware(
|
|
32
|
+
CORSMiddleware,
|
|
33
|
+
allow_origins=["http://localhost:9000", "http://127.0.0.1:9000"],
|
|
34
|
+
allow_credentials=True,
|
|
35
|
+
allow_methods=["*"],
|
|
36
|
+
allow_headers=["*"],
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@app.post("/api/runs", response_model=RunResponse)
|
|
41
|
+
async def create_run(body: RunCreate):
|
|
42
|
+
run_id = uuid.uuid4().hex[:12]
|
|
43
|
+
orchestrator._runs[run_id] = RunStatus(
|
|
44
|
+
run_id=run_id,
|
|
45
|
+
status="pending",
|
|
46
|
+
website_url=body.website_url,
|
|
47
|
+
credits=2000,
|
|
48
|
+
analytics_overview=[
|
|
49
|
+
AnalyticsMetric(key="performance", label="Performance", score=44, tone="red"),
|
|
50
|
+
AnalyticsMetric(key="accessibility", label="Accessibility", score=78, tone="yellow"),
|
|
51
|
+
AnalyticsMetric(key="best_practices", label="Best Practices", score=73, tone="yellow"),
|
|
52
|
+
AnalyticsMetric(key="seo", label="SEO", score=92, tone="green"),
|
|
53
|
+
],
|
|
54
|
+
feed_items=[
|
|
55
|
+
FeedItem(id="mentions", title="Found 2 mentions", status="Suggested"),
|
|
56
|
+
FeedItem(id="seo-geo", title="SEO + GEO Recommendations", status="Found 2 issues"),
|
|
57
|
+
FeedItem(id="meta-tags", title="Add Critical Meta Tags", status="High priority"),
|
|
58
|
+
FeedItem(id="schema", title="Implement Structured Data", status="In progress"),
|
|
59
|
+
],
|
|
60
|
+
chat_status="loading",
|
|
61
|
+
)
|
|
62
|
+
orchestrator._terminal_queues[run_id] = asyncio.Queue()
|
|
63
|
+
asyncio.create_task(orchestrator.run_orchestrator(run_id, body.website_url))
|
|
64
|
+
return RunResponse(run_id=run_id)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@app.get("/api/runs/{run_id}", response_model=RunStatus)
|
|
68
|
+
async def get_run(run_id: str):
|
|
69
|
+
run = orchestrator.get_run(run_id)
|
|
70
|
+
if not run:
|
|
71
|
+
raise HTTPException(status_code=404, detail="Run not found")
|
|
72
|
+
return run
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def stream_generator(run_id: str):
|
|
76
|
+
run = orchestrator.get_run(run_id)
|
|
77
|
+
if not run:
|
|
78
|
+
yield {"data": '{"line": "> Run not found."}'}
|
|
79
|
+
return
|
|
80
|
+
q = orchestrator.get_terminal_queue(run_id)
|
|
81
|
+
try:
|
|
82
|
+
while True:
|
|
83
|
+
try:
|
|
84
|
+
line = await asyncio.wait_for(q.get(), timeout=30.0)
|
|
85
|
+
yield {"data": json.dumps({"line": line})}
|
|
86
|
+
except asyncio.TimeoutError:
|
|
87
|
+
yield {"data": ""}
|
|
88
|
+
r = orchestrator.get_run(run_id)
|
|
89
|
+
if r and r.status in ("completed", "failed"):
|
|
90
|
+
break
|
|
91
|
+
except asyncio.CancelledError:
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@app.get("/api/runs/{run_id}/stream")
|
|
96
|
+
async def stream_run(run_id: str):
|
|
97
|
+
if not orchestrator.get_run(run_id):
|
|
98
|
+
raise HTTPException(status_code=404, detail="Run not found")
|
|
99
|
+
return EventSourceResponse(stream_generator(run_id))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class ChatRequest(PydanticBaseModel):
|
|
103
|
+
message: str
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@app.post("/api/runs/{run_id}/chat")
|
|
107
|
+
async def chat(run_id: str, body: ChatRequest):
|
|
108
|
+
run = orchestrator.get_run(run_id)
|
|
109
|
+
if not run:
|
|
110
|
+
raise HTTPException(status_code=404, detail="Run not found")
|
|
111
|
+
run.chat_messages.append(ChatMessage(role="user", content=body.message))
|
|
112
|
+
reply = await orchestrator.chat_reply(run_id, body.message)
|
|
113
|
+
run.chat_messages.append(ChatMessage(role="assistant", content=reply))
|
|
114
|
+
return {"messages": run.chat_messages}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def run():
|
|
118
|
+
import uvicorn
|
|
119
|
+
uvicorn.run("app.main:app", host="0.0.0.0", port=9000, reload=True)
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""Run orchestration: Backboard assistants as agents, SSE terminal lines."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import uuid
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
from backboard import BackboardClient
|
|
12
|
+
|
|
13
|
+
_BACKBOARD_BASE = "https://app.backboard.io/api"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def _add_message_with_search(thread_id: str, content: str) -> str:
|
|
17
|
+
"""Call the Backboard message endpoint directly with web_search enabled."""
|
|
18
|
+
api_key = os.getenv("BACKBOARD_API_KEY", "")
|
|
19
|
+
async with httpx.AsyncClient(timeout=120) as hx:
|
|
20
|
+
resp = await hx.post(
|
|
21
|
+
f"{_BACKBOARD_BASE}/threads/{thread_id}/messages",
|
|
22
|
+
headers={"X-API-Key": api_key},
|
|
23
|
+
data={
|
|
24
|
+
"content": content,
|
|
25
|
+
"stream": "true",
|
|
26
|
+
"memory": "off",
|
|
27
|
+
"web_search": "Auto",
|
|
28
|
+
"llm_provider": "openai",
|
|
29
|
+
"model_name": "gpt-5.4",
|
|
30
|
+
},
|
|
31
|
+
)
|
|
32
|
+
resp.raise_for_status()
|
|
33
|
+
|
|
34
|
+
body = ""
|
|
35
|
+
for line in resp.text.splitlines():
|
|
36
|
+
line = line.strip()
|
|
37
|
+
if line.startswith("data:"):
|
|
38
|
+
line = line[len("data:"):].strip()
|
|
39
|
+
if not line:
|
|
40
|
+
continue
|
|
41
|
+
try:
|
|
42
|
+
data = json.loads(line)
|
|
43
|
+
except json.JSONDecodeError:
|
|
44
|
+
continue
|
|
45
|
+
if data.get("type") == "run_ended":
|
|
46
|
+
body = data.get("final_content", "")
|
|
47
|
+
|
|
48
|
+
print(body)
|
|
49
|
+
return body
|
|
50
|
+
|
|
51
|
+
from app.schemas import (
|
|
52
|
+
AnalyticsMetric,
|
|
53
|
+
AuditCheck,
|
|
54
|
+
ChatMessage,
|
|
55
|
+
CompetitorItem,
|
|
56
|
+
CompetitorReport,
|
|
57
|
+
CompetitorReportRow,
|
|
58
|
+
DocumentItem,
|
|
59
|
+
FeedItem,
|
|
60
|
+
RunStatus,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
_AUDIT_PROMPT = """\
|
|
65
|
+
Search web for {url}
|
|
66
|
+
Audit the website {url}. Respond with ONLY a raw JSON object — no markdown fences, \
|
|
67
|
+
no explanatory text before or after. Use exactly these keys:
|
|
68
|
+
{{
|
|
69
|
+
"performance_score": <integer 0-100>,
|
|
70
|
+
"accessibility_score": <integer 0-100>,
|
|
71
|
+
"best_practices_score": <integer 0-100>,
|
|
72
|
+
"seo_score": <integer 0-100>,
|
|
73
|
+
"summary": "<2-3 sentence plain-text summary>",
|
|
74
|
+
"passed_checks": [{{"name": "...", "description": "1 sentence note", "value": "<ultra-short status, ≤20 chars, e.g. 42/42 or 18 int / 6 ext or Yes or OK>"}}],
|
|
75
|
+
"failed_checks": [{{"name": "...", "description": "1 sentence explaining the issue", "value": "<ultra-short status, ≤20 chars, e.g. Missing or No or None or 0/5>", "priority": "critical|high|medium", "how_to_fix": "numbered step-by-step instructions to fix this specific issue, 3-5 steps"}}]
|
|
76
|
+
}}
|
|
77
|
+
Check: page structure, meta title/description, H1, image alt text, HTTPS, mobile-friendliness, \
|
|
78
|
+
Core Web Vitals estimates, structured data, canonical URL, robots.txt, sitemap, broken links, \
|
|
79
|
+
accessibility basics. List every check — passing ones in passed_checks, failing ones in failed_checks.\
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _score_tone(score: int) -> str:
|
|
84
|
+
if score >= 90:
|
|
85
|
+
return "green"
|
|
86
|
+
if score >= 70:
|
|
87
|
+
return "yellow"
|
|
88
|
+
return "red"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _extract_json(text: str) -> dict:
|
|
92
|
+
"""Extract and parse the first JSON object found in text."""
|
|
93
|
+
try:
|
|
94
|
+
return json.loads(text)
|
|
95
|
+
except json.JSONDecodeError:
|
|
96
|
+
pass
|
|
97
|
+
match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
|
|
98
|
+
if match:
|
|
99
|
+
return json.loads(match.group(1))
|
|
100
|
+
start, end = text.find("{"), text.rfind("}")
|
|
101
|
+
if start != -1 and end != -1:
|
|
102
|
+
return json.loads(text[start : end + 1])
|
|
103
|
+
raise ValueError("No JSON object found in audit response")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# In-memory run state: run_id -> RunStatus and queue of terminal lines for SSE
|
|
107
|
+
_runs: dict[str, RunStatus] = {}
|
|
108
|
+
_terminal_queues: dict[str, asyncio.Queue[str]] = {}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _get_client() -> BackboardClient:
|
|
112
|
+
api_key = os.getenv("BACKBOARD_API_KEY")
|
|
113
|
+
if not api_key:
|
|
114
|
+
raise ValueError("BACKBOARD_API_KEY not set")
|
|
115
|
+
return BackboardClient(api_key=api_key)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _assistant_id(key: str) -> str:
|
|
119
|
+
val = os.getenv(key)
|
|
120
|
+
if not val:
|
|
121
|
+
raise ValueError(f"{key} not set in .env")
|
|
122
|
+
return val
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
async def _emit(run_id: str, line: str) -> None:
|
|
126
|
+
"""Emit a terminal line for this run (prefix with > if not already)."""
|
|
127
|
+
if not line.strip().startswith(">"):
|
|
128
|
+
line = f"> {line}"
|
|
129
|
+
q = _terminal_queues.get(run_id)
|
|
130
|
+
if q:
|
|
131
|
+
await q.put(line)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
_chat_threads: dict[str, str] = {}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def get_run(run_id: str) -> Optional[RunStatus]:
|
|
138
|
+
return _runs.get(run_id)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
async def chat_reply(run_id: str, message: str) -> str:
|
|
142
|
+
"""Send a chat message for this run and return the assistant reply."""
|
|
143
|
+
client = _get_client()
|
|
144
|
+
audit_id = _assistant_id("BACKBOARD_ASSISTANT_AUDIT")
|
|
145
|
+
run = _runs.get(run_id)
|
|
146
|
+
|
|
147
|
+
if run_id not in _chat_threads:
|
|
148
|
+
thread = await client.create_thread(audit_id)
|
|
149
|
+
_chat_threads[run_id] = str(thread.thread_id)
|
|
150
|
+
|
|
151
|
+
return (await _add_message_with_search(_chat_threads[run_id], message))[:1000]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def get_terminal_queue(run_id: str) -> asyncio.Queue[str]:
|
|
155
|
+
if run_id not in _terminal_queues:
|
|
156
|
+
_terminal_queues[run_id] = asyncio.Queue()
|
|
157
|
+
return _terminal_queues[run_id]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
async def run_orchestrator(run_id: str, website_url: str) -> None:
|
|
161
|
+
"""Run the agent pipeline and update run state. Emits terminal lines to queue."""
|
|
162
|
+
client = _get_client()
|
|
163
|
+
try:
|
|
164
|
+
content_id = _assistant_id("BACKBOARD_ASSISTANT_CONTENT")
|
|
165
|
+
competitor_id = _assistant_id("BACKBOARD_ASSISTANT_COMPETITOR")
|
|
166
|
+
brand_id = _assistant_id("BACKBOARD_ASSISTANT_BRAND")
|
|
167
|
+
audit_id = _assistant_id("BACKBOARD_ASSISTANT_AUDIT")
|
|
168
|
+
except ValueError as e:
|
|
169
|
+
_runs[run_id] = RunStatus(
|
|
170
|
+
run_id=run_id,
|
|
171
|
+
status="failed",
|
|
172
|
+
website_url=website_url,
|
|
173
|
+
project_description=str(e),
|
|
174
|
+
)
|
|
175
|
+
await _emit(run_id, f"Error: {e}")
|
|
176
|
+
return
|
|
177
|
+
|
|
178
|
+
run = _runs.get(run_id)
|
|
179
|
+
if run:
|
|
180
|
+
run.status = "running"
|
|
181
|
+
run.chat_status = "loading"
|
|
182
|
+
|
|
183
|
+
await _emit(run_id, "Checking what content and documents you have...")
|
|
184
|
+
content_thread = await client.create_thread(content_id)
|
|
185
|
+
content_prompt = f"Search web for {website_url}. Visit and summarize the website {website_url}. What content and key documents or product information does it offer? Be concise."
|
|
186
|
+
product_info = (await _add_message_with_search(str(content_thread.thread_id), content_prompt))[:2000]
|
|
187
|
+
if run:
|
|
188
|
+
run.project_description = product_info or f"Site: {website_url}"
|
|
189
|
+
run.documents = [DocumentItem(id="product", title="Product Information")]
|
|
190
|
+
run.feed_items = [
|
|
191
|
+
FeedItem(id="mentions", title="Found 2 mentions", status="Suggested"),
|
|
192
|
+
FeedItem(id="seo-geo", title="SEO + GEO Recommendations", status="Found 2 issues"),
|
|
193
|
+
FeedItem(id="meta-tags", title="Add Critical Meta Tags", status="High priority"),
|
|
194
|
+
FeedItem(id="schema", title="Implement Structured Data", status="In progress"),
|
|
195
|
+
]
|
|
196
|
+
await _emit(run_id, "Content and documents summarized.")
|
|
197
|
+
|
|
198
|
+
await _emit(run_id, "Now let me check out your competition...")
|
|
199
|
+
competitor_thread = await client.create_thread(competitor_id)
|
|
200
|
+
domain = website_url.replace("https://", "").replace("http://", "").split("/")[0]
|
|
201
|
+
search_query = f"{domain} competitors alternative privacy AI chat"
|
|
202
|
+
await _emit(run_id, f"Searching: {search_query}")
|
|
203
|
+
comp_prompt = f"Search web for {website_url}. Find direct and secondary competitors to the website {website_url}. For each competitor list: name, category (Direct or Secondary), and pricing (e.g. Free / $X/mo). Format as a short executive summary then a clear list."
|
|
204
|
+
comp_text = await _add_message_with_search(str(competitor_thread.thread_id), comp_prompt)
|
|
205
|
+
await _emit(run_id, "Evaluating competitor's positioning strategy...")
|
|
206
|
+
await _emit(run_id, "Competitor analysis complete.")
|
|
207
|
+
|
|
208
|
+
competitors: list[CompetitorItem] = []
|
|
209
|
+
report_rows: list[CompetitorReportRow] = []
|
|
210
|
+
for i, line in enumerate(comp_text.split("\n")):
|
|
211
|
+
line = line.strip()
|
|
212
|
+
if not line or line.startswith("#"):
|
|
213
|
+
continue
|
|
214
|
+
if "|" in line and i > 0:
|
|
215
|
+
parts = [p.strip() for p in line.split("|") if p.strip()]
|
|
216
|
+
if len(parts) >= 3:
|
|
217
|
+
report_rows.append(
|
|
218
|
+
CompetitorReportRow(
|
|
219
|
+
competitor=parts[0],
|
|
220
|
+
category=parts[1],
|
|
221
|
+
pricing=parts[2],
|
|
222
|
+
)
|
|
223
|
+
)
|
|
224
|
+
competitors.append(CompetitorItem(id=parts[0].lower().replace(" ", "-"), name=parts[0]))
|
|
225
|
+
elif " - " in line and ("Direct" in line or "Secondary" in line or "Free" in line or "$" in line):
|
|
226
|
+
parts = line.split(" - ", 1)
|
|
227
|
+
if len(parts) == 2:
|
|
228
|
+
name = parts[0].strip().strip("-*")
|
|
229
|
+
rest = parts[1].strip()
|
|
230
|
+
cat = "Direct" if "direct" in rest.lower() else "Secondary"
|
|
231
|
+
report_rows.append(CompetitorReportRow(competitor=name, category=cat, pricing=rest))
|
|
232
|
+
competitors.append(CompetitorItem(id=name.lower().replace(" ", "-"), name=name))
|
|
233
|
+
|
|
234
|
+
if run:
|
|
235
|
+
run.competitors = competitors or [CompetitorItem(id="c1", name="See report")]
|
|
236
|
+
run.documents = (run.documents or []) + [
|
|
237
|
+
DocumentItem(id="competitor", title="Competitor Analysis"),
|
|
238
|
+
]
|
|
239
|
+
from datetime import datetime
|
|
240
|
+
run.competitor_report = CompetitorReport(
|
|
241
|
+
title="Competitor Analysis",
|
|
242
|
+
date=datetime.utcnow().strftime("As of %B %d, %Y"),
|
|
243
|
+
executive_summary=comp_text[:1500] if comp_text else "No summary.",
|
|
244
|
+
rows=report_rows if report_rows else [
|
|
245
|
+
CompetitorReportRow(competitor="—", category="—", pricing=comp_text[:200] or "—"),
|
|
246
|
+
],
|
|
247
|
+
)
|
|
248
|
+
brand_prompt = f"Search web for {website_url}. Based on the website {website_url} and this product summary, describe the brand voice in 2-3 sentences: {product_info[:500]}"
|
|
249
|
+
await _emit(run_id, "Now let me figure out your brand voice...")
|
|
250
|
+
brand_thread = await client.create_thread(brand_id)
|
|
251
|
+
brand_snippet = (await _add_message_with_search(str(brand_thread.thread_id), brand_prompt))[:500]
|
|
252
|
+
await _emit(run_id, "Brand voice guide ready")
|
|
253
|
+
if run:
|
|
254
|
+
run.brand_voice_snippet = brand_snippet
|
|
255
|
+
run.documents = (run.documents or []) + [DocumentItem(id="brand", title="Brand Voice")]
|
|
256
|
+
|
|
257
|
+
await _emit(run_id, "Running website audit...")
|
|
258
|
+
await _emit(run_id, "Scanning page structure and metadata...")
|
|
259
|
+
audit_thread = await client.create_thread(audit_id)
|
|
260
|
+
audit_raw = await _add_message_with_search(
|
|
261
|
+
str(audit_thread.thread_id),
|
|
262
|
+
_AUDIT_PROMPT.format(url=website_url),
|
|
263
|
+
)
|
|
264
|
+
await _emit(run_id, "Page speed and core web vitals measured")
|
|
265
|
+
|
|
266
|
+
if run:
|
|
267
|
+
try:
|
|
268
|
+
audit_data = _extract_json(audit_raw)
|
|
269
|
+
perf = int(audit_data.get("performance_score", 50))
|
|
270
|
+
a11y = int(audit_data.get("accessibility_score", 50))
|
|
271
|
+
bp = int(audit_data.get("best_practices_score", 50))
|
|
272
|
+
seo = int(audit_data.get("seo_score", 50))
|
|
273
|
+
run.analytics_overview = [
|
|
274
|
+
AnalyticsMetric(key="performance", label="Performance", score=perf, tone=_score_tone(perf)),
|
|
275
|
+
AnalyticsMetric(key="accessibility", label="Accessibility", score=a11y, tone=_score_tone(a11y)),
|
|
276
|
+
AnalyticsMetric(key="best_practices", label="Best Practices", score=bp, tone=_score_tone(bp)),
|
|
277
|
+
AnalyticsMetric(key="seo", label="SEO", score=seo, tone=_score_tone(seo)),
|
|
278
|
+
]
|
|
279
|
+
run.passed_checks = [
|
|
280
|
+
AuditCheck(name=c["name"], description=c.get("description", ""), value=c.get("value", ""), passed=True)
|
|
281
|
+
for c in audit_data.get("passed_checks", [])
|
|
282
|
+
if isinstance(c, dict) and c.get("name")
|
|
283
|
+
]
|
|
284
|
+
failed = [
|
|
285
|
+
c for c in audit_data.get("failed_checks", [])
|
|
286
|
+
if isinstance(c, dict) and c.get("name")
|
|
287
|
+
]
|
|
288
|
+
run.failed_checks = [
|
|
289
|
+
AuditCheck(
|
|
290
|
+
name=c["name"],
|
|
291
|
+
description=c.get("description", ""),
|
|
292
|
+
value=c.get("value", ""),
|
|
293
|
+
passed=False,
|
|
294
|
+
how_to_fix=c.get("how_to_fix", ""),
|
|
295
|
+
)
|
|
296
|
+
for c in failed
|
|
297
|
+
]
|
|
298
|
+
run.feed_items = [
|
|
299
|
+
FeedItem(
|
|
300
|
+
id=c["name"].lower().replace(" ", "-")[:40],
|
|
301
|
+
title=c["name"],
|
|
302
|
+
status=f"{c.get('priority', 'medium').title()} priority",
|
|
303
|
+
description=c.get("description", ""),
|
|
304
|
+
how_to_fix=c.get("how_to_fix", ""),
|
|
305
|
+
action_label="Fix",
|
|
306
|
+
)
|
|
307
|
+
for c in failed
|
|
308
|
+
]
|
|
309
|
+
run.audit_summary = audit_data.get("summary", audit_raw[:1500])
|
|
310
|
+
await _emit(run_id, f"Found {len(failed)} SEO optimization opportunities (score: {seo}/100)")
|
|
311
|
+
except Exception:
|
|
312
|
+
run.audit_summary = audit_raw[:1500]
|
|
313
|
+
|
|
314
|
+
if run:
|
|
315
|
+
run.status = "completed"
|
|
316
|
+
run.project_name = domain.replace(".", " ").title() if domain else "Project"
|
|
317
|
+
run.chat_status = "ready"
|
|
318
|
+
run.chat_messages = [
|
|
319
|
+
ChatMessage(
|
|
320
|
+
role="assistant",
|
|
321
|
+
content="I finished the audit and competitor scan. Want me to draft a launch plan or prioritize the SEO fixes?",
|
|
322
|
+
)
|
|
323
|
+
]
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Pydantic schemas for API request/response and run state."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field, HttpUrl
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class RunCreate(BaseModel):
|
|
8
|
+
website_url: str = Field(..., description="Website URL to analyze")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RunResponse(BaseModel):
|
|
12
|
+
run_id: str
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DocumentItem(BaseModel):
|
|
16
|
+
id: str
|
|
17
|
+
title: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CompetitorItem(BaseModel):
|
|
21
|
+
id: str
|
|
22
|
+
name: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CompetitorReportRow(BaseModel):
|
|
26
|
+
competitor: str
|
|
27
|
+
category: str
|
|
28
|
+
pricing: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class CompetitorReport(BaseModel):
|
|
32
|
+
title: str = "Competitor Analysis"
|
|
33
|
+
date: str = ""
|
|
34
|
+
executive_summary: str = ""
|
|
35
|
+
rows: list[CompetitorReportRow] = Field(default_factory=list)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AnalyticsMetric(BaseModel):
|
|
39
|
+
key: str
|
|
40
|
+
label: str
|
|
41
|
+
score: int = Field(..., ge=0, le=100)
|
|
42
|
+
tone: str = "neutral"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class AuditCheck(BaseModel):
|
|
46
|
+
name: str
|
|
47
|
+
description: str
|
|
48
|
+
value: str = ""
|
|
49
|
+
passed: bool = True
|
|
50
|
+
how_to_fix: str = ""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class FeedItem(BaseModel):
|
|
54
|
+
id: str
|
|
55
|
+
title: str
|
|
56
|
+
status: str
|
|
57
|
+
description: str = ""
|
|
58
|
+
how_to_fix: str = ""
|
|
59
|
+
action_label: str = "Fix"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ChatMessage(BaseModel):
|
|
63
|
+
role: str
|
|
64
|
+
content: str
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class RunStatus(BaseModel):
|
|
68
|
+
run_id: str
|
|
69
|
+
status: str = Field(..., description="pending | running | completed | failed")
|
|
70
|
+
website_url: str = ""
|
|
71
|
+
project_name: str = ""
|
|
72
|
+
project_description: str = ""
|
|
73
|
+
documents: list[DocumentItem] = Field(default_factory=list)
|
|
74
|
+
competitors: list[CompetitorItem] = Field(default_factory=list)
|
|
75
|
+
competitor_report: Optional[CompetitorReport] = None
|
|
76
|
+
brand_voice_snippet: str = ""
|
|
77
|
+
audit_summary: str = ""
|
|
78
|
+
analytics_overview: list[AnalyticsMetric] = Field(default_factory=list)
|
|
79
|
+
passed_checks: list[AuditCheck] = Field(default_factory=list)
|
|
80
|
+
failed_checks: list[AuditCheck] = Field(default_factory=list)
|
|
81
|
+
feed_items: list[FeedItem] = Field(default_factory=list)
|
|
82
|
+
chat_status: str = "loading"
|
|
83
|
+
chat_messages: list[ChatMessage] = Field(default_factory=list)
|
|
84
|
+
credits: int = 2000
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "backboard-cmo"
|
|
3
|
+
version = "0.1.1"
|
|
4
|
+
description = "AI CMO Terminal — Backboard agents for traffic and users"
|
|
5
|
+
requires-python = ">=3.11"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"fastapi>=0.115.0",
|
|
8
|
+
"uvicorn[standard]>=0.32.0",
|
|
9
|
+
"backboard-sdk>=1.5.0",
|
|
10
|
+
"httpx>=0.27.0",
|
|
11
|
+
"pydantic>=2.0",
|
|
12
|
+
"sse-starlette>=2.0",
|
|
13
|
+
"python-dotenv>=1.0",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.scripts]
|
|
17
|
+
cmo-api = "app.main:run"
|
|
18
|
+
|
|
19
|
+
[build-system]
|
|
20
|
+
requires = ["hatchling"]
|
|
21
|
+
build-backend = "hatchling.build"
|
|
22
|
+
|
|
23
|
+
[tool.hatch.build.targets.wheel]
|
|
24
|
+
packages = ["app"]
|
|
25
|
+
|
|
26
|
+
[tool.hatch.build.targets.sdist]
|
|
27
|
+
exclude = ["web/", ".dockerignore", "Dockerfile", "start.sh"]
|
|
28
|
+
|
|
29
|
+
[tool.uv]
|
|
30
|
+
|
|
31
|
+
[dependency-groups]
|
|
32
|
+
dev = [
|
|
33
|
+
"pillow>=12.1.1",
|
|
34
|
+
]
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""One-off: create the four Backboard assistants. Print IDs for .env."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
from backboard import BackboardClient
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def main():
|
|
10
|
+
api_key = os.getenv("BACKBOARD_API_KEY")
|
|
11
|
+
if not api_key:
|
|
12
|
+
print("Set BACKBOARD_API_KEY")
|
|
13
|
+
return
|
|
14
|
+
client = BackboardClient(api_key=api_key)
|
|
15
|
+
|
|
16
|
+
assistants = [
|
|
17
|
+
(
|
|
18
|
+
"BACKBOARD_ASSISTANT_CONTENT",
|
|
19
|
+
"AI CMO Content Agent",
|
|
20
|
+
"You are a content analyst. Given a website URL, summarize its content and key documents or product information. Be concise and factual.",
|
|
21
|
+
),
|
|
22
|
+
(
|
|
23
|
+
"BACKBOARD_ASSISTANT_COMPETITOR",
|
|
24
|
+
"AI CMO Competitor Agent",
|
|
25
|
+
"You are a competitive analyst. Given a website, find direct and secondary competitors. For each: name, category (Direct/Secondary), pricing. Use web search for current data. Output a short executive summary then a clear list or table.",
|
|
26
|
+
),
|
|
27
|
+
(
|
|
28
|
+
"BACKBOARD_ASSISTANT_BRAND",
|
|
29
|
+
"AI CMO Brand Voice Agent",
|
|
30
|
+
"You infer brand voice from a website and its copy. Describe tone, vocabulary, and positioning in 2-3 sentences.",
|
|
31
|
+
),
|
|
32
|
+
(
|
|
33
|
+
"BACKBOARD_ASSISTANT_AUDIT",
|
|
34
|
+
"AI CMO Audit Agent",
|
|
35
|
+
"You audit websites: structure, metadata, and when possible Core Web Vitals or performance. Be concise.",
|
|
36
|
+
),
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
print("# Add these to your .env (do not delete these assistants):")
|
|
40
|
+
for env_key, name, system_prompt in assistants:
|
|
41
|
+
a = await client.create_assistant(name=name, system_prompt=system_prompt)
|
|
42
|
+
print(f"{env_key}={a.assistant_id}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
if __name__ == "__main__":
|
|
46
|
+
asyncio.run(main())
|