Add Dockerfile, Nomad spec, Gitea CI/CD, requirements and health endpoint
Some checks failed
Build and Deploy BlaaAi / build-and-deploy (push) Has been cancelled
Some checks failed
Build and Deploy BlaaAi / build-and-deploy (push) Has been cancelled
This commit is contained in:
278
app.py
Normal file
278
app.py
Normal file
@@ -0,0 +1,278 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
BlaaAi — AI-powered DBA listing analyzer
|
||||
FastAPI backend
|
||||
|
||||
Usage:
|
||||
uvicorn app:app --reload --port 8000
|
||||
"""
|
||||
|
||||
import json, os, smtplib
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime, timezone
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import BackgroundTasks, FastAPI, HTTPException
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pydantic import BaseModel
|
||||
from starlette.requests import Request
|
||||
|
||||
from fetch_dba import (
|
||||
DATA_DIR, ITEM_CACHE, create_search, detect_domain, enrich_listings,
|
||||
fetch_page, find_new, listings_file, load_meta, load_seen,
|
||||
save_seen, seen_file, list_searches,
|
||||
)
|
||||
from score import CRITERIA, METRICS_FILE, score_listings
|
||||
|
||||
app = FastAPI(title="BlaaAi")
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
executor = ThreadPoolExecutor(max_workers=4)
|
||||
|
||||
|
||||
# ── Meta helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
def update_meta(search_id: str, **kwargs) -> dict:
|
||||
p = DATA_DIR / search_id / "meta.json"
|
||||
meta = json.loads(p.read_text())
|
||||
meta.update(kwargs)
|
||||
p.write_text(json.dumps(meta, ensure_ascii=False, indent=2))
|
||||
return meta
|
||||
|
||||
|
||||
# ── Background pipeline ───────────────────────────────────────────────────────
|
||||
|
||||
def run_fetch_and_score(search_id: str, prefs: str = "") -> None:
|
||||
"""Synchronous fetch + score pipeline — runs in thread pool."""
|
||||
try:
|
||||
meta = load_meta(search_id)
|
||||
search_url = meta["url"]
|
||||
|
||||
# ── Fetch ──────────────────────────────────────────────────────────────
|
||||
update_meta(search_id, status="fetching")
|
||||
sf = seen_file(search_id)
|
||||
lf = listings_file(search_id)
|
||||
seen = load_seen(sf)
|
||||
|
||||
listings, _ = fetch_page(search_url, 1)
|
||||
new_items = find_new(listings, seen)
|
||||
|
||||
if new_items:
|
||||
new_items = enrich_listings(new_items)
|
||||
existing = json.loads(lf.read_text()) if lf.exists() else []
|
||||
existing.extend(new_items)
|
||||
lf.write_text(json.dumps(existing, ensure_ascii=False, indent=2))
|
||||
save_seen(sf, seen | {l["id"] for l in listings})
|
||||
|
||||
# ── Score ──────────────────────────────────────────────────────────────
|
||||
update_meta(search_id, status="scoring")
|
||||
items = json.loads(lf.read_text()) if lf.exists() else []
|
||||
domain = detect_domain(search_url)
|
||||
criteria = CRITERIA[domain]
|
||||
|
||||
score_listings(items, criteria, prefs, force=False, source_file=lf)
|
||||
|
||||
update_meta(
|
||||
search_id,
|
||||
status="ready",
|
||||
listing_count=len(items),
|
||||
last_scored_at=datetime.now().isoformat(timespec="seconds"),
|
||||
prefs=prefs,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
update_meta(search_id, status="error", error=str(e))
|
||||
raise
|
||||
|
||||
|
||||
# ── Pydantic models ───────────────────────────────────────────────────────────
|
||||
|
||||
class NewSearchRequest(BaseModel):
|
||||
url: str
|
||||
prefs: Optional[str] = ""
|
||||
|
||||
|
||||
class EmailRequest(BaseModel):
|
||||
email: str
|
||||
|
||||
|
||||
# ── Routes ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {
|
||||
"status": "healthy",
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"version": os.getenv("BUILD_VERSION", "unknown"),
|
||||
"commit": os.getenv("GIT_COMMIT", "unknown")[:7],
|
||||
}
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request):
|
||||
return templates.TemplateResponse(request, "index.html")
|
||||
|
||||
|
||||
@app.get("/search/{search_id}", response_class=HTMLResponse)
|
||||
async def search_view(request: Request, search_id: str):
|
||||
return templates.TemplateResponse(request, "index.html", {"search_id": search_id})
|
||||
|
||||
|
||||
@app.post("/api/searches")
|
||||
async def create(body: NewSearchRequest, background_tasks: BackgroundTasks):
|
||||
search_id = create_search(body.url)
|
||||
update_meta(search_id, status="queued", prefs=body.prefs or "")
|
||||
background_tasks.add_task(run_fetch_and_score, search_id, body.prefs or "")
|
||||
return {"id": search_id, "status": "queued"}
|
||||
|
||||
|
||||
@app.get("/api/searches")
|
||||
async def get_all():
|
||||
return list_searches()
|
||||
|
||||
|
||||
@app.get("/api/searches/{search_id}")
|
||||
async def get_search(search_id: str):
|
||||
try:
|
||||
meta = load_meta(search_id)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(404, "Søgning ikke fundet")
|
||||
|
||||
lf = listings_file(search_id)
|
||||
if lf.exists():
|
||||
items = json.loads(lf.read_text())
|
||||
ranked = sorted(
|
||||
[i for i in items if i.get("ai_score") is not None],
|
||||
key=lambda x: x["ai_score"],
|
||||
reverse=True,
|
||||
)
|
||||
meta["listings"] = ranked
|
||||
meta["listing_count"] = len(items)
|
||||
meta["scored_count"] = len(ranked)
|
||||
if "status" not in meta and ranked:
|
||||
meta["status"] = "ready"
|
||||
|
||||
if "status" not in meta:
|
||||
meta["status"] = "ready" if meta.get("listing_count", 0) > 0 else "unknown"
|
||||
|
||||
return meta
|
||||
|
||||
|
||||
@app.post("/api/searches/{search_id}/rescore")
|
||||
async def rescore(search_id: str, body: NewSearchRequest, background_tasks: BackgroundTasks):
|
||||
try:
|
||||
load_meta(search_id)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(404, "Søgning ikke fundet")
|
||||
|
||||
update_meta(search_id, status="queued", prefs=body.prefs or "")
|
||||
background_tasks.add_task(run_fetch_and_score, search_id, body.prefs or "")
|
||||
return {"id": search_id, "status": "queued"}
|
||||
|
||||
|
||||
@app.get("/metrics")
|
||||
async def get_metrics():
|
||||
global_metrics = {}
|
||||
if METRICS_FILE.exists():
|
||||
global_metrics = json.loads(METRICS_FILE.read_text())
|
||||
|
||||
# Per-search breakdown
|
||||
searches = []
|
||||
if DATA_DIR.exists():
|
||||
for d in sorted(DATA_DIR.iterdir(), key=lambda p: p.stat().st_mtime, reverse=True):
|
||||
mf = d / "metrics.json"
|
||||
if mf.exists():
|
||||
searches.append(json.loads(mf.read_text()))
|
||||
|
||||
# Item cache stats
|
||||
cached_items = len(list(ITEM_CACHE.glob("*.json"))) if ITEM_CACHE.exists() else 0
|
||||
|
||||
return {**global_metrics, "item_cache_size": cached_items, "searches": searches}
|
||||
|
||||
|
||||
@app.post("/api/searches/{search_id}/email")
|
||||
async def send_email(search_id: str, body: EmailRequest):
|
||||
try:
|
||||
meta = load_meta(search_id)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(404, "Søgning ikke fundet")
|
||||
|
||||
if meta.get("status") != "ready":
|
||||
raise HTTPException(400, "Analysen er ikke færdig endnu")
|
||||
|
||||
lf = listings_file(search_id)
|
||||
if not lf.exists():
|
||||
raise HTTPException(400, "Ingen resultater at sende")
|
||||
|
||||
items = json.loads(lf.read_text())
|
||||
ranked = sorted(
|
||||
[i for i in items if i.get("ai_score") is not None],
|
||||
key=lambda x: x["ai_score"],
|
||||
reverse=True,
|
||||
)[:10]
|
||||
|
||||
html = _build_email_html(ranked, meta)
|
||||
_send_email(body.email, "🔍 Dine DBA-resultater fra BlaaAi", html)
|
||||
return {"status": "sent", "to": body.email}
|
||||
|
||||
|
||||
# ── Email helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _build_email_html(ranked: list[dict], meta: dict) -> str:
|
||||
rows = ""
|
||||
for i, item in enumerate(ranked, 1):
|
||||
score = item.get("ai_score", 0)
|
||||
bar = "█" * int(score) + "░" * (10 - int(score))
|
||||
reason = item.get("ai_reason", "")
|
||||
warn = f'<p style="color:#dc2626;font-size:13px">⚠️ {item["ai_warnings"]}</p>' if item.get("ai_warnings") else ""
|
||||
rows += f"""
|
||||
<tr>
|
||||
<td style="padding:16px;border-bottom:1px solid #e5e7eb">
|
||||
<strong>#{i} [{score}] {item['name']}</strong><br>
|
||||
<span style="color:#6b7280">{item.get('price_dkk','?')} DKK</span>
|
||||
<span style="font-family:monospace;color:#6366f1;margin-left:8px">{bar}</span><br>
|
||||
<p style="margin:6px 0;font-size:14px">{reason}</p>
|
||||
{warn}
|
||||
<a href="{item['url']}" style="color:#6366f1;font-size:13px">Se annonce →</a>
|
||||
</td>
|
||||
</tr>"""
|
||||
|
||||
return f"""
|
||||
<html><body style="font-family:sans-serif;max-width:600px;margin:0 auto;padding:20px">
|
||||
<h1 style="color:#1f2937">🔍 Dine DBA-resultater</h1>
|
||||
<p style="color:#6b7280">Søgning: <a href="{meta['url']}">{meta['url'][:60]}…</a></p>
|
||||
<table style="width:100%;border-collapse:collapse">{rows}</table>
|
||||
<p style="color:#9ca3af;font-size:12px;margin-top:24px">Leveret af BlaaAi</p>
|
||||
</body></html>"""
|
||||
|
||||
|
||||
def _send_email(to: str, subject: str, html: str) -> None:
|
||||
host = os.environ.get("SMTP_HOST", "")
|
||||
user = os.environ.get("SMTP_USER", "")
|
||||
pwd = os.environ.get("SMTP_PASS", "")
|
||||
port = int(os.environ.get("SMTP_PORT", "587"))
|
||||
|
||||
if not host:
|
||||
raise HTTPException(503, "Email ikke konfigureret (SMTP_HOST mangler)")
|
||||
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = user
|
||||
msg["To"] = to
|
||||
msg.attach(MIMEText(html, "html"))
|
||||
|
||||
with smtplib.SMTP(host, port) as s:
|
||||
s.starttls()
|
||||
s.login(user, pwd)
|
||||
s.sendmail(user, to, msg.as_string())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
port = int(os.getenv("PORT", "8000"))
|
||||
uvicorn.run("app:app", host="0.0.0.0", port=port, reload=True)
|
||||
Reference in New Issue
Block a user