Repository navigation
feat: stylist chat that knows the wardrobe - #280
TheBlackSide243 wants to merge 3 commits into
Conversation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reviewer's GuideImplements 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 chatsequenceDiagram
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
Flow diagram for stylist citation resolutionflowchart 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]
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
There was a problem hiding this comment.
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
| context += f", the user lives in {current_user.location_name}" | ||
|
|
||
| try: | ||
| reply = await ask_stylist( |
There was a problem hiding this comment.
🟡 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.| .order_by(ClothingItem.created_at, ClothingItem.id) | ||
| ) |
There was a problem hiding this comment.
🟡 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`.| onSuccess: (reply) => | ||
| setMessages((current) => [ | ||
| ...current, | ||
| { role: 'assistant', content: reply.reply, items: reply.items }, |
There was a problem hiding this comment.
🟡 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.| detail="The last message must come from the user", | ||
| ) | ||
| try: | ||
| ai = get_ai_service() |
There was a problem hiding this comment.
🟡 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.| setMessages(next); | ||
| setDraft(''); | ||
| stylist.mutate( | ||
| next.slice(-20).map(({ role, content: body }) => ({ role, content: body })), |
There was a problem hiding this comment.
🟡 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`.| parts = [item.name, item.primary_color, item.pattern, item.material, item.type] | ||
| text = " ".join(p for p in parts if p) |
There was a problem hiding this comment.
🟡 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.- 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>
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 smallthumbnails.
POST /api/v1/stylistwith the conversation (last 20 turns, up to 150 items listed)./dashboard/stylist, nav entry, translations for all 8 locales.This is one of a set of small, independent PRs (each branches from
mainand can be reviewed ordropped on its own); they only overlap in the nav/router registration lines.
Type of Change
Checklist
Testing
ruff check,ruff format --check, migration heads check and the full pytest suite on top of currentmain(2030 passed).npm run lint,i18n:check,vocab:check,tsc --noEmitand vitest (693 passed).Test Environment
🤖 Generated with Claude Code
Summary by Sourcery
Add a wardrobe-aware AI stylist experience across the API and dashboard.
New Features:
Enhancements:
Tests: