Skip to content

feat: stylist chat that knows the wardrobe - #280

Open
TheBlackSide243 wants to merge 3 commits into
Anyesh:mainfrom
TheBlackSide243:pr/stylist-chat
Open

TheBlackSide243 wants to merge 3 commits into
Anyesh:mainfrom
TheBlackSide243:pr/stylist-chat

Conversation

@TheBlackSide243

@TheBlackSide243 TheBlackSide243 commented Oct 18, 2026 •

Copy link
Copy Markdown

Description

Adds a Stylist page: a chat with the configured text model that receives a compact listing of the
user's wardrobe and answers questions like "what goes with my jeans?" in the user's language.
Items it suggests are cited as [#n], resolved server-side to item ids and shown inline as small
thumbnails.

  • POST /api/v1/stylist with the conversation (last 20 turns, up to 150 items listed).
  • 503 when AI is turned off, 502 when the model fails, 422 when the last turn is not the user's.
  • The conversation is kept in sessionStorage only; nothing is stored server-side.
  • Frontend page /dashboard/stylist, nav entry, translations for all 8 locales.

This is one of a set of small, independent PRs (each branches from main and can be reviewed or
dropped on its own); they only overlap in the nav/router registration lines.

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update
  • Refactoring (no functional changes)
  • CI/CD or build changes

Checklist

  • I have read the CONTRIBUTING guide
  • My code follows the project's coding style
  • I have added tests that prove my fix/feature works
  • New and existing tests pass locally
  • I have updated documentation as needed
  • My changes don't introduce new warnings or errors

Testing

  • Backend: ruff check, ruff format --check, migration heads check and the full pytest suite on top of current main (2030 passed).
  • Frontend: npm run lint, i18n:check, vocab:check, tsc --noEmit and vitest (693 passed).
  • Running on my own self-hosted instance (Docker Compose on TrueNAS, Ollama).

Test Environment

  • Docker Compose
  • Kubernetes
  • Local development

🤖 Generated with Claude Code

Summary by Sourcery

Add a wardrobe-aware AI stylist experience across the API and dashboard.

New Features:

  • Add a wardrobe-aware stylist chat that answers in the user's language and links suggestions to owned clothing items.
  • Add a dashboard Stylist page with session-only conversation history, inline item thumbnails, example prompts, and localized navigation and messaging across all supported locales.

Enhancements:

  • Use each user's enabled AI endpoints for stylist requests, falling back to the shared service when none are configured.
  • Limit wardrobe context and conversation history while preserving item references across follow-up messages and filtering unavailable items from responses.

Tests:

  • Add backend coverage for citation resolution, wardrobe listings, conversation renumbering, endpoint behavior, AI configuration, and failure conditions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sourcery-ai

sourcery-ai Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

Reviewer's Guide

Implements an authenticated AI stylist experience end to end: the backend supplies a bounded, user-owned wardrobe and resolves model citations to item IDs, while the frontend provides a localized session-only chat page with inline clothing thumbnails and dashboard navigation.

Sequence diagram for the wardrobe-aware stylist chat

sequenceDiagram
    actor User
    participant StylistPage
    participant API as POST /api/v1/stylist
    participant Database
    participant StylistService
    participant AIModel

    User->>StylistPage: send message
    StylistPage->>API: messages
    API->>Database: load ready, non-archived wardrobe
    Database-->>API: user wardrobe items
    API->>StylistService: ask_stylist(items, conversation, context)
    StylistService->>AIModel: _call_with_fallback(system prompt, last 20 turns)
    AIModel-->>StylistService: reply with [#n] citations
    StylistService->>StylistService: resolve_citations(reply, wardrobe)
    StylistService-->>API: reply text and cited items
    API-->>StylistPage: reply and item records
    StylistPage-->>User: chat reply with inline thumbnails
Loading

Flow diagram for stylist citation resolution

flowchart LR
    A[Wardrobe items] --> B[Numbered wardrobe listing]
    B --> C[AI stylist prompt]
    C --> D[Reply containing #n citations]
    D --> E[resolve_citations]
    E --> F["[item:id] tokens"]
    F --> G[Inline clothing thumbnails]
Loading

File-Level Changes

Change Details Files
Adds an authenticated backend stylist chat endpoint that builds a bounded wardrobe context, invokes the configured AI service, and converts model citations into validated wardrobe item references.
  • Registers POST /api/v1/stylist with request validation, ownership/status filtering, date/location context, and documented AI error responses.
  • Limits conversations to 20 turns and wardrobe context to 150 non-unknown items.
  • Formats wardrobe metadata into a numbered prompt and resolves valid [#n] citations to item IDs while dropping invalid citations.
  • Adds endpoint and citation/listing behavior tests.
backend/app/api/router.py
backend/app/api/stylist.py
backend/app/services/stylist_service.py
backend/tests/test_stylist.py
Introduces a client-side Stylist dashboard page with session-only conversation history and inline wardrobe-item previews.
  • Adds a React Query mutation for authenticated stylist requests.
  • Provides chat composition, example prompts, loading/error states, clearing, Enter-to-send behavior, and a 2,000-character input limit.
  • Persists messages in sessionStorage and renders resolved item tokens as thumbnail/name chips.
  • Adds the stylist route to desktop and mobile navigation.
frontend/app/dashboard/stylist/page.tsx
frontend/lib/hooks/use-stylist.ts
frontend/components/sidebar.tsx
frontend/components/mobile-sidebar.tsx
Adds localized UI support for the new stylist feature across all configured locales.
  • Registers the stylist translation namespace.
  • Adds translated navigation labels and page strings for English, German, French, Italian, Japanese, Korean, Simplified Chinese, and Traditional Chinese.
frontend/lib/i18n/locales.ts
frontend/messages/de/nav.json
frontend/messages/de/stylist.json
frontend/messages/en/nav.json
frontend/messages/en/stylist.json
frontend/messages/fr/nav.json
frontend/messages/fr/stylist.json
frontend/messages/it/nav.json
frontend/messages/it/stylist.json
frontend/messages/ja/nav.json
frontend/messages/ja/stylist.json
frontend/messages/ko/nav.json
frontend/messages/ko/stylist.json
frontend/messages/zh-CN/nav.json
frontend/messages/zh-CN/stylist.json
frontend/messages/zh-TW/nav.json
frontend/messages/zh-TW/stylist.json

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@github-actions github-actions Bot added javascript Pull requests that update javascript code python Pull requests that update python code labels Oct 18, 2026

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

Hey - I've found 6 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="backend/app/api/stylist.py" line_range="73" />
<code_context>
+        context += f", the user lives in {current_user.location_name}"
+
+    try:
+        reply = await ask_stylist(
+            ai, items, [turn.model_dump() for turn in request.messages], context
+        )
</code_context>
<issue_to_address>
**Deleted pieces remain recommended**

When the user archives or deletes an item while the stylist request is waiting on the model, `ask_stylist` resolves citations against the item objects fetched before the AI call, and the endpoint returns those objects without checking whether they are still ready and unarchived. If another request archives or deletes a cited piece while the model is responding, the user receives a recommendation for an item that is no longer in the wardrobe, potentially with a broken thumbnail.

Revalidate cited item IDs against the user’s current ready, unarchived wardrobe before returning the response.
</issue_to_address>

### Comment 2
<location path="backend/app/api/stylist.py" line_range="65-66" />
<code_context>
+            )
+        )
+        .options(selectinload(ClothingItem.additional_images))
+        .order_by(ClothingItem.created_at, ClothingItem.id)
+    )
+    items = [i for i in result.scalars().all() if i.type and i.type != "unknown"]
+    context = f"today is {get_user_today(current_user).isoformat()}"
</code_context>
<issue_to_address>
**Newer wardrobe items are omitted**

When a user has more than 150 ready, non-archived items with a known type, the endpoint orders items oldest-first, and `ask_stylist` keeps only the first 150, so newer items never reach the model or citation resolver and cannot be recommended.

Order eligible items newest-first before limiting the list to `MAX_ITEMS`.

Also at `backend/app/services/stylist_service.py:72`.
</issue_to_address>

### Comment 3
<location path="frontend/app/dashboard/stylist/page.tsx" line_range="90-93" />
<code_context>
+    stylist.mutate(
+      next.slice(-20).map(({ role, content: body }) => ({ role, content: body })),
+      {
+        onSuccess: (reply) =>
+          setMessages((current) => [
+            ...current,
+            { role: 'assistant', content: reply.reply, items: reply.items },
+          ]),
+      }
</code_context>
<issue_to_address>
**Pending reply is not saved**

When the user leaves the Stylist page while a request is pending and it completes afterward, the assistant turn is added only by the page-scoped `mutate` success callback, which does not run after unmount, so the conversation reopens with an unanswered user message.

Handle and persist the response in a request callback that remains active after the page unmounts.
</issue_to_address>

### Comment 4
<location path="backend/app/api/stylist.py" line_range="49" />
<code_context>
+            detail="The last message must come from the user",
+        )
+    try:
+        ai = get_ai_service()
+    except AIDisabledError:
+        raise HTTPException(
</code_context>
<issue_to_address>
**Disabled text AI still runs**

When vision AI is enabled while text AI is explicitly disabled, `get_ai_service()` checks whether either AI capability is enabled, not whether text AI is enabled. With vision enabled and `AI_TEXT_ENABLED=false`, the stylist still calls the text model instead of returning 503, contrary to the capability setting.

Call `require_internal_ai("text")` before constructing or using the AI service, and map its error to 503.
</issue_to_address>

### Comment 5
<location path="frontend/app/dashboard/stylist/page.tsx" line_range="93-88" />
<code_context>
+        onSuccess: (reply) =>
+          setMessages((current) => [
+            ...current,
+            { role: 'assistant', content: reply.reply, items: reply.items },
+          ]),
+      }
</code_context>
<issue_to_address>
**Follow-ups lose cited items**

When a user follows up about an item cited in an earlier assistant reply, `resolve_citations` replaces numbered citations with UUID tokens, which the page stores and resubmits as assistant text. The next prompt lists items by number, not UUID, so the model cannot resolve a prior citation when answering a follow-up about that piece.

Preserve the original numbered citations in conversation history, or translate item tokens back to the current wardrobe listing's numbers before sending history to the model.

Also at `backend/app/services/stylist_service.py:60`.
</issue_to_address>

### Comment 6
<location path="backend/app/services/stylist_service.py" line_range="38-39" />
<code_context>
+def wardrobe_listing(items: list[ClothingItem]) -> str:
+    lines = []
+    for number, item in enumerate(items, start=1):
+        parts = [item.name, item.primary_color, item.pattern, item.material, item.type]
+        text = " ".join(p for p in parts if p)
+        extras = []
+        if item.season:
</code_context>
<issue_to_address>
**Garment subtypes are missing**

When a ready wardrobe item has a meaningful subtype that is not present in its name, when an item’s distinguishing detail is stored in `ClothingItem.subtype`, `wardrobe_listing` omits it and gives the model only the broader `type`. The stylist cannot distinguish pieces such as jeans from other pants and gives recommendations without that wardrobe detail.

Include each item’s subtype in `wardrobe_listing`, as the existing pairing and recommendation formatters do.
</issue_to_address>

Sourcery assessment

Needs a human reviewer. 6 findings to address first, and each chat sends the user’s wardrobe details, location context, and conversation history to the configured AI provider. Reverting stops future transmissions, but cannot retract data already sent.

Blocking findings: backend/app/api/stylist.py:73, backend/app/api/stylist.py:66, frontend/app/dashboard/stylist/page.tsx:93, backend/app/api/stylist.py:49, frontend/app/dashboard/stylist/page.tsx:88, and 1 more


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

context += f", the user lives in {current_user.location_name}"

try:
reply = await ask_stylist(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium · Deleted pieces remain recommended

When the user archives or deletes an item while the stylist request is waiting on the model, ask_stylist resolves citations against the item objects fetched before the AI call, and the endpoint returns those objects without checking whether they are still ready and unarchived. If another request archives or deletes a cited piece while the model is responding, the user receives a recommendation for an item that is no longer in the wardrobe, potentially with a broken thumbnail.

Revalidate cited item IDs against the user’s current ready, unarchived wardrobe before returning the response.

Prompt for AI agents
In `backend/app/api/stylist.py` at line 73:

**Deleted pieces remain recommended**

When the user archives or deletes an item while the stylist request is waiting on the model, `ask_stylist` resolves citations against the item objects fetched before the AI call, and the endpoint returns those objects without checking whether they are still ready and unarchived. If another request archives or deletes a cited piece while the model is responding, the user receives a recommendation for an item that is no longer in the wardrobe, potentially with a broken thumbnail.

Revalidate cited item IDs against the user’s current ready, unarchived wardrobe before returning the response.

Comment thread backend/app/api/stylist.py Outdated
Comment on lines +65 to +66
.order_by(ClothingItem.created_at, ClothingItem.id)
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium · Newer wardrobe items are omitted

When a user has more than 150 ready, non-archived items with a known type, the endpoint orders items oldest-first, and ask_stylist keeps only the first 150, so newer items never reach the model or citation resolver and cannot be recommended.

Order eligible items newest-first before limiting the list to MAX_ITEMS.

Also at backend/app/services/stylist_service.py:72.

Prompt for AI agents
In `backend/app/api/stylist.py` at lines 65-66:

**Newer wardrobe items are omitted**

When a user has more than 150 ready, non-archived items with a known type, the endpoint orders items oldest-first, and `ask_stylist` keeps only the first 150, so newer items never reach the model or citation resolver and cannot be recommended.

Order eligible items newest-first before limiting the list to `MAX_ITEMS`.

Also at `backend/app/services/stylist_service.py:72`.

Comment thread frontend/app/dashboard/stylist/page.tsx Outdated
Comment on lines +90 to +93
onSuccess: (reply) =>
setMessages((current) => [
...current,
{ role: 'assistant', content: reply.reply, items: reply.items },

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium · Pending reply is not saved

When the user leaves the Stylist page while a request is pending and it completes afterward, the assistant turn is added only by the page-scoped mutate success callback, which does not run after unmount, so the conversation reopens with an unanswered user message.

Handle and persist the response in a request callback that remains active after the page unmounts.

Prompt for AI agents
In `frontend/app/dashboard/stylist/page.tsx` at lines 90-93:

**Pending reply is not saved**

When the user leaves the Stylist page while a request is pending and it completes afterward, the assistant turn is added only by the page-scoped `mutate` success callback, which does not run after unmount, so the conversation reopens with an unanswered user message.

Handle and persist the response in a request callback that remains active after the page unmounts.

Comment thread backend/app/api/stylist.py Outdated
detail="The last message must come from the user",
)
try:
ai = get_ai_service()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium · Disabled text AI still runs

When vision AI is enabled while text AI is explicitly disabled, get_ai_service() checks whether either AI capability is enabled, not whether text AI is enabled. With vision enabled and AI_TEXT_ENABLED=false, the stylist still calls the text model instead of returning 503, contrary to the capability setting.

Call require_internal_ai("text") before constructing or using the AI service, and map its error to 503.

Prompt for AI agents
In `backend/app/api/stylist.py` at line 49:

**Disabled text AI still runs**

When vision AI is enabled while text AI is explicitly disabled, `get_ai_service()` checks whether either AI capability is enabled, not whether text AI is enabled. With vision enabled and `AI_TEXT_ENABLED=false`, the stylist still calls the text model instead of returning 503, contrary to the capability setting.

Call `require_internal_ai("text")` before constructing or using the AI service, and map its error to 503.

Comment thread frontend/app/dashboard/stylist/page.tsx Outdated
setMessages(next);
setDraft('');
stylist.mutate(
next.slice(-20).map(({ role, content: body }) => ({ role, content: body })),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium · Follow-ups lose cited items

When a user follows up about an item cited in an earlier assistant reply, resolve_citations replaces numbered citations with UUID tokens, which the page stores and resubmits as assistant text. The next prompt lists items by number, not UUID, so the model cannot resolve a prior citation when answering a follow-up about that piece.

Preserve the original numbered citations in conversation history, or translate item tokens back to the current wardrobe listing's numbers before sending history to the model.

Also at backend/app/services/stylist_service.py:60.

Prompt for AI agents
In `frontend/app/dashboard/stylist/page.tsx` at line 93:

**Follow-ups lose cited items**

When a user follows up about an item cited in an earlier assistant reply, `resolve_citations` replaces numbered citations with UUID tokens, which the page stores and resubmits as assistant text. The next prompt lists items by number, not UUID, so the model cannot resolve a prior citation when answering a follow-up about that piece.

Preserve the original numbered citations in conversation history, or translate item tokens back to the current wardrobe listing's numbers before sending history to the model.

Also at `backend/app/services/stylist_service.py:60`.

Comment thread backend/app/services/stylist_service.py Outdated
Comment on lines +38 to +39
parts = [item.name, item.primary_color, item.pattern, item.material, item.type]
text = " ".join(p for p in parts if p)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium · Garment subtypes are missing

When a ready wardrobe item has a meaningful subtype that is not present in its name, when an item’s distinguishing detail is stored in ClothingItem.subtype, wardrobe_listing omits it and gives the model only the broader type. The stylist cannot distinguish pieces such as jeans from other pants and gives recommendations without that wardrobe detail.

Include each item’s subtype in wardrobe_listing, as the existing pairing and recommendation formatters do.

Prompt for AI agents
In `backend/app/services/stylist_service.py` at lines 38-39:

**Garment subtypes are missing**

When a ready wardrobe item has a meaningful subtype that is not present in its name, when an item’s distinguishing detail is stored in `ClothingItem.subtype`, `wardrobe_listing` omits it and gives the model only the broader `type`. The stylist cannot distinguish pieces such as jeans from other pants and gives recommendations without that wardrobe detail.

Include each item’s subtype in `wardrobe_listing`, as the existing pairing and recommendation formatters do.

TheBlackSide243 and others added 2 commits October 18, 2026 22:21
- Off with text AI, whatever the vision setting says.
- Newest pieces first, so a large wardrobe shows the recent ones.
- Earlier replies are renumbered for the current listing before they go
  back to the model, so follow-ups about a cited piece work.
- The listing names the subtype (chinos, not just pants).
- Pieces archived or deleted while the model answered are not offered.
- The database connection is released while waiting on the model.
- A reply that arrives after leaving the page is kept for the next visit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Settings > AI lets each user pick their own endpoints and models; tagging,
suggestions and pairings already honour them. This feature now does too,
through services/user_ai.ai_service_for, with the server's AI_* settings
as the fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

javascript Pull requests that update javascript code python Pull requests that update python code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants