@bpmnkit/casen-worker-ai 0.1.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 (70) hide show
  1. package/.turbo/turbo-build.log +4 -0
  2. package/.turbo/turbo-check.log +5 -0
  3. package/.turbo/turbo-test.log +18 -0
  4. package/.turbo/turbo-typecheck.log +4 -0
  5. package/CHANGELOG.md +7 -0
  6. package/LICENSE +21 -0
  7. package/README.md +177 -0
  8. package/dist/config.d.ts +13 -0
  9. package/dist/config.d.ts.map +1 -0
  10. package/dist/config.js +19 -0
  11. package/dist/config.js.map +1 -0
  12. package/dist/config.test.d.ts +2 -0
  13. package/dist/config.test.d.ts.map +1 -0
  14. package/dist/config.test.js +35 -0
  15. package/dist/config.test.js.map +1 -0
  16. package/dist/index.d.ts +4 -0
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/index.js +53 -0
  19. package/dist/index.js.map +1 -0
  20. package/dist/llm.d.ts +18 -0
  21. package/dist/llm.d.ts.map +1 -0
  22. package/dist/llm.js +57 -0
  23. package/dist/llm.js.map +1 -0
  24. package/dist/operations/classify.d.ts +16 -0
  25. package/dist/operations/classify.d.ts.map +1 -0
  26. package/dist/operations/classify.js +74 -0
  27. package/dist/operations/classify.js.map +1 -0
  28. package/dist/operations/classify.test.d.ts +2 -0
  29. package/dist/operations/classify.test.d.ts.map +1 -0
  30. package/dist/operations/classify.test.js +74 -0
  31. package/dist/operations/classify.test.js.map +1 -0
  32. package/dist/operations/decide.d.ts +16 -0
  33. package/dist/operations/decide.d.ts.map +1 -0
  34. package/dist/operations/decide.js +60 -0
  35. package/dist/operations/decide.js.map +1 -0
  36. package/dist/operations/decide.test.d.ts +2 -0
  37. package/dist/operations/decide.test.d.ts.map +1 -0
  38. package/dist/operations/decide.test.js +65 -0
  39. package/dist/operations/decide.test.js.map +1 -0
  40. package/dist/operations/extract.d.ts +16 -0
  41. package/dist/operations/extract.d.ts.map +1 -0
  42. package/dist/operations/extract.js +70 -0
  43. package/dist/operations/extract.js.map +1 -0
  44. package/dist/operations/extract.test.d.ts +2 -0
  45. package/dist/operations/extract.test.d.ts.map +1 -0
  46. package/dist/operations/extract.test.js +65 -0
  47. package/dist/operations/extract.test.js.map +1 -0
  48. package/dist/operations/summarize.d.ts +16 -0
  49. package/dist/operations/summarize.d.ts.map +1 -0
  50. package/dist/operations/summarize.js +59 -0
  51. package/dist/operations/summarize.js.map +1 -0
  52. package/dist/operations/summarize.test.d.ts +2 -0
  53. package/dist/operations/summarize.test.d.ts.map +1 -0
  54. package/dist/operations/summarize.test.js +42 -0
  55. package/dist/operations/summarize.test.js.map +1 -0
  56. package/package.json +69 -0
  57. package/src/config.test.ts +40 -0
  58. package/src/config.ts +27 -0
  59. package/src/index.ts +54 -0
  60. package/src/llm.ts +67 -0
  61. package/src/operations/classify.test.ts +101 -0
  62. package/src/operations/classify.ts +85 -0
  63. package/src/operations/decide.test.ts +82 -0
  64. package/src/operations/decide.ts +70 -0
  65. package/src/operations/extract.test.ts +80 -0
  66. package/src/operations/extract.ts +80 -0
  67. package/src/operations/summarize.test.ts +48 -0
  68. package/src/operations/summarize.ts +68 -0
  69. package/tsconfig.json +10 -0
  70. package/tsconfig.tsbuildinfo +1 -0
package/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "@bpmnkit/casen-worker-ai",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "description": "AI task worker plugin for casen — classify, summarize, extract, and decide using Claude",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
+ }
13
+ },
14
+ "keywords": [
15
+ "casen-plugin",
16
+ "casen-worker",
17
+ "camunda",
18
+ "worker",
19
+ "ai",
20
+ "claude",
21
+ "anthropic",
22
+ "bpmnkit"
23
+ ],
24
+ "license": "MIT",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "https://github.com/bpmnkit/monorepo"
28
+ },
29
+ "homepage": "https://bpmnkit.com",
30
+ "bugs": {
31
+ "url": "https://github.com/bpmnkit/monorepo/issues"
32
+ },
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "casen": {
37
+ "group": "ai-worker",
38
+ "commands": [
39
+ {
40
+ "name": "classify",
41
+ "description": "Classify text into one of the given categories"
42
+ },
43
+ {
44
+ "name": "summarize",
45
+ "description": "Summarize text to a given length and style"
46
+ },
47
+ {
48
+ "name": "extract",
49
+ "description": "Extract structured fields from unstructured text"
50
+ },
51
+ {
52
+ "name": "decide",
53
+ "description": "Make a boolean decision based on a question, context, and optional policy"
54
+ }
55
+ ]
56
+ },
57
+ "scripts": {
58
+ "build": "tsc",
59
+ "typecheck": "tsc --noEmit",
60
+ "check": "biome check .",
61
+ "test": "vitest run"
62
+ },
63
+ "dependencies": {
64
+ "@anthropic-ai/sdk": "^0.40.0"
65
+ },
66
+ "devDependencies": {
67
+ "@bpmnkit/cli-sdk": "workspace:*"
68
+ }
69
+ }
@@ -0,0 +1,40 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "vitest"
2
+ import { resolveConfig } from "./config.js"
3
+
4
+ describe("resolveConfig", () => {
5
+ const original = { ...process.env }
6
+
7
+ beforeEach(() => {
8
+ for (const key of ["ANTHROPIC_API_KEY", "AI_MODEL", "AI_MAX_TOKENS", "AI_TIMEOUT_MS"]) {
9
+ Reflect.deleteProperty(process.env, key)
10
+ }
11
+ })
12
+
13
+ afterEach(() => {
14
+ Object.assign(process.env, original)
15
+ })
16
+
17
+ it("throws when ANTHROPIC_API_KEY is not set", () => {
18
+ expect(() => resolveConfig()).toThrow("ANTHROPIC_API_KEY")
19
+ })
20
+
21
+ it("returns defaults when only API key is set", () => {
22
+ process.env.ANTHROPIC_API_KEY = "sk-test"
23
+ const cfg = resolveConfig()
24
+ expect(cfg.apiKey).toBe("sk-test")
25
+ expect(cfg.model).toBe("claude-3-5-haiku-20241022")
26
+ expect(cfg.maxTokens).toBe(1024)
27
+ expect(cfg.timeoutMs).toBe(60000)
28
+ })
29
+
30
+ it("overrides defaults from env vars", () => {
31
+ process.env.ANTHROPIC_API_KEY = "sk-test"
32
+ process.env.AI_MODEL = "claude-opus-4-6"
33
+ process.env.AI_MAX_TOKENS = "2048"
34
+ process.env.AI_TIMEOUT_MS = "30000"
35
+ const cfg = resolveConfig()
36
+ expect(cfg.model).toBe("claude-opus-4-6")
37
+ expect(cfg.maxTokens).toBe(2048)
38
+ expect(cfg.timeoutMs).toBe(30000)
39
+ })
40
+ })
package/src/config.ts ADDED
@@ -0,0 +1,27 @@
1
+ export interface AiWorkerConfig {
2
+ apiKey: string
3
+ model: string
4
+ maxTokens: number
5
+ timeoutMs: number
6
+ }
7
+
8
+ /**
9
+ * Resolves config from environment variables.
10
+ * Throws immediately if ANTHROPIC_API_KEY is absent so the error surfaces at
11
+ * worker startup rather than mid-job.
12
+ */
13
+ export function resolveConfig(): AiWorkerConfig {
14
+ const apiKey = process.env.ANTHROPIC_API_KEY
15
+ if (!apiKey) {
16
+ throw new Error(
17
+ "ANTHROPIC_API_KEY is not set. Export it before starting the AI worker:\n" +
18
+ " export ANTHROPIC_API_KEY=sk-ant-...",
19
+ )
20
+ }
21
+ return {
22
+ apiKey,
23
+ model: process.env.AI_MODEL ?? "claude-3-5-haiku-20241022",
24
+ maxTokens: Number(process.env.AI_MAX_TOKENS ?? "1024"),
25
+ timeoutMs: Number(process.env.AI_TIMEOUT_MS ?? "60000"),
26
+ }
27
+ }
package/src/index.ts ADDED
@@ -0,0 +1,54 @@
1
+ import { type CasenPlugin, createWorkerCommand } from "@bpmnkit/cli-sdk"
2
+ import { resolveConfig } from "./config.js"
3
+ import { classify } from "./operations/classify.js"
4
+ import { decide } from "./operations/decide.js"
5
+ import { extract } from "./operations/extract.js"
6
+ import { summarize } from "./operations/summarize.js"
7
+
8
+ const plugin: CasenPlugin = {
9
+ id: "com.bpmnkit.casen-worker-ai",
10
+ name: "AI Worker",
11
+ version: "0.1.0",
12
+ groups: [
13
+ {
14
+ name: "ai-worker",
15
+ description: "AI-powered job workers — classify, summarize, extract, decide",
16
+ commands: [
17
+ createWorkerCommand({
18
+ jobType: "com.bpmnkit.ai.classify",
19
+ description: "Classify text into one of the given categories",
20
+ defaultVariables: { category: "unknown", confidence: 0, rationale: "no handler" },
21
+ async processJob(job) {
22
+ return classify(job, resolveConfig())
23
+ },
24
+ }),
25
+ createWorkerCommand({
26
+ jobType: "com.bpmnkit.ai.summarize",
27
+ description: "Summarize text to a given length and style",
28
+ defaultVariables: { summary: "", wordCount: 0 },
29
+ async processJob(job) {
30
+ return summarize(job, resolveConfig())
31
+ },
32
+ }),
33
+ createWorkerCommand({
34
+ jobType: "com.bpmnkit.ai.extract",
35
+ description: "Extract structured fields from unstructured text",
36
+ defaultVariables: { extracted: {}, missingFields: [] },
37
+ async processJob(job) {
38
+ return extract(job, resolveConfig())
39
+ },
40
+ }),
41
+ createWorkerCommand({
42
+ jobType: "com.bpmnkit.ai.decide",
43
+ description: "Make a boolean decision based on a question, context, and optional policy",
44
+ defaultVariables: { decision: false, rationale: "", confidence: 0 },
45
+ async processJob(job) {
46
+ return decide(job, resolveConfig())
47
+ },
48
+ }),
49
+ ],
50
+ },
51
+ ],
52
+ }
53
+
54
+ export default plugin
package/src/llm.ts ADDED
@@ -0,0 +1,67 @@
1
+ import Anthropic from "@anthropic-ai/sdk"
2
+ import type { AiWorkerConfig } from "./config.js"
3
+
4
+ export class RetryableError extends Error {
5
+ constructor(message: string) {
6
+ super(message)
7
+ this.name = "RetryableError"
8
+ }
9
+ }
10
+
11
+ /**
12
+ * Calls the Anthropic messages API and returns the text content.
13
+ *
14
+ * Throws {@link RetryableError} for transient failures (rate limits, timeouts,
15
+ * 5xx errors) so callers can return `{ outcome: "fail" }` and let Camunda retry.
16
+ * Throws a plain Error for hard failures (auth, invalid request).
17
+ */
18
+ export async function callLlm(
19
+ systemPrompt: string,
20
+ userMessage: string,
21
+ config: AiWorkerConfig,
22
+ ): Promise<string> {
23
+ const client = new Anthropic({
24
+ apiKey: config.apiKey,
25
+ timeout: config.timeoutMs,
26
+ maxRetries: 0, // we let Camunda handle retries via failJob
27
+ })
28
+
29
+ let message: Anthropic.Message
30
+ try {
31
+ message = await client.messages.create({
32
+ model: config.model,
33
+ max_tokens: config.maxTokens,
34
+ system: systemPrompt,
35
+ messages: [{ role: "user", content: userMessage }],
36
+ })
37
+ } catch (err) {
38
+ if (
39
+ err instanceof Anthropic.RateLimitError ||
40
+ err instanceof Anthropic.APIConnectionTimeoutError
41
+ ) {
42
+ throw new RetryableError(err.message)
43
+ }
44
+ if (err instanceof Anthropic.InternalServerError) {
45
+ throw new RetryableError(err.message)
46
+ }
47
+ throw err
48
+ }
49
+
50
+ const block = message.content[0]
51
+ if (!block || block.type !== "text") {
52
+ throw new Error("Unexpected response shape from Anthropic API")
53
+ }
54
+ return block.text
55
+ }
56
+
57
+ /**
58
+ * Parses an LLM response as JSON. Handles responses wrapped in a markdown
59
+ * code fence (```json ... ```) which models sometimes emit.
60
+ */
61
+ export function parseJsonResponse<T>(text: string): T {
62
+ const stripped = text
63
+ .replace(/^```(?:json)?\s*/i, "")
64
+ .replace(/\s*```\s*$/, "")
65
+ .trim()
66
+ return JSON.parse(stripped) as T
67
+ }
@@ -0,0 +1,101 @@
1
+ import { describe, expect, it, vi } from "vitest"
2
+ import type { AiWorkerConfig } from "../config.js"
3
+ import * as llmModule from "../llm.js"
4
+ import { classify } from "./classify.js"
5
+
6
+ const cfg: AiWorkerConfig = {
7
+ apiKey: "test",
8
+ model: "claude-test",
9
+ maxTokens: 512,
10
+ timeoutMs: 5000,
11
+ }
12
+
13
+ function makeJob(variables: Record<string, unknown>) {
14
+ return {
15
+ jobKey: "1",
16
+ processDefinitionId: "p1",
17
+ elementId: "e1",
18
+ processInstanceKey: "i1",
19
+ variables,
20
+ }
21
+ }
22
+
23
+ describe("classify", () => {
24
+ it("returns complete with valid category", async () => {
25
+ vi.spyOn(llmModule, "callLlm").mockResolvedValue(
26
+ JSON.stringify({ category: "billing", confidence: 0.9, rationale: "mentions invoice" }),
27
+ )
28
+ const result = await classify(
29
+ makeJob({ input: "I need help with my invoice", categories: ["billing", "technical"] }),
30
+ cfg,
31
+ )
32
+ expect(result.outcome).toBe("complete")
33
+ if (result.outcome === "complete") {
34
+ expect(result.variables.category).toBe("billing")
35
+ expect(result.variables.confidence).toBe(0.9)
36
+ }
37
+ })
38
+
39
+ it("returns error when model returns category outside allowed list", async () => {
40
+ vi.spyOn(llmModule, "callLlm").mockResolvedValue(
41
+ JSON.stringify({ category: "other", confidence: 0.5, rationale: "unsure" }),
42
+ )
43
+ const result = await classify(
44
+ makeJob({ input: "anything", categories: ["billing", "technical"] }),
45
+ cfg,
46
+ )
47
+ expect(result.outcome).toBe("error")
48
+ if (result.outcome === "error") {
49
+ expect(result.errorCode).toBe("AI_INVALID_CATEGORY")
50
+ }
51
+ })
52
+
53
+ it("returns error when model returns non-JSON", async () => {
54
+ vi.spyOn(llmModule, "callLlm").mockResolvedValue("Sorry, I cannot classify this.")
55
+ const result = await classify(
56
+ makeJob({ input: "anything", categories: ["billing", "technical"] }),
57
+ cfg,
58
+ )
59
+ expect(result.outcome).toBe("error")
60
+ if (result.outcome === "error") {
61
+ expect(result.errorCode).toBe("AI_PARSE_ERROR")
62
+ }
63
+ })
64
+
65
+ it("returns fail on RetryableError", async () => {
66
+ vi.spyOn(llmModule, "callLlm").mockRejectedValue(new llmModule.RetryableError("rate limited"))
67
+ const result = await classify(makeJob({ input: "anything", categories: ["billing"] }), cfg)
68
+ expect(result.outcome).toBe("fail")
69
+ if (result.outcome === "fail") {
70
+ expect(result.retryBackOff).toBe(30_000)
71
+ }
72
+ })
73
+
74
+ it("returns error on hard API failure", async () => {
75
+ vi.spyOn(llmModule, "callLlm").mockRejectedValue(new Error("invalid api key"))
76
+ const result = await classify(makeJob({ input: "anything", categories: ["billing"] }), cfg)
77
+ expect(result.outcome).toBe("error")
78
+ if (result.outcome === "error") {
79
+ expect(result.errorCode).toBe("AI_API_ERROR")
80
+ }
81
+ })
82
+
83
+ it("returns error when categories variable is missing", async () => {
84
+ const result = await classify(makeJob({ input: "text" }), cfg)
85
+ expect(result.outcome).toBe("error")
86
+ if (result.outcome === "error") {
87
+ expect(result.errorCode).toBe("AI_INVALID_INPUT")
88
+ }
89
+ })
90
+
91
+ it("handles markdown code-fence wrapped JSON", async () => {
92
+ vi.spyOn(llmModule, "callLlm").mockResolvedValue(
93
+ `\`\`\`json\n${JSON.stringify({ category: "billing", confidence: 0.8, rationale: "invoice" })}\n\`\`\``,
94
+ )
95
+ const result = await classify(
96
+ makeJob({ input: "invoice question", categories: ["billing", "technical"] }),
97
+ cfg,
98
+ )
99
+ expect(result.outcome).toBe("complete")
100
+ })
101
+ })
@@ -0,0 +1,85 @@
1
+ import type { WorkerJob, WorkerJobResult } from "@bpmnkit/cli-sdk"
2
+ import type { AiWorkerConfig } from "../config.js"
3
+ import { RetryableError, callLlm, parseJsonResponse } from "../llm.js"
4
+
5
+ interface ClassifyResponse {
6
+ category: string
7
+ confidence: number
8
+ rationale: string
9
+ }
10
+
11
+ const SYSTEM_PROMPT = `You are a text classification assistant.
12
+ The user will provide text to classify and a list of allowed categories.
13
+ Respond ONLY with valid JSON matching this exact shape:
14
+ { "category": "<one of the allowed categories>", "confidence": <0.0–1.0>, "rationale": "<one sentence>" }
15
+ Do not include any prose outside the JSON object.`
16
+
17
+ /**
18
+ * Classifies job.variables.input into one of job.variables.categories.
19
+ *
20
+ * Required job variables:
21
+ * - input: string — the text to classify
22
+ * - categories: string[] — allowed category names
23
+ *
24
+ * Optional:
25
+ * - context: string — additional domain context for the model
26
+ *
27
+ * Output variables: category, confidence, rationale, aiModel, processedAt
28
+ */
29
+ export async function classify(job: WorkerJob, config: AiWorkerConfig): Promise<WorkerJobResult> {
30
+ const input = String(job.variables.input ?? "")
31
+ const categories = job.variables.categories
32
+ if (!Array.isArray(categories) || categories.length === 0) {
33
+ return {
34
+ outcome: "error",
35
+ errorCode: "AI_INVALID_INPUT",
36
+ errorMessage: 'Job variable "categories" must be a non-empty array of strings.',
37
+ }
38
+ }
39
+ const context = job.variables.context ? `\nContext: ${String(job.variables.context)}` : ""
40
+ const userMessage = `Allowed categories: ${JSON.stringify(categories)}${context}\n\nText to classify:\n${input}`
41
+
42
+ let raw: string
43
+ try {
44
+ raw = await callLlm(SYSTEM_PROMPT, userMessage, config)
45
+ } catch (err) {
46
+ if (err instanceof RetryableError) {
47
+ return { outcome: "fail", errorMessage: err.message, retries: 2, retryBackOff: 30_000 }
48
+ }
49
+ return {
50
+ outcome: "error",
51
+ errorCode: "AI_API_ERROR",
52
+ errorMessage: err instanceof Error ? err.message : String(err),
53
+ }
54
+ }
55
+
56
+ let parsed: ClassifyResponse
57
+ try {
58
+ parsed = parseJsonResponse<ClassifyResponse>(raw)
59
+ } catch {
60
+ return {
61
+ outcome: "error",
62
+ errorCode: "AI_PARSE_ERROR",
63
+ errorMessage: `Model returned non-JSON response: ${raw.slice(0, 200)}`,
64
+ }
65
+ }
66
+
67
+ if (!categories.includes(parsed.category)) {
68
+ return {
69
+ outcome: "error",
70
+ errorCode: "AI_INVALID_CATEGORY",
71
+ errorMessage: `Model returned category "${parsed.category}" which is not in the allowed list: ${JSON.stringify(categories)}`,
72
+ }
73
+ }
74
+
75
+ return {
76
+ outcome: "complete",
77
+ variables: {
78
+ category: parsed.category,
79
+ confidence: parsed.confidence,
80
+ rationale: parsed.rationale,
81
+ aiModel: config.model,
82
+ processedAt: new Date().toISOString(),
83
+ },
84
+ }
85
+ }
@@ -0,0 +1,82 @@
1
+ import { describe, expect, it, vi } from "vitest"
2
+ import type { AiWorkerConfig } from "../config.js"
3
+ import * as llmModule from "../llm.js"
4
+ import { decide } from "./decide.js"
5
+
6
+ const cfg: AiWorkerConfig = {
7
+ apiKey: "test",
8
+ model: "claude-test",
9
+ maxTokens: 512,
10
+ timeoutMs: 5000,
11
+ }
12
+
13
+ function makeJob(variables: Record<string, unknown>) {
14
+ return {
15
+ jobKey: "1",
16
+ processDefinitionId: "p1",
17
+ elementId: "e1",
18
+ processInstanceKey: "i1",
19
+ variables,
20
+ }
21
+ }
22
+
23
+ describe("decide", () => {
24
+ it("returns complete with decision=true", async () => {
25
+ vi.spyOn(llmModule, "callLlm").mockResolvedValue(
26
+ JSON.stringify({ decision: true, rationale: "Meets all criteria.", confidence: 0.92 }),
27
+ )
28
+ const result = await decide(
29
+ makeJob({ question: "Should we approve?", context: "Score 720, amount 30000" }),
30
+ cfg,
31
+ )
32
+ expect(result.outcome).toBe("complete")
33
+ if (result.outcome === "complete") {
34
+ expect(result.variables.decision).toBe(true)
35
+ expect(result.variables.confidence).toBe(0.92)
36
+ }
37
+ })
38
+
39
+ it("returns complete with decision=false", async () => {
40
+ vi.spyOn(llmModule, "callLlm").mockResolvedValue(
41
+ JSON.stringify({ decision: false, rationale: "Exceeds threshold.", confidence: 0.87 }),
42
+ )
43
+ const result = await decide(makeJob({ question: "Approve?", context: "debt ratio 55%" }), cfg)
44
+ expect(result.outcome).toBe("complete")
45
+ if (result.outcome === "complete") {
46
+ expect(result.variables.decision).toBe(false)
47
+ }
48
+ })
49
+
50
+ it("returns error on parse failure", async () => {
51
+ vi.spyOn(llmModule, "callLlm").mockResolvedValue("I recommend approval based on the data.")
52
+ const result = await decide(makeJob({ question: "Approve?", context: "context" }), cfg)
53
+ expect(result.outcome).toBe("error")
54
+ if (result.outcome === "error") expect(result.errorCode).toBe("AI_PARSE_ERROR")
55
+ })
56
+
57
+ it("returns fail on RetryableError", async () => {
58
+ vi.spyOn(llmModule, "callLlm").mockRejectedValue(new llmModule.RetryableError("503"))
59
+ const result = await decide(makeJob({ question: "Approve?", context: "context" }), cfg)
60
+ expect(result.outcome).toBe("fail")
61
+ if (result.outcome === "fail") expect(result.retries).toBe(2)
62
+ })
63
+
64
+ it("includes policy in prompt when provided", async () => {
65
+ const spy = vi
66
+ .spyOn(llmModule, "callLlm")
67
+ .mockResolvedValue(
68
+ JSON.stringify({ decision: false, rationale: "Violates policy.", confidence: 0.95 }),
69
+ )
70
+ await decide(
71
+ makeJob({
72
+ question: "Approve?",
73
+ context: "score 650",
74
+ policy: "Minimum score is 680.",
75
+ }),
76
+ cfg,
77
+ )
78
+ const call = spy.mock.calls[0]
79
+ const [, userMessage] = call ?? []
80
+ expect(userMessage).toContain("Minimum score is 680.")
81
+ })
82
+ })
@@ -0,0 +1,70 @@
1
+ import type { WorkerJob, WorkerJobResult } from "@bpmnkit/cli-sdk"
2
+ import type { AiWorkerConfig } from "../config.js"
3
+ import { RetryableError, callLlm, parseJsonResponse } from "../llm.js"
4
+
5
+ interface DecideResponse {
6
+ decision: boolean
7
+ rationale: string
8
+ confidence: number
9
+ }
10
+
11
+ const SYSTEM_PROMPT = `You are a decision-making assistant.
12
+ The user will provide a yes/no question, relevant context, and optionally a policy to apply.
13
+ Respond ONLY with valid JSON matching this exact shape:
14
+ { "decision": <true|false>, "rationale": "<one or two sentences>", "confidence": <0.0–1.0> }
15
+ Apply the policy strictly if provided. Do not include any prose outside the JSON object.`
16
+
17
+ /**
18
+ * Makes a boolean decision for job.variables.question based on context and an optional policy.
19
+ *
20
+ * Required job variables:
21
+ * - question: string — the yes/no question to answer
22
+ * - context: string — relevant facts for the decision
23
+ *
24
+ * Optional:
25
+ * - policy: string — natural language policy text the model must apply
26
+ *
27
+ * Output variables: decision (boolean), rationale, confidence, aiModel, processedAt
28
+ */
29
+ export async function decide(job: WorkerJob, config: AiWorkerConfig): Promise<WorkerJobResult> {
30
+ const question = String(job.variables.question ?? "")
31
+ const context = String(job.variables.context ?? "")
32
+ const policy = job.variables.policy ? `\nPolicy to apply:\n${String(job.variables.policy)}` : ""
33
+ const userMessage = `Question: ${question}\n\nContext:\n${context}${policy}`
34
+
35
+ let raw: string
36
+ try {
37
+ raw = await callLlm(SYSTEM_PROMPT, userMessage, config)
38
+ } catch (err) {
39
+ if (err instanceof RetryableError) {
40
+ return { outcome: "fail", errorMessage: err.message, retries: 2, retryBackOff: 30_000 }
41
+ }
42
+ return {
43
+ outcome: "error",
44
+ errorCode: "AI_API_ERROR",
45
+ errorMessage: err instanceof Error ? err.message : String(err),
46
+ }
47
+ }
48
+
49
+ let parsed: DecideResponse
50
+ try {
51
+ parsed = parseJsonResponse<DecideResponse>(raw)
52
+ } catch {
53
+ return {
54
+ outcome: "error",
55
+ errorCode: "AI_PARSE_ERROR",
56
+ errorMessage: `Model returned non-JSON response: ${raw.slice(0, 200)}`,
57
+ }
58
+ }
59
+
60
+ return {
61
+ outcome: "complete",
62
+ variables: {
63
+ decision: parsed.decision,
64
+ rationale: parsed.rationale,
65
+ confidence: parsed.confidence,
66
+ aiModel: config.model,
67
+ processedAt: new Date().toISOString(),
68
+ },
69
+ }
70
+ }
@@ -0,0 +1,80 @@
1
+ import { describe, expect, it, vi } from "vitest"
2
+ import type { AiWorkerConfig } from "../config.js"
3
+ import * as llmModule from "../llm.js"
4
+ import { extract } from "./extract.js"
5
+
6
+ const cfg: AiWorkerConfig = {
7
+ apiKey: "test",
8
+ model: "claude-test",
9
+ maxTokens: 512,
10
+ timeoutMs: 5000,
11
+ }
12
+
13
+ function makeJob(variables: Record<string, unknown>) {
14
+ return {
15
+ jobKey: "1",
16
+ processDefinitionId: "p1",
17
+ elementId: "e1",
18
+ processInstanceKey: "i1",
19
+ variables,
20
+ }
21
+ }
22
+
23
+ describe("extract", () => {
24
+ it("returns complete with extracted fields and empty missingFields", async () => {
25
+ vi.spyOn(llmModule, "callLlm").mockResolvedValue(
26
+ JSON.stringify({
27
+ extracted: { vendorName: "Acme Corp", totalAmount: 1250.0 },
28
+ missingFields: [],
29
+ }),
30
+ )
31
+ const result = await extract(
32
+ makeJob({
33
+ input: "Invoice from Acme Corp, total $1250",
34
+ fields: ["vendorName", "totalAmount"],
35
+ }),
36
+ cfg,
37
+ )
38
+ expect(result.outcome).toBe("complete")
39
+ if (result.outcome === "complete") {
40
+ expect(result.variables.extracted).toEqual({ vendorName: "Acme Corp", totalAmount: 1250.0 })
41
+ expect(result.variables.missingFields).toEqual([])
42
+ }
43
+ })
44
+
45
+ it("returns complete even when some fields are missing", async () => {
46
+ vi.spyOn(llmModule, "callLlm").mockResolvedValue(
47
+ JSON.stringify({
48
+ extracted: { vendorName: "Acme Corp", invoiceDate: null },
49
+ missingFields: ["invoiceDate"],
50
+ }),
51
+ )
52
+ const result = await extract(
53
+ makeJob({ input: "Invoice from Acme Corp", fields: ["vendorName", "invoiceDate"] }),
54
+ cfg,
55
+ )
56
+ expect(result.outcome).toBe("complete")
57
+ if (result.outcome === "complete") {
58
+ expect(result.variables.missingFields).toEqual(["invoiceDate"])
59
+ }
60
+ })
61
+
62
+ it("returns error when fields variable is missing", async () => {
63
+ const result = await extract(makeJob({ input: "text" }), cfg)
64
+ expect(result.outcome).toBe("error")
65
+ if (result.outcome === "error") expect(result.errorCode).toBe("AI_INVALID_INPUT")
66
+ })
67
+
68
+ it("returns error on parse failure", async () => {
69
+ vi.spyOn(llmModule, "callLlm").mockResolvedValue("The fields are: name=Acme")
70
+ const result = await extract(makeJob({ input: "text", fields: ["name"] }), cfg)
71
+ expect(result.outcome).toBe("error")
72
+ if (result.outcome === "error") expect(result.errorCode).toBe("AI_PARSE_ERROR")
73
+ })
74
+
75
+ it("returns fail on RetryableError", async () => {
76
+ vi.spyOn(llmModule, "callLlm").mockRejectedValue(new llmModule.RetryableError("rate limited"))
77
+ const result = await extract(makeJob({ input: "text", fields: ["name"] }), cfg)
78
+ expect(result.outcome).toBe("fail")
79
+ })
80
+ })