Skip to content

Commit db00703

Browse files
authored
fix: keep the current plan when LLM refinement drops hunks or does not reduce violations
1 parent f078879 commit db00703

3 files changed

Lines changed: 100 additions & 2 deletions

File tree

‎pr_split/logs.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,14 @@
5959
REFINEMENT_EXHAUSTED = (
6060
"Refinement iteration limit reached ({iterations}), {remaining} violation(s) remain"
6161
)
62+
REFINEMENT_REJECTED = (
63+
"Refinement iteration {iteration} produced an invalid plan ({reason}); "
64+
"keeping the current plan, {remaining} violation(s) remain"
65+
)
66+
REFINEMENT_NO_IMPROVEMENT = (
67+
"Refinement iteration {iteration} did not reduce violations ({before} -> {after}); "
68+
"keeping the current plan"
69+
)
6270
STACK_LINKED = "Linked stack for PRs {prs}"
6371
STACK_LINK_FAILED = "Could not link stack for PRs {prs}: {detail}"
6472
MERGE_NODE_NOT_STACKED = (

‎pr_split/planner/client.py‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
Provider,
2121
)
2222
from ..diff_ops import ParsedDiff
23-
from ..exceptions import ErrorMsg, LLMError, PRSplitError
23+
from ..exceptions import ErrorMsg, LLMError, PlanValidationError, PRSplitError
2424
from ..schemas import Group, GroupAssignment
2525
from .chunker import (
2626
assign_uncovered_hunks,
@@ -42,7 +42,7 @@
4242
build_user_prompt,
4343
)
4444
from .scoring import score_plan
45-
from .validator import detect_loc_bound_violations
45+
from .validator import detect_loc_bound_violations, validate_coverage
4646

4747
_ANTHROPIC_TOOL_DEF = anthropic.types.ToolParam(
4848
name=SPLIT_TOOL_NAME,
@@ -379,6 +379,29 @@ def _refine_plan_with_llm(
379379
raw = _call_llm(system=system, user=user, settings=settings)
380380
refined = _parse_groups(raw)
381381
recompute_estimated_loc(refined, parsed_diff)
382+
# The incoming plan only had LOC warnings; never trade it for one
383+
# that drops or duplicates hunks, or that is no better.
384+
try:
385+
validate_coverage(refined, parsed_diff)
386+
except PlanValidationError as exc:
387+
logger.warning(
388+
logs.REFINEMENT_REJECTED.format(
389+
iteration=iteration, reason=exc, remaining=len(violations)
390+
)
391+
)
392+
return groups
393+
refined_violations = detect_loc_bound_violations(
394+
refined, settings.max_loc, settings.min_loc
395+
)
396+
if len(refined_violations) >= len(violations):
397+
logger.warning(
398+
logs.REFINEMENT_NO_IMPROVEMENT.format(
399+
iteration=iteration,
400+
before=len(violations),
401+
after=len(refined_violations),
402+
)
403+
)
404+
return groups
382405
groups = refined
383406
except LLMError:
384407
logger.warning(

‎tests/test_client.py‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -533,6 +533,73 @@ def test_refinement_falls_back_on_malformed_response(
533533
assert result == groups
534534
mock_call_llm.assert_called_once()
535535

536+
@patch("pr_split.planner.client._call_llm")
537+
def test_refinement_that_drops_hunks_is_rejected(
538+
self, mock_call_llm: MagicMock, monkeypatch: pytest.MonkeyPatch
539+
) -> None:
540+
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
541+
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
542+
parsed = parse_diff(_TWO_FILE_DIFF)
543+
settings = Settings(
544+
partition_strategy=PartitionStrategy.GRAPH,
545+
min_loc=5,
546+
max_loc=10,
547+
max_refinement_iterations=2,
548+
)
549+
# Only a.py survives: b.py's hunk is gone from the plan.
550+
mock_call_llm.return_value = RawToolOutput(
551+
groups=[
552+
{
553+
"id": "pr-1",
554+
"title": "feat: add a",
555+
"description": "Only a",
556+
"depends_on": [],
557+
"assignments": [
558+
{"file_path": "a.py", "assignment_type": "whole_file", "hunk_indices": [0]}
559+
],
560+
"estimated_loc": 3,
561+
}
562+
]
563+
)
564+
565+
groups = _undersized_groups()
566+
with patch("pr_split.planner.client.logger") as mock_logger:
567+
result = _refine_plan_with_llm(groups, parsed, settings, system="system")
568+
569+
assert result is groups
570+
assert [g.id for g in result] == ["pr-1", "pr-2"]
571+
mock_call_llm.assert_called_once()
572+
warning = mock_logger.warning.call_args[0][0]
573+
assert "produced an invalid plan" in warning
574+
assert "b.py[0] not assigned to any group" in warning
575+
576+
@patch("pr_split.planner.client._call_llm")
577+
def test_refinement_that_does_not_improve_is_rejected(
578+
self, mock_call_llm: MagicMock, monkeypatch: pytest.MonkeyPatch
579+
) -> None:
580+
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
581+
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
582+
parsed = parse_diff(_TWO_FILE_DIFF)
583+
settings = Settings(
584+
partition_strategy=PartitionStrategy.GRAPH,
585+
min_loc=5,
586+
max_loc=10,
587+
max_refinement_iterations=3,
588+
)
589+
# Same two undersized groups handed straight back.
590+
mock_call_llm.return_value = RawToolOutput(
591+
groups=_groups_to_raw_dicts(_undersized_groups()) # type: ignore[typeddict-item]
592+
)
593+
594+
groups = _undersized_groups()
595+
with patch("pr_split.planner.client.logger") as mock_logger:
596+
result = _refine_plan_with_llm(groups, parsed, settings, system="system")
597+
598+
assert result is groups
599+
mock_call_llm.assert_called_once()
600+
warning = mock_logger.warning.call_args[0][0]
601+
assert "did not reduce violations (2 -> 2)" in warning
602+
536603
def test_no_refinement_when_min_loc_is_none(self, monkeypatch: pytest.MonkeyPatch) -> None:
537604
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
538605
monkeypatch.delenv("OPENAI_API_KEY", raising=False)

0 commit comments

Comments
 (0)