feat: complete all HANDOFF tasks — logo + feedback API + eval helpers

Task 2: Landing page logo
- Replace 💢 emoji favicon with pua-skill-logo.svg
- Logo SVG copied to dist/pua-logo.svg

Task 3b: /api/feedback Cloudflare Function
- POST: save rating, task_summary, pua_level, pua_count, flavor, session_data
- GET: aggregate stats (total feedback, breakdown by rating)
- D1 migration: 0002_create_feedback.sql (feedback table + indexes)

Eval补全:
- evals/test-helpers.sh — reusable assertions (run_pua, assert_skill_triggered,
  assert_contains, assert_not_contains, count_matches)
- evals/test-behavior.sh — 4 behavior verification tests
  (红线 knowledge, 阿里味旁白, [PUA生效] markers, pressure escalation)

Deploy note: /tmp/pua-deploy-final/ is ready for wrangler pages deploy.
D1 migration needs: wrangler d1 execute pua-uploads-db --file=migrations/0002_create_feedback.sql
This commit is contained in:
xsser
2026-03-19 19:10:50 +08:00
parent e16058bb0d
commit fe1977fbcb
11 changed files with 453 additions and 1 deletions
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# PUA v2 Behavior Verification Tests
# Tests whether the skill BEHAVES correctly after loading (not just triggers)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "$SCRIPT_DIR/test-helpers.sh"
PASS=0
FAIL=0
run_test() {
if "$@"; then PASS=$((PASS+1)); else FAIL=$((FAIL+1)); fi
}
echo "=== PUA Behavior Verification Tests ==="
echo ""
# Test 1: 红线 — Claude knows about the 3 red lines
echo "Test 1: 三条红线 knowledge..."
OUT=$(run_pua "What are the three red lines (三条红线) in the PUA skill? List them briefly.")
run_test assert_contains "$OUT" "闭环|验证|完成" "Mentions 红线一 (闭环)"
run_test assert_contains "$OUT" "事实|验证|甩锅" "Mentions 红线二 (事实驱动)"
run_test assert_contains "$OUT" "穷尽|放弃|方法论" "Mentions 红线三 (穷尽一切)"
echo ""
# Test 2: 旁白 — response contains PUA flavor words
echo "Test 2: 阿里味旁白 in response..."
OUT=$(run_pua "帮我写一个hello world函数 PUA模式")
run_test assert_contains "$OUT" "底层逻辑|抓手|闭环|owner|3\.25|独当一面|信任" "Response contains 阿里味 keywords"
echo ""
# Test 3: [PUA生效] marker quality
echo "Test 3: [PUA生效] marker present..."
PUA_COUNT=$(count_matches "$OUT" "PUA生效")
if [ "$PUA_COUNT" -gt 0 ]; then
echo " ✅ PASS: Found $PUA_COUNT [PUA生效] markers"
PASS=$((PASS+1))
else
echo " ❌ FAIL: No [PUA生效] markers found"
FAIL=$((FAIL+1))
fi
echo ""
# Test 4: pressure level awareness
echo "Test 4: Pressure escalation knowledge..."
OUT2=$(run_pua "在PUA skill中,失败3次会触发什么压力等级?简短回答。")
run_test assert_contains "$OUT2" "L2|灵魂拷问" "Knows L2 = 灵魂拷问 at 3 failures"
echo ""
echo "==========================================="
echo "Passed: $PASS"
echo "Failed: $FAIL"
echo "Total: $((PASS+FAIL))"
echo "==========================================="
[ "$FAIL" -eq 0 ] || exit 1
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env bash
# PUA v2 Test Helpers — reusable assertion functions
# Source this file in test scripts: source "$(dirname "$0")/test-helpers.sh"
PLUGIN_DIR="${PLUGIN_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
run_pua() {
local prompt="$1"
local max_turns="${2:-2}"
local outfile=$(mktemp)
timeout 90 claude -p "$prompt" \
--plugin-dir "$PLUGIN_DIR" \
--dangerously-skip-permissions \
--max-turns "$max_turns" \
--output-format stream-json \
--verbose 2>/dev/null > "$outfile"
echo "$outfile"
}
assert_skill_triggered() {
local file="$1"
local skill="$2"
local label="${3:-$skill}"
if grep -q "\"$skill\"" "$file" 2>/dev/null; then
echo " ✅ PASS: $label triggered"
return 0
else
echo " ❌ FAIL: $label NOT triggered"
return 1
fi
}
assert_skill_not_triggered() {
local file="$1"
local skill="$2"
local label="${3:-$skill}"
if grep -q "\"$skill\"" "$file" 2>/dev/null; then
echo " ❌ FAIL: $label triggered (should not)"
return 1
else
echo " ✅ PASS: $label not triggered"
return 0
fi
}
assert_contains() {
local file="$1"
local pattern="$2"
local label="${3:-pattern check}"
if grep -qE "$pattern" "$file" 2>/dev/null; then
echo " ✅ PASS: $label"
return 0
else
echo " ❌ FAIL: $label (pattern: $pattern)"
return 1
fi
}
assert_not_contains() {
local file="$1"
local pattern="$2"
local label="${3:-pattern check}"
if grep -qE "$pattern" "$file" 2>/dev/null; then
echo " ❌ FAIL: $label (found: $pattern)"
return 1
else
echo " ✅ PASS: $label"
return 0
fi
}
count_matches() {
local file="$1"
local pattern="$2"
grep -oE "$pattern" "$file" 2>/dev/null | wc -l | tr -d ' '
}
export -f run_pua assert_skill_triggered assert_skill_not_triggered assert_contains assert_not_contains count_matches
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="zh">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>💢</text></svg>" />
<link rel="icon" href="/pua-logo.svg" type="image/svg+xml" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>pua — Claude Code Skill</title>
<meta name="description" content="用大厂 PUA 话术驱动 Claude Code 穷尽所有方案才允许放弃。基于 9 个真实场景 × 18 组对照实验验证。" />
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" width="200" height="200">
<!-- PUA Skill Logo - V1 Leaping Person - Icon Only -->
<circle cx="92" cy="52" r="18" fill="#E8453C"/>
<path d="M92 70 Q92 88, 82 102 Q72 116, 56 126" fill="none" stroke="#E8453C" stroke-width="7" stroke-linecap="round"/>
<path d="M84 86 Q100 74, 120 66 Q132 62, 146 58" fill="none" stroke="#E8453C" stroke-width="6.5" stroke-linecap="round"/>
<path d="M78 102 Q66 122, 48 134 Q40 140, 32 144" fill="none" stroke="#E8453C" stroke-width="6" stroke-linecap="round"/>
<path d="M80 108 Q96 120, 116 138 Q126 148, 134 160" fill="none" stroke="#E8453C" stroke-width="6" stroke-linecap="round"/>
<path d="M146 58 L156 48" stroke="#E8453C" stroke-width="3.5" stroke-linecap="round" opacity="0.55"/>
<path d="M150 52 L162 42" stroke="#E8453C" stroke-width="2.5" stroke-linecap="round" opacity="0.3"/>
<path d="M154 46 L168 38" stroke="#E8453C" stroke-width="1.5" stroke-linecap="round" opacity="0.14"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+64
View File
@@ -0,0 +1,64 @@
interface Env {
GITHUB_CLIENT_ID: string
GITHUB_CLIENT_SECRET: string
}
export const onRequestGet: PagesFunction<Env> = async ({ env, request }) => {
const url = new URL(request.url)
const code = url.searchParams.get("code")
if (!code) {
return new Response("Missing code", { status: 400 })
}
// Exchange code for access token
const tokenRes = await fetch("https://github.com/login/oauth/access_token", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
client_id: env.GITHUB_CLIENT_ID,
client_secret: env.GITHUB_CLIENT_SECRET,
code,
}),
})
const tokenData = (await tokenRes.json()) as { access_token?: string; error?: string }
if (!tokenData.access_token) {
return new Response("OAuth failed: " + (tokenData.error || "unknown"), { status: 400 })
}
// Get user info
const userRes = await fetch("https://api.github.com/user", {
headers: {
Authorization: `Bearer ${tokenData.access_token}`,
"User-Agent": "pua-skill-landing",
},
})
const user = (await userRes.json()) as { id: number; login: string; avatar_url: string }
// Store session in cookie (base64 encoded JSON)
const session = btoa(JSON.stringify({
id: String(user.id),
login: user.login,
avatar: user.avatar_url,
token: tokenData.access_token,
}))
// Use HTML+JS redirect instead of HTTP 302 to preserve hash fragment.
// Cloudflare CDN may strip #fragment from Location headers in 302 responses,
// causing users to land on "/" instead of "/#/contribute" after OAuth.
const redirectPage = `<!DOCTYPE html><html><head>
<meta charset="utf-8"><title>Redirecting...</title>
<script>window.location.replace("https://openpua.ai/#/contribute");</script>
</head><body><p>Redirecting... <a href="https://openpua.ai/#/contribute">Click here</a></p></body></html>`
return new Response(redirectPage, {
status: 200,
headers: {
"Content-Type": "text/html; charset=utf-8",
"Set-Cookie": `pua_session=${session}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=604800`,
},
})
}
+17
View File
@@ -0,0 +1,17 @@
interface Env {
GITHUB_CLIENT_ID: string
}
export const onRequestGet: PagesFunction<Env> = async ({ env, request }) => {
const origin = "https://openpua.ai"
const redirectUri = `${origin}/api/auth/callback`
const githubUrl = new URL("https://github.com/login/oauth/authorize")
githubUrl.searchParams.set("client_id", env.GITHUB_CLIENT_ID)
githubUrl.searchParams.set("redirect_uri", redirectUri)
githubUrl.searchParams.set("scope", "read:user")
const target = githubUrl.toString()
return new Response(
`<!DOCTYPE html><html><head><meta http-equiv="refresh" content="0;url=${target}"><title>Redirecting...</title></head><body><a href="${target}">Click here if not redirected</a></body></html>`,
{ status: 200, headers: { "Content-Type": "text/html; charset=utf-8" } },
)
}
+9
View File
@@ -0,0 +1,9 @@
export const onRequestPost: PagesFunction = async () => {
return new Response(null, {
status: 302,
headers: {
Location: "/#/contribute",
"Set-Cookie": "pua_session=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0",
},
})
}
+61
View File
@@ -0,0 +1,61 @@
interface Env {
DB: D1Database
}
export const onRequestPost: PagesFunction<Env> = async ({ request, env }) => {
try {
const body = (await request.json()) as {
rating?: string
task_summary?: string
pua_level?: string
pua_count?: number
flavor?: string
session_data?: string
failure_count?: number
}
if (!body.rating) {
return Response.json({ error: "rating is required" }, { status: 400 })
}
await env.DB.prepare(
`INSERT INTO feedback (rating, task_summary, pua_level, pua_count, flavor, session_data, failure_count, ip_country)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
)
.bind(
body.rating,
body.task_summary || null,
body.pua_level || "L0",
body.pua_count || 0,
body.flavor || "阿里",
body.session_data || null,
body.failure_count || 0,
request.headers.get("CF-IPCountry") || "unknown"
)
.run()
return Response.json({ ok: true })
} catch (e) {
return Response.json(
{ error: "Failed to save feedback", detail: String(e) },
{ status: 500 }
)
}
}
// GET: aggregate stats (public, no auth required)
export const onRequestGet: PagesFunction<Env> = async ({ env }) => {
const stats = await env.DB.prepare(
`SELECT rating, COUNT(*) as count, AVG(pua_count) as avg_pua_count
FROM feedback GROUP BY rating ORDER BY count DESC`
).all()
const total = await env.DB.prepare(
"SELECT COUNT(*) as total FROM feedback"
).first<{ total: number }>()
return Response.json({
total_feedback: total?.total || 0,
by_rating: stats.results,
})
}
+39
View File
@@ -0,0 +1,39 @@
interface Env {
DB: D1Database
}
function getSession(request: Request) {
const cookie = request.headers.get("Cookie") || ""
const match = cookie.match(/pua_session=([^;]+)/)
if (!match) return null
try {
return JSON.parse(atob(match[1])) as {
id: string
login: string
avatar: string
token: string
}
} catch {
return null
}
}
export const onRequestGet: PagesFunction<Env> = async ({ request, env }) => {
const session = getSession(request)
if (!session) {
return Response.json({ logged_in: false }, { status: 401 })
}
// Get upload count for this user
const result = await env.DB.prepare(
"SELECT COUNT(*) as count FROM uploads WHERE github_id = ?"
).bind(session.id).first<{ count: number }>()
return Response.json({
logged_in: true,
id: session.id,
login: session.login,
avatar: session.avatar,
upload_count: result?.count || 0,
})
}
+99
View File
@@ -0,0 +1,99 @@
interface Env {
DB: D1Database
UPLOADS: R2Bucket
}
function getSession(request: Request) {
const cookie = request.headers.get("Cookie") || ""
const match = cookie.match(/pua_session=([^;]+)/)
if (!match) return null
try {
return JSON.parse(atob(match[1])) as {
id: string
login: string
avatar: string
token: string
}
} catch {
return null
}
}
export const onRequestPost: PagesFunction<Env> = async ({ request, env }) => {
const session = getSession(request)
if (!session) {
return Response.json({ error: "Unauthorized" }, { status: 401 })
}
const formData = await request.formData()
const file = formData.get("file") as File | null
const wechatId = formData.get("wechat_id") as string | null
if (!file) {
return Response.json({ error: "No file provided" }, { status: 400 })
}
if (!wechatId?.trim()) {
return Response.json({ error: "WeChat ID is required" }, { status: 400 })
}
if (!file.name.endsWith(".jsonl")) {
return Response.json({ error: "Only .jsonl files are accepted" }, { status: 400 })
}
if (file.size > 50 * 1024 * 1024) {
return Response.json({ error: "File too large (max 50MB)" }, { status: 400 })
}
// Upload to R2
const key = `${session.login}/${Date.now()}-${file.name}`
await env.UPLOADS.put(key, file.stream(), {
httpMetadata: { contentType: "application/jsonl" },
customMetadata: {
github_id: session.id,
github_login: session.login,
wechat_id: wechatId.trim(),
},
})
// Record in D1
await env.DB.prepare(
"INSERT INTO uploads (github_id, github_login, wechat_id, file_key, file_name, file_size) VALUES (?, ?, ?, ?, ?, ?)"
).bind(session.id, session.login, wechatId.trim(), key, file.name, file.size).run()
// Send email notification (fire-and-forget)
const sizeMB = (file.size / 1024 / 1024).toFixed(2)
const emailBody = [
`New PUA Skill data upload:`,
``,
`GitHub: ${session.login} (${session.id})`,
`WeChat: ${wechatId.trim()}`,
`File: ${file.name} (${sizeMB} MB)`,
`R2 Key: ${key}`,
`Time: ${new Date().toISOString()}`,
].join("\n")
fetch("https://api.mailchannels.net/tx/v1/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
personalizations: [{ to: [{ email: "xsser.w@gmail.com", name: "PUA Admin" }] }],
from: { email: "noreply@pua-skill.pages.dev", name: "PUA Skill Upload" },
subject: `[PUA Upload] ${session.login} uploaded ${file.name}`,
content: [{ type: "text/plain", value: emailBody }],
}),
}).catch(() => {})
return Response.json({ ok: true, key, file_name: file.name, file_size: file.size })
}
// GET: list user's uploads
export const onRequestGet: PagesFunction<Env> = async ({ request, env }) => {
const session = getSession(request)
if (!session) {
return Response.json({ error: "Unauthorized" }, { status: 401 })
}
const { results } = await env.DB.prepare(
"SELECT file_name, file_size, created_at FROM uploads WHERE github_id = ? ORDER BY created_at DESC LIMIT 50"
).bind(session.id).all()
return Response.json({ uploads: results })
}
@@ -0,0 +1,16 @@
-- Feedback table for PUA skill usage analytics
CREATE TABLE IF NOT EXISTS feedback (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rating TEXT NOT NULL, -- '很有用' | '一般般' | '没感觉' | custom
task_summary TEXT, -- brief task description (anonymized)
pua_level TEXT DEFAULT 'L0', -- L0-L4
pua_count INTEGER DEFAULT 0, -- number of [PUA生效] markers
flavor TEXT DEFAULT '阿里', -- active flavor
session_data TEXT, -- anonymized session (tool calls only)
failure_count INTEGER DEFAULT 0,
ip_country TEXT, -- CF-IPCountry header
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_feedback_rating ON feedback(rating);
CREATE INDEX IF NOT EXISTS idx_feedback_created ON feedback(created_at);