Skip to content

Commit 08fbd2f

Browse files
author
dev-pd
committed
Real-page audit: live POST /api/run, UA fix, UI pivot, memory endpoint, conf tooltip
1 parent 38c3782 commit 08fbd2f

11 files changed

Lines changed: 187 additions & 268 deletions

File tree

backend/app/api/memory.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
1-
"""Memory (playbook/diary) endpoints. Wired in Phase 3+; empty router for now."""
1+
"""Memory endpoints — a peek at what Agent B has learned."""
22

33
from fastapi import APIRouter
44

5+
from app.models.memory import PlaybookEntry
6+
from app.repositories.playbook import PlaybookRepository
7+
58
router = APIRouter(prefix="/memory", tags=["memory"])
9+
10+
11+
@router.get("/playbook", response_model=list[PlaybookEntry])
12+
async def playbook() -> list[PlaybookEntry]:
13+
return await PlaybookRepository().all()

backend/app/api/run.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,47 @@
1-
"""Agent-run endpoints. Wired in Phase 1+; empty router for now."""
1+
"""Live agent-run endpoint — audit a real URL with A and B.
2+
3+
Runs both agents live (real page fetch + live SERP + Anthropic) on any URL +
4+
keyword the user enters, and records the results so a later replay is free. There
5+
is no answer key for an arbitrary real page, so this returns recommendations and
6+
cost, not scores — the scored A-vs-B proof lives on the planted benchmark.
7+
"""
8+
9+
from __future__ import annotations
210

311
from fastapi import APIRouter
12+
from pydantic import BaseModel
13+
14+
from app.agents.agent_a import run_agent_a
15+
from app.agents.agent_b import run_agent_b
16+
from app.models import AgentRunResult
17+
from app.tools.registry import build_default_registry
418

519
router = APIRouter(prefix="/run", tags=["run"])
20+
21+
22+
class RunRequest(BaseModel):
23+
url: str
24+
keyword: str
25+
26+
27+
class RunResponse(BaseModel):
28+
a: AgentRunResult
29+
b: AgentRunResult
30+
31+
32+
@router.post("", response_model=RunResponse)
33+
async def run(req: RunRequest) -> RunResponse:
34+
a = await run_agent_a(
35+
req.url,
36+
req.keyword,
37+
registry=build_default_registry(replay=False),
38+
replay=False,
39+
)
40+
b = await run_agent_b(
41+
req.url,
42+
req.keyword,
43+
registry=build_default_registry(replay=False),
44+
replay=False,
45+
memory_on=True,
46+
)
47+
return RunResponse(a=a, b=b)

backend/app/tools/fetch_page.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,17 @@ async def run(self, payload: FetchPageInput) -> Page:
4848
)
4949
return page
5050

51-
async with httpx.AsyncClient(follow_redirects=True, timeout=20.0) as client:
51+
# A browser-like User-Agent: many real sites 403 a request without one.
52+
headers = {
53+
"User-Agent": (
54+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
55+
"(KHTML, like Gecko) Chrome/124.0 Safari/537.36"
56+
),
57+
"Accept": "text/html,application/xhtml+xml",
58+
}
59+
async with httpx.AsyncClient(
60+
follow_redirects=True, timeout=20.0, headers=headers
61+
) as client:
5262
response = await client.get(payload.url)
5363
response.raise_for_status()
5464
page = extract_page(payload.url, response.text)

backend/app/tools/registry.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
Both agents are built from the identical registry — same scraper, same SERP
44
tool, same SEO checks. Agent B differs only by memory and proactivity, never by
55
having different tools.
6+
7+
`replay` controls the data layer: None defers to `REPLAY_MODE` (replay from
8+
Mongo), False forces a live fetch (real page + live SERP), True forces replay.
69
"""
710

811
from __future__ import annotations
@@ -13,11 +16,12 @@
1316
from app.tools.seo_checks import SeoChecksTool
1417

1518

16-
def build_default_registry() -> ToolRegistry:
19+
def build_default_registry(replay: bool | None = None) -> ToolRegistry:
20+
fetch = FetchPageTool(replay=replay)
1721
return ToolRegistry(
1822
[
19-
FetchPageTool(),
20-
SearchSerpTool(),
21-
SeoChecksTool(),
23+
fetch,
24+
SearchSerpTool(replay=replay),
25+
SeoChecksTool(fetch_tool=fetch),
2226
]
2327
)

frontend/src/App.tsx

Lines changed: 63 additions & 133 deletions
Original file line numberDiff line numberDiff line change
@@ -1,188 +1,118 @@
1-
import { Suspense, lazy, useEffect, useMemo, useState, type FormEvent } from 'react'
1+
import { useEffect, useState, type FormEvent } from 'react'
22
import './App.css'
3-
import { fetchReport } from './api'
3+
import { fetchPlaybook, runAgents } from './api'
44
import { AgentColumn } from './components/AgentColumn'
5-
import { AlertBanner, MemoryPanel } from './components/MemoryPanel'
6-
import type { Report, ReportCase } from './types'
5+
import { MemoryPanel } from './components/MemoryPanel'
6+
import type { PlaybookEntry, RunResponse } from './types'
77

8-
// Recharts is heavy — lazy-load the chart so it stays out of the main bundle.
9-
const LearningCurve = lazy(() =>
10-
import('./components/LearningCurve').then((m) => ({ default: m.LearningCurve })),
11-
)
8+
type View = 'audit' | 'memory'
9+
type RunState =
10+
| { status: 'idle' }
11+
| { status: 'running' }
12+
| { status: 'done'; result: RunResponse }
13+
| { status: 'error'; message: string }
1214

13-
type View = 'compare' | 'learning'
14-
type Load = { status: 'loading' } | { status: 'ok'; report: Report } | { status: 'error'; message: string }
15+
function AuditPage() {
16+
const [url, setUrl] = useState('')
17+
const [keyword, setKeyword] = useState('')
18+
const [run, setRun] = useState<RunState>({ status: 'idle' })
1519

16-
// The case where B most beats A on coverage (so the win shows first), else the first.
17-
function winningCase(report: Report): ReportCase | undefined {
18-
const sorted = [...report.cases].sort(
19-
(x, y) =>
20-
y.b.scores.coverage - y.a.scores.coverage - (x.b.scores.coverage - x.a.scores.coverage),
21-
)
22-
return sorted[0] ?? report.cases[0]
23-
}
24-
25-
function AggregateSummary({ report }: { report: Report }) {
26-
const a = report.aggregate.A
27-
const b = report.aggregate.B
28-
const bWins = b.coverage > a.coverage
29-
const metrics: Array<[string, number, number]> = [
30-
['coverage', a.coverage, b.coverage],
31-
['correctness', a.correctness, b.correctness],
32-
['novelty', a.novelty, b.novelty],
33-
['efficiency', a.efficiency, b.efficiency],
34-
]
35-
return (
36-
<div className="summary">
37-
<div className="summary-head">
38-
Overall (held-out eval): {bWins ? 'Agent B beats Agent A' : 'A vs B'}
39-
</div>
40-
<table className="summary-table">
41-
<thead>
42-
<tr>
43-
<th>metric</th>
44-
<th>A</th>
45-
<th>B</th>
46-
</tr>
47-
</thead>
48-
<tbody>
49-
{metrics.map(([name, av, bv]) => (
50-
<tr key={name}>
51-
<td>{name}</td>
52-
<td>{av.toFixed(2)}</td>
53-
<td className={bv > av ? 'win' : ''}>{bv.toFixed(2)}</td>
54-
</tr>
55-
))}
56-
</tbody>
57-
</table>
58-
</div>
59-
)
60-
}
61-
62-
function ComparePage({ report }: { report: Report }) {
63-
const initial = winningCase(report)
64-
const [url, setUrl] = useState(initial?.url ?? '')
65-
const [keyword, setKeyword] = useState(initial?.keyword ?? '')
66-
const [selectedId, setSelectedId] = useState(initial?.id ?? '')
67-
const [running, setRunning] = useState(false)
68-
const [notFound, setNotFound] = useState(false)
69-
70-
const selected: ReportCase | undefined = useMemo(
71-
() => report.cases.find((c) => c.id === selectedId),
72-
[report, selectedId],
73-
)
74-
75-
function handleSubmit(event: FormEvent<HTMLFormElement>) {
20+
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
7621
event.preventDefault()
77-
const match = report.cases.find(
78-
(c) => c.keyword.toLowerCase() === keyword.trim().toLowerCase() || c.url === url.trim(),
79-
)
80-
if (!match) {
81-
setNotFound(true)
82-
return
22+
if (!url.trim() || !keyword.trim()) return
23+
setRun({ status: 'running' })
24+
try {
25+
const result = await runAgents(url.trim(), keyword.trim())
26+
setRun({ status: 'done', result })
27+
} catch (error) {
28+
setRun({ status: 'error', message: error instanceof Error ? error.message : 'unknown error' })
8329
}
84-
setNotFound(false)
85-
setRunning(true)
86-
setSelectedId(match.id)
87-
// brief spinner to convey "running A and B"
88-
window.setTimeout(() => setRunning(false), 350)
8930
}
9031

9132
return (
9233
<>
9334
<form className="run-form" onSubmit={handleSubmit}>
9435
<input
95-
type="text"
96-
placeholder="Page URL"
36+
type="url"
37+
placeholder="Real page URL (e.g. https://example.com/blog/post)"
9738
value={url}
9839
onChange={(e) => setUrl(e.target.value)}
99-
list="known-urls"
40+
required
10041
/>
10142
<input
10243
type="text"
10344
placeholder="Target keyword"
10445
value={keyword}
10546
onChange={(e) => setKeyword(e.target.value)}
106-
list="known-keywords"
47+
required
10748
/>
108-
<button type="submit">Run A vs B</button>
109-
<datalist id="known-urls">
110-
{report.cases.map((c) => (
111-
<option key={c.id} value={c.url} />
112-
))}
113-
</datalist>
114-
<datalist id="known-keywords">
115-
{report.cases.map((c) => (
116-
<option key={c.id} value={c.keyword} />
117-
))}
118-
</datalist>
49+
<button type="submit" disabled={run.status === 'running'}>
50+
{run.status === 'running' ? 'Running…' : 'Run A vs B (live)'}
51+
</button>
11952
</form>
120-
{notFound && (
121-
<p className="hint">
122-
Recorded demo cases only — pick a keyword/URL from the suggestions (live runs need an API key).
123-
</p>
124-
)}
53+
<p className="hint">
54+
Live run on the real page — fetches it, pulls the live SERP, and runs both agents. Takes
55+
~1–2 minutes.
56+
</p>
12557

126-
<AggregateSummary report={report} />
58+
{run.status === 'error' && <p className="hint error">{run.message}</p>}
12759

128-
{running || !selected ? (
60+
{run.status === 'running' && (
12961
<div className="split">
130-
<div className="agent-column spinner-box">running Agent A…</div>
62+
<div className="agent-column spinner-box">running Agent A on the live page</div>
13163
<div className="agent-column spinner-box">running Agent B…</div>
13264
</div>
133-
) : (
65+
)}
66+
67+
{run.status === 'done' && (
13468
<main className="split">
135-
<AgentColumn agent="A" view={selected.a} />
136-
<AgentColumn agent="B" view={selected.b} />
69+
<AgentColumn view={run.result.a} />
70+
<AgentColumn view={run.result.b} />
13771
</main>
13872
)}
13973
</>
14074
)
14175
}
14276

143-
export default function App() {
144-
const [view, setView] = useState<View>('compare')
145-
const [load, setLoad] = useState<Load>({ status: 'loading' })
77+
function MemoryPage() {
78+
const [playbook, setPlaybook] = useState<PlaybookEntry[] | null>(null)
79+
const [error, setError] = useState<string | null>(null)
14680

14781
useEffect(() => {
14882
let active = true
149-
fetchReport()
150-
.then((report) => active && setLoad({ status: 'ok', report }))
151-
.catch((error: unknown) => {
152-
const message = error instanceof Error ? error.message : 'unknown error'
153-
if (active) setLoad({ status: 'error', message })
154-
})
83+
fetchPlaybook()
84+
.then((p) => active && setPlaybook(p))
85+
.catch((e: unknown) => active && setError(e instanceof Error ? e.message : 'error'))
15586
return () => {
15687
active = false
15788
}
15889
}, [])
15990

91+
if (error) return <p className="hint error">{error}</p>
92+
if (!playbook) return <p className="hint">loading playbook…</p>
93+
return (
94+
<main className="learning">
95+
<MemoryPanel playbook={playbook} />
96+
</main>
97+
)
98+
}
99+
100+
export default function App() {
101+
const [view, setView] = useState<View>('audit')
160102
return (
161103
<div className="app">
162104
<header className="app-header">
163105
<h1>Learning SEO Agent</h1>
164106
<nav className="nav">
165-
<button className={view === 'compare' ? 'active' : ''} onClick={() => setView('compare')}>
166-
Compare
107+
<button className={view === 'audit' ? 'active' : ''} onClick={() => setView('audit')}>
108+
Audit (live)
167109
</button>
168-
<button className={view === 'learning' ? 'active' : ''} onClick={() => setView('learning')}>
169-
Learning &amp; Memory
110+
<button className={view === 'memory' ? 'active' : ''} onClick={() => setView('memory')}>
111+
B's Memory
170112
</button>
171113
</nav>
172114
</header>
173-
174-
{load.status === 'loading' && <p className="hint">Running the agents… (first load replays the comparison)</p>}
175-
{load.status === 'error' && <p className="hint error">Could not load report: {load.message}</p>}
176-
{load.status === 'ok' && view === 'compare' && <ComparePage report={load.report} />}
177-
{load.status === 'ok' && view === 'learning' && (
178-
<main className="learning">
179-
<AlertBanner alert={load.report.alert} />
180-
<Suspense fallback={<p className="hint">loading chart…</p>}>
181-
<LearningCurve cases={load.report.cases} />
182-
</Suspense>
183-
<MemoryPanel playbook={load.report.playbook} />
184-
</main>
185-
)}
115+
{view === 'audit' ? <AuditPage /> : <MemoryPage />}
186116
</div>
187117
)
188118
}

0 commit comments

Comments
 (0)