(thread_id, turn_index) (the same key the workspace snapshots, artifacts and the trajectory-graph nodes use), so feedback lines up 1:1 with graph nodes and is never lost or mis-attributed when a conversation branches.
The exact instrument (every question, its options, and when each appears) lives in the frontend taxonomy feedback-taxonomy.ts, the single source of truth; this page is the overview.
Entry points
- Each AI reply has three hover-revealed icons (next to copy/retry): a trajectory icon (the whole co-construction), a single like/dislike glyph (this reply), and a report icon. Each opens the modal at its section. The glyph lights up (its like half green, dislike half red, the same regardless of which option you picked) once any feedback exists for that turn, and the row then stays visible so it reads as submitted. So rating, say, the objective also lights the reply’s glyph.
- The Objective / Plan / Artifact / Preferences panels each carry the same like/dislike glyph. When a component changed this turn and hasn’t been rated, its icon shows a pulsing yellow “ping” with a “just updated” cloud to its right, persisting until you open it, rate it, or the next turn arrives; the modal also flags it “Just updated”.
- The check-in after the latest reply routes into the modal; it records nothing itself, never blocks follow-ups, and appears only once the reply has finished streaming.
The three sections (conditional)
Questions appear only when they apply: a component’s questions show when it exists, and a delta question (and its “Just updated” badge) shows only when that component actually changed this turn (computed by diffing the live workspace against the pre-turn snapshot).On this specific reply
- An overall judgement (Worked well / Mixed / Didn’t work), then reasons (which depend on the judgement, e.g. “Respected my constraints”) and a free-text note.
- The four components as they stand at this step (objective, plan, artifact, preferences), each with an endorsement question, change-gated delta/attribution questions, and (for the objective, on rejection) a free-text capture of the emerging goal in the user’s own words.
On the whole trajectory
Task completion (4 states), objective endorsement given the whole history, goal evolution and goal ownership (after a few turns), an overall assessment with a reason and note, and a branch-reflection question when the conversation has more than one path.Report a technical issue
A short list of genuinely technical categories (broken response, network bug, tool failure, rendering, unsafe content, other) plus a description. Content judgements like “off-topic” or “incorrect reasoning” live in the reply section, not here.The data model
One unifiedfeedback table, discriminated by scope + aspect:
scope:message|objective|plan|artifact|preferences|conversationaspect:reaction/report(message); the component questions (target_endorsement,target_change,plan_endorsement,plan_delta,artifact_satisfaction,artifact_fit,artifact_delta,preferences_extracted,preferences_followed,preferences_delta); or the trajectory aspects (task_completion,objective_endorsement_history,goal_evolution,goal_ownership,trajectory_assessment,branch_value)sentiment: the 3-point valencepositive|neutral|negative; null for reportscategory: a report category, a reason tag, or a multi-state choice value (the choice maps to a sentiment so it still aggregates)artifact_id: which artifact anartifact-scope row rates (0= no specific artifact: every non-artifact row, and a rating of the live document)origin:userfor a real opinion,branch_copyfor feedback carried onto a fork
(thread_id, turn_index, user_id, scope, aspect, artifact_id) makes every write an idempotent upsert: a user holds at most one rating per aspect per turn, while rating many different aspects of the same turn in parallel. Because artifact_id is part of the key, several artifacts produced in the same turn can each be rated independently: the modal renders one artifact card per artifact (falling back to a single card for the live document when a turn produced none). The modal’s controls each own one field of one row and merge their update, so picking a reason never clobbers the sentiment.
One source of truth for the vocabulary
The backend validates only the structural shape of a row: a known scope, a well-formed aspect slug, and the report/sentiment split. It deliberately does not check the dimension aspects, report categories or reason tags against a list. That presentation vocabulary lives infrontend/src/lib/feedback-taxonomy.ts, so the UI can add or rename a reason without any backend change or migration. The backend keeps a copy of the report category values and uses it only to bound what a GitHub issue filed from a report can name. A category missing from that copy is still stored, and its issue says other.
Branching & persistence
Feedback is always read and written by the viewed thread’s(thread_id, turn_index). When a conversation is forked, the parent’s prefix feedback is copied onto the new thread (origin='branch_copy') alongside the workspace and artifacts. Admin statistics count only origin='user', so those copies never inflate the figures; editing a copied row on the fork makes it a genuine, independent opinion on that path.
Admin dashboard
/admin/feedback (admin only) shows headline counts (likes, dislikes, reports, tasks marked complete), the sentiment split per dimension, a 30-day trend, and the report-category breakdown. Beyond those it adds four analytics:
- Completion funnel — engaged (left any feedback) → self-assessed completion → marked complete, plus the distribution of the latest completion state per thread.
- Per-dimension evolution over time — small-multiples showing each co-construction dimension’s daily positive/neutral/negative responses across the trailing window.
- Negative signals — a triage panel of valenced-negative rows (dislikes, low ratings) across every scope, so dissatisfaction surfaces without hunting. Technical reports keep their own table.
- Filters & search on the recent-feedback feed — narrow by scope, question (aspect), and response (sentiment), and search comment text; results paginate.
[report] <category>. The reporter, the report’s comment and the conversation are never included, and an admin reads them in the dashboard and the trace instead. A category outside the fixed report categories is filed as other, and a report on a conversation whose ID does not have the server-generated form (th_ plus 32 hex characters) is refused, so no free text reaches the issue. On failure the button shows the reason (e.g. an expired token) and stays retryable. The filed issue link is stored on the report so it isn’t filed twice. Trace links need a Phoenix web base URL and a Phoenix REST API key (PHOENIX_API_KEY, since Phoenix runs with auth on). Issue creation needs a GITHUB_TOKEN (set it to Coco’s PAT so issues are filed as Coco), while the non-secret repo and default labels live in config.*.yaml under github:.
API
User-facing (/api/feedback, ownership-checked): PUT upserts one row, DELETE toggles one off, GET /{thread_id} returns all of the caller’s feedback for a thread. Admin (/api/admin/feedback, admin only): …/stats (headline counts, dimension rates, completion funnel/breakdown, per-dimension trend, daily trend), …/reports, the recent feed (with scope / aspect / sentiment / category / q / negative filters), and …/node (all of a user’s feedback for one turn, for the read-only viewer).