mirror of
https://github.com/tanweai/pua.git
synced 2026-08-29 02:02:20 +08:00
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:
Executable
+57
@@ -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
|
||||
Executable
+78
@@ -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
|
||||
Vendored
+1
-1
@@ -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 组对照实验验证。" />
|
||||
|
||||
Vendored
+12
@@ -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 |
@@ -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`,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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" } },
|
||||
)
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user