Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=<unix>,v1=<hmac-sha256 of "t.body">). 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=
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,13 @@ public page (plus `skill.md` and `manifest.json` for agents), `/@<handle>` 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.
Expand Down
34 changes: 34 additions & 0 deletions apps/web/public/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
7 changes: 7 additions & 0 deletions apps/web/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down
73 changes: 73 additions & 0 deletions apps/web/src/lib/chovy.js
Original file line number Diff line number Diff line change
@@ -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) };
}
}
208 changes: 208 additions & 0 deletions apps/web/src/routes/agents.js
Original file line number Diff line number Diff line change
@@ -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(
<NicheQuestions
user={user}
niche={niche}
questions={open}
settled={settled}
configured={chovyConfigured()}
notice={c.req.query('notice')}
error={c.req.query('error')}
/>,
),
);
});

/** 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,
});
}
}
7 changes: 6 additions & 1 deletion apps/web/src/routes/knowledge.js
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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(
Expand All @@ -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')}
/>,
Expand Down
Loading
Loading