ZENLLC commited on
Commit
781872c
·
verified ·
1 Parent(s): 3eff5bc

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +196 -219
app.py CHANGED
@@ -18,18 +18,14 @@ def call_chat_completion(
18
  model: str,
19
  system_prompt: str,
20
  user_prompt: str,
21
- temperature: float = 0.3,
22
  max_completion_tokens: int = 1800,
23
  ) -> str:
24
  """
25
- Generic OpenAI-compatible chat completion call using HTTP.
26
 
27
- Primary path:
28
- - Uses `max_completion_tokens` (new OpenAI-style param).
29
-
30
- Fallback:
31
- - If the provider complains that `max_completion_tokens` is unsupported,
32
- retry once using legacy `max_tokens` instead.
33
  """
34
  if not api_key:
35
  raise ValueError("API key is required.")
@@ -44,27 +40,29 @@ def call_chat_completion(
44
  "Content-Type": "application/json",
45
  }
46
 
47
- def _payload(use_new_param: bool) -> Dict[str, Any]:
48
- base = {
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  "model": model,
50
- "temperature": temperature,
51
  "messages": [
52
  {"role": "system", "content": system_prompt},
53
  {"role": "user", "content": user_prompt},
54
  ],
 
55
  }
56
- if use_new_param:
57
- base["max_completion_tokens"] = max_completion_tokens
58
- else:
59
- base["max_tokens"] = max_completion_tokens
60
- return base
61
-
62
- # First attempt: new param
63
- resp = requests.post(url, headers=headers, json=_payload(True), timeout=60)
64
-
65
- # If provider doesn't support max_completion_tokens, fallback to max_tokens
66
- if resp.status_code == 400 and "max_completion_tokens" in resp.text:
67
- resp = requests.post(url, headers=headers, json=_payload(False), timeout=60)
68
 
69
  if resp.status_code != 200:
70
  raise RuntimeError(
@@ -87,12 +85,7 @@ You are an expert operations consultant and technical writer.
87
 
88
  You generate clear, professional, implementation-ready Standard Operating Procedures (SOPs).
89
 
90
- The user will give:
91
- - A title or short description of the process
92
- - Context about the organization or scenario
93
- - Optional industry, tone, and detail level
94
-
95
- You MUST respond strictly as JSON, with no extra commentary, using this schema:
96
 
97
  {
98
  "title": "string",
@@ -125,12 +118,6 @@ You MUST respond strictly as JSON, with no extra commentary, using this schema:
125
  "last_updated": "string"
126
  }
127
  }
128
-
129
- Constraints:
130
- - Make steps specific, observable, and sequential.
131
- - Use inclusive, professional language.
132
- - Assume this will be used by mid-career professionals.
133
- - Keep lists between 3 and 8 items unless the user asks for more.
134
  """
135
 
136
 
@@ -141,117 +128,89 @@ def build_user_prompt(
141
  tone: str,
142
  detail_level: str,
143
  ) -> str:
144
- pieces = [
145
- f"Process title: {sop_title or 'Untitled SOP'}",
146
- f"Context / description: {description or 'N/A'}",
147
- f"Industry: {industry or 'General'}",
148
- f"Tone: {tone or 'Professional'}",
149
- f"Detail level: {detail_level or 'Standard'}",
150
- "Audience: Mid-career professionals who already understand basic workplace concepts but need clear structure and ownership.",
151
- ]
152
- return "\n".join(pieces)
153
 
154
 
155
  def parse_sop_json(raw_text: str) -> Dict[str, Any]:
156
- """
157
- Attempt to parse the LLM output as JSON.
158
- Also handles cases where the model wrapped the JSON in code fences.
159
- """
160
- text = raw_text.strip()
161
-
162
- # Strip markdown code fences if present
163
- if text.startswith("```"):
164
- parts = text.split("```")
165
- # Try to find part containing '{'
166
- candidates = [p for p in parts if "{" in p]
167
- if candidates:
168
- text = candidates[0]
169
- else:
170
- text = parts[-1]
171
- text = text.strip()
172
 
173
  # Extract JSON between first '{' and last '}'
174
- first_brace = text.find("{")
175
- last_brace = text.rfind("}")
176
- if first_brace != -1 and last_brace != -1:
177
- text = text[first_brace : last_brace + 1]
178
 
179
- return json.loads(text)
180
 
181
 
182
  def sop_to_markdown(sop: Dict[str, Any]) -> str:
183
- """Render the SOP JSON into a readable Markdown document."""
184
 
185
- def bullet_list(items: List[str]) -> str:
186
  if not items:
187
  return "_None specified._"
188
  return "\n".join(f"- {i}" for i in items)
189
 
190
- md = []
191
 
192
  md.append(f"# {sop.get('title', 'Standard Operating Procedure')}\n")
193
 
194
  md.append("## 1. Purpose")
195
- md.append(sop.get("purpose", "_No purpose provided._"))
196
 
197
  md.append("\n## 2. Scope")
198
- md.append(sop.get("scope", "_No scope provided._"))
199
 
200
- definitions = sop.get("definitions", [])
201
  md.append("\n## 3. Definitions")
202
- md.append(bullet_list(definitions))
203
 
204
- roles = sop.get("roles", [])
205
  md.append("\n## 4. Roles & Responsibilities")
206
- if roles:
207
- for role in roles:
208
- name = role.get("name", "Role")
209
- responsibilities = role.get("responsibilities", [])
210
- md.append(f"### {name}")
211
- md.append(bullet_list(responsibilities))
212
- else:
213
- md.append("_No roles specified._")
214
-
215
- prereq = sop.get("prerequisites", [])
216
  md.append("\n## 5. Prerequisites")
217
- md.append(bullet_list(prereq))
218
 
219
- steps = sop.get("steps", [])
220
  md.append("\n## 6. Procedure (Step-by-Step)")
221
- if steps:
222
- for step in steps:
223
- num = step.get("step_number", "?")
224
- title = step.get("title", "Step")
225
- owner = step.get("owner_role", "Owner")
226
- desc = step.get("description", "")
227
- inputs = step.get("inputs", [])
228
- outputs = step.get("outputs", [])
229
- md.append(f"### Step {num}: {title}")
230
- md.append(f"**Owner:** {owner}")
231
- md.append(desc or "_No description provided._")
232
- if inputs:
233
- md.append("**Inputs:**")
234
- md.append(bullet_list(inputs))
235
- if outputs:
236
- md.append("**Outputs:**")
237
- md.append(bullet_list(outputs))
238
- else:
239
- md.append("_No steps provided._")
240
 
241
  md.append("\n## 7. Escalation")
242
- md.append(bullet_list(sop.get("escalation", [])))
243
 
244
  md.append("\n## 8. Metrics & Success Criteria")
245
- md.append(bullet_list(sop.get("metrics", [])))
246
 
247
  md.append("\n## 9. Risks & Controls")
248
- md.append(bullet_list(sop.get("risks", [])))
249
 
250
- versioning = sop.get("versioning", {})
251
  md.append("\n## 10. Version Control")
252
- md.append(f"- Version: {versioning.get('version', '1.0')}")
253
- md.append(f"- Owner: {versioning.get('owner', 'Not specified')}")
254
- md.append(f"- Last Updated: {versioning.get('last_updated', 'Not specified')}")
255
 
256
  return "\n\n".join(md)
257
 
@@ -262,12 +221,19 @@ def sop_to_markdown(sop: Dict[str, Any]) -> str:
262
 
263
  def create_sop_steps_figure(sop: Dict[str, Any]) -> Figure:
264
  """
265
- Create a simple infographic-like matplotlib figure showing the SOP steps
266
- as a vertical flow with numbered boxes.
 
 
 
 
267
  """
 
268
  steps = sop.get("steps", [])
 
 
269
  if not steps:
270
- fig, ax = plt.subplots(figsize=(6, 2))
271
  ax.text(
272
  0.5,
273
  0.5,
@@ -275,87 +241,119 @@ def create_sop_steps_figure(sop: Dict[str, Any]) -> Figure:
275
  ha="center",
276
  va="center",
277
  fontsize=12,
278
- wrap=True,
279
  )
280
  ax.axis("off")
281
  fig.tight_layout()
282
  return fig
283
 
284
  n = len(steps)
285
- fig_height = max(3, n * 1.0)
286
- fig, ax = plt.subplots(figsize=(7, fig_height))
287
 
288
- y_positions = list(reversed(range(n))) # top to bottom
 
 
289
 
290
- for idx, (step, y) in enumerate(zip(steps, y_positions)):
291
- num = step.get("step_number", idx + 1)
292
- title = step.get("title", f"Step {num}")
 
 
 
 
 
 
 
 
 
 
 
 
 
293
  owner = step.get("owner_role", "")
294
  desc = step.get("description", "")
295
 
296
- desc_short = textwrap.shorten(desc, width=120, placeholder="...")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
297
 
298
- x0, x1 = 0.05, 0.95
299
- y0, y1 = y - 0.3, y + 0.3
 
 
 
300
 
301
- # Card rectangle (using default styles)
302
  ax.add_patch(
303
  plt.Rectangle(
304
- (x0, y0),
305
- x1 - x0,
306
- y1 - y0,
307
  fill=False,
308
- linewidth=1.5,
309
- linestyle="-",
310
  )
311
  )
312
 
313
- # Step number
314
  ax.text(
315
- x0 + 0.02,
316
- y,
317
- f"{num}",
 
318
  va="center",
319
- ha="left",
320
- fontsize=11,
321
  fontweight="bold",
322
  )
323
 
 
 
 
324
  # Title
325
  ax.text(
326
- x0 + 0.12,
327
- y + 0.15,
328
  title,
329
- va="center",
330
  ha="left",
331
- fontsize=11,
 
332
  fontweight="bold",
333
  )
334
 
335
- # Owner
336
  if owner:
337
  ax.text(
338
- x0 + 0.12,
339
- y,
340
  f"Owner: {owner}",
341
- va="center",
342
  ha="left",
343
- fontsize=9,
 
344
  style="italic",
345
  )
 
 
 
346
 
347
- # Description
348
  ax.text(
349
- x0 + 0.12,
350
- y - 0.18,
351
- desc_short,
352
- va="top",
353
  ha="left",
 
354
  fontsize=9,
355
- wrap=True,
356
  )
357
 
358
- ax.set_ylim(-1, n)
359
  ax.axis("off")
360
  fig.tight_layout()
361
  return fig
@@ -365,7 +363,7 @@ def create_sop_steps_figure(sop: Dict[str, Any]) -> Figure:
365
  # SAMPLE PRESETS
366
  # -----------------------------
367
 
368
- SAMPLE_SOPS = {
369
  "Volunteer Onboarding Workflow": {
370
  "title": "Volunteer Onboarding Workflow",
371
  "description": (
@@ -374,18 +372,18 @@ SAMPLE_SOPS = {
374
  ),
375
  "industry": "Nonprofit / Youth Development",
376
  },
377
- "New Employee Remote Onboarding": {
378
  "title": "Remote Employee Onboarding",
379
  "description": (
380
- "Design a remote onboarding SOP for new employees in a hybrid org, including "
381
- "IT setup, HR paperwork, culture onboarding, and 30-60-90 day milestones."
382
  ),
383
  "industry": "General / HR",
384
  },
385
- "Incident Response - IT Outage": {
386
  "title": "IT Outage Incident Response",
387
  "description": (
388
- "An SOP for responding to major IT outages affecting multiple sites, "
389
  "including triage, communication, escalation, and post-mortem."
390
  ),
391
  "industry": "IT / Operations",
@@ -396,77 +394,69 @@ SAMPLE_SOPS = {
396
  def load_sample(sample_name: str) -> Tuple[str, str, str]:
397
  if not sample_name or sample_name not in SAMPLE_SOPS:
398
  return "", "", "General"
399
- sample = SAMPLE_SOPS[sample_name]
400
- return (
401
- sample.get("title", ""),
402
- sample.get("description", ""),
403
- sample.get("industry", "General"),
404
- )
405
 
406
 
407
  # -----------------------------
408
- # MAIN GENERATION FUNCTION (UI HOOK)
409
  # -----------------------------
410
 
411
  def generate_sop_ui(
412
  api_key_state: str,
413
  api_key_input: str,
414
  base_url: str,
415
- model_name: str,
416
  sop_title: str,
417
  description: str,
418
  industry: str,
419
  tone: str,
420
  detail_level: str,
421
  ) -> Tuple[str, str, Figure, str]:
422
- """
423
- Main Gradio handler:
424
- - Picks API key (state vs input)
425
- - Calls LLM
426
- - Parses JSON
427
- - Returns Markdown SOP, raw JSON, figure, and updated key state
428
- """
429
  api_key = api_key_input or api_key_state
430
  if not api_key:
431
  return (
432
- "⚠️ Please enter an API key in the settings panel.",
433
  "",
434
  create_sop_steps_figure({"steps": []}),
435
  api_key_state,
436
  )
437
 
438
- if not model_name:
439
- model_name = "gpt-4.1-mini"
440
 
441
  user_prompt = build_user_prompt(sop_title, description, industry, tone, detail_level)
442
 
443
  try:
444
- raw_response = call_chat_completion(
445
  api_key=api_key,
446
  base_url=base_url,
447
- model=model_name,
448
  system_prompt=SOP_SYSTEM_PROMPT,
449
  user_prompt=user_prompt,
450
- temperature=0.25,
451
  max_completion_tokens=1800,
452
  )
453
 
454
- sop_json = parse_sop_json(raw_response)
455
- sop_md = sop_to_markdown(sop_json)
456
- fig = create_sop_steps_figure(sop_json)
457
- pretty_json = json.dumps(sop_json, indent=2, ensure_ascii=False)
458
 
459
- # Save key in state for this session only
460
- return sop_md, pretty_json, fig, api_key
461
 
462
  except Exception as e:
463
- error_msg = f"❌ Error generating SOP:\n\n{e}"
464
- fig = create_sop_steps_figure({"steps": []})
465
- return error_msg, "", fig, api_key_state
 
 
 
466
 
467
 
468
  # -----------------------------
469
- # BUILD GRADIO APP
470
  # -----------------------------
471
 
472
  with gr.Blocks(title="ZEN Simple SOP Builder") as demo:
@@ -481,7 +471,7 @@ Perfect for mid-career professionals who need clarity, structure, and ownership
481
  2. Describe the process you want to document
482
  3. Generate a full SOP + visual flow of the steps
483
 
484
- > Your API key is stored only in your session state and is not logged to disk.
485
  """
486
  )
487
 
@@ -489,70 +479,58 @@ Perfect for mid-career professionals who need clarity, structure, and ownership
489
 
490
  with gr.Row():
491
  with gr.Column(scale=1):
492
- gr.Markdown("### Step 1: API & Model Settings")
493
 
494
  api_key_input = gr.Textbox(
495
  label="LLM API Key",
496
  placeholder="Enter your API key (OpenAI or compatible provider)",
497
  type="password",
498
  )
 
499
  base_url = gr.Textbox(
500
- label="Base URL (optional)",
501
  value="https://api.openai.com",
502
- placeholder="e.g. https://api.openai.com or your custom OpenAI-compatible endpoint",
503
  )
 
504
  model_name = gr.Textbox(
505
  label="Model Name",
506
  value="gpt-4.1-mini",
507
  placeholder="e.g. gpt-4.1, gpt-4o, deepseek-chat, mistral-large, etc.",
508
  )
509
 
510
- gr.Markdown(
511
- """
512
- You can use any **OpenAI-compatible** provider
513
- by adjusting the Base URL and Model Name.
514
- """
515
- )
516
-
517
- gr.Markdown("### Step 2: Choose or Load a Sample")
518
-
519
  sample_dropdown = gr.Dropdown(
520
- label="Sample scenarios",
521
  choices=list(SAMPLE_SOPS.keys()),
522
  value=None,
523
- info="Optional: load a pre-filled example to see how it works.",
524
  )
525
  load_button = gr.Button("Load Sample into Form")
526
 
527
  with gr.Column(scale=2):
528
- gr.Markdown("### Step 3: Describe the SOP you want to generate")
529
 
530
  sop_title = gr.Textbox(
531
  label="SOP Title",
532
- placeholder="e.g. Volunteer Onboarding Workflow, IT Outage Response, New Program Launch",
533
  )
534
 
535
  description = gr.Textbox(
536
  label="Describe the process / context",
537
- placeholder="Describe what this SOP should cover, who it's for, and any constraints.",
538
  lines=6,
539
  )
540
 
541
  industry = gr.Textbox(
542
  label="Industry / Domain",
543
- placeholder="e.g. Nonprofit / Finance / Education / Healthcare / IT",
544
  value="General",
 
545
  )
546
 
547
  tone = gr.Dropdown(
548
  label="Tone",
549
- choices=[
550
- "Professional",
551
- "Executive",
552
- "Supportive",
553
- "Direct",
554
- "Compliance-focused",
555
- ],
556
  value="Professional",
557
  )
558
 
@@ -564,13 +542,13 @@ by adjusting the Base URL and Model Name.
564
 
565
  generate_button = gr.Button("🚀 Generate SOP", variant="primary")
566
 
567
- gr.Markdown("### Step 4: Results")
568
 
569
  with gr.Row():
570
  with gr.Column(scale=3):
571
  sop_output = gr.Markdown(
572
  label="Generated SOP",
573
- value="SOP output will appear here.",
574
  )
575
  with gr.Column(scale=2):
576
  sop_json_output = gr.Code(
@@ -579,8 +557,7 @@ by adjusting the Base URL and Model Name.
579
  )
580
 
581
  gr.Markdown("### Visual Flow of Steps")
582
-
583
- sop_figure = gr.Plot(label="SOP Steps Infographic")
584
 
585
  # Wire up events
586
  load_button.click(
 
18
  model: str,
19
  system_prompt: str,
20
  user_prompt: str,
 
21
  max_completion_tokens: int = 1800,
22
  ) -> str:
23
  """
24
+ OpenAI-compatible ChatCompletion caller.
25
 
26
+ - Uses `max_completion_tokens` (new OpenAI spec).
27
+ - Falls back to `max_tokens` for providers that still expect it.
28
+ - No temperature param (some models only allow default).
 
 
 
29
  """
30
  if not api_key:
31
  raise ValueError("API key is required.")
 
40
  "Content-Type": "application/json",
41
  }
42
 
43
+ # Primary payload using max_completion_tokens
44
+ new_payload = {
45
+ "model": model,
46
+ "messages": [
47
+ {"role": "system", "content": system_prompt},
48
+ {"role": "user", "content": user_prompt},
49
+ ],
50
+ "max_completion_tokens": max_completion_tokens,
51
+ }
52
+
53
+ resp = requests.post(url, headers=headers, json=new_payload, timeout=60)
54
+
55
+ # If provider doesn't support `max_completion_tokens`, try legacy `max_tokens`
56
+ if resp.status_code == 400 and "max_completion_tokens" in resp.text:
57
+ legacy_payload = {
58
  "model": model,
 
59
  "messages": [
60
  {"role": "system", "content": system_prompt},
61
  {"role": "user", "content": user_prompt},
62
  ],
63
+ "max_tokens": max_completion_tokens,
64
  }
65
+ resp = requests.post(url, headers=headers, json=legacy_payload, timeout=60)
 
 
 
 
 
 
 
 
 
 
 
66
 
67
  if resp.status_code != 200:
68
  raise RuntimeError(
 
85
 
86
  You generate clear, professional, implementation-ready Standard Operating Procedures (SOPs).
87
 
88
+ You MUST respond strictly as JSON using this schema:
 
 
 
 
 
89
 
90
  {
91
  "title": "string",
 
118
  "last_updated": "string"
119
  }
120
  }
 
 
 
 
 
 
121
  """
122
 
123
 
 
128
  tone: str,
129
  detail_level: str,
130
  ) -> str:
131
+ return f"""
132
+ Process Title: {sop_title or "Untitled SOP"}
133
+ Context: {description or "N/A"}
134
+ Industry: {industry or "General"}
135
+ Tone: {tone or "Professional"}
136
+ Detail Level: {detail_level or "Standard"}
137
+
138
+ Audience: Mid-career professionals.
139
+ """
140
 
141
 
142
  def parse_sop_json(raw_text: str) -> Dict[str, Any]:
143
+ """Clean model output and extract JSON."""
144
+ txt = raw_text.strip()
145
+
146
+ # Strip markdown fences if present
147
+ if txt.startswith("```"):
148
+ parts = txt.split("```")
149
+ txt = next((p for p in parts if "{" in p), parts[-1])
 
 
 
 
 
 
 
 
 
150
 
151
  # Extract JSON between first '{' and last '}'
152
+ first = txt.find("{")
153
+ last = txt.rfind("}")
154
+ if first != -1 and last != -1:
155
+ txt = txt[first:last + 1]
156
 
157
+ return json.loads(txt)
158
 
159
 
160
  def sop_to_markdown(sop: Dict[str, Any]) -> str:
161
+ """Format JSON SOP into Markdown."""
162
 
163
+ def bullet(items: List[str]) -> str:
164
  if not items:
165
  return "_None specified._"
166
  return "\n".join(f"- {i}" for i in items)
167
 
168
+ md: List[str] = []
169
 
170
  md.append(f"# {sop.get('title', 'Standard Operating Procedure')}\n")
171
 
172
  md.append("## 1. Purpose")
173
+ md.append(sop.get("purpose", "N/A"))
174
 
175
  md.append("\n## 2. Scope")
176
+ md.append(sop.get("scope", "N/A"))
177
 
 
178
  md.append("\n## 3. Definitions")
179
+ md.append(bullet(sop.get("definitions", [])))
180
 
 
181
  md.append("\n## 4. Roles & Responsibilities")
182
+ for r in sop.get("roles", []):
183
+ name = r.get("name", "Role")
184
+ md.append(f"### {name}")
185
+ md.append(bullet(r.get("responsibilities", [])))
186
+
 
 
 
 
 
187
  md.append("\n## 5. Prerequisites")
188
+ md.append(bullet(sop.get("prerequisites", [])))
189
 
 
190
  md.append("\n## 6. Procedure (Step-by-Step)")
191
+ for step in sop.get("steps", []):
192
+ md.append(f"### Step {step.get('step_number', '?')}: {step.get('title', 'Step')}")
193
+ md.append(f"**Owner:** {step.get('owner_role', 'N/A')}")
194
+ md.append(step.get("description", ""))
195
+ md.append("**Inputs:**")
196
+ md.append(bullet(step.get("inputs", [])))
197
+ md.append("**Outputs:**")
198
+ md.append(bullet(step.get("outputs", [])))
 
 
 
 
 
 
 
 
 
 
 
199
 
200
  md.append("\n## 7. Escalation")
201
+ md.append(bullet(sop.get("escalation", [])))
202
 
203
  md.append("\n## 8. Metrics & Success Criteria")
204
+ md.append(bullet(sop.get("metrics", [])))
205
 
206
  md.append("\n## 9. Risks & Controls")
207
+ md.append(bullet(sop.get("risks", [])))
208
 
209
+ v = sop.get("versioning", {})
210
  md.append("\n## 10. Version Control")
211
+ md.append(f"- Version: {v.get('version', '1.0')}")
212
+ md.append(f"- Owner: {v.get('owner', 'N/A')}")
213
+ md.append(f"- Last Updated: {v.get('last_updated', 'N/A')}")
214
 
215
  return "\n\n".join(md)
216
 
 
221
 
222
  def create_sop_steps_figure(sop: Dict[str, Any]) -> Figure:
223
  """
224
+ Create a clearer, more readable infographic-style figure
225
+ showing the SOP steps as stacked cards.
226
+
227
+ - Large, legible fonts
228
+ - Number block on the left
229
+ - Wrapped description text
230
  """
231
+
232
  steps = sop.get("steps", [])
233
+
234
+ # Empty state
235
  if not steps:
236
+ fig, ax = plt.subplots(figsize=(7, 2))
237
  ax.text(
238
  0.5,
239
  0.5,
 
241
  ha="center",
242
  va="center",
243
  fontsize=12,
 
244
  )
245
  ax.axis("off")
246
  fig.tight_layout()
247
  return fig
248
 
249
  n = len(steps)
 
 
250
 
251
+ # Figure height scales with number of steps (capped)
252
+ fig_height = min(14, max(4, 1.6 * n))
253
+ fig, ax = plt.subplots(figsize=(9, fig_height))
254
 
255
+ # Coordinate system: y from 0 (bottom) to n (top)
256
+ ax.set_xlim(0, 1)
257
+ ax.set_ylim(0, n)
258
+
259
+ card_top_margin = 0.25
260
+ card_bottom_margin = 0.25
261
+ card_height = 1 - (card_top_margin + card_bottom_margin)
262
+
263
+ for idx, step in enumerate(steps):
264
+ # y coordinate from top down
265
+ row_top = n - idx - card_top_margin
266
+ row_bottom = row_top - card_height
267
+ center_y = (row_top + row_bottom) / 2
268
+
269
+ step_number = step.get("step_number", idx + 1)
270
+ title = step.get("title", f"Step {step_number}")
271
  owner = step.get("owner_role", "")
272
  desc = step.get("description", "")
273
 
274
+ # Wrap description into multiple lines
275
+ desc_wrapped = textwrap.fill(desc, width=80)
276
+
277
+ # Card rectangle (full width)
278
+ card_x0 = 0.03
279
+ card_x1 = 0.97
280
+ card_width = card_x1 - card_x0
281
+
282
+ ax.add_patch(
283
+ plt.Rectangle(
284
+ (card_x0, row_bottom),
285
+ card_width,
286
+ card_height,
287
+ fill=False,
288
+ linewidth=1.6,
289
+ )
290
+ )
291
 
292
+ # Number block on the left
293
+ num_block_width = 0.08
294
+ num_block_x0 = card_x0
295
+ num_block_y0 = row_bottom
296
+ num_block_height = card_height
297
 
 
298
  ax.add_patch(
299
  plt.Rectangle(
300
+ (num_block_x0, num_block_y0),
301
+ num_block_width,
302
+ num_block_height,
303
  fill=False,
304
+ linewidth=1.4,
 
305
  )
306
  )
307
 
 
308
  ax.text(
309
+ num_block_x0 + num_block_width / 2,
310
+ center_y,
311
+ str(step_number),
312
+ ha="center",
313
  va="center",
314
+ fontsize=12,
 
315
  fontweight="bold",
316
  )
317
 
318
+ # Text block (title, owner, description)
319
+ text_x0 = num_block_x0 + num_block_width + 0.02
320
+
321
  # Title
322
  ax.text(
323
+ text_x0,
324
+ row_top - 0.08,
325
  title,
 
326
  ha="left",
327
+ va="top",
328
+ fontsize=12,
329
  fontweight="bold",
330
  )
331
 
332
+ # Owner line (optional)
333
  if owner:
334
  ax.text(
335
+ text_x0,
336
+ row_top - 0.28,
337
  f"Owner: {owner}",
 
338
  ha="left",
339
+ va="top",
340
+ fontsize=10,
341
  style="italic",
342
  )
343
+ desc_y = row_top - 0.48
344
+ else:
345
+ desc_y = row_top - 0.3
346
 
347
+ # Description (wrapped)
348
  ax.text(
349
+ text_x0,
350
+ desc_y,
351
+ desc_wrapped,
 
352
  ha="left",
353
+ va="top",
354
  fontsize=9,
 
355
  )
356
 
 
357
  ax.axis("off")
358
  fig.tight_layout()
359
  return fig
 
363
  # SAMPLE PRESETS
364
  # -----------------------------
365
 
366
+ SAMPLE_SOPS: Dict[str, Dict[str, str]] = {
367
  "Volunteer Onboarding Workflow": {
368
  "title": "Volunteer Onboarding Workflow",
369
  "description": (
 
372
  ),
373
  "industry": "Nonprofit / Youth Development",
374
  },
375
+ "Remote Employee Onboarding": {
376
  "title": "Remote Employee Onboarding",
377
  "description": (
378
+ "Design a remote onboarding SOP for new employees in a hybrid org, "
379
+ "covering IT setup, HR paperwork, culture onboarding, and 30-60-90 day milestones."
380
  ),
381
  "industry": "General / HR",
382
  },
383
+ "IT Outage Incident Response": {
384
  "title": "IT Outage Incident Response",
385
  "description": (
386
+ "Create an SOP for responding to major IT outages affecting multiple sites, "
387
  "including triage, communication, escalation, and post-mortem."
388
  ),
389
  "industry": "IT / Operations",
 
394
  def load_sample(sample_name: str) -> Tuple[str, str, str]:
395
  if not sample_name or sample_name not in SAMPLE_SOPS:
396
  return "", "", "General"
397
+ s = SAMPLE_SOPS[sample_name]
398
+ return s["title"], s["description"], s["industry"]
 
 
 
 
399
 
400
 
401
  # -----------------------------
402
+ # MAIN HANDLER (CALLED BY UI)
403
  # -----------------------------
404
 
405
  def generate_sop_ui(
406
  api_key_state: str,
407
  api_key_input: str,
408
  base_url: str,
409
+ model: str,
410
  sop_title: str,
411
  description: str,
412
  industry: str,
413
  tone: str,
414
  detail_level: str,
415
  ) -> Tuple[str, str, Figure, str]:
416
+ """Gradio event handler: generate SOP + JSON + figure."""
 
 
 
 
 
 
417
  api_key = api_key_input or api_key_state
418
  if not api_key:
419
  return (
420
+ "⚠️ Please enter an API key.",
421
  "",
422
  create_sop_steps_figure({"steps": []}),
423
  api_key_state,
424
  )
425
 
426
+ if not model:
427
+ model = "gpt-4.1-mini"
428
 
429
  user_prompt = build_user_prompt(sop_title, description, industry, tone, detail_level)
430
 
431
  try:
432
+ raw = call_chat_completion(
433
  api_key=api_key,
434
  base_url=base_url,
435
+ model=model,
436
  system_prompt=SOP_SYSTEM_PROMPT,
437
  user_prompt=user_prompt,
 
438
  max_completion_tokens=1800,
439
  )
440
 
441
+ sop = parse_sop_json(raw)
442
+ md = sop_to_markdown(sop)
443
+ fig = create_sop_steps_figure(sop)
444
+ json_out = json.dumps(sop, indent=2, ensure_ascii=False)
445
 
446
+ # Save key into session state
447
+ return md, json_out, fig, api_key
448
 
449
  except Exception as e:
450
+ return (
451
+ f"❌ Error generating SOP:\n\n{e}",
452
+ "",
453
+ create_sop_steps_figure({"steps": []}),
454
+ api_key_state,
455
+ )
456
 
457
 
458
  # -----------------------------
459
+ # GRADIO UI
460
  # -----------------------------
461
 
462
  with gr.Blocks(title="ZEN Simple SOP Builder") as demo:
 
471
  2. Describe the process you want to document
472
  3. Generate a full SOP + visual flow of the steps
473
 
474
+ > Your API key stays in this browser session and is not logged to disk.
475
  """
476
  )
477
 
 
479
 
480
  with gr.Row():
481
  with gr.Column(scale=1):
482
+ gr.Markdown("### Step 1 API & Model Settings")
483
 
484
  api_key_input = gr.Textbox(
485
  label="LLM API Key",
486
  placeholder="Enter your API key (OpenAI or compatible provider)",
487
  type="password",
488
  )
489
+
490
  base_url = gr.Textbox(
491
+ label="Base URL",
492
  value="https://api.openai.com",
493
+ placeholder="e.g. https://api.openai.com or your custom endpoint",
494
  )
495
+
496
  model_name = gr.Textbox(
497
  label="Model Name",
498
  value="gpt-4.1-mini",
499
  placeholder="e.g. gpt-4.1, gpt-4o, deepseek-chat, mistral-large, etc.",
500
  )
501
 
502
+ gr.Markdown("### Step 2 — Try a Sample Scenario")
 
 
 
 
 
 
 
 
503
  sample_dropdown = gr.Dropdown(
504
+ label="Sample SOPs",
505
  choices=list(SAMPLE_SOPS.keys()),
506
  value=None,
507
+ info="Optional: load a predefined example.",
508
  )
509
  load_button = gr.Button("Load Sample into Form")
510
 
511
  with gr.Column(scale=2):
512
+ gr.Markdown("### Step 3 Describe Your SOP")
513
 
514
  sop_title = gr.Textbox(
515
  label="SOP Title",
516
+ placeholder="e.g. Volunteer Onboarding Workflow, IT Outage Response",
517
  )
518
 
519
  description = gr.Textbox(
520
  label="Describe the process / context",
521
+ placeholder="What should this SOP cover? Who is it for? Any constraints?",
522
  lines=6,
523
  )
524
 
525
  industry = gr.Textbox(
526
  label="Industry / Domain",
 
527
  value="General",
528
+ placeholder="e.g. Nonprofit, HR, Education, Healthcare, IT",
529
  )
530
 
531
  tone = gr.Dropdown(
532
  label="Tone",
533
+ choices=["Professional", "Executive", "Supportive", "Direct", "Compliance-focused"],
 
 
 
 
 
 
534
  value="Professional",
535
  )
536
 
 
542
 
543
  generate_button = gr.Button("🚀 Generate SOP", variant="primary")
544
 
545
+ gr.Markdown("### Step 4 Results")
546
 
547
  with gr.Row():
548
  with gr.Column(scale=3):
549
  sop_output = gr.Markdown(
550
  label="Generated SOP",
551
+ value="Your SOP will appear here.",
552
  )
553
  with gr.Column(scale=2):
554
  sop_json_output = gr.Code(
 
557
  )
558
 
559
  gr.Markdown("### Visual Flow of Steps")
560
+ sop_figure = gr.Plot(label="SOP Steps Diagram")
 
561
 
562
  # Wire up events
563
  load_button.click(