LLM-driven system boundary & responsibility mapper on Civo

Transform unstructured architecture notes into interactive system diagrams showing team ownership using LLMs on Civo.

5 minutes reading time

Written by

Mostafa Ibrahim
Mostafa Ibrahim

Software Engineer @ GoCardless

Your architecture knowledge is scattered across Slack threads, wiki pages, and incident postmortems. Teams lose track of system boundaries, component ownership, and who's responsible for what. Manually maintaining system maps? They're outdated the moment you finish them.

This tutorial fixes that. You'll build a tool that automatically generates interactive system maps from messy architecture notes. Paste unstructured text, get color-coded diagrams showing team ownership and component relationships. Everything runs on Civo Kubernetes with relaxAI handling the LLM inference.

This tutorial is aimed at intermediate-level developers comfortable with Python and Docker. Kubernetes experience is helpful but not required. 

Prerequisites

Before starting, ensure you have:

Project Structure

Create the following directory structure:

system-mapper/
├── backend/
│ ├── Dockerfile
│ ├── requirements.txt
│ └── app.py
├── frontend/
│ ├── Dockerfile
│ ├── index.html
│ └── app.js
└── kubernetes/
├── backend-deployment.yaml
└── frontend-deployment.yaml

Step 1: Create and connect to your Civo Kubernetes cluster

  • Log in to the Civo Dashboard
  • Create a new Kubernetes cluster
  • Add a GPU node pool (one node is sufficient for this demo)
  • Download your kubeconfig file
  • Connect to the cluster by running set KUBECONFIG=path-to-kubeconfig-file in your terminal (use export instead of set on Linux or macOS)
  • Verify connectivity by running kubectl get nodes
LLM-Driven System Boundary & Responsibility Mapper on Civo

While this demo processes lightweight inputs, the GPU-enabled cluster ensures the setup can handle larger, more compute-intensive inference workloads without redesigning the system.

Step 2: Build the backend

In this step, you’ll build the FastAPI backend that powers the system mapper. This service accepts raw architecture notes, sends them to relaxAI for structured extraction, and returns a clean JSON response containing nodes and edges. 

By the end of this step, you’ll have a working /map endpoint running locally that converts unstructured text into a structured system graph. Open app.py. In this file, you’ll add all the code in this step, building the backend incrementally from imports to the /map endpoint and the local run block.

Import necessary libraries:

from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from openai import OpenAI
import os
import json

Initialize the FastAPI application:

app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # For demo purposes; tighten for production
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)

Configure the relaxAI client:

client = OpenAI(
api_key=os.environ.get("RELAXAI_API_KEY"),
base_url="https://api.relax.ai/v1"
)

Define request and response models:

class ArchitectureNotes(BaseModel):
"""Input: raw, unstructured architecture notes"""
notes: str
class SystemMap(BaseModel):
"""Output: structured system map with nodes and edges"""
nodes: list
edges: list

Define the extraction prompt:

EXTRACTION_PROMPT = """You are a system architecture extraction engine.
STRICT REQUIREMENTS:
- OUTPUT ONLY JSON.
- NO explanation.
- NO natural language.
- NO markdown.
- NO code fences (no ```json or ```).
- NO commentary.
- If unsure about a component or relationship, omit it.
Extract system components (nodes) and their relationships (edges) from the architecture notes.
REQUIRED JSON FORMAT (follow EXACTLY):
{
"nodes": [
{
"id": "unique-component-id",
"label": "Component Name",
"team": "Team Name",
"type": "service",
"description": "Brief description"
}
],
"edges": [
{
"source": "component-id-1",
"target": "component-id-2",
"relationship": "calls"
}
]
}
VALID node types: service, database, frontend, queue, boundary
VALID relationship types: calls, stores_in, publishes_to, reads_from
IDs: lowercase-with-hyphens (e.g., "user-service", "postgres-db")
Descriptions: under 100 characters
Architecture notes:
"""

Add a health check endpoint:

@app.get("/health")
def health_check():
"""Simple health check endpoint for Kubernetes liveness probes"""
return {"status": "healthy"}

Implement the /map endpoint:

@app.post("/map", response_model=SystemMap)
async def generate_map(input_data: ArchitectureNotes):
# Validate input
if not input_data.notes or len(input_data.notes.strip()) < 10:
raise HTTPException(
status_code=400,
detail="Notes must contain at least 10 characters"
)
try:
# Call relaxAI with extraction prompt
response = client.chat.completions.create(
model="Llama-4-Maverick-17B-128E", # Fast, capable model
messages=[
{
"role": "system",
"content": "You are a precise system architecture analyzer. Return only valid JSON."
},
{
"role": "user",
"content": EXTRACTION_PROMPT + input_data.notes
}
],
temperature=0.3, # Lower temperature for more consistent structured output
max_tokens=2000
)
# Extract LLM response
llm_output = response.choices[0].message.content.strip()
# Parse JSON (LLM sometimes wraps in markdown code blocks)
if llm_output.startswith("```json"):
llm_output = llm_output.split("```json")[1].split("```")[0].strip()
elif llm_output.startswith("```"):
llm_output = llm_output.split("```")[1].split("```")[0].strip()
system_map = json.loads(llm_output)
# Basic validation
if "nodes" not in system_map or "edges" not in system_map:
raise ValueError("LLM output missing required fields: nodes, edges")
return SystemMap(**system_map)
except json.JSONDecodeError as e:
raise HTTPException(
status_code=500,
detail=f"LLM returned invalid JSON: {str(e)}"
)
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Error generating map: {str(e)}"
)

Add the local development entry point:

if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)

At this point, your backend is complete and ready to accept architecture notes and return structured system data. In the next step, you’ll build a minimal frontend to visualize that data as an interactive system map.

Step 3: Build the frontend

Now that your backend can extract structured system maps from unstructured notes, you’ll build a minimal frontend to:

  • Accept architecture notes
  • Send them to the /map endpoint
  • Render the structured output as an interactive diagram

Create the frontend files

Create the following two files inside the frontend/ directory of your project:

The frontend consists of:

  • index.html → Full UI layout
  • app.js → Logic

Index.html

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>System Boundary Mapper</title>
<!-- Cytoscape.js: JavaScript library for visualizing and interacting with graphs -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/cytoscape/3.28.1/cytoscape.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
background: #f5f5f5;
padding: 20px;
}
.container {
max-width: 1400px;
margin: 0 auto;
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
overflow: hidden;
}
.header {
background: #2563eb;
color: white;
padding: 30px;
text-align: center;
}
.header h1 {
margin-bottom: 10px;
font-size: 28px;
}
.header p {
opacity: 0.9;
font-size: 16px;
}
.content {
display: grid;
grid-template-columns: 400px 1fr;
min-height: 600px;
}
.input-panel {
padding: 30px;
border-right: 1px solid #e5e7eb;
background: #fafafa;
}
.input-panel h2 {
margin-bottom: 15px;
font-size: 18px;
color: #1f2937;
}
.input-panel textarea {
width: 100%;
height: 300px;
padding: 15px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-family: monospace;
font-size: 14px;
resize: vertical;
margin-bottom: 15px;
}
.input-panel textarea:focus {
outline: none;
border-color: #2563eb;
}
.input-panel button {
width: 100%;
padding: 12px;
background: #2563eb;
color: white;
border: none;
border-radius: 6px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.input-panel button:hover {
background: #1d4ed8;
}
.input-panel button:disabled {
background: #9ca3af;
cursor: not-allowed;
}
.status {
margin-top: 15px;
padding: 10px;
border-radius: 6px;
font-size: 14px;
text-align: center;
}
.status.loading {
background: #dbeafe;
color: #1e40af;
}
.status.success {
background: #d1fae5;
color: #065f46;
}
.status.error {
background: #fee2e2;
color: #991b1b;
}
.graph-panel {
position: relative;
}
#graph {
width: 100%;
height: 600px;
background: #ffffff;
}
.node-details {
position: absolute;
top: 20px;
right: 20px;
background: white;
padding: 20px;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
max-width: 300px;
display: none;
}
.node-details.active {
display: block;
}
.node-details h3 {
margin-bottom: 10px;
color: #1f2937;
font-size: 18px;
}
.node-details .detail-row {
margin-bottom: 8px;
font-size: 14px;
}
.node-details .detail-label {
font-weight: 600;
color: #6b7280;
margin-right: 5px;
}
.legend {
position: absolute;
bottom: 20px;
left: 20px;
background: white;
padding: 15px;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.legend h4 {
margin-bottom: 10px;
font-size: 14px;
color: #1f2937;
}
.legend-item {
display: flex;
align-items: center;
margin-bottom: 6px;
font-size: 13px;
}
.legend-color {
width: 20px;
height: 20px;
border-radius: 4px;
margin-right: 8px;
}
.example-link {
margin-top: 15px;
padding: 10px;
background: #eff6ff;
border-radius: 6px;
font-size: 13px;
color: #1e40af;
cursor: pointer;
text-align: center;
}
.example-link:hover {
background: #dbeafe;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>System Boundary & Responsibility Mapper</h1>
<p>Turn messy architecture notes into interactive system diagrams</p>
</div>
<div class="content">
<div class="input-panel">
<h2>Architecture Notes</h2>
<textarea
id="notes"
placeholder="Paste your architecture notes here..."
></textarea>
<button id="generateBtn">Generate Map</button>
<div id="status" class="status" style="display: none;"></div>
</div>
<div class="graph-panel">
<div id="graph"></div>
<div id="nodeDetails" class="node-details">
<h3 id="nodeName"></h3>
<div class="detail-row">
<span class="detail-label">Team:</span>
<span id="nodeTeam"></span>
</div>
<div class="detail-row">
<span class="detail-label">Type:</span>
<span id="nodeType"></span>
</div>
<div class="detail-row">
<span class="detail-label">Description:</span>
<span id="nodeDescription"></span>
</div>
</div>
<div class="legend">
<h4>Team Ownership</h4>
<div class="legend-item">
<div class="legend-color" style="background: #ef4444;"></div>
Commerce
</div>
<div class="legend-item">
<div class="legend-color" style="background: #10b981;"></div>
Catalog
</div>
<div class="legend-item">
<div class="legend-color" style="background: #3b82f6;"></div>
Identity
</div>
<div class="legend-item">
<div class="legend-color" style="background: #8b5cf6;"></div>
Platform
</div>
<div class="legend-item">
<div class="legend-color" style="background: #06b6d4;"></div>
Data
</div>
<div class="legend-item">
<div class="legend-color" style="background: #f59e0b;"></div>
UI
</div>
<div class="legend-item">
<div class="legend-color" style="background: #f97316;"></div>
Operations
</div>
</div>
</div>
</div>
</div>
<script src="app.js"></script>
</body>
</html>

Here’s how the frontend should look:

LLM-Driven System Boundary & Responsibility Mapper on Civo 2

app.js

Configure backend URL:

const API_URL = window.location.hostname === 'localhost'
? 'http://localhost:8000'
: 'http://<BACKEND-EXTERNAL-IP>:8000';

Important note: The above value is effectively baked into the Docker image when the frontend is built. This means you must:

  1. Deploy the backend first
  2. Wait for the LoadBalancer external IP to be assigned
  3. Replace <BACKEND-EXTERNAL-IP> with that value
  4. Then build and push the frontend image

If you build the frontend before the backend’s external IP is available, the deployed UI will point to an invalid endpoint.

Initialize color mapping, default color, and example notes:

onst TEAM_COLORS = {
'UI': '#f59e0b',
'Platform': '#8b5cf6',
'Identity': '#3b82f6',
'Catalog': '#10b981',
'Commerce': '#ef4444',
'Data': '#06b6d4',
'Operations': '#f97316'
};
const DEFAULT_COLOR = '#6b7280';
let cy = null;

Initialize Cytoscape:

function initGraph() {
cy = cytoscape({
container: document.getElementById('graph'),
// Visual styling for nodes and edges
style: [
{
selector: 'node',
style: {
'background-color': 'data(color)',
'label': 'data(label)',
'width': 60,
'height': 60,
'text-valign': 'bottom',
'text-halign': 'center',
'text-margin-y': 5,
'font-size': '12px',
'text-wrap': 'wrap',
'text-max-width': '100px',
'border-width': 2,
'border-color': '#ffffff',
'overlay-opacity': 0
}
},
{
selector: 'edge',
style: {
'width': 2,
'line-color': '#cbd5e1',
'target-arrow-color': '#cbd5e1',
'target-arrow-shape': 'triangle',
'curve-style': 'bezier',
'arrow-scale': 1.5,
'label': 'data(relationship)',
'font-size': '10px',
'text-rotation': 'autorotate',
'text-margin-y': -10,
'color': '#64748b'
}
},
{
selector: 'node:selected',
style: {
'border-width': 4,
'border-color': '#2563eb'
}
}
],
// Automatic layout algorithm
layout: {
name: 'breadthfirst',
directed: true,
padding: 50,
spacingFactor: 1.5
},
// Enable zoom and pan
minZoom: 0.3,
maxZoom: 3,
wheelSensitivity: 0.2
});
// Show node details when clicked
cy.on('tap', 'node', function(event) {
const node = event.target;
showNodeDetails(node.data());
});
// Hide details when clicking background
cy.on('tap', function(event) {
if (event.target === cy) {
hideNodeDetails();
}
});
}

Create the node details panel functions:

function showNodeDetails(data) {
document.getElementById('nodeName').textContent = data.label;
document.getElementById('nodeTeam').textContent = data.team || 'Unknown';
document.getElementById('nodeType').textContent = data.type || 'Unknown';
document.getElementById('nodeDescription').textContent = data.description || 'No description';
document.getElementById('nodeDetails').classList.add('active');
}
function hideNodeDetails() {
document.getElementById('nodeDetails').classList.remove('active');
}

Create the status panel functions:

function showStatus(message, type) {
const statusEl = document.getElementById('status');
statusEl.textContent = message;
statusEl.className = `status ${type}`;
statusEl.style.display = 'block';
}
function hideStatus() {
document.getElementById('status').style.display = 'none';
}

Create the generate map function:

async function generateMap() {
const notes = document.getElementById('notes').value.trim();
if (!notes) {
showStatus('Please enter some architecture notes', 'error');
return;
}
const generateBtn = document.getElementById('generateBtn');
generateBtn.disabled = true;
showStatus('Analyzing architecture notes...', 'loading');
hideNodeDetails();
try {
// Call backend /map endpoint
const response = await fetch(`${API_URL}/map`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ notes: notes })
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to generate map');
}
const systemMap = await response.json();
// Render the graph
renderGraph(systemMap);
showStatus(`Generated map with ${systemMap.nodes.length} components`, 'success');
setTimeout(hideStatus, 3000);
} catch (error) {
showStatus(`Error: ${error.message}`, 'error');
console.error('Map generation error:', error);
} finally {
generateBtn.disabled = false;
}
}

Create the render graph function:

function renderGraph(systemMap) {
cy.elements().remove();
const elements = [];
systemMap.nodes.forEach(node => {
elements.push({
data: {
id: node.id,
label: node.label,
team: node.team,
type: node.type,
description: node.description,
color: TEAM_COLORS[node.team] || DEFAULT_COLOR
}
});
});
systemMap.edges.forEach(edge => {
elements.push({
data: {
source: edge.source,
target: edge.target,
relationship: edge.relationship
}
});
});
cy.add(elements);
cy.layout({
name: 'breadthfirst',
directed: true,
padding: 50,
spacingFactor: 1.5,
animate: true,
animationDuration: 500
}).run();
cy.fit(50);
}

Configure event listeners and page initialization:

document.getElementById('generateBtn').addEventListener('click', generateMap);
document.addEventListener('DOMContentLoaded', initGraph);

With that, your frontend is fully set up: the HTML provides the interface, Cytoscape visualizes the system graph, and app.js handles data fetching, graph rendering, and interactivity, ready to communicate with your backend /map endpoint.

Step 4: Dockerization

Backend Dockerfile:

# Use official Python runtime as base image
FROM python:3.11-slim
# Set working directory inside container
WORKDIR /app
# Copy requirements first (Docker layer caching optimization)
COPY requirements.txt .
# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY app.py .
# Expose port 8000 for FastAPI
EXPOSE 8000
# Run the application
# Uses uvicorn ASGI server for production-grade performance
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]

Backend requirements.txt:

fastapi==0.115.0
uvicorn[standard]==0.32.0
httpx==0.24.1
openai==1.3.0
pydantic==2.9.2
python-multipart==0.0.12

Frontend Dockerfile:

# Use nginx to serve static files
FROM nginx:alpine
# Copy frontend files to nginx's default directory
COPY index.html /usr/share/nginx/html/
COPY app.js /usr/share/nginx/html/
# Expose port 80
EXPOSE 80
# nginx starts automatically with the base image

Build and push images:

# Build backend image
docker build -t YOUR_REGISTRY/system-mapper-backend:latest .
# Push backend image
docker push YOUR_REGISTRY/system-mapper-backend:latest
# Build frontend image
docker build -t YOUR_REGISTRY/system-mapper-frontend:latest .
# Push frontend image
docker push YOUR_REGISTRY/system-mapper-frontend:latest

Step 5: Deploy on Civo Kubernetes

Backend deployment YAML:

# ---------------------------------------------------------
# Namespace
# ---------------------------------------------------------
apiVersion: v1
kind: Namespace
metadata:
name: system-mapper
---
# ---------------------------------------------------------
# RelaxAI Secret (replace YOUR_API_KEY_HERE with actual key)
# ---------------------------------------------------------
apiVersion: v1
kind: Secret
metadata:
name: relaxai-secret
namespace: system-mapper
type: Opaque
stringData:
api-key: "YOUR_API_KEY_HERE"
---
# ---------------------------------------------------------
# Backend Deployment
# ---------------------------------------------------------
apiVersion: apps/v1
kind: Deployment
metadata:
name: backend
namespace: system-mapper
labels:
app: backend
spec:
replicas: 2
selector:
matchLabels:
app: backend
template:
metadata:
labels:
app: backend
spec:
containers:
- name: backend
image: YOUR_REGISTRY/system-mapper-backend:latest # Replace with your image
ports:
- containerPort: 8000
name: http
env:
- name: RELAXAI_API_KEY
valueFrom:
secretKeyRef:
name: relaxai-secret
key: api-key
resources:
requests:
memory: "512Mi"
cpu: "500m"
limits:
memory: "1Gi"
cpu: "1000m"
livenessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 10
periodSeconds: 30
readinessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 5
periodSeconds: 10
---
# ---------------------------------------------------------
# Backend Service (LoadBalancer)
# ---------------------------------------------------------
apiVersion: v1
kind: Service
metadata:
name: backend-service
namespace: system-mapper
spec:
type: LoadBalancer
selector:
app: backend
ports:
- protocol: TCP
port: 8000
targetPort: 8000

Apply it:

kubectl apply -f kubernetes/backend-deployment.yaml

Frontend deployment YAML:

# ---------------------------------------------------------
# Frontend Deployment
# ---------------------------------------------------------
# Serves the static HTML/JS interface via nginx
apiVersion: apps/v1
kind: Deployment
metadata:
name: frontend
namespace: system-mapper
labels:
app: frontend
spec:
replicas: 2
selector:
matchLabels:
app: frontend
template:
metadata:
labels:
app: frontend
spec:
containers:
- name: frontend
image: YOUR_REGISTRY/system-mapper-frontend:latest # Replace with your image
ports:
- containerPort: 80
name: http
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "256Mi"
cpu: "200m"
# Health check (nginx has a /health endpoint from the config)
livenessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 10
periodSeconds: 30
readinessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 5
periodSeconds: 10
---
# ---------------------------------------------------------
# Frontend Service (LoadBalancer)
# ---------------------------------------------------------
# Exposes the frontend to the internet
apiVersion: v1
kind: Service
metadata:
name: frontend-service
namespace: system-mapper
spec:
type: LoadBalancer
selector:
app: frontend
ports:
- protocol: TCP
port: 80 # External port (access via http://<EXTERNAL-IP>)
targetPort: 80 # Container port

Apply it:

kubectl apply -f kubernetes/frontend-deployment.yaml

Verify deployments and get external IPs:

kubectl get pods -n system-mapper
kubectl get svc -n system-mapper

Step 6: Test and Visualize

With both the backend and frontend deployed, it’s time to verify that your system mapper works as expected. You can do this by entering real architecture notes in the frontend input box and checking whether the generated system map appears correctly in the browser.

Use this text in the frontend input box:

Our e-commerce platform: React frontend (UI) calls API Gateway (Platform). Gateway routes to User Service (Identity) for authentication using PostgreSQL, Product Service (Catalog) managing inventory with MongoDB and Elasticsearch, and Order Service (Commerce) processing payments via Stripe with PostgreSQL. All services publish events to Kafka (Platform). Background workers include Order Processor (Commerce) reading RabbitMQ for async payment tasks, and Analytics Worker (Data) consuming Kafka events to load Snowflake warehouse. Order Service calls Product Service to verify stock before checkout. Admin Dashboard (Operations) connects directly to Order Service and Product Service for management tasks.

If the system is configured correctly, the backend will extract components and their relationships, normalize the data into the required JSON format, and the frontend will render an interactive system map.

LLM-Driven System Boundary & Responsibility Mapper on Civo 3

This tutorial is small and focused, meant to serve as a solid starting point rather than a toy example. While the example inputs are simple, the same pipeline can handle longer documents, multiple texts at once, or integration into real-world applications. When scaling up like this, GPU acceleration helps keep processing fast and responsive. Since the cluster is already GPU-enabled, you can move from simple experiments to larger production workloads without changing the architecture.

Summary

You've just built a tool that solves a problem every growing team faces: keeping architecture knowledge accessible and up-to-date. By combining LLMs with Civo's GPU-ready infrastructure, you transformed unstructured text into interactive diagrams in seconds, eliminating manual diagramming, stale docs, and knowledge silos.

This pattern extends beyond system maps. Use the same approach for API dependency tracking, data flow visualization, or team responsibility matrices. Add authentication, save/load features, or integrate with your existing docs platform. The foundation is there; scale it as your needs grow.

Additional Resources

To learn more about the technologies used in this tutorial and explore next steps, check out these resources:

Mostafa Ibrahim
Mostafa Ibrahim

Software Engineer @ GoCardless

Mostafa Ibrahim is a software engineer and technical writer specializing in developer-focused content for SaaS and AI platforms. He currently works as a Software Engineer at GoCardless, contributing to production systems and scalable payment infrastructure.

Alongside his engineering work, Mostafa has written more than 200 technical articles reaching over 500,000 readers. His content covers topics including Kubernetes deployments, AI infrastructure, authentication systems, and retrieval-augmented generation (RAG) architectures.

View author profile