From e62c9f07ae7aca4cd7abb4d426fb7a541132dd5c Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Tue, 7 Jul 2026 13:31:40 -0400 Subject: [PATCH] feat: add canonical workflow prompt library to web UI (Closes #428) Expose short copy/paste operator prompts on /prompts derived from canonical workflow files with workflow path and SHA-256 hash. Adds JSON export at /api/prompts, copy buttons, and tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/webui-local-dev.md | 13 +- tests/test_webui_prompt_library.py | 81 ++++++++++++ tests/test_webui_skeleton.py | 7 +- webui/app.py | 12 +- webui/layout.py | 33 ++++- webui/prompt_library.py | 192 +++++++++++++++++++++++++++++ webui/prompt_views.py | 80 ++++++++++++ 7 files changed, 410 insertions(+), 8 deletions(-) create mode 100644 tests/test_webui_prompt_library.py create mode 100644 webui/prompt_library.py create mode 100644 webui/prompt_views.py diff --git a/docs/webui-local-dev.md b/docs/webui-local-dev.md index 011b71a..4b94aed 100644 --- a/docs/webui-local-dev.md +++ b/docs/webui-local-dev.md @@ -39,7 +39,8 @@ Optional environment variables: | `/projects` | Project registry list (#427) | | `/projects/{id}` | Project detail + onboarding checklist | | `/api/projects` | JSON registry export | -| `/prompts` | Stub — prompt library (#428) | +| `/prompts` | Prompt library with per-prompt copy buttons (#428) | +| `/api/prompts` | JSON prompt export with workflow hashes | | `/runtime` | Stub — MCP runtime health (#430) | | `/audit` | Stub — report audit paste (#431) | | `/worktrees` | Stub — hygiene dashboard (#432) | @@ -60,8 +61,16 @@ or credentials. Seed entry: **Gitea-Tools** on `https://gitea.prgs.cc` with `prgs-author`, `prgs-reviewer`, and `prgs-reconciler` profiles. +## Prompt library (#428) + +Prompts are generated at load time from canonical workflow files under +`skills/llm-project-workflow/workflows/`. SHA-256 hashes are computed from +`WEBUI_REPO_ROOT` (defaults to the repository root). Prompt bodies are short +copy/paste starters; canonical workflow files remain the only full policy +source. + ## Tests ```bash -pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py -q +pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py -q ``` \ No newline at end of file diff --git a/tests/test_webui_prompt_library.py b/tests/test_webui_prompt_library.py new file mode 100644 index 0000000..442800d --- /dev/null +++ b/tests/test_webui_prompt_library.py @@ -0,0 +1,81 @@ +"""Tests for web UI prompt library (#428).""" +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from starlette.testclient import TestClient + +from webui.app import create_app +from webui.prompt_library import load_prompt_library, prompt_to_dict + + +class TestWebuiPromptLibrary(unittest.TestCase): + def setUp(self): + self.client = TestClient(create_app()) + + def test_library_has_required_prompts(self): + entries = load_prompt_library() + slugs = {entry.slug for entry in entries} + self.assertEqual( + slugs, + { + "review-pr", + "work-issue", + "create-issue", + "comment-issue", + "cleanup", + "audit", + "onboarding", + }, + ) + + def test_workflow_entries_include_hash_and_task_mode(self): + review = next(e for e in load_prompt_library() if e.slug == "review-pr") + self.assertEqual( + review.workflow_path, + "skills/llm-project-workflow/workflows/review-merge-pr.md", + ) + self.assertEqual(review.task_mode, "review-merge-pr") + self.assertIsNotNone(review.workflow_hash) + self.assertGreater(len(review.workflow_hash), 12) + self.assertIn("eligible open PR", review.prompt_text) + + def test_comment_issue_defers_to_workflow(self): + comment = next(e for e in load_prompt_library() if e.slug == "comment-issue") + self.assertIn("comment_issue", comment.prompt_text) + self.assertIn("create-issue", comment.workflow_path) + self.assertIn("§16", comment.source_note) + + def test_prompts_page_renders_cards_and_copy_buttons(self): + response = self.client.get("/prompts") + self.assertEqual(response.status_code, 200) + text = response.text + self.assertIn("Prompt library", text) + self.assertIn("review-merge-pr.md", text) + self.assertIn("Copy prompt", text) + self.assertIn('data-copy-target="prompt-review-pr"', text) + self.assertNotIn("child issue", text.lower()) + + def test_api_prompts_json(self): + response = self.client.get("/api/prompts") + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(data["count"], 7) + self.assertEqual(len(data["prompts"]), 7) + first = data["prompts"][0] + self.assertIn("slug", first) + self.assertIn("prompt_text", first) + self.assertIn("workflow_path", first) + self.assertIn("workflow_hash", first) + + def test_prompt_to_dict_round_trip(self): + entry = load_prompt_library()[0] + payload = prompt_to_dict(entry) + self.assertEqual(payload["slug"], entry.slug) + self.assertEqual(payload["prompt_text"], entry.prompt_text) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_webui_skeleton.py b/tests/test_webui_skeleton.py index 7e139c6..937b095 100644 --- a/tests/test_webui_skeleton.py +++ b/tests/test_webui_skeleton.py @@ -30,12 +30,17 @@ class TestWebuiSkeleton(unittest.TestCase): self.assertIn("Read-only MVP", response.text) def test_route_stubs_render(self): - for path in ("/prompts", "/runtime", "/audit"): + for path in ("/runtime", "/audit"): with self.subTest(path=path): response = self.client.get(path) self.assertEqual(response.status_code, 200) self.assertIn("child issue", response.text.lower()) + def test_prompts_is_implemented(self): + response = self.client.get("/prompts") + self.assertEqual(response.status_code, 200) + self.assertIn("Prompt library", response.text) + def test_projects_is_implemented(self): response = self.client.get("/projects") self.assertEqual(response.status_code, 200) diff --git a/webui/app.py b/webui/app.py index 4f1cba9..dc99cdb 100644 --- a/webui/app.py +++ b/webui/app.py @@ -12,6 +12,8 @@ from starlette.routing import Route from webui.layout import render_page from webui.project_registry import find_project, load_registry, registry_to_dict from webui.project_views import render_project_detail, render_projects_list +from webui.prompt_library import library_to_dict +from webui.prompt_views import render_prompts_page _READ_ONLY_METHODS = frozenset({"GET", "HEAD", "OPTIONS"}) @@ -80,10 +82,11 @@ async def api_projects(_request: Request) -> JSONResponse: async def prompts(_request: Request) -> HTMLResponse: - return _stub_page( - "Prompts", - "Prompt library will surface canonical workflows from skills/llm-project-workflow/.", - ) + return HTMLResponse(render_prompts_page()) + + +async def api_prompts(_request: Request) -> JSONResponse: + return JSONResponse(library_to_dict()) async def runtime(_request: Request) -> HTMLResponse: @@ -134,6 +137,7 @@ def create_app() -> Starlette: Route("/projects/{project_id}", project_detail, methods=["GET"]), Route("/api/projects", api_projects, methods=["GET"]), Route("/prompts", prompts, methods=["GET"]), + Route("/api/prompts", api_prompts, methods=["GET"]), Route("/runtime", runtime, methods=["GET"]), Route("/audit", audit, methods=["GET"]), Route("/worktrees", worktrees, methods=["GET"]), diff --git a/webui/layout.py b/webui/layout.py index fc75efb..d948204 100644 --- a/webui/layout.py +++ b/webui/layout.py @@ -18,7 +18,7 @@ MVP_NOTICE = ( ) -def render_page(*, title: str, body_html: str) -> str: +def render_page(*, title: str, body_html: str, extra_head: str = "") -> str: nav_links = "".join( f'{label}' for href, label in NAV_ITEMS ) @@ -115,7 +115,38 @@ def render_page(*, title: str, body_html: str) -> str: }} ol.checklist li {{ margin-bottom: 0.85rem; }} ol.checklist p {{ margin: 0.25rem 0 0; font-size: 0.9rem; }} + .prompt-card {{ + margin: 1.25rem 0 1.75rem; + padding: 1rem 1.1rem; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); + }} + .prompt-card h3 {{ margin: 0 0 0.5rem; font-size: 1.05rem; }} + pre.prompt-text {{ + white-space: pre-wrap; + word-break: break-word; + margin: 0.75rem 0; + padding: 0.75rem 0.85rem; + border-radius: 6px; + background: var(--bg); + border: 1px solid var(--border); + font-size: 0.9rem; + color: var(--text); + }} + .copy-btn {{ + background: var(--accent); + color: #0b1219; + border: none; + border-radius: 6px; + padding: 0.4rem 0.85rem; + font-size: 0.85rem; + cursor: pointer; + }} + .copy-btn:hover {{ filter: brightness(1.08); }} + .muted {{ color: var(--muted); }} + {extra_head}
diff --git a/webui/prompt_library.py b/webui/prompt_library.py new file mode 100644 index 0000000..5d2e768 --- /dev/null +++ b/webui/prompt_library.py @@ -0,0 +1,192 @@ +"""Canonical workflow prompt library for the internal web UI (#428).""" + +from __future__ import annotations + +import hashlib +import os +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +_WORKFLOW_ROOT = Path("skills/llm-project-workflow/workflows") + +_DEFAULT_PROMPT_RE = re.compile( + r"\*\*Default task prompt:\*\*\s*\n+>\s*(.+?)(?=\n\n|\nDo not improvise)", + re.DOTALL, +) +_FRONTMATTER_TASK_MODE_RE = re.compile(r"^task_mode:\s*(\S+)", re.MULTILINE) + + +@dataclass(frozen=True) +class PromptEntry: + slug: str + label: str + prompt_text: str + workflow_path: str + task_mode: str | None + workflow_hash: str | None + source_note: str + + +def _repo_root() -> Path: + override = (os.environ.get("WEBUI_REPO_ROOT") or "").strip() + if override: + return Path(override).resolve() + return Path(__file__).resolve().parent.parent + + +def _workflow_file(path: str) -> Path: + return _repo_root() / path + + +def _sha256_hex(content: str) -> str: + return hashlib.sha256(content.encode("utf-8")).hexdigest() + + +def _read_workflow(path: str) -> tuple[str, str]: + file_path = _workflow_file(path) + text = file_path.read_text(encoding="utf-8") + return text, _sha256_hex(text) + + +def _extract_default_prompt(markdown: str) -> str | None: + match = _DEFAULT_PROMPT_RE.search(markdown) + if not match: + return None + lines = [line.strip() for line in match.group(1).splitlines()] + return " ".join(line for line in lines if line) + + +def _extract_task_mode(markdown: str) -> str | None: + match = _FRONTMATTER_TASK_MODE_RE.search(markdown) + return match.group(1) if match else None + + +def _entry_from_workflow( + *, + slug: str, + label: str, + workflow_path: str, + prompt_override: str | None = None, + source_note: str = "", +) -> PromptEntry: + markdown, digest = _read_workflow(workflow_path) + prompt_text = prompt_override or _extract_default_prompt(markdown) + if not prompt_text: + raise ValueError(f"No default task prompt found in {workflow_path}") + return PromptEntry( + slug=slug, + label=label, + prompt_text=prompt_text, + workflow_path=workflow_path, + task_mode=_extract_task_mode(markdown), + workflow_hash=digest, + source_note=source_note, + ) + + +def _static_entry( + *, + slug: str, + label: str, + prompt_text: str, + workflow_path: str, + source_note: str, +) -> PromptEntry: + path = _workflow_file(workflow_path) + digest = _sha256_hex(path.read_text(encoding="utf-8")) if path.is_file() else None + markdown = path.read_text(encoding="utf-8") if path.is_file() else "" + return PromptEntry( + slug=slug, + label=label, + prompt_text=prompt_text, + workflow_path=workflow_path, + task_mode=_extract_task_mode(markdown) if markdown else None, + workflow_hash=digest, + source_note=source_note, + ) + + +def load_prompt_library() -> tuple[PromptEntry, ...]: + """Load operator prompts derived from canonical workflows.""" + entries = ( + _entry_from_workflow( + slug="review-pr", + label="Review PR", + workflow_path=str(_WORKFLOW_ROOT / "review-merge-pr.md"), + ), + _entry_from_workflow( + slug="work-issue", + label="Work issue", + workflow_path=str(_WORKFLOW_ROOT / "work-issue.md"), + ), + _entry_from_workflow( + slug="create-issue", + label="Create issue", + workflow_path=str(_WORKFLOW_ROOT / "create-issue.md"), + ), + _static_entry( + slug="comment-issue", + label="Comment issue", + workflow_path=str(_WORKFLOW_ROOT / "create-issue.md"), + prompt_text=( + "Comment on the target Gitea issue only if exact comment_issue " + "capability is proven. Load the canonical create-issue workflow " + "first and follow §16 (comment-on-existing issue rule). Include " + "specific evidence; do not duplicate existing comments." + ), + source_note="Derived from create-issue.md §16; full policy remains in the workflow file.", + ), + _static_entry( + slug="cleanup", + label="PR queue cleanup", + workflow_path=str(_WORKFLOW_ROOT / "pr-queue-cleanup.md"), + prompt_text=( + "Run PR-only queue cleanup: build a complete open-PR inventory " + "with pagination proof, select exactly one eligible PR, dispatch " + "one canonical review for that PR, then stop after any terminal " + "review mutation. Load pr-queue-cleanup.md and review-merge-pr.md " + "before any mutation." + ), + source_note="Derived from pr-queue-cleanup.md; composes with review-merge-pr.md.", + ), + _entry_from_workflow( + slug="audit", + label="Reconciliation audit", + workflow_path=str(_WORKFLOW_ROOT / "reconcile-landed-pr.md"), + ), + _static_entry( + slug="onboarding", + label="Project onboarding", + workflow_path="docs/webui-local-dev.md", + prompt_text=( + "Start a new MCP Control Plane session: call mcp_get_control_plane_guide, " + "prove identity and task capability, then complete the onboarding checklist " + "for this project at /projects. Keep Gitea, MCP tools, and canonical " + "workflows as the source of truth." + ), + source_note="Operator onboarding; checklist details live in the project registry UI.", + ), + ) + return entries + + +def prompt_to_dict(entry: PromptEntry) -> dict[str, Any]: + return { + "slug": entry.slug, + "label": entry.label, + "prompt_text": entry.prompt_text, + "workflow_path": entry.workflow_path, + "task_mode": entry.task_mode, + "workflow_hash": entry.workflow_hash, + "source_note": entry.source_note, + } + + +def library_to_dict() -> dict[str, Any]: + entries = load_prompt_library() + return { + "count": len(entries), + "prompts": [prompt_to_dict(entry) for entry in entries], + } \ No newline at end of file diff --git a/webui/prompt_views.py b/webui/prompt_views.py new file mode 100644 index 0000000..1a6b22c --- /dev/null +++ b/webui/prompt_views.py @@ -0,0 +1,80 @@ +"""HTML views for the prompt library (#428).""" + +from __future__ import annotations + +import html + +from webui.prompt_library import PromptEntry, load_prompt_library + + +def _escape(text: str) -> str: + return html.escape(text, quote=True) + + +def render_prompts_page() -> str: + entries = load_prompt_library() + cards: list[str] = [] + for entry in entries: + cards.append(_render_prompt_card(entry)) + body = ( + "

Prompt library

" + "

Short copy/paste task prompts derived from canonical workflows. " + "Full policy remains in the cited workflow files — not duplicated here.

" + f"{''.join(cards)}" + f"{PROMPT_PAGE_SCRIPT}" + ) + from webui.layout import render_page + + return render_page(title="Prompts", body_html=body) + + +def _render_prompt_card(entry: PromptEntry) -> str: + hash_short = ( + f"{_escape(entry.workflow_hash[:12])}" + if entry.workflow_hash + else "n/a" + ) + task_mode = ( + f"{_escape(entry.task_mode)}" + if entry.task_mode + else "n/a" + ) + note = ( + f'

{_escape(entry.source_note)}

' + if entry.source_note + else "" + ) + prompt_id = f"prompt-{entry.slug}" + return ( + f'
' + f"

{_escape(entry.label)}

" + f'

Workflow: {_escape(entry.workflow_path)} · ' + f"task_mode: {task_mode} · sha256: {hash_short}

" + f'
{_escape(entry.prompt_text)}
' + f'" + f"{note}" + "
" + ) + + +PROMPT_PAGE_SCRIPT = """ + +""" \ No newline at end of file