refactor: replace ask/proxy UI with clean catalogue browser
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:
2026-10-06 11:42:36 +05:30
parent 2945526f83
commit af4412f3e7

416
server.py
View File

@@ -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,'&lt;').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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
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")))