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
26 changes: 25 additions & 1 deletion src/tools/news.tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -877,6 +877,10 @@ be a registered editor for the signal's beat, or the beat's publisher.

When rejecting, feedback is required to explain why.

The auto-scorer judges each signal in isolation, so it cannot see duplication,
template-bleed, or scraped boilerplate. Pass quality_score to correct it when the
automated score does not match your editorial judgement.

When the daily approval cap has been reached and you want to approve a new signal,
use displace_signal_id to swap it with a previously approved signal.

Expand All @@ -896,9 +900,22 @@ Authenticated via BIP-322 signature.`,
.string()
.optional()
.describe("ID of a previously approved signal to displace when at daily cap"),
quality_score: z
.number()
.int()
.min(0)
.max(100)
.optional()
.describe(
"Integer 0-100. Overrides the auto-scorer's quality_score on this signal. " +
"Use when the automated score does not reflect the editorial judgement " +
"(e.g. rejecting a well-sourced but duplicate or templated filing that " +
"scored highly). The original score is preserved in score_breakdown.override " +
"for audit."
),
},
},
async ({ signal_id, status, feedback, displace_signal_id }) => {
async ({ signal_id, status, feedback, displace_signal_id, quality_score }) => {
try {
const account = await getAccount();

Expand All @@ -925,6 +942,13 @@ Authenticated via BIP-322 signature.`,
if (displace_signal_id) {
payload.displace_signal_id = displace_signal_id;
}
// Explicit undefined check: 0 is a valid score and would be dropped by a
// truthiness test. Omitting the field leaves the API's no-override path.
// Covered by tests/tools/news-editor-review-signal.test.ts — the 0 case
// fails if this is refactored to `if (quality_score)`.
if (quality_score !== undefined) {
payload.quality_score = quality_score;
}

const res = await fetch(`${NEWS_BASE}/signals/${signal_id}/review`, {
method: "PATCH",
Expand Down
145 changes: 145 additions & 0 deletions tests/tools/news-editor-review-signal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

const mockGetAccount = vi.fn();

vi.mock("../../src/services/x402.service.js", () => ({
getAccount: mockGetAccount,
NETWORK: "mainnet",
}));

vi.mock("../../src/services/sbtc.service.js", () => ({
getSbtcService: vi.fn(() => ({ getBalance: vi.fn() })),
}));

vi.mock("../../src/services/hiro-api.js", () => ({
getHiroApi: vi.fn(() => ({
getAccountInfo: vi.fn(),
getMempoolTransactions: vi.fn(),
})),
}));

vi.mock("../../src/services/nonce-tracker.js", () => ({
getTrackedNonce: vi.fn(),
recordNonceUsed: vi.fn(),
reconcileWithChain: vi.fn(),
}));

// bip322Sign is real crypto over the mocked key material; stub it so the test
// asserts payload construction rather than re-testing the signer.
vi.mock("../../src/utils/bip322.js", () => ({
bip322Sign: vi.fn(() => "mock-signature"),
}));

vi.mock("@scure/btc-signer", () => ({
p2wpkh: vi.fn(() => ({ script: new Uint8Array([0x00]) })),
NETWORK: {},
TEST_NETWORK: {},
}));

const { registerNewsTools } = await import("../../src/tools/news.tools.js");

interface RegisteredTool {
handler: (args: Record<string, unknown>) => Promise<unknown>;
}

function createTrackingServer() {
const tools = new Map<string, RegisteredTool>();
const server = {
registerTool: vi.fn(
(
name: string,
_config: { description: string; inputSchema: unknown },
handler: RegisteredTool["handler"]
) => {
tools.set(name, { handler });
}
),
};
return { server, tools };
}

/**
* Registers the news tools, invokes news_editor_review_signal with `args`, and
* returns the JSON body that would have been PATCHed to /api/signals/:id/review.
*/
async function reviewPayload(
args: Record<string, unknown>
): Promise<Record<string, unknown>> {
const { server, tools } = createTrackingServer();
registerNewsTools(server as never);
const tool = tools.get("news_editor_review_signal");
if (!tool) throw new Error("news_editor_review_signal was not registered");

const fetchMock = vi.fn(async () => ({
ok: true,
status: 200,
text: async () => JSON.stringify({ id: "sig_1", status: "rejected" }),
}));
vi.stubGlobal("fetch", fetchMock);

const result = (await tool.handler(args)) as { isError?: boolean };
if (result?.isError) {
throw new Error(
`handler errored: ${JSON.stringify((result as Record<string, unknown>).content)}`
);
}
if (fetchMock.mock.calls.length === 0) {
throw new Error("handler did not issue a request");
}

const [, init] = fetchMock.mock.calls[0] as unknown as [
string,
{ body: string },
];
return JSON.parse(init.body) as Record<string, unknown>;
}

describe("news_editor_review_signal quality_score override", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
mockGetAccount.mockResolvedValue({
address: "SP000000000000000000002Q6VF78",
btcAddress: "bc1qeditor000000000000000000000000000000",
btcPrivateKey: new Uint8Array(32).fill(1),
btcPublicKey: new Uint8Array(33).fill(2),
privateKey: "0".repeat(64),
network: "mainnet",
});
});

it("forwards an editor-supplied quality_score", async () => {
const payload = await reviewPayload({
signal_id: "sig_1",
status: "rejected",
feedback: "duplicate refile",
quality_score: 15,
});

expect(payload.quality_score).toBe(15);
});

// The #810 case: fabricated sources score 0. A truthiness guard
// (`if (quality_score)`) drops it silently and the override becomes a no-op,
// so this asserts the most severe correction an editor can make survives.
it("forwards quality_score 0 rather than dropping it as falsy", async () => {
const payload = await reviewPayload({
signal_id: "sig_2",
status: "rejected",
feedback: "fabricated sources",
quality_score: 0,
});

expect(payload).toHaveProperty("quality_score");
expect(payload.quality_score).toBe(0);
});

it("omits quality_score entirely when not supplied", async () => {
const payload = await reviewPayload({
signal_id: "sig_3",
status: "approved",
});

expect(payload).not.toHaveProperty("quality_score");
});
});
Loading