ai-natural-language-tests / qa_refinement.py
CI
Deploy selected files to HF Space
bf2b5fc
Raw
History Blame Contribute Delete
6.29 kB
"""Test code refinement module for conversational refinement of generated tests."""
import logging
from pathlib import Path
from typing import List, Optional, Tuple
from qa_config import SYMBOLIC_RULES, get_llm, load_prompt_template
logger = logging.getLogger("ai-natural-language-tests")
FILE_HEADER_PREFIX = "// FILE: "
def strip_code_fence(text: str) -> str:
"""
Remove code fence markers from text.
Recognizes markers: ```typescript, ```javascript, ```js, ```
If no marker is found, returns the text unchanged (stripped).
"""
fence_markers = ["```typescript", "```javascript", "```js", "```"]
marker = next((item for item in fence_markers if item in text), None)
if marker is None:
return text.strip()
return text.split(marker, 1)[1].split("```", 1)[0].strip()
def split_file_sections(code_text: str) -> List[Tuple[str, str]]:
"""
Split code text into (filepath, code) tuples based on FILE_HEADER_PREFIX lines.
Returns a list of (filepath, code) tuples in order.
If no headers are found, returns an empty list (indicating bare snippet, not multi-file).
"""
lines = code_text.splitlines(keepends=True)
sections = []
current_path = None
current_code_lines = []
for line in lines:
if line.strip().startswith(FILE_HEADER_PREFIX):
# Save previous section if exists
if current_path is not None:
current_code = "".join(current_code_lines).rstrip()
sections.append((current_path, current_code))
# Extract new path
current_path = line.strip()[len(FILE_HEADER_PREFIX):].strip()
current_code_lines = []
else:
if current_path is not None:
current_code_lines.append(line)
# Save last section
if current_path is not None:
current_code = "".join(current_code_lines).rstrip()
sections.append((current_path, current_code))
return sections
def join_file_sections(sections: List[Tuple[str, str]]) -> str:
"""
Join (filepath, code) tuples back into a single code panel text.
Reconstructs the format: FILE_HEADER_PREFIX + filepath + newline + code
"""
chunks = []
for filepath, code in sections:
chunks.append(f"{FILE_HEADER_PREFIX}{filepath}\n{code}")
return "\n\n".join(chunks)
def refine_tests(
current_code: str,
instruction: str,
framework: str,
llm_provider: str = "openai",
) -> Tuple[str, List[Tuple[str, bool]]]:
"""
Refine generated test code based on a user instruction.
Args:
current_code: The current code panel text (may include FILE_HEADER_PREFIX lines).
instruction: Natural language instruction describing the refinement.
framework: The test framework ('cypress', 'playwright', 'webdriverio', 'appium').
llm_provider: LLM provider to use ('openai', 'anthropic', 'google').
Returns:
Tuple of (revised_panel_text, files_written)
- revised_panel_text: The updated code for the panel.
- files_written: List of (filepath, success) tuples indicating which files were written.
Raises:
ValueError: If the refined output is invalid (missing files, empty sections, etc.).
"""
if not current_code.strip():
raise ValueError("No current code to refine. Generate tests first.")
if not instruction.strip():
raise ValueError("Instruction is empty. Describe the change you want.")
# Parse original sections
original_sections = split_file_sections(current_code)
has_file_headers = len(original_sections) > 0
# Build the refinement prompt
prompt = load_prompt_template(
"test_refinement.yaml",
framework=framework,
instruction=instruction,
current_code=current_code,
symbolic_rules=SYMBOLIC_RULES,
)
logger.info(f"Refining tests with instruction: {instruction[:100]}...")
llm = get_llm(llm_provider)
response = llm.invoke(prompt)
response_content = response.content if hasattr(response, 'content') else str(response)
# Extract code from fence
revised_code = strip_code_fence(response_content)
# Parse revised sections
revised_sections = split_file_sections(revised_code)
# SAFETY RULE 1: If original had file headers, revised must have same files in same order
if has_file_headers:
original_paths = [path for path, _ in original_sections]
revised_paths = [path for path, _ in revised_sections]
if original_paths != revised_paths:
raise ValueError(
f"Refinement error: File list mismatch. "
f"Original: {original_paths}, Revised: {revised_paths}. "
f"The LLM must not add, remove, or rename files."
)
# SAFETY RULE 2: No empty file sections
for filepath, code in revised_sections:
if not code.strip():
raise ValueError(f"Refinement error: Empty code section for {filepath}. All files must have content.")
# Write files to disk (if original had headers)
files_written: List[Tuple[str, bool]] = []
if has_file_headers:
for filepath, code in revised_sections:
file_path = Path(filepath)
# SAFETY RULE 3: Only write to existing paths
if not file_path.exists():
logger.warning(f"File does not exist on disk, skipping write: {filepath}")
files_written.append((filepath, False))
continue
try:
file_path.write_text(code, encoding="utf-8")
logger.info(f"Updated: {filepath}")
files_written.append((filepath, True))
except Exception as e:
logger.error(f"Failed to write {filepath}: {e}")
files_written.append((filepath, False))
# Return revised panel text (reconstruct with headers if original had them)
if has_file_headers:
revised_panel_text = join_file_sections(revised_sections)
else:
# SAFETY RULE 4: Bare snippet (no headers) — return revision for panel only, no disk writes
revised_panel_text = revised_code
return revised_panel_text, files_written