diff --git a/packages/kbot/cpp/orchestrator/test-ipc-classifier.mjs b/packages/kbot/cpp/orchestrator/test-ipc-classifier.mjs index f899cc72..d2a8b7c7 100644 --- a/packages/kbot/cpp/orchestrator/test-ipc-classifier.mjs +++ b/packages/kbot/cpp/orchestrator/test-ipc-classifier.mjs @@ -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": , "distance": } - "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} */ (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` + ); } } } diff --git a/packages/kbot/cpp/packages/kbot/llm_client.cpp b/packages/kbot/cpp/packages/kbot/llm_client.cpp index b03f8c0d..ccfb4497 100644 --- a/packages/kbot/cpp/packages/kbot/llm_client.cpp +++ b/packages/kbot/cpp/packages/kbot/llm_client.cpp @@ -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. diff --git a/packages/kbot/cpp/packages/liboai/liboai/core/netimpl.cpp b/packages/kbot/cpp/packages/liboai/liboai/core/netimpl.cpp index 6a274f00..a45d4a3c 100644 --- a/packages/kbot/cpp/packages/liboai/liboai/core/netimpl.cpp +++ b/packages/kbot/cpp/packages/liboai/liboai/core/netimpl.cpp @@ -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) {