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:
yumoqing 2026-09-16 17:45:34 +08:00
parent ac1b029e24
commit 598578d288
3 changed files with 151 additions and 0 deletions

View 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 '![%s](media/%s)' % (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 ''

View File

@ -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(

View File

@ -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 提交评审。"