fix: seed outfit factory config
This commit is contained in:
@@ -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": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
为商品「{title}」生成人物上身实穿效果图:真人模特正面穿着这件衣服,完整保留款式、版型、颜色与印花图案,自然光、纯色棚拍背景,电商主图风格,不加文字与促销标签。
|
||||
@@ -0,0 +1 @@
|
||||
请生成 10 条适合台湾蝦皮电商的中文女装商品标题,突出卖点与适穿场景,每条控制在 30 字以内。标题之间用逗号「,」分隔,只输出标题本身,不要使用 Markdown 表格、不要换行、不要序号/編號/字元數、不要引号或表情。
|
||||
+7
-1
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 穿搭页通过
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user