
Transfer Old Latex To New
- 41 installs
- 2.6k repo stars
- Updated July 20, 2026
- huangwb8/chineseresearchlatex
Migrates body content from old proposals, papers, theses, CVs, or Word/PDF/Markdown into an existing ChineseResearchLaTeX template's content layer.
About
Moves existing document body content into the current repo's template content files without touching package sources or template style/skeleton. Developers use it to onboard old LaTeX projects or scattered Word/PDF/Markdown material into a ChineseResearchLaTeX template.
- Writes only to content files, never packages/ or template skeleton
- Compatible with the old migrating-latex-templates alias
Transfer Old Latex To New by the numbers
- 41 all-time installs (skills.sh)
- +2 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #389 of 688 Office & Documents skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/huangwb8/chineseresearchlatex --skill transfer-old-latex-to-newAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 41 |
|---|---|
| repo stars | ★ 2.6k |
| Last updated | July 20, 2026 |
| Repository | huangwb8/chineseresearchlatex ↗ |
What it does
Migrates body content from old proposals, papers, theses, CVs, or Word/PDF/Markdown into an existing ChineseResearchLaTeX template's content layer.
Files
ChineseResearchLaTeX 内容迁移技能
这个 skill 只做一件事:把旧材料里的正文、参考文献等内容,迁移到 当前仓库已有模板项目 的内容层。
它不是模板开发 skill,也不是公共包重构 skill。凡是要改模板源码、样式、packages/bensz-*、projects/* 内骨架文件、main.tex、@config.tex、.cls、.sty、profile、style 或构建脚本的任务,都应转给 make-latex-model 或对应产品线的模板开发流程。
硬性边界
处理任务时必须始终遵守:
- 绝不能修改
packages/下任何公共包源码、模板实现、profile、style、脚本或共享资源 - 绝不能修改
projects/下任何模板样式、入口骨架、wrapper、main.tex、extraTex/@config.tex、.cls、.sty、Lua/Python 构建脚本 - 只能把内容放到目标项目已有的内容层位置
- 如果现有模板没有合适承载位点,不能为了容纳内容去改模板;应明确报告“模板位点不足,需要模板开发 skill 介入”
当前仓库里默认可写的内容层只有 extraTex/**/*.tex(排除 extraTex/@config.tex)和 references/**/*.bib。
除非未来该 skill 的配置白名单明确放开,否则其它路径一律视为只读。
与当前仓库结构对齐
这个 skill 仍然要识别目标产品线,但目的只是选对承载项目,而不是判断“该不该改 packages/”。
- NSFC:选择合适的
projects/NSFC_* - SCI 论文:选择
projects/paper-sci-01/ - 毕业论文:选择最接近的
projects/thesis-* - 简历:选择
projects/cv-01/
然后把旧材料中的正文内容映射到这些现成项目的内容文件里;默认优先选择最接近的现有项目,而不是新建或重组模板结构。
输入原则
不要要求用户先整理成固定输入协议。默认接受并消化任意合理输入,例如:
- 一个完整旧项目目录
- 若干
.tex/.bib/.docx/.md/.txt - PDF、截图、图片
- 一份已有模板项目路径,加上一些迁移目标说明
- 多种输入混合出现
如果材料不完整,不要先追问“标准输入”;先判断目标产品线、合适承载项目、哪些内容能直接落入 extraTex/*.tex 或 references/*.bib,以及哪些诉求已经超出内容迁移边界。只有缺失信息会导致把正文放错位置时,才请求补充。
输出原则
不要把输出理解成“任意重构仓库”。本 skill 的有效输出通常只有:
- 把正文迁移到目标项目的
extraTex/*.tex - 把参考文献迁移到目标项目的
references/*.bib - 生成必要的迁移说明、风险提示、未落位清单
- 在构建成功后给出验证结果
以下动作都超出本 skill 的边界:模板源码改动、样式修复、wrapper / main.tex / template.json / README 的结构重写,以及公共包抽取或包级能力沉淀。默认把用户原始材料视为只读,目标模板视为只读骨架;样式差异只报告,不偷改。
推荐工作流
1. 识别目标产品线与承载项目
先回答三个问题:用户最终要落到哪条产品线、当前仓库哪个现有项目最适合作为承载容器、这次任务是“内容迁移”还是已经变成“模板开发”。如果已经变成模板开发,立即转交,不要继续伪装成内容迁移任务。
2. 清点可迁移内容
从输入中提炼可直接复用的正文段落、BibTeX、需要人工确认的缺口,以及会触发模板改动需求的超界诉求。
3. 做内容层映射
只在内容层做映射:
- 旧正文 →
extraTex/*.tex - 旧参考文献 →
references/*.bib
不要把“映射”理解成:
- 改章节命令
- 改模板标题样式
- 改目录/封面/页眉页脚
- 改公共包接口
4. 执行迁移
执行时只允许覆盖目标内容文件、新建目标内容文件(若模板已有对应承载位点)、以及写入或补齐 .bib。
如果某项需求必须改模板骨架才能完成,不要继续自动修改;应明确标出受阻位置和原因,并建议改用 make-latex-model。
5. 官方入口验证
迁移完成后,尽量用对应产品线的官方入口验证:
- NSFC:
python packages/bensz-nsfc/scripts/nsfc_project_tool.py build --project-dir <项目路径> - SCI:
python packages/bensz-paper/scripts/paper_project_tool.py build --project-dir <项目路径> - Thesis:
python packages/bensz-thesis/scripts/thesis_project_tool.py build --project-dir <项目路径> - CV:
python packages/bensz-cv/scripts/cv_project_tool.py build --project-dir <项目路径> --variant all
如果构建失败且原因来自模板骨架缺口,不要私自修模板;应如实报告。
Legacy CLI 的定位
scripts/run.py、scripts/migrate.sh 仍然保留,但只作为经典 old/new 目录迁移的后备入口。即使使用 legacy CLI,也必须继续遵守本文件的硬性边界:旧项目可以读,新项目只能写内容层,不能借 CLI 绕过模板保护。
不适用场景
以下场景不要继续使用本 skill 直接落地:
- 用户要“做一个新模板”
- 用户要“把旧样式 1:1 复刻到当前仓库”
- 用户要改
packages/bensz-*、projects/*里的模板入口或样式文件 - 用户要新增模板承载位点、封面结构、目录结构、profile、class、style
这些都应转交模板开发链路。
参考材料
优先阅读本文件。需要 legacy CLI 细节时,再查看:
scripts/README.mdreferences/quickstart.mdreferences/config_guide.mdreferences/api_reference.md
确认边界时,始终优先遵循:
- 只迁移内容,不改模板骨架
- 样式差异只报告,不偷改
Changelog
格式基于 Keep a Changelog。
[Unreleased]
Fixed
- 修复
scripts/validate_config.py默认读取config.yaml的路径错误:python scripts/validate_config.py现在会正确指向 skill 根目录配置,不再误读scripts/config.yaml并开箱失败。 - 修复
tests/test_smoke.py在 pytest 环境下返回布尔值导致的PytestReturnNotNoneWarning:现改为纯断言式测试,pytest skills/transfer-old-latex-to-new/tests -q恢复为无 warning 通过。
Changed
- 统一对外名称为
transfer-old-latex-to-new,README / 索引 / 示例命令同步保留migrating-latex-templates的兼容说明。 - 将版本号按
SKILL.md同步回config.yaml、README.md与项目级索引,当前统一为v2.0.0。 - 优化 legacy CLI 与配置校验链路:
scripts/run.py现在明确标注为ChineseResearchLaTeX模板迁移 legacy CLI,并为analyze/apply/compile/restore正式接入--profile;scripts/validate_config.py也新增未知 profile 拦截与skill_info.version/metadata.skill_version一致性校验。 - 收紧 skill 内部口径:
config.yaml为 legacy NSFC 增强节点补充作用域说明,scripts/README.md与scripts/core/README.md明确区分主 workflow、legacy CLI 与 NSFC 倾向模块。 - 新增 auto-test 优化记录:补充
plans/v202603280647.md、tests/v202603280647/、plans/B轮-v202603280653.md与tests/B轮-v202603280653/,沉淀本次 A 轮 / B 轮审查与验证证据。
[v2.0.0] - 2026-03-27
Changed
- 将 skill 的主定位从“旧 NSFC 标书迁移到新模板目录”升级为“面向当前 ChineseResearchLaTeX 四条产品线的模板迁移与重构编排”。
- 重写 SKILL.md:不再把
old/new双目录、固定runs/输出和固定交付物作为主约束,改为强调 AI 自主托管输入解析、目标落层和输出形态。 - 重写 README.md:用户使用方式改为“给任意材料 + 给目标”,不再要求用户先整理成固定输入输出协议。
- 更新 config.yaml:版本跃迁到
2.0.0,并明确该配置主要服务 legacy CLI,而不是限定整个 skill 的输入输出边界。 - 更新 scripts/README.md:将
run.py、migrate.sh等脚本降级为可选的 legacy CLI 后备说明。
# transfer-old-latex-to-new 技能配置文件
# version: 2.1.0
# last_updated: 2026-03-28
#
# 说明:
# - 当前 skill 的主心智已收敛为“把旧材料内容迁移到现有模板项目的内容层”
# - 本文件仍主要服务 legacy CLI 与可选脚本入口
# - 核心安全边界:不得修改 packages/ 公共包源码,也不得修改 projects/ 内模板骨架或样式
# - 带有 NSFC 年份/章节假设的节点属于 legacy 增强模块,不代表整个 skill 只服务 NSFC
skill_info:
name: transfer-old-latex-to-new
version: 2.1.0
description: "将旧项目、旧模板或多源材料中的正文内容迁移到当前 ChineseResearchLaTeX 现有模板项目的内容层;优先使用 transfer-old-latex-to-new 触发,也兼容旧别名 migrating-latex-templates;只允许写入正文与参考文献文件,绝不能修改 packages/ 公共包源码或 projects/ 模板样式骨架"
author: "Bensz Conan"
category: writing
orchestration:
input_contract: "flexible" # 任意合理输入皆可,由 AI 自主归并理解
output_contract: "content_only" # 交付物仅限内容层与必要说明,不做模板重构
source_edit_policy: "read_only_by_default" # 默认不原地改写用户源材料
repo_awareness: "existing_project_content_slots" # 以现有项目的正文承载位点为准
legacy_cli_mode: "optional" # run.py / migrate.sh 仅作为可选硬编码后备
template_protection:
forbid_package_source_edits: true
forbid_project_template_edits: true
on_template_gap: "report_instead_of_patch"
allow_resource_writes: false
allowed_write_patterns:
- "^extraTex/(?!@config\\.tex$).+\\.tex$"
- "^references/.+\\.bib$"
# =============================================================================
# 预设模板(简化配置使用)
# =============================================================================
profiles:
quick:
description: "快速模式(适合材料较少、结构较清晰的迁移或重构任务)"
ai:
batch_mode: false
max_workers: 2
cache:
enabled: false
content_optimization:
max_rounds: 3
balanced:
description: "平衡模式(适合一般复杂度的多文件迁移或模板接入任务)"
ai:
batch_mode: true
batch_size: 10
max_workers: 4
cache:
enabled: true
content_optimization:
max_rounds: 5
thorough:
description: "精确模式(适合结构复杂、跨目录或跨产品线的重构任务)"
ai:
batch_mode: true
batch_size: 20
max_workers: 8
cache:
enabled: true
content_optimization:
max_rounds: 7
# =============================================================================
# legacy CLI 核心参数
# =============================================================================
migration:
# 优化轮次控制
max_rounds: 5 # 最大优化轮次(AI根据项目复杂度自动调整)
min_rounds: 3 # 最小优化轮次
convergence_threshold: 0.05 # 收敛阈值(连续两轮改进<5%则退出)
# 备份策略
backup_mode: "snapshot" # 备份模式: snapshot(快照)/copy(复制)/none(跳过)
backup_location: "runs" # 备份位置: runs(默认,输出到本轮 .bensz-api run 目录的 input/backup/)/custom(自定义路径)
keep_backup_days: 30 # 自动清理备份保留天数
fine_grained_rollback: true # 细粒度回滚(支持单文件恢复)
# legacy CLI 的默认策略(主工作流仍以 AI 自主判断为准)
default_strategy: "smart" # smart(智能)/conservative(保守)/aggressive(激进)
# 内容生成策略
content_generation:
method: "smart" # smart(调用技能)/placeholder(占位符)/skip(跳过)
placeholder_text: "\\textbf{[此部分内容需要补充]}"
# 引用处理
reference_handling: "preserve" # preserve(保留原样)/update(更新编号)/recreate(重建)
# 图片与附件处理
figure_handling: "skip" # copy(复制)/link(软链接)/skip(跳过)
# 说明:
# - copy: 将旧项目的资源文件复制到新项目(仅在明确放开内容资源写入时使用)
# - link: 在新项目中创建指向旧项目资源的软链接(同样受模板保护策略约束)
# - skip: 跳过资源文件处理,只迁移正文与参考文献(默认,推荐)
# =============================================================================
# 质量阈值配置
# =============================================================================
quality_thresholds:
# 语义相似度(用于自动章节映射)
min_similarity: 0.7 # 最小语义相似度(低于此值需人工确认)
# 内容完整性
min_word_count: 50 # 最小章节字数(低于此值发出警告)
content_integrity: 0.95 # 内容完整性阈值(旧内容迁移比例)
# 引用完整性
max_ref_errors: 5 # 最大引用错误数(超过此值发出警告)
max_undefined_refs: 3 # 最大未定义引用数
# LaTeX编译
max_compile_warnings: 10 # 最大编译警告数
max_compile_errors: 0 # 最大编译错误数(必须为0)
# 逻辑连贯性
logical_coherence_threshold: 0.8 # 逻辑连贯性最低要求(AI评分)
# =============================================================================
# 编译参数配置
# =============================================================================
compilation:
# LaTeX引擎
engine: "xelatex" # 编译引擎: xelatex/lualatex/pdflatex
passes: 4 # 标准编译轮数
pass_sequence: # 编译序列
- "xelatex"
- "bibtex"
- "xelatex"
- "xelatex"
# 编译选项
interaction_mode: "nonstopmode" # 交互模式: nonstopmode/batchmode/errorstopmode
halt_on_error: false # 遇到错误是否立即中止
# 超时控制
timeout_per_pass: 120 # 单次编译超时(秒)
total_timeout: 600 # 总编译超时(秒)
# =============================================================================
# 缓存配置
# =============================================================================
cache:
enabled: true # 启用缓存
strategy: "layered" # 分层策略: layered(三层)/simple(单层)/none(禁用)
memory_max_size: 1000 # 内存缓存最大条目数
disk_db_path: "cache/mapping_cache.db" # 磁盘缓存路径(相对本轮 .bensz-api run 目录)
ttl_days: 30 # 缓存过期天数
# =============================================================================
# AI推理参数
# =============================================================================
ai:
# 模型选择
primary_model: "claude-opus-4-5" # 首选模型(用于复杂推理)
fallback_model: "claude-sonnet-4-5" # 降级模型(用于简单任务)
embedding_model: "text-embedding-3-small" # 嵌入模型(用于相似度计算)
# 批量调用配置
batch_mode: true # 启用批量模式
batch_size: 10 # 每批处理数量
max_concurrent_batches: 3 # 最大并发批次数
max_workers: 4 # 最大并行 worker 数(默认 CPU 核心数)
# 生成参数
temperature: 0.3 # 生成温度(低温度=更确定性的输出)
max_tokens_per_request: 8000 # 单次请求最大token数
top_p: 0.9 # nucleus sampling参数
top_k: 40 # top-k sampling参数
# 推理增强
enable_cot: true # 启用Chain-of-Thought推理
enable_self_reflection: true # 启用自我反思机制
max_reflection_rounds: 2 # 最大反思轮次
# =============================================================================
# 输出控制(legacy CLI 默认值;不代表 skill 的固定交付契约)
# =============================================================================
output:
# 报告生成
generate_report: true # 生成迁移报告
generate_change_summary: true # 生成变更摘要
generate_structure_comparison: true # 生成结构对比
# 日志控制
verbose: true # 详细日志输出
log_level: "INFO" # 日志级别: DEBUG/INFO/WARNING/ERROR
log_file: "migration.log" # 日志文件名
# 中间文件
keep_intermediate: false # 保留中间文件(用于调试)
intermediate_dir: "intermediate" # 中间文件目录(相对本轮 .bensz-api run 目录)
# 交付物(仅供 legacy CLI 参考)
deliverables_dir: "deliverables" # 交付物目录(相对本轮 .bensz-api run 目录)
deliverables:
- "migrated_proposal.pdf" # 迁移后的PDF
- "migration_log.md" # 迁移日志
- "change_summary.md" # 变更摘要
- "structure_comparison.md" # 结构对比
- "restore_guide.md" # 恢复指南
# =============================================================================
# 工作空间(本 skill 运行目录)
# =============================================================================
workspace:
runs_dir: ".bensz-api/skills/transfer-old-latex-to-new" # 运行产物根目录:./.bensz-api/skills/transfer-old-latex-to-new/<yyyy-mm-dd-hh-mm>/
# =============================================================================
# 智能决策规则(AI自主规划逻辑)
# =============================================================================
decision_rules:
# 迭代轮次决策
rounds_decision:
simple:
condition: "one_to_one_ratio >= 0.8" # 简单一对一映射≥80%
max_rounds: 3
moderate:
condition: "one_to_many_ratio + many_to_one_ratio >= 0.3" # 复杂映射≥30%
max_rounds: 5
complex:
condition: "new_added_count >= 3 OR structural_major_changes >= 2"
max_rounds: 7
# 迁移策略决策
strategy_decision:
conservative:
condition: "high_risk_issues == 0" # 无高风险问题
description: "保守策略:优先保持现有模板骨架不变,只迁移内容"
smart:
condition: "one_to_many_ratio + many_to_one_ratio >= 0.2" # 存在复杂映射
description: "智能策略:AI只在正文内容层做拆分/合并映射"
aggressive:
condition: "new_added_count >= 5" # 大量新增内容
description: "激进策略:仅在单个正文文件内部做较大幅度内容整编,仍不触碰模板骨架"
# 备份决策
backup_decision:
auto_snapshot:
condition: "backup_mode == 'snapshot'"
action: "create_git_stash"
manual_backup:
condition: "user_specified_backup_path"
action: "copy_to_specified_location"
# LaTeX编译失败处理
compilation_failure_handling:
missing_file:
action: "abort_and_report"
retry: false
syntax_error:
action: "attempt_auto_fix"
retry: true
max_retries: 3
reference_error:
action: "fix_references_and_continue"
retry: true
timeout:
action: "report_and_continue"
partial_result: true
# =============================================================================
# 结构映射配置(AI 驱动)
# =============================================================================
mapping:
# AI 映射策略
strategy: "ai_driven" # ai_driven(AI语义判断)/fallback(简单启发式)
# AI 判断阈值
thresholds:
high: 0.85 # 高置信度阈值(AI 认为高度确定)
medium: 0.7 # 中置信度阈值(AI 认为比较确定)
low: 0.5 # 低置信度阈值(需人工确认)
# 回退策略(当 AI 不可用时)
fallback:
enable_filename_exact_match: true # 文件名完全匹配
enable_filename_contains: true # 文件名包含关系
enable_jaccard_similarity: true # Jaccard 相似度
min_jaccard_threshold: 0.5 # 最小 Jaccard 相似度
# 拆分策略(用于一对多映射)
split_strategies:
semantic_split:
description: "按语义边界拆分(AI 判断)"
priority: 1
keyword_split:
description: "按关键词出现位置拆分"
priority: 2
paragraph_split:
description: "按段落边界拆分"
priority: 3
equal_split:
description: "等分拆分"
priority: 4 # 最后选择
# 合并策略(用于多对一映射)
merge_strategies:
sequential_merge:
description: "顺序拼接,添加过渡段"
priority: 1
semantic_reorder:
description: "仅在目标正文文件内部按语义重新排序后合并"
priority: 2
smart_merge:
description: "AI 在正文文件内部做智能整编"
priority: 3
# =============================================================================
# 优化配置
# =============================================================================
optimization:
# 每轮优化重点
round_focus:
- round: 1
focus: ["logical_coherence", "transitions"]
tools: ["manual_review", "nsfc-writing-core"]
- round: 2
focus: ["terminology_consistency", "reference_integrity"]
tools: ["grep_terminology", "ref_validator"]
- round: 3
focus: ["content_depth", "evidence_quality"]
tools: ["nsfc-rationale-writer", "nsfc-innovation-writer"]
- round: 4
focus: ["reference_integrity", "latex_compilation"]
tools: ["latex_compiler", "linter"]
- round: 5
focus: ["readability", "final_polish"]
tools: ["nsfc-writing-core", "human_review"]
# 收敛判断条件(满足任一即提前退出)
convergence_criteria:
- condition: "total_quality_score >= 0.9"
description: "总分≥90%"
- condition: "consecutive_improvement < 0.05 for 2 rounds"
description: "连续2轮改进<5%"
- condition: "latex_compilation == 1.0 AND all_scores >= 0.8"
description: "编译完美且无其他严重问题"
- condition: "no_issues_found"
description: "无发现任何问题"
# =============================================================================
# 风险评估
# =============================================================================
risk_assessment:
# 高风险定义
high_risk_indicators:
- "目标内容必须修改 main.tex/@config/模板样式 才能落位"
- "迁移诉求要求修改 packages/ 公共包源码"
- "many_to_one合并导致核心论证断裂"
- "删除核心正文内容"
# 中风险定义
medium_risk_indicators:
- "合并章节需要添加过渡段"
- "引用标签需要全局更新"
- "当前模板内容位点与旧材料语义错位"
- "图表资源缺失但不允许写入模板资源目录"
# 低风险定义
low_risk_indicators:
- "简单一对一映射"
- "正文润色"
- "非核心章节迁移"
# =============================================================================
# 技能集成配置
# =============================================================================
skill_integration:
# 可调用的 legacy NSFC 增强技能
available_skills:
nsfc-writing-core:
purpose: "统一术语、检查一致性"
trigger: "每次优化开始前"
priority: 1
nsfc-rationale-writer:
purpose: "补强立项依据"
trigger: "立项依据内容不足或质量低"
priority: 2
nsfc-aims-writer:
purpose: "补强研究内容与目标"
trigger: "研究内容章节需要优化"
priority: 2
nsfc-methods-feasibility-writer:
purpose: "补强方案可行性、生成风险应对"
trigger: "方案可行性章节需要优化或新增风险应对"
priority: 2
nsfc-innovation-writer:
purpose: "提炼创新点"
trigger: "特色与创新章节需要优化"
priority: 3
nsfc-foundation-conditions-writer:
purpose: "补强研究基础与工作条件"
trigger: "研究基础章节需要优化"
priority: 3
nsfc-workplan-writer:
purpose: "补强研究计划"
trigger: "研究计划章节需要优化"
priority: 3
# 技能调用策略
call_strategy: "on_demand" # on_demand(按需)/automatic(自动)/manual(手动)
# =============================================================================
# 调试选项
# =============================================================================
debug:
enabled: false # 调试模式开关
save_intermediate_json: true # 保存所有中间JSON文件
log_ai_decisions: true # 记录AI决策过程
verbose_error_messages: true # 详细错误信息
performance_profiling: true # 性能分析
# =============================================================================
# 版本兼容性
# =============================================================================
compatibility:
# legacy NSFC 增强模块支持的模板版本
supported_versions:
- "2025"
- "2026"
- "2024"
# 版本映射表
version_mapping:
"2024_to_2025":
structural_changes: ["章节编号体系变化", "部分章节合并"]
"2025_to_2026":
structural_changes: ["板块重组", "新增风险应对章节"]
# 不兼容的版本对
incompatible_pairs: []
# 自动版本检测
auto_detect: true # 自动检测项目版本
warn_on_unsupported: true # 遇到不支持版本时警告
# =============================================================================
# 插件系统
# =============================================================================
plugins:
enabled: false # 插件系统开关(默认关闭)
plugin_dir: "plugins" # 插件目录
auto_load: true # 自动加载插件
# =============================================================================
# 写作风格评分
# =============================================================================
style_scoring:
enabled: true # 启用写作风格评分
criteria:
- "clarity" # 清晰度
- "conciseness" # 简洁性
- "coherence" # 连贯性
- "academic_tone" # 学术性
threshold: 0.75 # 最低质量阈值(低于此值会警告)
# =============================================================================
# 字数自动适配(legacy NSFC 增强模块)
# =============================================================================
word_count_adaptation:
enabled: true # 启用字数自动适配
auto_expand: true # 字数不足时自动扩展
auto_compress: true # 字数过多时自动精简
target_tolerance: 50 # 目标容忍度(±50字)
priority_strategy: "skill_first" # 优先策略: skill_first(优先调用技能)/ai_direct(直接AI)
# =============================================================================
# 引用强制保护
# =============================================================================
reference_protection:
enabled: true # 启用引用强制保护
validation_mode: "strict" # 验证模式: strict(严格)/lenient(宽松)
auto_repair: true # 自动修复被破坏的引用
log_violations: true # 记录引用违规
# =============================================================================
# AI 内容智能优化
# =============================================================================
content_optimization:
enabled: true # 启用 AI 内容优化
auto_apply: true # 自动应用优化(无需人工确认)
min_improvement_threshold: 0.1 # 最低改进阈值(低于此值不应用)
optimization_types:
- "redundancy" # 删除冗余
- "logic" # 改进逻辑
- "evidence" # 补充证据
- "clarity" # 提高清晰度
preserve_references: true # 优化时保护引用
max_optimization_passes: 3 # 每轮最大优化次数
# =============================================================================
# 元数据
# =============================================================================
metadata:
skill_name: "transfer-old-latex-to-new"
skill_version: "2.1.0"
created_at: "2026-01-05"
last_updated: "2026-03-28"
author: "Bensz Conan"
license: "MIT"
# 依赖项
dependencies:
python: ">=3.8"
latex: "texlive-full >= 2020"
required_packages:
- "ctex"
- "xcolor"
- "graphicx"
- "float"
- "hyperref"
# 推荐配置
recommended:
os: ["Linux", "macOS", "Windows"]
latex_editor: ["VSCode", "Overleaf", "TeXShop"]
git_integration: true
transfer-old-latex-to-new — 用户使用指南
本 README 面向使用者:怎样把旧材料里的正文内容迁移到当前 ChineseResearchLaTeX 现有模板项目的内容层。
技能名称:transfer-old-latex-to-new 版本:v2.1.0 最后更新:2026-03-28
兼容历史别名:migrating-latex-templates。后续文档统一使用 transfer-old-latex-to-new。
现在这个 skill 是做什么的
它现在是一个内容迁移 skill,不是模板开发 skill。
它适合做这些事:
- 把旧标书正文接到当前
projects/NSFC_* - 把旧论文正文接到
projects/paper-sci-01/ - 把旧毕业论文正文接到合适的
projects/thesis-* - 把旧简历正文接到
projects/cv-01/ - 接受
tex/docx/pdf/md/截图/说明文字/旧项目目录的任意组合输入
它不负责做这些事:
- 修改
packages/里的公共包源码 - 修改
projects/里的模板样式、入口骨架、main.tex、extraTex/@config.tex - 为了复刻旧样式而重写模板
- 把共享逻辑上收到
packages/
一句话理解:
这个 skill 只把正文内容放到现有模板的合适位置,不碰模板源码和样式。
推荐用法
直接告诉 AI 目标、已有材料和目标项目即可。
最小可用 prompt:
请使用 transfer-old-latex-to-new skill。
目标:把这些旧材料迁移到 ChineseResearchLaTeX 当前合适的现有模板项目里。
输入:<你现有的项目目录、tex/docx/pdf/md、截图、说明文字,给什么都可以>
硬性约束:
- 只能迁移正文内容和参考文献
- 绝不能修改 packages/ 里的公共包源码
- 绝不能修改 projects/ 里的模板样式、main.tex、@config.tex、wrapper 或其它模板骨架
- 只把内容放到合适的 extraTex/*.tex 和 references/*.bib
输出:给我一个内容已经落位、最好可构建、并明确说明剩余缺口的结果常见场景
场景 1:旧 NSFC 标书迁到当前项目
请使用 transfer-old-latex-to-new skill。
目标:把我这份旧 NSFC 标书正文迁移到当前仓库合适的 NSFC 项目里。
输入:
- 旧项目目录:/path/to/old-nsfc
- 目标模板参考:projects/NSFC_Young
要求:
- 只迁移正文与参考文献
- 不要修改模板样式和项目骨架
- 如果当前模板缺少承载位点,只报告,不要偷改模板场景 2:把 Word/PDF/零散 tex 整理成论文正文
请使用 transfer-old-latex-to-new skill。
目标:把这些论文材料整理进当前仓库的 SCI 模板正文层。
输入:
- Word 稿
- 若干 PDF 截图
- 一个旧 tex 目录
- 目标项目:projects/paper-sci-01
要求:
- 只写入 extraTex 和 references
- 不修改 main.tex、模板样式和构建脚本场景 3:毕业论文正文接入
请使用 transfer-old-latex-to-new skill。
目标:把旧毕业论文正文接到 ChineseResearchLaTeX 当前 thesis 产品线里的一个现有项目。
输入:
- 旧模板目录:/path/to/legacy-thesis
- 当前最接近项目:projects/thesis-nju-master
要求:
- 只迁移内容,不开发新模板
- 如果版式差异必须通过改模板才能解决,请直接指出场景 4:简历正文接入
请使用 transfer-old-latex-to-new skill。
目标:把我现有的简历正文迁移到当前仓库的 cv 项目内容层。
输入:
- 旧简历 PDF
- 一份旧 tex
- 目标项目:projects/cv-01
要求:
- 只放正文内容
- 不改简历模板样式和骨架输入和输出怎么理解
输入
这个 skill 不要求固定输入协议。
你可以提供:
- 目录
- 单文件
- 多个文件混合
- 截图
- 文字描述
- 当前仓库里的某个项目作为目标参考
如果材料不完整,AI 会先尽量判断哪些内容能安全落位,哪些缺口超出内容迁移边界。
输出
这个 skill 也不预设一堆固定报告。
常见结果包括:
- 直接更新目标项目的
extraTex/*.tex - 更新目标项目的
references/*.bib - 补一份简短的迁移说明、未落位清单或风险提示
- 给出官方构建验证结果
它默认不会输出这些改动:
main.texextraTex/@config.tex.cls/.stypackages/下公共包源码- 模板 wrapper、style、profile、构建脚本
设计理念
这个 skill 现在遵循三条原则:
1. 现有模板骨架只读 目标模板是承载容器,不是让这个 skill 顺手改造的对象。
2. 内容层优先 默认只迁移到 extraTex/*.tex 和 references/*.bib。
3. 边界清晰 如果任务已经变成模板开发,应该转给 make-latex-model,而不是让这个 skill 偷偷越界。
备选用法(legacy CLI)
如果你的任务刚好还是经典的“旧目录 -> 新目录”迁移,也可以用本 skill 自带脚本。
注意:即使使用脚本,仍然只能写内容层,不能借 CLI 改模板骨架。
分析
python skills/transfer-old-latex-to-new/scripts/run.py analyze \
--old /path/to/old_project \
--new /path/to/new_project应用
python skills/transfer-old-latex-to-new/scripts/run.py apply \
--run-id <run_id> \
--old /path/to/old_project \
--new /path/to/new_project编译验证
python skills/transfer-old-latex-to-new/scripts/run.py compile \
--run-id <run_id> \
--new /path/to/new_project更完整的脚本说明见 scripts/README.md。
FAQ
它还会修改 packages/ 或模板样式吗?
不会。当前版本明确禁止这样做。
我只有 Word、PDF 和一些截图,没有标准 LaTeX 输入,能用吗?
能用。这个 skill 的默认前提就是“输入不必规整”。
如果当前模板放不下我的内容怎么办?
这个 skill 会指出缺口,但不会为了容纳内容去改模板。那属于模板开发任务。
输出一定会给我固定报告吗?
不会。只要能把内容安全落位,并把剩余问题讲清楚即可。
什么时候不该再用这个 skill?
当你的真实需求是“开发/修改模板”而不是“迁移正文内容”时,就不该继续用它。
相关文件
- 执行规范见 SKILL.md
- 默认参数见 config.yaml
- legacy CLI 说明见 scripts/README.md
- 变更记录见 CHANGELOG.md
API Reference(接口参考)
面向二次开发/排障:理解内部模块与核心产物(JSON/Markdown)。
CLI(scripts/run.py)
analyze:解析旧新项目结构,生成analysis/structure_diff.json与plan/migration_plan.json。apply:按计划写入新项目(默认跳过低置信度/需人工任务),并生成deliverables/change_summary.md。compile:对新项目执行 4 步编译,日志隔离到logs/latex_aux/,并生成logs/compile_summary.json。restore:将新项目恢复到 apply 前快照(backup/)。runs list/show/delete:runs 管理(迁移历史可追溯;删除需--yes)。
通用建议参数:
--runs-root /path/to/runs:指定 runs 输出根目录(用于隔离输出/测试/批处理)。
配置校验(scripts/validate_config.py)
在执行迁移前运行一次配置校验,用于提前发现:
- 阈值越界/类型错误
- 轮次配置不合理
- 编译序列偏离推荐 4 步法(警告)
关键产物(格式)
analysis/structure_diff.json
结构差异与映射推断结果(one-to-one / new_added / removed / low_confidence 等)。
plan/migration_plan.json
迁移任务列表(copy_one_to_one / placeholder_new_added / needs_manual)。
logs/apply_result.json
apply 执行结果(applied/skipped/warnings/resources/references/optimization/adaptation)。
核心模块
scripts/core/project_analyzer.py:解析main.tex与\\input{}链路,提取 headings/labels/refs/cites。scripts/core/mapping_engine.py:生成结构差异;可选走scripts/core/ai_integration.py,不可用时回退启发式。scripts/core/migration_plan.py:由 diff 生成计划(任务类型、备注、置信度等)。scripts/core/migrator.py:执行迁移 +(可选)内容优化 +(可选)字数适配 + 资源复制 + 引用完整性检查。scripts/core/compiler.py:编译 4 步法;中间文件隔离到logs/latex_aux/,成功后复制main.pdf回项目根目录。scripts/core/security_manager.py:写入白名单校验(禁止触碰模板系统文件)。
Case Study(2025 → 2026 迁移示例)
目标:把一个“2025 旧标书项目”的科学内容迁移到“2026 已调好格式的新模板项目”,并且 不破坏新模板样式。
场景假设
- 旧项目:
/path/to/NSFC_2025(包含main.tex与若干extraTex/*.tex内容文件) - 新项目:
/path/to/NSFC_2026(同样包含main.tex,但章节文件名/编号可能变化)
推荐执行方式
1) (可选)先校验配置
python skills/transfer-old-latex-to-new/scripts/validate_config.py2) 迁移(建议隔离 runs 输出)
python skills/transfer-old-latex-to-new/scripts/run.py analyze \
--old /path/to/NSFC_2025 \
--new /path/to/NSFC_2026 \
--runs-root /path/to/runs得到 run_id=<...> 后:
python skills/transfer-old-latex-to-new/scripts/run.py apply \
--run-id <run_id> \
--old /path/to/NSFC_2025 \
--new /path/to/NSFC_2026 \
--runs-root /path/to/runs3) 查看交付物(建议检查顺序)
- 结构差异:
runs/<run_id>/analysis/structure_diff.json - 迁移计划:
runs/<run_id>/plan/migration_plan.json - 变更摘要:
runs/<run_id>/deliverables/change_summary.md - 未映射旧内容清单:
runs/<run_id>/deliverables/unmapped_old_content.md
4) 编译验证(可选,但建议)
python skills/transfer-old-latex-to-new/scripts/run.py compile \
--run-id <run_id> \
--new /path/to/NSFC_2026 \
--runs-root /path/to/runs编译中间文件会被隔离到 runs/<run_id>/logs/latex_aux/,不会在项目目录产生 .aux/.log/.bbl 等文件。
5) 不满意就回滚
python skills/transfer-old-latex-to-new/scripts/run.py restore \
--run-id <run_id> \
--new /path/to/NSFC_2026 \
--runs-root /path/to/runs迁移后最小验收清单
apply完成后:新项目只出现extraTex/*.tex内容变更(模板文件未动)unmapped_old_content.md已人工确认(无遗漏关键科学内容)compile可生成 PDF(允许警告,但不应有致命错误)
transfer-old-latex-to-new 约定符合性速记
本文只保留当前仍有价值的“合规检查清单”。历史上关于旧命名、旧 frontmatter 和旧 NSFC 专用定位的长篇评估,已不再作为当前技能行为依据。
当前口径
- 当前 skill 名称:
transfer-old-latex-to-new - 当前定位:把旧材料迁移到 ChineseResearchLaTeX 现有模板项目的内容层
- 当前硬边界:
- 不改
packages/ - 不改
projects/的模板骨架、样式和入口 - 只写
extraTex/**/*.tex(排除@config.tex)与references/**/*.bib
需要持续满足的合规点
Frontmatter 与可发现性
name与 skill 真实名称一致description同时说明:- 做什么
- 何时用
metadata.keywords含 skill 名与核心触发词
SKILL.md 与正文一致性
- 触发范围、输入输出、默认写入边界一致
scripts/run.py、scripts/migrate.sh仅作为 legacy CLI,不得被写成主工作流- 如更新了引用文档,检查
SKILL.md中的引用是否仍准确
目录与文档分层
SKILL.md:主流程与硬约束references/:补充细则、legacy 差异、排障README.md/CHANGELOG.md:面向人类,不拿来承载 AI 执行规则
常见失配
- 把 skill 描述回旧的“NSFC 任意年份模板迁移器”
- 把 legacy CLI 当成默认入口
- 把可写范围从内容层偷偷扩到模板骨架
- 在 references 中保留大量历史评审意见,却不再对应当前实现
维护动作
- 改技能行为时,先改
SKILL.md - 若只是补充案例或边缘说明,再改
references/ - 若变更影响用户认知,再同步
README.md与CHANGELOG.md
Config Guide(配置指南)
配置文件:skills/transfer-old-latex-to-new/config.yaml
只看这几个块就够用
profiles:quick/balanced/thorough三档预设(影响 AI 并发、缓存、优化轮次等)。migration:迁移轮次、默认策略、占位符文本、资源处理方式等。quality_thresholds:相似度阈值、最小字数、引用/编译错误阈值等。compilation:编译引擎与 4 步法序列、超时等(默认xelatex → bibtex → xelatex → xelatex)。output:verbose控制进度/日志输出。workspace.runs_dir:默认 runs 子目录名(相对 skill 根目录)。
预留字段说明(当前不生效)
cache.*:缓存模块暂未接入主流程,仅保留配置入口。output.deliverables:作为期望产物清单展示,不做硬校验。migration.backup_location:当前仅支持runs,custom仍为预留选项。
runs 输出隔离(推荐)
配置层面可改 workspace.runs_dir,但更推荐运行时用 --runs-root:
python skills/transfer-old-latex-to-new/scripts/run.py analyze \
--old /path/to/old --new /path/to/new \
--runs-root /path/to/runs这样不用改配置,就能把“迁移产物/日志/快照”完全隔离到指定目录(尤其适合测试或批处理)。
配置校验(P1)
在运行迁移前,建议先跑一次配置校验:
python skills/transfer-old-latex-to-new/scripts/validate_config.py常见可提前发现的问题包括:
- 数值阈值越界(如相似度/超时)
- 轮次配置不合理(
max_rounds < min_rounds) - 编译序列不符合推荐 4 步法(会给出警告)
可选依赖:rich 进度条
本技能会尝试使用 rich 显示更美观的进度条;若环境未安装 rich,会自动回退到纯文本进度显示,不影响功能。
FAQ(常见问题)
1) 我到底该传哪个目录给 --old/--new?
传 LaTeX 项目根目录(必须包含 main.tex)。如果你传的是 extraTex/ 或某个子目录,会被判定为“缺少 main.tex”。
2) 迁移会不会把新模板改坏?
默认不会。本技能有写入白名单:
- ✅ 仅允许写:
extraTex/*.tex(排除extraTex/@config.tex)、references/*.bib、以及 runs 目录 - ❌ 禁止写:
main.tex、extraTex/@config.tex、.cls、.sty
触碰到禁止文件会直接报错并中止。
3) 为什么有些章节没有自动迁移?
通常是“低置信度/需人工”的映射任务。你可以:
- 查看
runs/<run_id>/deliverables/unmapped_old_content.md - 或谨慎使用
apply --allow-low执行低置信度任务
4) 我怎么确保 runs 产物不污染仓库?
两种方式:
- 运行时指定:
--runs-root /path/to/runs(推荐,测试/批处理必用) - 或在配置里修改
workspace.runs_dir(不如--runs-root直观)
5) 编译失败怎么办?
先看:runs/<run_id>/logs/latex_aux/main.log。
如果提示 command not found: xelatex/bibtex,说明系统缺少 TeX 环境,需要先安装 TeX Live / MacTeX。
6) 我想先检查配置有没有问题再跑迁移
运行配置校验:
python skills/transfer-old-latex-to-new/scripts/validate_config.py它会检查常见的类型/范围/组合错误,并对“非推荐 4 步法编译序列”给出警告。
核心功能实现状态
✅ 已实现并测试通过
1. 字数自动适配(WordCountAdapter)
- 文件:
scripts/core/word_count_adapter.py(7.2 KB) - 测试:
tests/test_word_count_adapter.py(2.2 KB) - 功能:
- ✅ 中文字数统计(排除 LaTeX 命令)
- ✅ 版本字数要求加载(2024→2025, 2025→2026)
- ✅ 自动判断是否需要适配
- ✅ 字数扩展/精简接口(预留 AI 集成点)
- ✅ 生成字数报告
2. 引用强制保护(ReferenceGuardian)
- 文件:
scripts/core/reference_guardian.py(4.1 KB) - 测试:
tests/test_reference_guardian.py(2.9 KB) - 功能:
- ✅ 保护 8 种引用类型(\ref, \cite, \citep, \citet, \eqref, \label, \includegraphics, \lstinputlisting)
- ✅ 占位符生成与恢复
- ✅ 引用完整性验证
- ✅ 引用统计报告
- ✅ 被破坏引用自动修复
3. 内容智能优化(ContentOptimizer)
- 文件:
scripts/core/content_optimizer.py(6.2 KB) - 测试:
tests/test_content_optimizer.py(1.6 KB) - 功能:
- ✅ 启发式问题检测(冗余、结构、证据)
- ✅ 5 种优化类型接口(冗余、逻辑、证据、清晰度、结构)
- ✅ 引用保护集成
- ✅ 优化报告生成
- ✅ 改进潜力评分
📋 集成说明
当前实现状态
所有核心模块已实现并可运行,提供:
- ✅ 完整的类实现
- ✅ 基础功能逻辑
- ✅ 测试验证
- ✅ 演示脚本(
demo_core_features.py)
AI 集成点(需要后续完成)
以下功能已预留接口,需要集成 AI 客户端: 1. 字数扩展/精简: WordCountAdapter._ai_expand_content() / _ai_compress_content() 2. 内容优化: ContentOptimizer._remove_redundancy() / _improve_logic() 等
集成方式
当 AI 客户端可用时,只需替换占位符实现:
# 当前(占位符)
expanded = self._ai_expand_content_placeholder(content, section_title, deficit)
# 集成 AI 后
expanded = self.ai_client.complete(prompt, max_tokens=4000)🚀 使用示例
# 运行演示
python demo_core_features.py
# 运行测试(需要 pytest)
pip install pytest
pytest tests/ -v📊 代码统计
| 模块 | 代码行数 | 测试行数 | 覆盖功能 |
|---|---|---|---|
| WordCountAdapter | ~220 | ~110 | 字数统计、要求判断、报告 |
| ReferenceGuardian | ~130 | ~160 | 保护、恢复、验证、报告 |
| ContentOptimizer | ~170 | ~80 | 问题检测、优化接口、报告 |
总计: ~520 行生产代码 + ~350 行测试代码
✅ 验证结果
✅ WordCountAdapter 导入成功
✅ ReferenceGuardian 导入成功
✅ ContentOptimizer 导入成功
✅ 字数统计: 3637 字
✅ 引用保护: 保护了 6 个引用
✅ 引用恢复: 成功
✅ 所有功能演示完成!常见迁移模式库
仅供 transfer-old-latex-to-new 在 legacy 内容迁移场景下快速选策略。当前 skill 的硬边界仍是:只迁移内容层,不改模板骨架。模式总览
| ID | 名称 | 典型场景 | 默认动作 | 风险 |
|---|---|---|---|---|
P01 | 直接复制 | 标题与承载位点基本一致 | 复制正文,最小清理 | 低 |
P02 | 编号调整 | 内容不变,仅章节编号或正文引用变化 | 只改正文里的编号提及 | 低 |
P03 | 语义拆分 | 旧一章对应新多章 | 按内容边界拆成多个内容文件 | 中 |
P04 | 智能合并 | 旧多章对应新一章 | 顺序合并并补最小过渡句 | 中 |
P05 | 上下文生成 | 新模板存在旧材料没有的内容槽位 | 标注缺口,必要时生成占位或迁移说明 | 中高 |
P06 | 跨板块迁移 | 内容需要挪到别的章节组 | 先确认语义归属,再迁移内容 | 中高 |
P07 | 删除忽略 | 旧内容在新模板无合法承载位点 | 保留在说明中,不强塞正文 | 低 |
默认决策顺序
1. 能 P01 就不要做更复杂模式。 2. 只有当旧内容天然包含多个独立语义块时,才用 P03。 3. 只有当目标模板明确要求聚合承载时,才用 P04。 4. 目标模板没有合法承载位点时,不要发明新文件或改骨架;转为 P05/P07。
各模式最小动作
P01 直接复制
- 适用:标题或承载意图高度一致。
- 动作:
- 读取旧正文
- 做最小注释/空白清理
- 写入目标内容文件
- 不做:
- 改模板命令层级
- 改
main.tex/@config.tex
P02 编号调整
- 适用:模板自动管理章节编号,但正文里还有“第 X 节”之类提法。
- 动作:只改正文中的编号引用,不改标题命令本身。
P03 语义拆分
- 适用:旧文件里天然包含多个独立主题。
- 拆分优先级:
1. 按现成小标题边界 2. 按明显关键词边界 3. 按段落边界
- 结果要求:每个新文件只保留属于该槽位的内容,不补模板结构。
P04 智能合并
- 适用:新模板把多个旧主题收拢到同一内容槽位。
- 动作:
- 按源文件顺序合并
- 只补最短过渡句
- 去掉显著重复的引言/总结
P05 上下文生成
- 适用:目标模板存在新槽位,但旧材料没有可直接迁移内容。
- 默认策略:
- 先尝试从邻近章节提炼已有信息
- 不够时生成“缺口说明”或占位文本
- 涉及新增论证时,应转给写作 skill,而不是让迁移 skill 越权补写
P06 跨板块迁移
- 适用:同一内容在新版模板里被归到别的章节组。
- 动作:先确认目标章节的真实语义,再移动正文;不要因为文件名相似而盲搬。
P07 删除忽略
- 适用:旧内容在新模板没有合法承载位点,且不应改骨架。
- 动作:在迁移报告中列为“未落位项”,说明原因与建议去向。
快速检查清单
- [ ] 目标路径属于允许写入的内容层
- [ ] 未改
packages/、main.tex、@config.tex、.cls/.sty - [ ] 拆分或合并后语义边界仍清楚
- [ ] 未落位内容已写入迁移说明
- [ ] 最终可用对应产品线官方入口验证
transfer-old-latex-to-new 优化摘要
历史上此文用于记录一次旧版“约定符合性优化”。当前只保留仍有用的维护结论,避免与现行 SKILL.md 重复。保留结论
- 技能名称、触发语义和内容层边界必须在
SKILL.mdfrontmatter 中讲清楚 - 用户文档若描述安装或入口,需与真实脚本一致
metadata.keywords、triggers等可发现性字段要和真实 skill 名保持一致
不再沿用的旧结论
- 旧的命名推荐、旧版 NSFC 专用定位、旧环境要求评分,不再是当前行为依据
当前维护动作
1. 先改 SKILL.md 2. 再改 references/ 3. 需要面向用户同步时再改 README.md / CHANGELOG.md
Quickstart(快速开始)
本目录是transfer-old-latex-to-new的“拆分文档”。核心入口仍是scripts/run.py。
一句话理解
给你一个“旧版标书 LaTeX 项目”和一个“已调好样式的新版模板 LaTeX 项目”,本技能只迁移内容到新版项目的 extraTex/*.tex(严格避开 main.tex / extraTex/@config.tex / .cls / .sty)。
最快用法:一键迁移
bash skills/transfer-old-latex-to-new/scripts/migrate.sh \
--old /path/to/NSFC_2025 \
--new /path/to/NSFC_2026如需把所有 runs 产物隔离到指定目录(推荐:测试/CI):
bash skills/transfer-old-latex-to-new/scripts/migrate.sh \
--old /path/to/NSFC_2025 \
--new /path/to/NSFC_2026 \
--runs-root /path/to/runs标准用法:run.py 四步闭环
python skills/transfer-old-latex-to-new/scripts/run.py analyze \
--old /path/to/NSFC_2025 \
--new /path/to/NSFC_2026
python skills/transfer-old-latex-to-new/scripts/run.py apply \
--run-id <run_id> \
--old /path/to/NSFC_2025 \
--new /path/to/NSFC_2026
python skills/transfer-old-latex-to-new/scripts/run.py compile \
--run-id <run_id> \
--new /path/to/NSFC_2026
python skills/transfer-old-latex-to-new/scripts/run.py restore \
--run-id <run_id> \
--new /path/to/NSFC_2026关键参数速查
--no-ai:禁用 AI(会自动走启发式/保守回退,保证能跑通)。--strategy smart|conservative|aggressive|fallback:迁移策略(fallback 强制不用 AI)。--allow-low:允许执行“低置信度”任务(谨慎)。--optimize:迁移后对已复制文件做内容优化(需在配置启用)。--adapt-word-count:迁移后做字数适配(需在配置启用)。--runs-root /path/to/runs:将 runs 产物输出到指定目录(用于隔离输出/测试)。
runs 管理(P1)
# 列出 runs(默认输出:run_id 及状态标签)
python skills/transfer-old-latex-to-new/scripts/run.py runs list --runs-root /path/to/runs
# 查看某次 run 的关键产物路径
python skills/transfer-old-latex-to-new/scripts/run.py runs show --runs-root /path/to/runs --run-id <run_id>输出目录(runs)
默认输出在 skills/transfer-old-latex-to-new/runs/<run_id>/;如果使用了 --runs-root,则输出在你指定的目录。
runs/<run_id>/
├── input_snapshot/
├── analysis/
├── plan/
├── backup/
├── logs/
│ └── latex_aux/
└── deliverables/交付物(Markdown 报告)主要在 deliverables/,编译相关日志在 logs/。
章节映射决策参考指南
用于在 legacy 迁移里快速判断“旧内容应该落到哪个现有内容槽位”。当前优先级始终是:选对承载位点 > 做复杂映射。
映射类型
| 类型 | 说明 | 默认策略 |
|---|---|---|
one_to_one | 旧一章对应新一章 | 直接映射 |
one_to_many | 旧一章拆成新多章 | 先拆分,再分别落位 |
many_to_one | 旧多章合成新一章 | 顺序合并,最小过渡 |
new_added | 新模板有、旧材料无 | 记录缺口或生成占位 |
removed | 旧材料有、新模板无 | 记录未落位,不强塞 |
快速判断维度
1. 标题相似度
- 先去掉编号和装饰性前缀
- 只看语义主词是否一致
- 标题高度相似且位置接近时,优先判为
one_to_one
2. 内容语义
- 看旧内容是否天然围绕同一目标展开
- 如果一个文件里明显包含多个主题,优先考虑
one_to_many
3. 位置与结构
- 旧新模板都在相近章节组时,风险较低
- 若跨“立项依据 / 研究内容 / 研究基础 / 年度计划”等大板块,优先人工确认
决策顺序
1. 先问:目标模板有没有合法承载位点? 2. 再问:旧内容是单主题还是多主题? 3. 再问:是直接复制、拆分、合并,还是只能记为未落位?
可用的最短决策树:
- 有唯一明显目标位点:
- 单主题 →
one_to_one - 多主题 →
one_to_many - 多个旧文件都显然服务同一目标位点 →
many_to_one - 没有合法位点:
- 可从现有上下文提炼 →
new_added - 否则 →
removed
高风险信号
- 仅文件名相似,但章节职责已变
- 旧内容混合“研究方案 + 可行性 + 风险应对”
- 目标模板要求新增信息,而旧材料没有支撑
- 需要改
main.tex/ 样式文件才能容纳内容
出现这些信号时,不要继续自动迁移,应转为“缺口说明”或升级到模板开发链路。
最小输出要求
- 给出最终映射类型
- 给出目标内容文件
- 如需拆分或合并,说明依据
- 如无法落位,明确列为未落位项与原因
Troubleshooting(故障排除)
1) 提示“缺少 main.tex”
现象:旧项目缺少 main.tex / 新项目缺少 main.tex
处理:
- 确认
--old/--new传入的是项目根目录(包含main.tex的那层) - 若你的入口不是
main.tex,请先按模板约定调整项目结构
2) 提示“禁止写入系统文件 / 不在白名单”
现象:禁止写入系统文件: .../main.tex 或 写入路径不在白名单中
解释:
- 本技能只允许写入:
extraTex/*.tex(排除@config.tex)、references/*.bib、以及 runs 目录
处理:
- 检查你的迁移计划是否指向了模板文件(应当修正映射/计划)
3) compile 失败 / command not found
现象:
command not found: xelatex/bibtexcompile_summary.success=false
处理:
- 先安装 TeX Live / MacTeX,并确认
xelatex --version/bibtex --version可用 - 查看日志:
runs/<run_id>/logs/latex_aux/main.log
4) 迁移后内容为空/不完整
处理:
- 查看结构差异:
runs/<run_id>/analysis/structure_diff.json - 查看未映射内容清单:
runs/<run_id>/deliverables/unmapped_old_content.md - 如存在大量
low_confidence,可考虑: - 手动调整映射后再 apply
- 或使用
--allow-low(谨慎)
2025 vs 2026 NSFC 结构差异速记
这是 legacy NSFC 示例,只用于解释“旧版内容如何映射到新版现有内容槽位”。它不是当前 skill 的通用规则,更不能覆盖 paper / thesis / cv 产品线。
关键变化
| 维度 | 2025 | 2026 | 迁移影响 |
|---|---|---|---|
| 板块结构 | 3 大板块 | 4 大板块 | 高 |
| 章节编号 | 数字编号为主 | 汉字板块 + 数字混合 | 中 |
| 风险应对 | 藏在可行性分析里 | 独立成节 | 中 |
| 研究基础 | 隶属研究内容板块 | 独立一级板块 | 高 |
最常见映射
基本一对一
立项依据→项目的立项依据研究内容→2.1 研究内容研究目标→2.2 研究目标关键科学问题→2.3 关键科学问题特色与创新→本研究的特色与创新研究基础 / 工作条件 / 年度计划 / 预期成果→ 对应新板块里的同名内容槽位
需要拆分
最典型的是旧版 研究方案:
- 旧版常把:
- 研究方法
- 技术路线
- 可行性分析
写在一个文件里
- 新版通常拆成:
3.1 研究方案3.2 可行性分析- 视模板而定的
3.3 风险应对
处理原则:
1. 方法与技术路线留在 研究方案 2. 理论/技术/团队可行性移到 可行性分析 3. 风险识别与预案单列;旧材料没有时只能记缺口
2026 常见新增项
- 研究风险应对
- 正在承担的项目
- 申请人简历
- 承担项目与完成情况
这些内容如果旧材料没有,默认不能凭空补全文本;只能:
- 从现有章节提炼已有信息
- 生成占位或缺口说明
- 交给专门写作流程补齐
迁移提醒
- 不要手动改章节编号,编号口径交给模板控制
- 只迁移正文内容,不迁移旧版章节命令结构
- 旧版字数往往低于新版要求;需要补写时应走写作 skill,而不是让迁移 skill 越权扩写
"""transfer_old_latex_to_new core."""
# 新增工具模块(v1.3.0)
from .cache_manager import CacheManager
from .config_utils import (
ConfigAccessor,
ConfigDefaults,
ThresholdDefaults,
apply_profile,
get_config_accessor,
)
from .json_utils import JsonParser, parse_json, parse_json_array, parse_batch_json
from .progress_utils import ProgressReporter, TaskGroup, iterate_with_progress, progress, task_group
from .prompt_templates import (
MAPPING_JUDGE_TEMPLATE,
OPTIMIZE_ANALYZE_TEMPLATE,
OPTIMIZE_TYPE_PROMPTS,
WORD_COUNT_COMPRESS_TEMPLATE,
WORD_COUNT_EXPAND_TEMPLATE,
)
__all__ = [
# 工具模块
"CacheManager",
"ConfigAccessor",
"ConfigDefaults",
"ThresholdDefaults",
"JsonParser",
"ProgressReporter",
"TaskGroup",
# 便捷函数
"apply_profile",
"get_config_accessor",
"parse_json",
"parse_json_array",
"parse_batch_json",
"iterate_with_progress",
"progress",
"task_group",
# 提示词模板
"MAPPING_JUDGE_TEMPLATE",
"OPTIMIZE_ANALYZE_TEMPLATE",
"OPTIMIZE_TYPE_PROMPTS",
"WORD_COUNT_COMPRESS_TEMPLATE",
"WORD_COUNT_EXPAND_TEMPLATE",
]
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
import json
import logging
from typing import Any, Awaitable, Callable, Dict, List, Optional, Union
JsonDict = Dict[str, Any]
Responder = Callable[[str, str, str], Union[str, JsonDict, None, Awaitable[Union[str, JsonDict, None]]]]
class AIIntegration:
"""
AI 集成层(优雅降级)
说明:
- 本仓库内的 Python 脚本默认不假设"可直接调用宿主 AI"。
- 若未提供 responder(或 enable_ai=False),将自动回退到 fallback。
- 该接口为后续真正的 AI 调用预留扩展点,同时保证当前功能可用。
- 支持批量调用优化(v1.3.0)
"""
def __init__(
self,
enable_ai: bool = True,
config: Optional[Dict[str, Any]] = None,
responder: Optional[Responder] = None,
) -> None:
self.enable_ai = bool(enable_ai)
self.config = config or {}
self.responder = responder
self.fallback_mode = False
self.request_count = 0
self.success_count = 0
self.batch_count = 0 # 批量调用计数
# 批量配置
ai_cfg = (config.get("ai", {}) or {}) if isinstance(config, dict) else {}
self.batch_mode = bool(ai_cfg.get("batch_mode", False))
self.batch_size = int(ai_cfg.get("batch_size", 10))
def is_available(self) -> bool:
return bool(self.enable_ai and (not self.fallback_mode) and (self.responder is not None))
def get_stats(self) -> Dict[str, Any]:
return {
"enabled": self.enable_ai,
"fallback_mode": self.fallback_mode,
"request_count": self.request_count,
"success_count": self.success_count,
"batch_count": self.batch_count,
"success_rate": self.success_count / max(self.request_count, 1),
"batch_mode_enabled": self.batch_mode,
}
async def process_request(
self,
*,
task: str,
prompt: str,
fallback: Callable[[], Any],
output_format: str = "json",
) -> Any:
self.request_count += 1
if not self.enable_ai:
self.fallback_mode = True
self._log_fallback(task, reason="AI disabled")
return fallback()
if self.responder is None:
self.fallback_mode = True
self._log_fallback(task, reason="No responder configured")
return fallback()
try:
raw = self.responder(task, prompt, output_format)
if hasattr(raw, "__await__"):
raw = await raw # type: ignore[misc]
if raw is None:
raise ValueError("Empty AI response")
if output_format == "json":
if isinstance(raw, dict):
self.success_count += 1
return raw
parsed = self._parse_json_response(str(raw))
if parsed is None:
raise ValueError("Failed to parse JSON response")
self.success_count += 1
return parsed
if output_format == "text":
self.success_count += 1
return str(raw).strip()
raise ValueError(f"Unsupported output_format: {output_format}")
except Exception as e:
self.fallback_mode = True
self._log_fallback(task, reason=str(e))
return fallback()
@staticmethod
def _parse_json_response(response_text: str) -> Optional[JsonDict]:
# 1) fenced code block
if "```json" in response_text:
start = response_text.find("```json") + 7
end = response_text.find("```", start)
if end != -1:
candidate = response_text[start:end].strip()
try:
obj = json.loads(candidate)
return obj if isinstance(obj, dict) else None
except Exception:
return None
# 2) first balanced {...}
start = response_text.find("{")
if start == -1:
return None
depth = 0
for i in range(start, len(response_text)):
ch = response_text[i]
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
candidate = response_text[start : i + 1]
try:
obj = json.loads(candidate)
return obj if isinstance(obj, dict) else None
except Exception:
return None
return None
@staticmethod
def _log_fallback(task: str, reason: str) -> None:
logger = logging.getLogger(__name__)
if reason in {"AI disabled", "No responder configured"}:
logger.info("[AIIntegration] fallback task=%s reason=%s", task, reason)
else:
logger.warning("[AIIntegration] fallback task=%s reason=%s", task, reason)
async def process_batch_requests(
self,
*,
task: str,
prompts: List[str],
fallback: Callable[[], List[Any]],
output_format: str = "json",
) -> List[Any]:
"""
批量处理 AI 请求(优化网络开销)
Args:
task: 任务名称
prompts: 提示词列表
fallback: 回退函数
output_format: 输出格式
Returns:
结果列表
"""
self.batch_count += 1
self.request_count += len(prompts)
if not self.enable_ai or self.responder is None:
self.fallback_mode = True
self._log_fallback(task, reason="AI disabled or No responder")
return fallback()
try:
# 构建批量提示词
batch_prompt = self._build_batch_prompt(prompts, output_format)
# 调用 AI
raw = self.responder(task, batch_prompt, output_format)
if hasattr(raw, "__await__"):
raw = await raw # type: ignore[misc]
if raw is None:
raise ValueError("Empty AI response")
# 解析批量结果
if output_format == "json":
results = self._parse_batch_json_response(raw)
if len(results) != len(prompts):
raise ValueError("Batch JSON response length mismatch")
self.success_count += len(results)
return results
elif output_format == "text":
# 文本模式下,按行分割返回
text = str(raw).strip()
results = [line.strip() for line in text.split("\n") if line.strip()]
self.success_count += len(results)
return results
else:
raise ValueError(f"Unsupported output_format: {output_format}")
except Exception as e:
self.fallback_mode = True
self._log_fallback(task, reason=str(e))
return fallback()
def _build_batch_prompt(self, prompts: List[str], output_format: str) -> str:
"""构建批量提示词"""
batch_prompt = "请批量处理以下请求,返回 JSON 数组:\n\n"
for i, prompt in enumerate(prompts, 1):
batch_prompt += f"\n## 请求 {i}\n{prompt}\n"
if output_format == "json":
batch_prompt += "\n## 输出格式\n"
batch_prompt += "请返回 JSON 数组,每个元素对应一个请求的结果:\n"
batch_prompt += "```json\n[结果1, 结果2, ...]\n```\n"
return batch_prompt
def _parse_batch_json_response(self, response: Any) -> List[Any]:
"""解析批量 JSON 响应"""
response_text = str(response)
# 尝试提取 JSON 数组
try:
# 1) 提取 fenced code block 中的 JSON
if "```json" in response_text:
start = response_text.find("```json") + 7
end = response_text.find("```", start)
if end != -1:
json_str = response_text[start:end].strip()
data = json.loads(json_str)
if isinstance(data, list):
return data
elif "```" in response_text:
# 尝试无语言标记的代码块
start = response_text.find("```") + 3
end = response_text.find("```", start)
if end != -1:
json_str = response_text[start:end].strip()
data = json.loads(json_str)
if isinstance(data, list):
return data
# 2) 直接解析整个响应
data = json.loads(response_text)
if isinstance(data, list):
return data
except (json.JSONDecodeError, ValueError):
pass
# 3) 尝试提取多个 JSON 对象
results = []
for match in response_text.split("{"):
if not match.strip():
continue
try:
obj = json.loads("{" + match.split("}")[0] + "}")
results.append(obj)
except (json.JSONDecodeError, ValueError, IndexError):
continue
return results if len(results) > 1 else []
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
分层缓存管理器
- L1: 内存缓存(当前会话)
- L2: SQLite 磁盘缓存(跨会话)
"""
from __future__ import annotations
import hashlib
import json
import sqlite3
import time
from pathlib import Path
from typing import Any, Dict, Optional
class CacheManager:
"""分层缓存管理器"""
# 缓存键最大长度(SQLite 限制)
MAX_KEY_LENGTH = 256
def __init__(self, cache_dir: str = "cache", db_name: str = "mapping_cache.db", ttl_days: int = 30):
"""
初始化缓存管理器
Args:
cache_dir: 缓存目录(相对 runs/<run_id>/)
db_name: 数据库文件名
ttl_days: 缓存过期天数
"""
self.cache_dir = Path(cache_dir)
self.cache_dir.mkdir(parents=True, exist_ok=True)
self.db_path = self.cache_dir / db_name
self.ttl_seconds = ttl_days * 86400
# L1: 内存缓存(当前会话)
self.memory_cache: Dict[str, Any] = {}
# L2: SQLite 磁盘缓存(跨会话)
self.conn = sqlite3.connect(str(self.db_path), check_same_thread=False)
self._init_db()
def _init_db(self) -> None:
"""初始化数据库表"""
self.conn.execute("""
CREATE TABLE IF NOT EXISTS mapping_cache (
cache_key TEXT PRIMARY KEY,
result TEXT NOT NULL,
timestamp REAL NOT NULL
)
""")
self.conn.execute("""
CREATE INDEX IF NOT EXISTS idx_timestamp ON mapping_cache(timestamp)
""")
self.conn.commit()
def _make_key(self, old_file: str, new_file: str, context: Optional[Dict[str, Any]] = None) -> str:
"""
生成缓存键
使用 SHA256 哈希确保键的唯一性和固定长度
"""
# 组合输入
combined = f"{old_file}|{new_file}"
if context:
context_str = json.dumps(context, sort_keys=True)
combined += f"|{context_str}"
# SHA256 哈希
hash_obj = hashlib.sha256(combined.encode('utf-8'))
hash_hex = hash_obj.hexdigest()
# 截取前 32 字符(足够唯一且不过长)
return f"cache_{hash_hex[:32]}"
def get(self, old_file: str, new_file: str, context: Optional[Dict[str, Any]] = None) -> Optional[Any]:
"""
获取缓存(L1 → L2 → None)
Args:
old_file: 旧文件路径
new_file: 新文件路径
context: 可选上下文信息
Returns:
缓存结果,如果不存在则返回 None
"""
cache_key = self._make_key(old_file, new_file, context)
# L1: 内存缓存
if cache_key in self.memory_cache:
return self.memory_cache[cache_key]
# L2: SQLite 缓存
cursor = self.conn.execute(
"SELECT result, timestamp FROM mapping_cache WHERE cache_key = ?",
(cache_key,)
)
row = cursor.fetchone()
if row:
result_json, timestamp = row
# 检查是否过期
if time.time() - timestamp < self.ttl_seconds:
try:
result = json.loads(result_json)
# 提升到 L1
self.memory_cache[cache_key] = result
return result
except (json.JSONDecodeError, ValueError):
# 缓存损坏,删除
self.conn.execute("DELETE FROM mapping_cache WHERE cache_key = ?", (cache_key,))
self.conn.commit()
return None
def set(self, old_file: str, new_file: str, result: Any, context: Optional[Dict[str, Any]] = None) -> None:
"""
设置缓存(同时写入 L1 和 L2)
Args:
old_file: 旧文件路径
new_file: 新文件路径
result: 要缓存的结果
context: 可选上下文信息
"""
cache_key = self._make_key(old_file, new_file, context)
current_time = time.time()
# 写入 L1
self.memory_cache[cache_key] = result
# 写入 L2
try:
result_json = json.dumps(result, ensure_ascii=False)
self.conn.execute(
"INSERT OR REPLACE INTO mapping_cache (cache_key, result, timestamp) VALUES (?, ?, ?)",
(cache_key, result_json, current_time)
)
self.conn.commit()
except (json.JSONDecodeError, ValueError, TypeError):
# 结果无法序列化,只缓存到 L1(内存)
pass
def clear_expired(self) -> int:
"""
清理过期缓存
Returns:
清理的条目数
"""
current_time = time.time()
cursor = self.conn.execute(
"DELETE FROM mapping_cache WHERE ? - timestamp > ?",
(current_time, self.ttl_seconds)
)
self.conn.commit()
return cursor.rowcount
def clear_all(self) -> int:
"""
清理所有缓存
Returns:
清理的条目数
"""
cursor = self.conn.execute("SELECT COUNT(*) FROM mapping_cache")
count = cursor.fetchone()[0]
self.conn.execute("DELETE FROM mapping_cache")
self.conn.commit()
# 清空 L1
self.memory_cache.clear()
return count
def get_stats(self) -> Dict[str, Any]:
"""
获取缓存统计信息
Returns:
统计信息字典
"""
# L1 统计
l1_count = len(self.memory_cache)
# L2 统计
cursor = self.conn.execute("SELECT COUNT(*) FROM mapping_cache")
l2_count = cursor.fetchone()[0]
cursor = self.conn.execute("SELECT COUNT(*) FROM mapping_cache WHERE ? - timestamp > ?",
(time.time(), self.ttl_seconds))
expired_count = cursor.fetchone()[0]
return {
"l1_count": l1_count,
"l2_count": l2_count,
"expired_count": expired_count,
"ttl_days": self.ttl_seconds / 86400,
}
def close(self) -> None:
"""关闭数据库连接"""
if self.conn:
self.conn.close()
def __del__(self) -> None:
"""析构函数,确保连接关闭"""
try:
self.close()
except Exception:
pass
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
import re
import subprocess
import os
import time
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
ERROR_RE = re.compile(r"^! ", re.MULTILINE)
UNDEF_REF_RE = re.compile(r"LaTeX Warning: Reference `[^']+' on page")
UNDEF_CITE_RE = re.compile(r"LaTeX Warning: Citation `[^']+' on page")
@dataclass(frozen=True)
class CompileStep:
command: List[str]
returncode: int
stdout_path: str
stderr_path: str
@dataclass(frozen=True)
class CompileSummary:
success: bool
steps: List[CompileStep]
error_count: int
undefined_ref_count: int
undefined_cite_count: int
def to_dict(self) -> Dict[str, Any]:
return {
"success": self.success,
"error_count": self.error_count,
"undefined_ref_count": self.undefined_ref_count,
"undefined_cite_count": self.undefined_cite_count,
"steps": [asdict(s) for s in self.steps],
}
def _run(cmd: List[str], cwd: Path, stdout_path: Path, stderr_path: Path, timeout_s: int) -> int:
p = subprocess.run(
cmd,
cwd=str(cwd),
stdout=stdout_path.open("w", encoding="utf-8"),
stderr=stderr_path.open("w", encoding="utf-8"),
timeout=timeout_s,
check=False,
env=os.environ.copy(),
text=True,
)
return int(p.returncode)
def compile_project(new_project: Path, logs_dir: Path, config: Dict[str, Any]) -> CompileSummary:
new_project = new_project.resolve()
logs_dir = logs_dir.resolve()
logs_dir.mkdir(parents=True, exist_ok=True)
# 创建 LaTeX 中间文件目录(与日志分离)
latex_aux_dir = logs_dir / "latex_aux"
latex_aux_dir.mkdir(parents=True, exist_ok=True)
compilation = config.get("compilation", {}) or {}
engine = str(compilation.get("engine", "xelatex"))
interaction = str(compilation.get("interaction_mode", "nonstopmode"))
pass_sequence = compilation.get("pass_sequence") or ["xelatex", "bibtex", "xelatex", "xelatex"]
halt_on_error = bool(compilation.get("halt_on_error", False))
timeout_per_pass = int(compilation.get("timeout_per_pass", 120))
total_timeout = int(compilation.get("total_timeout", 0) or 0)
passes = compilation.get("passes")
if passes is not None:
try:
pass_count = int(passes)
if pass_count > 0:
pass_sequence = list(pass_sequence)[:pass_count]
except Exception:
pass
steps: List[CompileStep] = []
start_time = time.time()
for idx, step in enumerate(pass_sequence, start=1):
remaining_total = None
if total_timeout > 0:
elapsed = time.time() - start_time
remaining_total = total_timeout - int(elapsed)
if remaining_total <= 0:
stdout_path = logs_dir / f"compile_{idx}_{step}.out.txt"
stderr_path = logs_dir / f"compile_{idx}_{step}.err.txt"
stdout_path.write_text("", encoding="utf-8")
stderr_path.write_text("total_timeout\n", encoding="utf-8")
steps.append(
CompileStep(
command=[step],
returncode=124,
stdout_path=str(stdout_path),
stderr_path=str(stderr_path),
)
)
break
timeout = min(timeout_per_pass, remaining_total) if remaining_total else timeout_per_pass
if step in {"bibtex", "biber"}:
# bibtex/biber 需要在 latex_aux_dir 中运行(因为 .aux/.bcf 文件在那里)
cmd = [step, "main"]
run_cwd = latex_aux_dir
else:
# xelatex 使用 -output-directory 参数将中间文件写到隔离目录
cmd = [
step,
f"-interaction={interaction}",
f"-output-directory={latex_aux_dir}",
"main.tex",
]
run_cwd = new_project
stdout_path = logs_dir / f"compile_{idx}_{step}.out.txt"
stderr_path = logs_dir / f"compile_{idx}_{step}.err.txt"
try:
# bibtex 在 latex_aux_dir 运行时,.aux 内的 bst/bib 路径通常是相对 new_project 的;
# 通过 BSTINPUTS/BIBINPUTS 将 new_project 加入搜索路径,避免 “I couldn't open style/database file”。
if step in {"bibtex", "biber"}:
env = os.environ.copy()
for key in ("BSTINPUTS", "BIBINPUTS"):
existing = env.get(key, "")
prefix = str(new_project) + os.pathsep
env[key] = prefix + existing
rc = subprocess.run(
cmd,
cwd=str(run_cwd),
stdout=stdout_path.open("w", encoding="utf-8"),
stderr=stderr_path.open("w", encoding="utf-8"),
timeout=timeout,
check=False,
text=True,
env=env,
).returncode
rc = int(rc)
else:
rc = _run(cmd, cwd=run_cwd, stdout_path=stdout_path, stderr_path=stderr_path, timeout_s=timeout)
except FileNotFoundError:
# 缺少 xelatex/bibtex 等环境时,输出可读错误
stdout_path.write_text("", encoding="utf-8")
stderr_path.write_text(f"command not found: {cmd[0]}\n", encoding="utf-8")
rc = 127
except subprocess.TimeoutExpired:
stdout_path.write_text("", encoding="utf-8")
stderr_path.write_text("timeout\n", encoding="utf-8")
rc = 124
steps.append(
CompileStep(
command=cmd,
returncode=rc,
stdout_path=str(stdout_path),
stderr_path=str(stderr_path),
)
)
if rc != 0 and halt_on_error:
# 用户选择遇到错误立即中止
break
# 简易汇总:读取 main.log(若存在)统计未定义引用等
# 注意:main.log 现在在 latex_aux_dir 中,但 main.pdf 仍在 new_project 中
log_path = latex_aux_dir / "main.log"
log_text = log_path.read_text(encoding="utf-8", errors="replace") if log_path.exists() else ""
error_count = len(ERROR_RE.findall(log_text))
undef_ref = len(UNDEF_REF_RE.findall(log_text))
undef_cite = len(UNDEF_CITE_RE.findall(log_text))
# 将生成的 PDF 复制到项目根目录(方便用户查看)
pdf_source = latex_aux_dir / "main.pdf"
pdf_target = new_project / "main.pdf"
if pdf_source.exists():
import shutil
shutil.copy2(pdf_source, pdf_target)
success = steps and all(s.returncode == 0 for s in steps) and error_count == 0
return CompileSummary(
success=bool(success),
steps=steps,
error_count=error_count,
undefined_ref_count=undef_ref,
undefined_cite_count=undef_cite,
)
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Optional
DEFAULT_CONFIG: Dict[str, Any] = {
"template_protection": {
"forbid_package_source_edits": True,
"forbid_project_template_edits": True,
"on_template_gap": "report_instead_of_patch",
"allow_resource_writes": False,
"allowed_write_patterns": [
r"^extraTex/(?!@config\.tex$).+\.tex$",
r"^references/.+\.bib$",
],
},
"migration": {
"max_rounds": 5,
"min_rounds": 3,
"convergence_threshold": 0.05,
"default_strategy": "smart",
"content_generation": {
"method": "placeholder",
"placeholder_text": "\\textbf{[此部分内容需要补充]}",
},
"figure_handling": "skip",
},
"quality_thresholds": {
"min_similarity": 0.7,
"min_word_count": 50,
},
"compilation": {
"engine": "xelatex",
"pass_sequence": ["xelatex", "bibtex", "xelatex", "xelatex"],
"interaction_mode": "nonstopmode",
"timeout_per_pass": 120,
"total_timeout": 600,
},
"mapping_heuristics": {
"title_similarity_weight": 0.4,
"content_similarity_weight": 0.3,
"position_proximity_weight": 0.2,
"structural_pattern_weight": 0.1,
"high_similarity_threshold": 0.85,
"medium_similarity_threshold": 0.7,
"low_similarity_threshold": 0.5,
},
"output": {
"verbose": True,
},
"workspace": {
"runs_dir": ".bensz-api/skills/transfer-old-latex-to-new",
},
# 预设配置(profiles)
"profiles": {
"quick": {
"description": "快速模式(适合小项目,<20 个文件)",
"ai": {
"batch_mode": False,
"max_workers": 2,
},
"cache": {
"enabled": False,
},
"content_optimization": {
"max_rounds": 3,
},
},
"balanced": {
"description": "平衡模式(适合中型项目,20-100 个文件)",
"ai": {
"batch_mode": True,
"batch_size": 10,
"max_workers": 4,
},
"cache": {
"enabled": True,
"memory_max_size": 1000,
},
"content_optimization": {
"max_rounds": 5,
},
},
"thorough": {
"description": "精确模式(适合大型项目,>100 个文件)",
"ai": {
"batch_mode": True,
"batch_size": 20,
"max_workers": 8,
},
"cache": {
"enabled": True,
"memory_max_size": 2000,
},
"content_optimization": {
"max_rounds": 7,
},
},
},
}
def _deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
merged: Dict[str, Any] = dict(base)
for key, value in (override or {}).items():
if isinstance(value, dict) and isinstance(merged.get(key), dict):
merged[key] = _deep_merge(merged[key], value)
else:
merged[key] = value
return merged
def load_config(skill_root: Path) -> Dict[str, Any]:
config_path = Path(skill_root) / "config.yaml"
config: Dict[str, Any] = dict(DEFAULT_CONFIG)
if not config_path.exists():
return config
try:
import yaml # type: ignore
except Exception:
return config
try:
raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
if not isinstance(raw, dict):
return config
return _deep_merge(config, raw)
except Exception:
return config
@dataclass(frozen=True)
class MappingThresholds:
high: float
medium: float
low: float
def get_mapping_thresholds(config: Dict[str, Any]) -> MappingThresholds:
mapping = config.get("mapping", {}) or {}
thresholds = (mapping.get("thresholds") or {}) if isinstance(mapping, dict) else {}
if isinstance(thresholds, dict) and thresholds:
return MappingThresholds(
high=float(thresholds.get("high", 0.85)),
medium=float(thresholds.get("medium", 0.7)),
low=float(thresholds.get("low", 0.5)),
)
mh = config.get("mapping_heuristics", {}) or {}
return MappingThresholds(
high=float(mh.get("high_similarity_threshold", 0.85)),
medium=float(mh.get("medium_similarity_threshold", 0.7)),
low=float(mh.get("low_similarity_threshold", 0.5)),
)
def get_runs_dir(skill_root: Path, config: Dict[str, Any]) -> Path:
runs_dir = (config.get("workspace", {}) or {}).get("runs_dir", "runs")
path = Path(str(runs_dir)).expanduser()
if path.is_absolute():
return path.resolve()
if str(runs_dir) == "runs":
return (Path(skill_root) / path).resolve()
return (Path.cwd() / path).resolve()
def apply_profile(config: Dict[str, Any], profile_name: Optional[str] = None) -> Dict[str, Any]:
"""
应用配置预设(profile)
Args:
config: 原始配置
profile_name: 预设名称(quick/balanced/thorough),None 表示不应用
Returns:
应用预设后的配置
"""
if not profile_name:
return config
profiles = config.get("profiles", {})
profile = profiles.get(profile_name, {})
if not profile:
return config
return _deep_merge(config, profile)
def list_profiles(config: Optional[Dict[str, Any]] = None) -> List[str]:
"""
列出可用的配置预设
Args:
config: 配置字典(None 则使用默认配置)
Returns:
预设名称列表
"""
if config is None:
config = DEFAULT_CONFIG
profiles = config.get("profiles", {})
return list(profiles.keys())
def get_profile_description(config: Dict[str, Any], profile_name: str) -> Optional[str]:
"""
获取预设的描述
Args:
config: 配置字典
profile_name: 预设名称
Returns:
预设描述,不存在返回 None
"""
profiles = config.get("profiles", {})
profile = profiles.get(profile_name, {})
return profile.get("description") if isinstance(profile, dict) else None
def load_config_with_profile(
skill_root: Path,
profile: Optional[str] = None,
) -> Dict[str, Any]:
"""
加载配置并应用预设
Args:
skill_root: 技能根目录
profile: 预设名称(可选)
Returns:
最终配置
"""
config = load_config(skill_root)
return apply_profile(config, profile)
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
配置工具模块
- 统一类型检查
- 简化配置访问
- 支持默认值
"""
from __future__ import annotations
from typing import Any, Callable, Dict, List, Optional, TypeVar, Union
from .config_loader import _deep_merge
T = TypeVar("T")
class ConfigAccessor:
"""
配置访问器
提供类型安全的配置访问方法,避免重复的 isinstance 检查
"""
def __init__(self, config: Optional[Dict[str, Any]] = None):
"""
初始化配置访问器
Args:
config: 配置字典(None 则返回默认值)
"""
self._config = config if isinstance(config, dict) else {}
def get(self, key: str, default: T = None, expected_type: Optional[type] = None) -> Union[T, Any]:
"""
获取配置值
Args:
key: 配置键(支持点号分隔的嵌套键,如 "ai.batch_mode")
default: 默认值
expected_type: 期望的类型(None 表示不检查)
Returns:
配置值或默认值
"""
# 支持嵌套键(如 "ai.batch_mode")
keys = key.split(".")
value = self._config
for k in keys:
if isinstance(value, dict):
value = value.get(k)
if value is None:
return default
else:
return default
# 检查类型
if value is None:
return default
if expected_type is not None and not isinstance(value, expected_type):
try:
# 尝试类型转换
return expected_type(value)
except (ValueError, TypeError):
return default
return value
def get_bool(self, key: str, default: bool = False) -> bool:
"""获取布尔值"""
return bool(self.get(key, default, bool))
def get_int(self, key: str, default: int = 0) -> int:
"""获取整数值"""
return int(self.get(key, default, int))
def get_float(self, key: str, default: float = 0.0) -> float:
"""获取浮点数值"""
return float(self.get(key, default, (int, float)))
def get_str(self, key: str, default: str = "") -> str:
"""获取字符串值"""
return str(self.get(key, default, str))
def get_list(self, key: str, default: Optional[List[Any]] = None) -> List[Any]:
"""获取列表值"""
if default is None:
default = []
value = self.get(key, default)
if isinstance(value, list):
return value
if isinstance(value, tuple):
return list(value)
return default
def get_dict(self, key: str, default: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""获取字典值"""
if default is None:
default = {}
value = self.get(key, default)
if isinstance(value, dict):
return value
return default
def sub(self, key: str) -> "ConfigAccessor":
"""
获取子配置的访问器
Args:
key: 子配置键
Returns:
新的 ConfigAccessor 实例
"""
return ConfigAccessor(self.get_dict(key))
def has(self, key: str) -> bool:
"""
检查配置键是否存在
Args:
key: 配置键
Returns:
是否存在
"""
keys = key.split(".")
value = self._config
for k in keys:
if isinstance(value, dict):
if k not in value:
return False
value = value[k]
else:
return False
return True
@property
def raw(self) -> Dict[str, Any]:
"""获取原始配置字典"""
return self._config.copy()
def apply_profile(config: Dict[str, Any], profile_name: str) -> Dict[str, Any]:
"""
应用配置预设(profile)
Args:
config: 原始配置
profile_name: 预设名称(quick/balanced/thorough)
Returns:
应用预设后的配置
"""
profiles = config.get("profiles", {})
profile = profiles.get(profile_name, {})
if not profile:
return config
return _deep_merge(config, profile)
def get_config_accessor(config: Optional[Dict[str, Any]] = None, profile: Optional[str] = None) -> ConfigAccessor:
"""
获取配置访问器
Args:
config: 配置字典
profile: 可选的预设名称
Returns:
ConfigAccessor 实例
"""
if profile and config:
config = apply_profile(config, profile)
return ConfigAccessor(config)
# 常量定义(消除魔法数字)
class ConfigDefaults:
"""配置默认值常量"""
# AI 调用
AI_BATCH_SIZE = 10
AI_MAX_WORKERS = 4
AI_TEMPERATURE = 0.3
# 缓存
CACHE_TTL_DAYS = 30
CACHE_MEMORY_MAX_SIZE = 1000
# 引用验证
REFERENCE_INTACT_RATE_MIN = 0.95
# 内容优化
CONTENT_MIN_IMPROVEMENT = 0.1
CONTENT_MIN_WORD_COUNT = 500
# 编译
COMPILE_TIMEOUT_PER_PASS = 120
COMPILE_TOTAL_TIMEOUT = 600
# 字数适配
WORD_COUNT_TOLERANCE = 50
WORD_COUNT_DEFAULT_TARGET = 3000
# LaTeX 命令检测(内容长度限制)
LATEX_CONTENT_PREVIEW_LENGTH = 500
LATEX_AI_PROMPT_LENGTH = 1500
LATEX_AI_MAX_LENGTH = 2000
class ThresholdDefaults:
"""阈值默认值"""
# 映射置信度阈值
MAPPING_HIGH = 0.85
MAPPING_MEDIUM = 0.7
MAPPING_LOW = 0.5
# 质量阈值
MIN_SIMILARITY = 0.7
MIN_WORD_COUNT = 50
CONTENT_INTEGRITY = 0.95
LOGICAL_COHERENCE = 0.8
# 编译阈值
MAX_COMPILE_WARNINGS = 10
MAX_COMPILE_ERRORS = 0
MAX_REF_ERRORS = 5
MAX_UNDEFINED_REFS = 3
"""
AI 内容智能优化器
自动识别并优化内容质量问题
"""
import json
import re
from typing import Dict, List, Optional
from pathlib import Path
from .ai_integration import AIIntegration
from .config_utils import ConfigDefaults
from .prompt_templates import OPTIMIZE_ANALYZE_TEMPLATE, OPTIMIZE_TYPE_PROMPTS
from .reference_guardian import ReferenceGuardian
class ContentOptimizer:
"""AI 内容智能优化器"""
def __init__(self, config: dict, skill_root: str):
self.config = config
self.skill_root = Path(skill_root)
self.ref_guardian = ReferenceGuardian(config)
async def optimize_content(
self,
content: str,
section_title: str,
optimization_goals: dict,
ai_integration: Optional[AIIntegration] = None,
) -> dict:
"""智能优化内容"""
if ai_integration is None:
ai_integration = AIIntegration(enable_ai=True, config=self.config)
# 第一步:保护引用
protected_content, ref_map = self.ref_guardian.protect_references(content)
original_refs = self.ref_guardian._extract_all_references(content)
# 第二步:AI 分析优化点
analysis = await self._analyze_optimization_points(
protected_content, section_title, optimization_goals, ai_integration
)
# 第三步:执行优化
optimized_content = protected_content
optimization_log = []
for point in analysis.get("optimization_points", []):
result = await self._apply_optimization(optimized_content, point, ai_integration)
if result.get("success"):
optimized_content = result["content"]
optimization_log.append({
"type": point["type"],
"description": point["description"],
"action": result["action"]
})
# 第四步:恢复引用
final_content = self.ref_guardian.restore_references(optimized_content, ref_map)
# 第五步:验证引用完整性
validation = self.ref_guardian.validate_references(final_content, original_refs)
return {
"original_content": content,
"optimized_content": final_content,
"optimization_log": optimization_log,
"reference_validation": validation,
"improvement_score": analysis.get("improvement_potential", 0)
}
async def _analyze_optimization_points(
self,
content: str,
section_title: str,
goals: dict,
ai_integration: AIIntegration,
) -> dict:
"""AI 分析优化点(使用当前 AI 环境)"""
# 构建分析提示
goal_desc = []
if goals.get("remove_redundancy"):
goal_desc.append("冗余表述")
if goals.get("improve_logic"):
goal_desc.append("逻辑连贯性")
if goals.get("add_evidence"):
goal_desc.append("证据支持")
if goals.get("improve_clarity"):
goal_desc.append("表述清晰度")
if goals.get("reorganize_structure"):
goal_desc.append("段落结构")
goals_str = "、".join(goal_desc) if goal_desc else "整体质量"
# 使用提示词模板
prompt = OPTIMIZE_ANALYZE_TEMPLATE.format(
section_title=section_title,
goals_str=goals_str,
content=content[:ConfigDefaults.LATEX_AI_PROMPT_LENGTH],
)
def fallback() -> dict:
return self._heuristic_analysis(content, goals)
result = await ai_integration.process_request(
task="analyze_optimization_points",
prompt=prompt,
fallback=fallback,
output_format="json",
)
return result if isinstance(result, dict) else fallback()
def _heuristic_analysis(self, content: str, goals: dict) -> dict:
"""启发式分析(AI 失败时的回退方案)"""
optimization_points = []
if goals.get("remove_redundancy"):
# 检测重复词(粗略启发式)
# 1) 统计较短的中文片段出现次数(更能捕捉“测试测试测试...”这类重复)
chunks = re.findall(r"[\u4e00-\u9fff]+", content)
bigrams: Dict[str, int] = {}
for chunk in chunks:
if len(chunk) < 2:
continue
for i in range(0, len(chunk) - 1):
bg = chunk[i : i + 2]
bigrams[bg] = bigrams.get(bg, 0) + 1
if bigrams:
bg, count = max(bigrams.items(), key=lambda x: x[1])
if count >= 15:
optimization_points.append(
{
"type": "redundancy",
"description": f"短语'{bg}'重复{count}次(疑似冗余)",
"location": "全文",
"severity": "medium",
"suggestion": "替换同义词或重组表述",
}
)
# 2) 统计连续中文“词块”重复(适合有分隔符的场景)
if not optimization_points:
words = re.findall(r"[\u4e00-\u9fff]{2,}", content)
word_counts: Dict[str, int] = {}
for word in words:
word_counts[word] = word_counts.get(word, 0) + 1
for word, count in word_counts.items():
if count > 8:
optimization_points.append(
{
"type": "redundancy",
"description": f"词语'{word}'重复{count}次",
"location": "全文",
"severity": "medium",
"suggestion": "替换同义词或重组表述",
}
)
break
if goals.get("improve_logic"):
# 检测段落连接
paragraphs = content.split('\n\n')
if len(paragraphs) > 1 and not any('因此' in p or '总之' in p for p in paragraphs):
optimization_points.append({
"type": "logic",
"description": "段落间缺乏过渡",
"location": "全文",
"severity": "medium",
"suggestion": "添加过渡句"
})
if goals.get("add_evidence") and len(content) < 500:
optimization_points.append({
"type": "evidence",
"description": "内容可能缺乏充分论证",
"location": "全文",
"severity": "high",
"suggestion": "补充数据或案例支持"
})
return {
"optimization_points": optimization_points,
"improvement_potential": min(0.7, len(optimization_points) * 0.2)
}
async def _apply_optimization(self, content: str, optimization_point: dict, ai_integration: AIIntegration) -> dict:
"""应用单个优化点(使用当前 AI 环境)"""
opt_type = optimization_point["type"]
description = optimization_point["description"]
# 从模板中获取提示词
prompt_template = OPTIMIZE_TYPE_PROMPTS.get(opt_type)
if not prompt_template:
return {"success": False, "reason": f"未知优化类型: {opt_type}"}
prompt = prompt_template.format(description=description)
prompt = prompt.replace("{content}", content[:ConfigDefaults.LATEX_AI_MAX_LENGTH])
def fallback() -> str:
return ""
response = await ai_integration.process_request(
task=f"apply_optimization_{opt_type}",
prompt=prompt,
fallback=fallback,
output_format="text",
)
response_text = str(response or "").strip()
if response_text:
return {"success": True, "content": response_text, "action": f"优化{opt_type}"}
return {"success": False, "reason": "AI 不可用或未返回内容"}
def generate_optimization_report(self, content: str, section_title: str) -> dict:
"""生成优化报告(不执行优化,仅分析)"""
# 简单的启发式分析
issues = []
# 检测可能的问题
if content.count("\n\n") < 2:
issues.append({
"type": "structure",
"severity": "low",
"description": "段落结构可能过于简单"
})
if len(content) < 500:
issues.append({
"type": "evidence",
"severity": "medium",
"description": "内容可能过于简略"
})
# 检测重复词
words = re.findall(r'[\u4e00-\u9fff]{2,}', content)
word_counts = {}
for word in words:
word_counts[word] = word_counts.get(word, 0) + 1
for word, count in word_counts.items():
if count > 10:
issues.append({
"type": "redundancy",
"severity": "low",
"description": f"词语'{word}'重复{count}次,可能存在冗余"
})
return {
"section": section_title,
"total_issues": len(issues),
"issues": issues,
"improvement_potential": min(0.8, len(issues) * 0.15)
}
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
统一的 JSON 解析工具
- 支持多种响应格式
- 提取 JSON 块
- 解析批量结果
"""
from __future__ import annotations
import json
from typing import Any, Dict, List, Optional, Union
class JsonParser:
"""
统一的 JSON 解析器
支持从 AI 响应中提取 JSON,无论格式如何
"""
@staticmethod
def parse_json_response(response_text: str) -> Optional[Dict[str, Any]]:
"""
从 AI 响应中解析 JSON 对象
支持格式:
1. fenced code block: ```json ... ```
2. 无语言标记的代码块: ``` ... ```
3. 直接的 JSON 对象: { ... }
Args:
response_text: AI 响应文本
Returns:
解析后的字典,失败返回 None
"""
if not response_text or not isinstance(response_text, str):
return None
response_text = response_text.strip()
# 1) 提取 fenced code block 中的 JSON
if "```json" in response_text:
start = response_text.find("```json") + 7
end = response_text.find("```", start)
if end != -1:
json_str = response_text[start:end].strip()
try:
obj = json.loads(json_str)
return obj if isinstance(obj, dict) else None
except (json.JSONDecodeError, ValueError):
pass
# 2) 提取无语言标记的代码块
if "```" in response_text:
start = response_text.find("```") + 3
# 找到换行符(跳过语言标识)
newline = response_text.find("\n", start)
if newline != -1:
start = newline + 1
end = response_text.find("```", start)
if end != -1:
json_str = response_text[start:end].strip()
try:
obj = json.loads(json_str)
return obj if isinstance(obj, dict) else None
except (json.JSONDecodeError, ValueError):
pass
# 3) 直接解析整个响应
try:
obj = json.loads(response_text)
return obj if isinstance(obj, dict) else None
except (json.JSONDecodeError, ValueError):
pass
# 4) 尝试找到第一个完整的 JSON 对象
start = response_text.find("{")
if start == -1:
return None
depth = 0
for i in range(start, len(response_text)):
ch = response_text[i]
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
json_str = response_text[start:i + 1]
try:
obj = json.loads(json_str)
return obj if isinstance(obj, dict) else None
except (json.JSONDecodeError, ValueError):
pass
return None
@staticmethod
def parse_json_array(response_text: str) -> Optional[List[Any]]:
"""
从 AI 响应中解析 JSON 数组
Args:
response_text: AI 响应文本
Returns:
解析后的列表,失败返回 None
"""
if not response_text or not isinstance(response_text, str):
return None
response_text = response_text.strip()
# 1) 提取 fenced code block 中的 JSON
if "```json" in response_text:
start = response_text.find("```json") + 7
end = response_text.find("```", start)
if end != -1:
json_str = response_text[start:end].strip()
try:
obj = json.loads(json_str)
return obj if isinstance(obj, list) else None
except (json.JSONDecodeError, ValueError):
pass
# 2) 提取无语言标记的代码块
if "```" in response_text:
start = response_text.find("```") + 3
newline = response_text.find("\n", start)
if newline != -1:
start = newline + 1
end = response_text.find("```", start)
if end != -1:
json_str = response_text[start:end].strip()
try:
obj = json.loads(json_str)
return obj if isinstance(obj, list) else None
except (json.JSONDecodeError, ValueError):
pass
# 3) 直接解析整个响应
try:
obj = json.loads(response_text)
return obj if isinstance(obj, list) else None
except (json.JSONDecodeError, ValueError):
pass
# 4) 尝试找到第一个完整的 JSON 数组
start = response_text.find("[")
if start == -1:
return None
depth = 0
for i in range(start, len(response_text)):
ch = response_text[i]
if ch == "[":
depth += 1
elif ch == "]":
depth -= 1
if depth == 0:
json_str = response_text[start:i + 1]
try:
obj = json.loads(json_str)
return obj if isinstance(obj, list) else None
except (json.JSONDecodeError, ValueError):
pass
return None
@staticmethod
def parse_batch_json_response(response_text: str) -> List[Any]:
"""
解析批量 JSON 响应
尝试多种方式从响应中提取多个 JSON 对象
Args:
response_text: AI 响应文本
Returns:
解析后的对象列表
"""
if not response_text or not isinstance(response_text, str):
return []
# 首先尝试解析为 JSON 数组
array_result = JsonParser.parse_json_array(response_text)
if array_result is not None:
return array_result
# 尝试解析为多个 JSON 对象(按行分割)
results = []
lines = response_text.split("\n")
for line in lines:
line = line.strip()
if not line or line.startswith("#") or line.startswith("//"):
continue
# 尝试解析为 JSON 对象
obj = JsonParser.parse_json_response(line)
if obj is not None:
results.append(obj)
continue
# 尝试按 { 分割
for match in line.split("{"):
if not match.strip():
continue
try:
obj_str = "{" + match.split("}")[0] + "}"
obj = json.loads(obj_str)
if isinstance(obj, dict):
results.append(obj)
except (json.JSONDecodeError, ValueError, IndexError):
continue
return results if len(results) > 1 else []
@staticmethod
def extract_field_from_text(text: str, field: str) -> Optional[Any]:
"""
从非结构化文本中提取字段值
例如:从 "score: 0.85" 中提取 0.85
Args:
text: 文本内容
field: 字段名
Returns:
字段值,未找到返回 None
"""
if not text or not field:
return None
# 尝试多种模式
patterns = [
f'{field}\\s*[::]\\s*([^\\s,;\\n]+)', # field: value 或 field:value
f'{field}\\s*=\\s*([^\\s,;\\n]+)', # field=value
f'["\']?{field}["\']?\\s*[:=]\\s*["\']?([^"\';\\n]+)["\']?', # JSON风格
]
import re
for pattern in patterns:
match = re.search(pattern, text, re.IGNORECASE)
if match:
value = match.group(1).strip(' "\'')
# 尝试转换为合适的类型
try:
# 尝试转换为数字
if "." in value:
return float(value)
return int(value)
except ValueError:
pass
# 尝试转换为布尔值
if value.lower() in ("true", "yes", "是"):
return True
if value.lower() in ("false", "no", "否"):
return False
return value
return None
@staticmethod
def safe_loads(json_str: str, default: Any = None) -> Any:
"""
安全的 JSON 加载
Args:
json_str: JSON 字符串
default: 解析失败时的默认值
Returns:
解析后的对象或默认值
"""
try:
return json.loads(json_str)
except (json.JSONDecodeError, ValueError, TypeError):
return default
# 便捷函数
def parse_json(response_text: str) -> Optional[Dict[str, Any]]:
"""解析 JSON 对象(便捷函数)"""
return JsonParser.parse_json_response(response_text)
def parse_json_array(response_text: str) -> Optional[List[Any]]:
"""解析 JSON 数组(便捷函数)"""
return JsonParser.parse_json_array(response_text)
def parse_batch_json(response_text: str) -> List[Any]:
"""解析批量 JSON 响应(便捷函数)"""
return JsonParser.parse_batch_json_response(response_text)
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
import re
from pathlib import Path
from typing import Iterable, List, Set, Tuple
INPUT_RE = re.compile(r"\\(input|include)\{([^}]+)\}")
GRAPHICS_RE = re.compile(r"\\(includegraphics|epsfig)\*?(?:\[[^\]]*\])?\{([^}]+)\}")
LSTINPUTLISTING_RE = re.compile(r"\\lstinputlisting(?:\[[^\]]*\])?\{([^}]+)\}")
IMPORT_RE = re.compile(r"\\(import|includefrom)\*?\{([^}]+)\}\{([^}]+)\}")
def strip_comments(tex: str) -> str:
lines = []
for line in tex.splitlines():
# 保留转义百分号 \% 的内容
parts = re.split(r"(?<!\\)%", line, maxsplit=1)
lines.append(parts[0])
return "\n".join(lines)
def normalize_ws(s: str) -> str:
return re.sub(r"[ \t]+", " ", s).strip()
def extract_inputs(tex: str) -> List[str]:
tex = strip_comments(tex)
return [m.group(2).strip() for m in INPUT_RE.finditer(tex)]
def ensure_tex_suffix(path_fragment: str) -> str:
p = path_fragment.strip()
if not p.lower().endswith(".tex"):
return p + ".tex"
return p
def safe_read_text(path: Path) -> str:
return path.read_text(encoding="utf-8", errors="replace")
HEADING_RE = re.compile(r"\\(section|subsection|subsubsection)\*?\{([^}]*)\}")
LABEL_RE = re.compile(r"\\label\{([^}]+)\}")
REF_RE = re.compile(r"\\ref\{([^}]+)\}")
CITE_RE = re.compile(r"\\cite[a-zA-Z]*\*?\{([^}]+)\}")
def extract_headings(tex: str) -> List[Tuple[str, str]]:
tex = strip_comments(tex)
return [(m.group(1), normalize_ws(m.group(2))) for m in HEADING_RE.finditer(tex)]
def extract_labels(tex: str) -> Set[str]:
return set(LABEL_RE.findall(strip_comments(tex)))
def extract_refs(tex: str) -> Set[str]:
return set(REF_RE.findall(strip_comments(tex)))
def extract_cites(tex: str) -> Set[str]:
keys: Set[str] = set()
for block in CITE_RE.findall(strip_comments(tex)):
for k in block.split(","):
k = k.strip()
if k:
keys.add(k)
return keys
def extract_graphics(tex: str) -> Set[str]:
"""
提取图片引用路径
支持: \includegraphics[options]{path}, \epsfig[options]{file=path}
"""
tex = strip_comments(tex)
paths: Set[str] = set()
for match in GRAPHICS_RE.finditer(tex):
raw = match.group(2).strip()
if "file=" in raw:
for part in raw.split(","):
part = part.strip()
if part.startswith("file="):
raw = part.split("=", 1)[1].strip()
break
if raw:
paths.add(raw)
return paths
def extract_lstinputlisting(tex: str) -> Set[str]:
"""
提取代码文件引用路径
支持: \lstinputlisting[options]{path}
"""
tex = strip_comments(tex)
return set(m.group(1).strip() for m in LSTINPUTLISTING_RE.finditer(tex))
def extract_imports(tex: str) -> Set[str]:
"""
提取 LaTeX import 路径
支持: \import{path}{file}, \includefrom{path}{file}
"""
tex = strip_comments(tex)
paths: Set[str] = set()
for match in IMPORT_RE.finditer(tex):
base = match.group(2).strip()
filename = match.group(3).strip()
if not filename:
continue
combined = Path(base) / filename if base else Path(filename)
paths.add(str(combined).replace("\\", "/"))
return paths
def extract_all_resource_paths(tex: str) -> Set[str]:
"""
提取所有外部资源文件路径(图片、代码、其他文件)
"""
resources: Set[str] = set()
resources.update(extract_graphics(tex))
resources.update(extract_lstinputlisting(tex))
resources.update(extract_imports(tex))
return resources
def strip_commands_for_summary(tex: str, max_chars: int = 240) -> str:
tex = strip_comments(tex)
tex = re.sub(r"\\[a-zA-Z@]+(\[[^\]]*\])?\{[^}]*\}", " ", tex)
tex = re.sub(r"\\[a-zA-Z@]+\*?", " ", tex)
tex = re.sub(r"\{|\}|\[|\]", " ", tex)
tex = re.sub(r"\s+", " ", tex).strip()
return tex[:max_chars]
TOKEN_RE = re.compile(r"[\u4e00-\u9fff]{2,}|[A-Za-z0-9]{2,}")
def tokenize(text: str) -> Set[str]:
tokens: Set[str] = set()
for tok in TOKEN_RE.findall(text):
tokens.add(tok)
# 对较长中文串补充 2~4 字 n-gram,提升章节名/文件名的匹配鲁棒性
if tok and all("\u4e00" <= ch <= "\u9fff" for ch in tok) and len(tok) >= 4:
for n in (2, 3, 4):
if len(tok) < n:
continue
for i in range(0, len(tok) - n + 1):
tokens.add(tok[i : i + n])
return tokens
def jaccard(a: Set[str], b: Set[str]) -> float:
if not a and not b:
return 1.0
if not a or not b:
return 0.0
inter = len(a & b)
union = len(a | b)
return inter / union if union else 0.0
def normalize_title(title: str) -> str:
s = title
s = re.sub(r"^[\d\.\s()()一二三四五六七八九十]+", "", s)
return s.strip()
def normalize_filename(stem: str) -> str:
s = stem
s = re.sub(r"^[\d\.\s()()一二三四五六七八九十]+", "", s)
s = re.sub(r"[_.\-]+", " ", s)
return s.strip()
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
from dataclasses import asdict, dataclass, field
from datetime import datetime
from typing import Any, Dict, List, Literal, Optional
TaskType = Literal["copy_one_to_one", "placeholder_new_added", "needs_manual"]
@dataclass(frozen=True)
class MigrationTask:
id: int
type: TaskType
priority: str
source: Optional[str] = None
target: Optional[str] = None
confidence: Optional[str] = None
score: Optional[float] = None
reason: Optional[str] = None
notes: Optional[str] = None
@dataclass(frozen=True)
class MigrationPlan:
generated_at: str
strategy: str
tasks: List[MigrationTask] = field(default_factory=list)
warnings: List[str] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
return {
"metadata": {
"generated_at": self.generated_at,
"strategy": self.strategy,
},
"tasks": [asdict(t) for t in self.tasks],
"warnings": list(self.warnings),
}
def build_plan_from_diff(structure_diff: Dict[str, Any], config: Dict[str, Any], strategy: str) -> MigrationPlan:
tasks: List[MigrationTask] = []
warnings: List[str] = []
task_id = 1
one_to_one = (((structure_diff or {}).get("mapping") or {}).get("one_to_one") or [])
low_conf = (((structure_diff or {}).get("mapping") or {}).get("low_confidence") or [])
new_added = (((structure_diff or {}).get("mapping") or {}).get("new_added") or [])
removed = (((structure_diff or {}).get("mapping") or {}).get("removed") or [])
for item in one_to_one:
tasks.append(
MigrationTask(
id=task_id,
type="copy_one_to_one",
priority="high" if item.get("confidence") == "high" else "medium",
source=item.get("old"),
target=item.get("new"),
confidence=item.get("confidence"),
score=item.get("score"),
reason=item.get("reason"),
)
)
task_id += 1
for item in low_conf:
tasks.append(
MigrationTask(
id=task_id,
type="needs_manual",
priority="medium",
source=item.get("old"),
target=item.get("new"),
confidence="low",
reason=item.get("reason"),
notes="低置信度映射:默认不自动写入,建议人工确认后再迁移。",
)
)
task_id += 1
placeholder = ((config.get("migration", {}) or {}).get("content_generation", {}) or {}).get(
"placeholder_text", "\\textbf{[此部分内容需要补充]}"
)
for item in new_added:
tasks.append(
MigrationTask(
id=task_id,
type="placeholder_new_added",
priority="low",
target=item.get("file"),
notes=f"新模板新增章节:默认写入占位符:{placeholder}",
)
)
task_id += 1
if removed:
warnings.append(f"存在 {len(removed)} 个旧章节未映射(将导出到 deliverables 供人工处理)。")
return MigrationPlan(
generated_at=datetime.now().isoformat(timespec="seconds"),
strategy=strategy,
tasks=tasks,
warnings=warnings,
)
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Dict, List, Optional, Set
from .latex_utils import (
ensure_tex_suffix,
extract_cites,
extract_headings,
extract_inputs,
extract_labels,
extract_refs,
safe_read_text,
strip_commands_for_summary,
)
@dataclass(frozen=True)
class TexFileInfo:
path: str
exists: bool
headings: List[Dict[str, str]]
labels: List[str]
refs: List[str]
cites: List[str]
char_count: int
summary: str
@dataclass(frozen=True)
class ProjectAnalysis:
project_path: str
main_tex: str
input_files: List[str]
extra_tex_files: List[str]
references_bib_files: List[str]
tex_files: Dict[str, TexFileInfo]
def to_dict(self) -> Dict:
return {
"project_path": self.project_path,
"main_tex": self.main_tex,
"input_files": self.input_files,
"extra_tex_files": self.extra_tex_files,
"references_bib_files": self.references_bib_files,
"tex_files": {k: asdict(v) for k, v in self.tex_files.items()},
}
def _resolve_input(base_dir: Path, fragment: str) -> Path:
frag = fragment.strip()
frag = frag.strip('"').strip("'")
frag = ensure_tex_suffix(frag)
return (base_dir / frag).resolve()
def _walk_inputs(project_root: Path, main_tex_path: Path, max_depth: int = 64) -> List[Path]:
visited: Set[Path] = set()
ordered: List[Path] = []
def visit(file_path: Path, depth: int) -> None:
if depth > max_depth:
return
file_path = file_path.resolve()
if file_path in visited:
return
visited.add(file_path)
ordered.append(file_path)
if not file_path.exists():
return
content = safe_read_text(file_path)
for frag in extract_inputs(content):
child = _resolve_input(project_root, frag)
visit(child, depth + 1)
visit(main_tex_path, 0)
# ordered 包含 main.tex;只返回 input 文件(不含 main)
return [p for p in ordered if p != main_tex_path]
def analyze_project(project_root: Path) -> ProjectAnalysis:
project_root = project_root.resolve()
main_tex = project_root / "main.tex"
if not main_tex.exists():
raise FileNotFoundError(f"未找到 main.tex: {main_tex}")
input_paths = _walk_inputs(project_root, main_tex)
input_rel = [str(p.relative_to(project_root)).replace("\\", "/") for p in input_paths]
extra_tex_files: List[str] = []
extra_dir = project_root / "extraTex"
if extra_dir.exists():
for p in sorted(extra_dir.glob("*.tex")):
rel = str(p.relative_to(project_root)).replace("\\", "/")
extra_tex_files.append(rel)
references_bib_files: List[str] = []
ref_dir = project_root / "references"
if ref_dir.exists():
for p in sorted(ref_dir.glob("*.bib")):
rel = str(p.relative_to(project_root)).replace("\\", "/")
references_bib_files.append(rel)
tex_files: Dict[str, TexFileInfo] = {}
def add_tex(rel_path: str) -> None:
abs_path = project_root / rel_path
exists = abs_path.exists()
content = safe_read_text(abs_path) if exists else ""
headings = [{"level": lvl, "title": title} for (lvl, title) in extract_headings(content)]
info = TexFileInfo(
path=rel_path,
exists=exists,
headings=headings,
labels=sorted(extract_labels(content)),
refs=sorted(extract_refs(content)),
cites=sorted(extract_cites(content)),
char_count=len(content),
summary=strip_commands_for_summary(content),
)
tex_files[rel_path] = info
add_tex("main.tex")
for rel_path in sorted(set(input_rel + extra_tex_files)):
add_tex(rel_path)
return ProjectAnalysis(
project_path=str(project_root),
main_tex="main.tex",
input_files=input_rel,
extra_tex_files=extra_tex_files,
references_bib_files=references_bib_files,
tex_files=tex_files,
)