diff --git a/.env.example b/.env.example index cc1280d..9f37759 100644 --- a/.env.example +++ b/.env.example @@ -55,3 +55,12 @@ CRAWL_MAX_DAYS=30 # Loyalty: "spent cents:percent off" pairs. The more an agent has paid, the less a day costs. CRAWL_LOYALTY=1000:20,5000:40,10000:60 CRAWL_FLOOR_CENTS=10 + +# The agent question loop. CHOVY_SIGNING_SECRET signs both directions +# (X-Chovy-Signature: t=,v1=). Unset means the +# internal routes answer 503 rather than accepting unsigned work, because a +# question creates a scored contribution and moves somebody's revenue share. +CHOVY_SIGNING_SECRET= +# Optional: where an answered question is delivered back. Without it the loop +# still records and scores; the agent has to come and read instead. +CHOVY_WEBHOOK_URL= diff --git a/README.md b/README.md index e699248..164237d 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,13 @@ public page (plus `skill.md` and `manifest.json` for agents), `/@` is an operator's profile, `/dashboard/niches` is their own view, and `/admin/knowledge` is where claims and contributions are verified. +When an agent gets stuck on something only a person who has done the job can +settle, it asks: the question lands on the operator's dashboard, the answer +becomes niche knowledge and a scored contribution, and Chovy is told. Saying +"not enough context" is a first-class answer that costs nothing, because a +guess that gets verified is worse for the niche than an open question. See +[docs/agent-questions.md](docs/agent-questions.md). + Score comes only from contributions somebody verified, and volume does not buy it: repeated submissions of a type pay less each time, anything claiming a customer or a payment needs an outside reference, and a duplicate books once. diff --git a/apps/web/public/styles.css b/apps/web/public/styles.css index 172d71f..a4f0ae3 100644 --- a/apps/web/public/styles.css +++ b/apps/web/public/styles.css @@ -934,3 +934,37 @@ td.evidence { max-width: 22rem; overflow-wrap: anywhere; } + +/* the agent question loop */ +.card.question { + gap: 0.5rem; +} +.badge.urgent { + display: inline-block; + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 0.1rem 0.4rem; + border-radius: var(--radius-lg); + border: 1px solid var(--destructive); + color: var(--destructive); +} +/* What the agent scraped on its way to being stuck. Quoted so a reader can see + it is reported text and not the site talking, and collapsed so it does not + bury the question it is context for. */ +.agent-summary { + cursor: pointer; + font-size: 0.8rem; + color: var(--muted); +} +.agent-quote { + margin: 0.4rem 0 0; + padding: 0.5rem 0.75rem; + border-left: 2px solid var(--border); + color: var(--muted); + font-size: 0.85rem; + white-space: pre-wrap; + overflow-wrap: anywhere; + max-height: 18rem; + overflow-y: auto; +} diff --git a/apps/web/src/app.js b/apps/web/src/app.js index 2e390a5..41bf72f 100644 --- a/apps/web/src/app.js +++ b/apps/web/src/app.js @@ -6,6 +6,7 @@ import { modulesFor, withModules } from './lib/modules.js'; import { partners } from './lib/partners.js'; import { gateway, gatewayFor } from './lib/pricing.js'; import { Denied } from './lib/service.js'; +import { registerAgents } from './routes/agents.js'; import { registerApi } from './routes/api.js'; import { registerAuth } from './routes/auth.js'; import { registerAutomotive } from './routes/automotive.js'; @@ -100,6 +101,12 @@ registerApi(app); registerAutomotive(app); registerMcp(app); +/** + * The agent question loop. Before the niche routes, because its dashboard and + * API paths are literal and must be matched before `/:slug` is asked. + */ +registerAgents(app); + /** * Last, because a niche's page is served from the site root: every other * route is registered before `/:slug` can be asked. A niche may not take a diff --git a/apps/web/src/lib/chovy.js b/apps/web/src/lib/chovy.js new file mode 100644 index 0000000..e1e05d9 --- /dev/null +++ b/apps/web/src/lib/chovy.js @@ -0,0 +1,73 @@ +import { config } from '@nichedb/config'; +import { domainEvent } from '@nichedb/knowledge'; +import { SIGNATURE_HEADER, signPayload, verifySignature } from '@nichedb/knowledge/signing'; + +/** + * The wire between NicheDB and Chovy. + * + * Both directions are signed with the same secret and the same scheme, so the + * side that signs a question verifies the answer with the code it already has. + * Neither side reads the other's tables; everything crosses as an event with a + * globally unique id, which is what makes a redelivery cheap to ignore. + */ + +export const chovyConfigured = () => Boolean(config.chovy.signingSecret); + +/** + * Check the signature on an internal request. + * + * Returns the reason on failure so an integrator debugging a 401 can tell a + * wrong secret from a wrong clock. The reason is safe to return: it says + * nothing a caller holding the body does not already know. + */ +export async function verifyInternalRequest(c) { + if (!chovyConfigured()) + return { ok: false, status: 503, reason: 'this deployment has no CHOVY_SIGNING_SECRET' }; + + // The raw text, not a re-serialised object: an HMAC is over bytes, and + // JSON.stringify(JSON.parse(x)) is not always x. + const rawBody = await c.req.text(); + const result = verifySignature({ + rawBody, + header: c.req.header(SIGNATURE_HEADER), + secret: config.chovy.signingSecret, + }); + if (!result.ok) return { ok: false, status: 401, reason: result.reason }; + + try { + return { ok: true, body: rawBody ? JSON.parse(rawBody) : {}, rawBody }; + } catch { + return { ok: false, status: 400, reason: 'body is not JSON' }; + } +} + +/** + * Tell Chovy something happened. + * + * Never throws and never blocks the thing that triggered it. An operator's + * answer is recorded and scored whether or not the agent is reachable; a + * delivery that failed is a log line, not a lost contribution. Without a + * configured URL this is a no-op, which is the normal state until a Chovy + * deployment exists to point at. + */ +export async function notifyChovy(type, { payload, nicheId = null, userId = null } = {}) { + if (!chovyConfigured() || !config.chovy.webhookUrl) return { sent: false, reason: 'not wired' }; + const event = domainEvent({ type, producer: 'nichedb', payload, nicheId, userId }); + const rawBody = JSON.stringify(event); + try { + const res = await fetch(config.chovy.webhookUrl, { + method: 'POST', + headers: { + 'content-type': 'application/json', + [SIGNATURE_HEADER]: signPayload({ rawBody, secret: config.chovy.signingSecret }), + }, + body: rawBody, + signal: AbortSignal.timeout(5000), + }); + if (!res.ok) console.error('[chovy] delivery refused', type, res.status); + return { sent: res.ok, status: res.status, eventId: event.id }; + } catch (err) { + console.error('[chovy] could not deliver', type, err?.message ?? err); + return { sent: false, reason: String(err?.message ?? err) }; + } +} diff --git a/apps/web/src/routes/agents.js b/apps/web/src/routes/agents.js new file mode 100644 index 0000000..f121f65 --- /dev/null +++ b/apps/web/src/routes/agents.js @@ -0,0 +1,208 @@ +import * as a from '@nichedb/db/agents'; +import * as k from '@nichedb/db/knowledge'; +import { chovyConfigured, notifyChovy, verifyInternalRequest } from '../lib/chovy.js'; +import { render, requireUser, respond } from '../lib/http.js'; +import { isAdmin } from '../lib/service.js'; +import { NicheQuestions } from '../views/knowledge.jsx'; + +/** + * The agent question loop. + * + * An agent gets stuck on something only a person who has done the job can + * settle, and asks. The operator answers in about two minutes, the answer + * becomes scored knowledge, and Chovy is told. + * + * The internal route is signed. Everything else here is an ordinary + * authenticated page or endpoint, because answering is a thing a person does. + */ +export function registerAgents(app) { + /* -------------------------------------------------------------- internal -- */ + + /** + * An agent asking for judgement. + * + * Signed with CHOVY_SIGNING_SECRET, because this creates work that pays: an + * unsigned endpoint here is a way to mint scored contributions. + */ + app.post('/api/v1/internal/expert-questions', async (c) => { + const auth = await verifyInternalRequest(c); + if (!auth.ok) return c.json({ error: auth.reason }, auth.status); + + const payload = auth.body?.payload ?? auth.body ?? {}; + const slug = payload.nicheSlug ?? payload.niche ?? null; + const niche = slug ? await k.getNiche(slug) : await k.getNicheById(payload.nicheId); + if (!niche) return c.json({ error: 'no such niche' }, 404); + if (!payload.title || !payload.question) + return c.json({ error: 'a question needs a title and a question' }, 400); + + const { question, duplicate } = await a.createQuestion({ + // Chovy's own id if it sent one, else the envelope's, so a redelivery of + // either shape books once. + externalId: payload.externalId ?? auth.body?.id ?? null, + nicheId: niche.id, + agentId: payload.agentId ?? niche.primary_agent_id ?? null, + title: payload.title, + question: payload.question, + context: payload.context ?? null, + options: payload.options ?? [], + urgency: payload.urgency ?? 'normal', + relatedResourceIds: payload.relatedResourceIds ?? [], + }); + + return c.json( + { + stored: Boolean(question), + duplicate, + question: question ? { id: String(question.id), status: question.status } : null, + }, + duplicate ? 200 : 201, + ); + }); + + /* ------------------------------------------------------------- operators -- */ + + app.get('/api/v1/niches/:slug/questions', async (c) => { + const niche = await k.getNiche(c.req.param('slug')); + if (!niche) return c.json({ error: 'not found' }, 404); + const user = requireUser(c); + const member = await k.memberOf({ nicheId: niche.id, userId: user.id }); + // A question carries the agent's raw research, which is not public. + if (member?.status !== 'active' && !isAdmin(user)) + return c.json({ error: 'you do not operate this niche' }, 403); + return c.json({ + questions: await a.listQuestions({ + nicheId: niche.id, + status: c.req.query('status') ?? 'waiting', + }), + }); + }); + + app.post('/api/v1/niches/:slug/questions/:id/answer', async (c) => { + const user = requireUser(c); + const niche = await k.getNiche(c.req.param('slug')); + if (!niche) return c.json({ error: 'not found' }, 404); + const body = await c.req.json().catch(() => ({})); + return answer(c, { + user, + questionId: c.req.param('id'), + kind: body.kind, + optionId: body.optionId, + body: body.answer ?? body.body, + }); + }); + + /* ------------------------------------------------------------- dashboard -- */ + + app.get('/dashboard/niches/:slug/questions', async (c) => { + const user = requireUser(c); + const niche = await k.getNiche(c.req.param('slug')); + if (!niche) return c.notFound(); + const member = await k.memberOf({ nicheId: niche.id, userId: user.id }); + if (member?.status !== 'active' && !isAdmin(user)) return c.notFound(); + + const [open, settled] = await Promise.all([ + a.listQuestions({ nicheId: niche.id, status: 'waiting' }), + a.listQuestions({ nicheId: niche.id, status: 'answered', limit: 20 }), + ]); + return c.html( + await render( + , + ), + ); + }); + + /** The form on the dashboard posts here. */ + app.post('/dashboard/questions/:id/answer', async (c) => { + const user = requireUser(c); + const form = await c.req.parseBody(); + return answer(c, { + user, + questionId: c.req.param('id'), + kind: form.kind, + optionId: form.optionId || null, + body: form.answer || null, + redirectTo: form.next ? String(form.next) : '/dashboard/niches', + }); + }); + + app.post('/dashboard/questions/:id/dismiss', async (c) => { + const user = requireUser(c); + const done = await a.dismissQuestion({ questionId: c.req.param('id'), influencerId: user.id }); + return respond(c, { + json: { dismissed: Boolean(done) }, + redirectTo: '/dashboard/niches', + notice: done ? 'Dismissed.' : null, + error: done ? null : 'That question is not yours to dismiss.', + }); + }); + + /** + * One place where an answer is recorded, whichever surface it came from. + * + * Chovy is told after the write, never before, and a delivery that fails + * does not fail the answer: the contribution is already banked. + */ + async function answer(c, { user, questionId, kind, optionId, body, redirectTo }) { + const out = await a.answerQuestion({ + questionId, + influencerId: user.id, + kind: kind ? String(kind) : 'answered', + optionId: optionId ? String(optionId) : null, + body: body ?? null, + }); + if (!out.ok) + return respond(c, { + json: { error: out.reason }, + status: 400, + redirectTo, + error: out.reason, + }); + + if (out.answer.kind === 'answered') { + await notifyChovy('expert_answer.created', { + nicheId: out.question.niche_id, + userId: user.id, + payload: { + questionId: String(out.question.id), + externalId: out.question.external_id, + answer: out.answer.body, + optionId: out.answer.option_id, + answeredBy: user.handle ?? null, + }, + }); + } else if (out.answer.kind === 'needs_research') { + await notifyChovy('agent.research_requested', { + nicheId: out.question.niche_id, + userId: user.id, + payload: { + questionId: String(out.question.id), + externalId: out.question.external_id, + note: out.answer.body, + }, + }); + } + + const said = + out.answer.kind === 'answered' + ? out.contributionStatus === 'verified' + ? `Thanks. That is ${out.points} points.` + : 'Thanks. It goes to review before it counts.' + : out.answer.kind === 'needs_research' + ? 'Sent back to the agent for more research.' + : 'Noted. It stays open for someone else.'; + + return respond(c, { + json: { ok: true, points: out.points, status: out.contributionStatus }, + redirectTo, + notice: said, + }); + } +} diff --git a/apps/web/src/routes/knowledge.js b/apps/web/src/routes/knowledge.js index 10cd442..cd05ee5 100644 --- a/apps/web/src/routes/knowledge.js +++ b/apps/web/src/routes/knowledge.js @@ -1,4 +1,5 @@ import { config } from '@nichedb/config'; +import * as agents from '@nichedb/db/agents'; import * as k from '@nichedb/db/knowledge'; import { CONTRIBUTION_EVENT_TYPES, @@ -118,10 +119,12 @@ export function registerKnowledge(app) { app.get('/dashboard/niches', async (c) => { const user = requireUser(c); - const [niches, contributions, tiers] = await Promise.all([ + const [niches, contributions, tiers, questions, questionCounts] = await Promise.all([ k.nichesForUser(user.id), k.listContributions({ influencerId: user.id, limit: 50 }), k.listTiers(), + agents.questionsAwaiting(user.id), + agents.openQuestionCounts(user.id), ]); return c.html( await render( @@ -130,6 +133,8 @@ export function registerKnowledge(app) { niches={niches} contributions={contributions} tiers={tiers} + questions={questions} + questionCounts={questionCounts} notice={c.req.query('notice')} error={c.req.query('error')} />, diff --git a/apps/web/src/views/knowledge.jsx b/apps/web/src/views/knowledge.jsx index 2c09d84..4fb9749 100644 --- a/apps/web/src/views/knowledge.jsx +++ b/apps/web/src/views/knowledge.jsx @@ -1,4 +1,4 @@ -import { formatBps, nextTierFor } from '@nichedb/knowledge'; +import { asJson, asJsonArray, formatBps, nextTierFor } from '@nichedb/knowledge'; import { Notice, Num, Relative } from './components.jsx'; import { Layout } from './Layout.jsx'; @@ -232,11 +232,11 @@ export const OpportunityPage = ({ user, opportunity, tiers, claim, notice, error ) : null} - {Object.keys(opportunity.dimensions ?? {}).length ? ( + {Object.keys(asJson(opportunity.dimensions)).length ? (

How that score is made

    - {Object.entries(opportunity.dimensions).map(([k, v]) => ( + {Object.entries(asJson(opportunity.dimensions)).map(([k, v]) => (
  • {k.replace(/_/g, ' ')} {v}
  • @@ -330,10 +330,23 @@ export const InfluencerPage = ({ user, influencer }) => ( ); /** The operator's own view: what needs them, and where they stand. */ -export const InfluencerDashboard = ({ user, niches, contributions, tiers, notice, error }) => ( +export const InfluencerDashboard = ({ + user, + niches, + contributions, + tiers, + questions = [], + questionCounts = {}, + notice, + error, +}) => ( + {/* First on the page, because this is the thing that actually wants a + human today. Everything below it is a record of what already happened. */} + +

    Your niches

    {niches.length === 0 ? (

    @@ -364,6 +377,12 @@ export const InfluencerDashboard = ({ user, niches, contributions, tiers, notice <>At the top of the ladder. )}

    + ); })} @@ -556,3 +575,138 @@ export const KnowledgeAdmin = ({ user, claims, pending, audit, notice, error })
); + +/** + * One question, with the three things a person can say about it. + * + * Everything from the agent is rendered as text. `context` in particular is + * whatever it scraped on the way to being stuck, so it is untrusted input on + * its way to a human and then back to a model: it goes in a quoted block that + * says where it came from, and nothing on either side of the loop treats it as + * an instruction. + */ +export const QuestionCard = ({ question, next }) => ( +
  • +

    + {question.urgency === 'high' ? urgent : null}{' '} + {question.title} +

    + {question.niche_slug ? ( +

    + {question.niche_name} + {question.status === 'researching' ? ' · sent back for research' : ''} +

    + ) : null} + +

    {question.question}

    + + {question.context ? ( +
    + What the agent found + {/* Quoted, not obeyed. This is crawled text. */} +
    {question.context}
    +
    + ) : null} + +
    + + {asJsonArray(question.options).length ? ( +
    + Which is it? + {asJsonArray(question.options).map((o) => ( + + ))} +
    + ) : null} + +