Skip to content

feat: structured error types for LLM self-correction - #13

Merged
pablospe merged 9 commits into
mainfrom
feedback-precision
Apr 17, 2026
Merged

pablospe merged 9 commits into
mainfrom
feedback-precision

Conversation

@pablospe

@pablospe pablospe commented Apr 17, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • Structured fields on TextNotFoundError (search_text, paragraph_ref, paragraph_preview, occurrence, total_occurrences) so agents retry without re-reading the document.
  • New ParagraphIndexError and BatchOperationError classes — both DocxEditError subclasses — replace stdlib IndexError / ValueError on LLM-facing paths. BREAKING, but except DocxEditError still catches them.
  • batch_edit wraps _apply_single_edit so inner field-level failures carry operation_index.
  • skills/docx/SKILL.md gains a recovery table for each structured error.

See openspec/changes/add-structured-error-types/ for the proposal, design rationale, and spec deltas.

Test plan

  • uv run pytest — 491 passed, 6 skipped
  • uv run ruff check . && uv run ruff format --check . — clean
  • openspec validate add-structured-error-types --strict --no-interactive — valid
  • New tests/test_error_quality.py documents before/after for every structured field

Summary by CodeRabbit

  • New Features

    • Added structured error types for document editing operations with detailed diagnostic information, improving error recovery and debugging.
    • Introduced OpenSpec workflow commands (Explore, Propose, Apply, Archive) for managing experimental changes.
  • Documentation

    • Enhanced error handling documentation with recovery patterns and diagnostic field references.
    • Added OpenSpec configuration framework and comprehensive workflow guidance.

- Remove OpenSpec markers from CLAUDE.md and AGENTS.md
- Remove legacy openspec/AGENTS.md (replaced by config.yaml)
- Add openspec/config.yaml with spec-driven schema
- Add .claude/skills and .claude/commands/opsx from openspec init
New structured-errors capability extending the HashMismatchError
pattern to TextNotFoundError, ParagraphIndexError, and
BatchOperationError so LLM callers can self-correct in-loop.
Extends the HashMismatchError structured-field pattern to the rest of
the LLM-facing error surface so agents can recover in-loop without
re-reading the document or regex-parsing messages.

- TextNotFoundError now carries search_text, paragraph_ref,
  paragraph_preview (80-char cap, matching HashMismatchError) and
  occurrence/total_occurrences for nth-match failures. The message
  embeds the current paragraph content when the search was scoped.
- ParagraphIndexError (new, DocxEditError subclass) replaces stdlib
  IndexError on out-of-range paragraph refs. Carries index and
  total_paragraphs. BREAKING.
- BatchOperationError (new, DocxEditError subclass) replaces
  ValueError on batch validation paths only. batch_edit also wraps
  _apply_single_edit so inner field-level failures carry
  operation_index. Non-batch ValueError paths unchanged. BREAKING.

Both new classes inherit from DocxEditError, so existing
'except DocxEditError' catch-all handlers still match.

skills/docx/SKILL.md gains a recovery table documenting each
structured error's fields and the retry pattern agents should follow.

See openspec/changes/add-structured-error-types for the full spec.
@coderabbitai

coderabbitai Bot commented Apr 17, 2026 •

Copy link
Copy Markdown

Warning

Rate limit exceeded

@pablospe has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 47 minutes and 11 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 47 minutes and 11 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: a07035a8-7926-4fea-a173-9e8d95638e8a

📥 Commits

Reviewing files that changed from the base of the PR and between ad1e7b1 and d7cd3aa.

📒 Files selected for processing (1)
  • skills/docx/SKILL.md
📝 Walkthrough

Walkthrough

The PR introduces OpenSpec workflow documentation defining AI assistant behavior for proposing, implementing, and archiving experimental changes, alongside a significant enhancement to docx_editor's exception surface by implementing structured, LLM-recoverable error types with standardized recovery fields and adaptive messaging.

Changes

Cohort / File(s) Summary
OpenSpec Workflow Commands
.claude/commands/opsx/apply.md, .claude/commands/opsx/archive.md, .claude/commands/opsx/explore.md, .claude/commands/opsx/propose.md
New AI assistant command workflows defining end-to-end control flows for applying OpenSpec changes, archiving completed work, exploring problems, and proposing new changes, each with detailed CLI invocations, state handling, and user interaction patterns.
OpenSpec Skill Definitions
.claude/skills/openspec-apply-change/SKILL.md, .claude/skills/openspec-archive-change/SKILL.md, .claude/skills/openspec-explore/SKILL.md, .claude/skills/openspec-propose/SKILL.md
New OpenSpec skill definitions paralleling the workflow commands, specifying assistant stance, guardrails, permissible activities, and structured output formats for each operation mode.
OpenSpec Configuration & Cleanup
openspec/config.yaml, AGENTS.md (removed), CLAUDE.md (removed OpenSpec block)
Added schema configuration file; removed legacy operational guidance blocks from project documentation files.
Exception Definitions
docx_editor/exceptions.py
Expanded TextNotFoundError with structured fields (search_text, paragraph_ref, paragraph_preview, occurrence, total_occurrences) and adaptive message building; introduced new exception types ParagraphIndexError and BatchOperationError (both inheriting from DocxEditError) with standardized recovery fields.
Exception Exports
docx_editor/__init__.py
Added public re-exports of ParagraphIndexError and BatchOperationError to module's public API.
Core Error Handling Updates
docx_editor/track_changes.py, docx_editor/comments.py
Updated exception handling throughout: replaced IndexError with ParagraphIndexError in paragraph resolution, replaced batch ValueError with BatchOperationError (operation index + reason), enhanced all TextNotFoundError raises to thread structured fields (paragraph reference, preview, occurrence data) instead of message-format strings.
Error Documentation
skills/docx/SKILL.md
Added structured "Error Handling & Recovery" section documenting four LLM-facing exception types with recovery examples.
Test Updates
tests/test_batch_edit.py, tests/test_error_quality.py, tests/test_paragraph_hash.py, tests/test_rewrite_paragraph.py
Updated batch-related tests to expect BatchOperationError instead of ValueError; added comprehensive new test suite (test_error_quality.py) validating structured exception fields, message content, inheritance, and recovery patterns; added new structured error validation tests in test_paragraph_hash.py.
OpenSpec Artifacts (Archived Change)
openspec/changes/archive/2026-04-17-add-structured-error-types/...
Archived OpenSpec change documentation including proposal, design document, specification, tasks checklist, and metadata recording the structured error enhancement initiative.
Top-level Specification
openspec/specs/structured-errors/spec.md
New specification document codifying the structured error contract for docx-editor edit operations, including required instance attributes, message-content rules, and public re-export requirements.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Poem

A rabbit hops through error fields so bright,
With structured messages, now crystal clear—
Each failure state now coded just right,
LLMs recover without fear! 🐰✨
Docs guide the way; the specs are here.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 71.70% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: structured error types for LLM self-correction' accurately and clearly describes the main change—introducing structured error types to support LLM error recovery.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feedback-precision

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov

codecov Bot commented Apr 17, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.49123% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 93.8%. Comparing base (9f0a8b5) to head (d7cd3aa).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
docx_editor/track_changes.py 90.9% 2 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##            main     #13     +/-   ##
=======================================
+ Coverage   93.6%   93.8%   +0.2%     
=======================================
  Files         10      10             
  Lines       2133    2170     +37     
  Branches     485     491      +6     
=======================================
+ Hits        1998    2037     +39     
+ Misses        35      34      -1     
+ Partials     100      99      -1     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 16

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
docx_editor/track_changes.py (2)

237-240: ⚠️ Potential issue | 🟡 Minor

Stale docstring — rewrite_paragraph no longer raises IndexError.

_resolve_paragraph now raises ParagraphIndexError (line 91), so this docstring is incorrect and the "breaking change" is under-documented at this raise site.

📝 Proposed fix
         Raises:
             HashMismatchError: If the paragraph hash doesn't match
-            IndexError: If paragraph index is out of range
+            ParagraphIndexError: If paragraph index is out of range
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docx_editor/track_changes.py` around lines 237 - 240, The docstring for
rewrite_paragraph is stale: replace the raised IndexError with
ParagraphIndexError and mention that rewrite_paragraph can propagate
ParagraphIndexError from _resolve_paragraph (in addition to HashMismatchError).
Update the "Raises:" section to list ParagraphIndexError (with a short note that
it's raised when the paragraph index is out of range via _resolve_paragraph) and
keep HashMismatchError unchanged.

1360-1368: ⚠️ Potential issue | 🟠 Major

_insert_text fallback drops occurrence context on re-raise.

When _get_nth_match raises TextNotFoundError because occurrence >= len(matches), the exception carries occurrence/total_occurrences. This branch then runs the cross-boundary fallback; on still-not-found it constructs a fresh TextNotFoundError(anchor) from None, discarding those structured fields — exactly the diagnostic signal the PR is introducing.

The sibling paths in replace_text (line 531) and suggest_deletion (line 634) bare-raise to preserve the original. Do the same here:

🔒️ Proposed fix
         # Find the text element containing the anchor text
         try:
             elem = self._get_nth_match(anchor, occurrence)
         except TextNotFoundError:
             # Fall back to cross-boundary search
             match = self._find_across_boundaries(anchor, occurrence)
             if match is None:
-                raise TextNotFoundError(anchor) from None
+                raise
             return self._insert_near_match(match, text, position)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docx_editor/track_changes.py` around lines 1360 - 1368, When `_get_nth_match`
raises TextNotFoundError in `_insert_text`, the current fallback constructs a
fresh TextNotFoundError and loses the original occurrence/total_occurrences
fields; change the except block to capture the original exception (except
TextNotFoundError as e:) and, if the cross-boundary fallback
`_find_across_boundaries` returns None, re-raise the original exception (use
bare `raise` or `raise e`) instead of creating a new TextNotFoundError so the
occurrence context is preserved; keep the successful cross-boundary path
returning `_insert_near_match(match, text, position)`.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.claude/commands/opsx/apply.md:
- Around line 16-20: Update the apply-mode behavior to require explicit user
confirmation before selecting a change instead of auto-selecting when only one
active change exists: remove or modify the "Auto-select if only one active
change exists" branch and replace it with a step that runs `openspec list
--json` and invokes the AskUserQuestion tool to present the single available
change for explicit confirmation (mention apply mode and AskUserQuestion tool by
name so the text and any implementation logic use the explicit-confirmation flow
rather than implicit auto-selection).
- Around line 65-70: The current task-applier flow marks a task checkbox `- [ ]`
→ `- [x]` immediately after making edits; change the flow so that after making
code changes you run a mandatory validation step (e.g., execute the
validation/check routine, tests, or a lint/compile step) and only if that
validation returns success do you update the task line to `- [x]`; update the
logic that performs the checkbox replacement (search for the code that performs
the direct replacement of `- [ ]` with `- [x]`) to gate it behind the validation
result, and add unit/integration tests that simulate a passing and failing
validation to confirm the checkbox is only toggled on success.

In @.claude/commands/opsx/archive.md:
- Around line 58-63: The cancel option currently is ignored because the flow
always proceeds to archive; modify the control flow so that the "Cancel" choice
aborts the operation and does not invoke the Task tool or continue to archive.
Specifically, change the logic described in the prompt options and the "If user
chooses sync" block so the Task tool (subagent_type: "general-purpose", prompt:
"Use Skill tool to invoke openspec-sync-specs...") is only invoked when the user
explicitly picks a sync option, and ensure only the user-selected archive
options ("Archive now" or "Archive without syncing") trigger the archival step;
if "Cancel" is selected, return without performing sync or archive. Ensure the
prompt text and branching reflect this corrected behavior.

In @.claude/skills/openspec-apply-change/SKILL.md:
- Around line 69-74: The current guidance toggles task checkboxes (`- [ ]` → `-
[x]`) immediately after edits; change the flow so each task is only marked
complete after a validation step succeeds (e.g., run tests, lint, or other
required CI checks). Update the SKILL.md text around the "Mark task complete in
the tasks file: `- [ ]` → `- [x]`" line to require running and passing
unit/integration tests and linting for any code change, and add an explicit
bullet that says "Write/modify tests for the change and confirm test/lint pass
before marking - [x]". Ensure the instruction references running the repo's test
command and CI checks as the gating step.
- Around line 20-24: Update the SKILL behavior for implementation mode so it
never auto-selects a change: remove the "Auto-select if only one active change
exists" fallback and the "Infer from conversation context" behavior for
implementation mode and instead always run `openspec list --json` and invoke the
AskUserQuestion tool to get explicit user confirmation before proceeding; keep
the name-when-provided path unchanged but ensure the implementation-mode flow
(referenced as "implementation mode" and the AskUserQuestion tool) requires
explicit user selection even if only one change is available.

In @.claude/skills/openspec-archive-change/SKILL.md:
- Around line 62-67: The prompt flow in SKILL.md lists "Cancel" as an option but
then always proceeds to archive; update the flow so selecting the "Cancel"
option aborts the archive and does not invoke the Task tool or
"openspec-sync-specs" subagent. Concretely: detect the user's chosen option
string ("Cancel") and short-circuit the archive path (no Task tool call with
prompt "Use Skill tool to invoke openspec-sync-specs for change '<name>'..." and
no subsequent archive step); only run the Task tool and proceed to archive when
the choice is one of the sync/archive confirmations ("Sync now
(recommended)","Archive without syncing","Archive now","Sync anyway").

In `@docx_editor/exceptions.py`:
- Around line 164-168: Move the helper function _truncate_preview so it is
defined before the TextNotFoundError class (specifically before
TextNotFoundError.__init__ which calls it); this ensures the helper is available
at class-definition time and prevents future refactors (e.g., using it in
default args or class attributes) from breaking name resolution. Locate the
current _truncate_preview implementation and cut/paste it above the
TextNotFoundError class declaration, keeping the same signature and docstring;
no other code changes are required.
- Around line 100-112: The current message-building chain in the exception
constructor drops paragraph context when occurrence/total_occurrences is
present; change the logic in the exception initializer (the block that currently
checks occurrence, total_occurrences, paragraph_ref and sets msg before calling
super().__init__(msg)) to build msg incrementally: start with a base message
(e.g. "Text not found" or the occurrence summary when
occurrence/total_occurrences provided), then if paragraph_ref is not None append
the paragraph scope ("in paragraph {paragraph_ref}") and if
self.paragraph_preview is not None append the preview string; ensure the final
msg passed to super().__init__(msg) contains both occurrence info and
paragraph_ref/paragraph_preview when supplied.
- Around line 115-130: ParagraphIndexError builds a misleading hint "valid:
P1-P{total_paragraphs}" when total_paragraphs == 0; update the __init__ of
ParagraphIndexError to conditionally format the message: keep the existing
message for total_paragraphs > 0, but when total_paragraphs == 0 replace the
"valid: ..." fragment with something like "no valid paragraph indices (document
has 0 paragraphs)" so callers won't see a spurious range; modify the constructor
logic that composes the super().__init__ string in the ParagraphIndexError class
to branch on self.total_paragraphs and emit the appropriate text.

In `@docx_editor/track_changes.py`:
- Around line 107-109: _paragraph_preview currently returns the full visible
text which can be large; add a one-line clarifying comment above the
_paragraph_preview method stating that truncation to 80 chars is handled by
TextNotFoundError.__init__ -> _truncate_preview so callers should not truncate
here and to avoid duplicate/inconsistent truncation or premature optimization
that would duplicate behavior.

In `@openspec/changes/add-structured-error-types/specs/structured-errors/spec.md`:
- Around line 22-24: The occurrence branch in TextNotFoundError.__init__
(exceptions.py) currently omits paragraph_ref from the constructed message when
both occurrence and paragraph_ref are provided; update the constructor so the
message always includes search_text and, if paragraph_ref is present, also
includes paragraph_ref (and paragraph_preview when present) even on the
occurrence path, and ensure all raise sites (e.g., docx_editor/comments.py and
track_changes.py) continue using the new structured signature; alternatively, if
you prefer precedence, update the spec to state that occurrence short-circuits
paragraph_ref explicitly and keep the code as-is.

In `@skills/docx/SKILL.md`:
- Around line 445-460: The example only demonstrates HashMismatchError and
TextNotFoundError but the doc mentions ParagraphIndexError and
BatchOperationError; update the example around doc.replace to also show recovery
patterns for ParagraphIndexError (use e.paragraph_index to locate and retry the
replace on the corrected index) and BatchOperationError (inspect e.errors or
similar batch error payload to iterate failures and retry or log per-paragraph),
referencing the existing symbols ParagraphIndexError, BatchOperationError and
doc.replace so readers/agents can see programmatic in-loop recovery for these
new error classes.

In `@tests/test_batch_edit.py`:
- Around line 385-390: The test test_non_batch_value_error_unchanged should
assert the specific error message from ParagraphRef.parse to avoid masking
unrelated ValueError regressions; update the pytest.raises in that test to
include match="Invalid paragraph reference" (or the exact message used by
ParagraphRef.parse) so the assertion validates both the exception type and the
expected message.

In `@tests/test_error_quality.py`:
- Around line 70-75: The assertion is over-stripping the preview by using
err.paragraph_preview.rstrip(".") which will remove any trailing periods from
real content; in the test (variables err, paragraph_preview, msg) replace the
rstrip(".") usage with a removesuffix("...") call so only a trailing ellipsis is
removed (preserving legitimate trailing periods), and then assert that
err.paragraph_preview.removesuffix("...") appears in msg.
- Around line 137-156: Add an explicit return type annotation to the helper
function _build_batch_doc (e.g., def _build_batch_doc(...) -> Document) to
satisfy ruff ANN202; ensure the Document symbol is available in the module scope
(import or qualify it) so the annotation resolves in the test file and then run
the linter/tests to confirm the warning is resolved.

In `@tests/test_paragraph_hash.py`:
- Around line 459-461: The test currently uses current_preview.rstrip(".") which
over-strips trailing periods; change the logic that computes preview_prefix
(variable preview_prefix derived from current_preview in
tests/test_paragraph_hash.py) to remove only the truncation ellipsis by using
str.removesuffix("...") (or equivalent) before taking the first 10 chars, so
that ordinary sentence-ending periods are preserved; update the assertion that
checks preview_prefix in msg accordingly and keep references to current_preview
and preview_prefix (and the list_paragraphs behavior) to ensure the test still
validates truncated previews.

---

Outside diff comments:
In `@docx_editor/track_changes.py`:
- Around line 237-240: The docstring for rewrite_paragraph is stale: replace the
raised IndexError with ParagraphIndexError and mention that rewrite_paragraph
can propagate ParagraphIndexError from _resolve_paragraph (in addition to
HashMismatchError). Update the "Raises:" section to list ParagraphIndexError
(with a short note that it's raised when the paragraph index is out of range via
_resolve_paragraph) and keep HashMismatchError unchanged.
- Around line 1360-1368: When `_get_nth_match` raises TextNotFoundError in
`_insert_text`, the current fallback constructs a fresh TextNotFoundError and
loses the original occurrence/total_occurrences fields; change the except block
to capture the original exception (except TextNotFoundError as e:) and, if the
cross-boundary fallback `_find_across_boundaries` returns None, re-raise the
original exception (use bare `raise` or `raise e`) instead of creating a new
TextNotFoundError so the occurrence context is preserved; keep the successful
cross-boundary path returning `_insert_near_match(match, text, position)`.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4f1c8301-eac6-43b7-a349-ba1542a8cbb0

📥 Commits

Reviewing files that changed from the base of the PR and between 9f0a8b5 and 01575ee.

📒 Files selected for processing (26)
  • .claude/commands/opsx/apply.md
  • .claude/commands/opsx/archive.md
  • .claude/commands/opsx/explore.md
  • .claude/commands/opsx/propose.md
  • .claude/skills/openspec-apply-change/SKILL.md
  • .claude/skills/openspec-archive-change/SKILL.md
  • .claude/skills/openspec-explore/SKILL.md
  • .claude/skills/openspec-propose/SKILL.md
  • AGENTS.md
  • CLAUDE.md
  • docx_editor/__init__.py
  • docx_editor/comments.py
  • docx_editor/exceptions.py
  • docx_editor/track_changes.py
  • openspec/AGENTS.md
  • openspec/changes/add-structured-error-types/.openspec.yaml
  • openspec/changes/add-structured-error-types/design.md
  • openspec/changes/add-structured-error-types/proposal.md
  • openspec/changes/add-structured-error-types/specs/structured-errors/spec.md
  • openspec/changes/add-structured-error-types/tasks.md
  • openspec/config.yaml
  • skills/docx/SKILL.md
  • tests/test_batch_edit.py
  • tests/test_error_quality.py
  • tests/test_paragraph_hash.py
  • tests/test_rewrite_paragraph.py
💤 Files with no reviewable changes (3)
  • CLAUDE.md
  • AGENTS.md
  • openspec/AGENTS.md

Comment thread .claude/commands/opsx/apply.md
Comment thread .claude/commands/opsx/apply.md
Comment thread .claude/commands/opsx/archive.md
Comment thread .claude/skills/openspec-apply-change/SKILL.md
Comment thread .claude/skills/openspec-apply-change/SKILL.md
Comment thread skills/docx/SKILL.md
Comment thread tests/test_batch_edit.py
Comment thread tests/test_error_quality.py Outdated
Comment thread tests/test_error_quality.py Outdated
Comment thread tests/test_paragraph_hash.py Outdated
…ases

- TextNotFoundError: include paragraph context when both occurrence and
  paragraph_ref are passed; previously paragraph_ref was silently dropped.
- ParagraphIndexError: when document has 0 paragraphs, say so instead of
  emitting 'valid: P1-P0'.
- Tighten test_non_batch_value_error_unchanged with a match pattern so it
  won't mask unrelated ValueErrors.
- Add coverage for TextNotFoundError(paragraph_ref=...) with no preview.
- hoist _truncate_preview above TextNotFoundError (exceptions.py)
- document single-source-of-truth for preview truncation (track_changes.py)
- extend recovery example with ParagraphIndexError + BatchOperationError (SKILL.md)
- use removesuffix("...") instead of rstrip(".") for preview assertions (tests)
- add type hints to _build_batch_doc test helper
Codecov patch gate flagged exceptions.py:113 and :137 as uncovered
diff lines. Add direct unit tests for both branches:

- nth-match miss with paragraph_ref appends 'Paragraph: <ref>.'
- empty-document ParagraphIndexError uses distinct phrasing
  (no misleading 'valid: P1-P0')

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
docx_editor/track_changes.py (2)

241-244: ⚠️ Potential issue | 🟡 Minor

Stale docstring — rewrite_paragraph now raises ParagraphIndexError, not IndexError.

_resolve_paragraph was updated to raise ParagraphIndexError (line 91), so the Raises: section here is out of date and will mislead callers trying to catch the right exception.

📝 Proposed fix
         Raises:
             HashMismatchError: If the paragraph hash doesn't match
-            IndexError: If paragraph index is out of range
+            ParagraphIndexError: If paragraph index is out of range
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docx_editor/track_changes.py` around lines 241 - 244, Update the docstring
for rewrite_paragraph to reflect that it raises ParagraphIndexError (not
IndexError) by replacing IndexError in the Raises: section with
ParagraphIndexError; ensure the other listed exception (HashMismatchError)
remains, and reference the existing ParagraphIndexError used by
_resolve_paragraph so callers can catch the correct exception type.

137-158: ⚠️ Potential issue | 🟠 Major

Unguarded ParagraphRef.parse() in batch methods leaks ValueError without operation_index.

Both batch_edit (line 141) and batch_rewrite (line 214) call ParagraphRef.parse() directly in their upfront validation loops without wrapping in try-except. If a batch receives a malformed paragraph reference like "not-a-ref", ParagraphRef.parse() raises a bare ValueError — callers cannot determine which operation failed, violating the batch contract that all errors carry operation_index for LLM recovery.

Wrap both calls:

Fix for batch_edit
-            ref = ParagraphRef.parse(op.paragraph)
+            try:
+                ref = ParagraphRef.parse(op.paragraph)
+            except ValueError as e:
+                raise BatchOperationError(i, str(e)) from e

Apply the equivalent guard around ParagraphRef.parse(ref_str) in batch_rewrite (line 214).

Additionally, rewrite_paragraph docstring (line 243) claims IndexError but the method raises ParagraphIndexError via _resolve_paragraph. Update the docstring.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docx_editor/track_changes.py` around lines 137 - 158, The batch handlers
(batch_edit and batch_rewrite) currently call ParagraphRef.parse(...) directly
in their validation loops which allows a bare ValueError to escape without the
required operation index; wrap the ParagraphRef.parse(op.paragraph) call in
batch_edit (the loop building parsed list) and the ParagraphRef.parse(ref_str)
call in batch_rewrite in a try/except that catches ValueError and raises
BatchOperationError(operation_index, str(e)) from e so callers receive the
operation index; also update the rewrite_paragraph docstring to mention
ParagraphIndexError (the error raised by _resolve_paragraph) instead of
IndexError.
tests/test_batch_edit.py (1)

261-398: ⚠️ Potential issue | 🟡 Minor

Test updates correctly reflect the new BatchOperationError contract.

Message-matching regexes align with the exceptions actually raised ("paragraph reference is required for batch mode", "replace requires...", etc.), and the new TestStructuredBatchOperationError exercises both the predispatch path and the inner _apply_single_edit wrapping (try/except ValueError → BatchOperationError). test_non_batch_value_error_unchanged nicely pins the scope of the breaking change.

One coverage gap worth considering: there's no test for a malformed ParagraphRef syntax supplied to batch_edit (e.g., paragraph="not-a-ref"). In the current implementation ParagraphRef.parse(op.paragraph) runs outside the try/except ValueError block in batch_edit, so it would escape as a bare ValueError without an operation_index — see the related note on docx_editor/track_changes.py.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_batch_edit.py` around lines 261 - 398, ParagraphRef.parse is
called outside the try/except in batch_edit so a malformed paragraph ref (e.g.
"not-a-ref") raises a plain ValueError without being wrapped with
BatchOperationError.operation_index; move or wrap the ParagraphRef.parse call(s)
inside the same try/except that wraps _apply_single_edit (or explicitly catch
ValueError around parsing) and re-raise a BatchOperationError with the correct
operation_index and reason (consistent with other cases), updating batch_edit
and any helper that parses op.paragraph to ensure every parsing error for a
batched op is converted to BatchOperationError referencing that op's index.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docx_editor/exceptions.py`:
- Around line 70-159: Add explicit return type annotations "-> None" to the
three new initializer methods to satisfy ruff ANN204: update
TextNotFoundError.__init__, ParagraphIndexError.__init__, and
BatchOperationError.__init__ to declare their return type as None (e.g. def
__init__(...) -> None:) while keeping their current parameters and bodies
intact.

In `@openspec/changes/archive/2026-04-17-add-structured-error-types/design.md`:
- Around line 58-59: The batch path currently calls
ParagraphRef.parse(op.paragraph) outside the batch's try/except, so malformed
refs surface as bare ValueError; update batch_edit and batch_rewrite in
docx_editor/track_changes.py to either move the ParagraphRef.parse call inside
the existing try/except that converts ValueError to BatchOperationError or
explicitly wrap the parse call with try: ... except ValueError as e: raise
BatchOperationError(operation_index=i, cause=e) from e so malformed-paragraph
errors are reported with the operation_index like other validation failures.

In `@openspec/changes/archive/2026-04-17-add-structured-error-types/proposal.md`:
- Line 23: Several raise sites still call TextNotFoundError with the old
single-arg signature; update every raise to the structured form
TextNotFoundError(paragraph_ref=..., paragraph_preview=..., occurrence=...)
using the local paragraph context or anchor where available (e.g. in the
comments handler use the anchor and document to build paragraph_ref and a short
paragraph_preview; in track_changes functions/_resolve_paragraph/_resolve_text
use the local paragraph or p variable to construct paragraph_ref and
paragraph_preview and pass the matched occurrence index or default 0). Replace
bare raises like raise TextNotFoundError(anchor_text) / raise
TextNotFoundError(text) with the structured keyword arguments and ensure
consistency with ParagraphRef.parse/ParagraphRef factory usage to build
paragraph_ref.

In `@openspec/specs/structured-errors/spec.md`:
- Around line 3-4: Replace the placeholder "TBD" under the "Purpose" header with
a concise one-sentence purpose that describes the spec's intent; update the
Purpose section in openspec/specs/structured-errors/spec.md to a single sentence
such as "Defines the LLM-consumable structured exception contract for
docx-editor edit operations, guaranteeing in-loop recovery via typed fields on
DocxEditError subclasses." to make the document self-describing and actionable
(look for the "Purpose" header and the DocxEditError symbol to ensure the
sentence references the contract and intended consumers).
- Around line 78-103: The spec omits how malformed ParagraphRef syntax (from
ParagraphRef.parse) invoked during the batch upfront-validation loop in
track_changes.py should be classified; update the requirement text to state that
any ValueError raised by ParagraphRef.parse when called during batch_edit
validation must be wrapped and re-raised as BatchOperationError(i, reason) so
operation_index is preserved, and add an explicit scenario mirroring the
existing ones that demonstrates a malformed paragraph ref at index N yields
BatchOperationError with operation_index == N and a descriptive reason;
reference ParagraphRef.parse, batch_edit, _apply_single_edit, and
BatchOperationError in the new text so implementers know to treat ParagraphRef
parsing errors the same as other batch-path ValueError cases.

In `@skills/docx/SKILL.md`:
- Around line 460-464: The recovery snippet for ParagraphIndexError must
explicitly handle the empty-document case: check e.total_paragraphs (from the
caught ParagraphIndexError) and if it is 0, abort or handle gracefully (e.g.,
return/raise/log) instead of indexing doc.list_paragraphs(); otherwise compute
safe_idx as before (using max/min on e.index and e.total_paragraphs), get the
paragraph reference via doc.list_paragraphs()[safe_idx - 1].split("|")[0], and
call doc.replace("stale text", "new text", paragraph=ref). Ensure references to
ParagraphIndexError, e.total_paragraphs, safe_idx, doc.list_paragraphs(), and
doc.replace(...) are updated accordingly.

In `@tests/test_error_quality.py`:
- Around line 169-188: The helper _build_batch_doc reaches into private
internals (doc._document_editor, editor.dom, editor._parse_fragment); replace
this by either (A) using a public API to create the same document (e.g.
Document.open / Document.save with public editor methods) or (B) moving the
current implementation into a single shared fixture (conftest) so tests don't
manipulate privates directly; update references to _build_batch_doc to call the
new fixture or public authoring helper and remove direct uses of
_document_editor and _parse_fragment from test files.
- Around line 113-115: The three tight string-match assertions in
tests/test_error_quality.py (the assertions checking "Only 1 occurrence(s) of
'needle'", "occurrence=3 requested", and "Paragraph: P2#cafe") should be relaxed
to simple presence checks to avoid coupling to message wording; update the
assertions to check that "needle" is in msg, that "3" (or "occurrence 3") is
present in msg, and that "P2#cafe" (or "P2#cafe" substring) is in msg so the
test verifies the structured content rather than exact phrasing.
- Around line 35-42: The doc_path pytest fixture uses tempfile.mkdtemp and
manual cleanup; change it to use pytest's tmp_path fixture instead: accept
tmp_path as a parameter to the doc_path fixture, copy the test_data
(Path(__file__).parent / "test_data" / "simple.docx") into tmp_path /
"test.docx" and yield that Path; remove the tempfile.mkdtemp, shutil.copy/rmtree
manual logic so pytest handles temp dir creation and cleanup automatically in
the doc_path fixture.

---

Outside diff comments:
In `@docx_editor/track_changes.py`:
- Around line 241-244: Update the docstring for rewrite_paragraph to reflect
that it raises ParagraphIndexError (not IndexError) by replacing IndexError in
the Raises: section with ParagraphIndexError; ensure the other listed exception
(HashMismatchError) remains, and reference the existing ParagraphIndexError used
by _resolve_paragraph so callers can catch the correct exception type.
- Around line 137-158: The batch handlers (batch_edit and batch_rewrite)
currently call ParagraphRef.parse(...) directly in their validation loops which
allows a bare ValueError to escape without the required operation index; wrap
the ParagraphRef.parse(op.paragraph) call in batch_edit (the loop building
parsed list) and the ParagraphRef.parse(ref_str) call in batch_rewrite in a
try/except that catches ValueError and raises
BatchOperationError(operation_index, str(e)) from e so callers receive the
operation index; also update the rewrite_paragraph docstring to mention
ParagraphIndexError (the error raised by _resolve_paragraph) instead of
IndexError.

In `@tests/test_batch_edit.py`:
- Around line 261-398: ParagraphRef.parse is called outside the try/except in
batch_edit so a malformed paragraph ref (e.g. "not-a-ref") raises a plain
ValueError without being wrapped with BatchOperationError.operation_index; move
or wrap the ParagraphRef.parse call(s) inside the same try/except that wraps
_apply_single_edit (or explicitly catch ValueError around parsing) and re-raise
a BatchOperationError with the correct operation_index and reason (consistent
with other cases), updating batch_edit and any helper that parses op.paragraph
to ensure every parsing error for a batched op is converted to
BatchOperationError referencing that op's index.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: dab01c3a-8646-4162-8627-f88880bc8099

📥 Commits

Reviewing files that changed from the base of the PR and between 01575ee and ad1e7b1.

📒 Files selected for processing (12)
  • docx_editor/exceptions.py
  • docx_editor/track_changes.py
  • openspec/changes/archive/2026-04-17-add-structured-error-types/.openspec.yaml
  • openspec/changes/archive/2026-04-17-add-structured-error-types/design.md
  • openspec/changes/archive/2026-04-17-add-structured-error-types/proposal.md
  • openspec/changes/archive/2026-04-17-add-structured-error-types/specs/structured-errors/spec.md
  • openspec/changes/archive/2026-04-17-add-structured-error-types/tasks.md
  • openspec/specs/structured-errors/spec.md
  • skills/docx/SKILL.md
  • tests/test_batch_edit.py
  • tests/test_error_quality.py
  • tests/test_paragraph_hash.py

Comment thread docx_editor/exceptions.py
Comment thread openspec/specs/structured-errors/spec.md
Comment thread openspec/specs/structured-errors/spec.md
Comment thread skills/docx/SKILL.md
Comment thread tests/test_error_quality.py
Comment thread tests/test_error_quality.py
Comment thread tests/test_error_quality.py
…mple

list_paragraphs()[0] would IndexError on an empty document because
max(1, min(n, 0)) == 1. Raise to the caller instead — an LLM copying
the snippet gets correct behaviour on documents with no paragraphs.
@pablospe
pablospe merged commit 8271215 into main Apr 17, 2026
10 checks passed
pablospe added a commit that referenced this pull request Jul 13, 2026
* test: add foreign track-changes fixtures from docx-revisions (MIT)

Two real-world fixtures copied from
https://github.com/balalofernandez/docx-revisions:

- OXML_TrackChanges_Test.docx: 8 revisions (4 w:ins + 4 w:del) from 6
  authors, covering a del+ins replacement pair, an em-dash insertion
  next to smart-quote runs, and identical text inserted by one author
  and deleted by another in the same paragraph.
- tricky-track-changes.docx: no revisions, but heavily fragmented runs
  (proofErr, bookmarks, lastRenderedPageBreak, formatting splits) that
  exercise cross-run search the way real Word output does.

THIRD_PARTY_NOTICES records the source and reproduces the upstream MIT
license.

* test: promote replace_document_xml helper to conftest

Move the word/document.xml swap helper from test_paragraph_location.py
to conftest.py under a public name so other test modules can build
synthetic .docx files without duplicating it. No behavior change.

* test: cover foreign revisions, table lifecycle, fragmented-run search

Closes ISSUES.md #13. New tests against the foreign fixtures plus
synthetic documents:

- parse all 8 foreign revisions with exact id/type/author/text/date,
  per-author filters, em-dash and smart-quote survival
- accept_all/reject_all end-to-end, per-author accept/reject, and the
  conflicting insert/delete pair over identical text
- partial accept survives save + reopen with the rest intact
- rejecting a deletion keeps xml:space="preserve" on the restored w:t
  (Word-realistic delText) and attribute-less foreign delText still
  round-trips
- tracked edits in table cells and nested tables accept/reject cleanly
- cross-run search over real Word fragmentation (proofErr, bookmarks,
  superscript splits), plus find_in_text_map unit tests for many-
  fragment matches and matches ending mid-run

Note: the fixture holds 4 w:ins + 4 w:del = 8 revisions total (the
task description's "4 ins + 8 del" was wrong).

* test: make fixture text assertions note-proof, compare Revision objects

Review findings from the multi-reviewer pass:

- The fixture's bracketed note runs quote expected outcomes verbatim,
  so several get_visible_text() substring assertions were satisfiable
  by a note instead of the body they meant to check. Route all such
  assertions through a _body_text() helper that strips the note runs;
  the reject-path and round-trip assertions are now genuinely
  discriminating and the accept-path assertions can no longer be
  satisfied by note text.
- Replace the hand-ordered (id, type, author, text, date) tuples with
  Revision instances compared via the dataclass's own __eq__, removing
  the silent field reordering against the dataclass definition.
- Simplify the reject_all em-dash check: with notes stripped,
  '— or is it?' must be absent outright.

* test: address second review round in foreign-revision tests

- Route the two remaining get_visible_text() assertions (smart-quote
  parsing check, tricky-fixture replace integration) through
  _body_text() so note runs can't satisfy them.
- Assert revisions 1007/1008 actually leave list_revisions() after the
  conflicting-pair accepts.
- Construct EXPECTED_REVISIONS with keyword arguments, matching the
  Revision construction convention in test_track_changes.py.
- Fix inaccurate comments/docstrings: the pending-state body reads
  'This sentence remains.' (single space); 'Quick Brown' has no note
  occurrence; the notes describe outcomes approximately, not verbatim.

* test: apply final review-round nits

- Guard against a doubled DRAFT after accept_all.
- Verify Test Author's rejected insertion is gone from the body in the
  author-filtered reject test, not just the restored deletion.
- Correct the 'Quick Brown' comment/docstring: it is a single-run match
  guarding against the note's 'ick Brown', not a fragmented one.
@pablospe
pablospe deleted the feedback-precision branch August 30, 2026 19:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant