Chatbot: the PIF and holding-tank answers, and no more dead ends on a provider hiccup

Marty hit "I hit a snag answering that one" on "Should I pif someone from the
holding tank". Two separate problems behind it.

The real bug: OpenRouter pads a slow upstream with keepalive lines before the JSON,
and when the provider gives up it can close having sent ONLY that padding. HTTP 200,
no body. JSON.parse threw and EVERY non-canned question died the same way, not just
this one. Now we find the actual JSON object in the stream, treat an empty answer as
a failure rather than sending a blank reply, retry once (the usual cause is one
provider dropping the request), and if both attempts fail we say plainly that it was
our side, not their question, and point at a person.

The content gap: PIF and the holding tank were in the system prompt but had no canned
answers, so they depended on the AI being up. Both are now canned and instant. The PIF
answer leads with the thing the question gets wrong, which is that you cannot PIF
somebody still in the tank: adopt first, they link a wallet, then the button appears.
It is honest that nothing obliges them to buy, that crypto transfers are irreversible,
and that the 50 percent coming back only happens if they actually purchase.

Also fixes a pre-existing routing bug found while testing: "how much do I earn"
returned the PACKAGE PRICE ladder, because the price pattern matches "how much do"
and the commission pattern only caught "how much earn".

qa/chatbot-parse.mjs (9 assertions) drives the real parse branch against a stub
server for every response shape: keepalive-only, keepalive-then-JSON, plain JSON,
SSE comments, empty content, non-JSON. qa/run.sh member: 0 bugs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-17 05:27:18 -05:00
parent 8ad2e01a74
commit 6c8cc0ede2
2 changed files with 97 additions and 8 deletions
+65
View File
@@ -0,0 +1,65 @@
// The OpenRouter response shapes that broke the chatbot on 2026-09-17, and the ones
// that must keep working. Runs the real parse branch from chatbot.js with a fake https
// server, so this is the actual code path, not a copy of it.
//
// Run: node qa/chatbot-parse.mjs
import http from 'node:http';
import https from 'node:https';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const ok = [], bad = [];
const t = (n, c, extra) => { (c ? ok : bad).push(n + (c || !extra ? '' : ' -> ' + extra)); };
let BODY = '';
const srv = http.createServer((req, res) => { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(BODY); });
await new Promise(r => srv.listen(0, '127.0.0.1', r));
const port = srv.address().port;
// point chatbot.js's https.request at the local plain-http stub
const realRequest = https.request;
https.request = (opts, cb) => http.request({ ...opts, hostname: '127.0.0.1', port, protocol: 'http:', rejectUnauthorized: false }, cb);
process.env.OPENROUTER_API_KEY = 'test-key';
const cb = require('../chatbot.js');
cb.init({ dataDir: '.', chain: null });
const good = JSON.stringify({ choices: [{ message: { content: 'Here is a straight answer.' } }] });
const ask = async q => (await cb.answer(q)).reply;
const FAILMSG = /did not come back to me just now/;
// 1. the exact production failure: 200 with only keepalive padding, no JSON at all
BODY = '\n \n \n';
t('keepalive-only body no longer crashes into a dead end', FAILMSG.test(await ask('explain something obscure')));
// 2. the shape that ALSO arrives normally: keepalive padding, THEN the real JSON
BODY = '\n \n' + good;
t('keepalive padding before real JSON still parses', /straight answer/.test(await ask('explain something obscure')));
// 3. plain JSON, the happy path
BODY = good;
t('plain JSON still parses', /straight answer/.test(await ask('explain something obscure')));
// 4. SSE-style comment lines before the JSON
BODY = ': OPENROUTER PROCESSING\n: OPENROUTER PROCESSING\n' + good;
t('SSE comment lines before the JSON still parse', /straight answer/.test(await ask('explain something obscure')));
// 5. 200 with an empty content string is a failure, not a blank reply to the member
BODY = JSON.stringify({ choices: [{ message: { content: ' ' } }] });
t('empty content is treated as a failure, never sent as a blank answer', FAILMSG.test(await ask('explain something obscure')));
// 6. garbage that is not JSON at all
BODY = '<html>502 bad gateway</html>';
t('non-JSON body is handled', FAILMSG.test(await ask('explain something obscure')));
// 7. canned answers never touch the network, so they survive any outage
BODY = '';
const pif = await ask('Should I pif someone from the holding tank');
t('the PIF question answers with no AI at all', /PIF is a gift/.test(pif), pif.slice(0, 80));
t('and it says you cannot PIF someone still in the tank', /still sitting in the holding tank/.test(pif));
t('and it does not promise they will buy', /no guarantee they buy at all/.test(pif));
https.request = realRequest;
srv.close();
console.log('PASS ' + ok.length);
for (const b of bad) console.log('FAIL ' + b);
process.exit(bad.length ? 1 : 0);