blob: 561d587caea46002d6ab345ab04fb581360a6a10 [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",
Marc Kupietz1cbb1552026-09-05 15:30:52 +02009 "z-ai/glm-5.3-flash" # via OpenRouter, see openRouterProvider() below
Marc Kupietz323a4d32026-09-03 12:04:35 +020010)
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)) {
Marc Kupietz1cbb1552026-09-05 15:30:52 +020032 stop(paste(
33 "The Synthetic API was replaced by OpenRouter, so", model,
34 "is no longer supported - use z-ai/glm-5.3-flash instead"
35 ))
36 } else if (grepl("/", model, fixed = TRUE)) {
37 # OpenRouter model ids are author/model. Its endpoint is OpenAI compatible,
38 # but tidyllm's openai provider does not allow for a custom base url, so
39 # these are queried directly (see below).
40 list(
41 name = "openrouter",
42 keyVar = "OPENROUTER_API_KEY",
43 baseUrl = "https://openrouter.ai/api/v1"
44 )
Marc Kupietz323a4d32026-09-03 12:04:35 +020045 } else {
46 stop(paste(
47 "Unsupported model:", model,
Marc Kupietz1cbb1552026-09-05 15:30:52 +020048 "- supported are gpt-*, claude-*, gemini-* and author/model (OpenRouter)"
Marc Kupietz323a4d32026-09-03 12:04:35 +020049 ))
50 }
51}
52
Marc Kupietz1cbb1552026-09-05 15:30:52 +020053# Models that go through tidyllm, as opposed to those queried directly
54usesTidyllm <- function(model) {
55 is.null(llmProvider(model)$baseUrl)
56}
57
58# OpenRouter serves one model from a couple of dozen hosts and would pick one by
59# availability, which the tests cannot use: the hosts differ in quantization and
60# answer a documentation prompt in anything between one second and seven
61# minutes. So the hosts are named, and everything outside the list is ruled out,
62# which keeps a change in the generated code about the Readme rather than about
63# which machine answered.
64#
65# OpenRouter works the list from the front and moves on whenever a host
66# declines. Baseten answers in about a second and serves every request, as long
67# as the OpenRouter account holds a Baseten key of its own, under Settings ->
68# Integrations. Without one, OpenRouter reaches the hosts through pools it
69# shares between all of its users, and those are exhausted often enough to cost
70# about one prompt in eight. The rest of the list carries those: Relace takes
71# some fifteen seconds and is the cheapest of the hosts, Z.ai tens of seconds
72# and has always answered. Relace serves the model in fp4 rather than fp8,
73# which is why it is not the first choice, although it is the fastest of the
74# hosts on shared capacity.
75#
76# Override with RKORAP_OPENROUTER_PROVIDER, comma separated, in that order.
77openRouterProviders <- function() {
78 configured <- Sys.getenv(
79 "RKORAP_OPENROUTER_PROVIDER",
80 unset = "baseten/fp8,relace/fp4,z-ai/fp8"
81 )
82 hosts <- trimws(strsplit(configured, ",", fixed = TRUE)[[1]])
83 as.list(hosts[nzchar(hosts)])
84}
85
Marc Kupietz323a4d32026-09-03 12:04:35 +020086# Helper function to skip if the API key of the given model is not available
87skip_if_no_api_key <- function(model) {
88 keyVar <- llmProvider(model)$keyVar
Marc Kupietz2deadd82025-07-09 08:53:33 +020089 skip_if_not(
Marc Kupietz323a4d32026-09-03 12:04:35 +020090 nzchar(Sys.getenv(keyVar)),
91 paste0("No API key for ", model, " found (need ", keyVar, ")")
Marc Kupietz2deadd82025-07-09 08:53:33 +020092 )
Marc Kupietzc9cb6772025-07-06 15:55:00 +020093}
94
Marc Kupietz06143702025-07-05 17:49:31 +020095# Helper function to find README.md file in current or parent directories
96find_readme_path <- function() {
97 readme_paths <- c("Readme.md", "../Readme.md", "../../Readme.md")
98 for (path in readme_paths) {
99 if (file.exists(path)) {
100 return(path)
101 }
102 }
103 return(NULL)
104}
105
106# Helper function to read README content
107read_readme_content <- function() {
108 readme_path <- find_readme_path()
109 if (is.null(readme_path)) {
110 return(NULL)
111 }
112 readme_content <- readLines(readme_path)
Marc Kupietz2deadd82025-07-09 08:53:33 +0200113
114 # Find the line with "## Installation" and truncate before it
115 installation_line <- grep("^## Installation", readme_content, ignore.case = TRUE)
116 if (length(installation_line) > 0) {
117 readme_content <- readme_content[1:(installation_line[1] - 1)]
118 }
119
Marc Kupietz06143702025-07-05 17:49:31 +0200120 paste(readme_content, collapse = "\n")
121}
122
Marc Kupietz323a4d32026-09-03 12:04:35 +0200123# Helper function to call an OpenAI compatible endpoint that tidyllm cannot be
124# pointed at, because its openai provider takes no custom base url
Marc Kupietz1cbb1552026-09-05 15:30:52 +0200125call_openai_compatible_api <- function(prompt, model, temperature, provider) {
126 body <- list(
127 model = model,
128 temperature = temperature,
129 messages = list(list(role = "user", content = prompt))
130 )
131 if (provider$name == "openrouter") {
132 body$provider <- list(
133 order = openRouterProviders(),
134 allow_fallbacks = FALSE
135 )
136 }
137 response <- httr2::request(paste0(provider$baseUrl, "/chat/completions")) |>
138 httr2::req_auth_bearer_token(Sys.getenv(provider$keyVar)) |>
139 httr2::req_headers(
140 # attributes the requests to the package in the OpenRouter activity log
141 "HTTP-Referer" = "https://github.com/KorAP/RKorAPClient",
142 "X-Title" = "RKorAPClient documentation prompting tests"
143 ) |>
144 httr2::req_body_json(body) |>
145 # every named host can be out of capacity at once, which passes as a 429
146 httr2::req_retry(max_tries = 5) |>
147 # generous, since the last hosts of the list take minutes rather than seconds
148 httr2::req_timeout(300) |>
Marc Kupietz323a4d32026-09-03 12:04:35 +0200149 httr2::req_perform()
150
151 httr2::resp_body_json(response)$choices[[1]]$message$content
152}
153
Marc Kupietz345211a2025-07-06 12:52:24 +0200154# Helper function to call LLM API using tidyllm
Marc Kupietz323a4d32026-09-03 12:04:35 +0200155call_llm_api <- function(prompt, model, max_tokens = 500, temperature = 0.1) {
Marc Kupietz2deadd82025-07-09 08:53:33 +0200156 cat("Calling LLM API with model:", model, "\n")
157 # Only print prompt up to the beginning of README content
158 readme_start <- regexpr("README Documentation:", prompt, fixed = TRUE)
159 if (readme_start > 0) {
160 prompt_preview <- substr(prompt, 1, readme_start - 1)
161 cat("Prompt (up to README):\n", prompt_preview, "\n")
162 } else {
163 cat("Prompt:\n", prompt, "\n")
164 }
165 tryCatch(
166 {
Marc Kupietz323a4d32026-09-03 12:04:35 +0200167 provider <- llmProvider(model)
Marc Kupietz06143702025-07-05 17:49:31 +0200168
Marc Kupietz1cbb1552026-09-05 15:30:52 +0200169 if (!usesTidyllm(model)) {
Marc Kupietz323a4d32026-09-03 12:04:35 +0200170 call_openai_compatible_api(
171 prompt,
172 model = model,
173 temperature = temperature,
Marc Kupietz1cbb1552026-09-05 15:30:52 +0200174 provider = provider
Marc Kupietz2deadd82025-07-09 08:53:33 +0200175 )
Marc Kupietz323a4d32026-09-03 12:04:35 +0200176 } else {
177 # Use tidyllm unified API
178 result <- tidyllm::llm_message(prompt) |>
179 tidyllm::chat(
180 .provider = switch(provider$name,
181 openai = tidyllm::openai(),
182 claude = tidyllm::claude(),
183 gemini = tidyllm::gemini()
184 ),
185 .model = model,
186 .temperature = temperature,
187 .max_tries = 3
188 )
Marc Kupietz345211a2025-07-06 12:52:24 +0200189
Marc Kupietz323a4d32026-09-03 12:04:35 +0200190 # Extract the reply text
191 tidyllm::get_reply(result)
192 }
Marc Kupietz2deadd82025-07-09 08:53:33 +0200193 },
194 error = function(e) {
Marc Kupietz323a4d32026-09-03 12:04:35 +0200195 message <- as.character(e)
196 # Conditions of the account rather than of the documentation: these must
197 # not turn a documentation test red
198 if (grepl("429", message)) {
Marc Kupietz2deadd82025-07-09 08:53:33 +0200199 skip("LLM API rate limit exceeded - please try again later or check your API key/credits")
Marc Kupietz323a4d32026-09-03 12:04:35 +0200200 } else if (grepl("401|403", message)) {
201 skip(paste0(
202 "LLM API authentication failed - please check ",
203 llmProvider(model)$keyVar
204 ))
205 } else if (grepl("402|credit balance|billing|quota|insufficient", message, ignore.case = TRUE)) {
206 skip(paste0("No credits available for ", model, ": ", message))
Marc Kupietz1cbb1552026-09-05 15:30:52 +0200207 } else if (grepl("404", message) && llmProvider(model)$name == "openrouter") {
208 # none of the named hosts is serving the model at the moment
209 skip(paste0(
210 "OpenRouter has no endpoint for ", model, " at ",
211 paste(openRouterProviders(), collapse = ", ")
212 ))
Marc Kupietz2deadd82025-07-09 08:53:33 +0200213 } else {
Marc Kupietz323a4d32026-09-03 12:04:35 +0200214 stop(paste("LLM API error:", message))
Marc Kupietz2deadd82025-07-09 08:53:33 +0200215 }
Marc Kupietz06143702025-07-05 17:49:31 +0200216 }
Marc Kupietz2deadd82025-07-09 08:53:33 +0200217 )
Marc Kupietz06143702025-07-05 17:49:31 +0200218}
219
220# Helper function to create README-guided prompt
Marc Kupietzffe28aa2026-09-05 17:50:45 +0200221#
222# Shaped the way a user would reasonably prompt, since what is tested here is
223# also what we recommend: the documentation first, the task once and at the end,
224# and the expectations stated rather than left to a persona ("you are an expert
225# R programmer" moves the wording of an answer, not its correctness).
226create_readme_prompt <- function(task_description) {
Marc Kupietz06143702025-07-05 17:49:31 +0200227 readme_text <- read_readme_content()
228 if (is.null(readme_text)) {
229 stop("README.md not found")
230 }
231
232 paste0(
Marc Kupietzffe28aa2026-09-05 17:50:45 +0200233 "The following is the README of the R package RKorAPClient.\n\n",
Marc Kupietz06143702025-07-05 17:49:31 +0200234 readme_text,
Marc Kupietzffe28aa2026-09-05 17:50:45 +0200235 "\n\nTask, based on that documentation: ", task_description,
236 "\n\nWrite clear, idiomatic tidyverse code, in the style of the README's",
237 " own examples. Answer with a single R code block and nothing else."
Marc Kupietz06143702025-07-05 17:49:31 +0200238 )
239}
240
241# Helper function to extract R code from markdown code blocks
242extract_r_code <- function(response_text) {
Marc Kupietz08cc2e52026-09-05 16:49:25 +0200243 # Asked for code alone, a model may still explain itself around it, or offer a
244 # second way of doing the same in a block of its own. Only the first block is
245 # what was asked for; stripping the fences and keeping everything else puts
246 # the prose in between into the code, where it does not parse.
247 block <- stringr::str_match(response_text, "(?s)```[^\\n]*\\n(.*?)```")[1, 2]
248 trimws(if (is.na(block)) response_text else block)
Marc Kupietz06143702025-07-05 17:49:31 +0200249}
250
Marc Kupietz08cc2e52026-09-05 16:49:25 +0200251test_that("the first code block is what is taken from a reply", {
252 fenced <- function(...) paste(c(...), collapse = "\n")
253
254 expect_equal(
255 extract_r_code(fenced("```r", "corpusStats(kco)", "```")),
256 "corpusStats(kco)"
257 )
258 # prose around the block does not belong to the code
259 expect_equal(
260 extract_r_code(fenced("Here you are:", "```R", "corpusStats(kco)", "```", "Hope that helps!")),
261 "corpusStats(kco)"
262 )
263 # a second block, offered as an alternative, would not parse together with the
264 # sentence introducing it
265 expect_equal(
266 extract_r_code(fenced(
267 "```r", "corpusStats(kco)", "```",
268 "Or, as a data frame:",
269 "```r", "corpusStats(kco, as.df = TRUE)", "```"
270 )),
271 "corpusStats(kco)"
272 )
273 # a reply that took "only the R code" literally has no fences to look for
274 expect_equal(extract_r_code("corpusStats(kco)"), "corpusStats(kco)")
275})
276
Marc Kupietze759b342025-07-05 19:48:20 +0200277# Helper function to test code syntax
278test_code_syntax <- function(code) {
Marc Kupietz2deadd82025-07-09 08:53:33 +0200279 tryCatch(
280 {
281 parse(text = code)
282 TRUE
283 },
284 error = function(e) {
285 cat("Syntax error:", as.character(e), "\n")
286 FALSE
287 }
288 )
Marc Kupietze759b342025-07-05 19:48:20 +0200289}
290
291# Helper function to run code if RUN_LLM_CODE is set
292run_code_if_enabled <- function(code, test_name) {
293 if (nzchar(Sys.getenv("RUN_LLM_CODE")) && Sys.getenv("RUN_LLM_CODE") == "true") {
294 cat("Running generated code for", test_name, "...\n")
Marc Kupietz2deadd82025-07-09 08:53:33 +0200295 tryCatch(
296 {
297 result <- eval(parse(text = code))
298 cat("Code executed successfully. Result type:", class(result), "\n")
299 if (is.data.frame(result)) {
300 cat("Result dimensions:", nrow(result), "rows,", ncol(result), "columns\n")
301 if (nrow(result) > 0) {
302 cat("First few rows:\n")
303 print(head(result, 3))
304 }
305 } else {
306 cat("Result preview:\n")
307 print(result)
Marc Kupietze759b342025-07-05 19:48:20 +0200308 }
Marc Kupietz2deadd82025-07-09 08:53:33 +0200309 return(TRUE)
310 },
311 error = function(e) {
312 cat("Runtime error:", as.character(e), "\n")
313 return(FALSE)
Marc Kupietze759b342025-07-05 19:48:20 +0200314 }
Marc Kupietz2deadd82025-07-09 08:53:33 +0200315 )
Marc Kupietze759b342025-07-05 19:48:20 +0200316 } else {
317 cat("Skipping code execution (set RUN_LLM_CODE=true to enable)\n")
318 return(NA)
319 }
320}
321
Marc Kupietz323a4d32026-09-03 12:04:35 +0200322for (model in llmModels()) {
323 test_that(paste(model, "can solve frequency query task with README guidance"), {
324 # Skip if offline
325 skip_if_offline()
Marc Kupietz2deadd82025-07-09 08:53:33 +0200326
Marc Kupietz323a4d32026-09-03 12:04:35 +0200327 # Skip if no API keys are set
328 skip_if_no_api_key(model)
Marc Kupietz2deadd82025-07-09 08:53:33 +0200329
Marc Kupietz323a4d32026-09-03 12:04:35 +0200330 # tidyllm is only suggested, so the tests must not fail without it
Marc Kupietz1cbb1552026-09-05 15:30:52 +0200331 if (usesTidyllm(model)) skip_if_not_installed("tidyllm")
Marc Kupietz06143702025-07-05 17:49:31 +0200332
Marc Kupietz323a4d32026-09-03 12:04:35 +0200333 # Check for README file
334 skip_if_not(!is.null(find_readme_path()), "Readme.md not found in current or parent directories")
Marc Kupietz06143702025-07-05 17:49:31 +0200335
Marc Kupietz323a4d32026-09-03 12:04:35 +0200336 # Create the prompt with README context and task
337 prompt <- create_readme_prompt(
Marc Kupietzffe28aa2026-09-05 17:50:45 +0200338 "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."
Marc Kupietz323a4d32026-09-03 12:04:35 +0200339 )
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 = 500)
343 generated_code <- extract_r_code(generated_response)
Marc Kupietzb8839d32025-07-06 14:42:30 +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("frequencyQuery", generated_code), "Generated code should include frequencyQuery")
348 expect_true(grepl("Demokratie", generated_code), "Generated code should include the search term 'Demokratie'")
349 last_year <- as.numeric(format(Sys.Date(), "%Y")) - 1
Marc Kupietz06143702025-07-05 17:49:31 +0200350
Marc Kupietz323a4d32026-09-03 12:04:35 +0200351 expect_true(grepl("Date in", generated_code), "Generated code should vc restriction on years")
Marc Kupietz06143702025-07-05 17:49:31 +0200352
Marc Kupietz323a4d32026-09-03 12:04:35 +0200353 # Check that the generated code contains essential RKorAPClient patterns
354 # expect_true(grepl("\\|>", generated_code) || grepl("%>%", generated_code), "Generated code should use pipe operators")
Marc Kupietz06143702025-07-05 17:49:31 +0200355
Marc Kupietz323a4d32026-09-03 12:04:35 +0200356 # Test code syntax
357 syntax_valid <- test_code_syntax(generated_code)
358 expect_true(syntax_valid, "Generated code should be syntactically valid R code")
Marc Kupietze759b342025-07-05 19:48:20 +0200359
Marc Kupietz323a4d32026-09-03 12:04:35 +0200360 # Print the generated code for manual inspection
361 cat("Generated code:\n", generated_code, "\n")
362
363 # Run the code if RUN_LLM_CODE is set
364 execution_result <- run_code_if_enabled(generated_code, "frequency query")
365 if (!is.na(execution_result)) {
366 expect_true(execution_result, "Generated code should execute without runtime errors")
367 }
368 })
Marc Kupietz06143702025-07-05 17:49:31 +0200369
Marc Kupietz345211a2025-07-06 12:52:24 +0200370
Marc Kupietz323a4d32026-09-03 12:04:35 +0200371 test_that(paste(model, "can solve collocation analysis task with README guidance"), {
372 # Skip if offline
373 skip_if_offline()
Marc Kupietz2deadd82025-07-09 08:53:33 +0200374
Marc Kupietz323a4d32026-09-03 12:04:35 +0200375 # Skip if no API keys are set
376 skip_if_no_api_key(model)
Marc Kupietz2deadd82025-07-09 08:53:33 +0200377
Marc Kupietz323a4d32026-09-03 12:04:35 +0200378 # tidyllm is only suggested, so the tests must not fail without it
Marc Kupietz1cbb1552026-09-05 15:30:52 +0200379 if (usesTidyllm(model)) skip_if_not_installed("tidyllm")
Marc Kupietz06143702025-07-05 17:49:31 +0200380
Marc Kupietz323a4d32026-09-03 12:04:35 +0200381 # Check for README file
382 skip_if_not(!is.null(find_readme_path()), "Readme.md not found in current or parent directories")
Marc Kupietz06143702025-07-05 17:49:31 +0200383
Marc Kupietz323a4d32026-09-03 12:04:35 +0200384 # Create the prompt for collocation analysis
385 prompt <- create_readme_prompt(
386 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.
Marc Kupietzffe28aa2026-09-05 17:50:45 +0200387 ")
Marc Kupietz323a4d32026-09-03 12:04:35 +0200388 )
Marc Kupietz06143702025-07-05 17:49:31 +0200389
Marc Kupietz323a4d32026-09-03 12:04:35 +0200390 # Call LLM API
391 generated_response <- call_llm_api(prompt, model, max_tokens = 500)
392 generated_code <- extract_r_code(generated_response)
Marc Kupietz06143702025-07-05 17:49:31 +0200393
Marc Kupietz323a4d32026-09-03 12:04:35 +0200394 # Basic checks on the generated code
395 expect_true(grepl("KorAPConnection", generated_code), "Generated code should include KorAPConnection")
396 expect_true(grepl("collocationAnalysis", generated_code), "Generated code should include collocationAnalysis")
Marc Kupietz3321dae2026-09-05 15:30:52 +0200397 # both ways of asking for the lemma are correct: the annotation layer in the
398 # query, as the Readme shows it, or collocationAnalysis' lemmatizeNodeQuery,
399 # which builds the same query from a plain word
400 expect_true(grepl("leverage", generated_code), "Generated code should include the node 'leverage'")
401 expect_true(
402 grepl("tt/l=leverage", generated_code) ||
403 grepl("lemmatizeNodeQuery\\s*=\\s*T", generated_code),
404 "Generated code should search for the lemma, via tt/l= or lemmatizeNodeQuery = TRUE"
405 )
Marc Kupietz323a4d32026-09-03 12:04:35 +0200406 # expect_true(grepl("auth", generated_code), "Generated code should include auth() for collocation analysis")
407 expect_true(grepl("instance/english", generated_code, fixed = TRUE), "Generated code should include the specified KorAP URL")
Marc Kupietze759b342025-07-05 19:48:20 +0200408
Marc Kupietz323a4d32026-09-03 12:04:35 +0200409 # Test code syntax
410 syntax_valid <- test_code_syntax(generated_code)
411 expect_true(syntax_valid, "Generated code should be syntactically valid R code")
Marc Kupietze759b342025-07-05 19:48:20 +0200412
Marc Kupietz323a4d32026-09-03 12:04:35 +0200413 # Print the generated code for manual inspection
414 cat("Generated collocation analysis code:\n", generated_code, "\n")
Marc Kupietz06143702025-07-05 17:49:31 +0200415
Marc Kupietz323a4d32026-09-03 12:04:35 +0200416 # Run the code if RUN_LLM_CODE is set
417 execution_result <- run_code_if_enabled(generated_code, "collocation analysis")
418 if (!is.na(execution_result)) {
419 expect_true(execution_result, "Generated code should execute without runtime errors")
420 }
421 })
Marc Kupietz2deadd82025-07-09 08:53:33 +0200422
Marc Kupietz323a4d32026-09-03 12:04:35 +0200423 test_that(paste(model, "can solve corpus query task with README guidance"), {
424 # Skip if offline
425 skip_if_offline()
Marc Kupietz2deadd82025-07-09 08:53:33 +0200426
Marc Kupietz323a4d32026-09-03 12:04:35 +0200427 # Skip if no API keys are set
428 skip_if_no_api_key(model)
Marc Kupietz06143702025-07-05 17:49:31 +0200429
Marc Kupietz323a4d32026-09-03 12:04:35 +0200430 # tidyllm is only suggested, so the tests must not fail without it
Marc Kupietz1cbb1552026-09-05 15:30:52 +0200431 if (usesTidyllm(model)) skip_if_not_installed("tidyllm")
Marc Kupietz06143702025-07-05 17:49:31 +0200432
Marc Kupietz323a4d32026-09-03 12:04:35 +0200433 # Check for README file
434 skip_if_not(!is.null(find_readme_path()), "Readme.md not found in current or parent directories")
Marc Kupietz06143702025-07-05 17:49:31 +0200435
Marc Kupietz323a4d32026-09-03 12:04:35 +0200436 # Create the prompt for corpus query
437 prompt <- create_readme_prompt(
Marc Kupietzffe28aa2026-09-05 17:50:45 +0200438 "write R code to perform a simple corpus query for 'Hello world' and fetch all results. The code should use the RKorAPClient package."
Marc Kupietz323a4d32026-09-03 12:04:35 +0200439 )
Marc Kupietz06143702025-07-05 17:49:31 +0200440
Marc Kupietz323a4d32026-09-03 12:04:35 +0200441 # Call LLM API
442 generated_response <- call_llm_api(prompt, model, max_tokens = 300)
443 generated_code <- extract_r_code(generated_response)
Marc Kupietz06143702025-07-05 17:49:31 +0200444
Marc Kupietz323a4d32026-09-03 12:04:35 +0200445 # Basic checks on the generated code
446 expect_true(grepl("KorAPConnection", generated_code), "Generated code should include KorAPConnection")
447 expect_true(grepl("corpusQuery", generated_code), "Generated code should include corpusQuery")
448 expect_true(grepl("Hello world", generated_code), "Generated code should include the search term 'Hello world'")
449 expect_true(grepl("fetchAll", generated_code), "Generated code should include fetchAll")
Marc Kupietze759b342025-07-05 19:48:20 +0200450
Marc Kupietz323a4d32026-09-03 12:04:35 +0200451 # Check that the generated code follows the README example pattern
452 expect_true(
453 grepl("\\|>", generated_code) || grepl("%>%", generated_code),
454 "Generated code should use pipe operators"
455 )
Marc Kupietze759b342025-07-05 19:48:20 +0200456
Marc Kupietz323a4d32026-09-03 12:04:35 +0200457 # Test code syntax
458 syntax_valid <- test_code_syntax(generated_code)
459 expect_true(syntax_valid, "Generated code should be syntactically valid R code")
460
461 # Print the generated code for manual inspection
462 cat("Generated corpus query code:\n", generated_code, "\n")
463
464 # Run the code if RUN_LLM_CODE is set
465 execution_result <- run_code_if_enabled(generated_code, "corpus query")
466 if (!is.na(execution_result)) {
467 expect_true(execution_result, "Generated code should execute without runtime errors")
468 }
469 })
Marc Kupietz81ddc342026-09-03 13:18:53 +0200470
471 test_that(paste(model, "can solve corpus size task with README guidance"), {
472 skip_if_offline()
473 skip_if_no_api_key(model)
Marc Kupietz1cbb1552026-09-05 15:30:52 +0200474 if (usesTidyllm(model)) skip_if_not_installed("tidyllm")
Marc Kupietz81ddc342026-09-03 13:18:53 +0200475 skip_if_not(!is.null(find_readme_path()), "Readme.md not found in current or parent directories")
476
477 prompt <- create_readme_prompt(
Marc Kupietzffe28aa2026-09-05 17:50:45 +0200478 "write R code that reports how many tokens the virtual corpus of newspaper texts published since 2020 contains."
Marc Kupietz81ddc342026-09-03 13:18:53 +0200479 )
480
481 generated_code <- extract_r_code(call_llm_api(prompt, model, max_tokens = 300))
482
483 expect_true(grepl("KorAPConnection", generated_code), "Generated code should include KorAPConnection")
484 expect_true(grepl("corpusStats", generated_code), "Generated code should include corpusStats")
485 expect_true(grepl("vc", generated_code), "Generated code should restrict to a virtual corpus")
486 expect_true(test_code_syntax(generated_code), "Generated code should be syntactically valid R code")
487
488 cat("Generated corpus size code:\n", generated_code, "\n")
489 })
490
491 test_that(paste(model, "can solve text metadata task with README guidance"), {
492 skip_if_offline()
493 skip_if_no_api_key(model)
Marc Kupietz1cbb1552026-09-05 15:30:52 +0200494 if (usesTidyllm(model)) skip_if_not_installed("tidyllm")
Marc Kupietz81ddc342026-09-03 13:18:53 +0200495 skip_if_not(!is.null(find_readme_path()), "Readme.md not found in current or parent directories")
496
497 prompt <- create_readme_prompt(
Marc Kupietzffe28aa2026-09-05 17:50:45 +0200498 "write R code that retrieves all metadata KorAP holds for the text with the sigle WPD17/L79/98721."
Marc Kupietz81ddc342026-09-03 13:18:53 +0200499 )
500
501 generated_code <- extract_r_code(call_llm_api(prompt, model, max_tokens = 300))
502
503 expect_true(grepl("KorAPConnection", generated_code), "Generated code should include KorAPConnection")
504 expect_true(grepl("textMetadata", generated_code), "Generated code should include textMetadata")
505 expect_true(grepl("WPD17/L79/98721", generated_code, fixed = TRUE), "Generated code should include the text sigle")
506 expect_true(test_code_syntax(generated_code), "Generated code should be syntactically valid R code")
507
508 cat("Generated text metadata code:\n", generated_code, "\n")
509 })
510
511 test_that(paste(model, "can solve association score task with README guidance"), {
512 skip_if_offline()
513 skip_if_no_api_key(model)
Marc Kupietz1cbb1552026-09-05 15:30:52 +0200514 if (usesTidyllm(model)) skip_if_not_installed("tidyllm")
Marc Kupietz81ddc342026-09-03 13:18:53 +0200515 skip_if_not(!is.null(find_readme_path()), "Readme.md not found in current or parent directories")
516
517 prompt <- create_readme_prompt(
518 paste(
519 "write R code that computes association scores for the word 'Grund' together with each of the",
520 "collocates 'triftiger' and 'guter', without searching for collocates first."
Marc Kupietzffe28aa2026-09-05 17:50:45 +0200521 )
Marc Kupietz81ddc342026-09-03 13:18:53 +0200522 )
523
524 generated_code <- extract_r_code(call_llm_api(prompt, model, max_tokens = 300))
525
526 expect_true(grepl("KorAPConnection", generated_code), "Generated code should include KorAPConnection")
527 expect_true(grepl("collocationScoreQuery", generated_code), "Generated code should include collocationScoreQuery")
528 expect_true(grepl("triftiger", generated_code), "Generated code should include the collocate 'triftiger'")
529 expect_true(grepl("guter", generated_code), "Generated code should include the collocate 'guter'")
530 expect_true(test_code_syntax(generated_code), "Generated code should be syntactically valid R code")
531
532 cat("Generated association score code:\n", generated_code, "\n")
533 })
Marc Kupietz1e9f7912026-09-03 13:23:42 +0200534
Marc Kupietzdb075d52026-09-08 09:38:49 +0200535 test_that(paste(model, "can solve result caching task with README guidance"), {
536 skip_if_offline()
537 skip_if_no_api_key(model)
Marc Kupietz1cbb1552026-09-05 15:30:52 +0200538 if (usesTidyllm(model)) skip_if_not_installed("tidyllm")
Marc Kupietzdb075d52026-09-08 09:38:49 +0200539 skip_if_not(!is.null(find_readme_path()), "Readme.md not found in current or parent directories")
540
541 prompt <- create_readme_prompt(
542 paste(
543 "write R code for an R Markdown document that reports how often 'Ameisenplage' occurs,",
544 "in a way that does not query the server again every time the document is knitted."
545 )
546 )
547
548 generated_code <- extract_r_code(call_llm_api(prompt, model, max_tokens = 300))
549
550 expect_true(grepl("KorAPConnection", generated_code), "Generated code should include KorAPConnection")
551 expect_true(grepl("cacheAs", generated_code), "Generated code should keep the result with cacheAs")
552 expect_true(grepl("Ameisenplage", generated_code), "Generated code should include the search term")
553 expect_true(test_code_syntax(generated_code), "Generated code should be syntactically valid R code")
554
555 cat("Generated result caching code:\n", generated_code, "\n")
556 })
557
558 test_that(paste(model, "can solve labelled corpora task with README guidance"), {
559 skip_if_offline()
560 skip_if_no_api_key(model)
Marc Kupietz1cbb1552026-09-05 15:30:52 +0200561 if (usesTidyllm(model)) skip_if_not_installed("tidyllm")
Marc Kupietzdb075d52026-09-08 09:38:49 +0200562 skip_if_not(!is.null(find_readme_path()), "Readme.md not found in current or parent directories")
563
564 prompt <- create_readme_prompt(
565 paste(
566 "write R code that reports how many tokens the newspaper texts published before 2010 and",
567 "those published since 2010 contain, with the two rows labelled 'before' and 'since'."
568 )
569 )
570
571 generated_code <- extract_r_code(call_llm_api(prompt, model, max_tokens = 300))
572
573 expect_true(grepl("corpusStats", generated_code), "Generated code should include corpusStats")
574 # the labels come from the names of the vc vector, not from a column added afterwards
575 expect_true(
576 grepl("(vc\\s*=\\s*)?c\\(\\s*[`\"']?before[`\"']?\\s*=", generated_code),
577 "Generated code should name the virtual corpora in the vc vector"
578 )
579 expect_true(test_code_syntax(generated_code), "Generated code should be syntactically valid R code")
580
581 cat("Generated labelled corpora code:\n", generated_code, "\n")
582 })
583
Marc Kupietz1e9f7912026-09-03 13:23:42 +0200584 # The code of the following two tasks cannot reasonably be executed in a test:
585 # authorization needs a browser flow or a token for restricted data, and a
586 # multi-VC collocation analysis runs for minutes. Only the generated code is
587 # inspected, which is the point anyway: can the Readme be followed?
588
589 test_that(paste(model, "can solve authorization task with README guidance"), {
590 skip_if_offline()
591 skip_if_no_api_key(model)
Marc Kupietz1cbb1552026-09-05 15:30:52 +0200592 if (usesTidyllm(model)) skip_if_not_installed("tidyllm")
Marc Kupietz1e9f7912026-09-03 13:23:42 +0200593 skip_if_not(!is.null(find_readme_path()), "Readme.md not found in current or parent directories")
594
595 prompt <- create_readme_prompt(
596 paste(
597 "write R code that authorizes the application so that it also receives KWIC snippets from",
598 "corpora with restricted licenses, and then queries 'Ameisenplage' including those snippets."
Marc Kupietzffe28aa2026-09-05 17:50:45 +0200599 )
Marc Kupietz1e9f7912026-09-03 13:23:42 +0200600 )
601
602 generated_code <- extract_r_code(call_llm_api(prompt, model, max_tokens = 300))
603
604 expect_true(grepl("KorAPConnection", generated_code), "Generated code should include KorAPConnection")
605 expect_true(
606 grepl("auth\\(|accessToken", generated_code),
607 "Generated code should authorize via auth() or an accessToken"
608 )
609 expect_true(
610 grepl("metadataOnly\\s*=\\s*FALSE", generated_code),
611 "Generated code should set metadataOnly = FALSE to receive KWIC snippets"
612 )
613 expect_true(test_code_syntax(generated_code), "Generated code should be syntactically valid R code")
614
615 cat("Generated authorization code:\n", generated_code, "\n")
616 })
617
618 test_that(paste(model, "can solve multi-VC comparison task with README guidance"), {
619 skip_if_offline()
620 skip_if_no_api_key(model)
Marc Kupietz1cbb1552026-09-05 15:30:52 +0200621 if (usesTidyllm(model)) skip_if_not_installed("tidyllm")
Marc Kupietz1e9f7912026-09-03 13:23:42 +0200622 skip_if_not(!is.null(find_readme_path()), "Readme.md not found in current or parent directories")
623
624 prompt <- create_readme_prompt(
625 paste(
626 "write R code that compares the collocates of 'Kritik' between newspaper texts published before 2010",
627 "and those published since 2010, and shows those collocates that are attested in both, ordered by how",
628 "differently they are associated."
Marc Kupietzffe28aa2026-09-05 17:50:45 +0200629 )
Marc Kupietz1e9f7912026-09-03 13:23:42 +0200630 )
631
632 generated_code <- extract_r_code(call_llm_api(prompt, model, max_tokens = 500))
633
634 expect_true(grepl("collocationAnalysis", generated_code), "Generated code should include collocationAnalysis")
Marc Kupietz73e3f372026-09-05 15:30:52 +0200635 # the labels of the comparison columns come from the names of the vc vector,
636 # which may just as well be built before the call rather than inside it
Marc Kupietz1e9f7912026-09-03 13:23:42 +0200637 expect_true(
Marc Kupietz73e3f372026-09-05 15:30:52 +0200638 grepl("vc\\s*=\\s*c\\(\\s*[A-Za-z.`\"']", generated_code) ||
639 grepl("c\\(\\s*[`\"']?[A-Za-z.][A-Za-z0-9._]*[`\"']?\\s*=[^=]", generated_code),
Marc Kupietz1e9f7912026-09-03 13:23:42 +0200640 "Generated code should pass a named vector of virtual corpora"
641 )
642 # one row per collocate and vc, so the comparison needs to be reduced
643 expect_true(
644 grepl("label", generated_code) || grepl("distinct", generated_code),
645 "Generated code should reduce the result to one row per collocate, via label or distinct()"
646 )
647 # imputed scores describe presence/absence rather than a measured contrast
648 expect_true(grepl("imputed", generated_code), "Generated code should take the imputed flag into account")
649 expect_true(test_code_syntax(generated_code), "Generated code should be syntactically valid R code")
650
651 cat("Generated multi-VC comparison code:\n", generated_code, "\n")
652 })
Marc Kupietz323a4d32026-09-03 12:04:35 +0200653}