From 49ca7f4565070c60e36741b455e0abea52d6b9f6 Mon Sep 17 00:00:00 2001 From: Bernardo Asbun <161155853+basbun@users.noreply.github.com> Date: Tue, 25 Aug 2026 09:08:01 +0300 Subject: [PATCH 01/15] Add configurable English note output --- CHANGELOG.md | 5 + README.md | 8 +- README.zh-CN.md | 8 +- skills/deeppapernote/SKILL.md | 39 ++- .../deeppapernote/references/deep-analysis.md | 4 +- .../references/figure-placement.md | 2 + .../deeppapernote/references/final-writing.md | 2 + .../deeppapernote/references/note-quality.md | 2 + .../references/obsidian-format.md | 2 + .../references/output-language.md | 52 ++++ .../deeppapernote/references/paper-types.md | 2 + .../scripts/build_synthesis_bundle.py | 60 +++-- skills/deeppapernote/scripts/common.py | 1 + skills/deeppapernote/scripts/contracts.py | 157 ++++++++++++ .../deeppapernote/scripts/lint_grounding.py | 9 +- skills/deeppapernote/scripts/lint_note.py | 208 ++++++++++------ skills/deeppapernote/scripts/localization.py | 43 ++++ skills/deeppapernote/scripts/plan_figures.py | 34 ++- skills/deeppapernote/scripts/run_pipeline.py | 14 +- .../scripts/write_obsidian_note.py | 12 +- tests/test_output_language.py | 226 ++++++++++++++++++ 21 files changed, 762 insertions(+), 128 deletions(-) create mode 100644 skills/deeppapernote/references/output-language.md create mode 100644 skills/deeppapernote/scripts/localization.py create mode 100644 tests/test_output_language.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a86c3eb..21f38e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,11 @@ Add an entry here when the project meaningfully changes for users, for example: ## Unreleased +### Added + +- Added end-to-end English output through `DEEPPAPERNOTE_OUTPUT_LANGUAGE=en` or `--language en`, including localized note schemas, paper-type planning contracts, figure callouts, grounding, final-note linting, and Formal Save validation. +- Kept Simplified Chinese as the backward-compatible default while making the selected language explicit in synthesis, lint, and save artifacts. + ## v2.2.0 ### Improved diff --git a/README.md b/README.md index 7e80726..00f4305 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,13 @@ Generate a deep-reading note for this paper: ``` -DeepPaperNote currently generates Chinese notes by default, and its writing and validation rules are optimized for Chinese output. +DeepPaperNote supports complete English and Simplified Chinese note schemas. Chinese remains the default for backward compatibility; set English persistently with: + +```bash +export DEEPPAPERNOTE_OUTPUT_LANGUAGE=en +``` + +The core pipeline commands also accept `--language en` for a single run. Section names, metadata fields, figure callouts, planning guidance, linting, and Formal Save all follow the selected language. ## 🎯 Why DeepPaperNote? diff --git a/README.zh-CN.md b/README.zh-CN.md index 2ff50ce..cd2f469 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -78,7 +78,13 @@ DeepPaperNote 需要 Python 3.10 或更高版本。核心 PDF 抽取路径依赖 把这篇论文整理成 Obsidian 笔记:<论文> ``` -DeepPaperNote 默认生成中文笔记,当前写作与校验规则也主要针对中文输出优化。 +DeepPaperNote 完整支持英文和简体中文笔记结构。为了向后兼容,默认仍为中文;可用以下设置长期启用英文: + +```bash +export DEEPPAPERNOTE_OUTPUT_LANGUAGE=en +``` + +核心流程命令也支持使用 `--language en` 进行单次英文运行。章节名称、元数据字段、图表占位、规划规则、校验与正式保存都会遵循所选语言。 ## 🎯 为什么选择 DeepPaperNote? diff --git a/skills/deeppapernote/SKILL.md b/skills/deeppapernote/SKILL.md index ad0f18f..a3e5e16 100644 --- a/skills/deeppapernote/SKILL.md +++ b/skills/deeppapernote/SKILL.md @@ -16,6 +16,20 @@ Chinese trigger examples: - `把这篇文章整理成 obsidian 笔记` - `读这篇论文并生成 md 笔记` +English trigger examples: +- `Generate a deep-reading note for this paper` +- `Turn this paper into an Obsidian research note` + +## Output Language + +DeepPaperNote supports `zh-CN` and `en`. Resolve the language in this order: +1. the user's explicit language request +2. `--language` when a supporting script exposes it +3. `DEEPPAPERNOTE_OUTPUT_LANGUAGE` +4. `zh-CN` for backward compatibility + +The synthesis bundle's `writing_contract.language`, localized sections, metadata fields, figure labels, and mechanism-flow heading are authoritative for drafting and linting. Never draft in one language and validate it under another. Read `references/output-language.md` when configuring, drafting, or debugging a non-default language. + This skill is intentionally narrow: - it handles one paper at a time - it does not update daily reading lists @@ -92,10 +106,10 @@ Non-negotiable rules: - evidence-first: draft from the synthesis bundle, `source_manifest`, raw sections, coverage metadata, explicit `note_plan`, and inspected paper evidence; never finish from title/abstract/headings alone - raw-source authority: for ordinary PDFs, `*_raw_sections.jsonl` and `*_source_manifest.json` are the canonical reading material; old top-N evidence buckets, truncated `section_texts`, and `candidate_chunks` are not model-facing writing inputs - fail-closed: if a usable PDF or sufficient evidence cannot be obtained after supported acquisition paths, stop and ask for better source material rather than producing a finished degraded note -- model-first: scripts structure evidence, but the model must decide emphasis, contribution, mechanism, limitations, and final Chinese prose -- required structure: include the canonical required sections, with `原文摘要翻译` before `一句话总结` and a dedicated `创新点` section immediately after `原文摘要翻译` -- abstract translation: when abstract metadata exists, `原文摘要翻译` is a faithful Chinese translation of the original abstract, not a bilingual block and not the model's own summary -- mechanism depth: method, framework, and system papers should include `### 机制流程` under `方法主线`, normally as a 3 to 4 step numbered flow with input, operation, and output destination +- model-first: scripts structure evidence, but the model must decide emphasis, contribution, mechanism, limitations, and final prose in the configured language +- required structure: include the localized canonical sections in the order declared by `writing_contract.must_include_sections` +- abstract fidelity: preserve the original abstract's meaning without adding later evidence or model judgments; translate it in `zh-CN` mode and render it faithfully in English in `en` mode +- mechanism depth: method, framework, and system papers should include the localized mechanism-flow subsection under the localized method section, normally as a 3 to 4 step numbered flow with input, operation, and output destination - placeholder-first figures: plan major figure/table placeholders first; replace one only when identity match and visual usability are both strong; otherwise keep the placeholder - final quality gates: lint is a floor; after lint passes, first run `final_quality_review` for analytical depth, then run `final_readability_review` for language polish, and rerun lint if either review edits the note @@ -141,19 +155,18 @@ Formal Save states: - Do not stop after a text-only draft just to ask whether the user wants figures inserted. Finish the figure replacement decision inside the same task unless the user explicitly asked for text only. - The note must use real heading levels: `#`, `##`, and `###`. - Every final note must start with an Obsidian YAML properties block above the `#` title heading. Include at least a `tags` field with a `papers/` value and useful `aliases`; include `date`, `doi`, or `arxiv_id` when known, and omit unavailable fields rather than inventing placeholders. -- `## 核心信息` must be a fixed metadata block only. Use only these fields, in this order, as `- 字段名: 值` bullets: `标题`, `标题翻译`, `作者`, `机构`, `发表时间`, `发表渠道`, `DOI`, `arXiv`, `论文链接`, `代码 / 项目`, `数据 / 资源`, `论文类型`. Omit unavailable fields; put any guide sentence, takeaway, or analysis in `一句话总结` or a later section instead. -- The note should include `原文摘要翻译` near the beginning when abstract metadata is available, before `一句话总结`. -- When abstract metadata is available, `原文摘要翻译` should directly translate the original paper abstract into Chinese rather than restating it as your own summary. -- The `原文摘要翻译` section itself should be Chinese-only; do not place English abstract sentences or English paragraph excerpts in that section. -- Do not mix later judgments, innovation summaries, or hindsight explanations into `原文摘要翻译`; keep it as the original abstract translated into Chinese. -- The note should include a dedicated `创新点` section immediately after `原文摘要翻译` and before `一句话总结`. -- The `创新点` section should not be empty praise. It should enumerate the paper's actual innovations and briefly explain why each one matters. +- The localized Core Information section must be a fixed metadata block only. Use only the fields and order declared by `writing_contract.core_info_fields`; omit unavailable fields and move commentary to a later analysis section. +- Include the localized Abstract section near the beginning when abstract metadata is available, before the one-sentence summary. +- The Abstract section should faithfully render the paper's original abstract in the configured language rather than replacing it with a model-written summary. +- Do not mix later judgments, contribution summaries, or hindsight explanations into the Abstract section. +- Include a dedicated localized Contributions section immediately after Abstract and before the one-sentence summary. +- Contributions should enumerate the paper's actual innovations and explain why each matters rather than offering empty praise. - High-quality notes should usually contain multiple meaningful `###` subheadings in the technical sections when the paper is non-trivial. - Generate the complete figure/table decision table and satisfy the generated `writing_contract.figure_table_contract` before drafting or saving. - After the synthesis bundle is built, complete the model-led Visual Review Gate and Figure/Table Decision Freeze before creating `note_plan`; no `review_pending` item may cross that boundary. - Pass the grounding and final-note figure gates before advancing; revise any failed decision coverage, insertion, structure, or status check. - An `insert` decision is complete only after Formal Save materializes the selected image into the paper-local `images/` directory and the write succeeds. -- The note must pass a style gate: no mixed Chinese-English prose lines except stable proper nouns or citation metadata. +- The note must pass the style gate for its configured language: `zh-CN` rejects mixed Chinese-English prose artifacts, while `en` rejects Chinese prose outside citation metadata. - The style gate also rejects mechanical term-replacement artifacts such as `KV缓存 of`, `批量ing`, `In相关 Researcher`, or `Single 序列 generation`; rewrite the sentence naturally instead of preserving a partially translated phrase. - Style gate enforcement: when `lint_note.py` output contains `passes_style_gate: false`, fix the reported issues and re-run lint. Keep fixing and re-running until lint passes — multiple rounds are normal and expected. Do not decide that any failure is an acceptable exception — proper nouns, math formulas, and citation metadata are not automatic exemptions. Only escalate to the user if the same failures appear unchanged across multiple rounds with no reduction, indicating the model is unable to make further progress independently. - If PDF or evidence quality is insufficient for a real deep note, fail closed: stop, report the blocked stage, and ask for the better PDF, OCR/source material, or other input needed to continue. @@ -172,7 +185,7 @@ Model-first rule: - central quantitative comparisons with three or more systems, settings, tasks, datasets, metrics, or ablation rows should normally be written as compact Markdown tables, followed by interpretation; do not leave the main result table as a loose bullet list when a table would be clearer - short papers still need a complete deep note: use the saved space to explain protocol details, ablations, limitations, and deployment or replication implications rather than compressing the note into a terse summary - after `final_quality_review` passes, reread the full note once more for readability; do not stop at formal compliance only -- in `final_readability_review`, ordinary English phrase leftovers should usually be rewritten into natural Chinese, while stable proper nouns may remain in English +- in `final_readability_review`, rewrite language leftovers into natural prose in the configured language while preserving stable proper nouns - do not use `final_readability_review` to invent new facts, empty filler text, or shallower but safer wording just to satisfy lint The topic references above can improve difficult runs, but the normal execution path should not depend on reading all of them. diff --git a/skills/deeppapernote/references/deep-analysis.md b/skills/deeppapernote/references/deep-analysis.md index 0909834..fc32aca 100644 --- a/skills/deeppapernote/references/deep-analysis.md +++ b/skills/deeppapernote/references/deep-analysis.md @@ -1,10 +1,12 @@ # Deep Analysis +Language note: the analytical standard is language-independent. Draft in the configured output language; for `en`, use the exact headings and labels in `output-language.md`. Chinese-specific examples below apply only to `zh-CN`. + Use this guide when the user wants a note that feels like a real research note rather than a cleaned-up summary. ## Goal -Produce a Chinese paper note that helps future rereading answer: +Produce a paper note in the configured language that helps future rereading answer: - this paper is really solving what problem - the core route or method chain is what - which evidence actually supports the claim diff --git a/skills/deeppapernote/references/figure-placement.md b/skills/deeppapernote/references/figure-placement.md index ce3cf01..8205b39 100644 --- a/skills/deeppapernote/references/figure-placement.md +++ b/skills/deeppapernote/references/figure-placement.md @@ -1,5 +1,7 @@ # Figure Placement +Language note: target sections and figure-callout labels must follow the generated writing contract. For English output, use the labels in `output-language.md`. + In MVP, the skill must plan figure placement even when it cannot extract image files. ## Goal diff --git a/skills/deeppapernote/references/final-writing.md b/skills/deeppapernote/references/final-writing.md index 8e0f2e4..d3aead2 100644 --- a/skills/deeppapernote/references/final-writing.md +++ b/skills/deeppapernote/references/final-writing.md @@ -1,5 +1,7 @@ # Final Writing +Language note: draft in `synthesis_bundle.writing_contract.language`. For `en`, use the exact schema in `output-language.md`; Chinese-only wording and typography rules below apply only to `zh-CN`. + The final note should not read like raw extracted evidence. Use the structured artifacts as inputs: diff --git a/skills/deeppapernote/references/note-quality.md b/skills/deeppapernote/references/note-quality.md index d420712..31bc596 100644 --- a/skills/deeppapernote/references/note-quality.md +++ b/skills/deeppapernote/references/note-quality.md @@ -1,5 +1,7 @@ # Note Quality +Language note: evaluate headings and language cleanliness against `synthesis_bundle.writing_contract`. For `en`, use `output-language.md`; Chinese-specific examples below apply only to `zh-CN`. + The note is high quality only if it satisfies most of the checks below. ## Minimum Bar diff --git a/skills/deeppapernote/references/obsidian-format.md b/skills/deeppapernote/references/obsidian-format.md index a5fbc03..5478f75 100644 --- a/skills/deeppapernote/references/obsidian-format.md +++ b/skills/deeppapernote/references/obsidian-format.md @@ -1,5 +1,7 @@ # Obsidian Format +Language note: section names, metadata labels, figure callouts, and the mechanism-flow heading must follow `synthesis_bundle.writing_contract`. For `en`, use the exact schema in `output-language.md`; Chinese-specific examples below apply only to `zh-CN`. + ## Heading Rules - Use `#` for the note title only. diff --git a/skills/deeppapernote/references/output-language.md b/skills/deeppapernote/references/output-language.md new file mode 100644 index 0000000..d9569f6 --- /dev/null +++ b/skills/deeppapernote/references/output-language.md @@ -0,0 +1,52 @@ +# Output Language + +DeepPaperNote supports two output schemas: + +| Setting | Language | Default | +|---|---|---| +| `zh-CN` | Simplified Chinese | Yes, for backward compatibility | +| `en` | English | No | + +Set a persistent preference with: + +```bash +export DEEPPAPERNOTE_OUTPUT_LANGUAGE=en +``` + +For a single command, use `--language en` with `run_pipeline.py`, `build_synthesis_bundle.py`, `lint_note.py`, or `write_obsidian_note.py` where applicable. An explicit user request overrides the persistent preference. + +## English note schema + +Use these top-level sections in this order: + +1. `Core Information` +2. `Abstract` +3. `Contributions` +4. `One-Sentence Summary` +5. `Research Questions` +6. `Data and Task Definition` +7. `Method` +8. `Key Results` +9. `Deep Analysis` +10. `Limitations` +11. `My Notes` +12. `References` + +The allowed Core Information fields, in order, are: + +`Title`, `Translated title`, `Authors`, `Institutions`, `Publication date`, `Venue`, `DOI`, `arXiv`, `Paper link`, `Code / Project`, `Data / Resources`, `Paper type`. + +Use `### Analytical Flow` for the mechanism-flow subsection. Each figure placeholder uses: + +```md +> [!figure] Figure 2 Human-readable label +> Suggested location: Method +> Why it matters: This figure clarifies the execution path. +> Current status: Placeholder retained; the recovered crop is incomplete. +``` + +For a materialized image, use the normal image embed followed immediately by one italic caption beginning with `Original paper item:`. + +## Validation invariant + +The bundle language, drafted note language, lint language, and save language must match. Do not reuse a passing lint artifact from another language. diff --git a/skills/deeppapernote/references/paper-types.md b/skills/deeppapernote/references/paper-types.md index 758cff0..664719a 100644 --- a/skills/deeppapernote/references/paper-types.md +++ b/skills/deeppapernote/references/paper-types.md @@ -1,5 +1,7 @@ # Paper Types +Language note: use the localized `contracts_by_paper_type` and section names from the generated synthesis bundle. The examples below use the backward-compatible Chinese schema; `output-language.md` defines the English schema. + Every note keeps the same 12 top-level sections from `NOTE_REQUIRED_SECTIONS`. Paper type only changes the typed semantics of shared sections and the recommended `###` subsections used in `note_plan.section_plan`. diff --git a/skills/deeppapernote/scripts/build_synthesis_bundle.py b/skills/deeppapernote/scripts/build_synthesis_bundle.py index 539171b..79e8a99 100644 --- a/skills/deeppapernote/scripts/build_synthesis_bundle.py +++ b/skills/deeppapernote/scripts/build_synthesis_bundle.py @@ -16,11 +16,11 @@ ) from contracts import ( NOTE_PLAN_REQUIRED_FIELDS, - NOTE_REQUIRED_SECTIONS, - PAPER_TYPE_CONTRACTS, PAPER_TYPE_VALUES, - WRITING_CONTRACT_RULES, + paper_type_contracts, + writing_contract_rules, ) +from localization import normalize_output_language def parser() -> argparse.ArgumentParser: @@ -35,6 +35,7 @@ def parser() -> argparse.ArgumentParser: required=True, help="Figure/table decision JSON path or string.", ) + p.add_argument("--language", default="", help="Output language: en or zh-CN. Defaults to DEEPPAPERNOTE_OUTPUT_LANGUAGE or zh-CN.") p.add_argument("--output", default="", help="Output JSON path.") return p @@ -315,17 +316,18 @@ def figure_table_manifest( } -def compact_writing_contract() -> dict: - depth_requirements = dict(WRITING_CONTRACT_RULES["note_plan_depth_requirements"]) +def compact_writing_contract(language: str | None = None) -> dict: + rules = writing_contract_rules(language) + depth_requirements = dict(rules["note_plan_depth_requirements"]) depth_requirements["required_section_focus_fields"] = list( depth_requirements["required_section_focus_fields"] ) depth_requirements["generic_focus_phrases"] = list( depth_requirements["generic_focus_phrases"] ) - usable_insert_candidate = dict(WRITING_CONTRACT_RULES["usable_insert_candidate"]) + usable_insert_candidate = dict(rules["usable_insert_candidate"]) usable_insert_candidate["kinds"] = list(usable_insert_candidate["kinds"]) - visual_review_contract = deepcopy(WRITING_CONTRACT_RULES["visual_review_contract"]) + visual_review_contract = deepcopy(rules["visual_review_contract"]) for field in ( "review_fields", "review_status_values", @@ -334,7 +336,7 @@ def compact_writing_contract() -> dict: "terminal_failure_reasons", ): visual_review_contract[field] = list(visual_review_contract[field]) - analysis_coverage = deepcopy(WRITING_CONTRACT_RULES["analysis_coverage_contract"]) + analysis_coverage = deepcopy(rules["analysis_coverage_contract"]) analysis_coverage["central_claim_fields"] = list( analysis_coverage["central_claim_fields"] ) @@ -345,18 +347,21 @@ def compact_writing_contract() -> dict: analysis_coverage["final_quality_review_checks"] ) return { - "language": "zh-CN", + "language": rules["language"], "contract_role": "manifest_quality_contract", "canonical_source": ( "SKILL.md defines the workflow; scripts/contracts.py defines " "machine-checkable contract data." ), - "must_include_sections": list(NOTE_REQUIRED_SECTIONS), + "must_include_sections": list(rules["required_sections"]), + "core_info_fields": list(rules["core_info_fields"]), + "figure_labels": dict(rules["figure_labels"]), + "mechanism_flow_heading": rules["mechanism_flow_heading"], "note_plan_contract": { "required_fields": list(NOTE_PLAN_REQUIRED_FIELDS), - "field_types": dict(WRITING_CONTRACT_RULES["note_plan_field_types"]), + "field_types": dict(rules["note_plan_field_types"]), "required_field_checks": deepcopy( - WRITING_CONTRACT_RULES["note_plan_required_field_checks"] + rules["note_plan_required_field_checks"] ), "artifact_preference": "short_json_planning_file", "grounding_field": "section_plan[*].evidence_sources", @@ -367,21 +372,21 @@ def compact_writing_contract() -> dict: "suggested_paper_type_role": "none", "allowed_paper_types": list(PAPER_TYPE_VALUES), }, - "contracts_by_paper_type": PAPER_TYPE_CONTRACTS, + "contracts_by_paper_type": paper_type_contracts(rules["language"]), "grounding_contract": { "source_of_truth": "source_manifest", "source_index_source_of_truth": "source_manifest", "truncation_source_of_truth": "source_manifest.coverage_or_pdf", "partial_reading_acceptance_owner": "note_plan_or_grounding", "accepted_reference_forms": list( - WRITING_CONTRACT_RULES["allowed_grounding_reference_forms"] + rules["allowed_grounding_reference_forms"] ), - "required_sections": list(WRITING_CONTRACT_RULES["grounding_required_sections"]), + "required_sections": list(rules["grounding_required_sections"]), "note_plan_depth_requirements": depth_requirements, "excluded_model_input_fields": list( - WRITING_CONTRACT_RULES["excluded_model_input_fields"] + rules["excluded_model_input_fields"] ), - "reject_old_references": list(WRITING_CONTRACT_RULES["old_bundle_reference_prefixes"]), + "reject_old_references": list(rules["old_bundle_reference_prefixes"]), "lint_command": ( "scripts/lint_grounding.py --note-plan ... " "--source-manifest ... --bundle-json ... --figure-decisions ..." @@ -391,16 +396,16 @@ def compact_writing_contract() -> dict: "placeholder_first": True, "visual_quality_gate": "fail_closed", "decision_table_required": True, - "decision_values": list(WRITING_CONTRACT_RULES["figure_decision_values"]), + "decision_values": list(rules["figure_decision_values"]), "usable_insert_candidate": usable_insert_candidate, "allowed_usable_placeholder_reasons": list( - WRITING_CONTRACT_RULES["allowed_usable_placeholder_reasons"] + rules["allowed_usable_placeholder_reasons"] ), "manual_visual_review_required_statuses": list( - WRITING_CONTRACT_RULES["manual_visual_review_required_statuses"] + rules["manual_visual_review_required_statuses"] ), "automatic_fail_closed_visual_statuses": list( - WRITING_CONTRACT_RULES["automatic_fail_closed_visual_statuses"] + rules["automatic_fail_closed_visual_statuses"] ), "manual_review_claim_requires_image_inspection": True, "visual_review": visual_review_contract, @@ -416,6 +421,7 @@ def bundle( assets_wrapper: dict, source_manifest: dict | None = None, figure_decisions_wrapper: dict | None = None, + output_language: str | None = None, ) -> dict: evidence_pack = ( evidence_wrapper.get("evidence_pack", {}) @@ -491,7 +497,7 @@ def bundle( "figure_assets": sanitize_figure_assets(assets_wrapper), "ocr_available": assets_wrapper.get("ocr_available", False), }, - "writing_contract": compact_writing_contract(), + "writing_contract": compact_writing_contract(output_language), } @@ -510,7 +516,15 @@ def main() -> None: if decision_path.exists(): figure_decisions.setdefault("decisions_path", str(decision_path.resolve())) emit( - bundle(metadata, evidence, figures, assets, source_manifest, figure_decisions), + bundle( + metadata, + evidence, + figures, + assets, + source_manifest, + figure_decisions, + normalize_output_language(args.language or runtime_config().get("output_language")), + ), args.output, ) diff --git a/skills/deeppapernote/scripts/common.py b/skills/deeppapernote/scripts/common.py index bb36709..1d30510 100644 --- a/skills/deeppapernote/scripts/common.py +++ b/skills/deeppapernote/scripts/common.py @@ -2384,6 +2384,7 @@ def enrich_metadata(record: dict[str, Any]) -> dict[str, Any]: def runtime_config() -> dict[str, Any]: return { + "output_language": env_config_value("DEEPPAPERNOTE_OUTPUT_LANGUAGE", default="zh-CN"), "obsidian_vault": env_config_value("DEEPPAPERNOTE_OBSIDIAN_VAULT"), "papers_dir": env_config_value("DEEPPAPERNOTE_PAPERS_DIR", default="Research/Papers"), "output_dir": env_config_value("DEEPPAPERNOTE_OUTPUT_DIR", default="tmp/DeepPaperNote"), diff --git a/skills/deeppapernote/scripts/contracts.py b/skills/deeppapernote/scripts/contracts.py index e4a3dbe..5e923e3 100644 --- a/skills/deeppapernote/scripts/contracts.py +++ b/skills/deeppapernote/scripts/contracts.py @@ -3,8 +3,11 @@ from __future__ import annotations +from copy import deepcopy from typing import Any, TypedDict +from localization import note_schema, normalize_output_language, required_sections + NOTE_REQUIRED_SECTIONS: tuple[str, ...] = ( "核心信息", "原文摘要翻译", @@ -20,6 +23,9 @@ "引用", ) +def note_required_sections(language: str | None = None) -> tuple[str, ...]: + return required_sections(language) + PAPER_TYPE_VALUES: tuple[str, ...] = ( "AI_method", "benchmark_or_dataset", @@ -269,6 +275,145 @@ def required_field_value_error( }, } +PAPER_TYPE_CONTRACTS_EN: dict[str, dict[str, Any]] = { + "AI_method": { + "paper_type": "AI_method", + "reader_lens": "A technical reader who may need to reproduce the method and its mechanism.", + "section_focus": ["problem setting", "method mechanism", "training or inference flow", "key equations", "strong baselines", "ablations and failure boundaries"], + "required_checks": ["Explain the mechanism flow, essential equations, experimental design, what the ablations establish, and the failure boundary."], + "formula_rules": ["Keep only the one to three equations needed to understand the method and explain their engineering meaning."], + "avoid_rules": ["Do not force a non-method paper into a model-architecture narrative."], + "boundary_questions": [ + "Which experiment or ablation supports the claimed benefit of the core mechanism?", + "Which comparisons apply only under the reported data, baselines, compute, or protocol?", + "What evidence shows failure, degradation, instability, or rising cost; if none is reported, what remains unproven?", + ], + "section_semantics": { + "Research Questions": "The specific technical problem and the shortcomings of existing methods.", + "Data and Task Definition": "Datasets, inputs and outputs, evaluation tasks, and experimental settings.", + "Method": "Model, algorithm, training, and inference mechanisms.", + "Key Results": "Main results, strong baselines, ablations, and decisive numbers.", + "Deep Analysis": "Why the method works, where it is fragile, and the cost of reproduction or extension.", + }, + "recommended_subsections": { + "Method": ["Analytical Flow", "Model Architecture", "Training Objective", "Inference and Sampling", "Implementation Details"], + "Key Results": ["Main Results and Strong Baselines", "What the Ablations Establish", "Failure or Unstable Settings"], + "Deep Analysis": ["Why It Works", "Complexity and Scalability", "Reproduction Notes"], + }, + "mechanism_flow_contract": {"apply_when_paper_type_in": ["AI_method"], "required_step_count": "3_to_4", "required_step_fields": ["input", "operation", "output_destination"]}, + }, + "benchmark_or_dataset": { + "paper_type": "benchmark_or_dataset", + "reader_lens": "A researcher assessing whether a benchmark or dataset is useful and where it is biased.", + "section_focus": ["task decomposition", "data sources and construction", "annotation protocol", "evaluation metrics", "coverage and bias", "sample statistics and access limits"], + "required_checks": ["Explain sources, construction or annotation, metrics, baselines, sample statistics, access or privacy constraints, and applicability."], + "formula_rules": ["Keep only essential metrics, sampling rules, or split definitions."], + "avoid_rules": ["Do not describe data construction as a model pipeline."], + "boundary_questions": [ + "What construct does the resource actually measure, and which capabilities are only proxies?", + "Which coverage gaps or biases follow from its tasks, labels, sampling, filtering, or evaluation protocol?", + "Do baseline results demonstrate discrimination, or only adaptation to this protocol?", + "How do sample composition, access, and privacy limits affect reproduction and generalization?", + ], + "section_semantics": { + "Research Questions": "The evaluation or data gap the resource is designed to address.", + "Data and Task Definition": "Sources, task splits, labels, and sample scope.", + "Method": "Construction, filtering, annotation, and evaluation protocol—not a model pipeline.", + "Key Results": "Baseline performance, difficulty, coverage, and bias.", + "Deep Analysis": "What the resource measures and what it cannot represent.", + }, + "recommended_subsections": { + "Data and Task Definition": ["Data Sources", "Task Splits", "Annotation and Filtering"], + "Method": ["Construction Process", "Evaluation Protocol", "Baseline Setup"], + "Key Results": ["Baseline Performance", "Difficulty Distribution", "Coverage and Bias"], + "Deep Analysis": ["What It Actually Measures", "Applicability Boundary"], + }, + }, + "clinical_or_psychology_empirical": { + "paper_type": "clinical_or_psychology_empirical", + "reader_lens": "A research reader focused on samples, variable relationships, uncertainty, and generalization.", + "section_focus": ["sample source", "inclusion and exclusion", "variables and instruments", "analysis pipeline", "effect sizes and uncertainty", "ethics, access, and generalization"], + "required_checks": ["Distinguish association, prediction, group difference, and causal interpretation; report sample, ethics, privacy, and generalization limits."], + "formula_rules": ["Keep only essential statistical models, effect sizes, intervals, or instrument definitions."], + "avoid_rules": ["Do not turn association, prediction, or group differences into unsupported causal claims."], + "boundary_questions": [ + "How do recruitment, eligibility, measurement, and annotation constrain generalization?", + "Does the design support association, prediction, group difference, or causality?", + "Does the interpretation depend on unobserved confounding, thresholds, missingness, or setting?", + "How do sample composition, missing data, privacy, and unavailable materials constrain reproduction?", + ], + "section_semantics": { + "Research Questions": "The clinical, psychological, or behavioral question, hypothesis, or variable relationship.", + "Data and Task Definition": "Recruitment, eligibility, variables, instruments, and measurement.", + "Method": "Study design, grouping, measurement flow, and statistical analysis.", + "Key Results": "Effects, associations, group differences, uncertainty, and significance.", + "Deep Analysis": "Interpretation, causal boundary, substantive meaning, and generalization limits.", + }, + "recommended_subsections": { + "Data and Task Definition": ["Sample and Eligibility", "Variables and Instruments", "Measurement Process"], + "Method": ["Study Design", "Analysis Model", "Primary Comparisons"], + "Key Results": ["Primary Effects", "Uncertainty and Significance", "Clinical or Psychological Interpretation"], + "Deep Analysis": ["Causal Interpretation Boundary", "Generalization Limits"], + }, + }, + "humanities_or_social_science": { + "paper_type": "humanities_or_social_science", + "reader_lens": "A reader evaluating theoretical framing, material interpretation, and argument structure.", + "section_focus": ["object of study", "materials", "theoretical framework", "argument path", "conceptual contribution", "interpretive boundary"], + "required_checks": ["Distinguish the author's argument, material evidence, normative judgment, and empirical fact."], + "formula_rules": ["Do not force equations; retain only essential formal definitions or coding rules."], + "avoid_rules": ["Do not present normative judgment, textual interpretation, or case analysis as experimental fact."], + "boundary_questions": [ + "Which materials, cases, or theoretical premises support the interpretation?", + "What alternative explanations fit the same material, and how are they addressed?", + "Which conclusions are conceptual or normative rather than directly empirical?", + ], + "section_semantics": { + "Research Questions": "The social, cultural, historical, institutional, or theoretical problem.", + "Data and Task Definition": "Materials, cases, texts, interviews, archives, or corpus scope—not an ML task.", + "Method": "Theoretical framework, conceptual distinctions, and argument path.", + "Key Results": "Interpretive findings, conceptual contribution, or revision of prior views.", + "Deep Analysis": "Argument strength, material limits, alternative explanations, and transferability.", + }, + "recommended_subsections": { + "Data and Task Definition": ["Material Scope", "Selection Criteria", "Case or Corpus Boundary"], + "Method": ["Theoretical Framework", "Conceptual Distinctions", "Argument Path"], + "Key Results": ["Core Interpretive Findings", "Conceptual Contribution"], + "Deep Analysis": ["Argument Strength", "Alternative Explanations", "Material Boundary"], + }, + }, + "survey_or_review": { + "paper_type": "survey_or_review", + "reader_lens": "A reader mapping a literature, taxonomy, evidence boundary, and open questions.", + "section_focus": ["review scope", "inclusion and exclusion", "taxonomy", "method families", "consensus and disagreement", "open questions"], + "required_checks": ["Explain scope, study selection, taxonomy, consensus, disagreement, and open questions."], + "formula_rules": ["Keep only classification axes, eligibility rules, evidence-synthesis rules, or meta-analytic statistics."], + "avoid_rules": ["Do not present findings summarized from the literature as a new experiment by the review authors."], + "boundary_questions": [ + "Which research routes may be missed by the search scope, eligibility criteria, or taxonomy?", + "Which statements reflect consensus, author-defined categories, or unresolved disagreement?", + "Which trends are artifacts of the covered literature and cannot establish technical maturity?", + ], + "section_semantics": { + "Research Questions": "The field problem, controversy, or knowledge gap organized by the review.", + "Data and Task Definition": "Literature scope, search and screening criteria, and review objects.", + "Method": "Taxonomy, review organization, and evidence-synthesis logic—not a single method architecture.", + "Key Results": "Consensus, disagreement, trends, representative directions, and open questions.", + "Deep Analysis": "Coverage blind spots, explanatory power of the taxonomy, and future opportunities.", + }, + "recommended_subsections": { + "Data and Task Definition": ["Review Scope", "Inclusion and Exclusion", "Literature Coverage"], + "Method": ["Taxonomy", "Method Families", "Evidence Organization"], + "Key Results": ["Representative Directions", "Consensus and Disagreement", "Open Questions"], + "Deep Analysis": ["Taxonomy Limits", "Uncovered Areas", "Future Research Opportunities"], + }, + }, +} + + +def paper_type_contracts(language: str | None = None) -> dict[str, dict[str, Any]]: + return deepcopy(PAPER_TYPE_CONTRACTS_EN if normalize_output_language(language) == "en" else PAPER_TYPE_CONTRACTS) + WRITING_CONTRACT_RULES: dict[str, Any] = { "required_sections": NOTE_REQUIRED_SECTIONS, "paper_type_values": PAPER_TYPE_VALUES, @@ -422,6 +567,18 @@ def required_field_value_error( }, } +def writing_contract_rules(language: str | None = None) -> dict[str, Any]: + resolved = normalize_output_language(language) + schema = note_schema(resolved) + rules = deepcopy(WRITING_CONTRACT_RULES) + rules["language"] = resolved + rules["required_sections"] = tuple(schema["sections"].values()) + rules["grounding_required_sections"] = tuple(schema["sections"][key] for key in ("research_questions", "data_and_task", "method", "key_results", "deep_analysis", "limitations")) + rules["core_info_fields"] = tuple(schema["core_info_fields"]) + rules["figure_labels"] = dict(schema["figure_labels"]) + rules["mechanism_flow_heading"] = schema["mechanism_flow"] + return rules + class MetadataRecord(TypedDict, total=False): title: str diff --git a/skills/deeppapernote/scripts/lint_grounding.py b/skills/deeppapernote/scripts/lint_grounding.py index 3a48333..15489fc 100644 --- a/skills/deeppapernote/scripts/lint_grounding.py +++ b/skills/deeppapernote/scripts/lint_grounding.py @@ -21,6 +21,7 @@ PAPER_TYPE_VALUES, WRITING_CONTRACT_RULES, required_field_value_error, + writing_contract_rules, ) from source_corpus import SourceCorpusLoadError, load_source_corpus @@ -379,6 +380,7 @@ def source_grounding_errors(source: Any, valid_ids: set[str], max_page: int) -> def validate_note_plan( note_plan: dict[str, Any], source_manifest: dict[str, Any], + language: str | None = None, ) -> list[dict[str, Any]]: issues: list[dict[str, Any]] = [] required_checks = WRITING_CONTRACT_RULES["note_plan_required_field_checks"] @@ -400,7 +402,7 @@ def validate_note_plan( valid_ids = source_section_ids(source_manifest) max_page = total_pages(source_manifest) - required_sections = set(WRITING_CONTRACT_RULES["grounding_required_sections"]) + required_sections = set(writing_contract_rules(language)["grounding_required_sections"]) grounded_sections: set[str] = set() section_plan = note_plan.get("section_plan", []) if not isinstance(section_plan, list): @@ -644,8 +646,10 @@ def main() -> None: bundle = load_record(args.bundle_json) if args.bundle_json else {} decisions = load_record(args.figure_decisions) + writing_contract = bundle.get("writing_contract", {}) if isinstance(bundle, dict) else {} + language = writing_contract.get("language") if isinstance(writing_contract, dict) else None issues = [] - issues.extend(validate_note_plan(note_plan, source_manifest)) + issues.extend(validate_note_plan(note_plan, source_manifest, language)) issues.extend(validate_bundle_contract(note_plan, bundle)) issues.extend(validate_figure_decisions(source_manifest, decisions, args.source_manifest)) error_issues = [item for item in issues if item.get("severity", "error") == "error"] @@ -653,6 +657,7 @@ def main() -> None: "status": "ok", "script": "lint_grounding.py", "paper_id": source_manifest.get("paper_id", note_plan.get("paper_id", "")), + "output_language": writing_contract_rules(language)["language"], "issues": issues, "warnings": [item for item in issues if item.get("severity") == "warning"], "passes_grounding": not error_issues, diff --git a/skills/deeppapernote/scripts/lint_note.py b/skills/deeppapernote/scripts/lint_note.py index fcc71e3..ff19caa 100644 --- a/skills/deeppapernote/scripts/lint_note.py +++ b/skills/deeppapernote/scripts/lint_note.py @@ -16,35 +16,41 @@ WRITING_CONTRACT_RULES, required_field_value_error, ) - -REQUIRED_SECTIONS = NOTE_REQUIRED_SECTIONS - -CORE_INFO_FIELDS = [ - "标题", - "标题翻译", - "作者", - "机构", - "发表时间", - "发表渠道", - "DOI", - "arXiv", - "论文链接", - "代码 / 项目", - "数据 / 资源", - "论文类型", -] - -CORE_INFO_FIELD_INDEX = {field: idx for idx, field in enumerate(CORE_INFO_FIELDS)} - -FIGURE_TARGET_SECTIONS = { - "研究问题", - "数据与任务定义", - "方法主线", - "关键结果", - "深度分析", - "局限", - "我的笔记", -} +from localization import note_schema, normalize_output_language + +ACTIVE_LANGUAGE = "zh-CN" +SCHEMA: dict = {} +SECTIONS: dict[str, str] = {} +REQUIRED_SECTIONS: tuple[str, ...] = NOTE_REQUIRED_SECTIONS +CORE_INFO_FIELDS: list[str] = [] +CORE_INFO_FIELD_INDEX: dict[str, int] = {} +CORE_INFO_FIELD_ALIASES: dict[str, str] = {} +FIGURE_TARGET_SECTIONS: set[str] = set() +FIGURE_LABELS: dict[str, str] = {} +MECHANISM_FLOW_HEADING = "机制流程" + +def configure_output_language(language: str | None = None) -> str: + global ACTIVE_LANGUAGE, SCHEMA, SECTIONS, REQUIRED_SECTIONS, CORE_INFO_FIELDS + global CORE_INFO_FIELD_INDEX, CORE_INFO_FIELD_ALIASES, FIGURE_TARGET_SECTIONS, FIGURE_LABELS, MECHANISM_FLOW_HEADING + ACTIVE_LANGUAGE = normalize_output_language(language) + SCHEMA = note_schema(ACTIVE_LANGUAGE) + SECTIONS = dict(SCHEMA["sections"]) + REQUIRED_SECTIONS = tuple(SECTIONS.values()) + CORE_INFO_FIELDS = list(SCHEMA["core_info_fields"]) + CORE_INFO_FIELD_INDEX = {field: idx for idx, field in enumerate(CORE_INFO_FIELDS)} + CORE_INFO_FIELD_ALIASES = dict(SCHEMA.get("core_info_aliases", {})) + FIGURE_TARGET_SECTIONS = {SECTIONS[key] for key in ("research_questions", "data_and_task", "method", "key_results", "deep_analysis", "limitations", "my_notes")} + FIGURE_LABELS = dict(SCHEMA["figure_labels"]) + MECHANISM_FLOW_HEADING = str(SCHEMA["mechanism_flow"]) + return ACTIVE_LANGUAGE + +def section(key: str) -> str: + return SECTIONS[key] + +def figure_prefix(key: str) -> str: + return f"> {FIGURE_LABELS[key]}" + +configure_output_language() FIGURE_BUCKET_RESIDUE_TOKENS = { "剩余", @@ -132,7 +138,7 @@ | clear\s+crop | - high[-\s]*(?:confidence|match) + (? argparse.ArgumentParser: p.add_argument("--plan-file", default="", help="Optional note_plan JSON path. Defaults to sibling .plan.json.") p.add_argument("--output", default="", help="Output JSON path.") p.add_argument("--paper-id", default="", help="Canonical paper id.") + p.add_argument("--language", default="", help="Output language: en or zh-CN. Defaults to DEEPPAPERNOTE_OUTPUT_LANGUAGE or zh-CN.") return p @@ -322,10 +329,10 @@ def find_missing_sections(text: str) -> list[str]: def front_matter_order_warnings(text: str) -> list[str]: warnings: list[str] = [] - required_order = ["## 原文摘要翻译", "## 创新点", "## 一句话总结"] + required_order = [f"## {section('abstract')}", f"## {section('contributions')}", f"## {section('one_sentence_summary')}"] positions = [] - for section in required_order: - idx = text.find(section) + for required_heading in required_order: + idx = text.find(required_heading) if idx < 0: return warnings positions.append(idx) @@ -376,6 +383,9 @@ def inspect_reference_hygiene(text: str) -> list[dict[str, object]]: "decoder", "pipeline", "framework", + "model", + "system", + "module", ] MECHANISM_IO_TOKENS = [ @@ -385,6 +395,10 @@ def inspect_reference_hygiene(text: str) -> list[dict[str, object]]: "送到", "生成", "得到", + "input", + "output", + "produces", + "returns", ] MECHANISM_ACTION_TOKENS = [ @@ -399,6 +413,14 @@ def inspect_reference_hygiene(text: str) -> list[dict[str, object]]: "拼接", "查询", "更新", + "align", + "compute", + "estimate", + "extract", + "encode", + "decode", + "update", + "aggregate", ] @@ -498,7 +520,7 @@ def inspect_reference_hygiene(text: str) -> list[dict[str, object]]: def is_metadata_line(line: str) -> bool: stripped = line.strip() - prefixes = [f"- {field}:" for field in CORE_INFO_FIELDS] + prefixes = [f"- {field}:" for field in (*CORE_INFO_FIELDS, *CORE_INFO_FIELD_ALIASES)] return any(stripped.startswith(prefix) for prefix in prefixes) @@ -512,9 +534,9 @@ def is_exempt_line(line: str) -> bool: return True if ( stripped.startswith("> [!figure]") - or stripped.startswith("> 建议位置:") - or stripped.startswith("> 放置原因:") - or stripped.startswith("> 当前状态:") + or stripped.startswith(figure_prefix("location")) + or stripped.startswith(figure_prefix("reason")) + or stripped.startswith(figure_prefix("status")) ): return True if re.search(r"https?://", stripped): @@ -556,7 +578,11 @@ def mixed_language_issues(text: str) -> list[dict[str, object]]: stripped = line.strip() section_name = section_name_for_line(lines, idx - 1) subsection_name = subsection_name_for_line(lines, idx - 1) - if section_name in {"核心信息", "引用"}: + if section_name in {section("core_information"), section("references")}: + continue + if ACTIVE_LANGUAGE == "en": + if re.search(r"[\u4e00-\u9fff]", stripped): + issues.append({"line_number": idx, "line": stripped, "reason": "non_english_text_present"}) continue if not re.search(r"[\u4e00-\u9fff]", stripped): continue @@ -578,6 +604,8 @@ def mixed_language_issues(text: str) -> list[dict[str, object]]: def mechanical_translation_artifact_issues(text: str) -> list[dict[str, object]]: + if ACTIVE_LANGUAGE == "en": + return [] issues: list[dict[str, object]] = [] for idx, line in enumerate(text.splitlines(), start=1): stripped = line.strip() @@ -620,11 +648,11 @@ def inspect_figure_callouts(text: str) -> list[str]: nxt = lines[j].strip() if not nxt.startswith(">"): break - if nxt.startswith("> 建议位置:"): + if nxt.startswith(figure_prefix("location")): has_location = True - if nxt.startswith("> 放置原因:"): + if nxt.startswith(figure_prefix("reason")): has_reason = True - if nxt.startswith("> 当前状态:"): + if nxt.startswith(figure_prefix("status")): has_status = True j += 1 if not has_location: @@ -648,9 +676,10 @@ def figure_callout_title(line: str) -> str: def figure_status_text(line: str) -> str: stripped = line.strip() - if not stripped.startswith("> 当前状态:"): + prefix = figure_prefix("status") + if not stripped.startswith(prefix): return "" - return stripped.removeprefix("> 当前状态:").strip() + return stripped.removeprefix(prefix).strip() def has_accepted_usable_placeholder_reason(status_text: str) -> bool: @@ -748,8 +777,9 @@ def figure_callout_placement_issues(text: str) -> list[dict[str, object]]: nxt = lines[j].strip() if not nxt.startswith(">"): break - if nxt.startswith("> 建议位置:"): - location = nxt.removeprefix("> 建议位置:").strip() + prefix = figure_prefix("location") + if nxt.startswith(prefix): + location = nxt.removeprefix(prefix).strip() break j += 1 @@ -919,14 +949,15 @@ def figure_structure_passes(text: str) -> bool: def core_info_structure_issues(text: str) -> list[dict[str, object]]: - body = section_body(text, "核心信息") + core_heading = section("core_information") + body = section_body(text, core_heading) if not body: return [] issues: list[dict[str, object]] = [] seen_fields: set[str] = set() last_known_index = -1 - base_line = _line_number_from_offset(text, text.find("## 核心信息")) + base_line = _line_number_from_offset(text, text.find(f"## {core_heading}")) for offset, raw_line in enumerate(body.splitlines(), start=1): stripped = raw_line.strip() @@ -945,6 +976,7 @@ def core_info_structure_issues(text: str) -> list[dict[str, object]]: continue field = match.group(1).strip() + field = CORE_INFO_FIELD_ALIASES.get(field, field) if field not in CORE_INFO_FIELD_INDEX: issues.append( { @@ -987,7 +1019,7 @@ def is_prose_line(line: str) -> bool: stripped = line.strip() if not stripped: return False - if stripped.startswith(("#", "-", "*", "> ", "```", "![[", "*论文原图编号")): + if stripped.startswith(("#", "-", "*", "> ", "```", "![[", f"*{FIGURE_LABELS['original_caption']}")): return False if stripped.startswith("`") and stripped.endswith("`"): return False @@ -1085,6 +1117,14 @@ def _strip_fenced_code_preserve_newlines(text: str) -> str: def _extract_math_blocks(text: str) -> tuple[list[dict[str, object]], list[dict[str, object]]]: sanitized = _strip_fenced_code_preserve_newlines(text) + if ACTIVE_LANGUAGE == "en": + # Currency amounts such as "$25 billion" are prose, not LaTeX delimiters. + sanitized = re.sub( + r"\$(?=\d+(?:\.\d+)?\s+(?:thousand|million|billion|trillion|dollars?|usd)\b)", + r"\$", + sanitized, + flags=re.IGNORECASE, + ) blocks: list[dict[str, object]] = [] issues: list[dict[str, object]] = [] consumed_lines: set[int] = set() @@ -1286,7 +1326,8 @@ def subsection_body(text: str, section_heading: str, subsection_heading: str) -> if not body: return "" pattern = rf"^###\s+{re.escape(subsection_heading)}\s*$" - match = re.search(pattern, body, flags=re.MULTILINE) + flags = re.MULTILINE | (re.IGNORECASE if ACTIVE_LANGUAGE == "en" else 0) + match = re.search(pattern, body, flags=flags) if not match: return "" start = match.end() @@ -1304,14 +1345,14 @@ def cleaned_section_lines(body: str) -> list[str]: continue if ( stripped.startswith("> [!figure]") - or stripped.startswith("> 建议位置:") - or stripped.startswith("> 放置原因:") - or stripped.startswith("> 当前状态:") + or stripped.startswith(figure_prefix("location")) + or stripped.startswith(figure_prefix("reason")) + or stripped.startswith(figure_prefix("status")) ): continue if stripped.startswith("!["): continue - if stripped.startswith("*论文原图编号:") and stripped.endswith("*"): + if stripped.startswith(f"*{FIGURE_LABELS['original_caption']}") and stripped.endswith("*"): continue if stripped.startswith("> "): stripped = stripped[2:].strip() @@ -1405,6 +1446,8 @@ def has_reference_entry(text: str) -> bool: return True if re.search(r"\b[A-Z][A-Za-z-]+ et al\.?\s*,?\s*(?:19|20)\d{2}\b", normalized): return True + if re.search(r"\b[A-Z][A-Za-z-]+(?:\s+(?:and|&|et al\.?|[A-Z][A-Za-z-]+))*\s*\((?:19|20)\d{2}\)", normalized): + return True if re.search(r"(?:19|20)\d{2}.*(?:DOI|doi|会议|期刊|arXiv)", normalized): return True return False @@ -1412,62 +1455,66 @@ def has_reference_entry(text: str) -> bool: def inspect_substantive_content(text: str) -> list[dict[str, object]]: issues: list[dict[str, object]] = [] - for section in REQUIRED_SECTIONS: - body = section_body(text, section) + for section_heading in REQUIRED_SECTIONS: + body = section_body(text, section_heading) content = normalized_section_content(body) if is_placeholder_like(content): - issues.append(issue(section, "section_empty_shell", "error", content or section)) - if section not in {"关键结果", "引用"} and is_honest_missing_declaration(content): - issues.append(issue(section, "section_honest_missing_not_allowed", "error", content)) + issues.append(issue(section_heading, "section_empty_shell", "error", content or section_heading)) + if section_heading not in {section("key_results"), section("references")} and is_honest_missing_declaration(content): + issues.append(issue(section_heading, "section_honest_missing_not_allowed", "error", content)) - innovation = section_body(text, "创新点") + contributions_heading = section("contributions") + innovation = section_body(text, contributions_heading) innovation_content = normalized_section_content(innovation) innovation_units = meaningful_units(innovation, GENERIC_INNOVATION_PATTERNS) if not innovation_units: - issues.append(issue("创新点", "innovation_empty_shell", "error", innovation_content)) + issues.append(issue(contributions_heading, "innovation_empty_shell", "error", innovation_content)) elif len(innovation_units) < 2: - issues.append(issue("创新点", "innovation_too_few_specific_points", "warning", innovation_content)) + issues.append(issue(contributions_heading, "innovation_too_few_specific_points", "warning", innovation_content)) - key_results = section_body(text, "关键结果") + key_results_heading = section("key_results") + key_results = section_body(text, key_results_heading) key_results_content = normalized_section_content(key_results) if is_honest_missing_declaration(key_results_content): issues.append( issue( - "关键结果", + key_results_heading, "key_results_honest_missing_not_allowed", "error", key_results_content, ) ) elif not meaningful_units(key_results, GENERIC_KEY_RESULT_PATTERNS): - issues.append(issue("关键结果", "key_results_empty_shell", "error", key_results_content)) + issues.append(issue(key_results_heading, "key_results_empty_shell", "error", key_results_content)) elif not has_number_token(key_results_content): issues.append( issue( - "关键结果", + key_results_heading, "key_results_quantitative_result_missing", "warning", key_results_content, ) ) - references = section_body(text, "引用") + references_heading = section("references") + references = section_body(text, references_heading) references_content = normalized_section_content(references) if is_honest_missing_declaration(references_content): - issues.append(issue("引用", "references_unavailable_declared", "warning", references_content)) + issues.append(issue(references_heading, "references_unavailable_declared", "warning", references_content)) elif is_placeholder_like(references_content) or not has_reference_entry(references_content): - issues.append(issue("引用", "references_placeholder", "error", references_content)) + issues.append(issue(references_heading, "references_placeholder", "error", references_content)) - limitations = section_body(text, "局限") + limitations_heading = section("limitations") + limitations = section_body(text, limitations_heading) limitations_content = normalized_section_content(limitations) if not meaningful_units(limitations, GENERIC_LIMITATION_PATTERNS): - issues.append(issue("局限", "limitations_empty_shell", "error", limitations_content)) + issues.append(issue(limitations_heading, "limitations_empty_shell", "error", limitations_content)) - for section in ("方法主线", "深度分析"): - body = section_body(text, section) + for section_heading in (section("method"), section("deep_analysis")): + body = section_body(text, section_heading) content = normalized_section_content(body) if not meaningful_units(body): - issues.append(issue(section, "section_empty_shell", "error", content or section)) + issues.append(issue(section_heading, "section_empty_shell", "error", content or section_heading)) deduped: list[dict[str, object]] = [] seen: set[tuple[str, str, str]] = set() @@ -1481,7 +1528,7 @@ def inspect_substantive_content(text: str) -> list[dict[str, object]]: def method_section_requires_mechanism_flow(text: str) -> bool: - body = section_body(text, "方法主线") + body = section_body(text, section("method")) if not body: return False lower = body.lower() @@ -1494,11 +1541,13 @@ def mechanism_flow_warnings(text: str) -> list[str]: warnings: list[str] = [] if not method_section_requires_mechanism_flow(text): return warnings - if "### 机制流程" not in text: + heading_pattern = rf"^###\s+{re.escape(MECHANISM_FLOW_HEADING)}\s*$" + heading_flags = re.MULTILINE | (re.IGNORECASE if ACTIVE_LANGUAGE == "en" else 0) + if not re.search(heading_pattern, text, flags=heading_flags): warnings.append("mechanism_flow_subsection_missing") return warnings - body = subsection_body(text, "方法主线", "机制流程") + body = subsection_body(text, section("method"), MECHANISM_FLOW_HEADING) if not body: warnings.append("mechanism_flow_subsection_empty") return warnings @@ -1508,8 +1557,9 @@ def mechanism_flow_warnings(text: str) -> list[str]: warnings.append("mechanism_flow_step_count_unexpected") step_text = " ".join(step_lines) - has_io_signal = any(token in step_text for token in MECHANISM_IO_TOKENS) - has_action_signal = any(token in step_text for token in MECHANISM_ACTION_TOKENS) + step_text_lower = step_text.lower() + has_io_signal = any(token.lower() in step_text_lower for token in MECHANISM_IO_TOKENS) + has_action_signal = any(token.lower() in step_text_lower for token in MECHANISM_ACTION_TOKENS) if not (has_io_signal and has_action_signal): warnings.append("mechanism_flow_too_abstract") @@ -1529,6 +1579,7 @@ def main() -> None: from common import emit args = parser().parse_args() + output_language = configure_output_language(args.language or None) path = Path(args.input).expanduser().resolve() # utf-8-sig strips a leading BOM and the replace() normalizes CRLF so # Windows-authored notes are linted identically to LF/BOM-less notes; @@ -1596,6 +1647,7 @@ def main() -> None: payload = { "status": "ok", "script": "lint_note.py", + "output_language": output_language, "paper_id": args.paper_id, "input_path": str(path), "headers": headers, diff --git a/skills/deeppapernote/scripts/localization.py b/skills/deeppapernote/scripts/localization.py new file mode 100644 index 0000000..07a534c --- /dev/null +++ b/skills/deeppapernote/scripts/localization.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""Language schemas shared by DeepPaperNote contracts and validators.""" + +from __future__ import annotations + +import os +from copy import deepcopy +from typing import Any + +DEFAULT_OUTPUT_LANGUAGE = "zh-CN" +SUPPORTED_OUTPUT_LANGUAGES = ("zh-CN", "en") +_ALIASES = {"zh": "zh-CN", "zh-cn": "zh-CN", "zh_cn": "zh-CN", "chinese": "zh-CN", "en": "en", "en-us": "en", "en_us": "en", "english": "en"} +_SCHEMAS: dict[str, dict[str, Any]] = { + "zh-CN": { + "sections": {"core_information": "核心信息", "abstract": "原文摘要翻译", "contributions": "创新点", "one_sentence_summary": "一句话总结", "research_questions": "研究问题", "data_and_task": "数据与任务定义", "method": "方法主线", "key_results": "关键结果", "deep_analysis": "深度分析", "limitations": "局限", "my_notes": "我的笔记", "references": "引用"}, + "core_info_fields": ("标题", "标题翻译", "作者", "机构", "发表时间", "发表渠道", "DOI", "arXiv", "论文链接", "代码 / 项目", "数据 / 资源", "论文类型"), + "core_info_aliases": {}, + "figure_labels": {"location": "建议位置:", "reason": "放置原因:", "status": "当前状态:", "original_caption": "论文原图编号:"}, + "mechanism_flow": "机制流程", + }, + "en": { + "sections": {"core_information": "Core Information", "abstract": "Abstract", "contributions": "Contributions", "one_sentence_summary": "One-Sentence Summary", "research_questions": "Research Questions", "data_and_task": "Data and Task Definition", "method": "Method", "key_results": "Key Results", "deep_analysis": "Deep Analysis", "limitations": "Limitations", "my_notes": "My Notes", "references": "References"}, + "core_info_fields": ("Title", "Translated title", "Authors", "Institutions", "Publication date", "Venue", "DOI", "arXiv", "Paper link", "Code / Project", "Data / Resources", "Paper type"), + "core_info_aliases": {"Translated Title": "Translated title", "Author": "Authors", "Institution": "Institutions", "Publication Date": "Publication date", "Paper Link": "Paper link", "Code": "Code / Project", "Project": "Code / Project", "Data": "Data / Resources", "Resources": "Data / Resources", "Paper Type": "Paper type"}, + "figure_labels": {"location": "Suggested location:", "reason": "Why it matters:", "status": "Current status:", "original_caption": "Original paper item:"}, + "mechanism_flow": "Analytical Flow", + }, +} + +def normalize_output_language(value: str | None = None) -> str: + raw = (value if value is not None else os.environ.get("DEEPPAPERNOTE_OUTPUT_LANGUAGE", "")).strip() + if not raw: + return DEFAULT_OUTPUT_LANGUAGE + normalized = _ALIASES.get(raw.lower(), raw) + if normalized not in SUPPORTED_OUTPUT_LANGUAGES: + raise ValueError(f"Unsupported DeepPaperNote output language: {raw}. Choose one of: {', '.join(SUPPORTED_OUTPUT_LANGUAGES)}.") + return normalized + +def note_schema(language: str | None = None) -> dict[str, Any]: + return deepcopy(_SCHEMAS[normalize_output_language(language)]) + +def required_sections(language: str | None = None) -> tuple[str, ...]: + return tuple(note_schema(language)["sections"].values()) diff --git a/skills/deeppapernote/scripts/plan_figures.py b/skills/deeppapernote/scripts/plan_figures.py index c508550..2698f11 100644 --- a/skills/deeppapernote/scripts/plan_figures.py +++ b/skills/deeppapernote/scripts/plan_figures.py @@ -6,7 +6,8 @@ import argparse import re -from common import caption_preference_score, maybe_load_json_record, normalize_whitespace +from common import caption_preference_score, maybe_load_json_record, normalize_whitespace, runtime_config +from localization import normalize_output_language def parser() -> argparse.ArgumentParser: @@ -16,6 +17,7 @@ def parser() -> argparse.ArgumentParser: p.add_argument("--assets", default="", help="PDF assets JSON path or string.") p.add_argument("--output", default="", help="Output JSON path.") p.add_argument("--paper-id", default="", help="Canonical paper id.") + p.add_argument("--language", default="", help="Output language: en or zh-CN.") p.add_argument("--max-items", type=int, default=12, help="Maximum number of figure/table items to keep. 0 means keep all.") return p @@ -34,7 +36,7 @@ def merge_inputs(primary: dict | None, evidence: dict | None, assets: dict | Non return merged -def classify_caption_kind(item_id: str, caption: str) -> tuple[str, str, str]: +def _classify_caption_kind_zh(item_id: str, caption: str) -> tuple[str, str, str]: text = f"{item_id} {caption}".lower() if any( token in text @@ -148,7 +150,27 @@ def classify_caption_kind(item_id: str, caption: str) -> tuple[str, str, str]: return "supporting_figure", "深度分析", "这张图更适合作为补充图,放在深度分析部分帮助解释作者论点。" -def build_figure_items(evidence_pack: dict, *, limit: int = 12) -> list[dict]: +ENGLISH_FIGURE_PLACEMENT: dict[str, tuple[str, str]] = { + "main_result": ("Key Results", "This figure or table carries a primary result and belongs in Key Results."), + "data_or_task_overview": ("Data and Task Definition", "This visual explains the source, construction, screening, or scope of the data and task."), + "method_overview": ("Analytical Flow", "This visual summarizes the method or system flow and belongs in Analytical Flow when the match is reliable."), + "data_or_task": ("Data and Task Definition", "This visual clarifies the task setting, sample, or dataset."), + "method_detail": ("Method", "This visual explains an internal mechanism or execution state and belongs in Method."), + "table_result": ("Key Results", "This result table helps readers locate the central quantitative evidence."), + "supporting_figure": ("Deep Analysis", "This supporting visual helps explain the authors' argument in Deep Analysis."), +} + + +def classify_caption_kind(item_id: str, caption: str, language: str | None = None) -> tuple[str, str, str]: + result = _classify_caption_kind_zh(item_id, caption) + if normalize_output_language(language) != "en": + return result + kind = result[0] + section, reason = ENGLISH_FIGURE_PLACEMENT[kind] + return kind, section, reason + + +def build_figure_items(evidence_pack: dict, *, limit: int = 12, language: str | None = None) -> list[dict]: raw_items = [] for item in evidence_pack.get("figure_captions", []) or []: if isinstance(item, dict): @@ -185,7 +207,7 @@ def build_figure_items(evidence_pack: dict, *, limit: int = 12) -> list[dict]: item = grouped[key] item_id = normalize_whitespace(str(item.get("id", ""))) caption = normalize_whitespace(str(item.get("caption", ""))) - kind, section, reason = classify_caption_kind(item_id, caption) + kind, section, reason = classify_caption_kind(item_id, caption, language) priority = 3 if kind == "method_overview": priority = 1 @@ -489,12 +511,14 @@ def main() -> None: page_assets = data.get("page_assets", []) if isinstance(data.get("page_assets"), list) else [] image_assets = data.get("image_assets", []) if isinstance(data.get("image_assets"), list) else [] figure_assets = data.get("figure_assets", []) if isinstance(data.get("figure_assets"), list) else [] - items = build_figure_items(evidence_pack, limit=args.max_items) + language = normalize_output_language(args.language or runtime_config().get("output_language")) + items = build_figure_items(evidence_pack, limit=args.max_items, language=language) items = attach_candidate_images(items, page_assets, image_assets, figure_assets) payload = { "status": "ok", "script": "plan_figures.py", "paper_id": args.paper_id or data.get("paper_id", ""), + "output_language": language, "figure_plan": { "paper_id": args.paper_id or data.get("paper_id", ""), "figures": items, diff --git a/skills/deeppapernote/scripts/run_pipeline.py b/skills/deeppapernote/scripts/run_pipeline.py index 40a0780..35315c9 100644 --- a/skills/deeppapernote/scripts/run_pipeline.py +++ b/skills/deeppapernote/scripts/run_pipeline.py @@ -28,6 +28,11 @@ def parser() -> argparse.ArgumentParser: default="auto", help="Local Zotero lookup policy used by the resolve stage.", ) + p.add_argument( + "--language", + default="", + help="Output language contract: en or zh-CN. Defaults to DEEPPAPERNOTE_OUTPUT_LANGUAGE or zh-CN.", + ) return p @@ -147,6 +152,8 @@ def main() -> None: str(evidence_json), "--assets", str(assets_json), + "--language", + args.language, "--output", str(figures_json), ] @@ -165,8 +172,7 @@ def main() -> None: str(figure_decisions_json), ] ) - run_step( - [ + bundle_command = [ py, str(scripts_dir / "build_synthesis_bundle.py"), "--metadata", @@ -184,7 +190,9 @@ def main() -> None: "--output", str(bundle_json), ] - ) + if args.language: + bundle_command.extend(["--language", args.language]) + run_step(bundle_command) print( "\n".join( diff --git a/skills/deeppapernote/scripts/write_obsidian_note.py b/skills/deeppapernote/scripts/write_obsidian_note.py index e220f0b..03dd5b4 100644 --- a/skills/deeppapernote/scripts/write_obsidian_note.py +++ b/skills/deeppapernote/scripts/write_obsidian_note.py @@ -22,6 +22,7 @@ runtime_config, ) from lint_note import inspect_reference_hygiene +from localization import normalize_output_language def parser() -> argparse.ArgumentParser: @@ -43,6 +44,7 @@ def parser() -> argparse.ArgumentParser: p.add_argument("--filename", default="", help="Explicit note filename.") p.add_argument("--asset-subdir", default="images", help="Asset folder name relative to the note directory.") p.add_argument("--paper-id", default="", help="Canonical paper id.") + p.add_argument("--language", default="", help="Output language: en or zh-CN. Defaults to DEEPPAPERNOTE_OUTPUT_LANGUAGE or zh-CN.") return p @@ -197,6 +199,8 @@ def require_lint_gate(lint: dict, key: str, gate: str, lint_path: str) -> None: def main() -> None: args = parser().parse_args() + config = runtime_config() + output_language = normalize_output_language(args.language or str(config.get("output_language", "")) or None) record = maybe_load_json_record(args.input) or {} title = args.title or str(record.get("title", "")).strip() @@ -208,6 +212,12 @@ def main() -> None: # utf-8-sig tolerates a BOM in the lint JSON (e.g. produced/edited on # Windows) that would otherwise crash json.loads before any gate check. lint = json.loads(Path(lint_path).read_text(encoding="utf-8-sig")) + lint_language = str(lint.get("output_language", "")).strip() + if lint_language and lint_language != output_language: + raise SystemExit( + f"write_obsidian_note.py refused to write note because lint language " + f"{lint_language} does not match requested language {output_language}." + ) require_lint_gate(lint, "passes_basic_structure", "basic structure", lint_path) require_lint_gate(lint, "passes_style_gate", "style", lint_path) require_lint_gate(lint, "passes_math_gate", "math", lint_path) @@ -232,7 +242,6 @@ def main() -> None: raise SystemExit("write_obsidian_note.py requires --content-file, --content, or --stdin.") require_reference_hygiene(note_text, "before save") - config = runtime_config() if args.vault: config["obsidian_vault"] = args.vault resolved_subdir = resolve_domain_subdir( @@ -271,6 +280,7 @@ def main() -> None: payload = { "status": "ok", "script": "write_obsidian_note.py", + "output_language": output_language, "paper_id": args.paper_id or record.get("paper_id", ""), "title": title, "note_path": str(target_path), diff --git a/tests/test_output_language.py b/tests/test_output_language.py new file mode 100644 index 0000000..223a0cb --- /dev/null +++ b/tests/test_output_language.py @@ -0,0 +1,226 @@ +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +from build_synthesis_bundle import compact_writing_contract +from lint_grounding import validate_note_plan +from localization import normalize_output_language, required_sections +from plan_figures import build_figure_items + + +ENGLISH_SECTIONS = ( + "Core Information", + "Abstract", + "Contributions", + "One-Sentence Summary", + "Research Questions", + "Data and Task Definition", + "Method", + "Key Results", + "Deep Analysis", + "Limitations", + "My Notes", + "References", +) + + +def english_note() -> str: + return """--- +tags: + - papers/methods +aliases: + - "Auditable Tool Use" +date: 2024 +doi: 10.1234/example +--- + +# Auditable Tool Use + +## Core Information + +- Title: Auditable Tool Use +- Authors: Smith et al. +- Publication date: 2024 +- Venue: Example Journal +- DOI: 10.1234/example +- Paper type: AI method + +## Abstract + +The paper develops an auditable state machine for multi-step question answering and evaluates whether explicit failure records improve answer reliability. + +## Contributions + +- It joins evidence selection and tool-state tracking in one execution record, preventing failed evidence from silently becoming trusted input. +- It adds explicit rollback states that distinguish missing evidence from reasoning errors and make the final answer traceable. + +## One-Sentence Summary + +An explicit tool-state record reduces error propagation in multi-step question answering. + +## Research Questions + +How can a multi-step question-answering system remain traceable when retrieval is incomplete, external tools fail, or intermediate results are misused? + +## Data and Task Definition + +The input contains a question, candidate evidence, and available tools; the output contains an answer, a state trace, and a failure label when completion is unsupported. + +## Method + +### Analytical Flow + +1. **Input:** A question and candidate evidence. **Operation:** Extract relevant evidence. **Output:** A grounded initial state. +2. **Input:** The current state and tool registry. **Operation:** Align the request with an available tool. **Output:** A planned call. +3. **Input:** Tool output and confidence. **Operation:** Update or roll back the state. **Output:** An auditable execution record. +4. **Input:** The final state. **Operation:** Decode an answer or refusal. **Output:** A response with provenance. + +> [!figure] Figure 1 System overview +> Suggested location: Method +> Why it matters: The figure shows how evidence and tool states move through the execution chain. +> Current status: Placeholder retained; the extracted crop is incomplete and cannot be interpreted independently. + +## Key Results + +Across three datasets, answer accuracy increased from 71.2% to 78.5%, while untraceable errors fell from 18% to 9%. + +## Deep Analysis + +The important contribution is not only the score increase. Failed calls become inspectable evidence rather than hidden intermediate state, which supports auditing and targeted recovery. + +## Limitations + +The evaluation covers English question-answering data and a narrow tool set, so it does not establish robustness for multimodal tools or high-latency services. + +## My Notes + +The state-record design is reusable in evidence-first paper workflows because it separates missing source material from model interpretation failure. + +## References + +- Smith et al. (2024). Auditable Tool Use for Multi-hop Question Answering. DOI: 10.1234/example +""" + + +def plan_payload() -> dict: + return { + "paper_type": "AI_method", + "paper_type_rationale": "The paper proposes and evaluates a model mechanism.", + "dominant_domain": "reasoning", + "must_cover": ["Method"], + "key_numbers": ["78.5%"], + "real_comparisons": ["71.2% versus 78.5%"], + "central_claims": [{ + "claim": "The method improves traceability.", + "supporting_evidence": [{"section_id": "sec:results"}], + "what_it_actually_proves": "The reported protocol records tool states.", + "what_it_does_not_prove": "It does not prove production robustness.", + }], + "claim_boundaries": ["Evidence is limited to the reported workflow."], + "negative_or_limiting_results": ["Multimodal tools were not tested."], + "mechanism_result_map": ["Rollback states explain fewer untraceable errors."], + "comparative_positioning": ["Compared with answer-only baselines."], + "reuse_takeaways": ["Track failure state explicitly."], + "followup_questions": ["Test missing and delayed tool outputs."], + "section_plan": [{"section": "Method", "evidence_sources": [{"section_id": "sec:method"}]}], + } + + +def test_language_aliases_and_invalid_value() -> None: + assert normalize_output_language("English") == "en" + assert normalize_output_language("zh") == "zh-CN" + with pytest.raises(ValueError): + normalize_output_language("fr") + + +def test_english_contract_exposes_localized_schema() -> None: + contract = compact_writing_contract("en") + assert contract["language"] == "en" + assert tuple(contract["must_include_sections"]) == ENGLISH_SECTIONS + assert tuple(required_sections("en")) == ENGLISH_SECTIONS + assert contract["mechanism_flow_heading"] == "Analytical Flow" + assert contract["figure_labels"]["status"] == "Current status:" + assert not any("\u4e00" <= character <= "\u9fff" for character in json.dumps(contract, ensure_ascii=False)) + + +def test_english_grounding_accepts_english_section_plan() -> None: + plan = plan_payload() + plan["central_claims"][0]["supporting_evidence"] = [{"section_id": "sec:method"}] + plan["section_plan"] = [ + { + "section": section, + "focus": f"Explain the paper-specific evidence and analytical role of {section}.", + "evidence_sources": [{"section_id": "sec:method"}], + } + for section in ( + "Research Questions", + "Data and Task Definition", + "Method", + "Key Results", + "Deep Analysis", + "Limitations", + ) + ] + manifest = { + "coverage": {"total_pages": 10, "text_truncated": False}, + "sections": [{"section_id": "sec:method", "title": "Method", "page_start": 1, "page_end": 10}], + "pages": [], + } + assert validate_note_plan(plan, manifest, "en") == [] + + +def test_english_figure_plan_uses_english_targets_and_reasons() -> None: + items = build_figure_items( + {"figure_captions": [{"id": "Figure 1", "caption": "Overview of the system architecture."}]}, + language="en", + ) + assert items[0]["section"] == "Analytical Flow" + assert "visual" in items[0]["reason"].lower() + assert not any("\u4e00" <= character <= "\u9fff" for character in json.dumps(items, ensure_ascii=False)) + + +def test_english_note_passes_every_lint_gate(tmp_path: Path) -> None: + note_path = tmp_path / "paper.md" + plan_path = tmp_path / "paper.plan.json" + output_path = tmp_path / "lint.json" + note_path.write_text(english_note(), encoding="utf-8") + plan_path.write_text(json.dumps(plan_payload()), encoding="utf-8") + script = Path(__file__).resolve().parents[1] / "skills/deeppapernote/scripts/lint_note.py" + + subprocess.run( + [ + sys.executable, + str(script), + "--language", + "en", + "--input", + str(note_path), + "--plan-file", + str(plan_path), + "--output", + str(output_path), + ], + check=True, + ) + payload = json.loads(output_path.read_text(encoding="utf-8")) + assert payload["output_language"] == "en" + assert payload["warnings"] == [] + assert all(value is True for key, value in payload.items() if key.startswith("passes_")) + + +def test_english_lint_rejects_chinese_prose(tmp_path: Path) -> None: + note_path = tmp_path / "paper.md" + plan_path = tmp_path / "paper.plan.json" + output_path = tmp_path / "lint.json" + note_path.write_text(english_note().replace("The important contribution", "这项工作的 contribution"), encoding="utf-8") + plan_path.write_text(json.dumps(plan_payload()), encoding="utf-8") + script = Path(__file__).resolve().parents[1] / "skills/deeppapernote/scripts/lint_note.py" + subprocess.run([sys.executable, str(script), "--language", "en", "--input", str(note_path), "--plan-file", str(plan_path), "--output", str(output_path)], check=True) + payload = json.loads(output_path.read_text(encoding="utf-8")) + assert payload["passes_style_gate"] is False + assert "mixed_language_lines_present" in payload["warnings"] From 6960e87d12ac8e42a6e720a621ad933d0b6795b3 Mon Sep 17 00:00:00 2001 From: dingdingcar <1054263492@qq.com> Date: Thu, 27 Aug 2026 03:09:15 +0800 Subject: [PATCH 02/15] feat: add device-local user configuration (#29) --- CHANGELOG.md | 4 +- README.md | 34 +- README.zh-CN.md | 34 +- skills/deeppapernote/SKILL.md | 52 ++- .../references/output-language.md | 16 +- .../references/user-configuration.md | 38 ++ .../scripts/build_synthesis_bundle.py | 16 +- .../scripts/check_environment.py | 8 +- skills/deeppapernote/scripts/common.py | 37 +- skills/deeppapernote/scripts/lint_note.py | 7 +- .../scripts/materialize_figure_asset.py | 12 +- skills/deeppapernote/scripts/plan_figures.py | 4 +- skills/deeppapernote/scripts/run_pipeline.py | 66 ++- .../scripts/user_configuration.py | 430 ++++++++++++++++++ .../scripts/write_obsidian_note.py | 15 +- tests/conftest.py | 14 + tests/test_citation_links.py | 43 +- tests/test_cli_fallback.py | 51 ++- tests/test_contracts_consistency.py | 3 +- tests/test_lint_note.py | 3 + tests/test_output_language.py | 10 +- tests/test_run_pipeline_manifest.py | 62 +++ tests/test_user_configuration.py | 422 +++++++++++++++++ 23 files changed, 1274 insertions(+), 107 deletions(-) create mode 100644 skills/deeppapernote/references/user-configuration.md create mode 100644 skills/deeppapernote/scripts/user_configuration.py create mode 100644 tests/test_user_configuration.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 21f38e6..fecee32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,8 +14,8 @@ Add an entry here when the project meaningfully changes for users, for example: ### Added -- Added end-to-end English output through `DEEPPAPERNOTE_OUTPUT_LANGUAGE=en` or `--language en`, including localized note schemas, paper-type planning contracts, figure callouts, grounding, final-note linting, and Formal Save validation. -- Kept Simplified Chinese as the backward-compatible default while making the selected language explicit in synthesis, lint, and save artifacts. +- Added end-to-end English output through User Configuration or `--language en`, including localized note schemas, paper-type planning contracts, figure callouts, grounding, final-note linting, and Formal Save validation. +- Added one device-local `config.json` for language and save preferences, with first-use/repair inspection, confirmed migration, atomic persistence, and run-scoped overrides. ## v2.2.0 diff --git a/README.md b/README.md index 00f4305..86b6894 100644 --- a/README.md +++ b/README.md @@ -78,13 +78,16 @@ Generate a deep-reading note for this paper: ``` -DeepPaperNote supports complete English and Simplified Chinese note schemas. Chinese remains the default for backward compatibility; set English persistently with: +DeepPaperNote supports complete English and Simplified Chinese note schemas. On first use, your Agent asks once for the output language and save mode, then writes the confirmed defaults to `~/.deeppapernote/config.json`. -```bash -export DEEPPAPERNOTE_OUTPUT_LANGUAGE=en +```json +{ + "output_language": "en", + "save_mode": "workspace" +} ``` -The core pipeline commands also accept `--language en` for a single run. Section names, metadata fields, figure callouts, planning guidance, linting, and Formal Save all follow the selected language. +The core pipeline also accepts `--language en` for one run without changing the saved preference. Section names, metadata fields, figure callouts, planning guidance, linting, and Formal Save all follow the resolved language. ## 🎯 Why DeepPaperNote? @@ -116,17 +119,26 @@ You do not need to install every skill. Choose the ones that match your workflow The canonical execution contract lives in [`skills/deeppapernote/SKILL.md`](./skills/deeppapernote/SKILL.md). -## 🗂️ Obsidian Setup +## 🗂️ User Configuration -To make an Obsidian vault the default save target, set: +DeepPaperNote stores durable device-local preferences in `~/.deeppapernote/config.json`. -```bash -export DEEPPAPERNOTE_OBSIDIAN_VAULT="/absolute/path/to/your/vault" +To make an Obsidian Vault the durable save target, use: + +```json +{ + "output_language": "zh-CN", + "save_mode": "obsidian", + "obsidian_vault": "/absolute/path/to/your/vault", + "papers_dir": "Research/Papers" +} ``` -- When a usable vault is configured or provided, DeepPaperNote saves the validated note and its paper-local `images/` directory there. -- When no vault is configured, DeepPaperNote asks first. It writes to the current workspace only after you explicitly choose not to use a vault. -- If a configured vault save fails, DeepPaperNote reports the blocked save instead of silently switching to another destination. +- First use collects every required field in one prompt; later repair asks only for affected fields. +- Existing legacy settings are offered once as migration candidates and become durable only after confirmation. +- Workspace mode ignores Obsidian-only fields. Obsidian mode requires an existing absolute Vault and a relative paper directory inside it. +- Run Overrides never modify `config.json`. A durable change requires an explicit request to change the future default. +- If configuration or Formal Save is blocked, DeepPaperNote reports the affected field instead of switching destinations. ## 🔧 Optional Enhancements diff --git a/README.zh-CN.md b/README.zh-CN.md index cd2f469..9485869 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -78,13 +78,16 @@ DeepPaperNote 需要 Python 3.10 或更高版本。核心 PDF 抽取路径依赖 把这篇论文整理成 Obsidian 笔记:<论文> ``` -DeepPaperNote 完整支持英文和简体中文笔记结构。为了向后兼容,默认仍为中文;可用以下设置长期启用英文: +DeepPaperNote 完整支持英文和简体中文笔记结构。首次使用时,Agent 会一次询问输出语言与保存模式,并把确认后的长期默认值写入 `~/.deeppapernote/config.json`。 -```bash -export DEEPPAPERNOTE_OUTPUT_LANGUAGE=en +```json +{ + "output_language": "zh-CN", + "save_mode": "workspace" +} ``` -核心流程命令也支持使用 `--language en` 进行单次英文运行。章节名称、元数据字段、图表占位、规划规则、校验与正式保存都会遵循所选语言。 +核心流程也支持使用 `--language en` 进行单次英文运行,且不会改写长期偏好。章节名称、元数据字段、图表占位、规划规则、校验与正式保存都会遵循解析后的语言。 ## 🎯 为什么选择 DeepPaperNote? @@ -116,17 +119,26 @@ DeepPaperNote 仍然是唯一主产品。仓库同时提供一个可选 companio 规范执行契约以 [`skills/deeppapernote/SKILL.md`](./skills/deeppapernote/SKILL.md) 为准。 -## 🗂️ Obsidian 配置 +## 🗂️ 用户配置 -如果希望默认保存到 Obsidian 库,请设置: +DeepPaperNote 将设备本地的长期偏好保存在 `~/.deeppapernote/config.json`。 -```bash -export DEEPPAPERNOTE_OBSIDIAN_VAULT="/你的/Obsidian/库/绝对路径" +如果希望长期保存到 Obsidian Vault,可使用: + +```json +{ + "output_language": "zh-CN", + "save_mode": "obsidian", + "obsidian_vault": "/你的/Obsidian/Vault/绝对路径", + "papers_dir": "Research/Papers" +} ``` -- 配置或提供了可用的 Vault 时,DeepPaperNote 会把校验完成的笔记及其论文专属 `images/` 目录保存到该 Vault。 -- 没有配置 Vault 时,DeepPaperNote 会先询问;只有你明确选择不使用 Vault 后,才会写入当前 workspace。 -- 如果已配置的 Vault 保存失败,DeepPaperNote 会报告保存受阻,而不会静默切换到其他目标。 +- 首次使用会在一个问题批次里收集所有必填项;后续修复只询问受影响字段。 +- 旧设置只会作为一次性迁移候选,得到确认后才会写入长期配置。 +- workspace 模式忽略 Obsidian 专属字段;Obsidian 模式要求 Vault 是已存在的绝对目录,论文目录是 Vault 内的相对路径。 +- 单次运行覆盖不会改写 `config.json`;只有明确要求修改未来默认值时才会持久化。 +- 配置或正式保存受阻时,DeepPaperNote 会报告具体字段,不会静默切换目标。 ## 🔧 可选增强 diff --git a/skills/deeppapernote/SKILL.md b/skills/deeppapernote/SKILL.md index a3e5e16..edb032a 100644 --- a/skills/deeppapernote/SKILL.md +++ b/skills/deeppapernote/SKILL.md @@ -8,7 +8,7 @@ description: Generate a high-quality deep-reading note for a single paper and wr Use this skill when the user wants one outcome: - read one paper carefully - generate a high-quality Markdown note -- save the note into an Obsidian-style vault when configured, or into the current workspace when no vault is configured +- save the note to the workspace or Obsidian target selected by User Configuration Chinese trigger examples: - `给这篇论文生成深度笔记` @@ -20,15 +20,15 @@ English trigger examples: - `Generate a deep-reading note for this paper` - `Turn this paper into an Obsidian research note` +## User Configuration + +Before a normal paper run, read `references/user-configuration.md` for configuration admission, migration, repair, Run Overrides, and Preference Changes. + ## Output Language -DeepPaperNote supports `zh-CN` and `en`. Resolve the language in this order: -1. the user's explicit language request -2. `--language` when a supporting script exposes it -3. `DEEPPAPERNOTE_OUTPUT_LANGUAGE` -4. `zh-CN` for backward compatibility +DeepPaperNote supports `zh-CN` and `en`. Resolve it through User Configuration and Run Overrides; neither language nor save mode has a silent default. -The synthesis bundle's `writing_contract.language`, localized sections, metadata fields, figure labels, and mechanism-flow heading are authoritative for drafting and linting. Never draft in one language and validate it under another. Read `references/output-language.md` when configuring, drafting, or debugging a non-default language. +The synthesis bundle's `writing_contract.language`, localized sections, metadata fields, figure labels, and mechanism-flow heading are authoritative for drafting and linting. Never draft in one language and validate it under another. Read `references/output-language.md` when drafting or debugging either language profile. This skill is intentionally narrow: - it handles one paper at a time @@ -58,21 +58,22 @@ The note must adapt to the paper type. Use the same base structure, but shift em ## Workflow Follow this order: -1. resolve the paper identity -2. collect metadata -3. acquire the best available PDF -4. extract canonical raw source text: `*_raw_sections.jsonl`, `*_source_manifest.json`, and optional derived `*_full_text.md` -5. extract structural indexes and PDF assets -6. plan figure placement -7. build the full figure/table decision table -8. build the manifest synthesis bundle -9. have the model read the bundle plus raw sections and create a short JSON `note_plan` that satisfies the generated bundle contract -10. draft from the plan only after the grounding gate passes -11. have the model write the note -12. lint the final note against the same `note_plan` — this stage completes only when the lint artifact exists and every reported `passes_*` gate is `true`; otherwise revise and rerun lint. If the lint output contains `passes_style_gate: false`, apply the Style Gate Enforcement rule before advancing to step 13, 14, or 15 -13. perform `final_quality_review` after lint passes -14. perform `final_readability_review` after the quality review passes -15. write into Obsidian +1. complete Configuration Readiness; advance only after inspection returns `ready` +2. resolve the paper identity +3. collect metadata +4. acquire the best available PDF +5. extract canonical raw source text: `*_raw_sections.jsonl`, `*_source_manifest.json`, and optional derived `*_full_text.md` +6. extract structural indexes and PDF assets +7. plan figure placement +8. build the full figure/table decision table +9. build the manifest synthesis bundle +10. have the model read the bundle plus raw sections and create a short JSON `note_plan` that satisfies the generated bundle contract +11. draft from the plan only after the grounding gate passes +12. have the model write the note +13. lint the final note against the same `note_plan` — this stage completes only when the lint artifact exists and every reported `passes_*` gate is `true`; otherwise revise and rerun lint. If the lint output contains `passes_style_gate: false`, apply the Style Gate Enforcement rule before advancing to step 14, 15, or 16 +14. perform `final_quality_review` after lint passes +15. perform `final_readability_review` after the quality review passes +16. perform Formal Save to the configured target This is the required workflow for a normal single-paper note request, not a loose suggestion. Unless this skill explicitly marks a stage as optional, required stages must not be silently skipped, reordered into a shortcut, or treated as complete just because a partial artifact already exists. @@ -147,9 +148,9 @@ Formal Save states: | Save Target state | Required action | |---|---| -| Vault configured or provided and usable | Perform the Formal Save to that vault. | -| Vault configured or provided, but the Formal Save fails | Keep the current Save Target and attempt an in-scope recovery. If it still cannot complete, report `blocked`; do not switch to workspace. | -| No vault configured or provided | Ask whether the user wants to provide one. Use workspace only after the user explicitly chooses not to use a vault. | +| `save_mode=obsidian` and the configured Vault is usable | Perform the Formal Save to that Vault. | +| `save_mode=obsidian` and Formal Save fails | Keep the current Save Target and attempt an in-scope recovery. If it still cannot complete, report `blocked`; do not switch to workspace. | +| `save_mode=workspace` | Perform the Formal Save inside the current workspace output root. | - A normal note-generation request should complete in one pass: note text, figure placeholder decisions, image materialization when confident, and final save. - Do not stop after a text-only draft just to ask whether the user wants figures inserted. Finish the figure replacement decision inside the same task unless the user explicitly asked for text only. @@ -194,6 +195,7 @@ The topic references above can improve difficult runs, but the normal execution Use these bundled scripts rather than rebuilding the workflow from scratch: - `scripts/check_environment.py` +- `scripts/user_configuration.py` - `scripts/create_input_record.py` - `scripts/locate_zotero_attachment.py` - `scripts/resolve_paper.py` diff --git a/skills/deeppapernote/references/output-language.md b/skills/deeppapernote/references/output-language.md index d9569f6..52df41f 100644 --- a/skills/deeppapernote/references/output-language.md +++ b/skills/deeppapernote/references/output-language.md @@ -2,18 +2,12 @@ DeepPaperNote supports two output schemas: -| Setting | Language | Default | -|---|---|---| -| `zh-CN` | Simplified Chinese | Yes, for backward compatibility | -| `en` | English | No | +| Setting | Language | +|---|---| +| `zh-CN` | Simplified Chinese | +| `en` | English | -Set a persistent preference with: - -```bash -export DEEPPAPERNOTE_OUTPUT_LANGUAGE=en -``` - -For a single command, use `--language en` with `run_pipeline.py`, `build_synthesis_bundle.py`, `lint_note.py`, or `write_obsidian_note.py` where applicable. An explicit user request overrides the persistent preference. +Resolve the profile through `user-configuration.md`. For a single command, use `--language en` with `run_pipeline.py`, `build_synthesis_bundle.py`, `lint_note.py`, or `write_obsidian_note.py` where applicable. A Run Override never changes User Configuration. ## English note schema diff --git a/skills/deeppapernote/references/user-configuration.md b/skills/deeppapernote/references/user-configuration.md new file mode 100644 index 0000000..072c728 --- /dev/null +++ b/skills/deeppapernote/references/user-configuration.md @@ -0,0 +1,38 @@ +# User Configuration + +Use one device-local User Configuration at `~/.deeppapernote/config.json`: + +- `output_language`: `zh-CN` or `en` +- `save_mode`: `workspace` or `obsidian` +- `obsidian_vault`: existing absolute directory, required only in Obsidian mode +- `papers_dir`: safe relative path inside the Vault, required only in Obsidian mode + +There is no implicit language or save-mode default. Workspace mode ignores stored Obsidian fields. Destination writability is a Formal Save concern; configuration inspection never creates a probe file in the workspace or Vault. + +## Configuration admission + +Complete Configuration Readiness before paper identity resolution: + +1. Run `scripts/user_configuration.py` without setters and read its structured state. +2. For `needs_input` on first use, ask one Configuration Prompt Batch for `output_language` and `save_mode`; require `obsidian_vault` and `papers_dir` in that same response when the user selects Obsidian. For later repair, ask only for `prompt_fields`. +3. For migration candidates, show the candidates and obtain confirmation for a complete configuration. Candidates alone never authorize the paper run. +4. Persist confirmed preferences with the relevant `--set-output-language`, `--set-save-mode`, `--set-vault`, and `--set-papers-dir` options. Use `--replace-invalid` only after the user explicitly confirms replacement of malformed or non-object JSON. +5. Run the inspector again. Configuration admission completes only when it returns `ready` after atomic persistence and readback validation; then continue the same paper request. + +Treat `invalid` as repairable input. Treat `blocked` as an I/O boundary: report its issue, preserve the current file, and stop before paper work. Never claim a preference was saved unless readback returned `ready`. + +## Resolution and persistence + +Resolve each preference using this exact precedence: + +`explicit request > CLI > current process environment > User Configuration` + +An explicit request about the current paper is a Run Override. Translate it to the matching runtime override and leave `config.json` byte-for-byte unchanged. Persist only explicit future-default wording as a Preference Change. + +While `config.json` is absent, supported process and shell values are migration candidates only. Once the file exists, shell startup files leave the preference path permanently; current process environment values remain run-scoped compatibility overrides. + +Preference Changes preserve unknown JSON fields and report a warning. Malformed or non-object JSON receives a unique invalid backup before a confirmed replacement. Writes use a same-directory temporary file, atomic replacement, and exact reread comparison. + +## Completion criteria + +Configuration is ready only when every active field is present and valid, the resolved values contain no missing or invalid field, and the workflow has not begun identity resolution. Obsidian mode requires an existing absolute Vault and a traversal-safe relative paper directory. Workspace mode requires neither Obsidian field and cannot be redirected by stale values. diff --git a/skills/deeppapernote/scripts/build_synthesis_bundle.py b/skills/deeppapernote/scripts/build_synthesis_bundle.py index 79e8a99..6522be0 100644 --- a/skills/deeppapernote/scripts/build_synthesis_bundle.py +++ b/skills/deeppapernote/scripts/build_synthesis_bundle.py @@ -35,7 +35,7 @@ def parser() -> argparse.ArgumentParser: required=True, help="Figure/table decision JSON path or string.", ) - p.add_argument("--language", default="", help="Output language: en or zh-CN. Defaults to DEEPPAPERNOTE_OUTPUT_LANGUAGE or zh-CN.") + p.add_argument("--language", default="", help="Run Override for output language: en or zh-CN.") p.add_argument("--output", default="", help="Output JSON path.") return p @@ -44,12 +44,14 @@ def load_record(value: str) -> dict: return maybe_load_json_record(value) or {} -def sanitize_reference_candidates(evidence_pack: dict, *, limit: int = 20) -> list[dict]: +def sanitize_reference_candidates( + evidence_pack: dict, config: dict, *, limit: int = 20 +) -> list[dict]: candidates = evidence_pack.get("reference_candidates", []) or [] if not isinstance(candidates, list): return [] try: - matched_candidates = resolve_reference_links(candidates[:limit], runtime_config()) + matched_candidates = resolve_reference_links(candidates[:limit], config) except Exception: matched_candidates = [ { @@ -435,6 +437,8 @@ def bundle( ) source_manifest = source_manifest or {} figure_decisions_wrapper = figure_decisions_wrapper or {} + config = runtime_config(cli_overrides={"output_language": output_language or ""}) + resolved_output_language = normalize_output_language(config["output_language"]) identity_contract = identity_contract_summary(metadata, source_manifest) canonical_metadata = accepted_bundle_metadata(metadata, identity_contract) @@ -482,7 +486,7 @@ def bundle( "coverage": source_manifest.get("coverage", {}) or {}, }, "source_index": source_index(source_manifest), - "references": {"candidates": sanitize_reference_candidates(evidence_pack)}, + "references": {"candidates": sanitize_reference_candidates(evidence_pack, config)}, "figure_plan": figure_plan, "figure_table_manifest": figure_table_manifest( figure_decisions_wrapper, @@ -497,7 +501,7 @@ def bundle( "figure_assets": sanitize_figure_assets(assets_wrapper), "ocr_available": assets_wrapper.get("ocr_available", False), }, - "writing_contract": compact_writing_contract(output_language), + "writing_contract": compact_writing_contract(resolved_output_language), } @@ -523,7 +527,7 @@ def main() -> None: assets, source_manifest, figure_decisions, - normalize_output_language(args.language or runtime_config().get("output_language")), + args.language, ), args.output, ) diff --git a/skills/deeppapernote/scripts/check_environment.py b/skills/deeppapernote/scripts/check_environment.py index ac8ccfd..68f980e 100644 --- a/skills/deeppapernote/scripts/check_environment.py +++ b/skills/deeppapernote/scripts/check_environment.py @@ -11,6 +11,7 @@ from _zotero_local import probe_zotero_local_api from common import emit, env_config_value, runtime_config +from user_configuration import inspect_configuration def parser() -> argparse.ArgumentParser: @@ -68,7 +69,8 @@ def find_local_zotero_hints() -> list[str]: def main() -> None: args = parser().parse_args() - config = runtime_config() + configuration = inspect_configuration() + config = runtime_config() if configuration["state"] == "ready" else {} zotero_local_api = probe_zotero_local_api() obsidian_vault = str(config.get("obsidian_vault", "")).strip() @@ -81,6 +83,7 @@ def main() -> None: "status": "ok", "script": "check_environment.py", "tool_role": "maintenance", + "user_configuration": configuration, "python": { "executable": sys.executable, "version": sys.version.split()[0], @@ -101,8 +104,7 @@ def main() -> None: "current_working_directory": str(Path.cwd().resolve()), "workspace_output_dir": str(config.get("workspace_output_dir", "DeepPaperNote_output")), "note": ( - "If no Obsidian vault is configured, DeepPaperNote can still save notes under " - "the current working directory." + "With save_mode=workspace, DeepPaperNote saves under the current working directory." ), }, "zotero": { diff --git a/skills/deeppapernote/scripts/common.py b/skills/deeppapernote/scripts/common.py index 4f0a49a..3130f88 100644 --- a/skills/deeppapernote/scripts/common.py +++ b/skills/deeppapernote/scripts/common.py @@ -17,6 +17,8 @@ from pathlib import Path, PureWindowsPath from typing import Any +from user_configuration import inspect_configuration, resolve_preferences + ARXIV_NS = { "atom": "http://www.w3.org/2005/Atom", "arxiv": "http://arxiv.org/schemas/atom", @@ -2400,11 +2402,24 @@ def enrich_metadata(record: dict[str, Any]) -> dict[str, Any]: return apply_identity_confidence(merged) -def runtime_config() -> dict[str, Any]: +def runtime_config( + *, + explicit_overrides: dict[str, Any] | None = None, + cli_overrides: dict[str, Any] | None = None, +) -> dict[str, Any]: + inspection = inspect_configuration() + if inspection["state"] != "ready": + raise RuntimeError(json.dumps(inspection, ensure_ascii=False, sort_keys=True)) + resolved = resolve_preferences( + explicit_overrides=explicit_overrides, + cli_overrides=cli_overrides, + ) + if resolved["issues"] or resolved["missing"]: + raise RuntimeError(json.dumps(resolved, ensure_ascii=False, sort_keys=True)) return { - "output_language": env_config_value("DEEPPAPERNOTE_OUTPUT_LANGUAGE", default="zh-CN"), - "obsidian_vault": env_config_value("DEEPPAPERNOTE_OBSIDIAN_VAULT"), - "papers_dir": env_config_value("DEEPPAPERNOTE_PAPERS_DIR", default="Research/Papers"), + "obsidian_vault": "", + "papers_dir": "", + **resolved["values"], "output_dir": env_config_value("DEEPPAPERNOTE_OUTPUT_DIR", default="tmp/DeepPaperNote"), "workspace_output_dir": env_config_value( "DEEPPAPERNOTE_WORKSPACE_OUTPUT_DIR", @@ -2414,6 +2429,8 @@ def runtime_config() -> dict[str, Any]: def configured_obsidian_vault(config: dict[str, Any]) -> Path | None: + if str(config.get("save_mode", "")).strip() == "workspace": + return None vault = str(config.get("obsidian_vault", "")).strip() if not vault: return None @@ -2426,14 +2443,18 @@ def configured_obsidian_vault(config: dict[str, Any]) -> Path | None: def require_obsidian_vault(config: dict[str, Any]) -> Path: vault_path = configured_obsidian_vault(config) if vault_path is None: - raise RuntimeError("Missing Obsidian vault configuration. Set DEEPPAPERNOTE_OBSIDIAN_VAULT.") + raise RuntimeError("Missing Obsidian Vault in User Configuration or the current Run Override.") return vault_path def resolve_note_output_mode(config: dict[str, Any]) -> tuple[str, Path]: - vault_path = configured_obsidian_vault(config) - if vault_path is not None: - return ("obsidian", vault_path) + save_mode = str(config.get("save_mode", "")).strip() + if save_mode == "obsidian": + return ("obsidian", require_obsidian_vault(config)) + if not save_mode: + vault_path = configured_obsidian_vault(config) + if vault_path is not None: + return ("obsidian", vault_path) workspace_root = Path.cwd().resolve() output_dir = str(config.get("workspace_output_dir", "DeepPaperNote_output")).strip() or "DeepPaperNote_output" output_path = workspace_root / Path( diff --git a/skills/deeppapernote/scripts/lint_note.py b/skills/deeppapernote/scripts/lint_note.py index ff19caa..7f3b41e 100644 --- a/skills/deeppapernote/scripts/lint_note.py +++ b/skills/deeppapernote/scripts/lint_note.py @@ -236,7 +236,7 @@ def parser() -> argparse.ArgumentParser: p.add_argument("--plan-file", default="", help="Optional note_plan JSON path. Defaults to sibling .plan.json.") p.add_argument("--output", default="", help="Output JSON path.") p.add_argument("--paper-id", default="", help="Canonical paper id.") - p.add_argument("--language", default="", help="Output language: en or zh-CN. Defaults to DEEPPAPERNOTE_OUTPUT_LANGUAGE or zh-CN.") + p.add_argument("--language", default="", help="Run Override for output language: en or zh-CN.") return p @@ -1576,10 +1576,11 @@ def strip_frontmatter(text: str) -> str: def main() -> None: - from common import emit + from common import emit, runtime_config args = parser().parse_args() - output_language = configure_output_language(args.language or None) + config = runtime_config(cli_overrides={"output_language": args.language}) + output_language = configure_output_language(str(config["output_language"])) path = Path(args.input).expanduser().resolve() # utf-8-sig strips a leading BOM and the replace() normalizes CRLF so # Windows-authored notes are linted identically to LF/BOM-less notes; diff --git a/skills/deeppapernote/scripts/materialize_figure_asset.py b/skills/deeppapernote/scripts/materialize_figure_asset.py index 787a8c8..c4b087b 100644 --- a/skills/deeppapernote/scripts/materialize_figure_asset.py +++ b/skills/deeppapernote/scripts/materialize_figure_asset.py @@ -25,6 +25,8 @@ def parser() -> argparse.ArgumentParser: p.add_argument("--input", default="", help="Optional metadata JSON path or JSON string.") p.add_argument("--title", default="", help="Explicit paper title.") p.add_argument("--vault", default="", help="Target Obsidian vault path.") + p.add_argument("--save-mode", choices=("workspace", "obsidian"), default="") + p.add_argument("--papers-dir", default="", help="Vault-relative paper directory.") p.add_argument("--subdir", default="", help="Vault-relative note subdirectory.") p.add_argument("--filename", default="", help="Optional note filename override.") p.add_argument("--asset-subdir", default="images", help="Asset folder name relative to the note directory.") @@ -40,9 +42,13 @@ def main() -> None: if not title: raise SystemExit("materialize_figure_asset.py requires --title or metadata with a title.") - config = runtime_config() - if args.vault: - config["obsidian_vault"] = args.vault + config = runtime_config( + cli_overrides={ + "save_mode": args.save_mode or ("obsidian" if args.vault else ""), + "obsidian_vault": args.vault, + "papers_dir": args.papers_dir, + } + ) resolved_subdir = resolve_domain_subdir( config, title=title, diff --git a/skills/deeppapernote/scripts/plan_figures.py b/skills/deeppapernote/scripts/plan_figures.py index 2698f11..2655e48 100644 --- a/skills/deeppapernote/scripts/plan_figures.py +++ b/skills/deeppapernote/scripts/plan_figures.py @@ -511,7 +511,9 @@ def main() -> None: page_assets = data.get("page_assets", []) if isinstance(data.get("page_assets"), list) else [] image_assets = data.get("image_assets", []) if isinstance(data.get("image_assets"), list) else [] figure_assets = data.get("figure_assets", []) if isinstance(data.get("figure_assets"), list) else [] - language = normalize_output_language(args.language or runtime_config().get("output_language")) + language = normalize_output_language( + runtime_config(cli_overrides={"output_language": args.language})["output_language"] + ) items = build_figure_items(evidence_pack, limit=args.max_items, language=language) items = attach_candidate_images(items, page_assets, image_assets, figure_assets) payload = { diff --git a/skills/deeppapernote/scripts/run_pipeline.py b/skills/deeppapernote/scripts/run_pipeline.py index 35315c9..a4b492e 100644 --- a/skills/deeppapernote/scripts/run_pipeline.py +++ b/skills/deeppapernote/scripts/run_pipeline.py @@ -4,10 +4,13 @@ from __future__ import annotations import argparse +import os import subprocess import sys from pathlib import Path +from common import runtime_config + def parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser(description=__doc__ or "run pipeline") @@ -31,17 +34,45 @@ def parser() -> argparse.ArgumentParser: p.add_argument( "--language", default="", - help="Output language contract: en or zh-CN. Defaults to DEEPPAPERNOTE_OUTPUT_LANGUAGE or zh-CN.", + choices=("", "en", "zh-CN"), + help="Run Override for the output language contract.", ) + p.add_argument("--save-mode", choices=("workspace", "obsidian"), default="") + p.add_argument("--vault", default="", help="Run Override for the Obsidian Vault.") + p.add_argument("--papers-dir", default="", help="Run Override for the Vault paper directory.") return p -def run_step(cmd: list[str]) -> None: - subprocess.run(cmd, check=True) +def run_step(cmd: list[str], *, env: dict[str, str]) -> None: + subprocess.run(cmd, check=True, env=env) def main() -> None: args = parser().parse_args() + try: + config = runtime_config( + cli_overrides={ + "output_language": args.language, + "save_mode": args.save_mode, + "obsidian_vault": args.vault, + "papers_dir": args.papers_dir, + } + ) + except RuntimeError as exc: + raise SystemExit(str(exc)) from exc + args.language = config["output_language"] + run_environment = os.environ.copy() + for field, name in { + "output_language": "DEEPPAPERNOTE_OUTPUT_LANGUAGE", + "save_mode": "DEEPPAPERNOTE_SAVE_MODE", + "obsidian_vault": "DEEPPAPERNOTE_OBSIDIAN_VAULT", + "papers_dir": "DEEPPAPERNOTE_PAPERS_DIR", + }.items(): + value = str(config.get(field, "")).strip() + if value: + run_environment[name] = value + else: + run_environment.pop(name, None) scripts_dir = Path(__file__).resolve().parent workdir = Path(args.workdir).expanduser().resolve() workdir.mkdir(parents=True, exist_ok=True) @@ -70,7 +101,8 @@ def main() -> None: args.zotero_mode, "--output", str(resolve_json), - ] + ], + env=run_environment, ) run_step( [ @@ -80,7 +112,8 @@ def main() -> None: str(resolve_json), "--output", str(metadata_json), - ] + ], + env=run_environment, ) run_step( [ @@ -94,7 +127,8 @@ def main() -> None: str(identity_trace_json), "--output", str(identity_json), - ] + ], + env=run_environment, ) run_step( [ @@ -106,7 +140,8 @@ def main() -> None: str(identity_json), "--output", str(fetch_json), - ] + ], + env=run_environment, ) run_step( [ @@ -120,7 +155,8 @@ def main() -> None: str(raw_sections_jsonl), "--full-text-output", str(full_text_md), - ] + ], + env=run_environment, ) run_step( [ @@ -132,7 +168,8 @@ def main() -> None: str(source_manifest_json), "--output", str(evidence_json), - ] + ], + env=run_environment, ) run_step( [ @@ -142,7 +179,8 @@ def main() -> None: str(fetch_json), "--output", str(assets_json), - ] + ], + env=run_environment, ) run_step( [ @@ -156,7 +194,8 @@ def main() -> None: args.language, "--output", str(figures_json), - ] + ], + env=run_environment, ) run_step( [ @@ -170,7 +209,8 @@ def main() -> None: str(assets_json), "--output", str(figure_decisions_json), - ] + ], + env=run_environment, ) bundle_command = [ py, @@ -192,7 +232,7 @@ def main() -> None: ] if args.language: bundle_command.extend(["--language", args.language]) - run_step(bundle_command) + run_step(bundle_command, env=run_environment) print( "\n".join( diff --git a/skills/deeppapernote/scripts/user_configuration.py b/skills/deeppapernote/scripts/user_configuration.py new file mode 100644 index 0000000..be44e1d --- /dev/null +++ b/skills/deeppapernote/scripts/user_configuration.py @@ -0,0 +1,430 @@ +#!/usr/bin/env python3 +"""Inspect and persist DeepPaperNote's device-local User Configuration.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import tempfile +from datetime import datetime, timezone +from pathlib import Path, PureWindowsPath +from typing import Any, Mapping, Sequence + +OUTPUT_LANGUAGES = {"zh-CN", "en"} +SAVE_MODES = {"workspace", "obsidian"} +KNOWN_FIELDS = ("output_language", "save_mode", "obsidian_vault", "papers_dir") +ALWAYS_REQUIRED = ("output_language", "save_mode") +ENV_FIELDS = { + "output_language": "DEEPPAPERNOTE_OUTPUT_LANGUAGE", + "save_mode": "DEEPPAPERNOTE_SAVE_MODE", + "obsidian_vault": "DEEPPAPERNOTE_OBSIDIAN_VAULT", + "papers_dir": "DEEPPAPERNOTE_PAPERS_DIR", +} + + +class ConfigurationWriteError(RuntimeError): + """Raised when a Preference Change cannot be durably verified.""" + + +class ConfigurationValidationError(ConfigurationWriteError): + """Raised when a Preference Change still needs user input or repair.""" + + def __init__( + self, + result: dict[str, Any], + message: str = "Preference Change is incomplete or invalid.", + ) -> None: + super().__init__(message) + self.result = result + + +def user_config_path() -> Path: + return Path.home() / ".deeppapernote" / "config.json" + + +def default_shell_paths() -> tuple[Path, ...]: + home = Path.home() + return tuple( + home / name + for name in (".zshenv", ".zprofile", ".zshrc", ".bash_profile", ".bashrc") + ) + + +def _clean_values(values: Mapping[str, Any] | None) -> dict[str, str]: + if not values: + return {} + return { + field: str(values[field]).strip() + for field in KNOWN_FIELDS + if field in values and str(values[field]).strip() + } + + +def _migration_candidates( + environ: Mapping[str, str], shell_paths: Sequence[Path] +) -> dict[str, dict[str, str]]: + candidates: dict[str, dict[str, str]] = {} + for field, name in ENV_FIELDS.items(): + value = environ.get(name, "").strip() + if value and _migration_value_supported(field, value): + candidates[field] = {"value": value, "source": "process_environment"} + for path in shell_paths: + if not path.is_file(): + continue + try: + lines = path.read_text(encoding="utf-8-sig").splitlines() + except OSError: + continue + for field, name in ENV_FIELDS.items(): + if field in candidates: + continue + pattern = re.compile(rf"^\s*(?:export\s+)?{re.escape(name)}=(.*)$") + for line in reversed(lines): + match = pattern.match(line) + if not match: + continue + value = match.group(1).strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}: + value = value[1:-1] + if value and _migration_value_supported(field, value): + candidates[field] = {"value": value, "source": str(path)} + break + return candidates + + +def _issue(field: str, code: str, message: str) -> dict[str, str]: + return {"field": field, "code": code, "message": message} + + +def _safe_relative_path(value: str) -> bool: + native = Path(value) + windows = PureWindowsPath(value) + if native.is_absolute() or windows.is_absolute() or windows.drive or windows.root: + return False + parts = tuple(part for part in re.split(r"[\\/]+", value) if part and part != ".") + return bool(parts) and ".." not in parts + + +def _migration_value_supported(field: str, value: str) -> bool: + if field == "output_language": + return value in OUTPUT_LANGUAGES + if field == "save_mode": + return value in SAVE_MODES + if field == "obsidian_vault": + try: + vault = Path(value).expanduser() + except (OSError, RuntimeError): + return False + return vault.is_absolute() and vault.is_dir() + if field == "papers_dir": + return _safe_relative_path(value) + return False + + +def _validate(configuration: Mapping[str, Any]) -> tuple[list[dict[str, str]], list[str]]: + issues: list[dict[str, str]] = [] + missing: list[str] = [] + for field in ALWAYS_REQUIRED: + if not str(configuration.get(field, "")).strip(): + missing.append(field) + language = str(configuration.get("output_language", "")).strip() + save_mode = str(configuration.get("save_mode", "")).strip() + if language and language not in OUTPUT_LANGUAGES: + issues.append(_issue("output_language", "invalid_enum", "Expected zh-CN or en.")) + if save_mode and save_mode not in SAVE_MODES: + issues.append(_issue("save_mode", "invalid_enum", "Expected workspace or obsidian.")) + if save_mode == "obsidian": + vault = str(configuration.get("obsidian_vault", "")).strip() + papers_dir = str(configuration.get("papers_dir", "")).strip() + if not vault: + missing.append("obsidian_vault") + else: + vault_path = Path(vault).expanduser() + if not vault_path.is_absolute() or not vault_path.is_dir(): + issues.append( + _issue( + "obsidian_vault", + "missing_vault", + "Expected an existing absolute directory.", + ) + ) + if not papers_dir: + missing.append("papers_dir") + elif not _safe_relative_path(papers_dir): + issues.append( + _issue( + "papers_dir", + "unsafe_path", + "Expected a safe relative path inside the Vault.", + ) + ) + return issues, missing + + +def _blocked_result(path: Path, code: str, message: str) -> dict[str, Any]: + return { + "state": "blocked", + "config_path": str(path), + "affected_fields": ["configuration"], + "prompt_fields": [], + "migration_candidates": {}, + "warnings": [], + "issues": [_issue("configuration", code, message)], + } + + +def _repair_result( + path: Path, + configuration: Mapping[str, Any], + issues: list[dict[str, str]], + missing: list[str], +) -> dict[str, Any]: + affected = [issue["field"] for issue in issues] + for field in missing: + if field not in affected: + affected.append(field) + result: dict[str, Any] = { + "state": "invalid" if issues else "needs_input" if missing else "ready", + "config_path": str(path), + "affected_fields": affected, + "prompt_fields": [field for field in KNOWN_FIELDS if field in affected], + "migration_candidates": {}, + "warnings": [], + "configuration": dict(configuration), + } + if issues: + result["issues"] = issues + return result + + +def _read_configuration(path: Path) -> tuple[dict[str, Any] | None, str]: + try: + raw = path.read_text(encoding="utf-8-sig") + except OSError as exc: + return None, f"unreadable:{exc}" + try: + value = json.loads(raw) + except (json.JSONDecodeError, UnicodeError) as exc: + return None, f"malformed:{exc}" + if not isinstance(value, dict): + return None, "non_object:Expected a JSON object." + return value, "" + + +def _path_is_writable(path: Path) -> bool: + candidate = path.parent + while not candidate.exists() and candidate != candidate.parent: + candidate = candidate.parent + return os.access(candidate, os.W_OK) + + +def inspect_configuration( + *, + config_path: str | Path | None = None, + environ: Mapping[str, str] | None = None, + shell_paths: Sequence[Path] | None = None, +) -> dict[str, Any]: + path = Path(config_path or user_config_path()).expanduser() + environment = os.environ if environ is None else environ + if not _path_is_writable(path): + return _blocked_result(path, "unwritable", "User Configuration is not writable.") + if not path.exists(): + return { + "state": "needs_input", + "config_path": str(path), + "affected_fields": list(ALWAYS_REQUIRED), + "prompt_fields": list(ALWAYS_REQUIRED), + "migration_candidates": _migration_candidates( + environment, default_shell_paths() if shell_paths is None else shell_paths + ), + "warnings": [], + } + + configuration, read_error = _read_configuration(path) + if read_error.startswith("unreadable:"): + return _blocked_result(path, "unreadable", read_error.partition(":")[2]) + if read_error: + code, _, message = read_error.partition(":") + return { + "state": "invalid", + "config_path": str(path), + "affected_fields": ["configuration"], + "prompt_fields": list(ALWAYS_REQUIRED), + "migration_candidates": {}, + "warnings": [], + "issues": [_issue("configuration", code, message)], + } + + assert configuration is not None + issues, missing = _validate(configuration) + return _repair_result(path, configuration, issues, missing) + + +def resolve_preferences( + *, + config_path: str | Path | None = None, + explicit_overrides: Mapping[str, Any] | None = None, + cli_overrides: Mapping[str, Any] | None = None, + environ: Mapping[str, str] | None = None, +) -> dict[str, Any]: + path = Path(config_path or user_config_path()).expanduser() + configuration, read_error = _read_configuration(path) + if read_error or configuration is None: + raise RuntimeError(f"User Configuration is not readable and valid: {read_error}") + environment = os.environ if environ is None else environ + layers = ( + ("user_configuration", _clean_values(configuration)), + ( + "process_environment", + { + field: environment.get(name, "").strip() + for field, name in ENV_FIELDS.items() + if environment.get(name, "").strip() + }, + ), + ("cli", _clean_values(cli_overrides)), + ("explicit_request", _clean_values(explicit_overrides)), + ) + values: dict[str, str] = {} + sources: dict[str, str] = {} + for source, layer in layers: + for field, value in layer.items(): + values[field] = value + sources[field] = source + issues, missing = _validate(values) + return {"values": values, "sources": sources, "issues": issues, "missing": missing} + + +def _invalid_backup_path(path: Path) -> Path: + stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ") + candidate = path.with_name(f"{path.stem}.invalid-{stamp}{path.suffix}") + counter = 1 + while candidate.exists(): + candidate = path.with_name(f"{path.stem}.invalid-{stamp}-{counter}{path.suffix}") + counter += 1 + return candidate + + +def _atomic_write(path: Path, configuration: Mapping[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary_name = "" + try: + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=path.parent, + prefix=f".{path.name}.", + suffix=".tmp", + delete=False, + ) as handle: + temporary_name = handle.name + json.dump(configuration, handle, ensure_ascii=False, indent=2) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + os.chmod(temporary_name, 0o600) + os.replace(temporary_name, path) + except OSError as exc: + if temporary_name: + try: + Path(temporary_name).unlink() + except OSError: + pass + raise ConfigurationWriteError(f"User Configuration write is blocked: {exc}") from exc + + +def persist_preferences( + preferences: Mapping[str, Any], + *, + config_path: str | Path | None = None, + environ: Mapping[str, str] | None = None, + replace_invalid: bool = False, +) -> dict[str, Any]: + path = Path(config_path or user_config_path()).expanduser() + if not _path_is_writable(path): + raise ConfigurationWriteError("User Configuration is not writable.") + existing: dict[str, Any] = {} + if path.exists(): + loaded, read_error = _read_configuration(path) + if read_error.startswith("unreadable:"): + raise ConfigurationWriteError(f"User Configuration is unreadable: {read_error}") + if read_error: + if not replace_invalid: + raise ConfigurationValidationError( + inspect_configuration(config_path=path, environ=environ), + "Invalid User Configuration requires an explicitly confirmed replacement.", + ) + backup = _invalid_backup_path(path) + try: + shutil.copy2(path, backup) + except OSError as exc: + raise ConfigurationWriteError( + f"Could not preserve invalid User Configuration: {exc}" + ) from exc + else: + assert loaded is not None + existing = loaded + updates = _clean_values(preferences) + candidate = {**existing, **updates} + issues, missing = _validate(candidate) + if issues or missing: + raise ConfigurationValidationError(_repair_result(path, candidate, issues, missing)) + unknown = sorted(set(existing) - set(KNOWN_FIELDS)) + _atomic_write(path, candidate) + reread, read_error = _read_configuration(path) + if read_error or reread != candidate: + raise ConfigurationWriteError( + "User Configuration readback did not match the persisted values." + ) + result = inspect_configuration(config_path=path, environ=environ) + if result["state"] != "ready": + raise ConfigurationWriteError("Persisted User Configuration did not pass validation.") + if unknown: + result["warnings"] = [ + f"Preserved unknown configuration fields: {', '.join(unknown)}" + ] + return result + + +def parser() -> argparse.ArgumentParser: + argument_parser = argparse.ArgumentParser(description=__doc__) + argument_parser.add_argument("--set-output-language", choices=sorted(OUTPUT_LANGUAGES)) + argument_parser.add_argument("--set-save-mode", choices=sorted(SAVE_MODES)) + argument_parser.add_argument("--set-vault") + argument_parser.add_argument("--set-papers-dir") + argument_parser.add_argument("--replace-invalid", action="store_true") + return argument_parser + + +def main() -> None: + args = parser().parse_args() + preferences = { + field: value + for field, value in { + "output_language": args.set_output_language, + "save_mode": args.set_save_mode, + "obsidian_vault": args.set_vault, + "papers_dir": args.set_papers_dir, + }.items() + if value is not None + } + try: + result = ( + persist_preferences(preferences, replace_invalid=args.replace_invalid) + if preferences + else inspect_configuration() + ) + except ConfigurationValidationError as exc: + result = exc.result + except ConfigurationWriteError as exc: + result = _blocked_result(user_config_path(), "write_failed", str(exc)) + print(json.dumps(result, ensure_ascii=False, indent=2)) + if result["state"] != "ready": + raise SystemExit(2) + + +if __name__ == "__main__": + main() diff --git a/skills/deeppapernote/scripts/write_obsidian_note.py b/skills/deeppapernote/scripts/write_obsidian_note.py index 03dd5b4..6f064f0 100644 --- a/skills/deeppapernote/scripts/write_obsidian_note.py +++ b/skills/deeppapernote/scripts/write_obsidian_note.py @@ -40,11 +40,13 @@ def parser() -> argparse.ArgumentParser: p.add_argument("--title", default="", help="Explicit title override.") p.add_argument("--output", default="", help="JSON status output path.") p.add_argument("--vault", default="", help="Target Obsidian vault path.") + p.add_argument("--save-mode", choices=("workspace", "obsidian"), default="") + p.add_argument("--papers-dir", default="", help="Vault-relative paper directory.") p.add_argument("--subdir", default="", help="Vault-relative subdirectory.") p.add_argument("--filename", default="", help="Explicit note filename.") p.add_argument("--asset-subdir", default="images", help="Asset folder name relative to the note directory.") p.add_argument("--paper-id", default="", help="Canonical paper id.") - p.add_argument("--language", default="", help="Output language: en or zh-CN. Defaults to DEEPPAPERNOTE_OUTPUT_LANGUAGE or zh-CN.") + p.add_argument("--language", default="", help="Run Override for output language: en or zh-CN.") return p @@ -199,7 +201,14 @@ def require_lint_gate(lint: dict, key: str, gate: str, lint_path: str) -> None: def main() -> None: args = parser().parse_args() - config = runtime_config() + config = runtime_config( + cli_overrides={ + "output_language": args.language, + "save_mode": args.save_mode or ("obsidian" if args.vault else ""), + "obsidian_vault": args.vault, + "papers_dir": args.papers_dir, + } + ) output_language = normalize_output_language(args.language or str(config.get("output_language", "")) or None) record = maybe_load_json_record(args.input) or {} @@ -242,8 +251,6 @@ def main() -> None: raise SystemExit("write_obsidian_note.py requires --content-file, --content, or --stdin.") require_reference_hygiene(note_text, "before save") - if args.vault: - config["obsidian_vault"] = args.vault resolved_subdir = resolve_domain_subdir( config, title=title, diff --git a/tests/conftest.py b/tests/conftest.py index f8ace52..72addde 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,11 +1,25 @@ from __future__ import annotations +import json import sys from pathlib import Path +import pytest PROJECT_ROOT = Path(__file__).resolve().parents[1] SCRIPTS_DIR = PROJECT_ROOT / "skills" / "deeppapernote" / "scripts" if str(SCRIPTS_DIR) not in sys.path: sys.path.insert(0, str(SCRIPTS_DIR)) + + +@pytest.fixture(autouse=True) +def configured_user_home(tmp_path: Path, monkeypatch) -> Path: + monkeypatch.setenv("HOME", str(tmp_path)) + config_path = tmp_path / ".deeppapernote" / "config.json" + config_path.parent.mkdir(exist_ok=True) + config_path.write_text( + json.dumps({"output_language": "zh-CN", "save_mode": "workspace"}), + encoding="utf-8", + ) + return config_path diff --git a/tests/test_citation_links.py b/tests/test_citation_links.py index cbbf158..c86b2aa 100644 --- a/tests/test_citation_links.py +++ b/tests/test_citation_links.py @@ -280,7 +280,11 @@ def test_bundle_exposes_reference_candidates_under_references(monkeypatch) -> No monkeypatch.setattr( build_synthesis_bundle, "runtime_config", - lambda: {"obsidian_vault": "", "papers_dir": "Research/Papers"}, + lambda **_kwargs: { + "output_language": "zh-CN", + "obsidian_vault": "", + "papers_dir": "Research/Papers", + }, ) synthesis = build_synthesis_bundle.bundle( @@ -314,3 +318,40 @@ def test_bundle_exposes_reference_candidates_under_references(monkeypatch) -> No assert candidates[0]["display_text"] == "Vaswani et al. (2017). Attention Is All You Need." assert candidates[0]["match_status"] == "vault_unavailable" assert candidates[0]["wikilink"] == "" + + +def test_bundle_reuses_cli_language_configuration_for_reference_resolution(monkeypatch) -> None: + def configured_runtime(*, cli_overrides): + assert cli_overrides == {"output_language": "en"} + return { + "output_language": "en", + "obsidian_vault": "", + "papers_dir": "Research/Papers", + } + + monkeypatch.setattr(build_synthesis_bundle, "runtime_config", configured_runtime) + + synthesis = build_synthesis_bundle.bundle( + metadata={"title": "Citation Paper"}, + evidence_wrapper={ + "evidence_pack": { + "reference_candidates": [{"display_text": "Unknown Paper."}] + } + }, + figures_wrapper={}, + assets_wrapper={}, + source_manifest={ + "identity_contract": { + "artifact_type": "canonical_identity", + "schema_version": 2, + "paper_id": "paper:citation-test", + "identity_verdict": "accepted", + "work_level_identity": {"title": "Citation Paper"}, + "accepted_metadata": {"title": "Citation Paper"}, + } + }, + output_language="en", + ) + + assert synthesis["writing_contract"]["language"] == "en" + assert synthesis["references"]["candidates"][0]["match_status"] == "vault_unavailable" diff --git a/tests/test_cli_fallback.py b/tests/test_cli_fallback.py index 3cef532..20bdf16 100644 --- a/tests/test_cli_fallback.py +++ b/tests/test_cli_fallback.py @@ -8,7 +8,6 @@ import pytest - PROJECT_ROOT = Path(__file__).resolve().parents[1] WRITE_SCRIPT = PROJECT_ROOT / "skills" / "deeppapernote" / "scripts" / "write_obsidian_note.py" MATERIALIZE_SCRIPT = ( @@ -203,6 +202,56 @@ def test_write_note_in_vault_mode_does_not_duplicate_paper_slug(tmp_path: Path) assert note_path.exists() +def test_write_note_uses_ready_chinese_obsidian_configuration( + tmp_path: Path, configured_user_home: Path +) -> None: + vault = tmp_path / "vault" + vault.mkdir() + configured_user_home.write_text( + json.dumps( + { + "output_language": "zh-CN", + "save_mode": "obsidian", + "obsidian_vault": str(vault), + "papers_dir": "Research/Papers", + } + ), + encoding="utf-8", + ) + env = os.environ.copy() + for name in ( + "DEEPPAPERNOTE_OUTPUT_LANGUAGE", + "DEEPPAPERNOTE_SAVE_MODE", + "DEEPPAPERNOTE_OBSIDIAN_VAULT", + "DEEPPAPERNOTE_PAPERS_DIR", + ): + env.pop(name, None) + + result = subprocess.run( + [ + sys.executable, + str(WRITE_SCRIPT), + "--title", + "配置驱动保存", + "--subdir", + "机器学习", + "--content", + "# 配置驱动保存\n\n中文 Obsidian 保存测试。\n", + ], + cwd=tmp_path, + env=env, + check=True, + capture_output=True, + text=True, + ) + payload = json.loads(result.stdout) + + assert payload["output_language"] == "zh-CN" + assert payload["output_mode"] == "obsidian" + assert Path(payload["note_path"]).is_file() + assert Path(payload["note_path"]).is_relative_to(vault) + + def test_write_note_refuses_when_math_gate_fails(tmp_path: Path) -> None: lint_path = tmp_path / "lint.json" lint_path.write_text( diff --git a/tests/test_contracts_consistency.py b/tests/test_contracts_consistency.py index ac7518b..51cd9dd 100644 --- a/tests/test_contracts_consistency.py +++ b/tests/test_contracts_consistency.py @@ -565,7 +565,8 @@ def test_skill_owns_formal_save_state_policy() -> None: assert marker not in obsidian_format_text assert "After such a refusal" not in skill_text assert "do not switch to workspace" in skill_text - assert "explicitly chooses not to use a vault" in skill_text + assert "`save_mode=workspace`" in skill_text + assert "references/user-configuration.md" in skill_text for policy_phrase in ("permission escalation", "workspace fallback", "explicit user consent"): assert policy_phrase not in obsidian_format_text diff --git a/tests/test_lint_note.py b/tests/test_lint_note.py index 5ca4761..a46c4a1 100644 --- a/tests/test_lint_note.py +++ b/tests/test_lint_note.py @@ -1119,6 +1119,7 @@ def test_note_plan_explicit_not_reported_entries_pass_plan_gate(tmp_path) -> Non def test_write_obsidian_note_refuses_failed_plan_gate(tmp_path) -> None: + (tmp_path / "vault").mkdir() lint_path = tmp_path / "lint.json" lint_path.write_text( json.dumps( @@ -1157,6 +1158,7 @@ def test_write_obsidian_note_refuses_failed_plan_gate(tmp_path) -> None: def test_write_obsidian_note_reports_lint_warning_details(tmp_path) -> None: + (tmp_path / "vault").mkdir() lint_path = tmp_path / "lint.json" lint_path.write_text( json.dumps( @@ -1275,6 +1277,7 @@ def test_real_image_embed_counts_as_figure_marker_in_full_lint(tmp_path) -> None def test_write_obsidian_note_refuses_failed_substantive_gate(tmp_path) -> None: + (tmp_path / "vault").mkdir() lint_path = tmp_path / "lint.json" lint_path.write_text( json.dumps( diff --git a/tests/test_output_language.py b/tests/test_output_language.py index 223a0cb..9c131ce 100644 --- a/tests/test_output_language.py +++ b/tests/test_output_language.py @@ -184,20 +184,24 @@ def test_english_figure_plan_uses_english_targets_and_reasons() -> None: assert not any("\u4e00" <= character <= "\u9fff" for character in json.dumps(items, ensure_ascii=False)) -def test_english_note_passes_every_lint_gate(tmp_path: Path) -> None: +def test_english_note_passes_every_lint_gate_from_user_configuration( + tmp_path: Path, configured_user_home: Path +) -> None: note_path = tmp_path / "paper.md" plan_path = tmp_path / "paper.plan.json" output_path = tmp_path / "lint.json" note_path.write_text(english_note(), encoding="utf-8") plan_path.write_text(json.dumps(plan_payload()), encoding="utf-8") + configured_user_home.write_text( + json.dumps({"output_language": "en", "save_mode": "workspace"}), + encoding="utf-8", + ) script = Path(__file__).resolve().parents[1] / "skills/deeppapernote/scripts/lint_note.py" subprocess.run( [ sys.executable, str(script), - "--language", - "en", "--input", str(note_path), "--plan-file", diff --git a/tests/test_run_pipeline_manifest.py b/tests/test_run_pipeline_manifest.py index 29c4c9d..bc49aad 100644 --- a/tests/test_run_pipeline_manifest.py +++ b/tests/test_run_pipeline_manifest.py @@ -173,6 +173,68 @@ def fake_run(cmd: list[str], check: bool = True, **kwargs) -> object: ) +def test_run_pipeline_stops_at_configuration_before_identity( + tmp_path: Path, + monkeypatch, + configured_user_home: Path, +) -> None: + configured_user_home.unlink() + workdir = tmp_path / "must-not-exist" + calls: list[list[str]] = [] + monkeypatch.setattr(run_pipeline.subprocess, "run", lambda cmd, **kwargs: calls.append(cmd)) + monkeypatch.setattr( + sys, + "argv", + [ + "run_pipeline.py", + "--input", + "paper.pdf", + "--workdir", + str(workdir), + ], + ) + + with pytest.raises(SystemExit, match="needs_input"): + run_pipeline.main() + + assert calls == [] + assert not workdir.exists() + + +def test_run_pipeline_propagates_run_override_without_persisting_it( + tmp_path: Path, + monkeypatch, + configured_user_home: Path, +) -> None: + original = configured_user_home.read_bytes() + calls: list[tuple[list[str], dict[str, str]]] = [] + + def fake_run(cmd: list[str], check: bool = True, **kwargs) -> object: + calls.append((cmd, kwargs["env"])) + return subprocess.CompletedProcess(cmd, 0) + + monkeypatch.setattr(run_pipeline.subprocess, "run", fake_run) + monkeypatch.setattr( + sys, + "argv", + [ + "run_pipeline.py", + "--input", + "paper.pdf", + "--workdir", + str(tmp_path / "run"), + "--language", + "en", + ], + ) + + run_pipeline.main() + + assert calls + assert all(env["DEEPPAPERNOTE_OUTPUT_LANGUAGE"] == "en" for _, env in calls) + assert configured_user_home.read_bytes() == original + + def test_run_pipeline_stops_before_fetch_when_identity_repair_is_exhausted( tmp_path: Path, monkeypatch, diff --git a/tests/test_user_configuration.py b/tests/test_user_configuration.py new file mode 100644 index 0000000..f94135b --- /dev/null +++ b/tests/test_user_configuration.py @@ -0,0 +1,422 @@ +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest +import user_configuration +from user_configuration import ( + ConfigurationWriteError, + inspect_configuration, + persist_preferences, + resolve_preferences, +) + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +CONFIG_SCRIPT = PROJECT_ROOT / "skills/deeppapernote/scripts/user_configuration.py" +ENVIRONMENT_SCRIPT = PROJECT_ROOT / "skills/deeppapernote/scripts/check_environment.py" + + +def test_first_use_requests_one_workspace_prompt_batch(tmp_path: Path) -> None: + result = inspect_configuration(config_path=tmp_path / "config.json", environ={}) + + assert result == { + "state": "needs_input", + "config_path": str(tmp_path / "config.json"), + "affected_fields": ["output_language", "save_mode"], + "prompt_fields": ["output_language", "save_mode"], + "migration_candidates": {}, + "warnings": [], + } + + +def test_workspace_configuration_ignores_inactive_obsidian_fields(tmp_path: Path) -> None: + path = tmp_path / "config.json" + path.write_text( + json.dumps( + { + "output_language": "zh-CN", + "save_mode": "workspace", + "obsidian_vault": "relative/missing", + "papers_dir": "../unsafe", + } + ), + encoding="utf-8", + ) + + result = inspect_configuration(config_path=path, environ={}) + + assert result["state"] == "ready" + assert result["affected_fields"] == [] + assert result["configuration"]["save_mode"] == "workspace" + + +def test_obsidian_configuration_requires_safe_existing_paths(tmp_path: Path) -> None: + path = tmp_path / "config.json" + path.write_text( + json.dumps( + { + "output_language": "de", + "save_mode": "obsidian", + "obsidian_vault": "relative/missing", + "papers_dir": "../unsafe", + } + ), + encoding="utf-8", + ) + + result = inspect_configuration(config_path=path, environ={}) + + assert result["state"] == "invalid" + assert result["affected_fields"] == [ + "output_language", + "obsidian_vault", + "papers_dir", + ] + assert [issue["code"] for issue in result["issues"]] == [ + "invalid_enum", + "missing_vault", + "unsafe_path", + ] + + +def test_inspection_does_not_probe_or_create_the_obsidian_destination(tmp_path: Path) -> None: + vault = tmp_path / "vault" + vault.mkdir() + papers_dir = vault / "Research/Papers" + path = tmp_path / "config.json" + path.write_text( + json.dumps( + { + "output_language": "zh-CN", + "save_mode": "obsidian", + "obsidian_vault": str(vault), + "papers_dir": "Research/Papers", + } + ), + encoding="utf-8", + ) + + result = inspect_configuration(config_path=path, environ={}) + + assert result["state"] == "ready" + assert not papers_dir.exists() + + +def test_precedence_is_explicit_cli_process_then_user_without_persistence(tmp_path: Path) -> None: + path = tmp_path / "config.json" + original = '{"output_language":"zh-CN","save_mode":"workspace"}\n' + path.write_text(original, encoding="utf-8") + + resolved = resolve_preferences( + config_path=path, + explicit_overrides={"output_language": "en"}, + cli_overrides={"output_language": "zh-CN", "save_mode": "obsidian"}, + environ={ + "DEEPPAPERNOTE_OUTPUT_LANGUAGE": "zh-CN", + "DEEPPAPERNOTE_SAVE_MODE": "workspace", + }, + ) + + assert resolved["values"]["output_language"] == "en" + assert resolved["sources"]["output_language"] == "explicit_request" + assert resolved["values"]["save_mode"] == "obsidian" + assert resolved["sources"]["save_mode"] == "cli" + assert path.read_text(encoding="utf-8") == original + + +def test_partial_repair_requests_only_affected_fields(tmp_path: Path) -> None: + path = tmp_path / "config.json" + path.write_text( + json.dumps({"output_language": "zh-CN", "save_mode": "obsidian"}), + encoding="utf-8", + ) + + result = inspect_configuration(config_path=path, environ={}) + + assert result["state"] == "needs_input" + assert result["prompt_fields"] == ["obsidian_vault", "papers_dir"] + + +def test_preference_change_preserves_unknown_fields_and_is_read_back(tmp_path: Path) -> None: + path = tmp_path / "config.json" + path.write_text( + json.dumps( + {"output_language": "zh-CN", "save_mode": "workspace", "future_field": 7} + ), + encoding="utf-8", + ) + + result = persist_preferences( + {"output_language": "en"}, config_path=path, environ={} + ) + + assert result["state"] == "ready" + assert result["configuration"]["future_field"] == 7 + assert result["configuration"]["output_language"] == "en" + assert result["warnings"] == ["Preserved unknown configuration fields: future_field"] + + +@pytest.mark.parametrize("payload", ["{broken", "[]"]) +def test_invalid_configuration_requires_confirmed_replacement_and_keeps_backup( + tmp_path: Path, payload: str +) -> None: + path = tmp_path / "config.json" + path.write_text(payload, encoding="utf-8") + + with pytest.raises(ConfigurationWriteError, match="confirmed replacement"): + persist_preferences( + {"output_language": "zh-CN", "save_mode": "workspace"}, + config_path=path, + environ={}, + ) + + result = persist_preferences( + {"output_language": "zh-CN", "save_mode": "workspace"}, + config_path=path, + environ={}, + replace_invalid=True, + ) + + backups = list(tmp_path.glob("config.invalid-*.json")) + assert len(backups) == 1 + assert backups[0].read_text(encoding="utf-8") == payload + assert result["state"] == "ready" + + +def test_absent_configuration_reports_process_and_shell_migration_candidates( + tmp_path: Path, +) -> None: + vault = tmp_path / "vault" + vault.mkdir() + shell = tmp_path / ".zshrc" + shell.write_text( + 'export DEEPPAPERNOTE_SAVE_MODE="obsidian"\n' + f'export DEEPPAPERNOTE_OBSIDIAN_VAULT="{vault}"\n', + encoding="utf-8", + ) + + result = inspect_configuration( + config_path=tmp_path / "config.json", + environ={"DEEPPAPERNOTE_OUTPUT_LANGUAGE": "en"}, + shell_paths=[shell], + ) + + assert result["state"] == "needs_input" + assert result["migration_candidates"] == { + "output_language": {"value": "en", "source": "process_environment"}, + "save_mode": {"value": "obsidian", "source": str(shell)}, + "obsidian_vault": {"value": str(vault), "source": str(shell)}, + } + + +def test_invalid_legacy_values_are_not_migration_candidates(tmp_path: Path) -> None: + result = inspect_configuration( + config_path=tmp_path / "config.json", + environ={ + "DEEPPAPERNOTE_OUTPUT_LANGUAGE": "de", + "DEEPPAPERNOTE_SAVE_MODE": "banana", + "DEEPPAPERNOTE_OBSIDIAN_VAULT": "relative/missing", + "DEEPPAPERNOTE_PAPERS_DIR": "../escape", + }, + shell_paths=[], + ) + + assert result["migration_candidates"] == {} + + +def test_unknown_home_legacy_vault_is_not_a_migration_candidate(tmp_path: Path) -> None: + shell = tmp_path / ".zshrc" + shell.write_text( + "DEEPPAPERNOTE_OBSIDIAN_VAULT=~definitely_no_such_user_xyz/vault\n", + encoding="utf-8", + ) + + result = inspect_configuration( + config_path=tmp_path / "config.json", + environ={ + "DEEPPAPERNOTE_OBSIDIAN_VAULT": "~definitely_no_such_user_xyz/vault" + }, + shell_paths=[shell], + ) + + assert result["state"] == "needs_input" + assert result["migration_candidates"] == {} + + +def test_existing_configuration_never_reads_shell_candidates(tmp_path: Path) -> None: + path = tmp_path / "config.json" + path.write_text( + json.dumps({"output_language": "zh-CN", "save_mode": "workspace"}), + encoding="utf-8", + ) + missing_shell = tmp_path / "must-not-be-read" + + result = inspect_configuration( + config_path=path, + environ={"DEEPPAPERNOTE_OUTPUT_LANGUAGE": "en"}, + shell_paths=[missing_shell], + ) + + assert result["state"] == "ready" + assert result["migration_candidates"] == {} + + +@pytest.mark.parametrize( + ("payload", "code"), [("{broken", "malformed"), ("[]", "non_object")] +) +def test_inspector_distinguishes_malformed_and_non_object( + tmp_path: Path, payload: str, code: str +) -> None: + path = tmp_path / "config.json" + path.write_text(payload, encoding="utf-8") + + result = inspect_configuration(config_path=path, environ={}) + + assert result["state"] == "invalid" + assert result["issues"][0]["code"] == code + + +def test_inspector_reports_unreadable_and_unwritable_as_blocked( + tmp_path: Path, monkeypatch +) -> None: + path = tmp_path / "config.json" + path.write_text( + json.dumps({"output_language": "zh-CN", "save_mode": "workspace"}), + encoding="utf-8", + ) + original_read = user_configuration._read_configuration + monkeypatch.setattr( + user_configuration, + "_read_configuration", + lambda candidate: (None, "unreadable:permission denied"), + ) + unreadable = inspect_configuration(config_path=path, environ={}) + assert unreadable["state"] == "blocked" + assert unreadable["issues"][0]["code"] == "unreadable" + + monkeypatch.setattr(user_configuration, "_read_configuration", original_read) + monkeypatch.setattr(user_configuration, "_path_is_writable", lambda candidate: False) + unwritable = inspect_configuration(config_path=path, environ={}) + assert unwritable["state"] == "blocked" + assert unwritable["issues"][0]["code"] == "unwritable" + + +def test_writability_uses_atomic_replace_parent_directory(tmp_path: Path, monkeypatch) -> None: + path = tmp_path / "config.json" + path.write_text( + json.dumps({"output_language": "zh-CN", "save_mode": "workspace"}), + encoding="utf-8", + ) + checked: list[Path] = [] + + def fake_access(candidate: Path, mode: int) -> bool: + checked.append(Path(candidate)) + return True + + monkeypatch.setattr(user_configuration.os, "access", fake_access) + + assert inspect_configuration(config_path=path, environ={})["state"] == "ready" + assert checked == [tmp_path] + + +def test_readback_mismatch_is_never_reported_as_saved(tmp_path: Path, monkeypatch) -> None: + path = tmp_path / "config.json" + path.write_text( + json.dumps({"output_language": "zh-CN", "save_mode": "workspace"}), + encoding="utf-8", + ) + + def corrupt_write(candidate_path: Path, configuration: object) -> None: + candidate_path.write_text("{}", encoding="utf-8") + + monkeypatch.setattr(user_configuration, "_atomic_write", corrupt_write) + + with pytest.raises(ConfigurationWriteError, match="readback"): + persist_preferences({"output_language": "en"}, config_path=path, environ={}) + + +def test_confirmed_migration_becomes_the_only_durable_source(tmp_path: Path) -> None: + path = tmp_path / "config.json" + shell = tmp_path / ".zshrc" + shell.write_text( + "export DEEPPAPERNOTE_OUTPUT_LANGUAGE=en\n" + "export DEEPPAPERNOTE_SAVE_MODE=workspace\n", + encoding="utf-8", + ) + candidates = inspect_configuration( + config_path=path, environ={}, shell_paths=[shell] + )["migration_candidates"] + + persist_preferences( + {field: candidate["value"] for field, candidate in candidates.items()}, + config_path=path, + environ={}, + ) + shell.write_text( + "export DEEPPAPERNOTE_OUTPUT_LANGUAGE=zh-CN\n", + encoding="utf-8", + ) + result = inspect_configuration(config_path=path, environ={}, shell_paths=[shell]) + + assert result["state"] == "ready" + assert result["configuration"]["output_language"] == "en" + assert result["migration_candidates"] == {} + + +def test_public_and_agent_contracts_share_user_configuration_source_of_truth() -> None: + readme = (PROJECT_ROOT / "README.md").read_text(encoding="utf-8") + readme_zh = (PROJECT_ROOT / "README.zh-CN.md").read_text(encoding="utf-8") + skill = (PROJECT_ROOT / "skills/deeppapernote/SKILL.md").read_text(encoding="utf-8") + contract = ( + PROJECT_ROOT / "skills/deeppapernote/references/user-configuration.md" + ).read_text(encoding="utf-8") + + for public_doc in (readme, readme_zh): + assert "~/.deeppapernote/config.json" in public_doc + assert "DEEPPAPERNOTE_OUTPUT_LANGUAGE" not in public_doc + assert "DEEPPAPERNOTE_OBSIDIAN_VAULT" not in public_doc + assert "references/user-configuration.md" in skill + assert skill.index("Configuration Readiness") < skill.index("resolve the paper identity") + assert "explicit request > CLI > current process environment > User Configuration" in contract + assert "scripts/user_configuration.py" in contract + + +def test_configuration_cli_keeps_semantic_failures_repairable( + configured_user_home: Path, +) -> None: + result = subprocess.run( + [sys.executable, str(CONFIG_SCRIPT), "--set-save-mode", "obsidian"], + env=os.environ.copy(), + capture_output=True, + text=True, + check=False, + ) + + payload = json.loads(result.stdout) + assert result.returncode == 2 + assert payload["state"] == "needs_input" + assert payload["affected_fields"] == ["obsidian_vault", "papers_dir"] + + +def test_environment_report_survives_missing_user_configuration( + tmp_path: Path, +) -> None: + home = tmp_path / "empty-home" + home.mkdir() + env = os.environ.copy() + env["HOME"] = str(home) + result = subprocess.run( + [sys.executable, str(ENVIRONMENT_SCRIPT)], + env=env, + capture_output=True, + text=True, + check=False, + ) + + payload = json.loads(result.stdout) + assert result.returncode == 0 + assert payload["user_configuration"]["state"] == "needs_input" From 864bc3d4b8cd8e4444febe31a238016532fbf39a Mon Sep 17 00:00:00 2001 From: dingdingcar <1054263492@qq.com> Date: Thu, 27 Aug 2026 03:26:24 +0800 Subject: [PATCH 03/15] feat: bind output language through formal save (#30) --- skills/deeppapernote/SKILL.md | 16 ++- .../references/evidence-first.md | 1 + .../references/output-language.md | 4 +- .../scripts/build_synthesis_bundle.py | 20 +++- .../deeppapernote/scripts/lint_grounding.py | 25 ++++- skills/deeppapernote/scripts/lint_note.py | 20 +++- skills/deeppapernote/scripts/localization.py | 20 ++++ .../scripts/plan_figure_table_decisions.py | 9 ++ skills/deeppapernote/scripts/run_pipeline.py | 2 + .../scripts/write_obsidian_note.py | 42 ++++++-- tests/test_build_synthesis_bundle_contract.py | 40 ++++++- tests/test_citation_links.py | 6 +- tests/test_cli_fallback.py | 96 ++++++++++++++++- tests/test_contracts_consistency.py | 42 +++++++- tests/test_extract_evidence.py | 4 + tests/test_figure_table_decisions.py | 33 ++++++ tests/test_lint_grounding.py | 24 ++++- tests/test_lint_note.py | 101 +++++++++++++----- tests/test_output_language.py | 14 ++- tests/test_run_pipeline_manifest.py | 6 ++ 20 files changed, 463 insertions(+), 62 deletions(-) diff --git a/skills/deeppapernote/SKILL.md b/skills/deeppapernote/SKILL.md index edb032a..a8cf2c0 100644 --- a/skills/deeppapernote/SKILL.md +++ b/skills/deeppapernote/SKILL.md @@ -24,11 +24,18 @@ English trigger examples: Before a normal paper run, read `references/user-configuration.md` for configuration admission, migration, repair, Run Overrides, and Preference Changes. -## Output Language +## Language Integrity Contract -DeepPaperNote supports `zh-CN` and `en`. Resolve it through User Configuration and Run Overrides; neither language nor save mode has a silent default. +After Configuration Readiness, resolve one `output_language` (`zh-CN` or `en`) for the run. `source_manifest.language_hint` describes source text only and never selects the note profile. -The synthesis bundle's `writing_contract.language`, localized sections, metadata fields, figure labels, and mechanism-flow heading are authoritative for drafting and linting. Never draft in one language and validate it under another. Read `references/output-language.md` when drafting or debugging either language profile. +Bind that exact value through Figure Plan → Figure/Table Decisions → Synthesis Bundle → Note Plan → Grounding Lint → Final Note Lint → Final Quality Review → Final Readability Review → Formal Save: + +- Every JSON artifact in the chain carries a top-level `output_language`; the Synthesis Bundle also carries the same value at `writing_contract.language`. +- Before producing its output, every adjacent consumer requires each input language and compares it with the resolved value. Missing, unsupported, or mismatched values stop the run; no stage infers or defaults an artifact language. +- Final Quality Review and Final Readability Review each receive the resolved value and check the note against only that profile. +- Final Note Lint records `note_sha256`. Any review edit invalidates the prior lint, so rerun Final Note Lint under the same language. Formal Save requires the lint language and `note_sha256` to match the final note, and validates Figure/Table Decisions language before any save side effect. + +This contract is complete only when every named stage is bound to the resolved value and Formal Save validates the final bytes. Read `references/output-language.md` for profile content while drafting or debugging either language. This skill is intentionally narrow: - it handles one paper at a time @@ -67,7 +74,7 @@ Follow this order: 7. plan figure placement 8. build the full figure/table decision table 9. build the manifest synthesis bundle -10. have the model read the bundle plus raw sections and create a short JSON `note_plan` that satisfies the generated bundle contract +10. have the model read the bundle plus raw sections and create a short JSON `note_plan` that satisfies the generated bundle contract, including its exact `output_language` 11. draft from the plan only after the grounding gate passes 12. have the model write the note 13. lint the final note against the same `note_plan` — this stage completes only when the lint artifact exists and every reported `passes_*` gate is `true`; otherwise revise and rerun lint. If the lint output contains `passes_style_gate: false`, apply the Style Gate Enforcement rule before advancing to step 14, 15, or 16 @@ -112,7 +119,6 @@ Non-negotiable rules: - abstract fidelity: preserve the original abstract's meaning without adding later evidence or model judgments; translate it in `zh-CN` mode and render it faithfully in English in `en` mode - mechanism depth: method, framework, and system papers should include the localized mechanism-flow subsection under the localized method section, normally as a 3 to 4 step numbered flow with input, operation, and output destination - placeholder-first figures: plan major figure/table placeholders first; replace one only when identity match and visual usability are both strong; otherwise keep the placeholder -- final quality gates: lint is a floor; after lint passes, first run `final_quality_review` for analytical depth, then run `final_readability_review` for language polish, and rerun lint if either review edits the note Reference usage policy: - do not load every reference file by default diff --git a/skills/deeppapernote/references/evidence-first.md b/skills/deeppapernote/references/evidence-first.md index c805874..5b4d728 100644 --- a/skills/deeppapernote/references/evidence-first.md +++ b/skills/deeppapernote/references/evidence-first.md @@ -58,6 +58,7 @@ Recommended shape: ```json { + "output_language": "zh-CN", "paper_type": "AI_method", "paper_type_rationale": "The paper proposes a model mechanism and evaluates it against baselines; the script suggestion was treated only as a hint.", "dominant_domain": "machine learning", diff --git a/skills/deeppapernote/references/output-language.md b/skills/deeppapernote/references/output-language.md index 52df41f..5c6ffd0 100644 --- a/skills/deeppapernote/references/output-language.md +++ b/skills/deeppapernote/references/output-language.md @@ -41,6 +41,6 @@ Use `### Analytical Flow` for the mechanism-flow subsection. Each figure placeho For a materialized image, use the normal image embed followed immediately by one italic caption beginning with `Original paper item:`. -## Validation invariant +## Contract ownership -The bundle language, drafted note language, lint language, and save language must match. Do not reuse a passing lint artifact from another language. +`SKILL.md` owns the cross-stage Language Integrity Contract. This reference owns only the profile-specific schema and labels above; apply them under the resolved language carried by that contract. diff --git a/skills/deeppapernote/scripts/build_synthesis_bundle.py b/skills/deeppapernote/scripts/build_synthesis_bundle.py index 6522be0..24e43c8 100644 --- a/skills/deeppapernote/scripts/build_synthesis_bundle.py +++ b/skills/deeppapernote/scripts/build_synthesis_bundle.py @@ -20,7 +20,7 @@ paper_type_contracts, writing_contract_rules, ) -from localization import normalize_output_language +from localization import normalize_output_language, require_artifact_output_language def parser() -> argparse.ArgumentParser: @@ -360,8 +360,11 @@ def compact_writing_contract(language: str | None = None) -> dict: "figure_labels": dict(rules["figure_labels"]), "mechanism_flow_heading": rules["mechanism_flow_heading"], "note_plan_contract": { - "required_fields": list(NOTE_PLAN_REQUIRED_FIELDS), - "field_types": dict(rules["note_plan_field_types"]), + "required_fields": ["output_language", *NOTE_PLAN_REQUIRED_FIELDS], + "field_types": { + "output_language": "string", + **dict(rules["note_plan_field_types"]), + }, "required_field_checks": deepcopy( rules["note_plan_required_field_checks"] ), @@ -439,12 +442,23 @@ def bundle( figure_decisions_wrapper = figure_decisions_wrapper or {} config = runtime_config(cli_overrides={"output_language": output_language or ""}) resolved_output_language = normalize_output_language(config["output_language"]) + require_artifact_output_language( + figures_wrapper, + "Figure Plan", + resolved_output_language, + ) + require_artifact_output_language( + figure_decisions_wrapper, + "Figure/Table Decisions", + resolved_output_language, + ) identity_contract = identity_contract_summary(metadata, source_manifest) canonical_metadata = accepted_bundle_metadata(metadata, identity_contract) return { "status": "ok", "script": "build_synthesis_bundle.py", + "output_language": resolved_output_language, "paper_id": identity_contract.get("paper_id") or canonical_metadata.get("paper_id") or evidence_wrapper.get("paper_id", ""), diff --git a/skills/deeppapernote/scripts/lint_grounding.py b/skills/deeppapernote/scripts/lint_grounding.py index 15489fc..3598b87 100644 --- a/skills/deeppapernote/scripts/lint_grounding.py +++ b/skills/deeppapernote/scripts/lint_grounding.py @@ -23,6 +23,7 @@ required_field_value_error, writing_contract_rules, ) +from localization import normalize_output_language, require_artifact_output_language from source_corpus import SourceCorpusLoadError, load_source_corpus @@ -187,6 +188,7 @@ def parser() -> argparse.ArgumentParser: help="Figure/table decisions JSON path or JSON string.", ) p.add_argument("--output", default="", help="Output JSON path.") + p.add_argument("--language", default="", help="Run Override for output language: en or zh-CN.") return p @@ -640,15 +642,34 @@ def validate_figure_decisions( def main() -> None: + from common import runtime_config + args = parser().parse_args() note_plan = load_record(args.note_plan) source_manifest = load_record(args.source_manifest) bundle = load_record(args.bundle_json) if args.bundle_json else {} decisions = load_record(args.figure_decisions) + language = normalize_output_language( + runtime_config(cli_overrides={"output_language": args.language})["output_language"] + ) writing_contract = bundle.get("writing_contract", {}) if isinstance(bundle, dict) else {} - language = writing_contract.get("language") if isinstance(writing_contract, dict) else None issues = [] + for artifact, name in ( + (bundle, "Synthesis Bundle"), + (note_plan, "Note Plan"), + (decisions, "Figure/Table Decisions"), + ( + {"output_language": writing_contract.get("language")} + if isinstance(writing_contract, dict) + else {}, + "Synthesis Bundle writing_contract", + ), + ): + try: + require_artifact_output_language(artifact, name, language) + except ValueError as exc: + issues.append(issue("output_language_contract_failed", artifact=name, reason=str(exc))) issues.extend(validate_note_plan(note_plan, source_manifest, language)) issues.extend(validate_bundle_contract(note_plan, bundle)) issues.extend(validate_figure_decisions(source_manifest, decisions, args.source_manifest)) @@ -657,7 +678,7 @@ def main() -> None: "status": "ok", "script": "lint_grounding.py", "paper_id": source_manifest.get("paper_id", note_plan.get("paper_id", "")), - "output_language": writing_contract_rules(language)["language"], + "output_language": language, "issues": issues, "warnings": [item for item in issues if item.get("severity") == "warning"], "passes_grounding": not error_issues, diff --git a/skills/deeppapernote/scripts/lint_note.py b/skills/deeppapernote/scripts/lint_note.py index 7f3b41e..1bc956b 100644 --- a/skills/deeppapernote/scripts/lint_note.py +++ b/skills/deeppapernote/scripts/lint_note.py @@ -4,6 +4,7 @@ from __future__ import annotations import argparse +import hashlib import json import re from pathlib import Path @@ -16,7 +17,7 @@ WRITING_CONTRACT_RULES, required_field_value_error, ) -from localization import note_schema, normalize_output_language +from localization import note_schema, normalize_output_language, require_artifact_output_language ACTIVE_LANGUAGE = "zh-CN" SCHEMA: dict = {} @@ -246,7 +247,10 @@ def resolve_note_plan_path(note_path: Path, plan_file: str) -> Path: return note_path.with_suffix(".plan.json") -def inspect_note_plan(plan_path: Path) -> tuple[bool, list[str]]: +def inspect_note_plan( + plan_path: Path, + output_language: str | None = None, +) -> tuple[bool, list[str]]: if not plan_path.exists(): return False, ["planning_artifact_missing"] @@ -259,6 +263,14 @@ def inspect_note_plan(plan_path: Path) -> tuple[bool, list[str]]: return True, ["planning_required_fields_invalid"] issues: list[str] = [] + try: + require_artifact_output_language( + plan, + "Note Plan", + output_language or ACTIVE_LANGUAGE, + ) + except ValueError as exc: + issues.append(f"planning_output_language_contract_failed: {exc}") missing_fields = [field for field in NOTE_PLAN_REQUIRED_FIELDS if field not in plan] if missing_fields: issues.append("planning_required_fields_missing") @@ -1600,7 +1612,8 @@ def main() -> None: reference_hygiene_issues = inspect_reference_hygiene(text) substantive_issues = inspect_substantive_content(text) planning_artifact_found, planning_artifact_issues = inspect_note_plan( - resolve_note_plan_path(path, args.plan_file) + resolve_note_plan_path(path, args.plan_file), + output_language, ) warnings.extend(inspect_figure_callouts(text)) for issue in figure_issues: @@ -1651,6 +1664,7 @@ def main() -> None: "output_language": output_language, "paper_id": args.paper_id, "input_path": str(path), + "note_sha256": hashlib.sha256(text.encode("utf-8")).hexdigest(), "headers": headers, "missing_sections": missing_sections, "warnings": warnings, diff --git a/skills/deeppapernote/scripts/localization.py b/skills/deeppapernote/scripts/localization.py index 07a534c..c61d409 100644 --- a/skills/deeppapernote/scripts/localization.py +++ b/skills/deeppapernote/scripts/localization.py @@ -36,6 +36,26 @@ def normalize_output_language(value: str | None = None) -> str: raise ValueError(f"Unsupported DeepPaperNote output language: {raw}. Choose one of: {', '.join(SUPPORTED_OUTPUT_LANGUAGES)}.") return normalized + +def require_artifact_output_language( + artifact: dict[str, Any], + artifact_name: str, + expected: str, +) -> str: + language = artifact.get("output_language") + if language not in SUPPORTED_OUTPUT_LANGUAGES: + raise ValueError( + f"{artifact_name} requires output_language with one of: " + f"{', '.join(SUPPORTED_OUTPUT_LANGUAGES)}." + ) + resolved = normalize_output_language(expected) + if language != resolved: + raise ValueError( + f"{artifact_name} output_language {language} does not match " + f"resolved output_language {resolved}." + ) + return str(language) + def note_schema(language: str | None = None) -> dict[str, Any]: return deepcopy(_SCHEMAS[normalize_output_language(language)]) diff --git a/skills/deeppapernote/scripts/plan_figure_table_decisions.py b/skills/deeppapernote/scripts/plan_figure_table_decisions.py index ab824b0..47e2755 100644 --- a/skills/deeppapernote/scripts/plan_figure_table_decisions.py +++ b/skills/deeppapernote/scripts/plan_figure_table_decisions.py @@ -15,9 +15,11 @@ file_sha256, maybe_load_json_record, normalize_whitespace, + runtime_config, ) from contracts import WRITING_CONTRACT_RULES from extract_pdf_assets import _render_crop, save_image_bytes +from localization import normalize_output_language, require_artifact_output_language from source_corpus import SourceCorpusLoadError, load_source_corpus DECISION_VALUES = set(WRITING_CONTRACT_RULES["figure_decision_values"]) @@ -39,6 +41,7 @@ def parser() -> argparse.ArgumentParser: ) p.add_argument("--output", default="", help="Output JSON path.") p.add_argument("--paper-id", default="", help="Canonical paper id.") + p.add_argument("--language", default="", help="Run Override for output language: en or zh-CN.") return p @@ -475,8 +478,12 @@ def build_decisions( def main() -> None: args = parser().parse_args() + language = normalize_output_language( + runtime_config(cli_overrides={"output_language": args.language})["output_language"] + ) if args.review_decisions: payload = apply_requested_repairs(load_record(args.review_decisions)) + require_artifact_output_language(payload, "Figure/Table Decisions", language) payload["status"] = "ok" payload["script"] = "plan_figure_table_decisions.py" emit(payload, args.output) @@ -488,6 +495,7 @@ def main() -> None: ) source_manifest = load_record(args.source_manifest) figures = load_record(args.figures) if args.figures else {} + require_artifact_output_language(figures, "Figure Plan", language) assets = load_record(args.assets) if args.assets else {} decisions = build_decisions( source_manifest, @@ -498,6 +506,7 @@ def main() -> None: payload = { "status": "ok", "script": "plan_figure_table_decisions.py", + "output_language": language, "paper_id": args.paper_id or source_manifest.get("paper_id", ""), "decisions": decisions, "summary": { diff --git a/skills/deeppapernote/scripts/run_pipeline.py b/skills/deeppapernote/scripts/run_pipeline.py index a4b492e..b5b8424 100644 --- a/skills/deeppapernote/scripts/run_pipeline.py +++ b/skills/deeppapernote/scripts/run_pipeline.py @@ -207,6 +207,8 @@ def main() -> None: str(figures_json), "--assets", str(assets_json), + "--language", + args.language, "--output", str(figure_decisions_json), ], diff --git a/skills/deeppapernote/scripts/write_obsidian_note.py b/skills/deeppapernote/scripts/write_obsidian_note.py index 6f064f0..85c37bd 100644 --- a/skills/deeppapernote/scripts/write_obsidian_note.py +++ b/skills/deeppapernote/scripts/write_obsidian_note.py @@ -4,6 +4,7 @@ from __future__ import annotations import argparse +import hashlib import json import re import shutil @@ -22,7 +23,7 @@ runtime_config, ) from lint_note import inspect_reference_hygiene -from localization import normalize_output_language +from localization import normalize_output_language, require_artifact_output_language def parser() -> argparse.ArgumentParser: @@ -216,17 +217,16 @@ def main() -> None: if not title: raise SystemExit("write_obsidian_note.py requires --title or metadata with a title.") + lint: dict = {} if args.lint_json: lint_path = str(Path(args.lint_json).expanduser().resolve()) # utf-8-sig tolerates a BOM in the lint JSON (e.g. produced/edited on # Windows) that would otherwise crash json.loads before any gate check. lint = json.loads(Path(lint_path).read_text(encoding="utf-8-sig")) - lint_language = str(lint.get("output_language", "")).strip() - if lint_language and lint_language != output_language: - raise SystemExit( - f"write_obsidian_note.py refused to write note because lint language " - f"{lint_language} does not match requested language {output_language}." - ) + try: + require_artifact_output_language(lint, "lint artifact", output_language) + except ValueError as exc: + raise SystemExit(str(exc)) from exc require_lint_gate(lint, "passes_basic_structure", "basic structure", lint_path) require_lint_gate(lint, "passes_style_gate", "style", lint_path) require_lint_gate(lint, "passes_math_gate", "math", lint_path) @@ -249,8 +249,33 @@ def main() -> None: note_text = sys.stdin.read() else: raise SystemExit("write_obsidian_note.py requires --content-file, --content, or --stdin.") + if lint: + lint_note_sha256 = str(lint.get("note_sha256", "")).strip() + if not lint_note_sha256: + raise SystemExit("lint artifact requires note_sha256.") + note_sha256 = hashlib.sha256(note_text.encode("utf-8")).hexdigest() + if lint_note_sha256 != note_sha256: + raise SystemExit( + "write_obsidian_note.py refused to write note because the final note " + "changed after Final Note Lint; rerun lint under the same output_language." + ) require_reference_hygiene(note_text, "before save") + if lint and not args.figure_decisions: + raise SystemExit("Formal Save requires Figure/Table Decisions with output_language.") + figure_decisions = maybe_load_json_record(args.figure_decisions) if args.figure_decisions else {} + if args.figure_decisions and figure_decisions is None: + raise SystemExit(f"Expected JSON object for --figure-decisions: {args.figure_decisions}") + if figure_decisions: + try: + require_artifact_output_language( + figure_decisions, + "Figure/Table Decisions", + output_language, + ) + except ValueError as exc: + raise SystemExit(str(exc)) from exc + resolved_subdir = resolve_domain_subdir( config, title=title, @@ -267,9 +292,6 @@ def main() -> None: asset_dir = resolve_note_asset_dir(target_path, args.asset_subdir) asset_subdir = asset_dir.relative_to(target_path.parent).as_posix() ensure_parent(target_path) - figure_decisions = maybe_load_json_record(args.figure_decisions) if args.figure_decisions else {} - if args.figure_decisions and figure_decisions is None: - raise SystemExit(f"Expected JSON object for --figure-decisions: {args.figure_decisions}") materialized_figures = ( materialize_insert_decisions( note_text, diff --git a/tests/test_build_synthesis_bundle_contract.py b/tests/test_build_synthesis_bundle_contract.py index 5208637..bf030d4 100644 --- a/tests/test_build_synthesis_bundle_contract.py +++ b/tests/test_build_synthesis_bundle_contract.py @@ -4,10 +4,48 @@ import pytest -from build_synthesis_bundle import bundle +from build_synthesis_bundle import bundle as build_bundle from contracts import WRITING_CONTRACT_RULES +def accepted_source_manifest() -> dict: + return { + "paper_id": "paper:contract", + "identity_contract": { + "artifact_type": "canonical_identity", + "schema_version": 2, + "paper_id": "paper:contract", + "identity_verdict": "accepted", + "work_level_identity": {"title": "Contract Paper"}, + "accepted_metadata": {"title": "Contract Paper"}, + }, + } + + +def bundle(*args: object, **kwargs: object) -> dict: + if not kwargs.get("figures_wrapper"): + kwargs["figures_wrapper"] = {"output_language": "zh-CN", "figure_plan": {}} + if not kwargs.get("figure_decisions_wrapper"): + kwargs["figure_decisions_wrapper"] = {"output_language": "zh-CN", "decisions": []} + return build_bundle(*args, **kwargs) + + +def test_bundle_rejects_mismatched_adjacent_artifact_language() -> None: + with pytest.raises( + ValueError, + match="Figure Plan output_language en does not match resolved output_language zh-CN", + ): + bundle( + metadata={"title": "Contract Paper"}, + evidence_wrapper={"evidence_pack": {}}, + figures_wrapper={"output_language": "en", "figure_plan": {}}, + assets_wrapper={}, + source_manifest=accepted_source_manifest(), + figure_decisions_wrapper={"output_language": "zh-CN", "decisions": []}, + output_language="zh-CN", + ) + + def test_bundle_refuses_raw_metadata_without_an_identity_contract() -> None: with pytest.raises(ValueError, match="accepted canonical identity contract"): bundle( diff --git a/tests/test_citation_links.py b/tests/test_citation_links.py index c86b2aa..fdd638c 100644 --- a/tests/test_citation_links.py +++ b/tests/test_citation_links.py @@ -299,8 +299,9 @@ def test_bundle_exposes_reference_candidates_under_references(monkeypatch) -> No ] } }, - figures_wrapper={}, + figures_wrapper={"output_language": "zh-CN", "figure_plan": {}}, assets_wrapper={}, + figure_decisions_wrapper={"output_language": "zh-CN", "decisions": []}, source_manifest={ "identity_contract": { "artifact_type": "canonical_identity", @@ -338,8 +339,9 @@ def configured_runtime(*, cli_overrides): "reference_candidates": [{"display_text": "Unknown Paper."}] } }, - figures_wrapper={}, + figures_wrapper={"output_language": "en", "figure_plan": {}}, assets_wrapper={}, + figure_decisions_wrapper={"output_language": "en", "decisions": []}, source_manifest={ "identity_contract": { "artifact_type": "canonical_identity", diff --git a/tests/test_cli_fallback.py b/tests/test_cli_fallback.py index 20bdf16..3759e27 100644 --- a/tests/test_cli_fallback.py +++ b/tests/test_cli_fallback.py @@ -1,5 +1,6 @@ from __future__ import annotations +import hashlib import json import os import subprocess @@ -257,6 +258,7 @@ def test_write_note_refuses_when_math_gate_fails(tmp_path: Path) -> None: lint_path.write_text( json.dumps( { + "output_language": "zh-CN", "passes_basic_structure": True, "passes_style_gate": True, "passes_math_gate": False, @@ -288,7 +290,9 @@ def test_write_note_refuses_when_math_gate_fails(tmp_path: Path) -> None: assert "math gate failed" in result.stderr -def test_write_note_accepts_legacy_lint_json_without_figure_gate(tmp_path: Path) -> None: +def test_write_note_rejects_legacy_lint_json_without_output_language_before_save( + tmp_path: Path, +) -> None: lint_path = tmp_path / "lint.json" lint_path.write_text( json.dumps( @@ -318,12 +322,94 @@ def test_write_note_accepts_legacy_lint_json_without_figure_gate(tmp_path: Path) ], cwd=tmp_path, env=env, - check=True, + check=False, capture_output=True, text=True, ) - payload = json.loads(result.stdout) - assert Path(payload["note_path"]).exists() + assert result.returncode != 0 + assert "lint artifact requires output_language" in result.stderr + assert not (tmp_path / "DeepPaperNote_output").exists() + + +def test_write_note_rejects_note_edited_after_final_lint_before_save( + tmp_path: Path, +) -> None: + linted_note = "# Final Review Test\n\nLinted body.\n" + lint_path = tmp_path / "lint.json" + lint_path.write_text( + json.dumps( + { + "output_language": "zh-CN", + "note_sha256": hashlib.sha256(linted_note.encode("utf-8")).hexdigest(), + "passes_basic_structure": True, + "passes_style_gate": True, + "passes_math_gate": True, + } + ), + encoding="utf-8", + ) + env = os.environ.copy() + env.pop("DEEPPAPERNOTE_OBSIDIAN_VAULT", None) + env["DEEPPAPERNOTE_WORKSPACE_OUTPUT_DIR"] = "DeepPaperNote_output" + env["DEEPPAPERNOTE_DISABLE_SHELL_CONFIG"] = "1" + + result = subprocess.run( + [ + sys.executable, + str(WRITE_SCRIPT), + "--title", + "Final Review Test", + "--content", + linted_note.replace("Linted", "Reviewed"), + "--lint-json", + str(lint_path), + ], + cwd=tmp_path, + env=env, + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode != 0 + assert "changed after Final Note Lint" in result.stderr + assert not (tmp_path / "DeepPaperNote_output").exists() + + +def test_write_note_rejects_mismatched_figure_decisions_before_save_side_effects( + tmp_path: Path, +) -> None: + decisions_path = tmp_path / "decisions.json" + decisions_path.write_text( + json.dumps({"output_language": "en", "decisions": []}), + encoding="utf-8", + ) + env = os.environ.copy() + env.pop("DEEPPAPERNOTE_OBSIDIAN_VAULT", None) + env["DEEPPAPERNOTE_WORKSPACE_OUTPUT_DIR"] = "DeepPaperNote_output" + env["DEEPPAPERNOTE_DISABLE_SHELL_CONFIG"] = "1" + + result = subprocess.run( + [ + sys.executable, + str(WRITE_SCRIPT), + "--title", + "Language Mismatch", + "--content", + "# Language Mismatch\n", + "--figure-decisions", + str(decisions_path), + ], + cwd=tmp_path, + env=env, + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode != 0 + assert "does not match resolved output_language zh-CN" in result.stderr + assert not (tmp_path / "DeepPaperNote_output").exists() def test_write_note_refuses_when_reference_hygiene_gate_fails(tmp_path: Path) -> None: @@ -331,6 +417,7 @@ def test_write_note_refuses_when_reference_hygiene_gate_fails(tmp_path: Path) -> lint_path.write_text( json.dumps( { + "output_language": "zh-CN", "passes_basic_structure": True, "passes_style_gate": True, "passes_math_gate": True, @@ -396,6 +483,7 @@ def test_write_note_refuses_when_figure_gate_fails(tmp_path: Path) -> None: lint_path.write_text( json.dumps( { + "output_language": "zh-CN", "passes_basic_structure": True, "passes_style_gate": True, "passes_math_gate": True, diff --git a/tests/test_contracts_consistency.py b/tests/test_contracts_consistency.py index 51cd9dd..bcb0a4d 100644 --- a/tests/test_contracts_consistency.py +++ b/tests/test_contracts_consistency.py @@ -52,6 +52,15 @@ def bundle(**kwargs: object) -> dict: + if not kwargs.get("figures_wrapper"): + kwargs["figures_wrapper"] = {"output_language": "zh-CN", "figure_plan": {}} + if not kwargs.get("figure_decisions_wrapper"): + kwargs["figure_decisions_wrapper"] = {"output_language": "zh-CN", "decisions": []} + elif "output_language" not in kwargs["figure_decisions_wrapper"]: + kwargs["figure_decisions_wrapper"] = { + "output_language": "zh-CN", + **kwargs["figure_decisions_wrapper"], + } source_manifest = dict(kwargs.pop("source_manifest", {}) or {}) source_manifest["identity_contract"] = { "artifact_type": "canonical_identity", @@ -92,6 +101,35 @@ def test_topic_references_do_not_redefine_canonical_workflow() -> None: assert "three-stage model-first pipeline" not in evidence_first +def test_skill_owns_one_fail_closed_output_language_contract() -> None: + skill = (SKILL_ROOT / "SKILL.md").read_text(encoding="utf-8") + output_reference = (SKILL_ROOT / "references" / "output-language.md").read_text( + encoding="utf-8" + ) + evidence_reference = (SKILL_ROOT / "references" / "evidence-first.md").read_text( + encoding="utf-8" + ) + + assert skill.count("## Language Integrity Contract") == 1 + for stage in ( + "Figure Plan", + "Figure/Table Decisions", + "Synthesis Bundle", + "Note Plan", + "Grounding Lint", + "Final Note Lint", + "Final Quality Review", + "Final Readability Review", + "Formal Save", + ): + assert stage in skill + assert "source_manifest.language_hint" in skill + assert "note_sha256" in skill + assert "before any save side effect" in skill + assert "`SKILL.md` owns the cross-stage Language Integrity Contract" in output_reference + assert '"output_language": "zh-CN"' in evidence_reference + + def test_codex_adapter_stays_thin() -> None: adapter = (SKILL_ROOT / "agents" / "openai.yaml").read_text(encoding="utf-8") @@ -268,6 +306,7 @@ def test_bundle_exposes_exact_note_plan_types_and_grounding_command() -> None: note_plan_contract = writing_contract["note_plan_contract"] assert note_plan_contract["field_types"] == { + "output_language": "string", **{field: "string" for field in NOTE_PLAN_STRING_FIELDS}, **{field: "array" for field in NOTE_PLAN_LIST_FIELDS}, } @@ -672,7 +711,8 @@ def test_evidence_first_note_plan_example_matches_lint_contract() -> None: assert match is not None example = json.loads(match.group(1)) - assert tuple(example.keys()) == NOTE_PLAN_REQUIRED_FIELDS + assert tuple(example.keys()) == ("output_language", *NOTE_PLAN_REQUIRED_FIELDS) + assert example["output_language"] == "zh-CN" assert all(isinstance(example[field], str) for field in NOTE_PLAN_REQUIRED_FIELDS[:3]) assert all(isinstance(example[field], list) for field in NOTE_PLAN_REQUIRED_FIELDS[3:]) assert example["paper_type"] in PAPER_TYPE_VALUES diff --git a/tests/test_extract_evidence.py b/tests/test_extract_evidence.py index 0513d4f..298febf 100644 --- a/tests/test_extract_evidence.py +++ b/tests/test_extract_evidence.py @@ -24,6 +24,10 @@ def bundle(*, metadata: dict, source_manifest: dict | None = None, **kwargs) -> dict: + if not kwargs.get("figures_wrapper"): + kwargs["figures_wrapper"] = {"output_language": "zh-CN", "figure_plan": {}} + if not kwargs.get("figure_decisions_wrapper"): + kwargs["figure_decisions_wrapper"] = {"output_language": "zh-CN", "decisions": []} manifest = dict(source_manifest or {}) if "identity_contract" not in manifest: title = str(metadata.get("title", "")) diff --git a/tests/test_figure_table_decisions.py b/tests/test_figure_table_decisions.py index af7cd41..8d09d8c 100644 --- a/tests/test_figure_table_decisions.py +++ b/tests/test_figure_table_decisions.py @@ -40,6 +40,7 @@ def run_decisions( figures: dict, assets: dict | None = None, ) -> dict: + figures = {"output_language": "zh-CN", **figures} source_path = write_json(tmp_path / "source_manifest.json", source_manifest) figures_path = write_json(tmp_path / "figures.json", figures) output_path = tmp_path / "figure_table_decisions.json" @@ -66,6 +67,7 @@ def run_review_decisions( *, check: bool = True, ) -> tuple[subprocess.CompletedProcess[str], dict | None]: + decisions = {"output_language": "zh-CN", **decisions} decisions_path = write_json(tmp_path / "review_decisions.json", decisions) output_path = tmp_path / "reviewed_decisions.json" result = subprocess.run( @@ -113,6 +115,7 @@ def test_figure_table_decisions_cover_every_caption(tmp_path: Path) -> None: }, } figures = { + "output_language": "zh-CN", "figure_plan": { "figures": [ { @@ -142,6 +145,36 @@ def test_figure_table_decisions_cover_every_caption(tmp_path: Path) -> None: assert decisions["Table 1"]["decision"] == "placeholder" assert decisions["Figure 2"]["decision"] == "low_priority" assert payload["summary"]["total_items"] == 3 + assert payload["output_language"] == "zh-CN" + + +def test_figure_table_decisions_reject_mismatched_figure_plan_language( + tmp_path: Path, +) -> None: + source_path = write_json(tmp_path / "source_manifest.json", {"captions": {}}) + figures_path = write_json( + tmp_path / "figures.json", + {"output_language": "en", "figure_plan": {"figures": []}}, + ) + + result = subprocess.run( + [ + sys.executable, + str(DECISIONS_SCRIPT), + "--source-manifest", + str(source_path), + "--figures", + str(figures_path), + "--language", + "zh-CN", + ], + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode != 0 + assert "Figure Plan output_language en does not match resolved output_language zh-CN" in result.stderr def test_figure_table_decisions_cover_source_corpus_captions(tmp_path: Path) -> None: diff --git a/tests/test_lint_grounding.py b/tests/test_lint_grounding.py index a96b25b..090cca9 100644 --- a/tests/test_lint_grounding.py +++ b/tests/test_lint_grounding.py @@ -55,11 +55,15 @@ def run_lint_grounding( bundle: dict | None = None, figure_decisions: dict | None = None, ) -> dict: + if figure_decisions is not None: + figure_decisions = {"output_language": "zh-CN", **figure_decisions} note_plan_path = write_json(tmp_path / "note_plan.json", note_plan) source_manifest_path = write_json(tmp_path / "source_manifest.json", source_manifest) figure_decisions_path = write_json( tmp_path / "figure_decisions.json", - figure_decisions if figure_decisions is not None else {"decisions": []}, + figure_decisions + if figure_decisions is not None + else {"output_language": "zh-CN", "decisions": []}, ) output_path = tmp_path / "grounding.json" cmd = [ @@ -113,6 +117,7 @@ def note_plan_with_sources(sources_by_section: dict[str, list[dict] | list[str]] for section, sources in sources_by_section.items() ] return { + "output_language": "zh-CN", "paper_type": "AI_method", "paper_type_rationale": "method paper", "dominant_domain": "AI", @@ -152,13 +157,30 @@ def grounded_note_plan() -> dict: def slim_bundle() -> dict: return { + "output_language": "zh-CN", "writing_contract": { + "language": "zh-CN", "contracts_by_paper_type": PAPER_TYPE_CONTRACTS, "paper_type_selection": {"source_of_truth": "note_plan.paper_type"}, } } +def test_lint_grounding_rejects_mismatched_note_plan_language(tmp_path: Path) -> None: + plan = grounded_note_plan() + plan["output_language"] = "en" + + result = run_lint_grounding(tmp_path, plan, source_manifest(), slim_bundle()) + failures = [ + item for item in result["issues"] if item["code"] == "output_language_contract_failed" + ] + + assert result["output_language"] == "zh-CN" + assert result["passes_grounding"] is False + assert failures[0]["artifact"] == "Note Plan" + assert "does not match resolved output_language zh-CN" in failures[0]["reason"] + + def test_lint_grounding_accepts_section_id_or_page_range(tmp_path: Path) -> None: result = run_lint_grounding(tmp_path, grounded_note_plan(), source_manifest(), slim_bundle()) diff --git a/tests/test_lint_note.py b/tests/test_lint_note.py index a46c4a1..9028862 100644 --- a/tests/test_lint_note.py +++ b/tests/test_lint_note.py @@ -91,6 +91,7 @@ def _valid_note_text() -> str: def _valid_plan_payload() -> dict: return { + "output_language": "zh-CN", "paper_type": "AI_method", "paper_type_rationale": "The paper proposes a model mechanism and evaluates it experimentally.", "dominant_domain": "reasoning", @@ -957,6 +958,32 @@ def test_note_plan_missing_fails_plan_gate(tmp_path) -> None: assert payload["passes_plan_gate"] is False +def test_note_plan_language_mismatch_fails_plan_gate(tmp_path) -> None: + note_path = tmp_path / "Paper.md" + plan_path = tmp_path / "Paper.plan.json" + note_path.write_text(_valid_note_text(), encoding="utf-8") + plan = _valid_plan_payload() + plan["output_language"] = "en" + plan_path.write_text(json.dumps(plan), encoding="utf-8") + + script_path = Path(__file__).resolve().parents[1] / "skills" / "deeppapernote" / "scripts" / "lint_note.py" + result = subprocess.run( + [sys.executable, str(script_path), "--input", str(note_path)], + check=True, + capture_output=True, + text=True, + ) + payload = json.loads(result.stdout) + + assert payload["output_language"] == "zh-CN" + assert payload["passes_plan_gate"] is False + assert any( + issue.startswith("planning_output_language_contract_failed:") + for issue in payload["planning_artifact_issues"] + ) + assert len(payload["note_sha256"]) == 64 + + def test_mechanical_translation_artifacts_fail_style_gate(tmp_path) -> None: note_path = tmp_path / "Paper.md" plan_path = tmp_path / "Paper.plan.json" @@ -1023,8 +1050,9 @@ def test_note_plan_empty_required_values_fail_plan_gate(tmp_path) -> None: note_path.write_text(_valid_note_text(), encoding="utf-8") plan_path.write_text( json.dumps( - { - "paper_type": "", + { + "output_language": "zh-CN", + "paper_type": "", "paper_type_rationale": "", "dominant_domain": " ", "must_cover": [], @@ -1078,8 +1106,9 @@ def test_note_plan_explicit_not_reported_entries_pass_plan_gate(tmp_path) -> Non note_path.write_text(_valid_note_text(), encoding="utf-8") plan_path.write_text( json.dumps( - { - "paper_type": "AI_method", + { + "output_language": "zh-CN", + "paper_type": "AI_method", "paper_type_rationale": "The paper proposes a model mechanism and evaluates it experimentally.", "dominant_domain": "reasoning", "must_cover": ["方法主线"], @@ -1123,8 +1152,9 @@ def test_write_obsidian_note_refuses_failed_plan_gate(tmp_path) -> None: lint_path = tmp_path / "lint.json" lint_path.write_text( json.dumps( - { - "passes_basic_structure": True, + { + "output_language": "zh-CN", + "passes_basic_structure": True, "passes_style_gate": True, "passes_math_gate": True, "passes_figure_gate": True, @@ -1162,8 +1192,9 @@ def test_write_obsidian_note_reports_lint_warning_details(tmp_path) -> None: lint_path = tmp_path / "lint.json" lint_path.write_text( json.dumps( - { - "passes_basic_structure": True, + { + "output_language": "zh-CN", + "passes_basic_structure": True, "passes_style_gate": False, "passes_math_gate": True, "warnings": ["mixed_language_lines_present"], @@ -1281,8 +1312,9 @@ def test_write_obsidian_note_refuses_failed_substantive_gate(tmp_path) -> None: lint_path = tmp_path / "lint.json" lint_path.write_text( json.dumps( - { - "passes_basic_structure": True, + { + "output_language": "zh-CN", + "passes_basic_structure": True, "passes_style_gate": True, "passes_math_gate": True, "passes_figure_gate": True, @@ -1315,8 +1347,10 @@ def test_write_obsidian_note_refuses_failed_substantive_gate(tmp_path) -> None: assert "substantive content gate failed" in result.stderr -def passing_lint_payload() -> dict: +def passing_lint_payload(note_text: str) -> dict: return { + "output_language": "zh-CN", + "note_sha256": hashlib.sha256(note_text.encode("utf-8")).hexdigest(), "passes_basic_structure": True, "passes_style_gate": True, "passes_math_gate": True, @@ -1349,12 +1383,14 @@ def test_write_obsidian_note_materializes_insert_decision(tmp_path) -> None: source_image = tmp_path / "page_001_fig_figure_1.png" source_image.write_bytes(b"fake-png") digest = hashlib.sha256(b"fake-png").hexdigest() + note_text = "# Figure Insert Paper\n\n![Figure 1](images/page_001_fig_figure_1.png)\n*Fig. 1 caption.*\n" lint_path = tmp_path / "lint.json" - lint_path.write_text(json.dumps(passing_lint_payload()), encoding="utf-8") + lint_path.write_text(json.dumps(passing_lint_payload(note_text)), encoding="utf-8") decisions_path = tmp_path / "figure_decisions.json" decisions_path.write_text( json.dumps( { + "output_language": "zh-CN", "decisions": [ { "source_id": "Figure 1", @@ -1392,7 +1428,7 @@ def test_write_obsidian_note_materializes_insert_decision(tmp_path) -> None: "--subdir", "Research/Papers/Figure Insert Paper", "--content", - "# Figure Insert Paper\n\n![Figure 1](images/page_001_fig_figure_1.png)\n*Fig. 1 caption.*\n", + note_text, "--lint-json", str(lint_path), "--figure-decisions", @@ -1420,12 +1456,14 @@ def test_write_obsidian_note_rejects_stale_reviewed_insert_bytes(tmp_path) -> No source_image = tmp_path / "page_001_fig_figure_1.png" reviewed_digest = hashlib.sha256(b"reviewed").hexdigest() source_image.write_bytes(b"changed-after-review") + note_text = "# Stale Figure Review\n\n![Figure 1](images/page_001_fig_figure_1.png)\n*Fig. 1 caption.*\n" lint_path = tmp_path / "lint.json" - lint_path.write_text(json.dumps(passing_lint_payload()), encoding="utf-8") + lint_path.write_text(json.dumps(passing_lint_payload(note_text)), encoding="utf-8") decisions_path = tmp_path / "figure_decisions.json" decisions_path.write_text( json.dumps( { + "output_language": "zh-CN", "decisions": [ { "source_id": "Figure 1", @@ -1464,7 +1502,7 @@ def test_write_obsidian_note_rejects_stale_reviewed_insert_bytes(tmp_path) -> No "--title", "Stale Figure Review", "--content", - "# Stale Figure Review\n\n![Figure 1](images/page_001_fig_figure_1.png)\n*Fig. 1 caption.*\n", + note_text, "--lint-json", str(lint_path), "--figure-decisions", @@ -1485,12 +1523,14 @@ def test_write_obsidian_note_rejects_unreferenced_insert_decision(tmp_path) -> N vault.mkdir() source_image = tmp_path / "page_001_fig_figure_1.png" source_image.write_bytes(b"fake-png") + note_text = "# Figure Insert Paper\n\n正文没有引用图片。\n" lint_path = tmp_path / "lint.json" - lint_path.write_text(json.dumps(passing_lint_payload()), encoding="utf-8") + lint_path.write_text(json.dumps(passing_lint_payload(note_text)), encoding="utf-8") decisions_path = tmp_path / "figure_decisions.json" decisions_path.write_text( json.dumps( { + "output_language": "zh-CN", "decisions": [ { "source_id": "Figure 1", @@ -1513,7 +1553,7 @@ def test_write_obsidian_note_rejects_unreferenced_insert_decision(tmp_path) -> N "--title", "Figure Insert Paper", "--content", - "# Figure Insert Paper\n\n正文没有引用图片。\n", + note_text, "--lint-json", str(lint_path), "--figure-decisions", @@ -1534,12 +1574,14 @@ def test_write_obsidian_note_rejects_plain_path_for_insert_decision(tmp_path) -> vault.mkdir() source_image = tmp_path / "page_001_fig_figure_1.png" source_image.write_bytes(b"fake-png") + note_text = "# Figure Insert Paper\n\n正文只提到 images/page_001_fig_figure_1.png 这个路径。\n" lint_path = tmp_path / "lint.json" - lint_path.write_text(json.dumps(passing_lint_payload()), encoding="utf-8") + lint_path.write_text(json.dumps(passing_lint_payload(note_text)), encoding="utf-8") decisions_path = tmp_path / "figure_decisions.json" decisions_path.write_text( json.dumps( { + "output_language": "zh-CN", "decisions": [ { "source_id": "Figure 1", @@ -1562,7 +1604,7 @@ def test_write_obsidian_note_rejects_plain_path_for_insert_decision(tmp_path) -> "--title", "Figure Insert Paper", "--content", - "# Figure Insert Paper\n\n正文只提到 images/page_001_fig_figure_1.png 这个路径。\n", + note_text, "--lint-json", str(lint_path), "--figure-decisions", @@ -1583,12 +1625,14 @@ def test_write_obsidian_note_rejects_unsafe_insert_filename(tmp_path) -> None: vault.mkdir() source_image = tmp_path / "page_001_fig_figure_1.png" source_image.write_bytes(b"fake-png") + note_text = "# Figure Insert Paper\n\n![Figure 1](images/../escaped.png)\n*Fig. 1 caption.*\n" lint_path = tmp_path / "lint.json" - lint_path.write_text(json.dumps(passing_lint_payload()), encoding="utf-8") + lint_path.write_text(json.dumps(passing_lint_payload(note_text)), encoding="utf-8") decisions_path = tmp_path / "figure_decisions.json" decisions_path.write_text( json.dumps( { + "output_language": "zh-CN", "decisions": [ { "source_id": "Figure 1", @@ -1611,7 +1655,7 @@ def test_write_obsidian_note_rejects_unsafe_insert_filename(tmp_path) -> None: "--title", "Figure Insert Paper", "--content", - "# Figure Insert Paper\n\n![Figure 1](images/../escaped.png)\n*Fig. 1 caption.*\n", + note_text, "--lint-json", str(lint_path), "--figure-decisions", @@ -1691,8 +1735,9 @@ def test_inspect_note_plan_reports_invalid_field_types(tmp_path) -> None: plan_path = tmp_path / "note.plan.json" plan_path.write_text( json.dumps( - { - "paper_type": "AI_method", + { + "output_language": "zh-CN", + "paper_type": "AI_method", "paper_type_rationale": "The paper proposes a model mechanism.", "dominant_domain": "reasoning", "must_cover": "method", @@ -1720,8 +1765,9 @@ def test_inspect_note_plan_reports_empty_section_plan(tmp_path) -> None: plan_path = tmp_path / "note.plan.json" plan_path.write_text( json.dumps( - { - "paper_type": "AI_method", + { + "output_language": "zh-CN", + "paper_type": "AI_method", "paper_type_rationale": "The paper proposes a model mechanism.", "dominant_domain": "reasoning", "must_cover": [], @@ -1761,8 +1807,9 @@ def test_inspect_note_plan_accepts_valid_plan(tmp_path) -> None: plan_path = tmp_path / "note.plan.json" plan_path.write_text( json.dumps( - { - "paper_type": "AI_method", + { + "output_language": "zh-CN", + "paper_type": "AI_method", "paper_type_rationale": "The paper proposes a model mechanism.", "dominant_domain": "reasoning", "must_cover": ["方法主线"], diff --git a/tests/test_output_language.py b/tests/test_output_language.py index 9c131ce..819f65f 100644 --- a/tests/test_output_language.py +++ b/tests/test_output_language.py @@ -9,7 +9,7 @@ from build_synthesis_bundle import compact_writing_contract from lint_grounding import validate_note_plan -from localization import normalize_output_language, required_sections +from localization import normalize_output_language, require_artifact_output_language, required_sections from plan_figures import build_figure_items @@ -109,6 +109,7 @@ def english_note() -> str: def plan_payload() -> dict: return { + "output_language": "en", "paper_type": "AI_method", "paper_type_rationale": "The paper proposes and evaluates a model mechanism.", "dominant_domain": "reasoning", @@ -138,6 +139,17 @@ def test_language_aliases_and_invalid_value() -> None: normalize_output_language("fr") +def test_artifact_language_requires_exact_supported_value() -> None: + with pytest.raises(ValueError, match="requires output_language"): + require_artifact_output_language({}, "Note Plan", "zh-CN") + with pytest.raises(ValueError, match="requires output_language"): + require_artifact_output_language( + {"output_language": "English"}, + "Note Plan", + "en", + ) + + def test_english_contract_exposes_localized_schema() -> None: contract = compact_writing_contract("en") assert contract["language"] == "en" diff --git a/tests/test_run_pipeline_manifest.py b/tests/test_run_pipeline_manifest.py index bc49aad..bca4a71 100644 --- a/tests/test_run_pipeline_manifest.py +++ b/tests/test_run_pipeline_manifest.py @@ -62,6 +62,7 @@ def test_run_pipeline_emits_manifest_raw_decisions_and_lightweight_bundle(tmp_pa identity_trace_path = workdir / "paper_identity_repair_trace.json" raw_sections_path = workdir / "paper_raw_sections.jsonl" evidence_path = workdir / "paper_evidence.json" + figures_path = workdir / "paper_figures.json" decisions_path = workdir / "paper_figure_table_decisions.json" bundle_path = workdir / "paper_bundle.json" assert identity_path.exists() @@ -76,6 +77,7 @@ def test_run_pipeline_emits_manifest_raw_decisions_and_lightweight_bundle(tmp_pa identity_trace = json.loads(identity_trace_path.read_text(encoding="utf-8")) source_manifest = json.loads(source_manifest_path.read_text(encoding="utf-8")) evidence = json.loads(evidence_path.read_text(encoding="utf-8")) + figures = json.loads(figures_path.read_text(encoding="utf-8")) decisions = json.loads(decisions_path.read_text(encoding="utf-8")) bundle = json.loads(bundle_path.read_text(encoding="utf-8")) @@ -90,6 +92,10 @@ def test_run_pipeline_emits_manifest_raw_decisions_and_lightweight_bundle(tmp_pa assert source_manifest["identity_contract"]["identity_verdict"] == "accepted" assert any(section["section_id"] == "sec:method" for section in source_manifest["sections"]) assert evidence["summary"]["source_corpus_used"] is True + assert figures["output_language"] == "zh-CN" + assert decisions["output_language"] == "zh-CN" + assert bundle["output_language"] == "zh-CN" + assert bundle["writing_contract"]["language"] == "zh-CN" assert {item["source_id"] for item in decisions["decisions"]} == {"Figure 1", "Table 1"} assert bundle["source_manifest"]["raw_sections_path"] == str(raw_sections_path.resolve()) assert bundle["identity_contract"]["identity_verdict"] == "accepted" From 16c4aab76f9d73dfc05d429943199f565de42290 Mon Sep 17 00:00:00 2001 From: dingdingcar <1054263492@qq.com> Date: Thu, 27 Aug 2026 03:34:33 +0800 Subject: [PATCH 04/15] fix: enforce formal save language admission (#30) --- .../deeppapernote/scripts/lint_grounding.py | 6 +- skills/deeppapernote/scripts/lint_note.py | 6 +- .../scripts/plan_figure_table_decisions.py | 9 +- .../scripts/write_obsidian_note.py | 99 ++++++++++--------- tests/test_cli_fallback.py | 78 +++++++++++++-- tests/test_figure_table_decisions.py | 10 ++ tests/test_output_language.py | 8 +- tests/test_run_pipeline_manifest.py | 82 +++++++++++++++ tests/test_windows_path_encoding.py | 81 ++++++++++++++- 9 files changed, 315 insertions(+), 64 deletions(-) diff --git a/skills/deeppapernote/scripts/lint_grounding.py b/skills/deeppapernote/scripts/lint_grounding.py index 3598b87..9c6b257 100644 --- a/skills/deeppapernote/scripts/lint_grounding.py +++ b/skills/deeppapernote/scripts/lint_grounding.py @@ -188,7 +188,11 @@ def parser() -> argparse.ArgumentParser: help="Figure/table decisions JSON path or JSON string.", ) p.add_argument("--output", default="", help="Output JSON path.") - p.add_argument("--language", default="", help="Run Override for output language: en or zh-CN.") + p.add_argument( + "--language", + default="", + help="Run Override for output language: en or zh-CN.", + ) return p diff --git a/skills/deeppapernote/scripts/lint_note.py b/skills/deeppapernote/scripts/lint_note.py index 1bc956b..f0248fe 100644 --- a/skills/deeppapernote/scripts/lint_note.py +++ b/skills/deeppapernote/scripts/lint_note.py @@ -17,7 +17,11 @@ WRITING_CONTRACT_RULES, required_field_value_error, ) -from localization import note_schema, normalize_output_language, require_artifact_output_language +from localization import ( + normalize_output_language, + note_schema, + require_artifact_output_language, +) ACTIVE_LANGUAGE = "zh-CN" SCHEMA: dict = {} diff --git a/skills/deeppapernote/scripts/plan_figure_table_decisions.py b/skills/deeppapernote/scripts/plan_figure_table_decisions.py index 47e2755..9c25338 100644 --- a/skills/deeppapernote/scripts/plan_figure_table_decisions.py +++ b/skills/deeppapernote/scripts/plan_figure_table_decisions.py @@ -41,7 +41,11 @@ def parser() -> argparse.ArgumentParser: ) p.add_argument("--output", default="", help="Output JSON path.") p.add_argument("--paper-id", default="", help="Canonical paper id.") - p.add_argument("--language", default="", help="Run Override for output language: en or zh-CN.") + p.add_argument( + "--language", + default="", + help="Run Override for output language: en or zh-CN.", + ) return p @@ -482,8 +486,9 @@ def main() -> None: runtime_config(cli_overrides={"output_language": args.language})["output_language"] ) if args.review_decisions: - payload = apply_requested_repairs(load_record(args.review_decisions)) + payload = load_record(args.review_decisions) require_artifact_output_language(payload, "Figure/Table Decisions", language) + payload = apply_requested_repairs(payload) payload["status"] = "ok" payload["script"] = "plan_figure_table_decisions.py" emit(payload, args.output) diff --git a/skills/deeppapernote/scripts/write_obsidian_note.py b/skills/deeppapernote/scripts/write_obsidian_note.py index 85c37bd..bd52c06 100644 --- a/skills/deeppapernote/scripts/write_obsidian_note.py +++ b/skills/deeppapernote/scripts/write_obsidian_note.py @@ -32,11 +32,15 @@ def parser() -> argparse.ArgumentParser: p.add_argument("--content-file", default="", help="Path to the final Markdown content.") p.add_argument("--content", default="", help="Inline Markdown content.") p.add_argument("--stdin", action="store_true", help="Read Markdown content from stdin.") - p.add_argument("--lint-json", default="", help="Optional lint JSON path. Refuse write if structure, style, or math gate failed.") + p.add_argument( + "--lint-json", + default="", + help="Required Final Note Lint JSON for Formal Save.", + ) p.add_argument( "--figure-decisions", default="", - help="Optional figure/table decisions JSON. Insert decisions must have referenced materialized images.", + help="Required Figure/Table Decisions JSON for Formal Save.", ) p.add_argument("--title", default="", help="Explicit title override.") p.add_argument("--output", default="", help="JSON status output path.") @@ -217,27 +221,41 @@ def main() -> None: if not title: raise SystemExit("write_obsidian_note.py requires --title or metadata with a title.") - lint: dict = {} - if args.lint_json: - lint_path = str(Path(args.lint_json).expanduser().resolve()) - # utf-8-sig tolerates a BOM in the lint JSON (e.g. produced/edited on - # Windows) that would otherwise crash json.loads before any gate check. - lint = json.loads(Path(lint_path).read_text(encoding="utf-8-sig")) - try: - require_artifact_output_language(lint, "lint artifact", output_language) - except ValueError as exc: - raise SystemExit(str(exc)) from exc - require_lint_gate(lint, "passes_basic_structure", "basic structure", lint_path) - require_lint_gate(lint, "passes_style_gate", "style", lint_path) - require_lint_gate(lint, "passes_math_gate", "math", lint_path) - if "passes_figure_gate" in lint and not lint.get("passes_figure_gate", False): - raise SystemExit(lint_failure_message(lint, "figure", lint_path)) - if "passes_plan_gate" in lint and not lint.get("passes_plan_gate", False): - raise SystemExit(lint_failure_message(lint, "plan", lint_path)) - if "passes_substantive_content" in lint and not lint.get("passes_substantive_content", False): - raise SystemExit(lint_failure_message(lint, "substantive content", lint_path)) - if "passes_reference_hygiene_gate" in lint and not lint.get("passes_reference_hygiene_gate", False): - raise SystemExit(lint_failure_message(lint, "reference hygiene", lint_path)) + if not args.lint_json: + raise SystemExit("Formal Save requires Final Note Lint with output_language.") + lint_path = str(Path(args.lint_json).expanduser().resolve()) + # utf-8-sig tolerates a BOM in the lint JSON (e.g. produced/edited on + # Windows) that would otherwise crash json.loads before any gate check. + lint = json.loads(Path(lint_path).read_text(encoding="utf-8-sig")) + try: + require_artifact_output_language(lint, "lint artifact", output_language) + except ValueError as exc: + raise SystemExit(str(exc)) from exc + require_lint_gate(lint, "passes_basic_structure", "basic structure", lint_path) + require_lint_gate(lint, "passes_style_gate", "style", lint_path) + require_lint_gate(lint, "passes_math_gate", "math", lint_path) + if "passes_figure_gate" in lint and not lint.get("passes_figure_gate", False): + raise SystemExit(lint_failure_message(lint, "figure", lint_path)) + if "passes_plan_gate" in lint and not lint.get("passes_plan_gate", False): + raise SystemExit(lint_failure_message(lint, "plan", lint_path)) + if "passes_substantive_content" in lint and not lint.get("passes_substantive_content", False): + raise SystemExit(lint_failure_message(lint, "substantive content", lint_path)) + if "passes_reference_hygiene_gate" in lint and not lint.get("passes_reference_hygiene_gate", False): + raise SystemExit(lint_failure_message(lint, "reference hygiene", lint_path)) + + if not args.figure_decisions: + raise SystemExit("Formal Save requires Figure/Table Decisions with output_language.") + figure_decisions = maybe_load_json_record(args.figure_decisions) + if figure_decisions is None: + raise SystemExit(f"Expected JSON object for --figure-decisions: {args.figure_decisions}") + try: + require_artifact_output_language( + figure_decisions, + "Figure/Table Decisions", + output_language, + ) + except ValueError as exc: + raise SystemExit(str(exc)) from exc if args.content_file: # utf-8-sig strips a leading BOM so it is never written into the saved @@ -249,33 +267,18 @@ def main() -> None: note_text = sys.stdin.read() else: raise SystemExit("write_obsidian_note.py requires --content-file, --content, or --stdin.") - if lint: - lint_note_sha256 = str(lint.get("note_sha256", "")).strip() - if not lint_note_sha256: - raise SystemExit("lint artifact requires note_sha256.") - note_sha256 = hashlib.sha256(note_text.encode("utf-8")).hexdigest() - if lint_note_sha256 != note_sha256: - raise SystemExit( - "write_obsidian_note.py refused to write note because the final note " - "changed after Final Note Lint; rerun lint under the same output_language." - ) + note_text = note_text.replace("\r\n", "\n") + lint_note_sha256 = str(lint.get("note_sha256", "")).strip() + if not lint_note_sha256: + raise SystemExit("lint artifact requires note_sha256.") + note_sha256 = hashlib.sha256(note_text.encode("utf-8")).hexdigest() + if lint_note_sha256 != note_sha256: + raise SystemExit( + "write_obsidian_note.py refused to write note because the final note " + "changed after Final Note Lint; rerun lint under the same output_language." + ) require_reference_hygiene(note_text, "before save") - if lint and not args.figure_decisions: - raise SystemExit("Formal Save requires Figure/Table Decisions with output_language.") - figure_decisions = maybe_load_json_record(args.figure_decisions) if args.figure_decisions else {} - if args.figure_decisions and figure_decisions is None: - raise SystemExit(f"Expected JSON object for --figure-decisions: {args.figure_decisions}") - if figure_decisions: - try: - require_artifact_output_language( - figure_decisions, - "Figure/Table Decisions", - output_language, - ) - except ValueError as exc: - raise SystemExit(str(exc)) from exc - resolved_subdir = resolve_domain_subdir( config, title=title, diff --git a/tests/test_cli_fallback.py b/tests/test_cli_fallback.py index 3759e27..88a02dd 100644 --- a/tests/test_cli_fallback.py +++ b/tests/test_cli_fallback.py @@ -17,12 +17,34 @@ ENV_SCRIPT = PROJECT_ROOT / "skills" / "deeppapernote" / "scripts" / "check_environment.py" +def formal_save_args(tmp_path: Path, note_text: str) -> list[str]: + lint_path = tmp_path / "passing-lint.json" + lint_path.write_text( + json.dumps( + { + "output_language": "zh-CN", + "note_sha256": hashlib.sha256(note_text.encode("utf-8")).hexdigest(), + "passes_basic_structure": True, + "passes_style_gate": True, + "passes_math_gate": True, + } + ), + encoding="utf-8", + ) + decisions_path = tmp_path / "figure-decisions.json" + decisions_path.write_text( + json.dumps({"output_language": "zh-CN", "decisions": []}), + encoding="utf-8", + ) + return ["--lint-json", str(lint_path), "--figure-decisions", str(decisions_path)] + + def test_write_note_falls_back_to_workspace(tmp_path: Path) -> None: env = os.environ.copy() env.pop("DEEPPAPERNOTE_OBSIDIAN_VAULT", None) env["DEEPPAPERNOTE_WORKSPACE_OUTPUT_DIR"] = "DeepPaperNote_output" env["DEEPPAPERNOTE_DISABLE_SHELL_CONFIG"] = "1" - + note_text = "# Fallback Output Test\n\nThis is a workspace fallback write test.\n" result = subprocess.run( [ sys.executable, @@ -30,7 +52,8 @@ def test_write_note_falls_back_to_workspace(tmp_path: Path) -> None: "--title", "Fallback Output Test", "--content", - "# Fallback Output Test\n\nThis is a workspace fallback write test.\n", + note_text, + *formal_save_args(tmp_path, note_text), ], cwd=tmp_path, env=env, @@ -56,6 +79,7 @@ def test_write_note_rejects_asset_directory_outside_save_target(tmp_path: Path) env["DEEPPAPERNOTE_DISABLE_SHELL_CONFIG"] = "1" outside_asset_dir = tmp_path / "outside-assets" + note_text = "# Unsafe Asset Directory\n" result = subprocess.run( [ sys.executable, @@ -63,7 +87,8 @@ def test_write_note_rejects_asset_directory_outside_save_target(tmp_path: Path) "--title", "Unsafe Asset Directory", "--content", - "# Unsafe Asset Directory\n", + note_text, + *formal_save_args(tmp_path, note_text), "--asset-subdir", str(outside_asset_dir), ], @@ -178,6 +203,7 @@ def test_write_note_in_vault_mode_does_not_duplicate_paper_slug(tmp_path: Path) env.pop("DEEPPAPERNOTE_OBSIDIAN_VAULT", None) env["DEEPPAPERNOTE_DISABLE_SHELL_CONFIG"] = "1" + note_text = "# My Test Paper\n\nVault write regression test.\n" result = subprocess.run( [ sys.executable, @@ -189,7 +215,8 @@ def test_write_note_in_vault_mode_does_not_duplicate_paper_slug(tmp_path: Path) "--subdir", "心理健康/My_Test_Paper", "--content", - "# My Test Paper\n\nVault write regression test.\n", + note_text, + *formal_save_args(tmp_path, note_text), ], cwd=tmp_path, env=env, @@ -228,6 +255,7 @@ def test_write_note_uses_ready_chinese_obsidian_configuration( ): env.pop(name, None) + note_text = "# 配置驱动保存\n\n中文 Obsidian 保存测试。\n" result = subprocess.run( [ sys.executable, @@ -237,7 +265,8 @@ def test_write_note_uses_ready_chinese_obsidian_configuration( "--subdir", "机器学习", "--content", - "# 配置驱动保存\n\n中文 Obsidian 保存测试。\n", + note_text, + *formal_save_args(tmp_path, note_text), ], cwd=tmp_path, env=env, @@ -308,7 +337,6 @@ def test_write_note_rejects_legacy_lint_json_without_output_language_before_save env.pop("DEEPPAPERNOTE_OBSIDIAN_VAULT", None) env["DEEPPAPERNOTE_WORKSPACE_OUTPUT_DIR"] = "DeepPaperNote_output" env["DEEPPAPERNOTE_DISABLE_SHELL_CONFIG"] = "1" - result = subprocess.run( [ sys.executable, @@ -352,6 +380,7 @@ def test_write_note_rejects_note_edited_after_final_lint_before_save( env.pop("DEEPPAPERNOTE_OBSIDIAN_VAULT", None) env["DEEPPAPERNOTE_WORKSPACE_OUTPUT_DIR"] = "DeepPaperNote_output" env["DEEPPAPERNOTE_DISABLE_SHELL_CONFIG"] = "1" + decisions_args = formal_save_args(tmp_path, linted_note)[2:] result = subprocess.run( [ @@ -363,6 +392,7 @@ def test_write_note_rejects_note_edited_after_final_lint_before_save( linted_note.replace("Linted", "Reviewed"), "--lint-json", str(lint_path), + *decisions_args, ], cwd=tmp_path, env=env, @@ -388,6 +418,8 @@ def test_write_note_rejects_mismatched_figure_decisions_before_save_side_effects env.pop("DEEPPAPERNOTE_OBSIDIAN_VAULT", None) env["DEEPPAPERNOTE_WORKSPACE_OUTPUT_DIR"] = "DeepPaperNote_output" env["DEEPPAPERNOTE_DISABLE_SHELL_CONFIG"] = "1" + note_text = "# Language Mismatch\n" + lint_args = formal_save_args(tmp_path, note_text)[:2] result = subprocess.run( [ @@ -396,7 +428,8 @@ def test_write_note_rejects_mismatched_figure_decisions_before_save_side_effects "--title", "Language Mismatch", "--content", - "# Language Mismatch\n", + note_text, + *lint_args, "--figure-decisions", str(decisions_path), ], @@ -458,6 +491,7 @@ def test_write_note_refuses_runtime_artifact_reference_without_lint_json(tmp_pat env["DEEPPAPERNOTE_WORKSPACE_OUTPUT_DIR"] = "DeepPaperNote_output" env["DEEPPAPERNOTE_DISABLE_SHELL_CONFIG"] = "1" + note_text = "# Direct Reference Hygiene Test\n\n## 引用\n\n- /private/tmp/dpn-test-runs/candidate/artifacts/llama_source_manifest.json\n" result = subprocess.run( [ sys.executable, @@ -465,7 +499,8 @@ def test_write_note_refuses_runtime_artifact_reference_without_lint_json(tmp_pat "--title", "Direct Reference Hygiene Test", "--content", - "# Direct Reference Hygiene Test\n\n## 引用\n\n- /private/tmp/dpn-test-runs/candidate/artifacts/llama_source_manifest.json\n", + note_text, + *formal_save_args(tmp_path, note_text), ], cwd=tmp_path, env=env, @@ -478,6 +513,33 @@ def test_write_note_refuses_runtime_artifact_reference_without_lint_json(tmp_pat assert not (tmp_path / "DeepPaperNote_output").exists() +def test_write_note_requires_integrity_artifacts_before_save(tmp_path: Path) -> None: + env = os.environ.copy() + env.pop("DEEPPAPERNOTE_OBSIDIAN_VAULT", None) + env["DEEPPAPERNOTE_WORKSPACE_OUTPUT_DIR"] = "DeepPaperNote_output" + env["DEEPPAPERNOTE_DISABLE_SHELL_CONFIG"] = "1" + + result = subprocess.run( + [ + sys.executable, + str(WRITE_SCRIPT), + "--title", + "Missing Integrity Artifacts", + "--content", + "# Missing Integrity Artifacts\n", + ], + cwd=tmp_path, + env=env, + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode != 0 + assert "Final Note Lint" in result.stderr + assert not (tmp_path / "DeepPaperNote_output").exists() + + def test_write_note_refuses_when_figure_gate_fails(tmp_path: Path) -> None: lint_path = tmp_path / "lint.json" lint_path.write_text( diff --git a/tests/test_figure_table_decisions.py b/tests/test_figure_table_decisions.py index 8d09d8c..4d76a55 100644 --- a/tests/test_figure_table_decisions.py +++ b/tests/test_figure_table_decisions.py @@ -425,6 +425,16 @@ def test_figure_table_decisions_apply_one_normalized_bbox_repair( ], } + result, payload = run_review_decisions( + tmp_path, + {"output_language": "en", **decisions}, + check=False, + ) + assert result.returncode != 0 + assert payload is None + assert "does not match resolved output_language zh-CN" in result.stderr + assert not (tmp_path / "candidate_repair1.png").exists() + _, payload = run_review_decisions(tmp_path, decisions) assert payload is not None decision = payload["decisions"][0] diff --git a/tests/test_output_language.py b/tests/test_output_language.py index 819f65f..aeb7840 100644 --- a/tests/test_output_language.py +++ b/tests/test_output_language.py @@ -6,13 +6,15 @@ from pathlib import Path import pytest - from build_synthesis_bundle import compact_writing_contract from lint_grounding import validate_note_plan -from localization import normalize_output_language, require_artifact_output_language, required_sections +from localization import ( + normalize_output_language, + require_artifact_output_language, + required_sections, +) from plan_figures import build_figure_items - ENGLISH_SECTIONS = ( "Core Information", "Abstract", diff --git a/tests/test_run_pipeline_manifest.py b/tests/test_run_pipeline_manifest.py index bca4a71..8f6c7d2 100644 --- a/tests/test_run_pipeline_manifest.py +++ b/tests/test_run_pipeline_manifest.py @@ -1,6 +1,8 @@ from __future__ import annotations +import hashlib import json +import os import subprocess import sys from pathlib import Path @@ -16,6 +18,7 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1] RUN_PIPELINE_SCRIPT = PROJECT_ROOT / "skills" / "deeppapernote" / "scripts" / "run_pipeline.py" +WRITE_NOTE_SCRIPT = PROJECT_ROOT / "skills" / "deeppapernote" / "scripts" / "write_obsidian_note.py" def write_test_pdf(path: Path) -> None: @@ -104,6 +107,85 @@ def test_run_pipeline_emits_manifest_raw_decisions_and_lightweight_bundle(tmp_pa removed_bundle_keys = ("evidence", "candidate_chunks", "section_texts", "summary") assert not any(key in bundle for key in removed_bundle_keys) + note_text = "# 本地 PDF 语言完整性\n\n本笔记验证工件链可安全保存。\n" + lint_path = workdir / "paper_lint.json" + lint_path.write_text( + json.dumps( + { + "output_language": "zh-CN", + "note_sha256": hashlib.sha256(note_text.encode("utf-8")).hexdigest(), + "passes_basic_structure": True, + "passes_style_gate": True, + "passes_math_gate": True, + } + ), + encoding="utf-8", + ) + save_cwd = tmp_path / "save-valid" + save_cwd.mkdir() + env = os.environ.copy() + env["DEEPPAPERNOTE_DISABLE_SHELL_CONFIG"] = "1" + env["DEEPPAPERNOTE_WORKSPACE_OUTPUT_DIR"] = "saved" + saved = subprocess.run( + [ + sys.executable, + str(WRITE_NOTE_SCRIPT), + "--title", + "本地 PDF 语言完整性", + "--content", + note_text, + "--lint-json", + str(lint_path), + "--figure-decisions", + str(decisions_path), + ], + cwd=save_cwd, + env=env, + check=True, + capture_output=True, + text=True, + ) + save_payload = json.loads(saved.stdout) + assert save_payload["output_language"] == "zh-CN" + assert Path(save_payload["note_path"]).read_text(encoding="utf-8") == note_text + assert Path(save_payload["images_dir"]).is_dir() + + for artifact_language, expected_error in ( + (None, "requires output_language"), + ("en", "does not match resolved output_language zh-CN"), + ): + invalid_decisions = dict(decisions) + if artifact_language is None: + invalid_decisions.pop("output_language") + else: + invalid_decisions["output_language"] = artifact_language + invalid_path = workdir / f"invalid-{artifact_language or 'missing'}.json" + invalid_path.write_text(json.dumps(invalid_decisions), encoding="utf-8") + failure_cwd = tmp_path / f"save-{artifact_language or 'missing'}" + failure_cwd.mkdir() + result = subprocess.run( + [ + sys.executable, + str(WRITE_NOTE_SCRIPT), + "--title", + "本地 PDF 语言失败", + "--content", + note_text, + "--lint-json", + str(lint_path), + "--figure-decisions", + str(invalid_path), + ], + cwd=failure_cwd, + env=env, + check=False, + capture_output=True, + text=True, + ) + assert result.returncode != 0 + assert expected_error in result.stderr + assert not (failure_cwd / "saved").exists() + def test_run_pipeline_does_not_materialize_before_final_save( tmp_path: Path, diff --git a/tests/test_windows_path_encoding.py b/tests/test_windows_path_encoding.py index 2684f66..fab16aa 100644 --- a/tests/test_windows_path_encoding.py +++ b/tests/test_windows_path_encoding.py @@ -9,6 +9,7 @@ from __future__ import annotations +import hashlib import json import os import subprocess @@ -22,12 +23,35 @@ import plan_figure_table_decisions from common import load_json_file, resolve_obsidian_note_path - PROJECT_ROOT = Path(__file__).resolve().parents[1] SCRIPTS = PROJECT_ROOT / "skills" / "deeppapernote" / "scripts" BOM = "" +def _formal_save_artifacts(tmp_path: Path, canonical_note_text: str) -> tuple[Path, Path]: + lint_path = tmp_path / "lint.json" + lint_path.write_text( + json.dumps( + { + "output_language": "zh-CN", + "note_sha256": hashlib.sha256( + canonical_note_text.encode("utf-8") + ).hexdigest(), + "passes_basic_structure": True, + "passes_style_gate": True, + "passes_math_gate": True, + } + ), + encoding="utf-8", + ) + decisions_path = tmp_path / "decisions.json" + decisions_path.write_text( + json.dumps({"output_language": "zh-CN", "decisions": []}), + encoding="utf-8", + ) + return lint_path, decisions_path + + # --------------------------------------------------------------------------- # # BOM handling (utf-8-sig) # # --------------------------------------------------------------------------- # @@ -173,6 +197,8 @@ def test_write_obsidian_note_strips_bom_from_saved_note(tmp_path: Path) -> None: content_file.write_bytes( BOM.encode("utf-8") + "# 标题\n\n正文内容。\n".encode("utf-8") ) + canonical_note_text = "# 标题\n\n正文内容。\n" + lint_path, decisions_path = _formal_save_artifacts(tmp_path, canonical_note_text) result = subprocess.run( [ sys.executable, @@ -180,6 +206,8 @@ def test_write_obsidian_note_strips_bom_from_saved_note(tmp_path: Path) -> None: "--title", "BOM Content Test", "--vault", str(vault), "--content-file", str(content_file), + "--lint-json", str(lint_path), + "--figure-decisions", str(decisions_path), ], cwd=tmp_path, env=_clean_env(), @@ -195,6 +223,57 @@ def test_write_obsidian_note_strips_bom_from_saved_note(tmp_path: Path) -> None: assert saved.lstrip().startswith("# 标题") +def test_write_obsidian_note_accepts_unchanged_crlf_after_final_lint( + tmp_path: Path, +) -> None: + vault = tmp_path / "vault" + vault.mkdir() + content_file = tmp_path / "note.md" + crlf_text = "# CRLF Note\r\n\r\n正文内容。\r\n" + content_file.write_bytes(crlf_text.encode("utf-8")) + lint_output = tmp_path / "lint-from-script.json" + + subprocess.run( + [ + sys.executable, + str(SCRIPTS / "lint_note.py"), + "--input", str(content_file), + "--output", str(lint_output), + ], + cwd=tmp_path, + env=_clean_env(), + check=True, + ) + lint = json.loads(lint_output.read_text(encoding="utf-8")) + lint.update({key: True for key in lint if key.startswith("passes_")}) + lint_output.write_text(json.dumps(lint), encoding="utf-8") + decisions_path = tmp_path / "decisions.json" + decisions_path.write_text( + json.dumps({"output_language": "zh-CN", "decisions": []}), + encoding="utf-8", + ) + + result = subprocess.run( + [ + sys.executable, + str(SCRIPTS / "write_obsidian_note.py"), + "--title", "CRLF Note", + "--vault", str(vault), + "--content-file", str(content_file), + "--lint-json", str(lint_output), + "--figure-decisions", str(decisions_path), + ], + cwd=tmp_path, + env=_clean_env(), + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stderr + saved = Path(json.loads(result.stdout)["note_path"]).read_bytes() + assert b"\r\n" not in saved + + def test_materialize_figure_asset_embed_uses_forward_slashes(tmp_path: Path) -> None: vault = tmp_path / "vault" vault.mkdir() From a307e10dc1af9f487630fe2aa44049db95cc9e53 Mon Sep 17 00:00:00 2001 From: dingdingcar <1054263492@qq.com> Date: Thu, 27 Aug 2026 04:02:20 +0800 Subject: [PATCH 05/15] feat: complete English output profile (#31) --- .../references/output-language.md | 10 +- .../scripts/build_synthesis_bundle.py | 6 +- skills/deeppapernote/scripts/contracts.py | 14 +- skills/deeppapernote/scripts/lint_note.py | 68 ++++- skills/deeppapernote/scripts/localization.py | 15 +- skills/deeppapernote/scripts/plan_figures.py | 2 +- tests/test_output_language.py | 284 +++++++++++++++++- 7 files changed, 367 insertions(+), 32 deletions(-) diff --git a/skills/deeppapernote/references/output-language.md b/skills/deeppapernote/references/output-language.md index 5c6ffd0..3aaed43 100644 --- a/skills/deeppapernote/references/output-language.md +++ b/skills/deeppapernote/references/output-language.md @@ -17,20 +17,20 @@ Use these top-level sections in this order: 2. `Abstract` 3. `Contributions` 4. `One-Sentence Summary` -5. `Research Questions` +5. `Research Question` 6. `Data and Task Definition` 7. `Method` 8. `Key Results` 9. `Deep Analysis` 10. `Limitations` -11. `My Notes` +11. `Research Notes` 12. `References` The allowed Core Information fields, in order, are: `Title`, `Translated title`, `Authors`, `Institutions`, `Publication date`, `Venue`, `DOI`, `arXiv`, `Paper link`, `Code / Project`, `Data / Resources`, `Paper type`. -Use `### Analytical Flow` for the mechanism-flow subsection. Each figure placeholder uses: +Use `### Mechanism Flow` for the mechanism-flow subsection. Each figure placeholder uses: ```md > [!figure] Figure 2 Human-readable label @@ -41,6 +41,10 @@ Use `### Analytical Flow` for the mechanism-flow subsection. Each figure placeho For a materialized image, use the normal image embed followed immediately by one italic caption beginning with `Original paper item:`. +`Abstract` is a faithful rendering of the source abstract in English. Preserve its meaning and scope; contribution claims, result interpretation, and hindsight judgment belong later unless the source abstract itself contains them. + +The English style gate checks headings, Core Information labels, figure callouts, inserted-image captions, and prose. Mark original non-English metadata with inline code inside `Core Information`, or with inline code or a Markdown link inside `References`; closed fenced code blocks and URLs keep their normal source text. In prose, a CJK identifier or stable proper noun must be an HTTP Markdown link or Obsidian wikilink. A source formula may retain only these CJK `\operatorname{...}` labels: `输入`, `输出`, `损失`, `状态`, `动作`, `奖励`, `标签`, `样本`, `预测`, and `目标`. The surrounding text remains subject to the English gate; inline code and free-form math text do not exempt Chinese prose. + ## Contract ownership `SKILL.md` owns the cross-stage Language Integrity Contract. This reference owns only the profile-specific schema and labels above; apply them under the resolved language carried by that contract. diff --git a/skills/deeppapernote/scripts/build_synthesis_bundle.py b/skills/deeppapernote/scripts/build_synthesis_bundle.py index 24e43c8..e1af552 100644 --- a/skills/deeppapernote/scripts/build_synthesis_bundle.py +++ b/skills/deeppapernote/scripts/build_synthesis_bundle.py @@ -348,7 +348,7 @@ def compact_writing_contract(language: str | None = None) -> dict: analysis_coverage["final_quality_review_checks"] = list( analysis_coverage["final_quality_review_checks"] ) - return { + contract = { "language": rules["language"], "contract_role": "manifest_quality_contract", "canonical_source": ( @@ -417,6 +417,10 @@ def compact_writing_contract(language: str | None = None) -> dict: }, "analysis_coverage_contract": analysis_coverage, } + abstract_contract = rules.get("abstract_contract") + if abstract_contract: + contract["abstract_contract"] = deepcopy(abstract_contract) + return contract def bundle( diff --git a/skills/deeppapernote/scripts/contracts.py b/skills/deeppapernote/scripts/contracts.py index 5e923e3..0d37641 100644 --- a/skills/deeppapernote/scripts/contracts.py +++ b/skills/deeppapernote/scripts/contracts.py @@ -289,14 +289,14 @@ def required_field_value_error( "What evidence shows failure, degradation, instability, or rising cost; if none is reported, what remains unproven?", ], "section_semantics": { - "Research Questions": "The specific technical problem and the shortcomings of existing methods.", + "Research Question": "The specific technical problem and the shortcomings of existing methods.", "Data and Task Definition": "Datasets, inputs and outputs, evaluation tasks, and experimental settings.", "Method": "Model, algorithm, training, and inference mechanisms.", "Key Results": "Main results, strong baselines, ablations, and decisive numbers.", "Deep Analysis": "Why the method works, where it is fragile, and the cost of reproduction or extension.", }, "recommended_subsections": { - "Method": ["Analytical Flow", "Model Architecture", "Training Objective", "Inference and Sampling", "Implementation Details"], + "Method": ["Mechanism Flow", "Model Architecture", "Training Objective", "Inference and Sampling", "Implementation Details"], "Key Results": ["Main Results and Strong Baselines", "What the Ablations Establish", "Failure or Unstable Settings"], "Deep Analysis": ["Why It Works", "Complexity and Scalability", "Reproduction Notes"], }, @@ -316,7 +316,7 @@ def required_field_value_error( "How do sample composition, access, and privacy limits affect reproduction and generalization?", ], "section_semantics": { - "Research Questions": "The evaluation or data gap the resource is designed to address.", + "Research Question": "The evaluation or data gap the resource is designed to address.", "Data and Task Definition": "Sources, task splits, labels, and sample scope.", "Method": "Construction, filtering, annotation, and evaluation protocol—not a model pipeline.", "Key Results": "Baseline performance, difficulty, coverage, and bias.", @@ -343,7 +343,7 @@ def required_field_value_error( "How do sample composition, missing data, privacy, and unavailable materials constrain reproduction?", ], "section_semantics": { - "Research Questions": "The clinical, psychological, or behavioral question, hypothesis, or variable relationship.", + "Research Question": "The clinical, psychological, or behavioral question, hypothesis, or variable relationship.", "Data and Task Definition": "Recruitment, eligibility, variables, instruments, and measurement.", "Method": "Study design, grouping, measurement flow, and statistical analysis.", "Key Results": "Effects, associations, group differences, uncertainty, and significance.", @@ -369,7 +369,7 @@ def required_field_value_error( "Which conclusions are conceptual or normative rather than directly empirical?", ], "section_semantics": { - "Research Questions": "The social, cultural, historical, institutional, or theoretical problem.", + "Research Question": "The social, cultural, historical, institutional, or theoretical problem.", "Data and Task Definition": "Materials, cases, texts, interviews, archives, or corpus scope—not an ML task.", "Method": "Theoretical framework, conceptual distinctions, and argument path.", "Key Results": "Interpretive findings, conceptual contribution, or revision of prior views.", @@ -395,7 +395,7 @@ def required_field_value_error( "Which trends are artifacts of the covered literature and cannot establish technical maturity?", ], "section_semantics": { - "Research Questions": "The field problem, controversy, or knowledge gap organized by the review.", + "Research Question": "The field problem, controversy, or knowledge gap organized by the review.", "Data and Task Definition": "Literature scope, search and screening criteria, and review objects.", "Method": "Taxonomy, review organization, and evidence-synthesis logic—not a single method architecture.", "Key Results": "Consensus, disagreement, trends, representative directions, and open questions.", @@ -577,6 +577,8 @@ def writing_contract_rules(language: str | None = None) -> dict[str, Any]: rules["core_info_fields"] = tuple(schema["core_info_fields"]) rules["figure_labels"] = dict(schema["figure_labels"]) rules["mechanism_flow_heading"] = schema["mechanism_flow"] + if schema.get("abstract_contract"): + rules["abstract_contract"] = deepcopy(schema["abstract_contract"]) return rules diff --git a/skills/deeppapernote/scripts/lint_note.py b/skills/deeppapernote/scripts/lint_note.py index f0248fe..619babd 100644 --- a/skills/deeppapernote/scripts/lint_note.py +++ b/skills/deeppapernote/scripts/lint_note.py @@ -199,6 +199,18 @@ def figure_prefix(key: str) -> str: MARKDOWN_IMAGE_EMBED_RE = re.compile(r"^!\[[^\]]*\]\([^)]+\)\s*$") FIGURE_CALLOUT_TITLE_RE = re.compile(r"^>\s*\[!figure\][+-]?\s*(.*)$") HTTP_URL_RE = re.compile(r"https?://\S+") +ENGLISH_METADATA_SOURCE_SPAN_PATTERNS = ( + re.compile(r"`[^`\n]+`"), + re.compile(r"\[[^\]\n]+\]\([^)\n]+\)"), +) +ENGLISH_CJK_ENTITY_LINK_RE = re.compile( + r"\[[^\]\n]*[\u4e00-\u9fff][^\]\n]*\]\(https?://[^)\n]+\)" + r"|\[\[[^\]\n]*[\u4e00-\u9fff][^\]\n]*\]\]" +) +ENGLISH_CJK_MATH_LABEL_RE = re.compile( + r"\\operatorname\{(?:输入|输出|损失|状态|动作|奖励|标签|样本|预测|目标)\}" +) +ENGLISH_MATH_SPAN_RE = re.compile(r"\$\$[^$\n]+\$\$|\$[^$\n]+\$") RUNTIME_ARTIFACT_REFERENCE_PATTERNS = [ re.compile( @@ -357,6 +369,13 @@ def front_matter_order_warnings(text: str) -> list[str]: return warnings +def english_top_level_section_warnings(text: str) -> list[str]: + if ACTIVE_LANGUAGE != "en": + return [] + actual = re.findall(r"^##\s+(.+?)\s*$", text, flags=re.MULTILINE) + return [] if actual == list(REQUIRED_SECTIONS) else ["top_level_section_profile_invalid"] + + def _inside_any_span(start: int, end: int, spans: list[tuple[int, int]]) -> bool: return any(span_start <= start and end <= span_end for span_start, span_end in spans) @@ -588,18 +607,43 @@ def subsection_name_for_line(lines: list[str], line_index: int) -> str: def mixed_language_issues(text: str) -> list[dict[str, object]]: issues: list[dict[str, object]] = [] lines = text.splitlines() + fenced_code_lines: set[int] = set() + fence_start: int | None = None + for line_index, line in enumerate(lines, start=1): + if not line.strip().startswith("```"): + continue + if fence_start is None: + fence_start = line_index + else: + fenced_code_lines.update(range(fence_start, line_index + 1)) + fence_start = None for idx, line in enumerate(lines, start=1): + stripped = line.strip() + if ACTIVE_LANGUAGE == "en": + if idx in fenced_code_lines: + continue + checked = stripped + section_name = section_name_for_line(lines, idx - 1) + if section_name in {section("core_information"), section("references")}: + for pattern in ENGLISH_METADATA_SOURCE_SPAN_PATTERNS: + checked = pattern.sub("", checked) + if stripped.startswith(f"*{FIGURE_LABELS['original_caption']}"): + checked = ENGLISH_METADATA_SOURCE_SPAN_PATTERNS[0].sub("", checked) + checked = ENGLISH_CJK_ENTITY_LINK_RE.sub("", checked) + checked = ENGLISH_MATH_SPAN_RE.sub( + lambda match: ENGLISH_CJK_MATH_LABEL_RE.sub("", match.group(0)), + checked, + ) + checked = HTTP_URL_RE.sub("", checked) + if re.search(r"[\u4e00-\u9fff]", checked): + issues.append({"line_number": idx, "line": stripped, "reason": "non_english_text_present"}) + continue if is_exempt_line(line): continue - stripped = line.strip() section_name = section_name_for_line(lines, idx - 1) subsection_name = subsection_name_for_line(lines, idx - 1) if section_name in {section("core_information"), section("references")}: continue - if ACTIVE_LANGUAGE == "en": - if re.search(r"[\u4e00-\u9fff]", stripped): - issues.append({"line_number": idx, "line": stripped, "reason": "non_english_text_present"}) - continue if not re.search(r"[\u4e00-\u9fff]", stripped): continue english_words = re.findall(r"\b[A-Za-z][A-Za-z0-9.-]*\b", stripped) @@ -1342,8 +1386,7 @@ def subsection_body(text: str, section_heading: str, subsection_heading: str) -> if not body: return "" pattern = rf"^###\s+{re.escape(subsection_heading)}\s*$" - flags = re.MULTILINE | (re.IGNORECASE if ACTIVE_LANGUAGE == "en" else 0) - match = re.search(pattern, body, flags=flags) + match = re.search(pattern, body, flags=re.MULTILINE) if not match: return "" start = match.end() @@ -1555,11 +1598,15 @@ def method_section_requires_mechanism_flow(text: str) -> bool: def mechanism_flow_warnings(text: str) -> list[str]: warnings: list[str] = [] + if ACTIVE_LANGUAGE == "en": + for heading in re.findall(r"^###\s+(.+?)\s*$", text, flags=re.MULTILINE): + if heading.casefold() == MECHANISM_FLOW_HEADING.casefold() and heading != MECHANISM_FLOW_HEADING: + warnings.append("mechanism_flow_heading_invalid") + break if not method_section_requires_mechanism_flow(text): return warnings heading_pattern = rf"^###\s+{re.escape(MECHANISM_FLOW_HEADING)}\s*$" - heading_flags = re.MULTILINE | (re.IGNORECASE if ACTIVE_LANGUAGE == "en" else 0) - if not re.search(heading_pattern, text, flags=heading_flags): + if not re.search(heading_pattern, text, flags=re.MULTILINE): warnings.append("mechanism_flow_subsection_missing") return warnings @@ -1636,6 +1683,7 @@ def main() -> None: if reason and reason not in warnings: warnings.append(reason) warnings.extend(front_matter_order_warnings(text)) + warnings.extend(english_top_level_section_warnings(text)) warnings.extend(mechanism_flow_warnings(text)) if not body_text.lstrip().startswith("# "): warnings.append("title_heading_missing") @@ -1690,6 +1738,8 @@ def main() -> None: "title_heading_missing", "no_level2_sections", "front_matter_order_invalid", + "top_level_section_profile_invalid", + "mechanism_flow_heading_invalid", } & set(warnings) ), diff --git a/skills/deeppapernote/scripts/localization.py b/skills/deeppapernote/scripts/localization.py index c61d409..a73b028 100644 --- a/skills/deeppapernote/scripts/localization.py +++ b/skills/deeppapernote/scripts/localization.py @@ -19,11 +19,20 @@ "mechanism_flow": "机制流程", }, "en": { - "sections": {"core_information": "Core Information", "abstract": "Abstract", "contributions": "Contributions", "one_sentence_summary": "One-Sentence Summary", "research_questions": "Research Questions", "data_and_task": "Data and Task Definition", "method": "Method", "key_results": "Key Results", "deep_analysis": "Deep Analysis", "limitations": "Limitations", "my_notes": "My Notes", "references": "References"}, + "sections": {"core_information": "Core Information", "abstract": "Abstract", "contributions": "Contributions", "one_sentence_summary": "One-Sentence Summary", "research_questions": "Research Question", "data_and_task": "Data and Task Definition", "method": "Method", "key_results": "Key Results", "deep_analysis": "Deep Analysis", "limitations": "Limitations", "my_notes": "Research Notes", "references": "References"}, "core_info_fields": ("Title", "Translated title", "Authors", "Institutions", "Publication date", "Venue", "DOI", "arXiv", "Paper link", "Code / Project", "Data / Resources", "Paper type"), - "core_info_aliases": {"Translated Title": "Translated title", "Author": "Authors", "Institution": "Institutions", "Publication Date": "Publication date", "Paper Link": "Paper link", "Code": "Code / Project", "Project": "Code / Project", "Data": "Data / Resources", "Resources": "Data / Resources", "Paper Type": "Paper type"}, + "core_info_aliases": {}, "figure_labels": {"location": "Suggested location:", "reason": "Why it matters:", "status": "Current status:", "original_caption": "Original paper item:"}, - "mechanism_flow": "Analytical Flow", + "mechanism_flow": "Mechanism Flow", + "abstract_contract": { + "source": "source_abstract", + "requirement": "faithful_rendering_in_output_language", + "forbidden_additions": [ + "later_contribution_claims", + "later_result_interpretation", + "hindsight_judgment", + ], + }, }, } diff --git a/skills/deeppapernote/scripts/plan_figures.py b/skills/deeppapernote/scripts/plan_figures.py index 2655e48..039e3f9 100644 --- a/skills/deeppapernote/scripts/plan_figures.py +++ b/skills/deeppapernote/scripts/plan_figures.py @@ -153,7 +153,7 @@ def _classify_caption_kind_zh(item_id: str, caption: str) -> tuple[str, str, str ENGLISH_FIGURE_PLACEMENT: dict[str, tuple[str, str]] = { "main_result": ("Key Results", "This figure or table carries a primary result and belongs in Key Results."), "data_or_task_overview": ("Data and Task Definition", "This visual explains the source, construction, screening, or scope of the data and task."), - "method_overview": ("Analytical Flow", "This visual summarizes the method or system flow and belongs in Analytical Flow when the match is reliable."), + "method_overview": ("Mechanism Flow", "This visual summarizes the method or system flow and belongs in Mechanism Flow when the match is reliable."), "data_or_task": ("Data and Task Definition", "This visual clarifies the task setting, sample, or dataset."), "method_detail": ("Method", "This visual explains an internal mechanism or execution state and belongs in Method."), "table_result": ("Key Results", "This result table helps readers locate the central quantitative evidence."), diff --git a/tests/test_output_language.py b/tests/test_output_language.py index aeb7840..7ce679a 100644 --- a/tests/test_output_language.py +++ b/tests/test_output_language.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import os import subprocess import sys from pathlib import Path @@ -20,13 +21,13 @@ "Abstract", "Contributions", "One-Sentence Summary", - "Research Questions", + "Research Question", "Data and Task Definition", "Method", "Key Results", "Deep Analysis", "Limitations", - "My Notes", + "Research Notes", "References", ) @@ -65,7 +66,7 @@ def english_note() -> str: An explicit tool-state record reduces error propagation in multi-step question answering. -## Research Questions +## Research Question How can a multi-step question-answering system remain traceable when retrieval is incomplete, external tools fail, or intermediate results are misused? @@ -75,7 +76,7 @@ def english_note() -> str: ## Method -### Analytical Flow +### Mechanism Flow 1. **Input:** A question and candidate evidence. **Operation:** Extract relevant evidence. **Output:** A grounded initial state. 2. **Input:** The current state and tool registry. **Operation:** Align the request with an available tool. **Output:** A planned call. @@ -99,7 +100,7 @@ def english_note() -> str: The evaluation covers English question-answering data and a narrow tool set, so it does not establish robustness for multimodal tools or high-latency services. -## My Notes +## Research Notes The state-record design is reusable in evidence-first paper workflows because it separates missing source material from model interpretation failure. @@ -157,8 +158,36 @@ def test_english_contract_exposes_localized_schema() -> None: assert contract["language"] == "en" assert tuple(contract["must_include_sections"]) == ENGLISH_SECTIONS assert tuple(required_sections("en")) == ENGLISH_SECTIONS - assert contract["mechanism_flow_heading"] == "Analytical Flow" - assert contract["figure_labels"]["status"] == "Current status:" + assert contract["mechanism_flow_heading"] == "Mechanism Flow" + assert contract["core_info_fields"] == [ + "Title", + "Translated title", + "Authors", + "Institutions", + "Publication date", + "Venue", + "DOI", + "arXiv", + "Paper link", + "Code / Project", + "Data / Resources", + "Paper type", + ] + assert contract["figure_labels"] == { + "location": "Suggested location:", + "reason": "Why it matters:", + "status": "Current status:", + "original_caption": "Original paper item:", + } + assert contract["abstract_contract"] == { + "source": "source_abstract", + "requirement": "faithful_rendering_in_output_language", + "forbidden_additions": [ + "later_contribution_claims", + "later_result_interpretation", + "hindsight_judgment", + ], + } assert not any("\u4e00" <= character <= "\u9fff" for character in json.dumps(contract, ensure_ascii=False)) @@ -172,7 +201,7 @@ def test_english_grounding_accepts_english_section_plan() -> None: "evidence_sources": [{"section_id": "sec:method"}], } for section in ( - "Research Questions", + "Research Question", "Data and Task Definition", "Method", "Key Results", @@ -193,7 +222,7 @@ def test_english_figure_plan_uses_english_targets_and_reasons() -> None: {"figure_captions": [{"id": "Figure 1", "caption": "Overview of the system architecture."}]}, language="en", ) - assert items[0]["section"] == "Analytical Flow" + assert items[0]["section"] == "Mechanism Flow" assert "visual" in items[0]["reason"].lower() assert not any("\u4e00" <= character <= "\u9fff" for character in json.dumps(items, ensure_ascii=False)) @@ -242,3 +271,240 @@ def test_english_lint_rejects_chinese_prose(tmp_path: Path) -> None: payload = json.loads(output_path.read_text(encoding="utf-8")) assert payload["passes_style_gate"] is False assert "mixed_language_lines_present" in payload["warnings"] + + +@pytest.mark.parametrize( + ("original", "mixed"), + [ + ("## Method", "## 方法 Method"), + ("- Title: Auditable Tool Use", "- 标题 Title: Auditable Tool Use"), + ("> Why it matters: The figure shows how evidence and tool states move through the execution chain.", "> Why it matters: 这张图 shows how evidence moves."), + ("*Original paper item: Figure 1. System overview.*", "*Original paper item: Figure 1. 这是系统概览.*"), + ], +) +def test_english_lint_rejects_chinese_structure_labels_and_captions( + tmp_path: Path, + original: str, + mixed: str, +) -> None: + note_path = tmp_path / "paper.md" + plan_path = tmp_path / "paper.plan.json" + output_path = tmp_path / "lint.json" + note = english_note() + if original.startswith("*Original paper item:"): + note = note.replace( + "> [!figure] Figure 1 System overview", + "![Figure 1](images/figure_1.png)\n" + original, + ) + note_path.write_text(note.replace(original, mixed), encoding="utf-8") + plan_path.write_text(json.dumps(plan_payload()), encoding="utf-8") + script = Path(__file__).resolve().parents[1] / "skills/deeppapernote/scripts/lint_note.py" + + subprocess.run( + [sys.executable, str(script), "--language", "en", "--input", str(note_path), "--plan-file", str(plan_path), "--output", str(output_path)], + check=True, + ) + payload = json.loads(output_path.read_text(encoding="utf-8")) + assert payload["passes_style_gate"] is False + assert "mixed_language_lines_present" in payload["warnings"] + + +def test_english_lint_limits_chinese_exemptions_to_marked_source_spans(tmp_path: Path) -> None: + note_path = tmp_path / "paper.md" + plan_path = tmp_path / "paper.plan.json" + output_path = tmp_path / "lint.json" + allowed = english_note().replace( + "- Title: Auditable Tool Use", + "- Title: `可审计工具使用`", + ).replace( + "The important contribution is not only the score increase.", + "The benchmark also evaluates [通义千问](https://example.org/qwen), and the source defines $\\operatorname{输入}=x$.", + ).replace( + "- Smith et al. (2024). Auditable Tool Use for Multi-hop Question Answering. DOI: 10.1234/example", + "- Smith et al. (2024). [可审计工具使用](https://example.org/paper). DOI: 10.1234/example", + ) + "\n```json\n{\"label\": \"原始代码输出\"}\n```\n" + note_path.write_text(allowed, encoding="utf-8") + plan_path.write_text(json.dumps(plan_payload()), encoding="utf-8") + script = Path(__file__).resolve().parents[1] / "skills/deeppapernote/scripts/lint_note.py" + + subprocess.run( + [sys.executable, str(script), "--language", "en", "--input", str(note_path), "--plan-file", str(plan_path), "--output", str(output_path)], + check=True, + ) + payload = json.loads(output_path.read_text(encoding="utf-8")) + assert payload["passes_style_gate"] is True + + note_path.write_text( + allowed.replace("- Title: `可审计工具使用`", "- Title: `可审计工具使用` 这是额外说明"), + encoding="utf-8", + ) + subprocess.run( + [sys.executable, str(script), "--language", "en", "--input", str(note_path), "--plan-file", str(plan_path), "--output", str(output_path)], + check=True, + ) + payload = json.loads(output_path.read_text(encoding="utf-8")) + assert payload["passes_style_gate"] is False + assert payload["mixed_language_issues"][0]["reason"] == "non_english_text_present" + + +@pytest.mark.parametrize( + "disguised", + [ + "`这是一整句中文分析不是稳定专有名词`", + "`这个方法显著提高了性能`", + "$\\text{这是一整句中文分析不是公式标签}=x$", + "$\\text{方法提高了性能}=x$", + "Plain prose cannot use \\operatorname{输入} outside math delimiters.", + ], +) +def test_english_lint_rejects_chinese_prose_disguised_as_bounded_span( + tmp_path: Path, + disguised: str, +) -> None: + note_path = tmp_path / "paper.md" + plan_path = tmp_path / "paper.plan.json" + output_path = tmp_path / "lint.json" + note_path.write_text( + english_note().replace( + "The important contribution is not only the score increase.", + disguised, + ), + encoding="utf-8", + ) + plan_path.write_text(json.dumps(plan_payload()), encoding="utf-8") + script = Path(__file__).resolve().parents[1] / "skills/deeppapernote/scripts/lint_note.py" + + subprocess.run( + [sys.executable, str(script), "--language", "en", "--input", str(note_path), "--plan-file", str(plan_path), "--output", str(output_path)], + check=True, + ) + payload = json.loads(output_path.read_text(encoding="utf-8")) + assert payload["passes_style_gate"] is False + + +@pytest.mark.parametrize( + ("original", "invalid"), + [ + ("- Authors: Smith et al.", "- Author: Smith et al."), + ("### Mechanism Flow", "### mechanism flow"), + ], +) +def test_english_lint_requires_exact_labels_and_mechanism_heading( + tmp_path: Path, + original: str, + invalid: str, +) -> None: + note_path = tmp_path / "paper.md" + plan_path = tmp_path / "paper.plan.json" + output_path = tmp_path / "lint.json" + note_path.write_text(english_note().replace(original, invalid), encoding="utf-8") + plan_path.write_text(json.dumps(plan_payload()), encoding="utf-8") + script = Path(__file__).resolve().parents[1] / "skills/deeppapernote/scripts/lint_note.py" + + subprocess.run( + [sys.executable, str(script), "--language", "en", "--input", str(note_path), "--plan-file", str(plan_path), "--output", str(output_path)], + check=True, + ) + payload = json.loads(output_path.read_text(encoding="utf-8")) + assert not all(value is True for key, value in payload.items() if key.startswith("passes_")) + + +def test_english_lint_requires_complete_top_level_heading_order(tmp_path: Path) -> None: + note_path = tmp_path / "paper.md" + plan_path = tmp_path / "paper.plan.json" + output_path = tmp_path / "lint.json" + note = english_note().replace("## Method", "## TEMP").replace("## Key Results", "## Method").replace("## TEMP", "## Key Results") + note_path.write_text(note, encoding="utf-8") + plan_path.write_text(json.dumps(plan_payload()), encoding="utf-8") + script = Path(__file__).resolve().parents[1] / "skills/deeppapernote/scripts/lint_note.py" + + subprocess.run( + [sys.executable, str(script), "--language", "en", "--input", str(note_path), "--plan-file", str(plan_path), "--output", str(output_path)], + check=True, + ) + payload = json.loads(output_path.read_text(encoding="utf-8")) + assert payload["passes_basic_structure"] is False + assert "top_level_section_profile_invalid" in payload["warnings"] + + +def test_english_lint_does_not_extend_unclosed_code_exemption_to_rest_of_note( + tmp_path: Path, +) -> None: + note_path = tmp_path / "paper.md" + plan_path = tmp_path / "paper.plan.json" + output_path = tmp_path / "lint.json" + note_path.write_text(english_note() + "\n```text\n这是未闭合代码块后的中文\n", encoding="utf-8") + plan_path.write_text(json.dumps(plan_payload()), encoding="utf-8") + script = Path(__file__).resolve().parents[1] / "skills/deeppapernote/scripts/lint_note.py" + + subprocess.run( + [sys.executable, str(script), "--language", "en", "--input", str(note_path), "--plan-file", str(plan_path), "--output", str(output_path)], + check=True, + ) + payload = json.loads(output_path.read_text(encoding="utf-8")) + assert payload["passes_style_gate"] is False + assert "mixed_language_lines_present" in payload["warnings"] + + +def test_english_local_pdf_pipeline_lints_and_formally_saves_note_with_images_dir( + tmp_path: Path, +) -> None: + fitz = pytest.importorskip("fitz") + project_root = Path(__file__).resolve().parents[1] + scripts = project_root / "skills/deeppapernote/scripts" + pdf_path = tmp_path / "paper.pdf" + document = fitz.open() + page = document.new_page() + source_abstract = "The paper develops an auditable state machine for multi-step question answering and evaluates whether explicit failure records improve answer reliability." + page.insert_textbox( + fitz.Rect(72, 72, 540, 500), + f"Abstract\n{source_abstract}\nMethod\nThe system records tool states.\nResults\nAccuracy is 78.5%.", + ) + document.save(pdf_path) + document.close() + + workdir = tmp_path / "run" + subprocess.run( + [sys.executable, str(scripts / "run_pipeline.py"), "--input", str(pdf_path), "--workdir", str(workdir), "--prefix", "paper", "--language", "en"], + check=True, + ) + assert json.loads((workdir / "paper_bundle.json").read_text(encoding="utf-8"))["output_language"] == "en" + raw_text = " ".join( + json.loads(line)["text"] + for line in (workdir / "paper_raw_sections.jsonl").read_text(encoding="utf-8").splitlines() + ) + assert source_abstract in " ".join(raw_text.split()) + + note_path = workdir / "paper.md" + plan_path = workdir / "paper.plan.json" + lint_path = workdir / "paper_lint.json" + note_path.write_text(english_note(), encoding="utf-8") + plan_path.write_text(json.dumps(plan_payload()), encoding="utf-8") + subprocess.run( + [sys.executable, str(scripts / "lint_note.py"), "--language", "en", "--input", str(note_path), "--plan-file", str(plan_path), "--output", str(lint_path)], + check=True, + ) + lint = json.loads(lint_path.read_text(encoding="utf-8")) + assert all(value is True for key, value in lint.items() if key.startswith("passes_")) + + save_root = tmp_path / "save" + save_root.mkdir() + env = os.environ.copy() + env["DEEPPAPERNOTE_DISABLE_SHELL_CONFIG"] = "1" + env["DEEPPAPERNOTE_WORKSPACE_OUTPUT_DIR"] = "saved" + saved = subprocess.run( + [sys.executable, str(scripts / "write_obsidian_note.py"), "--language", "en", "--title", "English Local PDF", "--content-file", str(note_path), "--lint-json", str(lint_path), "--figure-decisions", str(workdir / "paper_figure_table_decisions.json")], + cwd=save_root, + env=env, + check=True, + capture_output=True, + text=True, + ) + payload = json.loads(saved.stdout) + saved_note = Path(payload["note_path"]) + images_dir = Path(payload["images_dir"]) + assert payload["output_language"] == "en" + assert saved_note.read_text(encoding="utf-8") == english_note() + assert source_abstract in saved_note.read_text(encoding="utf-8") + assert images_dir == saved_note.parent / "images" + assert images_dir.is_dir() From 1a74b2d8b09feb090138b51dc257889c0e77ca3e Mon Sep 17 00:00:00 2001 From: dingdingcar <1054263492@qq.com> Date: Thu, 27 Aug 2026 04:13:17 +0800 Subject: [PATCH 06/15] docs: document bilingual configuration onboarding (#32) --- README.md | 66 ++++++++--- README.zh-CN.md | 66 ++++++++--- skills/deeppapernote/SKILL.md | 1 + .../references/output-language.md | 21 ++++ .../references/user-configuration.md | 8 +- tests/test_user_configuration.py | 112 ++++++++++++++++++ 6 files changed, 236 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 86b6894..8429454 100644 --- a/README.md +++ b/README.md @@ -78,16 +78,8 @@ Generate a deep-reading note for this paper: ``` -DeepPaperNote supports complete English and Simplified Chinese note schemas. On first use, your Agent asks once for the output language and save mode, then writes the confirmed defaults to `~/.deeppapernote/config.json`. - -```json -{ - "output_language": "en", - "save_mode": "workspace" -} -``` - -The core pipeline also accepts `--language en` for one run without changing the saved preference. Section names, metadata fields, figure callouts, planning guidance, linting, and Formal Save all follow the resolved language. +DeepPaperNote supports complete English and Simplified Chinese note schemas. On first use, your Agent asks once for every field required by the selected save mode, then writes the confirmed defaults to `~/.deeppapernote/config.json`. +The User Configuration section below covers both save modes, both language profiles, and one-run overrides. Section names, metadata fields, figure callouts, planning guidance, linting, and Formal Save all follow the resolved language. ## 🎯 Why DeepPaperNote? @@ -121,9 +113,25 @@ The canonical execution contract lives in [`skills/deeppapernote/SKILL.md`](./sk ## 🗂️ User Configuration -DeepPaperNote stores durable device-local preferences in `~/.deeppapernote/config.json`. +DeepPaperNote stores durable device-local preferences in one file: `~/.deeppapernote/config.json`. -To make an Obsidian Vault the durable save target, use: +| Field | Valid values | When required | +| --- | --- | --- | +| `output_language` | `zh-CN` or `en` | Always | +| `save_mode` | `workspace` or `obsidian` | Always | +| `obsidian_vault` | Existing absolute directory | Only for `obsidian` | +| `papers_dir` | Safe relative path inside the Vault | Only for `obsidian` | + +An English workspace configuration needs only the two always-required fields: + +```json +{ + "output_language": "en", + "save_mode": "workspace" +} +``` + +A Simplified Chinese Obsidian configuration also names the Vault and paper directory: ```json { @@ -134,11 +142,35 @@ To make an Obsidian Vault the durable save target, use: } ``` -- First use collects every required field in one prompt; later repair asks only for affected fields. -- Existing legacy settings are offered once as migration candidates and become durable only after confirmation. -- Workspace mode ignores Obsidian-only fields. Obsidian mode requires an existing absolute Vault and a relative paper directory inside it. -- Run Overrides never modify `config.json`. A durable change requires an explicit request to change the future default. -- If configuration or Formal Save is blocked, DeepPaperNote reports the affected field instead of switching destinations. +Configuration is resolved before paper identity or other expensive paper work. On first use, the Agent presents one Configuration Prompt Batch for every required field. If a later file has one missing or invalid field, it asks only for that field; an unreadable or unwritable file fails closed with the affected field instead of continuing. + +Legacy environment or shell values are shown only as one-time migration candidates while `config.json` is absent. They become preferences only after confirmation. Once the file exists, shell startup files stop supplying preferences; current-process values remain a hidden compatibility Run Override. Preference Changes preserve unknown JSON fields, and malformed JSON is backed up before confirmed replacement. + +### Run Overrides and Preference Changes + +Runtime values follow this exact precedence: + +`explicit request > CLI > current process environment > User Configuration` + +- “Generate this paper's note in English just this once” is a Run Override and leaves `config.json` byte-for-byte unchanged. +- “Generate this paper's note in Simplified Chinese just this once” is the corresponding `zh-CN` Run Override. +- “Change my default output language to English” is a Preference Change and updates the future default after confirmation. +- CLI options `--language`, `--save-mode`, `--vault`, and `--papers-dir` are Run Overrides. For example: + +```bash +python3 skills/deeppapernote/scripts/run_pipeline.py --input '' --language en --save-mode obsidian --vault /absolute/path/to/vault --papers-dir Research/Papers +``` + +Workspace mode does not use `obsidian_vault` or `papers_dir` for that run, but it preserves those preferences for future Obsidian runs. Run Overrides never become Preference Changes automatically. + +### Output Language Profiles and Formal Save + +- `zh-CN` uses `核心信息`, `原文摘要翻译`, `创新点`, `一句话总结`, `研究问题`, `数据与任务定义`, `方法主线`, `关键结果`, `深度分析`, `局限`, `我的笔记`, and `引用`, with `机制流程` under the method section. Figure callouts use `建议位置:`, `放置原因:`, and `当前状态:`; a materialized-image caption begins with `论文原图编号:`. +- `en` uses `Core Information`, `Abstract`, `Contributions`, `One-Sentence Summary`, `Research Question`, `Data and Task Definition`, `Method`, `Key Results`, `Deep Analysis`, `Limitations`, `Research Notes`, and `References`, with `Mechanism Flow`. Figure callouts use `Suggested location:`, `Why it matters:`, and `Current status:`; a materialized-image caption begins with `Original paper item:`. + +In either profile, Abstract faithfully renders the source abstract in the requested language; later contribution claims and interpretation stay in later sections. The same canonical Skill and one pipeline carry the chosen language through lint and Formal Save. + +Formal Save writes the Markdown note and its paper-local `images/` directory to the selected workspace or Obsidian target. The `images/` directory remains part of the saved paper layout even when no image is inserted. A blocked save reports the existing target and never silently switches modes. ## 🔧 Optional Enhancements diff --git a/README.zh-CN.md b/README.zh-CN.md index 9485869..636a1fc 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -78,16 +78,8 @@ DeepPaperNote 需要 Python 3.10 或更高版本。核心 PDF 抽取路径依赖 把这篇论文整理成 Obsidian 笔记:<论文> ``` -DeepPaperNote 完整支持英文和简体中文笔记结构。首次使用时,Agent 会一次询问输出语言与保存模式,并把确认后的长期默认值写入 `~/.deeppapernote/config.json`。 - -```json -{ - "output_language": "zh-CN", - "save_mode": "workspace" -} -``` - -核心流程也支持使用 `--language en` 进行单次英文运行,且不会改写长期偏好。章节名称、元数据字段、图表占位、规划规则、校验与正式保存都会遵循解析后的语言。 +DeepPaperNote 完整支持英文和简体中文笔记结构。首次使用时,Agent 会一次询问所选保存模式需要的全部字段,并把确认后的长期默认值写入 `~/.deeppapernote/config.json`。 +下方“用户配置”统一说明两种保存模式、两套语言 Profile 与单次覆盖方式。章节名称、元数据字段、图表占位、规划规则、校验与 Formal Save 都会遵循解析后的语言。 ## 🎯 为什么选择 DeepPaperNote? @@ -121,9 +113,25 @@ DeepPaperNote 仍然是唯一主产品。仓库同时提供一个可选 companio ## 🗂️ 用户配置 -DeepPaperNote 将设备本地的长期偏好保存在 `~/.deeppapernote/config.json`。 +DeepPaperNote 只用一个文件保存设备本地长期偏好:`~/.deeppapernote/config.json`。 + +| 字段 | 有效值 | 何时必填 | +| --- | --- | --- | +| `output_language` | `zh-CN` 或 `en` | 始终必填 | +| `save_mode` | `workspace` 或 `obsidian` | 始终必填 | +| `obsidian_vault` | 已存在的绝对目录 | 仅 `obsidian` 模式 | +| `papers_dir` | Vault 内安全的相对路径 | 仅 `obsidian` 模式 | + +英文 workspace 配置只需要两个始终必填的字段: + +```json +{ + "output_language": "en", + "save_mode": "workspace" +} +``` -如果希望长期保存到 Obsidian Vault,可使用: +简体中文 Obsidian 配置还要给出 Vault 与论文目录: ```json { @@ -134,11 +142,35 @@ DeepPaperNote 将设备本地的长期偏好保存在 `~/.deeppapernote/config.j } ``` -- 首次使用会在一个问题批次里收集所有必填项;后续修复只询问受影响字段。 -- 旧设置只会作为一次性迁移候选,得到确认后才会写入长期配置。 -- workspace 模式忽略 Obsidian 专属字段;Obsidian 模式要求 Vault 是已存在的绝对目录,论文目录是 Vault 内的相对路径。 -- 单次运行覆盖不会改写 `config.json`;只有明确要求修改未来默认值时才会持久化。 -- 配置或正式保存受阻时,DeepPaperNote 会报告具体字段,不会静默切换目标。 +配置会在论文身份解析等昂贵步骤之前完成。首次使用时,Agent 通过一个 Configuration Prompt Batch 一次询问全部必填字段;已有配置只坏了一个字段时,仅询问该字段。配置不可读或不可写时会标明受影响字段并失败关闭,不会带着无效配置继续。 + +仅当 `config.json` 不存在时,旧环境变量或 shell 设置才会作为一次性迁移候选展示;确认后才写入长期偏好。配置文件一旦存在,shell 启动文件就不再提供偏好;当前进程值只保留为隐藏的兼容 Run Override。Preference Change 会保留未知(unknown)JSON 字段;替换畸形 JSON 前会先备份,并且必须得到确认。 + +### Run Override 与 Preference Change + +每次运行都按以下固定优先级解析: + +`explicit request > CLI > current process environment > User Configuration` + +- “这篇论文仅本次用英文生成”是 Run Override,`config.json` 保持逐字节不变。 +- “这篇论文仅本次用中文生成”是对应的 `zh-CN` Run Override。 +- “以后默认生成英文笔记”是 Preference Change,确认后才修改未来默认值。 +- CLI 选项 `--language`、`--save-mode`、`--vault`、`--papers-dir` 都是 Run Override。例如: + +```bash +python3 skills/deeppapernote/scripts/run_pipeline.py --input '' --language en --save-mode obsidian --vault /absolute/path/to/vault --papers-dir Research/Papers +``` + +workspace 模式本次不会使用 `obsidian_vault` 与 `papers_dir`,但会保留这些偏好,供以后 Obsidian 运行继续使用。Run Override 不会自动变成 Preference Change。 + +### 输出语言 Profile 与 Formal Save + +- `zh-CN` 依次使用 `核心信息`、`原文摘要翻译`、`创新点`、`一句话总结`、`研究问题`、`数据与任务定义`、`方法主线`、`关键结果`、`深度分析`、`局限`、`我的笔记`、`引用`;方法部分使用 `机制流程`。图表 callout 使用 `建议位置:`、`放置原因:`、`当前状态:`,真实图片说明以 `论文原图编号:` 开头。 +- `en` 依次使用 `Core Information`、`Abstract`、`Contributions`、`One-Sentence Summary`、`Research Question`、`Data and Task Definition`、`Method`、`Key Results`、`Deep Analysis`、`Limitations`、`Research Notes`、`References`;方法部分使用 `Mechanism Flow`。图表 callout 使用 `Suggested location:`、`Why it matters:`、`Current status:`,真实图片说明以 `Original paper item:` 开头。 + +两种 Profile 的 Abstract 都必须忠实呈现原论文摘要,并使用请求的输出语言;后续创新点和结果解释留在后续章节。同一个 canonical Skill 与一条流水线(one pipeline)会把选定语言一直绑定到 lint 和 Formal Save。 + +Formal Save 会把 Markdown 笔记与 paper-local `images/` 目录写入选定的 workspace 或 Obsidian 目标。即使没有插入图片,`images/` 仍属于论文保存布局。保存受阻时会报告原目标,不会静默切换模式。 ## 🔧 可选增强 diff --git a/skills/deeppapernote/SKILL.md b/skills/deeppapernote/SKILL.md index a8cf2c0..f8837f2 100644 --- a/skills/deeppapernote/SKILL.md +++ b/skills/deeppapernote/SKILL.md @@ -38,6 +38,7 @@ Bind that exact value through Figure Plan → Figure/Table Decisions → Synthes This contract is complete only when every named stage is bound to the resolved value and Formal Save validates the final bytes. Read `references/output-language.md` for profile content while drafting or debugging either language. This skill is intentionally narrow: +- it is one canonical Skill and one pipeline with two output profiles - it handles one paper at a time - it does not update daily reading lists - it does not treat a shallow abstract rewrite as a successful output diff --git a/skills/deeppapernote/references/output-language.md b/skills/deeppapernote/references/output-language.md index 3aaed43..597b025 100644 --- a/skills/deeppapernote/references/output-language.md +++ b/skills/deeppapernote/references/output-language.md @@ -9,6 +9,27 @@ DeepPaperNote supports two output schemas: Resolve the profile through `user-configuration.md`. For a single command, use `--language en` with `run_pipeline.py`, `build_synthesis_bundle.py`, `lint_note.py`, or `write_obsidian_note.py` where applicable. A Run Override never changes User Configuration. +## Simplified Chinese note schema + +Use these top-level sections in this order: + +1. `核心信息` +2. `原文摘要翻译` +3. `创新点` +4. `一句话总结` +5. `研究问题` +6. `数据与任务定义` +7. `方法主线` +8. `关键结果` +9. `深度分析` +10. `局限` +11. `我的笔记` +12. `引用` + +Use `### 机制流程` for the mechanism-flow subsection. Chinese figure placeholders use the labels `建议位置:`, `放置原因:`, and `当前状态:`; a materialized image caption begins with `论文原图编号:`. + +`原文摘要翻译` is a faithful Chinese translation of the source abstract. Preserve its meaning and scope; contribution claims, result interpretation, and hindsight judgment belong later unless the source abstract itself contains them. + ## English note schema Use these top-level sections in this order: diff --git a/skills/deeppapernote/references/user-configuration.md b/skills/deeppapernote/references/user-configuration.md index 072c728..b0c7cc1 100644 --- a/skills/deeppapernote/references/user-configuration.md +++ b/skills/deeppapernote/references/user-configuration.md @@ -7,11 +7,11 @@ Use one device-local User Configuration at `~/.deeppapernote/config.json`: - `obsidian_vault`: existing absolute directory, required only in Obsidian mode - `papers_dir`: safe relative path inside the Vault, required only in Obsidian mode -There is no implicit language or save-mode default. Workspace mode ignores stored Obsidian fields. Destination writability is a Formal Save concern; configuration inspection never creates a probe file in the workspace or Vault. +There is no implicit language or save-mode default. Workspace mode ignores stored Obsidian fields for the current run but preserves them for a later Obsidian run. Destination writability is a Formal Save concern; configuration inspection never creates a probe file in the workspace or Vault. ## Configuration admission -Complete Configuration Readiness before paper identity resolution: +Complete Configuration Readiness before paper identity resolution. The inspector returns exactly one structured state: `ready`, `needs_input`, `invalid`, or `blocked`. 1. Run `scripts/user_configuration.py` without setters and read its structured state. 2. For `needs_input` on first use, ask one Configuration Prompt Batch for `output_language` and `save_mode`; require `obsidian_vault` and `papers_dir` in that same response when the user selects Obsidian. For later repair, ask only for `prompt_fields`. @@ -23,13 +23,13 @@ Treat `invalid` as repairable input. Treat `blocked` as an I/O boundary: report ## Resolution and persistence -Resolve each preference using this exact precedence: +Resolve each preference using this exact precedence; an explicit request is an explicit current-run parameter, including a natural-language request: `explicit request > CLI > current process environment > User Configuration` An explicit request about the current paper is a Run Override. Translate it to the matching runtime override and leave `config.json` byte-for-byte unchanged. Persist only explicit future-default wording as a Preference Change. -While `config.json` is absent, supported process and shell values are migration candidates only. Once the file exists, shell startup files leave the preference path permanently; current process environment values remain run-scoped compatibility overrides. +While `config.json` is absent, supported process and shell values are migration candidates only. Once the file exists, shell startup files leave the preference path permanently; current process environment values remain hidden, run-scoped compatibility overrides rather than public setup. Preference Changes preserve unknown JSON fields and report a warning. Malformed or non-object JSON receives a unique invalid backup before a confirmed replacement. Writes use a same-directory temporary file, atomic replacement, and exact reread comparison. diff --git a/tests/test_user_configuration.py b/tests/test_user_configuration.py index f94135b..438774e 100644 --- a/tests/test_user_configuration.py +++ b/tests/test_user_configuration.py @@ -2,12 +2,14 @@ import json import os +import re import subprocess import sys from pathlib import Path import pytest import user_configuration +from localization import SUPPORTED_OUTPUT_LANGUAGES, note_schema from user_configuration import ( ConfigurationWriteError, inspect_configuration, @@ -385,6 +387,116 @@ def test_public_and_agent_contracts_share_user_configuration_source_of_truth() - assert "scripts/user_configuration.py" in contract +def test_public_onboarding_covers_bilingual_configuration_and_run_overrides() -> None: + public_docs = { + "en": (PROJECT_ROOT / "README.md").read_text(encoding="utf-8"), + "zh-CN": (PROJECT_ROOT / "README.zh-CN.md").read_text(encoding="utf-8"), + } + + for text in public_docs.values(): + for field in user_configuration.KNOWN_FIELDS: + assert f"`{field}`" in text + values = ( + *sorted(user_configuration.OUTPUT_LANGUAGES), + *sorted(user_configuration.SAVE_MODES), + ) + for value in values: + assert f"`{value}`" in text + for option in ("--language", "--save-mode", "--vault", "--papers-dir"): + assert f"`{option}`" in text + assert '"output_language": "zh-CN"' in text + assert '"output_language": "en"' in text + assert ( + "explicit request > CLI > current process environment > User Configuration" + in text + ) + assert "Configuration Prompt Batch" in text + assert "Run Override" in text + assert "Preference Change" in text + assert "Formal Save" in text + assert "`images/`" in text + assert "unknown" in text.lower() + assert "DEEPPAPERNOTE_" not in text + + field_values = { + "output_language": set(user_configuration.OUTPUT_LANGUAGES), + "save_mode": set(user_configuration.SAVE_MODES), + } + for field, expected in field_values.items(): + row = re.search(rf"\| `{field}` \| ([^|]+) \|", text) + assert row is not None + assert set(re.findall(r"`([^`]+)`", row.group(1))) == expected + + profile = text.split("### Output Language Profiles and Formal Save", 1)[-1] + if profile == text: + profile = text.split("### 输出语言 Profile 与 Formal Save", 1)[-1] + assert profile != text + for language in SUPPORTED_OUTPUT_LANGUAGES: + schema = note_schema(language) + section_positions = [ + profile.index(f"`{section}`") + for section in schema["sections"].values() + ] + assert section_positions == sorted(section_positions) + for label in schema["figure_labels"].values(): + assert f"`{label}`" in profile + assert f"`{schema['mechanism_flow']}`" in profile + + assert "Generate this paper's note in English just this once" in public_docs["en"] + assert "Generate this paper's note in Simplified Chinese just this once" in public_docs["en"] + assert "Change my default output language to English" in public_docs["en"] + assert "这篇论文仅本次用英文生成" in public_docs["zh-CN"] + assert "这篇论文仅本次用中文生成" in public_docs["zh-CN"] + assert "以后默认生成英文笔记" in public_docs["zh-CN"] + + +def test_canonical_configuration_reference_matches_machine_contract() -> None: + skill = (PROJECT_ROOT / "skills/deeppapernote/SKILL.md").read_text( + encoding="utf-8" + ) + contract = ( + PROJECT_ROOT / "skills/deeppapernote/references/user-configuration.md" + ).read_text(encoding="utf-8") + + for field in user_configuration.KNOWN_FIELDS: + assert f"`{field}`" in contract + values = ( + *sorted(user_configuration.OUTPUT_LANGUAGES), + *sorted(user_configuration.SAVE_MODES), + ) + for value in values: + assert f"`{value}`" in contract + for state in ("ready", "needs_input", "invalid", "blocked"): + assert f"`{state}`" in contract + assert ( + "explicit request > CLI > current process environment > User Configuration" + in contract + ) + assert "preserve" in contract.lower() and "unknown" in contract.lower() + assert "Workspace mode" in contract and "preserve" in contract + assert "before paper identity resolution" in contract + assert "one canonical Skill" in skill + assert "one pipeline" in skill + assert "paper-local `images/`" in skill + + +def test_output_language_reference_matches_both_machine_schemas() -> None: + contract = ( + PROJECT_ROOT / "skills/deeppapernote/references/output-language.md" + ).read_text(encoding="utf-8") + + for language in SUPPORTED_OUTPUT_LANGUAGES: + schema = note_schema(language) + section_positions = [] + for section in schema["sections"].values(): + assert f"`{section}`" in contract + section_positions.append(contract.index(f"`{section}`")) + assert section_positions == sorted(section_positions) + for label in schema["figure_labels"].values(): + assert label in contract + assert schema["mechanism_flow"] in contract + + def test_configuration_cli_keeps_semantic_failures_repairable( configured_user_home: Path, ) -> None: From e96e4ac0386480f15fb3a6da1de2d48c45ba959c Mon Sep 17 00:00:00 2001 From: dingdingcar <1054263492@qq.com> Date: Thu, 27 Aug 2026 04:49:30 +0800 Subject: [PATCH 07/15] fix: make release validation runs reproducible (#33) --- skills/deeppapernote/scripts/common.py | 60 ++++++++++++++++++-- skills/deeppapernote/scripts/run_pipeline.py | 6 ++ tests/test_acquisition_artifacts.py | 51 +++++++++++++++++ tests/test_run_pipeline_manifest.py | 7 +++ 4 files changed, 119 insertions(+), 5 deletions(-) diff --git a/skills/deeppapernote/scripts/common.py b/skills/deeppapernote/scripts/common.py index 3130f88..024395c 100644 --- a/skills/deeppapernote/scripts/common.py +++ b/skills/deeppapernote/scripts/common.py @@ -1265,6 +1265,25 @@ def _is_unique_exact_zotero_title_observation( ) +def _is_unique_exact_arxiv_title_observation( + anchor: dict[str, Any], + item: dict[str, Any], + observation: dict[str, Any], +) -> bool: + relation = item.get("relation") + if not isinstance(relation, dict): + return False + return bool( + _string_field(item, "provider").lower() == "arxiv" + and _string_field(relation, "kind") == "arxiv_lookup" + and _string_field(relation, "match_kind") == "title" + and _string_field(relation, "match_resolution") == "unique_exact" + and _record_arxiv_id(observation) + and normalize_identity_title(_string_field(anchor, "title")) + == normalize_identity_title(_string_field(observation, "title")) + ) + + def adjudicate_identity_observations( anchor: dict[str, Any], observations: list[Any], @@ -1336,6 +1355,11 @@ def adjudicate_identity_observations( item, observation, ) + unique_exact_arxiv_title_match = _is_unique_exact_arxiv_title_observation( + anchor, + item, + observation, + ) if not title_author_year_match and not shared_identifiers: observation_provider = ( _string_field(item, "provider") @@ -1379,6 +1403,7 @@ def adjudicate_identity_observations( not shared_identifiers and not title_author_year_match and not unique_exact_zotero_title_match + and not unique_exact_arxiv_title_match ): rejected_observations.append( _identity_observation_summary( @@ -1393,6 +1418,8 @@ def adjudicate_identity_observations( acceptance_reason = "shared_identifier" elif unique_exact_zotero_title_match: acceptance_reason = "unique_exact_zotero_title" + elif unique_exact_arxiv_title_match: + acceptance_reason = "unique_exact_arxiv_title" else: acceptance_reason = "title_author_year" summary = _identity_observation_summary( @@ -2346,7 +2373,13 @@ def collect_metadata_observations(record: dict[str, Any]) -> list[dict[str, Any] title = normalize_whitespace(str(base.get("title", ""))) arxiv_id = normalize_whitespace(str(base.get("arxiv_id", ""))) - def append(provider: str, kind: str, value: str, candidate: dict[str, Any] | None) -> None: + def append( + provider: str, + kind: str, + value: str, + candidate: dict[str, Any] | None, + relation: dict[str, str] | None = None, + ) -> None: if not candidate: return observation = { @@ -2354,6 +2387,8 @@ def append(provider: str, kind: str, value: str, candidate: dict[str, Any] | Non "retrieved_by": {"kind": kind, "value": value}, "record": deepcopy(candidate), } + if relation: + observation["relation"] = relation if observation not in observations: observations.append(observation) @@ -2377,11 +2412,26 @@ def append(provider: str, kind: str, value: str, candidate: dict[str, Any] | Non append("openalex", "title", title, oa) cross = choose_best_title_match(title, search_crossref_by_title(title, limit=5)) append("crossref", "title", title, cross) - arxiv = choose_best_title_match( - title, - safe_fetch_arxiv_entries(search_query=f'ti:"{title}"', max_results=5), + arxiv_candidates = safe_fetch_arxiv_entries( + search_query=f'ti:"{title}"', max_results=5 + ) + arxiv = choose_best_title_match(title, arxiv_candidates) + exact_matches = [ + candidate + for candidate in arxiv_candidates + if normalize_identity_title(_string_field(candidate, "title")) + == normalize_identity_title(title) + ] + relation = ( + { + "kind": "arxiv_lookup", + "match_kind": "title", + "match_resolution": "unique_exact", + } + if arxiv is not None and len(exact_matches) == 1 and arxiv == exact_matches[0] + else None ) - append("arxiv", "title", title, arxiv) + append("arxiv", "title", title, arxiv, relation) return observations diff --git a/skills/deeppapernote/scripts/run_pipeline.py b/skills/deeppapernote/scripts/run_pipeline.py index b5b8424..cadc46b 100644 --- a/skills/deeppapernote/scripts/run_pipeline.py +++ b/skills/deeppapernote/scripts/run_pipeline.py @@ -82,11 +82,13 @@ def main() -> None: identity_json = workdir / f"{args.prefix}_identity.json" identity_trace_json = workdir / f"{args.prefix}_identity_repair_trace.json" fetch_json = workdir / f"{args.prefix}_fetch.json" + pdf_dir = workdir / f"{args.prefix}_pdfs" source_manifest_json = workdir / f"{args.prefix}_source_manifest.json" raw_sections_jsonl = workdir / f"{args.prefix}_raw_sections.jsonl" full_text_md = workdir / f"{args.prefix}_full_text.md" evidence_json = workdir / f"{args.prefix}_evidence.json" assets_json = workdir / f"{args.prefix}_assets.json" + assets_dir = workdir / f"{args.prefix}_assets" figures_json = workdir / f"{args.prefix}_figures.json" figure_decisions_json = workdir / f"{args.prefix}_figure_table_decisions.json" bundle_json = workdir / f"{args.prefix}_bundle.json" @@ -138,6 +140,8 @@ def main() -> None: str(metadata_json), "--identity", str(identity_json), + "--dest-dir", + str(pdf_dir), "--output", str(fetch_json), ], @@ -177,6 +181,8 @@ def main() -> None: str(scripts_dir / "extract_pdf_assets.py"), "--input", str(fetch_json), + "--assets-dir", + str(assets_dir), "--output", str(assets_json), ], diff --git a/tests/test_acquisition_artifacts.py b/tests/test_acquisition_artifacts.py index bc9da64..aefebb8 100644 --- a/tests/test_acquisition_artifacts.py +++ b/tests/test_acquisition_artifacts.py @@ -1505,6 +1505,57 @@ def test_collect_metadata_preserves_provider_result_as_identity_observation( ] +def test_exact_swe_bench_title_admits_one_unique_arxiv_match( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + title = "SWE-bench: Can Language Models Resolve Real-world Github Issues?" + resolve_payload = { + "status": "ok", + "script": "resolve_paper.py", + "paper_id": "title:2156776fbf55", + "source_type": "title_query", + "title": title, + "metadata_sources": ["title_query"], + } + monkeypatch.setattr(common, "search_semantic_scholar", lambda *args, **kwargs: []) + monkeypatch.setattr(common, "search_openalex_by_title", lambda *args, **kwargs: []) + monkeypatch.setattr(common, "search_crossref_by_title", lambda *args, **kwargs: []) + monkeypatch.setattr( + common, + "safe_fetch_arxiv_entries", + lambda **kwargs: [ + { + "source": "arxiv", + "source_type": "arxiv", + "title": "SWE-bench: Can Language Models Resolve Real-World GitHub Issues?", + "authors": ["Carlos E. Jimenez"], + "published": "2023-10-10T16:47:29Z", + "arxiv_id": "2310.06770", + "pdf_url": "https://arxiv.org/pdf/2310.06770v3", + } + ], + ) + + observations = common.collect_metadata_observations(resolve_payload) + identity, _ = build_identity_from_payloads( + tmp_path, + monkeypatch, + resolve_payload=resolve_payload, + metadata_payload={ + **resolve_payload, + "script": "collect_metadata.py", + "identity_observations": observations, + }, + ) + + assert identity["identity_verdict"] == "accepted" + assert identity["accepted_metadata"]["arxiv_id"] == "2310.06770" + assert identity["accepted_observations"][0]["reason"] == ( + "unique_exact_arxiv_title" + ) + + @pytest.mark.parametrize( "module", [extract_source_text, extract_evidence, extract_pdf_assets], diff --git a/tests/test_run_pipeline_manifest.py b/tests/test_run_pipeline_manifest.py index 8f6c7d2..e545227 100644 --- a/tests/test_run_pipeline_manifest.py +++ b/tests/test_run_pipeline_manifest.py @@ -253,12 +253,19 @@ def fake_run(cmd: list[str], check: bool = True, **kwargs) -> object: assert fetch_call[fetch_call.index("--identity") + 1] == str( (workdir / "paper_identity.json").resolve() ) + assert fetch_call[fetch_call.index("--dest-dir") + 1] == str( + (workdir / "paper_pdfs").resolve() + ) evidence_call = calls[5] assert "--source-manifest" in evidence_call assert evidence_call[evidence_call.index("--source-manifest") + 1] == str( (workdir / "paper_source_manifest.json").resolve() ) + assets_call = calls[6] + assert assets_call[assets_call.index("--assets-dir") + 1] == str( + (workdir / "paper_assets").resolve() + ) def test_run_pipeline_stops_at_configuration_before_identity( From efae153ecec1b7e96fa92b71da374747c9bdb95d Mon Sep 17 00:00:00 2001 From: dingdingcar <1054263492@qq.com> Date: Thu, 27 Aug 2026 04:57:09 +0800 Subject: [PATCH 08/15] test: isolate release validation configuration (#33) --- .../references/user-configuration.md | 2 ++ .../deeppapernote/scripts/user_configuration.py | 3 +++ tests/conftest.py | 17 +++++++++++++++-- tests/test_user_configuration.py | 16 +++++++++++++++- 4 files changed, 35 insertions(+), 3 deletions(-) diff --git a/skills/deeppapernote/references/user-configuration.md b/skills/deeppapernote/references/user-configuration.md index b0c7cc1..63f6aae 100644 --- a/skills/deeppapernote/references/user-configuration.md +++ b/skills/deeppapernote/references/user-configuration.md @@ -2,6 +2,8 @@ Use one device-local User Configuration at `~/.deeppapernote/config.json`: +For isolated validation only, `DEEPPAPERNOTE_CONFIG_PATH` may relocate this one file for the current process. It is not a preference, does not create a second configuration layer, and must not be persisted into the configuration itself. + - `output_language`: `zh-CN` or `en` - `save_mode`: `workspace` or `obsidian` - `obsidian_vault`: existing absolute directory, required only in Obsidian mode diff --git a/skills/deeppapernote/scripts/user_configuration.py b/skills/deeppapernote/scripts/user_configuration.py index be44e1d..9811cf3 100644 --- a/skills/deeppapernote/scripts/user_configuration.py +++ b/skills/deeppapernote/scripts/user_configuration.py @@ -42,6 +42,9 @@ def __init__( def user_config_path() -> Path: + override = os.environ.get("DEEPPAPERNOTE_CONFIG_PATH", "").strip() + if override: + return Path(override).expanduser() return Path.home() / ".deeppapernote" / "config.json" diff --git a/tests/conftest.py b/tests/conftest.py index 72addde..9430268 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,11 +15,24 @@ @pytest.fixture(autouse=True) def configured_user_home(tmp_path: Path, monkeypatch) -> Path: - monkeypatch.setenv("HOME", str(tmp_path)) config_path = tmp_path / ".deeppapernote" / "config.json" config_path.parent.mkdir(exist_ok=True) + for name in ( + "DEEPPAPERNOTE_OUTPUT_LANGUAGE", + "DEEPPAPERNOTE_SAVE_MODE", + "DEEPPAPERNOTE_OBSIDIAN_VAULT", + "DEEPPAPERNOTE_PAPERS_DIR", + ): + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv("DEEPPAPERNOTE_CONFIG_PATH", str(config_path)) config_path.write_text( - json.dumps({"output_language": "zh-CN", "save_mode": "workspace"}), + json.dumps( + { + "output_language": "zh-CN", + "save_mode": "workspace", + "papers_dir": "Research/Papers", + } + ), encoding="utf-8", ) return config_path diff --git a/tests/test_user_configuration.py b/tests/test_user_configuration.py index 438774e..737cdd7 100644 --- a/tests/test_user_configuration.py +++ b/tests/test_user_configuration.py @@ -15,6 +15,7 @@ inspect_configuration, persist_preferences, resolve_preferences, + user_config_path, ) PROJECT_ROOT = Path(__file__).resolve().parents[1] @@ -22,6 +23,15 @@ ENVIRONMENT_SCRIPT = PROJECT_ROOT / "skills/deeppapernote/scripts/check_environment.py" +def test_user_config_path_honors_process_isolation_override( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + isolated_path = tmp_path / "isolated" / "config.json" + monkeypatch.setenv("DEEPPAPERNOTE_CONFIG_PATH", str(isolated_path)) + + assert user_config_path() == isolated_path + + def test_first_use_requests_one_workspace_prompt_batch(tmp_path: Path) -> None: result = inspect_configuration(config_path=tmp_path / "config.json", environ={}) @@ -500,6 +510,10 @@ def test_output_language_reference_matches_both_machine_schemas() -> None: def test_configuration_cli_keeps_semantic_failures_repairable( configured_user_home: Path, ) -> None: + configured_user_home.write_text( + json.dumps({"output_language": "zh-CN", "save_mode": "workspace"}), + encoding="utf-8", + ) result = subprocess.run( [sys.executable, str(CONFIG_SCRIPT), "--set-save-mode", "obsidian"], env=os.environ.copy(), @@ -520,7 +534,7 @@ def test_environment_report_survives_missing_user_configuration( home = tmp_path / "empty-home" home.mkdir() env = os.environ.copy() - env["HOME"] = str(home) + env["DEEPPAPERNOTE_CONFIG_PATH"] = str(home / ".deeppapernote" / "config.json") result = subprocess.run( [sys.executable, str(ENVIRONMENT_SCRIPT)], env=env, From 91486a80cabb7371195448d63e36f66f7a0d2c0e Mon Sep 17 00:00:00 2001 From: dingdingcar <1054263492@qq.com> Date: Thu, 27 Aug 2026 05:01:22 +0800 Subject: [PATCH 09/15] fix: preserve LF in Formal Save on Windows (#33) --- skills/deeppapernote/scripts/write_obsidian_note.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/deeppapernote/scripts/write_obsidian_note.py b/skills/deeppapernote/scripts/write_obsidian_note.py index bd52c06..d659d8b 100644 --- a/skills/deeppapernote/scripts/write_obsidian_note.py +++ b/skills/deeppapernote/scripts/write_obsidian_note.py @@ -305,7 +305,7 @@ def main() -> None: if figure_decisions else [] ) - Path(target_path).write_text(note_text, encoding="utf-8") + Path(target_path).write_text(note_text, encoding="utf-8", newline="\n") require_reference_hygiene(Path(target_path).read_text(encoding="utf-8"), "after save") asset_dir.mkdir(parents=True, exist_ok=True) From 0be51a8776c8986c4036c5ecb2fb4f2876feeea9 Mon Sep 17 00:00:00 2001 From: dingdingcar <1054263492@qq.com> Date: Sat, 29 Aug 2026 02:37:04 +0800 Subject: [PATCH 10/15] feat: allow run overrides without user config --- CHANGELOG.md | 4 ++ README.md | 24 ++++++++-- README.zh-CN.md | 24 ++++++++-- skills/deeppapernote/SKILL.md | 6 ++- .../references/user-configuration.md | 20 ++++---- skills/deeppapernote/scripts/common.py | 21 ++++++--- .../scripts/user_configuration.py | 20 +++++++- tests/test_common.py | 46 +++++++++++++++++++ tests/test_user_configuration.py | 11 +++-- 9 files changed, 146 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fecee32..1b0ce52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,10 @@ Add an entry here when the project meaningfully changes for users, for example: - Added end-to-end English output through User Configuration or `--language en`, including localized note schemas, paper-type planning contracts, figure callouts, grounding, final-note linting, and Formal Save validation. - Added one device-local `config.json` for language and save preferences, with first-use/repair inspection, confirmed migration, atomic persistence, and run-scoped overrides. +### Changed + +- Complete valid Run Overrides now satisfy Configuration Readiness without reading `~/.deeppapernote/config.json`; incomplete overrides continue through the existing User Configuration fallback and validation flow. + ## v2.2.0 ### Improved diff --git a/README.md b/README.md index 8429454..4bc2c2a 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ Generate a deep-reading note for this paper: ``` -DeepPaperNote supports complete English and Simplified Chinese note schemas. On first use, your Agent asks once for every field required by the selected save mode, then writes the confirmed defaults to `~/.deeppapernote/config.json`. +DeepPaperNote supports complete English and Simplified Chinese note schemas. A complete set of inherited `DEEPPAPERNOTE_*` environment variables starts the run without reading `~/.deeppapernote/config.json`; otherwise your Agent uses that file as a fallback and asks only for unresolved fields. The User Configuration section below covers both save modes, both language profiles, and one-run overrides. Section names, metadata fields, figure callouts, planning guidance, linting, and Formal Save all follow the resolved language. ## 🎯 Why DeepPaperNote? @@ -113,7 +113,7 @@ The canonical execution contract lives in [`skills/deeppapernote/SKILL.md`](./sk ## 🗂️ User Configuration -DeepPaperNote stores durable device-local preferences in one file: `~/.deeppapernote/config.json`. +DeepPaperNote can run entirely from current-process environment variables. It also stores optional durable device-local preferences in `~/.deeppapernote/config.json` for fallback values and explicit future defaults. | Field | Valid values | When required | | --- | --- | --- | @@ -124,6 +124,13 @@ DeepPaperNote stores durable device-local preferences in one file: `~/.deeppaper An English workspace configuration needs only the two always-required fields: +```bash +export DEEPPAPERNOTE_OUTPUT_LANGUAGE=en +export DEEPPAPERNOTE_SAVE_MODE=workspace +``` + +The equivalent optional User Configuration is: + ```json { "output_language": "en", @@ -133,6 +140,15 @@ An English workspace configuration needs only the two always-required fields: A Simplified Chinese Obsidian configuration also names the Vault and paper directory: +```bash +export DEEPPAPERNOTE_OUTPUT_LANGUAGE=zh-CN +export DEEPPAPERNOTE_SAVE_MODE=obsidian +export DEEPPAPERNOTE_OBSIDIAN_VAULT="/absolute/path/to/your/vault" +export DEEPPAPERNOTE_PAPERS_DIR="Research/Papers" +``` + +The equivalent optional User Configuration is: + ```json { "output_language": "zh-CN", @@ -142,9 +158,9 @@ A Simplified Chinese Obsidian configuration also names the Vault and paper direc } ``` -Configuration is resolved before paper identity or other expensive paper work. On first use, the Agent presents one Configuration Prompt Batch for every required field. If a later file has one missing or invalid field, it asks only for that field; an unreadable or unwritable file fails closed with the affected field instead of continuing. +Configuration is resolved before paper identity or other expensive paper work. DeepPaperNote first resolves the explicit request, CLI arguments, and current process environment. If those values form a complete valid configuration, the run proceeds without reading User Configuration. Otherwise the Agent reads `config.json` as fallback and presents one Configuration Prompt Batch only for unresolved fields; an unreadable fallback file fails closed instead of continuing. -Legacy environment or shell values are shown only as one-time migration candidates while `config.json` is absent. They become preferences only after confirmation. Once the file exists, shell startup files stop supplying preferences; current-process values remain a hidden compatibility Run Override. Preference Changes preserve unknown JSON fields, and malformed JSON is backed up before confirmed replacement. +Current-process environment values are first-class Run Overrides and never change the optional file. Shell startup files are consulted only as migration candidates when inherited values are incomplete and `config.json` is absent. Preference Changes preserve unknown JSON fields, and malformed JSON is backed up before confirmed replacement. ### Run Overrides and Preference Changes diff --git a/README.zh-CN.md b/README.zh-CN.md index 636a1fc..f5517e6 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -78,7 +78,7 @@ DeepPaperNote 需要 Python 3.10 或更高版本。核心 PDF 抽取路径依赖 把这篇论文整理成 Obsidian 笔记:<论文> ``` -DeepPaperNote 完整支持英文和简体中文笔记结构。首次使用时,Agent 会一次询问所选保存模式需要的全部字段,并把确认后的长期默认值写入 `~/.deeppapernote/config.json`。 +DeepPaperNote 完整支持英文和简体中文笔记结构。如果当前进程继承的 `DEEPPAPERNOTE_*` 环境变量已经完整,运行时不会读取 `~/.deeppapernote/config.json`;否则 Agent 才把该文件作为 fallback,并只询问仍未解析的字段。 下方“用户配置”统一说明两种保存模式、两套语言 Profile 与单次覆盖方式。章节名称、元数据字段、图表占位、规划规则、校验与 Formal Save 都会遵循解析后的语言。 ## 🎯 为什么选择 DeepPaperNote? @@ -113,7 +113,7 @@ DeepPaperNote 仍然是唯一主产品。仓库同时提供一个可选 companio ## 🗂️ 用户配置 -DeepPaperNote 只用一个文件保存设备本地长期偏好:`~/.deeppapernote/config.json`。 +DeepPaperNote 可以完全使用当前进程环境变量运行;也可以在 `~/.deeppapernote/config.json` 中保存可选的设备本地长期偏好,作为 fallback 和明确的未来默认值。 | 字段 | 有效值 | 何时必填 | | --- | --- | --- | @@ -124,6 +124,13 @@ DeepPaperNote 只用一个文件保存设备本地长期偏好:`~/.deeppaperno 英文 workspace 配置只需要两个始终必填的字段: +```bash +export DEEPPAPERNOTE_OUTPUT_LANGUAGE=en +export DEEPPAPERNOTE_SAVE_MODE=workspace +``` + +等价的可选 User Configuration 是: + ```json { "output_language": "en", @@ -133,6 +140,15 @@ DeepPaperNote 只用一个文件保存设备本地长期偏好:`~/.deeppaperno 简体中文 Obsidian 配置还要给出 Vault 与论文目录: +```bash +export DEEPPAPERNOTE_OUTPUT_LANGUAGE=zh-CN +export DEEPPAPERNOTE_SAVE_MODE=obsidian +export DEEPPAPERNOTE_OBSIDIAN_VAULT="/你的/Obsidian/Vault/绝对路径" +export DEEPPAPERNOTE_PAPERS_DIR="Research/Papers" +``` + +等价的可选 User Configuration 是: + ```json { "output_language": "zh-CN", @@ -142,9 +158,9 @@ DeepPaperNote 只用一个文件保存设备本地长期偏好:`~/.deeppaperno } ``` -配置会在论文身份解析等昂贵步骤之前完成。首次使用时,Agent 通过一个 Configuration Prompt Batch 一次询问全部必填字段;已有配置只坏了一个字段时,仅询问该字段。配置不可读或不可写时会标明受影响字段并失败关闭,不会带着无效配置继续。 +配置会在论文身份解析等昂贵步骤之前完成。DeepPaperNote 会先解析显式请求、CLI 参数和当前进程环境变量;如果它们已经构成完整合法的配置,就不读取 User Configuration。否则才读取 `config.json` 作为 fallback,并通过一个 Configuration Prompt Batch 只询问仍未解析的字段;fallback 文件不可读时会失败关闭。 -仅当 `config.json` 不存在时,旧环境变量或 shell 设置才会作为一次性迁移候选展示;确认后才写入长期偏好。配置文件一旦存在,shell 启动文件就不再提供偏好;当前进程值只保留为隐藏的兼容 Run Override。Preference Change 会保留未知(unknown)JSON 字段;替换畸形 JSON 前会先备份,并且必须得到确认。 +当前进程环境变量是正式的 Run Override,不会改写可选配置文件。只有当继承的变量不完整且 `config.json` 不存在时,shell 启动文件才会作为迁移候选;确认后才写入长期偏好。Preference Change 会保留未知(unknown)JSON 字段;替换畸形 JSON 前会先备份,并且必须得到确认。 ### Run Override 与 Preference Change diff --git a/skills/deeppapernote/SKILL.md b/skills/deeppapernote/SKILL.md index f8837f2..afe6bc9 100644 --- a/skills/deeppapernote/SKILL.md +++ b/skills/deeppapernote/SKILL.md @@ -8,7 +8,7 @@ description: Generate a high-quality deep-reading note for a single paper and wr Use this skill when the user wants one outcome: - read one paper carefully - generate a high-quality Markdown note -- save the note to the workspace or Obsidian target selected by User Configuration +- save the note to the workspace or Obsidian target selected by resolved configuration Chinese trigger examples: - `给这篇论文生成深度笔记` @@ -24,6 +24,8 @@ English trigger examples: Before a normal paper run, read `references/user-configuration.md` for configuration admission, migration, repair, Run Overrides, and Preference Changes. +Resolve Run Overrides from the explicit request, CLI, and current process environment first. When they form a complete valid configuration for the selected Save Mode, Configuration Readiness is complete without reading User Configuration. Only inspect User Configuration when those Run Overrides need fallback values. + ## Language Integrity Contract After Configuration Readiness, resolve one `output_language` (`zh-CN` or `en`) for the run. `source_manifest.language_hint` describes source text only and never selects the note profile. @@ -66,7 +68,7 @@ The note must adapt to the paper type. Use the same base structure, but shift em ## Workflow Follow this order: -1. complete Configuration Readiness; advance only after inspection returns `ready` +1. complete Configuration Readiness: resolve Run Overrides first, and inspect User Configuration only when they are incomplete; advance only after the resolved run configuration is complete and valid 2. resolve the paper identity 3. collect metadata 4. acquire the best available PDF diff --git a/skills/deeppapernote/references/user-configuration.md b/skills/deeppapernote/references/user-configuration.md index 63f6aae..081862a 100644 --- a/skills/deeppapernote/references/user-configuration.md +++ b/skills/deeppapernote/references/user-configuration.md @@ -1,6 +1,6 @@ # User Configuration -Use one device-local User Configuration at `~/.deeppapernote/config.json`: +DeepPaperNote can resolve a run entirely from Run Overrides. It also supports one optional device-local User Configuration at `~/.deeppapernote/config.json` for fallback values and explicit future preferences: For isolated validation only, `DEEPPAPERNOTE_CONFIG_PATH` may relocate this one file for the current process. It is not a preference, does not create a second configuration layer, and must not be persisted into the configuration itself. @@ -13,13 +13,15 @@ There is no implicit language or save-mode default. Workspace mode ignores store ## Configuration admission -Complete Configuration Readiness before paper identity resolution. The inspector returns exactly one structured state: `ready`, `needs_input`, `invalid`, or `blocked`. +Complete Configuration Readiness before paper identity resolution: -1. Run `scripts/user_configuration.py` without setters and read its structured state. -2. For `needs_input` on first use, ask one Configuration Prompt Batch for `output_language` and `save_mode`; require `obsidian_vault` and `papers_dir` in that same response when the user selects Obsidian. For later repair, ask only for `prompt_fields`. -3. For migration candidates, show the candidates and obtain confirmation for a complete configuration. Candidates alone never authorize the paper run. -4. Persist confirmed preferences with the relevant `--set-output-language`, `--set-save-mode`, `--set-vault`, and `--set-papers-dir` options. Use `--replace-invalid` only after the user explicitly confirms replacement of malformed or non-object JSON. -5. Run the inspector again. Configuration admission completes only when it returns `ready` after atomic persistence and readback validation; then continue the same paper request. +1. Resolve the explicit request, CLI arguments, and current process environment in precedence order. +2. When those Run Overrides contain every active field and pass validation, complete Configuration Readiness without reading User Configuration. +3. Otherwise run `scripts/user_configuration.py` without setters. The inspector returns exactly one structured User Configuration state: `ready`, `needs_input`, `invalid`, or `blocked`. +4. For `needs_input` on first use, ask one Configuration Prompt Batch for the unresolved active fields. Require `obsidian_vault` and `papers_dir` when the resolved Save Mode is Obsidian. For later repair, ask only for `prompt_fields`. +5. For migration candidates, show the candidates and obtain confirmation before persisting future preferences. Candidates may still act as current-process Run Overrides when they are actually present in the process environment. +6. Persist confirmed preferences with the relevant `--set-output-language`, `--set-save-mode`, `--set-vault`, and `--set-papers-dir` options. Use `--replace-invalid` only after the user explicitly confirms replacement of malformed or non-object JSON. +7. Run the inspector again after a Preference Change. Persistence completes only when it returns `ready` after atomic write and readback validation; then resolve the run again. Treat `invalid` as repairable input. Treat `blocked` as an I/O boundary: report its issue, preserve the current file, and stop before paper work. Never claim a preference was saved unless readback returned `ready`. @@ -31,10 +33,10 @@ Resolve each preference using this exact precedence; an explicit request is an e An explicit request about the current paper is a Run Override. Translate it to the matching runtime override and leave `config.json` byte-for-byte unchanged. Persist only explicit future-default wording as a Preference Change. -While `config.json` is absent, supported process and shell values are migration candidates only. Once the file exists, shell startup files leave the preference path permanently; current process environment values remain hidden, run-scoped compatibility overrides rather than public setup. +Current process environment values are first-class Run Overrides and may satisfy the entire run without a configuration-file read. Shell startup files are not read when the inherited process environment is complete. If fallback is required while `config.json` is absent, supported shell values may be shown as migration candidates; they become persistent preferences only after confirmation. Preference Changes preserve unknown JSON fields and report a warning. Malformed or non-object JSON receives a unique invalid backup before a confirmed replacement. Writes use a same-directory temporary file, atomic replacement, and exact reread comparison. ## Completion criteria -Configuration is ready only when every active field is present and valid, the resolved values contain no missing or invalid field, and the workflow has not begun identity resolution. Obsidian mode requires an existing absolute Vault and a traversal-safe relative paper directory. Workspace mode requires neither Obsidian field and cannot be redirected by stale values. +Configuration is ready only when every active field is present and valid, the resolved values contain no missing or invalid field, and the workflow has not begun identity resolution. A User Configuration file is not required when Run Overrides already meet that condition. Obsidian mode requires an existing absolute Vault and a traversal-safe relative paper directory. Workspace mode requires neither Obsidian field and cannot be redirected by stale values. diff --git a/skills/deeppapernote/scripts/common.py b/skills/deeppapernote/scripts/common.py index 024395c..5044353 100644 --- a/skills/deeppapernote/scripts/common.py +++ b/skills/deeppapernote/scripts/common.py @@ -17,7 +17,11 @@ from pathlib import Path, PureWindowsPath from typing import Any -from user_configuration import inspect_configuration, resolve_preferences +from user_configuration import ( + inspect_configuration, + resolve_preferences, + resolve_run_overrides, +) ARXIV_NS = { "atom": "http://www.w3.org/2005/Atom", @@ -2457,15 +2461,20 @@ def runtime_config( explicit_overrides: dict[str, Any] | None = None, cli_overrides: dict[str, Any] | None = None, ) -> dict[str, Any]: - inspection = inspect_configuration() - if inspection["state"] != "ready": - raise RuntimeError(json.dumps(inspection, ensure_ascii=False, sort_keys=True)) - resolved = resolve_preferences( + resolved = resolve_run_overrides( explicit_overrides=explicit_overrides, cli_overrides=cli_overrides, ) if resolved["issues"] or resolved["missing"]: - raise RuntimeError(json.dumps(resolved, ensure_ascii=False, sort_keys=True)) + inspection = inspect_configuration() + if inspection["state"] != "ready": + raise RuntimeError(json.dumps(inspection, ensure_ascii=False, sort_keys=True)) + resolved = resolve_preferences( + explicit_overrides=explicit_overrides, + cli_overrides=cli_overrides, + ) + if resolved["issues"] or resolved["missing"]: + raise RuntimeError(json.dumps(resolved, ensure_ascii=False, sort_keys=True)) return { "obsidian_vault": "", "papers_dir": "", diff --git a/skills/deeppapernote/scripts/user_configuration.py b/skills/deeppapernote/scripts/user_configuration.py index 9811cf3..8e09dbf 100644 --- a/skills/deeppapernote/scripts/user_configuration.py +++ b/skills/deeppapernote/scripts/user_configuration.py @@ -277,9 +277,27 @@ def resolve_preferences( configuration, read_error = _read_configuration(path) if read_error or configuration is None: raise RuntimeError(f"User Configuration is not readable and valid: {read_error}") + resolved = resolve_run_overrides( + explicit_overrides=explicit_overrides, + cli_overrides=cli_overrides, + environ=environ, + ) + values = _clean_values(configuration) + sources = {field: "user_configuration" for field in values} + values.update(resolved["values"]) + sources.update(resolved["sources"]) + issues, missing = _validate(values) + return {"values": values, "sources": sources, "issues": issues, "missing": missing} + + +def resolve_run_overrides( + *, + explicit_overrides: Mapping[str, Any] | None = None, + cli_overrides: Mapping[str, Any] | None = None, + environ: Mapping[str, str] | None = None, +) -> dict[str, Any]: environment = os.environ if environ is None else environ layers = ( - ("user_configuration", _clean_values(configuration)), ( "process_environment", { diff --git a/tests/test_common.py b/tests/test_common.py index 1b02b71..ae3de0c 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -525,6 +525,52 @@ def test_runtime_config_ignores_read_arxiv_obsidian_vault(tmp_path: Path, monkey assert root == tmp_path / "DeepPaperNote_output" +def test_runtime_config_accepts_complete_environment_without_user_configuration( + tmp_path: Path, monkeypatch +) -> None: + config_path = tmp_path / "missing" / "config.json" + monkeypatch.setenv("DEEPPAPERNOTE_CONFIG_PATH", str(config_path)) + monkeypatch.setenv("DEEPPAPERNOTE_OUTPUT_LANGUAGE", "en") + monkeypatch.setenv("DEEPPAPERNOTE_SAVE_MODE", "workspace") + + config = common.runtime_config() + + assert config["output_language"] == "en" + assert config["save_mode"] == "workspace" + assert not config_path.exists() + + +def test_runtime_config_fills_partial_environment_from_user_configuration( + monkeypatch, +) -> None: + monkeypatch.setenv("DEEPPAPERNOTE_OUTPUT_LANGUAGE", "en") + + config = common.runtime_config() + + assert config["output_language"] == "en" + assert config["save_mode"] == "workspace" + + +def test_runtime_config_uses_complete_obsidian_environment_without_reading_invalid_file( + tmp_path: Path, monkeypatch +) -> None: + vault = tmp_path / "vault" + vault.mkdir() + config_path = tmp_path / "config.json" + config_path.write_text("{broken", encoding="utf-8") + monkeypatch.setenv("DEEPPAPERNOTE_CONFIG_PATH", str(config_path)) + monkeypatch.setenv("DEEPPAPERNOTE_OUTPUT_LANGUAGE", "zh-CN") + monkeypatch.setenv("DEEPPAPERNOTE_SAVE_MODE", "obsidian") + monkeypatch.setenv("DEEPPAPERNOTE_OBSIDIAN_VAULT", str(vault)) + monkeypatch.setenv("DEEPPAPERNOTE_PAPERS_DIR", "Research/Papers") + + config = common.runtime_config() + + assert config["obsidian_vault"] == str(vault) + assert config["papers_dir"] == "Research/Papers" + assert config_path.read_text(encoding="utf-8") == "{broken" + + def test_resolve_obsidian_note_path_in_workspace_mode(tmp_path: Path, monkeypatch) -> None: monkeypatch.chdir(tmp_path) config = { diff --git a/tests/test_user_configuration.py b/tests/test_user_configuration.py index 737cdd7..dcc5bcb 100644 --- a/tests/test_user_configuration.py +++ b/tests/test_user_configuration.py @@ -389,11 +389,13 @@ def test_public_and_agent_contracts_share_user_configuration_source_of_truth() - for public_doc in (readme, readme_zh): assert "~/.deeppapernote/config.json" in public_doc - assert "DEEPPAPERNOTE_OUTPUT_LANGUAGE" not in public_doc - assert "DEEPPAPERNOTE_OBSIDIAN_VAULT" not in public_doc + for name in user_configuration.ENV_FIELDS.values(): + assert name in public_doc assert "references/user-configuration.md" in skill - assert skill.index("Configuration Readiness") < skill.index("resolve the paper identity") + assert skill.index("Resolve Run Overrides") < skill.index("inspect User Configuration") + assert skill.index("inspect User Configuration") < skill.index("resolve the paper identity") assert "explicit request > CLI > current process environment > User Configuration" in contract + assert "without reading User Configuration" in contract assert "scripts/user_configuration.py" in contract @@ -426,7 +428,8 @@ def test_public_onboarding_covers_bilingual_configuration_and_run_overrides() -> assert "Formal Save" in text assert "`images/`" in text assert "unknown" in text.lower() - assert "DEEPPAPERNOTE_" not in text + for name in user_configuration.ENV_FIELDS.values(): + assert name in text field_values = { "output_language": set(user_configuration.OUTPUT_LANGUAGES), From d35a8a040f34af1bf705a4cbba318ddc631e22c5 Mon Sep 17 00:00:00 2001 From: dingdingcar <1054263492@qq.com> Date: Sat, 29 Aug 2026 03:44:58 +0800 Subject: [PATCH 11/15] feat: add vault-wide note save admission --- CHANGELOG.md | 2 + README.md | 2 +- README.zh-CN.md | 2 +- skills/deeppapernote/SKILL.md | 30 +- .../references/obsidian-format.md | 8 +- skills/deeppapernote/scripts/common.py | 11 +- .../scripts/extract_source_text.py | 7 + skills/deeppapernote/scripts/fetch_pdf.py | 3 + .../scripts/write_obsidian_note.py | 688 +++++++++++- tests/test_cli_fallback.py | 984 +++++++++++++++++- tests/test_common.py | 5 +- tests/test_contracts_consistency.py | 11 + tests/test_fetch_pdf.py | 48 + tests/test_lint_note.py | 25 + tests/test_run_pipeline_manifest.py | 48 + tests/test_windows_path_encoding.py | 40 + 16 files changed, 1849 insertions(+), 65 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b0ce52..39f7122 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,10 +16,12 @@ Add an entry here when the project meaningfully changes for users, for example: - Added end-to-end English output through User Configuration or `--language en`, including localized note schemas, paper-type planning contracts, figure callouts, grounding, final-note linting, and Formal Save validation. - Added one device-local `config.json` for language and save preferences, with first-use/repair inspection, confirmed migration, atomic persistence, and run-scoped overrides. +- Added Vault-wide Save Target Admission backed by the original PDF SHA-256 and a hidden paper-directory sidecar, so source-identical language variants share one directory and same-language overwrites require explicit hash-bound confirmation. ### Changed - Complete valid Run Overrides now satisfy Configuration Readiness without reading `~/.deeppapernote/config.json`; incomplete overrides continue through the existing User Configuration fallback and validation flow. +- Obsidian notes now use language-specific filenames, and Windows sidecars receive the native Hidden file attribute in addition to their dot-prefixed name. ## v2.2.0 diff --git a/README.md b/README.md index 4bc2c2a..094fec8 100644 --- a/README.md +++ b/README.md @@ -186,7 +186,7 @@ Workspace mode does not use `obsidian_vault` or `papers_dir` for that run, but i In either profile, Abstract faithfully renders the source abstract in the requested language; later contribution claims and interpretation stay in later sections. The same canonical Skill and one pipeline carry the chosen language through lint and Formal Save. -Formal Save writes the Markdown note and its paper-local `images/` directory to the selected workspace or Obsidian target. The `images/` directory remains part of the saved paper layout even when no image is inserted. A blocked save reports the existing target and never silently switches modes. +Formal Save writes the Markdown note and its paper-local `images/` directory to the selected workspace or Obsidian target. In Obsidian mode, DeepPaperNote fingerprints the original PDF, searches the entire Vault before drafting, and keeps language variants such as `.zh-CN.md` and `.en.md` in one source-identified directory. A same-language note is never silently overwritten: the save reports its path and hash and requires explicit confirmation. The directory identity lives in `.deeppapernote.json`; Windows saves also apply the native Hidden attribute. The `images/` directory remains part of the saved paper layout even when no image is inserted, and a blocked save never silently switches modes. ## 🔧 Optional Enhancements diff --git a/README.zh-CN.md b/README.zh-CN.md index f5517e6..4df91e1 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -186,7 +186,7 @@ workspace 模式本次不会使用 `obsidian_vault` 与 `papers_dir`,但会保 两种 Profile 的 Abstract 都必须忠实呈现原论文摘要,并使用请求的输出语言;后续创新点和结果解释留在后续章节。同一个 canonical Skill 与一条流水线(one pipeline)会把选定语言一直绑定到 lint 和 Formal Save。 -Formal Save 会把 Markdown 笔记与 paper-local `images/` 目录写入选定的 workspace 或 Obsidian 目标。即使没有插入图片,`images/` 仍属于论文保存布局。保存受阻时会报告原目标,不会静默切换模式。 +Formal Save 会把 Markdown 笔记与 paper-local `images/` 目录写入选定的 workspace 或 Obsidian 目标。在 Obsidian 模式下,DeepPaperNote 会为原始 PDF 计算指纹,并在开始写作前搜索整个 Vault;同一来源的 `.zh-CN.md` 与 `.en.md` 会共用同一个身份目录。已经存在同语言笔记时绝不会静默覆盖:保存程序会返回原笔记路径与哈希,并要求用户明确确认。目录身份保存在 `.deeppapernote.json` 中;Windows 上还会设置原生 Hidden 属性。即使没有插入图片,`images/` 仍属于论文保存布局;保存受阻时也不会静默切换模式。 ## 🔧 可选增强 diff --git a/skills/deeppapernote/SKILL.md b/skills/deeppapernote/SKILL.md index afe6bc9..11072f1 100644 --- a/skills/deeppapernote/SKILL.md +++ b/skills/deeppapernote/SKILL.md @@ -30,7 +30,7 @@ Resolve Run Overrides from the explicit request, CLI, and current process enviro After Configuration Readiness, resolve one `output_language` (`zh-CN` or `en`) for the run. `source_manifest.language_hint` describes source text only and never selects the note profile. -Bind that exact value through Figure Plan → Figure/Table Decisions → Synthesis Bundle → Note Plan → Grounding Lint → Final Note Lint → Final Quality Review → Final Readability Review → Formal Save: +Bind that exact value through Save Target Admission → Figure Plan → Figure/Table Decisions → Synthesis Bundle → Note Plan → Grounding Lint → Final Note Lint → Final Quality Review → Final Readability Review → Formal Save: - Every JSON artifact in the chain carries a top-level `output_language`; the Synthesis Bundle also carries the same value at `writing_contract.language`. - Before producing its output, every adjacent consumer requires each input language and compares it with the resolved value. Missing, unsupported, or mismatched values stop the run; no stage infers or defaults an artifact language. @@ -73,17 +73,23 @@ Follow this order: 3. collect metadata 4. acquire the best available PDF 5. extract canonical raw source text: `*_raw_sections.jsonl`, `*_source_manifest.json`, and optional derived `*_full_text.md` -6. extract structural indexes and PDF assets -7. plan figure placement -8. build the full figure/table decision table -9. build the manifest synthesis bundle -10. have the model read the bundle plus raw sections and create a short JSON `note_plan` that satisfies the generated bundle contract, including its exact `output_language` -11. draft from the plan only after the grounding gate passes -12. have the model write the note -13. lint the final note against the same `note_plan` — this stage completes only when the lint artifact exists and every reported `passes_*` gate is `true`; otherwise revise and rerun lint. If the lint output contains `passes_style_gate: false`, apply the Style Gate Enforcement rule before advancing to step 14, 15, or 16 -14. perform `final_quality_review` after lint passes -15. perform `final_readability_review` after the quality review passes -16. perform Formal Save to the configured target +6. perform Save Target Admission before drafting or domain routing: + - for Obsidian mode, run `scripts/write_obsidian_note.py --preflight` with the resolved title, exact `output_language`, Vault, and `*_source_manifest.json`; this program result is authoritative, so do not replace it with prompt-only duplicate checking + - when admission returns `reuse_source_directory` or `reuse_empty_same_name_directory`, use that directory and skip domain selection + - when it returns `same_language_note_exists`, stop before drafting and ask whether to overwrite the reported note. If the user approves, rerun preflight with `--overwrite-existing-note --expected-existing-note-sha256 ` and carry that exact confirmation into Formal Save; if the user declines, stop without writing + - for any other blocked conflict, report the returned ambiguity and stop without creating a second directory + - workspace mode does not scan an Obsidian Vault and continues through its normal domain routing +7. extract structural indexes and PDF assets +8. plan figure placement +9. build the full figure/table decision table +10. build the manifest synthesis bundle +11. have the model read the bundle plus raw sections and create a short JSON `note_plan` that satisfies the generated bundle contract, including its exact `output_language` +12. draft from the plan only after the grounding gate passes +13. have the model write the note +14. lint the final note against the same `note_plan` — this stage completes only when the lint artifact exists and every reported `passes_*` gate is `true`; otherwise revise and rerun lint. If the lint output contains `passes_style_gate: false`, apply the Style Gate Enforcement rule before advancing to step 15, 16, or 17 +15. perform `final_quality_review` after lint passes +16. perform `final_readability_review` after the quality review passes +17. perform Formal Save to the admitted target with `scripts/write_obsidian_note.py`, the same Source Manifest, and any user-approved overwrite hash; the script repeats admission before the first save side effect This is the required workflow for a normal single-paper note request, not a loose suggestion. Unless this skill explicitly marks a stage as optional, required stages must not be silently skipped, reordered into a shortcut, or treated as complete just because a partial artifact already exists. diff --git a/skills/deeppapernote/references/obsidian-format.md b/skills/deeppapernote/references/obsidian-format.md index 5478f75..7d0063a 100644 --- a/skills/deeppapernote/references/obsidian-format.md +++ b/skills/deeppapernote/references/obsidian-format.md @@ -16,8 +16,10 @@ Language note: section names, metadata labels, figure callouts, and the mechanis Default file name: - sanitized English title with underscores - default note layout is folder-per-paper: - - `<领域>//.md` + - `<领域>//.zh-CN.md` + - `<领域>//.en.md` - `<领域>//images/...` + - `<领域>//.deeppapernote.json` - when deciding `<领域>`, prefer matching an existing first-level domain folder under the user's papers directory - domain routing uses the editable taxonomy in `references/domain_rules.yaml`: application domains are checked before fallback method domains - reuse existing first-level folders conservatively; method-only evidence should not force reuse of an unrelated application folder @@ -26,6 +28,10 @@ Default file name: - always create the paper-local `images/` directory during final save, even if no real image is inserted - the paper-local `images/` directory is part of the required note layout, not an optional optimization +The hidden `.deeppapernote.json` sidecar is the program-owned directory identity record. It stores the exact original PDF SHA-256, the frozen note stem, and the language variants already saved in that directory. Keep the dot-prefixed name on every platform; on Windows the save script also applies the native Hidden file attribute and verifies it after each sidecar replacement. + +Before an Obsidian draft begins, use the save script's preflight result rather than inspecting names manually. It searches the entire Vault for the source SHA-256 and same-name directories. An exact source match reuses its frozen directory without domain routing; another language is added beside the existing note, while an existing note in the requested language requires explicit hash-bound overwrite confirmation. A nonempty same-name directory without a valid sidecar, a same-name directory for different source bytes, or multiple identity matches fails closed. + If the user already has a vault convention, preserve it. ## Markdown Style diff --git a/skills/deeppapernote/scripts/common.py b/skills/deeppapernote/scripts/common.py index 5044353..1cb6865 100644 --- a/skills/deeppapernote/scripts/common.py +++ b/skills/deeppapernote/scripts/common.py @@ -3064,8 +3064,15 @@ def infer_domain_label(title: str, abstract: str = "") -> str: def is_probable_paper_folder(path: Path) -> bool: if not path.is_dir(): return False - marker = path / f"{path.name}.md" - return marker.exists() + return any( + (path / marker).exists() + for marker in ( + f"{path.name}.md", + f"{path.name}.zh-CN.md", + f"{path.name}.en.md", + ".deeppapernote.json", + ) + ) def existing_domain_dirs(config: dict[str, Any]) -> list[str]: diff --git a/skills/deeppapernote/scripts/extract_source_text.py b/skills/deeppapernote/scripts/extract_source_text.py index 3d5f447..8746318 100644 --- a/skills/deeppapernote/scripts/extract_source_text.py +++ b/skills/deeppapernote/scripts/extract_source_text.py @@ -16,6 +16,7 @@ ensure_parent, extract_appendix_index, extract_caption_lines, + file_sha256, fitz, match_section_heading, maybe_load_json_record, @@ -302,6 +303,7 @@ def build_manifest( "schema_version": 1, "paper_id": record.get("paper_id") or paper_id_for_record(record), "title": record.get("title", ""), + "source_sha256": record.get("source_sha256", ""), "source_kind": "pdf_text", "raw_sections_path": ( str(Path(raw_sections_output).expanduser().resolve()) if raw_sections_output else "" @@ -353,6 +355,11 @@ def main() -> None: pdf_path = resolve_pdf_path(record) if pdf_path is None: raise SystemExit("extract_source_text.py requires a resolvable local PDF path.") + source_sha256 = file_sha256(pdf_path) + reported_source_sha256 = normalize_whitespace(str(record.get("source_sha256", ""))) + if reported_source_sha256 and reported_source_sha256 != source_sha256: + raise SystemExit("extract_source_text.py source_sha256 does not match acquired PDF.") + record["source_sha256"] = source_sha256 raw_sections_output = args.raw_sections_output if not raw_sections_output and args.output: diff --git a/skills/deeppapernote/scripts/fetch_pdf.py b/skills/deeppapernote/scripts/fetch_pdf.py index 4329a89..ee68af4 100644 --- a/skills/deeppapernote/scripts/fetch_pdf.py +++ b/skills/deeppapernote/scripts/fetch_pdf.py @@ -11,6 +11,7 @@ default_pdf_path, emit, fetch_record_from_canonical_identity, + file_sha256, http_get_bytes, maybe_load_json_record, paper_id_for_record, @@ -103,6 +104,7 @@ def main(argv: list[str] | None = None) -> None: "pdf_source": "local_pdf", "source_url": record.get("source_url", "") or str(pdf_path), "pdf_url": "", + "source_sha256": file_sha256(pdf_path), } if identity_summary: payload["identity_contract"] = identity_summary @@ -157,6 +159,7 @@ def main(argv: list[str] | None = None) -> None: "source_url": record.get("source_url", ""), "pdf_url": source_value, "file_size": target_path.stat().st_size, + "source_sha256": file_sha256(target_path), } if identity_summary: payload["identity_contract"] = identity_summary diff --git a/skills/deeppapernote/scripts/write_obsidian_note.py b/skills/deeppapernote/scripts/write_obsidian_note.py index d659d8b..0cb3790 100644 --- a/skills/deeppapernote/scripts/write_obsidian_note.py +++ b/skills/deeppapernote/scripts/write_obsidian_note.py @@ -6,9 +6,11 @@ import argparse import hashlib import json +import os import re import shutil import sys +import tempfile from pathlib import Path from common import ( @@ -25,6 +27,56 @@ from lint_note import inspect_reference_hygiene from localization import normalize_output_language, require_artifact_output_language +PAPER_DIRECTORY_SIDECAR = ".deeppapernote.json" +SOURCE_SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +WINDOWS_FILE_ATTRIBUTE_HIDDEN = 0x2 +WINDOWS_INVALID_FILE_ATTRIBUTES = 0xFFFFFFFF + + +def ensure_sidecar_hidden( + path: Path, + *, + platform: str | None = None, + get_attributes=None, + set_attributes=None, +) -> None: + if (platform or os.name) != "nt": + return + if (get_attributes is None) != (set_attributes is None): + raise ValueError("get_attributes and set_attributes must be provided together") + + if get_attributes is None: + import ctypes + + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + get_attributes = kernel32.GetFileAttributesW + get_attributes.argtypes = [ctypes.c_wchar_p] + get_attributes.restype = ctypes.c_uint32 + set_attributes = kernel32.SetFileAttributesW + set_attributes.argtypes = [ctypes.c_wchar_p, ctypes.c_uint32] + set_attributes.restype = ctypes.c_int + + def fail() -> None: + raise ctypes.WinError(ctypes.get_last_error()) + + else: + + def fail() -> None: + raise OSError(f"Could not set Windows Hidden attribute: {path}") + + path_value = str(path) + current = int(get_attributes(path_value)) + if current == WINDOWS_INVALID_FILE_ATTRIBUTES: + fail() + if not set_attributes(path_value, current | WINDOWS_FILE_ATTRIBUTE_HIDDEN): + fail() + verified = int(get_attributes(path_value)) + if ( + verified == WINDOWS_INVALID_FILE_ATTRIBUTES + or not verified & WINDOWS_FILE_ATTRIBUTE_HIDDEN + ): + fail() + def parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser(description=__doc__ or "write obsidian note") @@ -42,6 +94,11 @@ def parser() -> argparse.ArgumentParser: default="", help="Required Figure/Table Decisions JSON for Formal Save.", ) + p.add_argument( + "--source-manifest", + default="", + help="Required Source Manifest JSON for Obsidian Save Target Admission.", + ) p.add_argument("--title", default="", help="Explicit title override.") p.add_argument("--output", default="", help="JSON status output path.") p.add_argument("--vault", default="", help="Target Obsidian vault path.") @@ -52,6 +109,21 @@ def parser() -> argparse.ArgumentParser: p.add_argument("--asset-subdir", default="images", help="Asset folder name relative to the note directory.") p.add_argument("--paper-id", default="", help="Canonical paper id.") p.add_argument("--language", default="", help="Run Override for output language: en or zh-CN.") + p.add_argument( + "--preflight", + action="store_true", + help="Resolve the Obsidian Save Target without writing files.", + ) + p.add_argument( + "--overwrite-existing-note", + action="store_true", + help="Overwrite a same-language note after explicit user confirmation.", + ) + p.add_argument( + "--expected-existing-note-sha256", + default="", + help="SHA-256 returned by the conflict that the user approved overwriting.", + ) return p @@ -115,9 +187,11 @@ def materialize_insert_decisions( target_path: Path, decisions: dict, asset_subdir: str, + created_paths: list[Path] | None = None, ) -> list[dict]: - materialized: list[dict] = [] asset_dir = target_path.parent / asset_subdir + pending: list[tuple[Path, Path, str, dict]] = [] + planned_hashes: dict[Path, str] = {} for item in insert_decisions(decisions): source_value = str(item.get("source_image_path", "")).strip() source_image = Path(source_value).expanduser() @@ -146,25 +220,58 @@ def materialize_insert_decisions( raise SystemExit( f"Insert decision for {label} is not referenced as an image embed: {expected_relative}." ) - asset_dir.mkdir(parents=True, exist_ok=True) dest_image = asset_dir / filename if dest_image.resolve().parent != asset_dir.resolve(): raise SystemExit(f"Unsafe figure image destination: {dest_image}") - if source_image.resolve() != dest_image.resolve(): + existing_planned_sha256 = planned_hashes.get(dest_image) + if existing_planned_sha256 and existing_planned_sha256 != current_sha256: + raise SystemExit( + f"Insert decisions assign different bytes to the same image: {filename}" + ) + planned_hashes[dest_image] = current_sha256 + if dest_image.is_file() and file_sha256(dest_image) != current_sha256: + raise SystemExit( + f"Refusing to save because an existing paper-local image has different bytes: " + f"{filename}" + ) + pending.append( + ( + source_image, + dest_image, + current_sha256, + { + "source_id": item.get("source_id") + or item.get("label") + or item.get("item_id") + or "", + "source_image": str(source_image.resolve()), + "dest_image_path": str(dest_image), + "relative_markdown_path": expected_relative, + "reviewed_asset_sha256": current_sha256, + }, + ) + ) + + if pending: + asset_dir.mkdir(parents=True, exist_ok=True) + materialized: list[dict] = [] + for source_image, dest_image, current_sha256, record in pending: + if dest_image.is_file(): + if file_sha256(dest_image) != current_sha256: + raise SystemExit( + f"Refusing to save because an existing paper-local image has different bytes: " + f"{dest_image.name}" + ) + elif source_image.resolve() != dest_image.resolve(): + if created_paths is not None: + created_paths.append(dest_image) shutil.copy2(source_image, dest_image) if file_sha256(dest_image) != current_sha256: raise SystemExit( - f"Materialized image bytes do not match the reviewed asset SHA-256: {filename}" + f"Materialized image bytes do not match the reviewed asset SHA-256: " + f"{dest_image.name}" ) - materialized.append( - { - "source_id": item.get("source_id") or item.get("label") or item.get("item_id") or "", - "source_image": str(source_image.resolve()), - "dest_image_path": str(dest_image), - "relative_markdown_path": expected_relative, - "reviewed_asset_sha256": current_sha256, - } - ) + materialized.append(record) return materialized @@ -204,6 +311,368 @@ def require_lint_gate(lint: dict, key: str, gate: str, lint_path: str) -> None: raise SystemExit(lint_failure_message(lint, gate, lint_path)) +def require_source_manifest(path_value: str) -> dict: + if not path_value: + raise SystemExit( + "Obsidian Save Target Admission requires a Source Manifest with source_sha256." + ) + manifest = maybe_load_json_record(path_value) + if manifest is None or str(manifest.get("status", "")).strip() != "ok": + raise SystemExit(f"Expected successful Source Manifest JSON: {path_value}") + source_sha256 = str(manifest.get("source_sha256", "")).strip().lower() + if not SOURCE_SHA256_RE.fullmatch(source_sha256): + raise SystemExit("Source Manifest requires a 64-character source_sha256.") + manifest["source_sha256"] = source_sha256 + return manifest + + +def language_note_path(target_path: Path, output_language: str) -> Path: + suffix = target_path.suffix or ".md" + stem = target_path.stem + if stem.endswith(f".{output_language}"): + return target_path + return target_path.with_name(f"{stem}.{output_language}{suffix}") + + +def same_name_directories(vault: Path, directory_name: str) -> list[Path]: + return sorted( + { + path.resolve() + for path in vault.rglob("*") + if ( + path.is_dir() + and path.name == directory_name + and path.resolve().is_relative_to(vault.resolve()) + ) + } + ) + + +def read_paper_directory_sidecar(path: Path) -> dict | None: + try: + payload = json.loads(path.read_text(encoding="utf-8-sig")) + except (OSError, json.JSONDecodeError): + return None + if ( + not isinstance(payload, dict) + or payload.get("artifact_type") != "deeppapernote_paper_directory" + or payload.get("schema_version") != 1 + or not SOURCE_SHA256_RE.fullmatch(str(payload.get("source_sha256", ""))) + or not str(payload.get("note_stem", "")).strip() + or Path(str(payload.get("note_stem", ""))).name != payload.get("note_stem") + ): + return None + return payload + + +def source_directories(vault: Path, source_sha256: str) -> list[tuple[Path, dict]]: + matches: list[tuple[Path, dict]] = [] + for sidecar_path in vault.rglob(PAPER_DIRECTORY_SIDECAR): + if not sidecar_path.resolve().is_relative_to(vault.resolve()): + continue + payload = read_paper_directory_sidecar(sidecar_path) + if payload is not None and payload["source_sha256"] == source_sha256: + matches.append((sidecar_path.parent.resolve(), payload)) + return sorted(matches, key=lambda item: str(item[0])) + + +def block_directory_conflict( + args: argparse.Namespace, + *, + conflict_code: str, + target_directories: list[Path], + source_sha256: str, + output_language: str, +) -> None: + payload = { + "status": "blocked", + "script": "write_obsidian_note.py", + "conflict_code": conflict_code, + "requires_user_confirmation": False, + "target_directory": str(target_directories[0]), + "matching_directories": [str(path) for path in target_directories], + "source_sha256": source_sha256, + "output_language": output_language, + } + emit(payload, args.output) + raise SystemExit(2) + + +def block_note_conflict( + args: argparse.Namespace, + target_path: Path, + *, + conflict_code: str, + source_sha256: str, + output_language: str, +) -> None: + emit( + { + "status": "blocked", + "script": "write_obsidian_note.py", + "conflict_code": conflict_code, + "requires_user_confirmation": True, + "target_directory": str(target_path.parent), + "existing_note_path": str(target_path), + "existing_note_sha256": file_sha256(target_path), + "source_sha256": source_sha256, + "output_language": output_language, + }, + args.output, + ) + raise SystemExit(2) + + +def resolve_obsidian_save_target( + args: argparse.Namespace, + config: dict, + vault: Path, + *, + title: str, + abstract: str, + source_sha256: str, + output_language: str, +) -> tuple[Path, str, str, bool, str]: + source_matches = source_directories(vault, source_sha256) + if len(source_matches) > 1: + block_directory_conflict( + args, + conflict_code="multiple_source_directories", + target_directories=[path for path, _ in source_matches], + source_sha256=source_sha256, + output_language=output_language, + ) + + resolved_subdir = "" + recorded_language_target = False + if source_matches: + existing_dir, sidecar = source_matches[0] + notes = sidecar.get("notes", {}) + if not isinstance(notes, dict): + block_directory_conflict( + args, + conflict_code="invalid_language_note_record", + target_directories=[existing_dir], + source_sha256=source_sha256, + output_language=output_language, + ) + if output_language in notes: + note_record = notes[output_language] + filename = ( + str(note_record.get("filename", "")).strip() + if isinstance(note_record, dict) + else "" + ) + if ( + not filename + or filename in {".", ".."} + or "/" in filename + or "\\" in filename + or Path(filename).is_absolute() + or Path(filename).suffix.lower() != ".md" + ): + block_directory_conflict( + args, + conflict_code="invalid_language_note_record", + target_directories=[existing_dir], + source_sha256=source_sha256, + output_language=output_language, + ) + target_path = existing_dir / filename + recorded_language_target = True + if not target_path.is_file(): + block_directory_conflict( + args, + conflict_code="recorded_language_note_missing", + target_directories=[existing_dir], + source_sha256=source_sha256, + output_language=output_language, + ) + else: + target_path = existing_dir / f"{sidecar['note_stem']}.md" + admission = "reuse_source_directory" + domain_routing_skipped = True + else: + identity_path = resolve_obsidian_note_path( + config, + title=title, + filename=args.filename, + ) + name_matches = same_name_directories(vault, identity_path.parent.name) + if len(name_matches) > 1: + block_directory_conflict( + args, + conflict_code="multiple_same_name_directories", + target_directories=name_matches, + source_sha256=source_sha256, + output_language=output_language, + ) + if name_matches: + existing_dir = name_matches[0] + if any(existing_dir.iterdir()): + existing_sidecar = read_paper_directory_sidecar( + existing_dir / PAPER_DIRECTORY_SIDECAR + ) + block_directory_conflict( + args, + conflict_code=( + "same_name_different_source" + if existing_sidecar is not None + else "unidentified_same_name_directory" + ), + target_directories=name_matches, + source_sha256=source_sha256, + output_language=output_language, + ) + target_path = existing_dir / identity_path.name + admission = "reuse_empty_same_name_directory" + domain_routing_skipped = True + else: + resolved_subdir = resolve_domain_subdir( + config, + title=title, + abstract=abstract, + subdir=args.subdir, + ) + target_path = resolve_obsidian_note_path( + config, + title=title, + subdir=resolved_subdir, + filename=args.filename, + ) + admission = "new_directory" + domain_routing_skipped = False + + if not recorded_language_target: + target_path = language_note_path(target_path, output_language) + approved_existing_note_sha256 = "" + if target_path.is_file(): + existing_note_sha256 = file_sha256(target_path) + if not args.overwrite_existing_note: + block_note_conflict( + args, + target_path, + conflict_code="same_language_note_exists", + source_sha256=source_sha256, + output_language=output_language, + ) + expected_sha256 = args.expected_existing_note_sha256.strip().lower() + if not SOURCE_SHA256_RE.fullmatch(expected_sha256): + raise SystemExit( + "--overwrite-existing-note requires a 64-character " + "--expected-existing-note-sha256 from the conflict response." + ) + if existing_note_sha256 != expected_sha256: + block_note_conflict( + args, + target_path, + conflict_code="stale_overwrite_confirmation", + source_sha256=source_sha256, + output_language=output_language, + ) + approved_existing_note_sha256 = existing_note_sha256 + admission = "overwrite_same_language_note" + elif args.overwrite_existing_note: + raise SystemExit( + "--overwrite-existing-note is only valid when the target language note exists." + ) + + return ( + target_path, + resolved_subdir, + admission, + domain_routing_skipped, + approved_existing_note_sha256, + ) + + +def write_paper_directory_sidecar( + target_path: Path, + *, + paper_id: str, + title: str, + source_sha256: str, + output_language: str, + note_sha256: str, +) -> Path: + sidecar_path = target_path.parent / PAPER_DIRECTORY_SIDECAR + payload = read_paper_directory_sidecar(sidecar_path) or { + "artifact_type": "deeppapernote_paper_directory", + "schema_version": 1, + "paper_id": paper_id, + "title": title, + "source_sha256": source_sha256, + "note_stem": target_path.name.removesuffix(f".{output_language}{target_path.suffix}"), + "notes": {}, + } + if payload["source_sha256"] != source_sha256: + raise SystemExit(f"Paper directory sidecar source mismatch: {sidecar_path}") + notes = payload.get("notes") + if not isinstance(notes, dict): + notes = {} + payload["notes"] = notes + notes[output_language] = { + "filename": target_path.name, + "note_sha256": note_sha256, + } + fd, temp_name = tempfile.mkstemp( + dir=sidecar_path.parent, + prefix=f"{PAPER_DIRECTORY_SIDECAR}.", + suffix=".tmp", + ) + temp_path = Path(temp_name) + try: + with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle: + handle.write(json.dumps(payload, ensure_ascii=False, indent=2) + "\n") + os.replace(temp_path, sidecar_path) + finally: + temp_path.unlink(missing_ok=True) + ensure_sidecar_hidden(sidecar_path) + return sidecar_path + + +def atomic_write_note(path: Path, note_text: str) -> None: + fd, temp_name = tempfile.mkstemp( + dir=path.parent, + prefix=f".{path.name}.", + suffix=".tmp", + ) + temp_path = Path(temp_name) + try: + with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle: + handle.write(note_text) + if path.is_file(): + shutil.copymode(path, temp_path) + os.replace(temp_path, path) + finally: + temp_path.unlink(missing_ok=True) + + +def backup_file(path: Path) -> Path | None: + if not path.is_file(): + return None + fd, backup_name = tempfile.mkstemp( + dir=path.parent, + prefix=f".{path.name}.", + suffix=".backup", + ) + os.close(fd) + backup_path = Path(backup_name) + try: + shutil.copy2(path, backup_path) + except BaseException: + backup_path.unlink(missing_ok=True) + raise + return backup_path + + +def restore_file(path: Path, backup_path: Path | None) -> None: + if backup_path is None: + path.unlink(missing_ok=True) + else: + os.replace(backup_path, path) + + def main() -> None: args = parser().parse_args() config = runtime_config( @@ -215,12 +684,68 @@ def main() -> None: } ) output_language = normalize_output_language(args.language or str(config.get("output_language", "")) or None) + output_mode, root_path = resolve_note_output_mode(config) record = maybe_load_json_record(args.input) or {} title = args.title or str(record.get("title", "")).strip() if not title: raise SystemExit("write_obsidian_note.py requires --title or metadata with a title.") + source_manifest = ( + require_source_manifest(args.source_manifest) if output_mode == "obsidian" else {} + ) + admission = "workspace" + domain_routing_skipped = False + approved_existing_note_sha256 = "" + if output_mode == "obsidian": + ( + target_path, + resolved_subdir, + admission, + domain_routing_skipped, + approved_existing_note_sha256, + ) = resolve_obsidian_save_target( + args, + config, + root_path, + title=title, + abstract=str(record.get("abstract", "")), + source_sha256=str(source_manifest["source_sha256"]), + output_language=output_language, + ) + else: + if args.preflight: + raise SystemExit("--preflight requires Obsidian save mode.") + resolved_subdir = resolve_domain_subdir( + config, + title=title, + abstract=str(record.get("abstract", "")), + subdir=args.subdir, + ) + target_path = resolve_obsidian_note_path( + config, + title=title, + subdir=resolved_subdir, + filename=args.filename, + ) + + if args.preflight: + emit( + { + "status": "ok", + "script": "write_obsidian_note.py", + "phase": "save_target_admission", + "admission": admission, + "domain_routing_skipped": domain_routing_skipped, + "target_directory": str(target_path.parent), + "note_path": str(target_path), + "source_sha256": str(source_manifest["source_sha256"]), + "output_language": output_language, + }, + args.output, + ) + return + if not args.lint_json: raise SystemExit("Formal Save requires Final Note Lint with output_language.") lint_path = str(Path(args.lint_json).expanduser().resolve()) @@ -279,48 +804,133 @@ def main() -> None: ) require_reference_hygiene(note_text, "before save") - resolved_subdir = resolve_domain_subdir( - config, - title=title, - abstract=str(record.get("abstract", "")), - subdir=args.subdir, - ) + overwrote_existing_note = bool(approved_existing_note_sha256) + if output_mode == "obsidian": + # ponytail: this closes sequential TOCTOU changes; add a Vault lock only + # if concurrent DeepPaperNote writers must be fully serialized. + rechecked_target, _, _, _, rechecked_existing_sha256 = resolve_obsidian_save_target( + args, + config, + root_path, + title=title, + abstract=str(record.get("abstract", "")), + source_sha256=str(source_manifest["source_sha256"]), + output_language=output_language, + ) + if ( + rechecked_target.resolve() != target_path.resolve() + or rechecked_existing_sha256 != approved_existing_note_sha256 + ): + block_directory_conflict( + args, + conflict_code="save_target_changed", + target_directories=[target_path.parent, rechecked_target.parent], + source_sha256=str(source_manifest["source_sha256"]), + output_language=output_language, + ) - target_path = resolve_obsidian_note_path( - config, - title=title, - subdir=resolved_subdir, - filename=args.filename, - ) asset_dir = resolve_note_asset_dir(target_path, args.asset_subdir) asset_subdir = asset_dir.relative_to(target_path.parent).as_posix() + asset_directory_existed = asset_dir.exists() ensure_parent(target_path) - materialized_figures = ( - materialize_insert_decisions( - note_text, - target_path, - figure_decisions, - asset_subdir, - ) - if figure_decisions - else [] + paper_id = ( + args.paper_id + or str(source_manifest.get("paper_id", "")) + or str(record.get("paper_id", "")) ) - Path(target_path).write_text(note_text, encoding="utf-8", newline="\n") - require_reference_hygiene(Path(target_path).read_text(encoding="utf-8"), "after save") - asset_dir.mkdir(parents=True, exist_ok=True) + sidecar_path = ( + target_path.parent / PAPER_DIRECTORY_SIDECAR + if output_mode == "obsidian" + else None + ) + note_backup = None + sidecar_backup = None + created_assets: list[Path] = [] + note_attempted = False + sidecar_attempted = False + try: + note_backup = backup_file(target_path) + sidecar_backup = backup_file(sidecar_path) if sidecar_path is not None else None + materialized_figures = ( + materialize_insert_decisions( + note_text, + target_path, + figure_decisions, + asset_subdir, + created_assets, + ) + if figure_decisions + else [] + ) + if ( + approved_existing_note_sha256 + and file_sha256(target_path) != approved_existing_note_sha256 + ): + block_note_conflict( + args, + target_path, + conflict_code="stale_overwrite_confirmation", + source_sha256=str(source_manifest["source_sha256"]), + output_language=output_language, + ) + note_attempted = True + atomic_write_note(target_path, note_text) + require_reference_hygiene( + target_path.read_text(encoding="utf-8"), + "after save", + ) + asset_dir.mkdir(parents=True, exist_ok=True) + if sidecar_path is not None: + sidecar_attempted = True + write_paper_directory_sidecar( + target_path, + paper_id=paper_id, + title=title, + source_sha256=str(source_manifest["source_sha256"]), + output_language=output_language, + note_sha256=note_sha256, + ) + except BaseException: + if sidecar_attempted and sidecar_path is not None: + restore_file(sidecar_path, sidecar_backup) + if sidecar_backup is not None: + try: + ensure_sidecar_hidden(sidecar_path) + except OSError: + pass + if note_attempted: + restore_file(target_path, note_backup) + for created_asset in reversed(created_assets): + created_asset.unlink(missing_ok=True) + if not asset_directory_existed: + try: + asset_dir.rmdir() + except OSError: + pass + raise + finally: + if note_backup is not None: + note_backup.unlink(missing_ok=True) + if sidecar_backup is not None: + sidecar_backup.unlink(missing_ok=True) payload = { "status": "ok", "script": "write_obsidian_note.py", "output_language": output_language, - "paper_id": args.paper_id or record.get("paper_id", ""), + "paper_id": paper_id, "title": title, "note_path": str(target_path), "subdir": resolved_subdir, "images_dir": str(asset_dir), "materialized_figures": materialized_figures, + "overwrote_existing_note": overwrote_existing_note, + "admission": admission, + "domain_routing_skipped": domain_routing_skipped, } - output_mode, root_path = resolve_note_output_mode(config) + if sidecar_path is not None: + payload["sidecar_path"] = str(sidecar_path) + payload["source_sha256"] = str(source_manifest["source_sha256"]) payload["output_mode"] = output_mode payload["base_output_root"] = str(root_path) if config.get("obsidian_vault"): diff --git a/tests/test_cli_fallback.py b/tests/test_cli_fallback.py index 88a02dd..830e8f3 100644 --- a/tests/test_cli_fallback.py +++ b/tests/test_cli_fallback.py @@ -8,6 +8,7 @@ from pathlib import Path import pytest +import write_obsidian_note PROJECT_ROOT = Path(__file__).resolve().parents[1] WRITE_SCRIPT = PROJECT_ROOT / "skills" / "deeppapernote" / "scripts" / "write_obsidian_note.py" @@ -17,12 +18,16 @@ ENV_SCRIPT = PROJECT_ROOT / "skills" / "deeppapernote" / "scripts" / "check_environment.py" -def formal_save_args(tmp_path: Path, note_text: str) -> list[str]: +def formal_save_args( + tmp_path: Path, + note_text: str, + output_language: str = "zh-CN", +) -> list[str]: lint_path = tmp_path / "passing-lint.json" lint_path.write_text( json.dumps( { - "output_language": "zh-CN", + "output_language": output_language, "note_sha256": hashlib.sha256(note_text.encode("utf-8")).hexdigest(), "passes_basic_structure": True, "passes_style_gate": True, @@ -31,12 +36,966 @@ def formal_save_args(tmp_path: Path, note_text: str) -> list[str]: ), encoding="utf-8", ) - decisions_path = tmp_path / "figure-decisions.json" - decisions_path.write_text( - json.dumps({"output_language": "zh-CN", "decisions": []}), - encoding="utf-8", + decisions_path = tmp_path / "figure-decisions.json" + decisions_path.write_text( + json.dumps({"output_language": output_language, "decisions": []}), + encoding="utf-8", + ) + return ["--lint-json", str(lint_path), "--figure-decisions", str(decisions_path)] + + +def source_manifest_args(tmp_path: Path, source_sha256: str = "a" * 64) -> list[str]: + source_manifest_path = tmp_path / "source-manifest.json" + source_manifest_path.write_text( + json.dumps( + { + "status": "ok", + "paper_id": "paper:test", + "source_sha256": source_sha256, + } + ), + encoding="utf-8", + ) + return ["--source-manifest", str(source_manifest_path)] + + +def test_write_note_creates_language_variant_and_directory_sidecar(tmp_path: Path) -> None: + vault = tmp_path / "vault" + vault.mkdir() + env = os.environ.copy() + env["DEEPPAPERNOTE_DISABLE_SHELL_CONFIG"] = "1" + note_text = "# My Test Paper\n\nVault write admission test.\n" + + result = subprocess.run( + [ + sys.executable, + str(WRITE_SCRIPT), + "--vault", + str(vault), + "--title", + "My Test Paper", + "--subdir", + "Benchmark", + "--content", + note_text, + *source_manifest_args(tmp_path), + *formal_save_args(tmp_path, note_text), + ], + cwd=tmp_path, + env=env, + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stderr + payload = json.loads(result.stdout) + note_path = Path(payload["note_path"]) + sidecar_path = note_path.parent / ".deeppapernote.json" + assert note_path.name == "My_Test_Paper.zh-CN.md" + assert note_path.read_text(encoding="utf-8") == note_text + assert json.loads(sidecar_path.read_text(encoding="utf-8")) == { + "artifact_type": "deeppapernote_paper_directory", + "schema_version": 1, + "paper_id": "paper:test", + "title": "My Test Paper", + "source_sha256": "a" * 64, + "note_stem": "My_Test_Paper", + "notes": { + "zh-CN": { + "filename": "My_Test_Paper.zh-CN.md", + "note_sha256": hashlib.sha256(note_text.encode("utf-8")).hexdigest(), + } + }, + } + + +def test_write_note_reuses_empty_same_name_directory_before_domain_routing( + tmp_path: Path, +) -> None: + vault = tmp_path / "vault" + existing_dir = vault / "LegacyArchive/My_Test_Paper" + existing_dir.mkdir(parents=True) + env = os.environ.copy() + env["DEEPPAPERNOTE_DISABLE_SHELL_CONFIG"] = "1" + note_text = "# My Test Paper\n\nReuse the empty directory.\n" + + result = subprocess.run( + [ + sys.executable, + str(WRITE_SCRIPT), + "--vault", + str(vault), + "--title", + "My Test Paper", + "--subdir", + "NewDomain", + "--content", + note_text, + *source_manifest_args(tmp_path), + *formal_save_args(tmp_path, note_text), + ], + cwd=tmp_path, + env=env, + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stderr + note_path = Path(json.loads(result.stdout)["note_path"]) + assert note_path.parent == existing_dir.resolve() + assert not (vault / "Research/Papers/NewDomain/My_Test_Paper").exists() + + +def test_write_note_reuses_source_directory_for_another_language(tmp_path: Path) -> None: + vault = tmp_path / "vault" + vault.mkdir() + env = os.environ.copy() + env["DEEPPAPERNOTE_DISABLE_SHELL_CONFIG"] = "1" + chinese_note = "# My Test Paper\n\n中文笔记。\n" + english_note = "# My Test Paper\n\nEnglish note.\n" + + chinese_result = subprocess.run( + [ + sys.executable, + str(WRITE_SCRIPT), + "--vault", + str(vault), + "--title", + "My Test Paper", + "--subdir", + "ChineseDomain", + "--content", + chinese_note, + *source_manifest_args(tmp_path), + *formal_save_args(tmp_path, chinese_note), + ], + cwd=tmp_path, + env=env, + capture_output=True, + text=True, + ) + assert chinese_result.returncode == 0, chinese_result.stderr + chinese_path = Path(json.loads(chinese_result.stdout)["note_path"]) + + english_result = subprocess.run( + [ + sys.executable, + str(WRITE_SCRIPT), + "--vault", + str(vault), + "--title", + "My Test Paper", + "--subdir", + "EnglishDomain", + "--language", + "en", + "--content", + english_note, + *source_manifest_args(tmp_path), + *formal_save_args(tmp_path, english_note, "en"), + ], + cwd=tmp_path, + env=env, + capture_output=True, + text=True, + ) + + assert english_result.returncode == 0, english_result.stderr + english_path = Path(json.loads(english_result.stdout)["note_path"]) + assert english_path.parent == chinese_path.parent + assert english_path.name == "My_Test_Paper.en.md" + sidecar = json.loads((english_path.parent / ".deeppapernote.json").read_text(encoding="utf-8")) + assert set(sidecar["notes"]) == {"zh-CN", "en"} + + +def test_write_note_does_not_replace_another_language_note_image(tmp_path: Path) -> None: + vault = tmp_path / "vault" + vault.mkdir() + env = os.environ.copy() + env["DEEPPAPERNOTE_DISABLE_SHELL_CONFIG"] = "1" + + def save_args(note_text: str, language: str, source_image: Path) -> list[str]: + args = formal_save_args(tmp_path, note_text, language) + decisions_path = Path(args[-1]) + digest = hashlib.sha256(source_image.read_bytes()).hexdigest() + decisions_path.write_text( + json.dumps( + { + "output_language": language, + "decisions": [ + { + "source_id": "Figure 1", + "decision": "insert", + "source_image_path": str(source_image), + "source_image_filename": "shared.png", + "source_image_sha256": digest, + "visual_review": { + "status": "pass", + "reviewed_asset_sha256": digest, + }, + } + ], + } + ), + encoding="utf-8", + ) + return args + + chinese_image = tmp_path / "chinese.png" + english_image = tmp_path / "english.png" + chinese_image.write_bytes(b"chinese-image") + english_image.write_bytes(b"english-image") + chinese_note = "# My Test Paper\n\n![Figure 1](images/shared.png)\n" + english_note = "# My Test Paper\n\n![Figure 1](images/shared.png)\n" + + first = subprocess.run( + [ + sys.executable, + str(WRITE_SCRIPT), + "--vault", + str(vault), + "--title", + "My Test Paper", + "--content", + chinese_note, + *source_manifest_args(tmp_path), + *save_args(chinese_note, "zh-CN", chinese_image), + ], + cwd=tmp_path, + env=env, + capture_output=True, + text=True, + ) + assert first.returncode == 0, first.stderr + directory = Path(json.loads(first.stdout)["note_path"]).parent + + second = subprocess.run( + [ + sys.executable, + str(WRITE_SCRIPT), + "--vault", + str(vault), + "--title", + "My Test Paper", + "--language", + "en", + "--content", + english_note, + *source_manifest_args(tmp_path), + *save_args(english_note, "en", english_image), + ], + cwd=tmp_path, + env=env, + capture_output=True, + text=True, + ) + + assert second.returncode != 0 + assert "existing paper-local image has different bytes" in second.stderr + assert (directory / "images/shared.png").read_bytes() == b"chinese-image" + assert not (directory / "My_Test_Paper.en.md").exists() + + +def test_formal_save_rolls_back_when_sidecar_finalization_fails( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + vault = tmp_path / "vault" + vault.mkdir() + original_note = "# Rollback Paper\n\nOriginal body.\n" + replacement_note = "# Rollback Paper\n\nReplacement body.\n" + monkeypatch.setenv("DEEPPAPERNOTE_DISABLE_SHELL_CONFIG", "1") + first = subprocess.run( + [ + sys.executable, + str(WRITE_SCRIPT), + "--vault", + str(vault), + "--title", + "Rollback Paper", + "--content", + original_note, + *source_manifest_args(tmp_path), + *formal_save_args(tmp_path, original_note), + ], + cwd=tmp_path, + env=os.environ.copy(), + capture_output=True, + text=True, + ) + assert first.returncode == 0, first.stderr + note_path = Path(json.loads(first.stdout)["note_path"]) + sidecar_path = note_path.parent / ".deeppapernote.json" + original_sidecar = sidecar_path.read_bytes() + + monkeypatch.setattr( + sys, + "argv", + [ + str(WRITE_SCRIPT), + "--vault", + str(vault), + "--title", + "Rollback Paper", + "--content", + replacement_note, + "--overwrite-existing-note", + "--expected-existing-note-sha256", + hashlib.sha256(original_note.encode("utf-8")).hexdigest(), + *source_manifest_args(tmp_path), + *formal_save_args(tmp_path, replacement_note), + ], + ) + + def fail_hidden(_path: Path) -> None: + raise OSError("simulated Hidden attribute failure") + + monkeypatch.setattr(write_obsidian_note, "ensure_sidecar_hidden", fail_hidden) + + with pytest.raises(OSError, match="simulated Hidden attribute failure"): + write_obsidian_note.main() + + assert note_path.read_text(encoding="utf-8") == original_note + assert sidecar_path.read_bytes() == original_sidecar + + +def test_write_note_blocks_same_source_and_language_without_mutating_vault( + tmp_path: Path, +) -> None: + vault = tmp_path / "vault" + vault.mkdir() + env = os.environ.copy() + env["DEEPPAPERNOTE_DISABLE_SHELL_CONFIG"] = "1" + original_note = "# My Test Paper\n\nOriginal note.\n" + replacement_note = "# My Test Paper\n\nReplacement note.\n" + + first = subprocess.run( + [ + sys.executable, + str(WRITE_SCRIPT), + "--vault", + str(vault), + "--title", + "My Test Paper", + "--subdir", + "OriginalDomain", + "--content", + original_note, + *source_manifest_args(tmp_path), + *formal_save_args(tmp_path, original_note), + ], + cwd=tmp_path, + env=env, + capture_output=True, + text=True, + ) + assert first.returncode == 0, first.stderr + note_path = Path(json.loads(first.stdout)["note_path"]) + sidecar_path = note_path.parent / ".deeppapernote.json" + original_sidecar = sidecar_path.read_bytes() + + second = subprocess.run( + [ + sys.executable, + str(WRITE_SCRIPT), + "--vault", + str(vault), + "--title", + "My Test Paper", + "--subdir", + "DifferentDomain", + "--content", + replacement_note, + *source_manifest_args(tmp_path), + *formal_save_args(tmp_path, replacement_note), + ], + cwd=tmp_path, + env=env, + capture_output=True, + text=True, + ) + + assert second.returncode == 2, second.stderr + conflict = json.loads(second.stdout) + assert conflict["status"] == "blocked" + assert conflict["conflict_code"] == "same_language_note_exists" + assert conflict["requires_user_confirmation"] is True + assert conflict["existing_note_path"] == str(note_path) + assert conflict["existing_note_sha256"] == hashlib.sha256( + original_note.encode("utf-8") + ).hexdigest() + assert note_path.read_text(encoding="utf-8") == original_note + assert sidecar_path.read_bytes() == original_sidecar + assert not (vault / "Research/Papers/DifferentDomain").exists() + + +def test_write_note_overwrites_only_with_matching_existing_note_sha256( + tmp_path: Path, +) -> None: + vault = tmp_path / "vault" + vault.mkdir() + env = os.environ.copy() + env["DEEPPAPERNOTE_DISABLE_SHELL_CONFIG"] = "1" + original_note = "# My Test Paper\n\nOriginal note.\n" + replacement_note = "# My Test Paper\n\nApproved replacement.\n" + + first = subprocess.run( + [ + sys.executable, + str(WRITE_SCRIPT), + "--vault", + str(vault), + "--title", + "My Test Paper", + "--content", + original_note, + *source_manifest_args(tmp_path), + *formal_save_args(tmp_path, original_note), + ], + cwd=tmp_path, + env=env, + capture_output=True, + text=True, + ) + assert first.returncode == 0, first.stderr + note_path = Path(json.loads(first.stdout)["note_path"]) + original_sha256 = hashlib.sha256(original_note.encode("utf-8")).hexdigest() + + overwrite = subprocess.run( + [ + sys.executable, + str(WRITE_SCRIPT), + "--vault", + str(vault), + "--title", + "My Test Paper", + "--content", + replacement_note, + "--overwrite-existing-note", + "--expected-existing-note-sha256", + original_sha256, + *source_manifest_args(tmp_path), + *formal_save_args(tmp_path, replacement_note), + ], + cwd=tmp_path, + env=env, + capture_output=True, + text=True, + ) + + assert overwrite.returncode == 0, overwrite.stderr + payload = json.loads(overwrite.stdout) + assert payload["overwrote_existing_note"] is True + assert Path(payload["note_path"]) == note_path + assert note_path.read_text(encoding="utf-8") == replacement_note + sidecar = json.loads((note_path.parent / ".deeppapernote.json").read_text(encoding="utf-8")) + assert sidecar["notes"]["zh-CN"]["note_sha256"] == hashlib.sha256( + replacement_note.encode("utf-8") + ).hexdigest() + + +def test_write_note_blocks_overwrite_when_confirmed_hash_is_stale(tmp_path: Path) -> None: + vault = tmp_path / "vault" + vault.mkdir() + env = os.environ.copy() + env["DEEPPAPERNOTE_DISABLE_SHELL_CONFIG"] = "1" + original_note = "# My Test Paper\n\nOriginal note.\n" + replacement_note = "# My Test Paper\n\nStale replacement.\n" + + first = subprocess.run( + [ + sys.executable, + str(WRITE_SCRIPT), + "--vault", + str(vault), + "--title", + "My Test Paper", + "--content", + original_note, + *source_manifest_args(tmp_path), + *formal_save_args(tmp_path, original_note), + ], + cwd=tmp_path, + env=env, + capture_output=True, + text=True, + ) + assert first.returncode == 0, first.stderr + note_path = Path(json.loads(first.stdout)["note_path"]) + + overwrite = subprocess.run( + [ + sys.executable, + str(WRITE_SCRIPT), + "--vault", + str(vault), + "--title", + "My Test Paper", + "--content", + replacement_note, + "--overwrite-existing-note", + "--expected-existing-note-sha256", + "b" * 64, + *source_manifest_args(tmp_path), + *formal_save_args(tmp_path, replacement_note), + ], + cwd=tmp_path, + env=env, + capture_output=True, + text=True, + ) + + assert overwrite.returncode == 2, overwrite.stderr + conflict = json.loads(overwrite.stdout) + assert conflict["conflict_code"] == "stale_overwrite_confirmation" + assert conflict["existing_note_sha256"] == hashlib.sha256( + original_note.encode("utf-8") + ).hexdigest() + assert note_path.read_text(encoding="utf-8") == original_note + + +def test_write_note_blocks_same_name_directory_for_a_different_source( + tmp_path: Path, +) -> None: + vault = tmp_path / "vault" + vault.mkdir() + env = os.environ.copy() + env["DEEPPAPERNOTE_DISABLE_SHELL_CONFIG"] = "1" + first_note = "# Shared Title\n\nFirst source.\n" + second_note = "# Shared Title\n\nSecond source.\n" + + first = subprocess.run( + [ + sys.executable, + str(WRITE_SCRIPT), + "--vault", + str(vault), + "--title", + "Shared Title", + "--subdir", + "FirstDomain", + "--content", + first_note, + *source_manifest_args(tmp_path, "a" * 64), + *formal_save_args(tmp_path, first_note), + ], + cwd=tmp_path, + env=env, + capture_output=True, + text=True, + ) + assert first.returncode == 0, first.stderr + first_path = Path(json.loads(first.stdout)["note_path"]) + archived_dir = vault / "ArchivedPapers" / first_path.parent.name + archived_dir.parent.mkdir() + first_path.parent.rename(archived_dir) + first_path = archived_dir / first_path.name + + second = subprocess.run( + [ + sys.executable, + str(WRITE_SCRIPT), + "--vault", + str(vault), + "--title", + "Shared Title", + "--subdir", + "SecondDomain", + "--language", + "en", + "--content", + second_note, + *source_manifest_args(tmp_path, "b" * 64), + *formal_save_args(tmp_path, second_note, "en"), + ], + cwd=tmp_path, + env=env, + capture_output=True, + text=True, + ) + + assert second.returncode == 2, second.stderr + conflict = json.loads(second.stdout) + assert conflict["conflict_code"] == "same_name_different_source" + assert conflict["target_directory"] == str(first_path.parent) + assert first_path.read_text(encoding="utf-8") == first_note + assert not (vault / "Research/Papers/SecondDomain").exists() + + +def test_write_note_freezes_directory_name_for_the_same_source_bytes(tmp_path: Path) -> None: + vault = tmp_path / "vault" + vault.mkdir() + env = os.environ.copy() + env["DEEPPAPERNOTE_DISABLE_SHELL_CONFIG"] = "1" + chinese_note = "# First Metadata Title\n\n中文笔记。\n" + english_note = "# Revised Metadata Title\n\nEnglish note.\n" + + first = subprocess.run( + [ + sys.executable, + str(WRITE_SCRIPT), + "--vault", + str(vault), + "--title", + "First Metadata Title", + "--subdir", + "FirstDomain", + "--content", + chinese_note, + *source_manifest_args(tmp_path), + *formal_save_args(tmp_path, chinese_note), + ], + cwd=tmp_path, + env=env, + capture_output=True, + text=True, + ) + assert first.returncode == 0, first.stderr + first_path = Path(json.loads(first.stdout)["note_path"]) + + archived_dir = vault / "ArchivedSources" / first_path.parent.name + archived_dir.parent.mkdir() + first_path.parent.rename(archived_dir) + first_path = archived_dir / first_path.name + + second = subprocess.run( + [ + sys.executable, + str(WRITE_SCRIPT), + "--vault", + str(vault), + "--title", + "Revised Metadata Title", + "--subdir", + "SecondDomain", + "--language", + "en", + "--content", + english_note, + *source_manifest_args(tmp_path), + *formal_save_args(tmp_path, english_note, "en"), + ], + cwd=tmp_path, + env=env, + capture_output=True, + text=True, + ) + + assert second.returncode == 0, second.stderr + second_path = Path(json.loads(second.stdout)["note_path"]) + assert second_path.parent == first_path.parent + assert second_path.name == "First_Metadata_Title.en.md" + assert not (vault / "Research/Papers/SecondDomain/Revised_Metadata_Title").exists() + + +def test_save_target_preflight_reuses_source_before_note_generation(tmp_path: Path) -> None: + vault = tmp_path / "vault" + vault.mkdir() + env = os.environ.copy() + env["DEEPPAPERNOTE_DISABLE_SHELL_CONFIG"] = "1" + chinese_note = "# My Test Paper\n\n中文笔记。\n" + + first = subprocess.run( + [ + sys.executable, + str(WRITE_SCRIPT), + "--vault", + str(vault), + "--title", + "My Test Paper", + "--subdir", + "OriginalDomain", + "--content", + chinese_note, + *source_manifest_args(tmp_path), + *formal_save_args(tmp_path, chinese_note), + ], + cwd=tmp_path, + env=env, + capture_output=True, + text=True, + ) + assert first.returncode == 0, first.stderr + directory = Path(json.loads(first.stdout)["note_path"]).parent + + preflight = subprocess.run( + [ + sys.executable, + str(WRITE_SCRIPT), + "--preflight", + "--vault", + str(vault), + "--title", + "Changed Metadata Title", + "--subdir", + "MustNotBeUsed", + "--language", + "en", + *source_manifest_args(tmp_path), + ], + cwd=tmp_path, + env=env, + capture_output=True, + text=True, + ) + + assert preflight.returncode == 0, preflight.stderr + payload = json.loads(preflight.stdout) + assert payload["status"] == "ok" + assert payload["phase"] == "save_target_admission" + assert payload["admission"] == "reuse_source_directory" + assert payload["domain_routing_skipped"] is True + assert payload["target_directory"] == str(directory) + assert Path(payload["note_path"]).name == "My_Test_Paper.en.md" + assert not Path(payload["note_path"]).exists() + + +def test_save_target_preflight_reports_same_language_conflict_without_lint( + tmp_path: Path, +) -> None: + vault = tmp_path / "vault" + vault.mkdir() + env = os.environ.copy() + env["DEEPPAPERNOTE_DISABLE_SHELL_CONFIG"] = "1" + note_text = "# My Test Paper\n\nExisting note.\n" + + first = subprocess.run( + [ + sys.executable, + str(WRITE_SCRIPT), + "--vault", + str(vault), + "--title", + "My Test Paper", + "--content", + note_text, + *source_manifest_args(tmp_path), + *formal_save_args(tmp_path, note_text), + ], + cwd=tmp_path, + env=env, + capture_output=True, + text=True, + ) + assert first.returncode == 0, first.stderr + + preflight = subprocess.run( + [ + sys.executable, + str(WRITE_SCRIPT), + "--preflight", + "--vault", + str(vault), + "--title", + "My Test Paper", + *source_manifest_args(tmp_path), + ], + cwd=tmp_path, + env=env, + capture_output=True, + text=True, + ) + + assert preflight.returncode == 2, preflight.stderr + conflict = json.loads(preflight.stdout) + assert conflict["conflict_code"] == "same_language_note_exists" + assert conflict["requires_user_confirmation"] is True + + +def test_save_target_preflight_honors_recorded_language_note_filename( + tmp_path: Path, +) -> None: + vault = tmp_path / "vault" + vault.mkdir() + env = os.environ.copy() + env["DEEPPAPERNOTE_DISABLE_SHELL_CONFIG"] = "1" + note_text = "# My Test Paper\n\nExisting note.\n" + first = subprocess.run( + [ + sys.executable, + str(WRITE_SCRIPT), + "--vault", + str(vault), + "--title", + "My Test Paper", + "--content", + note_text, + *source_manifest_args(tmp_path), + *formal_save_args(tmp_path, note_text), + ], + cwd=tmp_path, + env=env, + capture_output=True, + text=True, + ) + assert first.returncode == 0, first.stderr + original_path = Path(json.loads(first.stdout)["note_path"]) + renamed_path = original_path.with_name("Custom Chinese Note.md") + original_path.rename(renamed_path) + sidecar_path = renamed_path.parent / ".deeppapernote.json" + sidecar = json.loads(sidecar_path.read_text(encoding="utf-8")) + sidecar["notes"]["zh-CN"]["filename"] = renamed_path.name + sidecar_path.write_text(json.dumps(sidecar), encoding="utf-8") + + preflight = subprocess.run( + [ + sys.executable, + str(WRITE_SCRIPT), + "--preflight", + "--vault", + str(vault), + "--title", + "My Test Paper", + *source_manifest_args(tmp_path), + ], + cwd=tmp_path, + env=env, + capture_output=True, + text=True, + ) + + assert preflight.returncode == 2, preflight.stderr + conflict = json.loads(preflight.stdout) + assert conflict["conflict_code"] == "same_language_note_exists" + assert conflict["existing_note_path"] == str(renamed_path) + + +def test_save_target_preflight_blocks_missing_recorded_language_note(tmp_path: Path) -> None: + vault = tmp_path / "vault" + vault.mkdir() + env = os.environ.copy() + env["DEEPPAPERNOTE_DISABLE_SHELL_CONFIG"] = "1" + note_text = "# My Test Paper\n\nExisting note.\n" + first = subprocess.run( + [ + sys.executable, + str(WRITE_SCRIPT), + "--vault", + str(vault), + "--title", + "My Test Paper", + "--content", + note_text, + *source_manifest_args(tmp_path), + *formal_save_args(tmp_path, note_text), + ], + cwd=tmp_path, + env=env, + capture_output=True, + text=True, + ) + assert first.returncode == 0, first.stderr + note_path = Path(json.loads(first.stdout)["note_path"]) + note_path.unlink() + + preflight = subprocess.run( + [ + sys.executable, + str(WRITE_SCRIPT), + "--preflight", + "--vault", + str(vault), + "--title", + "My Test Paper", + *source_manifest_args(tmp_path), + ], + cwd=tmp_path, + env=env, + capture_output=True, + text=True, + ) + + assert preflight.returncode == 2, preflight.stderr + conflict = json.loads(preflight.stdout) + assert conflict["conflict_code"] == "recorded_language_note_missing" + assert conflict["target_directory"] == str(note_path.parent) + + +def test_save_target_preflight_blocks_unidentified_nonempty_same_name_directory( + tmp_path: Path, +) -> None: + vault = tmp_path / "vault" + legacy_dir = vault / "Research/Papers/Legacy/My_Test_Paper" + legacy_dir.mkdir(parents=True) + legacy_note = legacy_dir / "My_Test_Paper.zh-CN.md" + legacy_note.write_text("# 手工创建的中文笔记\n", encoding="utf-8") + env = os.environ.copy() + env["DEEPPAPERNOTE_DISABLE_SHELL_CONFIG"] = "1" + + preflight = subprocess.run( + [ + sys.executable, + str(WRITE_SCRIPT), + "--preflight", + "--vault", + str(vault), + "--title", + "My Test Paper", + "--language", + "en", + *source_manifest_args(tmp_path), + ], + cwd=tmp_path, + env=env, + capture_output=True, + text=True, + ) + + assert preflight.returncode == 2, preflight.stderr + conflict = json.loads(preflight.stdout) + assert conflict["conflict_code"] == "unidentified_same_name_directory" + assert legacy_note.read_text(encoding="utf-8") == "# 手工创建的中文笔记\n" + + +def test_save_target_preflight_blocks_multiple_directories_for_the_same_source( + tmp_path: Path, +) -> None: + vault = tmp_path / "vault" + source_sha256 = "a" * 64 + directories = [ + vault / "Research/Papers/One/First_Title", + vault / "Research/Papers/Two/Second_Title", + ] + for index, directory in enumerate(directories, start=1): + directory.mkdir(parents=True) + (directory / ".deeppapernote.json").write_text( + json.dumps( + { + "artifact_type": "deeppapernote_paper_directory", + "schema_version": 1, + "paper_id": "paper:test", + "title": f"Title {index}", + "source_sha256": source_sha256, + "note_stem": directory.name, + "notes": {}, + } + ), + encoding="utf-8", + ) + env = os.environ.copy() + env["DEEPPAPERNOTE_DISABLE_SHELL_CONFIG"] = "1" + + preflight = subprocess.run( + [ + sys.executable, + str(WRITE_SCRIPT), + "--preflight", + "--vault", + str(vault), + "--title", + "Any Title", + *source_manifest_args(tmp_path, source_sha256), + ], + cwd=tmp_path, + env=env, + capture_output=True, + text=True, ) - return ["--lint-json", str(lint_path), "--figure-decisions", str(decisions_path)] + + assert preflight.returncode == 2, preflight.stderr + conflict = json.loads(preflight.stdout) + assert conflict["conflict_code"] == "multiple_source_directories" + assert set(conflict["matching_directories"]) == {str(path) for path in directories} def test_write_note_falls_back_to_workspace(tmp_path: Path) -> None: @@ -88,6 +1047,7 @@ def test_write_note_rejects_asset_directory_outside_save_target(tmp_path: Path) "Unsafe Asset Directory", "--content", note_text, + *source_manifest_args(tmp_path), *formal_save_args(tmp_path, note_text), "--asset-subdir", str(outside_asset_dir), @@ -216,6 +1176,7 @@ def test_write_note_in_vault_mode_does_not_duplicate_paper_slug(tmp_path: Path) "心理健康/My_Test_Paper", "--content", note_text, + *source_manifest_args(tmp_path), *formal_save_args(tmp_path, note_text), ], cwd=tmp_path, @@ -226,7 +1187,13 @@ def test_write_note_in_vault_mode_does_not_duplicate_paper_slug(tmp_path: Path) ) payload = json.loads(result.stdout) note_path = Path(payload["note_path"]) - assert note_path == vault / "Research/Papers" / "心理健康" / "My_Test_Paper" / "My_Test_Paper.md" + assert note_path == ( + vault + / "Research/Papers" + / "心理健康" + / "My_Test_Paper" + / "My_Test_Paper.zh-CN.md" + ) assert note_path.exists() @@ -266,6 +1233,7 @@ def test_write_note_uses_ready_chinese_obsidian_configuration( "机器学习", "--content", note_text, + *source_manifest_args(tmp_path), *formal_save_args(tmp_path, note_text), ], cwd=tmp_path, diff --git a/tests/test_common.py b/tests/test_common.py index ae3de0c..7880b5e 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -727,7 +727,10 @@ def test_existing_domain_dirs_excludes_root_level_paper_folder(tmp_path: Path) - (papers / "大模型").mkdir(parents=True) paper_dir = papers / "Attention_Is_All_You_Need" paper_dir.mkdir(parents=True) - (paper_dir / "Attention_Is_All_You_Need.md").write_text("# note\n", encoding="utf-8") + (paper_dir / "Attention_Is_All_You_Need.zh-CN.md").write_text( + "# note\n", + encoding="utf-8", + ) config = { "obsidian_vault": str(vault), diff --git a/tests/test_contracts_consistency.py b/tests/test_contracts_consistency.py index bcb0a4d..a0a87a9 100644 --- a/tests/test_contracts_consistency.py +++ b/tests/test_contracts_consistency.py @@ -610,6 +610,17 @@ def test_skill_owns_formal_save_state_policy() -> None: assert policy_phrase not in obsidian_format_text +def test_skill_requires_programmatic_save_target_admission_before_drafting() -> None: + text = (SKILL_ROOT / "SKILL.md").read_text(encoding="utf-8") + workflow = text.split("## Workflow", 1)[1].split("## Core Execution Contract", 1)[0] + + assert "Save Target Admission" in workflow + assert "write_obsidian_note.py --preflight" in workflow + assert workflow.index("Save Target Admission") < workflow.index("plan figure placement") + assert "same_language_note_exists" in workflow + assert "--expected-existing-note-sha256" in workflow + + def test_topic_references_keep_separate_note_plan_responsibilities() -> None: evidence_first = (SKILL_ROOT / "references" / "evidence-first.md").read_text( encoding="utf-8" diff --git a/tests/test_fetch_pdf.py b/tests/test_fetch_pdf.py index 50b2e24..2bd7b33 100644 --- a/tests/test_fetch_pdf.py +++ b/tests/test_fetch_pdf.py @@ -163,6 +163,54 @@ def fake_http_get_bytes(url: str) -> bytes: assert payload["paper_id"] == "doi:10.1234/trusted" assert payload["title"] == "Trusted Paper" assert payload["pdf_url"] == "https://example.test/trusted.pdf" + assert payload["source_sha256"] == ( + "0cdae4135f26b10e67e6c4972b573c29913948373e8dc6210624b283b99c830d" + ) + + +def test_fetch_pdf_hashes_the_original_bound_local_pdf(tmp_path: Path) -> None: + pdf_path = tmp_path / "original.pdf" + metadata_path = tmp_path / "metadata.json" + identity_path = tmp_path / "identity.json" + output_path = tmp_path / "fetch.json" + pdf_path.write_bytes(b"%PDF-1.7\nlocal-original") + metadata_path.write_text( + json.dumps({"status": "ok", "script": "collect_metadata.py"}), + encoding="utf-8", + ) + identity_path.write_text( + json.dumps( + { + "status": "ok", + "script": "build_identity_contract.py", + "artifact_type": "canonical_identity", + "schema_version": 2, + "paper_id": "paper:local-original", + "identity_verdict": "accepted", + "work_level_identity": {"title": "Local Original"}, + "source_manifestation": {"source_kind": "local_pdf"}, + "bound_sources": [{"kind": "local_pdf", "value": str(pdf_path)}], + } + ), + encoding="utf-8", + ) + + fetch_pdf.main( + [ + "--input", + str(metadata_path), + "--identity", + str(identity_path), + "--output", + str(output_path), + ] + ) + + payload = json.loads(output_path.read_text(encoding="utf-8")) + assert payload["pdf_path"] == str(pdf_path.resolve()) + assert payload["source_sha256"] == ( + "f131d003c562388dc22cf50d93c60ae52a598b4b2c72963adeb08d812aa92fca" + ) def test_fetch_pdf_refuses_legacy_identity_without_bound_sources( diff --git a/tests/test_lint_note.py b/tests/test_lint_note.py index 9028862..e42b55d 100644 --- a/tests/test_lint_note.py +++ b/tests/test_lint_note.py @@ -116,6 +116,15 @@ def _valid_plan_payload() -> dict: } +def _source_manifest_path(tmp_path: Path) -> Path: + path = tmp_path / "source_manifest.json" + path.write_text( + json.dumps({"status": "ok", "source_sha256": "a" * 64}), + encoding="utf-8", + ) + return path + + def test_reference_hygiene_allows_images_doi_arxiv_and_urls() -> None: note = ( _valid_note_text() @@ -1175,6 +1184,8 @@ def test_write_obsidian_note_refuses_failed_plan_gate(tmp_path) -> None: "# Plan Gate Paper", "--lint-json", str(lint_path), + "--source-manifest", + str(_source_manifest_path(tmp_path)), "--vault", str(tmp_path / "vault"), ], @@ -1215,6 +1226,8 @@ def test_write_obsidian_note_reports_lint_warning_details(tmp_path) -> None: "# Style Gate Paper", "--lint-json", str(lint_path), + "--source-manifest", + str(_source_manifest_path(tmp_path)), "--vault", str(tmp_path / "vault"), ], @@ -1336,6 +1349,8 @@ def test_write_obsidian_note_refuses_failed_substantive_gate(tmp_path) -> None: "# Substantive Gate Paper", "--lint-json", str(lint_path), + "--source-manifest", + str(_source_manifest_path(tmp_path)), "--vault", str(tmp_path / "vault"), ], @@ -1433,6 +1448,8 @@ def test_write_obsidian_note_materializes_insert_decision(tmp_path) -> None: str(lint_path), "--figure-decisions", str(decisions_path), + "--source-manifest", + str(_source_manifest_path(tmp_path)), "--vault", str(vault), "--output", @@ -1507,6 +1524,8 @@ def test_write_obsidian_note_rejects_stale_reviewed_insert_bytes(tmp_path) -> No str(lint_path), "--figure-decisions", str(decisions_path), + "--source-manifest", + str(_source_manifest_path(tmp_path)), "--vault", str(vault), ], @@ -1558,6 +1577,8 @@ def test_write_obsidian_note_rejects_unreferenced_insert_decision(tmp_path) -> N str(lint_path), "--figure-decisions", str(decisions_path), + "--source-manifest", + str(_source_manifest_path(tmp_path)), "--vault", str(vault), ], @@ -1609,6 +1630,8 @@ def test_write_obsidian_note_rejects_plain_path_for_insert_decision(tmp_path) -> str(lint_path), "--figure-decisions", str(decisions_path), + "--source-manifest", + str(_source_manifest_path(tmp_path)), "--vault", str(vault), ], @@ -1660,6 +1683,8 @@ def test_write_obsidian_note_rejects_unsafe_insert_filename(tmp_path) -> None: str(lint_path), "--figure-decisions", str(decisions_path), + "--source-manifest", + str(_source_manifest_path(tmp_path)), "--vault", str(vault), ], diff --git a/tests/test_run_pipeline_manifest.py b/tests/test_run_pipeline_manifest.py index e545227..671b5c8 100644 --- a/tests/test_run_pipeline_manifest.py +++ b/tests/test_run_pipeline_manifest.py @@ -18,6 +18,9 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1] RUN_PIPELINE_SCRIPT = PROJECT_ROOT / "skills" / "deeppapernote" / "scripts" / "run_pipeline.py" +EXTRACT_SOURCE_SCRIPT = ( + PROJECT_ROOT / "skills" / "deeppapernote" / "scripts" / "extract_source_text.py" +) WRITE_NOTE_SCRIPT = PROJECT_ROOT / "skills" / "deeppapernote" / "scripts" / "write_obsidian_note.py" @@ -92,6 +95,7 @@ def test_run_pipeline_emits_manifest_raw_decisions_and_lightweight_bundle(tmp_pa assert identity_trace["repair_attempts"] == [] assert source_manifest["coverage"]["text_pages_extracted"] == 4 assert source_manifest["coverage"]["text_truncated"] is False + assert source_manifest["source_sha256"] == hashlib.sha256(pdf_path.read_bytes()).hexdigest() assert source_manifest["identity_contract"]["identity_verdict"] == "accepted" assert any(section["section_id"] == "sec:method" for section in source_manifest["sections"]) assert evidence["summary"]["source_corpus_used"] is True @@ -187,6 +191,50 @@ def test_run_pipeline_emits_manifest_raw_decisions_and_lightweight_bundle(tmp_pa assert not (failure_cwd / "saved").exists() +def test_extract_source_text_rejects_a_mismatched_acquired_pdf_hash(tmp_path: Path) -> None: + pdf_path = tmp_path / "paper.pdf" + fetch_path = tmp_path / "fetch.json" + manifest_path = tmp_path / "paper_source_manifest.json" + write_test_pdf(pdf_path) + fetch_path.write_text( + json.dumps( + { + "status": "ok", + "script": "fetch_pdf.py", + "paper_id": "paper:hash-mismatch", + "title": "Hash Mismatch", + "pdf_path": str(pdf_path), + "source_sha256": "0" * 64, + "identity_contract": { + "artifact_type": "canonical_identity", + "schema_version": 2, + "paper_id": "paper:hash-mismatch", + "identity_verdict": "accepted", + "work_level_identity": {"title": "Hash Mismatch"}, + }, + } + ), + encoding="utf-8", + ) + + result = subprocess.run( + [ + sys.executable, + str(EXTRACT_SOURCE_SCRIPT), + "--input", + str(fetch_path), + "--output", + str(manifest_path), + ], + capture_output=True, + text=True, + ) + + assert result.returncode != 0 + assert "source_sha256 does not match acquired PDF" in result.stderr + assert not manifest_path.exists() + + def test_run_pipeline_does_not_materialize_before_final_save( tmp_path: Path, monkeypatch, diff --git a/tests/test_windows_path_encoding.py b/tests/test_windows_path_encoding.py index fab16aa..cecc342 100644 --- a/tests/test_windows_path_encoding.py +++ b/tests/test_windows_path_encoding.py @@ -12,6 +12,7 @@ import hashlib import json import os +import stat import subprocess import sys from pathlib import Path @@ -21,6 +22,7 @@ import lint_grounding import lint_note import plan_figure_table_decisions +import write_obsidian_note from common import load_json_file, resolve_obsidian_note_path PROJECT_ROOT = Path(__file__).resolve().parents[1] @@ -52,6 +54,15 @@ def _formal_save_artifacts(tmp_path: Path, canonical_note_text: str) -> tuple[Pa return lint_path, decisions_path +def _source_manifest(tmp_path: Path) -> Path: + path = tmp_path / "source-manifest.json" + path.write_text( + json.dumps({"status": "ok", "source_sha256": "a" * 64}), + encoding="utf-8", + ) + return path + + # --------------------------------------------------------------------------- # # BOM handling (utf-8-sig) # # --------------------------------------------------------------------------- # @@ -163,6 +174,30 @@ def test_source_image_filename_handles_backslash_path() -> None: assert plan_figure_table_decisions.source_image_filename(plan_item) == "page_004_fig.png" +def test_windows_sidecar_hidden_attribute_preserves_existing_attributes( + tmp_path: Path, +) -> None: + sidecar = tmp_path / ".deeppapernote.json" + sidecar.write_text("{}", encoding="utf-8") + attributes = {"value": 0x20} + + def get_attributes(_path: str) -> int: + return attributes["value"] + + def set_attributes(_path: str, value: int) -> bool: + attributes["value"] = value + return True + + write_obsidian_note.ensure_sidecar_hidden( + sidecar, + platform="nt", + get_attributes=get_attributes, + set_attributes=set_attributes, + ) + + assert attributes["value"] == 0x20 | 0x2 + + # --------------------------------------------------------------------------- # # End-to-end (subprocess) — the script main() read paths # # --------------------------------------------------------------------------- # @@ -208,6 +243,7 @@ def test_write_obsidian_note_strips_bom_from_saved_note(tmp_path: Path) -> None: "--content-file", str(content_file), "--lint-json", str(lint_path), "--figure-decisions", str(decisions_path), + "--source-manifest", str(_source_manifest(tmp_path)), ], cwd=tmp_path, env=_clean_env(), @@ -221,6 +257,9 @@ def test_write_obsidian_note_strips_bom_from_saved_note(tmp_path: Path) -> None: # frontmatter / the H1 title). assert not saved.startswith(BOM) assert saved.lstrip().startswith("# 标题") + if os.name == "nt": + sidecar = Path(payload["sidecar_path"]) + assert sidecar.stat().st_file_attributes & stat.FILE_ATTRIBUTE_HIDDEN def test_write_obsidian_note_accepts_unchanged_crlf_after_final_lint( @@ -262,6 +301,7 @@ def test_write_obsidian_note_accepts_unchanged_crlf_after_final_lint( "--content-file", str(content_file), "--lint-json", str(lint_output), "--figure-decisions", str(decisions_path), + "--source-manifest", str(_source_manifest(tmp_path)), ], cwd=tmp_path, env=_clean_env(), From 5086117eea1abc66919310bb2d7e9ba9300f59ed Mon Sep 17 00:00:00 2001 From: dingdingcar <1054263492@qq.com> Date: Sat, 29 Aug 2026 15:54:14 +0800 Subject: [PATCH 12/15] docs: simplify bilingual onboarding --- README.md | 85 ++--------------- README.zh-CN.md | 83 ++--------------- assets/hero-academic.svg | 2 +- .../references/user-configuration.md | 13 +++ tests/test_user_configuration.py | 93 ++----------------- 5 files changed, 40 insertions(+), 236 deletions(-) diff --git a/README.md b/README.md index 094fec8..b47de29 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Read one paper deeply. Add one durable page to your academic wiki.

-**Do you know this situation? You sit down to study an important paper, but the exhausting part is not simply reading it. It is turning what you understood into a note you can still use later.** The time usually disappears into work like this: +**You sit down to study an important paper. The hard part is not reading it—it is turning what you understood into a note you can still use later.** The time usually disappears into work like this: - switching between the PDF, Zotero, web pages, and your note app - manually organizing metadata, the abstract, figures, and the method backbone @@ -45,9 +45,9 @@ DeepPaperNote is an agent skill for reading **one paper at a time**. The same co ## 📰 News +- **[2026-08-29]** 🌐 Added complete English note support across note structure, figure callouts, validation, and Formal Save. [What's new](./CHANGELOG.md#unreleased) - **[2026-07-16]** 🧩 Added [`paper-glossary`](./skills/paper-glossary/README.md), an optional companion skill for building reusable Obsidian terminology notes. - **[2026-07-16]** 🔌 DeepPaperNote is now distributed as a plugin for multiple agents, with support for selecting multiple skills from the repository. [PR #12](https://github.com/917Dhj/DeepPaperNote/pull/12) -- **[v2.0.0]** 🚀 Released a deeper evidence-first paper-reading workflow with stronger note planning and figure handling. [Release notes](https://github.com/917Dhj/DeepPaperNote/releases/tag/v2.0.0) News lists only the three most recent user-facing milestones. See the [changelog](./CHANGELOG.md) and [GitHub Releases](https://github.com/917Dhj/DeepPaperNote/releases) for the full history. @@ -78,9 +78,6 @@ Generate a deep-reading note for this paper: ``` -DeepPaperNote supports complete English and Simplified Chinese note schemas. A complete set of inherited `DEEPPAPERNOTE_*` environment variables starts the run without reading `~/.deeppapernote/config.json`; otherwise your Agent uses that file as a fallback and asks only for unresolved fields. -The User Configuration section below covers both save modes, both language profiles, and one-run overrides. Section names, metadata fields, figure callouts, planning guidance, linting, and Formal Save all follow the resolved language. - ## 🎯 Why DeepPaperNote? ![DeepPaperNote usage example](./assets/usage-example.png) @@ -111,82 +108,18 @@ You do not need to install every skill. Choose the ones that match your workflow The canonical execution contract lives in [`skills/deeppapernote/SKILL.md`](./skills/deeppapernote/SKILL.md). -## 🗂️ User Configuration - -DeepPaperNote can run entirely from current-process environment variables. It also stores optional durable device-local preferences in `~/.deeppapernote/config.json` for fallback values and explicit future defaults. - -| Field | Valid values | When required | -| --- | --- | --- | -| `output_language` | `zh-CN` or `en` | Always | -| `save_mode` | `workspace` or `obsidian` | Always | -| `obsidian_vault` | Existing absolute directory | Only for `obsidian` | -| `papers_dir` | Safe relative path inside the Vault | Only for `obsidian` | - -An English workspace configuration needs only the two always-required fields: - -```bash -export DEEPPAPERNOTE_OUTPUT_LANGUAGE=en -export DEEPPAPERNOTE_SAVE_MODE=workspace -``` - -The equivalent optional User Configuration is: - -```json -{ - "output_language": "en", - "save_mode": "workspace" -} -``` - -A Simplified Chinese Obsidian configuration also names the Vault and paper directory: - -```bash -export DEEPPAPERNOTE_OUTPUT_LANGUAGE=zh-CN -export DEEPPAPERNOTE_SAVE_MODE=obsidian -export DEEPPAPERNOTE_OBSIDIAN_VAULT="/absolute/path/to/your/vault" -export DEEPPAPERNOTE_PAPERS_DIR="Research/Papers" -``` - -The equivalent optional User Configuration is: - -```json -{ - "output_language": "zh-CN", - "save_mode": "obsidian", - "obsidian_vault": "/absolute/path/to/your/vault", - "papers_dir": "Research/Papers" -} -``` - -Configuration is resolved before paper identity or other expensive paper work. DeepPaperNote first resolves the explicit request, CLI arguments, and current process environment. If those values form a complete valid configuration, the run proceeds without reading User Configuration. Otherwise the Agent reads `config.json` as fallback and presents one Configuration Prompt Batch only for unresolved fields; an unreadable fallback file fails closed instead of continuing. - -Current-process environment values are first-class Run Overrides and never change the optional file. Shell startup files are consulted only as migration candidates when inherited values are incomplete and `config.json` is absent. Preference Changes preserve unknown JSON fields, and malformed JSON is backed up before confirmed replacement. - -### Run Overrides and Preference Changes - -Runtime values follow this exact precedence: - -`explicit request > CLI > current process environment > User Configuration` - -- “Generate this paper's note in English just this once” is a Run Override and leaves `config.json` byte-for-byte unchanged. -- “Generate this paper's note in Simplified Chinese just this once” is the corresponding `zh-CN` Run Override. -- “Change my default output language to English” is a Preference Change and updates the future default after confirmation. -- CLI options `--language`, `--save-mode`, `--vault`, and `--papers-dir` are Run Overrides. For example: - -```bash -python3 skills/deeppapernote/scripts/run_pipeline.py --input '' --language en --save-mode obsidian --vault /absolute/path/to/vault --papers-dir Research/Papers -``` +## 🗂️ First-Run Preferences -Workspace mode does not use `obsidian_vault` or `papers_dir` for that run, but it preserves those preferences for future Obsidian runs. Run Overrides never become Preference Changes automatically. +The first time you hand a paper to your Agent, DeepPaperNote helps you choose: -### Output Language Profiles and Formal Save +- the default note language: English or Simplified Chinese +- the default save target: the current workspace or your Obsidian Vault -- `zh-CN` uses `核心信息`, `原文摘要翻译`, `创新点`, `一句话总结`, `研究问题`, `数据与任务定义`, `方法主线`, `关键结果`, `深度分析`, `局限`, `我的笔记`, and `引用`, with `机制流程` under the method section. Figure callouts use `建议位置:`, `放置原因:`, and `当前状态:`; a materialized-image caption begins with `论文原图编号:`. -- `en` uses `Core Information`, `Abstract`, `Contributions`, `One-Sentence Summary`, `Research Question`, `Data and Task Definition`, `Method`, `Key Results`, `Deep Analysis`, `Limitations`, `Research Notes`, and `References`, with `Mechanism Flow`. Figure callouts use `Suggested location:`, `Why it matters:`, and `Current status:`; a materialized-image caption begins with `Original paper item:`. +Once confirmed, these preferences are saved on your device and reused for future papers. You can still request a different language or save target for any individual run without changing your defaults. -In either profile, Abstract faithfully renders the source abstract in the requested language; later contribution claims and interpretation stay in later sections. The same canonical Skill and one pipeline carry the chosen language through lint and Formal Save. +DeepPaperNote never silently overwrites an existing note or switches save destinations when a save is blocked. -Formal Save writes the Markdown note and its paper-local `images/` directory to the selected workspace or Obsidian target. In Obsidian mode, DeepPaperNote fingerprints the original PDF, searches the entire Vault before drafting, and keeps language variants such as `.zh-CN.md` and `.en.md` in one source-identified directory. A same-language note is never silently overwritten: the save reports its path and hash and requires explicit confirmation. The directory identity lives in `.deeppapernote.json`; Windows saves also apply the native Hidden attribute. The `images/` directory remains part of the saved paper layout even when no image is inserted, and a blocked save never silently switches modes. +For advanced environment-variable and CLI configuration, see [User Configuration](./skills/deeppapernote/references/user-configuration.md). ## 🔧 Optional Enhancements diff --git a/README.zh-CN.md b/README.zh-CN.md index 4df91e1..0bddff9 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -45,9 +45,9 @@ DeepPaperNote 是一个专注于**一次精读一篇论文**的 Agent Skill。 ## 📰 最新动态 +- **[2026-08-29]** 🌐 新增完整英文笔记支持,从章节结构、图表标注到校验与 Formal Save,全流程保持英文一致。[查看更新](./CHANGELOG.md#unreleased) - **[2026-07-16]** 🧩 新增可选 companion skill [`paper-glossary`](./skills/paper-glossary/README.md),用于构建可复用的 Obsidian 术语笔记。 - **[2026-07-16]** 🔌 DeepPaperNote 现在以支持多个 Agent 的插件形式分发,并支持从仓库中选择多个 skill。[PR #12](https://github.com/917Dhj/DeepPaperNote/pull/12) -- **[v2.0.0]** 🚀 发布更深入的证据优先论文精读流程,并加强笔记规划与图表处理。[版本说明](https://github.com/917Dhj/DeepPaperNote/releases/tag/v2.0.0) 这里只保留最近三条用户可感知的重要动态。完整历史请查看 [CHANGELOG](./CHANGELOG.md) 与 [GitHub Releases](https://github.com/917Dhj/DeepPaperNote/releases)。 @@ -78,9 +78,6 @@ DeepPaperNote 需要 Python 3.10 或更高版本。核心 PDF 抽取路径依赖 把这篇论文整理成 Obsidian 笔记:<论文> ``` -DeepPaperNote 完整支持英文和简体中文笔记结构。如果当前进程继承的 `DEEPPAPERNOTE_*` 环境变量已经完整,运行时不会读取 `~/.deeppapernote/config.json`;否则 Agent 才把该文件作为 fallback,并只询问仍未解析的字段。 -下方“用户配置”统一说明两种保存模式、两套语言 Profile 与单次覆盖方式。章节名称、元数据字段、图表占位、规划规则、校验与 Formal Save 都会遵循解析后的语言。 - ## 🎯 为什么选择 DeepPaperNote? ![DeepPaperNote 使用示例](./assets/usage-example.png) @@ -111,82 +108,18 @@ DeepPaperNote 仍然是唯一主产品。仓库同时提供一个可选 companio 规范执行契约以 [`skills/deeppapernote/SKILL.md`](./skills/deeppapernote/SKILL.md) 为准。 -## 🗂️ 用户配置 - -DeepPaperNote 可以完全使用当前进程环境变量运行;也可以在 `~/.deeppapernote/config.json` 中保存可选的设备本地长期偏好,作为 fallback 和明确的未来默认值。 - -| 字段 | 有效值 | 何时必填 | -| --- | --- | --- | -| `output_language` | `zh-CN` 或 `en` | 始终必填 | -| `save_mode` | `workspace` 或 `obsidian` | 始终必填 | -| `obsidian_vault` | 已存在的绝对目录 | 仅 `obsidian` 模式 | -| `papers_dir` | Vault 内安全的相对路径 | 仅 `obsidian` 模式 | - -英文 workspace 配置只需要两个始终必填的字段: - -```bash -export DEEPPAPERNOTE_OUTPUT_LANGUAGE=en -export DEEPPAPERNOTE_SAVE_MODE=workspace -``` - -等价的可选 User Configuration 是: - -```json -{ - "output_language": "en", - "save_mode": "workspace" -} -``` - -简体中文 Obsidian 配置还要给出 Vault 与论文目录: - -```bash -export DEEPPAPERNOTE_OUTPUT_LANGUAGE=zh-CN -export DEEPPAPERNOTE_SAVE_MODE=obsidian -export DEEPPAPERNOTE_OBSIDIAN_VAULT="/你的/Obsidian/Vault/绝对路径" -export DEEPPAPERNOTE_PAPERS_DIR="Research/Papers" -``` - -等价的可选 User Configuration 是: - -```json -{ - "output_language": "zh-CN", - "save_mode": "obsidian", - "obsidian_vault": "/你的/Obsidian/Vault/绝对路径", - "papers_dir": "Research/Papers" -} -``` - -配置会在论文身份解析等昂贵步骤之前完成。DeepPaperNote 会先解析显式请求、CLI 参数和当前进程环境变量;如果它们已经构成完整合法的配置,就不读取 User Configuration。否则才读取 `config.json` 作为 fallback,并通过一个 Configuration Prompt Batch 只询问仍未解析的字段;fallback 文件不可读时会失败关闭。 - -当前进程环境变量是正式的 Run Override,不会改写可选配置文件。只有当继承的变量不完整且 `config.json` 不存在时,shell 启动文件才会作为迁移候选;确认后才写入长期偏好。Preference Change 会保留未知(unknown)JSON 字段;替换畸形 JSON 前会先备份,并且必须得到确认。 - -### Run Override 与 Preference Change - -每次运行都按以下固定优先级解析: - -`explicit request > CLI > current process environment > User Configuration` - -- “这篇论文仅本次用英文生成”是 Run Override,`config.json` 保持逐字节不变。 -- “这篇论文仅本次用中文生成”是对应的 `zh-CN` Run Override。 -- “以后默认生成英文笔记”是 Preference Change,确认后才修改未来默认值。 -- CLI 选项 `--language`、`--save-mode`、`--vault`、`--papers-dir` 都是 Run Override。例如: - -```bash -python3 skills/deeppapernote/scripts/run_pipeline.py --input '' --language en --save-mode obsidian --vault /absolute/path/to/vault --papers-dir Research/Papers -``` +## 🗂️ 首次使用设置 -workspace 模式本次不会使用 `obsidian_vault` 与 `papers_dir`,但会保留这些偏好,供以后 Obsidian 运行继续使用。Run Override 不会自动变成 Preference Change。 +第一次把论文交给 Agent 时,DeepPaperNote 会引导你选择: -### 输出语言 Profile 与 Formal Save +- 默认使用英文还是简体中文生成笔记 +- 默认保存到当前 workspace 还是 Obsidian Vault -- `zh-CN` 依次使用 `核心信息`、`原文摘要翻译`、`创新点`、`一句话总结`、`研究问题`、`数据与任务定义`、`方法主线`、`关键结果`、`深度分析`、`局限`、`我的笔记`、`引用`;方法部分使用 `机制流程`。图表 callout 使用 `建议位置:`、`放置原因:`、`当前状态:`,真实图片说明以 `论文原图编号:` 开头。 -- `en` 依次使用 `Core Information`、`Abstract`、`Contributions`、`One-Sentence Summary`、`Research Question`、`Data and Task Definition`、`Method`、`Key Results`、`Deep Analysis`、`Limitations`、`Research Notes`、`References`;方法部分使用 `Mechanism Flow`。图表 callout 使用 `Suggested location:`、`Why it matters:`、`Current status:`,真实图片说明以 `Original paper item:` 开头。 +确认后,这些偏好会保存在当前设备上,并自动用于后续论文。你仍然可以为某一次任务临时指定不同的语言或保存位置,而不改变默认偏好。 -两种 Profile 的 Abstract 都必须忠实呈现原论文摘要,并使用请求的输出语言;后续创新点和结果解释留在后续章节。同一个 canonical Skill 与一条流水线(one pipeline)会把选定语言一直绑定到 lint 和 Formal Save。 +DeepPaperNote 不会静默覆盖已有笔记,也不会在保存受阻时擅自切换保存位置。 -Formal Save 会把 Markdown 笔记与 paper-local `images/` 目录写入选定的 workspace 或 Obsidian 目标。在 Obsidian 模式下,DeepPaperNote 会为原始 PDF 计算指纹,并在开始写作前搜索整个 Vault;同一来源的 `.zh-CN.md` 与 `.en.md` 会共用同一个身份目录。已经存在同语言笔记时绝不会静默覆盖:保存程序会返回原笔记路径与哈希,并要求用户明确确认。目录身份保存在 `.deeppapernote.json` 中;Windows 上还会设置原生 Hidden 属性。即使没有插入图片,`images/` 仍属于论文保存布局;保存受阻时也不会静默切换模式。 +环境变量与 CLI 等高级配置请查看[用户配置说明](./skills/deeppapernote/references/user-configuration.md)。 ## 🔧 可选增强 diff --git a/assets/hero-academic.svg b/assets/hero-academic.svg index f0a740a..69ea8b9 100644 --- a/assets/hero-academic.svg +++ b/assets/hero-academic.svg @@ -42,7 +42,7 @@ AGENT SKILL DeepPaperNote - Turn comples papers into notes worth keeping. + Turn complex papers into notes worth keeping. Understand faster. Write less. Keep more. Structured notes for deep reading and real research workflows. diff --git a/skills/deeppapernote/references/user-configuration.md b/skills/deeppapernote/references/user-configuration.md index 081862a..77541c8 100644 --- a/skills/deeppapernote/references/user-configuration.md +++ b/skills/deeppapernote/references/user-configuration.md @@ -37,6 +37,19 @@ Current process environment values are first-class Run Overrides and may satisfy Preference Changes preserve unknown JSON fields and report a warning. Malformed or non-object JSON receives a unique invalid backup before a confirmed replacement. Writes use a same-directory temporary file, atomic replacement, and exact reread comparison. +## Advanced Run Overrides + +Normal Agent use does not require these options. For direct CLI or environment-based runs, use the following mappings: + +| Preference | Current-process environment | CLI option | +|---|---|---| +| `output_language` | `DEEPPAPERNOTE_OUTPUT_LANGUAGE` | `--language` | +| `save_mode` | `DEEPPAPERNOTE_SAVE_MODE` | `--save-mode` | +| `obsidian_vault` | `DEEPPAPERNOTE_OBSIDIAN_VAULT` | `--vault` | +| `papers_dir` | `DEEPPAPERNOTE_PAPERS_DIR` | `--papers-dir` | + +These values are Run Overrides for the current process or command. They do not become saved preferences unless the user separately confirms a Preference Change. + ## Completion criteria Configuration is ready only when every active field is present and valid, the resolved values contain no missing or invalid field, and the workflow has not begun identity resolution. A User Configuration file is not required when Run Overrides already meet that condition. Obsidian mode requires an existing absolute Vault and a traversal-safe relative paper directory. Workspace mode requires neither Obsidian field and cannot be redirected by stale values. diff --git a/tests/test_user_configuration.py b/tests/test_user_configuration.py index dcc5bcb..c7d9835 100644 --- a/tests/test_user_configuration.py +++ b/tests/test_user_configuration.py @@ -379,90 +379,6 @@ def test_confirmed_migration_becomes_the_only_durable_source(tmp_path: Path) -> assert result["migration_candidates"] == {} -def test_public_and_agent_contracts_share_user_configuration_source_of_truth() -> None: - readme = (PROJECT_ROOT / "README.md").read_text(encoding="utf-8") - readme_zh = (PROJECT_ROOT / "README.zh-CN.md").read_text(encoding="utf-8") - skill = (PROJECT_ROOT / "skills/deeppapernote/SKILL.md").read_text(encoding="utf-8") - contract = ( - PROJECT_ROOT / "skills/deeppapernote/references/user-configuration.md" - ).read_text(encoding="utf-8") - - for public_doc in (readme, readme_zh): - assert "~/.deeppapernote/config.json" in public_doc - for name in user_configuration.ENV_FIELDS.values(): - assert name in public_doc - assert "references/user-configuration.md" in skill - assert skill.index("Resolve Run Overrides") < skill.index("inspect User Configuration") - assert skill.index("inspect User Configuration") < skill.index("resolve the paper identity") - assert "explicit request > CLI > current process environment > User Configuration" in contract - assert "without reading User Configuration" in contract - assert "scripts/user_configuration.py" in contract - - -def test_public_onboarding_covers_bilingual_configuration_and_run_overrides() -> None: - public_docs = { - "en": (PROJECT_ROOT / "README.md").read_text(encoding="utf-8"), - "zh-CN": (PROJECT_ROOT / "README.zh-CN.md").read_text(encoding="utf-8"), - } - - for text in public_docs.values(): - for field in user_configuration.KNOWN_FIELDS: - assert f"`{field}`" in text - values = ( - *sorted(user_configuration.OUTPUT_LANGUAGES), - *sorted(user_configuration.SAVE_MODES), - ) - for value in values: - assert f"`{value}`" in text - for option in ("--language", "--save-mode", "--vault", "--papers-dir"): - assert f"`{option}`" in text - assert '"output_language": "zh-CN"' in text - assert '"output_language": "en"' in text - assert ( - "explicit request > CLI > current process environment > User Configuration" - in text - ) - assert "Configuration Prompt Batch" in text - assert "Run Override" in text - assert "Preference Change" in text - assert "Formal Save" in text - assert "`images/`" in text - assert "unknown" in text.lower() - for name in user_configuration.ENV_FIELDS.values(): - assert name in text - - field_values = { - "output_language": set(user_configuration.OUTPUT_LANGUAGES), - "save_mode": set(user_configuration.SAVE_MODES), - } - for field, expected in field_values.items(): - row = re.search(rf"\| `{field}` \| ([^|]+) \|", text) - assert row is not None - assert set(re.findall(r"`([^`]+)`", row.group(1))) == expected - - profile = text.split("### Output Language Profiles and Formal Save", 1)[-1] - if profile == text: - profile = text.split("### 输出语言 Profile 与 Formal Save", 1)[-1] - assert profile != text - for language in SUPPORTED_OUTPUT_LANGUAGES: - schema = note_schema(language) - section_positions = [ - profile.index(f"`{section}`") - for section in schema["sections"].values() - ] - assert section_positions == sorted(section_positions) - for label in schema["figure_labels"].values(): - assert f"`{label}`" in profile - assert f"`{schema['mechanism_flow']}`" in profile - - assert "Generate this paper's note in English just this once" in public_docs["en"] - assert "Generate this paper's note in Simplified Chinese just this once" in public_docs["en"] - assert "Change my default output language to English" in public_docs["en"] - assert "这篇论文仅本次用英文生成" in public_docs["zh-CN"] - assert "这篇论文仅本次用中文生成" in public_docs["zh-CN"] - assert "以后默认生成英文笔记" in public_docs["zh-CN"] - - def test_canonical_configuration_reference_matches_machine_contract() -> None: skill = (PROJECT_ROOT / "skills/deeppapernote/SKILL.md").read_text( encoding="utf-8" @@ -479,6 +395,10 @@ def test_canonical_configuration_reference_matches_machine_contract() -> None: ) for value in values: assert f"`{value}`" in contract + for name in user_configuration.ENV_FIELDS.values(): + assert f"`{name}`" in contract + for option in ("--language", "--save-mode", "--vault", "--papers-dir"): + assert f"`{option}`" in contract for state in ("ready", "needs_input", "invalid", "blocked"): assert f"`{state}`" in contract assert ( @@ -491,6 +411,11 @@ def test_canonical_configuration_reference_matches_machine_contract() -> None: assert "one canonical Skill" in skill assert "one pipeline" in skill assert "paper-local `images/`" in skill + assert "references/user-configuration.md" in skill + assert skill.index("Resolve Run Overrides") < skill.index("inspect User Configuration") + assert skill.index("inspect User Configuration") < skill.index("resolve the paper identity") + assert "without reading User Configuration" in contract + assert "scripts/user_configuration.py" in contract def test_output_language_reference_matches_both_machine_schemas() -> None: From b130723d2d52daa32a9b8a0dce2c8b50c6e981a5 Mon Sep 17 00:00:00 2001 From: dingdingcar <1054263492@qq.com> Date: Sat, 29 Aug 2026 22:17:17 +0800 Subject: [PATCH 13/15] docs: define final user report contract --- skills/deeppapernote/SKILL.md | 11 +++++++++++ tests/test_contracts_consistency.py | 18 ++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/skills/deeppapernote/SKILL.md b/skills/deeppapernote/SKILL.md index 11072f1..3e1bb2a 100644 --- a/skills/deeppapernote/SKILL.md +++ b/skills/deeppapernote/SKILL.md @@ -114,6 +114,17 @@ Completion-language rule: - if the workflow stopped early, name the current stage and the still-missing required stages instead of using completion language - lint is a floor, not the writing objective +Final user report: +- Keep the completion wording defined above. After a successful Formal Save, report in the user's conversation language. +- Lead with the final note link or path, save mode, and actual saved domain. Read the domain from the final note path under the configured papers root in Obsidian mode or output root in workspace mode; when Save Target Admission reused an existing directory, report that directory's existing domain. +- Then report, in order: + 1. paper title and strongest verified identifier + 2. Grounding Lint, Final Note Lint, Final Quality Review, and Final Readability Review results, plus the warning count + 3. materialized and retained-placeholder figure/table counts + 4. whether the saved note SHA-256 matches the Final Note Lint `note_sha256` +- Add overwrite actions, preference changes, or user-relevant warnings only when they occurred. +- Keep the report to these fields and derive every claim from current-run artifacts. + ## Core Execution Contract `SKILL.md` plus the generated `synthesis_bundle.json` must be enough to complete a normal note-generation run. diff --git a/tests/test_contracts_consistency.py b/tests/test_contracts_consistency.py index a0a87a9..576a2bd 100644 --- a/tests/test_contracts_consistency.py +++ b/tests/test_contracts_consistency.py @@ -101,6 +101,24 @@ def test_topic_references_do_not_redefine_canonical_workflow() -> None: assert "three-stage model-first pipeline" not in evidence_first +def test_skill_owns_one_final_user_report_contract() -> None: + skill = (SKILL_ROOT / "SKILL.md").read_text(encoding="utf-8") + + assert skill.count("Final user report:") == 1 + for required in ( + "user's conversation language", + "actual saved domain", + "report that directory's existing domain", + "materialized and retained-placeholder figure/table counts", + "Final Note Lint `note_sha256`", + "derive every claim from current-run artifacts", + ): + assert required in skill + + for reference in (SKILL_ROOT / "references").glob("*.md"): + assert "Final user report:" not in reference.read_text(encoding="utf-8") + + def test_skill_owns_one_fail_closed_output_language_contract() -> None: skill = (SKILL_ROOT / "SKILL.md").read_text(encoding="utf-8") output_reference = (SKILL_ROOT / "references" / "output-language.md").read_text( From f9c249ee31364c290a2e9aca408ef277f03647a1 Mon Sep 17 00:00:00 2001 From: dingdingcar <1054263492@qq.com> Date: Sun, 30 Aug 2026 02:06:56 +0800 Subject: [PATCH 14/15] release: prepare v2.3.0 --- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- CHANGELOG.md | 11 +++++++++++ README.md | 2 +- README.zh-CN.md | 2 +- pyproject.toml | 2 +- 6 files changed, 16 insertions(+), 5 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 702d6df..bcf648c 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "deeppapernote", "description": "Academic paper reading and Obsidian note generation skills for building a research WIKI.", - "version": "2.2.0", + "version": "2.3.0", "author": { "name": "917Dhj" }, diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 6081f92..01860fa 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "deeppapernote", - "version": "2.2.0", + "version": "2.3.0", "description": "Academic paper reading and Obsidian note generation skills for building a research WIKI.", "author": { "name": "917Dhj", diff --git a/CHANGELOG.md b/CHANGELOG.md index 39f7122..56cb7a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ Add an entry here when the project meaningfully changes for users, for example: ## Unreleased +## v2.3.0 + ### Added - Added end-to-end English output through User Configuration or `--language en`, including localized note schemas, paper-type planning contracts, figure callouts, grounding, final-note linting, and Formal Save validation. @@ -22,6 +24,15 @@ Add an entry here when the project meaningfully changes for users, for example: - Complete valid Run Overrides now satisfy Configuration Readiness without reading `~/.deeppapernote/config.json`; incomplete overrides continue through the existing User Configuration fallback and validation flow. - Obsidian notes now use language-specific filenames, and Windows sidecars receive the native Hidden file attribute in addition to their dot-prefixed name. +- Successful runs now report the saved note path and domain, validation results, figure and table counts, warning count, and final note integrity check. + +### Fixed + +- Formal Save now preserves LF line endings on Windows instead of applying platform-specific newline conversion. + +### Contributors + +- Added configurable English note output from [PR #26](https://github.com/917Dhj/DeepPaperNote/pull/26) by [@basbun](https://github.com/basbun). ## v2.2.0 diff --git a/README.md b/README.md index b47de29..3ceea15 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ DeepPaperNote is an agent skill for reading **one paper at a time**. The same co ## 📰 News -- **[2026-08-29]** 🌐 Added complete English note support across note structure, figure callouts, validation, and Formal Save. [What's new](./CHANGELOG.md#unreleased) +- **[v2.3.0]** 🌐 Added complete English note support across note structure, figure callouts, validation, and Formal Save. [Release notes](https://github.com/917Dhj/DeepPaperNote/releases/tag/v2.3.0) - **[2026-07-16]** 🧩 Added [`paper-glossary`](./skills/paper-glossary/README.md), an optional companion skill for building reusable Obsidian terminology notes. - **[2026-07-16]** 🔌 DeepPaperNote is now distributed as a plugin for multiple agents, with support for selecting multiple skills from the repository. [PR #12](https://github.com/917Dhj/DeepPaperNote/pull/12) diff --git a/README.zh-CN.md b/README.zh-CN.md index 0bddff9..264e720 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -45,7 +45,7 @@ DeepPaperNote 是一个专注于**一次精读一篇论文**的 Agent Skill。 ## 📰 最新动态 -- **[2026-08-29]** 🌐 新增完整英文笔记支持,从章节结构、图表标注到校验与 Formal Save,全流程保持英文一致。[查看更新](./CHANGELOG.md#unreleased) +- **[v2.3.0]** 🌐 新增完整英文笔记支持,从章节结构、图表标注到校验与 Formal Save,全流程保持英文一致。[版本说明](https://github.com/917Dhj/DeepPaperNote/releases/tag/v2.3.0) - **[2026-07-16]** 🧩 新增可选 companion skill [`paper-glossary`](./skills/paper-glossary/README.md),用于构建可复用的 Obsidian 术语笔记。 - **[2026-07-16]** 🔌 DeepPaperNote 现在以支持多个 Agent 的插件形式分发,并支持从仓库中选择多个 skill。[PR #12](https://github.com/917Dhj/DeepPaperNote/pull/12) diff --git a/pyproject.toml b/pyproject.toml index 05d615d..59700c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "deeppapernote" -version = "2.2.0" +version = "2.3.0" description = "An agent-neutral skill for turning one research paper into a high-quality Obsidian note." readme = "README.md" requires-python = ">=3.10" From cc99b24e3041fad1e82f3dd2ebcbe3092485e62c Mon Sep 17 00:00:00 2001 From: dingdingcar <1054263492@qq.com> Date: Sun, 30 Aug 2026 02:12:44 +0800 Subject: [PATCH 15/15] test: update hidden sidecar atomically --- tests/test_cli_fallback.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_cli_fallback.py b/tests/test_cli_fallback.py index 830e8f3..0720b72 100644 --- a/tests/test_cli_fallback.py +++ b/tests/test_cli_fallback.py @@ -835,7 +835,9 @@ def test_save_target_preflight_honors_recorded_language_note_filename( sidecar_path = renamed_path.parent / ".deeppapernote.json" sidecar = json.loads(sidecar_path.read_text(encoding="utf-8")) sidecar["notes"]["zh-CN"]["filename"] = renamed_path.name - sidecar_path.write_text(json.dumps(sidecar), encoding="utf-8") + updated_sidecar_path = sidecar_path.with_name(f"{sidecar_path.name}.tmp") + updated_sidecar_path.write_text(json.dumps(sidecar), encoding="utf-8") + os.replace(updated_sidecar_path, sidecar_path) preflight = subprocess.run( [