efarmz-slackbot-data 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.clever.json +12 -0
- package/.dockerignore +13 -0
- package/.env.example +28 -0
- package/.github/workflows/deploy-production.yaml +34 -0
- package/.prettierrc +6 -0
- package/.tasks/F1-bootstrap.md +110 -0
- package/.tasks/F2-domain-layer.md +173 -0
- package/.tasks/F3-application-layer.md +166 -0
- package/.tasks/F4-infrastructure-layer.md +229 -0
- package/.tasks/F5-config-main.md +160 -0
- package/.tasks/F6-schemas-deployment.md +129 -0
- package/CLAUDE.md +163 -0
- package/Dockerfile +15 -0
- package/PRD.md +119 -0
- package/docs/schemas/.gitkeep +0 -0
- package/docs/schemas/_guidelines.md +89 -0
- package/docs/schemas/efarmz_db.md +759 -0
- package/docs/schemas/example.md +16 -0
- package/eslint.config.mjs +18 -0
- package/package.json +54 -0
- package/releaserc.json +15 -0
- package/src/.gitkeep +0 -0
- package/src/application/agent/.gitkeep +0 -0
- package/src/application/agent/AgentContext.test.ts +263 -0
- package/src/application/agent/AgentContext.ts +93 -0
- package/src/application/agent/AgentLoop.test.ts +275 -0
- package/src/application/agent/AgentLoop.ts +101 -0
- package/src/application/agent/AgentRunResult.ts +11 -0
- package/src/application/agent/LLMMessage.ts +16 -0
- package/src/application/agent/tools/RunSqlTool.ts +23 -0
- package/src/application/formatting/.gitkeep +0 -0
- package/src/application/formatting/CsvRenderer.test.ts +162 -0
- package/src/application/formatting/CsvRenderer.ts +34 -0
- package/src/application/formatting/MonospaceTableRenderer.test.ts +129 -0
- package/src/application/formatting/MonospaceTableRenderer.ts +58 -0
- package/src/application/formatting/RenderedResponse.ts +7 -0
- package/src/application/formatting/ResponseRenderer.test.ts +159 -0
- package/src/application/formatting/ResponseRenderer.ts +39 -0
- package/src/application/formatting/ScalarRenderer.test.ts +36 -0
- package/src/application/formatting/ScalarRenderer.ts +12 -0
- package/src/application/usecases/.gitkeep +0 -0
- package/src/application/usecases/AnswerQuestion.test.ts +362 -0
- package/src/application/usecases/AnswerQuestion.ts +69 -0
- package/src/application/usecases/ParseQuestion.test.ts +39 -0
- package/src/application/usecases/ParseQuestion.ts +9 -0
- package/src/config/.gitkeep +0 -0
- package/src/config/Container.test.ts +35 -0
- package/src/config/Container.ts +74 -0
- package/src/config/constants.ts +9 -0
- package/src/config/env.test.ts +103 -0
- package/src/config/env.ts +41 -0
- package/src/domain/entities/.gitkeep +0 -0
- package/src/domain/entities/Conversation.test.ts +69 -0
- package/src/domain/entities/Conversation.ts +26 -0
- package/src/domain/entities/ConversationMessage.test.ts +49 -0
- package/src/domain/entities/ConversationMessage.ts +18 -0
- package/src/domain/entities/index.ts +2 -0
- package/src/domain/errors/.gitkeep +0 -0
- package/src/domain/errors/AgentLoopExceededError.ts +12 -0
- package/src/domain/errors/DomainError.test.ts +106 -0
- package/src/domain/errors/DomainError.ts +11 -0
- package/src/domain/errors/InvalidSqlError.ts +15 -0
- package/src/domain/errors/LLMError.ts +15 -0
- package/src/domain/errors/SchemaLoadError.ts +15 -0
- package/src/domain/errors/SqlExecutionError.ts +15 -0
- package/src/domain/errors/index.ts +15 -0
- package/src/domain/ports/.gitkeep +0 -0
- package/src/domain/ports/AdminLogger.ts +16 -0
- package/src/domain/ports/ConversationRepository.ts +10 -0
- package/src/domain/ports/LLMProvider.ts +33 -0
- package/src/domain/ports/Logger.ts +8 -0
- package/src/domain/ports/SchemaCatalog.ts +5 -0
- package/src/domain/ports/SlackMessenger.ts +8 -0
- package/src/domain/ports/SqlExecutor.ts +8 -0
- package/src/domain/ports/SqlValidator.ts +5 -0
- package/src/domain/ports/index.ts +17 -0
- package/src/domain/value-objects/.gitkeep +0 -0
- package/src/domain/value-objects/LLMProviderName.ts +6 -0
- package/src/domain/value-objects/QueryResult.test.ts +51 -0
- package/src/domain/value-objects/QueryResult.ts +18 -0
- package/src/domain/value-objects/Question.test.ts +59 -0
- package/src/domain/value-objects/Question.ts +22 -0
- package/src/domain/value-objects/QuestionFlags.test.ts +59 -0
- package/src/domain/value-objects/QuestionFlags.ts +18 -0
- package/src/domain/value-objects/ResponseRendering.ts +7 -0
- package/src/domain/value-objects/SqlQuery.test.ts +40 -0
- package/src/domain/value-objects/SqlQuery.ts +12 -0
- package/src/domain/value-objects/ThreadId.test.ts +68 -0
- package/src/domain/value-objects/ThreadId.ts +27 -0
- package/src/domain/value-objects/index.ts +13 -0
- package/src/infrastructure/llm/.gitkeep +0 -0
- package/src/infrastructure/llm/AnthropicLLMProvider.test.ts +229 -0
- package/src/infrastructure/llm/AnthropicLLMProvider.ts +45 -0
- package/src/infrastructure/llm/index.ts +4 -0
- package/src/infrastructure/llm/mappers/AnthropicMessageMapper.test.ts +173 -0
- package/src/infrastructure/llm/mappers/AnthropicMessageMapper.ts +34 -0
- package/src/infrastructure/llm/prompts/SystemPromptBuilder.test.ts +41 -0
- package/src/infrastructure/llm/prompts/SystemPromptBuilder.ts +31 -0
- package/src/infrastructure/llm/prompts/ToolDefinitions.ts +7 -0
- package/src/infrastructure/logging/.gitkeep +0 -0
- package/src/infrastructure/logging/PinoLogger.test.ts +59 -0
- package/src/infrastructure/logging/PinoLogger.ts +28 -0
- package/src/infrastructure/logging/index.ts +1 -0
- package/src/infrastructure/persistence/.gitkeep +0 -0
- package/src/infrastructure/persistence/InMemoryConversationRepository.test.ts +325 -0
- package/src/infrastructure/persistence/InMemoryConversationRepository.ts +69 -0
- package/src/infrastructure/persistence/PostgresPoolFactory.ts +11 -0
- package/src/infrastructure/persistence/PostgresSqlExecutor.test.ts +130 -0
- package/src/infrastructure/persistence/PostgresSqlExecutor.ts +34 -0
- package/src/infrastructure/persistence/index.ts +3 -0
- package/src/infrastructure/schemas/.gitkeep +0 -0
- package/src/infrastructure/schemas/FileSystemSchemaCatalog.test.ts +163 -0
- package/src/infrastructure/schemas/FileSystemSchemaCatalog.ts +35 -0
- package/src/infrastructure/schemas/index.ts +4 -0
- package/src/infrastructure/slack/.gitkeep +0 -0
- package/src/infrastructure/slack/BoltSlackMessenger.test.ts +59 -0
- package/src/infrastructure/slack/BoltSlackMessenger.ts +36 -0
- package/src/infrastructure/slack/SlackAdminLogger.test.ts +54 -0
- package/src/infrastructure/slack/SlackAdminLogger.ts +27 -0
- package/src/infrastructure/slack/SlackApp.ts +9 -0
- package/src/infrastructure/slack/handlers/AppMentionHandler.ts +52 -0
- package/src/infrastructure/slack/handlers/DirectMessageHandler.ts +65 -0
- package/src/infrastructure/slack/index.ts +5 -0
- package/src/infrastructure/sql/.gitkeep +0 -0
- package/src/infrastructure/sql/RegexSqlValidator.test.ts +242 -0
- package/src/infrastructure/sql/RegexSqlValidator.ts +53 -0
- package/src/infrastructure/sql/index.ts +1 -0
- package/src/main.ts +19 -0
- package/tsconfig.json +23 -0
- package/vitest.config.ts +15 -0
- package/vitest.setup.ts +23 -0
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import AnswerQuestion from "../usecases/AnswerQuestion.js";
|
|
3
|
+
import type ConversationRepository from "@/domain/ports/ConversationRepository.js";
|
|
4
|
+
import type SlackMessenger from "@/domain/ports/SlackMessenger.js";
|
|
5
|
+
import type AdminLogger from "@/domain/ports/AdminLogger.js";
|
|
6
|
+
import type { AdminLogEntry } from "@/domain/ports/AdminLogger.js";
|
|
7
|
+
import type { QueryResult } from "@/domain/value-objects/QueryResult.js";
|
|
8
|
+
import type RenderedResponse from "../formatting/RenderedResponse.js";
|
|
9
|
+
import Conversation from "@/domain/entities/Conversation.js";
|
|
10
|
+
import ThreadId from "@/domain/value-objects/ThreadId.js";
|
|
11
|
+
import Question from "@/domain/value-objects/Question.js";
|
|
12
|
+
import type AgentRunResult from "../agent/AgentRunResult.js";
|
|
13
|
+
|
|
14
|
+
class FakeConversationRepository implements ConversationRepository {
|
|
15
|
+
private savedConversation: Conversation | null = null;
|
|
16
|
+
private conversationToReturn: Conversation | null = null;
|
|
17
|
+
|
|
18
|
+
setSavedConversation(conversation: Conversation | null): this {
|
|
19
|
+
this.conversationToReturn = conversation;
|
|
20
|
+
return this;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async get(): Promise<Conversation | null> {
|
|
24
|
+
return this.conversationToReturn;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async save(conversation: Conversation): Promise<void> {
|
|
28
|
+
this.savedConversation = conversation;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
getSavedConversation(): Conversation | null {
|
|
32
|
+
return this.savedConversation;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async purge(): Promise<void> {
|
|
36
|
+
// no-op
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
class FakeAgentLoop {
|
|
41
|
+
private resultToReturn: AgentRunResult | null = null;
|
|
42
|
+
|
|
43
|
+
setResult(result: AgentRunResult): this {
|
|
44
|
+
this.resultToReturn = result;
|
|
45
|
+
return this;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async run(): Promise<AgentRunResult> {
|
|
49
|
+
if (!this.resultToReturn) {
|
|
50
|
+
throw new Error(`No result configured for FakeAgentLoop`);
|
|
51
|
+
}
|
|
52
|
+
return this.resultToReturn;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
class FakeResponseRenderer {
|
|
57
|
+
private renderedResponse: RenderedResponse | null = null;
|
|
58
|
+
|
|
59
|
+
setResponse(response: RenderedResponse): this {
|
|
60
|
+
this.renderedResponse = response;
|
|
61
|
+
return this;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
render(): RenderedResponse {
|
|
65
|
+
if (!this.renderedResponse) {
|
|
66
|
+
throw new Error(`No response configured for FakeResponseRenderer`);
|
|
67
|
+
}
|
|
68
|
+
return this.renderedResponse;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
class FakeSlackMessenger implements SlackMessenger {
|
|
73
|
+
private lastPostText: string | null = null;
|
|
74
|
+
private lastPostCsvBuffer: Buffer | null = null;
|
|
75
|
+
private lastPostCsvFilename: string | null = null;
|
|
76
|
+
|
|
77
|
+
async post(_threadId: never, text: string): Promise<void> {
|
|
78
|
+
this.lastPostText = text;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async postCsv(_threadId: never, text: string, csvBuffer: Buffer, filename: string): Promise<void> {
|
|
82
|
+
this.lastPostText = text;
|
|
83
|
+
this.lastPostCsvBuffer = csvBuffer;
|
|
84
|
+
this.lastPostCsvFilename = filename;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
getLastPostText(): string | null {
|
|
88
|
+
return this.lastPostText;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
getLastPostCsvBuffer(): Buffer | null {
|
|
92
|
+
return this.lastPostCsvBuffer;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
getLastPostCsvFilename(): string | null {
|
|
96
|
+
return this.lastPostCsvFilename;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
class FakeAdminLogger implements AdminLogger {
|
|
101
|
+
private loggedEntry: AdminLogEntry | null = null;
|
|
102
|
+
|
|
103
|
+
async logQuery(entry: AdminLogEntry): Promise<void> {
|
|
104
|
+
this.loggedEntry = entry;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
getLoggedEntry(): AdminLogEntry | null {
|
|
108
|
+
return this.loggedEntry;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
describe(`AnswerQuestion`, () => {
|
|
113
|
+
it(`should orchestrate complete flow without CSV`, async () => {
|
|
114
|
+
const threadId = ThreadId.create(`C12345`, `1234567890.123456`);
|
|
115
|
+
const question = Question.create(`How many users?`);
|
|
116
|
+
const userId = `U12345`;
|
|
117
|
+
|
|
118
|
+
const queryResult: QueryResult = {
|
|
119
|
+
columns: [`count`],
|
|
120
|
+
rows: [{ count: 42 }],
|
|
121
|
+
rowCount: 1,
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const agentResult: AgentRunResult = {
|
|
125
|
+
finalText: `There are 42 users`,
|
|
126
|
+
executedSqls: [`SELECT COUNT(*) as count FROM users`],
|
|
127
|
+
queryResult,
|
|
128
|
+
inputTokens: 100,
|
|
129
|
+
outputTokens: 50,
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const conversationRepo = new FakeConversationRepository();
|
|
133
|
+
const agentLoop = new FakeAgentLoop().setResult(agentResult);
|
|
134
|
+
const responseRenderer = new FakeResponseRenderer().setResponse({
|
|
135
|
+
text: `**42**`,
|
|
136
|
+
csvBuffer: undefined,
|
|
137
|
+
csvFilename: undefined,
|
|
138
|
+
});
|
|
139
|
+
const slackMessenger = new FakeSlackMessenger();
|
|
140
|
+
const adminLogger = new FakeAdminLogger();
|
|
141
|
+
|
|
142
|
+
const useCase = new AnswerQuestion(
|
|
143
|
+
conversationRepo,
|
|
144
|
+
agentLoop as any,
|
|
145
|
+
responseRenderer as any,
|
|
146
|
+
slackMessenger,
|
|
147
|
+
adminLogger,
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
await useCase.execute(question, threadId, userId);
|
|
151
|
+
|
|
152
|
+
// Verify conversation was saved with new messages
|
|
153
|
+
const savedConversation = conversationRepo.getSavedConversation();
|
|
154
|
+
expect(savedConversation).not.toBeNull();
|
|
155
|
+
expect(savedConversation!.messages.length).toBe(2);
|
|
156
|
+
expect(savedConversation!.messages[0]!.role).toBe(`user`);
|
|
157
|
+
expect(savedConversation!.messages[0]!.content).toBe(`How many users?`);
|
|
158
|
+
expect(savedConversation!.messages[1]!.role).toBe(`assistant`);
|
|
159
|
+
expect(savedConversation!.messages[1]!.content).toBe(`There are 42 users`);
|
|
160
|
+
|
|
161
|
+
// Verify message was posted without CSV
|
|
162
|
+
expect(slackMessenger.getLastPostText()).toBe(`**42**`);
|
|
163
|
+
expect(slackMessenger.getLastPostCsvBuffer()).toBeNull();
|
|
164
|
+
expect(slackMessenger.getLastPostCsvFilename()).toBeNull();
|
|
165
|
+
|
|
166
|
+
// Verify admin logging
|
|
167
|
+
const logEntry = adminLogger.getLoggedEntry();
|
|
168
|
+
expect(logEntry).not.toBeNull();
|
|
169
|
+
expect(logEntry!.userId).toBe(userId);
|
|
170
|
+
expect(logEntry!.question).toBe(`How many users?`);
|
|
171
|
+
expect(logEntry!.sqls).toEqual([`SELECT COUNT(*) as count FROM users`]);
|
|
172
|
+
expect(logEntry!.rowCount).toBe(1);
|
|
173
|
+
expect(logEntry!.inputTokens).toBe(100);
|
|
174
|
+
expect(logEntry!.outputTokens).toBe(50);
|
|
175
|
+
expect(logEntry!.durationMs).toBeGreaterThanOrEqual(0);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it(`should post CSV when query result exceeds threshold`, async () => {
|
|
179
|
+
const threadId = ThreadId.create(`C12345`, `1234567890.123456`);
|
|
180
|
+
const question = Question.create(`Get all users`);
|
|
181
|
+
const userId = `U12345`;
|
|
182
|
+
|
|
183
|
+
const rows = Array.from({ length: 20 }, (_, i) => ({
|
|
184
|
+
id: i + 1,
|
|
185
|
+
name: `user${i + 1}`,
|
|
186
|
+
}));
|
|
187
|
+
const queryResult: QueryResult = {
|
|
188
|
+
columns: [`id`, `name`],
|
|
189
|
+
rows,
|
|
190
|
+
rowCount: 20,
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const agentResult: AgentRunResult = {
|
|
194
|
+
finalText: `Found 20 users`,
|
|
195
|
+
executedSqls: [`SELECT id, name FROM users LIMIT 100`],
|
|
196
|
+
queryResult,
|
|
197
|
+
inputTokens: 150,
|
|
198
|
+
outputTokens: 75,
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const csvBuffer = Buffer.from(`id,name\n1,user1\n2,user2`);
|
|
202
|
+
|
|
203
|
+
const conversationRepo = new FakeConversationRepository();
|
|
204
|
+
const agentLoop = new FakeAgentLoop().setResult(agentResult);
|
|
205
|
+
const responseRenderer = new FakeResponseRenderer().setResponse({
|
|
206
|
+
text: `Table: ... (truncated)`,
|
|
207
|
+
csvBuffer,
|
|
208
|
+
csvFilename: `result_1234567890.csv`,
|
|
209
|
+
});
|
|
210
|
+
const slackMessenger = new FakeSlackMessenger();
|
|
211
|
+
const adminLogger = new FakeAdminLogger();
|
|
212
|
+
|
|
213
|
+
const useCase = new AnswerQuestion(
|
|
214
|
+
conversationRepo,
|
|
215
|
+
agentLoop as any,
|
|
216
|
+
responseRenderer as any,
|
|
217
|
+
slackMessenger,
|
|
218
|
+
adminLogger,
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
await useCase.execute(question, threadId, userId);
|
|
222
|
+
|
|
223
|
+
// Verify CSV was posted
|
|
224
|
+
expect(slackMessenger.getLastPostText()).toBe(`Table: ... (truncated)`);
|
|
225
|
+
expect(slackMessenger.getLastPostCsvBuffer()).toEqual(csvBuffer);
|
|
226
|
+
expect(slackMessenger.getLastPostCsvFilename()).toBe(`result_1234567890.csv`);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it(`should skip conversation history when --no-context flag is set`, async () => {
|
|
230
|
+
const threadId = ThreadId.create(`C12345`, `1234567890.123456`);
|
|
231
|
+
const question = Question.create(`Answer this --no-context`);
|
|
232
|
+
const userId = `U12345`;
|
|
233
|
+
|
|
234
|
+
expect(question.flags.noContext).toBe(true);
|
|
235
|
+
|
|
236
|
+
const agentResult: AgentRunResult = {
|
|
237
|
+
finalText: `Direct answer`,
|
|
238
|
+
executedSqls: [],
|
|
239
|
+
queryResult: null,
|
|
240
|
+
inputTokens: 50,
|
|
241
|
+
outputTokens: 25,
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const conversationRepo = new FakeConversationRepository();
|
|
245
|
+
// This mock should NOT be called when noContext is true
|
|
246
|
+
const getspy = vi.spyOn(conversationRepo, `get`);
|
|
247
|
+
|
|
248
|
+
const agentLoop = new FakeAgentLoop().setResult(agentResult);
|
|
249
|
+
const responseRenderer = new FakeResponseRenderer().setResponse({
|
|
250
|
+
text: `Direct answer`,
|
|
251
|
+
csvBuffer: undefined,
|
|
252
|
+
csvFilename: undefined,
|
|
253
|
+
});
|
|
254
|
+
const slackMessenger = new FakeSlackMessenger();
|
|
255
|
+
const adminLogger = new FakeAdminLogger();
|
|
256
|
+
|
|
257
|
+
const useCase = new AnswerQuestion(
|
|
258
|
+
conversationRepo,
|
|
259
|
+
agentLoop as any,
|
|
260
|
+
responseRenderer as any,
|
|
261
|
+
slackMessenger,
|
|
262
|
+
adminLogger,
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
await useCase.execute(question, threadId, userId);
|
|
266
|
+
|
|
267
|
+
// Verify get was NOT called
|
|
268
|
+
expect(getspy).not.toHaveBeenCalled();
|
|
269
|
+
|
|
270
|
+
// Verify conversation was created from scratch (new one)
|
|
271
|
+
const savedConversation = conversationRepo.getSavedConversation();
|
|
272
|
+
expect(savedConversation).not.toBeNull();
|
|
273
|
+
expect(savedConversation!.messages.length).toBe(2);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it(`should include existing conversation messages when noContext is false`, async () => {
|
|
277
|
+
const threadId = ThreadId.create(`C12345`, `1234567890.123456`);
|
|
278
|
+
const question = Question.create(`Follow-up question`);
|
|
279
|
+
const userId = `U12345`;
|
|
280
|
+
|
|
281
|
+
expect(question.flags.noContext).toBe(false);
|
|
282
|
+
|
|
283
|
+
const existingConversation = Conversation.create(threadId)
|
|
284
|
+
.append({ role: `user`, content: `First question`, timestamp: new Date() })
|
|
285
|
+
.append({ role: `assistant`, content: `First answer`, timestamp: new Date() });
|
|
286
|
+
|
|
287
|
+
const agentResult: AgentRunResult = {
|
|
288
|
+
finalText: `Follow-up answer`,
|
|
289
|
+
executedSqls: [`SELECT ...`],
|
|
290
|
+
queryResult: null,
|
|
291
|
+
inputTokens: 100,
|
|
292
|
+
outputTokens: 50,
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const conversationRepo = new FakeConversationRepository().setSavedConversation(existingConversation);
|
|
296
|
+
const agentLoop = new FakeAgentLoop().setResult(agentResult);
|
|
297
|
+
const responseRenderer = new FakeResponseRenderer().setResponse({
|
|
298
|
+
text: `Follow-up answer`,
|
|
299
|
+
csvBuffer: undefined,
|
|
300
|
+
csvFilename: undefined,
|
|
301
|
+
});
|
|
302
|
+
const slackMessenger = new FakeSlackMessenger();
|
|
303
|
+
const adminLogger = new FakeAdminLogger();
|
|
304
|
+
|
|
305
|
+
const useCase = new AnswerQuestion(
|
|
306
|
+
conversationRepo,
|
|
307
|
+
agentLoop as any,
|
|
308
|
+
responseRenderer as any,
|
|
309
|
+
slackMessenger,
|
|
310
|
+
adminLogger,
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
await useCase.execute(question, threadId, userId);
|
|
314
|
+
|
|
315
|
+
// Verify conversation now has 4 messages (2 existing + 2 new)
|
|
316
|
+
const savedConversation = conversationRepo.getSavedConversation();
|
|
317
|
+
expect(savedConversation).not.toBeNull();
|
|
318
|
+
expect(savedConversation!.messages.length).toBe(4);
|
|
319
|
+
expect(savedConversation!.messages[0]!.content).toBe(`First question`);
|
|
320
|
+
expect(savedConversation!.messages[1]!.content).toBe(`First answer`);
|
|
321
|
+
expect(savedConversation!.messages[2]!.content).toBe(`Follow-up question`);
|
|
322
|
+
expect(savedConversation!.messages[3]!.content).toBe(`Follow-up answer`);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it(`should log query with null queryResult when no SQL was executed`, async () => {
|
|
326
|
+
const threadId = ThreadId.create(`C12345`, `1234567890.123456`);
|
|
327
|
+
const question = Question.create(`What is the weather?`);
|
|
328
|
+
const userId = `U12345`;
|
|
329
|
+
|
|
330
|
+
const agentResult: AgentRunResult = {
|
|
331
|
+
finalText: `No SQL needed for this`,
|
|
332
|
+
executedSqls: [],
|
|
333
|
+
queryResult: null,
|
|
334
|
+
inputTokens: 100,
|
|
335
|
+
outputTokens: 50,
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
const conversationRepo = new FakeConversationRepository();
|
|
339
|
+
const agentLoop = new FakeAgentLoop().setResult(agentResult);
|
|
340
|
+
const responseRenderer = new FakeResponseRenderer().setResponse({
|
|
341
|
+
text: `No SQL needed for this`,
|
|
342
|
+
csvBuffer: undefined,
|
|
343
|
+
csvFilename: undefined,
|
|
344
|
+
});
|
|
345
|
+
const slackMessenger = new FakeSlackMessenger();
|
|
346
|
+
const adminLogger = new FakeAdminLogger();
|
|
347
|
+
|
|
348
|
+
const useCase = new AnswerQuestion(
|
|
349
|
+
conversationRepo,
|
|
350
|
+
agentLoop as any,
|
|
351
|
+
responseRenderer as any,
|
|
352
|
+
slackMessenger,
|
|
353
|
+
adminLogger,
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
await useCase.execute(question, threadId, userId);
|
|
357
|
+
|
|
358
|
+
const logEntry = adminLogger.getLoggedEntry();
|
|
359
|
+
expect(logEntry!.rowCount).toBe(0);
|
|
360
|
+
expect(logEntry!.sqls).toEqual([]);
|
|
361
|
+
});
|
|
362
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type ConversationRepository from "@/domain/ports/ConversationRepository.js";
|
|
2
|
+
import type SlackMessenger from "@/domain/ports/SlackMessenger.js";
|
|
3
|
+
import type AdminLogger from "@/domain/ports/AdminLogger.js";
|
|
4
|
+
import type Question from "@/domain/value-objects/Question.js";
|
|
5
|
+
import type ThreadId from "@/domain/value-objects/ThreadId.js";
|
|
6
|
+
import Conversation from "@/domain/entities/Conversation.js";
|
|
7
|
+
import ConversationMessage from "@/domain/entities/ConversationMessage.js";
|
|
8
|
+
import type AgentLoop from "../agent/AgentLoop.js";
|
|
9
|
+
import type ResponseRenderer from "../formatting/ResponseRenderer.js";
|
|
10
|
+
|
|
11
|
+
class AnswerQuestion {
|
|
12
|
+
constructor(
|
|
13
|
+
private readonly conversationRepository: ConversationRepository,
|
|
14
|
+
private readonly agentLoop: AgentLoop,
|
|
15
|
+
private readonly responseRenderer: ResponseRenderer,
|
|
16
|
+
private readonly slackMessenger: SlackMessenger,
|
|
17
|
+
private readonly adminLogger: AdminLogger,
|
|
18
|
+
) {}
|
|
19
|
+
|
|
20
|
+
async execute(question: Question, threadId: ThreadId, userId: string): Promise<void> {
|
|
21
|
+
const startTime = Date.now();
|
|
22
|
+
|
|
23
|
+
// 1. Charger conversation (sauf si --no-context)
|
|
24
|
+
let conversation: Conversation | null = null;
|
|
25
|
+
if (!question.flags.noContext) {
|
|
26
|
+
conversation = await this.conversationRepository.get(threadId);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 2. Exécuter la boucle agentique
|
|
30
|
+
const agentResult = await this.agentLoop.run({
|
|
31
|
+
conversation,
|
|
32
|
+
question,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// 3. Sauvegarder la conversation mise à jour
|
|
36
|
+
const updatedConversation = (conversation ?? Conversation.create(threadId))
|
|
37
|
+
.append(ConversationMessage.create(`user`, question.text))
|
|
38
|
+
.append(ConversationMessage.create(`assistant`, agentResult.finalText));
|
|
39
|
+
await this.conversationRepository.save(updatedConversation);
|
|
40
|
+
|
|
41
|
+
// 4. Formatter la réponse
|
|
42
|
+
const rendered = this.responseRenderer.render({
|
|
43
|
+
finalText: agentResult.finalText,
|
|
44
|
+
queryResult: agentResult.queryResult,
|
|
45
|
+
executedSqls: agentResult.executedSqls,
|
|
46
|
+
flags: question.flags,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// 5. Poster sur Slack
|
|
50
|
+
if (rendered.csvBuffer && rendered.csvFilename) {
|
|
51
|
+
await this.slackMessenger.postCsv(threadId, rendered.text, rendered.csvBuffer, rendered.csvFilename);
|
|
52
|
+
} else {
|
|
53
|
+
await this.slackMessenger.post(threadId, rendered.text);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 6. Logger pour admin
|
|
57
|
+
await this.adminLogger.logQuery({
|
|
58
|
+
userId,
|
|
59
|
+
question: question.text,
|
|
60
|
+
sqls: [...agentResult.executedSqls],
|
|
61
|
+
rowCount: agentResult.queryResult?.rowCount ?? 0,
|
|
62
|
+
durationMs: Date.now() - startTime,
|
|
63
|
+
inputTokens: agentResult.inputTokens,
|
|
64
|
+
outputTokens: agentResult.outputTokens,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export default AnswerQuestion;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import ParseQuestion from "../usecases/ParseQuestion.js";
|
|
3
|
+
|
|
4
|
+
describe(`ParseQuestion`, () => {
|
|
5
|
+
it(`strips single mention`, () => {
|
|
6
|
+
const q = ParseQuestion.execute(`<@U123456> Combien de commandes ?`);
|
|
7
|
+
expect(q.text).toBe(`Combien de commandes ?`);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it(`strips multiple mentions`, () => {
|
|
11
|
+
const q = ParseQuestion.execute(`<@U123456> <@U789ABC> Combien ?`);
|
|
12
|
+
expect(q.text).toBe(`Combien ?`);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it(`preserves flags`, () => {
|
|
16
|
+
const q = ParseQuestion.execute(`<@U123456> Combien ? --sql`);
|
|
17
|
+
expect(q.flags.showSql).toBe(true);
|
|
18
|
+
expect(q.text).toBe(`Combien ?`);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it(`handles text without mention`, () => {
|
|
22
|
+
const q = ParseQuestion.execute(`Combien de commandes ?`);
|
|
23
|
+
expect(q.text).toBe(`Combien de commandes ?`);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it(`preserves raw input unchanged`, () => {
|
|
27
|
+
const raw = `<@U123456> Combien ? --sql`;
|
|
28
|
+
const q = ParseQuestion.execute(raw);
|
|
29
|
+
expect(q.raw).toBe(raw);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it(`extracts all flags correctly`, () => {
|
|
33
|
+
const q = ParseQuestion.execute(`<@U123> test --sql --debug --no-context`);
|
|
34
|
+
expect(q.flags.showSql).toBe(true);
|
|
35
|
+
expect(q.flags.debug).toBe(true);
|
|
36
|
+
expect(q.flags.noContext).toBe(true);
|
|
37
|
+
expect(q.text).toBe(`test`);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
File without changes
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { createContainer } from "./Container.js";
|
|
3
|
+
|
|
4
|
+
describe("Container", () => {
|
|
5
|
+
describe("createContainer()", () => {
|
|
6
|
+
it("should instantiate all services without throwing", () => {
|
|
7
|
+
expect(() => {
|
|
8
|
+
createContainer();
|
|
9
|
+
}).not.toThrow();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("should return app, answerQuestion, and logger", () => {
|
|
13
|
+
const container = createContainer();
|
|
14
|
+
|
|
15
|
+
expect(container).toHaveProperty("app");
|
|
16
|
+
expect(container).toHaveProperty("answerQuestion");
|
|
17
|
+
expect(container).toHaveProperty("logger");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("should return a logger instance with methods", () => {
|
|
21
|
+
const { logger } = createContainer();
|
|
22
|
+
|
|
23
|
+
expect(typeof logger.debug).toBe("function");
|
|
24
|
+
expect(typeof logger.info).toBe("function");
|
|
25
|
+
expect(typeof logger.warn).toBe("function");
|
|
26
|
+
expect(typeof logger.error).toBe("function");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("should wire answerQuestion with all dependencies", () => {
|
|
30
|
+
const { answerQuestion } = createContainer();
|
|
31
|
+
|
|
32
|
+
expect(typeof answerQuestion.execute).toBe("function");
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
2
|
+
import { env } from "./env.js";
|
|
3
|
+
import PinoLogger from "@/infrastructure/logging/PinoLogger.js";
|
|
4
|
+
import FileSystemSchemaCatalog from "@/infrastructure/schemas/FileSystemSchemaCatalog.js";
|
|
5
|
+
import RegexSqlValidator from "@/infrastructure/sql/RegexSqlValidator.js";
|
|
6
|
+
import createPool from "@/infrastructure/persistence/PostgresPoolFactory.js";
|
|
7
|
+
import PostgresSqlExecutor from "@/infrastructure/persistence/PostgresSqlExecutor.js";
|
|
8
|
+
import InMemoryConversationRepository from "@/infrastructure/persistence/InMemoryConversationRepository.js";
|
|
9
|
+
import AnthropicLLMProvider from "@/infrastructure/llm/AnthropicLLMProvider.js";
|
|
10
|
+
import createSlackApp from "@/infrastructure/slack/SlackApp.js";
|
|
11
|
+
import BoltSlackMessenger from "@/infrastructure/slack/BoltSlackMessenger.js";
|
|
12
|
+
import SlackAdminLogger from "@/infrastructure/slack/SlackAdminLogger.js";
|
|
13
|
+
import AgentLoop from "@/application/agent/AgentLoop.js";
|
|
14
|
+
import ResponseRenderer from "@/application/formatting/ResponseRenderer.js";
|
|
15
|
+
import AnswerQuestion from "@/application/usecases/AnswerQuestion.js";
|
|
16
|
+
|
|
17
|
+
export function createContainer() {
|
|
18
|
+
const logger = new PinoLogger(env.LOG_LEVEL);
|
|
19
|
+
|
|
20
|
+
const schemaCatalog = new FileSystemSchemaCatalog(env.SCHEMAS_DIR);
|
|
21
|
+
|
|
22
|
+
const sqlValidator = new RegexSqlValidator();
|
|
23
|
+
|
|
24
|
+
const pool = createPool(env.DATABASE_URL);
|
|
25
|
+
const sqlExecutor = new PostgresSqlExecutor(pool, env.SQL_TIMEOUT_MS);
|
|
26
|
+
|
|
27
|
+
const conversationRepository = new InMemoryConversationRepository(
|
|
28
|
+
env.CONVERSATION_MAX_THREADS,
|
|
29
|
+
env.CONVERSATION_TTL_MS,
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const anthropicClient = new Anthropic({
|
|
33
|
+
apiKey: env.ANTHROPIC_API_KEY,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const llmProvider = new AnthropicLLMProvider(
|
|
37
|
+
anthropicClient,
|
|
38
|
+
env.LLM_MODEL,
|
|
39
|
+
schemaCatalog,
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const { app } = createSlackApp(env.SLACK_BOT_TOKEN, env.SLACK_SIGNING_SECRET);
|
|
43
|
+
|
|
44
|
+
const slackMessenger = new BoltSlackMessenger(app.client);
|
|
45
|
+
|
|
46
|
+
const adminLogger = new SlackAdminLogger(
|
|
47
|
+
app.client,
|
|
48
|
+
env.SLACK_ADMIN_CHANNEL_ID ?? null,
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const agentLoop = new AgentLoop(
|
|
52
|
+
llmProvider,
|
|
53
|
+
sqlValidator,
|
|
54
|
+
sqlExecutor,
|
|
55
|
+
schemaCatalog,
|
|
56
|
+
env.AGENT_MAX_TURNS,
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const responseRenderer = new ResponseRenderer(env.SQL_CSV_THRESHOLD);
|
|
60
|
+
|
|
61
|
+
const answerQuestion = new AnswerQuestion(
|
|
62
|
+
conversationRepository,
|
|
63
|
+
agentLoop,
|
|
64
|
+
responseRenderer,
|
|
65
|
+
slackMessenger,
|
|
66
|
+
adminLogger,
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
app,
|
|
71
|
+
answerQuestion,
|
|
72
|
+
logger,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { env } from './env.js';
|
|
2
|
+
|
|
3
|
+
export const SQL_MAX_ROWS = env.SQL_MAX_ROWS;
|
|
4
|
+
export const SQL_TIMEOUT_MS = env.SQL_TIMEOUT_MS;
|
|
5
|
+
export const SQL_CSV_THRESHOLD = env.SQL_CSV_THRESHOLD;
|
|
6
|
+
export const AGENT_MAX_TURNS = env.AGENT_MAX_TURNS;
|
|
7
|
+
export const CONVERSATION_TTL_MS = env.CONVERSATION_TTL_MS;
|
|
8
|
+
export const CONVERSATION_MAX_THREADS = env.CONVERSATION_MAX_THREADS;
|
|
9
|
+
export const CONVERSATION_MAX_MESSAGES = env.CONVERSATION_MAX_MESSAGES;
|