qikly 0.3.0__py3-none-any.whl
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.
- qikly/__init__.py +11 -0
- qikly/agent_api/agent_interface.py +533 -0
- qikly/agent_api/call_llm.py +17 -0
- qikly/agent_api/code_loader/code_loader.py +86 -0
- qikly/agent_api/code_loader/excerpt.py +194 -0
- qikly/agent_api/prompts/acceptance_criteria_prompt.py +5 -0
- qikly/agent_api/prompts/acceptance_criteria_review_prompt.py +11 -0
- qikly/agent_api/prompts/fix_prompt.py +18 -0
- qikly/agent_api/prompts/fixture_proposal_prompt.py +52 -0
- qikly/agent_api/prompts/integration_test_prompt.py +5 -0
- qikly/agent_api/prompts/patch_prompt.py +32 -0
- qikly/agent_api/prompts/system_test_prompt.py +5 -0
- qikly/agent_api/prompts/template_loader.py +14 -0
- qikly/agent_api/prompts/unit_test_prompt.py +10 -0
- qikly/agent_api/providers/anthropic.py +111 -0
- qikly/agent_api/providers/gemini.py +60 -0
- qikly/agent_api/providers/openai.py +41 -0
- qikly/agent_api/providers/router.py +336 -0
- qikly/agent_api/providers/timeouts.py +55 -0
- qikly/agent_api/retry.py +136 -0
- qikly/agent_api/usage.py +266 -0
- qikly/agent_tools/apply_patch.py +181 -0
- qikly/agent_tools/inspect_code.py +81 -0
- qikly/agent_tools/junit.py +96 -0
- qikly/agent_tools/run_tests.py +182 -0
- qikly/approval.py +107 -0
- qikly/cli.py +1397 -0
- qikly/console.py +48 -0
- qikly/criteria_compare.py +182 -0
- qikly/criteria_import.py +161 -0
- qikly/explain.py +163 -0
- qikly/forecast.py +146 -0
- qikly/inputs_public/agent_defs/acceptance_criteria_prompt.md +45 -0
- qikly/inputs_public/agent_defs/acceptance_criteria_review_prompt.md +63 -0
- qikly/inputs_public/agent_defs/code_agent.md +71 -0
- qikly/inputs_public/agent_defs/fix_prompt.md +13 -0
- qikly/inputs_public/agent_defs/fix_prompt_ineffective_section.md +20 -0
- qikly/inputs_public/agent_defs/fixture_proposal_prompt.md +63 -0
- qikly/inputs_public/agent_defs/integration_test_prompt.md +27 -0
- qikly/inputs_public/agent_defs/patch_prompt.md +16 -0
- qikly/inputs_public/agent_defs/patch_prompt_retry_after_apply_failure.md +20 -0
- qikly/inputs_public/agent_defs/patch_prompt_retry_after_generation_failure.md +5 -0
- qikly/inputs_public/agent_defs/patch_prompt_retry_after_too_large.md +12 -0
- qikly/inputs_public/agent_defs/system_test_prompt.md +27 -0
- qikly/inputs_public/agent_defs/test_agent.md +112 -0
- qikly/inputs_public/agent_defs/unit_test_prompt.md +26 -0
- qikly/inputs_public/config/settings.yaml +77 -0
- qikly/inputs_public/config/tasks/AGG_RUNLOG.yaml +51 -0
- qikly/inputs_public/config/tasks/CALC_CALENDAR.yaml +53 -0
- qikly/inputs_public/config/tasks/CALC_DISCOUNT.yaml +53 -0
- qikly/inputs_public/config/tasks/CALC_TAX.yaml +51 -0
- qikly/inputs_public/config/tasks/ETL_ADDRESS.yaml +53 -0
- qikly/inputs_public/config/tasks/ETL_EMAIL.yaml +51 -0
- qikly/inputs_public/config/tasks/ETL_NAME_SPLIT.yaml +50 -0
- qikly/inputs_public/config/tasks/MERGE_CONTACTS.yaml +53 -0
- qikly/inputs_public/config/tasks/MERGE_SALES.yaml +59 -0
- qikly/inputs_public/config/tasks/MERGE_STOCK.yaml +60 -0
- qikly/inputs_public/data/AGG_RUNLOG/input_01.jsonl +29 -0
- qikly/inputs_public/data/AGG_RUNLOG/input_02.jsonl +22 -0
- qikly/inputs_public/data/CALC_CALENDAR/input_01.csv +8 -0
- qikly/inputs_public/data/CALC_CALENDAR/input_02.csv +6 -0
- qikly/inputs_public/data/CALC_DISCOUNT/input_01.csv +10 -0
- qikly/inputs_public/data/CALC_DISCOUNT/input_02.csv +9 -0
- qikly/inputs_public/data/CALC_TAX/input_01.csv +8 -0
- qikly/inputs_public/data/CALC_TAX/input_02.csv +9 -0
- qikly/inputs_public/data/ETL_ADDRESS/input_01.csv +13 -0
- qikly/inputs_public/data/ETL_ADDRESS/input_02.csv +13 -0
- qikly/inputs_public/data/ETL_EMAIL/input_01.csv +23 -0
- qikly/inputs_public/data/ETL_EMAIL/input_02.csv +11 -0
- qikly/inputs_public/data/ETL_NAME_SPLIT/input_01.csv +8 -0
- qikly/inputs_public/data/ETL_NAME_SPLIT/input_02.csv +7 -0
- qikly/inputs_public/data/MERGE_CONTACTS/input_01.csv +11 -0
- qikly/inputs_public/data/MERGE_CONTACTS/input_02.csv +7 -0
- qikly/inputs_public/data/MERGE_SALES/input_01.csv +12 -0
- qikly/inputs_public/data/MERGE_SALES/input_02.csv +9 -0
- qikly/inputs_public/data/MERGE_STOCK/input_01.csv +18 -0
- qikly/inputs_public/data/MERGE_STOCK/input_02.csv +12 -0
- qikly/inputs_public/reference/MERGE_SALES/merge.py +192 -0
- qikly/inputs_public/reference/MERGE_STOCK/merge.py +170 -0
- qikly/jira.py +192 -0
- qikly/orchestrator/live_view.py +228 -0
- qikly/orchestrator/orchestrator.py +1105 -0
- qikly/orchestrator/reports/aggregate_report.py +591 -0
- qikly/orchestrator/reports/metrics_report.py +377 -0
- qikly/orchestrator/reports/report.py +502 -0
- qikly/orchestrator/reports/trend_report.py +166 -0
- qikly/orchestrator/run_all.py +346 -0
- qikly/orchestrator/run_summary.py +145 -0
- qikly/orchestrator/tuning/check_criteria.py +148 -0
- qikly/orchestrator/tuning/diff_acceptance_criteria.py +301 -0
- qikly/orchestrator/tuning/gen_and_eval_acceptance_criteria.py +102 -0
- qikly/orchestrator/tuning/propose_fixtures.py +208 -0
- qikly/orchestrator/tuning/refine_acceptance_criteria.py +281 -0
- qikly/paths.py +223 -0
- qikly/pr_comment.py +120 -0
- qikly/scaffold.py +273 -0
- qikly/validate.py +166 -0
- qikly/version_check.py +101 -0
- qikly-0.3.0.dist-info/METADATA +963 -0
- qikly-0.3.0.dist-info/RECORD +104 -0
- qikly-0.3.0.dist-info/WHEEL +5 -0
- qikly-0.3.0.dist-info/entry_points.txt +6 -0
- qikly-0.3.0.dist-info/licenses/LICENSE +201 -0
- qikly-0.3.0.dist-info/top_level.txt +1 -0
qikly/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""
|
|
2
|
+
qikly -- an autonomous coding agent that writes its own acceptance
|
|
3
|
+
criteria, and the tests that enforce them, then converges real code against
|
|
4
|
+
that bar.
|
|
5
|
+
|
|
6
|
+
The subpackages here (orchestrator, agent_api, agent_tools) were top-level
|
|
7
|
+
until they were nested under this one. They are generic enough names that
|
|
8
|
+
installing them at the top level of site-packages would eventually collide
|
|
9
|
+
with somebody else's `orchestrator`.
|
|
10
|
+
"""
|
|
11
|
+
__version__ = "0.3.0"
|
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
from qikly.agent_api.call_llm import call_llm
|
|
2
|
+
from qikly.agent_api.prompts.acceptance_criteria_prompt import build_acceptance_criteria_prompt
|
|
3
|
+
from qikly.agent_api.prompts.acceptance_criteria_review_prompt import build_acceptance_criteria_review_prompt
|
|
4
|
+
from qikly.agent_api.prompts.fixture_proposal_prompt import build_fixture_proposal_prompt, read_fixtures
|
|
5
|
+
from qikly.agent_api.prompts.fix_prompt import build_fix_prompt
|
|
6
|
+
from qikly.agent_api.prompts.patch_prompt import build_patch_prompt
|
|
7
|
+
from qikly.agent_api.prompts.integration_test_prompt import build_integration_test_prompt
|
|
8
|
+
from qikly.agent_api.prompts.system_test_prompt import build_system_test_prompt
|
|
9
|
+
from qikly.agent_api.prompts.unit_test_prompt import build_unit_test_prompt
|
|
10
|
+
from qikly.agent_api.code_loader.code_loader import load_codebase, load_target_files
|
|
11
|
+
|
|
12
|
+
import difflib
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
|
|
17
|
+
from qikly.paths import list_input_dir, resolve_input
|
|
18
|
+
|
|
19
|
+
# Resolved per call rather than bound once at import: which file answers
|
|
20
|
+
# "code_agent.md" depends on whether the project has an inputs_private/
|
|
21
|
+
# override, and binding at import time would freeze that decision before a
|
|
22
|
+
# caller has had a chance to chdir into the project.
|
|
23
|
+
def agent_md_path():
|
|
24
|
+
return resolve_input("agent_defs/code_agent.md")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_agent_md_path():
|
|
28
|
+
return resolve_input("agent_defs/test_agent.md")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
AGENT_SRC_ROOT = "outputs/agent_src/code"
|
|
32
|
+
|
|
33
|
+
_ACCEPTANCE_CRITERIA_RE = re.compile(r"^acceptance_criteria:.*?(?=^\S|\Z)", re.MULTILINE | re.DOTALL)
|
|
34
|
+
_TARGET_FILES_RE = re.compile(r"^target_files:\s*\n((?:[ \t]*-.*\n?)*)", re.MULTILINE)
|
|
35
|
+
|
|
36
|
+
# Known task-type prefixes. Add a new domain family's prefix here when it's
|
|
37
|
+
# added to config/tasks/, so task_type() recognizes it instead of falling
|
|
38
|
+
# back to the full task_id.
|
|
39
|
+
_TASK_TYPES = ("ETL", "CALC", "MERGE")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def task_type(task_id):
|
|
43
|
+
"""
|
|
44
|
+
The task-type prefix (the part before the first underscore) -- ETL,
|
|
45
|
+
CALC, MERGE, ... -- used to group findings/reports by domain family
|
|
46
|
+
(see orchestrator/tuning/refine_acceptance_criteria.py's multi-task
|
|
47
|
+
summary). Falls back to the full task_id if it doesn't match a known
|
|
48
|
+
prefix, rather than guessing or raising -- an unrecognized prefix just
|
|
49
|
+
means a new task type that hasn't been added to _TASK_TYPES yet.
|
|
50
|
+
"""
|
|
51
|
+
prefix = task_id.split("_", 1)[0]
|
|
52
|
+
return prefix if prefix in _TASK_TYPES else task_id
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def task_config_path(task_id):
|
|
56
|
+
"""
|
|
57
|
+
config/tasks/<task_id>.yaml -- one file per V&V task, so any number of
|
|
58
|
+
tasks can be defined side by side and selected independently (see
|
|
59
|
+
run.py's task discovery / multiprocessing driver). Resolves to your
|
|
60
|
+
inputs_private/ copy if you have written one, else the bundled example.
|
|
61
|
+
"""
|
|
62
|
+
return resolve_input(f"config/tasks/{task_id}.yaml")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def agent_src_code_path(task_id):
|
|
66
|
+
"""
|
|
67
|
+
outputs/agent_src/code/<task_id>/ -- each task gets its own code
|
|
68
|
+
subtree. This is what actually makes running multiple tasks
|
|
69
|
+
concurrently (one process per task) safe: without the per-task
|
|
70
|
+
subfolder, two processes would clear and rewrite the same shared
|
|
71
|
+
outputs/agent_src/code/ directory out from under each other.
|
|
72
|
+
"""
|
|
73
|
+
return os.path.join(AGENT_SRC_ROOT, task_id)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _available_task_ids():
|
|
77
|
+
"""
|
|
78
|
+
Duplicates orchestrator.orchestrator.discover_task_ids()'s logic rather
|
|
79
|
+
than importing it -- orchestrator.py already imports from this module,
|
|
80
|
+
so importing back would be circular. Both are one call to
|
|
81
|
+
list_input_dir(), low risk of drifting apart.
|
|
82
|
+
"""
|
|
83
|
+
return sorted(
|
|
84
|
+
os.path.splitext(os.path.basename(p))[0]
|
|
85
|
+
for p in list_input_dir("config/tasks")
|
|
86
|
+
if p.endswith(".yaml")
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _read_task(task_id):
|
|
91
|
+
path = task_config_path(task_id)
|
|
92
|
+
if not os.path.exists(path):
|
|
93
|
+
suggestion = difflib.get_close_matches(task_id, _available_task_ids(), n=1)
|
|
94
|
+
hint = f" Did you mean {suggestion[0]!r}?" if suggestion else ""
|
|
95
|
+
raise FileNotFoundError(f"task config not found at: {path}.{hint}")
|
|
96
|
+
return open(path).read()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _task_without_acceptance_criteria(task_id):
|
|
100
|
+
"""
|
|
101
|
+
Read this task's config with its acceptance_criteria section removed,
|
|
102
|
+
for the coding agent's FIX/PATCH prompts. Test generation reads the
|
|
103
|
+
task config in full (see agent_generate_*_tests below) --
|
|
104
|
+
acceptance_criteria is the bar QA holds the implementation to, not
|
|
105
|
+
something handed to the developer up front. Coding and test generation
|
|
106
|
+
otherwise share the same model at temperature=0 with a fixed seed, so
|
|
107
|
+
without withholding something, both sides read the spec identically and
|
|
108
|
+
every stage passes on the first attempt. This asymmetry is what makes
|
|
109
|
+
the coding agent's first attempt genuinely fallible, the way a
|
|
110
|
+
developer working from requirements alone would be.
|
|
111
|
+
"""
|
|
112
|
+
task_text = _read_task(task_id)
|
|
113
|
+
stripped = _ACCEPTANCE_CRITERIA_RE.sub("", task_text)
|
|
114
|
+
return stripped.rstrip() + "\n"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _extract_target_files(fix_text):
|
|
118
|
+
"""
|
|
119
|
+
Pull the file paths out of a FIX's target_files: list (see
|
|
120
|
+
agent_defs/code_agent.md for the format the model is asked to
|
|
121
|
+
follow). Used so the PATCH prompt only has to load the files the FIX
|
|
122
|
+
said it would touch, not the whole task codebase. Returns [] if the
|
|
123
|
+
section is missing or empty -- callers should fall back to loading
|
|
124
|
+
everything rather than giving the model no code context at all.
|
|
125
|
+
"""
|
|
126
|
+
match = _TARGET_FILES_RE.search(fix_text)
|
|
127
|
+
if not match:
|
|
128
|
+
return []
|
|
129
|
+
files = []
|
|
130
|
+
for line in match.group(1).splitlines():
|
|
131
|
+
line = line.strip()
|
|
132
|
+
if line.startswith("-"):
|
|
133
|
+
files.append(line[1:].strip().strip('"').strip("'"))
|
|
134
|
+
return [f for f in files if f]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def agent_generate_acceptance_criteria(task_id, seed=None):
|
|
138
|
+
"""
|
|
139
|
+
Proposes acceptance_criteria from a task's requirements/interface/
|
|
140
|
+
description alone, playing the QA-hardening role a human currently has
|
|
141
|
+
to play by hand (see agent_defs/acceptance_criteria_prompt.md).
|
|
142
|
+
Returns a plain list[str] -- deliberately not written to any file, so a
|
|
143
|
+
caller can merge in user-supplied custom criteria with a plain
|
|
144
|
+
`generated + custom` and so this never mutates an existing task's real,
|
|
145
|
+
hand-tuned acceptance_criteria as a side effect.
|
|
146
|
+
"""
|
|
147
|
+
task = _task_without_acceptance_criteria(task_id)
|
|
148
|
+
prompt = build_acceptance_criteria_prompt(task)
|
|
149
|
+
raw = call_llm("acceptance_criteria", prompt, seed=seed)
|
|
150
|
+
return _extract_criteria_list(raw)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _extract_criteria_list(text):
|
|
154
|
+
"""
|
|
155
|
+
Parses a `- "..."` YAML-bullet-list response (see
|
|
156
|
+
acceptance_criteria_prompt.md's required output format) into a list of
|
|
157
|
+
strings, tolerant of stray markdown fences the model might add anyway
|
|
158
|
+
(same tolerance _extract_diff/_extract_python already apply elsewhere).
|
|
159
|
+
"""
|
|
160
|
+
match = re.search(r"```(?:yaml)?\s*\n(.*?)(?:```|\Z)", text, re.DOTALL)
|
|
161
|
+
body = match.group(1) if match else text
|
|
162
|
+
criteria = []
|
|
163
|
+
for line in body.splitlines():
|
|
164
|
+
line = line.strip()
|
|
165
|
+
if line.startswith("-"):
|
|
166
|
+
item = line[1:].strip()
|
|
167
|
+
if len(item) >= 2 and item.startswith('"') and item.endswith('"'):
|
|
168
|
+
item = item[1:-1]
|
|
169
|
+
if item:
|
|
170
|
+
criteria.append(item)
|
|
171
|
+
return criteria
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _format_criteria_block(criteria):
|
|
175
|
+
"""
|
|
176
|
+
Inverse of _extract_criteria_list -- renders a list[str] back into an
|
|
177
|
+
`acceptance_criteria:` YAML section matching the format real task files
|
|
178
|
+
use. Shared by run.py's --generate-criteria criteria seeding
|
|
179
|
+
(orchestrator/orchestrator.py) and
|
|
180
|
+
orchestrator/tuning/refine_acceptance_criteria.py's scratch-file writer,
|
|
181
|
+
so both stay consistent with each other and with how a hand-written
|
|
182
|
+
task file actually looks.
|
|
183
|
+
"""
|
|
184
|
+
return "acceptance_criteria:\n" + "\n".join(f" - {json.dumps(c)}" for c in criteria) + "\n"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
_CRITERIA_CATEGORIES = {
|
|
188
|
+
"insufficient_strictness", "parsing_looseness", "rounding_precision",
|
|
189
|
+
"silent_failure", "internal_consistency", "domain_normalization",
|
|
190
|
+
"intent_mismatch", "schema_structural", "other",
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def agent_generate_acceptance_criteria_review(task_id, current_criteria, implementation_code, seed=None):
|
|
195
|
+
"""
|
|
196
|
+
Adversarial follow-up to agent_generate_acceptance_criteria: given the
|
|
197
|
+
criteria proposed so far and a real, converged implementation that
|
|
198
|
+
already satisfies them, asks the model to find what a plausible
|
|
199
|
+
implementation could still get away with (see
|
|
200
|
+
agent_defs/acceptance_criteria_review_prompt.md). This is where
|
|
201
|
+
the loop gets the signal one-shot generation structurally can't have --
|
|
202
|
+
an actual implementation choice to react to, not just the spec text.
|
|
203
|
+
Returns a list[(category, criterion_text)] of NEW findings only,
|
|
204
|
+
possibly empty (an empty list is the loop's stopping signal, not a
|
|
205
|
+
failure). category is one of _CRITERIA_CATEGORIES, grounded in the
|
|
206
|
+
recurring bug shapes actually observed across real runs (see
|
|
207
|
+
acceptance_criteria_review_prompt.md) -- not the same list[str] shape
|
|
208
|
+
agent_generate_acceptance_criteria returns, since a category label is
|
|
209
|
+
round-reporting metadata, not part of the criterion text itself (the
|
|
210
|
+
plain text is what gets fed back into the next round and written into
|
|
211
|
+
a task file, never the tag).
|
|
212
|
+
"""
|
|
213
|
+
task = _task_without_acceptance_criteria(task_id)
|
|
214
|
+
prompt = build_acceptance_criteria_review_prompt(task, current_criteria, implementation_code)
|
|
215
|
+
# Its own mode, not "acceptance_criteria": the reviewer is a separate agent
|
|
216
|
+
# with a separate prompt, and it has to be addressable separately to be
|
|
217
|
+
# runnable on a different model from the one that drafted the criteria.
|
|
218
|
+
raw = call_llm("acceptance_criteria_review", prompt, seed=seed)
|
|
219
|
+
return _extract_tagged_criteria_list(raw)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def agent_propose_fixture_rows(task_id, criteria, seed=None):
|
|
223
|
+
"""
|
|
224
|
+
Which criteria no existing fixture row can trigger, and what row would fix
|
|
225
|
+
each one. Returns a list of dicts, possibly empty.
|
|
226
|
+
|
|
227
|
+
A criterion nothing can exercise produces a test that passes whatever the
|
|
228
|
+
code does. Roughly two thirds of the faults planted in this project's own
|
|
229
|
+
measurements were caught by nobody for that reason, so the bar was partly
|
|
230
|
+
unmeasurable rather than partly wrong.
|
|
231
|
+
|
|
232
|
+
This NEVER writes to a fixture file. It returns proposals, and the caller
|
|
233
|
+
writes them somewhere a person reads. Two reasons. A row is harder to judge
|
|
234
|
+
than a criterion, because its correctness is relative to the criterion it
|
|
235
|
+
was proposed for rather than visible on its face. And a fixture set that
|
|
236
|
+
grows in whatever direction a model finds interesting stops resembling the
|
|
237
|
+
data you actually process, at which point every rate measured on it
|
|
238
|
+
describes a world that does not exist.
|
|
239
|
+
"""
|
|
240
|
+
import yaml
|
|
241
|
+
|
|
242
|
+
with open(task_config_path(task_id), encoding="utf-8") as handle:
|
|
243
|
+
config = yaml.safe_load(handle) or {}
|
|
244
|
+
paths = [p for p in (config.get("inputs") or [])]
|
|
245
|
+
task = _task_without_acceptance_criteria(task_id)
|
|
246
|
+
prompt = build_fixture_proposal_prompt(task, criteria, read_fixtures(paths))
|
|
247
|
+
return _extract_fixture_proposals(call_llm("fixture_proposal", prompt, seed=seed),
|
|
248
|
+
len(criteria))
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _extract_fixture_proposals(text, criteria_count):
|
|
252
|
+
"""
|
|
253
|
+
Parse `- [criterion N] [file] row | outcome` lines, and `covered`.
|
|
254
|
+
|
|
255
|
+
Anything that does not match the documented shape is dropped rather than
|
|
256
|
+
guessed at: a malformed row would be written into a proposal file that a
|
|
257
|
+
person is meant to be able to paste from, and half a row is worse than no
|
|
258
|
+
row. A criterion number outside the list is dropped for the same reason,
|
|
259
|
+
since it cannot be reviewed against anything.
|
|
260
|
+
"""
|
|
261
|
+
import re
|
|
262
|
+
|
|
263
|
+
out = []
|
|
264
|
+
for line in (text or "").splitlines():
|
|
265
|
+
line = line.strip().lstrip("-").strip()
|
|
266
|
+
head = re.match(r"\[criterion\s+(\d+)\]\s*(.*)", line, re.I)
|
|
267
|
+
if not head:
|
|
268
|
+
continue
|
|
269
|
+
index = int(head.group(1))
|
|
270
|
+
if not 1 <= index <= criteria_count:
|
|
271
|
+
continue
|
|
272
|
+
rest = head.group(2).strip()
|
|
273
|
+
if rest.lower().startswith("covered"):
|
|
274
|
+
out.append({"criterion": index, "covered": True})
|
|
275
|
+
continue
|
|
276
|
+
body = re.match(r"\[([^\]]+)\]\s*(.+?)\s*\|\s*(.+)$", rest)
|
|
277
|
+
if not body:
|
|
278
|
+
continue
|
|
279
|
+
row = body.group(2).strip()
|
|
280
|
+
# A description of a row is not a row. One reply proposed the literal
|
|
281
|
+
# text "(empty file)" to mean "a log with no events", and it was
|
|
282
|
+
# pasted into a .jsonl as a line, breaking the fixture. A proposal has
|
|
283
|
+
# to be the data itself, because the whole point is that it can be
|
|
284
|
+
# appended without further interpretation.
|
|
285
|
+
if row.startswith("(") and row.endswith(")"):
|
|
286
|
+
continue
|
|
287
|
+
out.append({"criterion": index, "covered": False,
|
|
288
|
+
"file": body.group(1).strip(),
|
|
289
|
+
"row": row,
|
|
290
|
+
"outcome": body.group(3).strip()})
|
|
291
|
+
return out
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _extract_tagged_criteria_list(text):
|
|
295
|
+
"""
|
|
296
|
+
Parses a `- [category] "..."` list (see
|
|
297
|
+
acceptance_criteria_review_prompt.md's required output format) into a
|
|
298
|
+
list of (category, criterion_text) tuples. Falls back to category
|
|
299
|
+
"other" for a missing or unrecognized tag rather than dropping the
|
|
300
|
+
finding -- a mis-tagged real finding is still a real finding, and a
|
|
301
|
+
parsing quirk here shouldn't silently lose it.
|
|
302
|
+
"""
|
|
303
|
+
match = re.search(r"```(?:yaml)?\s*\n(.*?)(?:```|\Z)", text, re.DOTALL)
|
|
304
|
+
body = match.group(1) if match else text
|
|
305
|
+
tag_re = re.compile(r'^-\s*\[(\w+)\]\s*(.*)$')
|
|
306
|
+
results = []
|
|
307
|
+
for line in body.splitlines():
|
|
308
|
+
line = line.strip()
|
|
309
|
+
if not line.startswith("-"):
|
|
310
|
+
continue
|
|
311
|
+
m = tag_re.match(line)
|
|
312
|
+
if m:
|
|
313
|
+
category, item = m.group(1).strip().lower(), m.group(2).strip()
|
|
314
|
+
else:
|
|
315
|
+
category, item = "other", line[1:].strip()
|
|
316
|
+
if category not in _CRITERIA_CATEGORIES:
|
|
317
|
+
category = "other"
|
|
318
|
+
if len(item) >= 2 and item.startswith('"') and item.endswith('"'):
|
|
319
|
+
item = item[1:-1]
|
|
320
|
+
if item:
|
|
321
|
+
results.append((category, item))
|
|
322
|
+
return results
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def agent_generate_fix(task_id, failure_info, seed=None, previous_ineffective_patch=None):
|
|
326
|
+
"""
|
|
327
|
+
Build FIX prompt and call the LLM to generate a FIX object.
|
|
328
|
+
"""
|
|
329
|
+
|
|
330
|
+
path = agent_md_path()
|
|
331
|
+
if not os.path.exists(path):
|
|
332
|
+
raise FileNotFoundError(f"code_agent.md not found at: {path}")
|
|
333
|
+
|
|
334
|
+
agent_md = open(path, encoding="utf-8").read()
|
|
335
|
+
task = _task_without_acceptance_criteria(task_id)
|
|
336
|
+
|
|
337
|
+
prompt = build_fix_prompt(
|
|
338
|
+
agent_md, task, failure_info, previous_ineffective_patch=previous_ineffective_patch
|
|
339
|
+
)
|
|
340
|
+
return call_llm("fix", prompt, seed=seed)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def agent_generate_patch(task_id, fix, seed=None, previous_patch=None, previous_patch_error=None,
|
|
344
|
+
previous_patch_too_large=False):
|
|
345
|
+
"""
|
|
346
|
+
Build PATCH prompt and call the LLM to generate a PATCH object. Only
|
|
347
|
+
loads the files the FIX named in its target_files: list, not the whole
|
|
348
|
+
task codebase -- keeps the prompt (and the model's ability to introduce
|
|
349
|
+
an unrelated change) scoped to what the FIX actually said it would
|
|
350
|
+
touch. Falls back to the whole task codebase if target_files couldn't be
|
|
351
|
+
parsed out of the FIX text, rather than giving the model no code at all.
|
|
352
|
+
"""
|
|
353
|
+
path = agent_md_path()
|
|
354
|
+
if not os.path.exists(path):
|
|
355
|
+
raise FileNotFoundError(f"code_agent.md not found at: {path}")
|
|
356
|
+
|
|
357
|
+
agent_md = open(path, encoding="utf-8").read()
|
|
358
|
+
task = _task_without_acceptance_criteria(task_id)
|
|
359
|
+
code_dir = agent_src_code_path(task_id)
|
|
360
|
+
target_files = _extract_target_files(fix)
|
|
361
|
+
# The FIX names the files and, in prose, the functions it means to
|
|
362
|
+
# change, so it is also the retrieval query for anything too large to
|
|
363
|
+
# load whole.
|
|
364
|
+
codebase_text = (load_target_files(code_dir, target_files, context=fix)
|
|
365
|
+
if target_files else load_codebase(code_dir))
|
|
366
|
+
|
|
367
|
+
prompt = build_patch_prompt(
|
|
368
|
+
agent_md, task, fix, codebase_text,
|
|
369
|
+
previous_patch=previous_patch, previous_patch_error=previous_patch_error,
|
|
370
|
+
previous_patch_too_large=previous_patch_too_large
|
|
371
|
+
)
|
|
372
|
+
raw_patch = call_llm("patch", prompt, seed=seed)
|
|
373
|
+
diff = _extract_diff(raw_patch)
|
|
374
|
+
return _recount_hunk_headers(diff)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _load_test_agent_md():
|
|
378
|
+
path = test_agent_md_path()
|
|
379
|
+
if not os.path.exists(path):
|
|
380
|
+
raise FileNotFoundError(f"test_agent.md not found at: {path}")
|
|
381
|
+
return open(path, encoding="utf-8").read()
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def read_acceptance_criteria(task_id):
|
|
385
|
+
"""This task's acceptance_criteria as a list, or [] if it has none."""
|
|
386
|
+
import yaml
|
|
387
|
+
|
|
388
|
+
data = yaml.safe_load(_read_task(task_id)) or {}
|
|
389
|
+
return list(data.get("acceptance_criteria") or [])
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _task_with_criteria(task_id, criteria):
|
|
393
|
+
"""
|
|
394
|
+
The task config text with its acceptance_criteria replaced by `criteria`.
|
|
395
|
+
|
|
396
|
+
Used to generate tests against one batch of the bar at a time. Passing
|
|
397
|
+
None returns the config untouched, which is the single-call behaviour.
|
|
398
|
+
"""
|
|
399
|
+
text = _read_task(task_id)
|
|
400
|
+
if criteria is None:
|
|
401
|
+
return text
|
|
402
|
+
block = ("acceptance_criteria:\n"
|
|
403
|
+
+ "".join(f" - {json.dumps(c)}\n" for c in criteria))
|
|
404
|
+
# Replace through a callable. re.sub treats a string replacement as a
|
|
405
|
+
# template, so the backslash escapes json.dumps emits get reinterpreted
|
|
406
|
+
# and the result is either a re.error or a tab injected into the YAML.
|
|
407
|
+
return _ACCEPTANCE_CRITERIA_RE.sub(lambda _m: block, text)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def criteria_batches(task_id, per_batch):
|
|
411
|
+
"""
|
|
412
|
+
Split this task's criteria into batches of `per_batch`, or [None] when
|
|
413
|
+
batching is off, which means one call carrying the whole bar.
|
|
414
|
+
|
|
415
|
+
Batching exists because the suite was measured not to grow with the bar:
|
|
416
|
+
across 67 archived suites, refinement raised the criteria count 54% and
|
|
417
|
+
the generated suite got 6% smaller, so tests per criterion fell from 2.21
|
|
418
|
+
to 1.32. One call for the whole set appears to produce a roughly fixed
|
|
419
|
+
amount of test code however much it is asked to cover, which dilutes
|
|
420
|
+
coverage rather than adding it.
|
|
421
|
+
"""
|
|
422
|
+
if not per_batch or per_batch < 1:
|
|
423
|
+
return [None]
|
|
424
|
+
criteria = read_acceptance_criteria(task_id)
|
|
425
|
+
if len(criteria) <= per_batch:
|
|
426
|
+
return [None]
|
|
427
|
+
return [criteria[i:i + per_batch] for i in range(0, len(criteria), per_batch)]
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def agent_generate_integration_tests(task_id, seed=None, criteria=None):
|
|
431
|
+
"""
|
|
432
|
+
Build the INTEGRATION test prompt (spec only, no implementation access)
|
|
433
|
+
and call the LLM to generate that test file's source.
|
|
434
|
+
|
|
435
|
+
`criteria` restricts the bar shown to this call. None means the whole set.
|
|
436
|
+
"""
|
|
437
|
+
test_agent_md = _load_test_agent_md()
|
|
438
|
+
task = _task_with_criteria(task_id, criteria)
|
|
439
|
+
|
|
440
|
+
prompt = build_integration_test_prompt(test_agent_md, task)
|
|
441
|
+
raw = call_llm("test_integration", prompt, seed=seed)
|
|
442
|
+
return _extract_python(raw)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def agent_generate_system_tests(task_id, seed=None, criteria=None):
|
|
446
|
+
"""
|
|
447
|
+
Build the SYSTEM test prompt (spec only, no implementation access) and
|
|
448
|
+
call the LLM to generate that test file's source.
|
|
449
|
+
"""
|
|
450
|
+
test_agent_md = _load_test_agent_md()
|
|
451
|
+
task = _task_with_criteria(task_id, criteria)
|
|
452
|
+
|
|
453
|
+
prompt = build_system_test_prompt(test_agent_md, task)
|
|
454
|
+
raw = call_llm("test_system", prompt, seed=seed)
|
|
455
|
+
return _extract_python(raw)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def agent_generate_unit_tests(task_id, seed=None, criteria=None):
|
|
459
|
+
"""
|
|
460
|
+
Build the UNIT test prompt and call the LLM to generate that test file's
|
|
461
|
+
source. Unlike the integration/system prompts, this one includes the
|
|
462
|
+
current outputs/agent_src/code/<task_id>/ implementation -- the one
|
|
463
|
+
deliberate exception to keeping test generation blind to the code under
|
|
464
|
+
test, since unit tests need to target the implementation's actual
|
|
465
|
+
functions by name.
|
|
466
|
+
"""
|
|
467
|
+
test_agent_md = _load_test_agent_md()
|
|
468
|
+
task = _task_with_criteria(task_id, criteria)
|
|
469
|
+
codebase_text = load_codebase(agent_src_code_path(task_id))
|
|
470
|
+
|
|
471
|
+
prompt = build_unit_test_prompt(test_agent_md, task, codebase_text)
|
|
472
|
+
raw = call_llm("test_unit", prompt, seed=seed)
|
|
473
|
+
return _extract_python(raw)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _extract_python(text):
|
|
477
|
+
"""
|
|
478
|
+
test_agent.md instructs the model to output raw Python source with no
|
|
479
|
+
wrapping. Some models add a ```python fenced block anyway -- strip that
|
|
480
|
+
wrapping the same way _extract_diff() does for PATCH output.
|
|
481
|
+
"""
|
|
482
|
+
match = re.search(r"```(?:python)?\s*\n(.*?)(?:```|\Z)", text, re.DOTALL)
|
|
483
|
+
body = match.group(1) if match else text
|
|
484
|
+
return body.strip("\n") + "\n"
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _extract_diff(text):
|
|
488
|
+
"""
|
|
489
|
+
code_agent.md instructs the model to wrap the PATCH in a `PATCH:` label and a
|
|
490
|
+
```diff fenced block. Strip that wrapping so callers get a plain unified diff.
|
|
491
|
+
"""
|
|
492
|
+
match = re.search(r"```diff\s*\n(.*?)(?:```|\Z)", text, re.DOTALL)
|
|
493
|
+
body = match.group(1) if match else text
|
|
494
|
+
return body.strip("\n") + "\n"
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
_HUNK_HEADER_RE = re.compile(r"^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*)$")
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _recount_hunk_headers(diff_text):
|
|
501
|
+
"""
|
|
502
|
+
LLM-generated diffs frequently have wrong hunk header line counts
|
|
503
|
+
(@@ -old_start,old_count +new_start,new_count @@) even when the hunk
|
|
504
|
+
body itself is correct, which makes `patch`/`git apply` reject an
|
|
505
|
+
otherwise-valid diff. Counting context/added/removed lines is a purely
|
|
506
|
+
mechanical operation, so recompute the counts ourselves rather than
|
|
507
|
+
relying on the model to get the arithmetic right.
|
|
508
|
+
"""
|
|
509
|
+
lines = diff_text.splitlines()
|
|
510
|
+
out = []
|
|
511
|
+
i = 0
|
|
512
|
+
while i < len(lines):
|
|
513
|
+
match = _HUNK_HEADER_RE.match(lines[i])
|
|
514
|
+
if not match:
|
|
515
|
+
out.append(lines[i])
|
|
516
|
+
i += 1
|
|
517
|
+
continue
|
|
518
|
+
|
|
519
|
+
old_start, new_start, trailer = match.groups()
|
|
520
|
+
body = []
|
|
521
|
+
j = i + 1
|
|
522
|
+
while j < len(lines) and not lines[j].startswith(("@@ ", "--- ", "+++ ", "diff ")):
|
|
523
|
+
body.append(lines[j])
|
|
524
|
+
j += 1
|
|
525
|
+
|
|
526
|
+
old_count = sum(1 for l in body if l.startswith((" ", "-")))
|
|
527
|
+
new_count = sum(1 for l in body if l.startswith((" ", "+")))
|
|
528
|
+
|
|
529
|
+
out.append(f"@@ -{old_start},{old_count} +{new_start},{new_count} @@{trailer}")
|
|
530
|
+
out.extend(body)
|
|
531
|
+
i = j
|
|
532
|
+
|
|
533
|
+
return "\n".join(out) + "\n"
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from qikly.agent_api.providers.router import route_model
|
|
2
|
+
from qikly.agent_api.retry import with_retry
|
|
3
|
+
|
|
4
|
+
def call_llm(mode, prompt, seed=None):
|
|
5
|
+
"""
|
|
6
|
+
Unified entry point for all LLM calls. Every mode is sent to the single
|
|
7
|
+
provider configured via LLM_PROVIDER (see agent_api/providers/router.py).
|
|
8
|
+
mode: "fix", "patch", "test_integration", "test_system", or "test_unit"
|
|
9
|
+
prompt: fully constructed prompt string
|
|
10
|
+
seed: optional int for best-effort reproducible output
|
|
11
|
+
"""
|
|
12
|
+
# Transport failures are retried here; bad answers are not. A rate limit
|
|
13
|
+
# or a gateway error says nothing about the task, and letting it through
|
|
14
|
+
# spends an attempt from the stage budget on a problem the model never
|
|
15
|
+
# saw. A refusal that retrying cannot fix, a depleted balance or a bad
|
|
16
|
+
# key, is raised immediately with its own message intact.
|
|
17
|
+
return with_retry(lambda: route_model(mode, prompt, seed=seed))
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
from qikly.agent_api.code_loader.excerpt import excerpt_file, relevant_names
|
|
4
|
+
|
|
5
|
+
def load_codebase(code_dir="outputs/agent_src/code"):
|
|
6
|
+
"""
|
|
7
|
+
Load every code file under code_dir and return a single text block, each
|
|
8
|
+
file prefixed with a header so the agent can reference it. code_dir is
|
|
9
|
+
already task-specific (see agent_src_code_path() in agent_interface.py),
|
|
10
|
+
so this never crosses into another task's code -- callers that need the
|
|
11
|
+
whole implementation in context (unit test generation, or PATCH when it
|
|
12
|
+
can't tell which files it needs) use this; PATCH generation itself
|
|
13
|
+
prefers load_target_files() below to avoid paying for files it isn't
|
|
14
|
+
touching.
|
|
15
|
+
"""
|
|
16
|
+
output = []
|
|
17
|
+
|
|
18
|
+
for root, dirs, files in os.walk(code_dir):
|
|
19
|
+
dirs[:] = [d for d in dirs if d != "old"]
|
|
20
|
+
for filename in files:
|
|
21
|
+
if filename.endswith(".py"):
|
|
22
|
+
path = os.path.join(root, filename)
|
|
23
|
+
# errors="replace" rather than strict. This reads code the
|
|
24
|
+
# model wrote, and a stray byte in it must not end the run:
|
|
25
|
+
# a decode error here killed one experiment pair outright.
|
|
26
|
+
# The replacement character reaches the prompt, where the
|
|
27
|
+
# FIX/PATCH loop can act on it, which is the whole point of
|
|
28
|
+
# having that loop.
|
|
29
|
+
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
|
30
|
+
content = f.read()
|
|
31
|
+
output.append(f"\n# FILE: {path}\n{content}\n")
|
|
32
|
+
|
|
33
|
+
return "\n".join(output)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def load_target_files(code_dir, target_files, context=None):
|
|
37
|
+
"""
|
|
38
|
+
Load just the given files -- as printed in a FIX's target_files list,
|
|
39
|
+
e.g. "outputs/agent_src/code/<task_id>/etl.py" -- instead of every file
|
|
40
|
+
under code_dir. Used to scope the PATCH prompt to what the FIX actually
|
|
41
|
+
said it would touch rather than the whole task codebase.
|
|
42
|
+
|
|
43
|
+
target_files is LLM output, not trusted input: any path that doesn't
|
|
44
|
+
resolve inside code_dir is silently skipped rather than read (no
|
|
45
|
+
escaping the task's own code directory). A path naming a file that
|
|
46
|
+
doesn't exist yet (a FIX about to create it for the first time) is also
|
|
47
|
+
silently skipped -- there's nothing to show the model.
|
|
48
|
+
|
|
49
|
+
`context` is the text that named this work: the FIX itself, and the
|
|
50
|
+
failing test output. Any file over the size threshold is excerpted against
|
|
51
|
+
the identifiers in it, so a large module contributes the definitions this
|
|
52
|
+
failure is about plus a signature for everything else, rather than either
|
|
53
|
+
filling the prompt or being unusable. Passing nothing means every file is
|
|
54
|
+
loaded whole, which is the old behaviour and still right for small ones.
|
|
55
|
+
"""
|
|
56
|
+
code_dir_abs = os.path.abspath(code_dir)
|
|
57
|
+
# Names from the FIX and the failure text decide what survives an
|
|
58
|
+
# excerpt. Without context nothing matches, which would collapse every
|
|
59
|
+
# definition, so an empty context means load whole instead.
|
|
60
|
+
wanted = relevant_names(context) if context else None
|
|
61
|
+
output = []
|
|
62
|
+
|
|
63
|
+
for rel_path in target_files:
|
|
64
|
+
candidate = os.path.abspath(rel_path)
|
|
65
|
+
try:
|
|
66
|
+
inside = os.path.commonpath([candidate, code_dir_abs]) == code_dir_abs
|
|
67
|
+
except ValueError:
|
|
68
|
+
# On Windows commonpath raises for paths on different drives. A
|
|
69
|
+
# model that names D:\tmp\x.py should have its suggestion
|
|
70
|
+
# ignored, not end the run with a traceback.
|
|
71
|
+
inside = False
|
|
72
|
+
if not inside:
|
|
73
|
+
continue
|
|
74
|
+
if not os.path.isfile(candidate):
|
|
75
|
+
continue
|
|
76
|
+
if wanted:
|
|
77
|
+
content = excerpt_file(candidate, wanted)
|
|
78
|
+
else:
|
|
79
|
+
# No names to match on, so excerpting would collapse every
|
|
80
|
+
# definition in the file. "I was not told what matters" reads as
|
|
81
|
+
# load it whole.
|
|
82
|
+
with open(candidate, "r", encoding="utf-8", errors="replace") as f:
|
|
83
|
+
content = f.read()
|
|
84
|
+
output.append(f"\n# FILE: {candidate}\n{content}\n")
|
|
85
|
+
|
|
86
|
+
return "\n".join(output)
|