ZENLLC commited on
Commit
440d3c6
·
verified ·
1 Parent(s): b7b935e

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +481 -0
app.py ADDED
@@ -0,0 +1,481 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import textwrap
3
+ import io
4
+ from typing import Dict, Any, List, Tuple
5
+
6
+ import gradio as gr
7
+ import requests
8
+ import matplotlib.pyplot as plt
9
+ from matplotlib.figure import Figure
10
+
11
+
12
+ # -----------------------------
13
+ # LLM CALL HELPERS
14
+ # -----------------------------
15
+
16
+ def call_chat_completion(
17
+ api_key: str,
18
+ base_url: str,
19
+ model: str,
20
+ system_prompt: str,
21
+ user_prompt: str,
22
+ temperature: float = 0.3,
23
+ max_tokens: int = 1800,
24
+ ) -> str:
25
+ """
26
+ Generic OpenAI-compatible chat completion call using HTTP.
27
+ Supports providers that mimic the /v1/chat/completions API.
28
+ """
29
+ if not api_key:
30
+ raise ValueError("API key is required.")
31
+
32
+ if not base_url:
33
+ base_url = "https://api.openai.com" # default
34
+
35
+ url = base_url.rstrip("/") + "/v1/chat/completions"
36
+
37
+ headers = {
38
+ "Authorization": f"Bearer {api_key}",
39
+ "Content-Type": "application/json",
40
+ }
41
+
42
+ payload = {
43
+ "model": model,
44
+ "temperature": temperature,
45
+ "max_tokens": max_tokens,
46
+ "messages": [
47
+ {"role": "system", "content": system_prompt},
48
+ {"role": "user", "content": user_prompt},
49
+ ],
50
+ }
51
+
52
+ resp = requests.post(url, headers=headers, json=payload, timeout=60)
53
+ if resp.status_code != 200:
54
+ raise RuntimeError(
55
+ f"LLM API error: {resp.status_code} - {resp.text[:400]}"
56
+ )
57
+
58
+ data = resp.json()
59
+ try:
60
+ return data["choices"][0]["message"]["content"]
61
+ except Exception as e:
62
+ raise RuntimeError(f"Unexpected LLM response format: {e}\n\n{data}")
63
+
64
+
65
+ # -----------------------------
66
+ # SOP GENERATION LOGIC
67
+ # -----------------------------
68
+
69
+ SOP_SYSTEM_PROMPT = """
70
+ You are an expert operations consultant and technical writer.
71
+
72
+ You generate **clear, professional, and implementation-ready Standard Operating Procedures (SOPs)**.
73
+
74
+ The user will give:
75
+ - A title or short description of the process
76
+ - Context about the organization or scenario
77
+ - Optional industry, tone, and detail level
78
+
79
+ You MUST respond **strictly as JSON**, with no extra commentary, using this schema:
80
+
81
+ {
82
+ "title": "string",
83
+ "purpose": "string",
84
+ "scope": "string",
85
+ "definitions": ["string", ...],
86
+ "roles": [
87
+ {
88
+ "name": "string",
89
+ "responsibilities": ["string", ...]
90
+ }
91
+ ],
92
+ "prerequisites": ["string", ...],
93
+ "steps": [
94
+ {
95
+ "step_number": 1,
96
+ "title": "string",
97
+ "description": "string",
98
+ "owner_role": "string",
99
+ "inputs": ["string", ...],
100
+ "outputs": ["string", ...]
101
+ }
102
+ ],
103
+ "escalation": ["string", ...],
104
+ "metrics": ["string", ...],
105
+ "risks": ["string", ...],
106
+ "versioning": {
107
+ "version": "1.0",
108
+ "owner": "string",
109
+ "last_updated": "string"
110
+ }
111
+ }
112
+
113
+ Constraints:
114
+ - Make steps specific, observable, and sequential.
115
+ - Use inclusive, professional language.
116
+ - Assume this will be used by mid-career professionals.
117
+ - Keep lists between 3 and 8 items unless the user asks for more.
118
+ """
119
+
120
+
121
+ def build_user_prompt(
122
+ sop_title: str,
123
+ description: str,
124
+ industry: str,
125
+ tone: str,
126
+ detail_level: str,
127
+ ) -> str:
128
+ pieces = [
129
+ f"Process title: {sop_title or 'Untitled SOP'}",
130
+ f"Context / description: {description or 'N/A'}",
131
+ f"Industry: {industry or 'General'}",
132
+ f"Tone: {tone or 'Professional'}",
133
+ f"Detail level: {detail_level or 'Standard'}",
134
+ "Audience: Mid-career professionals who already understand basic workplace concepts but need clear structure and ownership.",
135
+ ]
136
+ return "\n".join(pieces)
137
+
138
+
139
+ def parse_sop_json(raw_text: str) -> Dict[str, Any]:
140
+ """
141
+ Attempt to parse the LLM output as JSON.
142
+ Also handles cases where the model wrapped the JSON in code fences.
143
+ """
144
+ text = raw_text.strip()
145
+
146
+ # Strip markdown fences if present
147
+ if text.startswith("```"):
148
+ # Remove leading ```json or ```
149
+ text = text.split("```", 2)
150
+ if len(text) == 3:
151
+ text = text[1] if "{" in text[1] else text[2]
152
+ else:
153
+ text = text[-1]
154
+ text = text.strip()
155
+
156
+ # Try to find the first '{' and last '}' to extract JSON
157
+ first_brace = text.find("{")
158
+ last_brace = text.rfind("}")
159
+ if first_brace != -1 and last_brace != -1:
160
+ text = text[first_brace : last_brace + 1]
161
+
162
+ return json.loads(text)
163
+
164
+
165
+ def sop_to_markdown(sop: Dict[str, Any]) -> str:
166
+ """Render the SOP JSON into a readable Markdown document."""
167
+ def bullet_list(items: List[str]) -> str:
168
+ if not items:
169
+ return "_None specified._"
170
+ return "\n".join(f"- {i}" for i in items)
171
+
172
+ md = []
173
+
174
+ md.append(f"# {sop.get('title', 'Standard Operating Procedure')}\n")
175
+
176
+ md.append("## 1. Purpose")
177
+ md.append(sop.get("purpose", "_No purpose provided._"))
178
+
179
+ md.append("\n## 2. Scope")
180
+ md.append(sop.get("scope", "_No scope provided._"))
181
+
182
+ definitions = sop.get("definitions", [])
183
+ md.append("\n## 3. Definitions")
184
+ md.append(bullet_list(definitions))
185
+
186
+ roles = sop.get("roles", [])
187
+ md.append("\n## 4. Roles & Responsibilities")
188
+ if roles:
189
+ for role in roles:
190
+ name = role.get("name", "Role")
191
+ responsibilities = role.get("responsibilities", [])
192
+ md.append(f"### {name}")
193
+ md.append(bullet_list(responsibilities))
194
+ else:
195
+ md.append("_No roles specified._")
196
+
197
+ prereq = sop.get("prerequisites", [])
198
+ md.append("\n## 5. Prerequisites")
199
+ md.append(bullet_list(prereq))
200
+
201
+ steps = sop.get("steps", [])
202
+ md.append("\n## 6. Procedure (Step-by-Step)")
203
+ if steps:
204
+ for step in steps:
205
+ num = step.get("step_number", "?")
206
+ title = step.get("title", "Step")
207
+ owner = step.get("owner_role", "Owner")
208
+ desc = step.get("description", "")
209
+ inputs = step.get("inputs", [])
210
+ outputs = step.get("outputs", [])
211
+ md.append(f"### Step {num}: {title}")
212
+ md.append(f"**Owner:** {owner}")
213
+ md.append(desc or "_No description provided._")
214
+ if inputs:
215
+ md.append("**Inputs:**")
216
+ md.append(bullet_list(inputs))
217
+ if outputs:
218
+ md.append("**Outputs:**")
219
+ md.append(bullet_list(outputs))
220
+ else:
221
+ md.append("_No steps provided._")
222
+
223
+ md.append("\n## 7. Escalation")
224
+ md.append(bullet_list(sop.get("escalation", [])))
225
+
226
+ md.append("\n## 8. Metrics & Success Criteria")
227
+ md.append(bullet_list(sop.get("metrics", [])))
228
+
229
+ md.append("\n## 9. Risks & Controls")
230
+ md.append(bullet_list(sop.get("risks", [])))
231
+
232
+ versioning = sop.get("versioning", {})
233
+ md.append("\n## 10. Version Control")
234
+ md.append(f"- Version: {versioning.get('version', '1.0')}")
235
+ md.append(f"- Owner: {versioning.get('owner', 'Not specified')}")
236
+ md.append(f"- Last Updated: {versioning.get('last_updated', 'Not specified')}")
237
+
238
+ return "\n\n".join(md)
239
+
240
+
241
+ # -----------------------------
242
+ # INFOGRAPHIC / DATA VISUAL
243
+ # -----------------------------
244
+
245
+ def create_sop_steps_figure(sop: Dict[str, Any]) -> Figure:
246
+ """
247
+ Create a simple infographic-like matplotlib figure showing the SOP steps
248
+ as a vertical flow with numbered boxes.
249
+ """
250
+ steps = sop.get("steps", [])
251
+ if not steps:
252
+ fig, ax = plt.subplots(figsize=(6, 2))
253
+ ax.text(
254
+ 0.5,
255
+ 0.5,
256
+ "No steps available to visualize.",
257
+ ha="center",
258
+ va="center",
259
+ fontsize=12,
260
+ wrap=True,
261
+ )
262
+ ax.axis("off")
263
+ return fig
264
+
265
+ n = len(steps)
266
+ fig_height = max(3, n * 1.0)
267
+ fig, ax = plt.subplots(figsize=(7, fig_height))
268
+
269
+ y_positions = list(reversed(range(n))) # top to bottom
270
+
271
+ for idx, (step, y) in enumerate(zip(steps, y_positions)):
272
+ num = step.get("step_number", idx + 1)
273
+ title = step.get("title", f"Step {num}")
274
+ owner = step.get("owner_role", "")
275
+ desc = step.get("description", "")
276
+
277
+ # Clamp description length
278
+ desc_short = textwrap.shorten(desc, width=120, placeholder="...")
279
+
280
+ # Draw a rounded rectangle as a "card"
281
+ x0, x1 = 0.05, 0.95
282
+ y0, y1 = y - 0.3, y + 0.3
283
+
284
+ ax.add_patch(
285
+ plt.Rectangle(
286
+ (x0, y0),
287
+ x1 - x0,
288
+ y1 - y0,
289
+ fill=False,
290
+ linewidth=1.5,
291
+ linestyle="-",
292
+ )
293
+ )
294
+ # Number bubble
295
+ ax.text(
296
+ x0 + 0.02,
297
+ y,
298
+ f"{num}",
299
+ va="center",
300
+ ha="left",
301
+ fontsize=11,
302
+ fontweight="bold",
303
+ )
304
+
305
+ # Title & owner
306
+ ax.text(
307
+ x0 + 0.12,
308
+ y + 0.15,
309
+ title,
310
+ va="center",
311
+ ha="left",
312
+ fontsize=11,
313
+ fontweight="bold",
314
+ )
315
+ if owner:
316
+ ax.text(
317
+ x0 + 0.12,
318
+ y,
319
+ f"Owner: {owner}",
320
+ va="center",
321
+ ha="left",
322
+ fontsize=9,
323
+ style="italic",
324
+ )
325
+
326
+ # Description
327
+ ax.text(
328
+ x0 + 0.12,
329
+ y - 0.18,
330
+ desc_short,
331
+ va="top",
332
+ ha="left",
333
+ fontsize=9,
334
+ wrap=True,
335
+ )
336
+
337
+ ax.set_ylim(-1, n)
338
+ ax.axis("off")
339
+ fig.tight_layout()
340
+ return fig
341
+
342
+
343
+ # -----------------------------
344
+ # SAMPLE PRESETS
345
+ # -----------------------------
346
+
347
+ SAMPLE_SOPS = {
348
+ "Volunteer Onboarding Workflow": {
349
+ "title": "Volunteer Onboarding Workflow",
350
+ "description": (
351
+ "Create a clear SOP for onboarding new volunteers at a youth-serving "
352
+ "nonprofit. Include background checks, orientation, training, and site placement."
353
+ ),
354
+ "industry": "Nonprofit / Youth Development",
355
+ },
356
+ "New Employee Remote Onboarding": {
357
+ "title": "Remote Employee Onboarding",
358
+ "description": (
359
+ "Design a remote onboarding SOP for new employees in a hybrid org, including "
360
+ "IT setup, HR paperwork, culture onboarding, and 30-60-90 day milestones."
361
+ ),
362
+ "industry": "General / HR",
363
+ },
364
+ "Incident Response - IT Outage": {
365
+ "title": "IT Outage Incident Response",
366
+ "description": (
367
+ "An SOP for responding to major IT outages affecting multiple sites, "
368
+ "including triage, communication, escalation, and post-mortem."
369
+ ),
370
+ "industry": "IT / Operations",
371
+ },
372
+ }
373
+
374
+
375
+ def load_sample(sample_name: str) -> Tuple[str, str, str]:
376
+ if not sample_name or sample_name not in SAMPLE_SOPS:
377
+ return "", "", "General"
378
+ sample = SAMPLE_SOPS[sample_name]
379
+ return (
380
+ sample.get("title", ""),
381
+ sample.get("description", ""),
382
+ sample.get("industry", "General"),
383
+ )
384
+
385
+
386
+ # -----------------------------
387
+ # MAIN GENERATION FUNCTION (UI HOOK)
388
+ # -----------------------------
389
+
390
+ def generate_sop_ui(
391
+ api_key_state: str,
392
+ api_key_input: str,
393
+ base_url: str,
394
+ model_name: str,
395
+ sop_title: str,
396
+ description: str,
397
+ industry: str,
398
+ tone: str,
399
+ detail_level: str,
400
+ ) -> Tuple[str, str, Figure, str]:
401
+ """
402
+ Main Gradio handler:
403
+ - Picks API key (state vs input)
404
+ - Calls LLM
405
+ - Parses JSON
406
+ - Returns Markdown SOP, raw JSON, figure, and updated key state
407
+ """
408
+ api_key = api_key_input or api_key_state
409
+ if not api_key:
410
+ return (
411
+ "⚠️ Please enter an API key in the settings panel.",
412
+ "",
413
+ create_sop_steps_figure({"steps": []}),
414
+ api_key_state,
415
+ )
416
+
417
+ if not model_name:
418
+ model_name = "gpt-4.1-mini"
419
+
420
+ user_prompt = build_user_prompt(sop_title, description, industry, tone, detail_level)
421
+
422
+ try:
423
+ raw_response = call_chat_completion(
424
+ api_key=api_key,
425
+ base_url=base_url,
426
+ model=model_name,
427
+ system_prompt=SOP_SYSTEM_PROMPT,
428
+ user_prompt=user_prompt,
429
+ temperature=0.25,
430
+ max_tokens=1800,
431
+ )
432
+
433
+ sop_json = parse_sop_json(raw_response)
434
+ sop_md = sop_to_markdown(sop_json)
435
+ fig = create_sop_steps_figure(sop_json)
436
+ pretty_json = json.dumps(sop_json, indent=2, ensure_ascii=False)
437
+
438
+ # Save key in state for this session only
439
+ return sop_md, pretty_json, fig, api_key
440
+
441
+ except Exception as e:
442
+ error_msg = f"❌ Error generating SOP:\n\n{e}"
443
+ fig = create_sop_steps_figure({"steps": []})
444
+ return error_msg, "", fig, api_key_state
445
+
446
+
447
+ # -----------------------------
448
+ # BUILD GRADIO APP
449
+ # -----------------------------
450
+
451
+ with gr.Blocks(title="ZEN Simple SOP Builder") as demo:
452
+ gr.Markdown(
453
+ """
454
+ # 🧭 ZEN Simple SOP Builder
455
+
456
+ Generate clean, professional Standard Operating Procedures (SOPs) from a short description.
457
+ Perfect for mid-career professionals who need **clarity, structure, and ownership** — fast.
458
+
459
+ 1. Configure your API settings
460
+ 2. Describe the process you want to document
461
+ 3. Generate a full SOP + visual flow of the steps
462
+
463
+ > Your API key is stored only in your session state and **never logged or saved to disk**.
464
+ """
465
+ )
466
+
467
+ api_key_state = gr.State("")
468
+
469
+ with gr.Row():
470
+ with gr.Column(scale=1):
471
+ gr.Markdown("### Step 1: API & Model Settings")
472
+
473
+ api_key_input = gr.Textbox(
474
+ label="LLM API Key",
475
+ placeholder="Enter your API key (OpenAI, compatible provider, etc.)",
476
+ type="password",
477
+ )
478
+ base_url = gr.Textbox(
479
+ label="Base URL (optional)",
480
+ value="https://api.openai.com",
481
+ placeholder="e.g. https://api.openai.com or your custom OpenAI