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] 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"]