mikaassa commited on
Commit
4a00aee
·
verified ·
1 Parent(s): 9dab5a9

Add bundle CLIP runtime package

Browse files
.gitattributes CHANGED
@@ -1,35 +1,38 @@
 
 
1
  *.7z filter=lfs diff=lfs merge=lfs -text
2
  *.arrow filter=lfs diff=lfs merge=lfs -text
3
  *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
7
  *.gz filter=lfs diff=lfs merge=lfs -text
8
  *.h5 filter=lfs diff=lfs merge=lfs -text
 
9
  *.joblib filter=lfs diff=lfs merge=lfs -text
10
  *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
- *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
- *.model filter=lfs diff=lfs merge=lfs -text
13
- *.msgpack filter=lfs diff=lfs merge=lfs -text
14
- *.npy filter=lfs diff=lfs merge=lfs -text
15
- *.npz filter=lfs diff=lfs merge=lfs -text
16
- *.onnx filter=lfs diff=lfs merge=lfs -text
17
- *.ot filter=lfs diff=lfs merge=lfs -text
18
- *.parquet filter=lfs diff=lfs merge=lfs -text
19
- *.pb filter=lfs diff=lfs merge=lfs -text
20
- *.pickle filter=lfs diff=lfs merge=lfs -text
21
- *.pkl filter=lfs diff=lfs merge=lfs -text
22
- *.pt filter=lfs diff=lfs merge=lfs -text
23
- *.pth filter=lfs diff=lfs merge=lfs -text
24
- *.rar filter=lfs diff=lfs merge=lfs -text
25
- *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
30
- *.tgz filter=lfs diff=lfs merge=lfs -text
31
- *.wasm filter=lfs diff=lfs merge=lfs -text
32
- *.xz filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
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
.gitignore ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ config.json
2
+ .env
3
+ __pycache__/
4
+ *.pyc
5
+ logs/
6
+ *.log
7
+ build/
8
+ dist/
9
+ *.spec
README.md CHANGED
@@ -1,3 +1,180 @@
1
  ---
2
  license: apache-2.0
 
 
 
 
 
 
 
 
 
 
 
3
  ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ ## What This Is
38
+
39
+ `bundle-clip` is a self-contained local retrieval bundle used by the Auto Bundle workbench. It combines:
40
+
41
+ | Layer | Role |
42
+ | --- | --- |
43
+ | Kimi planning | Turns an input product image into bundle-search directions. |
44
+ | OpenCLIP encoder | Embeds image and text prompts into the same semantic space. |
45
+ | FAISS listing index | Retrieves high-similarity Temu listings from the prepared metadata. |
46
+ | Local web UI | Serves a review page at `http://127.0.0.1:9990/`. |
47
+
48
+ The pack is designed for fast local review, private experimentation, and offline-ish product matching after the LFS assets are downloaded.
49
+
50
+ ## Repository Layout
51
+
52
+ ```text
53
+ bundle-clip/
54
+ ├─ app/
55
+ │ └─ listing_search.html
56
+ ├─ work/
57
+ │ ├─ full_listing_server.py
58
+ │ ├─ stdio_listing_worker.py
59
+ │ ├─ full_clip_server.py
60
+ │ └─ build_full_listing_index.py
61
+ ├─ models/
62
+ │ └─ open_clip_pytorch_model.bin
63
+ ├─ data/
64
+ │ ├─ yunqi_clip_training/
65
+ │ │ └─ last_checkpoint.pt
66
+ │ ├─ full_listing_index/
67
+ │ │ ├─ products_listing.index
68
+ │ │ ├─ products_listing_meta.runtime.json
69
+ │ │ ├─ cleaning_report.json
70
+ │ │ └─ progress.json
71
+ │ └─ full_clip_index/
72
+ │ └─ products_full_prices.json
73
+ ├─ config.example.json
74
+ ├─ .gitattributes
75
+ └─ README.md
76
+ ```
77
+
78
+ ## Included Assets
79
+
80
+ | Asset | Purpose |
81
+ | --- | --- |
82
+ | `models/open_clip_pytorch_model.bin` | Base OpenCLIP model weights. |
83
+ | `data/yunqi_clip_training/last_checkpoint.pt` | Fine-tuned checkpoint for the bundle-search domain. |
84
+ | `data/full_listing_index/products_listing.index` | FAISS index for listing retrieval. |
85
+ | `data/full_listing_index/products_listing_meta.runtime.json` | Runtime metadata used to render product cards. |
86
+ | `data/full_clip_index/products_full_prices.json` | Price metadata used by the local search UI. |
87
+
88
+ Large files are tracked with Git LFS. Run `git lfs pull` after cloning.
89
+
90
+ ## Quick Start
91
+
92
+ ### 1. Clone With LFS
93
+
94
+ ```powershell
95
+ git lfs install
96
+ git clone https://huggingface.co/mikaassa/bundle-clip
97
+ cd bundle-clip
98
+ git lfs pull
99
+ ```
100
+
101
+ ### 2. Create Local Config
102
+
103
+ ```powershell
104
+ Copy-Item config.example.json config.json
105
+ ```
106
+
107
+ Fill in your private Kimi or Moonshot key:
108
+
109
+ ```json
110
+ {
111
+ "kimi": {
112
+ "api_key": "YOUR_KIMI_API_KEY",
113
+ "endpoint": "https://api.moonshot.cn/v1/chat/completions",
114
+ "model": "kimi-k2.6",
115
+ "temperature": 0.6,
116
+ "max_completion_tokens": 1200
117
+ }
118
+ }
119
+ ```
120
+
121
+ `config.json` is ignored by Git. Keep real API keys local.
122
+
123
+ ### 3. Start The Local Service
124
+
125
+ ```powershell
126
+ python .\work\full_listing_server.py
127
+ ```
128
+
129
+ Open:
130
+
131
+ ```text
132
+ http://127.0.0.1:9990/
133
+ ```
134
+
135
+ ## Workflow
136
+
137
+ ```mermaid
138
+ flowchart LR
139
+ A[Product image] --> B[Kimi bundle directions]
140
+ B --> C[English listing prompt]
141
+ C --> D[OpenCLIP embedding]
142
+ D --> E[FAISS nearest-neighbor search]
143
+ E --> F[Temu product cards]
144
+ ```
145
+
146
+ The local UI supports two review paths:
147
+
148
+ | Mode | Use Case |
149
+ | --- | --- |
150
+ | Image bundle search | Upload a product image, let Kimi produce bundle directions, then retrieve matching listings. |
151
+ | Direct CLIP search | Enter a manual listing keyword or prompt and search the index directly. |
152
+
153
+ ## Runtime Notes
154
+
155
+ - Default local port: `9990`
156
+ - Main service entry: `work/full_listing_server.py`
157
+ - Workbench worker entry: `work/stdio_listing_worker.py`
158
+ - Public page: `app/listing_search.html`
159
+ - Main metadata image field: `MAINIMAGE`
160
+
161
+ The service prefers repository-local `data/` and `models/` paths first. Older absolute-path fallbacks are only used when local assets are missing.
162
+
163
+ ## Safety
164
+
165
+ - Do not commit `config.json`, `.env`, logs, or local cache output.
166
+ - API keys should be supplied through `config.json` or environment variables only.
167
+ - This repository is a local runtime pack, not a public hosted inference endpoint.
168
+ - Product metadata and retrieval quality depend on the bundled index snapshot.
169
+
170
+ ## Environment Overrides
171
+
172
+ ```powershell
173
+ $env:MOONSHOT_API_KEY="YOUR_KIMI_API_KEY"
174
+ $env:KIMI_ENDPOINT="https://api.moonshot.cn/v1/chat/completions"
175
+ $env:KIMI_MODEL="kimi-k2.6"
176
+ ```
177
+
178
+ ## License
179
+
180
+ Released under the Apache 2.0 license. Check upstream model and data-source terms before redistribution or commercial deployment.
app/listing_search.html ADDED
@@ -0,0 +1,1288 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>
config.example.json ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
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
+ }
data/full_clip_index/products_full_prices.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a63876a98e6c4e4341566c90e00b0f4001a62ad69d55c474884e96c5aca141dc
3
+ size 37368003
data/full_listing_index/cleaning_report.json ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
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
+ }
data/full_listing_index/products_listing.index ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4e50a979e2386e24b862bfbd23fb90658ccc60e406e612144808318bde3f631e
3
+ size 560033837
data/full_listing_index/products_listing_meta.runtime.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:0c10a2a02aab00a382f9b02cc521e11a3225a28bf334fa987e226acf5832767f
3
+ size 139038585
data/full_listing_index/progress.json ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ {
2
+ "completed": 273454,
3
+ "total": 273454,
4
+ "status": "complete",
5
+ "updated_at": "2026-08-21 09:51:05"
6
+ }
data/yunqi_clip_training/last_checkpoint.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:57f6be30140cb9f03fd6d7b7aa83fefc2c4441ef4244608a8391084c0031c35a
3
+ size 692418197
models/open_clip_pytorch_model.bin ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1bd3c7172de5b207ceac554f5ab5266166f3b9baccc9af5989bc801016d080ad
3
+ size 605219813
work/build_full_listing_index.py ADDED
@@ -0,0 +1,395 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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()
work/full_clip_server.py ADDED
@@ -0,0 +1,615 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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)
work/full_listing_server.py ADDED
@@ -0,0 +1,951 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.")
work/stdio_listing_worker.py ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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()