diff --git a/server.py b/server.py index 763ce95..6c4dd57 100644 --- a/server.py +++ b/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/ normalized text artifacts (what agOS ingests) - GET /files/ 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/ normalized HTML artifacts served for agOS url-ingestion + GET /files/ 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 = """ +# ── UI ───────────────────────────────────────────────────────────────────── +_HTML = """ + + + -personal-learn · talk to my content +personal-learn · course catalogue -

personal-learn

talk to my course content
-
-
- - -
-
-

-
-""" +* { 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; } + + + + +
+

personal-learn

+ Futurense GenAI course catalogue +
+ +
+ + +
+
+ +
+
+
+
+
+
+ + + +""" 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")))