T-583 标题提示词模板数据层

This commit is contained in:
chengma
2026-07-10 11:43:35 +08:00
parent bd56a6cbf7
commit a71843928c
6 changed files with 164 additions and 24 deletions
+4
View File
@@ -245,6 +245,10 @@ def title_prompt_path(config=None) -> str:
return data_path("title_prompt.txt", config=config) 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: def cover_prompts_dir(config=None) -> str:
return data_path("prompts", "cover", config=config) return data_path("prompts", "cover", config=config)
+92 -18
View File
@@ -8,6 +8,7 @@ from importlib import resources
from . import appconfig from . import appconfig
TITLE_PROMPT_PATH = appconfig.title_prompt_path() TITLE_PROMPT_PATH = appconfig.title_prompt_path()
TITLE_TEMPLATES_DIR = appconfig.title_templates_dir()
COVER_PROMPTS_DIR = appconfig.cover_prompts_dir() COVER_PROMPTS_DIR = appconfig.cover_prompts_dir()
TEMPLATE_EXT = ".txt" TEMPLATE_EXT = ".txt"
INVALID_NAME_CHARS = set('\\/:*?"<>|') INVALID_NAME_CHARS = set('\\/:*?"<>|')
@@ -41,6 +42,7 @@ def save_title_prompt(text, path=TITLE_PROMPT_PATH) -> None:
def ensure_default_prompts( def ensure_default_prompts(
title_prompt_path=TITLE_PROMPT_PATH, title_prompt_path=TITLE_PROMPT_PATH,
cover_prompts_dir=COVER_PROMPTS_DIR, cover_prompts_dir=COVER_PROMPTS_DIR,
title_templates_dir=None,
) -> None: ) -> None:
"""Seed bundled default prompts into an empty user data directory. """Seed bundled default prompts into an empty user data directory.
@@ -54,13 +56,20 @@ def ensure_default_prompts(
if default_title: if default_title:
save_title_prompt(default_title, title_prompt_path) 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): if not list_cover_templates(cover_prompts_dir):
for name, text in _iter_default_cover_templates(): for name, text in _iter_default_cover_templates():
save_cover_template(name, text, cover_prompts_dir) save_cover_template(name, text, cover_prompts_dir)
def list_cover_templates(directory=COVER_PROMPTS_DIR): def list_templates(directory):
"""Return cover template names sorted by display name.""" """Return prompt template names sorted by display name."""
if not os.path.isdir(directory): if not os.path.isdir(directory):
return [] return []
@@ -71,18 +80,18 @@ def list_cover_templates(directory=COVER_PROMPTS_DIR):
return sorted(names, key=str.casefold) return sorted(names, key=str.casefold)
def load_cover_template(name, directory=COVER_PROMPTS_DIR) -> str: def load_template(name, directory) -> str:
"""Load one cover prompt template.""" """Load one prompt template."""
path = _template_path(name, directory) path = _template_path(name, directory)
if not os.path.exists(path): 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: with open(path, "r", encoding="utf-8") as fh:
return fh.read() return fh.read()
def save_cover_template(name, text, directory=COVER_PROMPTS_DIR) -> None: def save_template(name, text, directory) -> None:
"""Save one cover prompt template as UTF-8.""" """Save one prompt template as UTF-8."""
path = _template_path(name, directory) path = _template_path(name, directory)
os.makedirs(os.path.dirname(path), exist_ok=True) 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 "")) fh.write(str(text or ""))
def rename_cover_template(old, new, directory=COVER_PROMPTS_DIR) -> None: def rename_template(old, new, directory) -> None:
"""Rename a cover prompt template with duplicate-name protection.""" """Rename a prompt template with duplicate-name protection."""
old_path = _template_path(old, directory) old_path = _template_path(old, directory)
new_path = _template_path(new, directory) new_path = _template_path(new, directory)
if not os.path.exists(old_path): 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): 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.makedirs(os.path.dirname(new_path), exist_ok=True)
os.replace(old_path, new_path) 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: def delete_cover_template(name, directory=COVER_PROMPTS_DIR) -> None:
"""Delete one cover prompt template.""" """Delete one cover prompt template."""
path = _template_path(name, directory) delete_template(name, directory)
if not os.path.exists(path):
raise PromptError(f"封面提示词模板不存在: {_normalize_name(name)}")
os.remove(path)
def render_prompt(template_text, task) -> str: def render_prompt(template_text, task) -> str:
@@ -150,14 +219,19 @@ def _normalize_name(name) -> str:
value = value[: -len(TEMPLATE_EXT)] value = value[: -len(TEMPLATE_EXT)]
value = value.strip() value = value.strip()
if not value: if not value:
raise PromptError("封面提示词模板名不能为空") raise PromptError("提示词模板名不能为空")
if value in {".", ".."} or any(char in INVALID_NAME_CHARS for char in value): 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: if os.path.basename(value) != value:
raise PromptError(f"封面提示词模板名非法: {value}") raise PromptError(f"提示词模板名非法: {value}")
return 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: def _has_non_empty_file(path) -> bool:
try: try:
if not os.path.exists(path): if not os.path.exists(path):
+6 -4
View File
@@ -80,7 +80,7 @@ imported → collected → generated → applied
- cmhub 网关 Key → `data/config/cmhub.json`,schema `{ "api_key": "..." }`;`config.json` 只保存 Base URL、别名和超时,不保存 Key。 - cmhub 网关 Key → `data/config/cmhub.json`,schema `{ "api_key": "..." }`;`config.json` 只保存 Base URL、别名和超时,不保存 Key。
- 业务数据(账号、任务、各阶段结果)→ SQLite `data/cmshopee.db`。 - 业务数据(账号、任务、各阶段结果)→ SQLite `data/cmshopee.db`。
- 图片(采集的旧封面、AI 生成的新封面)→ `data/images/`(路径记在 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/<slug>/`。 - 登录态 → 各账号 `data/chrome_user_data_dir/<slug>/`。
T-538 后统一数据根为 `data/`:打包版默认 `<exe目录>/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/` 可写。 T-538 后统一数据根为 `data/`:打包版默认 `<exe目录>/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/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # AI 生成的新
提示词管理: 提示词管理:
- **标题提示词**:单个文本,「保存」写入 `data/title_prompt.txt`;软件启动时加载该文件回显到输入框(缺失则空)。T-549 后标题提示词支持 `{旧标题}` 占位符:若提示词含 `{旧标题}`,生成前替换为该任务旧标题且不再自动追加旧标题块;若不含,则保持旧行为自动追加“旧标题:...”块。两种情况都会保留“请只返回新标题,不要解释。”输出约束。 - **标题提示词**:`data/title_prompt.txt` 是当前工作文本,「保存标题提示词」写入该文件;软件启动时加载该文件回显到输入框(缺失则空)。标题命名模板另存于 `data/prompts/title/*.txt`,只负责把命名模板载入编辑框或保存当前编辑框内容为模板,不改变生成读取路径。T-549 后标题提示词支持 `{旧标题}` 占位符:若提示词含 `{旧标题}`,生成前替换为该任务旧标题且不再自动追加旧标题块;若不含,则保持旧行为自动追加“旧标题:...”块。两种情况都会保留“请只返回新标题,不要解释。”输出约束。
- **封面提示词**:多模板。下拉选模板(读 `data/prompts/cover/*.txt`),图标工具栏 新建/保存/另存为/重命名/删除;重名校验、删除二次确认、删空给默认。 - **封面提示词**:多模板。下拉选模板(读 `data/prompts/cover/*.txt`),图标工具栏 新建/保存/另存为/重命名/删除;重名校验、删除二次确认、删空给默认。
- **变量**:标题提示词本阶段只支持 `{旧标题}`,左侧按钮「插入旧标题」在标题提示词光标处插入 `{旧标题}`。封面提示词支持占位符 `{旧标题}`、`{新标题}`、`{商品id}`、`{店铺}`,生成前用该任务真实值替换(`render_prompt`)。「插入标题」= 在封面提示词光标处插入 `{新标题}`;「预览」= 用某条任务的值替换封面变量后展示,确认实际发送给 AI 的内容。 - **变量**:标题提示词本阶段只支持 `{旧标题}`,左侧按钮「插入旧标题」在标题提示词光标处插入 `{旧标题}`。封面提示词支持占位符 `{旧标题}`、`{新标题}`、`{商品id}`、`{店铺}`,生成前用该任务真实值替换(`render_prompt`)。「插入标题」= 在封面提示词光标处插入 `{新标题}`;「预览」= 用某条任务的值替换封面变量后展示,确认实际发送给 AI 的内容。
@@ -499,8 +499,10 @@ cmshopee/
│ ├── cmshopee.db # SQLite(账号/任务/结果) │ ├── cmshopee.db # SQLite(账号/任务/结果)
│ ├── chrome_user_data_dir/ # 各账号 Chrome 配置(含登录态) │ ├── chrome_user_data_dir/ # 各账号 Chrome 配置(含登录态)
│ ├── images/ # 旧封面/新封面本地图片 │ ├── images/ # 旧封面/新封面本地图片
│ ├── title_prompt.txt # 标题提示词(单文件,启动回显) │ ├── title_prompt.txt # 标题当前工作文本(启动回显)
│ └── prompts/cover/<名称>.txt │ └── prompts/
│ ├── title/<名称>.txt # 标题提示词命名模板
│ └── cover/<名称>.txt # 封面提示词命名模板
└── prototypes/ # 已验证原型/探查脚本(demo/set_*/get_title/cookies/inspect_images/grab/1.py) └── prototypes/ # 已验证原型/探查脚本(demo/set_*/get_title/cookies/inspect_images/grab/1.py)
# 逻辑待并入 app/editor.py 后清理;见 prototypes/README.md # 逻辑待并入 app/editor.py 后清理;见 prototypes/README.md
``` ```
+6 -2
View File
@@ -3,7 +3,7 @@ id: T-583
title: 标题提示词模板数据层:泛化 prompts 模板 CRUD + 新增 prompts/title 目录(加法,不换单文件) title: 标题提示词模板数据层:泛化 prompts 模板 CRUD + 新增 prompts/title 目录(加法,不换单文件)
phase: 7 phase: 7
deps: [] deps: []
status: TODO status: DONE
created: 2026-07-10 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 验证本次提交本身。
+1
View File
@@ -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, "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, "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, "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, "prompts", "cover"), appconfig.cover_prompts_dir(cfg))
self.assertEqual(os.path.join(data_root, "logs"), appconfig.diagnostic_log_dir(cfg)) self.assertEqual(os.path.join(data_root, "logs"), appconfig.diagnostic_log_dir(cfg))
+55
View File
@@ -45,14 +45,49 @@ class PromptTests(TempDirMixin, unittest.TestCase):
self.assert_removed(temp_dir) 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): def test_ensure_default_prompts_seeds_empty_user_prompt_files(self):
with self.make_temp_dir() as temp_dir: with self.make_temp_dir() as temp_dir:
title_path = os.path.join(temp_dir, "title_prompt.txt") 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") cover_dir = os.path.join(temp_dir, "prompts", "cover")
prompts.ensure_default_prompts(title_path, cover_dir) prompts.ensure_default_prompts(title_path, cover_dir)
self.assertIn("蝦皮台灣站", prompts.load_title_prompt(title_path)) 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.assertEqual(["papa1"], prompts.list_cover_templates(cover_dir))
self.assertIn( self.assertIn(
"商品标题:{新标题}", "商品标题:{新标题}",
@@ -64,13 +99,20 @@ class PromptTests(TempDirMixin, unittest.TestCase):
def test_ensure_default_prompts_does_not_overwrite_user_prompts(self): def test_ensure_default_prompts_does_not_overwrite_user_prompts(self):
with self.make_temp_dir() as temp_dir: with self.make_temp_dir() as temp_dir:
title_path = os.path.join(temp_dir, "title_prompt.txt") 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") cover_dir = os.path.join(temp_dir, "prompts", "cover")
prompts.save_title_prompt("用户标题提示词", title_path) prompts.save_title_prompt("用户标题提示词", title_path)
prompts.save_title_template("用户标题模板", "用户标题模板内容", title_templates_dir)
prompts.save_cover_template("用户模板", "用户封面提示词", cover_dir) prompts.save_cover_template("用户模板", "用户封面提示词", cover_dir)
prompts.ensure_default_prompts(title_path, cover_dir) prompts.ensure_default_prompts(title_path, cover_dir)
self.assertEqual("用户标题提示词", prompts.load_title_prompt(title_path)) 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(["用户模板"], prompts.list_cover_templates(cover_dir))
self.assertEqual( self.assertEqual(
"用户封面提示词", "用户封面提示词",
@@ -79,6 +121,19 @@ class PromptTests(TempDirMixin, unittest.TestCase):
self.assert_removed(temp_dir) 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): def test_render_prompt_replaces_known_variables(self):
task = { task = {
"old_title": "舊T恤", "old_title": "舊T恤",