mirror of
https://github.com/austintgriffith/ethskills.git
synced 2026-09-14 20:07:04 +08:00
f42b7402a2
* feedback: lower friction, capture praise, draft-and-ask flow Changes the feedback system from "fire-and-forget after permission" to "draft-then-ask," and broadens it from issue-only to issue + praise. feedback/SKILL.md - Lower the threshold: "anything that confused you, slowed you down, or genuinely helped" instead of "factually wrong + significant time wasted." Bias toward sending; weak signals are easy to ignore in the inbox, but missing signals can't be recovered. - Add `kind: "issue" | "praise"` so the corpus captures what's working, not just what broke. - Replace the two-step "ask permission, then silently POST" flow with a single draft-and-ask: the agent shows the rendered JSON inside the permission ask, the human approves or edits inline, the agent POSTs once. Same number of human turns; full review for free. - Document the pre-authorization escape hatch: humans who put "ethskills feedback is pre-approved" in their CLAUDE.md let agents skip the per-event ask. - Make `agent` required so model-pivoted analysis is possible. Keep `skill` optional but strongly encouraged. api/feedback.js - Accept `kind` field, validate as "praise" or fall back to "issue" (legacy entries without a kind are treated as issues). - Require non-empty `agent`; return 400 if missing. - Store kind in the persisted entry. inbox.html - Add Issues / Praise filter tabs alongside Unread / All. - Color-code praise (green accent + left border) so the two streams are visually distinct at a glance. - Show kind badge on each entry. Legacy entries without a kind render as issues. SKILL.md / CLAUDE.md (root index) - Refresh the Feedback skill description to match the new flow: bias-toward-sending framing, required fields, pre-auth note. index.html - Update the homepage Feedback section copy: drop the "if your human is unsatisfied" bar, invite both issues and praise. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feedback: keep agent param permissive at the API The previous commit made `agent` required at the API and would 400 on missing values. That's the wrong call: - The validation is theater. Any client can pass it by sending "agent": "unknown" — we can't verify the value, so the 400 only punishes well-meaning clients that happen to omit the field. - It contradicts the rest of this PR's thesis ("missing signals can't be recovered"). An agent reading a stale SKILL.md, or one that just forgot the field, would get rejected and give up. - The SKILL.md is the lever that shapes agent behavior, not the API. Mark the field required in the docs, keep the API permissive as a safety net. Drops the 400 and lets missing agent fall through as null, matching how legacy entries already render. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feedback: review cleanup — PII first, robust kind, schema clarity - feedback/SKILL.md: move sensitive-data section to step 1 so pre-authorized agents see it before any "skip the ask" guidance. - feedback/SKILL.md: replace the JSON-shaped (but unparseable) endpoint block with a clear required/optional field list. Agents that copy code fences sometimes try to parse them. - api/feedback.js: case- and whitespace-tolerant kind normalization ("Praise", " praise " no longer silently coerce to issue). - inbox.html: collapse the dead ternary that produced the same string in both branches. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feedback: rename problem→message, strict kind, sentinel pre-auth - Rename `problem` → `message` so the field reads correctly for both issues and praise. API accepts legacy `problem` for back-compat; inbox falls back to `e.problem` for entries already stored. - Reject unknown `kind` values with 400 instead of silently coercing to "issue". Empty/missing still defaults to "issue" (legacy compat). Stops `kind: "praize"` from polluting the issue bucket. - Move `agent` from required to strongly-encouraged in the skill so the doc matches the API's permissive validation. - Replace the natural-language pre-auth hint with a literal sentinel (`ethskills-feedback-preauthorized`) so the agent's check is a string match, not a judgment call. Add a line noting that pre-auth removes the human PII review — the agent is the only safeguard. - Clarify that the YAML-ish ask is a rendering; the wire format is JSON below. --------- Co-authored-by: Agent <agent@example.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: austintgriffith <austin.griffith@ethereum.org>
126 lines
4.2 KiB
JavaScript
126 lines
4.2 KiB
JavaScript
const RATE_LIMIT_WINDOW = 5 * 60 * 1000;
|
|
const recentIPs = new Map();
|
|
|
|
function rateLimit(ip) {
|
|
const now = Date.now();
|
|
const last = recentIPs.get(ip);
|
|
if (last && now - last < RATE_LIMIT_WINDOW) return false;
|
|
recentIPs.set(ip, now);
|
|
if (recentIPs.size > 1000) {
|
|
for (const [k, v] of recentIPs) {
|
|
if (now - v > RATE_LIMIT_WINDOW) recentIPs.delete(k);
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async function kvGet(path) {
|
|
const url = process.env.KV_REST_API_URL;
|
|
const token = process.env.KV_REST_API_TOKEN;
|
|
const res = await fetch(`${url}/${path}`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
return res.json();
|
|
}
|
|
|
|
async function kvPost(path, body) {
|
|
const url = process.env.KV_REST_API_URL;
|
|
const token = process.env.KV_REST_API_TOKEN;
|
|
const res = await fetch(`${url}/${path}`, {
|
|
method: 'POST',
|
|
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
return res.json();
|
|
}
|
|
|
|
export default async function handler(req, res) {
|
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
|
|
if (req.method === 'OPTIONS') return res.status(200).end();
|
|
|
|
if (!process.env.KV_REST_API_URL || !process.env.KV_REST_API_TOKEN) {
|
|
return res.status(500).json({ error: 'storage not configured' });
|
|
}
|
|
|
|
// POST: submit feedback (public) or mark as read (secret required)
|
|
if (req.method === 'POST') {
|
|
const { action } = req.query;
|
|
|
|
// Mark as read
|
|
if (action === 'read') {
|
|
const { secret, id } = req.query;
|
|
if (!process.env.FEEDBACK_SECRET || secret !== process.env.FEEDBACK_SECRET) {
|
|
return res.status(401).json({ error: 'unauthorized' });
|
|
}
|
|
if (!id) return res.status(400).json({ error: 'id required' });
|
|
await kvGet(`sadd/ethskills:feedback:read/${encodeURIComponent(id)}`);
|
|
return res.status(200).json({ ok: true });
|
|
}
|
|
|
|
// Submit feedback
|
|
const ip = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || 'unknown';
|
|
if (!rateLimit(ip)) {
|
|
return res.status(429).json({ error: 'Too many requests. Please wait 5 minutes.' });
|
|
}
|
|
|
|
const { kind, message, problem, skill, context, agent } = req.body || {};
|
|
// Accept `message` (current) or `problem` (legacy field name).
|
|
const body = typeof message === 'string' ? message
|
|
: typeof problem === 'string' ? problem
|
|
: null;
|
|
if (!body || body.trim().length < 10) {
|
|
return res.status(400).json({ error: 'message is required (min 10 chars)' });
|
|
}
|
|
|
|
const rawKind = String(kind ?? '').trim().toLowerCase();
|
|
let normalizedKind;
|
|
if (!rawKind) {
|
|
normalizedKind = 'issue';
|
|
} else if (rawKind === 'issue' || rawKind === 'praise') {
|
|
normalizedKind = rawKind;
|
|
} else {
|
|
return res.status(400).json({ error: 'kind must be "issue" or "praise"' });
|
|
}
|
|
|
|
const entry = JSON.stringify({
|
|
id: Date.now().toString(),
|
|
ts: new Date().toISOString(),
|
|
kind: normalizedKind,
|
|
message: body.trim().slice(0, 2000),
|
|
skill: skill ? String(skill).trim().slice(0, 100) : null,
|
|
context: context ? String(context).trim().slice(0, 2000) : null,
|
|
agent: agent ? String(agent).trim().slice(0, 100) : null,
|
|
});
|
|
|
|
const parsed = JSON.parse(entry);
|
|
await kvGet(`lpush/ethskills:feedback/${encodeURIComponent(entry)}`);
|
|
return res.status(200).json({ ok: true, id: parsed.id });
|
|
}
|
|
|
|
// GET: read feedback (secret required)
|
|
if (req.method === 'GET') {
|
|
const { secret } = req.query;
|
|
if (!process.env.FEEDBACK_SECRET || secret !== process.env.FEEDBACK_SECRET) {
|
|
return res.status(401).json({ error: 'unauthorized' });
|
|
}
|
|
|
|
const [listResult, readResult] = await Promise.all([
|
|
kvGet('lrange/ethskills:feedback/0/199'),
|
|
kvGet('smembers/ethskills:feedback:read'),
|
|
]);
|
|
|
|
const readIds = new Set(readResult.result || []);
|
|
const entries = (listResult.result || []).map(e => {
|
|
const parsed = typeof e === 'string' ? JSON.parse(e) : e;
|
|
return { ...parsed, read: readIds.has(parsed.id) };
|
|
});
|
|
|
|
return res.status(200).json({ count: entries.length, entries });
|
|
}
|
|
|
|
return res.status(405).json({ error: 'method not allowed' });
|
|
}
|