fix: seed outfit factory config

This commit is contained in:
2026-06-23 17:51:46 +08:00
parent cad8b61bcf
commit f033943db0
8 changed files with 233 additions and 12 deletions
+10
View File
@@ -19,6 +19,16 @@
"timeout_seconds": 0, "timeout_seconds": 0,
"connect_timeout_seconds": 30, "connect_timeout_seconds": 30,
"extra_body": {} "extra_body": {}
},
{
"name": "GPT-5.5 文本",
"url": "https://api.vectorengine.ai/v1/chat/completions",
"model": "gpt-5.5",
"api_key": "",
"api_type": "chat",
"timeout_seconds": 0,
"connect_timeout_seconds": 30,
"extra_body": {}
} }
] ]
} }
@@ -0,0 +1 @@
为商品「{title}」生成人物上身实穿效果图:真人模特正面穿着这件衣服,完整保留款式、版型、颜色与印花图案,自然光、纯色棚拍背景,电商主图风格,不加文字与促销标签。
@@ -0,0 +1 @@
请生成 10 条适合台湾蝦皮电商的中文女装商品标题,突出卖点与适穿场景,每条控制在 30 字以内。标题之间用逗号「,」分隔,只输出标题本身,不要使用 Markdown 表格、不要换行、不要序号/編號/字元數、不要引号或表情。
+7 -1
View File
@@ -26,7 +26,13 @@ from services import installer # noqa: E402
from services.file_service import get_data_dir # noqa: E402 from services.file_service import get_data_dir # noqa: E402
APP_EXE = "CMBot.exe" APP_EXE = "CMBot.exe"
CONFIG_FILES = ("app_config.json", "templates.json", "ai_models.json") CONFIG_FILES = (
"app_config.json",
"templates.json",
"ai_models.json",
"outfit_prompt.txt",
"title_prompt.txt",
)
logger = logging.getLogger("launcher") logger = logging.getLogger("launcher")
+87 -3
View File
@@ -130,6 +130,88 @@ def _seed_factory_config_if_missing(filename):
return False return False
def _factory_config_path(filename):
"""Return the packaged factory config path under the program root."""
from services.file_service import get_app_dir
return get_app_dir() / "config" / filename
def _load_json_file(path):
with open(str(path), encoding="utf-8-sig") as f:
return json.load(f)
def _model_list_from_data(data):
models = data.get("models") if isinstance(data, dict) else data
return models if isinstance(models, list) else None
def _model_name(model):
if not isinstance(model, dict):
return ""
return str(model.get("name", "")).strip()
def _append_missing_title_model(models_file):
"""Append the configured title model from factory ai_models.json if missing.
Existing user models and API keys are never overwritten. This only handles
the upgrade case where a user already has ai_models.json but lacks the new
app_config.title_model entry (docs/11 §6.1).
"""
title_model_name = str(load_config().get("title_model", "")).strip()
if not title_model_name or not models_file.exists():
return False
try:
user_data = _load_json_file(models_file)
except (json.JSONDecodeError, ValueError, OSError) as exc:
logger.warning("AI models file unreadable (%s): %s", exc, models_file)
return False
user_models = _model_list_from_data(user_data)
if user_models is None:
logger.warning("AI models file has no model list: %s", models_file)
return False
if any(_model_name(model) == title_model_name for model in user_models):
return False
factory_file = _factory_config_path(_AI_MODELS_FILENAME)
if not factory_file.exists():
logger.info("Factory AI models template not found: %s", factory_file)
return False
try:
factory_data = _load_json_file(factory_file)
except (json.JSONDecodeError, ValueError, OSError) as exc:
logger.warning("Factory AI models template unreadable (%s): %s", exc, factory_file)
return False
factory_models = _model_list_from_data(factory_data) or []
title_model = None
for model in factory_models:
if _model_name(model) == title_model_name:
title_model = dict(model)
break
if title_model is None:
logger.info(
"Factory AI models template has no title model named %s: %s",
title_model_name,
factory_file,
)
return False
try:
user_models.append(title_model)
with open(str(models_file), "w", encoding="utf-8") as f:
json.dump(user_data, f, ensure_ascii=False, indent=2)
logger.info("Appended missing title model %s to %s", title_model_name, models_file)
return True
except OSError as exc:
logger.warning("Failed to append title model to %s: %s", models_file, exc)
return False
def load_ai_models(): def load_ai_models():
"""Load AI model configs from ai_models.json. """Load AI model configs from ai_models.json.
@@ -140,17 +222,17 @@ def load_ai_models():
from services.file_service import get_config_path from services.file_service import get_config_path
_seed_factory_config_if_missing(_AI_MODELS_FILENAME) _seed_factory_config_if_missing(_AI_MODELS_FILENAME)
models_file = get_config_path(_AI_MODELS_FILENAME) models_file = get_config_path(_AI_MODELS_FILENAME)
_append_missing_title_model(models_file)
if not models_file.exists(): if not models_file.exists():
logger.info("AI models file not found: %s", models_file) logger.info("AI models file not found: %s", models_file)
return [] return []
try: try:
with open(str(models_file), encoding="utf-8-sig") as f: data = _load_json_file(models_file)
data = json.load(f)
except (json.JSONDecodeError, ValueError, OSError) as exc: except (json.JSONDecodeError, ValueError, OSError) as exc:
logger.warning("AI models file unreadable (%s): %s", exc, models_file) logger.warning("AI models file unreadable (%s): %s", exc, models_file)
return [] return []
models = data.get("models") if isinstance(data, dict) else data models = _model_list_from_data(data)
if not isinstance(models, list): if not isinstance(models, list):
logger.warning("AI models file has no model list: %s", models_file) logger.warning("AI models file has no model list: %s", models_file)
return [] return []
@@ -160,6 +242,7 @@ def load_ai_models():
def load_outfit_prompt(): def load_outfit_prompt():
"""Return the saved outfit prompt template, or the built-in default.""" """Return the saved outfit prompt template, or the built-in default."""
from services.file_service import get_config_path from services.file_service import get_config_path
_seed_factory_config_if_missing(_OUTFIT_PROMPT_FILENAME)
prompt_file = get_config_path(_OUTFIT_PROMPT_FILENAME) prompt_file = get_config_path(_OUTFIT_PROMPT_FILENAME)
if not prompt_file.exists(): if not prompt_file.exists():
return DEFAULT_OUTFIT_PROMPT return DEFAULT_OUTFIT_PROMPT
@@ -187,6 +270,7 @@ def save_outfit_prompt(text):
def load_title_prompt(): def load_title_prompt():
"""Return the saved 标题生成提示词, or the built-in default (docs/11 §17.3).""" """Return the saved 标题生成提示词, or the built-in default (docs/11 §17.3)."""
from services.file_service import get_config_path from services.file_service import get_config_path
_seed_factory_config_if_missing(_TITLE_PROMPT_FILENAME)
prompt_file = get_config_path(_TITLE_PROMPT_FILENAME) prompt_file = get_config_path(_TITLE_PROMPT_FILENAME)
if not prompt_file.exists(): if not prompt_file.exists():
return DEFAULT_TITLE_PROMPT return DEFAULT_TITLE_PROMPT
+7 -7
View File
@@ -1514,10 +1514,10 @@
任务: 任务:
- [x] 文档更新:`docs/11-ai-outfit.md`、`docs/10-lan-update.md`、`docs/09-packaging-release.md` 已明确出厂 title model 追加和两个 prompt txt 的非覆盖式补种 - [x] 文档更新:`docs/11-ai-outfit.md`、`docs/10-lan-update.md`、`docs/09-packaging-release.md` 已明确出厂 title model 追加和两个 prompt txt 的非覆盖式补种
- [ ] `packaging/default_config/ai_models.json` 加入 title model 模板(`api_key` 留空,不提交真实 key),并保持图片模型顺序符合 `tests/test_config_service.py` 预期 - [x] `packaging/default_config/ai_models.json` 加入 title model 模板(`api_key` 留空,不提交真实 key),并保持图片模型顺序符合 `tests/test_config_service.py` 预期
- [ ] `packaging/default_config/outfit_prompt.txt` / `title_prompt.txt` 纳入发布包默认配置 - [x] `packaging/default_config/outfit_prompt.txt` / `title_prompt.txt` 纳入发布包默认配置
- [ ] `src/launcher.py` `CONFIG_FILES` 加入 `outfit_prompt.txt` / `title_prompt.txt`,首次运行播种 - [x] `src/launcher.py` `CONFIG_FILES` 加入 `outfit_prompt.txt` / `title_prompt.txt`,首次运行播种
- [ ] `config_service`:`load_outfit_prompt` / `load_title_prompt` 读取前调用运行时兜底复制;用户文件存在不覆盖 - [x] `config_service`:`load_outfit_prompt` / `load_title_prompt` 读取前调用运行时兜底复制;用户文件存在不覆盖
- [ ] `config_service`:`load_ai_models` 在用户文件存在但缺 `app_config.title_model` 时,从 factory `ai_models.json` 追加同名模型;不覆盖同名模型、不改 `api_key`;factory 无同名只记录日志 - [x] `config_service`:`load_ai_models` 在用户文件存在但缺 `app_config.title_model` 时,从 factory `ai_models.json` 追加同名模型;不覆盖同名模型、不改 `api_key`;factory 无同名只记录日志
- [ ] 测试:缺 prompt 文件时从 factory 复制;已有 prompt 不覆盖;已有 `ai_models.json` 缺标题模型时追加;已有同名标题模型不重复;factory 无同名不报错 - [x] 测试:缺 prompt 文件时从 factory 复制;已有 prompt 不覆盖;已有 `ai_models.json` 缺标题模型时追加;已有同名标题模型不重复;factory 无同名不报错
- [ ] 验证:相关单测、全套测试、离屏启动 AI 穿搭页 - [x] 验证:`py_compile`、`test_config_service.py`、`test_launcher.py`、全套 `python -m unittest discover -s tests`、离屏启动主窗口并切到 AI 穿搭页通过
+102 -1
View File
@@ -66,7 +66,10 @@ class TestOutfitConfigHelpers(unittest.TestCase):
models = cs.load_ai_models() models = cs.load_ai_models()
self.assertEqual([m["name"] for m in models], ["GPT Image 2", "Nano Banana 2"]) self.assertEqual(
[m["name"] for m in models],
["GPT Image 2", "Nano Banana 2", "GPT-5.5 文本"],
)
self.assertTrue(all(m.get("api_key") == "" for m in models)) self.assertTrue(all(m.get("api_key") == "" for m in models))
def test_load_ai_models_seeds_missing_user_file_from_factory_template(self): def test_load_ai_models_seeds_missing_user_file_from_factory_template(self):
@@ -103,11 +106,85 @@ class TestOutfitConfigHelpers(unittest.TestCase):
self.assertEqual(cs.load_ai_models(), []) self.assertEqual(cs.load_ai_models(), [])
self.assertFalse((self.config_dir / "ai_models.json").exists()) self.assertFalse((self.config_dir / "ai_models.json").exists())
def test_load_ai_models_appends_missing_title_model_from_factory(self):
self.factory_config_dir.mkdir(parents=True, exist_ok=True)
(self.factory_config_dir / "ai_models.json").write_text(
json.dumps({
"models": [
{"name": "GPT Image 2", "api_key": ""},
{"name": "GPT-5.5 文本", "api_type": "chat", "api_key": ""},
]
}),
encoding="utf-8",
)
(self.config_dir / "ai_models.json").write_text(
json.dumps({"models": [{"name": "user", "api_key": "keep"}]}),
encoding="utf-8",
)
models = cs.load_ai_models()
self.assertEqual([m["name"] for m in models], ["user", "GPT-5.5 文本"])
self.assertEqual(models[0]["api_key"], "keep")
self.assertEqual(models[1]["api_key"], "")
saved = json.loads((self.config_dir / "ai_models.json").read_text(encoding="utf-8"))
self.assertEqual([m["name"] for m in saved["models"]], ["user", "GPT-5.5 文本"])
def test_load_ai_models_does_not_duplicate_existing_title_model(self):
self.factory_config_dir.mkdir(parents=True, exist_ok=True)
(self.factory_config_dir / "ai_models.json").write_text(
json.dumps({"models": [{"name": "GPT-5.5 文本", "api_key": ""}]}),
encoding="utf-8",
)
(self.config_dir / "ai_models.json").write_text(
json.dumps({"models": [{"name": "GPT-5.5 文本", "api_key": "keep"}]}),
encoding="utf-8",
)
models = cs.load_ai_models()
self.assertEqual(len(models), 1)
self.assertEqual(models[0]["api_key"], "keep")
def test_load_ai_models_factory_without_title_model_does_not_append(self):
self.factory_config_dir.mkdir(parents=True, exist_ok=True)
(self.factory_config_dir / "ai_models.json").write_text(
json.dumps({"models": [{"name": "other", "api_key": ""}]}),
encoding="utf-8",
)
(self.config_dir / "ai_models.json").write_text(
json.dumps({"models": [{"name": "user", "api_key": "keep"}]}),
encoding="utf-8",
)
models = cs.load_ai_models()
self.assertEqual([m["name"] for m in models], ["user"])
# -- outfit_prompt.txt ---------------------------------------------- # -- outfit_prompt.txt ----------------------------------------------
def test_prompt_default_when_missing(self): def test_prompt_default_when_missing(self):
self.assertEqual(cs.load_outfit_prompt(), cs.DEFAULT_OUTFIT_PROMPT) self.assertEqual(cs.load_outfit_prompt(), cs.DEFAULT_OUTFIT_PROMPT)
def test_prompt_seeds_missing_user_file_from_factory_template(self):
self.factory_config_dir.mkdir(parents=True, exist_ok=True)
(self.factory_config_dir / "outfit_prompt.txt").write_text(
"factory {title}", encoding="utf-8")
self.assertEqual(cs.load_outfit_prompt(), "factory {title}")
self.assertEqual(
(self.config_dir / "outfit_prompt.txt").read_text(encoding="utf-8"),
"factory {title}",
)
def test_prompt_does_not_overwrite_existing_user_file(self):
self.factory_config_dir.mkdir(parents=True, exist_ok=True)
(self.factory_config_dir / "outfit_prompt.txt").write_text(
"factory", encoding="utf-8")
(self.config_dir / "outfit_prompt.txt").write_text("user", encoding="utf-8")
self.assertEqual(cs.load_outfit_prompt(), "user")
def test_prompt_save_then_load_roundtrip(self): def test_prompt_save_then_load_roundtrip(self):
cs.save_outfit_prompt("hello {title} {product_id}") cs.save_outfit_prompt("hello {title} {product_id}")
self.assertEqual(cs.load_outfit_prompt(), "hello {title} {product_id}") self.assertEqual(cs.load_outfit_prompt(), "hello {title} {product_id}")
@@ -160,6 +237,30 @@ class TestOutfitConfigHelpers(unittest.TestCase):
"".encode("utf-8") + json.dumps([{"name": "z", "text": "t"}]).encode("utf-8")) "".encode("utf-8") + json.dumps([{"name": "z", "text": "t"}]).encode("utf-8"))
self.assertEqual(cs.load_outfit_prompts()[0]["name"], "z") self.assertEqual(cs.load_outfit_prompts()[0]["name"], "z")
# -- title_prompt.txt -----------------------------------------------
def test_title_prompt_default_when_missing(self):
self.assertEqual(cs.load_title_prompt(), cs.DEFAULT_TITLE_PROMPT)
def test_title_prompt_seeds_missing_user_file_from_factory_template(self):
self.factory_config_dir.mkdir(parents=True, exist_ok=True)
(self.factory_config_dir / "title_prompt.txt").write_text(
"factory title prompt", encoding="utf-8")
self.assertEqual(cs.load_title_prompt(), "factory title prompt")
self.assertEqual(
(self.config_dir / "title_prompt.txt").read_text(encoding="utf-8"),
"factory title prompt",
)
def test_title_prompt_does_not_overwrite_existing_user_file(self):
self.factory_config_dir.mkdir(parents=True, exist_ok=True)
(self.factory_config_dir / "title_prompt.txt").write_text(
"factory", encoding="utf-8")
(self.config_dir / "title_prompt.txt").write_text("user", encoding="utf-8")
self.assertEqual(cs.load_title_prompt(), "user")
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+18
View File
@@ -27,6 +27,10 @@ class _Base(unittest.TestCase):
(self.app / "config" / "templates.json").write_text("{}", encoding="utf-8") (self.app / "config" / "templates.json").write_text("{}", encoding="utf-8")
(self.app / "config" / "ai_models.json").write_text( (self.app / "config" / "ai_models.json").write_text(
json.dumps({"models": [{"name": "factory"}]}), encoding="utf-8") json.dumps({"models": [{"name": "factory"}]}), encoding="utf-8")
(self.app / "config" / "outfit_prompt.txt").write_text(
"factory outfit", encoding="utf-8")
(self.app / "config" / "title_prompt.txt").write_text(
"factory title", encoding="utf-8")
self.data = self.tmp / "data" self.data = self.tmp / "data"
self._prev = os.environ.get("CMBOT_DATA_DIR") self._prev = os.environ.get("CMBOT_DATA_DIR")
os.environ["CMBOT_DATA_DIR"] = str(self.data) os.environ["CMBOT_DATA_DIR"] = str(self.data)
@@ -51,17 +55,31 @@ class TestSeed(_Base):
self.assertTrue((self.data / "config" / "app_config.json").exists()) self.assertTrue((self.data / "config" / "app_config.json").exists())
self.assertTrue((self.data / "config" / "templates.json").exists()) self.assertTrue((self.data / "config" / "templates.json").exists())
self.assertTrue((self.data / "config" / "ai_models.json").exists()) self.assertTrue((self.data / "config" / "ai_models.json").exists())
self.assertTrue((self.data / "config" / "outfit_prompt.txt").exists())
self.assertTrue((self.data / "config" / "title_prompt.txt").exists())
def test_seed_does_not_overwrite(self): def test_seed_does_not_overwrite(self):
(self.data / "config").mkdir(parents=True) (self.data / "config").mkdir(parents=True)
(self.data / "config" / "app_config.json").write_text('{"update_source":"USER"}', encoding="utf-8") (self.data / "config" / "app_config.json").write_text('{"update_source":"USER"}', encoding="utf-8")
(self.data / "config" / "ai_models.json").write_text( (self.data / "config" / "ai_models.json").write_text(
json.dumps({"models": [{"name": "user"}]}), encoding="utf-8") json.dumps({"models": [{"name": "user"}]}), encoding="utf-8")
(self.data / "config" / "outfit_prompt.txt").write_text(
"user outfit", encoding="utf-8")
(self.data / "config" / "title_prompt.txt").write_text(
"user title", encoding="utf-8")
launcher.seed_defaults(self.app, self.data) launcher.seed_defaults(self.app, self.data)
kept = json.loads((self.data / "config" / "app_config.json").read_text(encoding="utf-8")) kept = json.loads((self.data / "config" / "app_config.json").read_text(encoding="utf-8"))
self.assertEqual(kept["update_source"], "USER") self.assertEqual(kept["update_source"], "USER")
kept_models = json.loads((self.data / "config" / "ai_models.json").read_text(encoding="utf-8")) kept_models = json.loads((self.data / "config" / "ai_models.json").read_text(encoding="utf-8"))
self.assertEqual(kept_models["models"][0]["name"], "user") self.assertEqual(kept_models["models"][0]["name"], "user")
self.assertEqual(
(self.data / "config" / "outfit_prompt.txt").read_text(encoding="utf-8"),
"user outfit",
)
self.assertEqual(
(self.data / "config" / "title_prompt.txt").read_text(encoding="utf-8"),
"user title",
)
class TestRun(_Base): class TestRun(_Base):