LockedIn / app /api /docs_page.py
JermaineAI's picture
Add custom API documentation page
a8990e8
Raw History Blame Contribute Delete
30.3 kB
from fastapi import APIRouter from fastapi.responses import HTMLResponse router = APIRouter() @router.get("/api-docs", include_in_schema=False) async def custom_api_docs() -> HTMLResponse: return HTMLResponse(_DOCS_HTML) _DOCS_HTML = r""" <!doctype html> <html lang="en"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>LockedIn AI Service API Docs</title> <meta name="description" content="Beautiful custom API documentation for the LockedIn AI Service roadmap generation API." /> <style> :root { color-scheme: dark; --bg: #07110f; --panel: rgba(255, 255, 255, 0.075); --panel-strong: rgba(255, 255, 255, 0.12); --line: rgba(255, 255, 255, 0.16); --text: #f5fff9; --muted: #b6c8bf; --soft: #8aa79a; --green: #72f2a7; --cyan: #7be7ff; --gold: #ffd166; --red: #ff7a90; --shadow: 0 28px 80px rgba(0, 0, 0, 0.34); --radius: 8px; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } * { box-sizing: border-box; } html { scroll-behavior: smooth; } body { margin: 0; min-height: 100vh; color: var(--text); background: radial-gradient(circle at 12% 12%, rgba(114, 242, 167, 0.17), transparent 26rem), radial-gradient(circle at 85% 4%, rgba(123, 231, 255, 0.13), transparent 30rem), linear-gradient(135deg, #07110f 0%, #0b1715 45%, #10170d 100%); letter-spacing: 0; } a { color: inherit; } .shell { width: min(1180px, calc(100% - 32px)); margin: 0 auto; } .nav { position: sticky; top: 0; z-index: 20; border-bottom: 1px solid var(--line); background: rgba(7, 17, 15, 0.82); backdrop-filter: blur(18px); } .nav-inner { display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 14px 0; } .brand { display: flex; align-items: center; gap: 12px; min-width: 0; } .mark { display: grid; width: 38px; height: 38px; place-items: center; border: 1px solid rgba(114, 242, 167, 0.38); border-radius: var(--radius); color: #06100d; background: linear-gradient(135deg, var(--green), var(--cyan)); font-weight: 900; box-shadow: 0 14px 38px rgba(114, 242, 167, 0.22); } .brand-copy strong { display: block; font-size: 0.96rem; } .brand-copy span { display: block; color: var(--soft); font-size: 0.8rem; } .nav-links { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; justify-content: flex-end; } .nav-links a, .button { display: inline-flex; align-items: center; justify-content: center; min-height: 36px; padding: 0 12px; border: 1px solid transparent; border-radius: var(--radius); color: var(--muted); text-decoration: none; font-size: 0.88rem; transition: 160ms ease; } .nav-links a:hover, .button:hover { border-color: var(--line); color: var(--text); background: rgba(255, 255, 255, 0.08); } .button.primary { color: #06100d; border-color: transparent; background: linear-gradient(135deg, var(--green), var(--cyan)); font-weight: 800; } .hero { padding: 74px 0 46px; } .hero-grid { display: grid; grid-template-columns: minmax(0, 1.08fr) minmax(330px, 0.72fr); gap: 28px; align-items: stretch; } .eyebrow { display: inline-flex; align-items: center; gap: 8px; width: fit-content; padding: 8px 10px; border: 1px solid rgba(114, 242, 167, 0.28); border-radius: var(--radius); color: var(--green); background: rgba(114, 242, 167, 0.08); font-size: 0.84rem; font-weight: 700; } .dot { width: 8px; height: 8px; border-radius: 99px; background: var(--green); box-shadow: 0 0 18px var(--green); } h1, h2, h3 { margin: 0; line-height: 1.04; letter-spacing: 0; } h1 { max-width: 880px; margin-top: 22px; font-size: clamp(2.75rem, 7vw, 6.6rem); } .lead { max-width: 760px; margin: 22px 0 0; color: var(--muted); font-size: clamp(1rem, 2vw, 1.26rem); line-height: 1.65; } .hero-actions { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 28px; } .hero-card { padding: 22px; border: 1px solid var(--line); border-radius: var(--radius); background: linear-gradient(180deg, rgba(255, 255, 255, 0.11), rgba(255, 255, 255, 0.055)); box-shadow: var(--shadow); } .hero-card h2 { margin-bottom: 14px; font-size: 1.05rem; } .stat-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } .stat { min-height: 92px; padding: 14px; border: 1px solid var(--line); border-radius: var(--radius); background: rgba(0, 0, 0, 0.18); } .stat strong { display: block; color: var(--green); font-size: 1.55rem; } .stat span { display: block; margin-top: 6px; color: var(--muted); font-size: 0.86rem; line-height: 1.35; } section { padding: 42px 0; } .section-head { display: flex; align-items: end; justify-content: space-between; gap: 18px; margin-bottom: 18px; } .section-head h2 { font-size: clamp(1.7rem, 3vw, 2.6rem); } .section-head p { max-width: 560px; margin: 0; color: var(--muted); line-height: 1.6; } .grid { display: grid; gap: 14px; } .grid.three { grid-template-columns: repeat(3, minmax(0, 1fr)); } .grid.two { grid-template-columns: repeat(2, minmax(0, 1fr)); } .panel { border: 1px solid var(--line); border-radius: var(--radius); background: var(--panel); box-shadow: 0 16px 44px rgba(0, 0, 0, 0.16); } .panel-pad { padding: 20px; } .panel h3 { margin-bottom: 10px; font-size: 1.08rem; } .panel p, .panel li { color: var(--muted); line-height: 1.62; } .panel p { margin: 0; } .endpoint { overflow: hidden; } .endpoint-top { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 18px 20px; border-bottom: 1px solid var(--line); background: rgba(255, 255, 255, 0.08); } .method { display: inline-flex; align-items: center; min-height: 32px; padding: 0 10px; border-radius: var(--radius); color: #06100d; background: var(--green); font-weight: 900; font-size: 0.82rem; } code, pre { font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; } .path { color: var(--text); font-weight: 700; overflow-wrap: anywhere; } .tag-list { display: flex; flex-wrap: wrap; gap: 8px; } .tag { display: inline-flex; align-items: center; min-height: 30px; padding: 0 10px; border: 1px solid var(--line); border-radius: var(--radius); color: var(--muted); background: rgba(255, 255, 255, 0.05); font-size: 0.82rem; } .code-card { overflow: hidden; border: 1px solid var(--line); border-radius: var(--radius); background: #06100d; } .code-top { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 46px; padding: 0 12px 0 16px; border-bottom: 1px solid rgba(255, 255, 255, 0.12); color: var(--soft); background: rgba(255, 255, 255, 0.045); font-size: 0.82rem; } .copy { min-height: 30px; padding: 0 10px; border: 1px solid var(--line); border-radius: var(--radius); color: var(--text); background: rgba(255, 255, 255, 0.08); cursor: pointer; } pre { margin: 0; padding: 18px; overflow-x: auto; color: #d9ffe8; font-size: 0.88rem; line-height: 1.62; } .table { overflow: hidden; border: 1px solid var(--line); border-radius: var(--radius); } .row { display: grid; grid-template-columns: 150px minmax(0, 1fr) 130px; gap: 16px; padding: 14px 16px; border-bottom: 1px solid var(--line); } .row:last-child { border-bottom: 0; } .row.header { color: var(--soft); background: rgba(255, 255, 255, 0.07); font-size: 0.78rem; font-weight: 800; text-transform: uppercase; } .row div { min-width: 0; overflow-wrap: anywhere; } .status { display: inline-flex; align-items: center; width: fit-content; min-height: 28px; padding: 0 9px; border-radius: var(--radius); font-weight: 900; font-size: 0.8rem; } .ok { color: #06100d; background: var(--green); } .warn { color: #171003; background: var(--gold); } .bad { color: #21040a; background: var(--red); } .timeline { display: grid; gap: 12px; } .step { display: grid; grid-template-columns: 42px minmax(0, 1fr); gap: 14px; } .step-num { display: grid; width: 42px; height: 42px; place-items: center; border: 1px solid rgba(114, 242, 167, 0.36); border-radius: var(--radius); color: var(--green); background: rgba(114, 242, 167, 0.08); font-weight: 900; } .footer { padding: 34px 0 56px; color: var(--soft); text-align: center; } @media (max-width: 900px) { .hero-grid, .grid.two, .grid.three { grid-template-columns: 1fr; } .section-head { align-items: start; flex-direction: column; } .nav-inner { align-items: flex-start; flex-direction: column; } .nav-links { justify-content: flex-start; } .row { grid-template-columns: 1fr; gap: 8px; } } @media (max-width: 560px) { .shell { width: min(100% - 22px, 1180px); } .hero { padding-top: 42px; } .stat-grid { grid-template-columns: 1fr; } } </style> </head> <body> <nav class="nav"> <div class="shell nav-inner"> <a class="brand" href="#top" aria-label="LockedIn API docs home"> <span class="mark">LI</span> <span class="brand-copy"> <strong>LockedIn AI Service</strong> <span>Roadmap generation API</span> </span> </a> <div class="nav-links" aria-label="Documentation navigation"> <a href="#overview">Overview</a> <a href="#endpoint">Endpoint</a> <a href="#schema">Schema</a> <a href="#errors">Errors</a> <a href="/docs">Swagger</a> <a class="button primary" href="/openapi.json">OpenAPI JSON</a> </div> </div> </nav> <main id="top"> <header class="hero"> <div class="shell hero-grid"> <div> <span class="eyebrow"><span class="dot"></span> Production API documentation</span> <h1>Generate clear learning roadmaps from one API request.</h1> <p class="lead"> LockedIn turns a learner's goal into a structured roadmap with phases, learning nodes, curated free resources, practical projects, stable backend IDs, and metadata your frontend can render immediately. </p> <div class="hero-actions"> <a class="button primary" href="#quickstart">Start with a request</a> <a class="button" href="/health">Check health</a> <a class="button" href="/redoc">Open ReDoc</a> </div> </div> <aside class="hero-card" aria-label="API summary"> <h2>Service snapshot</h2> <div class="stat-grid"> <div class="stat"> <strong>1</strong> <span>Main generation endpoint</span> </div> <div class="stat"> <strong>6</strong> <span>Supported free resource types</span> </div> <div class="stat"> <strong>3 x 3</strong> <span>Phases and nodes per roadmap</span> </div> <div class="stat"> <strong>7860</strong> <span>Default Hugging Face Spaces port</span> </div> </div> </aside> </div> </header> <section id="overview"> <div class="shell"> <div class="section-head"> <h2>What This API Does</h2> <p> A focused backend for generating beginner-friendly learning plans. The frontend sends a skill and optional preferences; the API handles search, model generation, validation, IDs, caching, and safe error responses. </p> </div> <div class="grid three"> <article class="panel panel-pad"> <h3>Curates free resources</h3> <p> The service searches Tavily and YouTube, filters paid-looking or invalid links, and only allows generated roadmaps to use known candidate URLs. </p> </article> <article class="panel panel-pad"> <h3>Returns frontend-ready JSON</h3> <p> Responses include stable IDs, normalized skill names, phases, nodes, project details, resources, source metadata, and cache status. </p> </article> <article class="panel panel-pad"> <h3>Keeps production errors safe</h3> <p> Users receive clean messages and a request ID. Detailed stack traces stay in container logs so developers can diagnose failures without leaking internals. </p> </article> </div> </div> </section> <section id="quickstart"> <div class="shell"> <div class="section-head"> <h2>Quick Start</h2> <p> The smallest valid request only needs a skill. Defaults fill in learner level, goals, time commitment, preferred resource types, and language. </p> </div> <div class="grid two"> <div class="code-card"> <div class="code-top"> <span>curl</span> <button class="copy" data-copy="curl-example">Copy</button> </div> <pre id="curl-example">curl -X POST https://jaykay73-lockedin.hf.space/api/v1/roadmaps/generate \ -H "Content-Type: application/json" \ -d '{"skill":"Learn Python"}'</pre> </div> <div class="code-card"> <div class="code-top"> <span>JavaScript fetch</span> <button class="copy" data-copy="fetch-example">Copy</button> </div> <pre id="fetch-example">const response = await fetch( "https://jaykay73-lockedin.hf.space/api/v1/roadmaps/generate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ skill: "Learn Python" }) } ); const body = await response.json(); if (!response.ok || body.success === false) { throw new Error(body.error?.message ?? "Roadmap generation failed"); } console.log(body.data);</pre> </div> </div> </div> </section> <section id="endpoint"> <div class="shell"> <div class="section-head"> <h2>Endpoint Reference</h2> <p> The generation endpoint is asynchronous from the client perspective: send one POST request and wait for a validated roadmap response. </p> </div> <article class="panel endpoint"> <div class="endpoint-top"> <div> <span class="method">POST</span> <span class="path">/api/v1/roadmaps/generate</span> </div> <span class="tag">Returns JSON</span> </div> <div class="panel-pad"> <p> Generates a complete learning roadmap for a requested skill. The API searches for free resources, asks the model to produce a roadmap using only those resources, validates the final schema, assigns IDs, and caches valid results. </p> <div style="height: 16px"></div> <div class="tag-list"> <span class="tag">Roadmaps</span> <span class="tag">AI generation</span> <span class="tag">Validated schema</span> <span class="tag">Cached results</span> </div> </div> </article> </div> </section> <section id="schema"> <div class="shell"> <div class="section-head"> <h2>Request Body</h2> <p> Send only <code>skill</code> for the default experience, or include optional fields to tailor the roadmap to a learner's level, goals, time, format preferences, and language. </p> </div> <div class="table" role="table" aria-label="Request fields"> <div class="row header" role="row"> <div>Field</div> <div>Description</div> <div>Required</div> </div> <div class="row" role="row"> <div><code>skill</code></div> <div>The skill or topic to learn. Example: <code>Learn Python</code>.</div> <div><span class="status ok">Required</span></div> </div> <div class="row" role="row"> <div><code>user_level</code></div> <div>Current learner level. Default: <code>complete beginner</code>.</div> <div><span class="status warn">Optional</span></div> </div> <div class="row" role="row"> <div><code>goal</code></div> <div>The learner's outcome. Default: learn step by step and build practical confidence.</div> <div><span class="status warn">Optional</span></div> </div> <div class="row" role="row"> <div><code>time_commitment</code></div> <div>Expected study time. Default: <code>3 to 5 hours per week</code>.</div> <div><span class="status warn">Optional</span></div> </div> <div class="row" role="row"> <div><code>preferred_resource_types</code></div> <div> Defaults to all supported formats: <code>youtube_video</code>, <code>article</code>, <code>free_course</code>, <code>documentation</code>, <code>free_book</code>, <code>interactive_practice</code>. </div> <div><span class="status warn">Optional</span></div> </div> <div class="row" role="row"> <div><code>language</code></div> <div>Preferred output language. Default: <code>English</code>.</div> <div><span class="status warn">Optional</span></div> </div> </div> <div style="height: 16px"></div> <div class="code-card"> <div class="code-top"> <span>Full request example</span> <button class="copy" data-copy="request-example">Copy</button> </div> <pre id="request-example">{ "skill": "Learn Python", "user_level": "complete beginner", "goal": "build small automation projects", "time_commitment": "5 hours per week", "preferred_resource_types": [ "youtube_video", "article", "free_course", "documentation", "free_book", "interactive_practice" ], "language": "English" }</pre> </div> </div> </section> <section id="response"> <div class="shell"> <div class="section-head"> <h2>Successful Response</h2> <p> A successful response returns <code>success: true</code> and a roadmap object designed for direct rendering in your frontend. </p> </div> <div class="grid two"> <article class="panel panel-pad"> <h3>Roadmap structure</h3> <ul> <li>Exactly 3 phases.</li> <li>Each phase has exactly 3 learning nodes.</li> <li>Each node has 2 to 4 free resources.</li> <li>Each phase includes one practical project.</li> <li>Top-level <code>projects</code> mirrors phase projects for easy UI rendering.</li> </ul> </article> <article class="panel panel-pad"> <h3>Metadata</h3> <ul> <li><code>model_used</code> shows the generation model or fallback.</li> <li><code>resource_sources</code> shows providers such as Tavily and YouTube.</li> <li><code>generated_at</code> is an ISO timestamp.</li> <li><code>cached</code> tells the frontend whether the response came from cache.</li> </ul> </article> </div> <div style="height: 16px"></div> <div class="code-card"> <div class="code-top"> <span>Response shape</span> <button class="copy" data-copy="response-example">Copy</button> </div> <pre id="response-example">{ "success": true, "data": { "roadmap_id": "roadmap_python_599569", "skill": "Learn Python", "normalized_skill": "python", "overview": "A beginner-friendly overview...", "estimated_total_duration": "6-8 weeks", "phases": [ { "id": "phase_1", "title": "Getting Started", "level": "beginner", "goal": "Learn the foundations.", "estimated_duration": "2 weeks", "nodes": [], "project": {} } ], "projects": [], "metadata": { "model_used": "deepseek-v4-flash", "resource_sources": ["tavily", "youtube"], "generated_at": "2026-04-30T20:18:18.238214Z", "cached": false, "fallback": false } } }</pre> </div> </div> </section> <section id="errors"> <div class="shell"> <div class="section-head"> <h2>Error Handling</h2> <p> The API uses real HTTP status codes. Production responses stay safe for users, while the backend logs detailed exceptions with the same <code>request_id</code>. </p> </div> <div class="table" role="table" aria-label="Status codes"> <div class="row header" role="row"> <div>Status</div> <div>Meaning</div> <div>Retry?</div> </div> <div class="row" role="row"> <div><span class="status ok">200</span></div> <div>Roadmap generated successfully.</div> <div>No</div> </div> <div class="row" role="row"> <div><span class="status warn">422</span></div> <div>Invalid request body, such as a blank skill or unsupported resource type.</div> <div>Fix request</div> </div> <div class="row" role="row"> <div><span class="status bad">500</span></div> <div>Unexpected backend error.</div> <div>Maybe</div> </div> <div class="row" role="row"> <div><span class="status bad">502</span></div> <div>The model returned malformed or schema-invalid output.</div> <div>Yes</div> </div> <div class="row" role="row"> <div><span class="status bad">503</span></div> <div>Generation, model provider, search provider, or validation workflow failed.</div> <div>Yes</div> </div> </div> <div style="height: 16px"></div> <div class="code-card"> <div class="code-top"> <span>Error response</span> <button class="copy" data-copy="error-example">Copy</button> </div> <pre id="error-example">{ "success": false, "error": { "code": "ROADMAP_GENERATION_FAILED", "message": "We could not generate this roadmap right now. Please try again.", "retryable": true, "request_id": "2e4d4e8fd3f049d28e1a7fd04ef63c8a" } }</pre> </div> </div> </section> <section id="frontend"> <div class="shell"> <div class="section-head"> <h2>Frontend Integration Pattern</h2> <p> Keep API keys on the backend. The frontend only calls LockedIn, handles loading states, checks <code>response.ok</code>, and logs request IDs for failed calls. </p> </div> <div class="timeline"> <div class="step"> <div class="step-num">1</div> <article class="panel panel-pad"> <h3>Collect the skill</h3> <p>Ask the learner what they want to learn. Optional controls can collect level, goal, time, language, and preferred resource types.</p> </article> </div> <div class="step"> <div class="step-num">2</div> <article class="panel panel-pad"> <h3>POST to the API</h3> <p>Send JSON to <code>/api/v1/roadmaps/generate</code>. Show a loading state because provider calls can take several seconds.</p> </article> </div> <div class="step"> <div class="step-num">3</div> <article class="panel panel-pad"> <h3>Render or recover</h3> <p>If successful, render phases, nodes, resources, and projects. If failed, show a friendly message and log the <code>request_id</code>.</p> </article> </div> </div> </div> </section> <section id="ops"> <div class="shell"> <div class="section-head"> <h2>Operational Notes</h2> <p> These details help keep the Hugging Face Spaces deployment stable and debuggable. </p> </div> <div class="grid three"> <article class="panel panel-pad"> <h3>Secrets</h3> <p> Set <code>DEEPSEEK_API_KEY</code>, <code>TAVILY_API_KEY</code>, and <code>YOUTUBE_API_KEY</code> as Space secrets. Never expose them in frontend code. </p> </article> <article class="panel panel-pad"> <h3>CORS</h3> <p> Set <code>CORS_ALLOWED_ORIGINS</code> to your frontend domain. During testing, a broad value may work, but production should use explicit origins. </p> </article> <article class="panel panel-pad"> <h3>Health</h3> <p> Use <code>/health</code> to verify the service is running. It does not prove external providers are healthy; it only checks the app process. </p> </article> </div> </div> </section> </main> <footer class="footer"> <div class="shell"> LockedIn AI Service API documentation. Built for frontend developers, demos, and production debugging. </div> </footer> <script> document.querySelectorAll("[data-copy]").forEach((button) => { button.addEventListener("click", async () => { const target = document.getElementById(button.dataset.copy); if (!target) return; await navigator.clipboard.writeText(target.textContent); const original = button.textContent; button.textContent = "Copied"; window.setTimeout(() => { button.textContent = original; }, 1100); }); }); </script> </body> </html> """
from fastapi import APIRouter
from fastapi.responses import HTMLResponse
router = APIRouter()
@router.get("/api-docs", include_in_schema=False)
async def custom_api_docs() -> HTMLResponse:
return HTMLResponse(_DOCS_HTML)
_DOCS_HTML = r"""
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>LockedIn AI Service API Docs</title>
<meta
name="description"
content="Beautiful custom API documentation for the LockedIn AI Service roadmap generation API."
/>
<style>
:root {
color-scheme: dark;
--bg: #07110f;
--panel: rgba(255, 255, 255, 0.075);
--panel-strong: rgba(255, 255, 255, 0.12);
--line: rgba(255, 255, 255, 0.16);
--text: #f5fff9;
--muted: #b6c8bf;
--soft: #8aa79a;
--green: #72f2a7;
--cyan: #7be7ff;
--gold: #ffd166;
--red: #ff7a90;
--shadow: 0 28px 80px rgba(0, 0, 0, 0.34);
--radius: 8px;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
min-height: 100vh;
color: var(--text);
background:
radial-gradient(circle at 12% 12%, rgba(114, 242, 167, 0.17), transparent 26rem),
radial-gradient(circle at 85% 4%, rgba(123, 231, 255, 0.13), transparent 30rem),
linear-gradient(135deg, #07110f 0%, #0b1715 45%, #10170d 100%);
letter-spacing: 0;
}
a {
color: inherit;
}
.shell {
width: min(1180px, calc(100% - 32px));
margin: 0 auto;
}
.nav {
position: sticky;
top: 0;
z-index: 20;
border-bottom: 1px solid var(--line);
background: rgba(7, 17, 15, 0.82);
backdrop-filter: blur(18px);
}
.nav-inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
padding: 14px 0;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.mark {
display: grid;
width: 38px;
height: 38px;
place-items: center;
border: 1px solid rgba(114, 242, 167, 0.38);