Build a CyberBrief Video Agent

Build a local AI agent that turns CISA records into reviewed video lessons.

Introduction

30 Second Summary

Security alerts move quickly. Turning one into a clear lesson requires trustworthy evidence for every claim.

In this project, you will build CyberBrief Studio, a local editorial agent that turns records from the Cybersecurity and Infrastructure Security Agency (CISA) Known Exploited Vulnerabilities Catalog into short lessons. You'll approve every script before it creates a cited vertical video with local narration.

What You'll Build

You'll watch a current CISA vulnerability become a cited vertical lesson that plays with local narration only after your approval.

By the end of this project, you'll have:

  • A live topic picker that shows five recent CISA records for a category, keyword, or CVE ID.
  • A source-grounded brief with a hook, a plain-language explanation, three takeaways, narration, and source URLs ready for human review.
  • A cited vertical MP4 you can watch with local narration while the selected CVE and CISA source remain visible.
  • Secret Mission: Produce a second episode in another security domain. Audit every generated claim against its source.

Are there any prerequisites?

You'll need a Windows 10 computer running 22H2 or newer with internet access. Keep enough local storage available for Ollama plus the Gemma 3 1B model.

You'll also need Python 3.10+ and Visual Studio Code.

Before We Start

Before any hands-on work, this step clarifies what CyberBrief Studio is designed to do. Your commitment keeps human review and explicit APPROVE confirmation ahead of rendering or future publication.

Set Up the Local Video Studio

CyberBrief Studio needs a repeatable Windows environment before it can process security data reliably. Local inference also removes paid API credentials from this project.

In this step, you will verify Python with pinned media libraries. You will also run Gemma 3 1B through Ollama on your computer.

In this step, get ready to:
  • Create the cyberbrief-studio folder with its pinned requirements file.
  • Prepare an isolated Python environment with the required media packages.
  • Install the local Ollama runtime with the Gemma 3 1B model.
Build the project foundation

The project folder gives every future file one predictable home. You will create it on your Desktop through Visual Studio Code.

  • Press the Windows key to open Windows search.
  • Type Visual Studio Code into the search field. Press Enter to start the app.

You will see the VS Code welcome screen. The project folder comes next.

  • Click File in the top menu bar.
  • Select Open Folder.

You will see the Windows folder picker.

  • Select Desktop in the folder picker.
  • Click New Folder.

The new folder name field is now ready.

  • Enter cyberbrief-studio as the folder name.
  • Press Enter to create the folder.

You will see the new cyberbrief-studio folder selected in the picker.

  • Click Select Folder to load the folder in VS Code.

VS Code now shows cyberbrief-studio at the top of the Explorer sidebar.

  • Click the New File icon in the Explorer sidebar.
  • Enter requirements.txt as the file name.
  • Press Enter to create the file.
  • Add the pinned media dependencies by pasting this exact content:
Pillow==12.3.0
imageio-ffmpeg==0.6.0

Why Pin These Packages?

A version pin makes every installation use the same package release. Pillow 12.3.0 creates the poster image.

imageio-ffmpeg 0.6.0 supplies the video writer plus a bundled FFmpeg executable. These pins make later rendering steps reproducible.

  • Save requirements.txt by pressing Ctrl+S.
  • Confirm the editor shows exactly two dependency lines.

Requirements File Look Wrong?

Check that requirements.txt appears directly inside cyberbrief-studio. Remove any extra file extension that Windows added.

Compare both package names with the reference tab below. Package names plus version pins must match exactly.

help me check why my requirements file does not match the two required package pins

✔️ Awesome, I've got everything!

Great. Your project folder now contains the saved dependency pins needed for poster and video rendering.

ⓧ I'd like to double check the full code

Compare your complete requirements.txt file with this reference:

Pillow==12.3.0
imageio-ffmpeg==0.6.0

What Should Match?

The first line pins Pillow. The second line pins imageio-ffmpeg.

The media packages require a compatible Python release. Check the interpreter before creating the isolated environment.

  • Click Terminal in the VS Code menu bar.
  • Select New Terminal.

You will see a PowerShell prompt inside VS Code. Its path should end with cyberbrief-studio.

  • Check your installed Python version by running:
python --version

✔️ I see version 3.10 or higher

Your output reports Python 3.10 or a newer release. That interpreter can install both pinned media packages.

ⓧ I see an older version

Your current interpreter is below the project's minimum version. Install a current supported Python release before creating the virtual environment.

python --version

Continue when the output reports Python 3.10 or newer.

ⓧ Command not found

PowerShell cannot currently find Python. Install it through the official Windows guidance before continuing.

  • Open the official Python Windows installation guide.
  • Follow its Python Install Manager guidance to install Python.
  • Restart the VS Code PowerShell terminal after installation.
  • Check the installed version by running:
python --version

Continue when PowerShell reports Python 3.10 or newer.

Create the isolated Python environment

A virtual environment keeps this project's packages separate from other Python projects. The .venv folder holds that isolated interpreter plus its installed libraries.

  • Create .venv inside cyberbrief-studio by running:
python -m venv .venv

When the command finishes, you will see a .venv folder in the Explorer sidebar. This confirms Python created the isolated environment.

Virtual Environment Missing?

Confirm the terminal path ends with cyberbrief-studio. A different path creates .venv in the wrong folder.

help me find why Python did not create .venv in my cyberbrief-studio folder

  • Activate the virtual environment in the current PowerShell terminal by running:
.\.venv\Scripts\Activate.ps1

The environment name now appears at the beginning of your PowerShell prompt. This shows that subsequent Python commands use .venv.

Activation Blocked by PowerShell?

PowerShell can block local activation scripts because of its current execution policy. Apply the documented current-user policy by running:

Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser

This changes the policy for your Windows user account. PowerShell may ask you to confirm the change.

  • Retry the virtual environment activation by running:
.\.venv\Scripts\Activate.ps1

The environment name should now appear at the start of the prompt.

help me resolve a PowerShell virtual environment activation problem

  • Install the packages listed in requirements.txt by running:
python -m pip install -r requirements.txt

The installation output lists Pillow 12.3.0 plus imageio-ffmpeg 0.6.0 as installed packages. Your isolated media environment is ready.

Package Installation Failed?

Confirm the environment name still appears at the start of the PowerShell prompt. Check that both lines in requirements.txt match the reference exactly.

help me debug the package installation in my activated virtual environment

Install Ollama and test Gemma locally

Ollama runs the language model as a native Windows application. Its command-line interface becomes available in PowerShell while the app runs in the background.

  • Check whether Ollama is already available by running:
ollama --version

✔️ I see version 0.33.3

Ollama 0.33.3 is installed. The local runtime is ready for its model.

ⓧ I see an older version

The installed release is older than the project pin. Use the current Windows installer to update it.

  • Open the official Ollama Windows download page.
  • Download OllamaSetup.exe.
  • Run OllamaSetup.exe from your Downloads folder.
  • Complete the installer.
  • Return to the VS Code PowerShell terminal.
  • Check the updated installation by running:
ollama --version

Continue when the output reports Ollama 0.33.3.

ⓧ Command not found

Ollama needs to be installed before PowerShell can run a local model. The Windows installer works inside your user account.

  • Open the official Ollama Windows download page.
  • Download OllamaSetup.exe.
  • Run OllamaSetup.exe from your Downloads folder.
  • Complete the installer.
  • Return to the VS Code PowerShell terminal.
  • Confirm the installation by running:
ollama --version

You will see the installed Ollama version. Continue when it reports 0.33.3.

Ollama Command Still Missing?

Close the current VS Code terminal after the installer finishes. Start a new PowerShell terminal so it receives the updated command path.

help me make the Ollama command available in my VS Code PowerShell terminal

The first model run downloads an 815 MB artifact. Plan for several minutes on a typical connection while Ollama displays download progress.

Before you run this check, do you think Gemma can answer without sending the prompt to a paid cloud API?

  • Download the model and start its local chat by running:
ollama run gemma3:1b

What Does This Command Do?

The command downloads gemma3:1b when the model is missing. It then opens an interactive local chat session.

  • Enter Reply with READY at the model prompt.
  • Press Enter to send the test prompt.

You should see a local response containing READY. That is the key setup win: Gemma is downloaded and answering through Ollama on your computer.

  • Press Ctrl+C to return to the PowerShell prompt.

Gemma Did Not Answer?

If the download stops, confirm that your internet connection is active. Run the model command again so Ollama can retry.

If the model opens without replying, confirm that you entered the short test prompt after the interactive session started.

help me diagnose why gemma3:1b did not complete its local response test

Studio Checkpoint

  • A .venv virtual environment exists inside cyberbrief-studio. It is activated in the current VS Code PowerShell terminal.
  • Ollama 0.33.3 is installed on Windows. Its command-line interface is available.
  • The gemma3:1b model is downloaded. It has answered the local test prompt.
  • The requirements.txt file pins Pillow 12.3.0 plus imageio-ffmpeg 0.6.0.

Your local video studio is ready. Next, you will connect it to an official vulnerability source and select a current security topic.

Discover CISA KEV Topics

Your local model is ready. It now needs trustworthy security facts to work from.

The CISA Known Exploited Vulnerabilities Catalog provides an authoritative list of vulnerabilities exploited in the wild. Its technical records still need filtering before they become useful microlearning topics.

In this step, your script downloads the live JSON catalog. It then offers five recent records based on a category, keyword, or CVE ID.

In this step, get ready to:
  • Connect the project to CISA's live KEV catalog.
  • Classify records into three security categories.
  • Select a recent topic and inspect its official source record.
Connect to the live CISA catalog

The catalog gives your agent a reliable starting point for source-grounded generation. The constants below define the feed location and the terms used to group records.

Why use CISA KEV?

Broad web feeds rank stories using popularity or recency. CISA KEV focuses on vulnerabilities with known exploitation.

That focus gives your topic picker an authoritative security signal. You still make the final editorial choice.

  • Create cyberbrief.py inside the cyberbrief-studio folder using Visual Studio Code's file controls.
  • Add the catalog imports, output paths, endpoint, and AI classification terms by pasting this code:
import json
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen

BASE_DIR = Path(__file__).resolve().parent
OUTPUT_DIR = BASE_DIR / "output"

KEV_URL = (
    "https://www.cisa.gov/sites/default/files/feeds/"
    "known_exploited_vulnerabilities.json"
)

AI_TERMS = (
    "artificial intelligence",
    "large language model",
    "llm",
    "machine learning",
    "langflow",
    "litellm",
    "mlflow",
    "model serving",
    "prompt",
)

What does this foundation do?

  • The networking imports download the catalog without adding another package.
  • The path constants keep future output inside the cyberbrief-studio folder.
  • The AI_TERMS tuple holds phrases that identify AI-related software records.
  • Add the embedded product terms and download helper directly below AI_TERMS by pasting this code:
EMBEDDED_TERMS = (
    "router",
    "camera",
    "firmware",
    "chipset",
    "android",
    "ios",
    "iot",
    "industrial",
    "plc",
    "hmi",
    "scada",
    "firewall",
    "appliance",
    "gateway",
    "switch",
    "controller",
    "medical device",
)


def fetch_json(url):
    request = Request(url, headers={"User-Agent": "CyberBriefStudio/1.0"})
    try:
        with urlopen(request, timeout=30) as response:
            return json.load(response)
    except (HTTPError, URLError, TimeoutError) as error:
        raise RuntimeError(f"Could not download {url}: {error}") from error

How does the download work?

  • The EMBEDDED_TERMS tuple covers devices, firmware, networking products, and industrial systems.
  • The fetch_json() function requests the live feed with a 30-second timeout.
  • The error handler turns connection failures into a message that identifies the unavailable URL.
  • Add the text preparation and category functions below fetch_json() by pasting this code:
def searchable_text(item):
    fields = (
        item.get("cveID", ""),
        item.get("vendorProject", ""),
        item.get("product", ""),
        item.get("vulnerabilityName", ""),
        item.get("shortDescription", ""),
        item.get("cwes", []),
    )
    return " ".join(
        " ".join(value) if isinstance(value, list) else str(value)
        for value in fields
    ).casefold()


def category_of(item):
    text = searchable_text(item)
    if any(term in text for term in AI_TERMS):
        return "AI security"
    if any(term in text for term in EMBEDDED_TERMS):
        return "Embedded and product security"
    return "Cybersecurity"

How does classification work?

The searchable_text() function combines the fields that help identify a record. Converting the result with casefold() makes later searches insensitive to capitalization.

The category_of() function checks AI terms first. It then checks embedded product terms before using Cybersecurity as the general category.

  • Save cyberbrief.py.

Seeing a syntax warning?

Check that each tuple ends with a closing parenthesis. Confirm that every line inside fetch_json() and the two classification functions keeps its indentation.

If the warning remains, help me compare my catalog foundation with the expected Python structure.

Build the topic picker

A live feed can contain many records. The topic picker sorts them by date before applying your category or search filter.

  • Start choose_topic() below category_of() by pasting this first section:
def choose_topic(catalog):
    items = sorted(
        catalog.get("vulnerabilities", []),
        key=lambda item: item.get("dateAdded", ""),
        reverse=True,
    )
    if not items:
        raise RuntimeError("The CISA catalog did not contain any vulnerabilities.")

    print(f"\nCISA KEV catalog version: {catalog.get('catalogVersion', 'unknown')}")
    print("1. Cybersecurity")
    print("2. AI security")
    print("3. Embedded and product security")
    print("4. All recent entries")

    category_choice = input("Choose a category [4]: ").strip() or "4"
    categories = {
        "1": "Cybersecurity",
        "2": "AI security",
        "3": "Embedded and product security",
        "4": "All",
    }
    category = categories.get(category_choice, "All")
    query = input("Optional keyword or CVE ID, or press Enter for suggestions: ").strip()

How does the first picker section work?

  • The catalog records are sorted by dateAdded with the newest entries first.
  • The category menu maps the four numbered choices to readable category names.
  • The optional query accepts a product keyword or a complete CVE ID.
  • Complete choose_topic() by pasting this code directly below the query line with the same indentation:
    if query:
        query_text = query.casefold()
        pool = [item for item in items if query_text in searchable_text(item)]
    elif category == "All":
        pool = items
    else:
        pool = [item for item in items if category_of(item) == category]

    if not pool:
        raise RuntimeError("No matching CISA KEV entries were found. Try another search.")

    suggestions = pool[:5]
    print("\nRecent high-priority topics:")
    for index, item in enumerate(suggestions, start=1):
        print(
            f"{index}. {item.get('dateAdded', 'unknown')} | "
            f"{item.get('cveID', 'no CVE')} | "
            f"{item.get('vulnerabilityName', 'untitled')}"
        )

    selection = input("Choose a topic [1]: ").strip() or "1"
    if not selection.isdigit() or not 1 <= int(selection) <= len(suggestions):
        raise RuntimeError("Choose one of the displayed topic numbers.")

    selected = suggestions[int(selection) - 1]
    print("\nSelected source record:")
    print(json.dumps(selected, indent=2, ensure_ascii=False))
    return selected

How does selection work?

A query searches every combined field from searchable_text(). A category choice filters records through category_of().

The picker displays the five newest matches. It prints the complete selected record so you can inspect the official facts before any model rewrites them.

  • Save cyberbrief.py.

Picker code looks misaligned?

Every line in the second picker block belongs inside choose_topic(). Keep four spaces before the first if query: line.

If you are unsure where a block belongs, help me repair the indentation in my choose_topic function.

Run the live topic search

The catalog connection and picker are ready. A short main routine now connects them into a runnable workflow.

  • Add the main routine at the bottom of cyberbrief.py by pasting this code:
def main():
    OUTPUT_DIR.mkdir(exist_ok=True)
    print("CyberBrief Studio")
    print("Downloading the live CISA Known Exploited Vulnerabilities catalog...")

    catalog = fetch_json(KEV_URL)
    topic = choose_topic(catalog)


if __name__ == "__main__":
    main()

What does the main routine do?

  • The output folder is prepared for the files created in later steps.
  • The live catalog is downloaded through fetch_json().
  • The downloaded catalog is passed into choose_topic() for filtering and selection.
  • Save cyberbrief.py.

✔️ Awesome, I've got everything!

Your source-grounded topic picker is complete. Confirm that cyberbrief.py is saved before running it.

ⓧ I'd like to double check the full code

import json
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen

BASE_DIR = Path(__file__).resolve().parent
OUTPUT_DIR = BASE_DIR / "output"

KEV_URL = (
    "https://www.cisa.gov/sites/default/files/feeds/"
    "known_exploited_vulnerabilities.json"
)

AI_TERMS = (
    "artificial intelligence",
    "large language model",
    "llm",
    "machine learning",
    "langflow",
    "litellm",
    "mlflow",
    "model serving",
    "prompt",
)

EMBEDDED_TERMS = (
    "router",
    "camera",
    "firmware",
    "chipset",
    "android",
    "ios",
    "iot",
    "industrial",
    "plc",
    "hmi",
    "scada",
    "firewall",
    "appliance",
    "gateway",
    "switch",
    "controller",
    "medical device",
)


def fetch_json(url):
    request = Request(url, headers={"User-Agent": "CyberBriefStudio/1.0"})
    try:
        with urlopen(request, timeout=30) as response:
            return json.load(response)
    except (HTTPError, URLError, TimeoutError) as error:
        raise RuntimeError(f"Could not download {url}: {error}") from error


def searchable_text(item):
    fields = (
        item.get("cveID", ""),
        item.get("vendorProject", ""),
        item.get("product", ""),
        item.get("vulnerabilityName", ""),
        item.get("shortDescription", ""),
        item.get("cwes", []),
    )
    return " ".join(
        " ".join(value) if isinstance(value, list) else str(value)
        for value in fields
    ).casefold()


def category_of(item):
    text = searchable_text(item)
    if any(term in text for term in AI_TERMS):
        return "AI security"
    if any(term in text for term in EMBEDDED_TERMS):
        return "Embedded and product security"
    return "Cybersecurity"


def choose_topic(catalog):
    items = sorted(
        catalog.get("vulnerabilities", []),
        key=lambda item: item.get("dateAdded", ""),
        reverse=True,
    )
    if not items:
        raise RuntimeError("The CISA catalog did not contain any vulnerabilities.")

    print(f"\nCISA KEV catalog version: {catalog.get('catalogVersion', 'unknown')}")
    print("1. Cybersecurity")
    print("2. AI security")
    print("3. Embedded and product security")
    print("4. All recent entries")

    category_choice = input("Choose a category [4]: ").strip() or "4"
    categories = {
        "1": "Cybersecurity",
        "2": "AI security",
        "3": "Embedded and product security",
        "4": "All",
    }
    category = categories.get(category_choice, "All")
    query = input("Optional keyword or CVE ID, or press Enter for suggestions: ").strip()

    if query:
        query_text = query.casefold()
        pool = [item for item in items if query_text in searchable_text(item)]
    elif category == "All":
        pool = items
    else:
        pool = [item for item in items if category_of(item) == category]

    if not pool:
        raise RuntimeError("No matching CISA KEV entries were found. Try another search.")

    suggestions = pool[:5]
    print("\nRecent high-priority topics:")
    for index, item in enumerate(suggestions, start=1):
        print(
            f"{index}. {item.get('dateAdded', 'unknown')} | "
            f"{item.get('cveID', 'no CVE')} | "
            f"{item.get('vulnerabilityName', 'untitled')}"
        )

    selection = input("Choose a topic [1]: ").strip() or "1"
    if not selection.isdigit() or not 1 <= int(selection) <= len(suggestions):
        raise RuntimeError("Choose one of the displayed topic numbers.")

    selected = suggestions[int(selection) - 1]
    print("\nSelected source record:")
    print(json.dumps(selected, indent=2, ensure_ascii=False))
    return selected


def main():
    OUTPUT_DIR.mkdir(exist_ok=True)
    print("CyberBrief Studio")
    print("Downloading the live CISA Known Exploited Vulnerabilities catalog...")

    catalog = fetch_json(KEV_URL)
    topic = choose_topic(catalog)


if __name__ == "__main__":
    main()

The script asks for three decisions during the run. Choose a category first.

  • Enter 1, 2, 3, or 4 when the category prompt appears.
  • Enter a keyword or CVE ID when the optional search prompt appears.
  • Press Enter at the optional search prompt to use the selected category without a narrower query.
  • Enter a number from 1 to 5 when the topic prompt appears.
  • Before you run the picker, predict which recent records your chosen filter will surface.
  • Run the topic picker from the activated PowerShell terminal using this command:
python cyberbrief.py

What should you see?

You should see the live catalog version followed by four category choices. Your filter then produces five dated suggestions with a CVE ID and vulnerability name.

After you choose a topic, you should see the complete selected source record as formatted JSON. This proves the agent can retrieve a current official record before any rewriting begins.

Catalog or search failed?

A download failure usually means the computer cannot reach the live feed. Confirm your internet connection before running the script again.

An empty search means no catalog record matched the exact keyword or CVE ID. Run the picker again with a broader keyword or leave the optional search blank.

If the picker still stops, help me diagnose my CISA catalog download or topic filter.

Your agent can now turn a live CISA record into a focused topic. Next up, you will use Ollama to rewrite those supplied facts into a structured lesson with a human approval gate.

Generate a Reviewed Lesson

Your topic picker now selects a recent record from the CISA Known Exploited Vulnerabilities Catalog. That gives each lesson a trustworthy starting point.

The selected record is still too dense for a beginner lesson. A local Ollama model can translate those facts into plain language.

In this step, you will constrain the model to use only supplied facts. A human approval gate then blocks every later media step until you accept the result.

In this step, get ready to:
  • Define the structured lesson that Ollama must return.
  • Validate the generated fields before attaching source metadata.
  • Save the brief before enforcing an exact human approval gate.
Define the structured brief contract

A JSON Schema gives the local model a fixed response shape. The schema requires one title, one hook, one explanation, three takeaways, and one narration script.

  • In cyberbrief.py, replace the constants from BASE_DIR through KEV_URL with this expanded block:
BASE_DIR = Path(__file__).resolve().parent
OUTPUT_DIR = BASE_DIR / "output"
BRIEF_PATH = OUTPUT_DIR / "brief.json"

KEV_URL = (
    "https://www.cisa.gov/sites/default/files/feeds/"
    "known_exploited_vulnerabilities.json"
)
OLLAMA_URL = "http://localhost:11434/api/chat"
MODEL = "gemma3:1b"

What do these constants control?

  • The BRIEF_PATH value keeps the reviewed lesson inside the existing output folder.
  • The OLLAMA_URL value points to Ollama's local chat endpoint.
  • The MODEL value selects the downloaded gemma3:1b model.
  • Add the response schema directly below the EMBEDDED_TERMS tuple:
BRIEF_SCHEMA = {
    "type": "object",
    "properties": {
        "title": {"type": "string"},
        "hook": {"type": "string"},
        "plain_explanation": {"type": "string"},
        "takeaways": {
            "type": "array",
            "items": {"type": "string"},
            "minItems": 3,
            "maxItems": 3,
        },
        "narration": {"type": "string"},
    },
    "required": [
        "title",
        "hook",
        "plain_explanation",
        "takeaways",
        "narration",
    ],
}

How does the schema constrain the lesson?

  • The properties object defines the five fields that form the lesson.
  • The minItems value requires at least three takeaways.
  • The maxItems value prevents the model from adding a fourth takeaway.
  • The required list makes every lesson field mandatory.
  • Save cyberbrief.py.
  • Check that the existing topic picker still runs by using:
python cyberbrief.py

What does this check prove?

This run confirms that the new constants and schema contain valid Python syntax. The existing topic picker still handles the interactive choices.

You should still see five dated suggestions followed by the selected official source record.

Seeing a syntax error?

Check that every schema key keeps its matching colon. Confirm that each opening brace has a closing brace.

Ask for help with the exact line reported by Python: help me find the syntax mistake in my BRIEF_SCHEMA definition.

The model also needs a narrow fact packet. Supplying only selected CISA fields keeps unrelated catalog data out of the prompt.

  • Add the reference helper and the first part of generate_brief() directly below choose_topic():
def first_reference(notes):
    matches = re.findall(r"https?://[^\s;]+", notes or "")
    return matches[0].rstrip(".,") if matches else None


def generate_brief(topic):
    source_facts = {
        "cveID": topic.get("cveID", ""),
        "vendorProject": topic.get("vendorProject", ""),
        "product": topic.get("product", ""),
        "vulnerabilityName": topic.get("vulnerabilityName", ""),
        "dateAdded": topic.get("dateAdded", ""),
        "shortDescription": topic.get("shortDescription", ""),
        "requiredAction": topic.get("requiredAction", ""),
        "knownRansomwareCampaignUse": topic.get(
            "knownRansomwareCampaignUse", "Unknown"
        ),
        "cwes": topic.get("cwes", []),
    }

What facts reach the model?

  • The source_facts object includes the affected vendor, product, description, required action, exploitation context, date, CVE, and weakness identifiers.
  • The first_reference() helper extracts the first linked advisory from the official record's notes.
  • Missing fields become empty values instead of guesses.
  • Continue generate_brief() by adding this prompt directly below the source_facts object:
    prompt = f"""
Create a beginner-friendly cybersecurity microlearning brief from the source facts.

Rules:
1. Use only the supplied facts.
2. Preserve the CVE ID, affected vendor, and product exactly.
3. Do not invent versions, attack steps, exploitation evidence, or mitigations.
4. Explain technical terms with everyday language.
5. Keep each takeaway below 18 words.
6. Keep narration between 90 and 120 words.
7. If a detail is missing, omit it rather than guessing.
8. Return only JSON matching the supplied schema.

SOURCE FACTS:
{json.dumps(source_facts, indent=2, ensure_ascii=False)}
""".strip()

Why is the prompt restrictive?

The prompt defines a closed evidence boundary around source_facts. It explicitly blocks invented versions, attack steps, exploitation evidence, and mitigations.

The narration length targets a short microlearning lesson. Missing facts must stay missing.

  • Save cyberbrief.py.
  • Confirm that the expanded file still parses by running:
python cyberbrief.py

What does this run check?

The current main flow still ends after topic selection. Reaching the selected source record proves that the new helper and prompt parse correctly.

You should see the same selected CISA record without a Python syntax error.

Validate and own the source metadata

Structured output shapes the model response. Python still verifies the result before trusting it.

Citation ownership stays outside the LLM. Python attaches the known CISA endpoint and any referenced advisory after generation.

  • Continue generate_brief() by adding the request payload directly below the prompt:
    payload = {
        "model": MODEL,
        "messages": [
            {
                "role": "system",
                "content": (
                    "You are a careful cybersecurity educator. "
                    "Accuracy is more important than drama."
                ),
            },
            {"role": "user", "content": prompt},
        ],
        "stream": False,
        "format": BRIEF_SCHEMA,
        "options": {"temperature": 0},
    }

    request = Request(
        OLLAMA_URL,
        data=json.dumps(payload).encode("utf-8"),
        headers={"Content-Type": "application/json"},
        method="POST",
    )

How is generation controlled?

  • The messages array combines an accuracy-focused system instruction with the grounded user prompt.
  • The stream value requests one complete response.
  • The format value supplies the response schema.
  • The temperature value reduces variation between runs.
  • Add the response handling directly below the Request object:
    try:
        with urlopen(request, timeout=180) as response:
            result = json.load(response)
    except (HTTPError, URLError, TimeoutError) as error:
        raise RuntimeError(
            "Ollama did not respond. Start Ollama and run gemma3:1b once."
        ) from error

    brief = json.loads(result["message"]["content"])
    required = {
        "title",
        "hook",
        "plain_explanation",
        "takeaways",
        "narration",
    }
    if not required.issubset(brief):
        raise RuntimeError("The model response is missing required brief fields.")
    if not isinstance(brief["takeaways"], list) or len(brief["takeaways"]) != 3:
        raise RuntimeError("The model must return exactly three takeaways.")

What gets validated?

The response must contain every required lesson field. The takeaways value must also be a list containing exactly three items.

A missing field or incorrect takeaway count stops the workflow before the brief is saved.

  • Save cyberbrief.py.
  • Check the latest additions for syntax errors by running:
python cyberbrief.py

What does this checkpoint show?

The topic picker remains the active main flow at this checkpoint. Seeing the selected source record confirms that the request and validation code parse successfully.

  • Complete generate_brief() by adding the source metadata directly below the takeaway validation:
    references = [KEV_URL]
    vendor_reference = first_reference(topic.get("notes", ""))
    if vendor_reference:
        references.append(vendor_reference)

    brief.update(
        {
            "cve_id": topic.get("cveID", ""),
            "vendor": topic.get("vendorProject", ""),
            "product": topic.get("product", ""),
            "date_added": topic.get("dateAdded", ""),
            "category": category_of(topic),
            "source_urls": references,
            "source_facts": source_facts,
        }
    )
    return brief

Why does Python add the citations?

The model writes the teaching fields. Python copies the CVE, vendor, product, date, category, source facts, and URLs from known inputs.

This split prevents the model from inventing a source link. Every saved brief retains the facts used to generate it.

Save the brief behind an approval gate

Review must happen against a durable artifact. Saving the brief before approval lets you inspect the exact content that later steps would render.

  • Add save_brief() and review_brief() directly below generate_brief():
def save_brief(brief):
    OUTPUT_DIR.mkdir(exist_ok=True)
    BRIEF_PATH.write_text(
        json.dumps(brief, indent=2, ensure_ascii=False),
        encoding="utf-8",
    )


def review_brief(brief):
    print("\nGenerated brief for human review:")
    print(json.dumps(brief, indent=2, ensure_ascii=False))
    approval = input("\nType APPROVE to render, or anything else to stop: ").strip()
    if approval != "APPROVE":
        raise SystemExit(
            f"Stopped before rendering. Review the saved brief at {BRIEF_PATH}."
        )

How does the approval gate work?

  • The save_brief() function creates the output folder before writing formatted JSON.
  • The review_brief() function prints the complete saved content for inspection.
  • Any input other than the exact uppercase value APPROVE stops the program.
  • In main(), find the line topic = choose_topic(catalog).
  • Add these three lines directly below it:
    brief = generate_brief(topic)
    save_brief(brief)
    review_brief(brief)

What changes in the main flow?

The selected topic now enters generation. The resulting brief is saved before the review function asks for approval.

Rendering remains outside this step. Approval only confirms that the lesson is ready for the next stage.

✔️ Awesome, I've got everything!

Great. Save cyberbrief.py before running the reviewed lesson workflow.

ⓧ I'd like to double check the full code

import json
import re
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen

BASE_DIR = Path(__file__).resolve().parent
OUTPUT_DIR = BASE_DIR / "output"
BRIEF_PATH = OUTPUT_DIR / "brief.json"

KEV_URL = (
    "https://www.cisa.gov/sites/default/files/feeds/"
    "known_exploited_vulnerabilities.json"
)
OLLAMA_URL = "http://localhost:11434/api/chat"
MODEL = "gemma3:1b"

AI_TERMS = (
    "artificial intelligence",
    "large language model",
    "llm",
    "machine learning",
    "langflow",
    "litellm",
    "mlflow",
    "model serving",
    "prompt",
)

EMBEDDED_TERMS = (
    "router",
    "camera",
    "firmware",
    "chipset",
    "android",
    "ios",
    "iot",
    "industrial",
    "plc",
    "hmi",
    "scada",
    "firewall",
    "appliance",
    "gateway",
    "switch",
    "controller",
    "medical device",
)

BRIEF_SCHEMA = {
    "type": "object",
    "properties": {
        "title": {"type": "string"},
        "hook": {"type": "string"},
        "plain_explanation": {"type": "string"},
        "takeaways": {
            "type": "array",
            "items": {"type": "string"},
            "minItems": 3,
            "maxItems": 3,
        },
        "narration": {"type": "string"},
    },
    "required": [
        "title",
        "hook",
        "plain_explanation",
        "takeaways",
        "narration",
    ],
}


def fetch_json(url):
    request = Request(url, headers={"User-Agent": "CyberBriefStudio/1.0"})
    try:
        with urlopen(request, timeout=30) as response:
            return json.load(response)
    except (HTTPError, URLError, TimeoutError) as error:
        raise RuntimeError(f"Could not download {url}: {error}") from error


def searchable_text(item):
    fields = (
        item.get("cveID", ""),
        item.get("vendorProject", ""),
        item.get("product", ""),
        item.get("vulnerabilityName", ""),
        item.get("shortDescription", ""),
        item.get("cwes", []),
    )
    return " ".join(
        " ".join(value) if isinstance(value, list) else str(value)
        for value in fields
    ).casefold()


def category_of(item):
    text = searchable_text(item)
    if any(term in text for term in AI_TERMS):
        return "AI security"
    if any(term in text for term in EMBEDDED_TERMS):
        return "Embedded and product security"
    return "Cybersecurity"


def choose_topic(catalog):
    items = sorted(
        catalog.get("vulnerabilities", []),
        key=lambda item: item.get("dateAdded", ""),
        reverse=True,
    )
    if not items:
        raise RuntimeError("The CISA catalog did not contain any vulnerabilities.")

    print(f"\nCISA KEV catalog version: {catalog.get('catalogVersion', 'unknown')}")
    print("1. Cybersecurity")
    print("2. AI security")
    print("3. Embedded and product security")
    print("4. All recent entries")

    category_choice = input("Choose a category [4]: ").strip() or "4"
    categories = {
        "1": "Cybersecurity",
        "2": "AI security",
        "3": "Embedded and product security",
        "4": "All",
    }
    category = categories.get(category_choice, "All")
    query = input("Optional keyword or CVE ID, or press Enter for suggestions: ").strip()

    if query:
        query_text = query.casefold()
        pool = [item for item in items if query_text in searchable_text(item)]
    elif category == "All":
        pool = items
    else:
        pool = [item for item in items if category_of(item) == category]

    if not pool:
        raise RuntimeError("No matching CISA KEV entries were found. Try another search.")

    suggestions = pool[:5]
    print("\nRecent high-priority topics:")
    for index, item in enumerate(suggestions, start=1):
        print(
            f"{index}. {item.get('dateAdded', 'unknown')} | "
            f"{item.get('cveID', 'no CVE')} | "
            f"{item.get('vulnerabilityName', 'untitled')}"
        )

    selection = input("Choose a topic [1]: ").strip() or "1"
    if not selection.isdigit() or not 1 <= int(selection) <= len(suggestions):
        raise RuntimeError("Choose one of the displayed topic numbers.")

    selected = suggestions[int(selection) - 1]
    print("\nSelected source record:")
    print(json.dumps(selected, indent=2, ensure_ascii=False))
    return selected


def first_reference(notes):
    matches = re.findall(r"https?://[^\s;]+", notes or "")
    return matches[0].rstrip(".,") if matches else None


def generate_brief(topic):
    source_facts = {
        "cveID": topic.get("cveID", ""),
        "vendorProject": topic.get("vendorProject", ""),
        "product": topic.get("product", ""),
        "vulnerabilityName": topic.get("vulnerabilityName", ""),
        "dateAdded": topic.get("dateAdded", ""),
        "shortDescription": topic.get("shortDescription", ""),
        "requiredAction": topic.get("requiredAction", ""),
        "knownRansomwareCampaignUse": topic.get(
            "knownRansomwareCampaignUse", "Unknown"
        ),
        "cwes": topic.get("cwes", []),
    }

    prompt = f"""
Create a beginner-friendly cybersecurity microlearning brief from the source facts.

Rules:
1. Use only the supplied facts.
2. Preserve the CVE ID, affected vendor, and product exactly.
3. Do not invent versions, attack steps, exploitation evidence, or mitigations.
4. Explain technical terms with everyday language.
5. Keep each takeaway below 18 words.
6. Keep narration between 90 and 120 words.
7. If a detail is missing, omit it rather than guessing.
8. Return only JSON matching the supplied schema.

SOURCE FACTS:
{json.dumps(source_facts, indent=2, ensure_ascii=False)}
""".strip()

    payload = {
        "model": MODEL,
        "messages": [
            {
                "role": "system",
                "content": (
                    "You are a careful cybersecurity educator. "
                    "Accuracy is more important than drama."
                ),
            },
            {"role": "user", "content": prompt},
        ],
        "stream": False,
        "format": BRIEF_SCHEMA,
        "options": {"temperature": 0},
    }

    request = Request(
        OLLAMA_URL,
        data=json.dumps(payload).encode("utf-8"),
        headers={"Content-Type": "application/json"},
        method="POST",
    )

    try:
        with urlopen(request, timeout=180) as response:
            result = json.load(response)
    except (HTTPError, URLError, TimeoutError) as error:
        raise RuntimeError(
            "Ollama did not respond. Start Ollama and run gemma3:1b once."
        ) from error

    brief = json.loads(result["message"]["content"])
    required = {
        "title",
        "hook",
        "plain_explanation",
        "takeaways",
        "narration",
    }
    if not required.issubset(brief):
        raise RuntimeError("The model response is missing required brief fields.")
    if not isinstance(brief["takeaways"], list) or len(brief["takeaways"]) != 3:
        raise RuntimeError("The model must return exactly three takeaways.")

    references = [KEV_URL]
    vendor_reference = first_reference(topic.get("notes", ""))
    if vendor_reference:
        references.append(vendor_reference)

    brief.update(
        {
            "cve_id": topic.get("cveID", ""),
            "vendor": topic.get("vendorProject", ""),
            "product": topic.get("product", ""),
            "date_added": topic.get("dateAdded", ""),
            "category": category_of(topic),
            "source_urls": references,
            "source_facts": source_facts,
        }
    )
    return brief


def save_brief(brief):
    OUTPUT_DIR.mkdir(exist_ok=True)
    BRIEF_PATH.write_text(
        json.dumps(brief, indent=2, ensure_ascii=False),
        encoding="utf-8",
    )


def review_brief(brief):
    print("\nGenerated brief for human review:")
    print(json.dumps(brief, indent=2, ensure_ascii=False))
    approval = input("\nType APPROVE to render, or anything else to stop: ").strip()
    if approval != "APPROVE":
        raise SystemExit(
            f"Stopped before rendering. Review the saved brief at {BRIEF_PATH}."
        )


def main():
    OUTPUT_DIR.mkdir(exist_ok=True)
    print("CyberBrief Studio")
    print("Downloading the live CISA Known Exploited Vulnerabilities catalog...")

    catalog = fetch_json(KEV_URL)
    topic = choose_topic(catalog)
    brief = generate_brief(topic)
    save_brief(brief)
    review_brief(brief)


if __name__ == "__main__":
    main()

Before you run the complete workflow, consider whether any input other than the exact approval word should let the lesson continue.

The command starts the existing topic prompts before local generation begins. Read ahead so you know where the approval checkpoint appears.

  • Save cyberbrief.py.
  • Start the reviewed lesson workflow by running:
python cyberbrief.py

What happens during this run?

The agent downloads the live catalog before showing the familiar topic choices. Ollama then generates one structured response from the selected source facts.

Local generation may leave the terminal quiet for a while. That pause means the model is processing the prompt on your computer.

  • Choose one of the displayed security categories.
  • Enter a keyword or CVE ID if you want to narrow the records.
  • Choose one of the five displayed topics.

You should see the selected source record followed by a generated brief. The terminal then waits at the exact APPROVE prompt.

  • Keep the approval prompt waiting in the terminal.
  • In VS Code's file sidebar, expand the output folder.
  • Open brief.json from that folder.

You should see the CVE metadata, five structured lesson fields, source URLs, and the source facts used by the model. The file already exists before approval.

  • Return to the waiting terminal.
  • Type APPROVE exactly.
  • Press Enter.

The script finishes without rendering media. That confirms the reviewed lesson has passed its human gate.

Ollama did not generate a brief?

Confirm that Ollama is still running in the Windows tray. The downloaded gemma3:1b model must remain available to the local endpoint.

If the model returns an invalid structure, rerun the agent so the schema can request a fresh response. You can also ask for help interpreting the generation error.

Your agent now turns one official record into a structured lesson without inventing its citations. Next up, you will transform the approved brief into a vertical learning card.

Design the Vertical Learning Card

Your agent now turns a selected vulnerability record into a structured brief that waits for your approval. That review gate protects the facts before any media is created.

A viewer also needs a readable visual that communicates the lesson while keeping the CISA Known Exploited Vulnerabilities Catalog source visible. In this step, you will use Pillow to turn the approved brief into a vertical learning card.

In this step, get ready to:
  • Build a vertical RGB poster canvas with a consistent visual style.
  • Display the approved title and vulnerability metadata.
  • Present three takeaways with a visible source footer.
Build the poster canvas

The poster uses a fixed `1080` by `1920` canvas. Text wrapping keeps longer titles inside the card instead of letting them run past the margins.

  • In `cyberbrief.py`, add text-wrapping support by placing this import below `import re`:
import textwrap

Why Text Wrapping Matters

Vulnerability titles vary in length. The `textwrap` module breaks longer text into predictable lines that fit the card.

  • Add the Pillow imports below the existing `urllib.request` import:
from PIL import Image, ImageDraw, ImageFont

What These Pillow Tools Do

  • `Image` creates the RGB canvas.
  • `ImageDraw` places text on the canvas.
  • `ImageFont` provides readable font objects for each text style.
  • Add the poster output path directly below `BRIEF_PATH`:
POSTER_PATH = OUTPUT_DIR / "poster.png"

Where the Poster Goes

`POSTER_PATH` keeps the generated image inside the existing `output` folder. Every approved run replaces that file with the latest poster.

  • Add the vertical canvas dimensions directly below `MODEL`:
VIDEO_SIZE = (1080, 1920)

Why Use a Fixed Canvas

`VIDEO_SIZE` gives every episode the same portrait dimensions. The next step can reuse this exact frame size when it renders the MP4.

  • Add the wrapping helper directly below `review_brief`:
def wrapped(text, width):
    return textwrap.fill(
        str(text),
        width=width,
        break_long_words=False,
        break_on_hyphens=False,
    )

What Does This Helper Do?

  • `str(text)` ensures the helper receives text even when a source value has another type.
  • `width` controls the approximate number of characters on each line.
  • The two `False` values keep long words and hyphenated terms intact.
  • Start `render_poster` directly below `wrapped` by adding this first section:
def render_poster(brief):
    background = (10, 18, 32)
    white = (244, 247, 250)
    muted = (171, 186, 201)
    accent = (66, 211, 146)
    warning = (255, 190, 92)

    image = Image.new("RGB", VIDEO_SIZE, color=background)
    draw = ImageDraw.Draw(image)
    kicker_font = ImageFont.load_default(size=38)
    title_font = ImageFont.load_default(size=72)
    label_font = ImageFont.load_default(size=34)
    body_font = ImageFont.load_default(size=46)
    footer_font = ImageFont.load_default(size=28)

    draw.text((70, 70), "CYBERBRIEF", font=kicker_font, fill=accent)
    draw.text((760, 70), brief["category"], font=footer_font, fill=muted)

    title = wrapped(brief["title"][:90], 22)
    draw.multiline_text(
        (70, 170),
        title,
        font=title_font,
        fill=white,
        spacing=14,
    )

How the Canvas Takes Shape

  • The color tuples create a dark background with white text and restrained accent colors.
  • The font sizes establish a visual hierarchy between the title and supporting labels.
  • The fixed coordinates keep the brand label and approved title inside consistent margins.
  • Complete the first version of `render_poster` by adding these lines directly below the title block:
    image.save(POSTER_PATH)
    return image

How the Image Is Saved

`image.save` uses the `.png` extension to write the canvas as a PNG file. Returning `image` also makes the same frame available to the video renderer in the next step.

  • At the bottom of `cyberbrief.py`, locate these two lines inside `main`:
    save_brief(brief)
    review_brief(brief)

Where Rendering Begins

The existing sequence saves the generated brief before requesting approval. The poster call belongs after `review_brief` so rejected content never reaches the canvas.

  • Replace those lines with this approved-render sequence:
    save_brief(brief)
    review_brief(brief)

    poster = render_poster(brief)

What Changes After Approval

`render_poster` runs only when `review_brief` accepts the exact approval input. The returned image is stored in `poster` for the video work that comes next.

  • Save `cyberbrief.py`.
  • Create the first poster preview by running this command in the activated VS Code PowerShell terminal:
python cyberbrief.py

What Happens on This Run?

The agent downloads the live catalog and generates a new brief from your selected record. It creates `output\poster.png` only after you approve that brief.

  • Choose a category when the terminal prompts you.
  • Choose one of the five displayed topics.
  • Review the generated brief against the printed source facts.
  • Enter `APPROVE` exactly when the brief is supported by those facts.
  • In the VS Code file sidebar, expand the `output` folder.
  • Click `poster.png` to open the preview.

Your first visual layer is working. You should see a dark vertical card with `CYBERBRIEF`, the selected category, and the approved title near the top.

Poster Preview Missing?

Confirm that you entered `APPROVE` with the same capitalization. Any other response stops the script before rendering.

Check that `POSTER_PATH` uses the existing `OUTPUT_DIR`. Also confirm that `render_poster` is called after `review_brief`.

Help me debug why `output\poster.png` was not created.

Add the lesson content and source footer

The first preview proves that Pillow can render the approved title. The remaining layout turns that canvas into a useful lesson by adding source-owned metadata and three short takeaways.

  • Inside `render_poster`, insert this metadata block directly above `image.save(POSTER_PATH)`:
    metadata = (
        f"{brief['cve_id']}  |  {brief['vendor']}  |  "
        f"Added {brief['date_added']}"
    )
    draw.multiline_text(
        (70, 570),
        wrapped(metadata, 48),
        font=label_font,
        fill=warning,
        spacing=8,
    )

Why Metadata Comes from Python

The CVE ID and date come from fields that Python attached to the approved brief. This keeps the identifying details tied to the selected source record.

The warning color separates the metadata from the lesson title. Wrapping protects the layout when a vendor name is long.

  • Save `cyberbrief.py`.
  • Render the metadata preview by running:
python cyberbrief.py

What This Check Proves

This run regenerates the approved poster with the new metadata layer. Opening the PNG confirms that the selected CVE and date fit below the title.

  • Select a displayed topic.
  • Review the new brief against its source facts.
  • Enter `APPROVE` exactly.
  • Return to `output\poster.png` in the VS Code file sidebar.

You should now see the selected CVE ID and vendor beneath the title. The line also shows the date that CISA added the record.

  • Inside `render_poster`, insert the takeaway layout directly below the metadata block:
    y_positions = (760, 1040, 1320)
    for index, (takeaway, y) in enumerate(zip(brief["takeaways"], y_positions), start=1):
        draw.text((70, y), f"TAKEAWAY {index}", font=label_font, fill=accent)
        draw.multiline_text(
            (70, y + 65),
            wrapped(takeaway[:170], 32),
            font=body_font,
            fill=white,
            spacing=12,
        )

How the Takeaways Stay Readable

  • `y_positions` gives each takeaway its own vertical section.
  • `enumerate` creates the three numbered labels.
  • The character limit and wrapping keep generated text inside the card margins.
  • Insert the source footer directly below the takeaway loop:
    footer = f"Source: CISA KEV | {brief['cve_id']} | Human review required"
    draw.multiline_text(
        (70, 1770),
        wrapped(footer, 62),
        font=footer_font,
        fill=muted,
        spacing=8,
    )

Why the Footer Stays Visible

The footer names CISA KEV and repeats the selected CVE ID. It also records that human review remains part of the workflow.

  • Save `cyberbrief.py`.
  • Pause and predict whether the finished poster keeps all three takeaways readable while preserving the source footer.
  • Render the completed learning card by running:
python cyberbrief.py

What the Final Run Checks

The command exercises the complete reviewed workflow. A valid approval now produces the finished poster from the selected brief.

  • Choose a category or enter a keyword or CVE ID.
  • Choose one of the five dated suggestions.
  • Compare the generated lesson with the displayed source record.
  • Enter `APPROVE` exactly when every claim is supported.
  • Return to `output\poster.png` in the VS Code file sidebar.

That is the complete learning card. You should see a `1080` by `1920` poster with the title, category, CVE ID, vendor, added date, three takeaways, and the CISA KEV human-review footer.

✔️ Awesome, I've got everything!

Your saved `cyberbrief.py` now renders the approved brief as `output\poster.png`. Double check that both files are saved before moving on.

ⓧ I'd like to double check the full code

Compare your current `cyberbrief.py` with this cumulative version:

import json
import re
import textwrap
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen

from PIL import Image, ImageDraw, ImageFont

BASE_DIR = Path(__file__).resolve().parent
OUTPUT_DIR = BASE_DIR / "output"
BRIEF_PATH = OUTPUT_DIR / "brief.json"
POSTER_PATH = OUTPUT_DIR / "poster.png"

KEV_URL = (
    "https://www.cisa.gov/sites/default/files/feeds/"
    "known_exploited_vulnerabilities.json"
)
OLLAMA_URL = "http://localhost:11434/api/chat"
MODEL = "gemma3:1b"
VIDEO_SIZE = (1080, 1920)

AI_TERMS = (
    "artificial intelligence",
    "large language model",
    "llm",
    "machine learning",
    "langflow",
    "litellm",
    "mlflow",
    "model serving",
    "prompt",
)

EMBEDDED_TERMS = (
    "router",
    "camera",
    "firmware",
    "chipset",
    "android",
    "ios",
    "iot",
    "industrial",
    "plc",
    "hmi",
    "scada",
    "firewall",
    "appliance",
    "gateway",
    "switch",
    "controller",
    "medical device",
)

BRIEF_SCHEMA = {
    "type": "object",
    "properties": {
        "title": {"type": "string"},
        "hook": {"type": "string"},
        "plain_explanation": {"type": "string"},
        "takeaways": {
            "type": "array",
            "items": {"type": "string"},
            "minItems": 3,
            "maxItems": 3,
        },
        "narration": {"type": "string"},
    },
    "required": [
        "title",
        "hook",
        "plain_explanation",
        "takeaways",
        "narration",
    ],
}


def fetch_json(url):
    request = Request(url, headers={"User-Agent": "CyberBriefStudio/1.0"})
    try:
        with urlopen(request, timeout=30) as response:
            return json.load(response)
    except (HTTPError, URLError, TimeoutError) as error:
        raise RuntimeError(f"Could not download {url}: {error}") from error


def searchable_text(item):
    fields = (
        item.get("cveID", ""),
        item.get("vendorProject", ""),
        item.get("product", ""),
        item.get("vulnerabilityName", ""),
        item.get("shortDescription", ""),
        item.get("cwes", []),
    )
    return " ".join(
        " ".join(value) if isinstance(value, list) else str(value)
        for value in fields
    ).casefold()


def category_of(item):
    text = searchable_text(item)
    if any(term in text for term in AI_TERMS):
        return "AI security"
    if any(term in text for term in EMBEDDED_TERMS):
        return "Embedded and product security"
    return "Cybersecurity"


def choose_topic(catalog):
    items = sorted(
        catalog.get("vulnerabilities", []),
        key=lambda item: item.get("dateAdded", ""),
        reverse=True,
    )
    if not items:
        raise RuntimeError("The CISA catalog did not contain any vulnerabilities.")

    print(f"\nCISA KEV catalog version: {catalog.get('catalogVersion', 'unknown')}")
    print("1. Cybersecurity")
    print("2. AI security")
    print("3. Embedded and product security")
    print("4. All recent entries")

    category_choice = input("Choose a category [4]: ").strip() or "4"
    categories = {
        "1": "Cybersecurity",
        "2": "AI security",
        "3": "Embedded and product security",
        "4": "All",
    }
    category = categories.get(category_choice, "All")
    query = input("Optional keyword or CVE ID, or press Enter for suggestions: ").strip()

    if query:
        query_text = query.casefold()
        pool = [item for item in items if query_text in searchable_text(item)]
    elif category == "All":
        pool = items
    else:
        pool = [item for item in items if category_of(item) == category]

    if not pool:
        raise RuntimeError("No matching CISA KEV entries were found. Try another search.")

    suggestions = pool[:5]
    print("\nRecent high-priority topics:")
    for index, item in enumerate(suggestions, start=1):
        print(
            f"{index}. {item.get('dateAdded', 'unknown')} | "
            f"{item.get('cveID', 'no CVE')} | "
            f"{item.get('vulnerabilityName', 'untitled')}"
        )

    selection = input("Choose a topic [1]: ").strip() or "1"
    if not selection.isdigit() or not 1 <= int(selection) <= len(suggestions):
        raise RuntimeError("Choose one of the displayed topic numbers.")

    selected = suggestions[int(selection) - 1]
    print("\nSelected source record:")
    print(json.dumps(selected, indent=2, ensure_ascii=False))
    return selected


def first_reference(notes):
    matches = re.findall(r"https?://[^\s;]+", notes or "")
    return matches[0].rstrip(".,") if matches else None


def generate_brief(topic):
    source_facts = {
        "cveID": topic.get("cveID", ""),
        "vendorProject": topic.get("vendorProject", ""),
        "product": topic.get("product", ""),
        "vulnerabilityName": topic.get("vulnerabilityName", ""),
        "dateAdded": topic.get("dateAdded", ""),
        "shortDescription": topic.get("shortDescription", ""),
        "requiredAction": topic.get("requiredAction", ""),
        "knownRansomwareCampaignUse": topic.get(
            "knownRansomwareCampaignUse", "Unknown"
        ),
        "cwes": topic.get("cwes", []),
    }

    prompt = f"""
Create a beginner-friendly cybersecurity microlearning brief from the source facts.

Rules:
1. Use only the supplied facts.
2. Preserve the CVE ID, affected vendor, and product exactly.
3. Do not invent versions, attack steps, exploitation evidence, or mitigations.
4. Explain technical terms with everyday language.
5. Keep each takeaway below 18 words.
6. Keep narration between 90 and 120 words.
7. If a detail is missing, omit it rather than guessing.
8. Return only JSON matching the supplied schema.

SOURCE FACTS:
{json.dumps(source_facts, indent=2, ensure_ascii=False)}
""".strip()

    payload = {
        "model": MODEL,
        "messages": [
            {
                "role": "system",
                "content": (
                    "You are a careful cybersecurity educator. "
                    "Accuracy is more important than drama."
                ),
            },
            {"role": "user", "content": prompt},
        ],
        "stream": False,
        "format": BRIEF_SCHEMA,
        "options": {"temperature": 0},
    }

    request = Request(
        OLLAMA_URL,
        data=json.dumps(payload).encode("utf-8"),
        headers={"Content-Type": "application/json"},
        method="POST",
    )

    try:
        with urlopen(request, timeout=180) as response:
            result = json.load(response)
    except (HTTPError, URLError, TimeoutError) as error:
        raise RuntimeError(
            "Ollama did not respond. Start Ollama and run gemma3:1b once."
        ) from error

    brief = json.loads(result["message"]["content"])
    required = {
        "title",
        "hook",
        "plain_explanation",
        "takeaways",
        "narration",
    }
    if not required.issubset(brief):
        raise RuntimeError("The model response is missing required brief fields.")
    if not isinstance(brief["takeaways"], list) or len(brief["takeaways"]) != 3:
        raise RuntimeError("The model must return exactly three takeaways.")

    references = [KEV_URL]
    vendor_reference = first_reference(topic.get("notes", ""))
    if vendor_reference:
        references.append(vendor_reference)

    brief.update(
        {
            "cve_id": topic.get("cveID", ""),
            "vendor": topic.get("vendorProject", ""),
            "product": topic.get("product", ""),
            "date_added": topic.get("dateAdded", ""),
            "category": category_of(topic),
            "source_urls": references,
            "source_facts": source_facts,
        }
    )
    return brief


def save_brief(brief):
    OUTPUT_DIR.mkdir(exist_ok=True)
    BRIEF_PATH.write_text(
        json.dumps(brief, indent=2, ensure_ascii=False),
        encoding="utf-8",
    )


def review_brief(brief):
    print("\nGenerated brief for human review:")
    print(json.dumps(brief, indent=2, ensure_ascii=False))
    approval = input("\nType APPROVE to render, or anything else to stop: ").strip()
    if approval != "APPROVE":
        raise SystemExit(
            f"Stopped before rendering. Review the saved brief at {BRIEF_PATH}."
        )


def wrapped(text, width):
    return textwrap.fill(
        str(text),
        width=width,
        break_long_words=False,
        break_on_hyphens=False,
    )


def render_poster(brief):
    background = (10, 18, 32)
    white = (244, 247, 250)
    muted = (171, 186, 201)
    accent = (66, 211, 146)
    warning = (255, 190, 92)

    image = Image.new("RGB", VIDEO_SIZE, color=background)
    draw = ImageDraw.Draw(image)
    kicker_font = ImageFont.load_default(size=38)
    title_font = ImageFont.load_default(size=72)
    label_font = ImageFont.load_default(size=34)
    body_font = ImageFont.load_default(size=46)
    footer_font = ImageFont.load_default(size=28)

    draw.text((70, 70), "CYBERBRIEF", font=kicker_font, fill=accent)
    draw.text((760, 70), brief["category"], font=footer_font, fill=muted)

    title = wrapped(brief["title"][:90], 22)
    draw.multiline_text(
        (70, 170),
        title,
        font=title_font,
        fill=white,
        spacing=14,
    )

    metadata = (
        f"{brief['cve_id']}  |  {brief['vendor']}  |  "
        f"Added {brief['date_added']}"
    )
    draw.multiline_text(
        (70, 570),
        wrapped(metadata, 48),
        font=label_font,
        fill=warning,
        spacing=8,
    )

    y_positions = (760, 1040, 1320)
    for index, (takeaway, y) in enumerate(zip(brief["takeaways"], y_positions), start=1):
        draw.text((70, y), f"TAKEAWAY {index}", font=label_font, fill=accent)
        draw.multiline_text(
            (70, y + 65),
            wrapped(takeaway[:170], 32),
            font=body_font,
            fill=white,
            spacing=12,
        )

    footer = f"Source: CISA KEV | {brief['cve_id']} | Human review required"
    draw.multiline_text(
        (70, 1770),
        wrapped(footer, 62),
        font=footer_font,
        fill=muted,
        spacing=8,
    )

    image.save(POSTER_PATH)
    return image


def main():
    OUTPUT_DIR.mkdir(exist_ok=True)
    print("CyberBrief Studio")
    print("Downloading the live CISA Known Exploited Vulnerabilities catalog...")

    catalog = fetch_json(KEV_URL)
    topic = choose_topic(catalog)
    brief = generate_brief(topic)
    save_brief(brief)
    review_brief(brief)

    poster = render_poster(brief)


if __name__ == "__main__":
    main()

Confirm that your imports, poster constants, helper, renderer, and `main` sequence match this reference. Save the file after correcting any differences.

Poster Text Missing or Overlapping?

Confirm that `brief["takeaways"]` contains exactly three items. The three fixed positions expect the validated brief structure from the previous step.

Check each indentation level inside the `for` loop. The numbered label and wrapped takeaway must both remain inside that loop.

Help me debug the layout in my generated CyberBrief poster.

Your approved lesson now has a cited visual format that is ready to watch. Next, you will add local narration and turn this poster into an MP4.

Render the Narrated MP4

Your approved poster gives each CyberBrief a readable visual. The final MP4 adds narration so someone can learn from the card without reading every line.

In this step, you will generate speech with Windows PowerShell. You will use imageio-ffmpeg to combine that speech with the poster inside a local video.

In this step, get ready to:
  • Create a Windows speech synthesis script.
  • Generate a WAV narration from the approved brief.
  • Render a vertical video with AAC audio.
Build local speech synthesis

The approved narration already exists inside the brief. A PowerShell script can pass that text to the Windows speech engine and save the spoken result as a WAV file.

  • Click the New File button in the VS Code Explorer sidebar.
  • Name the file narrate.ps1 inside the cyberbrief-studio folder.
  • Paste this speech synthesis script into narrate.ps1:
param(
    [Parameter(Mandatory = $true)]
    [string]$InputFile,

    [Parameter(Mandatory = $true)]
    [string]$OutputFile
)

Add-Type -AssemblyName System.Speech

$text = Get-Content -LiteralPath $InputFile -Raw -Encoding UTF8
$synth = New-Object System.Speech.Synthesis.SpeechSynthesizer

try {
    $synth.SetOutputToWaveFile($OutputFile)
    $synth.Speak($text)
}
finally {
    $synth.Dispose()
}

What does this script do?

  • The two parameters receive the narration text path and the WAV output path.
  • The script loads the Windows speech assembly and reads the approved narration as UTF-8 text.
  • The synthesizer speaks the text into a WAV file before releasing the speech engine.
  • Save narrate.ps1.
  • Confirm the VS Code Explorer lists narrate.ps1 directly inside cyberbrief-studio.

Script missing from the project?

Check that the filename ends with .ps1. Confirm it sits beside cyberbrief.py instead of inside output.

Ask for help if the file appears elsewhere. Help me place narrate.ps1 in the correct CyberBrief Studio folder.

✔️ Awesome, I've got everything!

Great. Your local narration script is saved beside the Python workflow.

ⓧ I'd like to double check the full code

Compare your narrate.ps1 file with this complete version.

param(
    [Parameter(Mandatory = $true)]
    [string]$InputFile,

    [Parameter(Mandatory = $true)]
    [string]$OutputFile
)

Add-Type -AssemblyName System.Speech

$text = Get-Content -LiteralPath $InputFile -Raw -Encoding UTF8
$synth = New-Object System.Speech.Synthesis.SpeechSynthesizer

try {
    $synth.SetOutputToWaveFile($OutputFile)
    $synth.Speak($text)
}
finally {
    $synth.Dispose()
}
Connect Python to the speech engine

Python needs paths for every media artifact before it can coordinate narration and video rendering. It also needs modules for launching PowerShell and reading the finished audio duration.

  • In cyberbrief.py, replace the import and path-constant section at the top with this complete section:
import json
import math
import re
import subprocess
import textwrap
import wave
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen

import imageio_ffmpeg
from PIL import Image, ImageDraw, ImageFont

BASE_DIR = Path(__file__).resolve().parent
OUTPUT_DIR = BASE_DIR / "output"
BRIEF_PATH = OUTPUT_DIR / "brief.json"
POSTER_PATH = OUTPUT_DIR / "poster.png"
NARRATION_TEXT_PATH = OUTPUT_DIR / "narration.txt"
NARRATION_WAV_PATH = OUTPUT_DIR / "narration.wav"
VIDEO_PATH = OUTPUT_DIR / "cyberbrief.mp4"
NARRATOR_PATH = BASE_DIR / "narrate.ps1"

KEV_URL = (
    "https://www.cisa.gov/sites/default/files/feeds/"
    "known_exploited_vulnerabilities.json"
)
OLLAMA_URL = "http://localhost:11434/api/chat"
MODEL = "gemma3:1b"
VIDEO_SIZE = (1080, 1920)
VIDEO_FPS = 1

What does this setup add?

  • The subprocess module launches the PowerShell narrator from Python.
  • The wave module reads the narration length from the generated audio file.
  • The new path constants keep the narration text, WAV audio, video, and PowerShell script tied to the cyberbrief-studio folder.
  • The VIDEO_FPS value keeps the static poster on screen at one frame per second.
  • Save cyberbrief.py.
  • Check the VS Code Problems panel for syntax errors.

You should see no Python syntax errors from the replacement.

Seeing import or path errors?

Check that each import appears once. Confirm the new path constants remain above KEV_URL.

Use the full replacement block to restore the expected order. Help me debug the imports and media paths in cyberbrief.py.

The narration function preserves the approved wording by writing brief["narration"] directly to a UTF-8 file. It supplies absolute paths to PowerShell so the script reads and writes the intended files.

  • In cyberbrief.py, add this function directly below render_poster():
def create_narration(brief):
    NARRATION_TEXT_PATH.write_text(brief["narration"], encoding="utf-8")
    if NARRATION_WAV_PATH.exists():
        NARRATION_WAV_PATH.unlink()

    subprocess.run(
        [
            "powershell.exe",
            "-NoProfile",
            "-ExecutionPolicy",
            "Bypass",
            "-File",
            str(NARRATOR_PATH.resolve()),
            "-InputFile",
            str(NARRATION_TEXT_PATH.resolve()),
            "-OutputFile",
            str(NARRATION_WAV_PATH.resolve()),
        ],
        check=True,
    )

How does Python create the narration?

  • The function writes the approved narration to output/narration.txt using UTF-8 encoding.
  • It removes an older narration file so every run starts with fresh audio.
  • It invokes powershell.exe with the script path plus absolute input and output paths.
  • The check=True setting stops Python if speech synthesis fails.
  • Save cyberbrief.py.
  • Confirm create_narration() appears directly below render_poster().

Function in the wrong place?

Make sure def create_narration(brief): starts at the far-left edge. Placing it inside render_poster() prevents the main workflow from finding it.

Check the indentation against the code block. Help me fix the placement of create_narration in cyberbrief.py.

Render the narrated video

The poster needs to remain visible for the full length of the narration. The workflow reads the WAV duration and calculates enough poster frames to cover every second.

  • Add this duration helper directly below create_narration():
def narration_duration():
    with wave.open(str(NARRATION_WAV_PATH), "rb") as audio:
        return audio.getnframes() / audio.getframerate()

How is the duration calculated?

A WAV file records its frame count and sample rate. Dividing those values gives the narration length in seconds.

  • Add this video renderer directly below narration_duration():
def render_video(image):
    duration = narration_duration()
    frame_count = max(1, math.ceil(duration * VIDEO_FPS))
    frame_bytes = image.tobytes()

    writer = imageio_ffmpeg.write_frames(
        str(VIDEO_PATH),
        VIDEO_SIZE,
        fps=VIDEO_FPS,
        quality=7,
        audio_path=str(NARRATION_WAV_PATH),
        audio_codec="aac",
    )
    writer.send(None)
    try:
        for _ in range(frame_count):
            writer.send(frame_bytes)
    finally:
        writer.close()

    if not VIDEO_PATH.exists() or VIDEO_PATH.stat().st_size == 0:
        raise RuntimeError("Video rendering did not create a valid output file.")
    return duration

How does the video renderer work?

  • The frame count rounds the narration duration up so the poster does not disappear before the audio ends.
  • The writer creates a 1080 by 1920 video at VIDEO_FPS using the poster's raw RGB bytes.
  • The audio_path setting attaches the WAV narration with the aac audio codec.
  • The final check rejects a missing or empty video file.
  • At the bottom of cyberbrief.py, replace the existing main workflow with this completed version:
def main():
    OUTPUT_DIR.mkdir(exist_ok=True)
    print("CyberBrief Studio")
    print("Downloading the live CISA Known Exploited Vulnerabilities catalog...")

    catalog = fetch_json(KEV_URL)
    topic = choose_topic(catalog)
    brief = generate_brief(topic)
    save_brief(brief)
    review_brief(brief)

    poster = render_poster(brief)
    create_narration(brief)
    duration = render_video(poster)

    print(f"\nPoster: {POSTER_PATH}")
    print(f"Narration: {NARRATION_WAV_PATH}")
    print(f"Video: {VIDEO_PATH}")
    print(f"Duration: {duration:.1f} seconds")
    print("Review the MP4 and its cited source before any publication workflow.")


if __name__ == "__main__":
    main()

What completes the workflow?

The approval gate still runs before any media generation. Once you enter APPROVE, the workflow renders the poster and generates the narration.

The final stage combines both artifacts into output/cyberbrief.mp4. The terminal reports every reviewable output path.

  • Save all project files.

✔️ Awesome, I've got everything!

Excellent. Your Python workflow and PowerShell narrator now contain every required media stage.

ⓧ I'd like to double check the full code

Compare your complete cyberbrief.py file with this version.

import json
import math
import re
import subprocess
import textwrap
import wave
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen

import imageio_ffmpeg
from PIL import Image, ImageDraw, ImageFont

BASE_DIR = Path(__file__).resolve().parent
OUTPUT_DIR = BASE_DIR / "output"
BRIEF_PATH = OUTPUT_DIR / "brief.json"
POSTER_PATH = OUTPUT_DIR / "poster.png"
NARRATION_TEXT_PATH = OUTPUT_DIR / "narration.txt"
NARRATION_WAV_PATH = OUTPUT_DIR / "narration.wav"
VIDEO_PATH = OUTPUT_DIR / "cyberbrief.mp4"
NARRATOR_PATH = BASE_DIR / "narrate.ps1"

KEV_URL = (
    "https://www.cisa.gov/sites/default/files/feeds/"
    "known_exploited_vulnerabilities.json"
)
OLLAMA_URL = "http://localhost:11434/api/chat"
MODEL = "gemma3:1b"
VIDEO_SIZE = (1080, 1920)
VIDEO_FPS = 1

AI_TERMS = (
    "artificial intelligence",
    "large language model",
    "llm",
    "machine learning",
    "langflow",
    "litellm",
    "mlflow",
    "model serving",
    "prompt",
)

EMBEDDED_TERMS = (
    "router",
    "camera",
    "firmware",
    "chipset",
    "android",
    "ios",
    "iot",
    "industrial",
    "plc",
    "hmi",
    "scada",
    "firewall",
    "appliance",
    "gateway",
    "switch",
    "controller",
    "medical device",
)

BRIEF_SCHEMA = {
    "type": "object",
    "properties": {
        "title": {"type": "string"},
        "hook": {"type": "string"},
        "plain_explanation": {"type": "string"},
        "takeaways": {
            "type": "array",
            "items": {"type": "string"},
            "minItems": 3,
            "maxItems": 3,
        },
        "narration": {"type": "string"},
    },
    "required": [
        "title",
        "hook",
        "plain_explanation",
        "takeaways",
        "narration",
    ],
}


def fetch_json(url):
    request = Request(url, headers={"User-Agent": "CyberBriefStudio/1.0"})
    try:
        with urlopen(request, timeout=30) as response:
            return json.load(response)
    except (HTTPError, URLError, TimeoutError) as error:
        raise RuntimeError(f"Could not download {url}: {error}") from error


def searchable_text(item):
    fields = (
        item.get("cveID", ""),
        item.get("vendorProject", ""),
        item.get("product", ""),
        item.get("vulnerabilityName", ""),
        item.get("shortDescription", ""),
        item.get("cwes", []),
    )
    return " ".join(
        " ".join(value) if isinstance(value, list) else str(value)
        for value in fields
    ).casefold()


def category_of(item):
    text = searchable_text(item)
    if any(term in text for term in AI_TERMS):
        return "AI security"
    if any(term in text for term in EMBEDDED_TERMS):
        return "Embedded and product security"
    return "Cybersecurity"


def choose_topic(catalog):
    items = sorted(
        catalog.get("vulnerabilities", []),
        key=lambda item: item.get("dateAdded", ""),
        reverse=True,
    )
    if not items:
        raise RuntimeError("The CISA catalog did not contain any vulnerabilities.")

    print(f"\nCISA KEV catalog version: {catalog.get('catalogVersion', 'unknown')}")
    print("1. Cybersecurity")
    print("2. AI security")
    print("3. Embedded and product security")
    print("4. All recent entries")

    category_choice = input("Choose a category [4]: ").strip() or "4"
    categories = {
        "1": "Cybersecurity",
        "2": "AI security",
        "3": "Embedded and product security",
        "4": "All",
    }
    category = categories.get(category_choice, "All")
    query = input("Optional keyword or CVE ID, or press Enter for suggestions: ").strip()

    if query:
        query_text = query.casefold()
        pool = [item for item in items if query_text in searchable_text(item)]
    elif category == "All":
        pool = items
    else:
        pool = [item for item in items if category_of(item) == category]

    if not pool:
        raise RuntimeError("No matching CISA KEV entries were found. Try another search.")

    suggestions = pool[:5]
    print("\nRecent high-priority topics:")
    for index, item in enumerate(suggestions, start=1):
        print(
            f"{index}. {item.get('dateAdded', 'unknown')} | "
            f"{item.get('cveID', 'no CVE')} | "
            f"{item.get('vulnerabilityName', 'untitled')}"
        )

    selection = input("Choose a topic [1]: ").strip() or "1"
    if not selection.isdigit() or not 1 <= int(selection) <= len(suggestions):
        raise RuntimeError("Choose one of the displayed topic numbers.")

    selected = suggestions[int(selection) - 1]
    print("\nSelected source record:")
    print(json.dumps(selected, indent=2, ensure_ascii=False))
    return selected


def first_reference(notes):
    matches = re.findall(r"https?://[^\s;]+", notes or "")
    return matches[0].rstrip(".,") if matches else None


def generate_brief(topic):
    source_facts = {
        "cveID": topic.get("cveID", ""),
        "vendorProject": topic.get("vendorProject", ""),
        "product": topic.get("product", ""),
        "vulnerabilityName": topic.get("vulnerabilityName", ""),
        "dateAdded": topic.get("dateAdded", ""),
        "shortDescription": topic.get("shortDescription", ""),
        "requiredAction": topic.get("requiredAction", ""),
        "knownRansomwareCampaignUse": topic.get(
            "knownRansomwareCampaignUse", "Unknown"
        ),
        "cwes": topic.get("cwes", []),
    }

    prompt = f"""
Create a beginner-friendly cybersecurity microlearning brief from the source facts.

Rules:
1. Use only the supplied facts.
2. Preserve the CVE ID, affected vendor, and product exactly.
3. Do not invent versions, attack steps, exploitation evidence, or mitigations.
4. Explain technical terms with everyday language.
5. Keep each takeaway below 18 words.
6. Keep narration between 90 and 120 words.
7. If a detail is missing, omit it rather than guessing.
8. Return only JSON matching the supplied schema.

SOURCE FACTS:
{json.dumps(source_facts, indent=2, ensure_ascii=False)}
""".strip()

    payload = {
        "model": MODEL,
        "messages": [
            {
                "role": "system",
                "content": (
                    "You are a careful cybersecurity educator. "
                    "Accuracy is more important than drama."
                ),
            },
            {"role": "user", "content": prompt},
        ],
        "stream": False,
        "format": BRIEF_SCHEMA,
        "options": {"temperature": 0},
    }

    request = Request(
        OLLAMA_URL,
        data=json.dumps(payload).encode("utf-8"),
        headers={"Content-Type": "application/json"},
        method="POST",
    )

    try:
        with urlopen(request, timeout=180) as response:
            result = json.load(response)
    except (HTTPError, URLError, TimeoutError) as error:
        raise RuntimeError(
            "Ollama did not respond. Start Ollama and run gemma3:1b once."
        ) from error

    brief = json.loads(result["message"]["content"])
    required = {
        "title",
        "hook",
        "plain_explanation",
        "takeaways",
        "narration",
    }
    if not required.issubset(brief):
        raise RuntimeError("The model response is missing required brief fields.")
    if not isinstance(brief["takeaways"], list) or len(brief["takeaways"]) != 3:
        raise RuntimeError("The model must return exactly three takeaways.")

    references = [KEV_URL]
    vendor_reference = first_reference(topic.get("notes", ""))
    if vendor_reference:
        references.append(vendor_reference)

    brief.update(
        {
            "cve_id": topic.get("cveID", ""),
            "vendor": topic.get("vendorProject", ""),
            "product": topic.get("product", ""),
            "date_added": topic.get("dateAdded", ""),
            "category": category_of(topic),
            "source_urls": references,
            "source_facts": source_facts,
        }
    )
    return brief


def save_brief(brief):
    OUTPUT_DIR.mkdir(exist_ok=True)
    BRIEF_PATH.write_text(
        json.dumps(brief, indent=2, ensure_ascii=False),
        encoding="utf-8",
    )


def review_brief(brief):
    print("\nGenerated brief for human review:")
    print(json.dumps(brief, indent=2, ensure_ascii=False))
    approval = input("\nType APPROVE to render, or anything else to stop: ").strip()
    if approval != "APPROVE":
        raise SystemExit(
            f"Stopped before rendering. Review the saved brief at {BRIEF_PATH}."
        )


def wrapped(text, width):
    return textwrap.fill(
        str(text),
        width=width,
        break_long_words=False,
        break_on_hyphens=False,
    )


def render_poster(brief):
    background = (10, 18, 32)
    white = (244, 247, 250)
    muted = (171, 186, 201)
    accent = (66, 211, 146)
    warning = (255, 190, 92)

    image = Image.new("RGB", VIDEO_SIZE, color=background)
    draw = ImageDraw.Draw(image)
    kicker_font = ImageFont.load_default(size=38)
    title_font = ImageFont.load_default(size=72)
    label_font = ImageFont.load_default(size=34)
    body_font = ImageFont.load_default(size=46)
    footer_font = ImageFont.load_default(size=28)

    draw.text((70, 70), "CYBERBRIEF", font=kicker_font, fill=accent)
    draw.text((760, 70), brief["category"], font=footer_font, fill=muted)

    title = wrapped(brief["title"][:90], 22)
    draw.multiline_text(
        (70, 170),
        title,
        font=title_font,
        fill=white,
        spacing=14,
    )

    metadata = (
        f"{brief['cve_id']}  |  {brief['vendor']}  |  "
        f"Added {brief['date_added']}"
    )
    draw.multiline_text(
        (70, 570),
        wrapped(metadata, 48),
        font=label_font,
        fill=warning,
        spacing=8,
    )

    y_positions = (760, 1040, 1320)
    for index, (takeaway, y) in enumerate(zip(brief["takeaways"], y_positions), start=1):
        draw.text((70, y), f"TAKEAWAY {index}", font=label_font, fill=accent)
        draw.multiline_text(
            (70, y + 65),
            wrapped(takeaway[:170], 32),
            font=body_font,
            fill=white,
            spacing=12,
        )

    footer = f"Source: CISA KEV | {brief['cve_id']} | Human review required"
    draw.multiline_text(
        (70, 1770),
        wrapped(footer, 62),
        font=footer_font,
        fill=muted,
        spacing=8,
    )

    image.save(POSTER_PATH)
    return image


def create_narration(brief):
    NARRATION_TEXT_PATH.write_text(brief["narration"], encoding="utf-8")
    if NARRATION_WAV_PATH.exists():
        NARRATION_WAV_PATH.unlink()

    subprocess.run(
        [
            "powershell.exe",
            "-NoProfile",
            "-ExecutionPolicy",
            "Bypass",
            "-File",
            str(NARRATOR_PATH.resolve()),
            "-InputFile",
            str(NARRATION_TEXT_PATH.resolve()),
            "-OutputFile",
            str(NARRATION_WAV_PATH.resolve()),
        ],
        check=True,
    )


def narration_duration():
    with wave.open(str(NARRATION_WAV_PATH), "rb") as audio:
        return audio.getnframes() / audio.getframerate()


def render_video(image):
    duration = narration_duration()
    frame_count = max(1, math.ceil(duration * VIDEO_FPS))
    frame_bytes = image.tobytes()

    writer = imageio_ffmpeg.write_frames(
        str(VIDEO_PATH),
        VIDEO_SIZE,
        fps=VIDEO_FPS,
        quality=7,
        audio_path=str(NARRATION_WAV_PATH),
        audio_codec="aac",
    )
    writer.send(None)
    try:
        for _ in range(frame_count):
            writer.send(frame_bytes)
    finally:
        writer.close()

    if not VIDEO_PATH.exists() or VIDEO_PATH.stat().st_size == 0:
        raise RuntimeError("Video rendering did not create a valid output file.")
    return duration


def main():
    OUTPUT_DIR.mkdir(exist_ok=True)
    print("CyberBrief Studio")
    print("Downloading the live CISA Known Exploited Vulnerabilities catalog...")

    catalog = fetch_json(KEV_URL)
    topic = choose_topic(catalog)
    brief = generate_brief(topic)
    save_brief(brief)
    review_brief(brief)

    poster = render_poster(brief)
    create_narration(brief)
    duration = render_video(poster)

    print(f"\nPoster: {POSTER_PATH}")
    print(f"Narration: {NARRATION_WAV_PATH}")
    print(f"Video: {VIDEO_PATH}")
    print(f"Duration: {duration:.1f} seconds")
    print("Review the MP4 and its cited source before any publication workflow.")


if __name__ == "__main__":
    main()

The local generation and encoding stages can sit quietly while your computer works. A longer pause during these stages does not mean the workflow has stopped.

Before you run the completed workflow, which files do you expect it to create after you approve the brief?

  • Run the completed CyberBrief Studio workflow with this command:
python cyberbrief.py
  • Choose a security category when prompted.
  • Select one of the five displayed topics.
  • Review the generated brief against the displayed source record.
  • Type APPROVE exactly when every claim is supported.
  • Watch the terminal until it reports the completed media paths.

You should see lines for Poster:, Narration:, Video:, and Duration:.

  • Expand the output folder in the VS Code Explorer.
  • Confirm it contains brief.json, poster.png, narration.txt, narration.wav, and cyberbrief.mp4.
  • Open output/cyberbrief.mp4 in your Windows video player.
  • Listen to the full narration.
  • Compare the visible CVE and source footer with the selected record.

You should see the vertical CyberBrief poster remain visible while the approved narration plays. The CVE and CISA source footer should match the selected source record.

That completes the local media pipeline. You now have a cited video package that stays under human review before any future publishing workflow.

Narration or video missing?

If narration does not start, confirm narrate.ps1 sits beside cyberbrief.py. Check that Windows has an installed speech voice.

If the WAV exists but the MP4 does not, confirm imageio-ffmpeg==0.6.0 remains installed in the activated virtual environment.

Share the traceback without including private information. Help me debug CyberBrief narration or MP4 rendering.

Secret mission

Run a Cross-Domain Fact-Checking Drill

Test CyberBrief Studio with a second CISA record from another security domain. Audit every generated claim before approval. Then review the replacement video and record one improvement for a future daily scheduler.

Clean Up Your Resources

Clean Up Your Resources

CyberBrief Studio runs entirely on your computer, so there are no ongoing service fees or cloud charges. Decide whether to keep the local studio ready, pause its running processes, or remove it from your computer.

Resources you used:

  • The cyberbrief-studio project directory containing cyberbrief.py, narrate.ps1, requirements.txt, .venv, plus your editorial review note.
  • The output directory containing the reviewed second episode in JSON, PNG, TXT, WAV, plus MP4 formats.
  • The Ollama 0.33.3 installation containing the downloaded Gemma 3 1B model identified as gemma3:1b.
  • The virtual environment containing Pillow 12.3.0 plus imageio-ffmpeg 0.6.0.

Keep everything running

No action is needed. Choose this if you plan to create more reviewed CyberBrief episodes.

  • Keep the cyberbrief-studio directory to preserve the complete workflow plus its virtual environment.
  • Keep the output directory to preserve the audited second episode.
  • Keep your editorial review note as the starting point for a future daily scheduler.
  • Leave Ollama installed so gemma3:1b remains ready for another local generation run.

Pause - I'll come back to this later

Shut down the running tools to free up memory. Your project files plus local model stay on disk for the next session.

  • Quit the Ollama tray application from its notification-area menu.
  • Close the current VS Code terminal to end its activated .venv session.
  • Close VS Code after saving your editorial review note.

Your source files, second episode, virtual environment, plus downloaded model remain available when you return.

Delete - I don't want to use this again

Removing the project directory is permanent. Your reviewed second episode plus editorial review note disappear with it.

  • Copy any reviewed output you want to preserve into a different directory.
  • Check which local models share Ollama's storage by running this command:
ollama ls

What Should I See?

You'll see gemma3:1b among the stored models. Any additional entry represents another local model you may want to preserve.

Can't List Your Models?

  • Return to the Ollama tray application from earlier before retrying the command.
  • Ask for help with the local model list.
  • Quit the Ollama tray application after checking the model list.
  • In the Visual Studio Code Explorer sidebar, locate the cyberbrief-studio directory.
  • Use the directory's context menu to reveal it in Windows File Explorer.
  • Close Visual Studio Code.
  • Select the cyberbrief-studio directory in File Explorer.
  • Press Shift+Delete.
  • Confirm the permanent deletion in the Windows prompt.

You should no longer see cyberbrief-studio in its previous File Explorer location.

Ollama is installed separately from the project directory. Remove it only if you no longer need local models for other projects.

  • Press the Windows key to open search.
  • Enter Windows Settings to open the settings app.
  • Open the apps list.
  • Select Ollama from the installed applications.
  • Use its uninstall control to remove the Ollama application.
  • Remove Ollama's model storage only if the earlier model list showed no model you need to keep.

You should no longer find Ollama among the installed applications.

Nice Work!

Nice Work!

You did it! You built CyberBrief Studio, a local review-first agent that turns records from the CISA Known Exploited Vulnerabilities Catalog into cited vertical microlearning videos.

You've learned how to:

  • Built a source-grounded topic picker that downloads the live CISA catalog. It finds recent vulnerabilities by security domain, keyword, or CVE ID.
  • Used Ollama to convert selected source facts into a structured lesson. Added an exact APPROVE gate before any media rendering begins.
  • Created a cited vertical video package containing a poster, narration, structured brief, source URLs, and a reviewable MP4.
  • Completed the Secret Mission by producing a second episode from another security domain. Audited every generated claim against its selected CISA record and referenced advisory before approval.

Ready to quiz yourself?