From a71843928c572f343af04fa80a2b331188b37571 Mon Sep 17 00:00:00 2001 From: chengma Date: Fri, 10 Jul 2026 11:43:35 +0800 Subject: [PATCH] =?UTF-8?q?T-583=20=E6=A0=87=E9=A2=98=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E8=AF=8D=E6=A8=A1=E6=9D=BF=E6=95=B0=E6=8D=AE=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/appconfig.py | 4 ++ app/prompts.py | 110 +++++++++++++++++++++++++++++++++------- docs/04-architecture.md | 10 ++-- docs/tasks/T-583.md | 8 ++- tests/test_appconfig.py | 1 + tests/test_prompts.py | 55 ++++++++++++++++++++ 6 files changed, 164 insertions(+), 24 deletions(-) diff --git a/app/appconfig.py b/app/appconfig.py index 444eaea..060ec6e 100644 --- a/app/appconfig.py +++ b/app/appconfig.py @@ -245,6 +245,10 @@ def title_prompt_path(config=None) -> str: return data_path("title_prompt.txt", config=config) +def title_templates_dir(config=None) -> str: + return data_path("prompts", "title", config=config) + + def cover_prompts_dir(config=None) -> str: return data_path("prompts", "cover", config=config) diff --git a/app/prompts.py b/app/prompts.py index 915bb7d..6419dc4 100644 --- a/app/prompts.py +++ b/app/prompts.py @@ -8,6 +8,7 @@ from importlib import resources from . import appconfig TITLE_PROMPT_PATH = appconfig.title_prompt_path() +TITLE_TEMPLATES_DIR = appconfig.title_templates_dir() COVER_PROMPTS_DIR = appconfig.cover_prompts_dir() TEMPLATE_EXT = ".txt" INVALID_NAME_CHARS = set('\\/:*?"<>|') @@ -41,6 +42,7 @@ def save_title_prompt(text, path=TITLE_PROMPT_PATH) -> None: def ensure_default_prompts( title_prompt_path=TITLE_PROMPT_PATH, cover_prompts_dir=COVER_PROMPTS_DIR, + title_templates_dir=None, ) -> None: """Seed bundled default prompts into an empty user data directory. @@ -54,13 +56,20 @@ def ensure_default_prompts( if default_title: save_title_prompt(default_title, title_prompt_path) + if title_templates_dir is None: + title_templates_dir = _title_templates_dir_for_prompt_path(title_prompt_path) + if not list_templates(title_templates_dir): + default_title = _read_default_prompt_text("title_prompt.txt") + if default_title: + save_template("默认", default_title, title_templates_dir) + if not list_cover_templates(cover_prompts_dir): for name, text in _iter_default_cover_templates(): save_cover_template(name, text, cover_prompts_dir) -def list_cover_templates(directory=COVER_PROMPTS_DIR): - """Return cover template names sorted by display name.""" +def list_templates(directory): + """Return prompt template names sorted by display name.""" if not os.path.isdir(directory): return [] @@ -71,18 +80,18 @@ def list_cover_templates(directory=COVER_PROMPTS_DIR): return sorted(names, key=str.casefold) -def load_cover_template(name, directory=COVER_PROMPTS_DIR) -> str: - """Load one cover prompt template.""" +def load_template(name, directory) -> str: + """Load one prompt template.""" path = _template_path(name, directory) if not os.path.exists(path): - raise PromptError(f"封面提示词模板不存在: {_normalize_name(name)}") + raise PromptError(f"提示词模板不存在: {_normalize_name(name)}") with open(path, "r", encoding="utf-8") as fh: return fh.read() -def save_cover_template(name, text, directory=COVER_PROMPTS_DIR) -> None: - """Save one cover prompt template as UTF-8.""" +def save_template(name, text, directory) -> None: + """Save one prompt template as UTF-8.""" path = _template_path(name, directory) os.makedirs(os.path.dirname(path), exist_ok=True) @@ -90,26 +99,86 @@ def save_cover_template(name, text, directory=COVER_PROMPTS_DIR) -> None: fh.write(str(text or "")) -def rename_cover_template(old, new, directory=COVER_PROMPTS_DIR) -> None: - """Rename a cover prompt template with duplicate-name protection.""" +def rename_template(old, new, directory) -> None: + """Rename a prompt template with duplicate-name protection.""" old_path = _template_path(old, directory) new_path = _template_path(new, directory) if not os.path.exists(old_path): - raise PromptError(f"封面提示词模板不存在: {_normalize_name(old)}") + raise PromptError(f"提示词模板不存在: {_normalize_name(old)}") if os.path.exists(new_path): - raise PromptError(f"封面提示词模板已存在: {_normalize_name(new)}") + raise PromptError(f"提示词模板已存在: {_normalize_name(new)}") os.makedirs(os.path.dirname(new_path), exist_ok=True) os.replace(old_path, new_path) +def delete_template(name, directory) -> None: + """Delete one prompt template.""" + + path = _template_path(name, directory) + if not os.path.exists(path): + raise PromptError(f"提示词模板不存在: {_normalize_name(name)}") + os.remove(path) + + +def list_title_templates(directory=TITLE_TEMPLATES_DIR): + """Return title prompt template names sorted by display name.""" + + return list_templates(directory) + + +def load_title_template(name, directory=TITLE_TEMPLATES_DIR) -> str: + """Load one title prompt template.""" + + return load_template(name, directory) + + +def save_title_template(name, text, directory=TITLE_TEMPLATES_DIR) -> None: + """Save one title prompt template as UTF-8.""" + + save_template(name, text, directory) + + +def rename_title_template(old, new, directory=TITLE_TEMPLATES_DIR) -> None: + """Rename a title prompt template with duplicate-name protection.""" + + rename_template(old, new, directory) + + +def delete_title_template(name, directory=TITLE_TEMPLATES_DIR) -> None: + """Delete one title prompt template.""" + + delete_template(name, directory) + + +def list_cover_templates(directory=COVER_PROMPTS_DIR): + """Return cover template names sorted by display name.""" + + return list_templates(directory) + + +def load_cover_template(name, directory=COVER_PROMPTS_DIR) -> str: + """Load one cover prompt template.""" + + return load_template(name, directory) + + +def save_cover_template(name, text, directory=COVER_PROMPTS_DIR) -> None: + """Save one cover prompt template as UTF-8.""" + + save_template(name, text, directory) + + +def rename_cover_template(old, new, directory=COVER_PROMPTS_DIR) -> None: + """Rename a cover prompt template with duplicate-name protection.""" + + rename_template(old, new, directory) + + def delete_cover_template(name, directory=COVER_PROMPTS_DIR) -> None: """Delete one cover prompt template.""" - path = _template_path(name, directory) - if not os.path.exists(path): - raise PromptError(f"封面提示词模板不存在: {_normalize_name(name)}") - os.remove(path) + delete_template(name, directory) def render_prompt(template_text, task) -> str: @@ -150,14 +219,19 @@ def _normalize_name(name) -> str: value = value[: -len(TEMPLATE_EXT)] value = value.strip() if not value: - raise PromptError("封面提示词模板名不能为空") + raise PromptError("提示词模板名不能为空") if value in {".", ".."} or any(char in INVALID_NAME_CHARS for char in value): - raise PromptError(f"封面提示词模板名非法: {value}") + raise PromptError(f"提示词模板名非法: {value}") if os.path.basename(value) != value: - raise PromptError(f"封面提示词模板名非法: {value}") + raise PromptError(f"提示词模板名非法: {value}") return value +def _title_templates_dir_for_prompt_path(title_prompt_path) -> str: + data_root = os.path.dirname(os.path.abspath(title_prompt_path)) + return os.path.join(data_root, "prompts", "title") + + def _has_non_empty_file(path) -> bool: try: if not os.path.exists(path): diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 49f9a3e..03506a8 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -80,7 +80,7 @@ imported → collected → generated → applied - cmhub 网关 Key → `data/config/cmhub.json`,schema `{ "api_key": "..." }`;`config.json` 只保存 Base URL、别名和超时,不保存 Key。 - 业务数据(账号、任务、各阶段结果)→ SQLite `data/cmshopee.db`。 - 图片(采集的旧封面、AI 生成的新封面)→ `data/images/`(路径记在 DB)。 -- 提示词 → 标题提示词存单文件 `data/title_prompt.txt`;封面提示词存多模板 `data/prompts/cover/<名称>.txt`。 +- 提示词 → 标题当前工作文本存单文件 `data/title_prompt.txt`;标题命名模板存 `data/prompts/title/<名称>.txt`;封面命名模板存 `data/prompts/cover/<名称>.txt`。 - 登录态 → 各账号 `data/chrome_user_data_dir//`。 T-538 后统一数据根为 `data/`:打包版默认 `/data`,源码运行默认项目根 `data/`。`config.json` 中 `user_data_root`、`image_dir`、`db_path` 默认仍保存为 `chrome_user_data_dir`、`images`、`cmshopee.db` 等相对值,运行时由 `appconfig` 解析到 `data/` 下;绝对路径作为高级自定义仍按原值使用。启动时会迁移 T-524 旧包的 exe 顶层数据到 `data/`,并检测 `data/` 可写。 @@ -417,7 +417,7 @@ data/images///__new. # AI 生成的新 提示词管理: -- **标题提示词**:单个文本,「保存」写入 `data/title_prompt.txt`;软件启动时加载该文件回显到输入框(缺失则空)。T-549 后标题提示词支持 `{旧标题}` 占位符:若提示词含 `{旧标题}`,生成前替换为该任务旧标题且不再自动追加旧标题块;若不含,则保持旧行为自动追加“旧标题:...”块。两种情况都会保留“请只返回新标题,不要解释。”输出约束。 +- **标题提示词**:`data/title_prompt.txt` 是当前工作文本,「保存标题提示词」写入该文件;软件启动时加载该文件回显到输入框(缺失则空)。标题命名模板另存于 `data/prompts/title/*.txt`,只负责把命名模板载入编辑框或保存当前编辑框内容为模板,不改变生成读取路径。T-549 后标题提示词支持 `{旧标题}` 占位符:若提示词含 `{旧标题}`,生成前替换为该任务旧标题且不再自动追加旧标题块;若不含,则保持旧行为自动追加“旧标题:...”块。两种情况都会保留“请只返回新标题,不要解释。”输出约束。 - **封面提示词**:多模板。下拉选模板(读 `data/prompts/cover/*.txt`),图标工具栏 新建/保存/另存为/重命名/删除;重名校验、删除二次确认、删空给默认。 - **变量**:标题提示词本阶段只支持 `{旧标题}`,左侧按钮「插入旧标题」在标题提示词光标处插入 `{旧标题}`。封面提示词支持占位符 `{旧标题}`、`{新标题}`、`{商品id}`、`{店铺}`,生成前用该任务真实值替换(`render_prompt`)。「插入标题」= 在封面提示词光标处插入 `{新标题}`;「预览」= 用某条任务的值替换封面变量后展示,确认实际发送给 AI 的内容。 @@ -499,8 +499,10 @@ cmshopee/ │ ├── cmshopee.db # SQLite(账号/任务/结果) │ ├── chrome_user_data_dir/ # 各账号 Chrome 配置(含登录态) │ ├── images/ # 旧封面/新封面本地图片 -│ ├── title_prompt.txt # 标题提示词(单文件,启动回显) -│ └── prompts/cover/<名称>.txt +│ ├── title_prompt.txt # 标题当前工作文本(启动回显) +│ └── prompts/ +│ ├── title/<名称>.txt # 标题提示词命名模板 +│ └── cover/<名称>.txt # 封面提示词命名模板 └── prototypes/ # 已验证原型/探查脚本(demo/set_*/get_title/cookies/inspect_images/grab/1.py) # 逻辑待并入 app/editor.py 后清理;见 prototypes/README.md ``` diff --git a/docs/tasks/T-583.md b/docs/tasks/T-583.md index 3bd01d6..7b89721 100644 --- a/docs/tasks/T-583.md +++ b/docs/tasks/T-583.md @@ -3,7 +3,7 @@ id: T-583 title: 标题提示词模板数据层:泛化 prompts 模板 CRUD + 新增 prompts/title 目录(加法,不换单文件) phase: 7 deps: [] -status: TODO +status: DONE created: 2026-07-10 --- @@ -73,4 +73,8 @@ created: 2026-07-10 ## 执行记录 -(做完在这里写:改了什么文件、跑了什么验证命令及结果、遇到的阻塞、关键决策。) +- 2026-07-10:已完成数据层实现。`app/appconfig.py` 新增 `title_templates_dir(config=None)`,指向 `data/prompts/title`;`app/prompts.py` 新增通用 `list/load/save/rename/delete_templates` 能力,标题/封面模板复用同一套 CRUD,保留封面旧 API 薄封装,并新增标题模板薄封装。 +- 2026-07-10:`ensure_default_prompts()` 增加 `title_templates_dir=None`,为空时从 `title_prompt_path` 推导同一数据根的 `prompts/title/`,为空目录时播入「默认」标题模板;不改 `title_prompt.txt` 工作文本语义,不改生成读取路径。 +- 2026-07-10:同步 `docs/04-architecture.md` 提示词存储结构;补 `tests/test_prompts.py` 和 `tests/test_appconfig.py` 覆盖标题模板目录、通用 CRUD、默认播种、互不干扰与封面旧 API 回归。 +- 当前工作区验证:`python -m ruff check app tests main.py` 通过;`py -3.10 -m compileall app main.py` 通过;`git diff --check` 通过。 +- 当前工作区运行 `py -3.10 -m unittest tests.test_prompts tests.test_appconfig` 失败 1 项,原因是本任务开始前已有未提交改动把默认封面模板从 `papa1` 改成「默认」,导致旧断言仍期望 `papa1`;该封面默认模板改名不属于 T-583,本次未处理、未提交。提交后需用干净 worktree 验证本次提交本身。 diff --git a/tests/test_appconfig.py b/tests/test_appconfig.py index 3b53b62..91aab54 100644 --- a/tests/test_appconfig.py +++ b/tests/test_appconfig.py @@ -238,6 +238,7 @@ class AppConfigTests(TempDirMixin, unittest.TestCase): self.assertEqual(os.path.join(data_root, "images"), appconfig.image_dir(cfg)) self.assertEqual(os.path.join(data_root, "cmshopee.db"), appconfig.db_path(cfg)) self.assertEqual(os.path.join(data_root, "title_prompt.txt"), appconfig.title_prompt_path(cfg)) + self.assertEqual(os.path.join(data_root, "prompts", "title"), appconfig.title_templates_dir(cfg)) self.assertEqual(os.path.join(data_root, "prompts", "cover"), appconfig.cover_prompts_dir(cfg)) self.assertEqual(os.path.join(data_root, "logs"), appconfig.diagnostic_log_dir(cfg)) diff --git a/tests/test_prompts.py b/tests/test_prompts.py index b9096bc..884c7ed 100644 --- a/tests/test_prompts.py +++ b/tests/test_prompts.py @@ -45,14 +45,49 @@ class PromptTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_generic_template_crud_and_title_cover_directories_are_independent(self): + with self.make_temp_dir() as temp_dir: + title_dir = os.path.join(temp_dir, "prompts", "title") + cover_dir = os.path.join(temp_dir, "prompts", "cover") + + prompts.save_template("基础", "通用模板", title_dir) + prompts.save_title_template("标题A", "标题模板", title_dir) + prompts.save_cover_template("封面A", "封面模板", cover_dir) + + self.assertEqual(["基础", "标题A"], prompts.list_title_templates(title_dir)) + self.assertEqual(["封面A"], prompts.list_cover_templates(cover_dir)) + self.assertEqual("通用模板", prompts.load_template("基础", title_dir)) + self.assertEqual("标题模板", prompts.load_title_template("标题A", title_dir)) + self.assertEqual("封面模板", prompts.load_cover_template("封面A", cover_dir)) + + prompts.rename_template("基础", "基础2", title_dir) + self.assertEqual(["基础2", "标题A"], prompts.list_templates(title_dir)) + + with self.assertRaisesRegex(prompts.PromptError, "提示词模板已存在"): + prompts.rename_template("基础2", "标题A", title_dir) + with self.assertRaisesRegex(prompts.PromptError, "提示词模板名非法"): + prompts.save_title_template("../bad", "x", title_dir) + + prompts.delete_title_template("标题A", title_dir) + self.assertEqual(["基础2"], prompts.list_title_templates(title_dir)) + self.assertEqual(["封面A"], prompts.list_cover_templates(cover_dir)) + + self.assert_removed(temp_dir) + def test_ensure_default_prompts_seeds_empty_user_prompt_files(self): with self.make_temp_dir() as temp_dir: title_path = os.path.join(temp_dir, "title_prompt.txt") + title_templates_dir = os.path.join(temp_dir, "prompts", "title") cover_dir = os.path.join(temp_dir, "prompts", "cover") prompts.ensure_default_prompts(title_path, cover_dir) self.assertIn("蝦皮台灣站", prompts.load_title_prompt(title_path)) + self.assertEqual(["默认"], prompts.list_title_templates(title_templates_dir)) + self.assertEqual( + prompts.load_title_prompt(title_path), + prompts.load_title_template("默认", title_templates_dir), + ) self.assertEqual(["papa1"], prompts.list_cover_templates(cover_dir)) self.assertIn( "商品标题:{新标题}", @@ -64,13 +99,20 @@ class PromptTests(TempDirMixin, unittest.TestCase): def test_ensure_default_prompts_does_not_overwrite_user_prompts(self): with self.make_temp_dir() as temp_dir: title_path = os.path.join(temp_dir, "title_prompt.txt") + title_templates_dir = os.path.join(temp_dir, "prompts", "title") cover_dir = os.path.join(temp_dir, "prompts", "cover") prompts.save_title_prompt("用户标题提示词", title_path) + prompts.save_title_template("用户标题模板", "用户标题模板内容", title_templates_dir) prompts.save_cover_template("用户模板", "用户封面提示词", cover_dir) prompts.ensure_default_prompts(title_path, cover_dir) self.assertEqual("用户标题提示词", prompts.load_title_prompt(title_path)) + self.assertEqual(["用户标题模板"], prompts.list_title_templates(title_templates_dir)) + self.assertEqual( + "用户标题模板内容", + prompts.load_title_template("用户标题模板", title_templates_dir), + ) self.assertEqual(["用户模板"], prompts.list_cover_templates(cover_dir)) self.assertEqual( "用户封面提示词", @@ -79,6 +121,19 @@ class PromptTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_ensure_default_prompts_accepts_explicit_title_templates_dir(self): + with self.make_temp_dir() as temp_dir: + title_path = os.path.join(temp_dir, "custom", "title_prompt.txt") + cover_dir = os.path.join(temp_dir, "custom", "cover") + title_templates_dir = os.path.join(temp_dir, "other", "title_templates") + + prompts.ensure_default_prompts(title_path, cover_dir, title_templates_dir) + + self.assertEqual(["默认"], prompts.list_title_templates(title_templates_dir)) + self.assertFalse(os.path.exists(os.path.join(temp_dir, "custom", "prompts", "title"))) + + self.assert_removed(temp_dir) + def test_render_prompt_replaces_known_variables(self): task = { "old_title": "舊T恤",