Skip to main content

AiCorsi — AI Knowledge Desk

Hakan Ates
Author
Hakan Ates
I build full-stack applications and LLM-powered tools.
An internal proof of concept built at Go Project: course editors upload their teaching material and get AI-generated study aids back — a two-host Italian audio podcast, quiz questions, and a streaming chat panel.
The six-stage pipeline narrating itself live in the podcast panel

A course PDF becoming a two-host Italian podcast (2× speed): the pipeline streams its stages live over SSE, survives a page reload mid-run, and queues the episode for TTS — an earlier finished episode keeps playing below.

Highlights
#

  • Built the AI layer of an internal e-learning tool at Go Project: a FastAPI + LangGraph backend written from scratch, plus the Angular feature libraries that drive it, delivered as a working proof of concept in about two weeks.
  • Designed a six-stage LangGraph pipeline that turns uploaded course PDFs into a two-host Italian podcast — on a measured demo run, a course PDF became a five-minute episode in about four minutes end to end.
  • Made minutes-long generation runs survive page reloads and navigation: Angular SSE clients persist thread and run identity, replay the stream from Last-Event-ID, and reconcile run status when the app restarts.
  • Split the backend into a LangGraph agent runtime and a lean FastAPI service that communicate only over HTTP, keeping the AI dependency stack out of the CRUD image so each side deploys on its own.

How it works
#

The system is two Docker services plus the app. The FastAPI service owns storage: documents, media records in Postgres, and audio synthesis. The LangGraph runtime owns the intelligence and calls back into the API service over HTTP rather than importing it. The Angular app talks to both and renders the agent’s progress live as the graph streams node updates over SSE.

flowchart TD
    U["Course editor"] --> APP

    subgraph APP["Angular 18 + Ionic app"]
        ED["Element editor
PDF upload"] POD["Podcast panel
live pipeline progress"] end subgraph AI["LangGraph agent service"] G["six-stage agent graph
fetch → knowledge doc → concepts
→ scenario → script → audio"] RP[("Redis + Postgres
run checkpoints")] end subgraph API["FastAPI api-service"] DOCS["document endpoints"] MEDIA["media endpoints
202 + background TTS"] PG[("Postgres")] end GEM["Google Gemini
flash / flash-lite / TTS"] ED -- "upload PDF" --> DOCS POD -- "start run" --> G G -. "SSE node updates,
resumable via Last-Event-ID" .-> POD G -- "fetch document base64" --> DOCS G -- "LLM calls" --> GEM G -- "POST dialogue (202)" --> MEDIA MEDIA -- "multi-speaker TTS" --> GEM MEDIA -. "range-streamed .wav" .-> POD

Portfolio Case Study
#

Problem. Go Project’s course editors manage teaching material (video, PDF, docs, slides) in an admin console. A teammate had built the app shell in 2024 — an Nx/Ionic workspace with an element editor, document upload, and an early chat demo on a Websocket component — but it couldn’t generate anything. I was brought in to build the AI layer end to end: the backend didn’t exist yet, and the frontend had no generation features and it was missing SSE components.

Element editor with metadata form, uploaded PDF attachment, and upload tile
The element editor: course metadata, an uploaded PDF with its selection checkbox, and the upload tile — the entry point for every generation feature.

Approach. The backend is deliberately two services: a slim FastAPI image for documents, media records, and TTS synthesis, and a LangGraph Platform image for the agent graphs, with the stated goal of “zero import statements that refer to app.services or app.db” in the agent code. The agent is a plain HTTP client of the API service, which keeps the heavy AI stack out of the CRUD image and lets each side deploy on its own.

I chose the LangGraph Platform runtime over hand-rolled FastAPI streaming because it gives durable runs backed by Redis and Postgres. A podcast generation takes minutes, and the run had to outlive any single client connection.

The document path is Gemini long-context ingestion, not retrieval. I built a Chroma vector store with MiniLM embeddings on day one, then made a deadline call: parked it and shipped whole PDFs into Gemini’s multimodal input instead, so podcast and quiz generation could go out in time. The Chroma path is still in the repo, unmounted rather than deleted.

The podcast pipeline itself is six typed stages: fetch document bytes, synthesize a knowledge document from mixed sources (PDF bytes, YouTube URIs, web URLs with grounding), extract core concepts and a narrative scenario as Pydantic structured outputs, write an Italian two-host script with per-line delivery cues, then hand the dialogue off for audio. The script format is designed backwards from the TTS API: speaker tags in the text map one-to-one onto Gemini’s multi-speaker voice config (Alex as Puck, Ben as Zephyr).

What was hard. The resumable streaming took the most thought. The requirement: leave the page or close the tab mid-run, come back, and keep watching the progress. Native EventSource can’t do that here since runs start with a POST body and rejoin with a Last-Event-ID header, so the clients wrap fetch-event-source in RxJS observables. The run id only arrives mid-stream inside a metadata event, so the parser captures it and the store persists thread id, run id, and last event id to localStorage. On startup a reconciliation step checks the run’s status and branches: finished runs replay their final state, live ones resubscribe from the last seen event, anything else surfaces as an error.

The run lifecycle: start, tab closes mid-run, rejoin via Last-Event-ID, audio lands after the run ends

The four phases of a run’s life: started from the panel, surviving a closed tab, rejoined from the last seen event on reload, and the audio arriving after the graph has already finished via the 202 background handoff.

Podcast panel mid-generation with live step label, cancel button, and a finished episode playing below
Mid-generation: the live step label with spinner and cancel button, while an earlier episode plays below. Reloading the page at this point rejoins the same stream — the hero GIF above shows it happening.

The audio was its own fight. Gemini TTS streams headerless raw PCM at 24 kHz, so the media service accumulates chunks and packs a 44-byte RIFF/WAVE header by hand, parsing sample rate and bit depth out of the mime type. Playback goes through an endpoint implementing HTTP range requests (206 Partial Content) so the browser audio element can seek.

Two finished episodes with the in-browser audio player
Finished episodes play and seek directly in the browser over the range-request endpoint.

There is also an asynchronous handoff across the service boundary: the graph’s final node POSTs the dialogue and gets a 202 with a media id for a file that doesn’t exist yet. The API service synthesizes audio in a background task while the frontend lists the record as pending and refreshes. A completion callback and an idempotency key on that handoff are known gaps; a retried POST today would queue duplicate audio work.

One episode pending TTS synthesis while an earlier one is already playable
The 202 handoff from the outside: the new episode sits in “Elaborazione dell’audio…” while the API service synthesizes in the background; the earlier one is already playable.

Outcome. A working end-to-end internal demo, built solo in under two weeks (backend June 30 to July 10, 2025; frontend AI libraries July 3 to 9): upload PDFs to a course element, watch the pipeline narrate its own six stages live in the UI, then play the finished podcast in the browser. On a measured demo run, a course PDF became a five-minute two-host episode in about four minutes end to end — roughly 80 seconds for the six-stage pipeline and the rest in TTS synthesis. Quiz generation runs on a second graph with question count and difficulty controls, the chat panel streams over the same custom SSE stack, and the shared UI kit has a Storybook with Chromatic visual regression on its core components.

Non-goals. This is a single-team internal POC by design: no auth or multi-tenancy, no usage analytics. Classic RAG was consciously deferred, the chat runs on a simplified demo graph rather than a production design (its streaming, document ingestion, and model calls are real), and test coverage is thin with no CI — it was scoped as a proof of concept, not a product.

Quiz generator with question count and difficulty controls and the generated question list
The quiz graph: question count and difficulty in, numbered Italian questions grounded in the uploaded document out.
Chatbot answering a question from the uploaded document
The chat panel answering from the uploaded PDF over the custom SSE stack.

Stack
#

Angular 18, Ionic 8, Capacitor 6, Nx, NgRx SignalStore, RxJS, Storybook · Python 3.13, FastAPI, LangGraph, LangChain, Google Gemini (2.5 flash / flash-lite / TTS), Redis, Postgres, ChromaDB (parked), Docker Compose