feat(duplicate): hybrid reasoning step — typed related issues, configurable top-N, confidence fix (closes #56)#100
Conversation
- Add `duplicate_candidates` to DefaultsConfig (default: 5) for configurable LLM candidate limit per issue #56 - Raise DuplicateConfidenceThreshold default from 0.8 → 0.85 to align with the prompt's confidence scale and prevent related issues being falsely flagged as duplicates - Wire DuplicateCandidates through mergeConfigs for inheritance support Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Kavirubc <hapuarachchikaviru@gmail.com>
…plicateResult Add RelatedIssueRef struct with number, title, and relationship fields. Replace json.RawMessage SimilarIssues with []RelatedIssueRef RelatedIssues so downstream code can parse and surface the LLM's per-candidate classification (duplicate/related/distinct) without manual JSON munging. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Kavirubc <hapuarachchikaviru@gmail.com>
…es schema - Add DISTINCT relationship category alongside DUPLICATE and RELATED - Add explicit instruction to classify ALL candidates in related_issues - Instruct LLM not to set is_duplicate for "related" issues to prevent related issues being falsely flagged as low-confidence duplicates - Update JSON schema to use related_issues array instead of similar_issues Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Kavirubc <hapuarachchikaviru@gmail.com>
…stable LLM interface - Replace hardcoded maxSimilar=3 with Config.Defaults.DuplicateCandidates (fallback 5) for per-repo tunability - Extract duplicateDetectorLLM interface to enable unit tests without a live LLM connection - Store result.RelatedIssues in ctx.Metadata["related_issues"] so the response builder can surface related-but-not-duplicate issues - Update in-code threshold fallback from 0.8 → 0.85 to match config default Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Kavirubc <hapuarachchikaviru@gmail.com>
…ateSection - When IsDuplicate=true: append "Also related to: #N (title)" inline - When not a duplicate but related issues exist: emit a [!NOTE] block instead of [!WARNING] to distinguish related-but-distinct from actual duplicates - Extract buildRelatedRefList helper for formatting related refs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Kavirubc <hapuarachchikaviru@gmail.com>
The LLM candidate window was hardcoded to 3 independent of --top-k. Replace the literal with prDupTopK so the flag controls both the vector search result count and the number of candidates forwarded to the LLM for duplicate analysis. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Kavirubc <hapuarachchikaviru@gmail.com>
Five tests covering: - UsesConfigDuplicateCandidates: DuplicateCandidates=2 sends exactly 2 to LLM - DefaultsToFiveWhenZero: DuplicateCandidates=0 falls back to 5 - ConfidenceGateBlocks: 0.75 confidence blocked by 0.8 threshold - RelatedIssuesStoredInMetadata: related_issues persisted to ctx.Metadata - RelatedNotMarkedDuplicate: 0.82 confidence blocked by 0.85 threshold, related issues still appear in metadata Uses fakeLLM implementing duplicateDetectorLLM interface to avoid live LLM dependency in CI. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Kavirubc <hapuarachchikaviru@gmail.com>
Replace C-style for loop with idiomatic `for i := range n` in newTestCtx helper per Go 1.22 range-over-integer feature. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Kavirubc <hapuarachchikaviru@gmail.com>
…ests - Add duplicate_candidates: 5 with explanatory comment to .github/simili.yaml and both DOCS example configs so users can discover and tune the field - Wire DuplicateCandidates into the E2E test config struct explicitly - Add TestDuplicateCandidatesDefault: verifies applyDefaults sets 5 and DuplicateConfidenceThreshold defaults to 0.85 - Add TestDuplicateCandidatesYAML: verifies field round-trips through YAML Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Kavirubc <hapuarachchikaviru@gmail.com>
📝 WalkthroughWalkthroughThis PR introduces a configurable Changes
Sequence DiagramsequenceDiagram
participant Config as Configuration
participant Detector as DuplicateDetector
participant LLM as LLM Client
participant Builder as ResponseBuilder
Config->>Detector: Provide DuplicateCandidates=5
Detector->>Detector: Collect up to 5 similar issues from context
Detector->>LLM: DetectDuplicate(context, input with 5 candidates)
LLM->>LLM: Analyze candidates, populate RelatedIssues array
LLM-->>Detector: DuplicateResult with RelatedIssues: []RelatedIssueRef
Detector->>Detector: Apply confidence threshold (0.85)
Detector->>Detector: Store RelatedIssues in context metadata
Detector-->>Builder: Context with duplicate result & related issues
Builder->>Builder: Format RelatedIssueRef entries
Builder->>Builder: Emit duplicate warning or related-issues note
Builder-->>Response: Formatted response section
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Suggested labels
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
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. Comment |
Simili Triage ReportNote Quality Score: 8.8/10 (Good) Classification
Quality Improvements
Similar Threads
Warning Possible Duplicate (Confidence: 90%) ⏳ This pull request will be automatically closed in 72 hours if no objections are raised. If you believe this is not a duplicate, please leave a comment explaining why. Generated by Simili Bot |
🧪 E2E Test✅ Bot responded: yes | Auto-closer (dry-run) | processed: 0 closed: 0 grace: 0 human: 0 | Test repo → gh-simili-bot/simili-e2e-22709371361 Auto-generated by E2E pipeline |
There was a problem hiding this comment.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
cmd/simili/commands/pr_duplicate.go (1)
197-209:⚠️ Potential issue | 🔴 CriticalGuard
--top-kbefore slice allocation to prevent runtime panic.If
prDupTopKis negative,make([]ai.SimilarIssueInput, 0, prDupTopK)panics. Also, cutoff should use>=for defensive bounds handling.🛡️ Proposed fix
- similar := make([]ai.SimilarIssueInput, 0, prDupTopK) + limit := prDupTopK + if limit <= 0 { + limit = 1 + } + similar := make([]ai.SimilarIssueInput, 0, limit) for _, c := range out.Candidates { if c.Type != "issue" { continue } similar = append(similar, ai.SimilarIssueInput{ Number: c.Number, Title: c.Title, URL: c.URL, Similarity: c.Score, }) - if len(similar) == prDupTopK { + if len(similar) >= limit { break } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@cmd/simili/commands/pr_duplicate.go` around lines 197 - 209, Guard against negative prDupTopK before allocating similar slice and tighten the cutoff check: ensure prDupTopK is clamped to a non-negative value (e.g., set to 0 if negative) before calling make([]ai.SimilarIssueInput, 0, prDupTopK) in the code around the loop that builds similar, and change the exit condition from if len(similar) == prDupTopK { break } to if len(similar) >= prDupTopK { break } so the loop defensively handles bounds; reference the prDupTopK variable and the similar slice construction and the loop that appends ai.SimilarIssueInput in pr_duplicate.go.internal/steps/duplicate_detector.go (1)
95-103:⚠️ Potential issue | 🟠 MajorGuard against nil
DuplicateResultbefore metadata writes.At Line 102, a
(nil, nil)return fromDetectDuplicatewould panic on dereference.Proposed fix
result, err := s.llm.DetectDuplicate(ctx.Ctx, input) if err != nil { log.Printf("[duplicate_detector] Failed to detect duplicates: %v (non-blocking)", err) return nil // Graceful degradation } + if result == nil { + log.Printf("[duplicate_detector] Empty duplicate result, skipping (non-blocking)") + return nil + } // Store full result and related issues in context. ctx.Metadata["duplicate_result"] = result ctx.Metadata["related_issues"] = result.RelatedIssues🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@internal/steps/duplicate_detector.go` around lines 95 - 103, The code assumes s.llm.DetectDuplicate returns a non-nil *DuplicateResult; add a nil-check after calling s.llm.DetectDuplicate to avoid panics before writing ctx.Metadata. Specifically, after result, err := s.llm.DetectDuplicate(ctx.Ctx, input) and the err check, verify result != nil (and if nil, log a non-blocking message and return or skip storing related issues) before assigning ctx.Metadata["duplicate_result"] = result and ctx.Metadata["related_issues"] = result.RelatedIssues so you never dereference a nil DuplicateResult.
🧹 Nitpick comments (2)
internal/steps/duplicate_detector.go (1)
105-109: Harden threshold fallback for invalid configuration values.Current fallback only handles
0; negative or>1values can silently mis-gate duplicate decisions.Suggested hardening
// Get threshold from config (default 0.85 to align with prompt guidance). threshold := ctx.Config.Transfer.DuplicateConfidenceThreshold - if threshold == 0 { + if threshold <= 0 || threshold > 1 { threshold = 0.85 }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@internal/steps/duplicate_detector.go` around lines 105 - 109, The current fallback only treats a zero value as invalid; update the threshold handling around ctx.Config.Transfer.DuplicateConfidenceThreshold so any out-of-range value (<= 0 or > 1) uses the default 0.85 instead of being accepted, and optionally emit a warning via the existing logger (e.g., ctx.Logger or process logger) when you replace an invalid config value; modify the block that declares threshold (variable name threshold) to validate the value and set threshold = 0.85 for invalid cases.internal/integrations/ai/llm.go (1)
326-329: NormalizeRelationshipvalues after JSON parse for resilience.Downstream logic relies on exact string matches (
related/duplicate/distinct). Normalizing here avoids missing classifications due to casing/whitespace drift in LLM output.Suggested normalization
// Ensure non-nil RelatedIssues if result.RelatedIssues == nil { result.RelatedIssues = []RelatedIssueRef{} } + for i := range result.RelatedIssues { + rel := strings.ToLower(strings.TrimSpace(result.RelatedIssues[i].Relationship)) + switch rel { + case "duplicate", "related", "distinct": + result.RelatedIssues[i].Relationship = rel + default: + result.RelatedIssues[i].Relationship = "distinct" + } + } return &result, nil }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@internal/integrations/ai/llm.go` around lines 326 - 329, After ensuring result.RelatedIssues is non-nil, normalize each RelatedIssueRef.Relationship value to a canonical form (e.g., trim whitespace and lower-case) so downstream exact string checks (like "related", "duplicate", "distinct") won't break on casing/spacing; update the loop that populates result.RelatedIssues (or add a small post-parse pass) to set ref.Relationship = strings.ToLower(strings.TrimSpace(ref.Relationship)) and optionally map known synonyms to your canonical tokens.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@internal/core/config/config.go`:
- Around line 313-315: Update the inline comment for the
DuplicateConfidenceThreshold field to match the runtime default of 0.85: locate
the Transfer struct definition or the DuplicateConfidenceThreshold field
declaration (referencing DuplicateConfidenceThreshold and c.Transfer) and change
the comment that currently says "0.8" to "0.85" so the inline documentation
matches the runtime default set in the initialization block.
- Around line 400-402: In mergeConfigs, change the override condition for
DuplicateCandidates to only accept positive values: instead of copying
child.Defaults.DuplicateCandidates when it's non-zero, copy it only when
child.Defaults.DuplicateCandidates > 0 so negative or zero child values do not
override the parent; update the branch that sets
result.Defaults.DuplicateCandidates accordingly (refer to mergeConfigs,
child.Defaults.DuplicateCandidates, result.Defaults.DuplicateCandidates) to
match applyDefaults' expectations.
In `@internal/steps/response_builder.go`:
- Around line 296-327: The duplicate-warning block should use the final gated
decision (ctx.Result.IsDuplicate) instead of the raw duplicateResult.IsDuplicate
so below-threshold predictions don't render warnings; update the condition to if
ctx.Result.IsDuplicate while still using duplicateResult for confidence and
reasoning display (keep references to duplicateResult.Confidence,
duplicateResult.DuplicateOf, duplicateResult.Reasoning), and leave the
relatedRefs branch intact (refs built via buildRelatedRefList) and the
auto-close gracePeriod logic unchanged.
---
Outside diff comments:
In `@cmd/simili/commands/pr_duplicate.go`:
- Around line 197-209: Guard against negative prDupTopK before allocating
similar slice and tighten the cutoff check: ensure prDupTopK is clamped to a
non-negative value (e.g., set to 0 if negative) before calling
make([]ai.SimilarIssueInput, 0, prDupTopK) in the code around the loop that
builds similar, and change the exit condition from if len(similar) == prDupTopK
{ break } to if len(similar) >= prDupTopK { break } so the loop defensively
handles bounds; reference the prDupTopK variable and the similar slice
construction and the loop that appends ai.SimilarIssueInput in pr_duplicate.go.
In `@internal/steps/duplicate_detector.go`:
- Around line 95-103: The code assumes s.llm.DetectDuplicate returns a non-nil
*DuplicateResult; add a nil-check after calling s.llm.DetectDuplicate to avoid
panics before writing ctx.Metadata. Specifically, after result, err :=
s.llm.DetectDuplicate(ctx.Ctx, input) and the err check, verify result != nil
(and if nil, log a non-blocking message and return or skip storing related
issues) before assigning ctx.Metadata["duplicate_result"] = result and
ctx.Metadata["related_issues"] = result.RelatedIssues so you never dereference a
nil DuplicateResult.
---
Nitpick comments:
In `@internal/integrations/ai/llm.go`:
- Around line 326-329: After ensuring result.RelatedIssues is non-nil, normalize
each RelatedIssueRef.Relationship value to a canonical form (e.g., trim
whitespace and lower-case) so downstream exact string checks (like "related",
"duplicate", "distinct") won't break on casing/spacing; update the loop that
populates result.RelatedIssues (or add a small post-parse pass) to set
ref.Relationship = strings.ToLower(strings.TrimSpace(ref.Relationship)) and
optionally map known synonyms to your canonical tokens.
In `@internal/steps/duplicate_detector.go`:
- Around line 105-109: The current fallback only treats a zero value as invalid;
update the threshold handling around
ctx.Config.Transfer.DuplicateConfidenceThreshold so any out-of-range value (<= 0
or > 1) uses the default 0.85 instead of being accepted, and optionally emit a
warning via the existing logger (e.g., ctx.Logger or process logger) when you
replace an invalid config value; modify the block that declares threshold
(variable name threshold) to validate the value and set threshold = 0.85 for
invalid cases.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 1955cb8a-429d-4b0d-a432-dbbf893e4168
📒 Files selected for processing (12)
.github/simili.yamlDOCS/examples/multi-repo/simili.yamlDOCS/examples/single-repo/simili.yamlcmd/simili/commands/pr_duplicate.gointernal/core/config/config.gointernal/core/config/config_test.gointernal/integrations/ai/llm.gointernal/integrations/ai/prompts.gointernal/steps/duplicate_detector.gointernal/steps/duplicate_detector_test.gointernal/steps/response_builder.gotests/integration/e2e_test.go
| if c.Transfer.DuplicateConfidenceThreshold == 0 { | ||
| c.Transfer.DuplicateConfidenceThreshold = 0.8 | ||
| c.Transfer.DuplicateConfidenceThreshold = 0.85 | ||
| } |
There was a problem hiding this comment.
Update stale default documentation for duplicate confidence threshold.
Runtime default is now 0.85, but the inline field comment still states 0.8, which can mislead config authors.
📝 Proposed doc fix
- DuplicateConfidenceThreshold float64 `yaml:"duplicate_confidence_threshold,omitempty"` // Default: 0.8
+ DuplicateConfidenceThreshold float64 `yaml:"duplicate_confidence_threshold,omitempty"` // Default: 0.85🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@internal/core/config/config.go` around lines 313 - 315, Update the inline
comment for the DuplicateConfidenceThreshold field to match the runtime default
of 0.85: locate the Transfer struct definition or the
DuplicateConfidenceThreshold field declaration (referencing
DuplicateConfidenceThreshold and c.Transfer) and change the comment that
currently says "0.8" to "0.85" so the inline documentation matches the runtime
default set in the initialization block.
| if child.Defaults.DuplicateCandidates != 0 { | ||
| result.Defaults.DuplicateCandidates = child.Defaults.DuplicateCandidates | ||
| } |
There was a problem hiding this comment.
Use positive-only override when merging duplicate_candidates.
mergeConfigs currently overrides parent when child value is any non-zero integer, including negative values. That conflicts with applyDefaults (which treats non-positive as invalid), and can unexpectedly replace a valid parent value before falling back to 5.
🔧 Proposed fix
- if child.Defaults.DuplicateCandidates != 0 {
+ if child.Defaults.DuplicateCandidates > 0 {
result.Defaults.DuplicateCandidates = child.Defaults.DuplicateCandidates
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if child.Defaults.DuplicateCandidates != 0 { | |
| result.Defaults.DuplicateCandidates = child.Defaults.DuplicateCandidates | |
| } | |
| if child.Defaults.DuplicateCandidates > 0 { | |
| result.Defaults.DuplicateCandidates = child.Defaults.DuplicateCandidates | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@internal/core/config/config.go` around lines 400 - 402, In mergeConfigs,
change the override condition for DuplicateCandidates to only accept positive
values: instead of copying child.Defaults.DuplicateCandidates when it's
non-zero, copy it only when child.Defaults.DuplicateCandidates > 0 so negative
or zero child values do not override the parent; update the branch that sets
result.Defaults.DuplicateCandidates accordingly (refer to mergeConfigs,
child.Defaults.DuplicateCandidates, result.Defaults.DuplicateCandidates) to
match applyDefaults' expectations.
| if duplicateResult.IsDuplicate { | ||
| confidencePct := int(duplicateResult.Confidence * 100) | ||
|
|
||
| parts = append(parts, "> [!WARNING]") | ||
| parts = append(parts, fmt.Sprintf("> **Possible Duplicate** (Confidence: %d%%)", confidencePct)) | ||
| parts = append(parts, fmt.Sprintf("> This %s might be a duplicate of #%d.", subject, duplicateResult.DuplicateOf)) | ||
|
|
||
| if duplicateResult.Reasoning != "" { | ||
| parts = append(parts, fmt.Sprintf("> _Reason: %s_", duplicateResult.Reasoning)) | ||
| } | ||
|
|
||
| // Surface related issues inline with the duplicate warning. | ||
| if len(relatedRefs) > 0 { | ||
| refs := buildRelatedRefList(relatedRefs) | ||
| parts = append(parts, fmt.Sprintf("> _Also related to: %s_", refs)) | ||
| } | ||
|
|
||
| // Auto-close grace period warning | ||
| gracePeriod := ctx.Config.AutoClose.GracePeriodHours | ||
| if gracePeriod <= 0 { | ||
| gracePeriod = 72 | ||
| } | ||
| parts = append(parts, ">") | ||
| parts = append(parts, fmt.Sprintf("> ⏳ **This %s will be automatically closed in %d hours** if no objections are raised. "+ | ||
| "If you believe this is **not** a duplicate, please leave a comment explaining why.", subject, gracePeriod)) | ||
| } else if len(relatedRefs) > 0 { | ||
| // Not a duplicate but related issues were found — emit a softer NOTE block. | ||
| refs := buildRelatedRefList(relatedRefs) | ||
| parts = append(parts, "> [!NOTE]") | ||
| parts = append(parts, "> **Related Issues Found**") | ||
| parts = append(parts, fmt.Sprintf("> This %s is not a duplicate, but is related to %s.", subject, refs)) | ||
| } |
There was a problem hiding this comment.
Use the gated decision for duplicate warning rendering.
This branch uses raw duplicateResult.IsDuplicate, so below-threshold results can still show a duplicate warning and auto-close message. It should follow the final gated decision (ctx.Result.IsDuplicate).
Proposed fix
- if duplicateResult.IsDuplicate {
- confidencePct := int(duplicateResult.Confidence * 100)
+ if ctx.Result.IsDuplicate {
+ confidencePct := int(ctx.Result.DuplicateConfidence * 100)
parts = append(parts, "> [!WARNING]")
parts = append(parts, fmt.Sprintf("> **Possible Duplicate** (Confidence: %d%%)", confidencePct))
- parts = append(parts, fmt.Sprintf("> This %s might be a duplicate of #%d.", subject, duplicateResult.DuplicateOf))
+ parts = append(parts, fmt.Sprintf("> This %s might be a duplicate of #%d.", subject, ctx.Result.DuplicateOf))🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@internal/steps/response_builder.go` around lines 296 - 327, The
duplicate-warning block should use the final gated decision
(ctx.Result.IsDuplicate) instead of the raw duplicateResult.IsDuplicate so
below-threshold predictions don't render warnings; update the condition to if
ctx.Result.IsDuplicate while still using duplicateResult for confidence and
reasoning display (keep references to duplicateResult.Confidence,
duplicateResult.DuplicateOf, duplicateResult.Reasoning), and leave the
relatedRefs branch intact (refs built via buildRelatedRefList) and the
auto-close gracePeriod logic unchanged.
Summary
defaults.duplicate_candidates(default: 5) replaces the hardcoded top-3 limit across both the pipeline step andpr-duplicateCLIRelatedIssues []RelatedIssueRef— replaces the opaquejson.RawMessage SimilarIssuesfield so downstream code can parse the LLM's per-candidate classification (duplicate/related/distinct) without manual JSON mungingrelated_issuesand explicitly forbids markingrelatedissues asis_duplicate: true, closing the 0.80–0.84 ambiguity gap[!WARNING]block appends "Also related to: #N" inline; when not a duplicate but related issues exist a softer[!NOTE]block is emittedpr-duplicatetop-k parity — LLM candidate limit now respects--top-kflag instead of hardcoded 3Files changed
internal/core/config/config.goDuplicateCandidates, fix threshold default 0.80 → 0.85, wiremergeConfigsinternal/integrations/ai/llm.goRelatedIssueRefstruct, replaceSimilarIssueswithRelatedIssuesinternal/integrations/ai/prompts.gorelated_issuesschema, DISTINCT category, anti-hedging instructioninternal/steps/duplicate_detector.goduplicateDetectorLLMinterface, metadata storage, threshold 0.85internal/steps/response_builder.go[!WARNING]and[!NOTE]blockscmd/simili/commands/pr_duplicate.goprDupTopKfor LLM candidate limitinternal/steps/duplicate_detector_test.gofakeLLMinterfaceinternal/core/config/config_test.goTestDuplicateCandidatesDefaultandTestDuplicateCandidatesYAMLtests/integration/e2e_test.goDuplicateCandidates: 5in E2E config.github/simili.yaml+DOCS/examples/duplicate_candidatesfieldTest plan
go build ./...— cleango vet ./...— cleango test ./...— 12 packages, 0 failuresSigned-off-by: Kavirubc)[!NOTE]block appearssimili pr-duplicate --top-k 7— verify 7 candidates forwarded to LLM🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Improvements
Tests