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.
Files changed (131) hide show
  1. package/.clever.json +12 -0
  2. package/.dockerignore +13 -0
  3. package/.env.example +28 -0
  4. package/.github/workflows/deploy-production.yaml +34 -0
  5. package/.prettierrc +6 -0
  6. package/.tasks/F1-bootstrap.md +110 -0
  7. package/.tasks/F2-domain-layer.md +173 -0
  8. package/.tasks/F3-application-layer.md +166 -0
  9. package/.tasks/F4-infrastructure-layer.md +229 -0
  10. package/.tasks/F5-config-main.md +160 -0
  11. package/.tasks/F6-schemas-deployment.md +129 -0
  12. package/CLAUDE.md +163 -0
  13. package/Dockerfile +15 -0
  14. package/PRD.md +119 -0
  15. package/docs/schemas/.gitkeep +0 -0
  16. package/docs/schemas/_guidelines.md +89 -0
  17. package/docs/schemas/efarmz_db.md +759 -0
  18. package/docs/schemas/example.md +16 -0
  19. package/eslint.config.mjs +18 -0
  20. package/package.json +54 -0
  21. package/releaserc.json +15 -0
  22. package/src/.gitkeep +0 -0
  23. package/src/application/agent/.gitkeep +0 -0
  24. package/src/application/agent/AgentContext.test.ts +263 -0
  25. package/src/application/agent/AgentContext.ts +93 -0
  26. package/src/application/agent/AgentLoop.test.ts +275 -0
  27. package/src/application/agent/AgentLoop.ts +101 -0
  28. package/src/application/agent/AgentRunResult.ts +11 -0
  29. package/src/application/agent/LLMMessage.ts +16 -0
  30. package/src/application/agent/tools/RunSqlTool.ts +23 -0
  31. package/src/application/formatting/.gitkeep +0 -0
  32. package/src/application/formatting/CsvRenderer.test.ts +162 -0
  33. package/src/application/formatting/CsvRenderer.ts +34 -0
  34. package/src/application/formatting/MonospaceTableRenderer.test.ts +129 -0
  35. package/src/application/formatting/MonospaceTableRenderer.ts +58 -0
  36. package/src/application/formatting/RenderedResponse.ts +7 -0
  37. package/src/application/formatting/ResponseRenderer.test.ts +159 -0
  38. package/src/application/formatting/ResponseRenderer.ts +39 -0
  39. package/src/application/formatting/ScalarRenderer.test.ts +36 -0
  40. package/src/application/formatting/ScalarRenderer.ts +12 -0
  41. package/src/application/usecases/.gitkeep +0 -0
  42. package/src/application/usecases/AnswerQuestion.test.ts +362 -0
  43. package/src/application/usecases/AnswerQuestion.ts +69 -0
  44. package/src/application/usecases/ParseQuestion.test.ts +39 -0
  45. package/src/application/usecases/ParseQuestion.ts +9 -0
  46. package/src/config/.gitkeep +0 -0
  47. package/src/config/Container.test.ts +35 -0
  48. package/src/config/Container.ts +74 -0
  49. package/src/config/constants.ts +9 -0
  50. package/src/config/env.test.ts +103 -0
  51. package/src/config/env.ts +41 -0
  52. package/src/domain/entities/.gitkeep +0 -0
  53. package/src/domain/entities/Conversation.test.ts +69 -0
  54. package/src/domain/entities/Conversation.ts +26 -0
  55. package/src/domain/entities/ConversationMessage.test.ts +49 -0
  56. package/src/domain/entities/ConversationMessage.ts +18 -0
  57. package/src/domain/entities/index.ts +2 -0
  58. package/src/domain/errors/.gitkeep +0 -0
  59. package/src/domain/errors/AgentLoopExceededError.ts +12 -0
  60. package/src/domain/errors/DomainError.test.ts +106 -0
  61. package/src/domain/errors/DomainError.ts +11 -0
  62. package/src/domain/errors/InvalidSqlError.ts +15 -0
  63. package/src/domain/errors/LLMError.ts +15 -0
  64. package/src/domain/errors/SchemaLoadError.ts +15 -0
  65. package/src/domain/errors/SqlExecutionError.ts +15 -0
  66. package/src/domain/errors/index.ts +15 -0
  67. package/src/domain/ports/.gitkeep +0 -0
  68. package/src/domain/ports/AdminLogger.ts +16 -0
  69. package/src/domain/ports/ConversationRepository.ts +10 -0
  70. package/src/domain/ports/LLMProvider.ts +33 -0
  71. package/src/domain/ports/Logger.ts +8 -0
  72. package/src/domain/ports/SchemaCatalog.ts +5 -0
  73. package/src/domain/ports/SlackMessenger.ts +8 -0
  74. package/src/domain/ports/SqlExecutor.ts +8 -0
  75. package/src/domain/ports/SqlValidator.ts +5 -0
  76. package/src/domain/ports/index.ts +17 -0
  77. package/src/domain/value-objects/.gitkeep +0 -0
  78. package/src/domain/value-objects/LLMProviderName.ts +6 -0
  79. package/src/domain/value-objects/QueryResult.test.ts +51 -0
  80. package/src/domain/value-objects/QueryResult.ts +18 -0
  81. package/src/domain/value-objects/Question.test.ts +59 -0
  82. package/src/domain/value-objects/Question.ts +22 -0
  83. package/src/domain/value-objects/QuestionFlags.test.ts +59 -0
  84. package/src/domain/value-objects/QuestionFlags.ts +18 -0
  85. package/src/domain/value-objects/ResponseRendering.ts +7 -0
  86. package/src/domain/value-objects/SqlQuery.test.ts +40 -0
  87. package/src/domain/value-objects/SqlQuery.ts +12 -0
  88. package/src/domain/value-objects/ThreadId.test.ts +68 -0
  89. package/src/domain/value-objects/ThreadId.ts +27 -0
  90. package/src/domain/value-objects/index.ts +13 -0
  91. package/src/infrastructure/llm/.gitkeep +0 -0
  92. package/src/infrastructure/llm/AnthropicLLMProvider.test.ts +229 -0
  93. package/src/infrastructure/llm/AnthropicLLMProvider.ts +45 -0
  94. package/src/infrastructure/llm/index.ts +4 -0
  95. package/src/infrastructure/llm/mappers/AnthropicMessageMapper.test.ts +173 -0
  96. package/src/infrastructure/llm/mappers/AnthropicMessageMapper.ts +34 -0
  97. package/src/infrastructure/llm/prompts/SystemPromptBuilder.test.ts +41 -0
  98. package/src/infrastructure/llm/prompts/SystemPromptBuilder.ts +31 -0
  99. package/src/infrastructure/llm/prompts/ToolDefinitions.ts +7 -0
  100. package/src/infrastructure/logging/.gitkeep +0 -0
  101. package/src/infrastructure/logging/PinoLogger.test.ts +59 -0
  102. package/src/infrastructure/logging/PinoLogger.ts +28 -0
  103. package/src/infrastructure/logging/index.ts +1 -0
  104. package/src/infrastructure/persistence/.gitkeep +0 -0
  105. package/src/infrastructure/persistence/InMemoryConversationRepository.test.ts +325 -0
  106. package/src/infrastructure/persistence/InMemoryConversationRepository.ts +69 -0
  107. package/src/infrastructure/persistence/PostgresPoolFactory.ts +11 -0
  108. package/src/infrastructure/persistence/PostgresSqlExecutor.test.ts +130 -0
  109. package/src/infrastructure/persistence/PostgresSqlExecutor.ts +34 -0
  110. package/src/infrastructure/persistence/index.ts +3 -0
  111. package/src/infrastructure/schemas/.gitkeep +0 -0
  112. package/src/infrastructure/schemas/FileSystemSchemaCatalog.test.ts +163 -0
  113. package/src/infrastructure/schemas/FileSystemSchemaCatalog.ts +35 -0
  114. package/src/infrastructure/schemas/index.ts +4 -0
  115. package/src/infrastructure/slack/.gitkeep +0 -0
  116. package/src/infrastructure/slack/BoltSlackMessenger.test.ts +59 -0
  117. package/src/infrastructure/slack/BoltSlackMessenger.ts +36 -0
  118. package/src/infrastructure/slack/SlackAdminLogger.test.ts +54 -0
  119. package/src/infrastructure/slack/SlackAdminLogger.ts +27 -0
  120. package/src/infrastructure/slack/SlackApp.ts +9 -0
  121. package/src/infrastructure/slack/handlers/AppMentionHandler.ts +52 -0
  122. package/src/infrastructure/slack/handlers/DirectMessageHandler.ts +65 -0
  123. package/src/infrastructure/slack/index.ts +5 -0
  124. package/src/infrastructure/sql/.gitkeep +0 -0
  125. package/src/infrastructure/sql/RegexSqlValidator.test.ts +242 -0
  126. package/src/infrastructure/sql/RegexSqlValidator.ts +53 -0
  127. package/src/infrastructure/sql/index.ts +1 -0
  128. package/src/main.ts +19 -0
  129. package/tsconfig.json +23 -0
  130. package/vitest.config.ts +15 -0
  131. package/vitest.setup.ts +23 -0
@@ -0,0 +1,51 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import QueryResult from "./QueryResult.js";
3
+
4
+ describe("QueryResult", () => {
5
+ it("creates a query result with columns and rows", () => {
6
+ const columns = ["id", "name", "email"];
7
+ const rows = [
8
+ { id: 1, name: "Alice", email: "alice@example.com" },
9
+ { id: 2, name: "Bob", email: "bob@example.com" },
10
+ ];
11
+
12
+ const result = QueryResult.create(columns, rows);
13
+
14
+ expect(result.columns).toEqual(columns);
15
+ expect(result.rows).toEqual(rows);
16
+ expect(result.rowCount).toBe(2);
17
+ });
18
+
19
+ it("handles empty result set", () => {
20
+ const columns = ["id", "name"];
21
+ const rows: Record<string, unknown>[] = [];
22
+
23
+ const result = QueryResult.create(columns, rows);
24
+
25
+ expect(result.columns).toEqual(columns);
26
+ expect(result.rows).toEqual([]);
27
+ expect(result.rowCount).toBe(0);
28
+ });
29
+
30
+ it("calculates row count correctly", () => {
31
+ const columns = ["col1"];
32
+ const rows = Array.from({ length: 100 }, (_, i) => ({ col1: i }));
33
+
34
+ const result = QueryResult.create(columns, rows);
35
+
36
+ expect(result.rowCount).toBe(100);
37
+ });
38
+
39
+ it("preserves row data integrity", () => {
40
+ const columns = ["id", "value"];
41
+ const rows = [
42
+ { id: 1, value: "test", extra: "field" },
43
+ { id: 2, value: null },
44
+ ];
45
+
46
+ const result = QueryResult.create(columns, rows);
47
+
48
+ expect(result.rows[0]).toEqual(rows[0]);
49
+ expect(result.rows[1]).toEqual(rows[1]);
50
+ });
51
+ });
@@ -0,0 +1,18 @@
1
+ interface QueryResult {
2
+ columns: string[];
3
+ rows: Record<string, unknown>[];
4
+ rowCount: number;
5
+ }
6
+
7
+ const QueryResult = {
8
+ create(columns: string[], rows: Record<string, unknown>[]): QueryResult {
9
+ return {
10
+ columns,
11
+ rows,
12
+ rowCount: rows.length,
13
+ };
14
+ },
15
+ };
16
+
17
+ export default QueryResult;
18
+ export type { QueryResult };
@@ -0,0 +1,59 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import Question from "./Question.js";
3
+
4
+ describe("Question", () => {
5
+ describe("create()", () => {
6
+ it("strips Slack mentions from the text", () => {
7
+ const question = Question.create(`<@U123> combien de commandes`);
8
+ expect(question.text).toBe(`combien de commandes`);
9
+ });
10
+
11
+ it("preserves the raw input unchanged", () => {
12
+ const raw = `<@U123> combien de commandes --sql`;
13
+ const question = Question.create(raw);
14
+ expect(question.raw).toBe(raw);
15
+ });
16
+
17
+ it("extracts --sql flag and removes it from text", () => {
18
+ const question = Question.create(`<@U123> combien de commandes --sql`);
19
+ expect(question.flags.showSql).toBe(true);
20
+ expect(question.text).toBe(`combien de commandes`);
21
+ });
22
+
23
+ it("extracts --debug and --no-context flags", () => {
24
+ const question = Question.create(`test --debug --no-context`);
25
+ expect(question.flags.debug).toBe(true);
26
+ expect(question.flags.noContext).toBe(true);
27
+ expect(question.flags.showSql).toBe(false);
28
+ expect(question.text).toBe(`test`);
29
+ });
30
+
31
+ it("defaults all flags to false when none are present", () => {
32
+ const question = Question.create(`simple question`);
33
+ expect(question.flags.showSql).toBe(false);
34
+ expect(question.flags.debug).toBe(false);
35
+ expect(question.flags.noContext).toBe(false);
36
+ expect(question.text).toBe(`simple question`);
37
+ });
38
+
39
+ it("handles flags anywhere in the text", () => {
40
+ const question = Question.create(`--sql what are my orders --debug`);
41
+ expect(question.flags.showSql).toBe(true);
42
+ expect(question.flags.debug).toBe(true);
43
+ expect(question.text).toBe(`what are my orders`);
44
+ });
45
+
46
+ it("trims whitespace after cleaning flags", () => {
47
+ const question = Question.create(` --sql what are my orders `);
48
+ expect(question.text).toBe(`what are my orders`);
49
+ });
50
+
51
+ it("handles multiple mentions", () => {
52
+ const question = Question.create(
53
+ `<@U123> <@U456> what are my orders --sql`,
54
+ );
55
+ expect(question.text).toBe(`what are my orders`);
56
+ expect(question.flags.showSql).toBe(true);
57
+ });
58
+ });
59
+ });
@@ -0,0 +1,22 @@
1
+ import QuestionFlags, { type QuestionFlagsType } from "./QuestionFlags.js";
2
+
3
+ class Question {
4
+ private constructor(
5
+ public readonly raw: string,
6
+ public readonly text: string,
7
+ public readonly flags: QuestionFlagsType,
8
+ ) {}
9
+
10
+ static create(raw: string): Question {
11
+ const flags = QuestionFlags.parse(raw);
12
+ const text = raw
13
+ .replace(/<@U[A-Z0-9]+>/g, ``)
14
+ .replace(/--sql\b/g, ``)
15
+ .replace(/--debug\b/g, ``)
16
+ .replace(/--no-context\b/g, ``)
17
+ .trim();
18
+ return new Question(raw, text, flags);
19
+ }
20
+ }
21
+
22
+ export default Question;
@@ -0,0 +1,59 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import QuestionFlags from "./QuestionFlags.js";
3
+
4
+ describe("QuestionFlags", () => {
5
+ describe("parse()", () => {
6
+ it("detects --sql flag", () => {
7
+ const flags = QuestionFlags.parse(`what are my orders --sql`);
8
+ expect(flags.showSql).toBe(true);
9
+ expect(flags.debug).toBe(false);
10
+ expect(flags.noContext).toBe(false);
11
+ });
12
+
13
+ it("detects --debug flag", () => {
14
+ const flags = QuestionFlags.parse(`what are my orders --debug`);
15
+ expect(flags.debug).toBe(true);
16
+ expect(flags.showSql).toBe(false);
17
+ expect(flags.noContext).toBe(false);
18
+ });
19
+
20
+ it("detects --no-context flag", () => {
21
+ const flags = QuestionFlags.parse(`what are my orders --no-context`);
22
+ expect(flags.noContext).toBe(true);
23
+ expect(flags.showSql).toBe(false);
24
+ expect(flags.debug).toBe(false);
25
+ });
26
+
27
+ it("detects multiple flags", () => {
28
+ const flags = QuestionFlags.parse(
29
+ `what are my orders --sql --debug --no-context`,
30
+ );
31
+ expect(flags.showSql).toBe(true);
32
+ expect(flags.debug).toBe(true);
33
+ expect(flags.noContext).toBe(true);
34
+ });
35
+
36
+ it("returns false for all flags when none present", () => {
37
+ const flags = QuestionFlags.parse(`simple question`);
38
+ expect(flags.showSql).toBe(false);
39
+ expect(flags.debug).toBe(false);
40
+ expect(flags.noContext).toBe(false);
41
+ });
42
+
43
+ it("detects flags at the beginning", () => {
44
+ const flags = QuestionFlags.parse(`--sql what are my orders`);
45
+ expect(flags.showSql).toBe(true);
46
+ });
47
+
48
+ it("detects flags in the middle", () => {
49
+ const flags = QuestionFlags.parse(`what --sql are my orders`);
50
+ expect(flags.showSql).toBe(true);
51
+ });
52
+
53
+ it("does not match partial flag names", () => {
54
+ const flags = QuestionFlags.parse(`what is --sqlalchemy --debugging`);
55
+ expect(flags.showSql).toBe(false);
56
+ expect(flags.debug).toBe(false);
57
+ });
58
+ });
59
+ });
@@ -0,0 +1,18 @@
1
+ interface QuestionFlags {
2
+ readonly showSql: boolean;
3
+ readonly debug: boolean;
4
+ readonly noContext: boolean;
5
+ }
6
+
7
+ const QuestionFlags = {
8
+ parse(text: string): QuestionFlags {
9
+ return {
10
+ showSql: /--sql\b/.test(text),
11
+ debug: /--debug\b/.test(text),
12
+ noContext: /--no-context\b/.test(text),
13
+ };
14
+ },
15
+ };
16
+
17
+ export default QuestionFlags;
18
+ export type { QuestionFlags as QuestionFlagsType };
@@ -0,0 +1,7 @@
1
+ enum ResponseRendering {
2
+ TEXT = `TEXT`,
3
+ TABLE = `TABLE`,
4
+ CSV = `CSV`,
5
+ }
6
+
7
+ export default ResponseRendering;
@@ -0,0 +1,40 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import SqlQuery from "./SqlQuery.js";
3
+ import type SqlValidator from "../ports/SqlValidator.js";
4
+
5
+ describe("SqlQuery", () => {
6
+ const mockValidator: SqlValidator = {
7
+ validate: (raw: string) => raw.toUpperCase(),
8
+ };
9
+
10
+ it("creates a validated SQL query", () => {
11
+ const raw = "select * from users";
12
+ const query = SqlQuery.create(raw, mockValidator);
13
+
14
+ expect(query.raw).toBe("SELECT * FROM USERS");
15
+ });
16
+
17
+ it("invokes the validator during creation", () => {
18
+ let validatedInput: string | null = null;
19
+ const validator: SqlValidator = {
20
+ validate: (raw: string) => {
21
+ validatedInput = raw;
22
+ return raw;
23
+ },
24
+ };
25
+
26
+ SqlQuery.create("test query", validator);
27
+
28
+ expect(validatedInput).toBe("test query");
29
+ });
30
+
31
+ it("stores the validated SQL string", () => {
32
+ const validator: SqlValidator = {
33
+ validate: (raw: string) => `/* validated */ ${raw}`,
34
+ };
35
+
36
+ const query = SqlQuery.create("select 1", validator);
37
+
38
+ expect(query.raw).toBe("/* validated */ select 1");
39
+ });
40
+ });
@@ -0,0 +1,12 @@
1
+ import type SqlValidator from "@/domain/ports/SqlValidator.js";
2
+
3
+ class SqlQuery {
4
+ private constructor(public readonly raw: string) {}
5
+
6
+ static create(raw: string, validator: SqlValidator): SqlQuery {
7
+ const validated = validator.validate(raw);
8
+ return new SqlQuery(validated);
9
+ }
10
+ }
11
+
12
+ export default SqlQuery;
@@ -0,0 +1,68 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import ThreadId from "./ThreadId.js";
3
+
4
+ describe("ThreadId", () => {
5
+ it("creates a ThreadId with valid channelId and threadTs", () => {
6
+ const threadId = ThreadId.create("C123", "1234567890.123456");
7
+
8
+ expect(threadId.channelId).toBe("C123");
9
+ expect(threadId.threadTs).toBe("1234567890.123456");
10
+ });
11
+
12
+ it("throws error when channelId is empty", () => {
13
+ expect(() => ThreadId.create("", "1234567890.123456")).toThrow(
14
+ `ThreadId.channelId cannot be empty`,
15
+ );
16
+ });
17
+
18
+ it("throws error when threadTs is empty", () => {
19
+ expect(() => ThreadId.create("C123", "")).toThrow(
20
+ `ThreadId.threadTs cannot be empty`,
21
+ );
22
+ });
23
+
24
+ it("serializes to string in format 'channelId:threadTs'", () => {
25
+ const threadId = ThreadId.create("C123", "1234567890.123456");
26
+
27
+ expect(threadId.toString()).toBe("C123:1234567890.123456");
28
+ });
29
+
30
+ it("parses serialized string back to ThreadId", () => {
31
+ const original = ThreadId.create("C456", "9876543210.654321");
32
+ const serialized = original.toString();
33
+ const parsed = ThreadId.parse(serialized);
34
+
35
+ expect(parsed.channelId).toBe(original.channelId);
36
+ expect(parsed.threadTs).toBe(original.threadTs);
37
+ });
38
+
39
+ it("parse().toString() returns the original serialized value", () => {
40
+ const serialized = "C789:5555555555.555555";
41
+ const threadId = ThreadId.parse(serialized);
42
+
43
+ expect(threadId.toString()).toBe(serialized);
44
+ });
45
+
46
+ it("throws error on invalid serialized format", () => {
47
+ expect(() => ThreadId.parse("invalid-no-colon")).toThrow(
48
+ `Invalid ThreadId format: expected "channelId:threadTs", got "invalid-no-colon"`,
49
+ );
50
+ });
51
+
52
+ it("throws error on partially invalid serialized format", () => {
53
+ expect(() => ThreadId.parse("C123:")).toThrow(
54
+ `Invalid ThreadId format: expected "channelId:threadTs", got "C123:"`,
55
+ );
56
+ });
57
+
58
+ it("handles colon in threadTs correctly", () => {
59
+ const threadId = ThreadId.create("C123", "1234567890.123456");
60
+ const serialized = threadId.toString();
61
+
62
+ expect(serialized).toBe("C123:1234567890.123456");
63
+ const parsed = ThreadId.parse(serialized);
64
+
65
+ expect(parsed.channelId).toBe("C123");
66
+ expect(parsed.threadTs).toBe("1234567890.123456");
67
+ });
68
+ });
@@ -0,0 +1,27 @@
1
+ class ThreadId {
2
+ private constructor(
3
+ public readonly channelId: string,
4
+ public readonly threadTs: string,
5
+ ) {}
6
+
7
+ static create(channelId: string, threadTs: string): ThreadId {
8
+ if (!channelId) throw new Error(`ThreadId.channelId cannot be empty`);
9
+ if (!threadTs) throw new Error(`ThreadId.threadTs cannot be empty`);
10
+ return new ThreadId(channelId, threadTs);
11
+ }
12
+
13
+ static parse(serialized: string): ThreadId {
14
+ const [channelId, threadTs] = serialized.split(`:`);
15
+ if (!channelId || !threadTs) {
16
+ throw new Error(`Invalid ThreadId format: expected "channelId:threadTs", got "${serialized}"`);
17
+ }
18
+ return ThreadId.create(channelId, threadTs);
19
+ }
20
+
21
+ toString(): string {
22
+ return `${this.channelId}:${this.threadTs}`;
23
+ }
24
+ }
25
+
26
+ export default ThreadId;
27
+ export type { ThreadId };
@@ -0,0 +1,13 @@
1
+ export { default as LLMProviderName } from "./LLMProviderName.js";
2
+
3
+ export { default as Question } from "./Question.js";
4
+
5
+ export { default as QuestionFlags } from "./QuestionFlags.js";
6
+
7
+ export { default as QueryResult } from "./QueryResult.js";
8
+
9
+ export { default as ResponseRendering } from "./ResponseRendering.js";
10
+
11
+ export { default as SqlQuery } from "./SqlQuery.js";
12
+
13
+ export { default as ThreadId } from "./ThreadId.js";
File without changes
@@ -0,0 +1,229 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import type Anthropic from "@anthropic-ai/sdk";
3
+ import AnthropicLLMProvider from "./AnthropicLLMProvider.js";
4
+ import type SchemaCatalog from "@/domain/ports/SchemaCatalog.js";
5
+ import type { LLMRequest } from "@/domain/ports/LLMProvider.js";
6
+ import LLMError from "@/domain/errors/LLMError.js";
7
+
8
+ describe(`AnthropicLLMProvider`, () => {
9
+ let mockClient: Anthropic;
10
+ let mockSchemaCatalog: SchemaCatalog;
11
+ let provider: AnthropicLLMProvider;
12
+
13
+ beforeEach(() => {
14
+ mockClient = {
15
+ messages: {
16
+ create: vi.fn(),
17
+ },
18
+ } as unknown as Anthropic;
19
+
20
+ mockSchemaCatalog = {
21
+ getDocumentation: vi.fn().mockReturnValue(`--- schema.md ---
22
+ Table: users
23
+ - id: UUID
24
+ - name: VARCHAR`),
25
+ };
26
+
27
+ provider = new AnthropicLLMProvider(
28
+ mockClient,
29
+ `claude-3-5-sonnet-20241022`,
30
+ mockSchemaCatalog,
31
+ );
32
+ });
33
+
34
+ it(`should build system prompt with schema caching`, async () => {
35
+ const request: LLMRequest = {
36
+ systemPrompt: `You are an assistant`,
37
+ messages: [{ role: `user`, content: `Hello` }],
38
+ };
39
+
40
+ const mockResponse: Anthropic.Message = {
41
+ id: `msg_123`,
42
+ type: `message`,
43
+ role: `assistant`,
44
+ content: [
45
+ { type: `text`, text: `Hi there` },
46
+ ],
47
+ model: `claude-3-5-sonnet-20241022`,
48
+ stop_reason: `end_turn`,
49
+ stop_sequence: null,
50
+ usage: { input_tokens: 10, output_tokens: 5 },
51
+ };
52
+
53
+ vi.mocked(mockClient.messages.create).mockResolvedValueOnce(mockResponse);
54
+
55
+ await provider.generate(request);
56
+
57
+ const callArgs = vi.mocked(mockClient.messages.create).mock.calls[0]?.[0];
58
+ expect(callArgs?.system).toBeInstanceOf(Array);
59
+ expect((callArgs?.system as Array<unknown>)?.length).toBe(2);
60
+
61
+ const systemPrompt = callArgs?.system as Array<{
62
+ type: string;
63
+ cache_control?: { type: string };
64
+ }>;
65
+ expect(systemPrompt[0]?.type).toBe(`text`);
66
+ expect(systemPrompt[1]?.cache_control).toEqual({ type: `ephemeral` });
67
+ });
68
+
69
+ it(`should call Anthropic API with correct parameters`, async () => {
70
+ const request: LLMRequest = {
71
+ systemPrompt: `You are an assistant`,
72
+ messages: [
73
+ { role: `user`, content: `What is 2+2?` },
74
+ { role: `assistant`, content: `4` },
75
+ ],
76
+ tools: [
77
+ {
78
+ name: `run_sql`,
79
+ description: `Execute SQL`,
80
+ inputSchema: { type: `object` },
81
+ },
82
+ ],
83
+ };
84
+
85
+ const mockResponse: Anthropic.Message = {
86
+ id: `msg_456`,
87
+ type: `message`,
88
+ role: `assistant`,
89
+ content: [{ type: `text`, text: `The answer is 4` }],
90
+ model: `claude-3-5-sonnet-20241022`,
91
+ stop_reason: `end_turn`,
92
+ stop_sequence: null,
93
+ usage: { input_tokens: 20, output_tokens: 10 },
94
+ };
95
+
96
+ vi.mocked(mockClient.messages.create).mockResolvedValueOnce(mockResponse);
97
+
98
+ await provider.generate(request);
99
+
100
+ expect(vi.mocked(mockClient.messages.create)).toHaveBeenCalledOnce();
101
+ const callArgs = vi.mocked(mockClient.messages.create).mock.calls[0]?.[0];
102
+
103
+ expect(callArgs?.model).toBe(`claude-3-5-sonnet-20241022`);
104
+ expect(callArgs?.max_tokens).toBe(4096);
105
+ expect((callArgs?.messages as unknown[])?.length).toBe(2);
106
+ expect((callArgs?.tools as unknown[])?.length).toBe(1);
107
+ });
108
+
109
+ it(`should return LLMResponse with correct structure`, async () => {
110
+ const request: LLMRequest = {
111
+ systemPrompt: `You are an assistant`,
112
+ messages: [{ role: `user`, content: `Query data` }],
113
+ };
114
+
115
+ const mockResponse: Anthropic.Message = {
116
+ id: `msg_789`,
117
+ type: `message`,
118
+ role: `assistant`,
119
+ content: [{ type: `text`, text: `Here is the data` }],
120
+ model: `claude-3-5-sonnet-20241022`,
121
+ stop_reason: `end_turn`,
122
+ stop_sequence: null,
123
+ usage: { input_tokens: 15, output_tokens: 8 },
124
+ };
125
+
126
+ vi.mocked(mockClient.messages.create).mockResolvedValueOnce(mockResponse);
127
+
128
+ const response = await provider.generate(request);
129
+
130
+ expect(response.content).toBe(`Here is the data`);
131
+ expect(response.stopReason).toBe(`end_turn`);
132
+ expect(response.inputTokens).toBe(15);
133
+ expect(response.outputTokens).toBe(8);
134
+ expect(response.toolUses).toEqual([]);
135
+ });
136
+
137
+ it(`should wrap Anthropic errors in LLMError`, async () => {
138
+ const request: LLMRequest = {
139
+ systemPrompt: `You are an assistant`,
140
+ messages: [{ role: `user`, content: `Hello` }],
141
+ };
142
+
143
+ const error = new Error(`API rate limit exceeded`);
144
+ vi.mocked(mockClient.messages.create).mockRejectedValueOnce(error);
145
+
146
+ try {
147
+ await provider.generate(request);
148
+ expect.fail(`Should have thrown LLMError`);
149
+ } catch (err) {
150
+ expect(err).toBeInstanceOf(LLMError);
151
+ if (err instanceof LLMError) {
152
+ expect(err.provider).toBe(`anthropic`);
153
+ expect(err.details).toBe(`API rate limit exceeded`);
154
+ }
155
+ }
156
+ });
157
+
158
+ it(`should call getDocumentation on schema catalog`, async () => {
159
+ const request: LLMRequest = {
160
+ systemPrompt: `You are an assistant`,
161
+ messages: [{ role: `user`, content: `Hello` }],
162
+ };
163
+
164
+ const mockResponse: Anthropic.Message = {
165
+ id: `msg_999`,
166
+ type: `message`,
167
+ role: `assistant`,
168
+ content: [{ type: `text`, text: `Response` }],
169
+ model: `claude-3-5-sonnet-20241022`,
170
+ stop_reason: `end_turn`,
171
+ stop_sequence: null,
172
+ usage: { input_tokens: 10, output_tokens: 5 },
173
+ };
174
+
175
+ vi.mocked(mockClient.messages.create).mockResolvedValueOnce(mockResponse);
176
+
177
+ await provider.generate(request);
178
+
179
+ expect(
180
+ vi.mocked(mockSchemaCatalog.getDocumentation),
181
+ ).toHaveBeenCalledOnce();
182
+ });
183
+
184
+ it(`should handle tool uses in response`, async () => {
185
+ const request: LLMRequest = {
186
+ systemPrompt: `You are an assistant`,
187
+ messages: [{ role: `user`, content: `Get user count` }],
188
+ tools: [
189
+ {
190
+ name: `run_sql`,
191
+ description: `Execute SQL`,
192
+ inputSchema: { type: `object` },
193
+ },
194
+ ],
195
+ };
196
+
197
+ const mockResponse: Anthropic.Message = {
198
+ id: `msg_tool`,
199
+ type: `message`,
200
+ role: `assistant`,
201
+ content: [
202
+ { type: `text`, text: `Let me execute:` },
203
+ {
204
+ type: `tool_use`,
205
+ id: `tool_call_1`,
206
+ name: `run_sql`,
207
+ input: { sql: `SELECT COUNT(*) FROM users` },
208
+ } as unknown as Anthropic.ToolUseBlock,
209
+ ],
210
+ model: `claude-3-5-sonnet-20241022`,
211
+ stop_reason: `tool_use`,
212
+ stop_sequence: null,
213
+ usage: { input_tokens: 25, output_tokens: 15 },
214
+ };
215
+
216
+ vi.mocked(mockClient.messages.create).mockResolvedValueOnce(mockResponse);
217
+
218
+ const response = await provider.generate(request);
219
+
220
+ expect(response.content).toBe(`Let me execute:`);
221
+ expect(response.toolUses).toHaveLength(1);
222
+ expect(response.toolUses[0]).toEqual({
223
+ id: `tool_call_1`,
224
+ name: `run_sql`,
225
+ input: { sql: `SELECT COUNT(*) FROM users` },
226
+ });
227
+ expect(response.stopReason).toBe(`tool_use`);
228
+ });
229
+ });
@@ -0,0 +1,45 @@
1
+ import Anthropic from "@anthropic-ai/sdk";
2
+ import type LLMProvider from "@/domain/ports/LLMProvider.js";
3
+ import type {
4
+ LLMRequest,
5
+ LLMResponse,
6
+ } from "@/domain/ports/LLMProvider.js";
7
+ import type SchemaCatalog from "@/domain/ports/SchemaCatalog.js";
8
+ import LLMError from "@/domain/errors/LLMError.js";
9
+ import SystemPromptBuilder from "./prompts/SystemPromptBuilder.js";
10
+ import AnthropicMessageMapper from "./mappers/AnthropicMessageMapper.js";
11
+
12
+ class AnthropicLLMProvider implements LLMProvider {
13
+ constructor(
14
+ private readonly client: Anthropic,
15
+ private readonly model: string,
16
+ private readonly schemaCatalog: SchemaCatalog,
17
+ ) {}
18
+
19
+ async generate(request: LLMRequest): Promise<LLMResponse> {
20
+ try {
21
+ const schemaDoc = this.schemaCatalog.getDocumentation();
22
+ const systemPrompt = SystemPromptBuilder.build(schemaDoc);
23
+
24
+ const response = await this.client.messages.create({
25
+ model: this.model,
26
+ max_tokens: 4096,
27
+ system: systemPrompt as unknown as string | Anthropic.TextBlockParam[],
28
+ messages: AnthropicMessageMapper.toSDK(request.messages),
29
+ tools: request.tools?.map((t) => ({
30
+ name: t.name,
31
+ description: t.description,
32
+ input_schema: t.inputSchema,
33
+ })) as Anthropic.Tool[] | undefined,
34
+ });
35
+
36
+ return AnthropicMessageMapper.fromSDK(response);
37
+ } catch (err) {
38
+ const errorMessage =
39
+ err instanceof Error ? err.message : String(err);
40
+ throw new LLMError(`anthropic`, errorMessage);
41
+ }
42
+ }
43
+ }
44
+
45
+ export default AnthropicLLMProvider;
@@ -0,0 +1,4 @@
1
+ export { default as AnthropicLLMProvider } from "./AnthropicLLMProvider.js";
2
+ export { default as SystemPromptBuilder } from "./prompts/SystemPromptBuilder.js";
3
+ export { default as AnthropicMessageMapper } from "./mappers/AnthropicMessageMapper.js";
4
+ export { default as ToolDefinitions } from "./prompts/ToolDefinitions.js";