feat: add web UI prompt library from canonical workflows (#428)
Surface short copy/paste operator prompts derived from canonical workflow
files with workflow path and SHA-256 citations. Adds /prompts, /prompts/{id},
and /api/prompts with one-click copy.
Closes #428
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
"""Tests for web UI prompt library (#428)."""
|
"""Tests for web UI prompt library (#428)."""
|
||||||
|
import json
|
||||||
import sys
|
import sys
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -8,73 +9,81 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|||||||
from starlette.testclient import TestClient
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
from webui.app import create_app
|
from webui.app import create_app
|
||||||
from webui.prompt_library import load_prompt_library, prompt_to_dict
|
from webui.prompt_library import find_prompt, library_to_dict, load_prompt_library, prompt_to_dict
|
||||||
|
|
||||||
|
REQUIRED_PROMPT_SLUGS = frozenset({
|
||||||
|
"review-pr",
|
||||||
|
"work-issue",
|
||||||
|
"create-issue",
|
||||||
|
"comment-issue",
|
||||||
|
"cleanup",
|
||||||
|
"audit",
|
||||||
|
"onboarding",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
class TestWebuiPromptLibrary(unittest.TestCase):
|
class TestPromptLibraryLoader(unittest.TestCase):
|
||||||
|
def test_library_loads_required_prompts(self):
|
||||||
|
entries = load_prompt_library()
|
||||||
|
slugs = {entry.slug for entry in entries}
|
||||||
|
self.assertEqual(slugs, REQUIRED_PROMPT_SLUGS)
|
||||||
|
|
||||||
|
def test_workflow_hashes_present(self):
|
||||||
|
review = find_prompt("review-pr")
|
||||||
|
self.assertIsNotNone(review)
|
||||||
|
assert review is not None
|
||||||
|
self.assertTrue(review.workflow_hash)
|
||||||
|
self.assertEqual(len(review.workflow_hash), 64)
|
||||||
|
|
||||||
|
def test_prompt_text_is_short(self):
|
||||||
|
for entry in load_prompt_library():
|
||||||
|
self.assertLess(len(entry.prompt_text), 400)
|
||||||
|
self.assertNotIn("Do not improvise around the gates", entry.prompt_text)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPromptLibraryRoutes(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.client = TestClient(create_app())
|
self.client = TestClient(create_app())
|
||||||
|
|
||||||
def test_library_has_required_prompts(self):
|
def test_prompts_page_lists_all_entries(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")
|
response = self.client.get("/prompts")
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
text = response.text
|
for label in (
|
||||||
self.assertIn("Prompt library", text)
|
"Review PR",
|
||||||
self.assertIn("review-merge-pr.md", text)
|
"Work issue",
|
||||||
self.assertIn("Copy prompt", text)
|
"Create issue",
|
||||||
self.assertIn('data-copy-target="prompt-review-pr"', text)
|
"Comment on issue",
|
||||||
self.assertNotIn("child issue", text.lower())
|
"Post-merge cleanup",
|
||||||
|
"Reconciliation audit",
|
||||||
|
"Project onboarding",
|
||||||
|
):
|
||||||
|
self.assertIn(label, response.text)
|
||||||
|
self.assertIn("Copy prompt", response.text)
|
||||||
|
self.assertIn("sha256:", response.text)
|
||||||
|
self.assertNotIn("child issue", response.text.lower())
|
||||||
|
|
||||||
|
def test_prompt_detail_route(self):
|
||||||
|
response = self.client.get("/prompts/work-issue")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn("work-issue.md", response.text)
|
||||||
|
self.assertIn("Copy prompt", response.text)
|
||||||
|
|
||||||
|
def test_prompt_detail_404(self):
|
||||||
|
self.assertEqual(self.client.get("/prompts/missing").status_code, 404)
|
||||||
|
|
||||||
def test_api_prompts_json(self):
|
def test_api_prompts_json(self):
|
||||||
response = self.client.get("/api/prompts")
|
response = self.client.get("/api/prompts")
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
data = response.json()
|
data = response.json()
|
||||||
self.assertEqual(data["count"], 7)
|
self.assertEqual(data["count"], 7)
|
||||||
self.assertEqual(len(data["prompts"]), 7)
|
review = next(item for item in data["prompts"] if item["slug"] == "review-pr")
|
||||||
first = data["prompts"][0]
|
self.assertIn("workflow_hash", review)
|
||||||
self.assertIn("slug", first)
|
self.assertIn("review-merge-pr.md", review["workflow_path"])
|
||||||
self.assertIn("prompt_text", first)
|
|
||||||
self.assertIn("workflow_path", first)
|
|
||||||
self.assertIn("workflow_hash", first)
|
|
||||||
|
|
||||||
def test_prompt_to_dict_round_trip(self):
|
def test_prompt_to_dict_roundtrip(self):
|
||||||
entry = load_prompt_library()[0]
|
entry = load_prompt_library()[0]
|
||||||
payload = prompt_to_dict(entry)
|
encoded = json.dumps(prompt_to_dict(entry))
|
||||||
self.assertEqual(payload["slug"], entry.slug)
|
self.assertIn("workflow_path", encoded)
|
||||||
self.assertEqual(payload["prompt_text"], entry.prompt_text)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+21
-2
@@ -12,8 +12,8 @@ from starlette.routing import Route
|
|||||||
from webui.layout import render_page
|
from webui.layout import render_page
|
||||||
from webui.project_registry import find_project, load_registry, registry_to_dict
|
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.project_views import render_project_detail, render_projects_list
|
||||||
from webui.prompt_library import library_to_dict
|
from webui.prompt_library import find_prompt, library_to_dict
|
||||||
from webui.prompt_views import render_prompts_page
|
from webui.prompt_views import render_prompt_detail, render_prompts_page
|
||||||
|
|
||||||
_READ_ONLY_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
|
_READ_ONLY_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
|
||||||
|
|
||||||
@@ -85,6 +85,24 @@ async def prompts(_request: Request) -> HTMLResponse:
|
|||||||
return HTMLResponse(render_prompts_page())
|
return HTMLResponse(render_prompts_page())
|
||||||
|
|
||||||
|
|
||||||
|
async def prompt_detail(request: Request) -> HTMLResponse:
|
||||||
|
prompt_id = request.path_params["prompt_id"]
|
||||||
|
prompt = find_prompt(prompt_id)
|
||||||
|
if prompt is None:
|
||||||
|
return HTMLResponse(
|
||||||
|
render_page(
|
||||||
|
title="Prompt not found",
|
||||||
|
body_html=(
|
||||||
|
"<h2>Prompt not found</h2>"
|
||||||
|
f"<p>No library entry for <code>{prompt_id}</code>.</p>"
|
||||||
|
'<p><a href="/prompts">← All prompts</a></p>'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
status_code=404,
|
||||||
|
)
|
||||||
|
return HTMLResponse(render_prompt_detail(prompt))
|
||||||
|
|
||||||
|
|
||||||
async def api_prompts(_request: Request) -> JSONResponse:
|
async def api_prompts(_request: Request) -> JSONResponse:
|
||||||
return JSONResponse(library_to_dict())
|
return JSONResponse(library_to_dict())
|
||||||
|
|
||||||
@@ -137,6 +155,7 @@ def create_app() -> Starlette:
|
|||||||
Route("/projects/{project_id}", project_detail, methods=["GET"]),
|
Route("/projects/{project_id}", project_detail, methods=["GET"]),
|
||||||
Route("/api/projects", api_projects, methods=["GET"]),
|
Route("/api/projects", api_projects, methods=["GET"]),
|
||||||
Route("/prompts", prompts, methods=["GET"]),
|
Route("/prompts", prompts, methods=["GET"]),
|
||||||
|
Route("/prompts/{prompt_id}", prompt_detail, methods=["GET"]),
|
||||||
Route("/api/prompts", api_prompts, methods=["GET"]),
|
Route("/api/prompts", api_prompts, methods=["GET"]),
|
||||||
Route("/runtime", runtime, methods=["GET"]),
|
Route("/runtime", runtime, methods=["GET"]),
|
||||||
Route("/audit", audit, methods=["GET"]),
|
Route("/audit", audit, methods=["GET"]),
|
||||||
|
|||||||
+20
-15
@@ -128,7 +128,7 @@ def load_prompt_library() -> tuple[PromptEntry, ...]:
|
|||||||
),
|
),
|
||||||
_static_entry(
|
_static_entry(
|
||||||
slug="comment-issue",
|
slug="comment-issue",
|
||||||
label="Comment issue",
|
label="Comment on issue",
|
||||||
workflow_path=str(_WORKFLOW_ROOT / "create-issue.md"),
|
workflow_path=str(_WORKFLOW_ROOT / "create-issue.md"),
|
||||||
prompt_text=(
|
prompt_text=(
|
||||||
"Comment on the target Gitea issue only if exact comment_issue "
|
"Comment on the target Gitea issue only if exact comment_issue "
|
||||||
@@ -140,16 +140,14 @@ def load_prompt_library() -> tuple[PromptEntry, ...]:
|
|||||||
),
|
),
|
||||||
_static_entry(
|
_static_entry(
|
||||||
slug="cleanup",
|
slug="cleanup",
|
||||||
label="PR queue cleanup",
|
label="Post-merge cleanup",
|
||||||
workflow_path=str(_WORKFLOW_ROOT / "pr-queue-cleanup.md"),
|
workflow_path="skills/llm-project-workflow/templates/worktree-cleanup.md",
|
||||||
prompt_text=(
|
prompt_text=(
|
||||||
"Run PR-only queue cleanup: build a complete open-PR inventory "
|
"Task: clean up branch/worktree for PR #<pr> / issue #<n> after merge. "
|
||||||
"with pagination proof, select exactly one eligible PR, dispatch "
|
"Confirm the merge on remote master before any deletion; never "
|
||||||
"one canonical review for that PR, then stop after any terminal "
|
"force-remove a dirty worktree."
|
||||||
"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.",
|
source_note="Full cleanup steps live in templates/worktree-cleanup.md.",
|
||||||
),
|
),
|
||||||
_entry_from_workflow(
|
_entry_from_workflow(
|
||||||
slug="audit",
|
slug="audit",
|
||||||
@@ -159,19 +157,26 @@ def load_prompt_library() -> tuple[PromptEntry, ...]:
|
|||||||
_static_entry(
|
_static_entry(
|
||||||
slug="onboarding",
|
slug="onboarding",
|
||||||
label="Project onboarding",
|
label="Project onboarding",
|
||||||
workflow_path="docs/webui-local-dev.md",
|
workflow_path="skills/llm-project-workflow/SKILL.md",
|
||||||
prompt_text=(
|
prompt_text=(
|
||||||
"Start a new MCP Control Plane session: call mcp_get_control_plane_guide, "
|
"Onboard this repository into the MCP Control Plane: prove identity "
|
||||||
"prove identity and task capability, then complete the onboarding checklist "
|
"and task capability, configure author/reviewer/reconciler profiles "
|
||||||
"for this project at /projects. Keep Gitea, MCP tools, and canonical "
|
"in separate namespaces, then complete the checklist at /projects. "
|
||||||
"workflows as the source of truth."
|
"Canonical router: skills/llm-project-workflow/SKILL.md."
|
||||||
),
|
),
|
||||||
source_note="Operator onboarding; checklist details live in the project registry UI.",
|
source_note="Checklist details live in webui/data/projects.registry.json and /projects.",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return entries
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def find_prompt(slug: str) -> PromptEntry | None:
|
||||||
|
for entry in load_prompt_library():
|
||||||
|
if entry.slug == slug:
|
||||||
|
return entry
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def prompt_to_dict(entry: PromptEntry) -> dict[str, Any]:
|
def prompt_to_dict(entry: PromptEntry) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"slug": entry.slug,
|
"slug": entry.slug,
|
||||||
|
|||||||
+24
-17
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import html
|
import html
|
||||||
|
|
||||||
|
from webui.layout import render_page
|
||||||
from webui.prompt_library import PromptEntry, load_prompt_library
|
from webui.prompt_library import PromptEntry, load_prompt_library
|
||||||
|
|
||||||
|
|
||||||
@@ -11,23 +12,6 @@ def _escape(text: str) -> str:
|
|||||||
return html.escape(text, quote=True)
|
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 = (
|
|
||||||
"<h2>Prompt library</h2>"
|
|
||||||
"<p>Short copy/paste task prompts derived from canonical workflows. "
|
|
||||||
"Full policy remains in the cited workflow files — not duplicated here.</p>"
|
|
||||||
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:
|
def _render_prompt_card(entry: PromptEntry) -> str:
|
||||||
hash_short = (
|
hash_short = (
|
||||||
f"<code>{_escape(entry.workflow_hash[:12])}</code>"
|
f"<code>{_escape(entry.workflow_hash[:12])}</code>"
|
||||||
@@ -78,3 +62,26 @@ document.querySelectorAll('.copy-btn').forEach((btn) => {
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def render_prompts_page() -> str:
|
||||||
|
entries = load_prompt_library()
|
||||||
|
cards = "".join(_render_prompt_card(entry) for entry in entries)
|
||||||
|
body = (
|
||||||
|
"<h2>Prompt library</h2>"
|
||||||
|
"<p>Short copy/paste task prompts derived from canonical workflows. "
|
||||||
|
"Full policy remains in the cited workflow files — not duplicated here.</p>"
|
||||||
|
f"{cards}"
|
||||||
|
"<p><a href=\"/api/prompts\">JSON API</a></p>"
|
||||||
|
f"{PROMPT_PAGE_SCRIPT}"
|
||||||
|
)
|
||||||
|
return render_page(title="Prompts", body_html=body)
|
||||||
|
|
||||||
|
|
||||||
|
def render_prompt_detail(entry: PromptEntry) -> str:
|
||||||
|
body = (
|
||||||
|
f"<p><a href=\"/prompts\">← All prompts</a></p>"
|
||||||
|
f"{_render_prompt_card(entry)}"
|
||||||
|
f"{PROMPT_PAGE_SCRIPT}"
|
||||||
|
)
|
||||||
|
return render_page(title=entry.label, body_html=body)
|
||||||
Reference in New Issue
Block a user