[grade=B] fix(DC-163 rev2): strict merge semantics, contract-proving settings/test, valid enginecheck fixture
Judge r1 (C) blockers B1-B3 + polish both applied — see body of rev1 commit for feature scope. urn:ump:l2tg66wmchkmd6fde4k3ojwc56zpxqh657spe37bu6dpvduswalq
This commit is contained in:
2 files changed
+71
-11
No files matched your search
@@ -180,6 +180,27 @@ describe('DC-163 classify engine selection', () => {
|
||||
expect(out.appId).toBe('uptime-kuma'); // NOT in the old regex list — catalog-wide
|
||||
});
|
||||
|
||||
test('LLM reroute: pattern deploy result + LLM unknown → action null, stale appId dropped', async () => {
|
||||
// Judge r1 blocker #1: when the LLM overrides the pattern's intent,
|
||||
// NO intent-specific field from the pattern may ride along.
|
||||
const cm = fakeCredStore(JSON.stringify({ baseUrl: 'http://llm.test/v1', apiKey: '***', model: 'test-model' }));
|
||||
const out = await intentMod.classify({
|
||||
message: 'Deploy Plex', // pattern: deploy + appId plex
|
||||
history: [],
|
||||
deps: { credentialManager: cm, env: {}, fetchImpl: llmReplyFetch('{"intent":"rm -rf","appId":"plex","reply":"hmm"}'), log: silentLog, timeoutMs: 5000 },
|
||||
});
|
||||
expect(out.engine).toBe('llm');
|
||||
expect(out.intent).toBe('unknown'); // illegal value coerced
|
||||
// action must be strictly null (unknown has no action) — never the
|
||||
// pattern's deploy action riding along
|
||||
expect(out.action).toBeNull();
|
||||
// stale pattern appId must NOT be carried under the new intent
|
||||
expect(out.appId === undefined || out.appId === null).toBe(true);
|
||||
const enriched = intentMod.enrich(out);
|
||||
expect(enriched.response.message).toBeTruthy(); // rebuilt for unknown, no deployPlan
|
||||
expect(enriched.deployPlan).toBeUndefined();
|
||||
});
|
||||
|
||||
test('LLM disagreement: pattern said recommend, LLM says deploy → deploy response rebuilt', async () => {
|
||||
const cm = fakeCredStore(JSON.stringify({ baseUrl: 'http://llm.test/v1', apiKey: LLM_TEST_KEY }));
|
||||
const out = await intentMod.classify({
|
||||
@@ -352,6 +373,23 @@ describe('DC-163 ai HTTP routes', () => {
|
||||
} finally { server.close(); }
|
||||
});
|
||||
|
||||
test('POST /ai/settings/test — sample MUST classify as backup; coerced unknown = failure', async () => {
|
||||
// Judge r1 blocker #2: accepting a coerced 'unknown' would let a model
|
||||
// that ignores our prompt "pass" the test. Strict expectation now.
|
||||
const cm = fakeCredStore(JSON.stringify({ baseUrl: 'http://llm.test/v1', apiKey: LLM_TEST_KEY, model: 'test-model' }));
|
||||
const fetchImpl = llmReplyFetch('{"intent":"chitchat","appId":null,"reply":"lol sure"}'); // illegal -> coerces to unknown
|
||||
const app = buildRouter({ credentialManager: cm, fetchImpl });
|
||||
const { server, base } = await start(app);
|
||||
try {
|
||||
const res = await fetch(`${base}/ai/settings/test`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
|
||||
const body = await res.json();
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.ok).toBe(false);
|
||||
expect(body.engine).toBe('llm-error');
|
||||
expect(body.reason).toMatch(/expected 'backup', got 'unknown'/);
|
||||
} finally { server.close(); }
|
||||
});
|
||||
|
||||
test('GET /ai/capabilities — engine reflects live configuration', async () => {
|
||||
const app = buildRouter();
|
||||
const { server, base } = await start(app);
|
||||
|
||||
@@ -167,19 +167,34 @@ function validateParsed(parsed) {
|
||||
}
|
||||
|
||||
// ─── Merge validated LLM fields onto the pattern result ────────────────────
|
||||
// Pattern stays the structural base (known-good shapes); LLM contributes the
|
||||
// semantic understanding. appId is never LOST silently when both engines
|
||||
// disagree — the LLM candidate wins if it is a valid catalog id, because the
|
||||
// LLM saw the full 78-app catalog while regex knows only its hardcoded list.
|
||||
// Same-intent: pattern stays the structural base (known-good shapes) and
|
||||
// the LLM adds semantic corrections (a valid catalog appId wins even when
|
||||
// regex missed it). Different-intent: the pattern's intent-SPECIFIC fields
|
||||
// (appId/serviceId/categories/response/action) belong to the OLD intent and
|
||||
// are dropped — merged fields come exclusively from the validated LLM
|
||||
// output, with `action` strictly ACTIONS_BY_INTENT[llm.intent] (null for
|
||||
// 'unknown'). Stale pattern data can never ride along under a new intent.
|
||||
function mergeIntent(patternResult, llm) {
|
||||
const merged = { ...patternResult };
|
||||
const intentChanged = llm.intent !== patternResult.intent;
|
||||
merged.intent = llm.intent;
|
||||
merged.action = ACTIONS_BY_INTENT[llm.intent] || patternResult.action || null;
|
||||
// If the LLM routed to a DIFFERENT intent than the pattern did, the
|
||||
// pattern's canned response object belongs to the old intent — drop it
|
||||
// and let enrich() rebuild the shape for the new one (with llmReply text).
|
||||
if (intentChanged) merged.response = null;
|
||||
if (intentChanged) {
|
||||
// Fields only ride along when they are meaningful for the NEW intent
|
||||
// (a 'plex' appId under intent 'unknown' is meaningless — drop it).
|
||||
return {
|
||||
intent: llm.intent,
|
||||
confidence: 0.85,
|
||||
message: patternResult.message,
|
||||
action: ACTIONS_BY_INTENT[llm.intent] ?? null,
|
||||
response: null,
|
||||
appId: (llm.intent === 'deploy' && llm.appId) ? llm.appId : undefined,
|
||||
serviceId: (llm.intent === 'diagnose' && llm.serviceId) ? llm.serviceId : undefined,
|
||||
categories: llm.intent === 'recommend'
|
||||
? ((llm.categories && llm.categories.length) ? llm.categories : ['media-streaming'])
|
||||
: undefined,
|
||||
llmReply: llm.reply || undefined,
|
||||
};
|
||||
}
|
||||
const merged = { ...patternResult };
|
||||
merged.action = ACTIONS_BY_INTENT[llm.intent] ?? null;
|
||||
if (llm.appId) merged.appId = llm.appId;
|
||||
if (llm.serviceId && !merged.serviceId) merged.serviceId = llm.serviceId;
|
||||
if (llm.intent === 'recommend') {
|
||||
@@ -645,6 +660,13 @@ module.exports = function ({ asyncHandler, siteConfig = null, log = console, fet
|
||||
const parsed = JSON.parse(repairParsed(content));
|
||||
const validated = validateParsed(parsed);
|
||||
if (!validated || !validated.intent) throw new Error('invalid classifier output');
|
||||
// Contract proof: the fixed sample MUST classify as 'backup'. An
|
||||
// 'unknown' here means the endpoint answers but the model/prompt is
|
||||
// not actually doing DashCaddy classification — report it as a
|
||||
// failure, not a pass.
|
||||
if (validated.intent !== 'backup') {
|
||||
return ok(res, { ok: false, engine: 'llm-error', reason: `sample misclassified: expected 'backup', got '${validated.intent}'` });
|
||||
}
|
||||
return ok(res, { ok: true, engine: 'llm', model: cfg.model, sample: { intent: validated.intent, reply: validated.reply } });
|
||||
} catch (e) {
|
||||
const reason = String((e && (e.name === 'AbortError' ? 'timeout' : e.message)) || 'error').slice(0, 200);
|
||||
|
||||
Reference in new issue
Block a user