Compare commits

..

44 Commits

Author SHA1 Message Date
zcr
a36235d58e 1 2026-04-14 10:11:29 +08:00
zcr
433aa3e751 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	docker-compose.yml
2026-04-14 10:10:56 +08:00
zcr
7bf080b3e7 1 2026-04-14 10:10:00 +08:00
zcr
03c4759895 fix:更新occasion映射服装类别 2026-03-05 15:41:39 +08:00
03ff6605a3 更新 docker-compose.yml 2026-02-09 14:54:17 +08:00
4b3b0f6aa8 更新 .gitea/workflows/prod_build_manual.yaml 2026-02-09 14:50:25 +08:00
zcr
c798d37fdd fix:防止服务宕机,增加自动重启 2026-02-03 15:34:10 +08:00
zcr
43fd576da6 fix:启动端口改由环境变量控制 2026-01-27 10:40:50 +08:00
zcr
1bbb9c945e fix:启动端口改由环境变量控制 2026-01-27 10:33:35 +08:00
pangkaicheng
3ca6b16eaf UPDATE: update prompt. Make generate button bold 2026-01-19 13:18:21 +08:00
pangkaicheng
f9d83f6a99 UPDATE: chat basic prompt. Limit Chatbot to conclude within three turns. 2026-01-19 12:16:47 +08:00
pangkaicheng
496e7ad590 FIX: delete iterative mode 2026-01-14 12:22:13 +08:00
pangkaicheng
a7b101253b UPDATE; fullfill LC request for lacking layering of Evening/workwear occasions 2026-01-14 12:22:13 +08:00
pangkaicheng
077ceea219 merge clothing stage and accessories stage of stylist guide 2026-01-14 12:22:13 +08:00
zcr
4fa815158f fix:推荐逻辑更新 弃用慢推荐 2026-01-12 11:24:55 +08:00
pangkaicheng
46793ba271 UPDATE: add color constrain in vector database for wedding occasion avoiding black items. 2026-01-07 17:26:44 +08:00
pangkaicheng
773db4fcc3 UPDATE: 1. update general rule. 2. ADD retry feature in quick mode if outfit is considered as incomplete 2026-01-07 16:21:17 +08:00
pangkaicheng
13b99f4dd3 FIX: Failed to find single item will not terminate whole process 2026-01-07 12:19:50 +08:00
pangkaicheng
9d6fbc92f1 UPDATE: subcategories filtering, General rules updates 2026-01-06 15:42:30 +08:00
pangkaicheng
e7ec547671 Modify DOC workflow 2026-01-02 19:45:52 +08:00
zcr
507d8a3e12 fix:取消品牌筛选逻辑 2025-12-30 15:06:38 +08:00
zcr
feb431e9c1 fix:修复任务失败不进入重试的逻辑。feat:优化快慢任务创建逻辑 2025-12-29 17:45:53 +08:00
zcr
042e6015f0 fix:无历史记录总结返回参数修正 2025-12-29 13:12:39 +08:00
zcr
8ccf899441 feat:重试和失败均通知前端 2025-12-24 11:30:11 +08:00
zcr
7a1496aeb7 feat:update DockerFile ,replace pip with uv 2025-12-23 17:56:22 +08:00
zcr
d8df6b28ea feat:update DockerFile ,replace pip with uv 2025-12-23 17:08:11 +08:00
zcr
79ec1c5300 feat:update DockerFile ,replace pip with uv 2025-12-23 17:05:51 +08:00
zcr
1f6ba5fd09 feat:update chatbot sys prompt 2025-12-23 16:38:40 +08:00
pangkaicheng
54aac900ad UPDATE: New retrieve algorithm. Solve multiple sungalsses issue. Only find item with score is positive. 2025-12-19 17:05:46 +08:00
zcr
884e7966dd feat:update actions file 2025-12-19 16:22:34 +08:00
zcr
2db36e2c1d feat:1.推荐接口入参、回调结果中增加request_summary、occasions字段
2.推荐outfit数量修改为1(快推荐模式)
2025-12-19 16:06:36 +08:00
pangkaicheng
3910c07c40 UPDATE: system prompt add RULE priority 2025-12-19 12:42:00 +08:00
pangkaicheng
c0c72a9c87 ADD function, brand restriction 2025-12-19 11:37:39 +08:00
zcr
39f8b942cb feat:1.通过请求数量区分刷新(num=1)和正常推荐(num>1) 2025-12-17 11:19:46 +08:00
zcr
3e70324261 feat:1.移除所有明文服务密钥,采用环境变量方式读取
2.回调信息简化 \ stylist_agent_server.py中 一部分逻辑更新
2025-12-16 17:29:05 +08:00
pangkaicheng
46b96995f0 BUG FIX:stylist_agent server, _execute_batch_recommendation(accessories) 2025-12-15 17:36:52 +08:00
pangkaicheng
b17cfb53d8 FIX: category 'category' referenced before assignment 2025-12-12 17:40:19 +08:00
pangkaicheng
85390d5e6d reconstruct whole recommendation pipeline and add new rec mode one-ask-for-all 2025-12-12 17:37:07 +08:00
pangkaicheng
0e9546aa1a Enable data auto process for new data 2025-12-10 17:27:56 +08:00
pangkaicheng
0b1d948f77 tons of modification for occasion filtering 2025-12-09 16:06:07 +08:00
zhh
ee695e7511 避免重复item加入回调参数中 2025-11-27 11:09:19 +08:00
zhh
9451e95667 新增 随机池种类递减(把新增的类型从随机池中剔除) 随机池中剔除Socks , 通过llm template prompt 控制单个outfit的服装类别唯一性 2025-11-27 10:47:17 +08:00
zhh
347547da00 新增 随机池种类递减(把新增的类型从随机池中剔除) 随机池中剔除Socks 2025-11-26 16:20:59 +08:00
zhh
ff5bdecb69 新增 随机池种类递减(把新增的类型从随机池中剔除) 随机池中剔除Socks 2025-11-26 16:10:12 +08:00
54 changed files with 4711 additions and 2669 deletions

2
.env
View File

@@ -1,2 +0,0 @@
GEMINI_API_KEY=AIzaSyAO4zXFke1bqyrXd9-RGfKJTLerwLSFKww
GOOGLE_APPLICATION_CREDENTIALS="/app/request.json"

View File

@@ -1,2 +0,0 @@
GEMINI_API_KEY=AIzaSyAO4zXFke1bqyrXd9-RGfKJTLerwLSFKww
GOOGLE_APPLICATION_CREDENTIALS="/workspace/lc_stylist_agent/app/request.json"

View File

@@ -0,0 +1,40 @@
name: 手动 LC python main 分支构建部署
on:
workflow_dispatch:
jobs:
scheduled_deploy:
runs-on: ubuntu-latest
env:
REMOTE_DEPLOY_PATH: /workspace/LC_Workspace/LitServe_Server_Workspace/lc_stylist_agent
steps:
- name: 1.检出代码
uses: actions/checkout@v4
with:
ref: 'main'
- name: 2.复制文件到服务器
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
password: ${{ secrets.SERVER_PASSWORD }}
source: "."
target: ${{ env.REMOTE_DEPLOY_PATH }}
- name: 3.重启docker-compose
uses: appleboy/ssh-action@v0.1.10
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
password: ${{ secrets.SERVER_PASSWORD }}
script: |
# 进入项目目录
cd ${{ env.REMOTE_DEPLOY_PATH }}
docker-compose down 2>&1
docker-compose up -d --build --remove-orphans 2>&1
# docker image prune -f 2>&1

18
.gitignore vendored
View File

@@ -1,12 +1,12 @@
.env
/.env
.vscode/
app/core/__pycache__/
data/db
data/image_data
app/core/data/
__pycache__/
data/
.idea/
*.sqlite3
*.log
db
*.sqlite
*.png
*.toml
.prod_env
google_application_credentials.json
*.bash
test
app/google_application_credentials.json

View File

@@ -1,46 +1,37 @@
# Change CUDA and cuDNN version here
FROM ghcr.io/astral-sh/uv:latest AS uv_bin
FROM nvidia/cuda:12.4.1-base-ubuntu22.04
ARG PYTHON_VERSION=3.10
ENV DEBIAN_FRONTEND=noninteractive
# 1. 基础环境配置
ENV UV_LINK_MODE=copy \
UV_COMPILE_BYTECODE=1 \
PYTHONUNBUFFERED=1 \
UV_PROJECT_ENVIRONMENT=/app/.venv
COPY --from=uv_bin /uv /uvx /bin/
RUN apt-get update && apt-get install -y --no-install-recommends \
software-properties-common \
wget \
&& add-apt-repository ppa:deadsnakes/ppa \
&& apt-get update && apt-get install -y --no-install-recommends \
python$PYTHON_VERSION \
python$PYTHON_VERSION-dev \
python$PYTHON_VERSION-venv \
&& wget https://bootstrap.pypa.io/get-pip.py -O get-pip.py \
&& python$PYTHON_VERSION get-pip.py \
&& rm get-pip.py \
&& ln -sf /usr/bin/python$PYTHON_VERSION /usr/bin/python \
&& ln -sf /usr/local/bin/pip$PYTHON_VERSION /usr/local/bin/pip \
&& python --version \
&& pip --version \
&& apt-get purge -y --auto-remove software-properties-common \
libcurl4-openssl-dev \
build-essential \
libgl1 \
libglib2.0-0 \
ca-certificates \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
####### Add your own installation commands here #######
# RUN pip install some-package
# RUN wget https://path/to/some/data/or/weights
# RUN apt-get update && apt-get install -y <package-name>
RUN apt-get update && \
apt-get install -y libcurl4-openssl-dev build-essential
RUN apt-get update
RUN apt-get -y install libgl1
RUN apt-get -y install libglib2.0-0
WORKDIR /app
COPY . /app
# Install litserve and requirements
RUN pip install --upgrade pip setuptools wheel
RUN pip install --no-cache-dir litserve==0.2.16 -r requirements.txt
RUN pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121
COPY pyproject.toml uv.lock ./
ENV UV_COMPILE_BYTECODE=0
RUN uv sync --frozen --no-dev --no-install-project --python 3.10
# 4. 拷贝项目文件并安装项目本身
COPY . .
RUN uv sync --frozen --no-dev --python 3.10
ENV PATH="/app/.venv/bin:$PATH"
EXPOSE 8000
CMD ["python", "-m","app.main"]
#CMD ["tail", "-f","/dev/null"]
CMD ["uv", "run","-m","app.main"]

View File

@@ -3,8 +3,8 @@ import os
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import Field
# ⚠️ 注意: 您需要安装 pydantic-settings: pip install pydantic-settings
DEBUG = os.environ.get("DEBUG", 1)
class Settings(BaseSettings):
@@ -16,6 +16,14 @@ class Settings(BaseSettings):
env_file_encoding='utf-8',
extra='ignore' # 忽略环境变量中多余的键
)
# 启动端口
SERVE_PORT: int = Field(default=8000, description='')
# 换脸模型服务
RE_FACE_MODEL_URL: str = Field(default="10.1.1.240:10071", description='')
# 调试配饰
LOCAL: int = Field(default=0, description="是否在本地运行1表示本地运行0表示生产环境运行")
# Redis 配置
REDIS_HOST: str = Field(default='10.1.1.240', description="Redis服务器地址")
@@ -28,19 +36,22 @@ class Settings(BaseSettings):
LLM_MODEL_NAME: str = Field(default="gemini-2.5-flash", description="使用的 LLM 模型名称")
# 路径配置参数
DATA_ROOT: str = Field(default="/workspace/lc_stylist_agent/app/core/data", description="数据根目录")
IMAGE_DIR: str = Field(default="/workspace/lc_stylist_agent/app/core/data/image_data", description="图片数据目录")
OUTFIT_OUTPUT_DIR: str = Field(default="/workspace/lc_stylist_agent/app/core/data/outfit_output", description="生成的搭配图片输出目录")
STYLIST_GUIDE_DIR: str = Field(default="/workspace/lc_stylist_agent/app/core/data/stylist_guide", description="风格指南文本目录")
DATA_ROOT: str = Field(default="/workspace/lc_stylist_agent/data", description="数据根目录")
OUTFIT_OUTPUT_DIR: str = Field(default="/workspace/lc_stylist_agent/data/outfit_output", description="生成的搭配图片输出目录")
STYLIST_GUIDE_DIR: str = Field(default="/workspace/lc_stylist_agent/data/stylist_guide", description="风格指南文本目录")
# 向量数据库配置参数
if DEBUG == 1:
VECTOR_DB_DIR: str = Field(default="/workspace/lc_stylist_agent/db", description="向量数据库目录")
else:
VECTOR_DB_DIR: str = Field(default="/db", description="向量数据库目录")
VECTOR_DB_DIR: str = Field(default="/db", description="向量数据库目录")
COLLECTION_NAME: str = Field(default="lc_clothing_embedding", description="向量数据库集合名称")
EMBEDDING_MODEL_NAME: str = Field(default="openai/clip-vit-base-patch32", description="CLIP嵌入模型名称")
# minio配置
MINIO_URL: str = Field(default="", description="URL")
MINIO_ACCESS: str = Field(default="", description="ACCESS")
MINIO_SECRET: str = Field(default="", description="SECRET")
MINIO_SECURE: bool = Field(default=True, description="SECRET")
MINIO_LC_DATA_PATH: str = Field(default="", description="图片数据路径")
# 创建配置实例,供应用其他部分使用
settings = Settings()

View File

View File

@@ -1,139 +0,0 @@
import time
from typing import Dict, List
import asyncio
from app.core.data_structure import Message, Role
from app.core.llm_interface import AsyncLLMInterface, AsyncGeminiLLM
from app.core.redis_manager import RedisManager
from app.core.system_prompt import BASIC_PROMPT, SUMMARY_PROMPT
from app.core.stylist_agent import AsyncStylistAgent
from app.core.vector_database import VectorDatabase
from app.core.config import settings
class ChatbotAgent:
def __init__(self, llm_model: AsyncLLMInterface = None):
self.llm = llm_model if llm_model else AsyncGeminiLLM(model_name=settings.LLM_MODEL_NAME)
self.redis = RedisManager(
host=settings.REDIS_HOST,
port=settings.REDIS_PORT,
db=settings.REDIS_DB,
key_prefix=settings.REDIS_HISTORY_KEY_PREFIX
)
self.vector_db = VectorDatabase(
vector_db_dir=settings.VECTOR_DB_DIR,
collection_name=settings.COLLECTION_NAME,
embedding_model_name=settings.EMBEDDING_MODEL_NAME
)
self.stylist_agent_kwages = {
'local_db': self.vector_db,
'max_len': 5,
'outfits_root': settings.OUTFIT_OUTPUT_DIR,
'image_dir': settings.IMAGE_DIR,
'stylist_guide_dir': settings.STYLIST_GUIDE_DIR,
'gemini_model_name': settings.LLM_MODEL_NAME
}
async def process_query(self, user_id: str, user_message: str) -> str:
"""
处理用户的最新输入,调用 LLM, 并更新历史记录。
"""
# 添加用户消息到历史
user_msg = Message(role=Role.USER, content=user_message)
chat_history = self.redis.get_history(user_id)
chat_history.append(user_msg)
# 生成 LLM 回复
try:
response_text = await self.llm.generate_response(chat_history, system_prompt=BASIC_PROMPT)
except Exception as e:
print(f"LLM 调用失败: {e}")
response_text = "抱歉,系统暂时无法响应,请稍后再试。"
# 添加助手消息到历史
if response_text:
assistant_msg = Message(role=Role.ASSISTANT, content=response_text)
else:
assistant_msg = Message(role=Role.ASSISTANT, content="No response generated. Try again later.")
self.redis.save_message(user_id, user_msg)
self.redis.save_message(user_id, assistant_msg)
return response_text
async def get_conversation_summary(self, user_id: str) -> str:
"""
分析用户的完整会话历史,并打包成一个简洁的需求总结。
这个总结可以直接作为输入 Prompt 传递给 Stylist Agent。`
"""
history_messages = self.redis.get_history(user_id)
input_message = "\n".join([f"{msg.role.value}: {msg.content}" for msg in history_messages])
# 临时调用 LLM 或使用本地逻辑生成总结
summary = await self.llm.generate_response(history=[Message(role=Role.USER, content=input_message)], system_prompt=SUMMARY_PROMPT)
return summary
async def recommend_outfit(self, request_summary: str, stylist_name: str, start_outfit: List[Dict[str, str]] = [], num_outfits: int = 1):
"""
基于用户的对话历史和需求,推荐一套搭配。
Args:
request_summary: 用户的request
start_outfit: 可选的初始搭配列表,每个元素包含 'item_id''category'
"""
tasks = []
for _ in range(num_outfits):
agent = AsyncStylistAgent(**self.stylist_agent_kwages)
task = agent.run_styling_process(request_summary, stylist_name, start_outfit)
tasks.append(task)
print(f"--- Starting {num_outfits} concurrent outfit generation tasks. ---")
try:
results = await asyncio.gather(*tasks, return_exceptions=True)
successful_outfits = []
failed_outfits = []
for result in results:
if isinstance(result, Exception):
# 任务执行中发生异常
failed_outfits.append(f"Failed: {result}")
else:
# 任务成功result 是 run_styling_process 返回的图片路径
successful_outfits.append(result)
return {
"successful_outfits": successful_outfits,
"failed_outfits": failed_outfits
}
except Exception as e:
print(f"An unexpected error occurred during concurrent recommendation: {e}")
return {"error": str(e)}
if __name__ == "__main__":
async def run():
start_time = time.time()
agent = ChatbotAgent()
user_id = "string"
# agent.redis.clear_history(user_id) # 清除历史,便于测试
# print(await agent.process_query(user_id, "I want a chic outfit for a summer party."))
# print(await agent.process_query(user_id, "I prefer something floral and light."))
request_summary = await agent.get_conversation_summary(user_id)
print(f"Conversation Summary:\n{request_summary}")
recommendation_results = await agent.recommend_outfit(request_summary, stylist_name="crystal", start_outfit=[], num_outfits=4)
print("\n--- Final Recommendation Results ---")
for i, path in enumerate(recommendation_results.get("successful_outfits", [])):
print(f"✅ Outfit {i + 1} saved to: {path}")
for error in recommendation_results.get("failed_outfits", []):
print(f"{error}")
print(time.time() - start_time)
asyncio.run(run())

View File

@@ -1,163 +0,0 @@
from google.genai import types
from typing import Dict, List
import asyncio
from google import genai
from app.core import system_prompt
from app.core.data_structure import Message, Role
from app.core.llm_interface_stream import AsyncLLMInterface, AsyncGeminiLLM
from app.core.redis_manager import RedisManager
from app.core.system_prompt import BASIC_PROMPT, SUMMARY_PROMPT
from app.core.stylist_agent import AsyncStylistAgent
from app.core.vector_database import VectorDatabase
from app.core.config import settings
class ChatbotAgent:
def __init__(self, llm_model: AsyncLLMInterface = None):
self.llm = llm_model if llm_model else AsyncGeminiLLM(model_name=settings.LLM_MODEL_NAME)
self.redis = RedisManager(
host=settings.REDIS_HOST,
port=settings.REDIS_PORT,
db=settings.REDIS_DB,
key_prefix=settings.REDIS_HISTORY_KEY_PREFIX
)
self.vector_db = VectorDatabase(
vector_db_dir=settings.VECTOR_DB_DIR,
collection_name=settings.COLLECTION_NAME,
embedding_model_name=settings.EMBEDDING_MODEL_NAME
)
self.stylist_agent_kwages = {
'local_db': self.vector_db,
'max_len': 5,
'outfits_root': settings.OUTFIT_OUTPUT_DIR,
'image_dir': settings.IMAGE_DIR,
'stylist_guide_dir': settings.STYLIST_GUIDE_DIR,
'gemini_model_name': settings.LLM_MODEL_NAME
}
self.gemini_client = genai.Client(
vertexai=True, project='aida-461108', location='us-central1'
)
async def process_query(self, user_id: str, user_message: str) -> str:
"""
处理用户的最新输入,调用 LLM, 并更新历史记录。
"""
# 添加用户消息到历史
user_msg = Message(role=Role.USER, content=user_message)
chat_history = self.redis.get_history(user_id)
chat_history.append(user_msg)
contents = []
for msg in chat_history:
gemini_role = "user" if msg.role == Role.USER else "model"
content = types.Content(
role=gemini_role,
parts=[types.Part.from_text(text=msg.content)]
)
contents.append(content)
response_parts = []
response_stream = await self.gemini_client.aio.models.generate_content_stream(
model='gemini-2.5-flash',
contents=contents,
config=types.GenerateContentConfig(
system_instruction=BASIC_PROMPT,
# temperature=0.3,
)
)
async for chunk in response_stream:
# 您可以在这里处理每一个文本块,例如发送给前端
print(chunk.text, end="", flush=True)
response_parts.append(chunk.text)
# 3. 将所有文本块合并成最终的字符串
response_text = "".join(response_parts)
# 添加助手消息到历史
if response_text:
assistant_msg = Message(role=Role.ASSISTANT, content=response_text)
else:
assistant_msg = Message(role=Role.ASSISTANT, content="No response generated. Try again later.")
self.redis.save_message(user_id, user_msg)
self.redis.save_message(user_id, assistant_msg)
return response_text
async def get_conversation_summary(self, user_id: str) -> str:
"""
分析用户的完整会话历史,并打包成一个简洁的需求总结。
这个总结可以直接作为输入 Prompt 传递给 Stylist Agent。`
"""
history_messages = self.redis.get_history(user_id)
input_message = "\n".join([f"{msg.role.value}: {msg.content}" for msg in history_messages])
# 临时调用 LLM 或使用本地逻辑生成总结
summary = await self.llm.generate_response(history=[Message(role=Role.USER, content=input_message)], system_prompt=SUMMARY_PROMPT)
return summary
async def recommend_outfit(self, request_summary: str, stylist_name: str, start_outfit: List[Dict[str, str]] = [], num_outfits: int = 1):
"""
基于用户的对话历史和需求,推荐一套搭配。
Args:
request_summary: 用户的request
start_outfit: 可选的初始搭配列表,每个元素包含 'item_id''category'
"""
tasks = []
for _ in range(num_outfits):
agent = AsyncStylistAgent(**self.stylist_agent_kwages)
task = agent.run_styling_process(request_summary, stylist_name, start_outfit)
tasks.append(task)
print(f"--- Starting {num_outfits} concurrent outfit generation tasks. ---")
try:
results = await asyncio.gather(*tasks, return_exceptions=True)
successful_outfits = []
failed_outfits = []
for result in results:
if isinstance(result, Exception):
# 任务执行中发生异常
failed_outfits.append(f"Failed: {result}")
else:
# 任务成功result 是 run_styling_process 返回的图片路径
successful_outfits.append(result)
return {
"successful_outfits": successful_outfits,
"failed_outfits": failed_outfits
}
except Exception as e:
print(f"An unexpected error occurred during concurrent recommendation: {e}")
return {"error": str(e)}
if __name__ == "__main__":
async def run():
# 阶段一:用户对话
agent = ChatbotAgent()
user_id = "string"
# agent.redis.clear_history(user_id) # 清除历史,便于测试
# await agent.process_query(user_id, "I want a chic outfit for a summer party.")
# print(await agent.process_query(user_id, "I prefer something floral and light."))
# 阶段二:读取聊天记录,生成推荐搭配
request_summary = await agent.get_conversation_summary(user_id)
print(f"Conversation Summary:\n{request_summary}")
recommendation_results = await agent.recommend_outfit(request_summary, stylist_name="crystal", start_outfit=[], num_outfits=1)
print("\n--- Final Recommendation Results ---")
for i, path in enumerate(recommendation_results.get("successful_outfits", [])):
print(f"✅ Outfit {i + 1} saved to: {path}")
for error in recommendation_results.get("failed_outfits", []):
print(f"{error}")
asyncio.run(run())

View File

@@ -1,16 +0,0 @@
from typing import List, Dict, Any
from enum import Enum
from pydantic import BaseModel, Field
import datetime
# 角色枚举,用于区分用户和系统的消息
class Role(str, Enum):
USER = "user"
ASSISTANT = "assistant"
SYSTEM = "system"
# 单条消息的数据模型
class Message(BaseModel):
role: Role = Field(..., description="Role of message sender")
content: str = Field(..., description="Content of the message")
# timestamp: str = Field(default_factory=lambda: datetime.datetime.now().isoformat()) # 记录时间戳

View File

@@ -1,56 +0,0 @@
from abc import ABC, abstractmethod
from typing import List
from google import genai
from google.genai import types
from app.core.data_structure import Message, Role
class AsyncLLMInterface(ABC):
@abstractmethod
async def generate_response(self, history: List[Message], system_prompt: str) -> str:
"""
根据对话历史和系统指令生成回复.
Args:
history: 包含多条 Message 的列表。
Returns:
LLM 生成的回复字符串。
"""
raise NotImplementedError("Subclasses must implement this method")
class AsyncGeminiLLM(AsyncLLMInterface):
def __init__(self, model_name: str = "gemini-2.5-flash"):
self.model_name = model_name
try:
self.gemini_client = genai.Client(
vertexai=True, project='aida-461108', location='us-central1'
)
except Exception as e:
raise type(e)(f"Failed to initialize Gemini Client. Check if GEMINI_API_KEY is set. Original error: {e}")
async def generate_response(self, history: List[Message], system_prompt: str) -> str:
contents = []
for msg in history:
gemini_role = "user" if msg.role == Role.USER else "model"
content = types.Content(
role=gemini_role,
parts=[types.Part.from_text(text=msg.content)]
)
contents.append(content)
try:
response = await self.gemini_client.aio.models.generate_content(
model=self.model_name,
contents=contents,
config=types.GenerateContentConfig(
system_instruction=system_prompt,
# temperature=0.3,
)
)
return response.text
except Exception as e:
raise type(e)(f"Gemini API call failed: {e}")

View File

@@ -1,61 +0,0 @@
from abc import ABC, abstractmethod
from typing import List
from google import genai
from google.genai import types
from app.core.data_structure import Message, Role
class AsyncLLMInterface(ABC):
@abstractmethod
async def generate_response(self, history: List[Message], system_prompt: str) -> str:
"""
根据对话历史和系统指令生成回复.
Args:
history: 包含多条 Message 的列表。
Returns:
LLM 生成的回复字符串。
"""
raise NotImplementedError("Subclasses must implement this method")
class AsyncGeminiLLM(AsyncLLMInterface):
def __init__(self, model_name: str = "gemini-2.5-flash"):
self.model_name = model_name
try:
self.gemini_client = genai.Client(
vertexai=True, project='aida-461108', location='us-central1'
)
except Exception as e:
raise type(e)(f"Failed to initialize Gemini Client. Check if GEMINI_API_KEY is set. Original error: {e}")
async def generate_response(self, history: List[Message], system_prompt: str):
contents = []
for msg in history:
gemini_role = "user" if msg.role == Role.USER else "model"
content = types.Content(
role=gemini_role,
parts=[types.Part.from_text(text=msg.content)]
)
contents.append(content)
return contents
# response_stream = await self.gemini_client.aio.models.generate_content_stream(
# model=self.model_name,
# contents=contents,
# config=types.GenerateContentConfig(
# system_instruction=system_prompt,
# # temperature=0.3,
# )
# )
#
# # 3. 异步迭代流,并 yield 每个块的文本
# async for chunk in response_stream:
# # 确保 chunk 中有可用的文本
# if chunk.text:
# print(chunk.text)
# yield chunk.text

View File

@@ -1,68 +0,0 @@
import logging
import redis
from typing import List, Optional
from app.core.data_structure import Message, Role
logger = logging.getLogger(__name__)
# 这是一个同步 Redis 客户端,用于演示如何替换内存存储。
# 在生产环境和异步 Web 框架中,应替换为 aioredis 等异步客户端。
class RedisManager:
"""同步管理器,用于在 Redis 中存储和检索对话历史。"""
def __init__(self, host: str = 'localhost', port: int = 6379, db: int = 0, key_prefix: str = "chat:history:"):
self.r: Optional[redis.Redis] = None
self.key_prefix = key_prefix
try:
# 尝试连接 Redis
self.r = redis.Redis(host=host, port=port, db=db, decode_responses=True)
self.r.ping()
logger.info("Successfully connected to Redis at {}:{}".format(host, port))
except Exception as e:
logger.error(f"⚠️ Failed to connect to Redis: {e}. Falling back to No-Op.")
self.r = None # 连接失败时设置为 None避免后续操作报错
def _get_key(self, user_id: str) -> str:
"""生成用户历史记录的 Redis 键名。"""
return f"{self.key_prefix}{user_id}"
def _message_to_json(self, message: Message) -> str:
"""将 Message 对象序列化为 JSON 字符串以便存储。"""
return message.model_dump_json()
def _json_to_message(self, data: str) -> Message:
"""将 JSON 字符串反序列化回 Message 对象。"""
try:
return Message.model_validate_json(data)
except Exception as e:
logger.error(f"Error deserializing message data: {data[:50]}... Error: {e}")
return Message(role=Role.ASSISTANT, content="[Deserialization Error]")
def save_message(self, user_id: str, message: Message):
"""将单条消息保存到用户历史记录列表的末尾。"""
if not self.r:
return
message_json = self._message_to_json(message)
# RPUSH将元素添加到列表的尾部
self.r.rpush(self._get_key(user_id), message_json)
def get_history(self, user_id: str) -> List[Message]:
"""检索用户的完整会话历史记录。"""
if not self.r:
return []
# LRANGE获取列表的所有元素 (0 到 -1)
raw_history = self.r.lrange(self._get_key(user_id), 0, -1)
# 将 JSON 字符串列表转换为 Message 对象列表
messages = [self._json_to_message(data) for data in raw_history]
return messages
def clear_history(self, user_id: str):
"""删除用户的完整历史记录。"""
if self.r:
self.r.delete(self._get_key(user_id))
logger.info(f"History cleared for {user_id}")

View File

@@ -1,302 +0,0 @@
import json
import os
import random
import uuid
from typing import List, Dict, Any, Optional
from google import genai
from app.core.utils import merge_images_to_square
class AsyncStylistAgent:
CATEGORY_SET = {'Activewear', 'Watches', 'Shopping Totes', 'Underwear', 'Sunglasses', 'Dresses', 'Outerwear', 'Handbags', 'Backpacks', 'Belts', 'Hats', 'Skirts', 'Swimwear', 'Jewelry', 'Briefcases', 'Socks', 'Neckties', 'Pants', 'Suits', 'Shoes', 'Shirts & Tops', 'Scarves & Shawls'}
def __init__(self, local_db, max_len: int, outfits_root: str, image_dir: str, stylist_guide_dir: str, gemini_model_name: str):
# self.outfit_items: List[Dict[str, str]] = []
self.outfit_id = str(uuid.uuid4())
self.gemini_client = genai.Client(
vertexai=True, project='aida-461108', location='us-central1'
)
self.local_db = local_db
self.max_len = max_len
self.output_outfit_path = os.path.join(outfits_root, f"{self.outfit_id}.jpg")
self.output_json_path = os.path.join(outfits_root, f"{self.outfit_id}_items.json")
self.image_dir = image_dir
self.stylist_guide_dir = stylist_guide_dir
self.gemini_model_name = gemini_model_name
self.stop_reason = ""
def _load_style_guide(self, path: str) -> str:
"""加载 markdown 风格指南内容。"""
try:
with open(path, 'r', encoding='utf-8') as f:
return f.read()
except Exception as e:
raise FileNotFoundError(f"Failed to load style guide from {path}: {e}")
def _build_system_prompt(self, request_summary: str = "") -> str:
"""Constructs the complete System Prompt."""
# Insert the style_guide content into the template
template = f"""
You are a professional fashion stylist Agent, specialized in creating complete outfits for the user.
Your task is to **create a cohesive and complete outfit**, strictly adhering to **BOTH** the user's explicit **Request Summary** and the **Outfit Style Guide**. You must decide the next logical item to add to the outfit based on the currently selected items (if any).
---
## Request from the User:
{request_summary}
## Core Guidance Document: Outfit Style Guide
{self.style_guide}
---
## Your Workflow and Constraints
1. **Style Adherence**: You must strictly observe all rules in the Style Guide concerning **color palette, fit, layering principles, pattern restrictions, accessory stacking, and shoe/bag coordination**.
2. **Step Planning**: The styling sequence must follow a **top-down, inside-out** approach: First major garments (tops/outerwear/bottoms/dresses), then shoes and bags, and finally accessories.
3. **Structured Output**: Every response must recommend the **next single item**. You must strictly use the **JSON format** for your output, as follows:
```json
{{
"action": "recommend_item",
"category": "YOUR_ITEM_CATEGORY",
"description": "YOUR_DETAILED_DESCRIPTION"
}}
```
* `action`: Must always be `"recommend_item"` until the outfit is complete.
* `category`: Must be the category of the item you are recommending, strictly selected from the following list: {list(self.CATEGORY_SET)}.
* `description`: This must be an **extremely detailed and precise** description of the item. This description is used for **high-accuracy vector search** in the database and must include:
* **Color** (e.g., milk tea, pure white, dark gray)
* **Fit/Silhouette** (e.g., Oversize, loose, slim-fit)
* **Material/Detail** (e.g., 100% cotton, linen, gold clasp, thin stripe, checkered pattern)
* **Role in the Outfit** (e.g., serves as the innermost base layer for layering; acts as the crucial tie accent for the smart casual look)
* **[CRITICAL FOR JEWELRY] If recommending 'Jewelry' (especially Necklaces), the description must specify its distinction (length, thickness, pendant style) from all previously selected necklaces to ensure layered variety.**
4. **Termination Condition**: Only when you deem the entire outfit complete and **all mandatory elements stipulated in the Style Guide are met**, you must output the following JSON format to terminate the process:
```json
{{
"action": "stop",
"reason": "OUTFIT_COMPLETE_AND_MEETS_ALL_MINI_GUIDELINES"
}}
```
Normally, five or six items are totally enough for an outfit.
5. **Context Dependency**: The user's next input (if not `Start`) will contain the **image and description of the selected item**. When recommending the next item, you must consider the coordination between the **already selected items** and the Style Guide.
**Now, please start building an outfit and output the JSON for the first item.**
"""
return template.strip()
def _clear_uploaded_files(self):
for f in self.gemini_client.files.list():
self.gemini_client.files.delete(name=f.name)
async def _call_gemini(self, user_input: str) -> str:
"""
实际调用 Gemini API 的函数,接受文本和可选的图片路径列表。
Args:
user_input: 发送给模型的主文本内容。
image_paths: 待发送图片的本地路径列表。
Returns:
模型的响应文本(预期为 JSON 字符串)。
"""
content_parts = []
# self._clear_uploaded_files()
# 1. 添加图片内容
# if self.outfit_items:
# merged_image_path = merge_images_to_square(self.outfit_items, max_len=self.max_len, output_path=self.output_outfit_path)
# try:
# myfile = await self.gemini_client.aio.files.upload(file=merged_image_path)
# content_parts.append(myfile)
# except Exception as e:
# print(f"Error loading image {merged_image_path}: {e}")
# 2. 添加文本内容
content_parts.append(user_input)
# print(f"\n--- Calling Gemini with {len(self.outfit_items) if self.outfit_items else 0} images and query:\n{user_input}")
try:
# 3. 实际 API 调用
response = await self.gemini_client.aio.models.generate_content(
model=self.gemini_model_name,
contents=content_parts,
config={
"system_instruction": self.system_prompt,
# 确保模型返回 JSON 格式
"response_mime_type": "application/json",
"response_schema": {
"type": "object",
"properties": {
"action": {"type": "string", "enum": ["recommend_item", "stop"]},
"category": {"type": "string"},
"description": {"type": "string"},
"reason": {"type": "string"}
},
"required": ["action"]
}
}
)
# response.text 将包含一个 JSON 字符串
return response.text
except Exception as e:
print(f"Gemini API Call failed: {e}")
# 返回一个停止信号以防止循环继续
return json.dumps({"action": "stop", "reason": f"API_ERROR: {str(e)}"})
def _parse_gemini_response(self, response_text: str) -> Optional[Dict[str, Any]]:
"""安全解析 Gemini 的 JSON 响应。"""
try:
# 有时 Gemini 可能会在 JSON 外面添加文字,尝试清理
response_text = response_text.strip().replace('```json', '').replace('```', '')
data = json.loads(response_text)
print(f"The agent response is: {data}")
return data
except json.JSONDecodeError as e:
print(f"Error parsing JSON from Gemini: {e}")
print(f"Raw response: {response_text}")
return None
def _get_next_item(self, item_description: str, category: str) -> Optional[Dict[str, str]]:
"""
1. 根据描述生成嵌入。
2. 查询本地数据库以找到最佳匹配项。
3. 模拟 Agent 审核匹配项(这里简化为总是通过)。
"""
try:
# 1. 生成查询嵌入
query_embedding = self.local_db.get_clip_embedding(item_description, is_image=False)
# 2. 执行查询,并过滤类别
results = self.local_db.query_local_db(query_embedding, category, n_results=1)
if not results:
print(f"❌ 数据库中未找到符合 '{category}' 和描述的单品。")
return None
# 3. 模拟 Agent 审核(实际应用中,你需要将图片发回给 Agent进行审核)
best_meta = results['metadatas'][0][0] # 第一个 batch 的第一个 metadata
return {
"item_id": best_meta['item_id'], # 从 metadata 字典中安全获取
"category": category,
"gpt_description": item_description,
'description': best_meta['description'],
# 假设 'item_path' 存储在 metadata 中,或从 'item_id' 推导
# 这里假设 item_id 就是文件名的一部分
"image_path": os.path.join(self.image_dir, f"{best_meta['item_id']}.jpg")
}
except Exception as e:
print(f"An error occurred during item retrieval: {e}")
return None
def _build_user_input(self) -> str:
"""构建发送给 Gemini 的用户输入,包含已选单品信息。"""
if not self.outfit_items:
return "Start"
# 将已选单品的信息作为上下文发回给 Agent
context = "Selected fashion items:\n"
for ii, item in enumerate(self.outfit_items):
context += f"{ii + 1}. Category: {item['category']}. Description: {item['description']}\n"
context += "\nPlease recommend the next single item based on the selected items, user's request, and style guide."
return context
async def run_styling_process(self, request_summary, stylist_name, start_outfit=[]):
self.outfit_items = start_outfit if start_outfit else []
"""主流程控制循环。"""
print(f"--- Starting Agent (Outfit ID: {self.outfit_id}) ---")
self.style_guide = self._load_style_guide(os.path.join(self.stylist_guide_dir, f"{stylist_name}_en.md"))
self.system_prompt = self._build_system_prompt(request_summary)
while True:
# 1. 准备用户输入(上下文)
user_input = self._build_user_input()
# 2. 调用 Gemini Agent
gemini_response_text = await self._call_gemini(user_input)
gemini_data = self._parse_gemini_response(gemini_response_text)
if not gemini_data:
print("🚨 Agent 返回无效响应,终止流程。")
self.stop_reason = "Agent failed to return response"
break
# 3. 检查终止条件
if gemini_data.get('action') == 'stop':
print(f"🛑 搭配完成,终止原因: {gemini_data.get('reason')}")
self.stop_reason = "Finish reason: " + gemini_data.get('reason', 'No reason provided')
break
# 4. 处理推荐单品
if gemini_data.get('action') == 'recommend_item':
category = gemini_data.get('category')
description = gemini_data.get('description')
# 4a. 检查类别是否有效 (重要步骤)
if category not in self.CATEGORY_SET:
print(f"❌ Agent 推荐了无效类别: {category}。要求 Agent 重新输出。")
# 在实际应用中,这里需要将错误信息发回给 Agent,要求它更正
# 这里简化为跳过本次循环
continue
# 4b. 在本地 DB 中查询单品
new_item = self._get_next_item(description, category)
if new_item:
# 4c. (实际步骤) 将选中的单品图片和描述发回给 Agent 进行最终审核
# 这里的代码框架省略了图片回传和二次审核的步骤,直接视为通过
# 实际你需要: new_user_input = f"Check this item: {new_item['description']}, path: {new_item['image_path']}"
# call_gemini_agent(...) -> 如果返回"pass",则添加到outfit_items
if new_item['item_id'] in [x['item_id'] for x in self.outfit_items]:
print("This item exists. Stop here.")
self.stop_reason = "Finish reason: Duplicate item selected."
break
if new_item['item_id'] == "ELG383":
if random.random() < 0.70:
self.stop_reason = "Finish reason: ELG383 is seleced repeatly."
break
self.outfit_items.append(new_item)
print(f" 成功添加单品: {new_item['category']} ({new_item['item_id']}). 当前搭配数量: {len(self.outfit_items)}")
else:
print("⚠️ 未找到匹配单品,无法继续搭配。终止。")
self.stop_reason = "Finish reason: No matching item found in local database."
break
if len(self.outfit_items) >= self.max_len: # 设置一个最大循环限制,防止无限循环
print("🚨 达到最大搭配数量限制,强制终止。")
self.stop_reason = "Finish reason: Reached max outfit length."
break
# 5. 流程结束后保存结果
self._save_outfit_results()
return self.output_outfit_path
def _save_outfit_results(self):
"""保存最终的 JSON 列表和图片到指定文件夹。"""
if not self.outfit_items:
raise ValueError("No outfit items to save.")
# 1. 保存 JSON 文件
results_list = [{'item_id': item['item_id'], 'category': item['category'], 'description': item['description'], 'gpt_description': item['gpt_description']} for item in self.outfit_items]
results_list.append({'stop_reason': self.stop_reason})
with open(self.output_json_path, 'w', encoding='utf-8') as f:
json.dump(results_list, f, ensure_ascii=False, indent=4)
merge_images_to_square(self.outfit_items, max_len=self.max_len, output_path=self.output_outfit_path, add_text=False)

View File

@@ -1,435 +0,0 @@
import asyncio
import io
import json
import logging
import os
import random
import uuid
from typing import List, Dict, Any, Optional
from google import genai
from google.cloud import storage
from google.oauth2 import service_account
from app.core.utils_litserve import merge_images_to_square
from app.server.utils.minio_client import minio_client, oss_upload_image
from app.server.utils.request_post import post_request
logger = logging.getLogger(__name__)
class AsyncStylistAgent:
CATEGORY_SET = {'Activewear', 'Watches', 'Shopping Totes', 'Underwear', 'Sunglasses', 'Dresses', 'Outerwear', 'Handbags', 'Backpacks', 'Belts', 'Hats', 'Skirts', 'Swimwear', 'Jewelry', 'Briefcases', 'Socks', 'Neckties', 'Pants', 'Suits', 'Shoes', 'Shirts & Tops', 'Scarves & Shawls'}
def __init__(self, local_db, max_len: int, gemini_model_name: str, outfit_id=str):
# self.outfit_items: List[Dict[str, str]] = []
self.outfit_id = outfit_id
self.gemini_client = genai.Client(
vertexai=True, project='aida-461108', location='us-central1'
)
self.local_db = local_db
self.max_len = max_len
self.gemini_model_name = gemini_model_name
self.stop_reason = ""
# 存储桶配置
try:
# TODO 目前写死路径 生产环境切换路径
self.credentials = service_account.Credentials.from_service_account_file(os.getenv("GOOGLE_APPLICATION_CREDENTIALS"))
except Exception as e:
# 这里的异常处理应根据实际情况调整
raise RuntimeError(f"Failed to load credentials from file {os.getenv('GOOGLE_APPLICATION_CREDENTIALS')}: {e}")
self.gcs_client = storage.Client(
project=self.credentials.project_id,
credentials=self.credentials
)
self.gcs_bucket = "lc_stylist_agent_outfit_items"
self.minio_bucket = "lanecarford"
def _load_style_guide(self, path: str) -> str:
"""加载 markdown 风格指南内容。"""
parts = path.split('/', 1)
if len(parts) != 2:
raise ValueError("MinIO path must be in 'bucket_name/object_name' format.")
bucket_name, object_name = parts
try:
# 1. 获取对象
response = minio_client.get_object(bucket_name, object_name)
# 2. 读取内容
content_bytes = response.read()
# 3. 关闭连接
response.close()
response.release_conn()
# 4. 解码并返回
return content_bytes.decode('utf-8')
except Exception as e:
raise Exception(f"Failed to load style guide from {path}: {e}")
def _build_system_prompt(self, request_summary: str = "") -> str:
"""Constructs the complete System Prompt."""
# Insert the style_guide content into the template
template = f"""
You are a professional fashion stylist Agent, specialized in creating complete outfits for the user.
Your task is to **create a cohesive and complete outfit**, strictly adhering to **BOTH** the user's explicit **Request Summary** and the **Outfit Style Guide**. You must decide the next logical item to add to the outfit based on the currently selected items (if any).
---
## Request from the User:
{request_summary}
## Core Guidance Document: Outfit Style Guide
{self.style_guide}
---
## Your Workflow and Constraints
1. **Style Adherence**: You must strictly observe all rules in the Style Guide concerning **color palette, fit, layering principles, pattern restrictions, accessory stacking, and shoe/bag coordination**.
2. **Step Planning**: The styling sequence must follow a **top-down, inside-out** approach: First major garments (tops/outerwear/bottoms/dresses), then shoes and bags, and finally accessories.
3. **Structured Output**: Every response must recommend the **next single item**. You must strictly use the **JSON format** for your output, as follows:
```json
{{
"action": "recommend_item",
"category": "YOUR_ITEM_CATEGORY",
"description": "YOUR_DETAILED_DESCRIPTION"
}}
```
* `action`: Must always be `"recommend_item"` until the outfit is complete.
* `category`: Must be the category of the item you are recommending, strictly selected from the following list: {list(self.CATEGORY_SET)}.
* `description`: This must be an **extremely detailed and precise** description of the item. This description is used for **high-accuracy vector search** in the database and must include:
* **Color** (e.g., milk tea, pure white, dark gray)
* **Fit/Silhouette** (e.g., Oversize, loose, slim-fit)
* **Material/Detail** (e.g., 100% cotton, linen, gold clasp, thin stripe, checkered pattern)
* **Role in the Outfit** (e.g., serves as the innermost base layer for layering; acts as the crucial tie accent for the smart casual look)
* **[CRITICAL FOR JEWELRY] If recommending 'Jewelry' (especially Necklaces), the description must specify its distinction (length, thickness, pendant style) from all previously selected necklaces to ensure layered variety.**
4. **Termination Condition**: Only when you deem the entire outfit complete and **all mandatory elements stipulated in the Style Guide are met**, you must output the following JSON format to terminate the process:
```json
{{
"action": "stop",
"reason": "OUTFIT_COMPLETE_AND_MEETS_ALL_MINI_GUIDELINES"
}}
```
Normally, five or six items are totally enough for an outfit.
5. **Context Dependency**: The user's next input (if not `Start`) will contain the **image and description of the selected item**. When recommending the next item, you must consider the coordination between the **already selected items** and the Style Guide.
**Now, please start building an outfit and output the JSON for the first item.**
"""
return template.strip()
def _clear_uploaded_files(self):
for f in self.gemini_client.files.list():
self.gemini_client.files.delete(name=f.name)
async def _call_gemini(self, user_input: str, user_id: str):
"""
实际调用 Gemini API 的函数,接受文本和可选的图片路径列表。
Args:
user_input: 发送给模型的主文本内容。
image_paths: 待发送图片的本地路径列表。
Returns:
模型的响应文本(预期为 JSON 字符串)。
"""
minio_path = ""
content_parts = []
# self._clear_uploaded_files()
# 1. 添加图片内容
if self.outfit_items:
merged_image = merge_images_to_square(self.outfit_items, max_len=self.max_len, add_text=False)
image_bytes_io = io.BytesIO()
image_format = 'JPEG'
mime_type = 'image/jpeg'
merged_image.save(image_bytes_io, format=image_format)
image_bytes = image_bytes_io.getvalue()
file_name = uuid.uuid4()
blob_name = f"lc_stylist_agent_outfit_items/{user_id}/{file_name}.jpg"
gcs_path = self._upload_to_gcs(bucket_name=self.gcs_bucket, blob_name=blob_name, mime_type=mime_type, image_bytes=image_bytes)
responses = oss_upload_image(oss_client=minio_client, bucket=self.minio_bucket, object_name=blob_name, image_bytes=image_bytes)
minio_path = f"{responses.bucket_name}/{responses.object_name}"
content_parts.append(gcs_path)
# 2. 添加文本内容
content_parts.append(user_input)
# print(f"\n--- Calling Gemini with {len(self.outfit_items) if self.outfit_items else 0} images and query:\n{user_input}")
try:
# 3. 实际 API 调用
response = await self.gemini_client.aio.models.generate_content(
model=self.gemini_model_name,
contents=content_parts,
config={
"system_instruction": self.system_prompt,
# 确保模型返回 JSON 格式
"response_mime_type": "application/json",
"response_schema": {
"type": "object",
"properties": {
"action": {"type": "string", "enum": ["recommend_item", "stop"]},
"category": {"type": "string"},
"description": {"type": "string"},
"reason": {"type": "string"}
},
"required": ["action"]
}
}
)
# response.text 将包含一个 JSON 字符串
return response.text, minio_path
except Exception as e:
print(f"Gemini API Call failed: {e}")
# 返回一个停止信号以防止循环继续
return json.dumps({"action": "stop", "reason": f"API_ERROR: {str(e)}"})
def _parse_gemini_response(self, response_text: str) -> Optional[Dict[str, Any]]:
"""安全解析 Gemini 的 JSON 响应。"""
try:
# 有时 Gemini 可能会在 JSON 外面添加文字,尝试清理
response_text = response_text.strip().replace('```json', '').replace('```', '')
data = json.loads(response_text)
# print(f"The agent response is: {data}")
return data
except json.JSONDecodeError as e:
print(f"Error parsing JSON from Gemini: {e}")
print(f"Raw response: {response_text}")
return None
def _get_next_item(self, item_description: str, category: str) -> Optional[Dict[str, str]]:
"""
1. 根据描述生成嵌入。
2. 查询本地数据库以找到最佳匹配项。
3. 模拟 Agent 审核匹配项(这里简化为总是通过)。
"""
try:
# 1. 生成查询嵌入
query_embedding = self.local_db.get_clip_embedding(item_description, is_image=False)
# 2. 执行查询,并过滤类别
results = self.local_db.query_local_db(query_embedding, category, n_results=1)
if not results:
print(f"❌ 数据库中未找到符合 '{category}' 和描述的单品。")
return None
# 3. 模拟 Agent 审核(实际应用中,你需要将图片发回给 Agent进行审核)
best_meta = results['metadatas'][0][0] # 第一个 batch 的第一个 metadata
return {
"item_id": best_meta['item_id'], # 从 metadata 字典中安全获取
"category": category,
"gpt_description": item_description,
'description': best_meta['description'],
# 假设 'item_path' 存储在 metadata 中,或从 'item_id' 推导
# 这里假设 item_id 就是文件名的一部分
"image_path": os.path.join(f"{best_meta['item_id']}.jpg")
}
except Exception as e:
print(f"An error occurred during item retrieval: {e}")
return None
def _build_user_input(self) -> str:
"""构建发送给 Gemini 的用户输入,包含已选单品信息。"""
if not self.outfit_items:
return "Start"
# 将已选单品的信息作为上下文发回给 Agent
context = "Selected fashion items:\n"
for ii, item in enumerate(self.outfit_items):
context += f"{ii + 1}. Category: {item['category']}. Description: {item['description']}\n"
context += "\nPlease recommend the next single item based on the selected items, user's request, and style guide."
return context
async def run_styling_process(self, request_summary, stylist_path, start_outfit=None, user_id="test", callback_url=""):
if start_outfit is None:
start_outfit = []
self.outfit_items = start_outfit if start_outfit else []
"""主流程控制循环。"""
print(f"--- Starting Agent (Outfit ID: {self.outfit_id}) ---")
self.style_guide = self._load_style_guide(stylist_path)
self.system_prompt = self._build_system_prompt(request_summary)
response_data = {"status": "",
"message": "",
"path": "",
"outfit_id": self.outfit_id,
"items": []
}
logger.info(response_data)
item_id = ""
item_category = ""
while True:
# 1. 准备用户输入(上下文)
user_input = self._build_user_input()
# 2. 调用 Gemini Agent
gemini_response_text, minio_path = await self._call_gemini(user_input, user_id)
gemini_data = self._parse_gemini_response(gemini_response_text)
response_data['path'] = minio_path
if item_id:
response_data['items'].append({"item_id": item_id, "category": item_category})
if not gemini_data:
print("🚨 Agent 返回无效响应,终止流程。")
self.stop_reason = "Agent failed to return response"
response_data['status'] = "failed"
response_data['message'] = self.stop_reason
break
# 3. 检查终止条件
if gemini_data.get('action') == 'stop':
print(f"🛑 搭配完成,终止原因: {gemini_data.get('reason')}")
self.stop_reason = "Finish reason: " + gemini_data.get('reason', 'No reason provided')
response_data['status'] = "stop"
response_data['message'] = self.stop_reason
# 4. 处理推荐单品
if gemini_data.get('action') == 'recommend_item':
category = gemini_data.get('category')
description = gemini_data.get('description')
# 4a. 检查类别是否有效 (重要步骤)
if category not in self.CATEGORY_SET:
print(f"❌ Agent 推荐了无效类别: {category}。要求 Agent 重新输出。")
# 在实际应用中,这里需要将错误信息发回给 Agent,要求它更正
# 这里简化为跳过本次循环
response_data['status'] = "continue"
response_data['message'] = f"❌ Agent 推荐了无效类别: {category}。要求 Agent 重新输出。",
continue
# 4b. 在本地 DB 中查询单品
new_item = self._get_next_item(description, category)
item_id = new_item.get('item_id')
item_category = new_item.get('category')
if new_item:
# 4c. (实际步骤) 将选中的单品图片和描述发回给 Agent 进行最终审核
# 这里的代码框架省略了图片回传和二次审核的步骤,直接视为通过
# 实际你需要: new_user_input = f"Check this item: {new_item['description']}, path: {new_item['image_path']}"
# call_gemini_agent(...) -> 如果返回"pass",则添加到outfit_items
if new_item['item_id'] in [x['item_id'] for x in self.outfit_items]:
print("This item exists. Stop here.")
self.stop_reason = "Finish reason: Duplicate item selected."
response_data['status'] = "stop"
response_data['message'] = self.stop_reason
break
if new_item['item_id'] == "ELG383":
if random.random() < 0.70:
self.stop_reason = "Finish reason: ELG383 is seleced repeatly."
response_data['status'] = "stop"
response_data['message'] = self.stop_reason
break
self.outfit_items.append(new_item)
# print(f" 成功添加单品: {new_item['category']} ({new_item['item_id']}). 当前搭配数量: {len(self.outfit_items)}")
response_data['status'] = "ok"
response_data['message'] = self.stop_reason
else:
print("⚠️ 未找到匹配单品,无法继续搭配。终止。")
self.stop_reason = "Finish reason: No matching item found in local database."
response_data['status'] = "stop"
response_data['message'] = self.stop_reason
break
if len(self.outfit_items) >= self.max_len: # 设置一个最大循环限制,防止无限循环
logger.info("🚨 达到最大搭配数量限制,强制终止。")
self.stop_reason = "Finish reason: Reached max outfit length."
response_data['status'] = "stop"
response_data['message'] = self.stop_reason
logger.info(f"request data {response_data}")
headers = {
'Accept': "*/*",
'Accept-Encoding': "gzip, deflate, br",
'User-Agent': "PostmanRuntime-ApipostRuntime/1.1.0",
'Connection': "keep-alive",
'Content-Type': "application/json"
}
url = f'{callback_url}/api/style/callback'
response = post_request(url=url, data=json.dumps(response_data), headers=headers)
logger.info(response.text)
return response_data
# def _save_outfit_results(self, user_id):
# """保存最终的 JSON 列表和图片到指定文件夹。"""
# if not self.outfit_items:
# raise ValueError("No outfit items to save.")
#
# # 1. 保存 JSON 文件
# results_list = [{'item_id': item['item_id'], 'category': item['category'], 'description': item['description'], 'gpt_description': item['gpt_description']} for item in self.outfit_items]
# results_list.append({'stop_reason': self.stop_reason})
#
# return upload_json_to_minio_sync(
# minio_client=minio_client,
# bucket_name=f"lanecarford",
# object_name=f"lc_stylist_agent_outfit_items/{user_id}/{uuid.uuid4()}.json",
# data=results_list
# )
def _upload_to_gcs(self, bucket_name: str, blob_name: str, mime_type, image_bytes) -> str:
"""同步方法:将文件上传到 GCS 并返回 GCS URI。"""
bucket = self.gcs_client.bucket(bucket_name)
blob = bucket.blob(blob_name)
blob.upload_from_string(
data=image_bytes,
content_type=mime_type
)
gcs_uri = f"gs://{bucket_name}/{blob_name}"
return gcs_uri
async def recommend_outfit(self, request_summary: str, stylist_name: str, start_outfit: List[Dict[str, str]] = [], num_outfits: int = 1):
"""
基于用户的对话历史和需求,推荐一套搭配。
Args:
request_summary: 用户的request
start_outfit: 可选的初始搭配列表,每个元素包含 'item_id''category'
"""
tasks = []
for _ in range(num_outfits):
agent = AsyncStylistAgent(**self.stylist_agent_kwages)
task = agent.run_styling_process(request_summary, stylist_name, start_outfit)
tasks.append(task)
print(f"--- Starting {num_outfits} concurrent outfit generation tasks. ---")
try:
results = await asyncio.gather(*tasks, return_exceptions=True)
successful_outfits = []
failed_outfits = []
for result in results:
if isinstance(result, Exception):
# 任务执行中发生异常
failed_outfits.append(f"Failed: {result}")
else:
# 任务成功result 是 run_styling_process 返回的图片路径
successful_outfits.append(result)
return {
"successful_outfits": successful_outfits,
"failed_outfits": failed_outfits
}
except Exception as e:
print(f"An unexpected error occurred during concurrent recommendation: {e}")
return {"error": str(e)}

View File

@@ -1,56 +0,0 @@
BASIC_PROMPT = """"""
WOMEN_BASIC_PROMPT = """You are a professional, friendly, and insightful AI women's styling assistant.
Your primary mission is to engage in a multi-turn conversation with the user to fully understand their dressing intent. You must adopt a professional yet approachable tone.
CONVERSATION GOALS:
1. **Occasion:** Determine the specific event (e.g., romantic dinner, summer wedding, business meeting).
2. **Style:** Pinpoint the desired aesthetic (e.g., classic elegance, edgy, minimalist, bohemian).
3. **Vibe/Details:** Gather any mood or specific constraints (e.g., needs to be comfortable, requires light colors, no bare shoulders).
4. **Item Preference:** Ask the user if they have any specific preferences for an item type or silhouette (e.g., preference for a dress, skirt, tailored pants, or a particular neckline/length).
GUIDANCE FOR RESPONSE GENERATION:
- After the user's initial request (e.g., "I want a chic outfit for dinner."), immediately reply with a friendly, targeted follow-up question to elicit the most crucial missing information (usually a combination of **Occasion** and **Style**).
- Be concise. Ask only 1 to 2 essential questions per turn.
- You must gather sufficient, clear intent before proceeding to actual clothing recommendations.
OUTPUT FORMAT INSTRUCTION:
- **DO NOT** use any Markdown formatting whatsoever (e.g., do not use asterisks (*), bold text (**), lists, or code blocks).
- **ONLY** output the plain text response spoken by the AI Assistant.
Example Follow-up (mimicking a conversational flow):
User: I want a chic outfit for dinner.
Your Response: Hey there! A chic dinner outfit, I love that! To give you the perfect recommendations, tell me: is this a romantic date, business dinner, or celebration with friends? And what's your go-to style vibe: classic elegance or something with more edge?"""
MEN_BASIC_PROMPT = """You are a professional, friendly, and insightful AI men's styling assistant.
Your primary mission is to engage in a multi-turn conversation with the user to fully understand their dressing intent. You must adopt a professional yet approachable tone.
CONVERSATION GOALS:
1. **Occasion:** Determine the specific event (e.g., romantic dinner, summer wedding, business meeting).
2. **Style:** Pinpoint the desired aesthetic (e.g., classic elegance, edgy, minimalist, bohemian).
3. **Vibe/Details:** Gather any mood or specific constraints (e.g., needs to be comfortable, requires light colors, no bare shoulders).
4. **Item Preference:** Ask the user if they have any specific preferences for an item type or silhouette (e.g., preference for a dress, skirt, tailored pants, or a particular neckline/length).
GUIDANCE FOR RESPONSE GENERATION:
- After the user's initial request (e.g., "I want a chic outfit for dinner."), immediately reply with a friendly, targeted follow-up question to elicit the most crucial missing information (usually a combination of **Occasion** and **Style**).
- Be concise. Ask only 1 to 2 essential questions per turn.
- You must gather sufficient, clear intent before proceeding to actual clothing recommendations.
OUTPUT FORMAT INSTRUCTION:
- **DO NOT** use any Markdown formatting whatsoever (e.g., do not use asterisks (*), bold text (**), lists, or code blocks).
- **ONLY** output the plain text response spoken by the AI Assistant.
Example Follow-up (mimicking a conversational flow):
User: I want a chic outfit for dinner.
Your Response: Hey there! A chic dinner outfit, I love that! To give you the perfect recommendations, tell me: is this a romantic date, business dinner, or celebration with friends? And what's your go-to style vibe: classic elegance or something with more edge?"""
SUMMARY_PROMPT = """Analyze the following chat history. Your task is to extract all user intentions, scenarios, style preferences, and constraints expressed during the conversation, and distill them into a concise, structured JSON object.
**YOUR OUTPUT MUST BE A JSON OBJECT ONLY, WITH NO SURROUNDING TEXT, MARKDOWN, OR EXPLANATION.**
JSON FIELD REQUIREMENTS:
- **occasion (string):** The specific event and purpose (e.g., "Romantic date dinner", "Summer outdoor wedding", "Casual Friday at office").
- **style (string):** The overall aesthetic description (e.g., "Classic elegance", "Modern minimalist", "Bohemian vibe", "Edgy and contemporary").
- **color_preference (string or list):** User's preferred or excluded colors/tones (e.g., "Light colors only", "Avoid deep shades", "['Cream', 'Pale Blue']", "No preference").
- **clothing_type (string):** User's preference for specific garment types, material, or silhouette (e.g., "Lightweight maxi dress", "Skirt with silk blouse", "Tailored wide-leg pants", "Floral print").
- **vibe_or_details (string):** Any other details, mood requirements, or specific constraints (e.g., "Needs to be comfortable and breathable", "Accent on accessories", "Must cover shoulders")."""

View File

@@ -1,160 +0,0 @@
from typing import List, Dict
import shutil
from PIL import Image, ImageDraw, ImageFont
# 9个 341x341 左右的单元格 (ALL_9_CELLS)
# 布局顺序: 从上到下,从左到右 (1 -> 9)
ALL_9_CELLS = [
# Top Row (Y=0, H=341)
(0, 0, 341, 341), # 1. Top-Left (341x341)
(341, 0, 341, 341), # 2. Top-Middle (341x341)
(682, 0, 342, 341), # 3. Top-Right (342x341)
# Middle Row (Y=341, H=341)
(0, 341, 341, 341), # 4. Mid-Left (341x341)
(341, 341, 341, 341), # 5. Center (341x341)
(682, 341, 342, 341), # 6. Mid-Right (342x341)
# Bottom Row (Y=682, H=342)
(0, 682, 341, 342), # 7. Bottom-Left (341x342)
(341, 682, 341, 342), # 8. Bottom-Middle (341x342)
(682, 682, 342, 342) # 9. Bottom-Right (342x342)
]
def merge_images_to_square(outfit_items: List[Dict[str, str]], max_len=9, output_path="temp.jpg", add_text=True):
"""
Loads up to 4 images from the given paths, resizes them while maintaining
aspect ratio, and merges them onto a 1024x1024 white background JPG.
The layout depends on the number of images:
1: Center the single image on the 1024x1024 canvas.
2: Place side-by-side, each scaled to fit a 512x1024 half.
3: Place in top-left (512x512), top-right (512x512), and bottom-left (512x512).
4: Place in all four 512x512 quadrants.
Args:
outfit_items: A list of item metadata (max length 9).
Returns:
The file path of the temporary merged JPG image.
"""
# Define the final canvas size
CANVAS_SIZE = 1024
# 1. Create the final white canvas
# Using 'RGB' mode for JPG output
canvas = Image.new('RGB', (CANVAS_SIZE, CANVAS_SIZE), 'white')
draw = ImageDraw.Draw(canvas)
font = ImageFont.load_default()
# 2. Define the quadrants/target areas (x, y, w, h)
# The positions are based on a 512x512 quadrant size
quadrants = {
1: [(0, 0, CANVAS_SIZE, CANVAS_SIZE)], # Single full-size placement
2: [(0, 0, 512, CANVAS_SIZE), (512, 0, 512, CANVAS_SIZE)], # Left, Right
3: [(0, 0, 512, 512), (512, 0, 512, 512), (0, 512, 512, 512)], # Top-Left, Top-Right, Bottom-Left
4: [(0, 0, 512, 512), (512, 0, 512, 512), (0, 512, 512, 512), (512, 512, 512, 512)], # All Four
5: ALL_9_CELLS[:5], # 布局前5个单元格 (1-5)
6: ALL_9_CELLS[:6], # 布局前6个单元格 (1-6)
7: ALL_9_CELLS[:7], # 布局前7个单元格 (1-7)
8: ALL_9_CELLS[:8], # 布局前8个单元格 (1-8)
9: ALL_9_CELLS[:9] # 布局全部9个单元格 (1-9)
}
# 3. Load and Filter Images
valid_images = []
image_paths = [item['image_path'] for item in outfit_items]
for path in image_paths:
try:
# We use Image.open() and convert to 'RGB' to handle potential transparency (RGBA)
# and ensure compatibility with the final 'RGB' canvas and JPG output.
img = Image.open(path).convert('RGB')
valid_images.append(img)
except Exception as e:
print(f"Error loading image {path}. Skipping: {e}")
num_images = len(valid_images)
if num_images == 0:
raise ValueError("No valid images were loaded.")
if num_images > max_len:
raise ValueError(f"Valid item number {num_images} exceed max limit {max_len}")
# Get the correct list of target areas based on the number of valid images
target_areas = quadrants.get(num_images, [])
# 4. Resize and Paste
for i, (img, item) in enumerate(zip(valid_images, outfit_items)):
item_id = item['item_id']
category = item['category']
if i >= len(target_areas):
# This should not happen if num_images <= 4
break
# Target area dimensions (x_start, y_start, width, height)
x_start, y_start, target_w, target_h = target_areas[i]
# Calculate new size while maintaining aspect ratio
original_w, original_h = img.size
# Calculate the ratio needed to fit within the target area
ratio_w = target_w / original_w
ratio_h = target_h / original_h
# Use the *smaller* of the two ratios to ensure the image fits entirely
resize_ratio = min(ratio_w, ratio_h)
# Calculate the new dimensions
new_w = int(original_w * resize_ratio)
new_h = int(original_h * resize_ratio)
# Resize the image. Image.Resampling.LANCZOS provides high-quality scaling.
# Pillow documentation recommends ANTIALIAS or BICUBIC for downscaling,
# but LANCZOS is a good general high-quality filter.
# Note: In Pillow versions > 9.0.0, Image.LANCZOS is now Image.Resampling.LANCZOS
resized_img = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
# Calculate the paste position to center the resized image within its target area
# Center X: (Target Width - New Width) / 2 + X Start
paste_x = (target_w - new_w) // 2 + x_start
# Center Y: (Target Height - New Height) / 2 + Y Start
# paste_y = (target_h - new_h) // 2 + y_start
TEXT_RESERVE_HEIGHT = 30
paste_y = (target_h - new_h - TEXT_RESERVE_HEIGHT) // 2 + y_start
paste_y = max(paste_y, y_start)
# Paste the resized image onto the canvas
canvas.paste(resized_img, (paste_x, paste_y))
full_text = f"ID: {item_id}, Category: {category}"
try:
# 推荐使用:计算文本的实际尺寸 (width, height)
bbox = draw.textbbox((0, 0), full_text, font=font)
text_w = bbox[2] - bbox[0]
text_h = bbox[3] - bbox[1]
except AttributeError:
# 兼容旧版本 Pillow
text_w, text_h = draw.textsize(full_text, font=font)
# 计算 X 轴起始位置:使其在目标区域 (target_w) 中居中
text_x_center = x_start + target_w // 2
text_x_start = text_x_center - text_w // 2
# 计算 Y 轴起始位置:将其放在目标区域的底部
# (目标区域的起始Y + 目标区域的高度 - 文本行的高度)
text_y_start = y_start + target_h - text_h - 5 # 减去 5 像素作为边距
# 3. 绘制合并后的文本
if add_text:
draw.text((text_x_start, text_y_start),
full_text,
fill='black',
font=font)
# Save as a high-quality JPG (quality=90 is a good balance)
# canvas.save(output_path, 'JPEG', quality=90)
return canvas

View File

@@ -1,320 +0,0 @@
import logging
from typing import List, Dict
from PIL import Image, ImageDraw, ImageFont
from app.server.utils.minio_client import oss_get_image, minio_client
from app.server.utils.minio_config import MINIO_LC_DATA_PATH
logger = logging.getLogger(__name__)
# 9个 341x341 左右的单元格 (ALL_9_CELLS)
# 布局顺序: 从上到下,从左到右 (1 -> 9)
ALL_9_CELLS = [
# Top Row (Y=0, H=341)
(0, 0, 341, 341), # 1. Top-Left (341x341)
(341, 0, 341, 341), # 2. Top-Middle (341x341)
(682, 0, 342, 341), # 3. Top-Right (342x341)
# Middle Row (Y=341, H=341)
(0, 341, 341, 341), # 4. Mid-Left (341x341)
(341, 341, 341, 341), # 5. Center (341x341)
(682, 341, 342, 341), # 6. Mid-Right (342x341)
# Bottom Row (Y=682, H=342)
(0, 682, 341, 342), # 7. Bottom-Left (341x342)
(341, 682, 341, 342), # 8. Bottom-Middle (341x342)
(682, 682, 342, 342) # 9. Bottom-Right (342x342)
]
def merge_images_to_square(outfit_items: List[Dict[str, str]], max_len=9, add_text=True):
"""
Loads up to 4 images from the given paths, resizes them while maintaining
aspect ratio, and merges them onto a 1024x1024 white background JPG.
The layout depends on the number of images:
1: Center the single image on the 1024x1024 canvas.
2: Place side-by-side, each scaled to fit a 512x1024 half.
3: Place in top-left (512x512), top-right (512x512), and bottom-left (512x512).
4: Place in all four 512x512 quadrants.
Args:
outfit_items: A list of item metadata (max length 9).
Returns:
The file path of the temporary merged JPG image.
"""
# Define the final canvas size
CANVAS_SIZE = 1024
# 定义每个 item 的外边距
MARGIN = 5 # 5像素外边距
# 1. Create the final white canvas
# Using 'RGB' mode for JPG output
canvas = Image.new('RGB', (CANVAS_SIZE, CANVAS_SIZE), 'white')
draw = ImageDraw.Draw(canvas)
font = ImageFont.load_default()
# 2. Define the quadrants/target areas (x, y, w, h)
# The positions are based on a 512x512 quadrant size
quadrants = {
1: [(0, 0, CANVAS_SIZE, CANVAS_SIZE)], # Single full-size placement
2: [(0, 0, 512, CANVAS_SIZE), (512, 0, 512, CANVAS_SIZE)], # Left, Right
3: [(0, 0, 512, 512), (512, 0, 512, 512), (0, 512, 512, 512)], # Top-Left, Top-Right, Bottom-Left
4: [(0, 0, 512, 512), (512, 0, 512, 512), (0, 512, 512, 512), (512, 512, 512, 512)], # All Four
5: ALL_9_CELLS[:5], # 布局前5个单元格 (1-5)
6: ALL_9_CELLS[:6], # 布局前6个单元格 (1-6)
7: ALL_9_CELLS[:7], # 布局前7个单元格 (1-7)
8: ALL_9_CELLS[:8], # 布局前8个单元格 (1-8)
9: ALL_9_CELLS[:9] # 布局全部9个单元格 (1-9)
}
# 3. Load and Filter Images
valid_images = []
image_paths = [item['image_path'] for item in outfit_items]
for path in image_paths:
try:
# We use Image.open() and convert to 'RGB' to handle potential transparency (RGBA)
# and ensure compatibility with the final 'RGB' canvas and JPG output.
img = oss_get_image(oss_client=minio_client, path=f"{MINIO_LC_DATA_PATH}/{path}", data_type="PIL").convert('RGB')
# img = Image.open(path).convert('RGB')
valid_images.append(img)
except Exception as e:
logger.error(f"Error loading image {path}. Skipping: {e}")
num_images = len(valid_images)
if num_images == 0:
raise ValueError("No valid images were loaded.")
if num_images > max_len:
raise ValueError(f"Valid item number {num_images} exceed max limit {max_len}")
# Get the correct list of target areas based on the number of valid images
target_areas = quadrants.get(num_images, [])
if not target_areas:
raise ValueError(f"No layout defined for {num_images} images.")
# 4. Resize and Paste
for i, (img, item) in enumerate(zip(valid_images, outfit_items)):
item_id = item['item_id']
category = item['category']
if i >= len(target_areas):
# This should not happen if num_images <= 4
break
# 原始目标区域 (x_start, y_start, width, height)
orig_x_start, orig_y_start, orig_w, orig_h = target_areas[i]
# 📢 应用边距:实际用于图像和文本的区域
# 新的起始点:向内移动 MARGIN
x_start = orig_x_start + MARGIN
y_start = orig_y_start + MARGIN
# 新的宽高:减去两倍的 MARGIN (左右/上下)
target_w = orig_w - 2 * MARGIN
target_h = orig_h - 2 * MARGIN
# --- 图像缩放与居中 ---
# Calculate new size while maintaining aspect ratio
original_w, original_h = img.size
# Calculate the ratio needed to fit within the *带边距的* 目标区域
ratio_w = target_w / original_w
ratio_h = target_h / original_h
# Use the *smaller* of the two ratios to ensure the image fits entirely
resize_ratio = min(ratio_w, ratio_h)
# Calculate the new dimensions
new_w = int(original_w * resize_ratio)
new_h = int(original_h * resize_ratio)
# Resize the image.
resized_img = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
# Calculate the paste position to center the resized image within its target area
# Center X: (Target Width - New Width) / 2 + X Start (带边距的 X_start)
paste_x = (target_w - new_w) // 2 + x_start
# 预留文本高度 ( TEXT_RESERVE_HEIGHT )
TEXT_RESERVE_HEIGHT = 30
# Center Y: (Target Height - New Height - 预留文本高度) / 2 + Y Start (带边距的 Y_start)
paste_y = (target_h - new_h - TEXT_RESERVE_HEIGHT) // 2 + y_start
# 确保图片顶部不超出目标区域的 Y_start
paste_y = max(paste_y, y_start)
# Paste the resized image onto the canvas
canvas.paste(resized_img, (paste_x, paste_y))
# --- 文本居中与定位 ---
full_text = f"ID: {item_id}, Category: {category}"
if add_text:
try:
# 推荐使用:计算文本的实际尺寸 (width, height)
bbox = draw.textbbox((0, 0), full_text, font=font)
text_w = bbox[2] - bbox[0]
text_h = bbox[3] - bbox[1]
except AttributeError:
# 兼容旧版本 Pillow
text_w, text_h = draw.textsize(full_text, font=font)
# 计算 X 轴起始位置:使其在 *带边距的目标区域* (target_w) 中居中
text_x_center = x_start + target_w // 2
text_x_start = text_x_center - text_w // 2
# 计算 Y 轴起始位置:将其放在 *带边距的目标区域* 的底部
# (带边距的起始Y + 带边距的高度 - 文本行的高度)
# 📢 在带边距的目标区域底部再减去 5 像素作为与底部的边距
text_y_start = y_start + target_h - text_h
draw.text((text_x_start, text_y_start),
full_text,
fill='black',
font=font)
# Save as a high-quality JPG (quality=90 is a good balance)
# canvas.save(output_path, 'JPEG', quality=90)
return canvas
# def merge_images_to_square(outfit_items: List[Dict[str, str]], max_len=9, add_text=True):
# """
# Loads up to 4 images from the given paths, resizes them while maintaining
# aspect ratio, and merges them onto a 1024x1024 white background JPG.
#
# The layout depends on the number of images:
# 1: Center the single image on the 1024x1024 canvas.
# 2: Place side-by-side, each scaled to fit a 512x1024 half.
# 3: Place in top-left (512x512), top-right (512x512), and bottom-left (512x512).
# 4: Place in all four 512x512 quadrants.
#
# Args:
# outfit_items: A list of item metadata (max length 9).
#
# Returns:
# The file path of the temporary merged JPG image.
# """
#
# # Define the final canvas size
# CANVAS_SIZE = 1024
#
# # 1. Create the final white canvas
# # Using 'RGB' mode for JPG output
# canvas = Image.new('RGB', (CANVAS_SIZE, CANVAS_SIZE), 'white')
# draw = ImageDraw.Draw(canvas)
# font = ImageFont.load_default()
#
# # 2. Define the quadrants/target areas (x, y, w, h)
# # The positions are based on a 512x512 quadrant size
# quadrants = {
# 1: [(0, 0, CANVAS_SIZE, CANVAS_SIZE)], # Single full-size placement
# 2: [(0, 0, 512, CANVAS_SIZE), (512, 0, 512, CANVAS_SIZE)], # Left, Right
# 3: [(0, 0, 512, 512), (512, 0, 512, 512), (0, 512, 512, 512)], # Top-Left, Top-Right, Bottom-Left
# 4: [(0, 0, 512, 512), (512, 0, 512, 512), (0, 512, 512, 512), (512, 512, 512, 512)], # All Four
# 5: ALL_9_CELLS[:5], # 布局前5个单元格 (1-5)
# 6: ALL_9_CELLS[:6], # 布局前6个单元格 (1-6)
# 7: ALL_9_CELLS[:7], # 布局前7个单元格 (1-7)
# 8: ALL_9_CELLS[:8], # 布局前8个单元格 (1-8)
# 9: ALL_9_CELLS[:9] # 布局全部9个单元格 (1-9)
# }
#
# # 3. Load and Filter Images
# valid_images = []
# image_paths = [item['image_path'] for item in outfit_items]
# for path in image_paths:
# try:
# # We use Image.open() and convert to 'RGB' to handle potential transparency (RGBA)
# # and ensure compatibility with the final 'RGB' canvas and JPG output.
# img = oss_get_image(oss_client=minio_client, path=f"{MINIO_LC_DATA_PATH}/{path}", data_type="PIL").convert('RGB')
# # img = Image.open(path).convert('RGB')
# valid_images.append(img)
# except Exception as e:
# logger.error(f"Error loading image {path}. Skipping: {e}")
#
# num_images = len(valid_images)
#
# if num_images == 0:
# raise ValueError("No valid images were loaded.")
#
# if num_images > max_len:
# raise ValueError(f"Valid item number {num_images} exceed max limit {max_len}")
#
# # Get the correct list of target areas based on the number of valid images
# target_areas = quadrants.get(num_images, [])
#
# # 4. Resize and Paste
# for i, (img, item) in enumerate(zip(valid_images, outfit_items)):
# item_id = item['item_id']
# category = item['category']
# if i >= len(target_areas):
# # This should not happen if num_images <= 4
# break
#
# # Target area dimensions (x_start, y_start, width, height)
# x_start, y_start, target_w, target_h = target_areas[i]
#
# # Calculate new size while maintaining aspect ratio
# original_w, original_h = img.size
#
# # Calculate the ratio needed to fit within the target area
# ratio_w = target_w / original_w
# ratio_h = target_h / original_h
#
# # Use the *smaller* of the two ratios to ensure the image fits entirely
# resize_ratio = min(ratio_w, ratio_h)
#
# # Calculate the new dimensions
# new_w = int(original_w * resize_ratio)
# new_h = int(original_h * resize_ratio)
#
# # Resize the image. Image.Resampling.LANCZOS provides high-quality scaling.
# # Pillow documentation recommends ANTIALIAS or BICUBIC for downscaling,
# # but LANCZOS is a good general high-quality filter.
# # Note: In Pillow versions > 9.0.0, Image.LANCZOS is now Image.Resampling.LANCZOS
# resized_img = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
#
# # Calculate the paste position to center the resized image within its target area
# # Center X: (Target Width - New Width) / 2 + X Start
# paste_x = (target_w - new_w) // 2 + x_start
# # Center Y: (Target Height - New Height) / 2 + Y Start
# # paste_y = (target_h - new_h) // 2 + y_start
#
# TEXT_RESERVE_HEIGHT = 30
# paste_y = (target_h - new_h - TEXT_RESERVE_HEIGHT) // 2 + y_start
# paste_y = max(paste_y, y_start)
#
# # Paste the resized image onto the canvas
# canvas.paste(resized_img, (paste_x, paste_y))
#
# full_text = f"ID: {item_id}, Category: {category}"
# try:
# # 推荐使用:计算文本的实际尺寸 (width, height)
# bbox = draw.textbbox((0, 0), full_text, font=font)
# text_w = bbox[2] - bbox[0]
# text_h = bbox[3] - bbox[1]
# except AttributeError:
# # 兼容旧版本 Pillow
# text_w, text_h = draw.textsize(full_text, font=font)
#
# # 计算 X 轴起始位置:使其在目标区域 (target_w) 中居中
# text_x_center = x_start + target_w // 2
# text_x_start = text_x_center - text_w // 2
#
# # 计算 Y 轴起始位置:将其放在目标区域的底部
# # (目标区域的起始Y + 目标区域的高度 - 文本行的高度)
# text_y_start = y_start + target_h - text_h - 5 # 减去 5 像素作为边距
#
# # 3. 绘制合并后的文本
# if add_text:
# draw.text((text_x_start, text_y_start),
# full_text,
# fill='black',
# font=font)
#
# # Save as a high-quality JPG (quality=90 is a good balance)
# # canvas.save(output_path, 'JPEG', quality=90)
#
# return canvas

View File

@@ -1,62 +0,0 @@
import os
from typing import List, Dict, Any, Optional
import torch
import chromadb
from transformers import CLIPProcessor, CLIPModel
from PIL import Image
class VectorDatabase():
def __init__(self, vector_db_dir: str, collection_name: str, embedding_model_name: str):
self.client = chromadb.PersistentClient(path=vector_db_dir)
self.collection = self.client.get_or_create_collection(name=collection_name)
self.device = "cuda" if torch.cuda.is_available() else "cpu"
self.model = CLIPModel.from_pretrained(embedding_model_name).to(self.device)
self.processor = CLIPProcessor.from_pretrained(embedding_model_name)
def get_clip_embedding(self, data: str | Image.Image, is_image: bool) -> List[float]:
"""生成图像或文本的 CLIP 嵌入,并进行 L2 归一化。"""
if is_image:
inputs = self.processor(images=data, return_tensors="pt").to(self.device)
with torch.no_grad():
features = self.model.get_image_features(**inputs)
else:
# 强制截断,解决序列长度问题
inputs = self.processor(
text=[data],
return_tensors="pt",
padding=True,
truncation=True
).to(self.device)
with torch.no_grad():
features = self.model.get_text_features(**inputs)
# L2 归一化
features = features / features.norm(p=2, dim=-1, keepdim=True)
return features.cpu().numpy().flatten().tolist()
def query_local_db(self, embedding: List[float], category: str, n_results: int = 3) -> List[Dict[str, Any]]:
"""
基于嵌入向量在本地数据库中查询相似单品。
实际应执行 ChromaDB 查询,并根据 category 进行过滤(metadatas)。
"""
# 实际应执行向量查询
# 为了演示流程,返回一个模拟结果
results = self.collection.query(
query_embeddings=[embedding],
n_results=n_results,
where={
"$and": [
{"category": category},
{"modality": "image"},
]
},
include=['documents', 'metadatas', 'distances']
)
return results

View File

@@ -1,7 +1,7 @@
import logging.config
import os
import litserve as ls
from app.core.config import DEBUG, settings
from app.config import settings
from app.server.ChatbotAgent.agent_server import LCAgent
from app.server.ChatbotAgent.chatbot_server import LCChatBot
from app.server.ReFace.server import ReFace
@@ -21,7 +21,7 @@ logging.config.dictConfig(LOGGER_CONFIG_DICT)
# STEP 2: START THE SERVER
if __name__ == "__main__":
logger.info(f"DEBUG -> :{DEBUG}")
logger.info(f"运行环境 1表示本地运行0表示生产环境运行 -> :{settings.LOCAL}")
logger.info(f"VECTOR_DB_DIR -> :{settings.VECTOR_DB_DIR}")
chat_boot_api = LCChatBot(enable_async=True, stream=True, api_path='/api/v1/chatbot')
agent_api = LCAgent(enable_async=True, api_path='/api/v1/agent')

View File

@@ -1,13 +0,0 @@
{
"type": "service_account",
"project_id": "aida-461108",
"private_key_id": "b4afaabebb84da24502b318a5fa175f1dc5c096a",
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCmk7LKrp8g9yD1\nWmF+mY2qHCEZ/5aIx6QRh0QoVPBL7Yi7ce009QxaE8fu8+QMgg8l3xMreXvgpt56\noFnVwpFusLjSdjgoFluElM2hYxXlO9q8cbBoU2nehOBLLJzGzkodT7xu/BOjNvKC\n//aTbjtJyk8Kj+ENa0/dPaUZs/PCtQqpAu8ag5nXrordVWfO0K25EjeYyoba35zk\nPp2fBi8KALZZI5Xfd2z9++K0K2mWWIMJic30idHvquj0WxlTRK2Pq8BmJXCQpJIi\nQ5E4egue16BfKjrF0Kxkpqd1RmdlEmaSKbbkZXe2z4jg0qknESRFOmRy8C3LnaB2\nHHJWLYM3AgMBAAECggEACUdroOQJSTTQSS/iWRhZ+S0yoC10nTnsZxg527qfiBs7\nOqB7WNqC+Ew8dDsca6CdvLuoaGDkCFJDTQwRn66u8JOM4sG4bxiPuzBEJBv45EQT\n8zCsuvhVNWgBdoPjAnq19jFdixvPnDqQrRYaY4FdxsaA5f24c57pW/xLGMYawLBt\n9RJZSuWmJdzKG1i5W8a8+4f/seNtuo2MtXU3mPJZPqRWPXTAZeaQPM/57ZQ+kzig\nOkAbQZNRmt1yPCjPCQD8vc8yCBMmjus/rlHXD/L7okYUlVZkob5I3FBrLl+ZyIXS\nqxEsBLBwRW3w8WbX+ZSVciQ72JK68W7LnOHSAENmAQKBgQDgBTCqp87KGLWVPb8w\nK+s1Sfh+nM3M4AlbLdcGBs1JCoddF6pAeY4wpf/ow1Tm4rqEuCYzMClPwxvkue+D\nY7lCQgy2FK3ahUzn8oVmvEPD/YPAojDSY3bH0lquHuS6oVKk834JUykButaAU3XY\nvUGNQuKdLKAeQRT8Q6um4m+EYQKBgQC+Wz6nYESKH6GiNnuFTH8hIkThPlbi4wua\nU1kGnPKe3ouE4zRLfPwQ6RRf1slQ/2hFLOatiTLYUgZWZQeBPSWp2EjYcOSzob+7\n11+KqeIRCD5DKxgf0cjJdihK9AM639OKlH2NvZ2507TksdeTPDzdaOMLwLWKexP5\nlYrdob0ulwKBgD81t7Gvf83Ogw4FSjkRa2Cx6ofvPrKcVIeBu7ZbnPkLG37M+qEO\nq2xWqorG8uHi/7YLL9wprr5u0yQKwuZT8SYc9PE7jIKoMjcQW0vNu2FF2zMzkIsM\nvatMU4Hl/awbcPJSMjH3YQ635WZ4Jjxtyl1NjhvDR7rBqmYzwe9o3QaBAoGANhPB\n1tbYYczepDCKIrI6o3US0FJfaJFLqInpDqHjoxJh3FyXbKKTEVLFwPxJsML+IjjB\nR6dkVGPo/P4yhZqTao7REvvvXMCksX5b3A6q9F+9IGPLtK5qNiFlDPYJPN59QC8z\nA+NMPZBRIW8MaP2B5Px5E8upRy/z2sGK86+RCP0CgYATGs75F97q+Zf8q+Pe3Nsb\ngqmhLoI3PZUSWgBcQgNF4nyCZceUrEl72wKO/NWLgxqQPtlra187ce69g7qARHLb\ntHq80nb0f7lil74B6+OlyNNO1htWA90fmGR2s16Mt0BwJRT+/EFuNqbJIUSLxKiW\nqlXBUbmHHzamo5DPYL8S/w==\n-----END PRIVATE KEY-----\n",
"client_email": "aida-239@aida-461108.iam.gserviceaccount.com",
"client_id": "103102077955178349079",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/aida-239%40aida-461108.iam.gserviceaccount.com",
"universe_domain": "googleapis.com"
}

View File

@@ -1,28 +1,65 @@
import asyncio
import json
import logging
import uuid
from enum import Enum
from typing import List, Optional
from pydantic import Field
import time
import litserve as ls
from pydantic import BaseModel
from app.core.config import settings
from app.config import settings
from app.server.ChatbotAgent.core.data_structure import Message, Role
from app.server.ChatbotAgent.core.llm_interface import AsyncGeminiLLM
from app.server.ChatbotAgent.core.redis_manager import RedisManager
from app.server.ChatbotAgent.core.stylist_agent_server import AsyncStylistAgent
from app.server.ChatbotAgent.core.system_prompt import SUMMARY_PROMPT
from app.server.ChatbotAgent.core.prompt import SUMMARY_PROMPT
from app.server.ChatbotAgent.core.vector_database import VectorDatabase
from app.server.utils.request_post import post_request
logger = logging.getLogger(__name__)
class OccasionEnum(str, Enum):
CASUAL = "Casual"
FORMAL = "Formal"
ACTIVEWEAR = "Activewear"
RESORT = "Resort"
EVENING = "Evening"
OUTDOOR = "Outdoor"
BUSINESS_WORKWEAR = "Business / workwear"
COCKTAIL_SEMI_FORMAL = "Cocktail / Semi-Formal"
BLACK_TIE_WHITE_TIE = "Black Tie / White Tie"
BRIDAL_WEDDING = "Bridal / Wedding"
FESTIVAL_CONCERT = "Festival / Concert"
PARTY_CLUBBING = "Party / Clubbing"
TRAVEL_TRANSIT = "Travel / Transit"
ATHLEISURE = "Athleisure"
BEACH_SWIM = "Beach / Swim"
SKI_SNOW_MOUNTAIN = "Ski / Snow / Mountain"
GARDEN_PARTY_DAYTIME = "Garden Party / Daytime Event"
class StylistResponse(BaseModel):
occasions: List[OccasionEnum] = Field(
description="A list of **applicable** occasions that are most strongly implied or explicitly requested by the user's conversation history. The first occasion in this list is the most applicable one. These occasions are used later in item retrieval for filtering and must strictly match the predefined OccasionEnum list."
)
summary: str = Field(
description="A detailed summary of the user's styling requirements, preferences, constraints, and specific item requests."
)
class AgentRequestModel(BaseModel):
user_id: str
session_id: str
num_outfits: int
stylist_path: str
batch_sources: List[str]
callback_url: str
gender: str
max_len: int = 9
occasions: Optional[list] = None
request_summary: Optional[str] = None
class LCAgent(ls.LitAPI):
@@ -41,7 +78,6 @@ class LCAgent(ls.LitAPI):
)
self.stylist_agent_kwages = {
'local_db': self.vector_db,
'max_len': 9,
'gemini_model_name': settings.LLM_MODEL_NAME
}
@@ -73,40 +109,79 @@ class LCAgent(ls.LitAPI):
async def background_run(self, request: AgentRequestModel, outfit_ids):
# 1. 根据用户ID查询对话历史总结对话内容
request_summary = await self.get_conversation_summary(request.session_id)
logger.info(f"request_summary: {request_summary}")
if request.request_summary and request.occasions:
request_summary = request.request_summary
occasions = request.occasions
else:
request_summary, occasions = await self.get_conversation_summary(request.session_id)
logger.info(f"request_summary: {request_summary},occasions : {occasions}")
# 2.根据对话总结推荐搭配
recommendation_results = await self.recommend_outfit(request_summary=request_summary,
stylist_name=request.stylist_path,
start_outfit=[],
num_outfits=request.num_outfits,
user_id=request.user_id,
gender=request.gender,
callback_url=request.callback_url,
max_len=request.max_len,
outfit_ids=outfit_ids)
recommendation_results = await self.recommend_outfit(
request_summary=request_summary,
occasions=occasions,
stylist_name=request.stylist_path,
start_outfit=[],
batch_sources=request.batch_sources,
num_outfits=request.num_outfits,
user_id=request.user_id,
gender=request.gender,
callback_url=request.callback_url,
outfit_ids=outfit_ids
)
logger.info("--- Final Recommendation Results ---")
for i, path in enumerate(recommendation_results.get("successful_outfits", [])):
logger.info(f"✅ Outfit {i + 1} saved to: {path}")
for failed in recommendation_results.get("failed_outfits", []):
logger.error(f"{failed}")
async def get_conversation_summary(self, session_id: str) -> str:
async def get_conversation_summary(self, session_id: str) -> dict:
"""
分析用户的完整会话历史,并打包成一个简洁的需求总结
这个总结可以直接作为输入 Prompt 传递给 Stylist Agent。`
分析用户的完整会话历史,并返回结构化的需求数据
Returns:
occasions: List[str], # 用于 Vector DB 筛选
summary: str # 用于 recommendation 的输入
"""
history_messages = self.redis.get_history(session_id)
input_message = "\n".join([f"{msg.role.value}: {msg.content}" for msg in history_messages])
# 临时调用 LLM 或使用本地逻辑生成总结
summary = await self.llm.generate_response(history=[Message(role=Role.USER, content=input_message)],
system_prompt=SUMMARY_PROMPT)
return summary
if not history_messages:
# 处理无历史记录的情况
return "User has no history provided.", []
async def recommend_outfit(self, request_summary: str, stylist_name: str, start_outfit=None, num_outfits: int = 1,
user_id: str = "test", gender: str = "male", callback_url: str = None, max_len: int = 9, outfit_ids=None):
input_message = "\n".join([f"{msg.role.value}: {msg.content}" for msg in history_messages])
json_schema = StylistResponse.model_json_schema()
raw_response = await self.llm.generate_response(
history=[Message(role=Role.USER, content=input_message)],
system_prompt=SUMMARY_PROMPT,
json_schema=json_schema
)
try:
# 验证并解析 JSON
parsed_result = StylistResponse.model_validate_json(raw_response)
print(f"Occasions: {[occ.value for occ in parsed_result.occasions]}")
print(f"Summary: {parsed_result.summary}") # 这是一个 string
except Exception as e:
logger.error(f"Schema validation failed: {e}")
return str(parsed_result.summary), [occ.value for occ in parsed_result.occasions]
async def recommend_outfit(
self,
request_summary: str,
occasions: List[str],
stylist_name: str,
start_outfit: List = [],
batch_sources: List[str] = [],
num_outfits: int = 1,
user_id: str = "test",
gender: str = "male",
callback_url: str = None,
outfit_ids=None
):
"""
基于用户的对话历史和需求,推荐一套搭配。
@@ -116,26 +191,54 @@ class LCAgent(ls.LitAPI):
"""
if outfit_ids is None:
outfit_ids = []
if start_outfit is None:
start_outfit = []
tasks = []
task_map = {}
stylist_agent_kwages = self.stylist_agent_kwages.copy()
tasks_mapping = {}
# 历史1快3慢推荐逻辑
# if num_outfits == 1:
# tasks_mapping[outfit_ids[0]] = "fast"
# else:
# for i in range(num_outfits):
# if i == 0:
# tasks_mapping[outfit_ids[i]] = "fast"
# else:
# tasks_mapping[outfit_ids[i]] = "slow"
for i in range(num_outfits):
stylist_agent_kwages['outfit_id'] = outfit_ids[i]
stylist_agent_kwages['max_len'] = max_len
tasks_mapping[outfit_ids[i]] = "fast"
for k, v in tasks_mapping.items():
logger.info(f"fast request outfit_id is : {k}")
# 通过请求数量判断 num == 1 单个outfit刷新
stylist_agent_kwages['outfit_id'] = k
stylist_agent_kwages['stylist_name'] = stylist_name
stylist_agent_kwages['gender'] = gender
stylist_agent_kwages['callback_url'] = callback_url
agent = AsyncStylistAgent(**stylist_agent_kwages)
task = agent.run_styling_process(
request_summary=request_summary,
stylist_path=stylist_name,
start_outfit=start_outfit,
user_id=user_id,
callback_url=callback_url,
gender=gender,
)
if v == "fast":
task = agent.run_quick_batch_styling(
request_summary=request_summary,
occasions=occasions,
start_outfit=start_outfit,
batch_sources=batch_sources,
user_id=user_id,
callback_url=callback_url,
)
else:
task = agent.run_iterative_styling(
request_summary=request_summary,
occasions=occasions,
start_outfit=start_outfit,
batch_sources=batch_sources,
user_id=user_id,
callback_url=callback_url,
)
tasks.append(task)
task_map[task] = {"outfit_id": outfit_ids[i], "retries": 0}
task_map[task] = {"outfit_id": k, "retries": 0}
logger.info(f"--- Starting {num_outfits} concurrent outfit generation tasks. ---")
# 2. 任务执行与重试循环
@@ -146,7 +249,8 @@ class LCAgent(ls.LitAPI):
retry_limit = 1 # 允许重试一次
while tasks_to_run:
try:
results = await asyncio.gather(*tasks, return_exceptions=True)
results = await asyncio.gather(*tasks_to_run, return_exceptions=True)
next_tasks_to_run = []
for task, result in zip(tasks_to_run, results):
task_info = task_map[task]
@@ -158,20 +262,42 @@ class LCAgent(ls.LitAPI):
logger.error(f"Outfit {outfit_id} failed with error: {result}. Current retries: {current_retries}.")
if current_retries < retry_limit:
# 尚未达到重试上限,准备重试
# 尚未达到重试上限,准备重试 并通知前端
object_data = {
'outfit_id': outfit_id,
"status": "retrying",
"path": "",
}
post_request(url=f'{callback_url}/api/style/callback', data=json.dumps(object_data))
task_info["retries"] += 1
logger.info(f"--- Retrying outfit {outfit_id} (Attempt {current_retries + 1}/{retry_limit}). ---")
# 重新创建任务 (可能需要短暂延迟,例如 time.sleep(1),但在此异步环境中,我们会通过重新创建 agent/task 来实现)
stylist_agent_kwages['outfit_id'] = outfit_id
stylist_agent_kwages['stylist_name'] = stylist_name
stylist_agent_kwages['gender'] = gender
stylist_agent_kwages['callback_url'] = callback_url
agent = AsyncStylistAgent(**stylist_agent_kwages)
new_task = agent.run_styling_process(
request_summary=request_summary,
stylist_path=stylist_name,
start_outfit=start_outfit,
user_id=user_id,
callback_url=callback_url
)
if tasks_mapping[outfit_id] == "fast":
new_task = agent.run_quick_batch_styling(
request_summary=request_summary,
occasions=occasions,
start_outfit=start_outfit,
batch_sources=batch_sources,
user_id=user_id,
callback_url=callback_url,
)
else:
new_task = agent.run_iterative_styling(
request_summary=request_summary,
occasions=occasions,
start_outfit=start_outfit,
batch_sources=batch_sources,
user_id=user_id,
callback_url=callback_url
)
# 将新任务添加到下一轮运行列表,并更新任务映射
next_tasks_to_run.append(new_task)
task_map[new_task] = task_info # 新任务继承旧任务的重试信息
@@ -179,8 +305,15 @@ class LCAgent(ls.LitAPI):
# 清理旧任务(可选,但推荐,以防内存泄漏或混淆)
del task_map[task]
else:
# 达到重试上限,最终失败
# 达到重试上限,最终失败 并通知前端
object_data = {
'outfit_id': outfit_id,
"status": "retry_failed",
"path": "",
}
response = post_request(url=f'{callback_url}/api/style/callback', data=json.dumps(object_data))
failed_outfits.append(f"Outfit {outfit_id} ultimately failed after {retry_limit} retries: {result}")
logger.info(f"request data {json.dumps(object_data, ensure_ascii=False, indent=2)} | JAVA callback info -> status:{response.status_code} | message:{response.text}")
del task_map[task]
else:
@@ -209,3 +342,68 @@ class LCAgent(ls.LitAPI):
"failed_outfits": failed_outfits,
"error": ""
}
if __name__ == "__main__":
async def test():
# 1. 准备测试实例
agent_api = LCAgent()
agent_api.setup(device='cpu')
# 2. 准备请求数据
import json
stylist_agent_kwages = agent_api.stylist_agent_kwages.copy()
if settings.LOCAL == 1:
request_file_path = "./data/2025_q4/request_test.json"
else:
request_file_path = "/mnt/data/workspace/Code/lc_stylist_agent/data/2025_q4/request_test.json"
with open(request_file_path, "r") as f:
request_data = json.load(f)
tasks_with_metadata = []
for test_content in request_data[6:7]:
occasions = test_content['occasions']
request_summary = test_content['request_summary']
for stylist_name in ["crystal", "mini", "vera", "edi"]:
stylist_agent_kwages['outfit_id'] = test_content['test_case_id'] + '_' + test_content['occasions'][0].replace('/', '_') + f"_{stylist_name}"
stylist_agent_kwages['stylist_name'] = stylist_name
stylist_agent_kwages['gender'] = "female"
stylist_agent_kwages['callback_url'] = ""
agent = AsyncStylistAgent(**stylist_agent_kwages)
coro = agent.run_quick_batch_styling(
request_summary=request_summary,
occasions=occasions,
start_outfit=[],
batch_sources=["2025_q4"],
user_id=test_content['test_case_id'],
callback_url="http://18.167.251.121:10095",
)
# 记录任务开始前的单调时间,并将元数据添加到列表中
description = f"Batch mode - Case {test_content['test_case_id']} - Stylist {stylist_name}"
tasks_with_metadata.append((coro, description))
tasks_only = [coro for coro, _ in tasks_with_metadata]
print(f"--- Launching {len(tasks_only)} concurrent styling tasks... ---")
results = await asyncio.gather(*tasks_only, return_exceptions=True)
time_samples = []
for i, result in enumerate(results):
coro, description = tasks_with_metadata[i]
if isinstance(result, Exception):
print(f"❌ 任务失败: {type(result).__name__} - {str(result)}")
continue
else:
response, duration = result
time_samples.append(duration)
print(f"✅ 任务成功 ({description}) [Time: {duration:.2f}s].")
print(f"Average time consumption is {sum(time_samples) / len(time_samples)}")
try:
# 使用 asyncio.run() 来执行顶层异步函数
asyncio.run(test())
except Exception as e:
logger.error(f"Test failed due to an unexpected error: {e}")

View File

@@ -1,17 +1,19 @@
import logging
import sys
import litserve as ls
from typing import AsyncGenerator
from google import genai
from pydantic import BaseModel
from app.core.config import settings
from sympy.core.evalf import rnd
from app.config import settings
from google.genai import types
from app.server.ChatbotAgent.core.data_structure import Message, Role
from app.server.ChatbotAgent.core.llm_interface import AsyncGeminiLLM
from app.server.ChatbotAgent.core.redis_manager import RedisManager
from app.server.ChatbotAgent.core.system_prompt import MEN_BASIC_PROMPT, WOMEN_BASIC_PROMPT
from app.server.ChatbotAgent.core.vector_database import VectorDatabase
from app.server.ChatbotAgent.core.prompt import BASIC_PROMPT
logger = logging.getLogger(__name__)
@@ -25,26 +27,12 @@ class PredictRequest(BaseModel):
class LCChatBot(ls.LitAPI):
def setup(self, device):
# self.llm = AsyncGeminiLLM(model_name=settings.LLM_MODEL_NAME)
self.redis = RedisManager(
host=settings.REDIS_HOST,
port=settings.REDIS_PORT,
db=settings.REDIS_DB,
key_prefix=settings.REDIS_HISTORY_KEY_PREFIX
)
# self.vector_db = VectorDatabase(
# vector_db_dir=settings.VECTOR_DB_DIR,
# collection_name=settings.COLLECTION_NAME,
# embedding_model_name=settings.EMBEDDING_MODEL_NAME
# )
# self.stylist_agent_kwages = {
# 'local_db': self.vector_db,
# 'max_len': 5,
# 'outfits_root': settings.OUTFIT_OUTPUT_DIR,
# 'image_dir': settings.IMAGE_DIR,
# 'stylist_guide_dir': settings.STYLIST_GUIDE_DIR,
# 'gemini_model_name': settings.LLM_MODEL_NAME
# }
self.gemini_client = genai.Client(
vertexai=True, project='aida-461108', location='us-central1'
)
@@ -61,10 +49,18 @@ class LCChatBot(ls.LitAPI):
user_msg = Message(role=Role.USER, content=user_message)
chat_history = self.redis.get_history(session_id)
chat_history.append(user_msg)
turn_count = sum(1 for msg in chat_history if msg.role == Role.USER)
if request.gender == 'male':
BASIC_PROMPT = MEN_BASIC_PROMPT
system_instruction = BASIC_PROMPT.format(gender='men')
else:
BASIC_PROMPT = WOMEN_BASIC_PROMPT
system_instruction = BASIC_PROMPT.format(gender='women')
if turn_count >= 3:
system_instruction += "\n\nCRITICAL: This is the final turn. Do not ask questions. Provide a brief summary and use the mandatory closing phrase."
else:
system_instruction += f"\n\nCURRENT STATE: This is turn {turn_count} of 3. Keep gathering info efficiently or provide a brief summary and use the mandatory closing phrase if you have sufficient information."
contents = []
@@ -80,7 +76,7 @@ class LCChatBot(ls.LitAPI):
model='gemini-2.5-flash',
contents=contents,
config=types.GenerateContentConfig(
system_instruction=BASIC_PROMPT,
system_instruction=system_instruction,
# temperature=0.3,
)
)
@@ -108,3 +104,108 @@ class LCChatBot(ls.LitAPI):
# The for-loop must have async keyword here since output is an AsyncGenerator
async for out in output:
yield {"output": out}
if __name__ == "__main__":
# sys.stdout = open('permanent.log', 'w', encoding='utf-8')
import asyncio
async def run_simple_test(text):
"""
一个简单的异步测试用例,用于测试 LCChatBot 的流式输出。
"""
print("\n" + "=" * 50)
# print("--- 🔬 开始 LCChatBot 简单流式测试 ---")
# 1. 初始化 LitAPI 和其依赖
chatbot_api = LCChatBot()
chatbot_api.setup(device="cpu")
# print("✅ Setup complete. Mock services initialized.")
# 2. 构造请求数据
request_data = PredictRequest(
user_id="simple_user",
session_id="simple_session",
user_message=text,
gender="female"
)
chatbot_api.redis.clear_history(request_data.session_id)
print(f"user: \n {request_data.user_message}")
# 3. 调用 predict 方法并处理流
response_generator = chatbot_api.predict(request_data)
print("agent:")
# 4. 异步迭代生成器,实时打印输出
async for chunk in response_generator:
print(chunk, end="", flush=True)
# text_list = [
# '我要去参加好朋友的婚礼,你能帮我挑一套衣服吗?',
# 'I need something to wear for a big presentation at work tomorrow. I want to look powerful but still approachable.',
# 'Who do you think is the best world leader right now?',
# 'Im going on a trip to Paris next week and need some outfits.',
# 'Help me find a cool outfit for a rock concert. I hate wearing dresses.',
# 'I want to look very cool, 或者是那种很有个性的风格 for a gallery opening.',
# "I'm going to a gala. Please list 5 different dress styles for me and use bold text for the names.",
# "I'm feeling really sad today and just want an outfit that matches my mood."
# ]
# for text in text_list:
# asyncio.run(run_simple_test(text))
async def run_interactive_test():
"""
手动输入测试:模拟真实用户与 AI 的多轮对话
"""
# 1. 初始化
chatbot_api = LCChatBot()
chatbot_api.setup(device="cpu")
session_id = "manual_test_session"
user_id = "test_user"
print("--- 👗 欢迎进入 AI 造型师测试 (输入 'quit' 退出) ---")
# 清空旧的测试记录,开始新会话
chatbot_api.redis.clear_history(session_id)
print("✅ 已清空历史记录,开始新会话。")
while True:
# 2. 获取手动输入
user_input = input("\nUser (你): ")
if user_input.lower() in ['quit', 'exit', '退出']:
break
# 3. 构造请求
request_data = PredictRequest(
user_id=user_id,
session_id=session_id,
user_message=user_input,
gender="female" # 或者根据需要修改
)
print("Agent (AI): ", end="")
# 4. 调用并流式打印
# 注意:这里的 predict 内部现在会计算 turn_count
response_generator = chatbot_api.predict(request_data)
full_response = ""
async for chunk in response_generator:
print(chunk, end="", flush=True)
full_response += chunk
# 将 AI 的回复存入 Redis (如果你的 predict 函数里没存的话)
# chatbot_api.redis.add_message(session_id, Role.ASSISTANT, full_response)
print() # 换行
asyncio.run(run_interactive_test())

View File

@@ -32,7 +32,7 @@ class AsyncGeminiLLM(AsyncLLMInterface):
except Exception as e:
raise type(e)(f"Failed to initialize Gemini Client. Check if GEMINI_API_KEY is set. Original error: {e}")
async def generate_response(self, history: List[Message], system_prompt: str) -> str:
async def generate_response(self, history: List[Message], system_prompt: str, json_schema=None) -> str:
contents = []
for msg in history:
@@ -44,14 +44,27 @@ class AsyncGeminiLLM(AsyncLLMInterface):
contents.append(content)
try:
response = await self.gemini_client.aio.models.generate_content(
model=self.model_name,
contents=contents,
config=types.GenerateContentConfig(
system_instruction=system_prompt,
# temperature=0.3,
if json_schema:
response = await self.gemini_client.aio.models.generate_content(
model=self.model_name,
contents=contents,
config=types.GenerateContentConfig(
system_instruction=system_prompt,
response_mime_type="application/json",
response_schema=json_schema
)
)
)
return response.text
return response.text
else:
response = await self.gemini_client.aio.models.generate_content(
model=self.model_name,
contents=contents,
config=types.GenerateContentConfig(
system_instruction=system_prompt,
# temperature=0.3,
)
)
return response.text
except Exception as e:
raise type(e)(f"Gemini API call failed: {e}")

View File

@@ -0,0 +1,217 @@
BASIC_PROMPT = """
You are a professional, friendly, and insightful AI {gender}'s styling assistant. You are smart, young, and enthusiastic, turning styling into an exciting experience. Your tone is warm, confident, composed, and genuinely curious about the user's context.
CONVERSATION GOALS:
1. **Occasion:** Determine the specific event (e.g., romantic dinner, summer wedding).
2. **Style:** Pinpoint the desired aesthetic (e.g., minimalist, bohemian).
3. **Vibe/Details:** Gather mood or constraints (e.g., comfort, specific colors).
4. **Item Preference:** Identify preferences for silhouettes or specific items.
CONVERSATION FLOW:
- You have a maximum of 3 turns (one turn = one user message and one assistant response) to gather information.
- On the 3rd turn, or *once you have sufficient info*, do NOT ask further questions.
- MANDATORY CLOSING: You must end your final response using the FINAL TURN CLOSING RULE below.
FINAL TURN CLOSING RULE:
- Once requirements are gathered, do NOT ask any more questions.
- End your final response with a creative and friendly variation of the closing phrase.
- REQUIRED COMPONENTS: Every variation MUST mention the "**Generate button**" (this phrase must be bounded using double asterisks).
PERSONALITY AND COMMUNICATION RULES:
- Always reply in English only. If the user speaks another language, state in English that you cannot speak that language and steer back to styling.
- Keep responses extremely SHORT (maximum 2 sentences).
- Use clear, modern, and vivid language. Avoid clichés or corporate jargon.
- Be positive and encouraging, never patronizing.
- When uncertain, state assumptions and invite correction.
- Gather sufficient intent before providing recommendations. Ask only 1 to 2 essential questions per turn.
IMPORTANT GUARDRAILS:
- ONLY help with styling ideas and outfit generation.
- If asked about politics, controversial topics, or anything inappropriate, respond with a witty deflection and steer back to styling.
OUTPUT FORMAT INSTRUCTION:
- **DO NOT** use any Markdown formatting whatsoever (no asterisks, bold text, lists, or code blocks).
- **ONLY** output the plain text response spoken by the AI Assistant.
EXAMPLE DIALOGUES:
User: 我想找件衣服参加婚礼。 Response: I would love to help you find the perfect wedding guest look if you can tell me the dress code or the venue vibe.
User: I need a chic outfit for dinner. Response: That sounds like a fabulous evening! Is this a romantic date or a casual night out with friends, and do you prefer a sleek dress or tailored separates?
User: What do you think about the current election? Response: I prefer to stick to the polls of fashion rather than politics! Tell me, what kind of style aesthetic are we aiming for today?
"""
SUMMARY_PROMPT = """
You are an expert fashion request analyzer. Analyze the conversation history provided by the user.
Your task is to:
1. Identify the most appropriate occasions from the allowed list based on the user's intent.
2. Write a detailed summary string that captures the user's style preferences, specific item requests, disliked items, body concerns, and color preferences. This summary will be used by a stylist to recommend outfits.
Extract this information accurately from the chat history.
"""
from app.taxonomy import FASHION_TAXONOMY, IGNORE_SUBCATEGORY
GENERAL_RULES = """## General Rule
### **Color:**
* Limit your outfit in two-three color shades max.
* Pair one accent color item with neutral colors
* Don't show neon color items
* Pair colors in warm tone/cool tone
* Denim and black and white is a neutral color matches with every other color.
### **Layering:**
* Style at least 2-3 layers
* Show white t-shirt, shirt hem under pull over cardigan and hoodies; Don't show hem in layerinmg except sweaters, cardigan and hoodies (if capable)
* Style hoodie under blazer/ bomber jacker, leathe jacket/ trechcoat to create a street style casual look (if capable)
"""
GENERAL_RULES_DICT = {
'clothing': """### **Body items mix & match:**
* Loose/oversized pair with fitted bottoms. Wide bottoms pair with fitted top.
* With loose clothing, use a belt or a "French tuck" (tucking just the front of the shirt) to define waistline
* Pair casual items with dressy items. Wear a blazer with a graphic tee, or a silk skirt with a chunky knit sweater.
* Always add an outer on a top and bottom to finish the outfit, mix and match in at least 2 layers.
* Mix menswear-inspired pieces (blazers, loafers, trousers) with feminine pieces (lace, silk, florals) for a dynamic look.
* one print per outfit""",
'bags': """### **Bag matching:**
* Large totes are for day/work; clutches and mini bags are for evening. Never bring a giant work tote to a cocktail event.
* A smooth satin dress pair with a leather, a beaded or velvet bag ; Heavy wool pair with a sleek leather bag
* Match bag and shoes in same color or match bag and coat in same color
* Straw and canvas bags for spring/summer. Velvet and faux fur bags for autumn/winter. Leather works year-round.""",
'shoes': """### **Shoes matching:**
* Dress with heels ; Jeans and trousers with heels or loafers
* Match shoes with skin tone or the bottom piece
* Chunky winter layers pair with a boot, platform shoe (boot, platform).
* Crop pants pair with ankle boots, don't show skin ; Don't show calf length pants ; Always suggest wide leg floor length pants
* Wide-leg trousers pair with a heel ; loafer ; heel boots ; or a sneaker.
* No socks with sandals.""",
'accessories': """### **Accessory matching:**
* Acceesory wear in a set of same metal colour (gold earrings with gold belt buckle)
* Wear chokers or short necklaces with V-necks; wear long pendants with crew necks or turtlenecks.
* Wear a belt in tuck-in outfits
* Style silk scarf for the neck; tie it on your bag handle, around your wrist, or use it as a belt for a pop of color.
* No watches, No hats, No sunglasses""",
}
all_items_template = f"""
# ROLE: Professional Fashion Stylist Agent
You are a professional fashion stylist for {{gender}}. Your goal is to generate a Complete, Head-to-Toe Outfit in a Single Batch.
## 1. INTEGRATION LOGIC (How to Think)
1. **Analyze User Request**: Identify the target occasion, mood, and specific color/item preferences from the [Request Summary].
2. **Apply Stylist Filter**: Use the [Stylist Guide] as the primary aesthetic filter. If the user request and Stylist Guide conflict, the user request takes precedence.
3. **Synthesis with Material**: Incorporate the [Material Hint] into your item descriptions to ensure the outfit is contextually appropriate for the {{occasion}} and better performance in item retrieval.
4. **Final Rule Check**: Validate the outfit against [General Rules] and ensure no items from the [Exclusion List] are included.
## 2. RULE HIERARCHY
1. **USER REQUEST**: (Highest Priority)
1. **STYLIST CORE RULES**: (Secondary Priority)
2. **GENERAL COORDINATION RULES**: (Standard Professional Logic)
---
## 3. CONTEXT & GUIDANCE
### [User Request Summary]
{{request_summary}}
### [Target Occasion]
{{occasion}}
### [Stylist Guide]
{{stylist_guide}}
### [Material Hint]
{{material_hint}}
### [General Rules]
{{general_rule}}
---
## 4. CONSTRAINTS & WORKFLOW
1. **Selection Pool (Mandatory)**: You MUST only choose items from the following **[Allowed Subcategories]**. Do not recommend any subcategory outside this list.
**ALLOWED**: {{allowed_subcategories}}
2. **Exclusion List**: Strictly FORBIDDEN to recommend: {",".join(IGNORE_SUBCATEGORY)}.
3. **Mandatory Composition**:
- **Clothing**: Follow the stylist and general rules for clothing selection.
- **Shoes**: Exactly one (1) pair is mandatory.
- **Bags & Accessories**: These are **CONDITIONAL**.
- If NO subcategories related to Bags or Accessories appear in the **[ALLOWED]** list, DO NOT recommend them.
- Simply omit these categories from your output if they are not in the allowed pool for the current {{occasion}}.
- Do not invent subcategories that are not explicitly listed in the [ALLOWED] section.
4. **Uniqueness**: Each **subcategory** (e.g., 'earrings', 't-shirts') can appear **EXACTLY ONCE**. No repeats.
5. **Visual Balance**: Explicitly describe the fit (Loose vs. Fitted) to maintain silhouette balance according to General Rules.
6. Max item number is {{max_len}}.
---
## 5. OUTPUT REQUIREMENT
- **Format**: Valid JSON matching the provided API schema (`recommended_items` list).
- **Description Quality**: Each 'description' field must be a precise string for vector search: **subcategory + Color + Fit/Silhouette + Material/Detail.**
- **Reasoning**: In the 'reason' field, explain how the outfit satisfies the User's Request while maintaining the Stylist's DNA.
Generate the complete outfit list now
"""
def build_iterative_schema(current_category):
schema = {
"type": "object",
"properties": {
"action": {"type": "string", "enum": ["recommend_item", "stop"]},
"subcategory": {
"type": "string",
"description": "The subcategory this single item. Only present if action is 'recommend_item'",
"enum": FASHION_TAXONOMY[current_category]
},
"description": {
"type": "string",
"description": "an **extremely detailed and precise** description of the item. This description is used for **high-accuracy vector search** in the database. It should include Color, Fit/Silhouette, Material/Detail, Role in the Outfit."
},
"reason": {"type": "string", "description": "The reason for the current action. Required if action is 'stop' (to summarize the final outfit)."}
},
"required": ["action", "subcategory", "description", "reason"]
}
return schema
def build_batch_schema(specified_category: str = "", subcategory_list: list = []):
assert (specified_category in FASHION_TAXONOMY.keys() or specified_category == "all")
if specified_category == "all":
category_range_desc = "all categories of the outfit"
else:
category_range_desc = f"only *{specified_category}* part of the outfit"
schema = {
"type": "object",
"properties": {
"reason": {
"type": "string",
"description": f"The justification for the selection of {category_range_desc}. This summary must explain how the recommended items meet the user's request and style requirements."
},
"recommended_items": {
"type": "array",
"description": "A list of descriptions of recommended items.",
"items": {
"type": "object",
"properties": {
"description": {"type": "string", "description": f"The detailed description for this recommended item."},
"subcategory": {
"type": "string",
"description": "The subcategory of the recommended item.",
"enum": subcategory_list
},
},
"required": ["subcategory", "description"]
}
}
},
"required": ["recommended_items", "reason"]
}
return schema

View File

@@ -1,44 +1,45 @@
import asyncio
import io
import json
import logging
import os
import random
import uuid
from typing import List, Dict, Any, Optional
from copy import deepcopy
import time
from google import genai
from google.cloud import storage
from google.oauth2 import service_account
from app.core.utils_litserve import merge_images_to_square
from app.server.utils.img_operation import merge_images_to_square
from app.server.utils.minio_client import minio_client, oss_upload_image
from app.server.utils.request_post import post_request
from app.config import settings
from app.server.ChatbotAgent.core.prompt import (
GENERAL_RULES,
GENERAL_RULES_DICT,
all_items_template,
build_iterative_schema,
build_batch_schema
)
from app.taxonomy import FASHION_TAXONOMY, ALL_SUBCATEGORY_LIST, OCCASION_CATEGORY_MAP, OCCASION_MATERIAL_MAP, SUBCATEGORY_MERGE_MAP
logger = logging.getLogger(__name__)
class AsyncStylistAgent:
CATEGORY_SET = {
'Activewear', 'Dresses', 'Outerwear', 'Pants', 'Shirts & Tops', 'Skirts', 'Suits', 'Shoes',
# 取消推荐配饰
# 'Swimwear', 'Underwear',
# , 'Watches', 'Shopping Totes', 'Sunglasses', 'Handbags', 'Backpacks', 'Belts', 'Hats', 'Jewelry', 'Briefcases', 'Socks', 'Neckties', 'Scarves & Shawls'
}
CATEGORY_SET_ALL = {
'Activewear', 'Dresses', 'Outerwear', 'Pants', 'Shirts & Tops', 'Skirts', 'Suits', 'Swimwear', 'Underwear',
'Watches', 'Shopping Totes', 'Sunglasses', 'Handbags', 'Backpacks', 'Belts', 'Hats', 'Jewelry',
'Briefcases', 'Socks', 'Neckties', 'Shoes', 'Scarves & Shawls'
}
ONE_PIECE_SUBS = {"dresses", "suits", "jumpsuits", "bodysuits", "swimwear", "underwear", "lingerie", "pajamas"}
TOP_SUBS = {"t-shirts", "shirts", "blouses", "polo shirts", "tank tops", "sweaters", "cardigans", "pullovers", "hoodies", "sweatshirts", "vests", "coats", "jackets", "blazers", "bras"}
BOTTOM_SUBS = {"trousers", "pants", "jeans", "joggers", "leggings", "shorts", "skirts", "skorts"}
def __init__(self, local_db, max_len: int, gemini_model_name: str, outfit_id=str):
def __init__(self, local_db: str, gemini_model_name: str, outfit_id: str, stylist_name: str, gender: str, callback_url: str):
# self.outfit_items: List[Dict[str, str]] = []
self.outfit_id = outfit_id
self.stylist_name = stylist_name
self.gender = gender
self.gemini_client = genai.Client(
vertexai=True, project='aida-461108', location='us-central1'
)
self.local_db = local_db
self.max_len = max_len
self.gemini_model_name = gemini_model_name
self.stop_reason = ""
@@ -55,198 +56,134 @@ class AsyncStylistAgent:
)
self.gcs_bucket = "lc_stylist_agent_outfit_items"
self.minio_bucket = "lanecarford"
self.callback_url = f'{callback_url}/api/style/callback'
def _load_style_guide(self, path: str):
def _is_outfit_complete(self, occasion: str, first_try: bool = True) -> bool:
"""
判断当前的 self.outfit_items 是否满足该场合的完整性要求
"""
if occasion not in OCCASION_CATEGORY_MAP:
# 兜底逻辑:如果场合没定义,至少要有衣服和鞋子
raise ValueError(f"Provided {occasion} is not defined in OCCASION_CATEGORY_MAP.")
requirements = OCCASION_CATEGORY_MAP[occasion]
current_subs = [item['subcategory'] for item in self.outfit_items]
current_cats = [item['category'] for item in self.outfit_items]
# --- 1. 鞋子检查 ---
# 如果该场合允许穿鞋List不为空则必须至少有一双鞋
if requirements.get("shoes") and "shoes" not in current_cats:
return False
# --- 2. 衣服检查 (最核心) ---
# 检查是否有全身单品 (One-piece)
has_one_piece = any(sub in self.ONE_PIECE_SUBS for sub in current_subs)
if not has_one_piece:
# 如果没有连体衣,必须同时有上装和下装
has_top = any(sub in self.TOP_SUBS for sub in current_subs)
has_bottom = any(sub in self.BOTTOM_SUBS for sub in current_subs)
if not (has_top and has_bottom):
return False
# --- 3. 包包检查 ---
# 如果该场合(如 Evening明确需要包且目前还没找到可以返回 False 继续重试
if requirements.get("bags") and "bags" not in current_cats and first_try:
return False
# --- 4. 配饰检查 ---
if requirements.get("accessories") and "accessories" not in current_cats and first_try:
return False
return True
def _load_style_guide(self, stylist_name: str):
"""加载 markdown 风格指南内容。"""
parts = path.split('/', 1)
if len(parts) != 2:
raise ValueError("MinIO path must be in 'bucket_name/object_name' format.")
bucket_name, object_name = parts
guide_path = os.path.join(settings.STYLIST_GUIDE_DIR, f"{stylist_name}_en.md")
try:
# 获取对象 读取内容
response = minio_client.get_object(bucket_name, object_name)
content_bytes = response.read()
json_response = minio_client.get_object(bucket_name, object_name.replace('.md', '.json'))
json_data = json_response.data
# 关闭连接
response.close()
json_response.close()
response.release_conn()
json_response.release_conn()
# 4. 解析 JSON 字符串
json_string = json_data.decode('utf-8')
json_content = json.loads(json_string)
return content_bytes.decode('utf-8'), json_content
with open(guide_path, 'r', encoding='utf-8') as file:
stylist_guide = file.read()
return stylist_guide
except Exception as e:
raise Exception(f"Failed to load style guide from {path}: {e}")
raise Exception(f"Failed to load style guide from {guide_path}: {e}")
def _build_system_prompt(self, request_summary: str = "", gender: str = "male") -> str:
"""Constructs the complete System Prompt."""
clothing_gender = "women's clothing"
if gender == "male":
clothing_gender = "men's clothing"
elif gender == "female":
clothing_gender = "women's clothing"
# Insert the style_guide content into the template
template = f"""
You are a professional fashion stylist Agent, specialized in creating complete, tailored outfits exclusively for {clothing_gender}.
Your task is to **create a cohesive and complete outfit**, strictly adhering to **BOTH** the user's explicit **Request Summary** and the **Outfit Style Guide**. You must decide the next logical item to add to the outfit based on the currently selected items (if any).
---
## Request from the User:
{request_summary}
## Core Guidance Document: Outfit Style Guide
{self.style_guide}
---
## Your Workflow and Constraints
1. **Style Adherence**: You must strictly observe all rules in the Style Guide concerning **color palette, fit, layering principles, pattern restrictions , shoe coordination**.
2. **Step Planning**: The styling sequence must follow a **top-down, inside-out** approach: First major garments (tops/outerwear/bottoms/dresses) then shoes.
3. **Structured Output**: Every response must recommend the **next single item**. You must strictly use the **JSON format** for your output, as follows:
```json
{{
"action": "recommend_item",
"category": "YOUR_ITEM_CATEGORY",
"description": "YOUR_DETAILED_DESCRIPTION"
}}
```
* `action`: Must always be `"recommend_item"` until the outfit is complete.
* `category`: Must be the category of the item you are recommending, strictly selected from the following list: {list(self.CATEGORY_SET)}.
* `description`: This must be an **extremely detailed and precise** description of the item. This description is used for **high-accuracy vector search** in the database and must include:
* **Color** (e.g., milk tea, pure white, dark gray)
* **Fit/Silhouette** (e.g., Oversize, loose, slim-fit)
* **Material/Detail** (e.g., 100% cotton, linen, gold clasp, thin stripe, checkered pattern)
* **Role in the Outfit** (e.g., serves as the innermost base layer for layering; acts as the crucial tie accent for the smart casual look)
4. **Termination Condition**: Only when you deem the entire outfit complete and **all mandatory elements stipulated in the Style Guide are met**, you must output the following JSON format to terminate the process:
```json
{{
"action": "stop",
"reason": "OUTFIT_COMPLETE_AND_MEETS_ALL_MINI_GUIDELINES"
}}
```
Normally, five or six items are totally enough for an outfit.
5. **Context Dependency**: The user's next input (if not `Start`) will contain the **image and description of the selected item**. When recommending the next item, you must consider the coordination between the **already selected items** and the Style Guide.
**Now, please start building an outfit and output the JSON for the first item.**
async def _call_gemini(self, user_input: str, user_id: str, file_name: str, output_schema: Dict[str, Any], image_bytes: bytes = None, system_prompt: str = "") -> str:
"""
return template.strip()
def _clear_uploaded_files(self):
for f in self.gemini_client.files.list():
self.gemini_client.files.delete(name=f.name)
async def _call_gemini(self, user_input: str, user_id: str):
"""
实际调用 Gemini API 的函数,接受文本和可选的图片路径列表。
实际调用 Gemini API 的函数接受文本和用户的id。
会在这个函数中merge图片然后上传到google cloud供gemini参考。
Args:
user_input: 发送给模型的主文本内容。
image_paths: 待发送图片的本地路径列表
user_id: 用户id
file_name: 用于存储图片的文件名。
image_bytes: 可选的图片字节数据。
Returns:
模型的响应文本(预期为 JSON 字符串)。
"""
minio_path = ""
content_parts = []
# self._clear_uploaded_files()
# 1. 添加图片内容
if self.outfit_items:
merged_image = merge_images_to_square(self.outfit_items, max_len=self.max_len + 1, add_text=False)
image_bytes_io = io.BytesIO()
image_format = 'JPEG'
mime_type = 'image/jpeg'
merged_image.save(image_bytes_io, format=image_format)
image_bytes = image_bytes_io.getvalue()
file_name = uuid.uuid4()
if image_bytes:
blob_name = f"lc_stylist_agent_outfit_items/{user_id}/{file_name}.jpg"
gcs_path = self._upload_to_gcs(bucket_name=self.gcs_bucket, blob_name=blob_name, mime_type=mime_type, image_bytes=image_bytes)
responses = oss_upload_image(oss_client=minio_client, bucket=self.minio_bucket, object_name=blob_name, image_bytes=image_bytes)
minio_path = f"{responses.bucket_name}/{responses.object_name}"
gcs_path = self._upload_to_gcs(bucket_name=self.gcs_bucket, blob_name=blob_name, mime_type='image/jpeg', image_bytes=image_bytes)
content_parts.append(gcs_path)
# 2. 添加文本内容
content_parts.append(user_input)
# print(f"\n--- Calling Gemini with {len(self.outfit_items) if self.outfit_items else 0} images and query:\n{user_input}")
try:
# 3. 实际 API 调用
response = await self.gemini_client.aio.models.generate_content(
model=self.gemini_model_name,
contents=content_parts,
config={
"system_instruction": self.system_prompt,
"system_instruction": system_prompt,
# 确保模型返回 JSON 格式
"response_mime_type": "application/json",
"response_schema": {
"type": "object",
"properties": {
"action": {"type": "string", "enum": ["recommend_item", "stop"]},
"category": {"type": "string"},
"description": {"type": "string"},
"reason": {"type": "string"}
},
"required": ["action"]
}
"response_schema": output_schema
}
)
# response.text 将包含一个 JSON 字符串
return response.text, minio_path
return response.text
except Exception as e:
print(f"Gemini API Call failed: {e}")
# 返回一个停止信号以防止循环继续
return json.dumps({"action": "stop", "reason": f"API_ERROR: {str(e)}"})
async def _merge_images(self, user_id: str):
async def _merge_images(self, file_name: str, user_id: str, stylist_name: str):
"""
实际调用 Gemini API 的函数,接受文本和可选的图片路径列表。
把所有的item图片组成一张图片并保存到jpg文件
Args:
user_input: 发送给模型的主文本内容。
image_paths: 待发送图片的本地路径列表。
user_id: 用户的id
stylist_name: 造型师的name
Returns:
模型的响应文本(预期为 JSON 字符串)。
(存储的路径, 内存图片数据)
"""
minio_path = ""
if self.outfit_items:
merged_image = merge_images_to_square(self.outfit_items, max_len=9, add_text=False)
image_bytes_io = io.BytesIO()
image_format = 'JPEG'
if not self.outfit_items:
return "", None
merged_image = merge_images_to_square(self.outfit_items, max_len=9, add_text=False)
image_bytes_io = io.BytesIO()
image_format = 'JPEG'
merged_image.save(image_bytes_io, format=image_format)
image_bytes = image_bytes_io.getvalue()
if settings.LOCAL == 1:
local_dir = os.path.join(settings.OUTFIT_OUTPUT_DIR, stylist_name)
os.makedirs(local_dir, exist_ok=True)
local_file_path = os.path.join(local_dir, f"{file_name}.jpg")
merged_image.save(image_bytes_io, format=image_format)
image_bytes = image_bytes_io.getvalue()
file_name = uuid.uuid4()
blob_name = f"lc_stylist_agent_outfit_items/{user_id}/{file_name}.jpg"
with open(local_file_path, 'wb') as f:
f.write(image_bytes)
return local_file_path, image_bytes
else:
# minio文件地址需保持变动否则前端缓存导致无法更新图片
blob_name = f"lc_stylist_agent_outfit_items/{user_id}/{file_name}-{len(self.outfit_items)}.jpg"
responses = oss_upload_image(oss_client=minio_client, bucket=self.minio_bucket, object_name=blob_name, image_bytes=image_bytes)
minio_path = f"{responses.bucket_name}/{responses.object_name}"
return minio_path
return minio_path, image_bytes
def _parse_gemini_response(self, response_text: str) -> Optional[Dict[str, Any]]:
"""安全解析 Gemini 的 JSON 响应。"""
@@ -261,264 +198,310 @@ class AsyncStylistAgent:
print(f"Raw response: {response_text}")
return None
def _get_next_item(self, item_description: str, category: str) -> Optional[Dict[str, str]]:
def _get_next_item(self, item_description: str, category: str, subcategory: str, occasions: List[str], batch_sources: List[str] = [], gender: str = "female") -> Optional[Dict[str, str]]:
"""
1. 根据描述生成嵌入。
2. 查询本地数据库以找到最佳匹配项。
3. 模拟 Agent 审核匹配项(这里简化为总是通过)。
"""
try:
# 1. 生成查询嵌入
query_embedding = self.local_db.get_clip_embedding(item_description, is_image=False)
# 1. 生成查询嵌入
query_embedding = self.local_db.get_clip_embedding(item_description, is_image=False)
search_subcategories = SUBCATEGORY_MERGE_MAP.get(subcategory, [subcategory])
# 2. 执行查询,并过滤类别
results = self.local_db.query_local_db(query_embedding, category, n_results=1)
# 特殊逻辑处理Clutch 和 Crossbody 在同一场合下不应互换(根据你的规则)
# 2. 执行查询,并过滤类别
try:
results = self.local_db.get_matched_item(
query_embedding,
category,
search_subcategories,
occasions=occasions,
batch_sources=batch_sources,
gender=gender,
n_results=1
)
if not results:
print(f"❌ 数据库中未找到符合 '{category}' 和描述的单品。")
print(f"No matching item found for description: {item_description}, category: {category}, subcategory: {subcategory}")
return None
# 3. 模拟 Agent 审核(实际应用中,你需要将图片发回给 Agent进行审核)
best_meta = results['metadatas'][0][0] # 第一个 batch 的第一个 metadata
best_meta = results[0] # 第一个 batch 的第一个 metadata
item_id = best_meta['item_id'].replace("_img", "")
batch_source = best_meta['batch_source']
return {
"item_id": best_meta['item_id'], # 从 metadata 字典中安全获取
"category": category,
"gpt_description": item_description,
"item_id": item_id, # 从 metadata 字典中安全获取
"category": best_meta['category'],
'description': best_meta['description'],
# 假设 'item_path' 存储在 metadata 中,或从 'item_id' 推导
# 这里假设 item_id 就是文件名的一部分
"image_path": os.path.join(f"{best_meta['item_id']}.jpg")
"subcategory": best_meta['subcategory'],
"brand": best_meta['brand'],
"gpt_description": item_description,
"gpt_subcategory": subcategory,
"image_path": os.path.join(settings.DATA_ROOT, batch_source, 'image_data', f"{item_id}.jpg")
}
except Exception as e:
print(f"An error occurred during item retrieval: {e}")
print((f"Internal error in _get_next_item: {str(e)}"))
return None
async def _get_random_accessories(self, stylist, item_count):
stylist_item = []
stylist_item_ids = []
def _build_system_prompt(self, template: str, general_rule: str, request_summary: str = "", occasion: str = "", stylist_guide: str = "", current_category: str = "clothing", allowed_subcategories: list = [], max_len: int = 4) -> str:
# 1. 材质偏好 (Occasion Material Map)
material_hint = OCCASION_MATERIAL_MAP.get(occasion, "")
# Insert the style_guide content into the template
if occasion == "Bridal / Wedding":
request_summary += "IMPORTANT: Strictly only recommend colorful or white items (clothing, shoes, and bags)."
elif occasion == "Evening":
request_summary += (" **EVENING STYLE MANDATE:** Prioritize high textural contrast. Avoid flat/matte monochrome or total black looks. Ensure visual depth through layering "
"(e.g., varying lengths or sheer panels). Use statement accessories to break simple silhouettes.")
elif occasion == "Business / workwear":
request_summary += (" **WORKWEAR STYLE MANDATE:** Focus on 'Power Dressing' silhouettes—prioritize sharp, oversized blazers paired with floor-length wide-leg trousers. "
"Use tonal dressing to create a sophisticated, elongated look. Balance masculine tailoring with polished, feminine textures. "
"Select structured, architectural bags and pointed-toe or sleek loafers. MANDATORY: Include a complete gold or silver jewelry set "
"(earrings, necklaces, bracelets, and rings).")
elif occasion == "Outdoor":
request_summary += " **OUTDOOR STYLE MANDATE:** No jeans allowed. Focus on functional yet polished alternatives like chinos, technical fabrics, or tailored shorts."
elif occasion == "Festival / Concert":
request_summary += " **FESTIVAL STYLE MANDATE:** No maxi dresses or maxi skirts. Prioritize shorter hemlines, sets, or trousers to ensure ease of movement."
elif occasion == "Beach / Swim":
request_summary += " **BEACH STYLE MANDATE:** No denim allowed. Focus on breathable, lightweight fabrics like linen, silk, or crochet."
filter_items = [
{"item_group_id": {"$ne": "Clothing"}},
{"item_group_id": {"$ne": "Shoes"}},
{"modality": "image"}
]
random_items = []
sys_template = template.format(
gender=self.gender,
current_category=current_category.upper(),
general_rule=general_rule,
request_summary=request_summary,
stylist_guide=stylist_guide,
occasion=occasion,
material_hint=material_hint,
allowed_subcategories=','.join(allowed_subcategories),
max_len=max_len
)
return sys_template.strip()
for i in stylist:
# 1. 根据stylist要求抽取item
query_embedding = self.local_db.get_clip_embedding(i['text'], is_image=False)
stylist_results = self.local_db.query_local_db(query_embedding, i['category'], n_results=10)
stylist_item += random.choices(stylist_results['metadatas'][0], k=i['count'])
stylist_item_ids += [item_id['item_id'] for item_id in stylist_item]
filter_items.append({"category": {"$ne": i["category"]}})
accessories_count = 9 - item_count - len(stylist_item)
if accessories_count > 0:
if accessories_count > 4:
accessories_count = 4
for i in range(accessories_count):
# 2. 在配饰池中过滤掉已经选中的item 然后抽两个item
random_poll = self.local_db.load_filtered_ids(filter_items)
logger.info(f"random_poll 数量: {len(random_poll)}")
item = self.local_db.random_get_accessories(random.choice(random_poll))
if item['metadatas'][0]['category'] in ['Shopping Totes', 'Handbags', 'Backpacks', 'Briefcases']:
filter_items.append({"category": {"$ne": "Shopping Totes"}})
filter_items.append({"category": {"$ne": "Handbags"}})
filter_items.append({"category": {"$ne": "Backpacks"}})
filter_items.append({"category": {"$ne": "Briefcases"}})
else:
filter_items.append({"category": {"$ne": item['metadatas'][0]['category']}})
random_items.append(item['metadatas'][0])
all_items = stylist_item + random_items
else:
all_items = stylist_item
items_data = []
for best_meta in all_items:
items_data.append({
"item_id": best_meta['item_id'], # 从 metadata 字典中安全获取
"category": best_meta['category'],
"gpt_description": best_meta['description'],
'description': best_meta['description'],
# 假设 'item_path' 存储在 metadata 中,或从 'item_id' 推导
# 这里假设 item_id 就是文件名的一部分
"image_path": os.path.join(f"{best_meta['item_id']}.jpg")
})
return items_data
def _build_user_input(self) -> str:
def _build_user_input(self, current_category: str, existing_subcategories: str) -> str:
"""构建发送给 Gemini 的用户输入,包含已选单品信息。"""
if not self.outfit_items:
return "Start"
context = ""
else:
context = "Selected fashion items:\n"
# 将已选单品的信息作为上下文发回给 Agent
context = "Selected fashion items:\n"
for ii, item in enumerate(self.outfit_items):
context += f"{ii + 1}. Category: {item['category']}. Description: {item['description']}\n"
context += "\nPlease recommend the next single item based on the selected items, user's request, and style guide."
context += f"{ii + 1}. Category: {item['category']}. Subcategory: {item['subcategory']}. Description: {item['description']}\n"
if current_category == 'clothing':
context += f"\nRecommend the next single item based on the selected items, user's request, and style guide. 【CRITICAL CONSTRAINT】You MUST strictly **maintain uniqueness**; do not recommend any clothing whose **Subcategory** is already present in this exclusion list: {existing_subcategories}."
elif current_category in ['shoes', 'bags']:
context += f"\nRecommend the next {current_category} based on the selected items, user's request, and style guide."
elif current_category == 'accessories':
context += f"\nRecommend a complete list of accessories to complement the selected outfit based on the user's request and accessories style guide. 【CRITICAL CONSTRAINT】You MUST strictly **maintain uniqueness**; do not recommend any accessories whose **Subcategory** is already present in this exclusion list: {existing_subcategories}."
elif current_category == 'all':
context += "\nRecommend a **complete, full outfit**, including all items (clothing, shoes, bags, and accessories), strictly following the Request Summary and Style Guide. Output the **complete list** of items in a single JSON response."
return context
async def run_styling_process(self, request_summary, stylist_path, start_outfit=None, user_id="test", callback_url="", gender: str = "male"):
if start_outfit is None:
start_outfit = []
self.outfit_items = start_outfit if start_outfit else []
"""主流程控制循环。"""
print(f"--- Starting Agent (Outfit ID: {self.outfit_id}) ---")
def post_operation(self, status: str, message: str, callback_url: str, img_path: str, request_summary=None, occasions=None):
"""处理完成后的回调操作。"""
if settings.LOCAL == 0:
# 生产回调请求数据处理
items = []
for item in self.outfit_items:
items.append(
{
"item_id": item['item_id'],
"category": item['subcategory']
}
)
response_data = {
'items': items,
'status': status,
# 'message': message,
'path': img_path,
'outfit_id': self.outfit_id,
"request_summary": request_summary,
"occasions": occasions
}
self.style_guide, self.style_accessories_guide = self._load_style_guide(stylist_path)
self.system_prompt = self._build_system_prompt(request_summary, gender)
response_data = {"status": "",
"message": "",
"path": "",
"outfit_id": self.outfit_id,
"items": []
}
logger.info(response_data)
item_id = ""
item_category = ""
headers = {
'Accept': "*/*",
'Accept-Encoding': "gzip, deflate, br",
'User-Agent': "PostmanRuntime-ApipostRuntime/1.1.0",
'Connection': "keep-alive",
'Content-Type': "application/json"
}
url = f'{callback_url}/api/style/callback'
while True:
# 1. 准备用户输入(上下文)
user_input = self._build_user_input()
if status in ['failed']:
# 失败直接打印参数 不发送结果
response_data['message'] = message
logger.error(f"request data {json.dumps(response_data, ensure_ascii=False, indent=2)}")
else:
response = post_request(url=callback_url, data=json.dumps(response_data))
logger.info(f"request data {json.dumps(response_data, ensure_ascii=False, indent=2)} | JAVA callback info -> status:{response.status_code} | message:{response.text}")
return response_data
else:
return {}
# 2. 调用 Gemini Agent
gemini_response_text, minio_path = await self._call_gemini(user_input, user_id)
gemini_data = self._parse_gemini_response(gemini_response_text)
response_data['path'] = minio_path
if item_id:
response_data['items'].append({"item_id": item_id, "category": item_category})
async def _execute_batch_recommendation(
self,
current_category: str, # this can be any category or all
system_prompt: str,
schema: Dict,
occasions: List[str],
batch_sources: List[str],
user_id: str,
url: str
):
user_input = self._build_user_input(current_category, existing_subcategories=", ".join([x['subcategory'] for x in self.outfit_items]))
# 合并图片
merged_image_path, image_bytes = await self._merge_images(self.outfit_id, user_id, self.stylist_name)
# 调用Gemini API
gemini_response_text = await self._call_gemini(
user_input,
user_id,
self.outfit_id,
schema,
image_bytes,
system_prompt
)
# 解析响应
gemini_data = self._parse_gemini_response(gemini_response_text)
recommended_items = gemini_data.get('recommended_items', [])
reason = gemini_data.get('reason', '')
failed_found_item_count = 0
if not gemini_data:
# if gemini_data:
print("🚨 Agent 返回无效响应,终止流程。")
self.stop_reason = "Agent failed to return response"
response_data['status'] = "failed"
response_data['message'] = self.stop_reason
response = post_request(url=url, data=json.dumps(response_data), headers=headers)
logger.info(f"request data {response_data} | JAVA callback info -> status:{response.status_code} | message:{response.text}")
break
if not recommended_items or not isinstance(recommended_items, List):
self.post_operation(
status="failed",
message="Agent returned invalid response, terminating process.",
callback_url=url,
img_path=merged_image_path
)
raise Exception("No recommended item from Gemini, terminating process.")
# 3. 检查终止条件
if gemini_data.get('action') == 'stop':
response_data['path'] = minio_path
response_data['items'].append({"item_id": item_id, "category": item_category})
response_data['status'] = "ok"
response = post_request(url=url, data=json.dumps(response_data), headers=headers)
logger.info(f"request data {response_data} | JAVA callback info -> status:{response.status_code} | message:{response.text}")
# 根据stylist要求随机增加配饰 3-4个配饰
new_item = await self._get_random_accessories(self.style_accessories_guide, len(self.outfit_items))
for item in new_item:
self.outfit_items.append(item)
response_data['items'].append({"item_id": item.get('item_id'), "category": item.get('category')})
response_data['path'] = await self._merge_images(user_id)
logger.info(f"🛑 搭配完成,终止原因: {gemini_data.get('reason')}")
self.stop_reason = "Finish reason: " + gemini_data.get('reason', 'No reason provided')
response_data['status'] = "stop"
response_data['message'] = self.stop_reason
response = post_request(url=url, data=json.dumps(response_data), headers=headers)
logger.info(f"request data {response_data} | JAVA callback info -> status:{response.status_code} | message:{response.text}")
break
# 4. 处理推荐单品
if gemini_data.get('action') == 'recommend_item':
category = gemini_data.get('category')
description = gemini_data.get('description')
else:
for idx, rec_item in enumerate(recommended_items):
subcategory = rec_item.get('subcategory')
description = rec_item.get('description')
# we need first determine the category if current category is 'all'
if current_category == "all":
category = self._identify_category(subcategory)
else:
category = current_category
allowed_subcategories = self._get_allowed_subcategories(occasions[0], category)
# 4a. 检查类别是否有效 (重要步骤)
if category not in self.CATEGORY_SET_ALL:
print(f"❌ Agent 推荐了无效类别: {category}。要求 Agent 重新输出。")
# 在实际应用中,这里需要将错误信息发回给 Agent,要求它更正
# 这里简化为跳过本次循环
response_data['status'] = "continue"
response_data['message'] = f"❌ Agent 推荐了无效类别: {category}。要求 Agent 重新输出。",
response = post_request(url=url, data=json.dumps(response_data), headers=headers)
logger.info(f"request data {response_data} | JAVA callback info -> status:{response.status_code} | message:{response.text}")
if subcategory not in allowed_subcategories:
failed_found_item_count += 1
logger.warning(f"Failed to found Item {idx + 1}: ({subcategory}) {rec_item}, invalid subcategory {subcategory} recommended by Agent.")
continue
# 4b. 在本地 DB 中查询单品
new_item = self._get_next_item(description, category)
item_id = new_item.get('item_id')
item_category = new_item.get('category')
# 4b. 如果这个子类别已经被推荐过了,就舍弃
if subcategory in [x['subcategory'] for x in self.outfit_items]:
logger.warning(f"Subcategory {subcategory} already recommended, skipping.")
continue
if new_item:
# 4c. (实际步骤) 将选中的单品图片和描述发回给 Agent 进行最终审核
# 这里的代码框架省略了图片回传和二次审核的步骤,直接视为通过
# 实际你需要: new_user_input = f"Check this item: {new_item['description']}, path: {new_item['image_path']}"
# call_gemini_agent(...) -> 如果返回"pass",则添加到outfit_items
if new_item['item_id'] in [x['item_id'] for x in self.outfit_items]:
print("This item exists. Stop here.")
self.stop_reason = "Finish reason: Duplicate item selected."
response_data['status'] = "stop"
response_data['message'] = self.stop_reason
response = post_request(url=url, data=json.dumps(response_data), headers=headers)
logger.info(f"request data {response_data} | JAVA callback info -> status:{response.status_code} | message:{response.text}")
break
if new_item['item_id'] == "ELG383":
if random.random() < 0.70:
self.stop_reason = "Finish reason: ELG383 is seleced repeatly."
response_data['status'] = "stop"
response_data['message'] = self.stop_reason
response = post_request(url=url, data=json.dumps(response_data), headers=headers)
logger.info(f"request data {response_data} | JAVA callback info -> status:{response.status_code} | message:{response.text}")
break
self.outfit_items.append(new_item)
# print(f" 成功添加单品: {new_item['category']} ({new_item['item_id']}). 当前搭配数量: {len(self.outfit_items)}")
response_data['status'] = "ok"
response_data['message'] = self.stop_reason
response = post_request(url=url, data=json.dumps(response_data), headers=headers)
logger.info(f"request data {response_data} | JAVA callback info -> status:{response.status_code} | message:{response.text}")
# 4c. 在本地 DB 中查询单品
new_item = self._get_next_item(description, category, subcategory, occasions, batch_sources, self.gender)
if not new_item:
failed_found_item_count += 1
logger.warning(f"Failed to found Item {idx + 1}: ({subcategory}) {rec_item}, no matching item is found.")
continue
elif new_item['item_id'] in [x['item_id'] for x in self.outfit_items]:
logger.warning(f"Found Item {idx + 1}: ({subcategory}) {rec_item}, found item is duplicated.")
continue
else:
print("⚠️ 未找到匹配单品,无法继续搭配。终止。")
self.stop_reason = "Finish reason: No matching item found in local database."
response_data['status'] = "stop"
response_data['message'] = self.stop_reason
response = post_request(url=url, data=json.dumps(response_data), headers=headers)
logger.info(f"request data {response_data} | JAVA callback info -> status:{response.status_code} | message:{response.text}")
break
self.outfit_items.append(new_item)
logger.info(f"Item {idx + 1}: ({subcategory}) {rec_item}, found item: {new_item}")
return reason
def _get_allowed_subcategories(self, occasion: str, category: str) -> List[str]:
"""根据场景和分类获取合法的子类别列表"""
# 如果 occasion 不在配置中,回退到全局分类
occ_config = OCCASION_CATEGORY_MAP.get(occasion, {})
if category == "all":
# 获取该场景下所有分类的合集
all_subs = []
for cat_subs in occ_config.values():
all_subs.extend(cat_subs)
return all_subs if all_subs else ALL_SUBCATEGORY_LIST
# 返回特定分类下的子类
return occ_config.get(category, FASHION_TAXONOMY.get(category, []))
if len(self.outfit_items) >= self.max_len: # 设置一个最大循环限制,防止无限循环
gemini_response_text, response_data['path'] = await self._call_gemini(user_input, user_id)
response_data['items'].append({"item_id": self.outfit_items[-1]['item_id'], "category": self.outfit_items[-1]['category']})
response_data['status'] = "ok"
response = post_request(url=url, data=json.dumps(response_data), headers=headers)
logger.info(f"request data {response_data} | JAVA callback info -> status:{response.status_code} | message:{response.text}")
def _identify_category(self, subcategory: str) -> str:
"""反向查找:通过子类确定它属于哪个大类"""
for cat, subs in FASHION_TAXONOMY.items():
if subcategory in subs:
return cat
return "unknown"
# 根据stylist要求随机增加配饰 3-4个配饰
new_item = await self._get_random_accessories(self.style_accessories_guide, len(self.outfit_items))
for item in new_item:
self.outfit_items.append(item)
response_data['items'].append({"item_id": item.get('item_id'), "category": item.get('category')})
response_data['path'] = await self._merge_images(user_id)
async def run_quick_batch_styling(self, request_summary, occasions, start_outfit: Optional[List] = None, batch_sources: List = [], user_id="test", callback_url=""):
start_time = time.monotonic()
# 深拷贝start_outfit 避免实例之间的参数泄漏 确保每个实例都有自己的 start_outfit 副本
if start_outfit is None:
self.outfit_items = []
else:
self.outfit_items = deepcopy(start_outfit)
stylist_guide = self._load_style_guide(self.stylist_name)
url = f'{callback_url}/api/style/callback'
logger.info("🚨 达到最大搭配数量限制,强制终止。")
self.stop_reason = "Finish reason: Reached max outfit length."
response_data['status'] = "stop"
response_data['message'] = self.stop_reason
response = post_request(url=url, data=json.dumps(response_data), headers=headers)
logger.info(f"request data {response_data} | JAVA callback info -> status:{response.status_code} | message:{response.text}")
MAX_LEN = 9
if not occasions:
occasions = ["Casual"]
logger.info(f"""--- Starting Agent (Outfit ID: {self.outfit_id}) ---
Occasion: {occasions[0]}. Stylist: {self.stylist_name}. User ID: {user_id}. Request Summary: {request_summary}. Batch sources: {batch_sources}""")
general_rules = "\n".join(GENERAL_RULES_DICT.values()) + '\n' + GENERAL_RULES
allowed_subcategories = self._get_allowed_subcategories(occasions[0], "all")
system_prompt = self._build_system_prompt(
all_items_template,
general_rules,
request_summary,
occasions[0],
stylist_guide,
"",
allowed_subcategories,
MAX_LEN
)
max_retries = 2
for attempt in range(max_retries):
logger.info(f"Batch recommendation attempt {attempt + 1}")
reason = await self._execute_batch_recommendation(
'all', # can be 'accessories' or 'all'
system_prompt,
build_batch_schema(specified_category="all", subcategory_list=allowed_subcategories),
occasions,
batch_sources,
user_id,
url
)
if self._is_outfit_complete(occasions[0], first_try=(attempt==0)):
logger.info(f"Successfully assembled a complete outfit for {occasions[0]} after {attempt + 1} attempts. Reason: {reason}")
break
return response_data
else:
self.post_operation(
status="failed",
message=f"Failed to assemble a complete outfit after {max_retries} attempts for {occasions[0]}. Current items: {self.outfit_items}. Subcategories required by this occasion is {allowed_subcategories}",
callback_url=url,
img_path=""
)
raise Exception(f"Failed to assemble a complete outfit after {max_retries} attempts for {occasions[0]}. Current items: {self.outfit_items}. Subcategories required by this occasion is {allowed_subcategories}")
# 推荐即将完成 回调通知前端
self.post_operation(
status="almost_done",
message="Recommendation has been completed and the outfit is being assembled",
callback_url=url,
img_path="",
)
final_image_path, _ = await self._merge_images(self.outfit_id, user_id, self.stylist_name)
response_data = self.post_operation(
status="stop",
message=reason,
callback_url=url,
img_path=final_image_path,
request_summary=request_summary,
occasions=occasions
)
end_time = time.monotonic()
total_duration = end_time - start_time
if settings.LOCAL == 1:
with open(os.path.join(settings.OUTFIT_OUTPUT_DIR, self.stylist_name, f'{self.outfit_id}.json'), 'w') as f:
json.dump({"request_summary": request_summary, "occasions": occasions, "items": self.outfit_items, "total_duration": total_duration}, f, indent=2)
return response_data, total_duration
def _upload_to_gcs(self, bucket_name: str, blob_name: str, mime_type, image_bytes) -> str:
"""同步方法:将文件上传到 GCS 并返回 GCS URI。"""

View File

@@ -1,56 +0,0 @@
BASIC_PROMPT = """"""
WOMEN_BASIC_PROMPT = """You are a professional, friendly, and insightful AI women's styling assistant.
Your primary mission is to engage in a multi-turn conversation with the user to fully understand their dressing intent. You must adopt a professional yet approachable tone.
CONVERSATION GOALS:
1. **Occasion:** Determine the specific event (e.g., romantic dinner, summer wedding, business meeting).
2. **Style:** Pinpoint the desired aesthetic (e.g., classic elegance, edgy, minimalist, bohemian).
3. **Vibe/Details:** Gather any mood or specific constraints (e.g., needs to be comfortable, requires light colors, no bare shoulders).
4. **Item Preference:** Ask the user if they have any specific preferences for an item type or silhouette (e.g., preference for a dress, skirt, tailored pants, or a particular neckline/length).
GUIDANCE FOR RESPONSE GENERATION:
- After the user's initial request (e.g., "I want a chic outfit for dinner."), immediately reply with a friendly, targeted follow-up question to elicit the most crucial missing information (usually a combination of **Occasion** and **Style**).
- Be concise. Ask only 1 to 2 essential questions per turn.
- You must gather sufficient, clear intent before proceeding to actual clothing recommendations.
OUTPUT FORMAT INSTRUCTION:
- **DO NOT** use any Markdown formatting whatsoever (e.g., do not use asterisks (*), bold text (**), lists, or code blocks).
- **ONLY** output the plain text response spoken by the AI Assistant.
Example Follow-up (mimicking a conversational flow):
User: I want a chic outfit for dinner.
Your Response: Hey there! A chic dinner outfit, I love that! To give you the perfect recommendations, tell me: is this a romantic date, business dinner, or celebration with friends? And what's your go-to style vibe: classic elegance or something with more edge?"""
MEN_BASIC_PROMPT = """You are a professional, friendly, and insightful AI men's styling assistant.
Your primary mission is to engage in a multi-turn conversation with the user to fully understand their dressing intent. You must adopt a professional yet approachable tone.
CONVERSATION GOALS:
1. **Occasion:** Determine the specific event (e.g., romantic dinner, summer wedding, business meeting).
2. **Style:** Pinpoint the desired aesthetic (e.g., classic elegance, edgy, minimalist, bohemian).
3. **Vibe/Details:** Gather any mood or specific constraints (e.g., needs to be comfortable, requires light colors, no bare shoulders).
4. **Item Preference:** Ask the user if they have any specific preferences for an item type or silhouette (e.g., preference for a dress, skirt, tailored pants, or a particular neckline/length).
GUIDANCE FOR RESPONSE GENERATION:
- After the user's initial request (e.g., "I want a chic outfit for dinner."), immediately reply with a friendly, targeted follow-up question to elicit the most crucial missing information (usually a combination of **Occasion** and **Style**).
- Be concise. Ask only 1 to 2 essential questions per turn.
- You must gather sufficient, clear intent before proceeding to actual clothing recommendations.
OUTPUT FORMAT INSTRUCTION:
- **DO NOT** use any Markdown formatting whatsoever (e.g., do not use asterisks (*), bold text (**), lists, or code blocks).
- **ONLY** output the plain text response spoken by the AI Assistant.
Example Follow-up (mimicking a conversational flow):
User: I want a chic outfit for dinner.
Your Response: Hey there! A chic dinner outfit, I love that! To give you the perfect recommendations, tell me: is this a romantic date, business dinner, or celebration with friends? And what's your go-to style vibe: classic elegance or something with more edge?"""
SUMMARY_PROMPT = """Analyze the following chat history. Your task is to extract all user intentions, scenarios, style preferences, and constraints expressed during the conversation, and distill them into a concise, structured JSON object.
**YOUR OUTPUT MUST BE A JSON OBJECT ONLY, WITH NO SURROUNDING TEXT, MARKDOWN, OR EXPLANATION.**
JSON FIELD REQUIREMENTS:
- **occasion (string):** The specific event and purpose (e.g., "Romantic date dinner", "Summer outdoor wedding", "Casual Friday at office").
- **style (string):** The overall aesthetic description (e.g., "Classic elegance", "Modern minimalist", "Bohemian vibe", "Edgy and contemporary").
- **color_preference (string or list):** User's preferred or excluded colors/tones (e.g., "Light colors only", "Avoid deep shades", "['Cream', 'Pale Blue']", "No preference").
- **clothing_type (string):** User's preference for specific garment types, material, or silhouette (e.g., "Lightweight maxi dress", "Skirt with silk blouse", "Tailored wide-leg pants", "Floral print").
- **vibe_or_details (string):** Any other details, mood requirements, or specific constraints (e.g., "Needs to be comfortable and breathable", "Must cover shoulders")."""

View File

@@ -1,29 +1,27 @@
import random
import time
import numpy as np
import torch
import chromadb
from PIL import Image
from typing import List, Dict, Any
from transformers import CLIPProcessor, CLIPModel
from app.taxonomy import OCCASION, CATEGORY_LIST, IGNORE_SUBCATEGORY, BRAND_WHITELIST
class VectorDatabase():
def __init__(self, vector_db_dir: str, collection_name: str, embedding_model_name: str):
self.client = chromadb.PersistentClient(path=vector_db_dir)
self.collection = self.client.get_or_create_collection(name=collection_name)
self.collection = self.client.get_or_create_collection(
name=collection_name,
configuration={
"hnsw": {
"space": "cosine",
}
}
)
self.device = "cuda" if torch.cuda.is_available() else "cpu"
self.model = CLIPModel.from_pretrained(embedding_model_name).to(self.device)
self.processor = CLIPProcessor.from_pretrained(embedding_model_name)
# self.cache_filtered_ids = self.load_filtered_ids([
# {"item_group_id": {"$ne": "Clothing"}},
# {"item_group_id": {"$ne": "Shoes"}},
# {"modality": "image"}
# ])
# self.total_count = len(self.cache_filtered_ids)
def get_clip_embedding(self, data: str | Image.Image, is_image: bool) -> List[float]:
"""生成图像或文本的 CLIP 嵌入,并进行 L2 归一化。"""
@@ -47,99 +45,98 @@ class VectorDatabase():
features = features / features.norm(p=2, dim=-1, keepdim=True)
return features.cpu().numpy().flatten().tolist()
def get_matched_item(self, embedding: List[float], category: str, search_subcategories: List[str], occasions: List[str] = [], batch_sources: List[str] = [], gender: str = 'female', n_results: int = 1) -> List[Dict[str, Any]]:
if 'ties' in search_subcategories or 'cufflinks' in search_subcategories:
gender = "male"
if category not in CATEGORY_LIST:
raise ValueError(f"Recommended {category} is not valid.")
and_conditions = [
{"category": category},
{"modality": "image"},
{"$or": [
{"gender": gender},
{"gender": "unisex"},
]},
{"subcategory": {"$in": search_subcategories}}
]
# 加了一条限制但是部署到生产的时候把他设定为False
brand_strication = False
if brand_strication:
and_conditions.append({"brand": {"$in": BRAND_WHITELIST}})
# 加一条occasion限制婚礼不能穿黑色
if any(o in ["Bridal / Wedding", "Beach / Swim"] for o in occasions):
and_conditions.append({"color": {"$nin": ["BLACK", "DARK GREY", "DARK BLUE", "NAVY"]}})
if batch_sources and len(batch_sources) > 0:
and_conditions.append({"batch_source": {"$in": batch_sources}})
def query_local_db(self, embedding: List[float], category: str, n_results: int = 3) -> List[Dict[str, Any]]:
"""
基于嵌入向量在本地数据库中查询相似单品。
实际应执行 ChromaDB 查询,并根据 category 进行过滤(metadatas)。
"""
# 实际应执行向量查询
# 为了演示流程,返回一个模拟结果
results = self.collection.query(
query_embeddings=[embedding],
n_results=n_results,
where={
"$and": [
{"category": category},
{"modality": "image"},
]
},
include=['documents', 'metadatas', 'distances']
n_results=500,
where={"$and": and_conditions},
include=['metadatas', 'distances'],
)
return results
def load_filtered_ids(self, filter_item):
# print("\n--- 初始化阶段:加载所有符合条件的 ID ---")
start_time = time.time()
FILTER_CRITERIA = {
"$and": filter_item
}
MAX_LIMIT = 100000
try:
# 获取所有符合条件的 ID
all_ids_results = self.collection.get(
where=FILTER_CRITERIA,
limit=MAX_LIMIT,
include=[]
)
all_matched_ids = all_ids_results['ids']
# print(f"🎉 成功加载 {len(all_matched_ids)} 个 ID 到缓存。")
print(time.time() - start_time)
return all_matched_ids
except Exception as e:
print(f"❌ 初始化失败:获取 ID 列表时发生错误: {e}")
if not results['ids'][0]:
return []
metadatas = results['metadatas'][0] # List[Dict[str, Any]]
item_rank_list = []
all_scores = []
for idx, metadata in enumerate(metadatas):
dist_img = results['distances'][0][idx]
score_vec = 1 - dist_img # cosine similarity range: [-1, 1]
score_subcategory = 1.0
# if subcategory == metadata['subcategory']:
# score_subcategory = 1
def random_get_accessories(self, ids):
# 2. 调用 ChromaDB只查询这一个 ID 的详细信息
try:
final_results = self.collection.get(
ids=ids,
include=["metadatas"] # 你只需要元数据
)
score_occ = 0.0
occasions = occasions[0:1] # 目前只考虑第一个场合
if occasions:
count = 0
for occ in occasions:
if occ not in OCCASION:
continue
count += 1
status_val = metadata.get(occ, -1)
if status_val == 1:
score_occ += 5.0
elif status_val == 0:
score_occ += 0.0
else:
score_occ -= 5.0
# 提取结果
if final_results['ids']:
return final_results
else:
return None
score_occ = score_occ / count if count else 0.0
except Exception as e:
print(f"❌ 获取最终记录时发生错误: {e}")
return None
final_score = 0.5 * score_vec + 0.1 * score_occ + 0.5 * score_subcategory
all_scores.append(final_score)
item_rank_list.append({
"score": final_score,
"metadata": metadata
})
item_rank_list.sort(key=lambda x: x['score'], reverse=True)
candidate_pool = [item for item in item_rank_list if item['score'] > 0.0]
if __name__ == '__main__':
stylist = {
'text': "gold necklace",
'count': 2,
'category': "Jewelry"
}
if not candidate_pool:
print(f"Warning: No positive scores found for {search_subcategories}. Falling back to top matches.")
return [item['metadata'] for item in item_rank_list[:n_results]]
max_len = 5
local_db = VectorDatabase(vector_db_dir="/workspace/lc_stylist_agent/db", collection_name="lc_clothing_embedding", embedding_model_name="openai/clip-vit-base-patch32")
A = local_db.load_filtered_ids([
{"item_group_id": {"$ne": "Clothing"}},
{"item_group_id": {"$ne": "Shoes"}},
{"modality": "image"}
])
# print(db.random_get_accessories())
start_time = time.time()
X = local_db.random_get_accessories(['ELI699_img'])
print(X)
print(time.time() - start_time)
# query_embedding = local_db.get_clip_embedding(stylist['text'], is_image=False)
#
# results = local_db.query_local_db(query_embedding, stylist['category'], n_results=10)
# # 2. 从结果集中抽 stylist['count'] 个item
# stylist_item = random.choices(results['metadatas'][0], k=stylist['count'])
# stylist_item_ids = [item_id['item_id'] for item_id in stylist_item]
#
# # 3. 从随机库中抽取配饰总数达到9件 需过滤掉已经抽中的item
# accessories_count = 9 - max_len - stylist['count']
#
# random_single_ids = random.choices(list(set(local_db.cache_filtered_ids) - set([f"{i}_img" for i in stylist_item_ids])), k=accessories_count)
# random_items = local_db.random_get_accessories(random_single_ids)['metadatas']
# all_items = stylist_item + random_items
# 采取topk截断
current_k = min(10, len(candidate_pool))
top_candidates = candidate_pool[:current_k]
top_scores = np.array([x['score'] for x in top_candidates])
#降低温度,使得选择稳定
temperature = 0.2
exp_scores = np.exp((top_scores - np.max(top_scores)) / temperature)
probabilities = exp_scores / np.sum(exp_scores)
# 采样 (或直接取 Top 1)
sampled_index = np.random.choice(a=current_k, p=probabilities, size=min(n_results, current_k), replace=False) # 不重复采样
sampled_items = [top_candidates[i]['metadata'] for i in sampled_index]
return sampled_items

View File

@@ -4,6 +4,8 @@ import litserve as ls
import requests
from pydantic import BaseModel
from app.config import settings
class PredictRequest(BaseModel):
input_image_list: list[str] # 待换脸图片
@@ -17,7 +19,7 @@ class ReFace(ls.LitAPI):
def predict(self, request):
# 服务的 URL
url = "http://10.1.1.240:10071/predict"
url = f"http://{settings.RE_FACE_MODEL_URL}/predict"
# 请求头
headers = {

View File

@@ -1,8 +1,9 @@
import logging
import os
from typing import List, Dict
from PIL import Image, ImageDraw, ImageFont
from app.server.utils.minio_client import oss_get_image, minio_client
from app.server.utils.minio_config import MINIO_LC_DATA_PATH
from app.config import settings
logger = logging.getLogger(__name__)
# 9个 341x341 左右的单元格 (ALL_9_CELLS)
@@ -44,6 +45,9 @@ def merge_images_to_square(outfit_items: List[Dict[str, str]], max_len=9, add_te
# Define the final canvas size
CANVAS_SIZE = 1024
# 定义每个 item 的外边距
MARGIN = 5 # 5像素外边距
# 1. Create the final white canvas
# Using 'RGB' mode for JPG output
canvas = Image.new('RGB', (CANVAS_SIZE, CANVAS_SIZE), 'white')
@@ -71,7 +75,11 @@ def merge_images_to_square(outfit_items: List[Dict[str, str]], max_len=9, add_te
try:
# We use Image.open() and convert to 'RGB' to handle potential transparency (RGBA)
# and ensure compatibility with the final 'RGB' canvas and JPG output.
img = oss_get_image(oss_client=minio_client, path=f"{MINIO_LC_DATA_PATH}/{path}", data_type="PIL").convert('RGB')
if settings.LOCAL == 1:
img = Image.open(path).convert('RGB')
else:
img_name = path.rsplit('/', 1)[-1]
img = oss_get_image(oss_client=minio_client, path=f"{settings.MINIO_LC_DATA_PATH}/{img_name}", data_type="PIL").convert('RGB')
# img = Image.open(path).convert('RGB')
valid_images.append(img)
except Exception as e:
@@ -83,26 +91,42 @@ def merge_images_to_square(outfit_items: List[Dict[str, str]], max_len=9, add_te
raise ValueError("No valid images were loaded.")
if num_images > max_len:
raise ValueError(f"Valid item number {num_images} exceed max limit {max_len}")
valid_images = valid_images[:max_len]
print(f"Valid item number {num_images} exceed max limit {max_len}, only first {max_len} items will be processed.")
# Get the correct list of target areas based on the number of valid images
target_areas = quadrants.get(num_images, [])
if not target_areas:
raise ValueError(f"No layout defined for {num_images} images.")
# 4. Resize and Paste
for i, (img, item) in enumerate(zip(valid_images, outfit_items)):
item_id = item['item_id']
category = item['category']
subcategory = item['subcategory']
if i >= len(target_areas):
# This should not happen if num_images <= 4
break
# Target area dimensions (x_start, y_start, width, height)
x_start, y_start, target_w, target_h = target_areas[i]
# 原始目标区域 (x_start, y_start, width, height)
orig_x_start, orig_y_start, orig_w, orig_h = target_areas[i]
# 📢 应用边距:实际用于图像和文本的区域
# 新的起始点:向内移动 MARGIN
x_start = orig_x_start + MARGIN
y_start = orig_y_start + MARGIN
# 新的宽高:减去两倍的 MARGIN (左右/上下)
target_w = orig_w - 2 * MARGIN
target_h = orig_h - 2 * MARGIN
# --- 图像缩放与居中 ---
# Calculate new size while maintaining aspect ratio
original_w, original_h = img.size
# Calculate the ratio needed to fit within the target area
# Calculate the ratio needed to fit within the *带边距的* 目标区域
ratio_w = target_w / original_w
ratio_h = target_h / original_h
@@ -113,45 +137,46 @@ def merge_images_to_square(outfit_items: List[Dict[str, str]], max_len=9, add_te
new_w = int(original_w * resize_ratio)
new_h = int(original_h * resize_ratio)
# Resize the image. Image.Resampling.LANCZOS provides high-quality scaling.
# Pillow documentation recommends ANTIALIAS or BICUBIC for downscaling,
# but LANCZOS is a good general high-quality filter.
# Note: In Pillow versions > 9.0.0, Image.LANCZOS is now Image.Resampling.LANCZOS
# Resize the image.
resized_img = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
# Calculate the paste position to center the resized image within its target area
# Center X: (Target Width - New Width) / 2 + X Start
# Center X: (Target Width - New Width) / 2 + X Start (带边距的 X_start)
paste_x = (target_w - new_w) // 2 + x_start
# Center Y: (Target Height - New Height) / 2 + Y Start
# paste_y = (target_h - new_h) // 2 + y_start
# 预留文本高度 ( TEXT_RESERVE_HEIGHT )
TEXT_RESERVE_HEIGHT = 30
# Center Y: (Target Height - New Height - 预留文本高度) / 2 + Y Start (带边距的 Y_start)
paste_y = (target_h - new_h - TEXT_RESERVE_HEIGHT) // 2 + y_start
# 确保图片顶部不超出目标区域的 Y_start
paste_y = max(paste_y, y_start)
# Paste the resized image onto the canvas
canvas.paste(resized_img, (paste_x, paste_y))
full_text = f"ID: {item_id}, Category: {category}"
try:
# 推荐使用:计算文本的实际尺寸 (width, height)
bbox = draw.textbbox((0, 0), full_text, font=font)
text_w = bbox[2] - bbox[0]
text_h = bbox[3] - bbox[1]
except AttributeError:
# 兼容旧版本 Pillow
text_w, text_h = draw.textsize(full_text, font=font)
# --- 文本居中与定位 ---
# 计算 X 轴起始位置:使其在目标区域 (target_w) 中居中
text_x_center = x_start + target_w // 2
text_x_start = text_x_center - text_w // 2
# 计算 Y 轴起始位置:将其放在目标区域的底部
# (目标区域的起始Y + 目标区域的高度 - 文本行的高度)
text_y_start = y_start + target_h - text_h - 5 # 减去 5 像素作为边距
# 3. 绘制合并后的文本
full_text = f"ID: {item_id}, Subcategory: {subcategory}"
if add_text:
try:
# 推荐使用:计算文本的实际尺寸 (width, height)
bbox = draw.textbbox((0, 0), full_text, font=font)
text_w = bbox[2] - bbox[0]
text_h = bbox[3] - bbox[1]
except AttributeError:
# 兼容旧版本 Pillow
text_w, text_h = draw.textsize(full_text, font=font)
# 计算 X 轴起始位置:使其在 *带边距的目标区域* (target_w) 中居中
text_x_center = x_start + target_w // 2
text_x_start = text_x_center - text_w // 2
# 计算 Y 轴起始位置:将其放在 *带边距的目标区域* 的底部
# (带边距的起始Y + 带边距的高度 - 文本行的高度)
# 📢 在带边距的目标区域底部再减去 5 像素作为与底部的边距
text_y_start = y_start + target_h - text_h
draw.text((text_x_start, text_y_start),
full_text,
fill='black',

View File

@@ -10,9 +10,9 @@ import urllib3
from PIL import Image
from minio import Minio
from app.server.utils.minio_config import MINIO_ACCESS, MINIO_SECRET, MINIO_URL, MINIO_SECURE
from app.config import settings
minio_client = Minio(MINIO_URL, access_key=MINIO_ACCESS, secret_key=MINIO_SECRET, secure=MINIO_SECURE)
minio_client = Minio(settings.MINIO_URL, access_key=settings.MINIO_ACCESS, secret_key=settings.MINIO_SECRET, secure=settings.MINIO_SECURE)
# 自定义 Retry 类

View File

@@ -1,6 +0,0 @@
# minio 配置
MINIO_URL = "www.minio-api.aida.com.hk"
MINIO_ACCESS = 'vXKFLSJkYeEq2DrSZvkB'
MINIO_SECRET = 'uKTZT3x7C43WvPN9QTc99DiRkwddWZrG9Uh3JVlR'
MINIO_SECURE = True
MINIO_LC_DATA_PATH = "lanecarford/lc_image_data"

View File

@@ -4,18 +4,24 @@ import time
import requests
def post_request(url, data=None, json_data=None, headers=None, auth=None, timeout=5):
def post_request(url, data=None, json_data=None, auth=None, timeout=5):
"""
发送POST请求的封装函数
:param url: 接口的URL地址
:param data: 要发送的数据(字典形式,用于表单数据等,会自动编码)
:param json_data: 要发送的JSON数据字典形式会自动转换为JSON字符串
:param headers: 请求头字典
:param auth: 认证信息(如 ('username', 'password') 形式用于基本认证)
:param timeout: 超时时间,单位为秒
:return: 返回接口的响应对象
"""
headers = {
'Accept': "*/*",
'Accept-Encoding': "gzip, deflate, br",
'User-Agent': "PostmanRuntime-ApipostRuntime/1.1.0",
'Connection': "keep-alive",
'Content-Type': "application/json"
}
try:
response = requests.post(
url,
@@ -52,6 +58,6 @@ if __name__ == '__main__':
'Content-Type': "application/json"
}
start_time = time.time()
X = post_request(url=url, data=json.dumps(object_data), headers=headers)
X = post_request(url=url, data=json.dumps(object_data))
print(time.time() - start_time)
print(X)

268
app/taxonomy.py Normal file
View File

@@ -0,0 +1,268 @@
# 这个文件用来储存所有的category和occasion这是标准文件。
OCCASION = [
"Casual", "Formal", "Activewear", "Resort", "Evening", "Outdoor",
"Business / workwear", "Cocktail / Semi-Formal", "Black Tie / White Tie",
"Bridal / Wedding", "Festival / Concert", "Party / Clubbing",
"Travel / Transit", "Athleisure", "Beach / Swim", "Ski / Snow / Mountain",
"Garden Party / Daytime Event"
]
FASHION_TAXONOMY = {
'clothing': [
# --- Tops ---
't-shirts', # T恤
'shirts', # 衬衫 (泛指梭织)
'blouses', # 女式衬衫
'polo shirts', # Polo衫
'tank tops', # 背心/坎肩
# --- Knits/Sweaters ---
'sweaters', # 毛衣 (泛指)
'cardigans', # 开衫
'pullovers', # 套头衫
'hoodies', # 连帽衫
'sweatshirts', # 圆领卫衣
'vests', # 马甲/背心 (外穿)
# --- Outerwear ---
'coats', # 大衣 (长款)
'jackets', # 夹克 (短款)
'blazers', # 西装外套
# --- Bottoms ---
'jeans', # 牛仔裤 (虽是材质,但在时尚界视为独立大类)
'trousers', # 西裤/正装长裤
'pants', # 长裤 (泛指休闲)
'joggers', # 束脚裤
'leggings', # 打底裤/紧身裤
'shorts', # 短裤
'skirts', # 半身裙
'skorts', # 裙裤
# --- One-Piece ---
'dresses', # 连衣裙
'jumpsuits', # 连体长裤
'bodysuits', # 连体紧身衣
'suits', # 套装 (西装套)
# --- Intimates/Swim ---
'bras', # 文胸
'underwear', # 内衣
'lingerie', # 性感内衣
'pajamas', # 睡衣套装
'swimwear', # 泳装
],
'shoes': [
'sneakers',
'formal shoes',
'heels',
'flats',
'sandals',
'slides',
'boots',
],
'bags': [
'shoulder bags',
'crossbody',
'bucket bags',
'tote bags',
'clutch bags',
'backpacks',
'travel bags',
'luggage',
],
'accessories': [
# --- Jewelry & Watches ---
'necklaces',
'earrings',
'bracelets',
'rings',
'cufflinks',
'watches',
# --- Head/Face ---
'hats',
'eyewear',
# --- Body/Textile ---
'belts',
'scarves',
'gloves',
'ties',
'bow ties',
'pocket squares',
'socks',
]
}
CATEGORY_LIST = list(FASHION_TAXONOMY.keys())
ALL_SUBCATEGORY_LIST = sum(FASHION_TAXONOMY.values(), [])
IGNORE_SUBCATEGORY = ['socks', 'watches', 'hats', 'eyewear']
BRAND_WHITELIST = [
"ALAÏA",
"BALENCIAGA",
"BALMAIN",
"BARBOUR",
"BOTTEGA VENETA",
"BRUNELLO CUCINELLI",
"ALEXANDERWANG",
"CHLOÉ",
"DIOR",
"FEAR OF GOD",
"FENDI",
"GIA STUDIOS",
"GUCCI",
"HELMUT LANG",
"JACQUEMUS",
"JIMMY CHOO",
"JW ANDERSON",
"KHAITE",
"LEMAIRE",
"LOEWE",
"MAISON MARGIELA",
"MIU MIU",
"MM6 MAISON MARGIELA",
"MONCLER",
"PEDDER RED",
"PETER DO",
"PHOEBE PHILO",
"PRADA",
"RICK OWENS",
"SACAI",
"SKIMS",
"THE ROW",
"THEORY",
"THOM BROWNE",
"THE FRANKIE SHOP",
"TOTEME",
]
OCCASION_CATEGORY_MAP = {
"Casual": {
"clothing": ["trousers", "pants", "jeans", "t-shirts", "tank tops", "polo shirts", "hoodies", "leggings", "shorts", "skirts"],
"shoes": ["sneakers", "flats", "boots"],
"bags": ["shoulder bags", "crossbody"],
"accessories": ["earrings", "necklaces", "bracelets", "rings"]
},
"Formal": {
"clothing": ["suits", "trousers", "shirts", "blazers", "skirts", "dresses", "coats"],
"shoes": ["formal shoes"],
"bags": [],
"accessories": ["ties", "earrings", "necklaces", "bracelets", "rings"]
},
"Activewear": {
"clothing": ["leggings", "tank tops", "pants", "joggers", "hoodies", "jackets"],
"shoes": ["sneakers"],
"bags": ["travel bags"],
"accessories": []
},
"Resort": {
"clothing": ["dresses", "shorts", "tank tops", "swimwear"],
"shoes": ["sandals"],
"bags": ["tote bags"],
"accessories": ["earrings", "necklaces", "bracelets", "rings", "scarves"]
},
"Evening": {
"clothing": ["dresses", "blazers", "coats"],
"shoes": ["heels"],
"bags": ["clutch bags"],
"accessories": ["earrings", "necklaces", "bracelets", "rings"]
},
"Outdoor": {
"clothing": ["jackets", "sweaters", "pants", "joggers", "leggings", "shorts"],
"shoes": ["boots"],
"bags": ["backpacks", "travel bags"],
"accessories": []
},
"Business / workwear": {
"clothing": ["trousers", "blouses", "blazers", "skirts"],
"shoes": ["formal shoes", "heels", "flats"],
"bags": ["tote bags", "shoulder bags"],
"accessories": []
},
"Cocktail / Semi-Formal": {
"clothing": ["dresses", "jumpsuits", "skirts", "blouses", "blazers", "coats"],
"shoes": ["heels"],
"bags": ["clutch bags", "shoulder bags"],
"accessories": ["earrings", "necklaces", "bracelets", "rings", "scarves"]
},
"Black Tie / White Tie": {
"clothing": ["dresses", "suits"],
"shoes": ["formal shoes"],
"bags": ["clutch bags"],
"accessories": ["earrings", "necklaces", "bracelets", "rings"]
},
"Bridal / Wedding": {
"clothing": ["dresses", "suits"],
"shoes": ["heels", "formal shoes"],
"bags": ["clutch bags"],
"accessories": ["earrings", "necklaces", "bracelets", "rings"]
},
"Festival / Concert": {
"clothing": ["jackets", "shorts", "tank tops", "skirts"],
"shoes": ["boots"],
"bags": ["crossbody", "shoulder bags"],
"accessories": ["earrings", "necklaces", "bracelets", "rings"]
},
"Party / Clubbing": {
"clothing": ["jackets", "dresses", "skirts", "bodysuits"],
"shoes": ["heels", "boots"],
"bags": ["clutch bags"],
"accessories": []
},
"Travel / Transit": {
"clothing": ["joggers", "sweatshirts", "t-shirts", "hoodies", "sweaters", "jackets"],
"shoes": ["sneakers"],
"bags": ["backpacks", "travel bags", "tote bags"],
"accessories": []
},
"Athleisure": {
"clothing": ["leggings", "hoodies", "tank tops", "joggers", "jackets"],
"shoes": ["sneakers"],
"bags": ["travel bags", "tote bags"],
"accessories": ["earrings", "bracelets"]
},
"Beach / Swim": {
"clothing": ["swimwear", "shorts", "dresses"],
"shoes": ["sandals"],
"bags": ["tote bags"],
"accessories": ["earrings", "necklaces", "bracelets", "rings"]
},
"Ski / Snow / Mountain": {
"clothing": ["jackets", "coats", "sweaters", "hoodies", "pants"],
"shoes": ["boots"],
"bags": [],
"accessories": ["gloves"]
},
"Garden Party / Daytime Event": {
"clothing": ["dresses", "skirts", "blouses", "cardigans"],
"shoes": ["sandals"],
"bags": ["shoulder bags", "crossbody"],
"accessories": ["earrings", "necklaces", "bracelets", "rings"]
}
}
OCCASION_MATERIAL_MAP = {
"Casual": "Cotton, denim, jersey, fleece, relaxed fits, distressed textures.",
"Formal": "Wool, silk, crisp cotton, structured tailoring, cufflinks, leather soles.",
"Activewear": "Spandex, moisture-wicking synthetics, mesh, breathable fabrics, compression.",
"Resort": "Linen, crochet, chiffon, straw/raffia, tropical prints, breezy silhouettes.",
"Evening": "Silk, satin, velvet, sequins, lace, sheer panels, metallic finishes.",
"Outdoor": "Gore-Tex, fleece, down, wool, waterproof leather, corduroy, flannel.",
"Business / workwear": "Gabardine, crepe, silk blends, modest cuts, structured leather bags.",
"Cocktail / Semi-Formal": "Satin, crepe, lace details, ruffles, asymmetric hems, polished hardware.",
"Black Tie / White Tie": "Tulle, silk taffeta, fine wool, crystals, pearls, opera gloves.",
"Bridal / Wedding": "Chiffon, lace, silk, floral prints (for guests), pastel tones.",
"Festival / Concert": "Denim, fringe, leather, crochet, sequins, bold prints, band tees.",
"Party / Clubbing": "Sequins, leather/pleather, mesh, cut-outs, bodycon fits, latex.",
"Travel / Transit": "Cotton jersey, cashmere blends, stretch denim, soft knits, layers.",
"Athleisure": "Scuba fabric, tech fleece, rib-knit, clean lines, logo details.",
"Beach / Swim": "Lycra, terry cloth, linen, straw, quick-dry fabrics, sheer voile.",
"Ski / Snow / Mountain": "Down feathers, wool, thermal tech, faux fur, waterproof shells.",
"Garden Party / Daytime Event": "Floral prints, linen, eyelet lace, cotton poplin, gingham, straw accessories."
}
SUBCATEGORY_MERGE_MAP = {
"clutch bags": ["clutch bags", "shoulder bags"],
"shoulder bags": ["shoulder bags", "clutch bags"],
"crossbody": ["crossbody", "shoulder bags"],
"luggage": ["luggage", "backpacks", "travel bags"],
"jumpsuits": ["jumpsuits", "dresses"],
"bodysuits": ["bodysuits", "dresses"],
"suits": ["suits", "dresses"],
"pullovers": ["pullovers", "sweaters"],
}

View File

@@ -1,42 +0,0 @@
# Outfit Style Guide
This guide outlines the preferred styling logic, brand affinities, patterns, and structure for Crystal's outfits, emphasizing **bold pattern mixing** balanced by **pure accessories**.
## I. Core Preferences and Prohibitions
| Category | Preference | Prohibition |
| :--- | :--- | :--- |
| **Brands/Material** | **Sacai** brand; **Denim** items | - |
| **Patterns** | **Plaid, Stripes, Floral, Leopard Print**; Active pattern clashing (min 2, **with specific restrictions**) | Monochromatic or single-pattern outfits; **Mixing different Animal Prints** (e.g., Leopard + Snake) |
| **Layering** | Max **2 items** on the upper body; Pattern mixing replaces layering | Excessive layering (叠穿) |
| **Fit/Silhouette** | Accepts **Oversize**; Flexible (can mix with slim-fit/leggings) | Tight-fitting or complicated silhouettes |
| **Shoe Styles** | **Boots, Platform Shoes, Pointed Low Heels, Mesh Shoes, Ballet Flats, White Sneakers** | Full High Heels (高跟鞋), Tall Boots |
| **Bags/Shoes (Pattern)** | **Solid Color** only for bags and shoes | **Patterned/Printed** Bags or Shoes |
| **Bags (Material)** | Must be **Leather** (or natural elements like Rattan/Wicker) | Non-leather materials (except rattan/wicker) |
| **Accessories** | **Gold Tones**; **Necklace** (Mandatory); Rings, **Hoop Earrings**, Bracelet, **Watch** | Vector-style accessories |
---
## II. Styling Pattern: Pattern Clash and Pure Balance
This stylist's style prioritizes visual impact through **clashing prints** rather than layering. Outfits are usually based on a bold print or denim, balanced by pure, solid-colored accessories.
### 1. Classic Outfit Examples
| Style | Structure | Keywords |
| :--- | :--- | :--- |
| **Edgy Casual** | Milk Tea Oversize Blazer + Beige Shirt + Olive Green Cargo Pants + Black Pointed Low Heels | Utility, Sharp Contrast, Subtle Prints |
| **Elegant Flow** | Milk Tea Oversize Blazer + Beige Shirt + Dark Brown Slim-fit Pants + White Mesh Shoes or Ballet Flats | Mixed Fit, Comfort Contrast |
| **Sporty Mix** | Milk Tea Oversize Blazer + Dark Brown or Leopard Print Yoga Set + White Sneakers | Athleisure, Pattern Pop |
| **Heavy Print** | Floral Dress + Denim Pants + Rattan Bag + Wrist Cord + Layered Beaded Necklaces | Pattern Clash (Floral + Denim), Natural Accent |
### 2. Outfit Extended Rules Summary
1. **Pattern Clash**: Actively mix patterns (Plaid/Stripe/Leopard) in one outfit, aiming for at least two non-forbidden prints. **CRITICAL RESTRICTIONS:** A) If a **Floral** print item is chosen, all other clothing items must be solid colors (no mixing floral with other prints). B) **Do not mix different animal prints** (e.g., Leopard and Snakeskin cannot be combined).
2. **Layering Limit**: Do not rely on multi-layering for depth; use print complexity instead. The upper body is limited to **max 2 pieces**.
3. **Fit Flexibility**: Oversize is welcome, but tight-fitting items (e.g., leggings) can be mixed. Prioritize durable fabrics like **Denim** for texture.
4. **Color Base**: No strict color restrictions on garments, but all major accessories (**bags and shoes**) must be **pure, solid colors**.
5. **Shoe/Bag Principle**: Shoes should provide height (low heel, platform, boots). Bags should be **leather and solid-colored**, with rattan/wicker bags accepted as natural accents.
6. **Accessory Requirements**: **Gold** jewelry is preferred for unified tone. **Necklace** is mandatory (min 1). Earrings must be **hoops**. Stack **bracelet/ring/watch**.
7. **Overall Balance**: If clothing patterns are complex, shoes/bags must be **simple and pure** to ground the look. Style leans toward **mixed-casual and energetic**, avoiding blandness.
8. **Scene Adaptability**: Add **cargo pants/low heels** for casual settings; **yoga sets** for sportier looks; use **denim** to balance out heavy prints.

View File

@@ -1,43 +0,0 @@
# Outfit Style Guide
This guide summarizes the stylist's latest preferences, prohibitions, and styling patterns.
## I. Core Preferences and Prohibitions
| Category | Preference | Prohibition |
| :--- | :--- | :--- |
| **Primary Colors** | **Black, White, Gray, Earth Tones** (Solid colors preferred) | Avoid **Yellow** |
| **Accent Colors** | Added via **bags, scarves** (Max 2 accent colors) | Avoid **Bright/Vivid** colors dominating |
| **Patterns** | **Plaid/Checkered, Stripes** (Used as accents) | Strictly avoid **Floral/Small Prints** |
| **Fit/Silhouette** | **Loose, Comfortable, Oversize**; **Clean and Sharp** tailoring | Tight-fitting or complicated silhouettes |
| **Pants Requirement** | Must be **Floor-Length/Puddle Pants** | **Cropped/Ankle-Length, Flare/Bootcut Pants** |
| **Footwear** | **White Sneakers**, **Flats/Single Shoes**, **Loafers** | **Heels, Tall Boots** |
| **Leather Bags** | Must be **Black or White** pure colors; Preference for **Small, Neat Black Top-Handle Bags** | Bulky/Large volume bags |
| **Accessories** | **Multiple Gold Accessories layered** (Necklace, Bracelet/Bangle, Earrings mandatory); **Watch** (Mandatory) | **Large/Bulky Earrings or Vector-style** accessories |
---
## II. Styling Pattern: Layering and Balance
The stylist's outfits emphasize **comfort** and **layering** (creating depth). The common structure is: **3 Upper Body Items + 1 Lower Body Item**. Primary colors are solid, with subtle patterns and accessories adding polish. A **Black Floor-Length Slip Dress** is a key base layer option.
### 1. Classic Outfit Examples
| Style | Structure | Keywords |
| :--- | :--- | :--- |
| **Smart Casual** | Milk Tea Oversize Blazer + White Shirt + **Loose Black Floor-Length Pants** + Tie + White Sneakers | Polished, Layered, Puddle Pants |
| **Short Jacket Look** | **Short Jacket/Blazer** + **Short Skirt/Shorts** + White Socks + **Black Loafers** + Small Black Top-Handle Bag | Short-Jacket Rule, Sharp, Black Accents |
| **Layered Base** | Loose Striped Knitwear + **Black Floor-Length Slip Dress (Inner)** + **Layered Gold Accessories** + Loafers | Dress Base, Longline, Gold Jewelry |
| **Sporty Chic** | Milk Tea Oversize Blazer + Black Sleeveless Activewear + **Black Floor-Length Legging** + White Sneakers + Black Fanny Pack | Mixed Style, Dynamic, Balanced |
### 2. Outfit Extended Rules Summary
1. **Color Palette**: Main garments limited to Black, White, Gray, and Earth Tones; accent colors in accessories max 2. Avoid yellow or vivid dominance.
2. **Layering Principle**: Min 2-3 layers on the upper body, with visible subtle patterns (collar/scarf). **Key:** The **Black Floor-Length Slip Dress** is a favored base.
3. **Fit Requirements**: All items favor loose/oversize for comfort; tailoring must be clean, avoiding tight or complex silhouettes.
4. **Pattern Restriction**: Only Plaid/Stripes as accents; strictly no florals.
5. **Pant Length**: All pants **must be floor-length**, prohibiting cropped and flare styles.
6. **Short Outerwear Rule**: Short outerwear must be paired with **short skirts or shorts**, and **Black Loafers** are mandatory for footwear.
7. **Shoe/Bag Coordination**: Footwear is flat and casual (Loafers, White Sneakers preferred). Leather bags are pure black/white, with a preference for a **small, neat black top-handle bag**.
8. **Accessory Stacking**: **Gold necklace, bracelet/bangle, and earrings should ideally all be present and layered**; watch is mandatory; rings are minimal.
9. **Overall Balance**: Simple bottoms when the top is layered/complex; overall style is neutral and polished.

93
data_ingestion/README.md Normal file
View File

@@ -0,0 +1,93 @@
## Steps
1. Prepare products-all.json and image_data (folder) using javascript to download. These files should be saved in `./data/BATCH_SOURCE` which is a new folder. Give a new batch_source id to each new incoming data.
1. Run `process_item.py` to categorize category, gender and occasions for each data. Output to `./data/{BATCH_SOURCE}/metadata_extraction.json`. This should be running on H200 device.
3. Organize all data and then embed them into db locally using `run_ingestion.py`
## Raw Data Structure
```json
## products-all.json
{
"id": "BUL808",
"name": "SARAH ZHUANG - 'Click & Link' diamond 18k gold earrings",
"brand": "SARAH ZHUANG",
"category": "Fine Jewellery And Watches",
"subcategory": "General",
"price": 17500,
"currency": "HKD",
"description": "Sarah Zhuang's Click & Link earrings embrace the allure of geometry. Forged into elegant rectangles with one side encrusted with diamonds, this gold pair will certainly elevate your cocktail ensembles.",
"tags": [
"sarah zhuang",
"fine jewellery and watches",
"in-stock",
"new",
"sarah",
"zhuang",
"'click",
"link'",
"diamond"
],
"imageUrl": "https://media.lanecrawford.com/B/U/L/BUL808_in_xl.jpg",
"url": "https://www.lanecrawford.com.hk/product/sarah-zhuang/-click-link-diamond-18k-gold-earrings/_/BUL808/product.lc?utm_medium=embed&utm_source=ai-recommended&utm_campaign=2025-christmas_lc_ai-recommended",
"color": "YELLOW GOLD",
"groupName": "Fine Jewellery",
"deptName": "Women's Fine Jewellery",
"onlineBU": "Fine Jewellery",
"stockAvailability": true
}
```
## Example in `metadata_extraction.json`
```json
"EOJ367": {
"subcategory": "necklaces",
"gender": "female",
"applicable_occasions": [
"Casual",
"Outdoor",
"Travel / Transit"
],
"inappropriate_occasions": [
"Formal",
"Black Tie / White Tie",
"Bridal / Wedding",
"Business / workwear",
"Cocktail / Semi-Formal"
]
}
```
## Metadata in Vector Database
```json
{
"item_id": "EOJ128",
"category": "accessories",
"subcategory": "eyewear",
"gender": "unisex",
"modality": "image",
"brand": "CELINE",
"color": "BROWN",
"description": "Immerse yourself in the depth of classic style with CELINE's Tortoiseshell Logo Sunglasses. Featuring a rich, tortoiseshell acetate frame and adorned with the iconic CELINE logo in gold, these sunglasses are a testament to timeless elegance and luxury. Perfect for those who appreciate a sophisticated aesthetic, they offer optimal UV protection while ensuring you remain at the forefront of fashion.",
"tags": "celine,accessories,in-stock,new,maxi,triomphe,acetate,round",
"price": 4500,
"url": "https://www.lanecrawford.com.hk/product/celine/maxi-triomphe-acetate-round-sunglasses/_/EOJ128/product.lc?utm_medium=embed&utm_source=ai-recommended&utm_campaign=2025-christmas_lc_ai-recommended",
"batch_source": "2025_q4",
"Outdoor": 0,
"Ski / Snow / Mountain": 0,
"Festival / Concert": 0,
"Activewear": 0,
"Casual": 1,
"Cocktail / Semi-Formal": -1,
"Formal": -1,
"Party / Clubbing": 0,
"Evening": 0,
"Travel / Transit": 0,
"Beach / Swim": 0,
"Garden Party / Daytime Event": 1,
"Black Tie / White Tie": -1,
"Resort": 1,
"Athleisure": 0,
"Business / workwear": -1,
"Bridal / Wedding": -1,
}
```

View File

@@ -0,0 +1,283 @@
import torch
import os
from transformers import AutoProcessor, AutoModelForVision2Seq
from PIL import Image
import json
from tqdm import tqdm
from app.taxonomy import OCCASION, FASHION_TAXONOMY, ALL_SUBCATEGORY_LIST
# data config
BATCH_SOURCE = '2025_q4'
RAW_DATA_PATH = f'./data/{BATCH_SOURCE}/products-all.json'
IMAGE_DIR = f'./data/{BATCH_SOURCE}/image_data'
# MLLM config
MODEL_NAME = "meta-llama/Llama-3.2-11B-Vision-Instruct"
DEVICE = "cuda:0" # 确保设备设置正确,与您的 Traceback 匹配
BATCH_SIZE = 50
OUTPUT_FILE = f'./data/{BATCH_SOURCE}/metadata_extraction.json'
# Load Model
processor = AutoProcessor.from_pretrained(MODEL_NAME)
if processor.tokenizer.padding_side != 'left':
processor.tokenizer.padding_side = 'left'
print(f"Set tokenizer padding_side to '{processor.tokenizer.padding_side}' for correct generation.")
model = AutoModelForVision2Seq.from_pretrained(MODEL_NAME, torch_dtype=torch.bfloat16).to(DEVICE)
model.eval()
# Load Data
with open(RAW_DATA_PATH, 'r', encoding='utf-8') as file:
data = json.load(file)
EXAMPLE_1_INFO = """
Product Name: ARMARIUM - Loren Wool Blend Tube Skirt
Category: Clothing / Bottoms
Color: RED
Description: Cut from cardinal-red virgin wool, Armarium's Loren skirt wields tailoring's exactitude in a column of colour. The low-slung waist and clean tube line are punctuated by a razor back slit—stride from boardroom to candlelit bar with modern hauteur.
Tags: armarium, clothing, in-stock, new, loren, wool, blend, tube
"""
EXAMPLE_1_JSON = json.dumps({
"subcategory": "skirts",
"gender": "female",
"applicable_occasions": [
"Business/workwear", "Evening", "Cocktail / Semi-Formal", "Party / Clubbing", "Formal"
],
"inappropriate_occasions": [
"Activewear", "Beach / Swim", "Athleisure", "Ski / Snow / Mountain", "Casual"
]
}, indent=4)
# 示例 2胸针 (Pin)
EXAMPLE_2_INFO = """
Product Name: TATEOSSIAN - Mayfair 18K Yellow Gold Rhodium Plated Sterling Silver Peg Pin
Category: Accessories / Accessories
Color: MULTI
Description: Crafted from 18k yellow gold and rhodium-plated sterling silver, this unique pins has been artfully finished with Tateossian's signature diamond engraving pattern.
Tags: tateossian, accessories, in-stock, new, mayfair, yellow, gold, rhodium
"""
EXAMPLE_2_JSON = json.dumps({
"subcategory": "jewelry",
"gender": "female",
"applicable_occasions": [
"Formal", "Black Tie / White Tie", "Bridal / Wedding", "Business/workwear", "Cocktail / Semi-Formal"
],
"inappropriate_occasions": [
"Casual", "Activewear", "Beach / Swim", "Outdoor", "Athleisure", "Ski / Snow / Mountain"
]
}, indent=4)
# --- 2. 构造对话格式 Prompt ---
BOS_TOKEN = "<|begin_of_text|>"
EOS_TOKEN = "<|eot_id|>"
SYSTEM_HEADER = "<|start_header_id|>system<|end_header_id|>\n"
USER_HEADER = "<|start_header_id|>user<|end_header_id|>\n"
ASSISTANT_HEADER = "<|start_header_id|>assistant<|end_header_id|>\n"
IMAGE_TOKEN = "<|image|>"
def format_product_info(product):
tags_str = ", ".join(product.get('tags', []))
info = (
f"Product Name: {product.get('name', 'N/A')}\n"
f"Category: {product.get('category', 'N/A')} / {product.get('deptName', 'N/A')}\n"
f"Color: {product.get('color', 'N/A')}\n"
f"Description: {product.get('description', '')}\n"
f"Tags: {tags_str}",
f"groupName: {product.get('groupName', 'N/A')}\n"
f"onlineBU: {product.get('onlineBU', 'N/A')}\n"
)
return info
def raw_category_mapping(raw_category: str) -> str:
if raw_category == 'Fine Jewellery And Watches':
return 'accessories'
else:
return raw_category.lower()
def generate_full_prompt(product_info, raw_category):
category = raw_category_mapping(raw_category)
subcategory_list = FASHION_TAXONOMY.get(category)
SYSTEM_PROMPT = f"""You are an expert fashion AI assistant. Your task is to analyze the provided product image and product details to:
1. determine the suitable occasions for wearing or using the item. You must choose occasions ONLY from the following strict list: {json.dumps(OCCASION, indent=4)}. Only relevant suitable or inappropriate occasions should be selected.
2. categorize it into suitable subcategory in strict list: {json.dumps(subcategory_list)}.
3. categorize it into appropriate gender in ["female", "male", "unisex"]
Output Format:
Return ONLY a valid JSON object with four keys: "subcategory", "gender", "applicable_occasions" and "inappropriate_occasions". Do not include any analysis or extra text outside of the final JSON object.
"""
# 组合对话序列
dialogue_prompt = (
# 1. System Instruction
f"{BOS_TOKEN}{SYSTEM_HEADER}{SYSTEM_PROMPT}{EOS_TOKEN}"
# 2. Example 1 (Few-Shot Round 1)
# 格式: <|start_header_id|>user<|end_header_id|>\n<|image|>\n{Text Instruction}<|eot_id|>
f"{USER_HEADER}\n{EXAMPLE_1_INFO}{EOS_TOKEN}"
f"{ASSISTANT_HEADER}{EXAMPLE_1_JSON}{EOS_TOKEN}"
# 3. Example 2 (Few-Shot Round 2)
f"{USER_HEADER}\n{EXAMPLE_2_INFO}{EOS_TOKEN}"
f"{ASSISTANT_HEADER}{EXAMPLE_2_JSON}{EOS_TOKEN}"
# 4. Target Item (Target Query)
f"{USER_HEADER}{IMAGE_TOKEN}\nInput Data:\n{product_info}{EOS_TOKEN}"
f"{ASSISTANT_HEADER}" # 最后的 Assistant Header 告诉模型从这里开始生成
)
return dialogue_prompt
# 2. 加载数据
products = data['products']
product_list = [
product for product in products
if product.get('category') in ['Clothing', 'Accessories', 'Shoes', 'Bags', 'Fine Jewellery And Watches']
and os.path.exists(os.path.join(IMAGE_DIR, f"{product.get('id')}.jpg"))
]
def validate_result(result_dict):
subcategory = result_dict.get("subcategory")
gender = result_dict.get("gender")
if not subcategory or not gender:
return False
if subcategory not in ALL_SUBCATEGORY_LIST:
return False
if gender not in ['female', 'male', 'unisex']:
return False
return True
if os.path.exists(OUTPUT_FILE):
with open(OUTPUT_FILE, 'r') as f:
final_results = json.load(f)
else:
final_results = {}
attemps = 0
while attemps < 3:
unfinished_products = [product for product in product_list if product.get('id') not in final_results.keys()]
attemps += 1
completion_ratio = len(final_results) / len(product_list)
if (completion_ratio > 0.85):
print("valid results surpass 85%. Finish Now.")
break
else:
print(f"Start {attemps} categorization process. Current ratio: {completion_ratio * 100}%")
try:
# 按照 BATCH_SIZE 进行切片迭代
for i in tqdm(range(0, len(unfinished_products), BATCH_SIZE)):
batch_samples = unfinished_products[i:i + BATCH_SIZE]
target_images = []
target_prompts = []
target_products_in_batch = []
# 准备当前批次的输入数据
for product in batch_samples:
product_id = product['id']
raw_category = product.get('category')
image_path = os.path.join(IMAGE_DIR, f"{product_id}.jpg")
try:
# 收集图片、Prompt 和产品数据
image = Image.open(image_path).convert("RGB")
product_info = format_product_info(product)
full_prompt = generate_full_prompt(product_info, raw_category)
target_images.append(image)
target_prompts.append(full_prompt)
target_products_in_batch.append(product)
except Exception as e:
# 跳过任何加载失败的单个样本
print(f"Skipping product {product_id} due to loading error: {e}")
continue
if not target_images:
continue # 如果整个批次都没有有效图片,跳过
# 4. 批量推理
print(f"\nProcessing batch {i//BATCH_SIZE + 1}/{int(len(unfinished_products)/BATCH_SIZE)+1} (Size: {len(target_images)})...")
# 处理器输入:使用嵌套列表 [[img1], [img2], ...]
inputs = processor(
images=[[img] for img in target_images],
text=target_prompts,
return_tensors="pt",
padding=True,
truncation=True
).to(model.device)
with torch.no_grad():
outputs = model.generate(
**inputs,
max_new_tokens=150,
do_sample=False
)
# 5. 批量解码和解析结果
input_lengths = inputs.input_ids.size(1)
for j in range(len(target_products_in_batch)):
product = target_products_in_batch[j]
product_id = product['id']
# 提取当前 item 的生成结果
# 注意: outputs 是 [batch_size, sequence_length]
newly_generated_tokens = outputs[j, input_lengths:]
generated_text = processor.decode(newly_generated_tokens, skip_special_tokens=True)
# 清理和解析
if generated_text.endswith(processor.tokenizer.eos_token):
generated_text = generated_text[:-len(processor.tokenizer.eos_token)]
try:
start_idx = generated_text.find('{')
end_idx = generated_text.rfind('}') + 1
if start_idx == -1 or end_idx == -1:
raise ValueError("JSON start or end delimiter not found.")
json_str = generated_text[start_idx:end_idx]
result_dict = json.loads(json_str)
if validate_result(result_dict):
final_results[product_id] = result_dict
except Exception as e:
print(f"ID {product_id}: FAILED to parse JSON. Raw Output: {generated_text.strip()}")
# 显存清理(可选,但在长任务中推荐)
del inputs, outputs
torch.cuda.empty_cache()
with open(OUTPUT_FILE, 'w', encoding='utf-8') as f:
json.dump(final_results, f, indent=4, ensure_ascii=False)
# 6. 保存最终结果
print("\n\n=== ALL BATCHES COMPLETE ===")
# 保存最终结果到 JSON 文件
with open(OUTPUT_FILE, 'w', encoding='utf-8') as f:
json.dump(final_results, f, indent=4, ensure_ascii=False)
print(f"Results saved to {OUTPUT_FILE}")
except Exception as e:
print(f"\n--- Execution Error ---")
print(f"An unexpected error occurred: {e}")

View File

@@ -0,0 +1,182 @@
import chromadb
import os
import json
from copy import deepcopy
import torch
from tqdm import tqdm
from PIL import Image
from transformers import CLIPProcessor, CLIPModel
from app.taxonomy import ALL_SUBCATEGORY_LIST, OCCASION
BATCH_SOURCE = '2025_q4'
DATA_DIR = f'./data/{BATCH_SOURCE}'
IMAGE_DIR = f'./data/{BATCH_SOURCE}/image_data'
RAW_DATA_PATH = f'{DATA_DIR}/products-all.json'
CATEGORIZED_METADATA_PATH = f'{DATA_DIR}/metadata_extraction.json'
ADD_TEXT_EMBEDDING = False
## Load data
with open(RAW_DATA_PATH, 'r', encoding='utf-8') as file:
raw_data = json.load(file)
with open(CATEGORIZED_METADATA_PATH, 'r', encoding='utf-8') as file:
categorized_data = json.load(file)
# Create Collection
client = chromadb.PersistentClient(path='./data/db')
collection = client.get_or_create_collection(
name="lc_clothing_embedding"
)
# if you wish to delete some item, uncomment following
results = collection.delete(
where={
"batch_source": BATCH_SOURCE
}
)
# Load model
processor = CLIPProcessor.from_pretrained("openai/clip-vit-base-patch32")
model = CLIPModel.from_pretrained("openai/clip-vit-base-patch32")
device = "cuda" if torch.cuda.is_available() else "cpu"
model.to(device)
def format_product_info(product):
tags_str = ", ".join(product.get('tags', []))
info = (
f"Product Name: {product.get('name', 'N/A')}\n"
f"Brand: {product.get('brand', 'N/A')}\n"
f"Category: {product.get('category', 'N/A')} / {product.get('deptName', 'N/A')}\n"
f"Color: {product.get('color', 'N/A')}\n"
f"Description: {product.get('description', '')}\n"
f"Tags: {tags_str}"
f"GroupName: {product.get('groupName', 'N/A')}\n"
f"DetpName: {product.get('deptName', 'N/A')}\n"
f"OnlineBU: {product.get('onlineBU', 'N/A')}\n"
)
return info
def raw_category_mapping(raw_category: str) -> str:
if raw_category == 'Fine Jewellery And Watches':
return 'accessories'
else:
return raw_category.lower()
# Combine all data together
valid_count = 0
all_count = 0
for raw_item in tqdm(raw_data['products']):
item_id = raw_item.get('id')
if not item_id:
print(f"This item {raw_item} did not have a valid item_id")
continue
raw_category = raw_item.get("category")
if raw_category not in ['Clothing', 'Accessories', 'Shoes', 'Bags', 'Fine Jewellery And Watches']:
continue
image_path = os.path.join(IMAGE_DIR, f"{item_id}.jpg")
if not os.path.exists(image_path):
print(f"Image not found: {image_path}")
continue
# All above is raw data error, it's not our business.
all_count += 1
processed_item = categorized_data.get(item_id, {})
if not processed_item:
print(f"{item_id} has not been categorized. It does not exist in {CATEGORIZED_METADATA_PATH}")
continue
category = raw_category_mapping(raw_category)
subcategory = processed_item.get("subcategory")
gender = processed_item.get("gender")
applicable_occasions = processed_item.get("applicable_occasions", [])
inappropriate_occasions = processed_item.get("inappropriate_occasions", [])
if subcategory not in ALL_SUBCATEGORY_LIST:
print(f"{item_id}'s category, {category}, does not valid.")
if gender not in ['female', 'male', 'unisex']:
print(f"{item_id}'s gender is not valid in {['female', 'male', 'unisex']}")
continue
occasions = applicable_occasions + inappropriate_occasions
if not set(occasions).issubset(set(OCCASION)):
# print(f"{item_id}'s some occasions is not vaild. \n Invalid occasion is {set(occasions).difference(set(OCCASION))}")
applicable_occasions = [o for o in applicable_occasions if o in OCCASION]
inappropriate_occasions = [o for o in inappropriate_occasions if o in OCCASION]
description = raw_item.get('description', "")
if not description:
f"{item_id}'s description is lost."
continue
url = raw_item.get('url', '')
if not url:
f"{item_id}'s url is lost."
continue
valid_count += 1
# Prepare metadata for db
item_img_metadata = {
"item_id": item_id,
"category": category,
"subcategory": subcategory,
"description": description,
"gender": gender,
'brand': raw_item.get('brand', ''),
'color': raw_item.get('color', ''),
'price': raw_item.get('price', ''),
'tags': ",".join(raw_item.get('tags', [])),
'url': url,
"modality": "image",
"batch_source": BATCH_SOURCE
}
for occasion in OCCASION:
item_img_metadata[occasion] = 0
for occasion in applicable_occasions:
item_img_metadata[occasion] = 1
for occasion in inappropriate_occasions:
item_img_metadata[occasion] = -1
# Get image feature
image = Image.open(image_path).convert("RGB")
inputs = processor(images=image, return_tensors="pt").to(device)
with torch.no_grad():
img_features = model.get_image_features(**inputs)
img_features = img_features / img_features.norm(p=2, dim=-1, keepdim=True)
img_embedding = img_features.cpu().numpy().flatten().tolist()
product_info = format_product_info(raw_item)
# 插入到 ChromaDB
collection.add(
ids=[f'{item_id}_img'],
documents=[product_info],
embeddings=[img_embedding],
metadatas=[item_img_metadata],
)
if ADD_TEXT_EMBEDDING:
item_txt_metadata = deepcopy(item_img_metadata)
item_txt_metadata["modality"] = "text"
# Get text feature
inputs = processor(text=[description], return_tensors="pt", padding=True, truncation=True).to(device)
with torch.no_grad():
txt_features = model.get_text_features(**inputs)
txt_features = txt_features / txt_features.norm(p=2, dim=-1, keepdim=True)
txt_embedding = txt_features.cpu().numpy().flatten().tolist()
collection.add(
ids=[f'{item_id}_txt'],
documents=[product_info],
embeddings=[txt_embedding],
metadatas=[item_txt_metadata],
)
print(f"Final valid ratio is {valid_count / all_count * 100}%. Total number is {all_count}, Valid number is {valid_count}")

View File

@@ -1,24 +1,32 @@
services:
lc_agent_server:
container_name: LC_Agent_Server
build:
context: .
dockerfile: Dockerfile
working_dir: /app
environment:
GOOGLE_APPLICATION_CREDENTIALS: /app/app/request.json
GOOGLE_APPLICATION_CREDENTIALS: /google_application_credentials.json
DEBUG: 0
volumes:
- ./app:/app/app
- ./.env:/app/.env
- ./db:/db
- ./data:/data
- ./google_application_credentials.json:/google_application_credentials.json
- /etc/localtime:/etc/localtime:ro
ports:
- "10070:8000"
- "${SERVE_PORT}:8000"
deploy:
resources:
reservations:
devices:
# 告诉 Docker 使用所有可用的 NVIDIA GPU
- driver: nvidia
device_ids: ['0']
capabilities: [ gpu ]
device_ids: [ '0' ]
capabilities: [ gpu ]
networks:
- lc_app_net
networks:
lc_app_net:
external: true
name: lc_app_net

BIN
docs/2025_q4 stat.xlsx Normal file

Binary file not shown.

BIN
docs/Edi.docx Executable file

Binary file not shown.

View File

@@ -0,0 +1,312 @@
<mxfile host="Electron" modified="2025-12-22T09:06:22.598Z" agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) draw.io/24.6.4 Chrome/124.0.6367.207 Electron/30.0.6 Safari/537.36" etag="KEo18_6q0PUKrVUfWbm_" version="24.6.4" type="device">
<diagram name="第 1 页" id="pGsAEtmh8_bcrSWBA4M-">
<mxGraphModel dx="4094" dy="1407" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="827" pageHeight="1169" math="0" shadow="0">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
<mxCell id="9H2VgkJE_G1r__vgVZNZ-7" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;fontSize=15;" parent="1" source="wmVdZQT5DwNNTv-fY12W-1" target="9H2VgkJE_G1r__vgVZNZ-9" edge="1">
<mxGeometry relative="1" as="geometry">
<mxPoint x="-1414" y="1835.9999999999998" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="wmVdZQT5DwNNTv-fY12W-1" value="Assemble Prompt" style="rounded=1;whiteSpace=wrap;html=1;fontSize=15;" parent="1" vertex="1">
<mxGeometry x="-1475" y="1717" width="120" height="60" as="geometry" />
</mxCell>
<mxCell id="wmVdZQT5DwNNTv-fY12W-3" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=0;entryDx=0;entryDy=0;entryPerimeter=0;exitX=0.5;exitY=1;exitDx=0;exitDy=0;fontSize=15;" parent="1" source="wmVdZQT5DwNNTv-fY12W-2" target="wmVdZQT5DwNNTv-fY12W-7" edge="1">
<mxGeometry relative="1" as="geometry">
<mxPoint x="-1584" y="1436" as="targetPoint" />
<Array as="points">
<mxPoint x="-1330" y="1410" />
<mxPoint x="-1240" y="1410" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="-v7OJhgbw4DDcAMX44wt-80" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="wmVdZQT5DwNNTv-fY12W-2" target="wmVdZQT5DwNNTv-fY12W-8" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="wmVdZQT5DwNNTv-fY12W-2" value="&lt;b style=&quot;font-size: 15px;&quot;&gt;Start&lt;/b&gt;&lt;div style=&quot;font-size: 15px;&quot;&gt;Get Summarized Query&lt;/div&gt;" style="ellipse;whiteSpace=wrap;html=1;fontSize=15;" parent="1" vertex="1">
<mxGeometry x="-1390" y="1300" width="120" height="80" as="geometry" />
</mxCell>
<mxCell id="9H2VgkJE_G1r__vgVZNZ-20" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=1;entryY=0.5;entryDx=0;entryDy=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;fontSize=15;" parent="1" source="wmVdZQT5DwNNTv-fY12W-7" target="9H2VgkJE_G1r__vgVZNZ-19" edge="1">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="-960" y="1470" />
<mxPoint x="-960" y="1830" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="-v7OJhgbw4DDcAMX44wt-81" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="wmVdZQT5DwNNTv-fY12W-7" target="-v7OJhgbw4DDcAMX44wt-4" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="wmVdZQT5DwNNTv-fY12W-7" value="occasions&lt;div style=&quot;font-size: 15px;&quot;&gt;gender&lt;/div&gt;&lt;div style=&quot;font-size: 15px;&quot;&gt;category&lt;/div&gt;" style="rounded=1;whiteSpace=wrap;html=1;fontSize=15;" parent="1" vertex="1">
<mxGeometry x="-1300" y="1440" width="120" height="60" as="geometry" />
</mxCell>
<mxCell id="9H2VgkJE_G1r__vgVZNZ-2" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;fontSize=15;" parent="1" source="wmVdZQT5DwNNTv-fY12W-8" target="9H2VgkJE_G1r__vgVZNZ-1" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="wmVdZQT5DwNNTv-fY12W-8" value="Query&lt;div style=&quot;font-size: 15px;&quot;&gt;String&lt;/div&gt;" style="rounded=1;whiteSpace=wrap;html=1;fontSize=15;" parent="1" vertex="1">
<mxGeometry x="-1475" y="1440" width="120" height="60" as="geometry" />
</mxCell>
<mxCell id="9H2VgkJE_G1r__vgVZNZ-3" value="No" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=0;entryDx=0;entryDy=0;fontSize=15;" parent="1" source="9H2VgkJE_G1r__vgVZNZ-1" target="wmVdZQT5DwNNTv-fY12W-1" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="9H2VgkJE_G1r__vgVZNZ-4" value="Yes" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;fontSize=15;" parent="1" source="9H2VgkJE_G1r__vgVZNZ-1" target="9H2VgkJE_G1r__vgVZNZ-5" edge="1">
<mxGeometry x="-0.5" relative="1" as="geometry">
<mxPoint x="-1244" y="1606" as="targetPoint" />
<mxPoint as="offset" />
</mxGeometry>
</mxCell>
<mxCell id="9H2VgkJE_G1r__vgVZNZ-1" value="Finished main&amp;nbsp;&lt;div style=&quot;font-size: 15px;&quot;&gt;clothing process?&lt;/div&gt;" style="rhombus;whiteSpace=wrap;html=1;fontSize=15;" parent="1" vertex="1">
<mxGeometry x="-1500" y="1560" width="170" height="100" as="geometry" />
</mxCell>
<mxCell id="9H2VgkJE_G1r__vgVZNZ-16" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;fontSize=15;" parent="1" source="9H2VgkJE_G1r__vgVZNZ-5" target="9H2VgkJE_G1r__vgVZNZ-17" edge="1">
<mxGeometry relative="1" as="geometry">
<mxPoint x="-1094" y="1706" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="9H2VgkJE_G1r__vgVZNZ-5" value="Main Outfit" style="rounded=1;whiteSpace=wrap;html=1;fontSize=15;" parent="1" vertex="1">
<mxGeometry x="-1150" y="1580" width="120" height="60" as="geometry" />
</mxCell>
<mxCell id="-v7OJhgbw4DDcAMX44wt-78" value="&lt;font style=&quot;font-size: 15px;&quot;&gt;Description&lt;/font&gt;" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="9H2VgkJE_G1r__vgVZNZ-9" target="-v7OJhgbw4DDcAMX44wt-2" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="9H2VgkJE_G1r__vgVZNZ-9" value="Query LLM" style="rounded=1;whiteSpace=wrap;html=1;fontSize=15;" parent="1" vertex="1">
<mxGeometry x="-1475" y="1810" width="120" height="60" as="geometry" />
</mxCell>
<mxCell id="9H2VgkJE_G1r__vgVZNZ-12" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0;entryY=0.5;entryDx=0;entryDy=0;exitX=0;exitY=0.5;exitDx=0;exitDy=0;fontSize=15;" parent="1" source="-v7OJhgbw4DDcAMX44wt-9" target="9H2VgkJE_G1r__vgVZNZ-1" edge="1">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="-1570" y="2050" />
<mxPoint x="-1570" y="1610" />
</Array>
<mxPoint x="-1604" y="1966" as="sourcePoint" />
</mxGeometry>
</mxCell>
<mxCell id="9H2VgkJE_G1r__vgVZNZ-13" value="Add item to outfit" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=15;" parent="9H2VgkJE_G1r__vgVZNZ-12" vertex="1" connectable="0">
<mxGeometry relative="1" as="geometry">
<mxPoint x="-3" y="-15" as="offset" />
</mxGeometry>
</mxCell>
<mxCell id="9H2VgkJE_G1r__vgVZNZ-18" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;fontSize=15;" parent="1" source="9H2VgkJE_G1r__vgVZNZ-17" target="9H2VgkJE_G1r__vgVZNZ-19" edge="1">
<mxGeometry relative="1" as="geometry">
<mxPoint x="-1094" y="1796" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="9H2VgkJE_G1r__vgVZNZ-17" value="Ask LLM to recommend Accessories" style="rounded=1;whiteSpace=wrap;html=1;fontSize=15;" parent="1" vertex="1">
<mxGeometry x="-1150" y="1690" width="120" height="60" as="geometry" />
</mxCell>
<mxCell id="9H2VgkJE_G1r__vgVZNZ-21" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;fontSize=15;" parent="1" source="9H2VgkJE_G1r__vgVZNZ-19" target="9H2VgkJE_G1r__vgVZNZ-22" edge="1">
<mxGeometry relative="1" as="geometry">
<mxPoint x="-1094" y="1865.9999999999998" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="9H2VgkJE_G1r__vgVZNZ-19" value="Retrieval&lt;div&gt;(Illustrated right)&lt;/div&gt;" style="rounded=1;whiteSpace=wrap;html=1;fontSize=15;labelBackgroundColor=none;fillColor=#dae8fc;strokeColor=#6c8ebf;" parent="1" vertex="1">
<mxGeometry x="-1150" y="1800" width="120" height="60" as="geometry" />
</mxCell>
<mxCell id="9H2VgkJE_G1r__vgVZNZ-22" value="End" style="ellipse;whiteSpace=wrap;html=1;aspect=fixed;fontSize=15;" parent="1" vertex="1">
<mxGeometry x="-1130" y="1920" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="-v7OJhgbw4DDcAMX44wt-79" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="-v7OJhgbw4DDcAMX44wt-2" target="-v7OJhgbw4DDcAMX44wt-9" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="-v7OJhgbw4DDcAMX44wt-2" value="Retrieval&lt;div&gt;(Illustrated right)&lt;/div&gt;" style="rounded=1;whiteSpace=wrap;html=1;fontSize=15;fillColor=#dae8fc;strokeColor=#6c8ebf;" parent="1" vertex="1">
<mxGeometry x="-1475" y="1920" width="120" height="60" as="geometry" />
</mxCell>
<mxCell id="-v7OJhgbw4DDcAMX44wt-5" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;fontSize=15;" parent="1" source="-v7OJhgbw4DDcAMX44wt-4" target="-v7OJhgbw4DDcAMX44wt-2" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="-v7OJhgbw4DDcAMX44wt-4" value="Database" style="shape=cylinder3;whiteSpace=wrap;html=1;boundedLbl=1;backgroundOutline=1;size=15;fontSize=15;" parent="1" vertex="1">
<mxGeometry x="-1285" y="1910" width="90" height="80" as="geometry" />
</mxCell>
<mxCell id="-v7OJhgbw4DDcAMX44wt-9" value="Sample item" style="rounded=1;whiteSpace=wrap;html=1;fontSize=15;" parent="1" vertex="1">
<mxGeometry x="-1475" y="2027" width="120" height="60" as="geometry" />
</mxCell>
<mxCell id="-v7OJhgbw4DDcAMX44wt-11" value="Offline Data Preparation" style="text;html=1;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=25;fontStyle=1" parent="1" vertex="1">
<mxGeometry x="-1654" width="310" height="70" as="geometry" />
</mxCell>
<mxCell id="-v7OJhgbw4DDcAMX44wt-12" value="Online Recommendation" style="text;html=1;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=25;fontStyle=1" parent="1" vertex="1">
<mxGeometry x="-1654" y="1169" width="310" height="70" as="geometry" />
</mxCell>
<mxCell id="-6mM7cjyf4a1SxbvEv1c-5" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="1" source="-v7OJhgbw4DDcAMX44wt-21" target="-v7OJhgbw4DDcAMX44wt-30">
<mxGeometry relative="1" as="geometry">
<mxPoint x="-1413" y="271.0588235294117" as="sourcePoint" />
<mxPoint x="-480" y="381" as="targetPoint" />
<Array as="points">
<mxPoint x="-920" y="170" />
<mxPoint x="-920" y="170" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="-6mM7cjyf4a1SxbvEv1c-6" value="&lt;font style=&quot;font-size: 20px;&quot;&gt;Direct Use&lt;/font&gt;" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" vertex="1" connectable="0" parent="-6mM7cjyf4a1SxbvEv1c-5">
<mxGeometry x="0.4826" y="1" relative="1" as="geometry">
<mxPoint x="-573" y="1" as="offset" />
</mxGeometry>
</mxCell>
<mxCell id="-v7OJhgbw4DDcAMX44wt-18" value="Raw Data" style="swimlane;whiteSpace=wrap;html=1;fontSize=20;" parent="1" vertex="1">
<mxGeometry x="-1583" y="70" width="170" height="540" as="geometry" />
</mxCell>
<mxCell id="-v7OJhgbw4DDcAMX44wt-21" value="&lt;font color=&quot;#ff0000&quot;&gt;id&lt;/font&gt;&lt;div&gt;&lt;font color=&quot;#ff0000&quot;&gt;category&lt;br&gt;&lt;/font&gt;&lt;div style=&quot;font-size: 20px;&quot;&gt;&lt;font color=&quot;#ff0000&quot;&gt;description&lt;/font&gt;&lt;/div&gt;&lt;div style=&quot;font-size: 20px;&quot;&gt;&lt;span style=&quot;background-color: initial;&quot;&gt;name&lt;/span&gt;&lt;br&gt;&lt;/div&gt;&lt;div style=&quot;font-size: 20px;&quot;&gt;brand&lt;/div&gt;&lt;div style=&quot;font-size: 20px;&quot;&gt;color&lt;/div&gt;&lt;div style=&quot;font-size: 20px;&quot;&gt;price&lt;/div&gt;&lt;div style=&quot;font-size: 20px;&quot;&gt;tags&lt;/div&gt;&lt;div style=&quot;font-size: 20px;&quot;&gt;url&lt;/div&gt;&lt;div style=&quot;font-size: 20px;&quot;&gt;groupName&lt;/div&gt;&lt;div style=&quot;font-size: 20px;&quot;&gt;deptName&lt;/div&gt;&lt;div style=&quot;font-size: 20px;&quot;&gt;onelineBU&lt;/div&gt;&lt;/div&gt;" style="text;html=1;align=left;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=default;fillColor=none;fontSize=20;dashed=1;" parent="-v7OJhgbw4DDcAMX44wt-18" vertex="1">
<mxGeometry x="20" y="70" width="130" height="300" as="geometry" />
</mxCell>
<mxCell id="-6mM7cjyf4a1SxbvEv1c-10" value="&lt;font style=&quot;font-size: 20px;&quot;&gt;Metadata&lt;/font&gt;" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=#6c8ebf;fillColor=#dae8fc;" vertex="1" parent="-v7OJhgbw4DDcAMX44wt-18">
<mxGeometry x="20" y="30" width="110" height="40" as="geometry" />
</mxCell>
<mxCell id="-6mM7cjyf4a1SxbvEv1c-11" value="&lt;font style=&quot;font-size: 20px;&quot;&gt;Image&lt;/font&gt;" style="swimlane;whiteSpace=wrap;html=1;fillColor=#00CCCC;" vertex="1" parent="-v7OJhgbw4DDcAMX44wt-18">
<mxGeometry x="25" y="434" width="100" height="100" as="geometry" />
</mxCell>
<mxCell id="-6mM7cjyf4a1SxbvEv1c-19" value="" style="shape=flexArrow;endArrow=classic;startArrow=classic;html=1;rounded=0;entryX=0.413;entryY=0.996;entryDx=0;entryDy=0;entryPerimeter=0;exitX=0.5;exitY=0;exitDx=0;exitDy=0;" edge="1" parent="-v7OJhgbw4DDcAMX44wt-18" source="-6mM7cjyf4a1SxbvEv1c-11" target="-v7OJhgbw4DDcAMX44wt-21">
<mxGeometry width="100" height="100" relative="1" as="geometry">
<mxPoint x="23" y="540" as="sourcePoint" />
<mxPoint x="123" y="440" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="-6mM7cjyf4a1SxbvEv1c-20" value="Corresponding" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;" vertex="1" parent="-v7OJhgbw4DDcAMX44wt-18">
<mxGeometry x="70" y="390" width="100" height="30" as="geometry" />
</mxCell>
<mxCell id="-v7OJhgbw4DDcAMX44wt-22" value="&lt;div style=&quot;font-size: 18px;&quot;&gt;&lt;font style=&quot;font-size: 18px;&quot;&gt;Comment:&lt;/font&gt;&lt;/div&gt;&lt;font style=&quot;font-size: 18px;&quot;&gt;1. Any field marked in &lt;b&gt;red&lt;/b&gt; is &lt;b&gt;mandatory&lt;/b&gt;.&lt;/font&gt;&lt;br&gt;&lt;div style=&quot;font-size: 18px;&quot;&gt;&lt;font style=&quot;font-size: 18px;&quot;&gt;2.The category field must be one of the&amp;nbsp;&lt;/font&gt;&lt;/div&gt;&lt;div style=&quot;font-size: 18px;&quot;&gt;&lt;font style=&quot;font-size: 18px;&quot;&gt;following &lt;b&gt;EXACT&amp;nbsp;&lt;/b&gt;value:&lt;br&gt;&lt;ul&gt;&lt;li&gt;&lt;font style=&quot;font-size: 18px;&quot;&gt;&lt;b&gt;Clothing&lt;/b&gt;&lt;/font&gt;&lt;/li&gt;&lt;li&gt;&lt;font style=&quot;font-size: 18px;&quot;&gt;&lt;b&gt;Accessories&lt;/b&gt;&lt;/font&gt;&lt;/li&gt;&lt;li&gt;&lt;font style=&quot;font-size: 18px;&quot;&gt;&lt;b&gt;Shoes&lt;/b&gt;&lt;/font&gt;&lt;/li&gt;&lt;li&gt;&lt;font style=&quot;font-size: 18px;&quot;&gt;&lt;b&gt;Bags&lt;/b&gt;&lt;/font&gt;&lt;/li&gt;&lt;li&gt;&lt;font style=&quot;font-size: 18px;&quot;&gt;&lt;b&gt;Fine Jewellery And Watches&lt;/b&gt;&lt;/font&gt;&lt;/li&gt;&lt;/ul&gt;&lt;div&gt;3.&amp;nbsp;&lt;span style=&quot;background-color: initial;&quot;&gt;&lt;b&gt;Optional fields&lt;/b&gt; can help increase the&amp;nbsp;&lt;/span&gt;&lt;/div&gt;&lt;div&gt;&lt;span style=&quot;background-color: initial;&quot;&gt;classification accuracy in the &lt;/span&gt;&lt;span style=&quot;background-color: initial;&quot;&gt;subsequent&lt;/span&gt;&lt;span style=&quot;background-color: initial;&quot;&gt; step.&lt;/span&gt;&lt;/div&gt;&lt;/font&gt;&lt;/div&gt;" style="text;html=1;align=left;verticalAlign=top;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;" parent="1" vertex="1">
<mxGeometry x="-1590" y="630" width="390" height="290" as="geometry" />
</mxCell>
<mxCell id="-v7OJhgbw4DDcAMX44wt-26" value="&lt;div style=&quot;font-size: 20px;&quot;&gt;&lt;font face=&quot;Helvetica&quot; style=&quot;font-size: 20px;&quot;&gt;Comment:&lt;/font&gt;&lt;/div&gt;&lt;div style=&quot;font-size: 20px;&quot;&gt;&lt;font face=&quot;USaPq8lgQ3RNGe1Oi-Ni&quot;&gt;&lt;font style=&quot;font-size: 20px;&quot;&gt;&lt;font style=&quot;font-size: 20px;&quot;&gt;1. &lt;b&gt;Cateogry&lt;/b&gt; field is directly transformed from raw data source.&lt;/font&gt;&lt;/font&gt;&lt;/font&gt;&lt;/div&gt;&lt;div style=&quot;font-size: 20px;&quot;&gt;&lt;font face=&quot;USaPq8lgQ3RNGe1Oi-Ni&quot;&gt;&lt;font style=&quot;font-size: 20px;&quot;&gt;&lt;font style=&quot;font-size: 20px;&quot;&gt;&lt;span style=&quot;white-space: pre;&quot;&gt;&#x9;&lt;/span&gt;a. Clothing -&amp;gt; clothing&lt;br&gt;&lt;span style=&quot;white-space: pre;&quot;&gt;&#x9;&lt;/span&gt;b. Shoes - &amp;gt; shoes&lt;br&gt;&lt;/font&gt;&lt;/font&gt;&lt;/font&gt;&lt;/div&gt;&lt;div style=&quot;font-size: 20px;&quot;&gt;&lt;font face=&quot;USaPq8lgQ3RNGe1Oi-Ni&quot;&gt;&lt;font style=&quot;font-size: 20px;&quot;&gt;&lt;font style=&quot;font-size: 20px;&quot;&gt;&lt;span style=&quot;white-space: pre;&quot;&gt;&#x9;&lt;/span&gt;c. Bags -&amp;gt; bags&lt;br&gt;&lt;span style=&quot;white-space: pre;&quot;&gt;&#x9;&lt;/span&gt;d. Accessories -&amp;gt; accessories&lt;br&gt;&lt;/font&gt;&lt;/font&gt;&lt;/font&gt;&lt;/div&gt;&lt;div style=&quot;font-size: 20px;&quot;&gt;&lt;font style=&quot;font-size: 20px;&quot;&gt;&lt;font style=&quot;font-size: 20px;&quot;&gt;&lt;span style=&quot;font-family: USaPq8lgQ3RNGe1Oi-Ni; white-space: pre;&quot;&gt;&#x9;&lt;/span&gt;e.&amp;nbsp;&lt;/font&gt;&lt;/font&gt;&lt;span style=&quot;background-color: initial;&quot;&gt;Fine Jewellery And Watches -&amp;gt;&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;background-color: initial;&quot;&gt;accessories&lt;/span&gt;&lt;/div&gt;&lt;div style=&quot;font-size: 20px;&quot;&gt;&lt;font face=&quot;USaPq8lgQ3RNGe1Oi-Ni&quot;&gt;&lt;font style=&quot;font-size: 20px;&quot;&gt;&lt;span style=&quot;background-color: initial;&quot;&gt;2. &lt;b&gt;S&lt;/b&gt;&lt;/span&gt;&lt;span style=&quot;background-color: initial;&quot;&gt;&lt;b&gt;ubcategory, gender, occasions&lt;/b&gt;&lt;/span&gt;&lt;span style=&quot;background-color: initial;&quot;&gt;&amp;nbsp;&lt;/span&gt;&lt;/font&gt;&lt;span style=&quot;background-color: initial;&quot;&gt;are&amp;nbsp;&lt;/span&gt;&lt;/font&gt;&lt;/div&gt;&lt;div style=&quot;font-size: 20px;&quot;&gt;&lt;font face=&quot;USaPq8lgQ3RNGe1Oi-Ni&quot;&gt;&lt;span style=&quot;background-color: initial;&quot;&gt;extracted and classified&lt;/span&gt;&lt;span style=&quot;background-color: initial;&quot;&gt; from the Raw Data&amp;nbsp;&lt;/span&gt;&lt;/font&gt;&lt;/div&gt;&lt;div style=&quot;font-size: 20px;&quot;&gt;&lt;font face=&quot;USaPq8lgQ3RNGe1Oi-Ni&quot; style=&quot;font-size: 20px;&quot;&gt;&lt;span style=&quot;background-color: initial;&quot;&gt;using the &lt;/span&gt;&lt;span style=&quot;background-color: initial;&quot;&gt;MLLM.&lt;/span&gt;&lt;/font&gt;&lt;/div&gt;&lt;div style=&quot;font-size: 20px;&quot;&gt;&lt;font face=&quot;USaPq8lgQ3RNGe1Oi-Ni&quot; style=&quot;font-size: 20px;&quot;&gt;&lt;span style=&quot;background-color: initial;&quot;&gt;2. Category, subcategory and&amp;nbsp;&lt;/span&gt;&lt;/font&gt;&lt;/div&gt;&lt;div style=&quot;font-size: 20px;&quot;&gt;&lt;font face=&quot;USaPq8lgQ3RNGe1Oi-Ni&quot; style=&quot;font-size: 20px;&quot;&gt;&lt;span style=&quot;background-color: initial;&quot;&gt;occasion taxonomy are detailed in attachment &quot;taxonomy.py&quot;.&lt;/span&gt;&lt;/font&gt;&lt;/div&gt;" style="text;html=1;align=left;verticalAlign=top;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;" parent="1" vertex="1">
<mxGeometry x="-1140" y="630" width="620" height="310" as="geometry" />
</mxCell>
<mxCell id="-v7OJhgbw4DDcAMX44wt-29" value="Vector Database" style="swimlane;whiteSpace=wrap;html=1;fontSize=20;" parent="1" vertex="1">
<mxGeometry x="-490" y="50" width="280" height="540" as="geometry" />
</mxCell>
<mxCell id="-v7OJhgbw4DDcAMX44wt-30" value="&lt;div&gt;&lt;div style=&quot;font-size: 20px;&quot;&gt;&lt;font color=&quot;#ff3e17&quot;&gt;item_id&lt;/font&gt;&lt;/div&gt;&lt;/div&gt;&lt;div style=&quot;font-size: 20px;&quot;&gt;&lt;font color=&quot;#007fff&quot;&gt;category&lt;/font&gt;&lt;/div&gt;&lt;div style=&quot;font-size: 20px;&quot;&gt;&lt;font color=&quot;#007fff&quot;&gt;subcategory&lt;/font&gt;&lt;/div&gt;&lt;div style=&quot;font-size: 20px;&quot;&gt;&lt;font color=&quot;#007fff&quot;&gt;gender&lt;/font&gt;&lt;br&gt;&lt;/div&gt;&lt;div style=&quot;font-size: 20px;&quot;&gt;&lt;font color=&quot;#ff3e17&quot;&gt;description&lt;/font&gt;&lt;/div&gt;&lt;div style=&quot;font-size: 20px;&quot;&gt;&lt;span style=&quot;background-color: initial;&quot;&gt;brand&lt;/span&gt;&lt;br&gt;&lt;/div&gt;&lt;div style=&quot;font-size: 20px;&quot;&gt;color&lt;/div&gt;&lt;div style=&quot;font-size: 20px;&quot;&gt;tags&lt;/div&gt;&lt;div style=&quot;font-size: 20px;&quot;&gt;price&lt;/div&gt;&lt;div style=&quot;font-size: 20px;&quot;&gt;url&lt;/div&gt;&lt;div style=&quot;font-size: 20px;&quot;&gt;batch_source&lt;/div&gt;&lt;div style=&quot;font-size: 20px;&quot;&gt;&lt;font color=&quot;#007fff&quot;&gt;occassion1&lt;/font&gt;&lt;/div&gt;&lt;div style=&quot;font-size: 20px;&quot;&gt;&lt;font color=&quot;#007fff&quot;&gt;occassion2&lt;/font&gt;&lt;/div&gt;&lt;div style=&quot;font-size: 20px;&quot;&gt;...&lt;/div&gt;" style="text;html=1;align=left;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=default;fillColor=none;fontSize=20;dashed=1;" parent="-v7OJhgbw4DDcAMX44wt-29" vertex="1">
<mxGeometry x="60" y="71" width="140" height="350" as="geometry" />
</mxCell>
<mxCell id="-6mM7cjyf4a1SxbvEv1c-9" value="&lt;font style=&quot;font-size: 20px;&quot;&gt;Metadata&lt;/font&gt;" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=#6c8ebf;fillColor=#dae8fc;" vertex="1" parent="-v7OJhgbw4DDcAMX44wt-29">
<mxGeometry x="60" y="27" width="110" height="40" as="geometry" />
</mxCell>
<mxCell id="-6mM7cjyf4a1SxbvEv1c-7" value="&lt;font style=&quot;font-size: 17px;&quot;&gt;Item Visual Embedding&lt;/font&gt;" style="shape=cube;whiteSpace=wrap;html=1;boundedLbl=1;backgroundOutline=1;darkOpacity=0.05;darkOpacity2=0.1;size=10;" vertex="1" parent="-v7OJhgbw4DDcAMX44wt-29">
<mxGeometry x="54" y="454" width="190" height="50" as="geometry" />
</mxCell>
<mxCell id="-v7OJhgbw4DDcAMX44wt-34" value="&lt;div style=&quot;font-size: 20px;&quot;&gt;&lt;font face=&quot;Helvetica&quot; style=&quot;font-size: 20px;&quot;&gt;Comment:&lt;/font&gt;&lt;/div&gt;&lt;div style=&quot;font-size: 20px;&quot;&gt;&lt;font face=&quot;USaPq8lgQ3RNGe1Oi-Ni&quot;&gt;&lt;font style=&quot;font-size: 20px;&quot;&gt;&lt;font style=&quot;font-size: 20px;&quot;&gt;1. Image of item will be embedded into&amp;nbsp;&lt;/font&gt;&lt;/font&gt;&lt;/font&gt;&lt;/div&gt;&lt;div style=&quot;font-size: 20px;&quot;&gt;&lt;font face=&quot;USaPq8lgQ3RNGe1Oi-Ni&quot;&gt;&lt;font style=&quot;font-size: 20px;&quot;&gt;&lt;font style=&quot;font-size: 20px;&quot;&gt;vector database.&lt;/font&gt;&lt;br&gt;&lt;/font&gt;&lt;/font&gt;&lt;/div&gt;&lt;div style=&quot;font-size: 20px;&quot;&gt;&lt;font face=&quot;USaPq8lgQ3RNGe1Oi-Ni&quot;&gt;&lt;font style=&quot;font-size: 20px;&quot;&gt;&lt;font style=&quot;font-size: 20px;&quot;&gt;2. &lt;b&gt;category, subcategory, gender and occasion&lt;/b&gt;&amp;nbsp;&lt;/font&gt;&lt;/font&gt;&lt;/font&gt;&lt;/div&gt;&lt;div style=&quot;font-size: 20px;&quot;&gt;&lt;font face=&quot;USaPq8lgQ3RNGe1Oi-Ni&quot;&gt;&lt;font style=&quot;font-size: 20px;&quot;&gt;&lt;font style=&quot;font-size: 20px;&quot;&gt;will&amp;nbsp;&lt;/font&gt;&lt;/font&gt;&lt;/font&gt;&lt;span style=&quot;font-family: USaPq8lgQ3RNGe1Oi-Ni; background-color: initial;&quot;&gt;be used for filtering.&lt;/span&gt;&lt;/div&gt;&lt;div style=&quot;font-size: 20px;&quot;&gt;&lt;font face=&quot;USaPq8lgQ3RNGe1Oi-Ni&quot;&gt;&lt;font style=&quot;font-size: 20px;&quot;&gt;&lt;font style=&quot;font-size: 20px;&quot;&gt;3.&amp;nbsp;&lt;/font&gt;&lt;/font&gt;&lt;/font&gt;&lt;span style=&quot;background-color: initial;&quot;&gt;&lt;b&gt;subcategory &lt;/b&gt;is used to maintain uniqueness.&lt;/span&gt;&lt;/div&gt;&lt;div style=&quot;font-size: 20px;&quot;&gt;&lt;font face=&quot;USaPq8lgQ3RNGe1Oi-Ni&quot;&gt;&lt;font style=&quot;font-size: 20px;&quot;&gt;&lt;font style=&quot;font-size: 20px;&quot;&gt;3. batch_source specifies the set of products&lt;/font&gt;&lt;/font&gt;&lt;/font&gt;&lt;/div&gt;&lt;div style=&quot;font-size: 20px;&quot;&gt;&lt;font face=&quot;USaPq8lgQ3RNGe1Oi-Ni&quot;&gt;&lt;font style=&quot;font-size: 20px;&quot;&gt;&lt;font style=&quot;font-size: 20px;&quot;&gt;that should be included in the retrieval.&lt;/font&gt;&lt;/font&gt;&lt;/font&gt;&lt;/div&gt;" style="text;html=1;align=left;verticalAlign=top;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;" parent="1" vertex="1">
<mxGeometry x="-510" y="670" width="500" height="210" as="geometry" />
</mxCell>
<mxCell id="-v7OJhgbw4DDcAMX44wt-48" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;fontSize=15;" parent="1" source="-v7OJhgbw4DDcAMX44wt-36" target="-v7OJhgbw4DDcAMX44wt-40" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="-v7OJhgbw4DDcAMX44wt-49" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;fontSize=15;" parent="1" source="-v7OJhgbw4DDcAMX44wt-36" target="-v7OJhgbw4DDcAMX44wt-38" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="-v7OJhgbw4DDcAMX44wt-53" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;fontSize=15;" parent="1" source="-v7OJhgbw4DDcAMX44wt-36" target="-v7OJhgbw4DDcAMX44wt-51" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="-v7OJhgbw4DDcAMX44wt-36" value="&lt;b style=&quot;font-size: 15px;&quot;&gt;Recommend Engine&lt;/b&gt;" style="ellipse;whiteSpace=wrap;html=1;fontSize=15;" parent="1" vertex="1">
<mxGeometry x="-450" y="1296" width="120" height="80" as="geometry" />
</mxCell>
<mxCell id="-v7OJhgbw4DDcAMX44wt-47" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=1;entryY=0.5;entryDx=0;entryDy=0;fontSize=15;" parent="1" source="-v7OJhgbw4DDcAMX44wt-38" target="-v7OJhgbw4DDcAMX44wt-44" edge="1">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="-220" y="1726" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="-v7OJhgbw4DDcAMX44wt-38" value="gender&lt;div style=&quot;font-size: 15px;&quot;&gt;category&lt;/div&gt;" style="rounded=1;whiteSpace=wrap;html=1;fontSize=15;" parent="1" vertex="1">
<mxGeometry x="-280" y="1417" width="120" height="60" as="geometry" />
</mxCell>
<mxCell id="-v7OJhgbw4DDcAMX44wt-43" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;fontSize=15;" parent="1" source="-v7OJhgbw4DDcAMX44wt-40" target="-v7OJhgbw4DDcAMX44wt-42" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="-v7OJhgbw4DDcAMX44wt-40" value="Description" style="rounded=1;whiteSpace=wrap;html=1;fontSize=15;" parent="1" vertex="1">
<mxGeometry x="-450" y="1417" width="120" height="60" as="geometry" />
</mxCell>
<mxCell id="-v7OJhgbw4DDcAMX44wt-45" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;fontSize=15;" parent="1" source="-v7OJhgbw4DDcAMX44wt-42" target="-v7OJhgbw4DDcAMX44wt-44" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="-v7OJhgbw4DDcAMX44wt-42" value="Text embeddings" style="rounded=1;whiteSpace=wrap;html=1;fontSize=15;" parent="1" vertex="1">
<mxGeometry x="-450" y="1556" width="120" height="60" as="geometry" />
</mxCell>
<mxCell id="-v7OJhgbw4DDcAMX44wt-60" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;fontSize=15;" parent="1" source="-v7OJhgbw4DDcAMX44wt-44" target="-v7OJhgbw4DDcAMX44wt-58" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="-v7OJhgbw4DDcAMX44wt-65" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;fontSize=15;" parent="1" source="-v7OJhgbw4DDcAMX44wt-44" target="-v7OJhgbw4DDcAMX44wt-63" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="-v7OJhgbw4DDcAMX44wt-44" value="Find most closed 500 items matches&amp;nbsp;&lt;div style=&quot;font-size: 15px;&quot;&gt;gender and category&lt;/div&gt;" style="rounded=1;whiteSpace=wrap;html=1;fontSize=15;" parent="1" vertex="1">
<mxGeometry x="-450" y="1675" width="120" height="102" as="geometry" />
</mxCell>
<mxCell id="-v7OJhgbw4DDcAMX44wt-64" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;fontSize=15;" parent="1" source="-v7OJhgbw4DDcAMX44wt-51" target="-v7OJhgbw4DDcAMX44wt-63" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="-v7OJhgbw4DDcAMX44wt-51" value="occasion_1&lt;br style=&quot;font-size: 15px;&quot;&gt;occasion_2&lt;div style=&quot;font-size: 15px;&quot;&gt;...&lt;/div&gt;" style="rounded=1;whiteSpace=wrap;html=1;fontSize=15;" parent="1" vertex="1">
<mxGeometry x="-620" y="1417" width="120" height="60" as="geometry" />
</mxCell>
<mxCell id="-v7OJhgbw4DDcAMX44wt-70" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=0;entryDx=0;entryDy=0;fontSize=15;" parent="1" source="-v7OJhgbw4DDcAMX44wt-58" target="-v7OJhgbw4DDcAMX44wt-68" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="-v7OJhgbw4DDcAMX44wt-58" value="similarity scores" style="rounded=1;whiteSpace=wrap;html=1;fontSize=15;" parent="1" vertex="1">
<mxGeometry x="-450" y="1827" width="120" height="60" as="geometry" />
</mxCell>
<mxCell id="-v7OJhgbw4DDcAMX44wt-67" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;fontSize=15;" parent="1" source="-v7OJhgbw4DDcAMX44wt-63" target="-v7OJhgbw4DDcAMX44wt-66" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="-v7OJhgbw4DDcAMX44wt-63" value="Calculate occasion score for each occasion" style="rounded=1;whiteSpace=wrap;html=1;fontSize=15;" parent="1" vertex="1">
<mxGeometry x="-620" y="1696" width="120" height="60" as="geometry" />
</mxCell>
<mxCell id="-v7OJhgbw4DDcAMX44wt-69" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;fontSize=15;" parent="1" source="-v7OJhgbw4DDcAMX44wt-66" target="-v7OJhgbw4DDcAMX44wt-68" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="-v7OJhgbw4DDcAMX44wt-66" value="occasion scores" style="rounded=1;whiteSpace=wrap;html=1;fontSize=15;" parent="1" vertex="1">
<mxGeometry x="-620" y="1827" width="120" height="60" as="geometry" />
</mxCell>
<mxCell id="-v7OJhgbw4DDcAMX44wt-72" value="Softmax&lt;div style=&quot;font-size: 15px;&quot;&gt;temperature = 0.5&lt;/div&gt;" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;fontSize=15;" parent="1" source="-v7OJhgbw4DDcAMX44wt-68" target="-v7OJhgbw4DDcAMX44wt-71" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="-v7OJhgbw4DDcAMX44wt-68" value="Weighted sum&lt;div style=&quot;font-size: 15px;&quot;&gt;0.6 * sim + 0.4 * occ&lt;/div&gt;" style="rounded=1;whiteSpace=wrap;html=1;fontSize=15;" parent="1" vertex="1">
<mxGeometry x="-560" y="1957" width="160" height="60" as="geometry" />
</mxCell>
<mxCell id="-v7OJhgbw4DDcAMX44wt-76" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;fontSize=15;" parent="1" source="-v7OJhgbw4DDcAMX44wt-71" target="-v7OJhgbw4DDcAMX44wt-75" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="-v7OJhgbw4DDcAMX44wt-71" value="Probability" style="rounded=1;whiteSpace=wrap;html=1;fontSize=15;" parent="1" vertex="1">
<mxGeometry x="-540" y="2087" width="120" height="60" as="geometry" />
</mxCell>
<mxCell id="-v7OJhgbw4DDcAMX44wt-75" value="Sample &amp;amp; End" style="ellipse;whiteSpace=wrap;html=1;rounded=1;fontSize=15;" parent="1" vertex="1">
<mxGeometry x="-520" y="2187" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="-v7OJhgbw4DDcAMX44wt-77" value="Retrieval Workflow" style="text;html=1;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=25;fontStyle=1" parent="1" vertex="1">
<mxGeometry x="-827" y="1169" width="310" height="70" as="geometry" />
</mxCell>
<mxCell id="-v7OJhgbw4DDcAMX44wt-82" value="" style="endArrow=none;dashed=1;html=1;rounded=0;endSize=0;startSize=0;" parent="1" edge="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="-1640" y="1154" as="sourcePoint" />
<mxPoint x="-16" y="1154" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="-v7OJhgbw4DDcAMX44wt-25" value="&lt;font style=&quot;font-size: 20px;&quot;&gt;Multimodal LLM&lt;/font&gt;" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0;entryY=0.5;entryDx=0;entryDy=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;" parent="1" source="-6mM7cjyf4a1SxbvEv1c-11" target="-v7OJhgbw4DDcAMX44wt-23" edge="1">
<mxGeometry x="0.1455" relative="1" as="geometry">
<mxPoint x="-1305" y="445" as="sourcePoint" />
<Array as="points">
<mxPoint x="-1250" y="525" />
<mxPoint x="-1250" y="315" />
</Array>
<mxPoint as="offset" />
</mxGeometry>
</mxCell>
<mxCell id="-v7OJhgbw4DDcAMX44wt-31" value="&lt;font style=&quot;font-size: 20px;&quot;&gt;Embedding Model&lt;/font&gt;" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0;entryDx=0;entryDy=20;entryPerimeter=0;" parent="1" source="-6mM7cjyf4a1SxbvEv1c-11" target="-6mM7cjyf4a1SxbvEv1c-7" edge="1">
<mxGeometry relative="1" as="geometry">
<mxPoint x="-530" y="529" as="targetPoint" />
<Array as="points">
<mxPoint x="-610" y="525" />
<mxPoint x="-436" y="524" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="-v7OJhgbw4DDcAMX44wt-23" value="Processed Data" style="swimlane;whiteSpace=wrap;html=1;fontSize=20;" parent="1" vertex="1">
<mxGeometry x="-1130" y="210" width="240" height="210" as="geometry" />
</mxCell>
<mxCell id="-v7OJhgbw4DDcAMX44wt-24" value="&lt;div&gt;&lt;font color=&quot;#007fff&quot;&gt;category&lt;/font&gt;&lt;/div&gt;&lt;div&gt;&lt;font color=&quot;#007fff&quot;&gt;subcategory&lt;br&gt;&lt;/font&gt;&lt;div style=&quot;font-size: 20px;&quot;&gt;&lt;font color=&quot;#007fff&quot;&gt;&lt;span style=&quot;background-color: initial;&quot;&gt;gender&lt;/span&gt;&lt;br&gt;&lt;/font&gt;&lt;/div&gt;&lt;div style=&quot;font-size: 20px;&quot;&gt;&lt;font color=&quot;#007fff&quot;&gt;applicable_occasions&lt;/font&gt;&lt;/div&gt;&lt;div style=&quot;font-size: 20px;&quot;&gt;&lt;font color=&quot;#007fff&quot;&gt;inapplicable_occasions&lt;/font&gt;&lt;br&gt;&lt;/div&gt;&lt;/div&gt;" style="text;html=1;align=left;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;fontSize=20;" parent="-v7OJhgbw4DDcAMX44wt-23" vertex="1">
<mxGeometry x="5" y="45" width="230" height="130" as="geometry" />
</mxCell>
<mxCell id="-6mM7cjyf4a1SxbvEv1c-18" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=-0.029;entryY=0.555;entryDx=0;entryDy=0;entryPerimeter=0;exitX=1.007;exitY=0.464;exitDx=0;exitDy=0;exitPerimeter=0;" edge="1" parent="1" source="-v7OJhgbw4DDcAMX44wt-24" target="-v7OJhgbw4DDcAMX44wt-30">
<mxGeometry relative="1" as="geometry">
<mxPoint x="-880" y="315.7088235294117" as="sourcePoint" />
<mxPoint x="-425.4199999999996" y="314.39999999999975" as="targetPoint" />
</mxGeometry>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>

Binary file not shown.

BIN
docs/LC Stylist Rules 总结.docx Executable file

Binary file not shown.

BIN
docs/Styling AI rules.xlsx Normal file

Binary file not shown.

BIN
docs/vera.docx Executable file

Binary file not shown.

View File

@@ -1,13 +0,0 @@
{
"type": "service_account",
"project_id": "aida-461108",
"private_key_id": "e0fa4eb8743342ee0d9af77296ec71101bf09706",
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDi/0QP9zvdmw/M\nff8koRw1lAEyC/zneayGmB5zHZYE9g+iZZ6U53Df6jgF5maMXoEhb7b62hSbY/Pv\niYcrUYsx1Za0BOp2H7S+WoZg87pYmBkBKY2aoazhnQyaOCB+YBp9E+ZL+yDVJ3JH\nvC1YTWl6CV13V+iPVo4z8kkWFDZWizPZm295JHbQyCHDWjS7hXzDhkXzRswqzsCF\n8f9C+uYCOxjhOZp8YQ7SirsbKFAHf1PCQMyF0zQvCmZ6NaTNZKMEga0kaxHsfwbA\n4YYH3QRXjjPvKI7dRLmjYuoVkVbyQTqOru/wSh0kTqUbhHHYqWAgqkODY4oagTAg\nE73fYqZPAgMBAAECggEAB+ayY/Xgl72X0VqKC33aIVlLU1vMdp2AbleUKRXyDK59\n69KqsEHVZs3ccQQh31KYybpS6FdimT1jlnEMi0BK3otciVvpx1PM3ZqsC8A1ZR5i\nMcKpraS2rkxiTb8/Y6FiZVEl1nuMGy9E0I9nFVLt0B7MVI+dm4OmmSieok3wzZaj\nr3oRRqX+YAtDEweZSB46HVUgeujkhNtizCCwu8agl7QX4cUjLBp/KzkgvCTRl118\n/BedCyrg+y+LtAT2raXuMrIiE/WdqFesQtt7rLIWCEqcpn0DSKrQ++LQvE9epz7y\nKdNVLeFiUmxSBHvTQRuR0jRMOZJreQnKSb6vJzodtQKBgQD8f3hCE9OIPs5xIxpz\nIRWHfghaPuvLMBZprxUqDe+QjN9EOVu5wFmpiMJybKA9kOybuW5GkbXg4U/kINEP\n5TogDjufWgggVUnfmovBpr3ld+EgRzmVDbY7Od0hBOoobYf34crVPSpMonKmMvjU\nZnhN9peQlO8/vw8Bkq/ebIGKxQKBgQDmJUB6BSEVsctA7lsQ/uBODVWdvh1Oxzhj\nKeP5fxvKDwcS5hxuG9k9x5a5LH8DAwfEEhtvNy05yMdCTL/CdP57L4uQbUG/+VQY\nodP6MGf3H38Iy+bFjPWlvhFlbyIbk8AdwsZm6a0n23jtjfFWYPwz/NVu2GLaea3s\nPj0JTJ5OAwKBgQCUkOgQcRv4wYO0nAP3E9NYGrcNapJQxqWZX3Qjf3mi4tCHkvw+\nikf3ccl/jByovPoLEospKJkMjWX1g72fDbAqplU8iLvZUnWaBJQQyGxZdTTYSjA+\nXIgJxx0uTXb9fJ0RJCC1YTzfRIIS+lDgoL5OmTZK0ucG6gMJWOb0B4IdRQKBgA6S\n0VBovr2W8p37+fxLh3ypz4Abp9NzPhjZcDw+Vk4nQXVq6OX4EXueBedX7/sK2BUM\naHxUbCK6mhOStJnluq+mRRoyMPWtHiwpIzk2k37Mmci+0LA1yuBh9swLi4dfhczc\npp+hsHtTpIa2dE/Z+F56ZjGMtkXLar4I+uh515RtAoGAQSLWfzsXoAdUsf16hE8s\nVTWmK7TiJH2UBH6Qa7OqEl4367BQjRm1EGRO3oUwyK/TmM2LSdA9G7fVpFydhkWR\nFJwuvWAsf3QT6FDup9jofgmQxvGS0fpJNENel5k1l5687gmkK6ulVSTfiRQFMYM0\nxJjMu9TnfDjmd2gG5GkWVqM=\n-----END PRIVATE KEY-----\n",
"client_email": "aida-gcs-test@aida-461108.iam.gserviceaccount.com",
"client_id": "117280364971550320059",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/aida-gcs-test%40aida-461108.iam.gserviceaccount.com",
"universe_domain": "googleapis.com"
}

22
pyproject.toml Executable file
View File

@@ -0,0 +1,22 @@
[project]
name = "lc-stylist-agent"
version = "0.1.0"
description = "Add your description here"
requires-python = "==3.10.*"
dependencies = [
"chromadb==1.1.1",
"google-cloud-storage==2.19.0",
"google-genai==1.45.0",
"litserve>=0.2.16",
"minio==7.2.18",
"numpy==1.24.4",
"open-clip-torch==2.24.0",
"opencv-python==4.9.0.80",
"pydantic-settings==2.11.0",
"pytorch-fid==0.3.0",
"redis==6.4.0",
"setuptools==80.9.0",
"torch-fidelity==0.3.0",
"torchmetrics==1.4.0.post0",
"transformers==4.41.1",
]

View File

@@ -0,0 +1,54 @@
# Outfit Style Guide
This guide outlines the preferred styling logic, brand affinities, patterns, and structure for Crystal's outfits, emphasizing **bold pattern mixing** balanced by .
## I. Core Preferences and Prohibitions
| Category | Preference (✔️) | Prohibition (❌) |
| :--- | :--- | :--- |
| **Brands/Material** | **Sacai** brand; **Denim** items | - |
| **Patterns** | **Plaid, Stripes, Floral, Leopard Print**; Active pattern clashing (min 2) | Monochromatic or single-pattern outfits |
| **Layering** | Max **2 items** on the upper body; Pattern mixing replaces layering | Excessive layering (叠穿) |
| **Fit/Silhouette** | Accepts **Oversize**; Flexible (can mix with slim-fit/leggings) | Tight-fitting or complicated silhouettes |
| **Shoe Styles** | **Boots, Platform Shoes, Pointed Low Heels, Mesh Shoes, Ballet Flats, White Sneakers** | Full High Heels (高跟鞋), Tall Boots |
| **Shoes (Pattern)** | **Solid Color** only for shoes | **Patterned/Printed** Shoes |
---
## I. Styling Pattern: Pattern Clash and Pure Balance
This stylist's style prioritizes visual impact through **clashing prints** rather than layering. Outfits are usually based on a bold print or denim, balanced by pure.
### 1. Classic Outfit Examples
| Style | Structure | Keywords |
| :--- | :--- | :--- |
| **Edgy Casual** | Milk Tea Oversize Blazer + Beige Shirt + Olive Green Cargo Pants + Black Pointed Low Heels | Utility, Sharp Contrast, Subtle Prints |
| **Elegant Flow** | Milk Tea Oversize Blazer + Beige Shirt + Dark Brown Slim-fit Pants + White Mesh Shoes or Ballet Flats | Mixed Fit, Comfort Contrast |
| **Sporty Mix** | Milk Tea Oversize Blazer + Dark Brown or Leopard Print Yoga Set + White Sneakers | Athleisure, Pattern Pop |
| **Heavy Print** | Floral Dress + Denim Pants | Pattern Clash (Floral + Denim), Natural Accent |
### 2. Outfit Extended Rules Summary
1. **Pattern Clash**: Actively mix **at least two** patterns (Plaid/Stripe/Floral/Leopard) in one outfit to maximize visual interest.
2. **Layering Limit**: Do not rely on multi-layering for depth; use print complexity instead. The upper body is limited to **max 2 pieces**.
3. **Fit Flexibility**: Oversize is welcome, but tight-fitting items (e.g., leggings) can be mixed. Prioritize durable fabrics like **Denim** for texture.
4. **Color Base**: No strict color restrictions on garments, but shoes must be **pure, solid colors**.
5. **Shoe Principle**: Shoes should provide height (low heel, platform, boots).
6. **Overall Balance**: If clothing patterns are complex, shoes must be **simple and pure** to ground the look. Style leans toward **mixed-casual and energetic**, avoiding blandness.
8. **Scene Adaptability**: Add **cargo pants/low heels** for casual settings; **yoga sets** for sportier looks; use **denim** to balance out heavy prints.
---
## Accessory Guide: Pure Balance
This section strictly outlines accessory selection, emphasizing Gold Tones and pure, solid colors to stabilize the outfit's primary focus on bold pattern clashing.
### I. Color, Pattern, and Material Constraints
Color & Tone: All jewelry must be in Gold Tones. Vector-style accessories are prohibited.
Accessory Pattern: Bags and shoes must be Solid Color only. Patterned or printed bags and shoes are strictly prohibited.
### II. Mandatory & Stacking Requirements
The use of accessories is essential to complete the look, focusing on stacking and specific shapes:
Mandatory Jewelry: A Necklace is mandatory (minimum one piece). A Watch must be included as part of the wrist stack.
Earrings: Must be Hoop Earrings.
Stacking: Encourage stacking of Bracelets and Rings alongside the mandatory watch.

30
stylist_guide/edi_en.md Normal file
View File

@@ -0,0 +1,30 @@
# Outfit Style Guide
This guide summarizes the preferred styling logic, colors, patterns, and structure for Edi's outfits, emphasizing functionality and a flexible approach to color and print mixing.
## I. Core Preferences and Prohibitions
Primary Colors: Black is preferred as a mixing base.
Dominant Colors: Maximum of 2 dominant colors per outfit (excluding Black and White).
Print Mixing: Prints are mixed, specifically by using the same pattern (e.g., stripes, plaid) but in different sizes (e.g., thin stripe mixed with wide stripe, large check with small check).
Prohibited Prints: None. All prints are acceptable, depending on the combination.
Shoes: Sneakers are worn most often for comfort and versatility.
Bags: Functional bags that can hold "everything" are preferred over mini bags. A specific favorite is the Lemaire croissant leather bag in a big size.
Color Mixing: All color tones are acceptable for mixing together, including highly varied tones (e.g., rainbow colors), as long as they create a mutual harmony.
## II. Styling Pattern: Functional and Harmonious
This stylist prioritizes practical, comfortable items (functional bag, sneakers) while embracing complex color and print compositions that lean on Black as a foundational element. The style aims for a unique, harmonious look achieved through flexible mixing.
## Accessory Style Guide
This section strictly outlines accessory selection, emphasizing a flexible approach to metal tones and highlighting the aesthetic of long-worn items.
### I. Color and Material Constraints
Metal Tones: Both Gold and Silver metals are preferred and should be mixed and matched together.
Aesthetic Preference: Items that show wear, such as Silver items that have changed color over time, are acceptable as they present a unique personal preference.
### II. Mandatory & Stacking Requirements
Mandatory Items: No specific jewelry piece is listed as mandatory, but the style encourages mixing both gold and silver jewelry.

52
stylist_guide/mini_en.md Normal file
View File

@@ -0,0 +1,52 @@
# Outfit Style Guide
This guide summarizes the preferred styling logic, colors, silhouettes, accessories, and layering patterns favored by the stylist.
## I. Core Preferences and Prohibitions
| Category | Preference (✔️) | Prohibition (❌) |
| :--- | :--- | :--- |
| **Primary Colors** | **Black, White, Gray, Earth Tones** (Solid colors preferred) | Avoid **Yellow** |
| **Accent Colors** | | Avoid **Bright/Vivid** colors dominating |
| **Patterns** | **Plaid/Checkered, Stripes** (Used as accents) | Strictly avoid **Floral/Small Prints (碎花)** |
| **Style/Fit** | **Loose, Comfortable, Oversize**; **Clean and Sharp** tailoring | Tight-fitting or complicated silhouettes |
| **Footwear** | **White Sneakers**, **Flats/Single Shoes**, **Loafers** | **Heels, Tall Boots** |
---
## II. Styling Pattern: Layering and Balance
This stylist's outfits emphasize **comfort** and **layering** (creating depth). The common structure is: **3 Upper Body Items + 1 Lower Body Item**. The pattern mainly uses solid primary colors, mixed with subtle patterns, aiming for a look that is **relaxed and casual yet polished**.
### 1. Classic Outfit Examples
| Style | Structure | Keywords |
| :--- | :--- | :--- |
| **Smart Casual** | Milk Tea Oversize Blazer + White Shirt + Loose Black Pants + White Sneakers | Neutral, Polished, Clean |
| **Everyday Casual** | Milk Tea Oversize Blazer + Dark Blue Jeans or Dark Gray Shorts + **Loafers**+ Detailed Vest/Tank Top | Relaxed, Comfortable, Detailed |
| **Sporty Chic** | Milk Tea Oversize Blazer + Black/Blue & White Striped Sleeveless Top + Black Leggings + White Sneakers | Mixed Style, Dynamic, Balanced |
| **Dress Base** | Floral Dress (**Exception**) + White Sneakers | Comfortable, Accents, Exception Handling |
### 2. Outfit Extended Rules Summary
1. **Color Palette**: Main garment colors are limited to **Black, White, Gray, and Earth Tones** to ensure a minimalist foundation.
2. **Layering Principle**: A minimum of **2-3 layers** on the upper body, with patterned edges (e.g., shirt collar) visible to add depth without complexity.
3. **Fit Requirements**: All items should lean toward a **loose/oversize** fit to prioritize comfort; tailoring must be **clean and sharp**.
4. **Pattern Restriction**: Only **plaid/checkered or stripes** are acceptable as subtle accents; **strictly no florals**, unless an extremely minimal exception is made.
5. **Shoe/Bag Coordination**: Footwear must be **flat and casual** (white sneakers are preferred).
6. **Overall Balance**: When the upper body is complex (layered), the lower body should remain **simple**; the overall style is **neutral and polished**, avoiding highly feminine heels or boots.
---
## Stylist Accessories Guide
### I. Gold Jewelry
Preference: Necklace, Bracelet, and Earrings must all be present and layered.
Prohibition: Avoid Large/Bulky Earrings or Vector-style accessories.
### II. Watch
Preference: Mandatory item.
Prohibition: N/A.
### III. Accent Colors
Preference: Added via accessories (e.g., scarves); Max 2 accent colors in total.
Prohibition: Avoid Bright/Vivid colors dominating the outfit.

35
stylist_guide/vera_en.md Normal file
View File

@@ -0,0 +1,35 @@
# Outfit Style Guide
This guide summarizes the preferred styling logic, colors, patterns, and structure for Vera's outfits, emphasizing harmony in color mixing and the use of statement bags.
## I. Core Preferences and Prohibitions
Primary Colors: Most often wears Khakis, Black, Creams, and sometimes Burgundies.
Dominant Colors: Maximum of two dominant colors per outfit, handled with care.
Print Mixing: Prints are mixed. Acceptable combinations include big polka dots with small polka dots or different floral prints.
Print Rule: Prints must be mixed only as long as the colors and shapes are harmonized.
Prohibited Prints: None. Most prints are considered wearable.
Color Mixing Prohibition: Avoid mixing warms and colds, and brights with muted tones.
Shoes: Wears loafers most often.
Bags: Prefers Bigger bags and bags that make a statement. Specific examples include The Row Margaux and Saint Laurent Icare.
Prohibited Bags: Not a fan of crossbody or micro mini bags.
## II. Styling Pattern: Harmonious Statement
This stylist prioritizes a core color palette of neutrals and deep tones (Khakis, Black, Creams, Burgundies) and uses print mixing (e.g., different sized polka dots or florals) only when colors and shapes are harmonized. The overall look is anchored by comfortable shoes (loafers) and a large, functional, statement bag.
## Accessory Style Guide
This section outlines accessory selection based on the desired overall aesthetic, emphasizing a balanced approach to metal tones.
### I. Metal Tone and Aesthetic Constraints
Jewelry Tone: Prefers Gold for a vintage and nostalgic feel.
Jewelry Tone: Prefers Silver for grungier looks.
Wear Preference: Wears both Gold and Silver, depending on the outfit's desired aesthetic.

2134
uv.lock generated Normal file

File diff suppressed because it is too large Load Diff