kbot cpp structured

This commit is contained in:
lovebird 2026-03-30 17:15:46 +02:00
parent a462282339
commit c90063d8a1
3 changed files with 105 additions and 22 deletions

View File

@ -23,8 +23,9 @@
* Stress (batch repeats, one worker): KBOT_CLASSIFIER_STRESS_RUNS=N (default 1)
* npm run test:ipc:classifier:openrouter:stress OpenRouter + 5 runs (override N via env)
*
* Reports (reports.js): cwd/tests/test-ipc-classifier__HH-mm.{json,md}; array-only distances in
* Reports (reports.js): cwd/tests/test-ipc-classifier__HH-mm.{json,md}; distances in
* test-ipc-classifier-distances__HH-mm.json (same timestamp as the main JSON).
* With -F structured, the prompt asks for {"items":[...]} to match json_object APIs.
*/
import { spawn } from 'node:child_process';
@ -214,12 +215,15 @@ export const JOB_VIEWER_MACHINE_LABELS = [
const ANCHOR = 'machine workshop';
/** Build one prompt: model returns a JSON array only. */
/** Keys we accept for the batch array when API forces a JSON object (e.g. response_format json_object). */
const BATCH_ARRAY_OBJECT_KEYS = ['items', 'distances', 'results', 'data', 'labels', 'rows'];
/** Build one prompt: plain mode = JSON array root; structured (-F structured) = JSON object with "items" (json_object API). */
function classifierBatchPrompt(labels) {
const numbered = labels.map((l, i) => `${i + 1}. ${JSON.stringify(l)}`).join('\n');
return `You classify business types against one anchor. Output ONLY a JSON array, no markdown fences, no commentary.
const structured = classifierFeatures.has('structured');
Rules for each element:
const rules = `Rules for each element:
- Use shape: {"label": <exact string from the list below>, "distance": <number>}
- "distance" is semantic distance from 0 (same as anchor or direct synonym) to 10 (unrelated). One decimal allowed.
- Include EXACTLY one object per line item below, in the SAME ORDER, with "label" copied character-for-character from the list.
@ -227,29 +231,86 @@ Rules for each element:
Anchor business type: ${ANCHOR}
Candidate labels (in order):
${numbered}
${numbered}`;
if (structured) {
return `You classify business types against one anchor. Output ONLY valid JSON: one object, no markdown fences, no commentary.
The API requires a JSON object (not a top-level array). Use exactly one top-level key "items" whose value is the array.
${rules}
Example: {"items":[{"label":"Example","distance":2.5},...]}`;
}
return `You classify business types against one anchor. Output ONLY a JSON array, no markdown fences, no commentary.
${rules}
Output: one JSON array, e.g. [{"label":"...","distance":2.5},...]`;
}
/**
* Parse model text into the batch array: root [...] or {"items":[...]} (json_object).
* @returns {unknown[] | null}
*/
function extractJsonArray(text) {
if (!text || typeof text !== 'string') return null;
let s = text.trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/u, '').trim();
/** @param {unknown} v */
const arrayFromParsed = (v) => {
if (Array.isArray(v)) return v;
if (!v || typeof v !== 'object') return null;
const o = /** @type {Record<string, unknown>} */ (v);
for (const key of BATCH_ARRAY_OBJECT_KEYS) {
const a = o[key];
if (Array.isArray(a)) return a;
}
for (const val of Object.values(o)) {
if (
Array.isArray(val) &&
val.length > 0 &&
val[0] &&
typeof val[0] === 'object' &&
val[0] !== null &&
'label' in val[0]
) {
return val;
}
}
return null;
};
try {
const v = JSON.parse(s);
return Array.isArray(v) ? v : null;
return arrayFromParsed(v);
} catch {
/* fall through */
}
const i = s.indexOf('[');
const j = s.lastIndexOf(']');
if (i < 0 || j <= i) return null;
try {
const v = JSON.parse(s.slice(i, j + 1));
return Array.isArray(v) ? v : null;
} catch {
return null;
if (i >= 0 && j > i) {
try {
const v = JSON.parse(s.slice(i, j + 1));
if (Array.isArray(v)) return v;
} catch {
/* ignore */
}
}
const oi = s.indexOf('{');
const oj = s.lastIndexOf('}');
if (oi >= 0 && oj > oi) {
try {
const v = JSON.parse(s.slice(oi, oj + 1));
return arrayFromParsed(v);
} catch {
/* ignore */
}
}
return null;
}
/**
@ -281,9 +342,9 @@ function normalizeBatchArray(arr, expectedLabels) {
function batchTimeoutMs() {
const raw = process.env.KBOT_CLASSIFIER_TIMEOUT_MS;
if (raw === undefined || raw === '') return 300_000;
if (raw === undefined || raw === '') return 30_000;
const n = Number.parseInt(raw, 10);
return Number.isFinite(n) && n > 0 ? n : 300_000;
return Number.isFinite(n) && n > 0 ? n : 30_000;
}
/** Sequential batch iterations on one worker (stress). Default 1 = single run. */
@ -326,7 +387,7 @@ function summarizeMs(values) {
/** Log progress while awaiting a long LLM call (no silent hang). */
function withHeartbeat(promise, ipcTimeoutMs, backendLabel) {
const every = 15_000;
const every = 10_000;
let n = 0;
const id = setInterval(() => {
n += 1;
@ -385,8 +446,8 @@ function processBatchResponse(p, labels) {
parseError = `missing: ${missing.join('; ')}`;
}
} else {
assert(false, 'batch response parses as JSON array');
parseError = 'could not parse JSON array from model text';
assert(false, 'batch response parses as JSON array or {"items":[...]}');
parseError = 'could not parse batch array from model text';
}
} else {
assert(false, 'kbot-ai success');
@ -424,7 +485,9 @@ async function run() {
` ⚠️ -F structured: ignored for local llama (use --backend remote for response_format json_object)\n`
);
} else {
console.log(` Structured: kbot-ai sends response_format {"type":"json_object"}\n`);
console.log(
` Structured: response_format json_object + prompt asks for {"items":[...]} (not a top-level array)\n`
);
}
}
}

View File

@ -107,6 +107,7 @@ LLMResponse LLMClient::execute_chat(const std::string& prompt) {
std::nullopt,
std::nullopt,
response_format);
logger::info("LLMClient: ChatCompletion returned (HTTP " + std::to_string(response.status_code) + ")");
logger::debug("LLMClient::execute_chat: Got response with status: " + std::to_string(response.status_code));
// liboai may not populate raw_json for custom base URLs — parse content directly.

View File

@ -1325,16 +1325,35 @@ void liboai::netimpl::Session::SetOption(const components::Timeout& timeout) {
}
void liboai::netimpl::Session::SetTimeout(const components::Timeout& timeout) {
CURLcode e = curl_easy_setopt(this->curl_, CURLOPT_TIMEOUT_MS, timeout.Milliseconds());
const long ms = timeout.Milliseconds();
CURLcode e = curl_easy_setopt(this->curl_, CURLOPT_TIMEOUT_MS, ms);
ErrorCheck(e, "liboai::netimpl::Session::SetTimeout()");
/* Connection phase (DNS/TCP/TLS) is governed separately; without this, some stacks
can sit in connect/handshake longer than expected while the transfer timer is idle. */
if (ms > 0) {
/* Avoid std::min — Windows headers may define min() as a macro. */
const long connect_ms = (ms < 120000L) ? ms : 120000L;
e = curl_easy_setopt(this->curl_, CURLOPT_CONNECTTIMEOUT_MS, connect_ms);
ErrorCheck(e, "liboai::netimpl::Session::SetTimeout() (connect)");
#if defined(LIBOAI_DEBUG)
_liboai_dbg(
"[dbg] [@%s] Set connect timeout to %ld milliseconds\n",
__func__, connect_ms
);
#endif
}
e = curl_easy_setopt(this->curl_, CURLOPT_NOSIGNAL, 1L);
ErrorCheck(e, "liboai::netimpl::Session::SetTimeout() (nosignal)");
#if defined(LIBOAI_DEBUG)
_liboai_dbg(
"[dbg] [@%s] Set timeout to %ld milliseconds\n",
__func__, timeout.Milliseconds()
__func__, ms
);
#endif
ErrorCheck(e, "liboai::netimpl::Session::SetTimeout()");
}
void liboai::netimpl::Session::SetOption(const components::Proxies& proxies) {