mod4test2 / app.py
ZENLLC's picture
Update app.py
566c8e3 verified
import json
import textwrap
from typing import Dict, Any, List, Tuple
import gradio as gr
import requests
import matplotlib.pyplot as plt
from matplotlib.figure import Figure
# ============================================================
# LLM CALLER (GPT-4.1 BY DEFAULT)
# ============================================================
def call_chat_completion(
api_key: str,
base_url: str,
model: str,
system_prompt: str,
user_prompt: str,
max_completion_tokens: int = 2000,
) -> str:
"""
OpenAI-compatible chat completion call.
- Uses new-style `max_completion_tokens` (for GPT-4.1, GPT-4o, etc.)
- Falls back to `max_tokens` if the provider doesn't support it.
- No temperature / top_p to avoid incompatibility with some models.
"""
if not api_key:
raise ValueError("API key is required.")
if not base_url:
base_url = "https://api.openai.com"
url = base_url.rstrip("/") + "/v1/chat/completions"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
payload = {
"model": model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
"max_completion_tokens": max_completion_tokens,
}
resp = requests.post(url, headers=headers, json=payload, timeout=60)
# Fallback for providers still expecting `max_tokens`
if resp.status_code == 400 and "max_completion_tokens" in resp.text:
payload.pop("max_completion_tokens", None)
payload["max_tokens"] = max_completion_tokens
resp = requests.post(url, headers=headers, json=payload, timeout=60)
if resp.status_code != 200:
raise RuntimeError(
f"LLM API error: {resp.status_code} - {resp.text[:400]}"
)
data = resp.json()
try:
return data["choices"][0]["message"]["content"]
except Exception as e:
raise RuntimeError(f"Unexpected LLM response format: {e}\n\n{json.dumps(data, indent=2)}") from e
# ============================================================
# SOP PROMPT + PARSING
# ============================================================
SOP_SYSTEM_PROMPT = """
You are an expert process engineer. Produce SOPs strictly as JSON with this schema:
{
"title": "string",
"purpose": "string",
"scope": "string",
"definitions": ["string", ...],
"roles": [
{
"name": "string",
"responsibilities": ["string", ...]
}
],
"prerequisites": ["string", ...],
"steps": [
{
"step_number": 1,
"title": "string",
"description": "string",
"owner_role": "string",
"inputs": ["string", ...],
"outputs": ["string", ...]
}
],
"escalation": ["string", ...],
"metrics": ["string", ...],
"risks": ["string", ...],
"versioning": {
"version": "1.0",
"owner": "string",
"last_updated": "string"
}
}
Return ONLY JSON. No explanation or commentary.
"""
def build_user_prompt(
sop_title: str,
description: str,
industry: str,
tone: str,
detail_level: str,
) -> str:
return f"""
SOP Title: {sop_title or "Untitled SOP"}
Context: {description or "N/A"}
Industry: {industry or "General"}
Tone: {tone or "Professional"}
Detail Level: {detail_level or "Standard"}
Audience: mid-career professionals who need clarity and accountability.
""".strip()
def parse_sop_json(raw_text: str) -> Dict[str, Any]:
"""Extract JSON from LLM output, stripping code fences if present."""
txt = raw_text.strip()
if txt.startswith("```"):
parts = txt.split("```")
# choose the first part that looks like JSON
txt = next((p for p in parts if "{" in p and "}" in p), parts[-1])
first = txt.find("{")
last = txt.rfind("}")
if first == -1 or last == -1:
raise ValueError("No JSON object detected in model output.")
txt = txt[first:last + 1]
return json.loads(txt)
def sop_to_markdown(sop: Dict[str, Any]) -> str:
"""Render SOP JSON β†’ readable Markdown document."""
def bullet(items):
if not items:
return "_None provided._"
return "\n".join(f"- {i}" for i in items)
md = []
md.append(f"# {sop.get('title', 'Standard Operating Procedure')}\n")
md.append("## 1. Purpose")
md.append(sop.get("purpose", "N/A"))
md.append("\n## 2. Scope")
md.append(sop.get("scope", "N/A"))
md.append("\n## 3. Definitions")
md.append(bullet(sop.get("definitions", [])))
md.append("\n## 4. Roles & Responsibilities")
for role in sop.get("roles", []):
md.append(f"### {role.get('name', 'Role')}")
md.append(bullet(role.get("responsibilities", [])))
md.append("\n## 5. Prerequisites")
md.append(bullet(sop.get("prerequisites", [])))
md.append("\n## 6. Procedure (Step-by-Step)")
for step in sop.get("steps", []):
md.append(f"### Step {step.get('step_number', '?')}: {step.get('title', 'Step')}")
md.append(f"**Owner:** {step.get('owner_role', 'N/A')}")
md.append(step.get("description", ""))
md.append("**Inputs:**")
md.append(bullet(step.get("inputs", [])))
md.append("**Outputs:**")
md.append(bullet(step.get("outputs", [])))
md.append("\n## 7. Escalation")
md.append(bullet(sop.get("escalation", [])))
md.append("\n## 8. Metrics")
md.append(bullet(sop.get("metrics", [])))
md.append("\n## 9. Risks")
md.append(bullet(sop.get("risks", [])))
v = sop.get("versioning", {})
md.append("\n## 10. Version Control")
md.append(f"- Version: {v.get('version', '1.0')}")
md.append(f"- Owner: {v.get('owner', 'N/A')}")
md.append(f"- Last Updated: {v.get('last_updated', 'N/A')}")
return "\n\n".join(md)
# ============================================================
# IMPROVED DIAGRAM β€” AUTO-SIZED CARDS, NO OVERFLOW
# ============================================================
def create_sop_steps_figure(sop: Dict[str, Any]) -> Figure:
"""
Draw each step as a stacked card with:
- dynamic height based on description length
- number block on the left
- title + owner + wrapped description inside card
"""
steps = sop.get("steps", [])
if not steps:
fig, ax = plt.subplots(figsize=(7, 2))
ax.text(0.5, 0.5, "No steps available to visualize.", ha="center", va="center")
ax.axis("off")
fig.tight_layout()
return fig
# First pass: determine required height for each card
card_heights = []
total_height = 0.0
for step in steps:
desc_lines = textwrap.wrap(step.get("description", ""), width=70)
# base height (title + owner) + 0.3 per line of description
base = 1.0 # title + owner + padding
per_line = 0.32
h = base + per_line * max(len(desc_lines), 1)
h += 0.3 # bottom padding
card_heights.append(h)
total_height += h
# Add spacing between cards
spacing = 0.4
total_height += spacing * (len(steps) + 1)
fig_height = min(20, max(5, total_height))
fig, ax = plt.subplots(figsize=(10, fig_height))
ax.set_xlim(0, 1)
ax.set_ylim(0, total_height)
y = total_height - spacing # start from top
for step, h in zip(steps, card_heights):
y_bottom = y - h
y_top = y
# Card boundaries
x0 = 0.05
x1 = 0.95
# Draw outer card
ax.add_patch(
plt.Rectangle(
(x0, y_bottom),
x1 - x0,
h,
fill=False,
linewidth=1.8,
)
)
# Number block
num_block_w = 0.08
ax.add_patch(
plt.Rectangle(
(x0, y_bottom),
num_block_w,
h,
fill=False,
linewidth=1.6,
)
)
# Step number text in the center of the number block
ax.text(
x0 + num_block_w / 2,
y_bottom + h / 2,
str(step.get("step_number", "?")),
ha="center",
va="center",
fontsize=13,
fontweight="bold",
)
# Text area start
text_x = x0 + num_block_w + 0.02
# Title
ax.text(
text_x,
y_top - 0.25,
step.get("title", ""),
ha="left",
va="top",
fontsize=12,
fontweight="bold",
)
# Owner
owner = step.get("owner_role", "")
if owner:
owner_y = y_top - 0.55
ax.text(
text_x,
owner_y,
f"Owner: {owner}",
ha="left",
va="top",
fontsize=10,
style="italic",
)
else:
owner_y = y_top - 0.5
# Description (wrapped)
desc_lines = textwrap.wrap(step.get("description", ""), width=70)
desc_y = owner_y - 0.4
for line in desc_lines:
ax.text(
text_x,
desc_y,
line,
ha="left",
va="top",
fontsize=9,
)
desc_y -= 0.3 # vertical spacing per line
y = y_bottom - spacing # move down for next card
ax.axis("off")
fig.tight_layout()
return fig
# ============================================================
# SAMPLE SCENARIOS
# ============================================================
SAMPLE_SOPS: Dict[str, Dict[str, str]] = {
"Volunteer Onboarding": {
"title": "Volunteer Onboarding",
"description": "Onboard new volunteers including application review, background checks, orientation, training, and site placement.",
"industry": "Nonprofit / Youth Development",
},
"Remote Employee Onboarding": {
"title": "Remote Employee Onboarding",
"description": "Design a remote onboarding SOP for hybrid employees including IT setup, HR paperwork, and culture onboarding.",
"industry": "HR / General",
},
"IT Outage Response": {
"title": "IT Outage Incident Response",
"description": "Major outage response SOP including detection, triage, escalation, communication, restoration, and post-mortem.",
"industry": "IT / Operations",
},
}
def load_sample(sample_name: str) -> Tuple[str, str, str]:
if not sample_name or sample_name not in SAMPLE_SOPS:
return "", "", "General"
s = SAMPLE_SOPS[sample_name]
return s["title"], s["description"], s["industry"]
# ============================================================
# MAIN HANDLER FOR GRADIO
# ============================================================
def generate_sop_ui(
api_key_state: str,
api_key_input: str,
base_url: str,
model_name: str,
sop_title: str,
description: str,
industry: str,
tone: str,
detail_level: str,
) -> Tuple[str, str, Figure, str]:
api_key = api_key_input or api_key_state
if not api_key:
return (
"⚠️ Please enter your API key in the left panel.",
"",
create_sop_steps_figure({"steps": []}),
api_key_state,
)
model = model_name or "gpt-4.1"
user_prompt = build_user_prompt(sop_title, description, industry, tone, detail_level)
try:
raw = call_chat_completion(
api_key=api_key,
base_url=base_url,
model=model,
system_prompt=SOP_SYSTEM_PROMPT,
user_prompt=user_prompt,
max_completion_tokens=2000,
)
sop = parse_sop_json(raw)
md = sop_to_markdown(sop)
fig = create_sop_steps_figure(sop)
json_out = json.dumps(sop, indent=2, ensure_ascii=False)
return md, json_out, fig, api_key # persist key in session state
except Exception as e:
return (
f"❌ Error generating SOP:\n\n{e}",
"",
create_sop_steps_figure({"steps": []}),
api_key_state,
)
# ============================================================
# GRADIO UI
# ============================================================
with gr.Blocks(title="ZEN Simple SOP Builder") as demo:
gr.Markdown(
"""
# 🧭 ZEN Simple SOP Builder
Generate clean, professional Standard Operating Procedures (SOPs) from a short description,
plus an auto-generated visual diagram of the steps.
Powered by your own API key (GPT-4.1 by default).
"""
)
api_key_state = gr.State("")
with gr.Row():
# LEFT COLUMN β€” API + Samples
with gr.Column(scale=1):
gr.Markdown("### Step 1 β€” API & Model Settings")
api_key_input = gr.Textbox(
label="LLM API Key",
placeholder="Enter your OpenAI (or compatible) API key",
type="password",
)
base_url = gr.Textbox(
label="Base URL",
value="https://api.openai.com",
placeholder="e.g. https://api.openai.com or custom OpenAI-compatible endpoint",
)
model_name = gr.Textbox(
label="Model Name",
value="gpt-4.1",
placeholder="e.g. gpt-4.1, gpt-4o, etc.",
)
gr.Markdown("### Load a Sample SOP")
sample_dropdown = gr.Dropdown(
label="Sample scenarios",
choices=list(SAMPLE_SOPS.keys()),
value=None,
info="Optional: load a ready-made example to test the tool.",
)
load_button = gr.Button("Load Sample into Form")
# RIGHT COLUMN β€” SOP Description
with gr.Column(scale=2):
gr.Markdown("### Step 2 β€” Describe the SOP")
sop_title = gr.Textbox(
label="SOP Title",
placeholder="e.g. Volunteer Onboarding Workflow",
)
description = gr.Textbox(
label="Describe the process / context",
placeholder="What should this SOP cover? Who is it for? Any constraints?",
lines=6,
)
industry = gr.Textbox(
label="Industry / Domain",
value="General",
placeholder="e.g. Nonprofit, HR, Education, Healthcare, IT",
)
tone = gr.Dropdown(
label="Tone",
choices=["Professional", "Executive", "Supportive", "Direct", "Compliance-focused"],
value="Professional",
)
detail_level = gr.Dropdown(
label="Detail Level",
choices=["Standard", "High detail", "Checklist-style", "Overview only"],
value="Standard",
)
generate_button = gr.Button("πŸš€ Generate SOP", variant="primary")
gr.Markdown("### Step 3 β€” Generated SOP")
with gr.Row():
with gr.Column(scale=3):
sop_output = gr.Markdown(
label="SOP (Markdown)",
value="Your SOP will appear here after generation.",
)
with gr.Column(scale=2):
sop_json_output = gr.Code(
label="Raw SOP JSON (for automation / export)",
language="json",
)
gr.Markdown("### Step 4 β€” Visual Workflow Diagram")
sop_figure = gr.Plot(label="SOP Steps Diagram")
# Wire up actions
load_button.click(
fn=load_sample,
inputs=[sample_dropdown],
outputs=[sop_title, description, industry],
)
generate_button.click(
fn=generate_sop_ui,
inputs=[
api_key_state,
api_key_input,
base_url,
model_name,
sop_title,
description,
industry,
tone,
detail_level,
],
outputs=[sop_output, sop_json_output, sop_figure, api_key_state],
)
if __name__ == "__main__":
demo.launch()