mikaassa commited on
Commit
d3fa640
·
verified ·
1 Parent(s): 386288f

Publish auto bundle 7z archive

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitignore +0 -27
  2. .zipignore +0 -56
  3. README.md +39 -192
  4. bundle/clip/.gitattributes +0 -38
  5. bundle/clip/.gitignore +0 -9
  6. bundle/clip/README.md +0 -228
  7. bundle/clip/app/listing_search.html +0 -1288
  8. bundle/clip/config.example.json +0 -9
  9. bundle/clip/data/full_clip_index/products_full_prices.json +0 -3
  10. bundle/clip/data/full_listing_index/cleaning_report.json +0 -8
  11. bundle/clip/data/full_listing_index/products_listing.index +0 -3
  12. bundle/clip/data/full_listing_index/products_listing_meta.runtime.json +0 -3
  13. bundle/clip/data/full_listing_index/progress.json +0 -6
  14. bundle/clip/data/yunqi_clip_training/last_checkpoint.pt +0 -3
  15. bundle/clip/docs/images/smart-workflow.png +0 -3
  16. bundle/clip/docs/images/workbench-overview.png +0 -3
  17. bundle/clip/models/open_clip_pytorch_model.bin +0 -3
  18. bundle/clip/work/build_full_listing_index.py +0 -395
  19. bundle/clip/work/full_clip_server.py +0 -615
  20. bundle/clip/work/full_listing_server.py +0 -951
  21. bundle/clip/work/stdio_listing_worker.py +0 -184
  22. bundle/python-cpu/Lib/site-packages/PIL/AvifImagePlugin.py +0 -3
  23. bundle/python-cpu/Lib/site-packages/PIL/BdfFontFile.py +0 -3
  24. bundle/python-cpu/Lib/site-packages/PIL/BlpImagePlugin.py +0 -3
  25. bundle/python-cpu/Lib/site-packages/PIL/BmpImagePlugin.py +0 -3
  26. bundle/python-cpu/Lib/site-packages/PIL/BufrStubImagePlugin.py +0 -3
  27. bundle/python-cpu/Lib/site-packages/PIL/ContainerIO.py +0 -3
  28. bundle/python-cpu/Lib/site-packages/PIL/CurImagePlugin.py +0 -3
  29. bundle/python-cpu/Lib/site-packages/PIL/DcxImagePlugin.py +0 -3
  30. bundle/python-cpu/Lib/site-packages/PIL/DdsImagePlugin.py +0 -3
  31. bundle/python-cpu/Lib/site-packages/PIL/EpsImagePlugin.py +0 -3
  32. bundle/python-cpu/Lib/site-packages/PIL/ExifTags.py +0 -3
  33. bundle/python-cpu/Lib/site-packages/PIL/FitsImagePlugin.py +0 -3
  34. bundle/python-cpu/Lib/site-packages/PIL/FliImagePlugin.py +0 -3
  35. bundle/python-cpu/Lib/site-packages/PIL/FontFile.py +0 -3
  36. bundle/python-cpu/Lib/site-packages/PIL/FpxImagePlugin.py +0 -3
  37. bundle/python-cpu/Lib/site-packages/PIL/FtexImagePlugin.py +0 -3
  38. bundle/python-cpu/Lib/site-packages/PIL/GbrImagePlugin.py +0 -3
  39. bundle/python-cpu/Lib/site-packages/PIL/GdImageFile.py +0 -3
  40. bundle/python-cpu/Lib/site-packages/PIL/GifImagePlugin.py +0 -3
  41. bundle/python-cpu/Lib/site-packages/PIL/GimpGradientFile.py +0 -3
  42. bundle/python-cpu/Lib/site-packages/PIL/GimpPaletteFile.py +0 -3
  43. bundle/python-cpu/Lib/site-packages/PIL/GribStubImagePlugin.py +0 -3
  44. bundle/python-cpu/Lib/site-packages/PIL/Hdf5StubImagePlugin.py +0 -3
  45. bundle/python-cpu/Lib/site-packages/PIL/IcnsImagePlugin.py +0 -3
  46. bundle/python-cpu/Lib/site-packages/PIL/IcoImagePlugin.py +0 -3
  47. bundle/python-cpu/Lib/site-packages/PIL/ImImagePlugin.py +0 -3
  48. bundle/python-cpu/Lib/site-packages/PIL/Image.py +0 -3
  49. bundle/python-cpu/Lib/site-packages/PIL/ImageChops.py +0 -3
  50. bundle/python-cpu/Lib/site-packages/PIL/ImageCms.py +0 -3
.gitignore DELETED
@@ -1,27 +0,0 @@
1
- cache/
2
- web/cache.json
3
- node_modules/
4
- cloudflare/node_modules/
5
- /config.json
6
- server/config.json
7
- server/cookie.json
8
- server/logs/
9
- bundle/clip/config.json
10
- bundle/clip/data/
11
- bundle/clip/models/
12
- *.zip
13
- *.7z
14
- *.rar
15
- bundle/python/
16
- bundle/python-cpu/
17
- bundle/python-runtime/
18
- bundle/_delete_pending_python_gpu/
19
- __pycache__/
20
- test_*.py
21
- build/
22
- runtime/*credential*
23
- runtime/cloud-config-state.json
24
- cloudflare/.npm-cache/
25
- cloudflare/.wrangler/
26
- cloudflare/.wrangler-config/
27
- cloudflare/.dev.vars
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.zipignore DELETED
@@ -1,56 +0,0 @@
1
- # Git
2
- .git/
3
- .gitignore
4
-
5
- # Python
6
- __pycache__/
7
- *.py[cod]
8
- *$py.class
9
- *.so
10
- .Python
11
- *.egg-info/
12
- /dist/
13
- /build/
14
- *.egg
15
-
16
- # Node
17
- npm-debug.log*
18
-
19
- # IDE
20
- .vscode/
21
- .idea/
22
- *.swp
23
- *.swo
24
- *~
25
-
26
- # OS
27
- .DS_Store
28
- Thumbs.db
29
- desktop.ini
30
-
31
- # Logs
32
- *.log
33
- logs/
34
-
35
- # Temp
36
- tmp/
37
- temp/
38
- *.tmp
39
- *.zip
40
- *.7z
41
- *.rar
42
- test_*.py
43
- /build/
44
-
45
- # Local data and private config
46
- cache/
47
- server/config.json
48
- bundle/clip/config.json
49
- bundle/python/
50
- bundle/_delete_pending_python_gpu/
51
-
52
- # Large runtime files (keep models and data but exclude huge node_modules)
53
- runtime/node/node_modules/
54
-
55
- # Test outputs
56
- test_output/
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
README.md CHANGED
@@ -1,143 +1,57 @@
1
  ---
2
  license: apache-2.0
3
- language:
4
- - zh
5
- - en
6
  tags:
7
- - clip
8
- - faiss
9
- - ecommerce
10
- - image-retrieval
11
- - product-search
12
  - temu
13
- pipeline_tag: image-feature-extraction
 
 
 
14
  ---
15
 
16
- <div align="center">
17
 
18
- # Bundle CLIP
19
 
20
- ### Temu product retrieval pack for local bundle-building workflows
 
 
21
 
22
- <p>
23
- <img alt="CLIP" src="https://img.shields.io/badge/CLIP-OpenCLIP-111827?style=for-the-badge">
24
- <img alt="FAISS" src="https://img.shields.io/badge/FAISS-Listing%20Index-2563eb?style=for-the-badge">
25
- <img alt="Local" src="https://img.shields.io/badge/Run-Local%209990-f97316?style=for-the-badge">
26
- <img alt="License" src="https://img.shields.io/badge/License-Apache--2.0-10b981?style=for-the-badge">
27
- </p>
28
 
29
- **Image in. Product ideas out.**
30
 
31
- This repository packages a local CLIP + FAISS retrieval service for ecommerce bundle discovery.
32
 
33
- </div>
34
-
35
- ---
 
 
36
 
37
- ## Screenshots
38
 
39
- <p align="center">
40
- <img src="docs/images/bundle-clip-search.png" alt="Bundle CLIP local listing search UI" width="92%">
41
- </p>
42
 
43
- <p align="center">
44
- <em>Local listing search page served by the bundled 9990 runtime.</em>
45
- </p>
46
 
47
- <table>
48
- <tr>
49
- <td width="50%">
50
- <img src="docs/images/smart-workflow.png" alt="Auto Bundle smart workflow">
51
- </td>
52
- <td width="50%">
53
- <img src="docs/images/workbench-overview.png" alt="Temu and 1688 workbench overview">
54
- </td>
55
- </tr>
56
- <tr>
57
- <td align="center"><strong>Smart Bundle Workflow</strong></td>
58
- <td align="center"><strong>Integrated Workbench</strong></td>
59
- </tr>
60
- </table>
61
 
62
- ## What This Is
63
 
64
- `bundle-clip` is a self-contained local retrieval bundle used by the Auto Bundle workbench. It combines:
65
 
66
- | Layer | Role |
67
- | --- | --- |
68
- | Kimi planning | Turns an input product image into bundle-search directions. |
69
- | OpenCLIP encoder | Embeds image and text prompts into the same semantic space. |
70
- | FAISS listing index | Retrieves high-similarity Temu listings from the prepared metadata. |
71
- | Local web UI | Serves a review page at `http://127.0.0.1:9990/`. |
72
 
73
- The pack is designed for fast local review, private experimentation, and offline-ish product matching after the LFS assets are downloaded.
74
 
75
- ## Repository Layout
76
 
77
  ```text
78
- bundle-clip/
79
- ├─ app/
80
- │ └─ listing_search.html
81
- ├─ docs/
82
- │ └─ images/
83
- ├─ work/
84
- │ ├─ full_listing_server.py
85
- │ ├─ stdio_listing_worker.py
86
- │ ├─ full_clip_server.py
87
- │ └─ build_full_listing_index.py
88
- ├─ models/
89
- │ └─ open_clip_pytorch_model.bin
90
- ├─ data/
91
- │ ├─ yunqi_clip_training/
92
- │ │ └─ last_checkpoint.pt
93
- │ ├─ full_listing_index/
94
- │ │ ├─ products_listing.index
95
- │ │ ├─ products_listing_meta.runtime.json
96
- │ │ ├─ cleaning_report.json
97
- │ │ └─ progress.json
98
- │ └─ full_clip_index/
99
- │ └─ products_full_prices.json
100
- ├─ config.example.json
101
- ├─ .gitattributes
102
- └─ README.md
103
- ```
104
-
105
- ## Included Assets
106
-
107
- | Asset | Purpose |
108
- | --- | --- |
109
- | `models/open_clip_pytorch_model.bin` | Base OpenCLIP model weights. |
110
- | `data/yunqi_clip_training/last_checkpoint.pt` | Fine-tuned checkpoint for the bundle-search domain. |
111
- | `data/full_listing_index/products_listing.index` | FAISS index for listing retrieval. |
112
- | `data/full_listing_index/products_listing_meta.runtime.json` | Runtime metadata used to render product cards. |
113
- | `data/full_clip_index/products_full_prices.json` | Price metadata used by the local search UI. |
114
-
115
- Large files are tracked with Git LFS. Run `git lfs pull` after cloning.
116
-
117
- ## Clone & Update
118
-
119
- Yes, you can clone this repository directly. The only catch is that the model weights and FAISS index are stored with Git LFS, so a complete first-time setup should be:
120
-
121
- ```powershell
122
- git lfs install
123
- git clone https://huggingface.co/mikaassa/bundle-clip
124
- cd bundle-clip
125
- git lfs pull
126
  ```
127
 
128
- If Git LFS is missing, the large assets will look like tiny text pointer files and the server will fail when loading the model or index.
129
-
130
- To update an existing local copy later:
131
-
132
- ```powershell
133
- cd bundle-clip
134
- git pull
135
- git lfs pull
136
- ```
137
-
138
- ## Quick Start
139
-
140
- ### 1. Clone With LFS
141
 
142
  ```powershell
143
  git lfs install
@@ -146,83 +60,16 @@ cd bundle-clip
146
  git lfs pull
147
  ```
148
 
149
- ### 2. Create Local Config
150
 
151
- ```powershell
152
- Copy-Item config.example.json config.json
153
- ```
154
-
155
- Fill in your private Kimi or Moonshot key:
156
-
157
- ```json
158
- {
159
- "kimi": {
160
- "api_key": "YOUR_KIMI_API_KEY",
161
- "endpoint": "https://api.moonshot.cn/v1/chat/completions",
162
- "model": "kimi-k2.6",
163
- "temperature": 0.6,
164
- "max_completion_tokens": 1200
165
- }
166
- }
167
- ```
168
-
169
- `config.json` is ignored by Git. Keep real API keys local.
170
-
171
- ### 3. Start The Local Service
172
-
173
- ```powershell
174
- python .\work\full_listing_server.py
175
- ```
176
-
177
- Open:
178
-
179
- ```text
180
- http://127.0.0.1:9990/
181
- ```
182
-
183
- ## Workflow
184
-
185
- ```mermaid
186
- flowchart LR
187
- A[Product image] --> B[Kimi bundle directions]
188
- B --> C[English listing prompt]
189
- C --> D[OpenCLIP embedding]
190
- D --> E[FAISS nearest-neighbor search]
191
- E --> F[Temu product cards]
192
- ```
193
-
194
- The local UI supports two review paths:
195
-
196
- | Mode | Use Case |
197
- | --- | --- |
198
- | Image bundle search | Upload a product image, let Kimi produce bundle directions, then retrieve matching listings. |
199
- | Direct CLIP search | Enter a manual listing keyword or prompt and search the index directly. |
200
-
201
- ## Runtime Notes
202
-
203
- - Default local port: `9990`
204
- - Main service entry: `work/full_listing_server.py`
205
- - Workbench worker entry: `work/stdio_listing_worker.py`
206
- - Public page: `app/listing_search.html`
207
- - Main metadata image field: `MAINIMAGE`
208
-
209
- The service prefers repository-local `data/` and `models/` paths first. Older absolute-path fallbacks are only used when local assets are missing.
210
-
211
- ## Safety
212
-
213
- - Do not commit `config.json`, `.env`, logs, or local cache output.
214
- - API keys should be supplied through `config.json` or environment variables only.
215
- - This repository is a local runtime pack, not a public hosted inference endpoint.
216
- - Product metadata and retrieval quality depend on the bundled index snapshot.
217
-
218
- ## Environment Overrides
219
-
220
- ```powershell
221
- $env:MOONSHOT_API_KEY="YOUR_KIMI_API_KEY"
222
- $env:KIMI_ENDPOINT="https://api.moonshot.cn/v1/chat/completions"
223
- $env:KIMI_MODEL="kimi-k2.6"
224
- ```
225
 
226
- ## License
227
 
228
- Released under the Apache 2.0 license. Check upstream model and data-source terms before redistribution or commercial deployment.
 
 
 
1
  ---
2
  license: apache-2.0
3
+ pipeline_tag: image-to-text
 
 
4
  tags:
 
 
 
 
 
5
  - temu
6
+ - product-bundling
7
+ - clip
8
+ - offline-package
9
+ - windows
10
  ---
11
 
12
+ # 自动组货
13
 
14
+ > 一个面向 Temu / 1688 组货工作流的 Windows 离线整包归档。
15
 
16
+ ![Version](https://img.shields.io/badge/package-7z_archive-2f6feb?style=for-the-badge)
17
+ ![Runtime](https://img.shields.io/badge/runtime-Windows_CPU-22c55e?style=for-the-badge)
18
+ ![Status](https://img.shields.io/badge/status-warehouse_only-ff7a45?style=for-the-badge)
19
 
20
+ ## What Is Inside
 
 
 
 
 
21
 
22
+ `自动组货.7z` 是完整项目压缩包,用来做仓库存档和迁移分发。它不是 Hugging Face Space,不会在网页上直接运行。
23
 
24
+ 这个包主要包含:
25
 
26
+ - 自动组货前端工作台
27
+ - 本地后端服务
28
+ - 浏览器扩展
29
+ - CLIP 组货相关模型与索引
30
+ - Windows 启动脚本与离线运行环境
31
 
32
+ ## Preview
33
 
34
+ ### Workbench
 
 
35
 
36
+ ![Workbench overview](docs/images/workbench-overview.png)
 
 
37
 
38
+ ### Workflow
 
 
 
 
 
 
 
 
 
 
 
 
 
39
 
40
+ ![Smart workflow](docs/images/smart-workflow.png)
41
 
42
+ ### CLIP Search
43
 
44
+ ![Bundle CLIP search](docs/images/bundle-clip-search.png)
 
 
 
 
 
45
 
46
+ ## Download
47
 
48
+ 直接在本页下载:
49
 
50
  ```text
51
+ 自动组货.7z
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  ```
53
 
54
+ 或者用 Git LFS 克隆:
 
 
 
 
 
 
 
 
 
 
 
 
55
 
56
  ```powershell
57
  git lfs install
 
60
  git lfs pull
61
  ```
62
 
63
+ ## Use On A New Computer
64
 
65
+ 1. 解压 `自动组货.7z`
66
+ 2. 进入解压后的 `自动组货` 文件夹
67
+ 3. 双击 `启动.bat`
68
+ 4. 如果是第一次部署,按项目内的示例配置补齐本机配置文件
69
+ 5. 浏览器扩展需要在 Chrome / Edge 里以开发者模式加载 `extension` 文件夹
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
 
71
+ ## Notes
72
 
73
+ - 这个仓库只作为大文件仓库使用。
74
+ - 配置、Cookie、授权凭据这类本机敏感文件不应该提交到仓库。
75
+ - 如果重新打包发布,建议保持文件名 `自动组货.7z`,这样下载链接和说明都不用改。
bundle/clip/.gitattributes DELETED
@@ -1,38 +0,0 @@
1
- data/** filter=lfs diff=lfs merge=lfs -text
2
- models/** filter=lfs diff=lfs merge=lfs -text
3
- *.7z filter=lfs diff=lfs merge=lfs -text
4
- *.arrow filter=lfs diff=lfs merge=lfs -text
5
- *.bin filter=lfs diff=lfs merge=lfs -text
6
- *.bz2 filter=lfs diff=lfs merge=lfs -text
7
- *.ckpt filter=lfs diff=lfs merge=lfs -text
8
- *.ftz filter=lfs diff=lfs merge=lfs -text
9
- *.gz filter=lfs diff=lfs merge=lfs -text
10
- *.h5 filter=lfs diff=lfs merge=lfs -text
11
- *.index filter=lfs diff=lfs merge=lfs -text
12
- *.joblib filter=lfs diff=lfs merge=lfs -text
13
- *.lfs.* filter=lfs diff=lfs merge=lfs -text
14
- *.mlmodel filter=lfs diff=lfs merge=lfs -text
15
- *.model filter=lfs diff=lfs merge=lfs -text
16
- *.msgpack filter=lfs diff=lfs merge=lfs -text
17
- *.npy filter=lfs diff=lfs merge=lfs -text
18
- *.npz filter=lfs diff=lfs merge=lfs -text
19
- *.onnx filter=lfs diff=lfs merge=lfs -text
20
- *.ot filter=lfs diff=lfs merge=lfs -text
21
- *.parquet filter=lfs diff=lfs merge=lfs -text
22
- *.pb filter=lfs diff=lfs merge=lfs -text
23
- *.pickle filter=lfs diff=lfs merge=lfs -text
24
- *.pkl filter=lfs diff=lfs merge=lfs -text
25
- *.pt filter=lfs diff=lfs merge=lfs -text
26
- *.pth filter=lfs diff=lfs merge=lfs -text
27
- *.rar filter=lfs diff=lfs merge=lfs -text
28
- *.safetensors filter=lfs diff=lfs merge=lfs -text
29
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
30
- *.tar.* filter=lfs diff=lfs merge=lfs -text
31
- *.tar filter=lfs diff=lfs merge=lfs -text
32
- *.tflite filter=lfs diff=lfs merge=lfs -text
33
- *.tgz filter=lfs diff=lfs merge=lfs -text
34
- *.wasm filter=lfs diff=lfs merge=lfs -text
35
- *.xz filter=lfs diff=lfs merge=lfs -text
36
- *.zip filter=lfs diff=lfs merge=lfs -text
37
- *.zst filter=lfs diff=lfs merge=lfs -text
38
- *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bundle/clip/.gitignore DELETED
@@ -1,9 +0,0 @@
1
- config.json
2
- .env
3
- __pycache__/
4
- *.pyc
5
- logs/
6
- *.log
7
- build/
8
- dist/
9
- *.spec
 
 
 
 
 
 
 
 
 
 
bundle/clip/README.md DELETED
@@ -1,228 +0,0 @@
1
- ---
2
- license: apache-2.0
3
- language:
4
- - zh
5
- - en
6
- tags:
7
- - clip
8
- - faiss
9
- - ecommerce
10
- - image-retrieval
11
- - product-search
12
- - temu
13
- pipeline_tag: image-feature-extraction
14
- ---
15
-
16
- <div align="center">
17
-
18
- # Bundle CLIP
19
-
20
- ### Temu product retrieval pack for local bundle-building workflows
21
-
22
- <p>
23
- <img alt="CLIP" src="https://img.shields.io/badge/CLIP-OpenCLIP-111827?style=for-the-badge">
24
- <img alt="FAISS" src="https://img.shields.io/badge/FAISS-Listing%20Index-2563eb?style=for-the-badge">
25
- <img alt="Local" src="https://img.shields.io/badge/Run-Local%209990-f97316?style=for-the-badge">
26
- <img alt="License" src="https://img.shields.io/badge/License-Apache--2.0-10b981?style=for-the-badge">
27
- </p>
28
-
29
- **Image in. Product ideas out.**
30
-
31
- This repository packages a local CLIP + FAISS retrieval service for ecommerce bundle discovery.
32
-
33
- </div>
34
-
35
- ---
36
-
37
- ## Screenshots
38
-
39
- <p align="center">
40
- <img src="docs/images/bundle-clip-search.png" alt="Bundle CLIP local listing search UI" width="92%">
41
- </p>
42
-
43
- <p align="center">
44
- <em>Local listing search page served by the bundled 9990 runtime.</em>
45
- </p>
46
-
47
- <table>
48
- <tr>
49
- <td width="50%">
50
- <img src="docs/images/smart-workflow.png" alt="Auto Bundle smart workflow">
51
- </td>
52
- <td width="50%">
53
- <img src="docs/images/workbench-overview.png" alt="Temu and 1688 workbench overview">
54
- </td>
55
- </tr>
56
- <tr>
57
- <td align="center"><strong>Smart Bundle Workflow</strong></td>
58
- <td align="center"><strong>Integrated Workbench</strong></td>
59
- </tr>
60
- </table>
61
-
62
- ## What This Is
63
-
64
- `bundle-clip` is a self-contained local retrieval bundle used by the Auto Bundle workbench. It combines:
65
-
66
- | Layer | Role |
67
- | --- | --- |
68
- | Kimi planning | Turns an input product image into bundle-search directions. |
69
- | OpenCLIP encoder | Embeds image and text prompts into the same semantic space. |
70
- | FAISS listing index | Retrieves high-similarity Temu listings from the prepared metadata. |
71
- | Local web UI | Serves a review page at `http://127.0.0.1:9990/`. |
72
-
73
- The pack is designed for fast local review, private experimentation, and offline-ish product matching after the LFS assets are downloaded.
74
-
75
- ## Repository Layout
76
-
77
- ```text
78
- bundle-clip/
79
- ├─ app/
80
- │ └─ listing_search.html
81
- ├─ docs/
82
- │ └─ images/
83
- ├─ work/
84
- │ ├─ full_listing_server.py
85
- │ ├─ stdio_listing_worker.py
86
- │ ├─ full_clip_server.py
87
- │ └─ build_full_listing_index.py
88
- ├─ models/
89
- │ └─ open_clip_pytorch_model.bin
90
- ├─ data/
91
- │ ├─ yunqi_clip_training/
92
- │ │ └─ last_checkpoint.pt
93
- │ ├─ full_listing_index/
94
- │ │ ├─ products_listing.index
95
- │ │ ├─ products_listing_meta.runtime.json
96
- │ │ ├─ cleaning_report.json
97
- │ │ └─ progress.json
98
- │ └─ full_clip_index/
99
- │ └─ products_full_prices.json
100
- ├─ config.example.json
101
- ├─ .gitattributes
102
- └─ README.md
103
- ```
104
-
105
- ## Included Assets
106
-
107
- | Asset | Purpose |
108
- | --- | --- |
109
- | `models/open_clip_pytorch_model.bin` | Base OpenCLIP model weights. |
110
- | `data/yunqi_clip_training/last_checkpoint.pt` | Fine-tuned checkpoint for the bundle-search domain. |
111
- | `data/full_listing_index/products_listing.index` | FAISS index for listing retrieval. |
112
- | `data/full_listing_index/products_listing_meta.runtime.json` | Runtime metadata used to render product cards. |
113
- | `data/full_clip_index/products_full_prices.json` | Price metadata used by the local search UI. |
114
-
115
- Large files are tracked with Git LFS. Run `git lfs pull` after cloning.
116
-
117
- ## Clone & Update
118
-
119
- Yes, you can clone this repository directly. The only catch is that the model weights and FAISS index are stored with Git LFS, so a complete first-time setup should be:
120
-
121
- ```powershell
122
- git lfs install
123
- git clone https://huggingface.co/mikaassa/bundle-clip
124
- cd bundle-clip
125
- git lfs pull
126
- ```
127
-
128
- If Git LFS is missing, the large assets will look like tiny text pointer files and the server will fail when loading the model or index.
129
-
130
- To update an existing local copy later:
131
-
132
- ```powershell
133
- cd bundle-clip
134
- git pull
135
- git lfs pull
136
- ```
137
-
138
- ## Quick Start
139
-
140
- ### 1. Clone With LFS
141
-
142
- ```powershell
143
- git lfs install
144
- git clone https://huggingface.co/mikaassa/bundle-clip
145
- cd bundle-clip
146
- git lfs pull
147
- ```
148
-
149
- ### 2. Create Local Config
150
-
151
- ```powershell
152
- Copy-Item config.example.json config.json
153
- ```
154
-
155
- Fill in your private Kimi or Moonshot key:
156
-
157
- ```json
158
- {
159
- "kimi": {
160
- "api_key": "YOUR_KIMI_API_KEY",
161
- "endpoint": "https://api.moonshot.cn/v1/chat/completions",
162
- "model": "kimi-k2.6",
163
- "temperature": 0.6,
164
- "max_completion_tokens": 1200
165
- }
166
- }
167
- ```
168
-
169
- `config.json` is ignored by Git. Keep real API keys local.
170
-
171
- ### 3. Start The Local Service
172
-
173
- ```powershell
174
- python .\work\full_listing_server.py
175
- ```
176
-
177
- Open:
178
-
179
- ```text
180
- http://127.0.0.1:9990/
181
- ```
182
-
183
- ## Workflow
184
-
185
- ```mermaid
186
- flowchart LR
187
- A[Product image] --> B[Kimi bundle directions]
188
- B --> C[English listing prompt]
189
- C --> D[OpenCLIP embedding]
190
- D --> E[FAISS nearest-neighbor search]
191
- E --> F[Temu product cards]
192
- ```
193
-
194
- The local UI supports two review paths:
195
-
196
- | Mode | Use Case |
197
- | --- | --- |
198
- | Image bundle search | Upload a product image, let Kimi produce bundle directions, then retrieve matching listings. |
199
- | Direct CLIP search | Enter a manual listing keyword or prompt and search the index directly. |
200
-
201
- ## Runtime Notes
202
-
203
- - Default local port: `9990`
204
- - Main service entry: `work/full_listing_server.py`
205
- - Workbench worker entry: `work/stdio_listing_worker.py`
206
- - Public page: `app/listing_search.html`
207
- - Main metadata image field: `MAINIMAGE`
208
-
209
- The service prefers repository-local `data/` and `models/` paths first. Older absolute-path fallbacks are only used when local assets are missing.
210
-
211
- ## Safety
212
-
213
- - Do not commit `config.json`, `.env`, logs, or local cache output.
214
- - API keys should be supplied through `config.json` or environment variables only.
215
- - This repository is a local runtime pack, not a public hosted inference endpoint.
216
- - Product metadata and retrieval quality depend on the bundled index snapshot.
217
-
218
- ## Environment Overrides
219
-
220
- ```powershell
221
- $env:MOONSHOT_API_KEY="YOUR_KIMI_API_KEY"
222
- $env:KIMI_ENDPOINT="https://api.moonshot.cn/v1/chat/completions"
223
- $env:KIMI_MODEL="kimi-k2.6"
224
- ```
225
-
226
- ## License
227
-
228
- Released under the Apache 2.0 license. Check upstream model and data-source terms before redistribution or commercial deployment.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bundle/clip/app/listing_search.html DELETED
@@ -1,1288 +0,0 @@
1
- <!doctype html>
2
- <html lang="zh-CN">
3
- <head>
4
- <meta charset="utf-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1">
6
- <title>纯 Listing 组货检索</title>
7
- <style>
8
- :root {
9
- color-scheme: light;
10
- --ink: #231f1a;
11
- --ink-soft: #3f3830;
12
- --muted: #7b7167;
13
- --line: #ded5c9;
14
- --line-soft: #ece4da;
15
- --panel: rgba(255, 252, 246, .88);
16
- --panel-solid: #fffcf6;
17
- --page: #f4efe7;
18
- --paper: #fbf7ef;
19
- --clay: #c56d3d;
20
- --clay-dark: #9e4f2b;
21
- --olive: #65724f;
22
- --red: #b44f37;
23
- --shadow: 0 22px 54px rgba(74, 51, 30, .10);
24
- font-family: "Fraunces", "Iowan Old Style", "Noto Serif SC", "Microsoft YaHei UI", serif;
25
- }
26
-
27
- * { box-sizing: border-box; }
28
- body {
29
- min-height: 100vh;
30
- margin: 0;
31
- color: var(--ink);
32
- background:
33
- radial-gradient(circle at 9% 4%, rgba(219, 157, 102, .20), transparent 28%),
34
- radial-gradient(circle at 92% 0%, rgba(101, 114, 79, .12), transparent 26%),
35
- linear-gradient(135deg, #f8f1e7 0%, #f1eadf 48%, #eee4d7 100%);
36
- }
37
- body::before {
38
- position: fixed;
39
- inset: 0;
40
- pointer-events: none;
41
- content: "";
42
- opacity: .42;
43
- background-image:
44
- linear-gradient(rgba(120, 92, 63, .045) 1px, transparent 1px),
45
- linear-gradient(90deg, rgba(120, 92, 63, .035) 1px, transparent 1px);
46
- background-size: 42px 42px;
47
- mask-image: linear-gradient(to bottom, #000, transparent 82%);
48
- }
49
- button, input, textarea {
50
- font: inherit;
51
- }
52
- .shell {
53
- width: min(1500px, calc(100% - 40px));
54
- margin: 0 auto;
55
- padding: 30px 0 44px;
56
- }
57
- .topbar {
58
- display: grid;
59
- grid-template-columns: minmax(0, 1fr) auto;
60
- gap: 22px;
61
- align-items: end;
62
- margin-bottom: 18px;
63
- padding: 18px 20px;
64
- border: 1px solid rgba(94, 73, 55, .12);
65
- border-radius: 28px;
66
- background: rgba(255, 251, 244, .68);
67
- box-shadow: 0 1px 0 rgba(255,255,255,.7) inset;
68
- backdrop-filter: blur(18px);
69
- }
70
- .eyebrow {
71
- margin: 0 0 8px;
72
- color: var(--clay-dark);
73
- font: 700 11px/1.2 "Microsoft YaHei UI", "Noto Sans SC", sans-serif;
74
- letter-spacing: .16em;
75
- text-transform: uppercase;
76
- }
77
- h1 {
78
- margin: 0;
79
- font-size: clamp(30px, 4vw, 48px);
80
- font-weight: 760;
81
- letter-spacing: -.045em;
82
- line-height: 1.05;
83
- }
84
- .subtitle {
85
- margin: 10px 0 0;
86
- max-width: 860px;
87
- color: var(--muted);
88
- font: 14px/1.8 "Microsoft YaHei UI", "Noto Sans SC", sans-serif;
89
- }
90
- .layout {
91
- display: flex;
92
- min-height: calc(100vh - 170px);
93
- flex-direction: column;
94
- gap: 18px;
95
- }
96
- .panel {
97
- border: 1px solid rgba(96, 73, 51, .14);
98
- border-radius: 28px;
99
- background: var(--panel);
100
- box-shadow: var(--shadow), 0 1px 0 rgba(255,255,255,.72) inset;
101
- backdrop-filter: blur(20px);
102
- }
103
- .controls {
104
- padding: 18px;
105
- }
106
- .section-title {
107
- margin: 0 0 14px;
108
- font-size: 19px;
109
- letter-spacing: -.02em;
110
- }
111
- .field-label {
112
- display: block;
113
- margin: 17px 0 8px;
114
- color: var(--ink-soft);
115
- font: 800 12px/1.4 "Microsoft YaHei UI", "Noto Sans SC", sans-serif;
116
- letter-spacing: .02em;
117
- }
118
- .dropzone {
119
- display: grid;
120
- min-height: 226px;
121
- place-items: center;
122
- padding: 16px;
123
- border: 1.5px dashed rgba(126, 103, 80, .34);
124
- border-radius: 22px;
125
- background:
126
- linear-gradient(180deg, rgba(255,255,255,.56), rgba(255,248,237,.72)),
127
- repeating-linear-gradient(-45deg, rgba(159, 111, 70, .035) 0 1px, transparent 1px 9px);
128
- text-align: center;
129
- cursor: pointer;
130
- transition: transform .18s cubic-bezier(.2,.8,.2,1), border-color .18s cubic-bezier(.2,.8,.2,1), background .18s cubic-bezier(.2,.8,.2,1);
131
- }
132
- .dropzone:hover,
133
- .dropzone.active {
134
- transform: translateY(-1px);
135
- border-color: rgba(197, 109, 61, .74);
136
- background: #fff9f0;
137
- }
138
- .dropzone input { display: none; }
139
- .preview {
140
- display: none;
141
- width: 100%;
142
- max-height: 270px;
143
- border-radius: 18px;
144
- object-fit: contain;
145
- background: #eee5d8;
146
- box-shadow: 0 16px 32px rgba(61, 43, 25, .12);
147
- }
148
- .preview.visible { display: block; }
149
- .drop-copy.hidden { display: none; }
150
- .drop-copy strong {
151
- display: block;
152
- margin-bottom: 8px;
153
- font-size: 16px;
154
- }
155
- .drop-copy span {
156
- color: var(--muted);
157
- font: 12px/1.7 "Microsoft YaHei UI", "Noto Sans SC", sans-serif;
158
- }
159
- textarea {
160
- width: 100%;
161
- min-height: 112px;
162
- resize: vertical;
163
- padding: 13px 14px;
164
- border: 1px solid var(--line);
165
- border-radius: 16px;
166
- outline: 0;
167
- color: var(--ink);
168
- line-height: 1.62;
169
- background: rgba(255, 253, 249, .86);
170
- box-shadow: 0 1px 0 rgba(255,255,255,.75) inset;
171
- font-family: "Microsoft YaHei UI", "Noto Sans SC", sans-serif;
172
- }
173
- textarea:focus,
174
- input:focus {
175
- border-color: rgba(197, 109, 61, .72);
176
- box-shadow: 0 0 0 4px rgba(197, 109, 61, .13), 0 1px 0 rgba(255,255,255,.75) inset;
177
- }
178
- .options {
179
- display: grid;
180
- grid-template-columns: 1fr 1fr;
181
- gap: 10px;
182
- }
183
- .option small {
184
- display: block;
185
- margin-bottom: 6px;
186
- color: var(--muted);
187
- font: 12px/1.4 "Microsoft YaHei UI", "Noto Sans SC", sans-serif;
188
- }
189
- .option input {
190
- width: 100%;
191
- padding: 11px 12px;
192
- border: 1px solid var(--line);
193
- border-radius: 15px;
194
- outline: 0;
195
- color: var(--ink);
196
- background: rgba(255, 253, 249, .92);
197
- font-family: "Microsoft YaHei UI", "Noto Sans SC", sans-serif;
198
- }
199
- .button-row {
200
- display: grid;
201
- grid-template-columns: 1fr 1fr;
202
- gap: 10px;
203
- margin-top: 12px;
204
- }
205
- button {
206
- min-height: 44px;
207
- border-radius: 999px;
208
- font: 850 14px/1 "Microsoft YaHei UI", "Noto Sans SC", sans-serif;
209
- cursor: pointer;
210
- transition: transform .16s cubic-bezier(.2,.8,.2,1), box-shadow .16s cubic-bezier(.2,.8,.2,1), background .16s cubic-bezier(.2,.8,.2,1);
211
- }
212
- button:hover:not(:disabled) {
213
- transform: translateY(-1px);
214
- }
215
- .primary {
216
- border: 1px solid rgba(116, 55, 25, .14);
217
- color: #fffaf3;
218
- background: linear-gradient(180deg, var(--clay), var(--clay-dark));
219
- box-shadow: 0 12px 22px rgba(158, 79, 43, .22), 0 1px 0 rgba(255,255,255,.28) inset;
220
- }
221
- .secondary {
222
- border: 1px solid rgba(103, 82, 62, .18);
223
- color: var(--ink);
224
- background: rgba(255, 252, 246, .76);
225
- box-shadow: 0 1px 0 rgba(255,255,255,.72) inset;
226
- }
227
- button:disabled {
228
- cursor: wait;
229
- opacity: .62;
230
- transform: none;
231
- }
232
- .hint {
233
- margin: 12px 0 0;
234
- color: var(--muted);
235
- font: 12px/1.75 "Microsoft YaHei UI", "Noto Sans SC", sans-serif;
236
- }
237
- .build-panel {
238
- margin-top: 16px;
239
- padding: 14px;
240
- border: 1px solid rgba(104, 84, 65, .13);
241
- border-radius: 20px;
242
- background: rgba(246, 238, 228, .62);
243
- }
244
- .progress-line {
245
- display: flex;
246
- justify-content: space-between;
247
- gap: 12px;
248
- color: var(--muted);
249
- font: 12px/1.45 "Microsoft YaHei UI", "Noto Sans SC", sans-serif;
250
- }
251
- .bar {
252
- height: 8px;
253
- margin: 10px 0;
254
- overflow: hidden;
255
- border-radius: 999px;
256
- background: #e8dccf;
257
- }
258
- .bar span {
259
- display: block;
260
- width: 0;
261
- height: 100%;
262
- background: linear-gradient(90deg, var(--olive), #9c8d63);
263
- transition: width .25s cubic-bezier(.2,.8,.2,1);
264
- }
265
- .log {
266
- height: 140px;
267
- margin: 10px 0 0;
268
- padding: 12px;
269
- overflow: auto;
270
- border-radius: 16px;
271
- color: #f4eadb;
272
- background: #2a2119;
273
- font: 12px/1.55 "Cascadia Mono", Consolas, monospace;
274
- white-space: pre-wrap;
275
- }
276
- .kimi-prompt {
277
- min-height: 230px;
278
- font-size: 12px;
279
- line-height: 1.62;
280
- background:
281
- linear-gradient(90deg, rgba(197,109,61,.10) 0 1px, transparent 1px),
282
- rgba(255, 253, 249, .92);
283
- background-size: 24px 100%;
284
- padding-left: 18px;
285
- }
286
- .results-panel {
287
- min-height: min(70vh, 760px);
288
- padding: 20px;
289
- }
290
- .results-head {
291
- display: flex;
292
- justify-content: space-between;
293
- gap: 14px;
294
- align-items: center;
295
- margin-bottom: 14px;
296
- }
297
- .results-head h2 {
298
- margin: 0;
299
- font-size: 22px;
300
- letter-spacing: -.025em;
301
- }
302
- .status {
303
- color: var(--muted);
304
- font: 13px/1.45 "Microsoft YaHei UI", "Noto Sans SC", sans-serif;
305
- }
306
- #serverStatus {
307
- padding: 9px 12px;
308
- border: 1px solid rgba(104, 84, 65, .15);
309
- border-radius: 999px;
310
- background: rgba(255,252,246,.74);
311
- white-space: nowrap;
312
- }
313
- .results-grid {
314
- display: grid;
315
- grid-template-columns: repeat(auto-fill, minmax(212px, 1fr));
316
- gap: 14px;
317
- }
318
- .plan-panel {
319
- display: none;
320
- margin-bottom: 16px;
321
- padding: 15px;
322
- border: 1px solid rgba(197, 109, 61, .22);
323
- border-radius: 22px;
324
- background: linear-gradient(180deg, rgba(255, 249, 239, .92), rgba(250, 241, 230, .72));
325
- box-shadow: 0 1px 0 rgba(255,255,255,.8) inset;
326
- }
327
- .plan-panel.visible { display: none; }
328
- .plan-panel h3 {
329
- margin: 0 0 8px;
330
- font-size: 16px;
331
- letter-spacing: -.01em;
332
- }
333
- .plan-summary {
334
- margin: 0 0 12px;
335
- color: var(--muted);
336
- font: 13px/1.55 "Microsoft YaHei UI", "Noto Sans SC", sans-serif;
337
- }
338
- .prompt-editor {
339
- display: grid;
340
- gap: 9px;
341
- max-height: 360px;
342
- overflow: auto;
343
- padding-right: 4px;
344
- }
345
- .prompt-edit-row {
346
- display: grid;
347
- grid-template-columns: 34px 1fr 1.25fr;
348
- gap: 8px;
349
- align-items: center;
350
- }
351
- .prompt-edit-index {
352
- color: var(--clay-dark);
353
- font: 900 12px/1 "Microsoft YaHei UI", "Noto Sans SC", sans-serif;
354
- }
355
- .prompt-edit-row input {
356
- width: 100%;
357
- min-height: 36px;
358
- padding: 8px 10px;
359
- border: 1px solid var(--line);
360
- border-radius: 13px;
361
- outline: 0;
362
- color: var(--ink);
363
- background: rgba(255,255,255,.72);
364
- font: 12px/1.4 "Microsoft YaHei UI", "Noto Sans SC", sans-serif;
365
- }
366
- .plan-tools {
367
- display: flex;
368
- gap: 10px;
369
- align-items: center;
370
- margin-top: 13px;
371
- }
372
- .plan-tools button {
373
- min-height: 38px;
374
- padding: 0 16px;
375
- font-size: 13px;
376
- }
377
- .prompt-group {
378
- margin-bottom: 18px;
379
- padding: 14px;
380
- border: 1px solid rgba(104, 84, 65, .14);
381
- border-radius: 22px;
382
- background: rgba(255, 252, 246, .72);
383
- box-shadow: 0 1px 0 rgba(255,255,255,.72) inset;
384
- }
385
- .prompt-group-title {
386
- margin: 0 0 5px;
387
- font-size: 16px;
388
- letter-spacing: -.015em;
389
- }
390
- .prompt-group-prompt {
391
- margin: 0 0 13px;
392
- color: var(--muted);
393
- font: 12px/1.6 "Microsoft YaHei UI", "Noto Sans SC", sans-serif;
394
- }
395
- .empty {
396
- display: grid;
397
- min-height: 462px;
398
- place-items: center;
399
- border: 1px dashed rgba(104, 84, 65, .22);
400
- border-radius: 22px;
401
- color: var(--muted);
402
- background: rgba(255, 252, 246, .44);
403
- text-align: center;
404
- font-family: "Microsoft YaHei UI", "Noto Sans SC", sans-serif;
405
- }
406
- .empty strong {
407
- display: block;
408
- margin-bottom: 8px;
409
- color: var(--ink);
410
- font-family: "Fraunces", "Iowan Old Style", "Noto Serif SC", serif;
411
- font-size: 18px;
412
- }
413
- .product {
414
- overflow: hidden;
415
- border: 1px solid rgba(104, 84, 65, .14);
416
- border-radius: 20px;
417
- background: rgba(255, 252, 246, .86);
418
- box-shadow: 0 12px 28px rgba(74, 51, 30, .08);
419
- transition: transform .18s cubic-bezier(.2,.8,.2,1), box-shadow .18s cubic-bezier(.2,.8,.2,1);
420
- }
421
- .product-media {
422
- position: relative;
423
- overflow: hidden;
424
- background: #eadfce;
425
- }
426
- .product-tag {
427
- position: absolute;
428
- left: 10px;
429
- right: 10px;
430
- bottom: 10px;
431
- display: none;
432
- padding: 7px 9px;
433
- border: 1px solid rgba(255,255,255,.48);
434
- border-radius: 999px;
435
- color: #fffaf3;
436
- background: rgba(36, 29, 23, .68);
437
- box-shadow: 0 12px 26px rgba(22, 18, 14, .22);
438
- font: 11px/1.25 "Microsoft YaHei UI", "Noto Sans SC", sans-serif;
439
- overflow: hidden;
440
- text-overflow: ellipsis;
441
- white-space: nowrap;
442
- backdrop-filter: blur(10px);
443
- }
444
- .product-tag.visible {
445
- display: block;
446
- }
447
- .product:hover {
448
- transform: translateY(-2px);
449
- box-shadow: 0 18px 34px rgba(74, 51, 30, .12);
450
- }
451
- .product-image {
452
- display: block;
453
- width: 100%;
454
- height: 190px;
455
- object-fit: cover;
456
- background: #eadfce;
457
- }
458
- .product-body {
459
- padding: 12px;
460
- }
461
- .product-meta {
462
- display: flex;
463
- justify-content: space-between;
464
- gap: 8px;
465
- margin-bottom: 8px;
466
- color: var(--muted);
467
- font: 12px/1.45 "Microsoft YaHei UI", "Noto Sans SC", sans-serif;
468
- }
469
- .score {
470
- color: var(--olive);
471
- font-weight: 900;
472
- }
473
- .product-title {
474
- min-height: 64px;
475
- margin: 0 0 10px;
476
- font: 13px/1.52 "Microsoft YaHei UI", "Noto Sans SC", sans-serif;
477
- display: -webkit-box;
478
- -webkit-line-clamp: 3;
479
- -webkit-box-orient: vertical;
480
- overflow: hidden;
481
- }
482
- .product-footer {
483
- display: flex;
484
- justify-content: space-between;
485
- gap: 8px;
486
- padding-top: 10px;
487
- border-top: 1px solid var(--line-soft);
488
- }
489
- .price {
490
- color: var(--red);
491
- font-size: 17px;
492
- font-weight: 900;
493
- }
494
- .sales {
495
- color: var(--muted);
496
- font: 12px/1.45 "Microsoft YaHei UI", "Noto Sans SC", sans-serif;
497
- }
498
- [hidden] {
499
- display: none !important;
500
- }
501
- .composer-head {
502
- display: flex;
503
- justify-content: space-between;
504
- gap: 14px;
505
- align-items: center;
506
- margin-bottom: 14px;
507
- }
508
- .mode-tabs {
509
- display: flex;
510
- gap: 6px;
511
- padding: 4px;
512
- border: 1px solid rgba(104, 84, 65, .14);
513
- border-radius: 999px;
514
- background: rgba(246, 238, 228, .62);
515
- }
516
- .mode-tab {
517
- min-height: 34px;
518
- padding: 0 13px;
519
- border: 0;
520
- color: var(--muted);
521
- background: transparent;
522
- box-shadow: none;
523
- font-size: 13px;
524
- }
525
- .mode-tab.active {
526
- color: var(--ink);
527
- background: rgba(255, 252, 246, .92);
528
- box-shadow: 0 1px 0 rgba(255,255,255,.72) inset, 0 8px 18px rgba(74, 51, 30, .08);
529
- }
530
- .composer-mode {
531
- display: none;
532
- }
533
- .composer-mode.active {
534
- display: grid;
535
- grid-template-columns: minmax(260px, 360px) minmax(300px, 1fr);
536
- gap: 14px;
537
- align-items: stretch;
538
- }
539
- .composer-mode.text-mode.active {
540
- grid-template-columns: 1fr;
541
- }
542
- .composer-bottom {
543
- display: grid;
544
- grid-template-columns: minmax(260px, 360px) minmax(280px, 1fr) auto;
545
- gap: 12px;
546
- align-items: end;
547
- margin-top: 14px;
548
- }
549
- .composer-actions {
550
- display: flex;
551
- gap: 10px;
552
- justify-content: flex-end;
553
- align-items: center;
554
- }
555
- .composer-actions button {
556
- min-width: 138px;
557
- padding: 0 18px;
558
- }
559
- .composer-log {
560
- margin-top: 14px;
561
- }
562
- @media (max-width: 900px) {
563
- .topbar {
564
- align-items: stretch;
565
- grid-template-columns: 1fr;
566
- }
567
- .composer-mode.active,
568
- .composer-bottom {
569
- grid-template-columns: 1fr;
570
- }
571
- .composer-actions {
572
- justify-content: stretch;
573
- }
574
- .composer-actions button {
575
- flex: 1;
576
- }
577
- }
578
-
579
- /* Industrial terminal skin: keeps the agreed interaction model, only changes the surface language. */
580
- :root {
581
- --ink: #f2f2ec;
582
- --ink-soft: #d7d3c8;
583
- --muted: #8f897f;
584
- --line: #302b25;
585
- --line-soft: #26221e;
586
- --panel: #151411;
587
- --panel-solid: #151411;
588
- --page: #0d0d0b;
589
- --paper: #151411;
590
- --clay: #ff6a32;
591
- --clay-dark: #d94f1f;
592
- --olive: #42dfff;
593
- --red: #ff614a;
594
- --shadow: none;
595
- font-family: "JetBrains Mono", "Cascadia Mono", Consolas, "Microsoft YaHei UI", monospace;
596
- }
597
- body {
598
- color: var(--ink);
599
- background: #0d0d0b;
600
- }
601
- body::before {
602
- opacity: .22;
603
- background:
604
- repeating-linear-gradient(0deg, transparent 0 2px, rgba(255,255,255,.035) 2px 3px),
605
- linear-gradient(90deg, rgba(255,106,50,.04) 1px, transparent 1px);
606
- background-size: 100% 4px, 82px 100%;
607
- mask-image: none;
608
- }
609
- .shell {
610
- width: min(1240px, calc(100% - 44px));
611
- padding-top: 32px;
612
- }
613
- .topbar,
614
- .panel,
615
- .controls,
616
- .results-panel,
617
- .build-panel,
618
- .prompt-group,
619
- .product,
620
- .empty,
621
- textarea,
622
- input,
623
- button,
624
- .dropzone,
625
- .mode-tabs,
626
- .mode-tab,
627
- #serverStatus {
628
- border-radius: 0;
629
- box-shadow: none;
630
- backdrop-filter: none;
631
- }
632
- .topbar {
633
- padding: 0 0 28px;
634
- border: 0;
635
- border-bottom: 1px solid #302b25;
636
- background: transparent;
637
- }
638
- .eyebrow {
639
- color: var(--clay);
640
- font: 900 10px/1.2 "JetBrains Mono", "Cascadia Mono", Consolas, monospace;
641
- letter-spacing: .22em;
642
- }
643
- .eyebrow::before {
644
- content: "POD / ";
645
- }
646
- h1 {
647
- max-width: 760px;
648
- color: #fffdf5;
649
- font-family: "Arial Black", "Microsoft YaHei UI", sans-serif;
650
- font-size: clamp(34px, 4.6vw, 56px);
651
- font-weight: 950;
652
- letter-spacing: -.06em;
653
- line-height: .92;
654
- text-shadow: 2px 2px 0 #0d0d0b, 4px 4px 0 rgba(66,223,255,.45);
655
- text-transform: uppercase;
656
- }
657
- .subtitle,
658
- .hint,
659
- .status,
660
- .field-label,
661
- .option small,
662
- .drop-copy span,
663
- .plan-summary,
664
- .prompt-group-prompt,
665
- .sales,
666
- .product-meta {
667
- color: var(--muted);
668
- font-family: "JetBrains Mono", "Cascadia Mono", Consolas, "Microsoft YaHei UI", monospace;
669
- }
670
- #serverStatus {
671
- padding: 10px 12px;
672
- border: 1px solid #302b25;
673
- color: var(--olive);
674
- background: #171511;
675
- text-transform: uppercase;
676
- }
677
- .layout {
678
- gap: 16px;
679
- }
680
- .panel {
681
- border: 1px solid #302b25;
682
- background: #151411;
683
- }
684
- .results-panel {
685
- min-height: 68vh;
686
- padding: 18px;
687
- }
688
- .results-head {
689
- padding-bottom: 14px;
690
- border-bottom: 1px solid #302b25;
691
- }
692
- .results-head h2,
693
- .section-title,
694
- .plan-panel h3,
695
- .prompt-group-title {
696
- color: #fffdf5;
697
- font-family: "Arial Black", "Microsoft YaHei UI", sans-serif;
698
- letter-spacing: -.04em;
699
- }
700
- .results-grid {
701
- grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
702
- gap: 1px;
703
- padding: 1px;
704
- background: #302b25;
705
- }
706
- .empty {
707
- border: 1px dashed #3b352e;
708
- color: var(--muted);
709
- background: #11100e;
710
- }
711
- .empty strong {
712
- color: #fffdf5;
713
- font-family: "Arial Black", "Microsoft YaHei UI", sans-serif;
714
- letter-spacing: -.03em;
715
- }
716
- .controls {
717
- padding: 16px;
718
- }
719
- .composer-head {
720
- padding-bottom: 14px;
721
- border-bottom: 1px solid #302b25;
722
- }
723
- .mode-tabs {
724
- gap: 1px;
725
- padding: 0;
726
- border: 1px solid #302b25;
727
- background: #302b25;
728
- }
729
- .mode-tab {
730
- min-height: 36px;
731
- color: var(--muted);
732
- background: #151411;
733
- text-transform: uppercase;
734
- }
735
- .mode-tab.active {
736
- color: #0d0d0b;
737
- background: var(--olive);
738
- }
739
- .dropzone {
740
- min-height: 176px;
741
- border: 1px dashed #3b352e;
742
- background: #11100e;
743
- }
744
- .dropzone:hover,
745
- .dropzone.active {
746
- transform: none;
747
- border-color: var(--clay);
748
- background: #171511;
749
- }
750
- .drop-copy strong {
751
- color: #fffdf5;
752
- font-family: "Arial Black", "Microsoft YaHei UI", sans-serif;
753
- letter-spacing: -.035em;
754
- }
755
- .preview,
756
- .product-image {
757
- background: #0f0e0c;
758
- }
759
- textarea,
760
- .option input,
761
- .prompt-edit-row input {
762
- border: 1px solid #302b25;
763
- color: #fffdf5;
764
- background: #0f0e0c;
765
- box-shadow: none;
766
- }
767
- textarea:focus,
768
- input:focus {
769
- border-color: var(--clay);
770
- box-shadow: 0 0 0 1px var(--clay);
771
- }
772
- .kimi-prompt {
773
- min-height: 176px;
774
- background:
775
- repeating-linear-gradient(90deg, transparent 0 23px, rgba(255,106,50,.12) 23px 24px),
776
- #0f0e0c;
777
- }
778
- .composer-bottom {
779
- border-top: 1px solid #302b25;
780
- padding-top: 14px;
781
- }
782
- .primary {
783
- border: 1px solid var(--clay);
784
- color: #0d0d0b;
785
- background: var(--clay);
786
- text-transform: uppercase;
787
- }
788
- .secondary {
789
- border: 1px solid #3b352e;
790
- color: #fffdf5;
791
- background: #1b1915;
792
- text-transform: uppercase;
793
- }
794
- button:hover:not(:disabled) {
795
- transform: none;
796
- outline: 1px solid var(--olive);
797
- outline-offset: -2px;
798
- }
799
- .build-panel {
800
- border: 1px solid #302b25;
801
- background: #11100e;
802
- }
803
- .bar {
804
- border: 1px solid #302b25;
805
- background: #0d0d0b;
806
- }
807
- .bar span {
808
- background: var(--olive);
809
- }
810
- .log {
811
- border: 1px solid #302b25;
812
- color: #e9e3d4;
813
- background: #080807;
814
- }
815
- .product {
816
- border: 0;
817
- background: #151411;
818
- }
819
- .product:hover {
820
- transform: none;
821
- outline: 1px solid var(--clay);
822
- outline-offset: -1px;
823
- box-shadow: none;
824
- }
825
- .product-media {
826
- background: #0f0e0c;
827
- }
828
- .product-tag {
829
- left: 8px;
830
- right: 8px;
831
- bottom: 8px;
832
- border: 1px solid rgba(66, 223, 255, .58);
833
- color: var(--olive);
834
- background: rgba(8, 8, 7, .82);
835
- box-shadow: none;
836
- text-transform: uppercase;
837
- backdrop-filter: none;
838
- }
839
- .product-title {
840
- min-height: 48px;
841
- color: #fffdf5;
842
- font-family: "JetBrains Mono", "Cascadia Mono", Consolas, "Microsoft YaHei UI", monospace;
843
- font-size: 12px;
844
- line-height: 1.35;
845
- -webkit-line-clamp: 2;
846
- }
847
- .product-footer {
848
- border-top: 1px solid #302b25;
849
- }
850
- .price {
851
- color: var(--clay);
852
- font-family: "Arial Black", "Microsoft YaHei UI", sans-serif;
853
- font-size: 18px;
854
- }
855
- </style>
856
- </head>
857
- <body>
858
- <main class="shell">
859
- <header class="topbar">
860
- <div>
861
- <p class="eyebrow">IMAGE TO KIMI TO LISTING CLIP</p>
862
- <h1>纯 Listing 组货检索</h1>
863
- <p class="subtitle">图片交给 Kimi 生成 10 个可组货商品检索词,再用纯 listing CLIP 索引召回;下方文本框单独用于直接检索 CLIP。</p>
864
- </div>
865
- <div class="status" id="serverStatus">9990 等待连接</div>
866
- </header>
867
-
868
- <section class="layout">
869
- <section class="panel results-panel">
870
- <div class="results-head">
871
- <h2>匹配结果</h2>
872
- <span class="status" id="status">等待输入</span>
873
- </div>
874
- <div class="plan-panel" id="planPanel">
875
- <h3>Kimi 组货商品 JSON</h3>
876
- <p class="plan-summary" id="planSummary"></p>
877
- <div class="prompt-editor" id="promptEditor"></div>
878
- <div class="plan-tools">
879
- <button class="primary" id="editedPromptButton" type="button">用编辑后 Prompt 检索</button>
880
- <span class="status">改英文 en 最影响 CLIP 召回</span>
881
- </div>
882
- </div>
883
- <div id="resultsGrid">
884
- <div class="empty"><div><strong>还没有结果</strong><span>上传图片跑 Kimi,或输入文本直接检索 CLIP</span></div></div>
885
- </div>
886
- </section>
887
-
888
- <aside class="panel controls">
889
- <div class="composer-head">
890
- <h2 class="section-title">输入</h2>
891
- <div class="mode-tabs" aria-label="输入模式">
892
- <button class="mode-tab active" id="imageModeTab" type="button">图片组货</button>
893
- <button class="mode-tab" id="textModeTab" type="button">直接 CLIP</button>
894
- </div>
895
- </div>
896
-
897
- <section class="composer-mode active" id="imageMode">
898
- <label class="dropzone" id="dropzone" for="imageInput">
899
- <input id="imageInput" type="file" accept="image/*">
900
- <img class="preview" id="preview" alt="待分析图片预览">
901
- <span class="drop-copy" id="dropCopy">
902
- <strong>点击或拖入商品图片</strong>
903
- <span>Image -> Kimi -> 组货商品 JSON -> Listing CLIP</span>
904
- </span>
905
- </label>
906
- <div>
907
- <label class="field-label" for="kimiPromptInput">发给 Kimi 的 Prompt(可编辑)</label>
908
- <textarea class="kimi-prompt" id="kimiPromptInput"></textarea>
909
- </div>
910
- </section>
911
-
912
- <section class="composer-mode text-mode" id="textMode">
913
- <div>
914
- <label class="field-label" for="queryInput">CLIP 检索词 / Listing 搜索</label>
915
- <textarea id="queryInput" placeholder="这里不发给 Kimi,只用于直接检索 CLIP。例如:transparent storage box for makeup organizer"></textarea>
916
- </div>
917
- </section>
918
-
919
- <div class="composer-bottom">
920
- <div class="options">
921
- <label class="option">
922
- <small>最低价格 USD</small>
923
- <input id="minPrice" type="number" min="0" step="0.01" placeholder="不限">
924
- </label>
925
- <label class="option">
926
- <small>最高价格 USD</small>
927
- <input id="maxPrice" type="number" min="0" step="0.01" placeholder="不限">
928
- </label>
929
- </div>
930
- <p class="hint">Kimi 请求只带图片;直接 CLIP 模式只使用文本。结果卡片只显示图片、方向、标题、价格和销量。</p>
931
- <div class="composer-actions">
932
- <button class="primary" id="assemblyButton" type="button">生成 Kimi 组货方案</button>
933
- <button class="secondary" id="searchButton" type="button">仅 Listing 检索</button>
934
- </div>
935
- </div>
936
- </aside>
937
- </section>
938
- </main>
939
-
940
- <script>
941
- const apiBase = 'http://127.0.0.1:9990';
942
- const defaultKimiPrompt = `你是跨境电商组货商品检索词生成器。你只根据用户上传的图片生成可一起售卖/一起购买的商品检索词。
943
-
944
- 任务:输出10个“具体可采购商品”,用于后续纯 listing CLIP 检索。
945
-
946
- 生成原则:
947
- 1. 不要只找外观相似品;优先覆盖互补品、同场景加购、替代升级、耗材补充、收纳展示、维护清洁、配套工具、礼盒套装里的其他商品。
948
- 2. 每条必须是具体商品,不要写大类、策略、理由或营销词。不要输出“配件、用品、产品、套装、工具”这种过宽泛词,除非前面有清晰具体限定。
949
- 3. 中文 zh 要像能直接给采购看的商品短名:主体品类 + 关键材质/结构/场景/人群/规格,尽量 6-18 个中文字符。
950
- 4. 英文 en 要像英文 listing 标题检索词:6-14 个英文词,必须包含明确 product noun,并尽量包含 material / shape / color / scene / target user / size / function 中的2-4个要素。
951
- 5. 如果图片主体不确定,根据最明显视觉元素推断;不要解释不确定性。
952
- 6. 10条之间要有明显差异,避免同义改写刷数量。
953
-
954
- 输出格式:只返回合法 JSON 对象,且只能包含 prompts 字段。
955
- prompts 是长度为10的数组,每个元素只能包含 zh 和 en 两个字段。`;
956
- let selectedFile = null;
957
- let currentPlan = { prompts: [] };
958
-
959
- // Escape API-provided text before inserting it into cards.
960
- function escapeHtml(value) {
961
- const text = String(value === null || value === undefined ? '' : value);
962
- return text.replace(/[&<>"']/g, function replaceCharacter(character) {
963
- const entities = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
964
- return entities[character];
965
- });
966
- }
967
-
968
- // Build the product image URL returned by the listing service.
969
- function getProductImageUrl(product) {
970
- if (product.img_url && /^https?:\/\//i.test(String(product.img_url))) return `${apiBase}/api/cdn/image?url=${encodeURIComponent(String(product.img_url))}`;
971
- return '';
972
- }
973
-
974
- // Remove the whole product card when its CDN image fails to load.
975
- function handleProductImageError(imageElement) {
976
- const productCard = imageElement.closest('.product');
977
- if (productCard) productCard.remove();
978
- }
979
-
980
- // Show the selected image locally before sending it to Kimi.
981
- function showPreview(file) {
982
- const preview = document.getElementById('preview');
983
- const dropCopy = document.getElementById('dropCopy');
984
- if (!file) {
985
- preview.removeAttribute('src');
986
- preview.classList.remove('visible');
987
- dropCopy.classList.remove('hidden');
988
- return;
989
- }
990
- preview.src = URL.createObjectURL(file);
991
- preview.classList.add('visible');
992
- dropCopy.classList.add('hidden');
993
- }
994
-
995
- // Accept an image from the file picker or drag-and-drop area.
996
- function setSelectedFile(file) {
997
- if (!file || !file.type || !file.type.startsWith('image/')) return;
998
- selectedFile = file;
999
- showPreview(file);
1000
- document.getElementById('status').textContent = `已选择:${file.name}`;
1001
- }
1002
-
1003
- // Render one search result card.
1004
- function renderProductCard(product, index) {
1005
- const title = product.title || product.listing_text || product.title_en || `商品 ${product.id || index + 1}`;
1006
- const price = product.price_usd === undefined || product.price_usd === null || product.price_usd === '' ? '--' : `$${Number(product.price_usd).toFixed(2)}`;
1007
- const salesValue = product.sales_total === undefined || product.sales_total === null ? product.sales : product.sales_total;
1008
- const sales = salesValue === undefined || salesValue === '' || salesValue === null ? '' : `销量 ${salesValue}`;
1009
- const imageUrl = getProductImageUrl(product);
1010
- const tag = product.bundle_tag || product.search_prompt || product.search_prompt_en || '';
1011
- return `<article class="product">
1012
- <div class="product-media">
1013
- <img class="product-image" src="${escapeHtml(imageUrl)}" alt="${escapeHtml(title)}" onerror="handleProductImageError(this)">
1014
- <span class="product-tag${tag ? ' visible' : ''}">${escapeHtml(tag)}</span>
1015
- </div>
1016
- <div class="product-body">
1017
- <p class="product-title">${escapeHtml(title)}</p>
1018
- <div class="product-footer"><span class="price">${escapeHtml(price)}</span><span class="sales">${escapeHtml(sales)}</span></div>
1019
- </div>
1020
- </article>`;
1021
- }
1022
-
1023
- // Render all cards or an empty state.
1024
- function renderResults(results) {
1025
- const grid = document.getElementById('resultsGrid');
1026
- if (!results || results.length === 0) {
1027
- grid.innerHTML = '<div class="empty"><div><strong>没有符合条件的商品</strong><span>换个英文 prompt 或放宽价格范围</span></div></div>';
1028
- return;
1029
- }
1030
- let html = '<div class="results-grid">';
1031
- let index = 0;
1032
- while (index < results.length) {
1033
- html += renderProductCard(results[index], index);
1034
- index += 1;
1035
- }
1036
- grid.innerHTML = `${html}</div>`;
1037
- }
1038
-
1039
- // Render Kimi's bundle-product plan as editable prompt rows.
1040
- function renderPlan(plan, resultCount) {
1041
- const panel = document.getElementById('planPanel');
1042
- const prompts = plan && Array.isArray(plan.prompts) ? plan.prompts : [];
1043
- currentPlan = { prompts };
1044
- document.getElementById('planSummary').textContent = `${prompts.length} 个组货商品 · 每个 Top 1 · ${resultCount} 个召回位 · thinking disabled`;
1045
- let editorHtml = '';
1046
- let promptIndex = 0;
1047
- while (promptIndex < prompts.length) {
1048
- const prompt = prompts[promptIndex] || {};
1049
- editorHtml += `<div class="prompt-edit-row">
1050
- <span class="prompt-edit-index">#${promptIndex + 1}</span>
1051
- <input class="prompt-zh" data-index="${promptIndex}" value="${escapeHtml(prompt.zh || '')}" placeholder="中文商品名">
1052
- <input class="prompt-en" data-index="${promptIndex}" value="${escapeHtml(prompt.en || '')}" placeholder="English listing keywords">
1053
- </div>`;
1054
- promptIndex += 1;
1055
- }
1056
- document.getElementById('promptEditor').innerHTML = editorHtml;
1057
- panel.classList.add('visible');
1058
- }
1059
-
1060
- // Read the currently edited prompt rows back into JSON shape.
1061
- function readEditedPlan() {
1062
- const rows = Array.from(document.querySelectorAll('.prompt-edit-row'));
1063
- const prompts = rows.map(function mapPromptRow(row) {
1064
- return {
1065
- zh: row.querySelector('.prompt-zh').value.trim(),
1066
- en: row.querySelector('.prompt-en').value.trim(),
1067
- };
1068
- }).filter(function keepPrompt(prompt) {
1069
- return prompt.zh || prompt.en;
1070
- });
1071
- currentPlan = { prompts };
1072
- return currentPlan;
1073
- }
1074
-
1075
- // Render listing results as one output wall and move each Kimi prompt onto the product image.
1076
- function renderPromptGroups(groups) {
1077
- const grid = document.getElementById('resultsGrid');
1078
- if (!groups || groups.length === 0) {
1079
- renderResults([]);
1080
- return;
1081
- }
1082
- const products = [];
1083
- let groupIndex = 0;
1084
- while (groupIndex < groups.length) {
1085
- const group = groups[groupIndex];
1086
- const groupResults = group.results || [];
1087
- const promptText = `${group.prompt || ''}${group.prompt_en ? ` / ${group.prompt_en}` : ''}`;
1088
- let resultIndex = 0;
1089
- while (resultIndex < groupResults.length) {
1090
- products.push(Object.assign({}, groupResults[resultIndex], { bundle_tag: promptText }));
1091
- resultIndex += 1;
1092
- }
1093
- groupIndex += 1;
1094
- }
1095
- renderResults(products);
1096
- }
1097
-
1098
- // Request one pure listing search.
1099
- async function runSearch() {
1100
- const query = document.getElementById('queryInput').value.trim();
1101
- const topK = 24;
1102
- const minPrice = document.getElementById('minPrice').value.trim();
1103
- const maxPrice = document.getElementById('maxPrice').value.trim();
1104
- if (!query) {
1105
- document.getElementById('status').textContent = '先输入 listing/prompt';
1106
- return;
1107
- }
1108
- const button = document.getElementById('searchButton');
1109
- button.disabled = true;
1110
- button.textContent = '检索中…';
1111
- document.getElementById('planPanel').classList.remove('visible');
1112
- document.getElementById('status').textContent = '正在查纯 listing 索引…';
1113
- try {
1114
- const form = new FormData();
1115
- form.append('query', query);
1116
- form.append('top_k', String(topK));
1117
- if (minPrice !== '') form.append('min_price', minPrice);
1118
- if (maxPrice !== '') form.append('max_price', maxPrice);
1119
- const startedAt = performance.now();
1120
- const response = await fetch(`${apiBase}/api/search/text`, { method: 'POST', body: form });
1121
- const data = await response.json();
1122
- if (!response.ok || data.error) throw new Error(data.error || '检索失败');
1123
- renderResults(data.results || []);
1124
- document.getElementById('status').textContent = `${(data.results || []).length} 条 · ${Math.round(performance.now() - startedAt)} ms`;
1125
- } catch (error) {
1126
- document.getElementById('resultsGrid').innerHTML = `<div class="empty"><div><strong>检索失败</strong><span>${escapeHtml(error.message)}</span></div></div>`;
1127
- document.getElementById('status').textContent = '不可用';
1128
- } finally {
1129
- button.disabled = false;
1130
- button.textContent = '仅 Listing 检索';
1131
- }
1132
- }
1133
-
1134
- // Search the listing index again with manually edited Kimi prompts.
1135
- async function runEditedPromptSearch() {
1136
- const plan = readEditedPlan();
1137
- const minPrice = document.getElementById('minPrice').value.trim();
1138
- const maxPrice = document.getElementById('maxPrice').value.trim();
1139
- if (!plan.prompts.length) {
1140
- document.getElementById('status').textContent = '先生成或填写 Prompt';
1141
- return;
1142
- }
1143
- const button = document.getElementById('editedPromptButton');
1144
- button.disabled = true;
1145
- button.textContent = '检索中…';
1146
- document.getElementById('status').textContent = '用编辑后 Prompt 查 Listing CLIP…';
1147
- try {
1148
- const payload = { prompts: plan.prompts, top_k: 1 };
1149
- if (minPrice !== '') payload.min_price = Number(minPrice);
1150
- if (maxPrice !== '') payload.max_price = Number(maxPrice);
1151
- const startedAt = performance.now();
1152
- const response = await fetch(`${apiBase}/api/search/prompts`, {
1153
- method: 'POST',
1154
- headers: { 'Content-Type': 'application/json' },
1155
- body: JSON.stringify(payload),
1156
- });
1157
- const data = await response.json();
1158
- if (!response.ok || data.error) throw new Error(data.error || '编辑 Prompt 检索失败');
1159
- renderPlan(data.plan, (data.results || []).length);
1160
- renderPromptGroups(data.groups || []);
1161
- document.getElementById('status').textContent = `${(data.results || []).length} 条 · ${Math.round(performance.now() - startedAt)} ms`;
1162
- } catch (error) {
1163
- document.getElementById('resultsGrid').innerHTML = `<div class="empty"><div><strong>编辑 Prompt 检索失败</strong><span>${escapeHtml(error.message)}</span></div></div>`;
1164
- document.getElementById('status').textContent = '不可用';
1165
- } finally {
1166
- button.disabled = false;
1167
- button.textContent = '用编辑后 Prompt 检索';
1168
- }
1169
- }
1170
-
1171
- // Run the uploaded image through Kimi JSON prompts and pure listing CLIP recall.
1172
- async function runAssembly() {
1173
- const topK = 1;
1174
- const minPrice = document.getElementById('minPrice').value.trim();
1175
- const maxPrice = document.getElementById('maxPrice').value.trim();
1176
- const kimiPrompt = document.getElementById('kimiPromptInput').value.trim() || defaultKimiPrompt;
1177
- if (!selectedFile) {
1178
- document.getElementById('status').textContent = '先上传图片;Kimi 不读取文本框';
1179
- return;
1180
- }
1181
- const button = document.getElementById('assemblyButton');
1182
- button.disabled = true;
1183
- document.getElementById('searchButton').disabled = true;
1184
- document.getElementById('editedPromptButton').disabled = true;
1185
- button.textContent = '组货中…';
1186
- document.getElementById('status').textContent = 'Image -> Kimi -> 组货商品 JSON -> Listing CLIP…';
1187
- document.getElementById('planPanel').classList.remove('visible');
1188
- document.getElementById('resultsGrid').innerHTML = '<div class="empty"><div><strong>正在生成组货商品</strong><span>Kimi 完成后会查纯 listing 索引</span></div></div>';
1189
- try {
1190
- const form = new FormData();
1191
- form.append('file', selectedFile);
1192
- form.append('kimi_prompt', kimiPrompt);
1193
- form.append('top_k', String(topK));
1194
- if (minPrice !== '') form.append('min_price', minPrice);
1195
- if (maxPrice !== '') form.append('max_price', maxPrice);
1196
- const startedAt = performance.now();
1197
- const response = await fetch(`${apiBase}/api/assemble`, { method: 'POST', body: form });
1198
- const data = await response.json();
1199
- if (!response.ok || data.error) throw new Error(data.error || '组货失败');
1200
- if (data.kimi_prompt) document.getElementById('kimiPromptInput').value = data.kimi_prompt;
1201
- renderPlan(data.plan, (data.results || []).length);
1202
- renderPromptGroups(data.groups || []);
1203
- document.getElementById('status').textContent = `${(data.results || []).length} 条 · ${Math.round(performance.now() - startedAt)} ms`;
1204
- } catch (error) {
1205
- document.getElementById('resultsGrid').innerHTML = `<div class="empty"><div><strong>组货失败</strong><span>${escapeHtml(error.message)}</span></div></div>`;
1206
- document.getElementById('status').textContent = '组货不可用';
1207
- } finally {
1208
- button.disabled = false;
1209
- document.getElementById('searchButton').disabled = false;
1210
- document.getElementById('editedPromptButton').disabled = false;
1211
- button.textContent = '生成 Kimi 组货方案';
1212
- }
1213
- }
1214
-
1215
- // Render only the compact server readiness badge.
1216
- function renderServerStatus(data) {
1217
- const kimiStatus = data.kimi_configured ? 'Kimi ready' : 'Kimi key missing';
1218
- document.getElementById('serverStatus').textContent = `${data.index_exists ? '9990 index ready' : '9990 index pending'} · ${kimiStatus}`;
1219
- }
1220
-
1221
- // Poll the server status for the compact readiness badge.
1222
- async function refreshStatus() {
1223
- try {
1224
- const response = await fetch(`${apiBase}/api/index/status`, { cache: 'no-store' });
1225
- const data = await response.json();
1226
- renderServerStatus(data);
1227
- } catch (error) {
1228
- document.getElementById('serverStatus').textContent = '9990 未启动';
1229
- }
1230
- }
1231
-
1232
- // Handle drag-over feedback for the image drop area.
1233
- function handleDragOver(event) {
1234
- event.preventDefault();
1235
- document.getElementById('dropzone').classList.add('active');
1236
- }
1237
-
1238
- // Remove drag-over feedback after the pointer leaves the drop area.
1239
- function handleDragLeave() {
1240
- document.getElementById('dropzone').classList.remove('active');
1241
- }
1242
-
1243
- // Handle a dropped image file.
1244
- function handleDrop(event) {
1245
- event.preventDefault();
1246
- handleDragLeave();
1247
- setSelectedFile(event.dataTransfer.files[0]);
1248
- }
1249
-
1250
- // Update the selected file when the native file picker changes.
1251
- function handleFileChange(event) {
1252
- setSelectedFile(event.target.files[0]);
1253
- }
1254
-
1255
- // Search the CLIP text box with Ctrl+Enter.
1256
- function handleQueryKeydown(event) {
1257
- if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') runSearch();
1258
- }
1259
-
1260
- // Switch the bottom composer between image assembly and direct CLIP search.
1261
- function switchMode(mode) {
1262
- const imageMode = document.getElementById('imageMode');
1263
- const textMode = document.getElementById('textMode');
1264
- const imageTab = document.getElementById('imageModeTab');
1265
- const textTab = document.getElementById('textModeTab');
1266
- const isImageMode = mode === 'image';
1267
- imageMode.classList.toggle('active', isImageMode);
1268
- textMode.classList.toggle('active', !isImageMode);
1269
- imageTab.classList.toggle('active', isImageMode);
1270
- textTab.classList.toggle('active', !isImageMode);
1271
- }
1272
-
1273
- document.getElementById('assemblyButton').addEventListener('click', runAssembly);
1274
- document.getElementById('searchButton').addEventListener('click', runSearch);
1275
- document.getElementById('editedPromptButton').addEventListener('click', runEditedPromptSearch);
1276
- document.getElementById('imageModeTab').addEventListener('click', function activateImageMode() { switchMode('image'); });
1277
- document.getElementById('textModeTab').addEventListener('click', function activateTextMode() { switchMode('text'); });
1278
- document.getElementById('queryInput').addEventListener('keydown', handleQueryKeydown);
1279
- document.getElementById('kimiPromptInput').value = defaultKimiPrompt;
1280
- document.getElementById('imageInput').addEventListener('change', handleFileChange);
1281
- document.getElementById('dropzone').addEventListener('dragover', handleDragOver);
1282
- document.getElementById('dropzone').addEventListener('dragleave', handleDragLeave);
1283
- document.getElementById('dropzone').addEventListener('drop', handleDrop);
1284
- setInterval(refreshStatus, 3000);
1285
- refreshStatus();
1286
- </script>
1287
- </body>
1288
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bundle/clip/config.example.json DELETED
@@ -1,9 +0,0 @@
1
- {
2
- "kimi": {
3
- "api_key": "",
4
- "endpoint": "https://api.moonshot.cn/v1/chat/completions",
5
- "model": "kimi-k2.6",
6
- "temperature": 0.6,
7
- "max_completion_tokens": 1200
8
- }
9
- }
 
 
 
 
 
 
 
 
 
 
bundle/clip/data/full_clip_index/products_full_prices.json DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:a63876a98e6c4e4341566c90e00b0f4001a62ad69d55c474884e96c5aca141dc
3
- size 37368003
 
 
 
 
bundle/clip/data/full_listing_index/cleaning_report.json DELETED
@@ -1,8 +0,0 @@
1
- {
2
- "source": 286548,
3
- "kept": 273454,
4
- "dropped_missing": 0,
5
- "dropped_short": 7554,
6
- "dropped_duplicate": 5540,
7
- "updated_at": "2026-08-21 09:13:05"
8
- }
 
 
 
 
 
 
 
 
 
bundle/clip/data/full_listing_index/products_listing.index DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:4e50a979e2386e24b862bfbd23fb90658ccc60e406e612144808318bde3f631e
3
- size 560033837
 
 
 
 
bundle/clip/data/full_listing_index/products_listing_meta.runtime.json DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:0c10a2a02aab00a382f9b02cc521e11a3225a28bf334fa987e226acf5832767f
3
- size 139038585
 
 
 
 
bundle/clip/data/full_listing_index/progress.json DELETED
@@ -1,6 +0,0 @@
1
- {
2
- "completed": 273454,
3
- "total": 273454,
4
- "status": "complete",
5
- "updated_at": "2026-08-21 09:51:05"
6
- }
 
 
 
 
 
 
 
bundle/clip/data/yunqi_clip_training/last_checkpoint.pt DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:57f6be30140cb9f03fd6d7b7aa83fefc2c4441ef4244608a8391084c0031c35a
3
- size 692418197
 
 
 
 
bundle/clip/docs/images/smart-workflow.png DELETED

Git LFS Details

  • SHA256: dbeee62d6d835c400e42189e53a6d350c15045e33a1a204051f9518994790a19
  • Pointer size: 131 Bytes
  • Size of remote file: 967 kB
bundle/clip/docs/images/workbench-overview.png DELETED

Git LFS Details

  • SHA256: f9eda4e9eb24f0f90d25a6c6d154530a7b7d44110334c2ef4c4757de9c310cb6
  • Pointer size: 132 Bytes
  • Size of remote file: 1.25 MB
bundle/clip/models/open_clip_pytorch_model.bin DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:1bd3c7172de5b207ceac554f5ab5266166f3b9baccc9af5989bc801016d080ad
3
- size 605219813
 
 
 
 
bundle/clip/work/build_full_listing_index.py DELETED
@@ -1,395 +0,0 @@
1
- """Build a cleaned full-product CLIP text index from listing metadata."""
2
-
3
- import argparse
4
- import json
5
- import os
6
- import re
7
- import time
8
- from pathlib import Path
9
-
10
- import faiss
11
- import numpy as np
12
- import open_clip
13
- import torch
14
-
15
-
16
- CLIP_DIR = Path(r"F:\Clip")
17
- SOURCE_INDEX_DIR = CLIP_DIR / "data" / "full_clip_index"
18
- SOURCE_METADATA_PATH = SOURCE_INDEX_DIR / "products_full_meta.json"
19
- PRICE_METADATA_PATH = SOURCE_INDEX_DIR / "products_full_prices.json"
20
- MODEL_PATH = CLIP_DIR / "models" / "open_clip_pytorch_model.bin"
21
- CHECKPOINT_PATH = CLIP_DIR / "data" / "yunqi_clip_training" / "last_checkpoint.pt"
22
- OUTPUT_DIR = CLIP_DIR / "data" / "full_listing_index"
23
- METADATA_PATH = OUTPUT_DIR / "products_listing_meta.json"
24
- EMBEDDINGS_PATH = OUTPUT_DIR / "products_listing_embeddings.npy"
25
- INDEX_PATH = OUTPUT_DIR / "products_listing.index"
26
- PROGRESS_PATH = OUTPUT_DIR / "progress.json"
27
- REPORT_PATH = OUTPUT_DIR / "cleaning_report.json"
28
- MODEL_NAME = "ViT-B-32"
29
- DEFAULT_BATCH_SIZE = 256
30
- DEFAULT_SAVE_EVERY = 2048
31
-
32
- STOP_WORDS = {
33
- "with",
34
- "for",
35
- "and",
36
- "the",
37
- "set",
38
- "pcs",
39
- "piece",
40
- "pieces",
41
- "pack",
42
- "new",
43
- "hot",
44
- "sale",
45
- "best",
46
- "high",
47
- "quality",
48
- "portable",
49
- "creative",
50
- "fashion",
51
- "women",
52
- "men",
53
- "kids",
54
- "girls",
55
- "boys",
56
- "home",
57
- "office",
58
- "outdoor",
59
- "indoor",
60
- }
61
-
62
-
63
- def parse_arguments():
64
- """Parse options for a resumable listing-index build."""
65
- parser = argparse.ArgumentParser(description=__doc__)
66
- parser.add_argument("--batch-size", type=int, default=DEFAULT_BATCH_SIZE)
67
- parser.add_argument("--save-every", type=int, default=DEFAULT_SAVE_EVERY)
68
- parser.add_argument("--force-clean", action="store_true")
69
- return parser.parse_args()
70
-
71
-
72
- def write_json_atomic(path, payload):
73
- """Write JSON through a temporary file and replace the target atomically."""
74
- path.parent.mkdir(parents=True, exist_ok=True)
75
- temporary_path = path.with_suffix(path.suffix + ".tmp")
76
- temporary_path.write_text(
77
- json.dumps(payload, ensure_ascii=False, indent=2),
78
- encoding="utf-8",
79
- )
80
- last_error = None
81
- attempt = 0
82
- while attempt < 10:
83
- try:
84
- os.replace(temporary_path, path)
85
- return
86
- except PermissionError as error:
87
- last_error = error
88
- time.sleep(0.5)
89
- attempt += 1
90
- raise last_error
91
-
92
-
93
- def clean_spaces(value):
94
- """Collapse noisy whitespace and remove invisible control characters."""
95
- text = str(value or "").replace("\u0000", " ")
96
- text = re.sub(r"\s+", " ", text)
97
- return text.strip()
98
-
99
-
100
- def looks_mojibake(value):
101
- """Detect obviously broken text so English listing can be preferred."""
102
- text = str(value or "")
103
- if not text:
104
- return False
105
- bad_count = 0
106
- for character in text:
107
- if character == "�":
108
- bad_count += 1
109
- return bad_count >= max(3, len(text) // 12)
110
-
111
-
112
- def choose_listing_text(record):
113
- """Choose the cleanest searchable listing text from one product record."""
114
- title_en = clean_spaces(record.get("title_en", ""))
115
- title_cn = clean_spaces(record.get("title_cn", ""))
116
- title = clean_spaces(record.get("title", ""))
117
- if title_en:
118
- return title_en
119
- if title and not looks_mojibake(title):
120
- return title
121
- if title_cn and not looks_mojibake(title_cn):
122
- return title_cn
123
- return title_en or title or title_cn
124
-
125
-
126
- def normalize_for_exact_dedupe(value):
127
- """Build a strict title key for exact duplicate removal."""
128
- text = clean_spaces(value).lower()
129
- text = re.sub(r"[^a-z0-9]+", " ", text)
130
- text = re.sub(r"\s+", " ", text)
131
- return text.strip()
132
-
133
-
134
- def make_family_key(value):
135
- """Build a coarse key used later to avoid same-looking Top results."""
136
- normalized = normalize_for_exact_dedupe(value)
137
- tokens = normalized.split(" ")
138
- kept_tokens = []
139
- for token in tokens:
140
- if len(token) <= 2:
141
- continue
142
- if token in STOP_WORDS:
143
- continue
144
- if token.isdigit():
145
- continue
146
- kept_tokens.append(token)
147
- unique_tokens = []
148
- for token in kept_tokens:
149
- if token not in unique_tokens:
150
- unique_tokens.append(token)
151
- if len(unique_tokens) <= 2:
152
- return normalized[:80]
153
- return " ".join(unique_tokens[:10])
154
-
155
-
156
- def load_source_products():
157
- """Load the existing full product metadata generated by the image index."""
158
- if not SOURCE_METADATA_PATH.exists():
159
- raise FileNotFoundError(f"Missing source metadata: {SOURCE_METADATA_PATH}")
160
- return json.loads(SOURCE_METADATA_PATH.read_text(encoding="utf-8"))
161
-
162
-
163
- def clean_products(raw_products):
164
- """Drop unusable listings and exact duplicate listing records."""
165
- cleaned_products = []
166
- seen_titles = set()
167
- seen_ids = set()
168
- dropped_short = 0
169
- dropped_duplicate = 0
170
- dropped_missing = 0
171
- for raw_product in raw_products:
172
- product_id = str(raw_product.get("id", "")).strip()
173
- listing_text = choose_listing_text(raw_product)
174
- exact_key = normalize_for_exact_dedupe(listing_text)
175
- if not product_id or not listing_text:
176
- dropped_missing += 1
177
- continue
178
- if len(exact_key) < 8:
179
- dropped_short += 1
180
- continue
181
- if product_id in seen_ids or exact_key in seen_titles:
182
- dropped_duplicate += 1
183
- continue
184
- product = raw_product.copy()
185
- product["title"] = listing_text
186
- product["listing_text"] = listing_text
187
- product["listing_key"] = exact_key
188
- product["family_key"] = make_family_key(listing_text)
189
- product["img_url"] = f"/listing-images/{product_id}.jpg"
190
- cleaned_products.append(product)
191
- seen_ids.add(product_id)
192
- seen_titles.add(exact_key)
193
- report = {
194
- "source": len(raw_products),
195
- "kept": len(cleaned_products),
196
- "dropped_missing": dropped_missing,
197
- "dropped_short": dropped_short,
198
- "dropped_duplicate": dropped_duplicate,
199
- "updated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
200
- }
201
- return cleaned_products, report
202
-
203
-
204
- def load_or_create_clean_products(force_clean):
205
- """Reuse cleaned metadata unless a fresh cleaning pass is requested."""
206
- if METADATA_PATH.exists() and not force_clean:
207
- return json.loads(METADATA_PATH.read_text(encoding="utf-8"))
208
- raw_products = load_source_products()
209
- cleaned_products, report = clean_products(raw_products)
210
- write_json_atomic(METADATA_PATH, cleaned_products)
211
- write_json_atomic(REPORT_PATH, report)
212
- return cleaned_products
213
-
214
-
215
- def load_model():
216
- """Load the trained CLIP text tower on CPU."""
217
- if not MODEL_PATH.exists():
218
- raise FileNotFoundError(f"Missing base model: {MODEL_PATH}")
219
- if not CHECKPOINT_PATH.exists():
220
- raise FileNotFoundError(f"Missing trained checkpoint: {CHECKPOINT_PATH}")
221
- device = torch.device("cpu")
222
- model, _, _ = open_clip.create_model_and_transforms(
223
- MODEL_NAME,
224
- pretrained=str(MODEL_PATH),
225
- )
226
- checkpoint = torch.load(CHECKPOINT_PATH, map_location=device, weights_only=False)
227
- model.load_state_dict(checkpoint["model"])
228
- model = model.to(device)
229
- model.eval()
230
- tokenizer = open_clip.get_tokenizer(MODEL_NAME)
231
- return model, tokenizer, device
232
-
233
-
234
- def load_progress(total):
235
- """Read the last completed text embedding count for resume."""
236
- if not PROGRESS_PATH.exists():
237
- return 0
238
- progress = json.loads(PROGRESS_PATH.read_text(encoding="utf-8"))
239
- if int(progress.get("total", total)) != total:
240
- raise RuntimeError("Existing listing progress does not match cleaned metadata")
241
- return max(0, min(total, int(progress.get("completed", 0))))
242
-
243
-
244
- def open_embedding_memmap(total, dimension):
245
- """Create or reopen the text embedding memmap."""
246
- OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
247
- if EMBEDDINGS_PATH.exists():
248
- embedding_matrix = np.lib.format.open_memmap(EMBEDDINGS_PATH, mode="r+")
249
- if embedding_matrix.shape != (total, dimension):
250
- raise RuntimeError("Existing listing embedding matrix shape mismatch")
251
- return embedding_matrix
252
- return np.lib.format.open_memmap(
253
- EMBEDDINGS_PATH,
254
- mode="w+",
255
- dtype="float32",
256
- shape=(total, dimension),
257
- )
258
-
259
-
260
- def restore_faiss_index(embedding_matrix, completed, dimension):
261
- """Rebuild an in-memory FAISS index from completed text vectors."""
262
- index = faiss.IndexFlatIP(dimension)
263
- chunk_size = 8192
264
- start_index = 0
265
- while start_index < completed:
266
- end_index = min(start_index + chunk_size, completed)
267
- index.add(np.asarray(embedding_matrix[start_index:end_index], dtype="float32"))
268
- start_index = end_index
269
- return index
270
-
271
-
272
- def write_faiss_index_file(index, path):
273
- """Write a FAISS index through Python bytes so Windows Unicode paths stay valid."""
274
- serialized_index = faiss.serialize_index(index)
275
- path.write_bytes(serialized_index.tobytes())
276
-
277
-
278
- def encode_text_batch(model, tokenizer, texts, device):
279
- """Encode one batch of listing strings with the trained text tower."""
280
- tokens = tokenizer(texts).to(device)
281
- with torch.inference_mode():
282
- features = model.encode_text(tokens)
283
- features = features / features.norm(dim=-1, keepdim=True)
284
- return features.cpu().numpy().astype("float32")
285
-
286
-
287
- def encode_products(products, model, tokenizer, device, batch_size, save_every):
288
- """Encode cleaned listings and print live throughput."""
289
- dimension = int(model.text_projection.shape[1])
290
- total = len(products)
291
- completed = load_progress(total)
292
- embedding_matrix = open_embedding_memmap(total, dimension)
293
- index = restore_faiss_index(embedding_matrix, completed, dimension)
294
- started_at = time.perf_counter()
295
- while completed < total:
296
- batch_end = min(completed + batch_size, total)
297
- texts = []
298
- product_index = completed
299
- while product_index < batch_end:
300
- texts.append(products[product_index]["listing_text"])
301
- product_index += 1
302
- batch_features = encode_text_batch(model, tokenizer, texts, device)
303
- embedding_matrix[completed:batch_end] = batch_features
304
- index.add(batch_features)
305
- completed = batch_end
306
- if completed % save_every < batch_size or completed >= total:
307
- embedding_matrix.flush()
308
- write_json_atomic(
309
- PROGRESS_PATH,
310
- {
311
- "completed": completed,
312
- "total": total,
313
- "status": "building",
314
- "updated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
315
- },
316
- )
317
- elapsed = time.perf_counter() - started_at
318
- rate = completed / max(elapsed, 1e-6)
319
- eta_minutes = (total - completed) / max(rate, 1e-6) / 60
320
- print(
321
- "listing_embedding",
322
- completed,
323
- "/",
324
- total,
325
- "rate",
326
- f"{rate:.2f}/s",
327
- "eta_minutes",
328
- f"{eta_minutes:.1f}",
329
- flush=True,
330
- )
331
- return embedding_matrix, index
332
-
333
-
334
- def write_final_outputs(products, embedding_matrix, index):
335
- """Publish the finished listing index without touching the image index."""
336
- embedding_matrix.flush()
337
- temporary_index_path = INDEX_PATH.with_suffix(INDEX_PATH.suffix + ".tmp")
338
- write_faiss_index_file(index, temporary_index_path)
339
- os.replace(temporary_index_path, INDEX_PATH)
340
- write_json_atomic(METADATA_PATH, products)
341
- write_json_atomic(
342
- PROGRESS_PATH,
343
- {
344
- "completed": len(products),
345
- "total": len(products),
346
- "status": "complete",
347
- "updated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
348
- },
349
- )
350
- return index.ntotal
351
-
352
-
353
- def main():
354
- """Run the full cleaned listing-index build."""
355
- arguments = parse_arguments()
356
- products = load_or_create_clean_products(arguments.force_clean)
357
- model, tokenizer, device = load_model()
358
- print(
359
- "listing_index_start",
360
- json.dumps(
361
- {
362
- "device": str(device),
363
- "products": len(products),
364
- "batch_size": arguments.batch_size,
365
- "output_dir": str(OUTPUT_DIR),
366
- },
367
- ensure_ascii=False,
368
- ),
369
- flush=True,
370
- )
371
- embedding_matrix, index = encode_products(
372
- products,
373
- model,
374
- tokenizer,
375
- device,
376
- max(1, arguments.batch_size),
377
- max(1, arguments.save_every),
378
- )
379
- vector_count = write_final_outputs(products, embedding_matrix, index)
380
- print(
381
- "listing_index_complete",
382
- json.dumps(
383
- {
384
- "vectors": vector_count,
385
- "dimension": int(embedding_matrix.shape[1]),
386
- "index": str(INDEX_PATH),
387
- },
388
- ensure_ascii=False,
389
- ),
390
- flush=True,
391
- )
392
-
393
-
394
- if __name__ == "__main__":
395
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bundle/clip/work/full_clip_server.py DELETED
@@ -1,615 +0,0 @@
1
- """Serve the full trained CLIP index and Kimi assembly planner."""
2
-
3
- import base64
4
- import io
5
- import json
6
- import os
7
- import time
8
- import urllib.error
9
- import urllib.request
10
- from pathlib import Path
11
- from urllib.parse import urlparse
12
-
13
- import faiss
14
- import numpy as np
15
- import open_clip
16
- import torch
17
- from fastapi import FastAPI, File, Form, UploadFile
18
- from fastapi.middleware.cors import CORSMiddleware
19
- from fastapi.responses import HTMLResponse, JSONResponse
20
- from fastapi.staticfiles import StaticFiles
21
- from PIL import Image
22
- import uvicorn
23
-
24
-
25
- APP_ROOT = Path(__file__).resolve().parent.parent
26
- BUNDLED_FULL_INDEX_DIR = APP_ROOT / "data" / "full_clip_index"
27
- BUNDLED_MODEL_PATH = APP_ROOT / "models" / "open_clip_pytorch_model.bin"
28
- CLIP_DIR = APP_ROOT if BUNDLED_FULL_INDEX_DIR.exists() and BUNDLED_MODEL_PATH.exists() else Path(r"F:\Clip")
29
- FULL_INDEX_DIR = CLIP_DIR / "data" / "full_clip_index"
30
- INDEX_PATH = FULL_INDEX_DIR / "products_full.index"
31
- METADATA_PATH = FULL_INDEX_DIR / "products_full_meta.json"
32
- PRICE_METADATA_PATH = FULL_INDEX_DIR / "products_full_prices.json"
33
- PROGRESS_PATH = FULL_INDEX_DIR / "progress.json"
34
- IMAGE_DIR = APP_ROOT / "images"
35
- BASE_MODEL_PATH = CLIP_DIR / "models" / "open_clip_pytorch_model.bin"
36
- TRAINED_CHECKPOINT_PATH = CLIP_DIR / "data" / "yunqi_clip_training" / "last_checkpoint.pt"
37
- HTML_PATH = APP_ROOT / "app" / "clip_search.html" if (APP_ROOT / "app" / "clip_search.html").exists() else APP_ROOT / "clip_search.html"
38
- MODEL_NAME = "ViT-B-32"
39
- CONFIG_PATH = APP_ROOT / "config.json"
40
- KIMI_API_KEY_ENV = "MOONSHOT_API_KEY"
41
- KIMI_ENDPOINT_ENV = "KIMI_ENDPOINT"
42
- KIMI_MODEL_ENV = "KIMI_MODEL"
43
- APP_CONFIG = {}
44
- KIMI_CONFIG = {}
45
- KIMI_API_URL = "https://api.moonshot.cn/v1/chat/completions"
46
- KIMI_MODEL = "kimi-k2.6"
47
- KIMI_TEMPERATURE = 0.6
48
- KIMI_MAX_COMPLETION_TOKENS = 1800
49
-
50
-
51
- def load_app_config():
52
- """Load local app configuration without committing user secrets."""
53
- if not CONFIG_PATH.exists():
54
- return {}
55
- return json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
56
-
57
-
58
- def read_kimi_config():
59
- """Return Kimi API settings from environment variables, config.json, and defaults."""
60
- config = APP_CONFIG.get("kimi", {}) if isinstance(APP_CONFIG, dict) else {}
61
- return {
62
- "api_key": os.environ.get(KIMI_API_KEY_ENV, "").strip() or str(config.get("api_key", "")).strip(),
63
- "endpoint": os.environ.get(KIMI_ENDPOINT_ENV, "").strip() or str(config.get("endpoint", KIMI_API_URL)).strip(),
64
- "model": os.environ.get(KIMI_MODEL_ENV, "").strip() or str(config.get("model", KIMI_MODEL)).strip(),
65
- "temperature": float(config.get("temperature", KIMI_TEMPERATURE)),
66
- "max_completion_tokens": int(config.get("max_completion_tokens", KIMI_MAX_COMPLETION_TOKENS)),
67
- }
68
-
69
-
70
- APP_CONFIG = load_app_config()
71
- KIMI_CONFIG = read_kimi_config()
72
- KIMI_API_URL = KIMI_CONFIG["endpoint"]
73
- KIMI_MODEL = KIMI_CONFIG["model"]
74
- KIMI_TEMPERATURE = KIMI_CONFIG["temperature"]
75
- KIMI_MAX_COMPLETION_TOKENS = KIMI_CONFIG["max_completion_tokens"]
76
-
77
-
78
- def read_faiss_index_file(path):
79
- """Read a FAISS index through Python bytes so Windows Unicode paths stay valid."""
80
- index_bytes = np.frombuffer(path.read_bytes(), dtype="uint8")
81
- return faiss.deserialize_index(index_bytes)
82
-
83
-
84
- def load_runtime():
85
- """Load the completed full FAISS index, metadata, and trained CLIP model."""
86
- if not INDEX_PATH.exists() or not METADATA_PATH.exists():
87
- raise FileNotFoundError("全量索引尚未生成完成")
88
- if PROGRESS_PATH.exists():
89
- progress = json.loads(PROGRESS_PATH.read_text(encoding="utf-8"))
90
- if progress.get("status") != "complete":
91
- raise RuntimeError(
92
- f"全量索引仍在构建:{progress.get('completed', 0)}/{progress.get('total', 0)}"
93
- )
94
- if not BASE_MODEL_PATH.exists() or not TRAINED_CHECKPOINT_PATH.exists():
95
- raise FileNotFoundError("基础模型或最终训练 checkpoint 不存在")
96
- device = torch.device("cpu")
97
- model, _, preprocess = open_clip.create_model_and_transforms(
98
- MODEL_NAME,
99
- pretrained=str(BASE_MODEL_PATH),
100
- )
101
- checkpoint = torch.load(TRAINED_CHECKPOINT_PATH, map_location=device, weights_only=False)
102
- model.load_state_dict(checkpoint["model"])
103
- model = model.to(device)
104
- model.eval()
105
- index = read_faiss_index_file(INDEX_PATH)
106
- products = json.loads(METADATA_PATH.read_text(encoding="utf-8"))
107
- price_metadata = {}
108
- if PRICE_METADATA_PATH.exists():
109
- price_metadata = json.loads(PRICE_METADATA_PATH.read_text(encoding="utf-8"))
110
- if index.ntotal != len(products):
111
- raise RuntimeError(f"索引数量 {index.ntotal} 与元数据数量 {len(products)} 不一致")
112
- tokenizer = open_clip.get_tokenizer(MODEL_NAME)
113
- return model, preprocess, tokenizer, device, index, products, price_metadata
114
-
115
-
116
- def get_rank_window(top_k):
117
- """Clamp the requested result count to a safe full-index range."""
118
- return max(1, min(int(top_k), 100))
119
-
120
-
121
- def encode_image(image, model, preprocess, device):
122
- """Encode one uploaded image with the trained CLIP image tower."""
123
- tensor = preprocess(image.convert("RGB")).unsqueeze(0).to(device)
124
- with torch.inference_mode():
125
- feature = model.encode_image(tensor)
126
- feature = feature / feature.norm(dim=-1, keepdim=True)
127
- return feature.cpu().numpy().astype("float32")
128
-
129
-
130
- def encode_text(query, model, tokenizer, device):
131
- """Encode one prompt with the trained CLIP text tower."""
132
- tokens = tokenizer([query]).to(device)
133
- with torch.inference_mode():
134
- feature = model.encode_text(tokens)
135
- feature = feature / feature.norm(dim=-1, keepdim=True)
136
- return feature.cpu().numpy().astype("float32")
137
-
138
-
139
- def search_index(
140
- query_vector,
141
- top_k,
142
- index,
143
- products,
144
- price_metadata,
145
- min_price=None,
146
- max_price=None,
147
- ):
148
- """Search FAISS and apply an optional price constraint before returning Top K."""
149
- output_count = get_rank_window(top_k)
150
- search_count = index.ntotal if min_price is not None or max_price is not None else output_count
151
- scores, indices = index.search(query_vector, search_count)
152
- results = []
153
- for result_position in range(len(indices[0])):
154
- product_index = int(indices[0][result_position])
155
- if product_index < 0 or product_index >= len(products):
156
- continue
157
- product = products[product_index].copy()
158
- product["similarity"] = round(float(scores[0][result_position]) * 100, 2)
159
- product["img_url"] = f"/images/{product['id']}.jpg"
160
- product_id = str(product["id"])
161
- if product_id in price_metadata:
162
- product.update(price_metadata[product_id])
163
- if min_price is not None or max_price is not None:
164
- price = product.get("price_usd")
165
- if price is None:
166
- continue
167
- if min_price is not None and float(price) < float(min_price):
168
- continue
169
- if max_price is not None and float(price) > float(max_price):
170
- continue
171
- product["rank"] = len(results) + 1
172
- results.append(product)
173
- if len(results) >= output_count:
174
- break
175
- return results
176
-
177
-
178
- def resolve_image_from_request(file, img_url):
179
- """Read an uploaded image or an allowed local image URL into PIL."""
180
- if isinstance(file, (bytes, bytearray)) and file:
181
- return Image.open(io.BytesIO(file)).convert("RGB")
182
- if file and file.filename:
183
- return Image.open(io.BytesIO(file)).convert("RGB")
184
- if img_url:
185
- parsed_url = urlparse(img_url)
186
- local_name = os.path.basename(parsed_url.path)
187
- local_path = IMAGE_DIR / local_name
188
- if parsed_url.path.startswith("/images/") and local_path.exists():
189
- return Image.open(local_path).convert("RGB")
190
- raise ValueError("没有提供有效图片")
191
-
192
-
193
- def merge_search_results(image_results, text_results, image_weight):
194
- """Merge image and listing search results by product ID and CLIP score."""
195
- result_by_id = {}
196
- text_weight = 1.0 - image_weight
197
- for product in image_results:
198
- product_id = str(product.get("id", ""))
199
- item = product.copy()
200
- item["image_similarity"] = float(product.get("similarity", 0.0))
201
- item["text_similarity"] = 0.0
202
- result_by_id[product_id] = item
203
- for product in text_results:
204
- product_id = str(product.get("id", ""))
205
- if product_id not in result_by_id:
206
- item = product.copy()
207
- item["image_similarity"] = 0.0
208
- item["text_similarity"] = float(product.get("similarity", 0.0))
209
- result_by_id[product_id] = item
210
- else:
211
- result_by_id[product_id]["text_similarity"] = float(product.get("similarity", 0.0))
212
- results = []
213
- for item in result_by_id.values():
214
- item["similarity"] = round(
215
- item["image_similarity"] * image_weight
216
- + item["text_similarity"] * text_weight,
217
- 2,
218
- )
219
- results.append(item)
220
- index = 0
221
- while index < len(results):
222
- best_index = index
223
- candidate_index = index + 1
224
- while candidate_index < len(results):
225
- if results[candidate_index]["similarity"] > results[best_index]["similarity"]:
226
- best_index = candidate_index
227
- candidate_index += 1
228
- if best_index != index:
229
- results[index], results[best_index] = results[best_index], results[index]
230
- results[index]["rank"] = index + 1
231
- index += 1
232
- return results
233
-
234
-
235
- def filter_by_price(products, min_price, max_price):
236
- """Keep products inside the requested USD price range."""
237
- if min_price is None and max_price is None:
238
- return products
239
- filtered = []
240
- for product in products:
241
- price = product.get("price_usd")
242
- if price is None:
243
- continue
244
- if min_price is not None and float(price) < float(min_price):
245
- continue
246
- if max_price is not None and float(price) > float(max_price):
247
- continue
248
- filtered.append(product)
249
- return filtered
250
-
251
-
252
- def image_to_data_url(image):
253
- """Resize an input image and encode it as a compact Kimi data URL."""
254
- image_copy = image.copy().convert("RGB")
255
- image_copy.thumbnail((1280, 1280))
256
- image_buffer = io.BytesIO()
257
- image_copy.save(image_buffer, format="JPEG", quality=85, optimize=True)
258
- encoded = base64.b64encode(image_buffer.getvalue()).decode("ascii")
259
- return f"data:image/jpeg;base64,{encoded}"
260
-
261
-
262
- def build_clip_evidence(image, listing, model, preprocess, tokenizer, device, index, products, price_metadata):
263
- """Collect weak CLIP evidence so Kimi can correct noisy retrieval signals."""
264
- evidence = []
265
- if image is not None:
266
- image_vector = encode_image(image, model, preprocess, device)
267
- image_results = search_index(image_vector, 8, index, products, price_metadata)
268
- evidence.append({"source": "image", "results": image_results})
269
- if listing:
270
- listing_vector = encode_text(listing, model, tokenizer, device)
271
- listing_results = search_index(listing_vector, 8, index, products, price_metadata)
272
- evidence.append({"source": "listing", "results": listing_results})
273
- return evidence
274
-
275
-
276
- def call_kimi(image, listing, min_price, max_price, clip_evidence):
277
- """Call domestic Kimi K2.6 in JSON and non-thinking mode."""
278
- api_key = KIMI_CONFIG["api_key"]
279
- if not api_key:
280
- raise RuntimeError(f"未配置 {CONFIG_PATH.name} 里的 kimi.api_key 或 {KIMI_API_KEY_ENV} 环境变量")
281
- price_rule = {
282
- "currency": "USD",
283
- "min": min_price,
284
- "max": max_price,
285
- }
286
- system_prompt = (
287
- "你是商品组货规划助手,不是单纯的相似商品检索器。"
288
- "请根据输入图片和Listing生成10个用于商品向量检索的中英文Prompt,目标是找出可以一起销售或一起购买的一组商品。"
289
- "CLIP召回结果不够精准,只能作为弱证据,禁止直接照抄CLIP误召回的品类。"
290
- "10个Prompt必须发散到不同组货方向,不能只是同一商品的颜色、材质或包装改写。"
291
- "10个方向依次覆盖:1核心相似品,2功能替代品,3互补配件,4共同使用工具,5配套耗材,6高概率一起购买的关联品,7收纳整理品,8包装展示品,9人群场景关联品,10套装组合方案。"
292
- "互补品必须和主商品的使用场景有明确关系,不要生成无关的氛围用品。"
293
- "例如主商品是扳手,可以发散到锤子、螺丝刀、卷尺、螺丝螺母、工具收纳包,而不是只生成不同颜色的扳手。"
294
- "Listing明确写出的品类优先;图片用于确认外观、颜色、形状和材质。"
295
- "如果图片和Listing明显冲突,内部自行纠偏,并优先保留Listing主品类。"
296
- "只能返回合法JSON对象,且只能有一个字段 prompts。"
297
- "prompts 必须是长度为10的数组,数组元素只能是对象,且只能包含 zh 和 en 两个字段。"
298
- "zh 是简短具体的中文检索词,en 是语义完全一致的英文检索词。"
299
- "不要返回plan_name、summary、role、reason、price_filter、input_conflict或clip_adjustment。"
300
- )
301
- user_text = (
302
- "输入Listing:\n"
303
- + (listing or "未提供")
304
- + "\n价格筛选(美元):\n"
305
- + json.dumps(price_rule, ensure_ascii=False)
306
- + "\nCLIP弱证据(可能不准确,只用于发现偏差):\n"
307
- + json.dumps(clip_evidence, ensure_ascii=False)
308
- + "\n请只返回JSON对象,不要Markdown代码围栏。"
309
- )
310
- content = [{"type": "text", "text": user_text}]
311
- if image is not None:
312
- content.insert(
313
- 0,
314
- {
315
- "type": "image_url",
316
- "image_url": {"url": image_to_data_url(image)},
317
- },
318
- )
319
- payload = {
320
- "model": KIMI_MODEL,
321
- "messages": [
322
- {"role": "system", "content": system_prompt},
323
- {"role": "user", "content": content},
324
- ],
325
- "thinking": {"type": "disabled"},
326
- "temperature": KIMI_TEMPERATURE,
327
- "response_format": {"type": "json_object"},
328
- "max_completion_tokens": KIMI_MAX_COMPLETION_TOKENS,
329
- }
330
- request = urllib.request.Request(
331
- KIMI_API_URL,
332
- data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
333
- headers={
334
- "Authorization": f"Bearer {api_key}",
335
- "Content-Type": "application/json",
336
- },
337
- method="POST",
338
- )
339
- response_data = None
340
- attempt = 0
341
- while attempt < 3:
342
- try:
343
- with urllib.request.urlopen(request, timeout=120) as response:
344
- response_data = json.loads(response.read().decode("utf-8"))
345
- break
346
- except urllib.error.HTTPError as error:
347
- detail = error.read().decode("utf-8", errors="replace")
348
- retryable = error.code in (429, 500, 502, 503, 504)
349
- if not retryable or attempt >= 2:
350
- raise RuntimeError(
351
- f"Kimi API HTTP {error.code}: {detail[:500]}"
352
- ) from error
353
- retry_after = error.headers.get("Retry-After")
354
- try:
355
- delay = float(retry_after) if retry_after else 2.0 + attempt * 2.0
356
- except (TypeError, ValueError):
357
- delay = 2.0 + attempt * 2.0
358
- time.sleep(min(max(delay, 1.0), 10.0))
359
- attempt += 1
360
- except urllib.error.URLError as error:
361
- if attempt >= 2:
362
- raise RuntimeError(f"Kimi API 网络错误: {error.reason}") from error
363
- time.sleep(2.0 + attempt * 2.0)
364
- attempt += 1
365
- choices = response_data.get("choices", [])
366
- if not choices:
367
- raise RuntimeError("Kimi API 没有返回 choices")
368
- content_text = choices[0].get("message", {}).get("content", "")
369
- if isinstance(content_text, dict):
370
- return content_text
371
- try:
372
- return json.loads(content_text)
373
- except (TypeError, json.JSONDecodeError) as error:
374
- raise RuntimeError("Kimi 返回内容不是合法 JSON") from error
375
-
376
-
377
- def normalize_kimi_prompts(raw_plan, listing, min_price, max_price):
378
- """Validate Kimi prompts and fill missing prompts without inventing products."""
379
- raw_prompts = raw_plan.get("prompts", []) if isinstance(raw_plan, dict) else []
380
- if not isinstance(raw_prompts, list):
381
- raw_prompts = []
382
- prompts = []
383
- default_roles = [
384
- "核心相似品",
385
- "功能替代品",
386
- "互补配件",
387
- "共同使用工具",
388
- "配套耗材",
389
- "关联加购品",
390
- "收纳整理品",
391
- "包装展示品",
392
- "人群场景关联品",
393
- "场景套装/收纳方案",
394
- ]
395
- for raw_prompt in raw_prompts:
396
- if isinstance(raw_prompt, dict):
397
- prompt_text = str(
398
- raw_prompt.get("zh", raw_prompt.get("prompt", ""))
399
- ).strip()
400
- prompt_english = str(
401
- raw_prompt.get("en", raw_prompt.get("prompt_en", ""))
402
- ).strip()
403
- prompt_role = raw_prompt.get(
404
- "role",
405
- default_roles[min(len(prompts), len(default_roles) - 1)],
406
- )
407
- prompt_reason = raw_prompt.get("reason", "适合图片和Listing检索")
408
- else:
409
- prompt_text = str(raw_prompt).strip()
410
- prompt_english = ""
411
- prompt_role = default_roles[min(len(prompts), len(default_roles) - 1)]
412
- prompt_reason = "适合图片和Listing检索"
413
- if not prompt_text:
414
- continue
415
- prompts.append(
416
- {
417
- "prompt": prompt_text,
418
- "prompt_en": prompt_english,
419
- "role": prompt_role,
420
- "reason": prompt_reason,
421
- }
422
- )
423
- if len(prompts) >= 10:
424
- break
425
- fallback_text = listing.strip() or "符合输入图片风格的商品"
426
- fallback_roles = [
427
- "核心相似品",
428
- "功能替代品",
429
- "互补配件",
430
- "共同使用工具",
431
- "配套耗材",
432
- "关联加购品",
433
- "收纳整理品",
434
- "包装展示品",
435
- "人群场景关联品",
436
- "场景套装/收纳方案",
437
- ]
438
- index = 0
439
- while len(prompts) < 10:
440
- prompts.append(
441
- {
442
- "prompt": f"{fallback_text},{fallback_roles[index]}",
443
- "prompt_en": "",
444
- "role": fallback_roles[index],
445
- "reason": "Kimi未返回足够Prompt,使用Listing补足检索方向",
446
- }
447
- )
448
- index += 1
449
- return {"prompts": prompts}
450
-
451
-
452
- def create_app():
453
- """Load runtime assets and create the FastAPI application."""
454
- model, preprocess, tokenizer, device, index, products, price_metadata = load_runtime()
455
- application = FastAPI(title="Full CLIP Product Search")
456
- application.add_middleware(
457
- CORSMiddleware,
458
- allow_origins=["*"],
459
- allow_credentials=True,
460
- allow_methods=["*"],
461
- allow_headers=["*"],
462
- )
463
- if IMAGE_DIR.exists():
464
- application.mount("/images", StaticFiles(directory=IMAGE_DIR), name="images")
465
-
466
- @application.post("/api/search/image")
467
- async def search_image(
468
- file: UploadFile = File(None),
469
- img_url: str = Form(None),
470
- top_k: int = Form(12),
471
- ):
472
- """Search full product metadata using an uploaded image."""
473
- try:
474
- contents = await file.read() if file and file.filename else None
475
- image = resolve_image_from_request(contents, img_url)
476
- query_vector = encode_image(image, model, preprocess, device)
477
- return {"results": search_index(query_vector, top_k, index, products, price_metadata)}
478
- except Exception as error:
479
- return JSONResponse({"error": str(error)}, status_code=400)
480
-
481
- @application.post("/api/search/text")
482
- async def search_text(
483
- query: str = Form(...),
484
- top_k: int = Form(12),
485
- ):
486
- """Search full product metadata using a text prompt."""
487
- try:
488
- query_vector = encode_text(query, model, tokenizer, device)
489
- return {"results": search_index(query_vector, top_k, index, products, price_metadata)}
490
- except Exception as error:
491
- return JSONResponse({"error": str(error)}, status_code=400)
492
-
493
- @application.post("/api/assemble")
494
- async def assemble_products(
495
- file: UploadFile = File(None),
496
- listing: str = Form(""),
497
- min_price: float = Form(None),
498
- max_price: float = Form(None),
499
- top_k: int = Form(1),
500
- ):
501
- """Generate ten Kimi prompts and retrieve one product for each direction."""
502
- try:
503
- contents = await file.read() if file and file.filename else None
504
- image = resolve_image_from_request(contents, "") if contents else None
505
- if image is None and not listing.strip():
506
- raise ValueError("请至少提供图片或 Listing")
507
- clip_evidence = build_clip_evidence(
508
- image,
509
- listing.strip(),
510
- model,
511
- preprocess,
512
- tokenizer,
513
- device,
514
- index,
515
- products,
516
- price_metadata,
517
- )
518
- raw_plan = call_kimi(
519
- image,
520
- listing.strip(),
521
- min_price,
522
- max_price,
523
- clip_evidence,
524
- )
525
- plan = normalize_kimi_prompts(
526
- raw_plan,
527
- listing,
528
- min_price,
529
- max_price,
530
- )
531
- groups = []
532
- selected_results = []
533
- public_prompts = []
534
- prompt_index = 0
535
- while prompt_index < len(plan["prompts"]):
536
- prompt_item = plan["prompts"][prompt_index]
537
- prompt_text = prompt_item["prompt"]
538
- prompt_english = prompt_item.get("prompt_en", "").strip()
539
- public_prompts.append(
540
- {"zh": prompt_text, "en": prompt_english}
541
- )
542
- recall_text = prompt_english or prompt_text
543
- prompt_vector = encode_text(recall_text, model, tokenizer, device)
544
- prompt_matches = search_index(
545
- prompt_vector,
546
- 100,
547
- index,
548
- products,
549
- price_metadata,
550
- min_price,
551
- max_price,
552
- )
553
- price_matches = prompt_matches
554
- group_results = []
555
- result_index = 0
556
- while result_index < min(1, len(price_matches)):
557
- selected = price_matches[result_index].copy()
558
- selected["prompt_index"] = prompt_index + 1
559
- selected["search_prompt"] = prompt_text
560
- selected["search_prompt_en"] = prompt_english
561
- selected["prompt_role"] = prompt_item["role"]
562
- selected["prompt_reason"] = prompt_item["reason"]
563
- selected["prompt_rank"] = result_index + 1
564
- group_results.append(selected)
565
- selected_results.append(selected)
566
- result_index += 1
567
- groups.append(
568
- {
569
- "prompt_index": prompt_index + 1,
570
- "prompt": prompt_text,
571
- "prompt_en": prompt_english,
572
- "role": prompt_item["role"],
573
- "reason": prompt_item["reason"],
574
- "results": group_results,
575
- }
576
- )
577
- prompt_index += 1
578
- return {
579
- "plan": {"prompts": public_prompts},
580
- "results": selected_results,
581
- "groups": groups,
582
- "prompts_searched": len(groups),
583
- "results_per_prompt": 1,
584
- "model": KIMI_MODEL,
585
- "thinking": "disabled",
586
- }
587
- except Exception as error:
588
- return JSONResponse({"error": str(error)}, status_code=400)
589
-
590
- @application.get("/api/index/status")
591
- async def index_status():
592
- """Return the loaded full-index count for a quick browser health check."""
593
- return {
594
- "vectors": index.ntotal,
595
- "products": len(products),
596
- "price_records": len(price_metadata),
597
- "device": str(device),
598
- "checkpoint": str(TRAINED_CHECKPOINT_PATH),
599
- "kimi_model": KIMI_MODEL,
600
- "kimi_configured": bool(KIMI_CONFIG["api_key"]),
601
- }
602
-
603
- @application.get("/", response_class=HTMLResponse)
604
- async def search_page():
605
- """Serve the standalone image and prompt search page."""
606
- return HTMLResponse(HTML_PATH.read_text(encoding="utf-8"))
607
-
608
- return application
609
-
610
-
611
- app = create_app()
612
-
613
-
614
- if __name__ == "__main__":
615
- uvicorn.run(app, host="127.0.0.1", port=8888)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bundle/clip/work/full_listing_server.py DELETED
@@ -1,951 +0,0 @@
1
- """Provide the cleaned pure-listing CLIP index for the integrated worker."""
2
-
3
- import base64
4
- import asyncio
5
- import concurrent.futures
6
- import io
7
- import json
8
- import os
9
- import subprocess
10
- import sys
11
- import time
12
- import urllib.error
13
- import urllib.parse
14
- import urllib.request
15
- from pathlib import Path
16
-
17
- import faiss
18
- import numpy as np
19
- import open_clip
20
- import torch
21
- from fastapi import Body, FastAPI, File, Form, Request, UploadFile
22
- from fastapi.middleware.cors import CORSMiddleware
23
- from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, Response
24
- from fastapi.staticfiles import StaticFiles
25
- from PIL import Image
26
- import uvicorn
27
-
28
-
29
- APP_ROOT = Path(__file__).resolve().parent.parent
30
- BUNDLED_INDEX_DIR = APP_ROOT / "data" / "full_listing_index"
31
- BUNDLED_MODEL_PATH = APP_ROOT / "models" / "open_clip_pytorch_model.bin"
32
- CLIP_DIR = APP_ROOT if BUNDLED_INDEX_DIR.exists() and BUNDLED_MODEL_PATH.exists() else Path(r"F:\Clip")
33
- LISTING_INDEX_DIR = CLIP_DIR / "data" / "full_listing_index"
34
- INDEX_PATH = LISTING_INDEX_DIR / "products_listing.index"
35
- METADATA_PATH = LISTING_INDEX_DIR / "products_listing_meta.json"
36
- RUNTIME_METADATA_PATH = LISTING_INDEX_DIR / "products_listing_meta.runtime.json"
37
- PRICE_METADATA_PATH = CLIP_DIR / "data" / "full_clip_index" / "products_full_prices.json"
38
- PROGRESS_PATH = LISTING_INDEX_DIR / "progress.json"
39
- REPORT_PATH = LISTING_INDEX_DIR / "cleaning_report.json"
40
- BUILD_LOG_PATH = LISTING_INDEX_DIR / "build.log"
41
- SERVER_LOG_PATH = APP_ROOT / "logs" / "server.log"
42
- IMAGE_DIR = APP_ROOT / "images"
43
- BASE_MODEL_PATH = CLIP_DIR / "models" / "open_clip_pytorch_model.bin"
44
- TRAINED_CHECKPOINT_PATH = CLIP_DIR / "data" / "yunqi_clip_training" / "last_checkpoint.pt"
45
- HTML_PATH = APP_ROOT / "app" / "listing_search.html" if (APP_ROOT / "app" / "listing_search.html").exists() else APP_ROOT / "listing_search.html"
46
- BUILD_SCRIPT_PATH = APP_ROOT / "work" / "build_full_listing_index.py"
47
- MODEL_NAME = "ViT-B-32"
48
- CONFIG_PATH = APP_ROOT / "config.json"
49
- RUNTIME_METADATA_FIELDS = [
50
- "id",
51
- "title",
52
- "listing_key",
53
- "family_key",
54
- "image_url",
55
- "price_usd",
56
- "sales_total",
57
- ]
58
- KIMI_API_KEY_ENV = "MOONSHOT_API_KEY"
59
- KIMI_ENDPOINT_ENV = "KIMI_ENDPOINT"
60
- KIMI_MODEL_ENV = "KIMI_MODEL"
61
- APP_CONFIG = {}
62
- KIMI_CONFIG = {}
63
- KIMI_API_URL = "https://api.moonshot.cn/v1/chat/completions"
64
- KIMI_MODEL = "kimi-k2.6"
65
- KIMI_TEMPERATURE = 0.6
66
- KIMI_MAX_COMPLETION_TOKENS = 1200
67
- KIMI_PROMPT_BATCH_RANGES = [(1, 3), (4, 6), (7, 10)]
68
- KIMI_PROMPT_SLOT_RANGES = KIMI_PROMPT_BATCH_RANGES
69
- DEFAULT_KIMI_SYSTEM_PROMPT = """
70
- 你是跨境电商组货商品检索词生成器。你只根据用户上传的图片生成可一起售卖/一起购买的商品检索词。
71
-
72
- 任务:输出10个“具体可采购商品”,用于后续纯 listing CLIP 检索。
73
-
74
- 生成原则:
75
- 1. 不要只找外观相似品;优先覆盖互补品、同场景加购、替代升级、耗材补充、收纳展示、维护清洁、配套工具、礼盒套装里的其他商品。
76
- 2. 每条必须是具体商品,不要写大类、策略、理由或营销词。不要输出“配件、用品、产品、套装、工具”这种过宽泛词,除非前面有清晰具体限定。
77
- 3. 中文 zh 要像能直接给采购看的商品短名:主体品类 + 关键材质/结构/场景/人群/规格,尽量 6-18 个中文字符。
78
- 4. 英文 en 要像英文 listing 标题检索词:6-14 个英文词,必须包含明确 product noun,并尽量包含 material / shape / color / scene / target user / size / function 中的2-4个要素。
79
- 5. 如果图片主体不确定,根据最明显视觉元素推断;不要解释不确定性。
80
- 6. 10条之间要有明显差异,避免同义改写刷数量。
81
-
82
- 输出格式:只返回合法 JSON 对象,且只能包含 prompts 字段。
83
- prompts 是长度为10的数组,每个元素只能包含 zh 和 en 两个字段。
84
- """.strip()
85
-
86
- RUNTIME_CACHE = {
87
- "model": None,
88
- "tokenizer": None,
89
- "device": None,
90
- "index": None,
91
- "products": None,
92
- "prices": None,
93
- }
94
- BUILD_PROCESS = {"process": None}
95
-
96
-
97
- def write_bundle_log(message, payload=None):
98
- """Write one compact bundle API log line for the Node diagnostics collector."""
99
- entry = {"message": str(message or ""), "payload": payload or {}}
100
- sys.stderr.write("[BUNDLE API] " + json.dumps(entry, ensure_ascii=True) + "\n")
101
- sys.stderr.flush()
102
-
103
-
104
- def load_app_config():
105
- """Load local app configuration without requiring secrets to be committed."""
106
- if not CONFIG_PATH.exists():
107
- return {}
108
- return json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
109
-
110
-
111
- def read_kimi_config():
112
- """Return Kimi settings from environment variables, config.json, and safe defaults."""
113
- config = APP_CONFIG.get("kimi", {}) if isinstance(APP_CONFIG, dict) else {}
114
- return {
115
- "api_key": os.environ.get(KIMI_API_KEY_ENV, "").strip() or str(config.get("api_key", "")).strip(),
116
- "endpoint": os.environ.get(KIMI_ENDPOINT_ENV, "").strip() or str(config.get("endpoint", KIMI_API_URL)).strip(),
117
- "model": os.environ.get(KIMI_MODEL_ENV, "").strip() or str(config.get("model", KIMI_MODEL)).strip(),
118
- "temperature": float(config.get("temperature", KIMI_TEMPERATURE)),
119
- "max_completion_tokens": int(config.get("max_completion_tokens", KIMI_MAX_COMPLETION_TOKENS)),
120
- }
121
-
122
-
123
- def read_json_file(path, fallback):
124
- """Read a JSON file when it exists, otherwise return the fallback value."""
125
- if not path.exists():
126
- return fallback
127
- return json.loads(path.read_text(encoding="utf-8"))
128
-
129
-
130
- def read_faiss_index_file(path):
131
- """Read a FAISS index through Python bytes so Windows Unicode paths stay valid."""
132
- index_bytes = np.frombuffer(path.read_bytes(), dtype="uint8")
133
- return faiss.deserialize_index(index_bytes)
134
-
135
-
136
- def resolve_listing_metadata_path():
137
- """Return the slim runtime metadata when bundled, otherwise use the original build metadata."""
138
- if RUNTIME_METADATA_PATH.exists():
139
- return RUNTIME_METADATA_PATH
140
- return METADATA_PATH
141
-
142
-
143
- def normalize_product_metadata_rows(rows):
144
- """Convert compact runtime metadata rows back into product dictionaries."""
145
- if not rows:
146
- return []
147
- if isinstance(rows[0], dict):
148
- return rows
149
- products = []
150
- for row in rows:
151
- product = {}
152
- values = row if isinstance(row, list) else []
153
- for index, field_name in enumerate(RUNTIME_METADATA_FIELDS):
154
- if index < len(values) and values[index] not in (None, ""):
155
- product[field_name] = values[index]
156
- products.append(product)
157
- return products
158
-
159
-
160
- APP_CONFIG = load_app_config()
161
- KIMI_CONFIG = read_kimi_config()
162
- KIMI_API_URL = KIMI_CONFIG["endpoint"]
163
- KIMI_MODEL = KIMI_CONFIG["model"]
164
- KIMI_TEMPERATURE = KIMI_CONFIG["temperature"]
165
- KIMI_MAX_COMPLETION_TOKENS = KIMI_CONFIG["max_completion_tokens"]
166
-
167
-
168
- def append_server_log(message):
169
- """Append one timestamped server log line without recording secrets."""
170
- SERVER_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
171
- timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
172
- with SERVER_LOG_PATH.open("a", encoding="utf-8") as log_file:
173
- log_file.write(f"{timestamp} {message}\n")
174
-
175
-
176
- def should_skip_access_log(path):
177
- """Return whether a noisy internal endpoint should be hidden from server logs."""
178
- return path in {"/api/index/status", "/api/server/log", "/api/cdn/image"}
179
-
180
-
181
- def validate_cdn_image_url(image_url):
182
- """Validate that the proxied image URL is a plain HTTP(S) CDN URL."""
183
- parsed_url = urllib.parse.urlparse(str(image_url or "").strip())
184
- if parsed_url.scheme not in {"http", "https"}:
185
- raise ValueError("CDN image URL must be http or https")
186
- if not parsed_url.netloc:
187
- raise ValueError("CDN image URL host is missing")
188
- return parsed_url.geturl()
189
-
190
-
191
- def fetch_cdn_image_bytes(safe_url):
192
- """Fetch one validated CDN image in a worker thread for the async proxy endpoint."""
193
- request = urllib.request.Request(
194
- safe_url,
195
- headers={
196
- "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
197
- "Accept": "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
198
- },
199
- method="GET",
200
- )
201
- with urllib.request.urlopen(request, timeout=30) as cdn_response:
202
- image_bytes = cdn_response.read()
203
- content_type = cdn_response.headers.get("Content-Type", "image/jpeg")
204
- status_code = getattr(cdn_response, "status", 200)
205
- return image_bytes, content_type, status_code
206
-
207
-
208
- def read_server_log_filtered(max_bytes):
209
- """Read recent server logs while hiding noisy internal heartbeat entries."""
210
- raw_log = read_tail(SERVER_LOG_PATH, max_bytes)
211
- hidden_patterns = [
212
- " /api/index/status ",
213
- " /api/server/log ",
214
- " /api/cdn/image ",
215
- ]
216
- visible_lines = []
217
- for line in raw_log.splitlines():
218
- if not line.startswith("20"):
219
- continue
220
- if any(pattern in line for pattern in hidden_patterns):
221
- continue
222
- visible_lines.append(line)
223
- return "\n".join(visible_lines)
224
-
225
-
226
- def load_model_runtime():
227
- """Load the trained CLIP text tower only once per server process."""
228
- if RUNTIME_CACHE["model"] is not None:
229
- return
230
- if not BASE_MODEL_PATH.exists() or not TRAINED_CHECKPOINT_PATH.exists():
231
- raise FileNotFoundError("Base model or trained checkpoint is missing")
232
- write_bundle_log("CLIP load progress", {"stage": "model_base", "progress": 78, "message": "正在载入 CLIP 基础模型。", "status": "loading", "error": ""})
233
- device = torch.device("cpu")
234
- model, _, _ = open_clip.create_model_and_transforms(
235
- MODEL_NAME,
236
- pretrained=str(BASE_MODEL_PATH),
237
- )
238
- write_bundle_log("CLIP load progress", {"stage": "model_checkpoint", "progress": 87, "message": "正在载入 CLIP 训练权重。", "status": "loading", "error": ""})
239
- checkpoint = torch.load(TRAINED_CHECKPOINT_PATH, map_location=device, weights_only=False)
240
- model.load_state_dict(checkpoint["model"])
241
- model = model.to(device)
242
- model.eval()
243
- RUNTIME_CACHE["model"] = model
244
- RUNTIME_CACHE["tokenizer"] = open_clip.get_tokenizer(MODEL_NAME)
245
- RUNTIME_CACHE["device"] = device
246
- write_bundle_log("CLIP load progress", {"stage": "model_ready", "progress": 96, "message": "CLIP 模型已载入,正在完成初始化。", "status": "loading", "error": ""})
247
-
248
-
249
- def load_index_runtime():
250
- """Load or reload the completed listing FAISS index and product metadata."""
251
- metadata_path = resolve_listing_metadata_path()
252
- if not INDEX_PATH.exists() or not metadata_path.exists():
253
- raise FileNotFoundError("Listing index is not ready; start the build first")
254
- progress = read_json_file(PROGRESS_PATH, {})
255
- if progress.get("status") != "complete":
256
- raise RuntimeError(
257
- f"Listing index is still building: {progress.get('completed', 0)}/{progress.get('total', 0)}"
258
- )
259
- index_mtime = INDEX_PATH.stat().st_mtime
260
- cached_mtime = RUNTIME_CACHE.get("index_mtime")
261
- if RUNTIME_CACHE["index"] is not None and cached_mtime == index_mtime:
262
- return
263
- write_bundle_log("CLIP load progress", {"stage": "index", "progress": 46, "message": "正在载入 FAISS 商品索引。", "status": "loading", "error": ""})
264
- RUNTIME_CACHE["index"] = read_faiss_index_file(INDEX_PATH)
265
- write_bundle_log("CLIP load progress", {"stage": "metadata", "progress": 60, "message": "正在载入商品元数据。", "status": "loading", "error": ""})
266
- RUNTIME_CACHE["products"] = normalize_product_metadata_rows(read_json_file(metadata_path, []))
267
- write_bundle_log("CLIP load progress", {"stage": "prices", "progress": 70, "message": "正在载入价格数据。", "status": "loading", "error": ""})
268
- RUNTIME_CACHE["prices"] = read_json_file(PRICE_METADATA_PATH, {})
269
- RUNTIME_CACHE["index_mtime"] = index_mtime
270
- if RUNTIME_CACHE["index"].ntotal != len(RUNTIME_CACHE["products"]):
271
- raise RuntimeError("Listing index count does not match metadata count")
272
-
273
-
274
- def encode_text(query):
275
- """Encode one listing query with the trained CLIP text tower."""
276
- load_model_runtime()
277
- tokenizer = RUNTIME_CACHE["tokenizer"]
278
- model = RUNTIME_CACHE["model"]
279
- device = RUNTIME_CACHE["device"]
280
- tokens = tokenizer([query]).to(device)
281
- with torch.inference_mode():
282
- feature = model.encode_text(tokens)
283
- feature = feature / feature.norm(dim=-1, keepdim=True)
284
- return feature.cpu().numpy().astype("float32")
285
-
286
-
287
- def get_rank_window(top_k):
288
- """Clamp the requested result count to a practical range."""
289
- return max(1, min(int(top_k), 100))
290
-
291
-
292
- def should_keep_price(product, min_price, max_price):
293
- """Return whether a product is inside the optional USD price range."""
294
- if min_price is None and max_price is None:
295
- return True
296
- price = product.get("price_usd")
297
- if price is None:
298
- return False
299
- if min_price is not None and float(price) < float(min_price):
300
- return False
301
- if max_price is not None and float(price) > float(max_price):
302
- return False
303
- return True
304
-
305
-
306
- def resolve_product_image_url(product):
307
- """Return the MAINIMAGE/CDN URL from metadata, or an empty string when unavailable."""
308
- image_fields = [
309
- "MAINIMAGE",
310
- "mainImage",
311
- "main_image",
312
- "mainimage",
313
- "image_url",
314
- "imgUrl",
315
- "img_url",
316
- ]
317
- for field_name in image_fields:
318
- image_value = str(product.get(field_name, "") or "").strip()
319
- if image_value.lower().startswith(("http://", "https://")):
320
- return image_value
321
- return ""
322
-
323
-
324
- def add_sidecar_fields(product):
325
- """Attach price data and the required CDN image URL to one product."""
326
- product_id = str(product.get("id", ""))
327
- prices = RUNTIME_CACHE["prices"] or {}
328
- if product_id in prices:
329
- product.update(prices[product_id])
330
- product["img_url"] = resolve_product_image_url(product)
331
- return product
332
-
333
-
334
- def search_listing_index(query_vector, top_k, min_price, max_price):
335
- """Search the text index and dedupe similar listing families before returning."""
336
- load_index_runtime()
337
- index = RUNTIME_CACHE["index"]
338
- products = RUNTIME_CACHE["products"]
339
- output_count = get_rank_window(top_k)
340
- if min_price is not None or max_price is not None:
341
- search_count = index.ntotal
342
- else:
343
- search_count = min(index.ntotal, max(output_count * 30, 300))
344
- scores, indices = index.search(query_vector, search_count)
345
- results = []
346
- seen_families = set()
347
- seen_images = set()
348
- result_position = 0
349
- while result_position < len(indices[0]):
350
- product_index = int(indices[0][result_position])
351
- if product_index < 0 or product_index >= len(products):
352
- result_position += 1
353
- continue
354
- product = products[product_index].copy()
355
- product = add_sidecar_fields(product)
356
- if not product.get("img_url"):
357
- result_position += 1
358
- continue
359
- if not should_keep_price(product, min_price, max_price):
360
- result_position += 1
361
- continue
362
- family_key = str(product.get("family_key", product.get("listing_key", "")))
363
- image_key = str(product.get("local_img", product.get("image_url", "")))
364
- if family_key in seen_families or image_key in seen_images:
365
- result_position += 1
366
- continue
367
- product["similarity"] = round(float(scores[0][result_position]) * 100, 2)
368
- product["rank"] = len(results) + 1
369
- product["source"] = "Listing"
370
- results.append(product)
371
- seen_families.add(family_key)
372
- seen_images.add(image_key)
373
- if len(results) >= output_count:
374
- break
375
- result_position += 1
376
- return results
377
-
378
-
379
- def image_to_data_url(image):
380
- """Encode an uploaded image as a compact Kimi-compatible data URL."""
381
- image_copy = image.copy().convert("RGB")
382
- image_copy.thumbnail((1280, 1280))
383
- image_buffer = io.BytesIO()
384
- image_copy.save(image_buffer, format="JPEG", quality=85, optimize=True)
385
- encoded = base64.b64encode(image_buffer.getvalue()).decode("ascii")
386
- return f"data:image/jpeg;base64,{encoded}"
387
-
388
-
389
- def read_uploaded_image(contents):
390
- """Read uploaded bytes into a normalized PIL image."""
391
- if not contents:
392
- return None
393
- return Image.open(io.BytesIO(contents)).convert("RGB")
394
-
395
-
396
- def parse_kimi_prompt_json(response_data):
397
- """Extract the JSON prompt object from one Kimi chat completion response."""
398
- choices = response_data.get("choices", []) if isinstance(response_data, dict) else []
399
- if not choices:
400
- raise RuntimeError("Kimi API returned no choices")
401
- content_text = choices[0].get("message", {}).get("content", "")
402
- if isinstance(content_text, dict):
403
- return content_text
404
- try:
405
- return json.loads(content_text)
406
- except (TypeError, json.JSONDecodeError) as error:
407
- raise RuntimeError("Kimi response is not valid JSON") from error
408
-
409
-
410
- def collect_kimi_batch_prompts(raw_plan):
411
- """Return valid prompts in the exact order supplied by one completed Kimi batch."""
412
- raw_prompts = raw_plan.get("prompts", []) if isinstance(raw_plan, dict) else []
413
- if not isinstance(raw_prompts, list):
414
- return []
415
- prompts = []
416
- skipped_count = 0
417
- for idx, raw_prompt in enumerate(raw_prompts):
418
- if isinstance(raw_prompt, dict):
419
- prompt_item = {
420
- "zh": str(raw_prompt.get("zh", raw_prompt.get("prompt", ""))).strip(),
421
- "en": str(raw_prompt.get("en", raw_prompt.get("prompt_en", ""))).strip(),
422
- }
423
- else:
424
- prompt_item = {"zh": str(raw_prompt).strip(), "en": ""}
425
- if prompt_item["zh"] or prompt_item["en"]:
426
- prompts.append(prompt_item)
427
- else:
428
- skipped_count += 1
429
- write_bundle_log("Kimi prompt skipped (empty)", {
430
- "index": idx,
431
- "raw": raw_prompt,
432
- })
433
- if skipped_count > 0:
434
- write_bundle_log("Kimi batch prompts collected", {
435
- "valid": len(prompts),
436
- "skipped": skipped_count,
437
- "total": len(raw_prompts),
438
- })
439
- return prompts
440
-
441
-
442
- def call_kimi_prompt_batch(image_data_url, min_price, max_price, kimi_prompt, batch_start, batch_end, kimi_user_prompt=""):
443
- """Ask Kimi for one independent batch whose results keep provider return order."""
444
- api_key = KIMI_CONFIG["api_key"]
445
- if not api_key:
446
- raise RuntimeError(f"Missing Kimi api_key in {CONFIG_PATH.name} or {KIMI_API_KEY_ENV} environment variable")
447
- price_rule = {"currency": "USD", "min": min_price, "max": max_price}
448
- batch_count = max(1, batch_end - batch_start + 1)
449
- system_prompt = (kimi_prompt or DEFAULT_KIMI_SYSTEM_PROMPT).strip()
450
- user_prompt = str(kimi_user_prompt or "").strip()
451
- schema_guard = (
452
- "\n\n硬性输出约束:只返回合法JSON对象,不能返回Markdown代码围栏。"
453
- f"JSON只能包含prompts字段;prompts必须是长度为{batch_count}的数组;"
454
- "每个元素只能包含zh和en两个字段,不要输出slot。"
455
- )
456
- user_text = (
457
- (user_prompt + "\n\n" if user_prompt else "")
458
- + f"请生成本批次的 {batch_count} 个 CLIP 检索方向。"
459
- "这些结果会按照各批次实际返回先后拼接,不要输出编号。\n"
460
- "Price filter:\n"
461
- + json.dumps(price_rule, ensure_ascii=False)
462
- + "\n只返回JSON,不要Markdown代码围栏。"
463
- )
464
- content = [{"type": "text", "text": user_text}]
465
- if image_data_url:
466
- content.insert(0, {"type": "image_url", "image_url": {"url": image_data_url}})
467
- payload = {
468
- "model": KIMI_MODEL,
469
- "messages": [
470
- {"role": "system", "content": system_prompt + schema_guard},
471
- {"role": "user", "content": content},
472
- ],
473
- "thinking": {"type": "disabled"},
474
- "temperature": KIMI_TEMPERATURE,
475
- "response_format": {"type": "json_object"},
476
- "max_completion_tokens": min(KIMI_MAX_COMPLETION_TOKENS, 650),
477
- }
478
- request = urllib.request.Request(
479
- KIMI_API_URL,
480
- data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
481
- headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
482
- method="POST",
483
- )
484
- response_data = None
485
- attempt = 0
486
- while attempt < 3:
487
- try:
488
- write_bundle_log("Kimi prompts POST", {
489
- "endpoint": KIMI_API_URL,
490
- "model": KIMI_MODEL,
491
- "attempt": attempt + 1,
492
- "batch_start": batch_start,
493
- "batch_end": batch_end,
494
- "min_price": min_price,
495
- "max_price": max_price,
496
- })
497
- with urllib.request.urlopen(request, timeout=120) as response:
498
- response_data = json.loads(response.read().decode("utf-8"))
499
- write_bundle_log("Kimi prompts response", {
500
- "status": "ok",
501
- "model": KIMI_MODEL,
502
- "attempt": attempt + 1,
503
- "batch_start": batch_start,
504
- "batch_end": batch_end,
505
- })
506
- break
507
- except urllib.error.HTTPError as error:
508
- detail = error.read().decode("utf-8", errors="replace")
509
- write_bundle_log("Kimi prompts HTTP error", {
510
- "status": error.code,
511
- "attempt": attempt + 1,
512
- "batch_start": batch_start,
513
- "batch_end": batch_end,
514
- "detail": detail[:300],
515
- })
516
- if error.code not in (429, 500, 502, 503, 504) or attempt >= 2:
517
- raise RuntimeError(f"Kimi API HTTP {error.code}: {detail[:500]}") from error
518
- retry_after = error.headers.get("Retry-After")
519
- try:
520
- delay = float(retry_after) if retry_after else 2.0 + attempt * 2.0
521
- except (TypeError, ValueError):
522
- delay = 2.0 + attempt * 2.0
523
- time.sleep(min(max(delay, 1.0), 10.0))
524
- attempt += 1
525
- except urllib.error.URLError as error:
526
- write_bundle_log("Kimi prompts network error", {
527
- "attempt": attempt + 1,
528
- "batch_start": batch_start,
529
- "batch_end": batch_end,
530
- "reason": str(error.reason),
531
- })
532
- if attempt >= 2:
533
- raise RuntimeError(f"Kimi API network error: {error.reason}") from error
534
- time.sleep(2.0 + attempt * 2.0)
535
- attempt += 1
536
- raw_plan = parse_kimi_prompt_json(response_data)
537
- prompts = collect_kimi_batch_prompts(raw_plan)
538
- write_bundle_log("Kimi batch complete", {
539
- "batch_start": batch_start,
540
- "batch_end": batch_end,
541
- "expected_count": batch_end - batch_start + 1,
542
- "actual_count": len(prompts),
543
- "raw_response": response_data,
544
- })
545
- return prompts
546
-
547
-
548
- def call_kimi_prompts(image, min_price, max_price, kimi_prompt, max_workers=3, on_batch_completed=None, kimi_user_prompt=""):
549
- """Ask Kimi concurrently for ten JSON bundle-product prompts, ordered by completion time."""
550
- image_data_url = image_to_data_url(image) if image is not None else ""
551
- batch_ranges = KIMI_PROMPT_BATCH_RANGES
552
- safe_workers = max(1, min(int(max_workers or 1), len(batch_ranges)))
553
- completed_prompts = []
554
- errors = []
555
- with concurrent.futures.ThreadPoolExecutor(max_workers=safe_workers) as executor:
556
- future_map = {}
557
- for batch_start, batch_end in batch_ranges:
558
- future = executor.submit(
559
- call_kimi_prompt_batch,
560
- image_data_url,
561
- min_price,
562
- max_price,
563
- kimi_prompt,
564
- batch_start,
565
- batch_end,
566
- kimi_user_prompt,
567
- )
568
- future_map[future] = (batch_start, batch_end)
569
- for future in concurrent.futures.as_completed(future_map):
570
- batch_start, batch_end = future_map[future]
571
- try:
572
- batch_prompts = future.result()
573
- completed_prompts.extend(batch_prompts)
574
- if callable(on_batch_completed) and batch_prompts:
575
- on_batch_completed(batch_start, batch_end, batch_prompts)
576
- except Exception as error:
577
- errors.append(f"{batch_start}-{batch_end}: {error}")
578
- write_bundle_log("Kimi prompt batch failed", {
579
- "batch_start": batch_start,
580
- "batch_end": batch_end,
581
- "error": str(error),
582
- })
583
- if not completed_prompts and errors:
584
- raise RuntimeError("Kimi parallel prompts failed: " + "; ".join(errors))
585
- write_bundle_log("Kimi all batches complete", {
586
- "total_prompts": len(completed_prompts),
587
- "expected_total": sum(end - start + 1 for start, end in batch_ranges),
588
- "batch_count": len(batch_ranges),
589
- "errors": errors,
590
- })
591
- return {"prompts": completed_prompts, "batch_errors": errors, "batch_workers": safe_workers}
592
-
593
-
594
- def normalize_kimi_prompts(raw_plan, fill_missing=True):
595
- """Validate Kimi prompts and optionally fill missing directions."""
596
- raw_prompts = raw_plan.get("prompts", []) if isinstance(raw_plan, dict) else []
597
- if not isinstance(raw_prompts, list):
598
- raw_prompts = []
599
- prompts = []
600
- for raw_prompt in raw_prompts:
601
- if isinstance(raw_prompt, dict):
602
- prompt_zh = str(raw_prompt.get("zh", raw_prompt.get("prompt", ""))).strip()
603
- prompt_en = str(raw_prompt.get("en", raw_prompt.get("prompt_en", ""))).strip()
604
- else:
605
- prompt_zh = str(raw_prompt).strip()
606
- prompt_en = ""
607
- if not prompt_zh and not prompt_en:
608
- continue
609
- prompts.append({"zh": prompt_zh, "en": prompt_en})
610
- if len(prompts) >= 10:
611
- break
612
- fallback_text = "related product bundle"
613
- if fill_missing:
614
- while len(prompts) < 10:
615
- prompts.append({"zh": fallback_text, "en": fallback_text})
616
- return {"prompts": prompts}
617
-
618
-
619
- def search_prompt_groups(prompts, min_price, max_price, top_k, prompt_offset=0):
620
- """Run each Kimi prompt through the listing CLIP index and group results."""
621
- groups = []
622
- selected_results = []
623
- prompt_index = 0
624
- while prompt_index < len(prompts):
625
- prompt_item = prompts[prompt_index]
626
- recall_text = prompt_item.get("en") or prompt_item.get("zh") or ""
627
- query_vector = encode_text(recall_text)
628
- matches = search_listing_index(query_vector, top_k, min_price, max_price)
629
- group_results = []
630
- result_index = 0
631
- while result_index < len(matches):
632
- product = matches[result_index].copy()
633
- product["prompt_index"] = prompt_offset + prompt_index + 1
634
- product["search_prompt"] = prompt_item.get("zh", "")
635
- product["search_prompt_en"] = prompt_item.get("en", "")
636
- product["prompt_rank"] = result_index + 1
637
- group_results.append(product)
638
- selected_results.append(product)
639
- result_index += 1
640
- groups.append(
641
- {
642
- "prompt_index": prompt_offset + prompt_index + 1,
643
- "prompt": prompt_item.get("zh", ""),
644
- "prompt_en": prompt_item.get("en", ""),
645
- "results": group_results,
646
- }
647
- )
648
- prompt_index += 1
649
- return groups, selected_results
650
-
651
-
652
- def read_tail(path, max_bytes):
653
- """Read the end of a log file without loading the whole file."""
654
- if not path.exists():
655
- return ""
656
- with path.open("rb") as log_file:
657
- log_file.seek(0, os.SEEK_END)
658
- size = log_file.tell()
659
- log_file.seek(max(0, size - max_bytes), os.SEEK_SET)
660
- return log_file.read().decode("utf-8", errors="replace")
661
-
662
-
663
- def is_build_running():
664
- """Return whether the current build subprocess is still active."""
665
- process = BUILD_PROCESS.get("process")
666
- if process is None:
667
- return False
668
- return process.poll() is None
669
-
670
-
671
- def start_build_process(batch_size, force_clean):
672
- """Start the listing-index build in the background and append logs."""
673
- if is_build_running():
674
- return False
675
- LISTING_INDEX_DIR.mkdir(parents=True, exist_ok=True)
676
- command = [
677
- sys.executable,
678
- str(BUILD_SCRIPT_PATH),
679
- "--batch-size",
680
- str(max(1, min(int(batch_size), 2048))),
681
- ]
682
- if force_clean:
683
- command.append("--force-clean")
684
- log_file = BUILD_LOG_PATH.open("a", encoding="utf-8")
685
- log_file.write(f"\nserver_start_build {command}\n")
686
- log_file.flush()
687
- BUILD_PROCESS["process"] = subprocess.Popen(
688
- command,
689
- stdout=log_file,
690
- stderr=subprocess.STDOUT,
691
- cwd=str(BUILD_SCRIPT_PATH.parent),
692
- )
693
- return True
694
-
695
-
696
- def build_status_payload():
697
- """Return progress, cleaning report, and recent build log for the UI."""
698
- progress = read_json_file(PROGRESS_PATH, {})
699
- report = read_json_file(REPORT_PATH, {})
700
- inferred_running = is_build_running()
701
- if not inferred_running and progress.get("status") == "building":
702
- completed = int(progress.get("completed", 0) or 0)
703
- total = int(progress.get("total", 0) or 0)
704
- inferred_running = total > 0 and completed < total
705
- payload = {
706
- "running": inferred_running,
707
- "progress": progress,
708
- "report": report,
709
- "log": read_tail(BUILD_LOG_PATH, 20000),
710
- "index_exists": INDEX_PATH.exists(),
711
- "metadata_exists": resolve_listing_metadata_path().exists(),
712
- }
713
- if INDEX_PATH.exists():
714
- payload["index_size_mb"] = round(INDEX_PATH.stat().st_size / 1024 / 1024, 2)
715
- return payload
716
-
717
-
718
- def create_app():
719
- """Create the legacy FastAPI application for pure listing search."""
720
- application = FastAPI(title="Pure Listing CLIP Search")
721
- application.add_middleware(
722
- CORSMiddleware,
723
- allow_origins=["*"],
724
- allow_credentials=True,
725
- allow_methods=["*"],
726
- allow_headers=["*"],
727
- )
728
- if IMAGE_DIR.exists():
729
- application.mount("/listing-images", StaticFiles(directory=IMAGE_DIR), name="listing-images")
730
-
731
- @application.middleware("http")
732
- async def log_http_request(request: Request, call_next):
733
- """Write one compact access log line for every API/page request."""
734
- started_at = time.perf_counter()
735
- skip_access_log = should_skip_access_log(request.url.path)
736
- try:
737
- response = await call_next(request)
738
- except Exception as error:
739
- duration_ms = int((time.perf_counter() - started_at) * 1000)
740
- if not skip_access_log:
741
- append_server_log(
742
- f"{request.method} {request.url.path} ERROR {duration_ms}ms {type(error).__name__}: {error}"
743
- )
744
- raise
745
- duration_ms = int((time.perf_counter() - started_at) * 1000)
746
- if not skip_access_log:
747
- append_server_log(
748
- f"{request.method} {request.url.path} {response.status_code} {duration_ms}ms"
749
- )
750
- return response
751
-
752
- @application.get("/api/server/log", response_class=PlainTextResponse)
753
- async def server_log(max_bytes: int = 50000):
754
- """Return the recent local server log as plain text."""
755
- safe_max_bytes = max(1000, min(int(max_bytes), 1000000))
756
- return PlainTextResponse(read_server_log_filtered(safe_max_bytes))
757
-
758
- @application.get("/api/cdn/image")
759
- async def proxy_cdn_image(url: str):
760
- """Fetch one remote CDN image through this server so the request is visible in logs."""
761
- started_at = time.perf_counter()
762
- try:
763
- safe_url = validate_cdn_image_url(url)
764
- image_bytes, content_type, status_code = await asyncio.to_thread(fetch_cdn_image_bytes, safe_url)
765
- duration_ms = int((time.perf_counter() - started_at) * 1000)
766
- append_server_log(f"CDN GET {safe_url} {status_code} {len(image_bytes)}B {duration_ms}ms")
767
- return Response(
768
- content=image_bytes,
769
- media_type=content_type,
770
- headers={"Cache-Control": "public, max-age=86400"},
771
- )
772
- except Exception as error:
773
- duration_ms = int((time.perf_counter() - started_at) * 1000)
774
- append_server_log(f"CDN GET {url} ERROR {duration_ms}ms {type(error).__name__}: {error}")
775
- return Response(status_code=502)
776
-
777
- @application.post("/api/index/build/start")
778
- async def start_index_build(
779
- batch_size: int = Form(256),
780
- force_clean: bool = Form(False),
781
- ):
782
- """Start a background cleaned listing-index build."""
783
- try:
784
- started = start_build_process(batch_size, force_clean)
785
- return {"started": started, "status": build_status_payload()}
786
- except Exception as error:
787
- return JSONResponse({"error": str(error)}, status_code=400)
788
-
789
- @application.get("/api/index/status")
790
- async def index_status():
791
- """Return listing-index build and load status."""
792
- status = build_status_payload()
793
- status["kimi_model"] = KIMI_MODEL
794
- status["kimi_configured"] = bool(KIMI_CONFIG["api_key"])
795
- if INDEX_PATH.exists() and METADATA_PATH.exists():
796
- try:
797
- load_index_runtime()
798
- status["vectors"] = RUNTIME_CACHE["index"].ntotal
799
- status["products"] = len(RUNTIME_CACHE["products"])
800
- status["price_records"] = len(RUNTIME_CACHE["prices"])
801
- except Exception as error:
802
- status["load_error"] = str(error)
803
- return status
804
-
805
- @application.post("/api/search/text")
806
- async def search_text(
807
- query: str = Form(...),
808
- top_k: int = Form(24),
809
- min_price: float = Form(None),
810
- max_price: float = Form(None),
811
- ):
812
- """Search the cleaned pure-listing index."""
813
- try:
814
- if not query.strip():
815
- raise ValueError("Query is empty")
816
- query_vector = encode_text(query.strip())
817
- results = search_listing_index(query_vector, top_k, min_price, max_price)
818
- return {"results": results}
819
- except Exception as error:
820
- return JSONResponse({"error": str(error)}, status_code=400)
821
-
822
- @application.post("/api/search/prompts")
823
- async def search_prompts(payload: dict = Body(...)):
824
- """Search the listing index with manually edited Kimi prompt JSON."""
825
- try:
826
- plan = normalize_kimi_prompts(payload, fill_missing=False)
827
- if not plan["prompts"]:
828
- raise ValueError("Edited prompts are empty")
829
- min_price = payload.get("min_price")
830
- max_price = payload.get("max_price")
831
- safe_top_k = max(1, min(int(payload.get("top_k", 1)), 10))
832
- groups, selected_results = search_prompt_groups(
833
- plan["prompts"],
834
- min_price,
835
- max_price,
836
- safe_top_k,
837
- )
838
- return {
839
- "plan": plan,
840
- "groups": groups,
841
- "results": selected_results,
842
- "prompts_searched": len(groups),
843
- "results_per_prompt": safe_top_k,
844
- "source": "edited_prompts",
845
- }
846
- except Exception as error:
847
- return JSONResponse({"error": str(error)}, status_code=400)
848
-
849
- @application.post("/api/assemble")
850
- async def assemble_products(
851
- file: UploadFile = File(None),
852
- listing: str = Form(""),
853
- kimi_prompt: str = Form(""),
854
- top_k: int = Form(2),
855
- min_price: float = Form(None),
856
- max_price: float = Form(None),
857
- ):
858
- """Run image-only Kimi JSON prompts, then search the listing CLIP index.
859
- 核心流程(按你要求):
860
- - Kimi 生成 10 个方向(3+3+4 并发)
861
- - 哪个 Kimi 批次先返回,哪个批次先进入 CLIP
862
- - 当前接口保持一次性返回,不使用 SSE
863
- - 最终固定返回 10 个商品,按 Kimi 实际返回顺序排序
864
- """
865
- try:
866
- contents = await file.read() if file and file.filename else None
867
- image = read_uploaded_image(contents)
868
- if image is None:
869
- raise ValueError("Please upload an image for Kimi bundle generation")
870
- effective_system_prompt = DEFAULT_KIMI_SYSTEM_PROMPT
871
- effective_user_prompt = kimi_prompt.strip()
872
- safe_top_k = max(1, min(int(top_k), 10))
873
- batch_groups = []
874
- selected_results = []
875
- searched_prompt_count = 0
876
-
877
- def search_completed_kimi_batch(_batch_start, _batch_end, batch_prompts):
878
- """Search one completed Kimi batch before slower Kimi batches finish."""
879
- nonlocal searched_prompt_count
880
- batch_plan = normalize_kimi_prompts({"prompts": batch_prompts}, fill_missing=False)
881
- prompts = batch_plan["prompts"]
882
- if not prompts:
883
- return
884
- groups, batch_results = search_prompt_groups(
885
- prompts,
886
- min_price,
887
- max_price,
888
- safe_top_k,
889
- searched_prompt_count,
890
- )
891
- searched_prompt_count += len(prompts)
892
- batch_groups.extend(groups)
893
- selected_results.extend(batch_results)
894
-
895
- # === Kimi 批次返回后立即进入 CLIP,接口最终一次返回 ===
896
- raw_plan = call_kimi_prompts(
897
- image,
898
- min_price,
899
- max_price,
900
- effective_system_prompt,
901
- on_batch_completed=search_completed_kimi_batch,
902
- kimi_user_prompt=effective_user_prompt,
903
- )
904
- plan = normalize_kimi_prompts(raw_plan)
905
- groups = batch_groups
906
- if not groups:
907
- groups, selected_results = search_prompt_groups(plan["prompts"], min_price, max_price, safe_top_k)
908
-
909
- # === 固定返回 10 个商品 ===
910
- if len(selected_results) > 10:
911
- selected_results = selected_results[:10]
912
- elif len(selected_results) < 10:
913
- while len(selected_results) < 10:
914
- selected_results.append({
915
- "id": f"fallback-{len(selected_results)}",
916
- "title": "related product bundle",
917
- "prompt_index": 1,
918
- "search_prompt": "",
919
- "search_prompt_en": "",
920
- "prompt_rank": 1,
921
- "similarity": 50.0,
922
- "source": "fallback"
923
- })
924
-
925
- return {
926
- "plan": plan,
927
- "groups": groups,
928
- "results": selected_results,
929
- "prompts_searched": len(groups),
930
- "results_per_prompt": safe_top_k,
931
- "model": KIMI_MODEL,
932
- "thinking": "disabled",
933
- "kimi_prompt": effective_user_prompt,
934
- "return_count": len(selected_results)
935
- }
936
- except Exception as error:
937
- return JSONResponse({"error": str(error)}, status_code=400)
938
-
939
- @application.get("/", response_class=HTMLResponse)
940
- async def listing_page():
941
- """Serve the standalone pure-listing search page."""
942
- return HTMLResponse(HTML_PATH.read_text(encoding="utf-8"))
943
-
944
- return application
945
-
946
-
947
- app = create_app()
948
-
949
-
950
- if __name__ == "__main__":
951
- raise SystemExit("9990 HTTP service is disabled. Use stdio_listing_worker.py through the 3000 server.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bundle/clip/work/stdio_listing_worker.py DELETED
@@ -1,184 +0,0 @@
1
- """Serve CLIP listing operations over stdin/stdout without opening an HTTP port."""
2
-
3
- import base64
4
- import json
5
- import sys
6
-
7
- from full_listing_server import (
8
- call_kimi_prompts,
9
- read_uploaded_image,
10
- normalize_kimi_prompts,
11
- search_prompt_groups,
12
- encode_text,
13
- search_listing_index,
14
- build_status_payload,
15
- load_index_runtime,
16
- load_model_runtime,
17
- RUNTIME_CACHE,
18
- KIMI_CONFIG,
19
- KIMI_MODEL,
20
- DEFAULT_KIMI_SYSTEM_PROMPT,
21
- )
22
-
23
-
24
- def configure_stdio_encoding():
25
- """Force safe UTF-8 stream writes even when data contains surrogate escapes."""
26
- for stream in (sys.stdout, sys.stderr):
27
- reconfigure = getattr(stream, "reconfigure", None)
28
- if reconfigure:
29
- reconfigure(encoding="utf-8", errors="backslashreplace")
30
-
31
-
32
- def write_json_line(payload):
33
- """Write one JSON response line and flush immediately for the Node parent."""
34
- sys.stdout.write(json.dumps(payload, ensure_ascii=True) + "\n")
35
- sys.stdout.flush()
36
-
37
-
38
- def read_optional_float(value):
39
- """Convert one optional numeric value from JSON into a float or None."""
40
- if value is None or value == "":
41
- return None
42
- return float(value)
43
-
44
-
45
- def read_uploaded_image_from_base64(value):
46
- """Decode one base64 image payload into a normalized PIL image."""
47
- if not value:
48
- return None
49
- return read_uploaded_image(base64.b64decode(str(value)))
50
-
51
-
52
- def handle_index_status(_payload):
53
- """Return listing-index readiness without requiring an HTTP request."""
54
- status = build_status_payload()
55
- status["kimi_model"] = KIMI_MODEL
56
- status["kimi_configured"] = bool(KIMI_CONFIG["api_key"])
57
- try:
58
- load_index_runtime()
59
- status["vectors"] = RUNTIME_CACHE["index"].ntotal
60
- status["products"] = len(RUNTIME_CACHE["products"])
61
- status["price_records"] = len(RUNTIME_CACHE["prices"])
62
- except Exception as error:
63
- status["load_error"] = str(error)
64
- return status
65
-
66
-
67
- def handle_warmup(_payload):
68
- """Load the listing index and trained text model before the first search."""
69
- load_index_runtime()
70
- load_model_runtime()
71
- return {
72
- "vectors": RUNTIME_CACHE["index"].ntotal,
73
- "products": len(RUNTIME_CACHE["products"]),
74
- "model_ready": RUNTIME_CACHE["model"] is not None,
75
- }
76
-
77
-
78
- def handle_search_text(payload):
79
- """Search the listing CLIP index with one manual keyword."""
80
- query = str(payload.get("query", "")).strip()
81
- if not query:
82
- raise ValueError("Query is empty")
83
- top_k = max(1, min(int(payload.get("top_k", 24)), 100))
84
- min_price = read_optional_float(payload.get("min_price"))
85
- max_price = read_optional_float(payload.get("max_price"))
86
- query_vector = encode_text(query)
87
- return {"results": search_listing_index(query_vector, top_k, min_price, max_price)}
88
-
89
-
90
- def handle_assemble(payload):
91
- """Run image-only Kimi JSON prompts, then search the listing CLIP index."""
92
- image = read_uploaded_image_from_base64(payload.get("image_base64"))
93
- if image is None:
94
- raise ValueError("Please provide an image for Kimi bundle generation")
95
- min_price = read_optional_float(payload.get("min_price"))
96
- max_price = read_optional_float(payload.get("max_price"))
97
- effective_system_prompt = str(payload.get("kimi_system_prompt", "")).strip() or DEFAULT_KIMI_SYSTEM_PROMPT
98
- effective_user_prompt = str(payload.get("kimi_prompt", "")).strip()
99
- safe_top_k = max(1, min(int(payload.get("top_k", 2)), 10))
100
- batch_groups = []
101
- batch_selected_results = []
102
- searched_prompt_count = 0
103
-
104
- def search_completed_kimi_batch(_batch_start, _batch_end, batch_prompts):
105
- """Search one returned Kimi batch immediately so later Kimi calls do not block CLIP."""
106
- nonlocal searched_prompt_count
107
- batch_plan = normalize_kimi_prompts({"prompts": batch_prompts}, fill_missing=False)
108
- prompts = batch_plan["prompts"]
109
- if not prompts:
110
- return
111
- groups, selected_results = search_prompt_groups(
112
- prompts,
113
- min_price,
114
- max_price,
115
- safe_top_k,
116
- searched_prompt_count,
117
- )
118
- searched_prompt_count += len(prompts)
119
- batch_groups.extend(groups)
120
- batch_selected_results.extend(selected_results)
121
-
122
- raw_plan = call_kimi_prompts(
123
- image,
124
- min_price,
125
- max_price,
126
- effective_system_prompt,
127
- on_batch_completed=search_completed_kimi_batch,
128
- kimi_user_prompt=effective_user_prompt,
129
- )
130
- plan = normalize_kimi_prompts(raw_plan)
131
- if batch_groups:
132
- groups = batch_groups
133
- selected_results = batch_selected_results
134
- else:
135
- groups, selected_results = search_prompt_groups(plan["prompts"], min_price, max_price, safe_top_k)
136
- return {
137
- "plan": plan,
138
- "groups": groups,
139
- "results": selected_results,
140
- "prompts_searched": len(groups),
141
- "results_per_prompt": safe_top_k,
142
- "model": KIMI_MODEL,
143
- "thinking": "disabled",
144
- "kimi_prompt": effective_user_prompt,
145
- }
146
-
147
-
148
- def dispatch(payload):
149
- """Route one worker JSON command to the matching CLIP operation."""
150
- action = str(payload.get("action", "")).strip()
151
- if action == "index_status":
152
- return handle_index_status(payload)
153
- if action == "warmup":
154
- return handle_warmup(payload)
155
- if action == "search_text":
156
- return handle_search_text(payload)
157
- if action == "assemble":
158
- return handle_assemble(payload)
159
- raise ValueError("Unknown CLIP worker action: " + action)
160
-
161
-
162
- def main():
163
- """Read JSON-line requests forever and return JSON-line responses."""
164
- for line in sys.stdin:
165
- text = line.strip()
166
- if not text:
167
- continue
168
- request = {}
169
- try:
170
- request = json.loads(text)
171
- request_id = request.get("id")
172
- result = dispatch(request)
173
- write_json_line({"id": request_id, "ok": True, "result": result})
174
- except Exception as error:
175
- write_json_line({
176
- "id": request.get("id", ""),
177
- "ok": False,
178
- "error": str(error),
179
- })
180
-
181
-
182
- if __name__ == "__main__":
183
- configure_stdio_encoding()
184
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bundle/python-cpu/Lib/site-packages/PIL/AvifImagePlugin.py DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:80733b71d411630ea63c782333de241a12bd83f0f1cafcd8ccde4516bed4b5a1
3
- size 9596
 
 
 
 
bundle/python-cpu/Lib/site-packages/PIL/BdfFontFile.py DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:2c54f5932a79e45789cd0b1fbd64694f2f53009f5485e3fe7bd95e2ddf77cf35
3
- size 3463
 
 
 
 
bundle/python-cpu/Lib/site-packages/PIL/BlpImagePlugin.py DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:522b1467bdc20f8edfd8b19346573e4df6f10dda93915b72a3b8efe6f6104c17
3
- size 17048
 
 
 
 
bundle/python-cpu/Lib/site-packages/PIL/BmpImagePlugin.py DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:e23760527cf54ebb06b6e56d73be70ff896666c04f55fdd1047bf99434f9da63
3
- size 20377
 
 
 
 
bundle/python-cpu/Lib/site-packages/PIL/BufrStubImagePlugin.py DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:3b2a82334b9422e153315099108f4fda20377a83785d540f7c57cdb835bfc9e4
3
- size 1757
 
 
 
 
bundle/python-cpu/Lib/site-packages/PIL/ContainerIO.py DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:23ac8efd814412a30a035724804133176aff224e69fc68ccf91ad63896234a56
3
- size 4777
 
 
 
 
bundle/python-cpu/Lib/site-packages/PIL/CurImagePlugin.py DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:3aab7fa3ba3b1d758a01e368073544be1c4a14cbf75b751cf390a73a842e8612
3
- size 2814
 
 
 
 
bundle/python-cpu/Lib/site-packages/PIL/DcxImagePlugin.py DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:2f6d5cde0557e883e6938cee7ff96db779807917dcb8209c5e1902569b9e7765
3
- size 2264
 
 
 
 
bundle/python-cpu/Lib/site-packages/PIL/DdsImagePlugin.py DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:eae2016bb3476be917ff549086c18b89d8e911d0f6d3f0900486a90fe2808cac
3
- size 19733
 
 
 
 
bundle/python-cpu/Lib/site-packages/PIL/EpsImagePlugin.py DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:3582af51b5623fc766008fa1e3df028a918aaf7073f4f6435fc5d1607f3deb2c
3
- size 17253
 
 
 
 
bundle/python-cpu/Lib/site-packages/PIL/ExifTags.py DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:362cafb665044fbf41322a248a3313a7fabc75e596ce9c9db5371305f272f0a1
3
- size 10339
 
 
 
 
bundle/python-cpu/Lib/site-packages/PIL/FitsImagePlugin.py DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:2ff9142b7f02394b95f65a13e64f4366a12df74a3600245d64f63696fb1ed0bd
3
- size 4875
 
 
 
 
bundle/python-cpu/Lib/site-packages/PIL/FliImagePlugin.py DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:2f5bdfe108ec68c57479141399fcb5c27a89f65f61195e65bd320b5bbce7cc3f
3
- size 5113
 
 
 
 
bundle/python-cpu/Lib/site-packages/PIL/FontFile.py DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:a424a370fdf10adc8e780110f3e12f9366e0e479ec49c560400e715108ff0554
3
- size 4419
 
 
 
 
bundle/python-cpu/Lib/site-packages/PIL/FpxImagePlugin.py DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:b3610e701e56459102379804abd517a576a44e2dfc905322133d8ccd1029c7f5
3
- size 7724
 
 
 
 
bundle/python-cpu/Lib/site-packages/PIL/FtexImagePlugin.py DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:fb32afbe131373351ede8d1fe07a430bdfc32ba94a1d3e4b0017f4af9fb797ee
3
- size 3685
 
 
 
 
bundle/python-cpu/Lib/site-packages/PIL/GbrImagePlugin.py DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:21bff2df967334e98ca86d92b8eca3c518773b1b78d90a5c11c80b6ba0619ad0
3
- size 3156
 
 
 
 
bundle/python-cpu/Lib/site-packages/PIL/GdImageFile.py DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:ace96a8ea2945fc25827a18b1b3a8792cf85a4033bdb43b2a1d1c636222e9283
3
- size 2951
 
 
 
 
bundle/python-cpu/Lib/site-packages/PIL/GifImagePlugin.py DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:0cf9dd837ba23b412c95765aea4422bbf6e7c2210c6eefde91eab43d4308b0d5
3
- size 43596
 
 
 
 
bundle/python-cpu/Lib/site-packages/PIL/GimpGradientFile.py DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:cec216d4c915ea4a39964e072555de8121d4df250fc16d695329d1b39cf48990
3
- size 4137
 
 
 
 
bundle/python-cpu/Lib/site-packages/PIL/GimpPaletteFile.py DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:126047710f44c42e1fe3bb5a0fb4bb1ba55a324afd1e3ca7955f0d7535d5e624
3
- size 1935
 
 
 
 
bundle/python-cpu/Lib/site-packages/PIL/GribStubImagePlugin.py DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:73c88f12a40e48171616a756122b9cb9b8da5f061f6520761a365761568f0313
3
- size 1786
 
 
 
 
bundle/python-cpu/Lib/site-packages/PIL/Hdf5StubImagePlugin.py DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:ac831d7fbb83b248c9ab07ba0af18bc3fb6325f875828e6f2929e67ed05d08a8
3
- size 1768
 
 
 
 
bundle/python-cpu/Lib/site-packages/PIL/IcnsImagePlugin.py DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:4e8092cb968527bffa9e266ccc53917c232bf3f5393babe0775447315aff27fa
3
- size 12798
 
 
 
 
bundle/python-cpu/Lib/site-packages/PIL/IcoImagePlugin.py DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:0d9a018f6ccabfbe9293de8f2aaf1e97d69900cd89df050b56a3dea27a3fd33d
3
- size 13499
 
 
 
 
bundle/python-cpu/Lib/site-packages/PIL/ImImagePlugin.py DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:5fd881490e31eeb6aec5b3889fbda3eefe9301481995da580f970073a67530d0
3
- size 11992
 
 
 
 
bundle/python-cpu/Lib/site-packages/PIL/Image.py DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:59f1b98ed7bfd7b516b9f13ffd9dd3bc0aa1a495042accdb5ae4609b41c329d5
3
- size 158171
 
 
 
 
bundle/python-cpu/Lib/site-packages/PIL/ImageChops.py DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:859f043d43e5408cee82c11e755f2dae4297d230420c66fa0acce66ba65e3194
3
- size 8257
 
 
 
 
bundle/python-cpu/Lib/site-packages/PIL/ImageCms.py DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:0effeeecadc71257d6eabcbd4cf43277ff35c6aeda0e7ba4c60ab67676bf49de
3
- size 41773