feat(mirror): 章节镜像文件机制(2026-09-16用户裁定'章节写文件,用户参与度高发现问题及时修复')——bid_chapter_mirror.py:write_chapter(replace/append)+fill_material_gap后自动镜像{项目根}/chapters/{order:03d}_{章节号}_{标题}.md,配图本地化chapters/media/同目录(网关URL/本地路径映射复制,远端http保留),头部注记库内状态/版本;库仍是机制正本(评审/合成读库),镜像失败不阻塞;标题改名清孤儿文件;幂等覆盖
This commit is contained in:
parent
ac1b029e24
commit
598578d288
136
pipeline_bidding/bid_chapter_mirror.py
Normal file
136
pipeline_bidding/bid_chapter_mirror.py
Normal file
@ -0,0 +1,136 @@
|
||||
"""章节镜像文件(2026-09-16 用户裁定:章节写文件,用户参与度高,看文件即时发现问题)。
|
||||
|
||||
规则(用户定):
|
||||
- 章节正文镜像为 md 文件,按章节命名:{项目根}/chapters/{order:03d}_{章节号}_{标题}.md
|
||||
- 图片等媒体与文本放同一目录(chapters/media/),正文图片引用改写为相对路径,
|
||||
用户本地打开 md 即可看到图。
|
||||
- **库(bid_chapters.content)仍是机制正本**(评审/合成/评分读库),文件是查看镜像;
|
||||
每次内容变更(write_chapter replace/append、fill_material_gap 补料)同步刷新。
|
||||
- 镜像失败不阻塞写作主流程(try 包裹,warning 日志)——查看层不能反过来卡死生产层。
|
||||
|
||||
文件名安全:章节号/标题里的路径分隔符与特殊字符替换为 _;order_no 补零排序,
|
||||
文件管理器里天然按章节顺序排列。
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
||||
logger = logging.getLogger("pipeline.bidding.mirror")
|
||||
|
||||
_IMG_RE = re.compile(r'!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)')
|
||||
|
||||
|
||||
def _safe_name(s, maxlen=60):
|
||||
"""文件名安全化:路径分隔符/空白/常见特殊字符 → _。"""
|
||||
s = (s or "").strip()
|
||||
for ch in ('/', '\\', ':', '*', '?', '"', '<', '>', '|', '\n', '\r', '\t'):
|
||||
s = s.replace(ch, '_')
|
||||
s = re.sub(r'\s+', '_', s)
|
||||
return (s or "untitled")[:maxlen]
|
||||
|
||||
|
||||
def _localize_images(content, media_dir):
|
||||
"""正文图片本地化:网关/本地路径可映射的图 → 复制到 media/ 并改相对引用。
|
||||
|
||||
- /idfile|/download 网关 URL 与本地绝对路径:复制进 media/,引用改 media/xxx
|
||||
(相对 chapters/ 目录),用户本地打开 md 直接可见图。
|
||||
- 其余 http(s) URL:保留原样(查看端可联网拉取;镜像进程不代下载远端,
|
||||
失败率与耗时不可控)。
|
||||
返回 (新正文, 本地化图片数)。
|
||||
"""
|
||||
from .bid_compose_capability import _filesroot_path
|
||||
n = 0
|
||||
|
||||
def _sub(m):
|
||||
nonlocal n
|
||||
alt, url = m.group(1), m.group(2)
|
||||
src = ""
|
||||
if url.startswith('http://') or url.startswith('https://'):
|
||||
from urllib.parse import urlparse, unquote
|
||||
path = unquote(urlparse(url).path or '')
|
||||
for lead in ('/idfile/', '/download/'):
|
||||
if path.startswith(lead):
|
||||
cand = _filesroot_path(path[len(lead):])
|
||||
if cand and os.path.isfile(cand):
|
||||
src = cand
|
||||
break
|
||||
elif url.startswith('/') and os.path.isfile(url):
|
||||
src = url
|
||||
if not src:
|
||||
return m.group(0) # 映射不到 → 原样保留
|
||||
try:
|
||||
os.makedirs(media_dir, exist_ok=True)
|
||||
ext = os.path.splitext(src)[1] or '.png'
|
||||
name = hashlib.sha1(src.encode('utf-8')).hexdigest()[:16] + ext
|
||||
dst = os.path.join(media_dir, name)
|
||||
if not os.path.isfile(dst):
|
||||
with open(src, 'rb') as f1, open(dst, 'wb') as f2:
|
||||
f2.write(f1.read())
|
||||
n += 1
|
||||
return '' % (alt, name)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("mirror: 图片本地化失败 %s: %s", src[:120], e)
|
||||
return m.group(0)
|
||||
|
||||
new_content = _IMG_RE.sub(_sub, content or "")
|
||||
return new_content, n
|
||||
|
||||
|
||||
async def sync_chapter_mirror(sor, project_id, chapter_id):
|
||||
"""把章节正文(库内正本)镜像为 {项目根}/chapters/ 下的 md 文件。
|
||||
|
||||
幂等:同章节重复调用覆盖同名文件(重写轮次天然刷新)。
|
||||
返回镜像文件路径(失败返回 '',不抛异常)。
|
||||
"""
|
||||
try:
|
||||
from .bid_common import rec_to_dict
|
||||
recs = await sor.sqlExe(
|
||||
"SELECT id, chapter_no, title, order_no, content, version, status, "
|
||||
"review_score, updated_at FROM bid_chapters WHERE id=${cid}$",
|
||||
{"cid": chapter_id})
|
||||
await sor.sqlExe("COMMIT", {})
|
||||
if not recs:
|
||||
return ''
|
||||
d = rec_to_dict(recs[0])
|
||||
content = d.get("content") or ""
|
||||
if not content.strip():
|
||||
return '' # 无正文不建空文件
|
||||
from .bid_compose_capability import _workspace_dir
|
||||
ws = await _workspace_dir(sor, project_id)
|
||||
if not ws:
|
||||
logger.warning("mirror: 工作空间解析失败,跳过镜像 pid=%s", project_id)
|
||||
return ''
|
||||
chap_dir = os.path.join(ws, "chapters")
|
||||
os.makedirs(chap_dir, exist_ok=True)
|
||||
try:
|
||||
order = int(d.get("order_no") or 0)
|
||||
except (TypeError, ValueError):
|
||||
order = 0
|
||||
fname = "%03d_%s_%s.md" % (order, _safe_name(d.get("chapter_no")),
|
||||
_safe_name(d.get("title")))
|
||||
# 清掉同章节旧命名文件(标题变更后不留孤儿)
|
||||
prefix = "%03d_%s_" % (order, _safe_name(d.get("chapter_no")))
|
||||
for old in os.listdir(chap_dir):
|
||||
if old.startswith(prefix) and old != fname and old.endswith(".md"):
|
||||
try:
|
||||
os.remove(os.path.join(chap_dir, old))
|
||||
except OSError:
|
||||
pass
|
||||
body, n_img = _localize_images(content, os.path.join(chap_dir, "media"))
|
||||
# 头部状态注记(镜像是查看件,标注库内正本状态便于用户判断新旧)
|
||||
header = ("<!-- 章节镜像(库内为正本)|章节 %s|v%s|状态 %s|评审 %s 分|更新 %s -->\n\n"
|
||||
% (d.get("chapter_no"), d.get("version"), d.get("status"),
|
||||
d.get("review_score") if d.get("review_score") is not None else "-",
|
||||
d.get("updated_at")))
|
||||
fpath = os.path.join(chap_dir, fname)
|
||||
with open(fpath, "w", encoding="utf-8") as f:
|
||||
f.write(header + body.rstrip() + "\n")
|
||||
if n_img:
|
||||
logger.info("mirror: %s 本地化 %d 张图 → %s", fname, n_img, chap_dir)
|
||||
return fpath
|
||||
except Exception as e: # noqa: BLE001 镜像永不阻塞主流程
|
||||
logger.warning("sync_chapter_mirror failed pid=%s cid=%s: %s",
|
||||
project_id, chapter_id, str(e)[:200])
|
||||
return ''
|
||||
@ -275,6 +275,12 @@ async def fill_material_gap(gap_id, fill_content="", kb_doc_id="",
|
||||
who=who, agent_id=agent_id,
|
||||
detail="补齐材料「%s」替换 %d 处占位符(章节状态 %s 保持)"
|
||||
% (g.get("material_name", "")[:40], replaced, c.get("status")))
|
||||
# 补料后刷新章节镜像文件(2026-09-16:用户看文件即时发现问题)
|
||||
try:
|
||||
from .bid_chapter_mirror import sync_chapter_mirror
|
||||
await sync_chapter_mirror(sor, pid, chap_id)
|
||||
except Exception as _me: # noqa: BLE001
|
||||
logger.warning("material_fill mirror failed: %s", str(_me)[:120])
|
||||
|
||||
# 标 filled
|
||||
await sor.sqlExe(
|
||||
|
||||
@ -148,6 +148,15 @@ async def write_chapter(chapter_id, content, summary="", mode="replace",
|
||||
logger.warning("material gap scan failed: %s", str(_e)[:120])
|
||||
_gap_note = (";本轮自动登记 %d 项材料缺口(合成后统一出缺失清单)" % _n_gaps) \
|
||||
if _n_gaps else ""
|
||||
# ── 章节镜像文件(2026-09-16 用户裁定:章节写文件,用户看文件即时发现问题)──
|
||||
# 库仍是正本;镜像 = {项目根}/chapters/{order:03d}_{章节号}_{标题}.md,
|
||||
# 图片本地化到 chapters/media/ 同目录。失败不阻塞(try 在 mirror 模块内)。
|
||||
_mir = ""
|
||||
try:
|
||||
from .bid_chapter_mirror import sync_chapter_mirror
|
||||
_mir = await sync_chapter_mirror(sor, d.get("project_id"), chapter_id)
|
||||
except Exception as _e: # noqa: BLE001
|
||||
logger.warning("chapter mirror failed: %s", str(_e)[:120])
|
||||
if mode == "append":
|
||||
return True, ("章节「%s」已追加 %d 字符(累计 %d 字符,v%d)%s。"
|
||||
"全部批次写完后回读核对总行数/总字数,再调 submit_chapter 提交评审。"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user