refactor: replace ask/proxy UI with clean catalogue browser
All checks were successful
Stuffle/personal-learn/pipeline/head This commit looks good
All checks were successful
Stuffle/personal-learn/pipeline/head This commit looks good
personal-learn.armco.dev is a browse-only catalogue viewer: - list/filter 458 artifacts by module and type - video-timestamp deep-links, PDF source links, view-text links - sticky sidebar nav by course module, type filter chips, search Removed /api/ask proxy + AGOS_CORPUS_ID dependency entirely. "Talk to the content" lives in agos.armco.dev (corpus chat there). Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
416
server.py
416
server.py
@@ -1,40 +1,33 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
personal-learn content server + thin KB demo UI.
|
||||
personal-learn — course catalogue browser.
|
||||
|
||||
Serves the normalized corpus for agOS url-ingestion AND a small demo surface
|
||||
that proves "talk to my content" with provenance deep-links (jump straight to
|
||||
the video second / PDF page a chunk came from).
|
||||
Serves the normalized Futurense GenAI KB corpus so agOS url-ingestion can
|
||||
fetch artifacts, and provides a catalogue browser UI for navigating content
|
||||
(transcripts, slides, notebooks) with video-timestamp and PDF-page deep-links.
|
||||
|
||||
"Talk to the content" lives in agos.armco.dev (chat against the corpus there).
|
||||
|
||||
Routes:
|
||||
GET /kb/<path> normalized text artifacts (what agOS ingests)
|
||||
GET /files/<path> original artifacts (videos, PDFs) for deep-linking
|
||||
GET /catalog.json artifact catalog
|
||||
GET / demo UI: browse catalog + ask-your-content
|
||||
POST /api/ask server-side proxy -> agOS corpus query (avoids
|
||||
browser CORS/auth); returns chunks + parsed deep-links
|
||||
GET /kb/<path> normalized HTML artifacts served for agOS url-ingestion
|
||||
GET /files/<path> original media (videos, PDFs) for deep-link anchors
|
||||
GET /catalog.json full artifact catalog (JSON array)
|
||||
GET / catalogue browser UI
|
||||
|
||||
Env:
|
||||
KB_ROOT default /Users/mohit/futurense_courses
|
||||
AGOS_BASE default http://localhost:2000
|
||||
AGOS_CORPUS_ID corpus to query (set after ingest)
|
||||
AGOS_TOKEN optional bearer for prod agOS
|
||||
PORT default 8900
|
||||
KB_ROOT root that contains _kb_corpus/ and the raw course dirs (default /app)
|
||||
PORT default 8081
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import httpx
|
||||
import os
|
||||
from pathlib import Path
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, PlainTextResponse
|
||||
|
||||
from fastapi import FastAPI, Query
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
KB_ROOT = Path(os.environ.get("KB_ROOT", "/Users/mohit/futurense_courses"))
|
||||
KB_ROOT = Path(os.environ.get("KB_ROOT", "/app"))
|
||||
CORPUS_DIR = KB_ROOT / "_kb_corpus"
|
||||
AGOS_BASE = os.environ.get("AGOS_BASE", "http://localhost:2000").rstrip("/")
|
||||
CORPUS_ID = os.environ.get("AGOS_CORPUS_ID", "")
|
||||
AGOS_TOKEN = os.environ.get("AGOS_TOKEN", "")
|
||||
|
||||
app = FastAPI(title="personal-learn")
|
||||
|
||||
@@ -48,131 +41,272 @@ def catalog():
|
||||
return JSONResponse(json.loads(p.read_text()) if p.exists() else [])
|
||||
|
||||
|
||||
_TS = re.compile(r"\[(\d{2}):(\d{2}):(\d{2})\]")
|
||||
_PAGE = re.compile(r"\[page (\d+)\]")
|
||||
|
||||
|
||||
def _deeplink(chunk_meta: dict, content: str) -> dict:
|
||||
"""Derive a jump-to link from inline provenance markers in the chunk."""
|
||||
out = {"video_url": "", "ts": None, "page": None}
|
||||
m = _TS.search(content or "")
|
||||
if m:
|
||||
h, mi, s = int(m.group(1)), int(m.group(2)), int(m.group(3))
|
||||
out["ts"] = h * 3600 + mi * 60 + s
|
||||
p = _PAGE.search(content or "")
|
||||
if p:
|
||||
out["page"] = int(p.group(1))
|
||||
return out
|
||||
|
||||
|
||||
@app.post("/api/ask")
|
||||
async def ask(req: Request):
|
||||
body = await req.json()
|
||||
query = (body.get("query") or "").strip()
|
||||
corpus_id = body.get("corpus_id") or CORPUS_ID
|
||||
if not query:
|
||||
return JSONResponse({"error": "empty query"}, status_code=400)
|
||||
if not corpus_id:
|
||||
return JSONResponse({"error": "AGOS_CORPUS_ID not configured"}, status_code=400)
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if AGOS_TOKEN:
|
||||
headers["Authorization"] = f"Bearer {AGOS_TOKEN}"
|
||||
base = f"{AGOS_BASE}/api/v1/corpora/{corpus_id}"
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
r = await client.post(f"{base}/query",
|
||||
json={"query": query, "top_k": body.get("top_k", 5)},
|
||||
headers=headers)
|
||||
# Resolve source_id -> source.metadata so each chunk gets its
|
||||
# provenance base (video_url / source_url) — chunk metadata alone
|
||||
# doesn't carry the source's custom fields.
|
||||
src_meta = {}
|
||||
sr = await client.get(f"{base}/sources", headers=headers)
|
||||
if sr.status_code == 200:
|
||||
for s in sr.json().get("sources", []):
|
||||
m = s.get("metadata")
|
||||
if isinstance(m, str):
|
||||
try:
|
||||
m = json.loads(m)
|
||||
except Exception:
|
||||
m = {}
|
||||
src_meta[s.get("source_id")] = m or {}
|
||||
if r.status_code != 200:
|
||||
return JSONResponse({"error": f"agos {r.status_code}", "detail": r.text[:400]},
|
||||
status_code=502)
|
||||
data = r.json()
|
||||
results = []
|
||||
for ch in data.get("chunks", []):
|
||||
content = ch.get("content", "")
|
||||
dl = _deeplink(ch, content)
|
||||
sm = src_meta.get(ch.get("source_id"), {})
|
||||
results.append({
|
||||
"score": ch.get("score"),
|
||||
"content": content,
|
||||
"title": sm.get("title", ""),
|
||||
"course": sm.get("course", ""),
|
||||
"video_url": sm.get("video_url", ""),
|
||||
"source_url": sm.get("source_url", ""),
|
||||
"artifact_type": sm.get("artifact_type", ""),
|
||||
**dl,
|
||||
})
|
||||
return JSONResponse({"query": query, "results": results})
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def home():
|
||||
return HTML
|
||||
return _HTML
|
||||
|
||||
|
||||
HTML = """<!doctype html><html><head><meta charset="utf-8">
|
||||
# ── UI ─────────────────────────────────────────────────────────────────────
|
||||
_HTML = """<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>personal-learn · talk to my content</title>
|
||||
<title>personal-learn · course catalogue</title>
|
||||
<style>
|
||||
:root{--bg:#0e1117;--card:#161b22;--line:#30363d;--teal:#14b8a6;--ink:#e6edf3;--dim:#8b949e}
|
||||
*{box-sizing:border-box} body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.5 Inter,system-ui,sans-serif}
|
||||
header{padding:1.1rem 1.4rem;background:linear-gradient(135deg,#1e3a8a,#0f766e);display:flex;align-items:center;gap:.7rem}
|
||||
header h1{font-size:1.25rem;margin:0}
|
||||
.wrap{max-width:900px;margin:0 auto;padding:1.2rem}
|
||||
.ask{display:flex;gap:.5rem;margin:.5rem 0 1rem}
|
||||
.ask input{flex:1;padding:.75rem .9rem;border-radius:10px;border:1px solid var(--line);background:var(--card);color:var(--ink);font-size:1rem}
|
||||
.ask button{padding:.75rem 1.1rem;border:0;border-radius:10px;background:var(--teal);color:#012;font-weight:600;cursor:pointer}
|
||||
.res{border:1px solid var(--line);background:var(--card);border-radius:12px;padding:.9rem 1rem;margin:.6rem 0}
|
||||
.res .meta{color:var(--dim);font-size:.8rem;margin-bottom:.4rem;display:flex;gap:.6rem;flex-wrap:wrap;align-items:center}
|
||||
.score{color:var(--teal);font-weight:600}
|
||||
.jump{color:#012;background:var(--teal);border-radius:6px;padding:.15rem .5rem;text-decoration:none;font-size:.8rem;font-weight:600}
|
||||
.txt{white-space:pre-wrap;font-size:.92rem}
|
||||
.dim{color:var(--dim)} .count{color:var(--dim);font-size:.85rem}
|
||||
</style></head><body>
|
||||
<header><h1>personal-learn</h1><span class="dim">talk to my course content</span></header>
|
||||
<div class="wrap">
|
||||
<div class="ask">
|
||||
<input id="q" placeholder="Ask about your courses… e.g. how does agentic RAG self-correct?" />
|
||||
<button onclick="ask()">Ask</button>
|
||||
</div>
|
||||
<div id="out"></div>
|
||||
<p class="count" id="cat"></p>
|
||||
</div>
|
||||
<script>
|
||||
function fmtTs(s){if(s==null)return null;const h=(s/3600|0),m=((s%3600)/60|0),x=s%60;return (h?h+":":"")+String(m).padStart(2,"0")+":"+String(x).padStart(2,"0")}
|
||||
async function ask(){
|
||||
const q=document.getElementById('q').value.trim(); if(!q)return;
|
||||
const out=document.getElementById('out'); out.innerHTML='<p class=dim>Searching…</p>';
|
||||
const r=await fetch('/api/ask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({query:q,top_k:6})});
|
||||
const d=await r.json();
|
||||
if(d.error){out.innerHTML='<p class=dim>Error: '+d.error+' '+(d.detail||'')+'</p>';return}
|
||||
if(!d.results.length){out.innerHTML='<p class=dim>No matches.</p>';return}
|
||||
out.innerHTML=d.results.map(function(x){
|
||||
let jump='';
|
||||
if(x.ts!=null && x.video_url) jump='<a class=jump target=_blank href="'+x.video_url+'#t='+x.ts+'">▶ '+fmtTs(x.ts)+'</a>';
|
||||
else if(x.page!=null && x.source_url) jump='<a class=jump target=_blank href="'+x.source_url+'#page='+x.page+'">page '+x.page+'</a>';
|
||||
const src=[x.course,x.title].filter(Boolean).join(' · ');
|
||||
return '<div class=res><div class=meta><span class=score>'+(x.score||0).toFixed(3)+'</span>'+(jump||'')+'<span>'+(x.artifact_type||'')+'</span><span class=dim>'+src+'</span></div><div class=txt>'+(x.content||'').replace(/</g,'<').slice(0,700)+'</div></div>';
|
||||
}).join('');
|
||||
:root {
|
||||
--bg: #0e1117; --card: #161b22; --line: #30363d;
|
||||
--teal: #14b8a6; --ink: #e6edf3; --dim: #8b949e;
|
||||
--tag-bg: #1e293b;
|
||||
}
|
||||
document.getElementById('q').addEventListener('keydown',e=>{if(e.key==='Enter')ask()});
|
||||
fetch('/catalog.json').then(r=>r.json()).then(c=>{document.getElementById('cat').textContent=c.length+' artifacts indexed'});
|
||||
</script></body></html>"""
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: var(--bg); color: var(--ink);
|
||||
font: 15px/1.6 Inter, system-ui, sans-serif; }
|
||||
|
||||
/* ── header ── */
|
||||
header {
|
||||
padding: 1rem 1.4rem; display: flex; align-items: center; gap: .8rem;
|
||||
background: linear-gradient(135deg, #1e3a8a, #0f766e);
|
||||
position: sticky; top: 0; z-index: 10;
|
||||
}
|
||||
header h1 { margin: 0; font-size: 1.2rem; }
|
||||
.subtitle { color: #a7f3d0; font-size: .85rem; }
|
||||
|
||||
/* ── layout ── */
|
||||
.layout { display: grid; grid-template-columns: 240px 1fr; min-height: 100vh; }
|
||||
@media (max-width: 700px) { .layout { grid-template-columns: 1fr; } }
|
||||
|
||||
/* ── sidebar ── */
|
||||
nav {
|
||||
padding: 1rem; border-right: 1px solid var(--line);
|
||||
position: sticky; top: 57px; height: calc(100vh - 57px);
|
||||
overflow-y: auto; background: var(--card);
|
||||
}
|
||||
.nav-section { margin-bottom: 1.2rem; }
|
||||
.nav-label { color: var(--dim); font-size: .75rem; text-transform: uppercase;
|
||||
letter-spacing: .05em; margin-bottom: .4rem; }
|
||||
.nav-btn {
|
||||
display: block; width: 100%; text-align: left;
|
||||
padding: .35rem .6rem; border-radius: 6px; border: none;
|
||||
background: transparent; color: var(--ink); cursor: pointer;
|
||||
font-size: .88rem; transition: background .15s;
|
||||
}
|
||||
.nav-btn:hover { background: rgba(255,255,255,.06); }
|
||||
.nav-btn.active { background: rgba(20,184,166,.15); color: var(--teal); font-weight: 600; }
|
||||
|
||||
/* ── main ── */
|
||||
main { padding: 1.2rem 1.4rem; }
|
||||
.toolbar { display: flex; gap: .6rem; flex-wrap: wrap; align-items: center;
|
||||
margin-bottom: 1rem; }
|
||||
.search {
|
||||
flex: 1; min-width: 180px; padding: .55rem .8rem;
|
||||
border-radius: 8px; border: 1px solid var(--line);
|
||||
background: var(--card); color: var(--ink); font-size: .9rem;
|
||||
}
|
||||
.type-filter { display: flex; gap: .35rem; flex-wrap: wrap; }
|
||||
.type-btn {
|
||||
padding: .3rem .7rem; border-radius: 20px; border: 1px solid var(--line);
|
||||
background: var(--card); color: var(--dim); cursor: pointer;
|
||||
font-size: .8rem; transition: all .15s;
|
||||
}
|
||||
.type-btn.active { border-color: var(--teal); color: var(--teal);
|
||||
background: rgba(20,184,166,.1); }
|
||||
|
||||
.stats { color: var(--dim); font-size: .82rem; margin-bottom: .8rem; }
|
||||
|
||||
/* ── cards ── */
|
||||
.grid { display: grid; gap: .7rem; }
|
||||
.card {
|
||||
background: var(--card); border: 1px solid var(--line);
|
||||
border-radius: 10px; padding: .8rem 1rem;
|
||||
display: flex; gap: .8rem; align-items: flex-start;
|
||||
}
|
||||
.card:hover { border-color: #4b5563; }
|
||||
.type-icon { font-size: 1.3rem; flex-shrink: 0; margin-top: .05rem; }
|
||||
.card-body { flex: 1; min-width: 0; }
|
||||
.card-title {
|
||||
font-weight: 600; font-size: .92rem;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.card-meta { color: var(--dim); font-size: .78rem; margin-top: .15rem;
|
||||
display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; }
|
||||
.badge {
|
||||
display: inline-block; padding: .1rem .45rem; border-radius: 4px;
|
||||
font-size: .72rem; font-weight: 600;
|
||||
background: var(--tag-bg); color: var(--dim);
|
||||
}
|
||||
.badge.transcript { color: #34d399; background: rgba(52,211,153,.1); }
|
||||
.badge.pdf { color: #f87171; background: rgba(248,113,113,.1); }
|
||||
.badge.notebook { color: #fbbf24; background: rgba(251,191,36,.1); }
|
||||
.badge.material { color: #60a5fa; background: rgba(96,165,250,.1); }
|
||||
|
||||
.jump-links { display: flex; gap: .4rem; flex-wrap: wrap; margin-top: .4rem; }
|
||||
.jump {
|
||||
padding: .2rem .55rem; border-radius: 6px; font-size: .78rem; font-weight: 600;
|
||||
text-decoration: none; border: 1px solid;
|
||||
}
|
||||
.jump.video { color: #34d399; border-color: #34d399;
|
||||
background: rgba(52,211,153,.08); }
|
||||
.jump.pdf { color: #f87171; border-color: #f87171;
|
||||
background: rgba(248,113,113,.08); }
|
||||
.jump.view { color: var(--teal); border-color: var(--teal);
|
||||
background: rgba(20,184,166,.08); }
|
||||
|
||||
.empty { color: var(--dim); padding: 2rem 0; text-align: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<h1>personal-learn</h1>
|
||||
<span class="subtitle">Futurense GenAI course catalogue</span>
|
||||
</header>
|
||||
|
||||
<div class="layout">
|
||||
<nav id="nav">
|
||||
<div class="nav-section">
|
||||
<div class="nav-label">Module</div>
|
||||
</div>
|
||||
<div class="nav-section">
|
||||
<div class="nav-label">Stats</div>
|
||||
<div id="sidebar-stats" style="color:var(--dim);font-size:.82rem;line-height:1.8"></div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
<div class="toolbar">
|
||||
<input class="search" id="search" placeholder="Search title, course, date…" oninput="render()">
|
||||
<div class="type-filter" id="type-filter"></div>
|
||||
</div>
|
||||
<div class="stats" id="stats"></div>
|
||||
<div class="grid" id="grid"></div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const TYPE_ICONS = { transcript:'🎬', pdf:'📄', notebook:'📓', material:'📎' };
|
||||
|
||||
let ALL = [], activeModule = 'all', activeType = 'all';
|
||||
|
||||
const fmt = s => s.replace(/-/g,' ').replace(/^(Module|Academy)/,'$& ');
|
||||
const secToTs = s => {
|
||||
const h = (s/3600|0), m = ((s%3600)/60|0), x = s%60;
|
||||
return (h ? h+':' : '') + String(m).padStart(2,'0') + ':' + String(x).padStart(2,'0');
|
||||
};
|
||||
const parseTs = c => { const m = c.match(/\[(\d{2}):(\d{2}):(\d{2})\]/); return m ? +m[1]*3600 + +m[2]*60 + +m[3] : null; };
|
||||
const parsePage = c => { const m = c.match(/\[page (\d+)\]/); return m ? +m[1] : null; };
|
||||
|
||||
async function loadCatalog() {
|
||||
const r = await fetch('/catalog.json');
|
||||
ALL = await r.json();
|
||||
buildNav();
|
||||
buildTypeFilter();
|
||||
render();
|
||||
}
|
||||
|
||||
function buildNav() {
|
||||
const modules = ['all', ...new Set(ALL.map(a => a.course_folder))];
|
||||
const nav = document.getElementById('nav');
|
||||
const section = nav.querySelector('.nav-section');
|
||||
section.innerHTML = '<div class="nav-label">Module</div>';
|
||||
modules.forEach(m => {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'nav-btn' + (m === 'all' ? ' active' : '');
|
||||
btn.textContent = m === 'all' ? 'All modules' : fmt(m);
|
||||
btn.title = m === 'all' ? '' : m;
|
||||
btn.onclick = () => { activeModule = m; updateNav(); render(); };
|
||||
section.appendChild(btn);
|
||||
});
|
||||
|
||||
// sidebar stats
|
||||
const byType = {};
|
||||
ALL.forEach(a => byType[a.type] = (byType[a.type]||0)+1);
|
||||
document.getElementById('sidebar-stats').innerHTML =
|
||||
Object.entries(byType).map(([t,n]) => `<span>${TYPE_ICONS[t]||'•'} ${n} ${t}s</span>`).join('<br>');
|
||||
}
|
||||
|
||||
function updateNav() {
|
||||
document.querySelectorAll('.nav-btn').forEach(b => {
|
||||
const m = b.title || (b.textContent.trim() === 'All modules' ? 'all' : b.textContent.trim());
|
||||
b.classList.toggle('active', m === activeModule);
|
||||
});
|
||||
}
|
||||
|
||||
function buildTypeFilter() {
|
||||
const types = ['all', ...new Set(ALL.map(a => a.type))];
|
||||
const el = document.getElementById('type-filter');
|
||||
types.forEach(t => {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'type-btn' + (t === 'all' ? ' active' : '');
|
||||
btn.textContent = t === 'all' ? 'All types' : (TYPE_ICONS[t]||'') + ' ' + t;
|
||||
btn.onclick = () => { activeType = t; updateTypeFilter(); render(); };
|
||||
el.appendChild(btn);
|
||||
});
|
||||
}
|
||||
|
||||
function updateTypeFilter() {
|
||||
document.querySelectorAll('.type-btn').forEach(b => {
|
||||
const t = b.textContent.replace(/^[^ ]+ /,'').trim();
|
||||
const is = (t === 'All types' && activeType === 'all') || t === activeType;
|
||||
b.classList.toggle('active', is);
|
||||
});
|
||||
}
|
||||
|
||||
function render() {
|
||||
const q = document.getElementById('search').value.toLowerCase().trim();
|
||||
let items = ALL;
|
||||
if (activeModule !== 'all') items = items.filter(a => a.course_folder === activeModule);
|
||||
if (activeType !== 'all') items = items.filter(a => a.type === activeType);
|
||||
if (q) items = items.filter(a =>
|
||||
(a.title||'').toLowerCase().includes(q) ||
|
||||
(a.course||'').toLowerCase().includes(q) ||
|
||||
(a.date||'').includes(q)
|
||||
);
|
||||
|
||||
document.getElementById('stats').textContent =
|
||||
items.length + ' artifact' + (items.length !== 1 ? 's' : '') +
|
||||
(q || activeModule !== 'all' || activeType !== 'all' ? ' (filtered)' : '');
|
||||
|
||||
document.getElementById('grid').innerHTML = items.length
|
||||
? items.map(cardHTML).join('')
|
||||
: '<div class="empty">No artifacts match the current filters.</div>';
|
||||
}
|
||||
|
||||
function cardHTML(a) {
|
||||
const icon = TYPE_ICONS[a.type] || '•';
|
||||
const badge = `<span class="badge ${a.type}">${a.type}</span>`;
|
||||
const date = a.date ? `<span>${a.date}</span>` : '';
|
||||
const course = a.course ? `<span style="color:var(--dim)">${a.course}</span>` : '';
|
||||
|
||||
const links = [];
|
||||
if (a.type === 'transcript' && a.video_url) {
|
||||
links.push(`<a class="jump video" href="${a.video_url}" target="_blank">▶ video</a>`);
|
||||
}
|
||||
if (a.source_url && a.type === 'pdf') {
|
||||
links.push(`<a class="jump pdf" href="${a.source_url}" target="_blank">📄 PDF</a>`);
|
||||
}
|
||||
if (a.served_url) {
|
||||
links.push(`<a class="jump view" href="${a.served_url}" target="_blank">view text</a>`);
|
||||
}
|
||||
|
||||
return `<div class="card">
|
||||
<div class="type-icon">${icon}</div>
|
||||
<div class="card-body">
|
||||
<div class="card-title">${esc(a.title || a.id)}</div>
|
||||
<div class="card-meta">${badge}${date}${course}</div>
|
||||
${links.length ? '<div class="jump-links">' + links.join('') + '</div>' : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const esc = s => s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
|
||||
loadCatalog();
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", "8900")))
|
||||
uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", "8081")))
|
||||
|
||||
Reference in New Issue
Block a user