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
48 changes: 48 additions & 0 deletions src/__tests__/llm-json.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, expect, test } from "bun:test";
import { extractJsonObject } from "../llm-json.ts";

describe("extractJsonObject", () => {
test("parses a clean JSON object", () => {
expect(extractJsonObject('{"a":1,"b":"x"}')).toEqual({ a: 1, b: "x" });
});

test("strips a ```json code fence", () => {
expect(extractJsonObject('```json\n{"ok":true}\n```')).toEqual({
ok: true,
});
});

test("strips a plain ``` code fence", () => {
expect(extractJsonObject('```\n{"ok":false}\n```')).toEqual({ ok: false });
});

test("strips a leading <think> block", () => {
const raw = '<think>reasoning here</think>\n{"stage":"pitch"}';
expect(extractJsonObject(raw)).toEqual({ stage: "pitch" });
});

test("extracts the object from surrounding prose", () => {
const raw = 'Ответ: {"winner":"a"} — надеюсь, помог';
expect(extractJsonObject(raw)).toEqual({ winner: "a" });
});

test("returns null for prose with no JSON object", () => {
expect(extractJsonObject("I cannot determine the outcome")).toBeNull();
});

test("returns null for an empty string", () => {
expect(extractJsonObject("")).toBeNull();
});

test("returns null for a JSON array (objects only)", () => {
expect(extractJsonObject("[1,2,3]")).toBeNull();
});

test("returns null for malformed JSON", () => {
expect(extractJsonObject('{"a": }')).toBeNull();
});

test("returns null for a non-string input", () => {
expect(extractJsonObject(undefined as unknown as string)).toBeNull();
});
});
81 changes: 81 additions & 0 deletions src/__tests__/prompt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { describe, expect, test } from "bun:test";
import { composeSystemPrompt } from "../prompt.ts";
import { marinaPrime } from "../styles/marina-prime.ts";

describe("composeSystemPrompt — core sections", () => {
test("includes the persona name", () => {
expect(composeSystemPrompt(marinaPrime, "qualify")).toContain(
"Тебя зовут Марина",
);
});

test("includes the framework blurb", () => {
expect(composeSystemPrompt(marinaPrime, "qualify")).toContain("NEPQ");
});

test("separates sections with blank lines", () => {
expect(composeSystemPrompt(marinaPrime, "qualify")).toContain("\n\n");
});
});

describe("composeSystemPrompt — few-shot toggle", () => {
test("included by default", () => {
expect(composeSystemPrompt(marinaPrime, "qualify")).toContain(
"ПРИМЕРЫ ДИАЛОГА",
);
});

test("omitted when includeFewShot is false", () => {
const prompt = composeSystemPrompt(marinaPrime, "qualify", null, {
includeFewShot: false,
});
expect(prompt).not.toContain("ПРИМЕРЫ ДИАЛОГА");
});
});

describe("composeSystemPrompt — KB context", () => {
test("omitted when no context is provided", () => {
expect(composeSystemPrompt(marinaPrime, "qualify")).not.toContain(
"KB CONTEXT (актуальные факты",
);
});

test("included verbatim when context is provided", () => {
const kb = "Корея: оклад ₩110 000 за смену.";
const prompt = composeSystemPrompt(marinaPrime, "qualify", kb);
expect(prompt).toContain("KB CONTEXT (актуальные факты");
expect(prompt).toContain(kb);
});
});

describe("composeSystemPrompt — human persona disclosure branch", () => {
test("evasive answer when botDisclosureOnDirectQuestion is false", () => {
expect(composeSystemPrompt(marinaPrime, "qualify")).toContain(
"отвечай уклончиво",
);
});

test("honest answer when botDisclosureOnDirectQuestion is true", () => {
const style = structuredClone(marinaPrime);
style.guardrails.botDisclosureOnDirectQuestion = true;
const prompt = composeSystemPrompt(style, "qualify");
expect(prompt).toContain("ЧЕСТНО ответь, что ты ИИ-ассистент");
expect(prompt).not.toContain("отвечай уклончиво");
});
});

describe("composeSystemPrompt — persona facts", () => {
test("no facts section when persona has no facts", () => {
expect(composeSystemPrompt(marinaPrime, "qualify")).not.toContain(
"ЛИЧНЫЕ ФАКТЫ",
);
});

test("facts section rendered when persona facts are present", () => {
const style = structuredClone(marinaPrime);
style.persona.facts = { возраст: "28", город: "Москва" };
const prompt = composeSystemPrompt(style, "qualify");
expect(prompt).toContain("ЛИЧНЫЕ ФАКТЫ");
expect(prompt).toContain("город: Москва");
});
});
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import { shadowDecide } from "./shadow-eval.ts";
import { shadowDecide } from "../shadow-eval.ts";

describe("shadowDecide", () => {
test("0 pairs → inconclusive", () => {
Expand Down
115 changes: 115 additions & 0 deletions src/__tests__/stage-classifier.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { describe, expect, test } from "bun:test";
import type { ChatClient } from "@chatman-media/rag";
import { classifyStage, parseClassifierOutput } from "../stage-classifier.ts";

/** Minimal ChatClient whose `complete` returns (or throws) a fixed value. */
function stubChat(reply: string | (() => never)): ChatClient {
return {
async complete() {
if (typeof reply === "function") return reply();
return reply;
},
};
}

describe("parseClassifierOutput", () => {
test("parses a clean object", () => {
expect(parseClassifierOutput('{"stage":"pitch","confidence":0.9}')).toEqual(
{ stage: "pitch", confidence: 0.9 },
);
});

test("strips a ```json code fence", () => {
const raw = '```json\n{"stage":"qualify","confidence":0.8}\n```';
expect(parseClassifierOutput(raw)).toEqual({
stage: "qualify",
confidence: 0.8,
});
});

test("extracts the object past an 'Ответ:' prefix", () => {
const raw = 'Ответ: {"stage":"close","confidence":0.7}';
expect(parseClassifierOutput(raw)).toEqual({
stage: "close",
confidence: 0.7,
});
});

test("clamps a percentage-style confidence (95 → 0.95)", () => {
expect(parseClassifierOutput('{"stage":"pitch","confidence":95}')).toEqual({
stage: "pitch",
confidence: 0.95,
});
});

test("returns null for malformed JSON", () => {
expect(parseClassifierOutput("not json at all")).toBeNull();
});

test("returns null when stage field is missing", () => {
expect(parseClassifierOutput('{"confidence":0.9}')).toBeNull();
});

test("returns null when confidence is not a number", () => {
expect(
parseClassifierOutput('{"stage":"pitch","confidence":"high"}'),
).toBeNull();
});
});

describe("classifyStage — fallback paths", () => {
const base = {
userMessage: "сколько платят?",
currentStage: "qualify" as const,
turnNumber: 3,
};

test("LLM error → regex fallback with reason 'llm-error'", async () => {
const result = await classifyStage({
...base,
chat: stubChat(() => {
throw new Error("network down");
}),
});
expect(result.source).toBe("regex-fallback");
expect(result.fallbackReason).toBe("llm-error");
});

test("unparseable output → reason 'parse-error'", async () => {
const result = await classifyStage({
...base,
chat: stubChat("I have no idea"),
});
expect(result.fallbackReason).toBe("parse-error");
});

test("unknown stage → reason 'unknown-stage'", async () => {
const result = await classifyStage({
...base,
chat: stubChat('{"stage":"smalltalk","confidence":0.9}'),
});
expect(result.fallbackReason).toBe("unknown-stage");
});

test("below-threshold confidence → reason 'low-confidence'", async () => {
const result = await classifyStage({
...base,
chat: stubChat('{"stage":"pitch","confidence":0.3}'),
});
expect(result.fallbackReason).toBe("low-confidence");
});
});

describe("classifyStage — LLM path", () => {
test("valid high-confidence verdict is taken as-is", async () => {
const result = await classifyStage({
userMessage: "сколько платят?",
currentStage: "qualify",
turnNumber: 3,
chat: stubChat('{"stage":"pitch","confidence":0.92}'),
});
expect(result.source).toBe("llm");
expect(result.stage).toBe("pitch");
expect(result.confidence).toBe(0.92);
});
});
77 changes: 0 additions & 77 deletions src/ab-router.test.ts

This file was deleted.

28 changes: 5 additions & 23 deletions src/coach.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { extractJsonObject } from "./llm-json.ts";
import type { ISelfPlayMatchesRepo } from "./store.ts";
/**
* Coach-LLM: reads recent self-play LOSSES and DRAWS for a style,
Expand Down Expand Up @@ -216,31 +217,12 @@ export async function proposeStyleEdits(

/**
* Tolerant JSON parser. Strips code fences, attempts JSON.parse, falls
* back to extracting an outer object via regex. Always returns a valid
* CoachProposal (with raw output preserved on parse failure).
* back to extracting an outer object. Always returns a valid CoachProposal
* (with raw output preserved on parse failure).
*/
export function parseProposal(raw: string): CoachProposal {
const stripped = raw
.replace(/^```(?:json)?\s*/i, "")
.replace(/\s*```\s*$/i, "")
.trim();
// First try a direct parse.
try {
const parsed = JSON.parse(stripped);
return normalizeProposal(parsed, raw);
} catch {
/* fall through */
}
// Try to extract the outermost {...} block.
const m = stripped.match(/\{[\s\S]*\}/);
if (m) {
try {
const parsed = JSON.parse(m[0]);
return normalizeProposal(parsed, raw);
} catch {
/* fall through */
}
}
const parsed = extractJsonObject(raw);
if (parsed) return normalizeProposal(parsed, raw);
return {
summary: "(coach output unparseable — see raw)",
edits: {},
Expand Down
Loading
Loading