blob: 8c35df4cdfcb6618ff744e30a119cffd1e9be9fc [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() {
13 configured <- Sys.getenv("RKORAP_LLM_MODELS", unset = "")
14 if (nzchar(configured)) {
15 trimws(strsplit(configured, ",", fixed = TRUE)[[1]])
16 } else {
17 defaultLlmModels
18 }
19}
20
21# Provider and API key environment variable belonging to a model id
22llmProvider <- function(model) {
23 if (grepl("^gpt-", model, ignore.case = TRUE)) {
24 list(name = "openai", keyVar = "OPENAI_API_KEY")
25 } else if (grepl("^claude-", model, ignore.case = TRUE)) {
26 list(name = "claude", keyVar = "ANTHROPIC_API_KEY")
27 } else if (grepl("^gemini-", model, ignore.case = TRUE)) {
28 list(name = "gemini", keyVar = "GOOGLE_API_KEY")
29 } else if (grepl("^hf:", model, ignore.case = TRUE)) {
30 # OpenAI compatible endpoint, but tidyllm's openai provider does not allow
31 # for a custom base url, so these are queried directly (see below)
32 list(name = "synthetic", keyVar = "SYNTHETIC_API_KEY")
33 } else {
34 stop(paste(
35 "Unsupported model:", model,
36 "- supported prefixes: gpt-, claude-, gemini-, hf: (Synthetic)"
37 ))
38 }
39}
40
41# Helper function to skip if the API key of the given model is not available
42skip_if_no_api_key <- function(model) {
43 keyVar <- llmProvider(model)$keyVar
Marc Kupietz2deadd82025-07-09 08:53:33 +020044 skip_if_not(
Marc Kupietz323a4d32026-09-03 12:04:35 +020045 nzchar(Sys.getenv(keyVar)),
46 paste0("No API key for ", model, " found (need ", keyVar, ")")
Marc Kupietz2deadd82025-07-09 08:53:33 +020047 )
Marc Kupietzc9cb6772025-07-06 15:55:00 +020048}
49
Marc Kupietz06143702025-07-05 17:49:31 +020050# Helper function to find README.md file in current or parent directories
51find_readme_path <- function() {
52 readme_paths <- c("Readme.md", "../Readme.md", "../../Readme.md")
53 for (path in readme_paths) {
54 if (file.exists(path)) {
55 return(path)
56 }
57 }
58 return(NULL)
59}
60
61# Helper function to read README content
62read_readme_content <- function() {
63 readme_path <- find_readme_path()
64 if (is.null(readme_path)) {
65 return(NULL)
66 }
67 readme_content <- readLines(readme_path)
Marc Kupietz2deadd82025-07-09 08:53:33 +020068
69 # Find the line with "## Installation" and truncate before it
70 installation_line <- grep("^## Installation", readme_content, ignore.case = TRUE)
71 if (length(installation_line) > 0) {
72 readme_content <- readme_content[1:(installation_line[1] - 1)]
73 }
74
Marc Kupietz06143702025-07-05 17:49:31 +020075 paste(readme_content, collapse = "\n")
76}
77
Marc Kupietz323a4d32026-09-03 12:04:35 +020078# Helper function to call an OpenAI compatible endpoint that tidyllm cannot be
79# pointed at, because its openai provider takes no custom base url
80call_openai_compatible_api <- function(prompt, model, temperature, baseUrl, keyVar) {
81 response <- httr2::request(paste0(baseUrl, "/chat/completions")) |>
82 httr2::req_auth_bearer_token(Sys.getenv(keyVar)) |>
83 httr2::req_body_json(list(
84 model = model,
85 temperature = temperature,
86 messages = list(list(role = "user", content = prompt))
87 )) |>
88 httr2::req_retry(max_tries = 3) |>
89 httr2::req_timeout(120) |>
90 httr2::req_perform()
91
92 httr2::resp_body_json(response)$choices[[1]]$message$content
93}
94
Marc Kupietz345211a2025-07-06 12:52:24 +020095# Helper function to call LLM API using tidyllm
Marc Kupietz323a4d32026-09-03 12:04:35 +020096call_llm_api <- function(prompt, model, max_tokens = 500, temperature = 0.1) {
Marc Kupietz2deadd82025-07-09 08:53:33 +020097 cat("Calling LLM API with model:", model, "\n")
98 # Only print prompt up to the beginning of README content
99 readme_start <- regexpr("README Documentation:", prompt, fixed = TRUE)
100 if (readme_start > 0) {
101 prompt_preview <- substr(prompt, 1, readme_start - 1)
102 cat("Prompt (up to README):\n", prompt_preview, "\n")
103 } else {
104 cat("Prompt:\n", prompt, "\n")
105 }
106 tryCatch(
107 {
Marc Kupietz323a4d32026-09-03 12:04:35 +0200108 provider <- llmProvider(model)
Marc Kupietz06143702025-07-05 17:49:31 +0200109
Marc Kupietz323a4d32026-09-03 12:04:35 +0200110 if (provider$name == "synthetic") {
111 call_openai_compatible_api(
112 prompt,
113 model = model,
114 temperature = temperature,
115 baseUrl = "https://api.synthetic.new/openai/v1",
116 keyVar = provider$keyVar
Marc Kupietz2deadd82025-07-09 08:53:33 +0200117 )
Marc Kupietz323a4d32026-09-03 12:04:35 +0200118 } else {
119 # Use tidyllm unified API
120 result <- tidyllm::llm_message(prompt) |>
121 tidyllm::chat(
122 .provider = switch(provider$name,
123 openai = tidyllm::openai(),
124 claude = tidyllm::claude(),
125 gemini = tidyllm::gemini()
126 ),
127 .model = model,
128 .temperature = temperature,
129 .max_tries = 3
130 )
Marc Kupietz345211a2025-07-06 12:52:24 +0200131
Marc Kupietz323a4d32026-09-03 12:04:35 +0200132 # Extract the reply text
133 tidyllm::get_reply(result)
134 }
Marc Kupietz2deadd82025-07-09 08:53:33 +0200135 },
136 error = function(e) {
Marc Kupietz323a4d32026-09-03 12:04:35 +0200137 message <- as.character(e)
138 # Conditions of the account rather than of the documentation: these must
139 # not turn a documentation test red
140 if (grepl("429", message)) {
Marc Kupietz2deadd82025-07-09 08:53:33 +0200141 skip("LLM API rate limit exceeded - please try again later or check your API key/credits")
Marc Kupietz323a4d32026-09-03 12:04:35 +0200142 } else if (grepl("401|403", message)) {
143 skip(paste0(
144 "LLM API authentication failed - please check ",
145 llmProvider(model)$keyVar
146 ))
147 } else if (grepl("402|credit balance|billing|quota|insufficient", message, ignore.case = TRUE)) {
148 skip(paste0("No credits available for ", model, ": ", message))
Marc Kupietz2deadd82025-07-09 08:53:33 +0200149 } else {
Marc Kupietz323a4d32026-09-03 12:04:35 +0200150 stop(paste("LLM API error:", message))
Marc Kupietz2deadd82025-07-09 08:53:33 +0200151 }
Marc Kupietz06143702025-07-05 17:49:31 +0200152 }
Marc Kupietz2deadd82025-07-09 08:53:33 +0200153 )
Marc Kupietz06143702025-07-05 17:49:31 +0200154}
155
156# Helper function to create README-guided prompt
157create_readme_prompt <- function(task_description, specific_task) {
158 readme_text <- read_readme_content()
159 if (is.null(readme_text)) {
160 stop("README.md not found")
161 }
162
163 paste0(
164 "You are an expert R programmer. Based on the following README documentation for the RKorAPClient package, ",
165 task_description, "\n\n",
166 "README Documentation:\n",
167 readme_text,
168 "\n\nTask: ", specific_task,
169 "\n\nProvide only the R code without explanations."
170 )
171}
172
173# Helper function to extract R code from markdown code blocks
174extract_r_code <- function(response_text) {
175 # Remove markdown code blocks if present
176 code <- gsub("```[rR]?\\n?", "", response_text)
177 code <- gsub("```\\n?$", "", code)
178 # Remove leading/trailing whitespace
179 trimws(code)
180}
181
Marc Kupietze759b342025-07-05 19:48:20 +0200182# Helper function to test code syntax
183test_code_syntax <- function(code) {
Marc Kupietz2deadd82025-07-09 08:53:33 +0200184 tryCatch(
185 {
186 parse(text = code)
187 TRUE
188 },
189 error = function(e) {
190 cat("Syntax error:", as.character(e), "\n")
191 FALSE
192 }
193 )
Marc Kupietze759b342025-07-05 19:48:20 +0200194}
195
196# Helper function to run code if RUN_LLM_CODE is set
197run_code_if_enabled <- function(code, test_name) {
198 if (nzchar(Sys.getenv("RUN_LLM_CODE")) && Sys.getenv("RUN_LLM_CODE") == "true") {
199 cat("Running generated code for", test_name, "...\n")
Marc Kupietz2deadd82025-07-09 08:53:33 +0200200 tryCatch(
201 {
202 result <- eval(parse(text = code))
203 cat("Code executed successfully. Result type:", class(result), "\n")
204 if (is.data.frame(result)) {
205 cat("Result dimensions:", nrow(result), "rows,", ncol(result), "columns\n")
206 if (nrow(result) > 0) {
207 cat("First few rows:\n")
208 print(head(result, 3))
209 }
210 } else {
211 cat("Result preview:\n")
212 print(result)
Marc Kupietze759b342025-07-05 19:48:20 +0200213 }
Marc Kupietz2deadd82025-07-09 08:53:33 +0200214 return(TRUE)
215 },
216 error = function(e) {
217 cat("Runtime error:", as.character(e), "\n")
218 return(FALSE)
Marc Kupietze759b342025-07-05 19:48:20 +0200219 }
Marc Kupietz2deadd82025-07-09 08:53:33 +0200220 )
Marc Kupietze759b342025-07-05 19:48:20 +0200221 } else {
222 cat("Skipping code execution (set RUN_LLM_CODE=true to enable)\n")
223 return(NA)
224 }
225}
226
Marc Kupietz323a4d32026-09-03 12:04:35 +0200227for (model in llmModels()) {
228 test_that(paste(model, "can solve frequency query task with README guidance"), {
229 # Skip if offline
230 skip_if_offline()
Marc Kupietz2deadd82025-07-09 08:53:33 +0200231
Marc Kupietz323a4d32026-09-03 12:04:35 +0200232 # Skip if no API keys are set
233 skip_if_no_api_key(model)
Marc Kupietz2deadd82025-07-09 08:53:33 +0200234
Marc Kupietz323a4d32026-09-03 12:04:35 +0200235 # tidyllm is only suggested, so the tests must not fail without it
236 if (llmProvider(model)$name != "synthetic") skip_if_not_installed("tidyllm")
Marc Kupietz06143702025-07-05 17:49:31 +0200237
Marc Kupietz323a4d32026-09-03 12:04:35 +0200238 # Check for README file
239 skip_if_not(!is.null(find_readme_path()), "Readme.md not found in current or parent directories")
Marc Kupietz06143702025-07-05 17:49:31 +0200240
Marc Kupietz323a4d32026-09-03 12:04:35 +0200241 # Create the prompt with README context and task
242 prompt <- create_readme_prompt(
243 "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.",
244 "Write R code to query frequency of 'Demokratie' from the past three years using RKorAPClient."
245 )
Marc Kupietz06143702025-07-05 17:49:31 +0200246
Marc Kupietz323a4d32026-09-03 12:04:35 +0200247 # Call LLM API
248 generated_response <- call_llm_api(prompt, model, max_tokens = 500)
249 generated_code <- extract_r_code(generated_response)
Marc Kupietzb8839d32025-07-06 14:42:30 +0200250
Marc Kupietz323a4d32026-09-03 12:04:35 +0200251 # Basic checks on the generated code
252 expect_true(grepl("KorAPConnection", generated_code), "Generated code should include KorAPConnection")
253 expect_true(grepl("frequencyQuery", generated_code), "Generated code should include frequencyQuery")
254 expect_true(grepl("Demokratie", generated_code), "Generated code should include the search term 'Demokratie'")
255 last_year <- as.numeric(format(Sys.Date(), "%Y")) - 1
Marc Kupietz06143702025-07-05 17:49:31 +0200256
Marc Kupietz323a4d32026-09-03 12:04:35 +0200257 expect_true(grepl("Date in", generated_code), "Generated code should vc restriction on years")
Marc Kupietz06143702025-07-05 17:49:31 +0200258
Marc Kupietz323a4d32026-09-03 12:04:35 +0200259 # Check that the generated code contains essential RKorAPClient patterns
260 # expect_true(grepl("\\|>", generated_code) || grepl("%>%", generated_code), "Generated code should use pipe operators")
Marc Kupietz06143702025-07-05 17:49:31 +0200261
Marc Kupietz323a4d32026-09-03 12:04:35 +0200262 # Test code syntax
263 syntax_valid <- test_code_syntax(generated_code)
264 expect_true(syntax_valid, "Generated code should be syntactically valid R code")
Marc Kupietze759b342025-07-05 19:48:20 +0200265
Marc Kupietz323a4d32026-09-03 12:04:35 +0200266 # Print the generated code for manual inspection
267 cat("Generated code:\n", generated_code, "\n")
268
269 # Run the code if RUN_LLM_CODE is set
270 execution_result <- run_code_if_enabled(generated_code, "frequency query")
271 if (!is.na(execution_result)) {
272 expect_true(execution_result, "Generated code should execute without runtime errors")
273 }
274 })
Marc Kupietz06143702025-07-05 17:49:31 +0200275
Marc Kupietz345211a2025-07-06 12:52:24 +0200276
Marc Kupietz323a4d32026-09-03 12:04:35 +0200277 test_that(paste(model, "can solve collocation analysis task with README guidance"), {
278 # Skip if offline
279 skip_if_offline()
Marc Kupietz2deadd82025-07-09 08:53:33 +0200280
Marc Kupietz323a4d32026-09-03 12:04:35 +0200281 # Skip if no API keys are set
282 skip_if_no_api_key(model)
Marc Kupietz2deadd82025-07-09 08:53:33 +0200283
Marc Kupietz323a4d32026-09-03 12:04:35 +0200284 # tidyllm is only suggested, so the tests must not fail without it
285 if (llmProvider(model)$name != "synthetic") skip_if_not_installed("tidyllm")
Marc Kupietz06143702025-07-05 17:49:31 +0200286
Marc Kupietz323a4d32026-09-03 12:04:35 +0200287 # Check for README file
288 skip_if_not(!is.null(find_readme_path()), "Readme.md not found in current or parent directories")
Marc Kupietz06143702025-07-05 17:49:31 +0200289
Marc Kupietz323a4d32026-09-03 12:04:35 +0200290 # Create the prompt for collocation analysis
291 prompt <- create_readme_prompt(
292 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.
293 "),
294 "Write R code to perform collocation analysis for lemma 'leverage' using RKorAPClient."
295 )
Marc Kupietz06143702025-07-05 17:49:31 +0200296
Marc Kupietz323a4d32026-09-03 12:04:35 +0200297 # Call LLM API
298 generated_response <- call_llm_api(prompt, model, max_tokens = 500)
299 generated_code <- extract_r_code(generated_response)
Marc Kupietz06143702025-07-05 17:49:31 +0200300
Marc Kupietz323a4d32026-09-03 12:04:35 +0200301 # Basic checks on the generated code
302 expect_true(grepl("KorAPConnection", generated_code), "Generated code should include KorAPConnection")
303 expect_true(grepl("collocationAnalysis", generated_code), "Generated code should include collocationAnalysis")
304 expect_true(grepl("tt/l=leverage", generated_code), "Generated code should include the search the lemma 'leverage'")
305 # expect_true(grepl("auth", generated_code), "Generated code should include auth() for collocation analysis")
306 expect_true(grepl("instance/english", generated_code, fixed = TRUE), "Generated code should include the specified KorAP URL")
Marc Kupietze759b342025-07-05 19:48:20 +0200307
Marc Kupietz323a4d32026-09-03 12:04:35 +0200308 # Test code syntax
309 syntax_valid <- test_code_syntax(generated_code)
310 expect_true(syntax_valid, "Generated code should be syntactically valid R code")
Marc Kupietze759b342025-07-05 19:48:20 +0200311
Marc Kupietz323a4d32026-09-03 12:04:35 +0200312 # Print the generated code for manual inspection
313 cat("Generated collocation analysis code:\n", generated_code, "\n")
Marc Kupietz06143702025-07-05 17:49:31 +0200314
Marc Kupietz323a4d32026-09-03 12:04:35 +0200315 # Run the code if RUN_LLM_CODE is set
316 execution_result <- run_code_if_enabled(generated_code, "collocation analysis")
317 if (!is.na(execution_result)) {
318 expect_true(execution_result, "Generated code should execute without runtime errors")
319 }
320 })
Marc Kupietz2deadd82025-07-09 08:53:33 +0200321
Marc Kupietz323a4d32026-09-03 12:04:35 +0200322 test_that(paste(model, "can solve corpus query task with README guidance"), {
323 # Skip if offline
324 skip_if_offline()
Marc Kupietz2deadd82025-07-09 08:53:33 +0200325
Marc Kupietz323a4d32026-09-03 12:04:35 +0200326 # Skip if no API keys are set
327 skip_if_no_api_key(model)
Marc Kupietz06143702025-07-05 17:49:31 +0200328
Marc Kupietz323a4d32026-09-03 12:04:35 +0200329 # tidyllm is only suggested, so the tests must not fail without it
330 if (llmProvider(model)$name != "synthetic") skip_if_not_installed("tidyllm")
Marc Kupietz06143702025-07-05 17:49:31 +0200331
Marc Kupietz323a4d32026-09-03 12:04:35 +0200332 # Check for README file
333 skip_if_not(!is.null(find_readme_path()), "Readme.md not found in current or parent directories")
Marc Kupietz06143702025-07-05 17:49:31 +0200334
Marc Kupietz323a4d32026-09-03 12:04:35 +0200335 # Create the prompt for corpus query
336 prompt <- create_readme_prompt(
337 "write R code to perform a simple corpus query for 'Hello world' and fetch all results. The code should use the RKorAPClient package.",
338 "Write R code to query 'Hello world' and fetch all results using RKorAPClient."
339 )
Marc Kupietz06143702025-07-05 17:49:31 +0200340
Marc Kupietz323a4d32026-09-03 12:04:35 +0200341 # Call LLM API
342 generated_response <- call_llm_api(prompt, model, max_tokens = 300)
343 generated_code <- extract_r_code(generated_response)
Marc Kupietz06143702025-07-05 17:49:31 +0200344
Marc Kupietz323a4d32026-09-03 12:04:35 +0200345 # Basic checks on the generated code
346 expect_true(grepl("KorAPConnection", generated_code), "Generated code should include KorAPConnection")
347 expect_true(grepl("corpusQuery", generated_code), "Generated code should include corpusQuery")
348 expect_true(grepl("Hello world", generated_code), "Generated code should include the search term 'Hello world'")
349 expect_true(grepl("fetchAll", generated_code), "Generated code should include fetchAll")
Marc Kupietze759b342025-07-05 19:48:20 +0200350
Marc Kupietz323a4d32026-09-03 12:04:35 +0200351 # Check that the generated code follows the README example pattern
352 expect_true(
353 grepl("\\|>", generated_code) || grepl("%>%", generated_code),
354 "Generated code should use pipe operators"
355 )
Marc Kupietze759b342025-07-05 19:48:20 +0200356
Marc Kupietz323a4d32026-09-03 12:04:35 +0200357 # Test code syntax
358 syntax_valid <- test_code_syntax(generated_code)
359 expect_true(syntax_valid, "Generated code should be syntactically valid R code")
360
361 # Print the generated code for manual inspection
362 cat("Generated corpus query code:\n", generated_code, "\n")
363
364 # Run the code if RUN_LLM_CODE is set
365 execution_result <- run_code_if_enabled(generated_code, "corpus query")
366 if (!is.na(execution_result)) {
367 expect_true(execution_result, "Generated code should execute without runtime errors")
368 }
369 })
370}