shitton of the ai changes
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const {
|
||||
DEFAULT_PIPELINE_FALLBACK_POLICIES,
|
||||
} = await import("../dist/ai/user-request-pipeline/blueprint.js");
|
||||
const {
|
||||
decidePipelineFallback,
|
||||
fallbackReasonFromStageStatus,
|
||||
resolvePipelineFallbackAction,
|
||||
} = await import("../dist/ai/user-request-pipeline/fallback-executor.js");
|
||||
|
||||
test("fallback executor resolves configured failed action", () => {
|
||||
assert.equal(
|
||||
resolvePipelineFallbackAction({
|
||||
stage: "input_size_gate",
|
||||
reason: "failed",
|
||||
policies: DEFAULT_PIPELINE_FALLBACK_POLICIES,
|
||||
}),
|
||||
"notify_user",
|
||||
);
|
||||
});
|
||||
|
||||
test("fallback executor uses default action for missing policy", () => {
|
||||
assert.equal(
|
||||
resolvePipelineFallbackAction({
|
||||
stage: "send_response",
|
||||
reason: "failed",
|
||||
policies: [],
|
||||
}),
|
||||
"fail_request",
|
||||
);
|
||||
assert.equal(
|
||||
resolvePipelineFallbackAction({
|
||||
stage: "send_response",
|
||||
reason: "unavailable",
|
||||
policies: [],
|
||||
}),
|
||||
"continue_without_stage",
|
||||
);
|
||||
});
|
||||
|
||||
test("fallback decision exposes notify and continuation flags", () => {
|
||||
const decision = decidePipelineFallback({
|
||||
stage: "document_rag",
|
||||
reason: "failed",
|
||||
policies: DEFAULT_PIPELINE_FALLBACK_POLICIES,
|
||||
});
|
||||
|
||||
assert.equal(decision.action, "notify_user");
|
||||
assert.equal(decision.shouldNotifyUser, true);
|
||||
assert.equal(decision.shouldContinue, true);
|
||||
assert.equal(decision.shouldFailRequest, false);
|
||||
});
|
||||
|
||||
test("fallback reason maps only failed and skipped statuses", () => {
|
||||
assert.equal(fallbackReasonFromStageStatus("failed"), "failed");
|
||||
assert.equal(fallbackReasonFromStageStatus("skipped"), "unavailable");
|
||||
assert.equal(fallbackReasonFromStageStatus("succeeded"), undefined);
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const {UserRequestPipeline} = await import("../dist/ai/user-request-pipeline/pipeline.js");
|
||||
const {splitAttachmentsBySize} = await import("../dist/ai/user-request-pipeline/size-gate.js");
|
||||
const {PIPELINE_ATTACHMENT_LIMIT_BYTES} = await import("../dist/ai/user-request-pipeline/types.js");
|
||||
|
||||
function baseState() {
|
||||
return {
|
||||
requestId: "test-request",
|
||||
chatId: 1,
|
||||
messageId: 2,
|
||||
fromId: 3,
|
||||
receivedAt: new Date(0).toISOString(),
|
||||
text: "hello",
|
||||
settings: {
|
||||
provider: "OLLAMA",
|
||||
responseLanguage: "default",
|
||||
voiceMode: "execute",
|
||||
imageOutputMode: "photo",
|
||||
},
|
||||
inputAttachments: [],
|
||||
outputAttachments: [],
|
||||
artifacts: [],
|
||||
toolRankDecisions: [],
|
||||
audit: [],
|
||||
};
|
||||
}
|
||||
|
||||
test("pipeline runs only requested stage slice", async () => {
|
||||
const state = baseState();
|
||||
const pipeline = new UserRequestPipeline({
|
||||
stageNames: ["input_size_gate", "download_attachments"],
|
||||
stages: [{
|
||||
name: "input_size_gate",
|
||||
async run() {
|
||||
return {
|
||||
stage: "input_size_gate",
|
||||
status: "succeeded",
|
||||
details: {checked: true},
|
||||
};
|
||||
},
|
||||
}],
|
||||
});
|
||||
|
||||
await pipeline.run(state, new AbortController().signal);
|
||||
|
||||
assert.equal(state.audit.length, 3);
|
||||
assert.equal(state.audit[0].stage, "input_size_gate");
|
||||
assert.equal(state.audit[0].status, "running");
|
||||
assert.equal(state.audit[1].stage, "input_size_gate");
|
||||
assert.equal(state.audit[1].status, "succeeded");
|
||||
assert.deepEqual(state.audit[1].details, {checked: true});
|
||||
assert.equal(state.audit[2].stage, "download_attachments");
|
||||
assert.equal(state.audit[2].status, "skipped");
|
||||
assert.deepEqual(state.audit[2].details, {
|
||||
reason: "stage_not_registered",
|
||||
fallbackAction: "continue_without_stage",
|
||||
});
|
||||
});
|
||||
|
||||
test("pipeline stops when fallback decision is fail_request", async () => {
|
||||
const state = baseState();
|
||||
const pipeline = new UserRequestPipeline({
|
||||
stageNames: ["send_response"],
|
||||
stages: [{
|
||||
name: "send_response",
|
||||
async run() {
|
||||
throw new Error("send failed");
|
||||
},
|
||||
}],
|
||||
fallbackPolicies: [{
|
||||
stage: "send_response",
|
||||
onUnavailable: "fail_request",
|
||||
onFailed: "fail_request",
|
||||
}],
|
||||
});
|
||||
|
||||
await assert.rejects(() => pipeline.run(state, new AbortController().signal), /send failed/);
|
||||
assert.equal(state.audit.at(-1).stage, "send_response");
|
||||
assert.equal(state.audit.at(-1).status, "failed");
|
||||
assert.equal(state.audit.at(-1).details.fallbackAction, "fail_request");
|
||||
});
|
||||
|
||||
test("pipeline continues when fallback decision allows continuation", async () => {
|
||||
const state = baseState();
|
||||
const pipeline = new UserRequestPipeline({
|
||||
stageNames: ["document_rag", "send_response"],
|
||||
stages: [
|
||||
{
|
||||
name: "document_rag",
|
||||
async run() {
|
||||
throw new Error("rag failed");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "send_response",
|
||||
async run() {
|
||||
return {
|
||||
stage: "send_response",
|
||||
status: "succeeded",
|
||||
};
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await pipeline.run(state, new AbortController().signal);
|
||||
assert.equal(state.audit.some(event => event.stage === "document_rag" && event.status === "failed"), true);
|
||||
assert.equal(state.audit.at(-1).stage, "send_response");
|
||||
assert.equal(state.audit.at(-1).status, "succeeded");
|
||||
});
|
||||
|
||||
test("pipeline persists stage artifacts and direction-aware attachments", async () => {
|
||||
const state = baseState();
|
||||
const pipeline = new UserRequestPipeline({
|
||||
stageNames: ["persist_output_artifacts"],
|
||||
stages: [{
|
||||
name: "persist_output_artifacts",
|
||||
async run() {
|
||||
return {
|
||||
stage: "persist_output_artifacts",
|
||||
status: "succeeded",
|
||||
artifacts: [{
|
||||
kind: "final_text",
|
||||
stage: "persist_output_artifacts",
|
||||
createdAt: new Date(0).toISOString(),
|
||||
text: "answer",
|
||||
}],
|
||||
attachments: [
|
||||
{
|
||||
direction: "input",
|
||||
kind: "document",
|
||||
fileName: "input.txt",
|
||||
sizeBytes: 10,
|
||||
},
|
||||
{
|
||||
direction: "output",
|
||||
kind: "document",
|
||||
fileName: "output.txt",
|
||||
sizeBytes: 20,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
}],
|
||||
});
|
||||
|
||||
await pipeline.run(state, new AbortController().signal);
|
||||
|
||||
assert.equal(state.artifacts.length, 1);
|
||||
assert.equal(state.artifacts[0].kind, "final_text");
|
||||
assert.equal(state.inputAttachments.length, 1);
|
||||
assert.equal(state.inputAttachments[0].fileName, "input.txt");
|
||||
assert.equal(state.outputAttachments.length, 1);
|
||||
assert.equal(state.outputAttachments[0].fileName, "output.txt");
|
||||
});
|
||||
|
||||
test("size gate splits accepted and rejected attachments", () => {
|
||||
const result = splitAttachmentsBySize([
|
||||
{
|
||||
direction: "input",
|
||||
kind: "document",
|
||||
fileName: "small.txt",
|
||||
sizeBytes: PIPELINE_ATTACHMENT_LIMIT_BYTES,
|
||||
},
|
||||
{
|
||||
direction: "input",
|
||||
kind: "document",
|
||||
fileName: "large.txt",
|
||||
sizeBytes: PIPELINE_ATTACHMENT_LIMIT_BYTES + 1,
|
||||
},
|
||||
]);
|
||||
|
||||
assert.deepEqual(result.accepted.map(attachment => attachment.fileName), ["small.txt"]);
|
||||
assert.equal(result.rejected.length, 1);
|
||||
assert.equal(result.rejected[0].attachment.fileName, "large.txt");
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import test, {after} from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "tg-chat-bot-rag-"));
|
||||
process.env.BOT_TOKEN = process.env.BOT_TOKEN ?? "test-token";
|
||||
process.env.CREATOR_ID = process.env.CREATOR_ID ?? "1";
|
||||
process.env.DATA_PATH = tempRoot;
|
||||
process.env.DB_PATH = `file:${path.join(tempRoot, "test.sqlite")}`;
|
||||
process.env.TEST_ENVIRONMENT = "true";
|
||||
|
||||
const {Environment} = await import("../dist/common/environment.js");
|
||||
Environment.load();
|
||||
|
||||
const {DatabaseManager} = await import("../dist/db/database-manager.js");
|
||||
DatabaseManager.init();
|
||||
await DatabaseManager.ready;
|
||||
|
||||
const {ArtifactStore} = await import("../dist/common/artifact-store.js");
|
||||
const {filterUserVisibleStoredAttachments} = await import("../dist/common/stored-attachment-utils.js");
|
||||
const {AiProvider} = await import("../dist/model/ai-provider.js");
|
||||
const {persistRagArtifactAttachment} = await import("../dist/ai/rag-artifact-store.js");
|
||||
|
||||
after(async () => {
|
||||
await DatabaseManager.close().catch(() => undefined);
|
||||
fs.rmSync(tempRoot, {recursive: true, force: true});
|
||||
});
|
||||
|
||||
test("internal artifacts are not treated as user-visible attachments", () => {
|
||||
const visible = filterUserVisibleStoredAttachments([
|
||||
{
|
||||
kind: "document",
|
||||
fileId: "visible",
|
||||
fileName: "visible.txt",
|
||||
cachePath: "/tmp/visible.txt",
|
||||
},
|
||||
{
|
||||
kind: "document",
|
||||
fileId: "internal",
|
||||
fileName: "rag.json",
|
||||
cachePath: "/tmp/rag.json",
|
||||
scope: "internal_artifact",
|
||||
artifactKind: "rag",
|
||||
},
|
||||
]);
|
||||
|
||||
assert.equal(visible.length, 1);
|
||||
assert.equal(visible[0].fileId, "visible");
|
||||
});
|
||||
|
||||
test("RAG artifacts persist structured ollama metadata", async () => {
|
||||
const chatId = 42;
|
||||
const messageId = 7;
|
||||
|
||||
const attachment = await persistRagArtifactAttachment({
|
||||
provider: AiProvider.OLLAMA,
|
||||
prepared: {
|
||||
provider: AiProvider.OLLAMA,
|
||||
prepared: true,
|
||||
cleanup: async () => undefined,
|
||||
artifact: {
|
||||
query: "What is in the file?",
|
||||
extractedDocuments: [
|
||||
{documentIndex: 0, fileName: "report.txt", textChars: 120},
|
||||
],
|
||||
selectedChunks: [
|
||||
{
|
||||
sourceId: "doc1-1",
|
||||
documentIndex: 0,
|
||||
documentName: "report.txt",
|
||||
chunkIndex: 0,
|
||||
chunkCount: 1,
|
||||
textChars: 120,
|
||||
score: 0.91,
|
||||
},
|
||||
],
|
||||
skippedDocuments: [
|
||||
{documentIndex: 1, fileName: "ignored.bin", reason: "unsupported format"},
|
||||
],
|
||||
providerState: {
|
||||
embeddingModel: "nomic-embed-text:latest",
|
||||
topK: 8,
|
||||
chunkSize: 1400,
|
||||
chunkOverlap: 220,
|
||||
maxContextChars: 14000,
|
||||
minScore: 0.12,
|
||||
maxArchiveFiles: 200,
|
||||
maxArchiveBytes: 50 * 1024 * 1024,
|
||||
maxArchiveDepth: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
downloads: [{
|
||||
kind: "document",
|
||||
fileId: "file-1",
|
||||
fileName: "report.txt",
|
||||
buffer: Buffer.from("hello world"),
|
||||
path: path.join(tempRoot, "report.txt"),
|
||||
}],
|
||||
chatId,
|
||||
messageId,
|
||||
details: {
|
||||
embeddingModel: "nomic-embed-text:latest",
|
||||
topK: 8,
|
||||
chunkSize: 1400,
|
||||
chunkOverlap: 220,
|
||||
maxContextChars: 14000,
|
||||
artifact: {
|
||||
query: "What is in the file?",
|
||||
extractedDocuments: [
|
||||
{documentIndex: 0, fileName: "report.txt", textChars: 120},
|
||||
],
|
||||
selectedChunks: [
|
||||
{
|
||||
sourceId: "doc1-1",
|
||||
documentIndex: 0,
|
||||
documentName: "report.txt",
|
||||
chunkIndex: 0,
|
||||
chunkCount: 1,
|
||||
textChars: 120,
|
||||
score: 0.91,
|
||||
},
|
||||
],
|
||||
skippedDocuments: [
|
||||
{documentIndex: 1, fileName: "ignored.bin", reason: "unsupported format"},
|
||||
],
|
||||
providerState: {
|
||||
embeddingModel: "nomic-embed-text:latest",
|
||||
topK: 8,
|
||||
chunkSize: 1400,
|
||||
chunkOverlap: 220,
|
||||
maxContextChars: 14000,
|
||||
minScore: 0.12,
|
||||
maxArchiveFiles: 200,
|
||||
maxArchiveBytes: 50 * 1024 * 1024,
|
||||
maxArchiveDepth: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(attachment?.artifactKind, "rag");
|
||||
assert.equal(fs.existsSync(attachment.cachePath), true);
|
||||
|
||||
const stored = await ArtifactStore.getByMessage(chatId, messageId);
|
||||
assert.equal(stored.length, 1);
|
||||
assert.equal(stored[0].kind, "rag");
|
||||
assert.equal(stored[0].payload.providerState.query, "What is in the file?");
|
||||
assert.equal(stored[0].payload.providerState.selectedChunks[0].score, 0.91);
|
||||
assert.equal(stored[0].payload.providerState.skippedDocuments[0].reason, "unsupported format");
|
||||
assert.equal(stored[0].payload.providerState.ollama.embeddingModel, "nomic-embed-text:latest");
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const {
|
||||
buildToolRankerSystemPrompt,
|
||||
getToolRankerAvailableToolInfos,
|
||||
sanitizeToolRankerResult,
|
||||
} = await import("../dist/ai/tool-ranker-metadata.js");
|
||||
|
||||
function toolInfos(...toolTypes) {
|
||||
return getToolRankerAvailableToolInfos(toolTypes.map(type => ({type})));
|
||||
}
|
||||
|
||||
function promptFor(...toolTypes) {
|
||||
return buildToolRankerSystemPrompt({
|
||||
availableTools: toolInfos(...toolTypes),
|
||||
includeExamples: true,
|
||||
maxExamplesPerTool: 1,
|
||||
compact: true,
|
||||
});
|
||||
}
|
||||
|
||||
test("prompt contains only available tools", () => {
|
||||
const prompt = promptFor("no_tool", "get_datetime", "image_generation", "code_interpreter", "file_search");
|
||||
|
||||
assert.ok(prompt.includes("no_tool"));
|
||||
assert.ok(prompt.includes("get_datetime"));
|
||||
assert.ok(prompt.includes("image_generation"));
|
||||
assert.ok(prompt.includes("code_interpreter"));
|
||||
assert.ok(prompt.includes("file_search"));
|
||||
assert.ok(!prompt.includes("get_weather"));
|
||||
assert.ok(!prompt.includes("python_interpreter"));
|
||||
});
|
||||
|
||||
test("prompt does not contain disabled tools", () => {
|
||||
const prompt = promptFor("no_tool", "read_file", "search_files");
|
||||
|
||||
assert.ok(prompt.includes("read_file"));
|
||||
assert.ok(prompt.includes("search_files"));
|
||||
assert.ok(!prompt.includes("get_weather"));
|
||||
assert.ok(!prompt.includes("shell_execute"));
|
||||
assert.ok(!prompt.includes("python_interpreter"));
|
||||
});
|
||||
|
||||
test("examples are filtered when tools are unavailable", () => {
|
||||
const prompt = promptFor("no_tool", "read_file", "search_files");
|
||||
|
||||
assert.ok(prompt.includes("прочитай src/index.ts"));
|
||||
assert.ok(prompt.includes("найди где используется sendMessage"));
|
||||
assert.ok(!prompt.includes("погода завтра"));
|
||||
assert.ok(!prompt.includes("выполни этот python код"));
|
||||
});
|
||||
|
||||
test("prompt includes image generation routing example", () => {
|
||||
const prompt = promptFor("no_tool", "image_generation");
|
||||
|
||||
assert.ok(prompt.includes("сделай его лысым"));
|
||||
assert.ok(prompt.includes(JSON.stringify({toolNames: ["image_generation"]})));
|
||||
});
|
||||
|
||||
test("prompt includes weather routing example", () => {
|
||||
const prompt = promptFor("no_tool", "get_weather");
|
||||
|
||||
assert.ok(prompt.includes("погода завтра"));
|
||||
assert.ok(prompt.includes(JSON.stringify({toolNames: ["get_weather"]})));
|
||||
});
|
||||
|
||||
test("prompt includes web search routing example for current information", () => {
|
||||
const prompt = promptFor("no_tool", "web_search");
|
||||
|
||||
assert.ok(prompt.includes("найди актуальную документацию OpenAI API"));
|
||||
assert.ok(prompt.includes(JSON.stringify({toolNames: ["web_search"]})));
|
||||
});
|
||||
|
||||
test("prompt includes read file routing example for known file paths", () => {
|
||||
const prompt = promptFor("no_tool", "read_file");
|
||||
|
||||
assert.ok(prompt.includes("прочитай src/index.ts"));
|
||||
assert.ok(prompt.includes(JSON.stringify({toolNames: ["read_file"]})));
|
||||
});
|
||||
|
||||
test("prompt includes search files routing example for usage search", () => {
|
||||
const prompt = promptFor("no_tool", "search_files");
|
||||
|
||||
assert.ok(prompt.includes("найди где используется sendMessage"));
|
||||
assert.ok(prompt.includes(JSON.stringify({toolNames: ["search_files"]})));
|
||||
});
|
||||
|
||||
test("prompt includes edit file patch routing example for targeted edits", () => {
|
||||
const prompt = promptFor("no_tool", "edit_file_patch");
|
||||
|
||||
assert.ok(prompt.includes("исправь этот баг патчем"));
|
||||
assert.ok(prompt.includes(JSON.stringify({toolNames: ["edit_file_patch"]})));
|
||||
});
|
||||
|
||||
test("prompt includes update file routing example for full overwrite", () => {
|
||||
const prompt = promptFor("no_tool", "update_file");
|
||||
|
||||
assert.ok(prompt.includes("полностью перезапиши config.json"));
|
||||
assert.ok(prompt.includes(JSON.stringify({toolNames: ["update_file"]})));
|
||||
});
|
||||
|
||||
test("prompt includes delete path caution for explicit deletion only", () => {
|
||||
const prompt = promptFor("no_tool", "delete_path");
|
||||
|
||||
assert.ok(prompt.includes("delete_path only when the user clearly asks to delete or remove something."));
|
||||
assert.ok(prompt.includes("удали папку dist"));
|
||||
assert.ok(prompt.includes(JSON.stringify({toolNames: ["delete_path"]})));
|
||||
});
|
||||
|
||||
test("sanitizer returns no_tool for normal explanation", () => {
|
||||
const result = sanitizeToolRankerResult({
|
||||
raw: "объясни docker volumes",
|
||||
availableToolNames: ["read_file", "search_files"],
|
||||
});
|
||||
|
||||
assert.deepEqual(result, ["no_tool"]);
|
||||
});
|
||||
|
||||
test("sanitizer removes unavailable tools", () => {
|
||||
const result = sanitizeToolRankerResult({
|
||||
raw: JSON.stringify({toolNames: ["read_file", "missing_tool"]}),
|
||||
availableToolNames: ["read_file"],
|
||||
});
|
||||
|
||||
assert.deepEqual(result, ["read_file"]);
|
||||
});
|
||||
|
||||
test("sanitizer deduplicates tools", () => {
|
||||
const result = sanitizeToolRankerResult({
|
||||
raw: JSON.stringify({toolNames: ["read_file", "read_file", "search_files"]}),
|
||||
availableToolNames: ["read_file", "search_files"],
|
||||
});
|
||||
|
||||
assert.deepEqual(result, ["read_file", "search_files"]);
|
||||
});
|
||||
|
||||
test("sanitizer handles malformed output", () => {
|
||||
const result = sanitizeToolRankerResult({
|
||||
raw: "```json\nnot json\n```",
|
||||
availableToolNames: ["read_file"],
|
||||
});
|
||||
|
||||
assert.deepEqual(result, ["no_tool"]);
|
||||
});
|
||||
|
||||
test("sanitizer removes no_tool when mixed with real tools", () => {
|
||||
const result = sanitizeToolRankerResult({
|
||||
raw: JSON.stringify({toolNames: ["no_tool", "read_file"]}),
|
||||
availableToolNames: ["read_file"],
|
||||
});
|
||||
|
||||
assert.deepEqual(result, ["read_file"]);
|
||||
});
|
||||
Reference in New Issue
Block a user