Files
pod_trend_agent/ui_app.py
3218485270 b7f429db89 模板导出增强 + 模特性别分组 + 三合一提示词精简
1) 模板导出:识别「基码表-胸围」填 sku.bust(多个胸围列都填);申报价格模糊匹配多列统一按加价后价格填写;详情图文不再拼接 img_url_2;SPU 款式来源统一填「现货款」;商品产地国家简称映射(沙特→沙特阿拉伯)
2) 模特性别分组:model_features 按男女分组,按模板类目含男/女固定取对应性别模特(含 Pinterest 模式 pipeline)
3) 三合一提示词:去掉 DESIGN CONTENT 四要素描述(设计已由设计稿提供)
4) 生图尺寸:全部改为读 config 不再硬编码(设计图 compose.design_size / 合成图 compose.size / 种草图 seed_shot.size)
2026-08-26 18:05:11 +08:00

1210 lines
54 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""POD 热点抓取 Agent —— 桌面 UITkinter 轻量版)。
功能(简洁版):
- 国家多选(US/GB/JP/AU
- LLM 后端选择(mock/static/openai_compat/openai/deepseek/qwen/moonshot
- 种子数量上限(style/related
- 运行:后台线程调用 graph.agent.run_countryprint 输出重定向到日志区
- 结果表格:国家/热点词/类别/风险/分数;双击看完整详情
- 打开产物目录
打包(PyInstaller):
pyinstaller -F -w ui_app.py --add-data "config.yaml;." --add-data "configs;configs" --add-data "prompts;prompts"
数据文件(config.yaml/configs/prompts)打进 _MEIPASSoutput/ 与 .cache/ 写在 exe 旁运行目录。
自检:python ui_app.py --self-test(无 GUI,跑通 GB mock 核心链路,验证打包环境)。
"""
import contextlib
import io
import json
import os
import queue
import shutil
import sys
import threading
import time
from pathlib import Path
import tkinter as tk
from tkinter import filedialog, ttk, messagebox
import yaml
from graph.agent import run_country
COUNTRIES = ["US", "GB", "JP", "AU", "MX", "DE", "BR", "SA", "PL", "ES", "IT", "CA"]
# LLM 只适配 OpenAI 兼容协议:openai(真调用,可在下方配置自定义模型/URL/Key)/ mock(无 key 演示占位)
PROVIDERS = ["openai", "mock"]
def resource_root() -> Path:
"""数据根:开发=脚本目录;打包=_MEIPASSconfig.yaml/configs/prompts 解压处)。"""
if getattr(sys, "frozen", False):
return Path(getattr(sys, "_MEIPASS", Path(sys.executable).parent))
return Path(__file__).resolve().parent
def runtime_root() -> Path:
"""运行根(可写):打包=exe 旁;开发=脚本目录。output/.cache 写这里。"""
if getattr(sys, "frozen", False):
return Path(sys.executable).resolve().parent
return Path(__file__).resolve().parent
OUTPUT_ROOT = runtime_root() / "output"
def config_root() -> Path:
"""配置生效根:exe 旁的可编辑副本优先(config.yaml 存在时),缺失则用内置默认(_MEIPASS)。
开发模式 = 脚本目录(即项目根,行为不变)。
"""
if getattr(sys, "frozen", False):
run = Path(sys.executable).resolve().parent
if (run / "config.yaml").exists():
return run
return Path(getattr(sys, "_MEIPASS", run))
return Path(__file__).resolve().parent
def ensure_defaults() -> None:
"""打包后首次运行:把内置默认 config.yaml/configs/prompts 复制到 exe 旁(已存在不覆盖),
用户可直接编辑这份默认配置;exe 内置的作为兜底。"""
if not getattr(sys, "frozen", False):
return
run = Path(sys.executable).resolve().parent
data = Path(getattr(sys, "_MEIPASS", run))
for rel in ("config.yaml", "configs", "prompts"):
src = data / rel
dst = run / rel
if src.exists() and not dst.exists():
try:
if src.is_dir():
shutil.copytree(src, dst)
else:
shutil.copy2(src, dst)
print(f"[UI] 已生成默认配置: {dst}")
except Exception as e: # noqa: BLE001
print(f"[UI] 默认配置生成失败 {rel}: {e}")
def load_config() -> dict:
with open(config_root() / "config.yaml", encoding="utf-8") as f:
return yaml.safe_load(f)
def load_spus(country: str = None) -> list:
"""从 db 读可选 SPU 列表(可按国家过滤;UI 选品下拉用)。"""
try:
from graph.product import list_spus
pcfg = load_config().get("product") or {}
dbp = resolve_data_path(pcfg.get("db_path", "db/spu_sku.db"))
return list_spus(dbp, country) or []
except Exception:
return []
def load_colors(spu_code: str) -> list:
"""款号 → 颜色列表(UI 颜色下拉用)。"""
try:
from graph.product import list_colors
pcfg = load_config().get("product") or {}
dbp = resolve_data_path(pcfg.get("db_path", "db/spu_sku.db"))
return list_colors(dbp, spu_code) or []
except Exception:
return []
def resolve_data_path(rel: str) -> Path:
"""数据文件相对路径解析:exe 旁自定义优先 → _MEIPASS 内置兜底。"""
p = Path(rel)
if p.is_absolute():
return p
for root in (runtime_root(), resource_root()):
cand = root / p
if cand.exists():
return cand
return resource_root() / p
# 全局日志文件:UI 启动时创建(logs/<时间戳>.log),所有 print(采集+运行)同时落盘
LOG_FILE = None
class StdoutRedirector(io.TextIOBase):
"""把 print 输出转发到 UI 队列 + 写入全局日志文件(带时间戳,详细记录每步含错误)。"""
def __init__(self, q: queue.Queue):
self._q = q
def write(self, s: str) -> int:
if s:
self._q.put(("log", s))
if LOG_FILE:
try:
LOG_FILE.write(f"[{time.strftime('%H:%M:%S')}] {s}")
LOG_FILE.flush()
except Exception:
pass
return len(s)
def flush(self):
if LOG_FILE:
try:
LOG_FILE.flush()
except Exception:
pass
def apply_openai_cfg(config: dict, oai: dict) -> None:
"""把 UI 的 OpenAI 配置写入 llm_screen + compose。
- API Key 不在 UI 显示:始终从 config.yamlllm_screen.api_key / compose.api_key
或环境变量(LLM_API_KEY / OPENAI_API_KEY)读取。
- LLM 与图像的 Base URL 独立(生图模型可能走不同网关)。
"""
if not oai:
return
ls = config.setdefault("llm_screen", {})
cp = config.setdefault("compose", {})
if oai.get("llm_base_url"):
ls["base_url"] = oai["llm_base_url"]
if oai.get("img_base_url"):
cp["base_url"] = oai["img_base_url"]
if oai.get("llm_model"):
ls["model"] = oai["llm_model"]
if oai.get("image_model"):
cp["model"] = oai["image_model"]
def _apply_provider_mode(config: dict, provider: str) -> None:
"""OpenAI 模式开关:openai=全链路真 LLM/真生图;mock=全 mock 演示。
联动:seed_provider / llm_screen.provider / compose.backend / product.backend。
API Key 已在 config.yaml 或环境变量配置好(用户已配)。
"""
on = (provider or "").strip().lower() == "openai"
config["seed_provider"] = provider or "mock"
ls = config.setdefault("llm_screen", {})
ls["provider"] = "openai" if on else "mock"
cp = config.setdefault("compose", {})
cp["backend"] = "openai" if on else "mock"
pp = config.setdefault("product", {})
pp["backend"] = "openai" if on else "mock"
# Pinterest 参考模式:LLM(搜索词/图片分析)提供商跟随模式开关
pin = config.setdefault("pinterest", {})
pin["provider"] = "openai" if on else "mock"
print(f"[UI] 模式开关: {'OpenAI(真 LLM + 真生图)' if on else 'Mock(演示)'} | "
f"llm_screen={ls['provider']} compose={cp['backend']} product={pp['backend']} "
f"pinterest={pin['provider']}")
def fetch_keywords(country, provider, max_seeds, log_q, oai=None):
"""后台线程:仅跑「采集+去重过滤」链路(seed→fetch→filter),
把过滤后的关键词(数量不定)实时回传 UI 显示。"""
from graph.agent import build_country_config
from graph.nodes.fetch_node import fetch_node
from graph.nodes.filter_node import filter_node
from graph.nodes.seed_node import seed_node
config = load_config()
config["seed_provider"] = provider
cfg = config.setdefault("seed_provider_cfg", {})
cfg["max_seeds"] = max_seeds
apply_openai_cfg(config, oai)
_apply_provider_mode(config, provider) # OpenAI 模式开关(采集只用 seed,其余为后续统一)
old_stdout = sys.stdout
sys.stdout = StdoutRedirector(log_q)
try:
cc = build_country_config(config, country, config_root())
state = {
"country": country, "config": config, "country_config": cc,
"prompts_dir": str(config_root() / "prompts" / country),
"output_dir": str(OUTPUT_ROOT / country),
"raw_rows": [], "filtered_rows": [], "errors": [], "stats": {},
}
log_q.put(("log", f"\n===== 采集热点 {country}provider={provider}=====\n"))
st = seed_node(state)
state["country_config"] = st.get("country_config") or cc
state["stats"] = st.get("stats") or {}
state["errors"] = list(st.get("errors") or [])
ft = fetch_node(state)
state["raw_rows"] = ft.get("raw_rows") or []
state["errors"] += list(ft.get("errors") or [])
fl = filter_node(state)
kept = fl.get("filtered_rows") or []
# 去重(大小写不敏感,保留首次出现顺序)+ 限长
seen, keywords = set(), []
for r in kept:
t = (r.get("topic") or "").strip()
if not t or t.lower() in seen:
continue
seen.add(t.lower())
keywords.append({
"topic": t, "source": r.get("source", ""),
"kind": r.get("kind", ""), "raw_score": r.get("raw_score"),
})
log_q.put(("log", f"[{country}] 采集原始 {len(state['raw_rows'])} 条 → 规则过滤保留 {len(kept)} 条 → 去重后 {len(keywords)} 个关键词\n"))
if keywords:
# 采集成功 → ① 清除该国旧热点简报缓存(design_briefs.json),
# 下次「运行」走完整流水线用新采集热点重建简报(fetch 用 collected 缓存,不重复撞 Google
out_dir = OUTPUT_ROOT / country
cache_f = out_dir / "design_briefs.json"
if cache_f.exists():
try:
cache_f.unlink()
log_q.put(("log", f"[UI] 已清除 {country} 旧热点简报缓存,下次运行将使用新采集热点重建\n"))
except Exception as e: # noqa: BLE001
log_q.put(("log", f"[UI] 清除旧热点缓存失败: {e}\n"))
# ② 把"过滤后"的关键词写入 collected_keywords.json(运行流水线 fetch 节点优先读取)
try:
out_dir.mkdir(parents=True, exist_ok=True)
(out_dir / "collected_keywords.json").write_text(
json.dumps({"country": country,
"collected_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
"keywords": keywords}, ensure_ascii=False, indent=2),
encoding="utf-8")
log_q.put(("log", f"[UI] 已缓存过滤后热点 {len(keywords)} 条(collected_keywords.json),运行将直接用\n"))
except Exception as e: # noqa: BLE001
log_q.put(("log", f"[UI] 写入热点缓存失败: {e}\n"))
log_q.put(("fetched", {"country": country, "keywords": keywords}))
except Exception as e: # noqa: BLE001
import traceback
log_q.put(("log", f"采集失败: {e}\n{traceback.format_exc()}\n"))
log_q.put(("error", None))
finally:
sys.stdout = old_stdout
try:
_pack_cache(country, log_q) # 采集完成 → 自动打包缓存/去重数据
except Exception as e: # noqa: BLE001
log_q.put(("log", f"[UI] 缓存打包失败: {e}\n"))
def run_pipeline(countries, provider, max_seeds, log_q,
spu_code="", sku_code="", spu_count=0,
spu_tasks=None, oai=None, markup_percent=0.0, code_prefix="DG",
template_path=""):
"""后台线程:缓存热点产品流程(选定国家后不再全部重跑种子词/抓取)。
1) 有缓存热点(output/<国家>/design_briefs.json)→ 直接加载,按 SPU 数量取未用热点,
每款号一个热点,生成设计稿 → 三图合成 → 多模态标题 → 模板导出。
2) 无缓存 → 先跑一次完整流水线生成缓存,再继续。
spu_tasks: [{"spu": "DG004", "skus": "..."}] 多款号批量选品
spu_count: 款号数量上限(≥1;热点数按它来)
oai: {"llm_base_url","img_base_url","llm_model","image_model"}Key 从 config/env 读取)
markup_percent: 加价百分比(按 SKU 最低价,后续定价用)
code_prefix: 货号前缀(OSS 上传 key{国家}/{时间戳}/{前缀+3位计数}_{4位随机}.jpg000 起最多 999
template_path: 用户上传的自定义商品上传模板(xlsx 绝对路径),后续模板导出从此模板解析
"""
config = load_config()
config["seed_provider"] = provider
cfg = config.setdefault("seed_provider_cfg", {})
cfg["max_seeds"] = max_seeds
apply_openai_cfg(config, oai)
# OpenAI 模式开关:provider=openai → 全链路真 LLM/真生图;mock → 全 mock 演示
_apply_provider_mode(config, provider)
if spu_code or sku_code or spu_count or spu_tasks or markup_percent or code_prefix or template_path:
p = config.setdefault("product", {})
if spu_code:
p["spu_code"] = spu_code
if template_path:
p["template_path"] = template_path # 自定义模板:template_export 从此解析
if sku_code:
p["sku_code"] = sku_code
if spu_tasks:
p["spu_tasks"] = spu_tasks
p.pop("spu_code", None) # 任务清单优先
if spu_count:
p["spu_count"] = spu_count
if markup_percent:
p["markup_percent"] = markup_percent
if code_prefix:
p["code_prefix"] = code_prefix
# 任务扩展:每个「集合」(款-颜色集 + 独立数量 count)按自己数量复制 N 份(N=该集合设计数);
# 每份 skus 保留颜色集合(多色)→ 每个设计配全部颜色各出一张主图
if spu_tasks or spu_code:
base = [dict(t) for t in (spu_tasks or [{"spu": spu_code, "skus": sku_code}])]
tasks_list = []
for t in base:
n = int(t.get("count") or 0) or spu_count or 1 # 每集合独立数量,无则全局
for _ in range(n):
tt = dict(t)
tt.pop("count", None)
tasks_list.append(tt)
config.setdefault("product", {})["spu_tasks"] = tasks_list
if spu_count:
config.setdefault("product", {})["spu_count"] = spu_count
old_stdout = sys.stdout
sys.stdout = StdoutRedirector(log_q)
results = {}
task_ts = time.strftime("%Y%m%d%H%M%S") # 任务开始时间戳(OSS 路径段)
try:
from graph.product_batch import load_cached_briefs, run_product_batch
for c in countries:
log_q.put(("log", f"\n===== 开始处理 {c}(缓存热点模式)=====\n"))
# 只要有采集缓存(collected_keywords)或简报缓存 → 缓存模式:
# run_product_batch 内部简报不足会自动「从采集缓存生成简报」,绝不触发 seed/fetch/Google
has_cache = bool(load_cached_briefs(runtime_root() / "output" / c)) \
or (runtime_root() / "output" / c / "collected_keywords.json").exists()
if has_cache:
out = run_product_batch(c, config, config_root(), runtime_root(),
list(spu_tasks or []), spu_count, log_q,
task_timestamp=task_ts)
items = out.get("product") or []
errs = out.get("errors") or []
log_q.put(("log", f"[{c}] 缓存热点完成:{len(items)} 个产品(每个热点对应一款)\n"))
else:
log_q.put(("log", f"[{c}] 无任何采集缓存 → 走完整流水线(seed+fetch+screen,首次需 Google)…\n"))
state = run_country(c, config, config_root(), output_root=runtime_root(), task_timestamp=task_ts)
items = state.get("briefs", []) or []
errs = state.get("errors") or []
log_q.put(("log", f"[{c}] 完整流水线完成:简报 {len(items)} 条,兜底错误 {len(errs)}\n"))
results[c] = items
ts_dir = runtime_root() / "output" / countries[0] / task_ts
log_q.put(("log", f"\n✅ 任务完成,产物文件夹:{ts_dir}\n"
f" (缓存/去重记录在 {runtime_root() / 'output' / countries[0]} 根目录,不进任务文件夹)\n"))
log_q.put(("done", results))
except Exception as e: # noqa: BLE001
import traceback
log_q.put(("log", f"运行失败: {e}\n{traceback.format_exc()}\n"))
log_q.put(("error", None))
finally:
sys.stdout = old_stdout
try:
for c in countries:
_pack_cache(c, log_q) # 运行完成 → 自动打包缓存/去重数据
except Exception as e: # noqa: BLE001
log_q.put(("log", f"[UI] 缓存打包失败: {e}\n"))
def run_pinterest_pipeline(countries, provider, log_q, spu_tasks=None, spu_count=0,
oai=None, markup_percent=0.0, code_prefix="DG", template_path=""):
"""Pinterest 参考模式后台线程:独立于 Google Trends 采集链路。
各国独立种子词池 → LLM 搜索词(json_schema + 动态注入防重复)→ 爬图
→ LLM 分析图片 → 设计简报 → 产品生成(设计稿/主图/种草图/模板导出)。
"""
config = load_config()
config["seed_provider"] = provider
apply_openai_cfg(config, oai)
_apply_provider_mode(config, provider) # 联动 pinterest.provider / compose / product backend
p = config.setdefault("product", {})
if template_path:
p["template_path"] = template_path
if spu_tasks:
p["spu_tasks"] = spu_tasks
p.pop("spu_code", None)
if spu_count:
p["spu_count"] = spu_count
if markup_percent:
p["markup_percent"] = markup_percent
if code_prefix:
p["code_prefix"] = code_prefix
# 任务扩展(与 run_pipeline 一致):每个集合按自己数量复制 N 份
if spu_tasks:
tasks_list = []
for t in spu_tasks:
n = int(t.get("count") or 0) or spu_count or 1
for _ in range(n):
tt = dict(t)
tt.pop("count", None)
tasks_list.append(tt)
p["spu_tasks"] = tasks_list
if spu_count:
p["spu_count"] = spu_count
# 简报数上限联动:设计数 ≥ 任务数(否则任务会复用简报)
pcfg = config.setdefault("pinterest", {})
pcfg["max_designs"] = max(int(pcfg.get("max_designs", 10) or 10), int(spu_count or 1))
old_stdout = sys.stdout
sys.stdout = StdoutRedirector(log_q)
results = {}
task_ts = time.strftime("%Y%m%d%H%M%S")
try:
from graph.agent import run_pinterest_ref
for c in countries:
log_q.put(("log", f"\n===== Pinterest 参考模式 {c}SPU 数量 {spu_count}=====\n"))
state = run_pinterest_ref(c, config, config_root(), runtime_root(), task_timestamp=task_ts)
items = state.get("product") or []
errs = state.get("errors") or []
log_q.put(("log", f"[{c}] Pinterest 参考完成:{len(items)} 个产品,兜底错误 {len(errs)}\n"))
results[c] = items
ts_dir = runtime_root() / "output" / countries[0] / task_ts
log_q.put(("log", f"\n✅ Pinterest 参考任务完成,产物文件夹:{ts_dir}\n"
f" (缓存/去重记录在 {runtime_root() / 'output' / countries[0]} 根目录,不进任务文件夹)\n"))
log_q.put(("done", results))
except Exception as e: # noqa: BLE001
import traceback
log_q.put(("log", f"运行失败: {e}\n{traceback.format_exc()}\n"))
log_q.put(("error", None))
finally:
sys.stdout = old_stdout
try:
for c in countries:
_pack_cache(c, log_q) # 运行完成 → 自动打包缓存/去重数据
except Exception as e: # noqa: BLE001
log_q.put(("log", f"[UI] 缓存打包失败: {e}\n"))
def _pack_cache(country: str, log_q=None) -> str:
"""运行/采集完成后,把该国缓存+去重数据打包成 zipoutput/cache_packs/<国>_<时间戳>.zip),
内含 design_briefs / used_designs / collected_keywords / products.json + .cache 关键缓存,
方便直接查看缓存与去重效果。"""
import zipfile
src = OUTPUT_ROOT / country
pack_dir = OUTPUT_ROOT / "cache_packs"
pack_dir.mkdir(parents=True, exist_ok=True)
stamp = time.strftime("%Y%m%d_%H%M%S")
zip_path = pack_dir / f"{country}_{stamp}.zip"
files = [
("design_briefs.json", "简报(热点+风格+提示词)"),
("used_designs.json", "已用记录(去重)"),
("collected_keywords.json", "采集过滤结果"),
("product/products.json", "产品产物清单"),
]
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
for rel, _ in files:
p = src / rel
if p.exists():
zf.write(p, f"output/{country}/{rel}")
# .cache 关键缓存(种子/抓取,各取最新)
cache_root = runtime_root() / ".cache"
for sub in ("seeds", "google_trends"):
d = cache_root / sub
if not d.exists():
continue
picks = sorted(d.glob(f"*{country}*")) or []
if sub == "google_trends":
picks = sorted(d.glob("*.json"), key=lambda x: x.stat().st_mtime, reverse=True)[:10]
for f in picks:
try:
zf.write(f, f".cache/{sub}/{f.name}")
except Exception:
pass
if log_q:
log_q.put(("log", f"[UI] 缓存打包完成: {zip_path}\n"))
return str(zip_path)
class App(tk.Tk):
def __init__(self):
super().__init__()
self.title("POD 热点抓取 Agent")
self.geometry("920x660")
self._q = queue.Queue()
self._busy = False
self._results = {}
# 运行日志落盘:logs/<时间戳>.logexe 旁 logs 目录),记录本次打开软件的所有日志
global LOG_FILE
try:
log_dir = runtime_root() / "logs"
log_dir.mkdir(parents=True, exist_ok=True)
LOG_FILE = open(log_dir / f"{time.strftime('%Y%m%d_%H%M%S')}.log",
"a", encoding="utf-8", buffering=1)
except Exception as e: # noqa: BLE001
LOG_FILE = None
print(f"[UI] 日志文件创建失败(不影响运行): {e}")
self._build_ui()
self._load_cache()
self.after(100, self._poll)
def _default_provider(self) -> str:
"""默认模式:config.yaml llm_screen.provider(用户已配 key → openai 默认)。"""
try:
return "openai" if (load_config().get("llm_screen") or {}).get("provider") == "openai" else "mock"
except Exception:
return "mock"
# ---------- UI 构建 ----------
def _build_ui(self):
top = ttk.Frame(self, padding=8)
top.pack(fill="x")
ttk.Label(top, text="国家:").pack(side="left")
self.country_var = tk.StringVar(value="US")
self.country_combo = ttk.Combobox(top, textvariable=self.country_var,
values=COUNTRIES, state="readonly", width=6)
self.country_combo.pack(side="left", padx=4)
self.country_combo.bind("<<ComboboxSelected>>", lambda e: self._on_country_selected())
ttk.Label(top, text=" 模式:").pack(side="left", padx=(14, 0))
self.provider_var = tk.StringVar(value=self._default_provider())
ttk.Radiobutton(top, text="OpenAI(真 LLM+生图)", value="openai",
variable=self.provider_var).pack(side="left", padx=2)
ttk.Radiobutton(top, text="Mock 演示", value="mock",
variable=self.provider_var).pack(side="left", padx=2)
ttk.Label(top, text=" 流程:").pack(side="left", padx=(14, 0))
self.flow_var = tk.StringVar(value="trends")
ttk.Radiobutton(top, text="热点采集", value="trends",
variable=self.flow_var).pack(side="left", padx=2)
ttk.Radiobutton(top, text="Pinterest 参考", value="pinterest",
variable=self.flow_var).pack(side="left", padx=2)
ttk.Label(top, text=" 种子数量:").pack(side="left", padx=(14, 0))
self.seed_var = tk.StringVar(value="24")
ttk.Entry(top, textvariable=self.seed_var, width=4).pack(side="left")
# 右上角:热点状态(缓存时间 / 采集时间)
self.hotspot_time_var = tk.StringVar(value="")
ttk.Label(top, textvariable=self.hotspot_time_var, anchor="e",
foreground="#185FA5").pack(side="right")
# —— 商品上传模板(必须上传后才能开始任务;template_export 从该模板解析并填充)——
tpl = ttk.LabelFrame(self, text="商品上传模板(必须上传 .xlsx 后才能运行)", padding=4)
tpl.pack(fill="x", padx=8, pady=(2, 0))
ttk.Button(tpl, text="📁 选择模板…", command=self._choose_template).pack(side="left")
self.template_path_var = tk.StringVar(value="")
self.template_name_var = tk.StringVar(value="(未选择)")
ttk.Label(tpl, textvariable=self.template_name_var, anchor="w",
foreground="#993C1D").pack(side="left", padx=(8, 0))
# —— 选品面板:国家→款号→颜色→添加任务(支持多款号 + 数量上限)——
sel = ttk.LabelFrame(self, text="选品(选定国家自动加载款号;款号 → 颜色 → 添加任务)", padding=6)
sel.pack(fill="x", padx=8, pady=(4, 0))
# 左:款号列表
lf1 = ttk.Frame(sel)
lf1.pack(side="left", padx=4)
ttk.Label(lf1, text="款号(选定后自动加载颜色与最低价):").pack(anchor="w")
self.spu_list = tk.Listbox(lf1, selectmode="extended", height=5, width=14, exportselection=False)
self.spu_list.pack(side="left")
self.spu_list.bind("<<ListboxSelect>>", self._on_spu_selected)
self.min_price_var = tk.StringVar(value="")
ttk.Label(lf1, textvariable=self.min_price_var, anchor="w", foreground="#993C1D").pack(fill="x", pady=(4, 0))
# 中:颜色多选(点击即多选)+ 添加按钮
lf2 = ttk.Frame(sel)
lf2.pack(side="left", padx=(10, 4))
ttk.Label(lf2, text="当前款号颜色(点击多选,显示色名/最低价):").pack(anchor="w")
self.sku_list = tk.Listbox(lf2, selectmode="multiple", height=5, width=34, exportselection=False)
self.sku_list.pack(side="left")
b2 = ttk.Frame(lf2)
b2.pack(side="left", padx=4)
ttk.Button(b2, text="全选", command=self._select_all_colors).pack(fill="x")
ttk.Button(b2, text="清空", command=lambda: self.sku_list.selection_clear(0, "end")).pack(fill="x", pady=2)
ttk.Button(b2, text="+添加选品", command=self._add_task).pack(fill="x", pady=(4, 0))
ttk.Label(b2, text="该款SPU/设计数(1-N):", anchor="w").pack(fill="x", pady=(6, 0))
self.count_var = tk.StringVar(value="10")
ttk.Entry(b2, textvariable=self.count_var, width=6).pack(fill="x")
# 右:任务清单
lf3 = ttk.Frame(sel)
lf3.pack(side="left", padx=4)
ttk.Label(lf3, text="选品任务清单:").pack(anchor="w")
self.task_stat_var = tk.StringVar(value="0 个任务")
ttk.Label(lf3, textvariable=self.task_stat_var, anchor="w",
foreground="#993C1D").pack(anchor="w")
self.task_list = tk.Listbox(lf3, height=5, width=40, exportselection=False)
self.task_list.pack(side="left")
b3 = ttk.Frame(lf3)
b3.pack(side="left", padx=4)
ttk.Button(b3, text="删选中", command=self._del_task).pack(fill="x")
ttk.Button(b3, text="清空", command=self._clear_tasks).pack(fill="x", pady=2)
# 加价百分比(供后续定价使用)+ 货号前缀
lf4 = ttk.Frame(sel)
lf4.pack(side="left", padx=(12, 0))
ttk.Label(lf4, text="加价%:").pack(anchor="w")
self.markup_var = tk.StringVar(value="0")
ttk.Entry(lf4, textvariable=self.markup_var, width=7).pack(anchor="w", pady=(2, 0))
ttk.Label(lf4, text="按 SKU 最低价加价", anchor="w", foreground="#5F5E5A").pack(anchor="w", pady=(4, 0))
lf5 = ttk.Frame(sel)
lf5.pack(side="left", padx=(12, 0))
ttk.Label(lf5, text="货号前缀:").pack(anchor="w")
self.code_prefix_var = tk.StringVar(value="DG")
ttk.Entry(lf5, textvariable=self.code_prefix_var, width=8).pack(anchor="w", pady=(2, 0))
ttk.Label(lf5, text="前缀+3位计数(000起)\nOSS: 国家/时间戳/货号_4位随机", anchor="w",
foreground="#5F5E5A").pack(anchor="w", pady=(4, 0))
self._sku_map = {}
self._tasks: list = []
self._reload_spus()
bar = ttk.Frame(self, padding=(8, 0))
bar.pack(fill="x")
self.fetch_btn = ttk.Button(bar, text="📥 采集热点", command=self._on_fetch)
self.fetch_btn.pack(side="left")
self.run_btn = ttk.Button(bar, text="▶ 运行", command=self._on_run)
self.run_btn.pack(side="left", padx=6)
ttk.Button(bar, text="打开产物目录", command=self._open_output).pack(side="left", padx=8)
ttk.Button(bar, text="打开日志目录", command=self._open_logs).pack(side="left")
ttk.Button(bar, text="清空日志", command=self._clear_log).pack(side="left")
ttk.Label(self, text="运行日志:").pack(anchor="w", padx=8)
self.log_text = tk.Text(self, height=8, state="disabled")
self.log_text.pack(fill="x", padx=8)
ttk.Label(self, text="设计简报(双击查看完整提示词):").pack(anchor="w", padx=8)
cols = ("country", "topic", "category", "risk", "score")
self.tree = ttk.Treeview(self, columns=cols, show="headings", height=12)
for col, title, w, anchor in zip(
cols, ["国家", "热点词", "类别", "风险", "分数"],
[52, 340, 100, 70, 64],
["center", "w", "w", "center", "center"],
):
self.tree.heading(col, text=title)
self.tree.column(col, width=w, anchor=anchor)
self.tree.pack(fill="both", expand=True, padx=8, pady=(2, 8))
self.tree.bind("<Double-1>", self._show_detail)
def _log(self, s: str):
self.log_text.config(state="normal")
self.log_text.insert("end", s)
self.log_text.see("end")
self.log_text.config(state="disabled")
def _reload_spus(self):
"""按当前所选国家加载该国款号到款号列表,并刷新颜色。"""
country = self.country_var.get()
self._spu_list = load_spus(country)
self.spu_list.delete(0, "end")
for s in self._spu_list:
self.spu_list.insert("end", s["code"])
if self._spu_list:
self.spu_list.selection_set(0)
self._on_spu_selected()
def _on_country_selected(self):
"""切换国家:刷新该国款号 + 重新加载该国热点缓存。"""
self._reload_spus()
self._load_cache()
def _on_spu_selected(self, _evt=None):
sel = self.spu_list.curselection()
if not sel:
self.sku_list.delete(0, "end")
self.min_price_var.set("")
return
spu = self._spu_list[sel[0]]["code"] if sel[0] < len(self._spu_list) else None
if not spu:
return
colors = load_colors(spu)
self._sku_map = {c["sku_code"]: c for c in colors}
self.sku_list.delete(0, "end")
for c in colors:
price = c.get("price")
price_txt = f" ¥{price:g}" if price not in (None, "") else ""
self.sku_list.insert("end", f"{c['sku_code']} {c.get('color', '')}{price_txt}")
# 最低价标注(该款号所有颜色 SKU 中的最低价)
prices = [float(c["price"]) for c in colors if c.get("price") not in (None, "")]
if prices:
self.min_price_var.set(f"最低价: ¥{min(prices):g}")
else:
self.min_price_var.set("")
if colors:
self.sku_list.selection_set(0) # 默认选中第一个颜色
def _current_spu(self):
sel = self.spu_list.curselection()
if sel and sel[0] < len(self._spu_list):
return self._spu_list[sel[0]]["code"]
return ""
def _add_task(self):
"""把当前选中的【款号】加入任务清单(可多选、可重复新增)。
每个款号一条「集合」:skus=选中颜色集合(多色用逗号分隔),
count=当前数量输入框值(每集合独立数量,= 该集合的 SPU/设计数)。"""
sel_idx = self.spu_list.curselection()
if not sel_idx:
messagebox.showwarning("提示", "请先选中至少一个款号(可 Ctrl/Shift 多选)")
return
sel_spus = []
for i in sel_idx:
if i < len(self._spu_list):
sel_spus.append(self._spu_list[i]["code"])
if not sel_spus:
return
try:
per_count = max(int(self.count_var.get() or 0), 1)
except ValueError:
per_count = 1
raw_colors = [self.sku_list.get(i).split(" ")[0] for i in self.sku_list.curselection()]
if not raw_colors:
raw_colors = [self._sku_map and next(iter(self._sku_map))]
if not raw_colors[0]:
messagebox.showwarning("提示", "当前款号无颜色数据")
return
color_codes = [c.split("-")[-1] for c in raw_colors] # 颜色码(如 BL01
for spu in sel_spus:
colors_of_spu = load_colors(spu) or []
skus = []
for cc in color_codes:
m = next((c["sku_code"] for c in colors_of_spu
if c["sku_code"].split("-")[-1] == cc), None)
skus.append(m or (colors_of_spu[0]["sku_code"] if colors_of_spu else cc))
# 每款一条「集合」(可重复新增),颜色集合逗号分隔 + 独立数量
self._tasks.append({"spu": spu, "skus": ",".join(skus), "count": per_count})
self._refresh_tasks()
def _upsert_task(self, spu: str, skus: str):
for t in self._tasks:
if t["spu"] == spu:
t["skus"] = skus
break
else:
self._tasks.append({"spu": spu, "skus": skus})
self._refresh_tasks()
def _del_task(self):
sel = self.task_list.curselection()
if not sel:
return
for i in reversed(sel):
self._tasks.pop(i)
self._refresh_tasks()
def _clear_tasks(self):
self._tasks = []
self._refresh_tasks()
def _refresh_tasks(self):
self.task_list.delete(0, "end")
for t in self._tasks:
skus = t["skus"] or "(首色)"
cnt = t.get("count") or 0
cnt_txt = f" × 设计数{cnt}" if cnt else ""
self.task_list.insert("end", f"{t['spu']}{skus}{cnt_txt}")
# 显示当前任务统计(集合数 × 各自数量 = 总 SPU/设计数)
total = sum(int(t.get("count") or 0) for t in self._tasks)
self.task_stat_var.set(f"{len(self._tasks)} 个集合 · 总设计数 {total}(每集合独立数量)")
def _select_all_colors(self):
self.sku_list.selection_set(0, "end")
def _load_cache(self):
"""前台热点显示(两个独立配置文件):
① collected_keywords.json = 完整采集池(全量,不随用量减少)
② used_designs.json = 已用记录 → 已用热点从显示列表移除(用完即消失)
③ design_briefs.json 只是运行简报(不决定前台显示),无 collected 时兜底。"""
c = self.country_var.get()
self._results = {}
out_dir = OUTPUT_ROOT / c
ga = ""
# ① 完整采集池 → 剔除已用 → 显示"可用热点"
try:
cp = out_dir / "collected_keywords.json"
if cp.exists():
cdata = json.loads(cp.read_text(encoding="utf-8"))
kws = cdata.get("keywords") or []
# 已用 topic 集合(used_designs.json
used_topics = set()
up = out_dir / "used_designs.json"
if up.exists():
try:
ud = json.loads(up.read_text(encoding="utf-8")).get("used", []) or []
used_topics = {str(u.get("topic", "")).strip().lower() for u in ud}
except Exception:
pass
avail = [k for k in kws
if str(k.get("topic", "")).strip().lower() not in used_topics]
self._results[c] = [
{"topic": k.get("topic", ""),
"source": k.get("source", "collected"),
"raw_score": k.get("raw_score")} for k in avail
]
ga = (f"{str(cdata.get('collected_at', ''))}"
f"(可用 {len(avail)} / 采集 {len(kws)},已用 {len(used_topics)}")
except Exception:
pass
# ② 兜底:无 collected 时读简报
if not self._results.get(c):
try:
p = out_dir / "design_briefs.json"
if p.exists():
data = json.loads(p.read_text(encoding="utf-8"))
self._results[c] = data.get("design_briefs", []) or []
ga = str(data.get("generated_at", ""))
except Exception:
pass
self._refresh_table()
total = sum(len(x) for x in self._results.values())
if total:
self.hotspot_time_var.set(f"可用热点 {total} 条 · {ga or '时间未知'}")
self._log(f"[UI] 已加载 {c} 可用热点 {total} 条({ga or '未知'}\n")
else:
self.hotspot_time_var.set(f"{c} 无热点(点「📥 采集热点」)")
# ---------- 采集(独立步骤)----------
def _oai_cfg(self) -> dict:
"""前端不再配置 OpenAI:所有 key/URL/模型从 config.yaml 读取(返回空,不覆盖配置)。"""
return {}
def _on_fetch(self):
"""仅采集+去重过滤热点关键词,直接显示在结果列表。"""
if self._busy:
return
try:
ms = int(self.seed_var.get())
except ValueError:
messagebox.showerror("参数错误", "种子数量必须是数字")
return
try:
int(self.count_var.get())
except ValueError:
messagebox.showerror("参数错误", "数量上限必须是数字")
return
self._clear_log()
self._results = {}
self._busy = True
self.fetch_btn.config(state="disabled", text="采集中…")
self.run_btn.config(state="disabled")
threading.Thread(
target=fetch_keywords,
args=(self.country_var.get(), self.provider_var.get(), ms, self._q,
self._oai_cfg()),
daemon=True,
).start()
# ---------- 运行 ----------
def _choose_template(self):
"""选择自定义商品上传模板(.xlsx),后续 template_export 从该模板解析。
导入时校验「经营站点」是否与当前国家一致(如 JP → 日本站),不一致则导入失败。"""
path = filedialog.askopenfilename(
title="选择商品上传模板",
filetypes=[("Excel 模板", "*.xlsx"), ("Excel 文件", "*.xls")],
)
if not path:
return
# 站点校验:模板第 1-2 行含「经营站点」+ 站点名(美国站/日本站/英国站/澳洲站…)
site_ok, site_name = self._check_template_site(path)
if not site_ok:
messagebox.showerror(
"模板导入失败",
f"模板经营站点「{site_name or '未知'}」与当前国家「{self.country_var.get()}」不一致,请重新导入对应站点模板。",
)
self._log(f"[UI] 模板导入失败:站点「{site_name or '未知'}」≠ 国家 {self.country_var.get()}\n")
return
self.template_path_var.set(path)
self.template_name_var.set(f"已选: {Path(path).name}")
self._log(f"[UI] 商品上传模板已选择: {path}{site_name}\n")
def _check_template_site(self, path: str):
"""读取模板经营站点(A1=经营站点 → A2=站点名),返回 (是否匹配当前国家, 站点名)。"""
# 国家 → 允许的站点关键词
country_sites = {
"US": ("美国站", "美国"),
"GB": ("英国站", "英国"),
"JP": ("日本站", "日本"),
"AU": ("澳大利亚站", "澳洲站", "澳洲"),
"MX": ("墨西哥站", "墨西哥"),
"DE": ("德国站", "德国"),
"BR": ("巴西站", "巴西"),
"SA": ("沙特站", "沙特阿拉伯站", "沙特阿拉伯", "沙特"),
"PL": ("波兰站", "波兰"),
"ES": ("西班牙站", "西班牙"),
"IT": ("意大利站", "意大利"),
"CA": ("加拿大站", "加拿大"),
}
allowed = country_sites.get(str(self.country_var.get()).upper(), ())
site_name = ""
try:
import openpyxl
wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
ws = None
for _sn in wb.sheetnames:
if "模版" in _sn or "模板" in _sn:
ws = wb[_sn]
break
if ws is None:
ws = wb.worksheets[0]
# 定位「经营站点」表头所在列(第 1 行),站点名在下一行同列(如 A1=经营站点 → A2=美国站)
site_col = None
for c in range(1, 6):
v = ws.cell(1, c).value
if v and "站点" in str(v):
site_col = c
break
if site_col is not None:
v2 = ws.cell(2, site_col).value
if v2:
site_name = str(v2).strip()
wb.close()
except Exception: # noqa: BLE001
return True, "" # 读不到站点信息 → 放行(不阻塞)
if not site_name:
return True, "" # 模板无站点信息 → 放行
if any(k in site_name for k in allowed):
return True, site_name
return False, site_name
def _missing_basemaps(self, tasks):
"""任务开始前校验 basemap/<款号>/<颜色>/ 是否存在底图;返回缺失的 (款号/颜色) 列表。"""
cfg = {}
try:
import yaml as _yaml
cfg = _yaml.safe_load((config_root() / "config.yaml").read_text(encoding="utf-8"))
except Exception: # noqa: BLE001
pass
bm_rel = (cfg.get("product") or {}).get("basemap_dir", "basemap")
bm_root = None
for r in (runtime_root(), config_root()):
cand = r / bm_rel
if cand.exists():
bm_root = cand
break
if bm_root is None:
bm_root = config_root() / bm_rel
def _norm(s: str) -> str:
return str(s or "").replace(" ", "").replace(" ", "").strip().lower()
missing = []
for t in tasks:
spu = str(t.get("spu", ""))
for sc in str(t.get("skus") or "").split(","):
sc = sc.strip()
if not sc:
continue
# SKU code 与文件夹名两边都去空格后再比较(兼容 db code 或文件夹名带空格)
spu_dir = bm_root / spu
if not spu_dir.is_dir():
missing.append(f"{spu}/{sc}")
continue
target = _norm(sc)
found = False
for cand in spu_dir.iterdir():
if (cand.is_dir() and _norm(cand.name) == target
and any(p.is_file() for p in cand.iterdir())):
found = True
break
if not found:
missing.append(f"{spu}/{sc}")
return missing
def _on_run(self):
if self._busy:
return
if not self.template_path_var.get().strip():
messagebox.showwarning("提示", "请先上传商品上传模板(📁 选择模板…)后才能开始任务")
return
countries = [self.country_var.get()]
try:
ms = int(self.seed_var.get())
spu_count = int(self.count_var.get())
markup = float(self.markup_var.get())
except ValueError:
messagebox.showerror("参数错误", "种子数量 / 款号数量 / 加价% 必须是数字")
return
if spu_count < 1:
messagebox.showerror("参数错误", "款号数量必须 ≥ 1(不支持无上限)")
return
if markup < 0:
messagebox.showerror("参数错误", "加价%不能为负数")
return
# 任务清单优先;空则用当前选中款号+颜色(保留旧行为)
tasks = list(self._tasks)
if not tasks:
spu = self._current_spu()
if not spu:
messagebox.showwarning("提示", "请先添加选品(款号 → 颜色 → +添加选品)")
return
sel_colors = [self.sku_list.get(i).split(" ")[0] for i in self.sku_list.curselection()]
if not sel_colors:
messagebox.showwarning("提示", "请至少选择一个颜色")
return
tasks = [{"spu": spu, "skus": ",".join(sel_colors)}]
# ① 兜底校验:任务开始前检查所有款号颜色的 basemap 底图是否存在;
# 存在缺失 → 直接报错提醒,不开始任何任务
missing = self._missing_basemaps(tasks)
if missing:
messagebox.showerror(
"底图缺失,任务未开始",
"以下款号颜色缺少底图(basemap/<款号>/<颜色>/ 无图片):\n\n "
+ "\n ".join(missing[:10])
+ ("\n …" if len(missing) > 10 else "")
+ "\n\n请补齐底图后重新运行(本次未执行任何任务)。",
)
self._log("[UI] 底图缺失校验未通过,任务未开始: " + ", ".join(missing[:10]) + "\n")
return
self._clear_log()
self._results = {}
self._busy = True
self.run_btn.config(state="disabled", text="运行中…")
self.fetch_btn.config(state="disabled")
if self.flow_var.get() == "pinterest":
# Pinterest 参考模式:独立于 Google Trends 采集链路
threading.Thread(
target=run_pinterest_pipeline,
args=(countries, self.provider_var.get(), self._q,
tasks, spu_count, self._oai_cfg(), markup,
self.code_prefix_var.get().strip() or "DG",
self.template_path_var.get().strip()),
daemon=True,
).start()
else:
threading.Thread(
target=run_pipeline,
args=(countries, self.provider_var.get(), ms, self._q,
"", "", spu_count, tasks, self._oai_cfg(), markup,
self.code_prefix_var.get().strip() or "DG",
self.template_path_var.get().strip()),
daemon=True,
).start()
def _poll(self):
try:
n = 0
while True:
kind, payload = self._q.get_nowait()
if kind == "log":
self.log_text.config(state="normal")
self.log_text.insert("end", payload)
self.log_text.see("end")
self.log_text.config(state="disabled")
n += 1
if n >= 200: # 大批量日志时分批刷新,避免 UI 卡顿导致显示延迟
self.log_text.update_idletasks()
n = 0
elif kind == "error":
self._busy = False
self.run_btn.config(state="normal", text="▶ 运行")
self.fetch_btn.config(state="normal", text="📥 采集热点")
elif kind == "fetched":
self._results = {payload.get("country"): payload.get("keywords") or []}
self._busy = False
self.run_btn.config(state="normal", text="▶ 运行")
self.fetch_btn.config(state="normal", text="📥 采集热点")
self._refresh_table()
now = time.strftime("%Y-%m-%d %H:%M:%S")
self.hotspot_time_var.set(f"采集成功 {now} · {len(payload.get('keywords') or [])} 条")
self._log(f"[UI] 采集完成:{len(payload.get('keywords') or [])} 个关键词,时间 {now}\n")
messagebox.showinfo("采集完成",
f"热点采集成功:{len(payload.get('keywords') or [])} 个关键词\n时间:{now}")
elif kind == "done":
self._results = payload or {}
self._busy = False
self.run_btn.config(state="normal", text="▶ 运行")
self.fetch_btn.config(state="normal", text="📥 采集热点")
self._refresh_table()
self._load_cache() # 运行完成 → 刷新前台:用掉的热点从列表移除
except queue.Empty:
pass
self.after(100, self._poll)
# ---------- 结果 ----------
def _refresh_table(self):
self.tree.delete(*self.tree.get_children())
for c, briefs in self._results.items():
for b in briefs:
score = b.get("score")
self.tree.insert("", "end", values=(
c, b.get("topic", ""),
b.get("design_category") or b.get("kind") or b.get("spu_code") or "(采集)",
b.get("risk_level") or "-",
round(float(score), 3) if score not in (None, "") else "-"))
def _show_detail(self, _evt):
sel = self.tree.selection()
if not sel:
return
country, topic = self.tree.item(sel[0], "values")[0], self.tree.item(sel[0], "values")[1]
for c, briefs in self._results.items():
if c != country:
continue
for b in briefs:
if b.get("topic") == topic:
self._detail_window(b)
return
def _detail_window(self, b):
win = tk.Toplevel(self)
win.title(f"{b.get('topic', '')} 详情")
win.geometry("780x540")
txt = tk.Text(win, wrap="word", padx=10, pady=10)
txt.pack(fill="both", expand=True)
for zh, k in [
("国家", "country"), ("热点词", "topic"), ("类别", "design_category"),
("风险", "risk_level"), ("风险原因", "risk_reasons"), ("概念", "concept"),
("主体 motif", "motif"), ("艺术风格", "art_style"), ("配色", "color_palette"),
("构图", "composition"), ("负向", "negative_prompt"),
("image_prompt", "image_prompt"), ("composite_prompt", "composite_prompt"),
]:
v = b.get(k)
if v in (None, "", []):
continue
txt.insert("end", f"【{zh}\n{json.dumps(v, ensure_ascii=False) if not isinstance(v, str) else v}\n\n")
txt.config(state="disabled")
# ---------- 工具 ----------
def _clear_log(self):
self.log_text.config(state="normal")
self.log_text.delete("1.0", "end")
self.log_text.config(state="disabled")
def _open_output(self):
os.makedirs(OUTPUT_ROOT, exist_ok=True)
try:
os.startfile(OUTPUT_ROOT) # Windows
except AttributeError:
import subprocess
subprocess.Popen(["open" if sys.platform == "darwin" else "xdg-open", str(OUTPUT_ROOT)])
def _open_logs(self):
log_dir = runtime_root() / "logs"
os.makedirs(log_dir, exist_ok=True)
try:
os.startfile(log_dir)
except AttributeError:
import subprocess
subprocess.Popen(["open" if sys.platform == "darwin" else "xdg-open", str(log_dir)])
def self_test():
"""无 GUI 自检:验证打包环境(依赖/数据文件/核心链路),结果写入运行根 self_test_result.txt。"""
import traceback
lines: list = []
def log(s):
print(s)
lines.append(str(s))
try:
log("=== POD Agent 自检 ===")
log(f"数据根: {resource_root()}")
log(f"运行根: {runtime_root()}")
log(f"配置根: {config_root()} (exe 旁配置优先)")
cfg = load_config()
log(f"config 加载 OK | seed_provider: {cfg.get('seed_provider')} | 国家: {cfg.get('countries')}")
# 自检不上传图床(避免 mock 占位图污染用户 OSS bucket
if "oss" in cfg:
cfg["oss"]["enabled"] = False
for c in COUNTRIES:
p = config_root() / "prompts" / c / "aesthetics.yaml"
log(f" prompts/{c}: {'OK' if p.exists() else 'MISSING!'}")
state = run_country("GB", cfg, config_root(), output_root=runtime_root())
briefs = state.get("briefs", []) or []
errs = state.get("errors") or []
log(f"GB 流水线完成:简报 {len(briefs)} 条,兜底错误 {len(errs)}")
if briefs:
log(f"样例: {briefs[0].get('topic')} | {briefs[0].get('risk_level')} | {briefs[0].get('design_category')}")
out = runtime_root() / "output" / "GB"
log(f"产物目录: {out} 存在={'是' if out.exists() else '否'}")
log("自检完成 OK")
except Exception as e: # noqa: BLE001
log(f"自检失败: {e}")
log(traceback.format_exc())
finally:
try:
(runtime_root() / "self_test_result.txt").write_text(
"\n".join(lines), encoding="utf-8")
except Exception:
pass
def main():
# windowed 打包(console=False)下 sys.stdout/stderr 为 Noneprint 会崩;兜底为丢弃流
if sys.stdout is None:
sys.stdout = io.StringIO()
if sys.stderr is None:
sys.stderr = io.StringIO()
ensure_defaults()
if "--self-test" in sys.argv:
self_test()
return
App().mainloop()
if __name__ == "__main__":
main()