LockedIn / app /api /docs_page.py
JermaineAI's picture
Add custom API documentation page
a8990e8
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>
"""