blob: 886ccf677e4e4d0b19453cc95c640d94d12cf59f [file] [log] [blame]
Marc Kupietz323a4d32026-09-03 12:04:35 +02001# Models the documentation is prompted with. Cheap, fast models are used on
2# purpose: the tasks are simple, and if such a model cannot follow the Readme,
3# that says something about the Readme, which is what is being tested here.
4# Override with a comma separated list in RKORAP_LLM_MODELS, e.g. to add
5# OpenAI's cheapest models, "gpt-5-nano" or "gpt-5.6-luna".
6defaultLlmModels <- c(
7 "gemini-3.5-flash-lite",
8 "claude-sonnet-5",
9 "hf:zai-org/GLM-5.3-Flash" # GLM-5.3-Flash, via the Synthetic API
10)
Marc Kupietza28a99a2025-07-06 14:52:47 +020011
Marc Kupietz323a4d32026-09-03 12:04:35 +020012llmModels <- function() {
Marc Kupietz6957ac92026-09-03 12:18:27 +020013 configured <- Sys.getenv("RKORAP_LLM_MODELS", unset = NA_character_)
14 if (is.na(configured)) {
15 return(defaultLlmModels)
Marc Kupietz323a4d32026-09-03 12:04:35 +020016 }
Marc Kupietz6957ac92026-09-03 12:18:27 +020017 # set but empty means no models at all, which is how these tests are kept out
18 # of the pipeline job that runs everything else
19 models <- trimws(strsplit(configured, ",", fixed = TRUE)[[1]])
20 models[nzchar(models)]
Marc Kupietz323a4d32026-09-03 12:04:35 +020021}
22
23# Provider and API key environment variable belonging to a model id
24llmProvider <- function(model) {
25 if (grepl("^gpt-", model, ignore.case = TRUE)) {
26 list(name = "openai", keyVar = "OPENAI_API_KEY")
27 } else if (grepl("^claude-", model, ignore.case = TRUE)) {
28 list(name = "claude", keyVar = "ANTHROPIC_API_KEY")
29 } else if (grepl("^gemini-", model, ignore.case = TRUE)) {
30 list(name = "gemini", keyVar = "GOOGLE_API_KEY")
31 } else if (grepl("^hf:", model, ignore.case = TRUE)) {
32 # OpenAI compatible endpoint, but tidyllm's openai provider does not allow
33 # for a custom base url, so these are queried directly (see below)
34 list(name = "synthetic", keyVar = "SYNTHETIC_API_KEY")
35 } else {
36 stop(paste(
37 "Unsupported model:", model,
38 "- supported prefixes: gpt-, claude-, gemini-, hf: (Synthetic)"
39 ))
40 }
41}
42
43# Helper function to skip if the API key of the given model is not available
44skip_if_no_api_key <- function(model) {
45 keyVar <- llmProvider(model)$keyVar
Marc Kupietz2deadd82025-07-09 08:53:33 +020046 skip_if_not(
Marc Kupietz323a4d32026-09-03 12:04:35 +020047 nzchar(Sys.getenv(keyVar)),
48 paste0("No API key for ", model, " found (need ", keyVar, ")")
Marc Kupietz2deadd82025-07-09 08:53:33 +020049 )
Marc Kupietzc9cb6772025-07-06 15:55:00 +020050}
51
Marc Kupietz06143702025-07-05 17:49:31 +020052# Helper function to find README.md file in current or parent directories
53find_readme_path <- function() {
54 readme_paths <- c("Readme.md", "../Readme.md", "../../Readme.md")
55 for (path in readme_paths) {
56 if (file.exists(path)) {
57 return(path)
58 }
59 }
60 return(NULL)
61}
62
63# Helper function to read README content
64read_readme_content <- function() {
65 readme_path <- find_readme_path()
66 if (is.null(readme_path)) {
67 return(NULL)
68 }
69 readme_content <- readLines(readme_path)
Marc Kupietz2deadd82025-07-09 08:53:33 +020070
71 # Find the line with "## Installation" and truncate before it
72 installation_line <- grep("^## Installation", readme_content, ignore.case = TRUE)
73 if (length(installation_line) > 0) {
74 readme_content <- readme_content[1:(installation_line[1] - 1)]
75 }
76
Marc Kupietz06143702025-07-05 17:49:31 +020077 paste(readme_content, collapse = "\n")
78}
79
Marc Kupietz323a4d32026-09-03 12:04:35 +020080# Helper function to call an OpenAI compatible endpoint that tidyllm cannot be
81# pointed at, because its openai provider takes no custom base url
82call_openai_compatible_api <- function(prompt, model, temperature, baseUrl, keyVar) {
83 response <- httr2::request(paste0(baseUrl, "/chat/completions")) |>
84 httr2::req_auth_bearer_token(Sys.getenv(keyVar)) |>
85 httr2::req_body_json(list(
86 model = model,
87 temperature = temperature,
88 messages = list(list(role = "user", content = prompt))
89 )) |>
90 httr2::req_retry(max_tries = 3) |>
91 httr2::req_timeout(120) |>
92 httr2::req_perform()
93
94 httr2::resp_body_json(response)$choices[[1]]$message$content
95}
96
Marc Kupietz345211a2025-07-06 12:52:24 +020097# Helper function to call LLM API using tidyllm
Marc Kupietz323a4d32026-09-03 12:04:35 +020098call_llm_api <- function(prompt, model, max_tokens = 500, temperature = 0.1) {
Marc Kupietz2deadd82025-07-09 08:53:33 +020099 cat("Calling LLM API with model:", model, "\n")
100 # Only print prompt up to the beginning of README content
101 readme_start <- regexpr("README Documentation:", prompt, fixed = TRUE)
102 if (readme_start > 0) {
103 prompt_preview <- substr(prompt, 1, readme_start - 1)
104 cat("Prompt (up to README):\n", prompt_preview, "\n")
105 } else {
106 cat("Prompt:\n", prompt, "\n")
107 }
108 tryCatch(
109 {
Marc Kupietz323a4d32026-09-03 12:04:35 +0200110 provider <- llmProvider(model)
Marc Kupietz06143702025-07-05 17:49:31 +0200111
Marc Kupietz323a4d32026-09-03 12:04:35 +0200112 if (provider$name == "synthetic") {
113 call_openai_compatible_api(
114 prompt,
115 model = model,
116 temperature = temperature,
117 baseUrl = "https://api.synthetic.new/openai/v1",
118 keyVar = provider$keyVar
Marc Kupietz2deadd82025-07-09 08:53:33 +0200119 )
Marc Kupietz323a4d32026-09-03 12:04:35 +0200120 } else {
121 # Use tidyllm unified API
122 result <- tidyllm::llm_message(prompt) |>
123 tidyllm::chat(
124 .provider = switch(provider$name,
125 openai = tidyllm::openai(),
126 claude = tidyllm::claude(),
127 gemini = tidyllm::gemini()
128 ),
129 .model = model,
130 .temperature = temperature,
131 .max_tries = 3
132 )
Marc Kupietz345211a2025-07-06 12:52:24 +0200133
Marc Kupietz323a4d32026-09-03 12:04:35 +0200134 # Extract the reply text
135 tidyllm::get_reply(result)
136 }
Marc Kupietz2deadd82025-07-09 08:53:33 +0200137 },
138 error = function(e) {
Marc Kupietz323a4d32026-09-03 12:04:35 +0200139 message <- as.character(e)
140 # Conditions of the account rather than of the documentation: these must
141 # not turn a documentation test red
142 if (grepl("429", message)) {
Marc Kupietz2deadd82025-07-09 08:53:33 +0200143 skip("LLM API rate limit exceeded - please try again later or check your API key/credits")
Marc Kupietz323a4d32026-09-03 12:04:35 +0200144 } else if (grepl("401|403", message)) {
145 skip(paste0(
146 "LLM API authentication failed - please check ",
147 llmProvider(model)$keyVar
148 ))
149 } else if (grepl("402|credit balance|billing|quota|insufficient", message, ignore.case = TRUE)) {
150 skip(paste0("No credits available for ", model, ": ", message))
Marc Kupietz2deadd82025-07-09 08:53:33 +0200151 } else {
Marc Kupietz323a4d32026-09-03 12:04:35 +0200152 stop(paste("LLM API error:", message))
Marc Kupietz2deadd82025-07-09 08:53:33 +0200153 }
Marc Kupietz06143702025-07-05 17:49:31 +0200154 }
Marc Kupietz2deadd82025-07-09 08:53:33 +0200155 )
Marc Kupietz06143702025-07-05 17:49:31 +0200156}
157
158# Helper function to create README-guided prompt
159create_readme_prompt <- function(task_description, specific_task) {
160 readme_text <- read_readme_content()
161 if (is.null(readme_text)) {
162 stop("README.md not found")
163 }
164
165 paste0(
166 "You are an expert R programmer. Based on the following README documentation for the RKorAPClient package, ",
167 task_description, "\n\n",
168 "README Documentation:\n",
169 readme_text,
170 "\n\nTask: ", specific_task,
171 "\n\nProvide only the R code without explanations."
172 )
173}
174
175# Helper function to extract R code from markdown code blocks
176extract_r_code <- function(response_text) {
177 # Remove markdown code blocks if present
178 code <- gsub("```[rR]?\\n?", "", response_text)
179 code <- gsub("```\\n?$", "", code)
180 # Remove leading/trailing whitespace
181 trimws(code)
182}
183
Marc Kupietze759b342025-07-05 19:48:20 +0200184# Helper function to test code syntax
185test_code_syntax <- function(code) {
Marc Kupietz2deadd82025-07-09 08:53:33 +0200186 tryCatch(
187 {
188 parse(text = code)
189 TRUE
190 },
191 error = function(e) {
192 cat("Syntax error:", as.character(e), "\n")
193 FALSE
194 }
195 )
Marc Kupietze759b342025-07-05 19:48:20 +0200196}
197
198# Helper function to run code if RUN_LLM_CODE is set
199run_code_if_enabled <- function(code, test_name) {
200 if (nzchar(Sys.getenv("RUN_LLM_CODE")) && Sys.getenv("RUN_LLM_CODE") == "true") {
201 cat("Running generated code for", test_name, "...\n")
Marc Kupietz2deadd82025-07-09 08:53:33 +0200202 tryCatch(
203 {
204 result <- eval(parse(text = code))
205 cat("Code executed successfully. Result type:", class(result), "\n")
206 if (is.data.frame(result)) {
207 cat("Result dimensions:", nrow(result), "rows,", ncol(result), "columns\n")
208 if (nrow(result) > 0) {
209 cat("First few rows:\n")
210 print(head(result, 3))
211 }
212 } else {
213 cat("Result preview:\n")
214 print(result)
Marc Kupietze759b342025-07-05 19:48:20 +0200215 }
Marc Kupietz2deadd82025-07-09 08:53:33 +0200216 return(TRUE)
217 },
218 error = function(e) {
219 cat("Runtime error:", as.character(e), "\n")
220 return(FALSE)
Marc Kupietze759b342025-07-05 19:48:20 +0200221 }
Marc Kupietz2deadd82025-07-09 08:53:33 +0200222 )
Marc Kupietze759b342025-07-05 19:48:20 +0200223 } else {
224 cat("Skipping code execution (set RUN_LLM_CODE=true to enable)\n")
225 return(NA)
226 }
227}
228
Marc Kupietz323a4d32026-09-03 12:04:35 +0200229for (model in llmModels()) {
230 test_that(paste(model, "can solve frequency query task with README guidance"), {
231 # Skip if offline
232 skip_if_offline()
Marc Kupietz2deadd82025-07-09 08:53:33 +0200233
Marc Kupietz323a4d32026-09-03 12:04:35 +0200234 # Skip if no API keys are set
235 skip_if_no_api_key(model)
Marc Kupietz2deadd82025-07-09 08:53:33 +0200236
Marc Kupietz323a4d32026-09-03 12:04:35 +0200237 # tidyllm is only suggested, so the tests must not fail without it
238 if (llmProvider(model)$name != "synthetic") skip_if_not_installed("tidyllm")
Marc Kupietz06143702025-07-05 17:49:31 +0200239
Marc Kupietz323a4d32026-09-03 12:04:35 +0200240 # Check for README file
241 skip_if_not(!is.null(find_readme_path()), "Readme.md not found in current or parent directories")
Marc Kupietz06143702025-07-05 17:49:31 +0200242
Marc Kupietz323a4d32026-09-03 12:04:35 +0200243 # Create the prompt with README context and task
244 prompt <- create_readme_prompt(
245 "write R code to perform a frequency query for the word 'Demokratie' across the past three years. The code should use the RKorAPClient package and return a data frame.",
246 "Write R code to query frequency of 'Demokratie' from the past three years using RKorAPClient."
247 )
Marc Kupietz06143702025-07-05 17:49:31 +0200248
Marc Kupietz323a4d32026-09-03 12:04:35 +0200249 # Call LLM API
250 generated_response <- call_llm_api(prompt, model, max_tokens = 500)
251 generated_code <- extract_r_code(generated_response)
Marc Kupietzb8839d32025-07-06 14:42:30 +0200252
Marc Kupietz323a4d32026-09-03 12:04:35 +0200253 # Basic checks on the generated code
254 expect_true(grepl("KorAPConnection", generated_code), "Generated code should include KorAPConnection")
255 expect_true(grepl("frequencyQuery", generated_code), "Generated code should include frequencyQuery")
256 expect_true(grepl("Demokratie", generated_code), "Generated code should include the search term 'Demokratie'")
257 last_year <- as.numeric(format(Sys.Date(), "%Y")) - 1
Marc Kupietz06143702025-07-05 17:49:31 +0200258
Marc Kupietz323a4d32026-09-03 12:04:35 +0200259 expect_true(grepl("Date in", generated_code), "Generated code should vc restriction on years")
Marc Kupietz06143702025-07-05 17:49:31 +0200260
Marc Kupietz323a4d32026-09-03 12:04:35 +0200261 # Check that the generated code contains essential RKorAPClient patterns
262 # expect_true(grepl("\\|>", generated_code) || grepl("%>%", generated_code), "Generated code should use pipe operators")
Marc Kupietz06143702025-07-05 17:49:31 +0200263
Marc Kupietz323a4d32026-09-03 12:04:35 +0200264 # Test code syntax
265 syntax_valid <- test_code_syntax(generated_code)
266 expect_true(syntax_valid, "Generated code should be syntactically valid R code")
Marc Kupietze759b342025-07-05 19:48:20 +0200267
Marc Kupietz323a4d32026-09-03 12:04:35 +0200268 # Print the generated code for manual inspection
269 cat("Generated code:\n", generated_code, "\n")
270
271 # Run the code if RUN_LLM_CODE is set
272 execution_result <- run_code_if_enabled(generated_code, "frequency query")
273 if (!is.na(execution_result)) {
274 expect_true(execution_result, "Generated code should execute without runtime errors")
275 }
276 })
Marc Kupietz06143702025-07-05 17:49:31 +0200277
Marc Kupietz345211a2025-07-06 12:52:24 +0200278
Marc Kupietz323a4d32026-09-03 12:04:35 +0200279 test_that(paste(model, "can solve collocation analysis task with README guidance"), {
280 # Skip if offline
281 skip_if_offline()
Marc Kupietz2deadd82025-07-09 08:53:33 +0200282
Marc Kupietz323a4d32026-09-03 12:04:35 +0200283 # Skip if no API keys are set
284 skip_if_no_api_key(model)
Marc Kupietz2deadd82025-07-09 08:53:33 +0200285
Marc Kupietz323a4d32026-09-03 12:04:35 +0200286 # tidyllm is only suggested, so the tests must not fail without it
287 if (llmProvider(model)$name != "synthetic") skip_if_not_installed("tidyllm")
Marc Kupietz06143702025-07-05 17:49:31 +0200288
Marc Kupietz323a4d32026-09-03 12:04:35 +0200289 # Check for README file
290 skip_if_not(!is.null(find_readme_path()), "Readme.md not found in current or parent directories")
Marc Kupietz06143702025-07-05 17:49:31 +0200291
Marc Kupietz323a4d32026-09-03 12:04:35 +0200292 # Create the prompt for collocation analysis
293 prompt <- create_readme_prompt(
294 paste("Write R code to perform a collocation analysis for the lemma 'leverage' based on the current English Wikipedia Corpus using default parameters", "and show the three highest collocates according to their log dice score.
295 "),
296 "Write R code to perform collocation analysis for lemma 'leverage' using RKorAPClient."
297 )
Marc Kupietz06143702025-07-05 17:49:31 +0200298
Marc Kupietz323a4d32026-09-03 12:04:35 +0200299 # Call LLM API
300 generated_response <- call_llm_api(prompt, model, max_tokens = 500)
301 generated_code <- extract_r_code(generated_response)
Marc Kupietz06143702025-07-05 17:49:31 +0200302
Marc Kupietz323a4d32026-09-03 12:04:35 +0200303 # Basic checks on the generated code
304 expect_true(grepl("KorAPConnection", generated_code), "Generated code should include KorAPConnection")
305 expect_true(grepl("collocationAnalysis", generated_code), "Generated code should include collocationAnalysis")
306 expect_true(grepl("tt/l=leverage", generated_code), "Generated code should include the search the lemma 'leverage'")
307 # expect_true(grepl("auth", generated_code), "Generated code should include auth() for collocation analysis")
308 expect_true(grepl("instance/english", generated_code, fixed = TRUE), "Generated code should include the specified KorAP URL")
Marc Kupietze759b342025-07-05 19:48:20 +0200309
Marc Kupietz323a4d32026-09-03 12:04:35 +0200310 # Test code syntax
311 syntax_valid <- test_code_syntax(generated_code)
312 expect_true(syntax_valid, "Generated code should be syntactically valid R code")
Marc Kupietze759b342025-07-05 19:48:20 +0200313
Marc Kupietz323a4d32026-09-03 12:04:35 +0200314 # Print the generated code for manual inspection
315 cat("Generated collocation analysis code:\n", generated_code, "\n")
Marc Kupietz06143702025-07-05 17:49:31 +0200316
Marc Kupietz323a4d32026-09-03 12:04:35 +0200317 # Run the code if RUN_LLM_CODE is set
318 execution_result <- run_code_if_enabled(generated_code, "collocation analysis")
319 if (!is.na(execution_result)) {
320 expect_true(execution_result, "Generated code should execute without runtime errors")
321 }
322 })
Marc Kupietz2deadd82025-07-09 08:53:33 +0200323
Marc Kupietz323a4d32026-09-03 12:04:35 +0200324 test_that(paste(model, "can solve corpus query task with README guidance"), {
325 # Skip if offline
326 skip_if_offline()
Marc Kupietz2deadd82025-07-09 08:53:33 +0200327
Marc Kupietz323a4d32026-09-03 12:04:35 +0200328 # Skip if no API keys are set
329 skip_if_no_api_key(model)
Marc Kupietz06143702025-07-05 17:49:31 +0200330
Marc Kupietz323a4d32026-09-03 12:04:35 +0200331 # tidyllm is only suggested, so the tests must not fail without it
332 if (llmProvider(model)$name != "synthetic") skip_if_not_installed("tidyllm")
Marc Kupietz06143702025-07-05 17:49:31 +0200333
Marc Kupietz323a4d32026-09-03 12:04:35 +0200334 # Check for README file
335 skip_if_not(!is.null(find_readme_path()), "Readme.md not found in current or parent directories")
Marc Kupietz06143702025-07-05 17:49:31 +0200336
Marc Kupietz323a4d32026-09-03 12:04:35 +0200337 # Create the prompt for corpus query
338 prompt <- create_readme_prompt(
339 "write R code to perform a simple corpus query for 'Hello world' and fetch all results. The code should use the RKorAPClient package.",
340 "Write R code to query 'Hello world' and fetch all results using RKorAPClient."
341 )
Marc Kupietz06143702025-07-05 17:49:31 +0200342
Marc Kupietz323a4d32026-09-03 12:04:35 +0200343 # Call LLM API
344 generated_response <- call_llm_api(prompt, model, max_tokens = 300)
345 generated_code <- extract_r_code(generated_response)
Marc Kupietz06143702025-07-05 17:49:31 +0200346
Marc Kupietz323a4d32026-09-03 12:04:35 +0200347 # Basic checks on the generated code
348 expect_true(grepl("KorAPConnection", generated_code), "Generated code should include KorAPConnection")
349 expect_true(grepl("corpusQuery", generated_code), "Generated code should include corpusQuery")
350 expect_true(grepl("Hello world", generated_code), "Generated code should include the search term 'Hello world'")
351 expect_true(grepl("fetchAll", generated_code), "Generated code should include fetchAll")
Marc Kupietze759b342025-07-05 19:48:20 +0200352
Marc Kupietz323a4d32026-09-03 12:04:35 +0200353 # Check that the generated code follows the README example pattern
354 expect_true(
355 grepl("\\|>", generated_code) || grepl("%>%", generated_code),
356 "Generated code should use pipe operators"
357 )
Marc Kupietze759b342025-07-05 19:48:20 +0200358
Marc Kupietz323a4d32026-09-03 12:04:35 +0200359 # Test code syntax
360 syntax_valid <- test_code_syntax(generated_code)
361 expect_true(syntax_valid, "Generated code should be syntactically valid R code")
362
363 # Print the generated code for manual inspection
364 cat("Generated corpus query code:\n", generated_code, "\n")
365
366 # Run the code if RUN_LLM_CODE is set
367 execution_result <- run_code_if_enabled(generated_code, "corpus query")
368 if (!is.na(execution_result)) {
369 expect_true(execution_result, "Generated code should execute without runtime errors")
370 }
371 })
372}