File size: 28,904 Bytes
807b4ac
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
"""Serve the cleaned pure-listing CLIP index on port 9990."""

import base64
import io
import json
import os
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path

import faiss
import open_clip
import torch
from fastapi import Body, FastAPI, File, Form, Request, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, Response
from fastapi.staticfiles import StaticFiles
from PIL import Image
import uvicorn


APP_ROOT = Path(__file__).resolve().parent.parent
BUNDLED_INDEX_DIR = APP_ROOT / "data" / "full_listing_index"
BUNDLED_MODEL_PATH = APP_ROOT / "models" / "open_clip_pytorch_model.bin"
CLIP_DIR = APP_ROOT if BUNDLED_INDEX_DIR.exists() and BUNDLED_MODEL_PATH.exists() else Path(r"F:\Clip")
LISTING_INDEX_DIR = CLIP_DIR / "data" / "full_listing_index"
INDEX_PATH = LISTING_INDEX_DIR / "products_listing.index"
METADATA_PATH = LISTING_INDEX_DIR / "products_listing_meta.json"
PRICE_METADATA_PATH = CLIP_DIR / "data" / "full_clip_index" / "products_full_prices.json"
PROGRESS_PATH = LISTING_INDEX_DIR / "progress.json"
REPORT_PATH = LISTING_INDEX_DIR / "cleaning_report.json"
BUILD_LOG_PATH = LISTING_INDEX_DIR / "build.log"
SERVER_LOG_PATH = APP_ROOT / "logs" / "server.log"
IMAGE_DIR = Path(r"F:\bundle\images")
BASE_MODEL_PATH = CLIP_DIR / "models" / "open_clip_pytorch_model.bin"
TRAINED_CHECKPOINT_PATH = CLIP_DIR / "data" / "yunqi_clip_training" / "last_checkpoint.pt"
HTML_PATH = APP_ROOT / "app" / "listing_search.html" if (APP_ROOT / "app" / "listing_search.html").exists() else APP_ROOT / "listing_search.html"
BUILD_SCRIPT_PATH = APP_ROOT / "work" / "build_full_listing_index.py"
MODEL_NAME = "ViT-B-32"
CONFIG_PATH = APP_ROOT / "config.json"
KIMI_API_KEY_ENV = "MOONSHOT_API_KEY"
KIMI_ENDPOINT_ENV = "KIMI_ENDPOINT"
KIMI_MODEL_ENV = "KIMI_MODEL"
APP_CONFIG = {}
KIMI_CONFIG = {}
KIMI_API_URL = "https://api.moonshot.cn/v1/chat/completions"
KIMI_MODEL = "kimi-k2.6"
KIMI_TEMPERATURE = 0.6
KIMI_MAX_COMPLETION_TOKENS = 1200
DEFAULT_KIMI_SYSTEM_PROMPT = """
你是跨境电商组货商品检索词生成器。你只根据用户上传的图片生成可一起售卖/一起购买的商品检索词。

任务:输出10个“具体可采购商品”,用于后续纯 listing CLIP 检索。

生成原则:
1. 不要只找外观相似品;优先覆盖互补品、同场景加购、替代升级、耗材补充、收纳展示、维护清洁、配套工具、礼盒套装里的其他商品。
2. 每条必须是具体商品,不要写大类、策略、理由或营销词。不要输出“配件、用品、产品、套装、工具”这种过宽泛词,除非前面有清晰具体限定。
3. 中文 zh 要像能直接给采购看的商品短名:主体品类 + 关键材质/结构/场景/人群/规格,尽量 6-18 个中文字符。
4. 英文 en 要像英文 listing 标题检索词:6-14 个英文词,必须包含明确 product noun,并尽量包含 material / shape / color / scene / target user / size / function 中的2-4个要素。
5. 如果图片主体不确定,根据最明显视觉元素推断;不要解释不确定性。
6. 10条之间要有明显差异,避免同义改写刷数量。

输出格式:只返回合法 JSON 对象,且只能包含 prompts 字段。
prompts 是长度为10的数组,每个元素只能包含 zh 和 en 两个字段。
""".strip()

RUNTIME_CACHE = {
    "model": None,
    "tokenizer": None,
    "device": None,
    "index": None,
    "products": None,
    "prices": None,
}
BUILD_PROCESS = {"process": None}


def load_app_config():
    """Load local app configuration without requiring secrets to be committed."""
    if not CONFIG_PATH.exists():
        return {}
    return json.loads(CONFIG_PATH.read_text(encoding="utf-8"))


def read_kimi_config():
    """Return Kimi settings from environment variables, config.json, and safe defaults."""
    config = APP_CONFIG.get("kimi", {}) if isinstance(APP_CONFIG, dict) else {}
    return {
        "api_key": os.environ.get(KIMI_API_KEY_ENV, "").strip() or str(config.get("api_key", "")).strip(),
        "endpoint": os.environ.get(KIMI_ENDPOINT_ENV, "").strip() or str(config.get("endpoint", KIMI_API_URL)).strip(),
        "model": os.environ.get(KIMI_MODEL_ENV, "").strip() or str(config.get("model", KIMI_MODEL)).strip(),
        "temperature": float(config.get("temperature", KIMI_TEMPERATURE)),
        "max_completion_tokens": int(config.get("max_completion_tokens", KIMI_MAX_COMPLETION_TOKENS)),
    }


def read_json_file(path, fallback):
    """Read a JSON file when it exists, otherwise return the fallback value."""
    if not path.exists():
        return fallback
    return json.loads(path.read_text(encoding="utf-8"))


APP_CONFIG = load_app_config()
KIMI_CONFIG = read_kimi_config()
KIMI_API_URL = KIMI_CONFIG["endpoint"]
KIMI_MODEL = KIMI_CONFIG["model"]
KIMI_TEMPERATURE = KIMI_CONFIG["temperature"]
KIMI_MAX_COMPLETION_TOKENS = KIMI_CONFIG["max_completion_tokens"]


def append_server_log(message):
    """Append one timestamped server log line without recording secrets."""
    SERVER_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
    timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
    with SERVER_LOG_PATH.open("a", encoding="utf-8") as log_file:
        log_file.write(f"{timestamp} {message}\n")


def should_skip_access_log(path):
    """Return whether a noisy internal endpoint should be hidden from server logs."""
    return path in {"/api/index/status", "/api/server/log", "/api/cdn/image"}


def validate_cdn_image_url(image_url):
    """Validate that the proxied image URL is a plain HTTP(S) CDN URL."""
    parsed_url = urllib.parse.urlparse(str(image_url or "").strip())
    if parsed_url.scheme not in {"http", "https"}:
        raise ValueError("CDN image URL must be http or https")
    if not parsed_url.netloc:
        raise ValueError("CDN image URL host is missing")
    return parsed_url.geturl()


def read_server_log_filtered(max_bytes):
    """Read recent server logs while hiding noisy internal heartbeat entries."""
    raw_log = read_tail(SERVER_LOG_PATH, max_bytes)
    hidden_patterns = [
        " /api/index/status ",
        " /api/server/log ",
        " /api/cdn/image ",
    ]
    visible_lines = []
    for line in raw_log.splitlines():
        if not line.startswith("20"):
            continue
        if any(pattern in line for pattern in hidden_patterns):
            continue
        visible_lines.append(line)
    return "\n".join(visible_lines)


def load_model_runtime():
    """Load the trained CLIP text tower only once per server process."""
    if RUNTIME_CACHE["model"] is not None:
        return
    if not BASE_MODEL_PATH.exists() or not TRAINED_CHECKPOINT_PATH.exists():
        raise FileNotFoundError("Base model or trained checkpoint is missing")
    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
    model, _, _ = open_clip.create_model_and_transforms(
        MODEL_NAME,
        pretrained=str(BASE_MODEL_PATH),
    )
    checkpoint = torch.load(TRAINED_CHECKPOINT_PATH, map_location=device, weights_only=False)
    model.load_state_dict(checkpoint["model"])
    model = model.to(device)
    model.eval()
    RUNTIME_CACHE["model"] = model
    RUNTIME_CACHE["tokenizer"] = open_clip.get_tokenizer(MODEL_NAME)
    RUNTIME_CACHE["device"] = device


def load_index_runtime():
    """Load or reload the completed listing FAISS index and product metadata."""
    if not INDEX_PATH.exists() or not METADATA_PATH.exists():
        raise FileNotFoundError("Listing index is not ready; start the build first")
    progress = read_json_file(PROGRESS_PATH, {})
    if progress.get("status") != "complete":
        raise RuntimeError(
            f"Listing index is still building: {progress.get('completed', 0)}/{progress.get('total', 0)}"
        )
    index_mtime = INDEX_PATH.stat().st_mtime
    cached_mtime = RUNTIME_CACHE.get("index_mtime")
    if RUNTIME_CACHE["index"] is not None and cached_mtime == index_mtime:
        return
    RUNTIME_CACHE["index"] = faiss.read_index(str(INDEX_PATH))
    RUNTIME_CACHE["products"] = read_json_file(METADATA_PATH, [])
    RUNTIME_CACHE["prices"] = read_json_file(PRICE_METADATA_PATH, {})
    RUNTIME_CACHE["index_mtime"] = index_mtime
    if RUNTIME_CACHE["index"].ntotal != len(RUNTIME_CACHE["products"]):
        raise RuntimeError("Listing index count does not match metadata count")


def encode_text(query):
    """Encode one listing query with the trained CLIP text tower."""
    load_model_runtime()
    tokenizer = RUNTIME_CACHE["tokenizer"]
    model = RUNTIME_CACHE["model"]
    device = RUNTIME_CACHE["device"]
    tokens = tokenizer([query]).to(device)
    with torch.inference_mode():
        feature = model.encode_text(tokens)
        feature = feature / feature.norm(dim=-1, keepdim=True)
    return feature.cpu().numpy().astype("float32")


def get_rank_window(top_k):
    """Clamp the requested result count to a practical range."""
    return max(1, min(int(top_k), 100))


def should_keep_price(product, min_price, max_price):
    """Return whether a product is inside the optional USD price range."""
    if min_price is None and max_price is None:
        return True
    price = product.get("price_usd")
    if price is None:
        return False
    if min_price is not None and float(price) < float(min_price):
        return False
    if max_price is not None and float(price) > float(max_price):
        return False
    return True


def resolve_product_image_url(product):
    """Return the MAINIMAGE/CDN URL from metadata, or an empty string when unavailable."""
    image_fields = [
        "MAINIMAGE",
        "mainImage",
        "main_image",
        "mainimage",
        "image_url",
        "imgUrl",
        "img_url",
    ]
    for field_name in image_fields:
        image_value = str(product.get(field_name, "") or "").strip()
        if image_value.lower().startswith(("http://", "https://")):
            return image_value
    return ""


def add_sidecar_fields(product):
    """Attach price data and the required CDN image URL to one product."""
    product_id = str(product.get("id", ""))
    prices = RUNTIME_CACHE["prices"] or {}
    if product_id in prices:
        product.update(prices[product_id])
    product["img_url"] = resolve_product_image_url(product)
    return product


def search_listing_index(query_vector, top_k, min_price, max_price):
    """Search the text index and dedupe similar listing families before returning."""
    load_index_runtime()
    index = RUNTIME_CACHE["index"]
    products = RUNTIME_CACHE["products"]
    output_count = get_rank_window(top_k)
    if min_price is not None or max_price is not None:
        search_count = index.ntotal
    else:
        search_count = min(index.ntotal, max(output_count * 30, 300))
    scores, indices = index.search(query_vector, search_count)
    results = []
    seen_families = set()
    seen_images = set()
    result_position = 0
    while result_position < len(indices[0]):
        product_index = int(indices[0][result_position])
        if product_index < 0 or product_index >= len(products):
            result_position += 1
            continue
        product = products[product_index].copy()
        product = add_sidecar_fields(product)
        if not product.get("img_url"):
            result_position += 1
            continue
        if not should_keep_price(product, min_price, max_price):
            result_position += 1
            continue
        family_key = str(product.get("family_key", product.get("listing_key", "")))
        image_key = str(product.get("local_img", product.get("image_url", "")))
        if family_key in seen_families or image_key in seen_images:
            result_position += 1
            continue
        product["similarity"] = round(float(scores[0][result_position]) * 100, 2)
        product["rank"] = len(results) + 1
        product["source"] = "Listing"
        results.append(product)
        seen_families.add(family_key)
        seen_images.add(image_key)
        if len(results) >= output_count:
            break
        result_position += 1
    return results


def image_to_data_url(image):
    """Encode an uploaded image as a compact Kimi-compatible data URL."""
    image_copy = image.copy().convert("RGB")
    image_copy.thumbnail((1280, 1280))
    image_buffer = io.BytesIO()
    image_copy.save(image_buffer, format="JPEG", quality=85, optimize=True)
    encoded = base64.b64encode(image_buffer.getvalue()).decode("ascii")
    return f"data:image/jpeg;base64,{encoded}"


def read_uploaded_image(contents):
    """Read uploaded bytes into a normalized PIL image."""
    if not contents:
        return None
    return Image.open(io.BytesIO(contents)).convert("RGB")


def call_kimi_prompts(image, min_price, max_price, kimi_prompt):
    """Ask Kimi to turn the uploaded image into concise JSON bundle-product prompts."""
    api_key = KIMI_CONFIG["api_key"]
    if not api_key:
        raise RuntimeError(f"Missing Kimi api_key in {CONFIG_PATH.name} or {KIMI_API_KEY_ENV} environment variable")
    price_rule = {"currency": "USD", "min": min_price, "max": max_price}
    system_prompt = (kimi_prompt or DEFAULT_KIMI_SYSTEM_PROMPT).strip()
    schema_guard = (
        "\n\n硬性输出约束:只返回合法JSON对象,不能返回Markdown代码围栏。"
        "JSON只能包含prompts字段;prompts必须是长度为10的数组;"
        "每个元素只能包含zh和en两个字符串字段。"
    )
    user_text = (
        "Price filter:\n"
        + json.dumps(price_rule, ensure_ascii=False)
        + "\n只返回JSON,不要Markdown代码围栏。"
    )
    content = [{"type": "text", "text": user_text}]
    if image is not None:
        content.insert(0, {"type": "image_url", "image_url": {"url": image_to_data_url(image)}})
    payload = {
        "model": KIMI_MODEL,
        "messages": [
            {"role": "system", "content": system_prompt + schema_guard},
            {"role": "user", "content": content},
        ],
        "thinking": {"type": "disabled"},
        "temperature": KIMI_TEMPERATURE,
        "response_format": {"type": "json_object"},
        "max_completion_tokens": KIMI_MAX_COMPLETION_TOKENS,
    }
    request = urllib.request.Request(
        KIMI_API_URL,
        data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
        headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
        method="POST",
    )
    response_data = None
    attempt = 0
    while attempt < 3:
        try:
            with urllib.request.urlopen(request, timeout=120) as response:
                response_data = json.loads(response.read().decode("utf-8"))
            break
        except urllib.error.HTTPError as error:
            detail = error.read().decode("utf-8", errors="replace")
            if error.code not in (429, 500, 502, 503, 504) or attempt >= 2:
                raise RuntimeError(f"Kimi API HTTP {error.code}: {detail[:500]}") from error
            retry_after = error.headers.get("Retry-After")
            try:
                delay = float(retry_after) if retry_after else 2.0 + attempt * 2.0
            except (TypeError, ValueError):
                delay = 2.0 + attempt * 2.0
            time.sleep(min(max(delay, 1.0), 10.0))
            attempt += 1
        except urllib.error.URLError as error:
            if attempt >= 2:
                raise RuntimeError(f"Kimi API network error: {error.reason}") from error
            time.sleep(2.0 + attempt * 2.0)
            attempt += 1
    choices = response_data.get("choices", [])
    if not choices:
        raise RuntimeError("Kimi API returned no choices")
    content_text = choices[0].get("message", {}).get("content", "")
    if isinstance(content_text, dict):
        return content_text
    try:
        return json.loads(content_text)
    except (TypeError, json.JSONDecodeError) as error:
        raise RuntimeError("Kimi response is not valid JSON") from error


def normalize_kimi_prompts(raw_plan, fill_missing=True):
    """Validate Kimi prompts and optionally fill missing directions with generic bundle text."""
    raw_prompts = raw_plan.get("prompts", []) if isinstance(raw_plan, dict) else []
    if not isinstance(raw_prompts, list):
        raw_prompts = []
    prompts = []
    for raw_prompt in raw_prompts:
        if isinstance(raw_prompt, dict):
            prompt_zh = str(raw_prompt.get("zh", raw_prompt.get("prompt", ""))).strip()
            prompt_en = str(raw_prompt.get("en", raw_prompt.get("prompt_en", ""))).strip()
        else:
            prompt_zh = str(raw_prompt).strip()
            prompt_en = ""
        if not prompt_zh and not prompt_en:
            continue
        prompts.append({"zh": prompt_zh, "en": prompt_en})
        if len(prompts) >= 10:
            break
    fallback_text = "related product bundle"
    while fill_missing and len(prompts) < 10:
        prompts.append({"zh": fallback_text, "en": fallback_text})
    return {"prompts": prompts}


def search_prompt_groups(prompts, min_price, max_price, top_k):
    """Run each Kimi prompt through the listing CLIP index and group results."""
    groups = []
    selected_results = []
    prompt_index = 0
    while prompt_index < len(prompts):
        prompt_item = prompts[prompt_index]
        recall_text = prompt_item.get("en") or prompt_item.get("zh") or ""
        query_vector = encode_text(recall_text)
        matches = search_listing_index(query_vector, top_k, min_price, max_price)
        group_results = []
        result_index = 0
        while result_index < len(matches):
            product = matches[result_index].copy()
            product["prompt_index"] = prompt_index + 1
            product["search_prompt"] = prompt_item.get("zh", "")
            product["search_prompt_en"] = prompt_item.get("en", "")
            product["prompt_rank"] = result_index + 1
            group_results.append(product)
            selected_results.append(product)
            result_index += 1
        groups.append(
            {
                "prompt_index": prompt_index + 1,
                "prompt": prompt_item.get("zh", ""),
                "prompt_en": prompt_item.get("en", ""),
                "results": group_results,
            }
        )
        prompt_index += 1
    return groups, selected_results


def read_tail(path, max_bytes):
    """Read the end of a log file without loading the whole file."""
    if not path.exists():
        return ""
    with path.open("rb") as log_file:
        log_file.seek(0, os.SEEK_END)
        size = log_file.tell()
        log_file.seek(max(0, size - max_bytes), os.SEEK_SET)
        return log_file.read().decode("utf-8", errors="replace")


def is_build_running():
    """Return whether the current build subprocess is still active."""
    process = BUILD_PROCESS.get("process")
    if process is None:
        return False
    return process.poll() is None


def start_build_process(batch_size, force_clean):
    """Start the listing-index build in the background and append logs."""
    if is_build_running():
        return False
    LISTING_INDEX_DIR.mkdir(parents=True, exist_ok=True)
    command = [
        sys.executable,
        str(BUILD_SCRIPT_PATH),
        "--batch-size",
        str(max(1, min(int(batch_size), 2048))),
    ]
    if force_clean:
        command.append("--force-clean")
    log_file = BUILD_LOG_PATH.open("a", encoding="utf-8")
    log_file.write(f"\nserver_start_build {command}\n")
    log_file.flush()
    BUILD_PROCESS["process"] = subprocess.Popen(
        command,
        stdout=log_file,
        stderr=subprocess.STDOUT,
        cwd=str(BUILD_SCRIPT_PATH.parent),
    )
    return True


def build_status_payload():
    """Return progress, cleaning report, and recent build log for the UI."""
    progress = read_json_file(PROGRESS_PATH, {})
    report = read_json_file(REPORT_PATH, {})
    inferred_running = is_build_running()
    if not inferred_running and progress.get("status") == "building":
        completed = int(progress.get("completed", 0) or 0)
        total = int(progress.get("total", 0) or 0)
        inferred_running = total > 0 and completed < total
    payload = {
        "running": inferred_running,
        "progress": progress,
        "report": report,
        "log": read_tail(BUILD_LOG_PATH, 20000),
        "index_exists": INDEX_PATH.exists(),
        "metadata_exists": METADATA_PATH.exists(),
    }
    if INDEX_PATH.exists():
        payload["index_size_mb"] = round(INDEX_PATH.stat().st_size / 1024 / 1024, 2)
    return payload


def create_app():
    """Create the 9990 FastAPI application for pure listing search."""
    application = FastAPI(title="Pure Listing CLIP Search")
    application.add_middleware(
        CORSMiddleware,
        allow_origins=["*"],
        allow_credentials=True,
        allow_methods=["*"],
        allow_headers=["*"],
    )
    if IMAGE_DIR.exists():
        application.mount("/listing-images", StaticFiles(directory=IMAGE_DIR), name="listing-images")

    @application.middleware("http")
    async def log_http_request(request: Request, call_next):
        """Write one compact access log line for every API/page request."""
        started_at = time.perf_counter()
        skip_access_log = should_skip_access_log(request.url.path)
        try:
            response = await call_next(request)
        except Exception as error:
            duration_ms = int((time.perf_counter() - started_at) * 1000)
            if not skip_access_log:
                append_server_log(
                    f"{request.method} {request.url.path} ERROR {duration_ms}ms {type(error).__name__}: {error}"
                )
            raise
        duration_ms = int((time.perf_counter() - started_at) * 1000)
        if not skip_access_log:
            append_server_log(
                f"{request.method} {request.url.path} {response.status_code} {duration_ms}ms"
            )
        return response

    @application.get("/api/server/log", response_class=PlainTextResponse)
    async def server_log(max_bytes: int = 50000):
        """Return the recent local server log as plain text."""
        safe_max_bytes = max(1000, min(int(max_bytes), 1000000))
        return PlainTextResponse(read_server_log_filtered(safe_max_bytes))

    @application.get("/api/cdn/image")
    async def proxy_cdn_image(url: str):
        """Fetch one remote CDN image through this server so the request is visible in logs."""
        started_at = time.perf_counter()
        try:
            safe_url = validate_cdn_image_url(url)
            request = urllib.request.Request(
                safe_url,
                headers={
                    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
                    "Accept": "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
                },
                method="GET",
            )
            with urllib.request.urlopen(request, timeout=30) as cdn_response:
                image_bytes = cdn_response.read()
                content_type = cdn_response.headers.get("Content-Type", "image/jpeg")
                status_code = getattr(cdn_response, "status", 200)
            duration_ms = int((time.perf_counter() - started_at) * 1000)
            append_server_log(f"CDN GET {safe_url} {status_code} {len(image_bytes)}B {duration_ms}ms")
            return Response(
                content=image_bytes,
                media_type=content_type,
                headers={"Cache-Control": "public, max-age=86400"},
            )
        except Exception as error:
            duration_ms = int((time.perf_counter() - started_at) * 1000)
            append_server_log(f"CDN GET {url} ERROR {duration_ms}ms {type(error).__name__}: {error}")
            return Response(status_code=502)

    @application.post("/api/index/build/start")
    async def start_index_build(
        batch_size: int = Form(256),
        force_clean: bool = Form(False),
    ):
        """Start a background cleaned listing-index build."""
        try:
            started = start_build_process(batch_size, force_clean)
            return {"started": started, "status": build_status_payload()}
        except Exception as error:
            return JSONResponse({"error": str(error)}, status_code=400)

    @application.get("/api/index/status")
    async def index_status():
        """Return listing-index build and load status."""
        status = build_status_payload()
        status["kimi_model"] = KIMI_MODEL
        status["kimi_configured"] = bool(KIMI_CONFIG["api_key"])
        if INDEX_PATH.exists() and METADATA_PATH.exists():
            try:
                load_index_runtime()
                status["vectors"] = RUNTIME_CACHE["index"].ntotal
                status["products"] = len(RUNTIME_CACHE["products"])
                status["price_records"] = len(RUNTIME_CACHE["prices"])
            except Exception as error:
                status["load_error"] = str(error)
        return status

    @application.post("/api/search/text")
    async def search_text(
        query: str = Form(...),
        top_k: int = Form(24),
        min_price: float = Form(None),
        max_price: float = Form(None),
    ):
        """Search the cleaned pure-listing index."""
        try:
            if not query.strip():
                raise ValueError("Query is empty")
            query_vector = encode_text(query.strip())
            results = search_listing_index(query_vector, top_k, min_price, max_price)
            return {"results": results}
        except Exception as error:
            return JSONResponse({"error": str(error)}, status_code=400)

    @application.post("/api/search/prompts")
    async def search_prompts(payload: dict = Body(...)):
        """Search the listing index with manually edited Kimi prompt JSON."""
        try:
            plan = normalize_kimi_prompts(payload, fill_missing=False)
            if not plan["prompts"]:
                raise ValueError("Edited prompts are empty")
            min_price = payload.get("min_price")
            max_price = payload.get("max_price")
            safe_top_k = max(1, min(int(payload.get("top_k", 1)), 10))
            groups, selected_results = search_prompt_groups(
                plan["prompts"],
                min_price,
                max_price,
                safe_top_k,
            )
            return {
                "plan": plan,
                "groups": groups,
                "results": selected_results,
                "prompts_searched": len(groups),
                "results_per_prompt": safe_top_k,
                "source": "edited_prompts",
            }
        except Exception as error:
            return JSONResponse({"error": str(error)}, status_code=400)

    @application.post("/api/assemble")
    async def assemble_products(
        file: UploadFile = File(None),
        listing: str = Form(""),
        kimi_prompt: str = Form(""),
        top_k: int = Form(2),
        min_price: float = Form(None),
        max_price: float = Form(None),
    ):
        """Run image-only Kimi JSON prompts, then search the listing CLIP index."""
        try:
            contents = await file.read() if file and file.filename else None
            image = read_uploaded_image(contents)
            if image is None:
                raise ValueError("Please upload an image for Kimi bundle generation")
            effective_kimi_prompt = kimi_prompt.strip() or DEFAULT_KIMI_SYSTEM_PROMPT
            raw_plan = call_kimi_prompts(image, min_price, max_price, effective_kimi_prompt)
            plan = normalize_kimi_prompts(raw_plan)
            safe_top_k = max(1, min(int(top_k), 10))
            groups, selected_results = search_prompt_groups(
                plan["prompts"],
                min_price,
                max_price,
                safe_top_k,
            )
            return {
                "plan": plan,
                "groups": groups,
                "results": selected_results,
                "prompts_searched": len(groups),
                "results_per_prompt": safe_top_k,
                "model": KIMI_MODEL,
                "thinking": "disabled",
                "kimi_prompt": effective_kimi_prompt,
            }
        except Exception as error:
            return JSONResponse({"error": str(error)}, status_code=400)

    @application.get("/", response_class=HTMLResponse)
    async def listing_page():
        """Serve the standalone pure-listing search page."""
        return HTMLResponse(HTML_PATH.read_text(encoding="utf-8"))

    return application


app = create_app()


if __name__ == "__main__":
    uvicorn.run(app, host="127.0.0.1", port=9990)