|
|
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 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
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_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("```") |
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
card_heights = [] |
|
|
total_height = 0.0 |
|
|
|
|
|
for step in steps: |
|
|
desc_lines = textwrap.wrap(step.get("description", ""), width=70) |
|
|
|
|
|
base = 1.0 |
|
|
per_line = 0.32 |
|
|
h = base + per_line * max(len(desc_lines), 1) |
|
|
h += 0.3 |
|
|
card_heights.append(h) |
|
|
total_height += h |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
for step, h in zip(steps, card_heights): |
|
|
y_bottom = y - h |
|
|
y_top = y |
|
|
|
|
|
|
|
|
x0 = 0.05 |
|
|
x1 = 0.95 |
|
|
|
|
|
|
|
|
ax.add_patch( |
|
|
plt.Rectangle( |
|
|
(x0, y_bottom), |
|
|
x1 - x0, |
|
|
h, |
|
|
fill=False, |
|
|
linewidth=1.8, |
|
|
) |
|
|
) |
|
|
|
|
|
|
|
|
num_block_w = 0.08 |
|
|
ax.add_patch( |
|
|
plt.Rectangle( |
|
|
(x0, y_bottom), |
|
|
num_block_w, |
|
|
h, |
|
|
fill=False, |
|
|
linewidth=1.6, |
|
|
) |
|
|
) |
|
|
|
|
|
|
|
|
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_x = x0 + num_block_w + 0.02 |
|
|
|
|
|
|
|
|
ax.text( |
|
|
text_x, |
|
|
y_top - 0.25, |
|
|
step.get("title", ""), |
|
|
ha="left", |
|
|
va="top", |
|
|
fontsize=12, |
|
|
fontweight="bold", |
|
|
) |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
y = y_bottom - spacing |
|
|
|
|
|
ax.axis("off") |
|
|
fig.tight_layout() |
|
|
return fig |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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"] |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
except Exception as e: |
|
|
return ( |
|
|
f"β Error generating SOP:\n\n{e}", |
|
|
"", |
|
|
create_sop_steps_figure({"steps": []}), |
|
|
api_key_state, |
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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(): |
|
|
|
|
|
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") |
|
|
|
|
|
|
|
|
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") |
|
|
|
|
|
|
|
|
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() |
|
|
|