HASHFOR_BACKEND / apply_progress_patch.py
moizshah956's picture
Create apply_progress_patch.py
bda5fb6 verified
Raw History Blame Contribute Delete
19.6 kB
#!/usr/bin/env python3
"""
Applies "real progress" plumbing to your backend:
python apply_progress_patch.py [directory containing the three files]
Edits: ai_visibility.py, seo_analyzer.py, app.py
- Every anchor must match EXACTLY ONCE (whitespace-tolerant). If any anchor
fails in a file, that file is left untouched and the failures are listed.
- A .bak copy is written before a file is modified.
- Safe to re-run: files that already contain the marker are skipped.
"""
import re
import sys
import shutil
import textwrap
from pathlib import Path
MARKER = "# --- live progress (real, event-driven) ---"
class Patcher:
def __init__(self, path):
self.path = Path(path)
self.src = self.path.read_text(encoding="utf-8")
self.errors = []
def sub(self, name, old, new):
"""Replace a block that starts at the beginning of a line.
`old` is matched token-by-token (whitespace-insensitive).
`new` is written at indent 0 and re-indented to the anchor's indent."""
toks = old.split()
pat = re.compile(r"(?m)^([ \t]*)" + r"\s+".join(re.escape(t) for t in toks))
ms = list(pat.finditer(self.src))
if len(ms) != 1:
self.errors.append(f"[{self.path.name}] '{name}': matched {len(ms)}x (expected 1)")
return
m = ms[0]
indent = m.group(1)
block = textwrap.dedent(new).strip("\n")
block = "\n".join((indent + ln) if ln.strip() else ln for ln in block.split("\n"))
self.src = self.src[: m.start()] + block + self.src[m.end():]
def save(self):
if self.errors:
print(f"\n✗ {self.path.name}: NOT modified")
for e in self.errors:
print(" ", e)
return False
shutil.copy(self.path, str(self.path) + ".bak")
self.path.write_text(self.src, encoding="utf-8")
print(f"✓ {self.path.name}: patched (backup: {self.path.name}.bak)")
return True
# ----------------------------------------------------------------------------
# ai_visibility.py
# ----------------------------------------------------------------------------
AI_HELPERS = f'''
{MARKER}
# Each stage owns a slice of the 0-100 bar. progress = slice_start + (done/total) * slice_size,
# so the bar only moves when real work completes.
def _stage_ranges(observed=True):
if observed:
return {{"discover": (0, 8), "crawl": (8, 40), "scan": (40, 60), "analyze": (60, 95), "report": (95, 100)}}
return {{"discover": (0, 10), "crawl": (10, 50), "scan": (50, 90), "analyze": (90, 90), "report": (90, 100)}}
def _evt(stage, done=0, total=1, message="", observed=True, **extra):
lo, hi = _stage_ranges(observed)[stage]
frac = (done / total) if total else 1
return {{"stage": stage, "done": done, "total": total,
"progress": round(lo + (hi - lo) * frac, 1), "message": message, **extra}}
async def _emit(cb, evt):
"""Never let progress reporting break the analysis."""
if cb is None:
return
try:
r = cb(evt)
if asyncio.iscoroutine(r):
await r
except Exception:
pass
def _get_anthropic_client():'''
def patch_ai_visibility(path):
p = Patcher(path)
if MARKER in p.src:
print(f"- {p.path.name}: already patched, skipping")
return True
p.sub("helpers", "def _get_anthropic_client():", AI_HELPERS)
p.sub(
"observed signature",
"async def analyze_observed_ai_visibility(brand_name, target_prompts=None, competitor_names=None, use_live_ai=False, brand_facts=None, engines=None):",
"async def analyze_observed_ai_visibility(brand_name, target_prompts=None, competitor_names=None, use_live_ai=False, brand_facts=None, engines=None, progress_cb=None):",
)
p.sub(
"observed engine loop",
"""per_engine_raw = {}
for engine in available:
raw_results = []
for prompt in target_prompts:
answer = await loop.run_in_executor(None, _call_engine_sync, engine, prompt)
raw_results.append({"prompt": prompt, "answer": answer})
per_engine_raw[engine] = raw_results
per_engine_metrics = {}
for engine, raw_results in per_engine_raw.items():
valid = [r for r in raw_results if r["answer"] is not None]
representation = await _check_representation_accuracy(brand_facts, valid, loop)
per_engine_metrics[engine] = _compute_engine_metrics(brand_name, target_prompts, competitor_names, raw_results, representation)""",
"""
total_steps = len(available) * len(target_prompts) + len(available) # prompts + one accuracy check per engine
done = 0
per_engine_raw = {}
for engine in available:
raw_results = []
for prompt in target_prompts:
answer = await loop.run_in_executor(None, _call_engine_sync, engine, prompt)
raw_results.append({"prompt": prompt, "answer": answer})
done += 1
await _emit(progress_cb, _evt(
"analyze", done, total_steps,
f"Asking {ENGINE_DISPLAY_NAMES[engine]}",
engine=engine, answered=answer is not None,
))
per_engine_raw[engine] = raw_results
per_engine_metrics = {}
for engine, raw_results in per_engine_raw.items():
valid = [r for r in raw_results if r["answer"] is not None]
representation = await _check_representation_accuracy(brand_facts, valid, loop)
per_engine_metrics[engine] = _compute_engine_metrics(brand_name, target_prompts, competitor_names, raw_results, representation)
done += 1
await _emit(progress_cb, _evt("analyze", done, total_steps, f"Checking {ENGINE_DISPLAY_NAMES[engine]} answers"))
""",
)
p.sub("run signature", "include_raw_metrics=False,", "include_raw_metrics=False,\nprogress_cb=None,")
p.sub(
"discover + crawl",
"""urls = await discover_urls_parallel(base_url, max_pages)
if not urls:
urls = [base_url]
playwright_data, failed_fetches = await fetch_all_pages_parallel(urls, max_concurrent)""",
"""
obs = bool(auto_measure_observed)
await _emit(progress_cb, _evt("discover", 0, 1, "Reading sitemap & internal links", obs))
urls = await discover_urls_parallel(base_url, max_pages)
if not urls:
urls = [base_url]
await _emit(progress_cb, _evt("discover", 1, 1, f"Found {len(urls)} pages", obs, urls_found=len(urls)))
async def _on_page(done, total, page):
await _emit(progress_cb, _evt(
"crawl", done, total, "Crawling pages", obs,
url=page.get("requested_url") or page.get("url"),
ok=not page.get("fetch_failed"),
))
playwright_data, failed_fetches = await fetch_all_pages_parallel(urls, max_concurrent, on_page=_on_page)
""",
)
p.sub(
"scan",
"""robots_txt = await _fetch_robots_txt(base_url)
robots_bot_permissions = _parse_robots_txt(robots_txt, path="/") if robots_txt else None
sitemap_present, llms_txt_present = await _check_sitemap_and_llms_txt(base_url)
page_results = await asyncio.gather(*[
analyze_page_ai_visibility(
seo_data, domain, use_ai=use_ai,
robots_bot_permissions=robots_bot_permissions,
sitemap_present=sitemap_present, llms_txt_present=llms_txt_present,
)
for seo_data in playwright_data
])""",
"""
scan_total = len(playwright_data) + 2 # pages + robots.txt + sitemap/llms.txt
await _emit(progress_cb, _evt("scan", 0, scan_total, "Checking robots.txt", obs))
robots_txt = await _fetch_robots_txt(base_url)
robots_bot_permissions = _parse_robots_txt(robots_txt, path="/") if robots_txt else None
await _emit(progress_cb, _evt("scan", 1, scan_total, "Checking sitemap & llms.txt", obs))
sitemap_present, llms_txt_present = await _check_sitemap_and_llms_txt(base_url)
await _emit(progress_cb, _evt("scan", 2, scan_total, "Scanning page content", obs))
scanned = 2
async def _analyze_one(seo_data):
nonlocal scanned
res = await analyze_page_ai_visibility(
seo_data, domain, use_ai=use_ai,
robots_bot_permissions=robots_bot_permissions,
sitemap_present=sitemap_present, llms_txt_present=llms_txt_present,
)
scanned += 1
await _emit(progress_cb, _evt("scan", scanned, scan_total, "Scanning page content", obs, url=seo_data.get("url")))
return res
page_results = await asyncio.gather(*[_analyze_one(d) for d in playwright_data])
""",
)
p.sub(
"auto-generate emit",
"if auto_measure_observed:",
"""
if auto_measure_observed:
await _emit(progress_cb, _evt("analyze", 0, 1, "Inferring customer prompts from your homepage", True))
""",
)
# NOTE: the patch above replaces only the `if` line; the original body that
# follows stays attached to it (the emit is the first statement of the block).
p.sub(
"observed call",
"""observed = await analyze_observed_ai_visibility(
brand_name=inferred_brand_name or domain, target_prompts=target_prompts,
competitor_names=competitor_names, use_live_ai=use_live_ai_for_observed,
brand_facts=brand_facts,
)""",
"""
observed = await analyze_observed_ai_visibility(
brand_name=inferred_brand_name or domain, target_prompts=target_prompts,
competitor_names=competitor_names, use_live_ai=use_live_ai_for_observed,
brand_facts=brand_facts, progress_cb=progress_cb,
)
await _emit(progress_cb, _evt("report", 0, 1, "Building report", obs))
""",
)
return p.save()
# ----------------------------------------------------------------------------
# seo_analyzer.py
# ----------------------------------------------------------------------------
SEO_HELPERS = f'''
{MARKER}
_SEO_STAGE_RANGES = {{"discover": (0, 8), "crawl": (8, 45), "scan": (45, 70), "analyze": (70, 95), "report": (95, 100)}}
def _evt(stage, done=0, total=1, message="", **extra):
lo, hi = _SEO_STAGE_RANGES[stage]
frac = (done / total) if total else 1
return {{"stage": stage, "done": done, "total": total,
"progress": round(lo + (hi - lo) * frac, 1), "message": message, **extra}}
async def _emit(cb, evt):
"""Never let progress reporting break the analysis."""
if cb is None:
return
try:
r = cb(evt)
if asyncio.iscoroutine(r):
await r
except Exception:
pass
async def fetch_robots_txt(base_url):'''
def patch_seo_analyzer(path):
p = Patcher(path)
if MARKER in p.src:
print(f"- {p.path.name}: already patched, skipping")
return True
p.sub("helpers", "async def fetch_robots_txt(base_url):", SEO_HELPERS)
# latent bug: callers unpack two values, this returned a bare list
p.sub("empty-return fix", 'print("[fail] No URLs to fetch!")\n return []',
'print("[fail] No URLs to fetch!")\nreturn [], []')
p.sub("fetch signature", "async def fetch_all_pages_parallel(urls, max_concurrent=1):",
"async def fetch_all_pages_parallel(urls, max_concurrent=1, on_page=None):")
p.sub(
"fetch loop hook",
"result = await task\n results.append(result)",
"""
result = await task
results.append(result)
if on_page and result is not None:
try:
r = on_page(i + 1, len(urls), result)
if asyncio.iscoroutine(r):
await r
except Exception:
pass
""",
)
p.sub("suggestions signature", "async def generate_all_page_suggestions_parallel(pages):",
"async def generate_all_page_suggestions_parallel(pages, progress_cb=None):")
p.sub(
"suggestions tracker",
'suggestions = {}\n\n print(f"[ai] Generating AI suggestions for {len(pages)} pages in parallel...")',
"""
suggestions = {}
print(f"[ai] Generating AI suggestions for {len(pages)} pages in parallel...")
_ai_done = {"n": 0}
async def _tracked(coro):
try:
return await coro
finally:
_ai_done["n"] += 1
await _emit(progress_cb, _evt("analyze", _ai_done["n"], len(pages), "Generating AI suggestions"))
""",
)
p.sub("suggestions wrap", "task = generate_page_suggestions_async(page)",
"task = _tracked(generate_page_suggestions_async(page))")
p.sub("add_comprehensive signature", "async def add_comprehensive_suggestions_async(results):",
"async def add_comprehensive_suggestions_async(results, progress_cb=None):")
p.sub("add_comprehensive call", "suggestions_dict = await generate_all_page_suggestions_parallel(results)",
"suggestions_dict = await generate_all_page_suggestions_parallel(results, progress_cb=progress_cb)")
p.sub(
"orchestrator signature",
'async def run_seo_and_suggestions_async(base_url, max_pages=20, tmp_dir="/tmp", use_ai=True, max_concurrent=3):',
'async def run_seo_and_suggestions_async(base_url, max_pages=20, tmp_dir="/tmp", use_ai=True, max_concurrent=3, progress_cb=None):',
)
p.sub("emit discover start", "# STEP 1: Discover URLs in parallel",
'# STEP 1: Discover URLs in parallel\nawait _emit(progress_cb, _evt("discover", 0, 1, "Reading sitemap & internal links"))')
p.sub("emit discover done", 'print(f"[info] Found {len(urls)} URLs to analyze")',
'print(f"[info] Found {len(urls)} URLs to analyze")\n'
'await _emit(progress_cb, _evt("discover", 1, 1, f"Found {len(urls)} pages", urls_found=len(urls)))')
p.sub(
"crawl hook",
"playwright_data, failed_fetches = await fetch_all_pages_parallel(urls, max_concurrent)",
"""
async def _on_page(done, total, page):
await _emit(progress_cb, _evt(
"crawl", done, total, "Crawling pages",
url=page.get("requested_url") or page.get("url"),
ok=not page.get("fetch_failed"),
))
playwright_data, failed_fetches = await fetch_all_pages_parallel(urls, max_concurrent, on_page=_on_page)
""",
)
p.sub(
"scan emit",
"results = await analyze_pages_parallel(playwright_data, domain)",
"""
await _emit(progress_cb, _evt("scan", 0, 1, "Analyzing page content"))
results = await analyze_pages_parallel(playwright_data, domain)
await _emit(progress_cb, _evt("scan", 1, 1, "Page content analyzed"))
""",
)
p.sub("ai suggestions call", "await add_comprehensive_suggestions_async(results)",
"await add_comprehensive_suggestions_async(results, progress_cb=progress_cb)")
p.sub("report emit", "# STEP 6: Save to CSV",
'# STEP 6: Save to CSV\nawait _emit(progress_cb, _evt("report", 0, 1, "Building report"))')
p.sub("full audit signature",
"async def run_full_seo_audit(base_url, max_pages=20, use_ai=True, max_concurrent=3):",
"async def run_full_seo_audit(base_url, max_pages=20, use_ai=True, max_concurrent=3, progress_cb=None):")
p.sub("full audit call",
'tmp_dir="/tmp",\n use_ai=use_ai,\n max_concurrent=max_concurrent,',
'tmp_dir="/tmp",\nuse_ai=use_ai,\nmax_concurrent=max_concurrent,\nprogress_cb=progress_cb,')
return p.save()
# ----------------------------------------------------------------------------
# app.py
# ----------------------------------------------------------------------------
APP_HELPERS = f'''
{MARKER}
async def _stream_job(make_coro):
"""Run an analysis coroutine and yield its progress events as they happen,
then the final result. `make_coro(cb)` must return the awaitable."""
q: asyncio.Queue = asyncio.Queue()
async def cb(evt):
await q.put(evt)
task = asyncio.create_task(make_coro(cb))
try:
while not task.done() or not q.empty():
try:
evt = await asyncio.wait_for(q.get(), timeout=0.5)
except asyncio.TimeoutError:
continue
yield {{"type": "progress", **evt}}
result = await task
if isinstance(result, dict) and result.get("status") == "error":
yield {{"type": "error", "message": result.get("message", "Analysis failed")}}
else:
yield {{"type": "result", "data": json.loads(json.dumps(result, default=str))}}
except Exception as e:
yield {{"type": "error", "message": str(e)}}
finally:
if not task.done():
task.cancel()
def _normalize_url(url):
return url if str(url).startswith(("http://", "https://")) else f"https://{{url}}"
async def analyze_ai_stream(url, max_pages, max_concurrent, use_ai, measure_observed):
async for msg in _stream_job(lambda cb: run_ai_visibility_analysis(
base_url=_normalize_url(url), max_pages=int(max_pages), max_concurrent=int(max_concurrent),
use_ai=use_ai, auto_measure_observed=bool(measure_observed), progress_cb=cb,
)):
yield msg
async def analyze_seo_stream(url, max_pages, max_concurrent, use_ai):
async for msg in _stream_job(lambda cb: run_full_seo_audit(
base_url=_normalize_url(url), max_pages=int(max_pages),
use_ai=use_ai, max_concurrent=int(max_concurrent), progress_cb=cb,
)):
yield msg
# ---- Gradio Interface ----'''
APP_WIRING = '''
# Streaming endpoints (hidden; consumed by the frontend via @gradio/client)
with demo:
ai_stream_out = gr.JSON(visible=False)
ai_stream_btn = gr.Button("stream-ai", visible=False)
ai_stream_btn.click(
fn=analyze_ai_stream,
inputs=[url_input, max_pages_input, max_concurrent_input, use_ai_check, measure_observed_check],
outputs=ai_stream_out,
api_name="analyze_ai_stream",
)
seo_stream_out = gr.JSON(visible=False)
seo_stream_btn = gr.Button("stream-seo", visible=False)
seo_stream_btn.click(
fn=analyze_seo_stream,
inputs=[url_input, max_pages_input, max_concurrent_input, use_ai_check],
outputs=seo_stream_out,
api_name="analyze_seo_stream",
)
if __name__ == "__main__":'''
def patch_app(path):
p = Patcher(path)
if MARKER in p.src:
print(f"- {p.path.name}: already patched, skipping")
return True
p.sub("json import", "import gradio as gr", "import gradio as gr\nimport json")
p.sub("stream helpers", "# ---- Gradio Interface ----", APP_HELPERS)
p.sub("stream wiring", 'if __name__ == "__main__":', APP_WIRING)
return p.save()
if __name__ == "__main__":
root = Path(sys.argv[1] if len(sys.argv) > 1 else ".")
ok = True
for fn, name in ((patch_ai_visibility, "ai_visibility.py"),
(patch_seo_analyzer, "seo_analyzer.py"),
(patch_app, "app.py")):
f = root / name
if not f.exists():
print(f"✗ {name}: not found in {root.resolve()}")
ok = False
continue
ok = fn(f) and ok
sys.exit(0 if ok else 1)