diff --git a/packaging/default_config/ai_models.json b/packaging/default_config/ai_models.json index 7388e32..2a93a89 100644 --- a/packaging/default_config/ai_models.json +++ b/packaging/default_config/ai_models.json @@ -19,6 +19,16 @@ "timeout_seconds": 0, "connect_timeout_seconds": 30, "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": {} } ] } diff --git a/packaging/default_config/outfit_prompt.txt b/packaging/default_config/outfit_prompt.txt new file mode 100644 index 0000000..81e40ab --- /dev/null +++ b/packaging/default_config/outfit_prompt.txt @@ -0,0 +1 @@ +为商品「{title}」生成人物上身实穿效果图:真人模特正面穿着这件衣服,完整保留款式、版型、颜色与印花图案,自然光、纯色棚拍背景,电商主图风格,不加文字与促销标签。 \ No newline at end of file diff --git a/packaging/default_config/title_prompt.txt b/packaging/default_config/title_prompt.txt new file mode 100644 index 0000000..19c9490 --- /dev/null +++ b/packaging/default_config/title_prompt.txt @@ -0,0 +1 @@ +请生成 10 条适合台湾蝦皮电商的中文女装商品标题,突出卖点与适穿场景,每条控制在 30 字以内。标题之间用逗号「,」分隔,只输出标题本身,不要使用 Markdown 表格、不要换行、不要序号/編號/字元數、不要引号或表情。 diff --git a/src/launcher.py b/src/launcher.py index 2dbfcf1..8bd425c 100644 --- a/src/launcher.py +++ b/src/launcher.py @@ -26,7 +26,13 @@ from services import installer # noqa: E402 from services.file_service import get_data_dir # noqa: E402 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") diff --git a/src/services/config_service.py b/src/services/config_service.py index cf95cfa..ec69048 100644 --- a/src/services/config_service.py +++ b/src/services/config_service.py @@ -130,6 +130,88 @@ def _seed_factory_config_if_missing(filename): 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(): """Load AI model configs from ai_models.json. @@ -140,17 +222,17 @@ def load_ai_models(): from services.file_service import get_config_path _seed_factory_config_if_missing(_AI_MODELS_FILENAME) models_file = get_config_path(_AI_MODELS_FILENAME) + _append_missing_title_model(models_file) if not models_file.exists(): logger.info("AI models file not found: %s", models_file) return [] try: - with open(str(models_file), encoding="utf-8-sig") as f: - data = json.load(f) + 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 [] - models = data.get("models") if isinstance(data, dict) else data + models = _model_list_from_data(data) if not isinstance(models, list): logger.warning("AI models file has no model list: %s", models_file) return [] @@ -160,6 +242,7 @@ def load_ai_models(): def load_outfit_prompt(): """Return the saved outfit prompt template, or the built-in default.""" from services.file_service import get_config_path + _seed_factory_config_if_missing(_OUTFIT_PROMPT_FILENAME) prompt_file = get_config_path(_OUTFIT_PROMPT_FILENAME) if not prompt_file.exists(): return DEFAULT_OUTFIT_PROMPT @@ -187,6 +270,7 @@ def save_outfit_prompt(text): def load_title_prompt(): """Return the saved 标题生成提示词, or the built-in default (docs/11 §17.3).""" from services.file_service import get_config_path + _seed_factory_config_if_missing(_TITLE_PROMPT_FILENAME) prompt_file = get_config_path(_TITLE_PROMPT_FILENAME) if not prompt_file.exists(): return DEFAULT_TITLE_PROMPT diff --git a/tasks.md b/tasks.md index 9b999db..45bdeb6 100644 --- a/tasks.md +++ b/tasks.md @@ -1514,10 +1514,10 @@ 任务: - [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` 预期 -- [ ] `packaging/default_config/outfit_prompt.txt` / `title_prompt.txt` 纳入发布包默认配置 -- [ ] `src/launcher.py` `CONFIG_FILES` 加入 `outfit_prompt.txt` / `title_prompt.txt`,首次运行播种 -- [ ] `config_service`:`load_outfit_prompt` / `load_title_prompt` 读取前调用运行时兜底复制;用户文件存在不覆盖 -- [ ] `config_service`:`load_ai_models` 在用户文件存在但缺 `app_config.title_model` 时,从 factory `ai_models.json` 追加同名模型;不覆盖同名模型、不改 `api_key`;factory 无同名只记录日志 -- [ ] 测试:缺 prompt 文件时从 factory 复制;已有 prompt 不覆盖;已有 `ai_models.json` 缺标题模型时追加;已有同名标题模型不重复;factory 无同名不报错 -- [ ] 验证:相关单测、全套测试、离屏启动 AI 穿搭页 +- [x] `packaging/default_config/ai_models.json` 加入 title model 模板(`api_key` 留空,不提交真实 key),并保持图片模型顺序符合 `tests/test_config_service.py` 预期 +- [x] `packaging/default_config/outfit_prompt.txt` / `title_prompt.txt` 纳入发布包默认配置 +- [x] `src/launcher.py` `CONFIG_FILES` 加入 `outfit_prompt.txt` / `title_prompt.txt`,首次运行播种 +- [x] `config_service`:`load_outfit_prompt` / `load_title_prompt` 读取前调用运行时兜底复制;用户文件存在不覆盖 +- [x] `config_service`:`load_ai_models` 在用户文件存在但缺 `app_config.title_model` 时,从 factory `ai_models.json` 追加同名模型;不覆盖同名模型、不改 `api_key`;factory 无同名只记录日志 +- [x] 测试:缺 prompt 文件时从 factory 复制;已有 prompt 不覆盖;已有 `ai_models.json` 缺标题模型时追加;已有同名标题模型不重复;factory 无同名不报错 +- [x] 验证:`py_compile`、`test_config_service.py`、`test_launcher.py`、全套 `python -m unittest discover -s tests`、离屏启动主窗口并切到 AI 穿搭页通过 diff --git a/tests/test_config_service.py b/tests/test_config_service.py index 96c05b3..f47f525 100644 --- a/tests/test_config_service.py +++ b/tests/test_config_service.py @@ -66,7 +66,10 @@ class TestOutfitConfigHelpers(unittest.TestCase): 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)) 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.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 ---------------------------------------------- def test_prompt_default_when_missing(self): 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): cs.save_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")) 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__": unittest.main() diff --git a/tests/test_launcher.py b/tests/test_launcher.py index b0ae750..23c2545 100644 --- a/tests/test_launcher.py +++ b/tests/test_launcher.py @@ -27,6 +27,10 @@ class _Base(unittest.TestCase): (self.app / "config" / "templates.json").write_text("{}", encoding="utf-8") (self.app / "config" / "ai_models.json").write_text( 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._prev = os.environ.get("CMBOT_DATA_DIR") 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" / "templates.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): (self.data / "config").mkdir(parents=True) (self.data / "config" / "app_config.json").write_text('{"update_source":"USER"}', encoding="utf-8") (self.data / "config" / "ai_models.json").write_text( 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) kept = json.loads((self.data / "config" / "app_config.json").read_text(encoding="utf-8")) self.assertEqual(kept["update_source"], "USER") 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( + (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):