Files
Austin Griffith f42b7402a2 feedback: lower friction, capture praise, draft-and-ask flow (#155)
* 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>
2026-04-29 21:51:28 -06:00

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' });
}