“이번 주 재미있는 프로젝트, Mermaid 플로우차트 생성기!” 업데이트 — V2 및 기타…
Source: Dev.to
소개
이전 글에서 Ollama와 로컬 LLM을 이용해 Mermaid 차트를 생성하는 방법을 다룬 뒤(구독과 비용을 요구하는 사이트가 너무 지겨워서), 애플리케이션을 개선하고 업데이트하기로 했습니다. 그 이유는 크게 두 가지입니다.
- 첫 번째 앱에 사용하고자 하는 LLM에 대한 정보가 하드코딩되어 있었습니다.
- 애플리케이션이 반복적으로 동작하지 않았습니다.
핵심 개선 사항
이번 개선의 핵심은 LLM 선택을 추상화하고 깔끔하고 재사용 가능한 워크플로우를 만드는 것입니다.
동적 LLM 선택 (하드코딩된 정보 해결)
단일 하드코딩 모델을 사용하는 대신, 로컬 Ollama 인스턴스에 존재하는 모든 모델을 동적으로 탐색하고 활용하도록 애플리케이션을 설계합니다.
- 모델 탐색 구현 – Ollama의
/api/tags엔드포인트(http://localhost:11434/api/tags)에 요청을 보냅니다. 이 엔드포인트는 로컬에 설치된 모든 모델의 JSON 목록을 반환합니다. - 선택 인터페이스 – CLI – 탐색된 모델 리스트를 번호와 함께 출력하고, 사용자가 번호로 선택하도록 프롬프트합니다.
- 선택 인터페이스 – GUI – 가져온 모델 이름을 옵션으로 채운 드롭다운이나 라디오 버튼 그룹(예: Streamlit)을 사용합니다.
- 모델 이름 전달 – 선택된 모델 이름(예:
llama3:8b-instruct-q4_0)을 이후/api/chat엔드포인트 호출 시 payload에 변수로 넣어 사용합니다.
반복 워크플로우 및 오류 처리 (반복성 해결)
비반복 애플리케이션은 차트마다 재시작을 강요해 불편합니다. 반복성은 단순히 루프를 도는 것이 아니라, 같은 세션 내에서 성공·실패를 유연하게 처리하는 것을 의미합니다.
- 메인 실행 루프 – 기본 로직(
사용자 프롬프트 → LLM 호출 → 이미지 생성)을while True루프로 감싸고, 사용자가 명시적으로 종료를 선택할 때만 탈출합니다. - 세션 상태 (GUI) – Streamlit과 같은 GUI 프레임워크를 사용할 경우
st.session_state를 활용해 생성된 Mermaid 코드와 이미지 경로를 버튼 클릭·재렌더링 사이에 유지합니다. - 입력 검증 – 사용자의 프롬프트가 비어 있는지 확인합니다.
- 연결 확인 – 모델을 가져오거나 코드를 생성하기 전에 Ollama 서버가 실행 중인지 확인합니다.
- 파일 처리 안전성 –
mmdc용 임시 파일이 생성되므로, 정리 로직이 디버깅 가능하도록 합니다(예:DEBUG_MODE가 비활성화된 경우에만 임시 파일을 삭제).
V3 아이디어 (계속)
| 개선 항목 | 설명 | 기대 효과 |
|---|---|---|
| 코드 리뷰/수정 모드 | mmdc가 구문 오류로 렌더링에 실패하면 Mermaid 코드 와 mmdc 오류 로그를 LLM에 (특정 시스템 프롬프트와 함께) 자동 전송해 구문을 수정하도록 요청합니다. | 사용자의 좌절감을 줄이고 LLM이 만든 일반적인 구문 오류를 자동으로 고칩니다. |
| 다이어그램 히스토리 | 생성된 텍스트 프롬프트, 출력 코드, 해당 이미지 파일 경로를 간단한 로컬 데이터베이스(SQLite) 또는 구조화 파일(JSON/YAML)로 저장합니다. | 사용자가 과거 다이어그램을 쉽게 찾아 재사용할 수 있어 재생성 필요성을 없앱니다. |
| 출력 포맷 옵션 | PNG 외에 SVG(확대에 유리) 또는 PDF와 같은 포맷으로 다이어그램을 출력하는 옵션을 추가합니다. | 고품질 벡터 그래픽이 필요한 사용자에게 활용도를 높여줍니다. |
| 영구 설정 | 마지막으로 사용한 LLM 모델을 설정 파일(config.json 등)로 저장합니다. | 애플리케이션 시작 시 선호 모델을 자동 선택해 사용자의 시간을 절약합니다. |
코드 및 구현
1 — 콘솔 버전
가상 Python 환경을 만들고 필요한 의존성을 설치합니다.
pip install --upgrade pip
pip install requests
npm install -g @mermaid-js/mermaid-cli
코드 🧑💻
# app_V3.py
import subprocess
import os
import requests
import json
import re
import glob
import sys
import time
from pathlib import Path
DEBUG_MODE = True
OLLAMA_BASE_URL = "http://localhost:11434"
OLLAMA_CHAT_URL = f"{OLLAMA_BASE_URL}/api/chat"
OLLAMA_TAGS_URL = f"{OLLAMA_BASE_URL}/api/tags"
INPUT_DIR = Path("./input")
OUTPUT_DIR = Path("./output")
def check_mmdc_installed():
"""Checks if 'mmdc' is installed."""
try:
subprocess.run(['mmdc', '--version'], check=True, capture_output=True, timeout=5)
return True
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.CalledProcessError):
print("Error: Mermaid CLI (mmdc) not found or misconfigured.")
print("Try: npm install -g @mermaid-js/mermaid-cli")
return False
# MODEL SELECTION
def get_installed_models():
"""Fetches locally installed Ollama models."""
try:
response = requests.get(OLLAMA_TAGS_URL, timeout=5)
response.raise_for_status()
return sorted([m['name'] for m in response.json().get('models', [])])
except:
return []
def select_model_interactive():
"""Interactive menu to choose a model."""
print("\n--- Ollama Model Selection ---")
models = get_installed_models()
if not models:
return input("No models found. Enter model name manually (e.g., llama3): ").strip() or "llama3"
for idx, model in enumerate(models, 1):
print(f"{idx}. {model}")
while True:
choice = input(f"\nSelect a model (1-{len(models)}) or type custom name: ").strip()
if choice.isdigit() and 1 <= int(choice) <= len(models):
return models[int(choice) - 1]
elif choice:
return choice
def clean_mermaid_code(code_string):
"""Clean common LLM formatting errors from Mermaid code."""
cleaned = code_string.replace(u'\xa0', ' ').replace(u'\u200b', '')
cleaned = cleaned.replace("```mermaid", "").replace("```", "")
cleaned = re.sub(r'[ \t\r\f\v]+', ' ', cleaned)
lines = cleaned.splitlines()
rebuilt = []
for line in lines:
s_line = line.strip()
if s_line:
rebuilt.append(s_line)
final = '\n'.join(rebuilt)
final = re.sub(r'(\])([A-Za-z0-9])', r'\1\n\2', final)
return final.strip()
def generate_mermaid_code(user_prompt, model_name):
"""Calls Ollama to generate the code."""
system_msg = (
"You are a Mermaid Diagram Generator. Output ONLY valid Mermaid code. "
"Do not include explanations. Start with 'graph TD' or 'flowchart LR'. "
"Use simple ASCII characters for node IDs."
)
payload = {
"model": model_name,
"messages": [{"role": "system", "content": system_msg}, {"role": "user", "content": user_prompt}],
"stream": False,
"options": {"temperature": 0.1}
}
try:
print(f"Thinking ({model_name})...")
response = requests.post(OLLAMA_CHAT_URL, json=payload, timeout=60)
response.raise_for_status()
content = response.json().get("message", {}).get("content", "").strip()
match = re.search(r"```mermaid\\n(.*?)```", content, re.DOTALL)
if match:
return clean_mermaid_code(match.group(1))
else:
return clean_mermaid_code(content)
except Exception as e:
print(f"Error generating Mermaid code: {e}")
return None
def render_mermaid_to_png(mermaid_code, output_path):
"""Uses mmdc to render Mermaid code to PNG."""
temp_mmd = INPUT_DIR / "temp.mmd"
temp_mmd.write_text(mermaid_code, encoding="utf-8")
try:
subprocess.run(
["mmdc", "-i", str(temp_mmd), "-o", str(output_path)],
check=True,
capture_output=True,
timeout=30,
)
return True
except subprocess.CalledProcessError as e:
print(f"mmdc error: {e.stderr.decode()}")
return False
finally:
if not DEBUG_MODE:
try:
temp_mmd.unlink()
except FileNotFoundError:
pass
def main():
if not check_mmdc_installed():
sys.exit(1)
INPUT_DIR.mkdir(exist_ok=True)
OUTPUT_DIR.mkdir(exist_ok=True)
model_name = select_model_interactive()
while True:
user_prompt = input("\nEnter a description for your diagram (or 'quit' to exit): ").strip()
if user_prompt.lower() in {"quit", "exit"}:
print("Goodbye!")
break
if not user_prompt:
print("Prompt cannot be empty.")
continue
mermaid_code = generate_mermaid_code(user_prompt, model_name)
if not mermaid_code:
continue
timestamp = int(time.time())
output_file = OUTPUT_DIR / f"diagram_{timestamp}.png"
if render_mermaid_to_png(mermaid_code, output_file):
print(f"Diagram saved to: {output_file}")
else:
print("Failed to render diagram.")
if __name__ == "__main__":
main()
GUI 버전(예: Streamlit)으로 코드를 변형하거나 위에 나열한 V3 아이디어를 추가해 확장해도 좋습니다.