Skip to content

fix: stop AI from logging habits the user didn't mention - #166

Merged
thomasluizon merged 6 commits into
mainfrom
fix/ai-strict-habit-matching
May 15, 2026
Merged

fix: stop AI from logging habits the user didn't mention#166
thomasluizon merged 6 commits into
mainfrom
fix/ai-strict-habit-matching

Conversation

@thomasluizon

@thomasluizon thomasluizon commented May 15, 2026

Copy link
Copy Markdown
Owner

Summary

Reported case: user wrote "Eu meditei hoje à noite e já fiz meu yoga". The AI logged `Yoga noturno` (correct) and `Fazer anotações no diário` (NOT mentioned). It also missed the meditation habit. The model is substituting related-but-different habits when no exact-name match exists.

Root cause

The system prompt currently emphasizes acting immediately when intent is clear (`CoreIdentitySection`) and "Use those IDs directly for actions whenever possible" (`GlobalRulesSection` Rule 13), but it never explicitly forbids picking a habit that's merely tangentially related to what the user said. Combined with `BulkLogHabitsTool`'s loose description — "Use this when the user mentions completing several activities at once" — the model freely fills in extra habits that share a tag, parent, routine, or theme with the one the user named.

Changes

  • `GlobalRulesSection.cs` — added Rule 18 STRICT MATCH FOR LOG / COMPLETE / SKIP: only call `log_habit`, `bulk_log_habits`, `skip_habit`, or status-change tools on habits whose title clearly corresponds to the words the user used (obvious translations such as meditei → Meditate are fine). Never substitute a related habit when no match exists — tell the user no matching habit exists and offer to create one.
  • `BulkLogHabitsTool.cs` — tightened the tool description to forbid bundling habits that share a tag, parent, routine, or theme but weren't named. Kept the words `multiple` and `habit_ids` so the existing `ChatToolMetadataTests` assertions still pass.

Test plan

  • `Orbit.Application.Tests` — 24/24 passing (includes `ChatToolMetadataTests` covering the `bulk_log_habits` description assertions).
  • `Orbit.Infrastructure.Tests` — couldn't rebuild locally (Rider holds the running Api's DLLs); existing `GlobalRulesSectionTests` only assert on strings I didn't touch, change is purely additive.
  • Manual: after rebuild + restart, send "meditei e fiz yoga" in chat. The AI should only log habits whose title clearly matches "meditate" and "yoga". If no meditation habit exists, it should ask before substituting.
  • Manual: send "fiz meu treino" when only the user's "Run 5km" habit exists — AI should ask, not auto-log the run.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Tools now act only on habits explicitly mentioned by the user, preventing unintended inclusion of related/tagged/routine-similar habits.
    • System enforces no habit substitution for log/skip actions and asks users to create a new habit when no clear title match exists.
  • Documentation

    • Updated tool descriptions and global rules wording to clarify strict matching and behavior.
  • Tests

    • Added/updated tests to verify no-substitution behavior and batch habit lookup/filtering.

Review Change Stack

Reported: when the user wrote "Eu meditei hoje à noite e já fiz meu
yoga" the AI logged Yoga noturno (correct) AND Fazer anotações no
diário (NOT mentioned). The chat prompt currently says "act
immediately when the user's intent is clear" plus "Use those IDs
directly for actions whenever possible," but never explicitly forbids
substituting a semantically-related habit when no exact-name match
exists. Combined with the bulk_log_habits tool's loose description
("Use this when the user mentions completing several activities at
once"), the model fills in extra habits that share a tag, routine,
parent, or theme with the one the user actually named.

- Add Rule 18 (STRICT MATCH FOR LOG / COMPLETE / SKIP) to
  GlobalRulesSection. Only act on habits whose title clearly
  corresponds to the words the user used. Obvious translations are
  allowed ("meditei" -> "Meditate"); related-but-different habits
  are not. If no match exists, tell the user and offer to create
  one - never substitute.
- Tighten BulkLogHabitsTool.Description to forbid bundling habits
  that share a tag, parent, routine, or theme but weren't named.
  Existing ChatToolMetadataTests assertions (contains "multiple" and
  "habit_ids") still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread src/Orbit.Infrastructure/Services/Prompts/Sections/Static/GlobalRulesSection.cs Outdated

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 793758c7e5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/Orbit.Infrastructure/Services/Prompts/Sections/Static/GlobalRulesSection.cs Outdated
@claude

claude Bot commented May 15, 2026

Copy link
Copy Markdown

Review Summary

The fix is correct and well-targeted. Root cause analysis is accurate — the model was substituting tangentially-related habits because neither the tool description nor the system prompt explicitly prohibited it. Both the BulkLogHabitsTool description tightening and the new Rule 18 address the right layers (tool-level + system-prompt-level).

What looks good

  • Rule 18 is clearly worded, includes concrete examples (Portuguese translations), and covers all relevant tools (log_habit, bulk_log_habits, skip_habit, status-change).
  • The "no match → tell user and offer to create" fallback is the right UX behavior.
  • Keeping the change minimal (2 lines) avoids unintended side-effects on other prompt rules.

Issues

  1. Missing testGlobalRulesSectionTests has no assertion for Rule 18. See inline comment on GlobalRulesSection.cs:33. Every other significant rule is covered; this one should be too.
  2. AssertTool dead parameters — The PR description says "multiple" and "habit_ids" were preserved for the metadata tests, but descriptionFragment is never used in assertions. See inline comment on BulkLogHabitsTool.cs:17. Harmless for this PR, but worth fixing.

Caveats

Prompt-only mitigation is inherently probabilistic — the LLM may still occasionally substitute habits in complex multi-language contexts. No server-side guard is practical here (you can't verify "did the user say this title" at the API layer), so the prompt approach is the right call; just worth noting in the PR for future reference.

Verdict: Approve after adding the GlobalRulesSectionTests test for Rule 18.

Codex P1: Rule 18 as written required user's words to match habit
titles, which would regress valid flows like "log that one" or "mark
the first one done" after a list/query. Softened the rule to clearly
restrict SUBSTITUTION ONLY, and added an explicit carve-out: indirect
references resolve to whatever habit the AI was just discussing.

Claude: added a Build_ContainsNoSubstitutionRule test in
GlobalRulesSectionTests so future refactors can't silently drop the
rule. Asserts the key phrases ("NO HABIT SUBSTITUTION FOR LOG /
COMPLETE / SKIP", "bulk_log_habits", "no more, no fewer",
"Indirect references").

Claude: AssertTool in ChatToolMetadataTests took descriptionFragment
and schemaFragment parameters but never asserted on them - so the PR
description's claim that the test guarded those words was wrong.
Wired both parameters into proper Should().Contain assertions (case-
insensitive for the description, exact for the schema). This caught
two pre-existing gaps:
- duplicate_habit description didn't mention "duplicate" at all
  ("Create an exact copy..."). Updated to "Duplicate an existing
  habit, creating an exact copy...". Improves AI tool discoverability
  when the user says "duplicate".
- update_goal_progress test asserted on schema fragment "delta", but
  the schema uses "current_value" / "goal_name". Updated the fragment.

Full Application.Tests suite: 1642/1642 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread src/Orbit.Infrastructure/Services/Prompts/Sections/Static/GlobalRulesSection.cs Outdated
Comment thread tests/Orbit.Application.Tests/Chat/Tools/ChatToolMetadataTests.cs
@claude

claude Bot commented May 15, 2026

Copy link
Copy Markdown

Review summary

Root cause analysis is correct and the fix is well-scoped. Rule 18 is clearly written, explicitly carves out indirect references ("log that one"), and avoids over-constraining the model. BulkLogHabitsTool description now reinforces it at the tool level.

One gap: bulk_skip_habits is not listed in Rule 18 and its description was not updated, so the same substitution problem can occur on skip actions. See inline comment.

Incidental improvements (all good):

  • AssertTool now actually exercises descriptionFragment and schemaFragment — the old helper declared them but never used them, so every schema/description assertion was silently dead. Strong improvement.
  • Stale "delta" schema fragment corrected to "current_value" (the field that actually exists in UpdateGoalProgressTool).
  • DuplicateHabitTool description updated to satisfy the newly-enforced description assertion.

No API contract, auth, database, or security concerns — all changes are confined to AI prompt wording and test assertions.

Round 2 of PR review feedback (Claude bot):

Rule 18 covered log_habit, bulk_log_habits, and skip_habit but
forgot bulk_skip_habits, and BulkSkipHabitsTool.Description was
not tightened the same way as BulkLogHabitsTool. So the same
substitution problem (model skipping habits the user didn't
mention) could occur on bulk skip flows.

- Add bulk_skip_habits to Rule 18's tool list and an example
  ("pulei o treino") so it's clear the rule applies to skip flows
  in any language.
- Mirror BulkLogHabitsTool's tightened description on
  BulkSkipHabitsTool: "Use this only for habits the user EXPLICITLY
  mentioned skipping - never include extra habits that share a tag,
  parent, routine, or theme but were not named."
- Extend Build_ContainsNoSubstitutionRule to assert all four tool
  names (log_habit, bulk_log_habits, skip_habit, bulk_skip_habits)
  are present in the rule, so future refactors can't silently drop
  one.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread src/Orbit.Infrastructure/Services/Prompts/Sections/Static/GlobalRulesSection.cs Outdated
@claude

claude Bot commented May 15, 2026

Copy link
Copy Markdown

Review summary

The fix is correct and well-targeted. Root cause is properly identified, the two-layer defence (system prompt rule + tool description) is the right approach, and the indirect-reference carve-out in Rule 18 prevents a regression for flows like "log that one".

What's good

  • Rule 18 covers all four relevant tools and handles multilingual input.
  • AssertTool in ChatToolMetadataTests now actually asserts on both parameters — previously the fragments were accepted but never checked. The duplicate_habit description fix and the deltacurrent_value schema fragment correction are good catches.
  • New Build_ContainsNoSubstitutionRule test pins the key phrases so the rule can't be silently dropped in a refactor.

Two issues noted inline

  1. N+1 in BulkSkipHabitsTool.ExecuteAsync (pre-existing, but this PR touches the file): each habit ID hits the DB individually; BulkLogHabitsTool was already updated to batch-load. Worth aligning.
  2. "status-change tools" in Rule 18 is undefined — for habits there is no separate status-change tool, so the phrase adds ambiguity. Either name the specific tool (e.g. update_goal_status) or drop the clause.

Both are minor; the core fix is solid. LGTM with those addressed.

Round 3 of PR review feedback (Claude bot):

- Drop "or status-change tools" from Rule 18. The phrase was
  undefined - for habits there is no separate status-change tool
  (logging IS the state change). Goal status changes are out of
  scope for the no-substitution rule. The four habit tools
  (log_habit, bulk_log_habits, skip_habit, bulk_skip_habits) are
  the relevant set.

- Fix the N+1 in BulkSkipHabitsTool.ExecuteAsync. Previously each
  habit ID triggered its own FindOneTrackedAsync call inside the
  loop; BulkLogHabitsTool was already updated to batch-load via
  FindTrackedAsync with a Contains predicate. Aligning the two
  tools now.

- Update BulkSkipHabitsToolTests SetupHabitLookup helper and the
  AllNotFound_ReturnsError test to mock FindTrackedAsync instead
  of FindOneTrackedAsync, since the production call changed.

Full Application.Tests suite: 1642/1642 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented May 15, 2026

Copy link
Copy Markdown

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9c000ef9-ee5b-42b0-b24b-aaa7fa8d81d9

📥 Commits

Reviewing files that changed from the base of the PR and between e3cb864 and dc63cfa.

📒 Files selected for processing (3)
  • src/Orbit.Infrastructure/Services/Prompts/Sections/Static/GlobalRulesSection.cs
  • tests/Orbit.Application.Tests/Chat/Tools/BulkLogHabitsToolTests.cs
  • tests/Orbit.Infrastructure.Tests/Services/PromptSectionTests.cs
✅ Files skipped from review due to trivial changes (1)
  • src/Orbit.Infrastructure/Services/Prompts/Sections/Static/GlobalRulesSection.cs
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/Orbit.Infrastructure.Tests/Services/PromptSectionTests.cs

📝 Walkthrough

Walkthrough

Clarifies tool descriptions to require explicit habit matches, refactors BulkSkipHabitsTool to batch-load tracked habits, adds a global no-substitution prompt rule, and updates tests to validate batch lookup behavior and ownership filtering.

Changes

Habit Tool No-substitution Enforcement

Layer / File(s) Summary
Tool descriptions clarify no substitution
src/Orbit.Application/Chat/Tools/Implementations/BulkLogHabitsTool.cs, src/Orbit.Application/Chat/Tools/Implementations/BulkSkipHabitsTool.cs, src/Orbit.Application/Chat/Tools/Implementations/DuplicateHabitTool.cs
All three tool Description strings updated to require explicit habit matching, prohibiting inclusion of related habits that share tags, parents, routines, or themes.
BulkSkipHabitsTool query optimization
src/Orbit.Application/Chat/Tools/Implementations/BulkSkipHabitsTool.cs
ExecuteAsync refactored from repeated single-entity queries (FindOneTrackedAsync) to a single batch FindTrackedAsync call (including Logs) that returns a collection which is then matched against requested IDs.
System prompt no-substitution rule
src/Orbit.Infrastructure/Services/Prompts/Sections/Static/GlobalRulesSection.cs
New rule 18 added prohibiting substitution for log/skip: tools must target habits whose titles explicitly match the activity (allowing obvious translations) and should prompt to create if none match; multiple activities must map exactly to multiple habits.
BulkSkipHabitsTool test updates
tests/Orbit.Application.Tests/Chat/Tools/BulkSkipHabitsToolTests.cs
Mocks updated to use FindTrackedAsync returning lists; added test verifying habits for other users are ignored and result in no-skipped-habits error; helper updated to compile predicates and return matching lists.
BulkLogHabitsTool test updates
tests/Orbit.Application.Tests/Chat/Tools/BulkLogHabitsToolTests.cs
Added test verifying habits for other users are not logged; mocked FindTrackedAsync now applies the provided predicate to filter test habits.
Tool metadata and prompt validation tests
tests/Orbit.Application.Tests/Chat/Tools/ChatToolMetadataTests.cs, tests/Orbit.Infrastructure.Tests/Services/PromptSectionTests.cs
ChatToolMetadataTests updated to expect current_value parameter fragment and stronger description/schema assertions; PromptSectionTests adds test confirming GlobalRulesSection includes the no-substitution rule.

Estimated Code Review Effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 A hop through habits, titles clear and bright,
No fuzzy swaps at logging time or night.
Batch-loaded lists now spring without delay,
Tests and rules ensure we only act on what you say.
A little rabbit cheers: precise matches, hip hooray!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title clearly and concisely describes the main objective: preventing the AI from logging habits that users did not explicitly mention, which aligns directly with the primary bug fix.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/ai-strict-habit-matching

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

Comment thread tests/Orbit.Application.Tests/Chat/Tools/BulkSkipHabitsToolTests.cs Outdated
Comment thread src/Orbit.Infrastructure/Services/Prompts/Sections/Static/GlobalRulesSection.cs Outdated
Comment thread tests/Orbit.Application.Tests/Chat/Tools/ChatToolMetadataTests.cs
@claude

claude Bot commented May 15, 2026

Copy link
Copy Markdown

Review

Correctness — the root cause analysis is accurate and the fixes address it properly. The N+1 fix in BulkSkipHabitsTool (batch-load all requested habits in one FindTrackedAsync call instead of one FindOneTrackedAsync per ID) is the right approach and mirrors the existing implementation in BulkLogHabitsTool. The && h.UserId == userId predicate keeps user isolation intact in the DB query.

Prompt engineering — Rule 18 in GlobalRulesSection is logically sound and covers the right cases (strict match, obvious translations, indirect references). The tool description tightening for bulk_log_habits and bulk_skip_habits adds a second enforcement point at the schema level, which is good defence-in-depth. One concern: at ~230 words, Rule 18 is the longest rule in the section — see inline comment for a more scannable structure.

Tests — the infrastructure test Build_ContainsNoSubstitutionRule is appropriately scoped. The ChatToolMetadataTests strengthening is a meaningful side-win: both descriptionFragment and schemaFragment were previously unused in assertions, so all 21 tool checks are now meaningfully tighter. See inline comment on the BulkSkipHabitsToolTests mock simplification for a minor coverage gap.

No API contract changes, no migration, no auth/authz impact.

Two inline comments posted (one actionable test suggestion, one soft style note). Otherwise good to merge.

@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.

🧹 Nitpick comments (1)
tests/Orbit.Application.Tests/Chat/Tools/BulkSkipHabitsToolTests.cs (1)

126-130: ⚡ Quick win

Make the repository mock predicate-aware to avoid false positives.

SetupHabitLookup currently returns all provided habits regardless of the expression passed by the tool, so tests can still pass if filtering logic regresses.

Suggested test-mock adjustment
         _habitRepo.FindTrackedAsync(
             Arg.Any<Expression<Func<Habit, bool>>>(),
             Arg.Any<Func<IQueryable<Habit>, IQueryable<Habit>>?>(),
             Arg.Any<CancellationToken>()
-        ).Returns(habits.ToList());
+        ).Returns(callInfo =>
+        {
+            var predicate = callInfo.ArgAt<Expression<Func<Habit, bool>>>(0).Compile();
+            return habits.Where(predicate).ToList();
+        });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/Orbit.Application.Tests/Chat/Tools/BulkSkipHabitsToolTests.cs` around
lines 126 - 130, The mock for _habitRepo.FindTrackedAsync is returning the
entire habits list unconditionally; update the test setup (e.g.,
SetupHabitLookup or the Arrange where _habitRepo.FindTrackedAsync is configured)
to capture the passed Expression<Func<Habit,bool>> predicate and the optional
Func<IQueryable<Habit>,IQueryable<Habit>> queryTransform, compile and apply the
predicate to the in-memory habits collection (and then apply queryTransform if
not null) so the mock returns only matching items; use NSubstitute's
Returns(callInfo => ...) or Arg.Do to access callInfo.ArgAt<Expression...>(0)
and callInfo.ArgAt<Func<IQueryable<Habit>,IQueryable<Habit>>?>(1) to implement
the filtering so tests fail if tool filtering regresses.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@tests/Orbit.Application.Tests/Chat/Tools/BulkSkipHabitsToolTests.cs`:
- Around line 126-130: The mock for _habitRepo.FindTrackedAsync is returning the
entire habits list unconditionally; update the test setup (e.g.,
SetupHabitLookup or the Arrange where _habitRepo.FindTrackedAsync is configured)
to capture the passed Expression<Func<Habit,bool>> predicate and the optional
Func<IQueryable<Habit>,IQueryable<Habit>> queryTransform, compile and apply the
predicate to the in-memory habits collection (and then apply queryTransform if
not null) so the mock returns only matching items; use NSubstitute's
Returns(callInfo => ...) or Arg.Do to access callInfo.ArgAt<Expression...>(0)
and callInfo.ArgAt<Func<IQueryable<Habit>,IQueryable<Habit>>?>(1) to implement
the filtering so tests fail if tool filtering regresses.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 338c0f51-bbb8-4805-ac1b-dcc3357e9f53

📥 Commits

Reviewing files that changed from the base of the PR and between 2d2e126 and 7e52ec0.

📒 Files selected for processing (7)
  • src/Orbit.Application/Chat/Tools/Implementations/BulkLogHabitsTool.cs
  • src/Orbit.Application/Chat/Tools/Implementations/BulkSkipHabitsTool.cs
  • src/Orbit.Application/Chat/Tools/Implementations/DuplicateHabitTool.cs
  • src/Orbit.Infrastructure/Services/Prompts/Sections/Static/GlobalRulesSection.cs
  • tests/Orbit.Application.Tests/Chat/Tools/BulkSkipHabitsToolTests.cs
  • tests/Orbit.Application.Tests/Chat/Tools/ChatToolMetadataTests.cs
  • tests/Orbit.Infrastructure.Tests/Services/PromptSectionTests.cs

Round 4 of PR review feedback (Claude bot):

- Restructure Rule 18 from a single ~230-word paragraph into a lead
  sentence + sub-bullets. The dense paragraph could dilute model
  attention to the key constraint; bulleted structure makes each
  sub-clause scannable. All assertion phrases in the existing
  GlobalRulesSectionTests test stay present (key phrase, all four
  tool names, "no more, no fewer", "Indirect references").

- Restore predicate filtering in BulkSkipHabitsToolTests
  SetupHabitLookup. The simplified mock from the previous round
  returned all habits regardless of predicate, which meant the
  production query's `h.UserId == userId` ownership check was no
  longer exercised by unit tests. Compile the predicate and apply
  it to the seeded habits so the ownership scoping is verified
  the same way it was before the N+1 refactor.

- Add DifferentUserHabit_IsNotSkipped regression test that seeds a
  habit owned by a different user and asserts it is filtered out
  (result.Success false, "No habits were skipped"). Locks in the
  ownership scoping explicitly.

Application.Tests: 1643/1643 passing (was 1642 - the new test).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread src/Orbit.Infrastructure/Services/Prompts/Sections/Static/GlobalRulesSection.cs Outdated
Comment thread tests/Orbit.Application.Tests/Chat/Tools/BulkSkipHabitsToolTests.cs
Comment thread tests/Orbit.Application.Tests/Chat/Tools/ChatToolMetadataTests.cs
@claude

claude Bot commented May 15, 2026

Copy link
Copy Markdown

Review Summary

Correctness & logic — solid. The N+1 fix in BulkSkipHabitsTool (batch FindTrackedAsync instead of per-habit FindOneTrackedAsync) is correct, and the ownership predicate (h.UserId == userId) is preserved in the DB query. Rule 18 in GlobalRulesSection is well-scoped: the indirect-reference carve-out ("log that one", "skip it" after naming a habit) is the right UX call.

Test coverage — good. DifferentUserHabit_IsNotSkipped correctly exercises the ownership check by applying the real predicate in the mock. The PromptSectionTests assertions cover all key strings in Rule 18.

One gap worth a follow-up: BulkLogHabitsToolTests.SetupHabitsFound (not changed here) returns habits without applying the predicate and has no ownership test, unlike the updated BulkSkipHabitsToolTests. Since both tools use the same batch-load pattern with the same h.UserId == userId guard, they should have symmetrical coverage.

No API contract changes, no auth/authz changes, no migration required. Approve once the manual chat test passes.

…est coverage

Round 5 of PR review feedback (Claude bot):

- Rule 18 heading: "NO HABIT SUBSTITUTION FOR LOG / COMPLETE / SKIP"
  -> "NO HABIT SUBSTITUTION FOR LOG / SKIP". The body already lists
  the four real tool names; "COMPLETE" in the heading suggested a
  separate complete tool that doesn't exist (logging IS completing
  for habits). Heading now matches the actual tool set. Test
  assertion updated accordingly.

- BulkLogHabitsToolTests symmetry with BulkSkipHabitsToolTests:
  - SetupHabitsFound now compiles + applies the predicate to the
    seeded habits, so the production query's `h.UserId == userId`
    ownership check is exercised by these tests too (previously
    the mock returned all habits regardless of predicate).
  - Add DifferentUserHabit_IsNotLogged regression test mirroring
    DifferentUserHabit_IsNotSkipped, asserting that a habit owned
    by another user is filtered out before the log loop sees it.

Application.Tests: 1644/1644 passing (was 1643 - the new test).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment on lines +53 to +56
var habits = await habitRepository.FindTrackedAsync(
h => habitIds.Contains(h.Id) && h.UserId == userId,
q => q.Include(h => h.Logs),
ct);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Good fix — the batch load exactly mirrors the existing pattern in BulkLogHabitsTool and eliminates N round-trips to the DB. One detail worth noting: because the ownership filter (h.UserId == userId) lives entirely in this query, TrySkipHabit never needs its own userId guard, which keeps it clean. The approach is correct.

AssertTool(skipHabitTool, "skip_habit", "Skip", "date");
AssertTool(suggestBreakdownTool, "suggest_breakdown", "Suggest", "suggested_sub_habits");
AssertTool(updateGoalProgressTool, "update_goal_progress", "goal", "delta");
AssertTool(updateGoalProgressTool, "update_goal_progress", "goal", "current_value");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This was a silent pre-existing bug: the old AssertTool helper only checked that the serialised schema contained "type", so the stale "delta" fragment never triggered a failure even though the actual parameter is current_value. The new helper now validates both the description fragment and the schema fragment, which is what caught it. Fix is correct.

Comment on lines +33 to +38
18. NO HABIT SUBSTITUTION FOR LOG / SKIP. When the user describes an activity ("I meditated", "fiz yoga", "log my workout", "pulei o treino"):
- Only call log_habit, bulk_log_habits, skip_habit, or bulk_skip_habits on habits whose title clearly matches the described activity. Obvious translations are fine ("meditei" -> "Meditate" / "Meditar").
- Do NOT log or skip a habit just because it shares a tag, parent, time-of-day, routine, or general theme with the described activity.
- If NO habit in the index clearly matches, do NOT substitute a related habit. Tell the user briefly that you don't see a matching habit and ask if they want to create one.
- When the user describes multiple activities, log exactly the habits they described - no more, no fewer.
- This rule restricts SUBSTITUTION ONLY. Indirect references like "log that one", "mark the first one done", "skip it", or "complete it" after you have already named a specific habit are still valid - resolve them to the habit you were just discussing, then act.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The indirect-reference carve-out on the last bullet is important — without it the rule would break conversational flows like "now log it" after the assistant has already named a habit. Well-targeted.

One heads-up for later: this rule is enforced entirely through the system prompt and tool descriptions; there's no server-side guard that prevents the model from calling these tools with unrelated IDs. That's the right trade-off for a prompt-engineering fix, but if the mismatch recurs it would be worth considering whether a post-call audit log (e.g., logging which IDs were submitted vs. what the user said) could help diagnose future regressions.

Comment on lines 142 to 153
{
_habitRepo.FindOneTrackedAsync(
// Apply the predicate so the production query's `h.UserId == userId`
// ownership check is still exercised by the unit tests.
_habitRepo.FindTrackedAsync(
Arg.Any<Expression<Func<Habit, bool>>>(),
Arg.Any<Func<IQueryable<Habit>, IQueryable<Habit>>?>(),
Arg.Any<CancellationToken>()
).Returns(callInfo =>
{
var predicate = callInfo.ArgAt<Expression<Func<Habit, bool>>>(0).Compile();
return habits.FirstOrDefault(predicate);
return habits.Where(predicate).ToList();
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Compiling and applying the predicate in-memory is the right approach for a unit test — it exercises the exact ownership expression (h.UserId == userId) that the production code passes to EF Core. The caveat (documented by the comment) is that this doesn't prove EF Core can translate the expression to SQL; the integration test suite covers that path via a real database, which is the correct division of responsibility.

@@ -9,7 +9,7 @@ public class DuplicateHabitTool(
public string Name => "duplicate_habit";

public string Description =>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This description change was required to satisfy the newly-strengthened AssertTool check in ChatToolMetadataTests (which now validates that the description contains the expected fragment). The old text "Create an exact copy…" didn't contain "duplicate", so without this tweak the test would fail. Wording reads naturally.

@claude

claude Bot commented May 15, 2026

Copy link
Copy Markdown

Review summary

Changes reviewed: GlobalRulesSection.cs, BulkLogHabitsTool.cs, BulkSkipHabitsTool.cs, DuplicateHabitTool.cs, and the accompanying test updates.

What's good

  • Root cause analysis is accurate. The PR correctly identifies that the problem was the absence of an explicit no-substitution constraint, not a code bug.
  • Rule 18 is well-scoped. The indirect-reference carve-out ("log that one", "skip it") prevents the rule from breaking valid conversational flows while still blocking the substitution case.
  • N+1 fix in BulkSkipHabitsTool. The batch FindTrackedAsync call matches the existing pattern in BulkLogHabitsTool and is correct. Ownership scoping (h.UserId == userId) remains in the query predicate.
  • Test quality improved. AssertTool now validates both description and schema fragments, not just non-null and "type". This surfaced the silent delta / current_value mismatch that was already in the codebase.
  • Ownership tests are sound. Compiling the predicate in the mock exercises the exact expression used in production; real DB translation is covered by integration tests.

No blocking issues

No API contract changes, no auth regressions, no new N+1 queries, no migration needed. All changes are either prompt engineering, tool descriptions, or test hardening.

One note for follow-up

Rule 18 is enforced purely through the prompt and tool descriptions — the server accepts any valid habit IDs regardless of what the user said. That's the right trade-off here, but if substitution recurs it may be worth adding structured logging of which IDs each AI tool call receives so future regressions can be diagnosed from logs rather than reproduced manually.

Verdict: approved, no changes required.

@thomasluizon
thomasluizon merged commit 8c6a816 into main May 15, 2026
3 checks passed
@thomasluizon
thomasluizon deleted the fix/ai-strict-habit-matching branch May 15, 2026 20:43
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