
Paper Spine
- 860 installs
- 4.6k repo stars
- Updated July 28, 2026
- wubing2023/paperspine
Helps with ai & agent building tasks.
About
paper-spine is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- paper-spine
- AI & Agent Building
- AI-coding skill
Paper Spine by the numbers
- 860 all-time installs (skills.sh)
- +69 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #1,273 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/wubing2023/paperspine --skill paper-spineAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 860 |
|---|---|
| repo stars | ★ 4.6k |
| Last updated | July 28, 2026 |
| Repository | wubing2023/paperspine ↗ |
What it does
Helps with ai & agent building tasks.
Files
PaperSpine Orchestrator
Use this skill as the suite entrypoint. It is the main orchestrator: it routes the user to UI, intake, research, citation, rewrite, build, LaTeX, translate, humanize, audit, and update branch skills.
Update detection: If the user asks to update, upgrade, check for updates, or manage the PaperSpine installation, route immediately to paper-spine-update without starting the writing workflow.
Operating Principle
PaperSpine is a research-writing workflow, not a prose patcher. Its job is to learn the target scene and strong examples first, force a user-confirmed motivation, design the paper row by row, and only then write or rebuild the manuscript.
Never fabricate data, metrics, p-values, datasets, citations, figures, or experimental claims. User materials are authoritative for this paper's results. External examples teach structure and rhetoric only.
Required Configuration
Prefer reading paper_rewriting_output/paper_spine_config.json. If it is missing, route to paper-spine-intake or ask the same fields directly.
Required fields:
| Field | Allowed Values |
|---|---|
workflow | rewrite_existing, build_from_materials |
scene | journal, conference, report_review, competition |
tier | flash, pro |
output_language | en, zh |
target_name | free text |
materials_dir | path or empty |
draft_path | path or empty |
user_motivation | free text or empty |
official_urls | list |
special_requirements | list |
word_output | none, docx |
translation_package | none, zh |
reference_mode | local_first, specified_paths, web |
reference_paths | list of local reference folders/files; default ["."] |
citation_target_count | integer; default 20 |
Non-Negotiable Route
1. If configuration is missing or incomplete, run the terminal wizard from paper-spine-ui, then paper-spine-intake; do not ask the user to hand-write JSON or answer a long plain chat checklist when a terminal is available. The wizard is the supported Claude Code/Codex command-line UI. In Claude Code, /paperspine is the preferred entry: it launches the external intake window automatically when configuration is missing.
Codex hard constraint (do not skip): when paper_rewriting_output/paper_spine_config.json is missing, the FIRST tool action after reading this skill MUST be to run the absolute launch_paperspine_ui.ps1 (resolve under ~/.codex/skills/... or ~/.claude/skills/...) with sandbox_permissions: require_escalated so the window can open. Do NOT inspect materials, infer defaults, create config files, or continue the workflow before the UI has launched or explicitly failed. Only fall back to numbered/chat intake if the launch returns an error. 2. Always create or verify source_map.md. 3. Always use paper-spine-research before choosing the final motivation. Research must first index local/default references according to reference_mode and reference_paths; web collection supplements this index but does not replace it. 4. Research must create reference_materials/, research_dossier.md, exemplar_learning_dossier.md, style_profile.md, sota_gap_map.md, and motivation_options_after_research.md. 5. Use paper-spine-citation to create citation_support_bank.md. This bank is separate from exemplar learning: it supports Introduction, Related Work, Discussion, background, limitation, and application claims. Generate at least citation_target_count * 3 candidates; default target is 20, so the default candidate pool is 60. About 80% should be recent, using current_year - 3 as the simple threshold. 6. Stop for user confirmation of the controlling motivation. Do not write or rewrite until confirmed_motivation.md records the user's chosen motivation. The final motivation should be concise and specific. Do not inflate one narrow contribution into a multi-claim motivation. 7. If workflow is rewrite_existing, use paper-spine-rewrite. 8. If workflow is build_from_materials, use paper-spine-build. 9. Before drafting, both workflows must create section_blueprints.md and writing_rationale_matrix.md. The matrix is the execution plan, not a post-hoc summary. 10. Run the integrity audit: python scripts/integrity_audit.py paper_rewriting_output --markdown --write. This produces integrity_audit.md — a teaching report where every finding includes root cause, fix action, downstream impact, and a teaching note. The report must show no BLOCKED findings before LaTeX compilation can proceed. 11. Use paper-spine-latex for final LaTeX structure, figure placement, citation safety, and compile-oriented cleanup. 12. Always produce final LaTeX source. Compile PDF when a TeX engine is available. Markdown alone is not a final PaperSpine output. 13. If word_output is docx, produce and check a Word version. 14. If output_language is en and translation_package is zh, use paper-spine-translate to produce the complete translation_zh/ package. Run python scripts/translate_guard.py paper_rewriting_output --markdown --write and require PASS. The translation package must cover every required intermediate and final artifact with row-by-row translation of large tabular files. Summaries are not acceptable. 15. Use paper-spine-audit before declaring the work complete.
If another skill is unavailable, follow the referenced workflow locally and produce the same artifacts.
Standard Artifacts
Write workflow artifacts under paper_rewriting_output/ unless the user asks otherwise.
Common required artifacts:
paper_spine_config.jsonpaper_spine_config.mdsource_map.mdreference_materials/source_index.mdresearch_dossier.mdexemplar_learning_dossier.mdstyle_profile.mdsota_gap_map.mdmotivation_options_after_research.mdcitation_support_bank.mdconfirmed_motivation.mdsection_blueprints.mdwriting_rationale_matrix.md
Rewrite existing:
original_logic_map.mdevidence_bank.mdrewrite_matrix.mdlogic_transfer_audit.md- revised manuscript
Build from materials:
source_inventory.mdevidence_bank.mdfigure_asset_map.mdclaim_register.md- manuscript draft as an intermediate artifact
Final artifacts:
latex_report.mdfinal_artifact_manifest.mdfinal_paper/main.texfinal_paper/paper.pdfwhen a TeX compiler is availablefinal_paper/paper.docxandword_report.mdwhen Word output is requestedtranslation_zh/when English output requests a Chinese translation package
Writing Rationale Matrix Requirement
writing_rationale_matrix.md must be created before final writing in both rewrite_existing and build_from_materials. It must be a Markdown table used as the execution plan:
| Row ID | Manuscript Unit | Current/Planned Function | Motivation Link | Reference/SOTA Pattern Learned | Target Scene or Venue Norm | User Evidence or Citation Anchor | Planned Change | Final Text Check |
|---|
The first data row must justify the whole-work framework, structure, or main throughline in depth: why this controlling structure is chosen, how SOTA/target examples informed it, how it follows the confirmed motivation, which user evidence anchors it, and how the final manuscript will be checked against it. Subsequent rows must follow the target document in order and split it into the smallest useful writing units: paragraph-level moves, paragraph groups, model steps, assumptions, result/claim units, review synthesis units, competition solution blocks, headings, captions, and other argument-bearing fragments.
This is flexible by scene. A journal paper may naturally use abstract, introduction, methods, results, and discussion units. A competition paper may use problem restatement, assumptions, model construction, solving process, validation, sensitivity, strengths/weaknesses, and recommendations. A report or review may use executive summary, background, taxonomy, comparison, synthesis, and recommendation units. Do not force all tasks into a fixed IMRaD template.
Each row must explain concrete anchors across multiple dimensions: it advances or narrows the confirmed motivation, transfers a structural pattern learned from SOTA/example work, matches a target-scene norm, uses a user-provided evidence item, creates a front/back echo, fixes an original logic failure, and/or constrains a claim to available evidence. For important rows, write enough reasoning that the user can learn why this writing move is better.
A shallow matrix is a failure. If most rows say only "improve clarity" or "polish wording", stop and redo the research/blueprint stage.
Branch Map
Read references/orchestrator-branch-map.md when the workflow needs to be debugged or when a branch output fails audit. The rule is simple: route back to the branch that owns the weak artifact instead of patching the final paper directly.
Command-Line UI
Claude Code and Codex do not guarantee a native graphical picker for skills. The supported UI is the bundled terminal wizard. When configuration is missing, run paper-spine-intake. In Claude Code, /paperspine must launch the intake UI automatically; do not ask the user to call a separate UI command. The launcher opens the bundled terminal TUI, which supports Up/Down for option values, Left/Right for fields, Enter for edit or confirm, and S to save. Claude Code does not currently provide third-party skills with an API for embedding a custom keyboard UI directly inside the chat input box, so the real terminal TUI is the supported interactive path. Use native structured questions only when the host exposes them reliably in the current session. Use chat fallback only when terminal execution is impossible.
interface:
display_name: "PaperSpine"
short_description: "Orchestrate the PaperSpine workflow"
brand_color: "#2563EB"
default_prompt: "Use $paper-spine to configure, research, rewrite or build, integrate LaTeX, and audit my paper/report."
policy:
allow_implicit_invocation: true
Interactive Intake
Use this reference when PaperSpine needs workflow configuration.
Question Order
Ask these fields in order:
1. Workflow: rewrite_existing or build_from_materials. 2. Scene: journal, conference, report_review, or competition. 3. Tier: flash or pro. 4. Output language: en or zh. 5. Target name: journal, conference, course, report type, or competition name. 6. Draft path for rewrite_existing, or materials directory for build_from_materials. 7. User motivation, if known. 8. Official URLs, if known. 9. Special requirements. 10. Optional Word output: none or docx. 11. Optional translated artifact package for English output: none or zh.
Supported Command-Line UI
Claude Code and Codex do not guarantee a native graphical picker for skills. The supported PaperSpine UI is the bundled terminal wizard. In Claude Code, use the /paperspine command when installed, or launch the PowerShell wrapper so the wizard runs in a real interactive terminal window:
# Pass the ABSOLUTE path to the installed launcher. Codex/Claude run from the
# user's project folder, where `scripts/` does not exist, so a relative path is
# the most common reason the UI window never opens. Resolve the install dir:
$launcher = @(
"$env:USERPROFILE\.codex\skills\paper-spine-ui\scripts\launch_paperspine_ui.ps1",
"$env:USERPROFILE\.claude\skills\paper-spine-ui\scripts\launch_paperspine_ui.ps1",
"$env:USERPROFILE\.codex\skills\paper-spine-intake\scripts\launch_paperspine_ui.ps1",
"$env:USERPROFILE\.claude\skills\paper-spine-intake\scripts\launch_paperspine_ui.ps1"
) | Where-Object { Test-Path $_ } | Select-Object -First 1
powershell.exe -NoProfile -ExecutionPolicy Bypass -File $launcher -OutputDir paper_rewriting_outputFor a user-run terminal, the direct wizard command is:
WIZARD="$HOME/.codex/skills/paper-spine-ui/scripts/intake_wizard.py"
[ -f "$WIZARD" ] || WIZARD="$HOME/.claude/skills/paper-spine-ui/scripts/intake_wizard.py"
python "$WIZARD" --output-dir paper_rewriting_outputDo not run the direct Python wizard inside a hidden agent Bash/tool execution surface, because stdin may not be connected and the command can hang. The wizard shows numbered menus, option descriptions, free-text fields, a final review screen, and an edit loop before writing config files.
For first-time setup or changing interface language:
WIZARD="$HOME/.codex/skills/paper-spine-ui/scripts/intake_wizard.py"
[ -f "$WIZARD" ] || WIZARD="$HOME/.claude/skills/paper-spine-ui/scripts/intake_wizard.py"
python "$WIZARD" --setup-global --output-dir paper_rewriting_outputUse native structured questions only when the host exposes them reliably in the current session. The result must still be written to the same JSON/Markdown config files.
Last-Resort Chat Fallback
Use this only when neither terminal execution nor native structured questions are available. Ask the user to paste this:
workflow:
scene:
tier:
output_language:
target_name:
draft_path:
materials_dir:
user_motivation:
official_urls:
special_requirements:
word_output:
translation_package:The JSON is the source of truth for later skills.
Orchestrator Branch Map
PaperSpine is a main-orchestrator workflow plus branch skills. The orchestrator does not perform every operation itself; it calls branch skills in a fixed order, verifies their artifacts, and routes back when a required artifact is weak or missing.
Branch Order
1. paper-spine-ui: open a real terminal UI window when configuration is missing or incomplete. 2. paper-spine-intake: write paper_spine_config.json and paper_spine_config.md. 3. paper-spine-research: ingest local references, collect official requirements, learn target examples/SOTA, and propose motivation options. 4. paper-spine-citation: build citation_support_bank.md for literature statements in Introduction/Discussion/background. 5. User confirmation: write confirmed_motivation.md only after the user chooses or revises the motivation. 6. paper-spine-rewrite or paper-spine-build: produce logic maps, evidence banks, section blueprints, and writing_rationale_matrix.md. 7. paper-spine-latex: assemble final LaTeX, optional PDF, and optional Word. 8. paper-spine-audit: check artifacts, rationale depth, citation bank, unsupported claims, translation coverage, LaTeX, and Word output.
Loop Rule
If a branch output fails audit, route back to that branch. Do not patch the final paper directly when the missing artifact should have been created earlier.
Examples:
- Missing local source paths: return to
paper-spine-research. - Too few citation candidates: return to
paper-spine-citation. - Shallow writing matrix: return to
paper-spine-rewriteor
paper-spine-build.
- Broken labels/citations in LaTeX: return to
paper-spine-latex.
Suite Map
PaperSpine is split into task-focused skills:
| Skill | Responsibility |
|---|---|
paper-spine | route the workflow |
paper-spine-ui | launch external terminal configuration UI |
paper-spine-intake | collect configuration |
paper-spine-research | index local references, research target scene, and learn examples |
paper-spine-citation | build citation support candidates |
paper-spine-rewrite | rewrite an existing draft |
paper-spine-build | build from a materials folder |
paper-spine-latex | assemble and guard LaTeX |
paper-spine-translate | produce complete translation_zh/ with row-by-row translation |
paper-spine-humanize | reduce AI detection patterns via tiered stylistic constraints |
paper-spine-audit | check completeness, integrity audit, structured review, and translation coverage |
paper-spine-update | check and update local PaperSpine installs |
Use the orchestrator for end-to-end tasks. Use a child skill directly when the user asks for that stage only.
Translation Package Coverage
Use this reference when output_language is en and translation_package is zh.
The translation package is a complete Chinese reading aid for the whole workflow. It must not replace the authoritative English manuscript.
Required Folder
paper_rewriting_output/translation_zh/
manifest.md
translation_coverage.md
paper_spine_config.zh.md
source_map.zh.md
reference_materials/source_index.zh.md
research_dossier.zh.md
exemplar_learning_dossier.zh.md
style_profile.zh.md
sota_gap_map.zh.md
motivation_options_after_research.zh.md
confirmed_motivation.zh.md
section_blueprints.zh.md
writing_rationale_matrix.zh.md
final_structure.zh.md
final_paper.zh.md
full_paper_translation.zh.md
latex_report.zh.md
final_artifact_manifest.zh.md
artifact_check.zh.mdRewrite workflow also requires:
original_logic_map.zh.mdrewrite_matrix.zh.mdlogic_transfer_audit.zh.md
Build workflow also requires:
source_inventory.zh.mdevidence_bank.zh.mdfigure_asset_map.zh.mdclaim_register.zh.md
Full Paper Translation
full_paper_translation.zh.md is mandatory. It must translate the final paper's reader-facing text completely:
- title and subtitle,
- abstract,
- every section and subsection body paragraph,
- figure captions,
- table captions and table notes,
- limitations, conclusion, acknowledgements when present,
- appendix narrative text when present.
Preserve citation keys, LaTeX labels, equations, file paths, raw numeric values, URLs, dataset identifiers, and code identifiers. Translate the surrounding prose and explain technical terms consistently.
Translation Rules
Translate explanations, section logic, evidence summaries, rationale, and final reader-facing structure. Do not stop after translating only the config or a few intermediate files.
Large intermediate artifacts must be translated completely, not summarized. This especially applies to:
writing_rationale_matrix.mdsection_blueprints.mdresearch_dossier.mdexemplar_learning_dossier.mdsota_gap_map.mdoriginal_logic_map.mdrewrite_matrix.mdsource_inventory.mdevidence_bank.mdclaim_register.md
For Markdown tables, preserve the table row structure. Translate every row and every explanatory cell. For writing_rationale_matrix.zh.md, the translated table must contain at least the same number of rationale rows as writing_rationale_matrix.md; a shortened summary table is a failed output.
translation_coverage.md must list every required English artifact and every final-paper textual unit with its Chinese counterpart and status translated, not applicable, or missing. Partial translation is a failed audit.
Writing Rationale Matrix
Use this reference in both rewrite_existing and build_from_materials.
The matrix is the core writing method. It must be written before final prose and then used as the checklist for drafting/revision. It is not a post-hoc change log and it is not a fixed IMRaD template.
Principle
Create one row for each smallest useful writing unit that needs a deliberate choice. The unit size depends on the task:
- journal or conference paper: framework, title/abstract units, Introduction
moves, method-design explanations, result evidence units, figure/table claims, discussion moves, captions, and claim-bearing headings;
- report or review: executive summary units, problem/background units, taxonomy
or comparison units, evidence synthesis units, recommendation units, limitation/future-work units, and important headings/figures/tables;
- competition paper/report: problem restatement, assumptions, notation, model
design, solution procedure, validation, sensitivity/robustness, strengths and weaknesses, recommendations, and visual/table claims.
Do not force every task into Abstract/Introduction/Methods/Results/Discussion. Do not use only one row per top-level section. Split the manuscript into paragraph-sized or claim-sized units whenever the writing decision changes.
Required Table
| Row ID | Manuscript Unit | Current Problem or Planned Function | Motivation Link | Reference/SOTA Pattern Learned | Target Scene or Venue Norm | User Evidence or Citation Anchor | Planned Change/Text Move | Final Text Check |
|---|
Required Coverage
- The first data row must justify the whole-work framework, structure, or main
throughline.
- Every subsequent row should correspond to a real writing unit in manuscript
order.
- Use task-specific units rather than a fixed template. For example, a
competition solution may need rows for assumptions, symbols, model derivation, algorithm steps, sensitivity analysis, and recommendations; a review may need rows for search scope, inclusion logic, taxonomy, comparison dimensions, and synthesis claims.
- Include titles, subsection headings, figure captions, and table captions when
they carry argument logic.
- If one paragraph has two separate functions, split it into two rows. If three
short paragraphs share one function, one row may cover that paragraph group.
Reason Quality Bar
Every row must contain concrete reasoning, not generic polishing language. It should explain how the unit uses all major anchors whenever possible:
- advances or narrows the confirmed motivation,
- transfers a structural move learned from SOTA/example work without copying
wording or results,
- matches a target journal/conference/report/competition norm,
- uses a specific user evidence item, figure, table, result, or citation anchor,
- moves evidence next to the claim it supports,
- creates a front/back echo with an earlier or later unit,
- fixes an original logic failure,
- prevents overclaiming beyond available evidence.
Generic reasons such as "improve clarity", "make academic", "polish wording", or "add detail" do not pass.
Minimum depth requirement:
- Use at least 8 rows for ordinary manuscripts/reports. Longer manuscripts
should have many more rows. A complex paper often needs 20-60 rows.
- The first row is the whole-work framework row. It must deeply explain the
controlling structure: why this structure is chosen, how it comes from the confirmed motivation, what SOTA/example pattern it transfers, how it matches the target scene, which user evidence anchors it, and how later sections will be checked against it.
- Each non-trivial row must contain enough detail to teach the writing decision,
not only name the section. Include motivation, learned reference/SOTA pattern, target-scene norm, evidence/citation anchor, planned text move, and final check. If any of these is not applicable, state why.
- Do not compress a large decision into one phrase such as "conservation-first
abstract" or "add ablation section". Explain why that move changes the paper's logic and how the final text will prove it.
Rewrite And Build Discipline
The final manuscript should be drafted row by row. For rewrite tasks, each row should be traceable to the original logic map and/or a deliberate structural change. For build-from-materials tasks, each row should be traceable to the source inventory, evidence bank, claim register, or figure asset map.
After drafting, update the Final Text Check column with the final location or a short pass/fail note.
Detail Standard
A row should be detailed enough that the user can learn the writing logic from it. Do not write rows like "graphic path fix" or "rewrite abstract" as the main explanation. For every non-trivial row, include:
- what the original or planned unit is trying to achieve,
- why the current version is logically weak or why this unit is needed,
- how the confirmed motivation controls this unit,
- which SOTA/example/target-scene pattern is being transferred,
- which user evidence, figure, table, result, or citation anchor supports it,
- what exact rhetorical move will be made,
- how the final text should be checked.
For important units, prefer 2-4 sentences inside cells rather than keywords. The matrix should be useful as a learning document, not only an audit checklist.
Deep Framework Row Example
The whole-work row should look more like a design memo than a TODO item:
| Row ID | Manuscript Unit | Current Problem or Planned Function | Motivation Link | Reference/SOTA Pattern Learned | Target Scene or Venue Norm | User Evidence or Citation Anchor | Planned Change/Text Move | Final Text Check |
|---|---|---|---|---|---|---|---|---|
| F1 | Whole-work framework | Decide the paper's controlling argument before editing any paragraph. The current draft may contain multiple valid technical points, but the paper needs one dominant contribution and a sequence of evidence that makes that contribution inevitable. | The confirmed motivation becomes the spine: the paper first exposes the missing signal or unresolved evaluation problem, then shows why the proposed design is needed, then lets results test that promise. Secondary engineering choices are positioned as enabling mechanisms, not competing innovations. | Learn from SOTA and target examples at the level of structure: how they stage gap, design rationale, evidence order, failure modes, and bounded discussion. Do not copy wording; transfer the move that makes the reader accept why this paper had to be written. | The target scene expects a recognizable argument architecture. A journal paper may need IMRaD and independent evaluation; a competition report may need assumptions, model logic, validation, sensitivity, and recommendations; a course report may need rubric-visible evidence. | Anchor the framework to the user's actual draft/materials: source_map.md, evidence_bank.md, figures/tables, citations, experiment settings, claim register, and any special requirements. External papers only teach writing logic. | Reframe the manuscript so every major section answers one part of the same question. Move background, method rationale, results interpretation, captions, and discussion claims until the evidence sequence is visible. | Pass only if a reader can summarize the paper's problem, gap, design, evidence, limitation, and implication from the ordered section openings and captions without seeing unsupported claims. |
#!/usr/bin/env python3
"""Shared utilities for PaperSpine scripts — standard library only."""
from __future__ import annotations
import re
from dataclasses import dataclass
from difflib import SequenceMatcher
from pathlib import Path
# — file reading ————————————————————————————————————————————————————————————
def read_text(path: Path) -> str:
for encoding in ("utf-8", "utf-8-sig", "gb18030", "latin-1"):
try:
return path.read_text(encoding=encoding)
except UnicodeDecodeError:
continue
return path.read_text(errors="replace")
# — LaTeX / Markdown normalization ————————————————————————————————————————————
def strip_tex_comments(text: str) -> str:
lines = []
for line in text.splitlines():
cut = None
for match in re.finditer(r"(?<!\\)%", line):
cut = match.start()
break
lines.append(line if cut is None else line[:cut])
return "\n".join(lines)
def normalize_tex(text: str) -> str:
text = strip_tex_comments(text)
text = re.sub(r"\\(section|subsection|subsubsection)\*?\{([^{}]*)\}", r"\n\n\2\n\n", text)
text = re.sub(r"\\(caption|label|includegraphics)(?:\[[^\]]*\])?\{[^{}]*\}", " ", text)
text = re.sub(r"\\(cite\w*|ref|autoref|cref|Cref|eqref)\*?(?:\[[^\]]*\]){0,2}\{([^{}]*)\}", " [REF] ", text)
text = re.sub(r"\\begin\{(figure|figure\*|table|table\*|equation|align|align\*)\}.*?\\end\{\1\}", " ", text, flags=re.DOTALL)
text = re.sub(r"\\[a-zA-Z]+\*?(?:\[[^\]]*\])?", " ", text)
text = re.sub(r"[{}]", " ", text)
return text
def normalize_markdown(text: str) -> str:
text = re.sub(r"```.*?```", " ", text, flags=re.DOTALL)
text = re.sub(r"`[^`]*`", " ", text)
text = re.sub(r"!\[[^\]]*\]\([^)]+\)", " ", text)
text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text)
return text
# — paragraph splitting ———————————————————————————————————————————————————————
def split_paragraphs(text: str) -> list[str]:
raw_parts = re.split(r"\n\s*\n+", text)
paragraphs: list[str] = []
for part in raw_parts:
cleaned = re.sub(r"\s+", " ", part).strip()
word_count = len(re.findall(r"[A-Za-z]+|\d+(?:\.\d+)?", cleaned))
if word_count >= 8:
paragraphs.append(cleaned)
return paragraphs
# — text canonicalization and similarity —————————————————————————————————————
@dataclass
class CanonParagraph:
text: str
canonical_text: str
tokens: set[str]
def canonical(paragraph: str) -> str:
paragraph = paragraph.lower()
paragraph = re.sub(r"\[[^\]]+\]", " ", paragraph)
paragraph = re.sub(r"[^a-z0-9]+", " ", paragraph)
return re.sub(r"\s+", " ", paragraph).strip()
def make_canon(paragraph: str) -> CanonParagraph:
text = canonical(paragraph)
return CanonParagraph(text=paragraph, canonical_text=text, tokens=set(text.split()))
def similarity_canon(a: CanonParagraph, b: CanonParagraph) -> float:
ca = a.canonical_text
cb = b.canonical_text
if not ca or not cb:
return 0.0
seq_score = SequenceMatcher(None, ca, cb).ratio()
jaccard = len(a.tokens & b.tokens) / len(a.tokens | b.tokens) if a.tokens or b.tokens else 0.0
return round((seq_score * 0.65) + (jaccard * 0.35), 4)
def preview(text: str, limit: int = 180) -> str:
text = re.sub(r"\s+", " ", text).strip()
return text if len(text) <= limit else text[: limit - 3] + "..."
# — Markdown table parsing ————————————————————————————————————————————————————
def split_table_line(line: str) -> list[str]:
return [cell.strip() for cell in line.strip().strip("|").split("|")]
def is_separator_row(cells: list[str]) -> bool:
if not cells:
return False
return all(cell and set(cell) <= {"-", ":", " "} for cell in cells)
def table_rows(text: str) -> tuple[list[str], list[list[str]]]:
"""Return (header, data_rows) for the first Markdown table in *text*."""
rows: list[list[str]] = []
for raw in text.splitlines():
line = raw.strip()
if not (line.startswith("|") and line.endswith("|")):
continue
cells = split_table_line(line)
if is_separator_row(cells):
continue
rows.append(cells)
if not rows:
return [], []
return rows[0], rows[1:]
def markdown_tables(text: str) -> list[list[list[str]]]:
"""Return all Markdown tables as list of (header + data) row lists."""
tables: list[list[list[str]]] = []
current: list[list[str]] = []
for raw in text.splitlines():
line = raw.strip()
if line.startswith("|") and line.endswith("|"):
cells = split_table_line(line)
if is_separator_row(cells):
continue
current.append(cells)
else:
if current:
tables.append(current)
current = []
if current:
tables.append(current)
return tables
def year_from_row(row: list[str]) -> int | None:
joined = " ".join(row)
years = [int(value) for value in re.findall(r"\b(19\d{2}|20\d{2})\b", joined)]
return max(years) if years else None
#!/usr/bin/env python3
"""Check whether required PaperSpine workflow artifacts exist and are usable."""
from __future__ import annotations
import argparse
import json
import re
import shutil
from dataclasses import dataclass
from pathlib import Path
from _paper_spine_utils import (
markdown_tables,
year_from_row,
)
WORKFLOWS = ("rewrite_existing", "build_from_materials")
TIERS = ("flash", "pro")
PDF_POLICIES = ("auto", "always", "never")
WORD_POLICIES = ("auto", "always", "never")
COMMON = (
"paper_spine_config.json",
"paper_spine_config.md",
"source_map.md",
"reference_materials/source_index.md",
"research_dossier.md",
"exemplar_learning_dossier.md",
"style_profile.md",
"sota_gap_map.md",
"motivation_options_after_research.md",
"citation_support_bank.md",
"confirmed_motivation.md",
"section_blueprints.md",
"writing_rationale_matrix.md",
)
REWRITE = (
"original_logic_map.md",
"evidence_bank.md",
"rewrite_matrix.md",
"logic_transfer_audit.md",
)
BUILD = (
"source_inventory.md",
"evidence_bank.md",
"figure_asset_map.md",
"claim_register.md",
)
FINAL_LATEX = (
"latex_report.md",
"final_artifact_manifest.md",
"final_paper/main.tex",
)
FINAL_PDF = (
"final_paper/paper.pdf",
)
FINAL_WORD = (
"final_paper/paper.docx",
"word_report.md",
)
TRANSLATION_COMMON = (
"translation_zh/manifest.md",
"translation_zh/translation_coverage.md",
"translation_zh/paper_spine_config.zh.md",
"translation_zh/source_map.zh.md",
"translation_zh/reference_materials/source_index.zh.md",
"translation_zh/research_dossier.zh.md",
"translation_zh/exemplar_learning_dossier.zh.md",
"translation_zh/style_profile.zh.md",
"translation_zh/sota_gap_map.zh.md",
"translation_zh/motivation_options_after_research.zh.md",
"translation_zh/citation_support_bank.zh.md",
"translation_zh/confirmed_motivation.zh.md",
"translation_zh/section_blueprints.zh.md",
"translation_zh/writing_rationale_matrix.zh.md",
"translation_zh/final_structure.zh.md",
"translation_zh/final_paper.zh.md",
"translation_zh/full_paper_translation.zh.md",
"translation_zh/latex_report.zh.md",
"translation_zh/final_artifact_manifest.zh.md",
"translation_zh/artifact_check.zh.md",
)
TRANSLATION_REWRITE = (
"translation_zh/original_logic_map.zh.md",
"translation_zh/rewrite_matrix.zh.md",
"translation_zh/logic_transfer_audit.zh.md",
)
TRANSLATION_BUILD = (
"translation_zh/source_inventory.zh.md",
"translation_zh/evidence_bank.zh.md",
"translation_zh/figure_asset_map.zh.md",
"translation_zh/claim_register.zh.md",
)
GENERIC_CELL_VALUES = {
"",
"-",
"--",
"n/a",
"na",
"none",
"todo",
"tbd",
"x",
"improve clarity",
"make academic",
"polish wording",
"add detail",
"提升清晰度",
"学术化",
"润色",
"补充细节",
"待定",
"无",
}
BAD_GENERIC_PHRASES = (
"improve clarity",
"make academic",
"polish wording",
"add detail",
"提升清晰度",
"学术化",
"润色",
"补充细节",
)
FRAMEWORK_TERMS = (
"framework",
"whole-paper",
"overall",
"structure",
"spine",
"throughline",
"architecture",
"框架",
"整体",
"全文",
"全局",
"结构",
"主线",
)
RATIONALE_MIN_ROWS = 8
RATIONALE_MIN_CHARS = 320
FRAMEWORK_MIN_CHARS = 500
TRANSLATION_MIN_RATIO = 0.30
CITATION_BANK_MULTIPLIER = 3
CITATION_BANK_RECENT_RATIO = 0.80
CURRENT_YEAR = 2026
TRANSLATION_SOURCE_BY_TARGET = {
"translation_zh/paper_spine_config.zh.md": "paper_spine_config.md",
"translation_zh/source_map.zh.md": "source_map.md",
"translation_zh/reference_materials/source_index.zh.md": "reference_materials/source_index.md",
"translation_zh/research_dossier.zh.md": "research_dossier.md",
"translation_zh/exemplar_learning_dossier.zh.md": "exemplar_learning_dossier.md",
"translation_zh/style_profile.zh.md": "style_profile.md",
"translation_zh/sota_gap_map.zh.md": "sota_gap_map.md",
"translation_zh/motivation_options_after_research.zh.md": "motivation_options_after_research.md",
"translation_zh/citation_support_bank.zh.md": "citation_support_bank.md",
"translation_zh/confirmed_motivation.zh.md": "confirmed_motivation.md",
"translation_zh/section_blueprints.zh.md": "section_blueprints.md",
"translation_zh/writing_rationale_matrix.zh.md": "writing_rationale_matrix.md",
"translation_zh/final_structure.zh.md": "final_structure.md",
"translation_zh/final_paper.zh.md": "final_paper.md",
"translation_zh/full_paper_translation.zh.md": "final_paper/main.tex",
"translation_zh/latex_report.zh.md": "latex_report.md",
"translation_zh/final_artifact_manifest.zh.md": "final_artifact_manifest.md",
"translation_zh/artifact_check.zh.md": "artifact_check.md",
"translation_zh/original_logic_map.zh.md": "original_logic_map.md",
"translation_zh/rewrite_matrix.zh.md": "rewrite_matrix.md",
"translation_zh/logic_transfer_audit.zh.md": "logic_transfer_audit.md",
"translation_zh/source_inventory.zh.md": "source_inventory.md",
"translation_zh/evidence_bank.zh.md": "evidence_bank.md",
"translation_zh/figure_asset_map.zh.md": "figure_asset_map.md",
"translation_zh/claim_register.zh.md": "claim_register.md",
}
RATIONALE_ANCHOR_CATEGORIES = {
"motivation": ("motivation", "spine", "throughline", "动机", "主线", "贡献"),
"reference": ("reference", "sota", "example", "pattern", "paper", "literature", "参考", "样例", "论文", "文献"),
"target": ("target", "scene", "venue", "journal", "conference", "competition", "rubric", "norm", "目标", "场景", "期刊", "会议", "比赛", "评分", "规范"),
"evidence": ("evidence", "figure", "table", "result", "citation", "data", "source", "claim", "证据", "图", "表", "结果", "引用", "数据", "素材", "主张"),
"text_move": ("reframe", "rewrite", "move", "place", "sequence", "contrast", "echo", "narrow", "写作", "重构", "改写", "前后呼应", "收束", "对照"),
}
@dataclass
class CheckResult:
output_dir: str
workflow: str
tier: str
pdf_policy: str
tex_engine: str
word_policy: str
translation_required: bool
required: list[str]
missing: list[str]
content_issues: list[str]
@property
def ok(self) -> bool:
return not self.missing and not self.content_issues
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Check PaperSpine artifacts.")
parser.add_argument("output_dir", nargs="?", default="paper_rewriting_output")
parser.add_argument("--workflow", choices=WORKFLOWS)
parser.add_argument("--tier", choices=TIERS)
parser.add_argument(
"--pdf-policy",
choices=PDF_POLICIES,
default="auto",
help="Require final_paper/paper.pdf: auto when a TeX engine exists, always, or never.",
)
parser.add_argument(
"--word-policy",
choices=WORD_POLICIES,
default="auto",
help="Require Word artifacts: auto when requested/present, always, or never.",
)
parser.add_argument("--markdown", action="store_true")
parser.add_argument("--json", action="store_true")
parser.add_argument(
"--write",
action="store_true",
help="Write artifact_check.md into the output directory.",
)
return parser.parse_args()
def read_config(output_dir: Path) -> dict[str, object]:
config_path = output_dir / "paper_spine_config.json"
if not config_path.exists():
return {}
return json.loads(config_path.read_text(encoding="utf-8"))
def detect_tex_engine() -> str:
for name in ("latexmk", "xelatex", "pdflatex"):
path = shutil.which(name)
if path:
return name
return ""
def config_requests_word(config: dict[str, object]) -> bool:
value = config.get("word_output")
return value is True or str(value).lower() in {"docx", "word", "true", "yes"}
def config_requests_translation(config: dict[str, object]) -> bool:
language = str(config.get("output_language") or "").lower()
package = str(config.get("translation_package") or "").lower()
return language == "en" and package == "zh"
def required_artifacts(
workflow: str,
output_dir: Path,
config: dict[str, object],
pdf_policy: str,
word_policy: str,
tex_engine: str,
) -> tuple[list[str], bool]:
items = list(COMMON)
if workflow == "build_from_materials":
items.extend(BUILD)
else:
items.extend(REWRITE)
items.extend(FINAL_LATEX)
if pdf_policy == "always" or (pdf_policy == "auto" and tex_engine):
items.extend(FINAL_PDF)
word_exists = (output_dir / "final_paper" / "paper.docx").exists()
if word_policy == "always" or (
word_policy == "auto" and (word_exists or config_requests_word(config))
):
items.extend(FINAL_WORD)
translation_required = config_requests_translation(config)
if translation_required:
items.extend(TRANSLATION_COMMON)
if workflow == "build_from_materials":
items.extend(TRANSLATION_BUILD)
else:
items.extend(TRANSLATION_REWRITE)
return items, translation_required
def normalize(text: str) -> str:
return " ".join(text.strip().lower().split())
def is_generic_cell(text: str) -> bool:
return normalize(text).strip(" .。::") in GENERIC_CELL_VALUES
def header_has(header: list[str], alternatives: tuple[str, ...]) -> bool:
joined = " ".join(normalize(cell) for cell in header)
return any(term in joined for term in alternatives)
def column_index(header: list[str], alternatives: tuple[str, ...]) -> int | None:
for index, cell in enumerate(header):
normalized = normalize(cell)
if any(term in normalized for term in alternatives):
return index
return None
def find_rationale_table(text: str) -> tuple[list[str], list[list[str]]] | None:
for table in markdown_tables(text):
if not table:
continue
header = table[0]
if header_has(header, ("manuscript", "unit", "writing", "section", "单元", "段落", "章节")) and header_has(
header, ("motivation", "动机")
):
return header, table[1:]
return None
def validate_writing_rationale_matrix(output_dir: Path) -> list[str]:
path = output_dir / "writing_rationale_matrix.md"
if not path.exists():
return []
issues: list[str] = []
text = path.read_text(encoding="utf-8", errors="ignore")
table = find_rationale_table(text)
if table is None:
return [
"writing_rationale_matrix.md must contain a Markdown table with manuscript/writing units and motivation links."
]
header, rows = table
required_columns = {
"manuscript unit": ("manuscript", "unit", "writing", "section", "单元", "段落", "章节"),
"motivation link": ("motivation", "动机"),
"reference or SOTA pattern": ("reference", "sota", "example", "样例", "参考", "文献", "优秀"),
"target scene or venue norm": ("target", "scene", "venue", "norm", "目标", "场景", "期刊", "会议", "比赛", "规范"),
"evidence or citation anchor": ("evidence", "citation", "anchor", "证据", "引用", "材料", "数据"),
"planned change or text move": ("planned", "change", "move", "function", "plan", "修改", "计划", "写法", "功能"),
"final text check": ("final", "check", "最终", "检查", "落点"),
}
column_map: dict[str, int] = {}
for label, alternatives in required_columns.items():
index = column_index(header, alternatives)
if index is None:
issues.append(f"writing_rationale_matrix.md is missing a `{label}` column.")
else:
column_map[label] = index
if len(rows) < RATIONALE_MIN_ROWS:
issues.append(
f"writing_rationale_matrix.md has fewer than {RATIONALE_MIN_ROWS} rationale rows; split the manuscript/report into finer task-specific writing units."
)
if rows:
first_row = " ".join(rows[0]).lower()
if not any(term in first_row for term in FRAMEWORK_TERMS):
issues.append(
"writing_rationale_matrix.md first data row must justify the overall framework, structure, or throughline."
)
checked_rows = 0
for row_number, row in enumerate(rows, start=1):
if len(row) < len(header):
row = row + [""] * (len(header) - len(row))
combined = " ".join(row)
lowered = normalize(combined)
if not lowered:
continue
checked_rows += 1
rationale_cells: list[str] = []
for label in (
"motivation link",
"reference or SOTA pattern",
"target scene or venue norm",
"evidence or citation anchor",
"planned change or text move",
"final text check",
):
index = column_map.get(label)
if index is None or index >= len(row):
continue
value = row[index]
rationale_cells.append(value)
if is_generic_cell(value):
issues.append(f"writing_rationale_matrix.md row {row_number} has a generic or empty `{label}` cell.")
rationale_text = " ".join(rationale_cells).strip()
if len(rationale_text) < RATIONALE_MIN_CHARS:
issues.append(
f"writing_rationale_matrix.md row {row_number} rationale is too thin; explain the writing decision with motivation, reference/SOTA, target-scene, evidence, and text-move anchors."
)
if row_number == 1 and len(rationale_text) < FRAMEWORK_MIN_CHARS:
issues.append(
"writing_rationale_matrix.md first data row is too shallow; the whole-work framework row must deeply justify the controlling structure before section-level writing."
)
category_hits = sum(
1 for terms in RATIONALE_ANCHOR_CATEGORIES.values() if any(term in lowered for term in terms)
)
required_hits = 5 if row_number == 1 else 4
if category_hits < required_hits:
issues.append(
f"writing_rationale_matrix.md row {row_number} lacks enough concrete anchors; include motivation, learned reference/SOTA pattern, target-scene norm, evidence/citation, and the planned text move."
)
if any(phrase in lowered for phrase in BAD_GENERIC_PHRASES) and not any(
token in lowered for token in ("because", "动机", "evidence", "sota", "reference", "目标", "证据", "引用")
):
issues.append(
f"writing_rationale_matrix.md row {row_number} uses generic polishing language without a concrete anchor."
)
if len(issues) >= 25:
issues.append("writing_rationale_matrix.md has additional issues not shown; fix the matrix and rerun the check.")
break
if checked_rows == 0:
issues.append("writing_rationale_matrix.md has no usable rationale rows.")
return issues
def table_row_count(text: str) -> int:
table = find_rationale_table(text)
if table is None:
return 0
_, rows = table
return len([row for row in rows if any(cell.strip() for cell in row)])
def find_citation_table(text: str) -> tuple[list[str], list[list[str]]] | None:
for table in markdown_tables(text):
if not table:
continue
header = table[0]
joined = " ".join(normalize(cell) for cell in header)
has_reference = any(term in joined for term in ("citation", "reference", "bibtex"))
if has_reference and "claim" in joined and "sentence" in joined:
return header, table[1:]
return None
def validate_citation_support_bank(output_dir: Path, config: dict[str, object]) -> list[str]:
path = output_dir / "citation_support_bank.md"
if not path.exists():
return []
issues: list[str] = []
try:
target_count = max(1, int(config.get("citation_target_count") or 20))
except (TypeError, ValueError):
target_count = 20
required_candidates = target_count * CITATION_BANK_MULTIPLIER
required_recent = int(required_candidates * CITATION_BANK_RECENT_RATIO + 0.999)
recent_threshold = CURRENT_YEAR - 3
text = path.read_text(encoding="utf-8", errors="ignore")
table = find_citation_table(text)
if table is None:
return [
"citation_support_bank.md must contain a Markdown table with reference/citation, claim, and sentence columns."
]
_, rows = table
rows = [row for row in rows if any(cell.strip() for cell in row)]
if len(rows) < required_candidates:
issues.append(
f"citation_support_bank.md has fewer than {required_candidates} candidates; create 3x the target citation count before selecting final citations."
)
recent_rows = [row for row in rows if (year_from_row(row) or 0) >= recent_threshold]
if len(recent_rows) < required_recent:
issues.append(
f"citation_support_bank.md should keep about 80% recent candidates since {recent_threshold}; found {len(recent_rows)} of required {required_recent}."
)
weak_rows = []
for index, row in enumerate(rows[:required_candidates], start=1):
joined = " ".join(row)
has_reference = any(token in joined for token in ("@", "doi", "DOI", "http", "arXiv", "Journal", "Proceedings"))
has_sentence = len(joined) >= 80 and bool(re.search(r"[.!?。!?]", joined))
if not has_reference or not has_sentence:
weak_rows.append(index)
if weak_rows:
issues.append(
"citation_support_bank.md rows must pair each paper with one or two usable support sentences; weak rows include "
+ ", ".join(str(row) for row in weak_rows[:8])
+ "."
)
return issues
def validate_translation_package(output_dir: Path, required: list[str]) -> list[str]:
issues: list[str] = []
coverage_path = output_dir / "translation_zh" / "translation_coverage.md"
coverage_text = ""
if coverage_path.exists():
coverage_text = coverage_path.read_text(encoding="utf-8", errors="ignore")
coverage_lower = coverage_text.lower()
if any(status in coverage_lower for status in ("missing", "partial", "not translated", "未翻译", "缺失", "部分")):
issues.append("translation_zh/translation_coverage.md reports missing or partial translation.")
required_translation_targets = [name for name in required if name.startswith("translation_zh/")]
for target_name in required_translation_targets:
source_name = TRANSLATION_SOURCE_BY_TARGET.get(target_name)
if coverage_text and target_name not in coverage_text and Path(target_name).name not in coverage_text:
issues.append(f"translation_zh/translation_coverage.md does not mention `{target_name}`.")
if not source_name:
continue
source_path = output_dir / source_name
target_path = output_dir / target_name
if not source_path.exists() or not target_path.exists():
continue
source_text = source_path.read_text(encoding="utf-8", errors="ignore")
target_text = target_path.read_text(encoding="utf-8", errors="ignore")
if len(source_text.strip()) >= 300:
minimum = max(120, int(len(source_text.strip()) * TRANSLATION_MIN_RATIO))
if len(target_text.strip()) < minimum:
issues.append(
f"{target_name} appears too short for a complete translation of {source_name}; translate the full artifact, not only a summary."
)
source_matrix = output_dir / "writing_rationale_matrix.md"
target_matrix = output_dir / "translation_zh" / "writing_rationale_matrix.zh.md"
if source_matrix.exists() and target_matrix.exists():
source_rows = table_row_count(source_matrix.read_text(encoding="utf-8", errors="ignore"))
target_rows = table_row_count(target_matrix.read_text(encoding="utf-8", errors="ignore"))
if source_rows and target_rows < source_rows:
issues.append(
"translation_zh/writing_rationale_matrix.zh.md must preserve and translate every row from writing_rationale_matrix.md."
)
return issues
def validate_content(output_dir: Path, required: list[str], translation_required: bool, config: dict[str, object]) -> list[str]:
issues: list[str] = []
issues.extend(validate_writing_rationale_matrix(output_dir))
issues.extend(validate_citation_support_bank(output_dir, config))
if translation_required:
issues.extend(validate_translation_package(output_dir, required))
return issues
def check(
output_dir: Path,
workflow: str,
tier: str,
config: dict[str, object],
pdf_policy: str,
word_policy: str,
) -> CheckResult:
tex_engine = detect_tex_engine()
required, translation_required = required_artifacts(
workflow, output_dir, config, pdf_policy, word_policy, tex_engine
)
missing = [name for name in required if not (output_dir / name).exists()]
content_issues = validate_content(output_dir, required, translation_required, config)
return CheckResult(
output_dir=str(output_dir),
workflow=workflow,
tier=tier,
pdf_policy=pdf_policy,
tex_engine=tex_engine or "not found",
word_policy=word_policy,
translation_required=translation_required,
required=required,
missing=missing,
content_issues=content_issues,
)
def to_markdown(result: CheckResult) -> str:
lines = [
"# PaperSpine Artifact Check",
"",
f"- Output directory: `{result.output_dir}`",
f"- Workflow: `{result.workflow}`",
f"- Tier: `{result.tier}`",
f"- PDF policy: `{result.pdf_policy}`",
f"- TeX engine: `{result.tex_engine}`",
f"- Word policy: `{result.word_policy}`",
f"- Translation package required: {'yes' if result.translation_required else 'no'}",
f"- Status: {'PASS' if result.ok else 'FAIL'}",
"",
"## Missing",
"",
]
if result.missing:
lines.extend(f"- `{name}`" for name in result.missing)
else:
lines.append("- None")
lines.extend(["", "## Content Issues", ""])
if result.content_issues:
lines.extend(f"- {issue}" for issue in result.content_issues)
else:
lines.append("- None")
lines.append("")
return "\n".join(lines)
def main() -> int:
args = parse_args()
output_dir = Path(args.output_dir)
config = read_config(output_dir)
workflow = args.workflow or str(config.get("workflow") or "rewrite_existing")
tier = args.tier or str(config.get("tier") or "flash")
result = check(
output_dir,
workflow,
tier,
config,
args.pdf_policy,
args.word_policy,
)
markdown = to_markdown(result)
if args.write:
output_dir.mkdir(parents=True, exist_ok=True)
(output_dir / "artifact_check.md").write_text(markdown, encoding="utf-8")
if args.json:
print(json.dumps(result.__dict__, ensure_ascii=False, indent=2))
if args.markdown or not args.json:
print(markdown)
return 0 if result.ok else 1
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
"""Verify Chinese-language citations for authenticity and completeness.
Checks citation format, DOI resolveability, and structural integrity for
中文参考文献 (Chinese-language references). Produces a structured report
flagging SUSPICIOUS / INCOMPLETE / FAKE citations.
"""
from __future__ import annotations
import argparse
import json
import re
import time
from dataclasses import dataclass, field
from pathlib import Path
from urllib.request import Request, urlopen
from _paper_spine_utils import table_rows
USER_AGENT = "PaperSpine/3.0 (citation-zh; https://github.com/WUBING2023/PaperSpine)"
DOI_RE = re.compile(r"(?:doi\s*[:=]\s*|https?://doi\.org/)?(10\.\d{4,}/[^\s,;)]+)", re.IGNORECASE)
# Chinese citation format patterns
CN_AUTHOR_RE = re.compile(r"[^\x00-\x7f]{2,4}(?:[,,、\s]+[^\x00-\x7f]{2,4})*") # Chinese author names
CN_JOURNAL_RE = re.compile(r"《([^》]+)》") # 《期刊名》
CN_YEAR_RE = re.compile(r"(\d{4})[年]?")
CN_VOLUME_RE = re.compile(r"(\d+)\s*[卷\((]")
CN_PAGES_RE = re.compile(r"(\d+)[-~]\s*(\d+)")
@dataclass
class CitationCheckZH:
candidate_id: str
reference_text: str
status: str # VERIFIED / SUSPICIOUS / INCOMPLETE / FAKE
has_author: bool = False
has_title: bool = False
has_journal: bool = False
has_year: bool = False
has_doi: bool = False
doi_resolves: bool = False
issues: list[str] = field(default_factory=list)
@dataclass
class CitationVerificationZHResult:
path: str
total: int = 0
verified: int = 0
suspicious: int = 0
incomplete: int = 0
fake: int = 0
checks: list[CitationCheckZH] = field(default_factory=list)
@property
def ok(self) -> bool:
return self.fake == 0 and self.suspicious == 0
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Verify Chinese citations for PaperSpine.")
parser.add_argument("output_dir", nargs="?", default="paper_rewriting_output")
parser.add_argument("--json", action="store_true")
parser.add_argument("--markdown", action="store_true")
parser.add_argument("--write", action="store_true")
return parser.parse_args()
def check_zh_format(ref: str) -> dict:
return {
"has_author": bool(CN_AUTHOR_RE.search(ref)),
"has_journal": bool(CN_JOURNAL_RE.search(ref)),
"has_year": bool(CN_YEAR_RE.search(ref)),
}
def has_doi(ref: str) -> str:
m = DOI_RE.search(ref)
return m.group(1) if m else ""
def verify_doi(doi: str) -> bool:
try:
req = Request(f"https://api.crossref.org/works/{doi}", headers={"User-Agent": USER_AGENT})
with urlopen(req, timeout=15) as resp:
return resp.status == 200
except Exception:
return False
def check_citation_bank_zh(out_dir: Path) -> CitationVerificationZHResult:
bank_path = out_dir / "citation_support_bank.md"
if not bank_path.exists():
return CitationVerificationZHResult(str(bank_path))
text = bank_path.read_text(encoding="utf-8", errors="ignore")
_, rows = table_rows(text)
if not rows:
return CitationVerificationZHResult(str(bank_path))
result = CitationVerificationZHResult(str(bank_path), total=len(rows))
for row in rows:
joined = " ".join(row)
candidate_id = row[0] if len(row) > 0 else "?"
ref = row[1] if len(row) > 1 else joined
check = CitationCheckZH(candidate_id=candidate_id, reference_text=ref[:120])
fmt = check_zh_format(ref)
check.has_author = fmt["has_author"]
check.has_journal = fmt["has_journal"]
check.has_year = fmt["has_year"]
# Check for obvious fabrications
if len(ref.strip()) < 20:
check.status = "FAKE"
check.issues.append("Citation text too short — likely fabricated")
elif not check.has_author and not check.has_journal:
check.status = "FAKE"
check.issues.append("No author or journal found — likely fabricated")
elif not check.has_author or not check.has_journal or not check.has_year:
check.status = "INCOMPLETE"
missing = []
if not check.has_author: missing.append("author")
if not check.has_journal: missing.append("journal")
if not check.has_year: missing.append("year")
check.issues.append(f"Missing: {', '.join(missing)}")
else:
doi = has_doi(ref)
check.has_doi = bool(doi)
if doi:
time.sleep(0.3)
check.doi_resolves = verify_doi(doi)
if not check.doi_resolves:
check.status = "SUSPICIOUS"
check.issues.append(f"DOI {doi[:30]} does not resolve")
else:
check.status = "VERIFIED"
else:
check.status = "SUSPICIOUS"
check.issues.append("No DOI — cannot verify authenticity. Add DOI or verify manually")
if check.status == "VERIFIED": result.verified += 1
elif check.status == "SUSPICIOUS": result.suspicious += 1
elif check.status == "INCOMPLETE": result.incomplete += 1
elif check.status == "FAKE": result.fake += 1
result.checks.append(check)
return result
def to_markdown(result: CitationVerificationZHResult) -> str:
lines = [
"# Chinese Citation Verification Report",
"",
f"- Total citations: {result.total}",
f"- Verified: {result.verified}",
f"- Suspicious: {result.suspicious}",
f"- Incomplete: {result.incomplete}",
f"- Likely fake: {result.fake}",
f"- Status: {'PASS' if result.ok else 'FAIL'}",
"",
"## Details",
"",
"| ID | Reference | Status | Issues |",
"|---|---|---|---|",
]
for c in result.checks:
lines.append(f"| {c.candidate_id} | {c.reference_text[:60]} | {c.status} | {'; '.join(c.issues[:2])} |")
lines.append("")
return "\n".join(lines)
def main() -> int:
args = parse_args()
out_dir = Path(args.output_dir)
result = check_citation_bank_zh(out_dir)
if args.json:
print(json.dumps({"ok": result.ok, "total": result.total, "verified": result.verified, "suspicious": result.suspicious, "incomplete": result.incomplete, "fake": result.fake}, ensure_ascii=False, indent=2))
if args.markdown or not args.json:
print(to_markdown(result))
if args.write:
(out_dir / "citation_verification_zh.md").write_text(to_markdown(result), encoding="utf-8")
return 0 if result.ok else 1
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
"""Validate PaperSpine humanize_matrix.md and scan for remaining AI patterns.
Self-contained — standard library only, no dependencies on other PaperSpine
modules. Can be distributed standalone with paper-spine-humanize skill.
"""
from __future__ import annotations
import argparse
import json
import re
import statistics
import sys
from dataclasses import dataclass, field
from pathlib import Path
# --- self-contained table helpers (no _paper_spine_utils import) ---
def _split_table_line(line: str) -> list[str]:
return [c.strip() for c in line.strip().strip("|").split("|")]
def _is_sep(cells: list[str]) -> bool:
return bool(cells) and all(c and set(c) <= {"-", ":", " "} for c in cells)
def _table_rows(text: str) -> tuple[list[str], list[list[str]]]:
rows: list[list[str]] = []
for raw in text.splitlines():
line = raw.strip()
if not (line.startswith("|") and line.endswith("|")):
continue
cells = _split_table_line(line)
if _is_sep(cells):
continue
rows.append(cells)
return (rows[0], rows[1:]) if rows else ([], [])
def _split_paragraphs(text: str) -> list[str]:
parts = re.split(r"\n\s*\n+", text)
return [re.sub(r"\s+", " ", p).strip() for p in parts if len(p.strip()) > MIN_PARAGRAPH_CHARS]
# --- AI pattern detection ---
AI_CONNECTORS_ZH = [
"首先", "其次", "再次", "最后", "综上所述", "总而言之", "总的来说",
"此外", "另外", "不仅如此", "值得注意的是", "需要指出的是", "不容忽视的是",
"具有重要意义", "具有重要的理论意义", "具有重要的现实意义",
"为……奠定基础", "在……的过程中",
]
AI_CONNECTORS_EN = [
"firstly", "secondly", "thirdly", "finally", "in conclusion", "to sum up",
"furthermore", "moreover", "additionally", "it is worth noting",
"it should be pointed out", "it cannot be ignored", "plays a crucial role",
"has significant implications",
]
CNKI_DIMENSIONS = (
"sentence structure", "paragraph similarity", "information density",
"connector frequency", "term-context matching",
)
# --- tunable detection thresholds ---
# A paragraph shorter than this many characters is ignored for coverage.
MIN_PARAGRAPH_CHARS = 50
# Matrix rows must cover at least this fraction of manuscript paragraphs.
MIN_COVERAGE_RATIO = 0.5
# Coverage is only enforced once the manuscript has more than this many paragraphs.
COVERAGE_MIN_PARAGRAPHS = 2
# Sentence-length sampling window (characters).
SENTENCE_MIN_CHARS = 5
SENTENCE_MAX_CHARS = 300
# Below this stddev sentence lengths are too uniform (AI signal).
MIN_SENTENCE_LENGTH_STDDEV = 6
# Connector occurrences per 1k characters above this are an AI signal.
MAX_CONNECTOR_DENSITY = 8
@dataclass
class HumanizeCheckResult:
path: str
ok: bool
matrix_rows: int = 0
manuscript_paragraphs: int = 0
coverage_ratio: float = 0.0
sentence_length_stddev: float = 0.0
connector_density: float = 0.0
findings: list[str] = field(default_factory=list)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Validate PaperSpine humanize_matrix.md")
parser.add_argument("output_dir", nargs="?", default="paper_rewriting_output")
parser.add_argument("--json", action="store_true")
parser.add_argument("--markdown", action="store_true")
parser.add_argument("--write", action="store_true", help="Write humanize_report.md")
return parser.parse_args()
def sentence_lengths(text: str) -> list[int]:
sents = re.split(r"[.。!!??;;\n]+", text)
return [
len(s.strip())
for s in sents
if SENTENCE_MIN_CHARS < len(s.strip()) < SENTENCE_MAX_CHARS
]
def count_connectors(text: str, lang: str) -> int:
pool = AI_CONNECTORS_ZH if lang == "zh" else AI_CONNECTORS_EN
return sum(text.count(c) for c in pool)
def check_matrix(matrix_path: Path, manuscript_text: str, lang: str) -> HumanizeCheckResult:
result = HumanizeCheckResult(str(matrix_path), False)
if not matrix_path.exists():
result.findings.append("humanize_matrix.md not found")
return result
text = matrix_path.read_text(encoding="utf-8", errors="ignore")
header, rows = _table_rows(text)
if not header:
result.findings.append("humanize_matrix.md has no parseable table")
return result
result.matrix_rows = len(rows)
result.manuscript_paragraphs = len(_split_paragraphs(manuscript_text))
if result.manuscript_paragraphs > 0:
result.coverage_ratio = result.matrix_rows / result.manuscript_paragraphs
if (
result.coverage_ratio < MIN_COVERAGE_RATIO
and result.manuscript_paragraphs > COVERAGE_MIN_PARAGRAPHS
):
result.findings.append(
f"Coverage {result.coverage_ratio:.0%}: {result.matrix_rows} rows for "
f"{result.manuscript_paragraphs} paragraphs. Minimum {MIN_COVERAGE_RATIO:.0%}."
)
header_text = " ".join(c.lower() for c in header)
for col in ("ai pattern", "detection dim", "severity", "applied change", "teaching"):
if col not in header_text:
result.findings.append(f"Missing column: {col}")
empty_rows = [i for i, row in enumerate(rows, start=1) if any(not c.strip() for c in row)]
if empty_rows:
result.findings.append(f"Rows with empty cells: {empty_rows[:8]}")
severity_counts = {"high": 0, "medium": 0, "low": 0}
dim_hits: set[str] = set()
for row in rows:
joined = " ".join(row).lower()
for sev in severity_counts:
if sev in joined:
severity_counts[sev] += 1
for dim in CNKI_DIMENSIONS:
if dim in joined:
dim_hits.add(dim)
if result.matrix_rows > 2 and severity_counts["high"] == 0:
result.findings.append("No high-severity patterns found — matrix may be under-reporting")
missing_dims = set(CNKI_DIMENSIONS) - dim_hits
if missing_dims:
result.findings.append(f"Dimensions not covered: {', '.join(sorted(missing_dims))}")
lengths = sentence_lengths(manuscript_text)
if len(lengths) > 2:
result.sentence_length_stddev = round(statistics.stdev(lengths), 2)
if result.sentence_length_stddev < MIN_SENTENCE_LENGTH_STDDEV:
result.findings.append(
f"Sentence length stddev = {result.sentence_length_stddev} — too uniform. "
f"AI text typically < {MIN_SENTENCE_LENGTH_STDDEV}; human text > 10."
)
char_count = len(manuscript_text)
conn_count = count_connectors(manuscript_text, lang)
if char_count > 0:
result.connector_density = round(conn_count / (char_count / 1000), 2)
threshold = MAX_CONNECTOR_DENSITY
if result.connector_density > threshold:
result.findings.append(
f"Connector density = {result.connector_density}/1k chars (threshold: {threshold}). "
"High connector density is a strong AI signal."
)
# Check for long dash separators (common AI pattern)
dash_pattern = re.search(r"[—\-—―]{3,}", manuscript_text)
if dash_pattern:
result.findings.append(
"Long dash separators detected (e.g. '————'). "
"These are a strong AI-generation signal — replace with section headings or blank lines."
)
result.ok = not result.findings
return result
def to_markdown(result: HumanizeCheckResult) -> str:
lines = [
"# Humanize Check Report",
"",
f"- Matrix path: `{result.path}`",
f"- Matrix rows: {result.matrix_rows}",
f"- Manuscript paragraphs: {result.manuscript_paragraphs}",
f"- Coverage: {result.coverage_ratio:.0%}",
f"- Sentence length stddev: {result.sentence_length_stddev}",
f"- Connector density: {result.connector_density}/1k chars",
f"- Status: {'PASS' if result.ok else 'FAIL'}",
"",
"## Findings",
"",
]
lines.extend(f"- {f}" for f in result.findings) if result.findings else lines.append("- None")
lines.append("")
return "\n".join(lines)
def main() -> int:
args = parse_args()
out_dir = Path(args.output_dir)
matrix_path = out_dir / "humanize_matrix.md"
if not matrix_path.exists():
print(f"Matrix not found: {matrix_path}", file=sys.stderr)
return 2
lang = "zh"
config_path = out_dir / "paper_spine_config.json"
if config_path.exists():
try:
config = json.loads(config_path.read_text(encoding="utf-8"))
lang = config.get("output_language", "zh")
except json.JSONDecodeError:
pass
manuscript_text = ""
final_paper = out_dir / "final_paper"
if final_paper.is_dir():
for f in final_paper.glob("*.tex"):
manuscript_text += f.read_text(encoding="utf-8", errors="ignore") + "\n"
result = check_matrix(matrix_path, manuscript_text, lang)
if args.json:
print(json.dumps({
"ok": result.ok, "matrix_rows": result.matrix_rows,
"paragraphs": result.manuscript_paragraphs,
"coverage": result.coverage_ratio,
"sentence_stddev": result.sentence_length_stddev,
"connector_density": result.connector_density,
"findings": result.findings,
}, ensure_ascii=False, indent=2))
if args.markdown or not args.json:
print(to_markdown(result))
if args.write:
report_path = out_dir / "humanize_report.md"
report_path.write_text(to_markdown(result), encoding="utf-8")
print(f"Wrote {report_path}", file=sys.stderr)
return 0 if result.ok else 1
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
"""Create PaperSpine configuration from an interactive terminal wizard."""
from __future__ import annotations
import argparse
import json
import os
import re
import shutil
import sys
import unicodedata
from dataclasses import asdict, dataclass
from pathlib import Path
WORKFLOWS = ("rewrite_existing", "build_from_materials")
SCENES = ("journal", "conference", "report_review", "competition")
TIERS = ("flash", "pro")
LANGUAGES = ("en", "zh")
UI_LANGUAGES = ("zh", "en")
WORD_OUTPUTS = ("none", "docx")
TRANSLATION_PACKAGES = ("none", "zh")
REFERENCE_MODES = ("local_first", "specified_paths", "web")
HUMANIZE_TIERS = ("none", "light", "medium", "heavy")
DETECTION_PLATFORMS = ("cnki", "weipu", "general")
GLOBAL_CONFIG_ENV = "PAPERSPINE_CONFIG_HOME"
CHOICE_FIELDS = {
"workflow": WORKFLOWS,
"scene": SCENES,
"tier": TIERS,
"output_language": LANGUAGES,
"word_output": WORD_OUTPUTS,
"translation_package": TRANSLATION_PACKAGES,
"reference_mode": REFERENCE_MODES,
"humanize_tier": HUMANIZE_TIERS,
"detection_platform": DETECTION_PLATFORMS,
"ui_language": UI_LANGUAGES,
}
FIELD_ORDER = (
"workflow",
"scene",
"tier",
"output_language",
"word_output",
"translation_package",
"target_name",
"draft_path",
"materials_dir",
"user_motivation",
"official_urls",
"reference_mode",
"reference_paths",
"citation_target_count",
"special_requirements",
"detection_platform",
"humanize_tier",
"ui_language",
)
CHOICE_HELP = {
"workflow": {
"rewrite_existing": ("改进已有初稿", "Improve an existing draft"),
"build_from_materials": ("从素材文件夹从零构筑", "Build from a materials folder"),
},
"scene": {
"journal": ("期刊论文", "Journal paper"),
"conference": ("会议论文", "Conference paper"),
"report_review": ("课程报告、技术报告或综述", "Report, technical report, or review"),
"competition": ("竞赛论文或竞赛报告", "Competition paper or report"),
},
"tier": {
"flash": ("轻量调研:3+3 篇样例加官方要求", "Light research: 3+3 examples plus official requirements"),
"pro": ("深度调研:6+6 篇样例加官方要求", "Deep research: 6+6 examples plus official requirements"),
},
"output_language": {
"en": ("英文最终稿", "English final output"),
"zh": ("中文最终稿", "Chinese final output"),
},
"word_output": {
"none": ("不额外生成 Word", "Do not generate Word"),
"docx": ("生成并检查 Word 文件", "Generate and check DOCX"),
},
"translation_package": {
"none": ("不翻译", "Do not translate"),
"zh": ("生成完整中文翻译包", "Generate complete Chinese translation package"),
},
"reference_mode": {
"local_first": ("默认先读取本地/当前工作文件夹,再补充网络来源", "Default: read local/current-folder references first, then supplement from web"),
"specified_paths": ("只优先读取用户指定的本地参考文献路径", "Prefer user-specified local reference paths"),
"web": ("主要从网络检索参考材料", "Mainly collect references from the web"),
},
"ui_language": {
"zh": ("中文界面", "Chinese interface"),
"en": ("English UI", "English interface"),
},
"humanize_tier": {
"none": ("不降 AI 痕迹", "No humanization"),
"light": ("轻度 — 替换连接词,微调句式", "Light — replace connectors, vary sentence length"),
"medium": ("中度 — 句式打散 + 信息密度 + 第一人称", "Medium — break patterns + density + first-person"),
"heavy": ("强度 — 结构不规整 + 术语变体(保持学术语气)", "Heavy — structural variation + term variants (academic tone)"),
},
"detection_platform": {
"cnki": ("知网 AIGC 检测", "CNKI AIGC detection"),
"weipu": ("维普 AIGC 检测", "Weipu AIGC detection"),
"general": ("通用策略 — 跨平台兼容", "General — cross-platform compatible"),
},
}
LABELS = {
"zh": {
"banner": "PaperSpine 配置向导",
"welcome": "Welcome back!",
"tagline": "动机驱动的论文/报告 Skill Suite",
"flowline": "先学习目标场景,再确认动机,最后构筑可审计的 LaTeX 成果",
"why_1": "我们做 PaperSpine,是为了让 AI 先学习,再写作。",
"why_2": "不是把论文润色得更长,而是把动机、证据与结构连成一条清晰主线。",
"why_3": "它面向论文、报告与竞赛写作:调研目标场景,学习优秀样例,再逐段生成。",
"continue": "按任意键进入配置",
"workflow": "工作流",
"scene": "目标场景",
"tier": "调研深度",
"output_language": "最终输出语言",
"word_output": "Word 版本",
"translation_package": "生成英文产物后是否翻译",
"ui_language": "界面语言",
"humanize_tier": "降 AI 痕迹",
"detection_platform": "目标检测平台",
"target_name": "目标名称",
"draft_path": "初稿路径",
"materials_dir": "素材文件夹路径",
"user_motivation": "初始动机假设",
"official_urls": "官方链接",
"reference_mode": "文献读取模式",
"reference_paths": "本地参考文献路径",
"citation_target_count": "最终引用目标数",
"special_requirements": "特殊要求",
"review": "检查配置",
"confirm": "确认写入配置",
"edit": "输入要修改的字段编号,或直接回车完成",
"invalid": "输入无效,请重新选择。",
"wrote": "已写入",
"keyboard_help": "←/→ 切换选项;↑/↓ 切换字段;Enter 编辑/确认;S 保存;Q 退出",
"keyboard_subtitle": "上下切换字段,左右切换选项,所有路径与清单字段可按 Enter 直接编辑。",
"progress": "进度",
"current_value": "当前值",
"fields_header": "配置字段",
"previous": "上一个",
"next": "下一个",
"current_marker": "当前",
"choice_hint": "左右键切换候选项,当前项位于中间。",
"text_field_hint": "该字段已尽量从当前文件夹自动读取;Enter 可手动覆盖。",
"save_hint": "检查无误后按 S 或 Enter 保存配置。",
"text_help": "输入新内容。列表字段可用分号分隔。直接回车保留当前值。",
"last_field_hint": "←/→ 切换语言,按 Enter 保存并退出。",
"save": "保存并退出",
"quit": "退出但不保存",
"auto": "自动读取",
"enter_edit": "Enter 修改",
"empty": "空",
},
"en": {
"banner": "PaperSpine Configuration Wizard",
"welcome": "Welcome back!",
"tagline": "Motivation-driven paper/report skill suite",
"flowline": "Learn the target scene, confirm motivation, then build auditable LaTeX",
"why_1": "PaperSpine exists so AI learns before it writes.",
"why_2": "It does not make papers longer; it connects motivation, evidence, and structure.",
"why_3": "For papers, reports, and competitions: research the scene, learn strong examples, then draft unit by unit.",
"continue": "Press any key to configure",
"workflow": "Workflow",
"scene": "Target scene",
"tier": "Research tier",
"output_language": "Final output language",
"word_output": "Word output",
"translation_package": "Translate after English output",
"ui_language": "UI language",
"humanize_tier": "AI humanization",
"detection_platform": "Detection platform",
"target_name": "Target name",
"draft_path": "Draft path",
"materials_dir": "Materials directory",
"user_motivation": "Initial motivation hypothesis",
"official_urls": "Official URLs",
"reference_mode": "Reference reading mode",
"reference_paths": "Local reference paths",
"citation_target_count": "Target citation count",
"special_requirements": "Special requirements",
"review": "Review configuration",
"confirm": "Write config",
"edit": "Enter field number to edit, or press Enter to finish",
"invalid": "Invalid input. Please choose again.",
"wrote": "Wrote",
"keyboard_help": "Left/Right: option; Up/Down: field; Enter: edit/confirm; S: save; Q: quit",
"keyboard_subtitle": "Use Up/Down for fields, Left/Right for choices, and Enter to edit paths or lists.",
"progress": "Progress",
"current_value": "Current value",
"fields_header": "Fields",
"previous": "Previous",
"next": "Next",
"current_marker": "Current",
"choice_hint": "Use Left/Right to cycle choices. The active value is centered.",
"text_field_hint": "Auto-filled from the current folder when possible. Press Enter to override.",
"save_hint": "Press S or Enter to save after review.",
"text_help": "Enter a new value. Separate list fields with semicolons. Press Enter to keep current.",
"last_field_hint": "Left/Right to switch language, Enter to save and exit.",
"save": "Save and exit",
"quit": "Exit without saving",
"auto": "Auto-read",
"enter_edit": "Enter to edit",
"empty": "empty",
},
}
@dataclass
class PaperSpineConfig:
workflow: str
scene: str
tier: str
output_language: str
target_name: str
materials_dir: str
draft_path: str
user_motivation: str
official_urls: list[str]
reference_mode: str
reference_paths: list[str]
citation_target_count: int
special_requirements: list[str]
word_output: str
translation_package: str
humanize_tier: str
detection_platform: str
ui_language: str
ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
URL_RE = re.compile(r"https?://[^\s)>\]]+")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Create PaperSpine config files.")
parser.add_argument("--output-dir", default="paper_rewriting_output")
parser.add_argument("--workflow", choices=WORKFLOWS)
parser.add_argument("--scene", choices=SCENES)
parser.add_argument("--tier", choices=TIERS)
parser.add_argument("--output-language", choices=LANGUAGES)
parser.add_argument("--ui-language", choices=UI_LANGUAGES)
parser.add_argument("--word-output", choices=WORD_OUTPUTS, default="none")
parser.add_argument("--translation-package", choices=TRANSLATION_PACKAGES, default="none")
parser.add_argument("--target-name", default="")
parser.add_argument("--materials-dir", default="")
parser.add_argument("--draft-path", default="")
parser.add_argument("--user-motivation", default="")
parser.add_argument("--official-url", action="append", default=[])
parser.add_argument("--reference-mode", choices=REFERENCE_MODES, default="local_first")
parser.add_argument("--reference-path", action="append", default=[])
parser.add_argument("--citation-target-count", type=int, default=20)
parser.add_argument("--special-requirement", action="append", default=[])
parser.add_argument("--humanize-tier", choices=HUMANIZE_TIERS, default="none")
parser.add_argument("--detection-platform", choices=DETECTION_PLATFORMS, default="general")
parser.add_argument("--setup-global", action="store_true", help="Choose and save global PaperSpine UI preferences.")
parser.add_argument("--no-interactive", action="store_true")
parser.add_argument("--keyboard-ui", action="store_true", help="Use arrow-key terminal UI when a real Windows terminal is available.")
parser.add_argument("--classic-input", action="store_true", help="Force numbered prompt input.")
parser.add_argument("--preview-keyboard-frame", action="store_true", help="Print a static keyboard UI frame for tests/previews and exit.")
parser.add_argument("--preview-width", type=int, default=118)
return parser.parse_args()
def global_config_path() -> Path:
base = os.environ.get(GLOBAL_CONFIG_ENV)
if base:
return Path(base) / "config.json"
return Path.home() / ".paperspine" / "config.json"
def load_global_config() -> dict[str, str]:
path = global_config_path()
if not path.exists():
return {}
try:
return json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return {}
def save_global_config(data: dict[str, str]) -> None:
path = global_config_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def tr(ui_language: str, key: str) -> str:
return LABELS.get(ui_language, LABELS["zh"]).get(key, key)
_ANSI_ENABLED = True
def _enable_windows_vt() -> bool:
"""Enable ANSI escape processing on the Windows console.
Without ENABLE_VIRTUAL_TERMINAL_PROCESSING, legacy conhost renders color and
box-drawing escape sequences as literal garbage (e.g. ``←[0m``). Returns True
when virtual-terminal mode is active.
"""
import ctypes
from ctypes import wintypes
kernel32 = ctypes.windll.kernel32
# Declare signatures so 64-bit handle pointers are not truncated to c_int,
# which would make GetConsoleMode fail and wrongly disable color.
kernel32.GetStdHandle.restype = wintypes.HANDLE
kernel32.GetStdHandle.argtypes = [wintypes.DWORD]
kernel32.GetConsoleMode.restype = wintypes.BOOL
kernel32.GetConsoleMode.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)]
kernel32.SetConsoleMode.restype = wintypes.BOOL
kernel32.SetConsoleMode.argtypes = [wintypes.HANDLE, wintypes.DWORD]
ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004
STD_OUTPUT_HANDLE = -11
handle = kernel32.GetStdHandle(wintypes.DWORD(STD_OUTPUT_HANDLE & 0xFFFFFFFF))
if not handle or handle == wintypes.HANDLE(-1).value:
return False
mode = wintypes.DWORD()
if not kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
return False
if mode.value & ENABLE_VIRTUAL_TERMINAL_PROCESSING:
return True
return bool(kernel32.SetConsoleMode(handle, mode.value | ENABLE_VIRTUAL_TERMINAL_PROCESSING))
def configure_windows_console() -> None:
global _ANSI_ENABLED
if os.name != "nt" or not sys.stdout.isatty():
return
try:
os.system("chcp 65001 > nul")
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
except Exception:
pass
# Colors only render if the console accepts ANSI; fall back to plain text
# rather than printing raw escape codes when it does not.
try:
if not _enable_windows_vt():
_ANSI_ENABLED = False
except Exception:
_ANSI_ENABLED = False
def help_text(key: str, value: str, ui_language: str) -> str:
zh_help, en_help = CHOICE_HELP.get(key, {}).get(value, ("", ""))
return zh_help if ui_language == "zh" else en_help
def ansi(text: str, code: str) -> str:
if not _ANSI_ENABLED or not sys.stdout.isatty():
return text
return f"\033[{code}m{text}\033[0m"
def strip_ansi(text: str) -> str:
return ANSI_RE.sub("", text)
def char_width(char: str) -> int:
if unicodedata.combining(char):
return 0
return 2 if unicodedata.east_asian_width(char) in {"F", "W"} else 1
def display_width(text: str) -> int:
return sum(char_width(char) for char in strip_ansi(text))
def visible_len(text: str) -> int:
return display_width(text)
def crop_plain(text: str, width: int) -> str:
if width <= 1:
return ""
if display_width(text) <= width:
return text
current = 0
chars: list[str] = []
for char in text:
next_width = current + char_width(char)
if next_width > max(0, width - 1):
break
chars.append(char)
current = next_width
return "".join(chars) + "…"
def pad_ansi(text: str, width: int, align: str = "center") -> str:
length = visible_len(text)
if length > width:
plain = ANSI_RE.sub("", text)
text = crop_plain(plain, width)
length = visible_len(text)
gap = max(0, width - length)
if align == "left":
return text + " " * gap
if align == "right":
return " " * gap + text
left = gap // 2
return " " * left + text + " " * (gap - left)
def term_width(default: int = 118) -> int:
return max(96, shutil.get_terminal_size((default, 36)).columns)
def term_height(default: int = 34) -> int:
return max(24, shutil.get_terminal_size((118, default)).lines)
def wrap_plain(text: str, width: int, max_lines: int = 2) -> list[str]:
text = " ".join(str(text).split()) or ""
if not text:
return [""]
lines: list[str] = []
current = ""
current_width = 0
for char in text:
if char == "\n":
lines.append(current)
current = ""
current_width = 0
continue
next_width = current_width + char_width(char)
if next_width > width and current:
lines.append(current.rstrip())
current = char
current_width = char_width(char)
if len(lines) >= max_lines:
break
else:
current += char
current_width = next_width
if current and len(lines) < max_lines:
lines.append(current.rstrip())
if len(lines) == max_lines and display_width(" ".join(lines)) < display_width(text):
lines[-1] = crop_plain(lines[-1], max(1, width))
return lines or [""]
def style(text: str, code: str, color: bool = True) -> str:
return ansi(text, code) if color else text
def safe_input(prompt: str = "> ") -> str:
try:
return input(prompt).strip()
except EOFError:
return ""
def _read_key_windows() -> str:
import msvcrt
ch = msvcrt.getwch()
if ch in ("\x00", "\xe0"):
code = msvcrt.getwch()
return {"H": "up", "P": "down", "K": "left", "M": "right"}.get(code, "")
if ch in ("\r", "\n"):
return "enter"
if ch in ("s", "S"):
return "save"
if ch in ("q", "Q", "\x1b"):
return "quit"
return ch
def _read_key_unix() -> str:
import termios
import tty
fd = sys.stdin.fileno()
old = termios.tcgetattr(fd)
try:
tty.setraw(fd)
ch = sys.stdin.read(1)
if ch == "\x1b":
old_blocking = termios.tcgetattr(fd)
try:
attrs = termios.tcgetattr(fd)
attrs[5][termios.VMIN] = 0
attrs[5][termios.VTIME] = 1
termios.tcsetattr(fd, termios.TCSANOW, attrs)
nxt = sys.stdin.read(1)
finally:
termios.tcsetattr(fd, termios.TCSANOW, old_blocking)
if nxt == "[":
code = sys.stdin.read(1)
return {"A": "up", "B": "down", "C": "right", "D": "left"}.get(code, "")
return "quit"
if ch in ("\r", "\n"):
return "enter"
if ch in ("s", "S"):
return "save"
if ch in ("q", "Q"):
return "quit"
if ord(ch) == 3:
raise KeyboardInterrupt()
return ch
finally:
termios.tcsetattr(fd, termios.TCSANOW, old)
def read_key() -> str:
if os.name == "nt":
return _read_key_windows()
return _read_key_unix()
def clear_screen() -> None:
os.system("cls" if os.name == "nt" else "clear")
def print_banner(ui_language: str) -> None:
title = tr(ui_language, "banner")
line = "=" * max(48, len(title) + 8)
print(f"\n{line}\n {title}\n{line}")
def print_centered_box(lines: list[str], width: int, accent: str = "38;5;208") -> None:
print(ansi("╭" + "─" * (width - 2) + "╮", accent))
for line in lines:
print(ansi("│", accent) + pad_ansi(line, width - 2) + ansi("│", accent))
print(ansi("╰" + "─" * (width - 2) + "╯", accent))
def print_welcome_screen(ui_language: str, wait: bool = False) -> None:
if not sys.stdout.isatty():
print("PaperSpine v3")
print(tr(ui_language, "welcome"))
print(tr(ui_language, "flowline"))
print(str(Path.cwd()))
return
if wait:
clear_screen()
width = min(term_width(), 126)
accent = "38;5;250"
white = "1;97"
muted = "90"
mountain = [
" /\\ /\\ ",
" /\\ / \\ /\\ / \\ /\\ ",
" /\\ / \\ / \\ / \\ / \\ / \\ /\\ ",
"__/ \\__/ \\___/ \\__/ \\___/ \\____/ \\___/ \\__",
]
title = [
"██████╗ █████╗ ██████╗ ███████╗██████╗ ███████╗██████╗ ██╗███╗ ██╗███████╗",
"██╔══██╗██╔══██╗██╔══██╗██╔════╝██╔══██╗██╔════╝██╔══██╗██║████╗ ██║██╔════╝",
"██████╔╝███████║██████╔╝█████╗ ██████╔╝███████╗██████╔╝██║██╔██╗ ██║█████╗ ",
"██╔═══╝ ██╔══██║██╔═══╝ ██╔══╝ ██╔══██╗╚════██║██╔═══╝ ██║██║╚██╗██║██╔══╝ ",
"██║ ██║ ██║██║ ███████╗██║ ██║███████║██║ ██║██║ ╚████║███████╗",
]
lines: list[str] = ["", ansi(tr(ui_language, "welcome"), muted), ""]
lines.extend(ansi(line, white) for line in mountain)
lines.append("")
lines.extend(ansi(line, white) for line in title)
lines.extend(
[
"",
ansi(tr(ui_language, "tagline"), white),
ansi(tr(ui_language, "flowline"), muted),
"",
tr(ui_language, "why_1"),
tr(ui_language, "why_2"),
tr(ui_language, "why_3"),
"",
ansi("X: Wbingo353332", muted),
ansi("Douyin: 91362158854", muted),
ansi("Xiaohongshu: 4770513150", muted),
ansi("Bilibili: 彬_2023 (ID: 450856661)", muted),
"",
ansi(str(Path.cwd()), muted),
"",
]
)
print()
print(pad_ansi(ansi("PaperSpine v3", accent), width))
print()
for line in lines:
print(pad_ansi(line, width))
if wait:
print()
print(pad_ansi(ansi(tr(ui_language, "continue"), muted), width))
read_key()
def choose(key: str, values: tuple[str, ...], ui_language: str, default: str | None = None) -> str:
default = default or values[0]
print(f"\n{tr(ui_language, key)}")
for index, value in enumerate(values, start=1):
suffix = " [default]" if value == default else ""
description = help_text(key, value, ui_language)
description = f" - {description}" if description else ""
print(f" {index}. {value}{suffix}{description}")
while True:
answer = safe_input("> ")
if not answer:
return default
if answer.isdigit():
idx = int(answer)
if 1 <= idx <= len(values):
return values[idx - 1]
if answer in values:
return answer
print(tr(ui_language, "invalid"))
def ask_text(key: str, ui_language: str, default: str = "") -> str:
suffix = f" [{default}]" if default else ""
answer = safe_input(f"{tr(ui_language, key)}{suffix}: ")
return answer or default
def split_items(value: str) -> list[str]:
if not value:
return []
raw = value.replace("\n", ";").replace(",", ";").split(";")
return [item.strip() for item in raw if item.strip()]
def default_language(scene: str) -> str:
if scene in {"journal", "conference"}:
return "en"
return "zh"
def display_config(config: PaperSpineConfig) -> list[str]:
data = asdict(config)
keys = list(data)
print("\n" + "-" * 72)
for index, key in enumerate(keys, start=1):
value = data[key]
rendered = ", ".join(value) if isinstance(value, list) else value
print(f"{index:>2}. {key}: {rendered}")
print("-" * 72)
return keys
def edit_config(config: PaperSpineConfig) -> PaperSpineConfig:
while True:
print(f"\n{tr(config.ui_language, 'review')}")
keys = display_config(config)
answer = safe_input(f"{tr(config.ui_language, 'confirm')} [Y/n]: ").lower()
if answer in {"", "y", "yes"}:
return config
field = safe_input(f"{tr(config.ui_language, 'edit')}: ")
if not field:
return config
if not field.isdigit() or not 1 <= int(field) <= len(keys):
print(tr(config.ui_language, "invalid"))
continue
edit_field(config, keys[int(field) - 1], classic=True)
def can_use_keyboard_ui(force: bool = False) -> bool:
if not sys.stdin.isatty() or not sys.stdout.isatty():
return False
if os.name == "nt":
try:
import msvcrt # noqa: F401
except ImportError:
return False
return True
try:
import termios # noqa: F401
import tty # noqa: F401
except ImportError:
return False
return True
def rendered_value(config: PaperSpineConfig, field: str) -> str:
value = getattr(config, field)
if isinstance(value, list):
return "; ".join(value)
return str(value)
def option_triplet(config: PaperSpineConfig, field: str) -> tuple[str, str, str] | None:
if field not in CHOICE_FIELDS:
return None
options = CHOICE_FIELDS[field]
current = getattr(config, field)
index = options.index(current) if current in options else 0
return options[(index - 1) % len(options)], options[index], options[(index + 1) % len(options)]
def set_choice_value(config: PaperSpineConfig, field: str, direction: int) -> None:
options = CHOICE_FIELDS[field]
current = getattr(config, field)
index = options.index(current) if current in options else 0
setattr(config, field, options[(index + direction) % len(options)])
normalize_config(config)
def edit_field(config: PaperSpineConfig, field: str, classic: bool = False) -> None:
if field in CHOICE_FIELDS:
if classic:
setattr(config, field, choose(field, CHOICE_FIELDS[field], config.ui_language, getattr(config, field)))
normalize_config(config)
return
clear_screen()
print_banner(config.ui_language)
print(f"{tr(config.ui_language, field)}")
print(tr(config.ui_language, "text_help"))
current = rendered_value(config, field)
if current:
print(f"\nCurrent: {current}")
answer = safe_input("> ")
if not answer:
return
if field in {"official_urls", "special_requirements", "reference_paths"}:
setattr(config, field, split_items(answer))
elif field == "citation_target_count":
try:
config.citation_target_count = int(answer)
except ValueError:
return
else:
setattr(config, field, answer)
normalize_config(config)
def normalize_config(config: PaperSpineConfig) -> None:
if config.output_language != "en":
config.translation_package = "none"
if config.workflow == "rewrite_existing":
config.materials_dir = config.materials_dir or ""
else:
config.draft_path = config.draft_path or ""
if config.reference_mode not in REFERENCE_MODES:
config.reference_mode = "local_first"
if not config.reference_paths:
config.reference_paths = ["."]
config.citation_target_count = max(1, int(config.citation_target_count or 20))
def find_first_existing_dir(names: tuple[str, ...]) -> str:
cwd = Path.cwd()
for name in names:
if (cwd / name).is_dir():
return name
for path in cwd.iterdir():
if path.is_dir() and path.name not in {"paper_rewriting_output", ".git", ".vscode"}:
has_materials = any(path.glob(pattern) for pattern in ("*.md", "*.txt", "*.csv", "*.png", "*.jpg", "*.pdf", "*.docx"))
if has_materials:
return path.name
return ""
def find_candidate_draft() -> str:
cwd = Path.cwd()
candidates: list[Path] = []
for pattern in ("*.tex", "*.md", "*.docx", "*.pdf"):
candidates.extend(path for path in cwd.glob(pattern) if path.is_file())
candidates = [path for path in candidates if not path.name.lower().startswith("readme")]
if not candidates:
return ""
preferred = ("draft", "manuscript", "paper", "main", "初稿", "论文")
candidates.sort(key=lambda p: (0 if any(token in p.name.lower() for token in preferred) else 1, len(p.name)))
return candidates[0].name
def read_small_text_files(root: Path, limit: int = 8) -> str:
chunks: list[str] = []
for path in root.rglob("*"):
if len(chunks) >= limit:
break
if not path.is_file() or path.suffix.lower() not in {".md", ".txt"}:
continue
try:
text = path.read_text(encoding="utf-8", errors="ignore")
except OSError:
continue
chunks.append(text[:2000])
return "\n".join(chunks)
def infer_motivation(text: str, ui_language: str) -> str:
markers = ("核心想法", "核心动机", "motivation", "Motivation", "主线", "创新点")
for line in text.splitlines():
clean = line.strip(" #::*-")
if any(marker in line for marker in markers) and 12 <= len(clean) <= 180:
return clean
if ui_language == "zh":
return "请先调研目标场景和优秀样例,再生成候选动机并由用户确认。"
return "Research the target scene and strong examples first, then propose motivation options for user confirmation."
def infer_urls(text: str) -> list[str]:
seen: list[str] = []
for match in URL_RE.findall(text):
url = match.rstrip(".,;,。;")
if url not in seen:
seen.append(url)
return seen[:6]
def auto_config_project(config: PaperSpineConfig, args: argparse.Namespace) -> None:
cwd = Path.cwd()
materials_dir = find_first_existing_dir(("materials", "素材", "source_materials", "data"))
draft_path = find_candidate_draft()
if not args.workflow:
if materials_dir and not draft_path:
config.workflow = "build_from_materials"
elif draft_path:
config.workflow = "rewrite_existing"
if not args.materials_dir and materials_dir:
config.materials_dir = materials_dir
if not args.draft_path and draft_path:
config.draft_path = draft_path
if not args.target_name:
config.target_name = cwd.name.replace("_", " ").replace("-", " ")
material_root = cwd / config.materials_dir if config.materials_dir else cwd
text = read_small_text_files(material_root if material_root.exists() else cwd)
if not args.user_motivation:
config.user_motivation = infer_motivation(text, config.ui_language)
if not args.official_url:
config.official_urls = infer_urls(text)
if not args.reference_path:
reference_dirs = [
path.name
for path in cwd.iterdir()
if path.is_dir()
and path.name.lower()
in {"reference_materials", "references", "literature", "papers", "citations", "文献", "参考文献"}
]
config.reference_paths = reference_dirs or ["."]
if not args.special_requirement:
requirements = [
"必须输出 final_paper/main.tex;如果本机有 LaTeX 编译器则编译 paper.pdf。",
"必须生成详细 writing_rationale_matrix.md,逐段解释写作逻辑。",
]
figures_dir = material_root / "figures"
if figures_dir.exists() and any(figures_dir.glob("*.*")):
requirements.append("复制并引用素材图片到最终 LaTeX 项目的 figures/。")
if config.workflow == "build_from_materials":
requirements.append("从素材从零构筑,不把技术说明当成初稿润色。")
config.special_requirements = requirements
normalize_config(config)
def field_label(config: PaperSpineConfig, field: str, index: int, selected: bool, width: int) -> str:
label = f"{index:02d}. {tr(config.ui_language, field)}"
color = "1;97" if selected else "90"
marker = ">" if selected else " "
return pad_ansi(ansi(f"{marker} {label}", color), width, align="left")
def choice_columns(config: PaperSpineConfig, field: str, width: int, color: bool = True) -> list[str]:
gray = "90"
white = "1;97"
triplet = option_triplet(config, field)
if not triplet:
return []
prev, current, nxt = triplet
slot = max(16, (width - 8) // 3)
items = [
("<- " + tr(config.ui_language, "previous"), prev, gray),
(tr(config.ui_language, "current_marker"), current, white),
(tr(config.ui_language, "next") + " ->", nxt, gray),
]
rows: list[list[str]] = []
for marker, value, item_color in items:
desc = help_text(field, value, config.ui_language) or value
lines = [
style(marker, item_color, color),
style(value, item_color, color),
]
lines.extend(style(line, item_color, color) for line in wrap_plain(desc, slot, max_lines=2))
rows.append([pad_ansi(line, slot) for line in lines[:4]])
while any(len(row) < 4 for row in rows):
for row in rows:
if len(row) < 4:
row.append(" " * slot)
return [
rows[0][line] + " " + rows[1][line] + " " + rows[2][line]
for line in range(4)
]
def text_value_lines(config: PaperSpineConfig, field: str, width: int, color: bool = True) -> list[str]:
current = rendered_value(config, field) or tr(config.ui_language, "empty")
current_lines = wrap_plain(current, max(24, width - 8), max_lines=4)
lines = [
style(tr(config.ui_language, "current_value"), "90", color),
*[style(line, "1;97", color) for line in current_lines],
"",
style(tr(config.ui_language, "text_field_hint"), "90", color),
]
return lines
def right_panel_lines(config: PaperSpineConfig, field: str, width: int, index: int, total: int, color: bool = True) -> list[str]:
if field == "save":
title = tr(config.ui_language, "save")
lines = [
style(title, "1;97", color),
"",
style(tr(config.ui_language, "save_hint"), "90", color),
"",
style("S / Enter", "1;97", color),
]
return [pad_ansi(line, width) for line in lines]
title = tr(config.ui_language, field)
value = rendered_value(config, field) or tr(config.ui_language, "empty")
header = f"{tr(config.ui_language, 'progress')} {index + 1}/{total - 1} · {title}"
lines = [
style(header, "1;97", color),
style(f"{tr(config.ui_language, 'current_value')}: {crop_plain(value, max(12, width - 18))}", "90", color),
"",
]
if field in CHOICE_FIELDS:
if index == total - 2:
lines.append(style(tr(config.ui_language, "last_field_hint"), "1;97", color))
else:
lines.append(style(tr(config.ui_language, "choice_hint"), "90", color))
lines.append("")
lines.extend(choice_columns(config, field, width, color=color))
else:
lines.extend(text_value_lines(config, field, width, color=color))
return [pad_ansi(line, width, align="left") for line in lines]
def render_keyboard_frame(
config: PaperSpineConfig,
index: int = 0,
width: int | None = None,
height: int | None = None,
color: bool = True,
) -> list[str]:
fields = list(FIELD_ORDER) + ["save"]
index = max(0, min(index, len(fields) - 1))
field = fields[index]
width = max(96, min(width or term_width(), 140))
height = max(24, height or term_height())
left_w = max(30, int((width - 3) * 0.30))
right_w = width - left_w - 3
body_h = min(max(len(fields) + 2, 18), max(18, height - 7))
accent = "38;5;244"
muted = "90"
left_items = [
field_label(config, item, row + 1, row == index, left_w - 2)
if item != "save"
else pad_ansi(style(f"S. {tr(config.ui_language, 'save')}", "1;97" if row == index else muted, color), left_w - 2, align="left")
for row, item in enumerate(fields)
]
if len(left_items) > body_h:
half = body_h // 2
start = min(max(0, index - half), len(left_items) - body_h)
left_items = left_items[start : start + body_h]
top_pad = max(0, (body_h - len(left_items)) // 2)
left_lines = [" " * (left_w - 2)] * top_pad + left_items
left_lines += [" " * (left_w - 2)] * (body_h - len(left_lines))
right_content = right_panel_lines(config, field, right_w - 2, index, len(fields), color=color)
right_top_pad = max(0, (body_h - len(right_content)) // 2)
right_lines = [" " * (right_w - 2)] * right_top_pad + right_content
right_lines += [" " * (right_w - 2)] * (body_h - len(right_lines))
top = style("╭" + "─" * (width - 2) + "╮", accent, color)
split_top = style("├" + "─" * left_w + "┬" + "─" * right_w + "┤", accent, color)
split_mid = style("├" + "─" * left_w + "┼" + "─" * right_w + "┤", accent, color)
bottom = style("╰" + "─" * left_w + "┴" + "─" * right_w + "╯", accent, color)
title = style("PaperSpine", "1;97", color) + style(" " + tr(config.ui_language, "banner"), muted, color)
subtitle = style(tr(config.ui_language, "keyboard_subtitle"), muted, color)
help_line = style(tr(config.ui_language, "keyboard_help"), muted, color)
cwd_line = style(" " + str(Path.cwd()), muted, color)
frame = [
top,
style("│", accent, color) + pad_ansi(title, width - 2) + style("│", accent, color),
style("│", accent, color) + pad_ansi(subtitle, width - 2) + style("│", accent, color),
style("│", accent, color) + pad_ansi(cwd_line, width - 2) + style("│", accent, color),
split_top,
style("│", accent, color)
+ pad_ansi(style(tr(config.ui_language, "fields_header"), muted, color), left_w)
+ style("│", accent, color)
+ pad_ansi(help_line, right_w)
+ style("│", accent, color),
split_mid,
]
for left, right in zip(left_lines, right_lines, strict=False):
frame.append(
style("│", accent, color)
+ pad_ansi(left, left_w)
+ style("│", accent, color)
+ pad_ansi(right, right_w, align="left")
+ style("│", accent, color)
)
frame.append(bottom)
return frame
def keyboard_editor(config: PaperSpineConfig) -> PaperSpineConfig:
fields = list(FIELD_ORDER) + ["save"]
index = 0
while True:
normalize_config(config)
field = fields[index]
clear_screen()
print("\n".join(render_keyboard_frame(config, index=index, color=True)))
key = read_key()
if key == "up":
index = (index - 1) % len(fields)
elif key == "down":
index = (index + 1) % len(fields)
elif key == "left" and field in CHOICE_FIELDS:
set_choice_value(config, field, -1)
elif key == "right" and field in CHOICE_FIELDS:
set_choice_value(config, field, 1)
elif key == "enter":
if field == "save":
return config
if field in CHOICE_FIELDS:
if index == len(fields) - 2:
return config
index = (index + 1) % len(fields)
else:
edit_field(config, field)
elif key == "save":
return config
elif key == "quit":
raise KeyboardInterrupt(tr(config.ui_language, "quit"))
def base_config_from_args(args: argparse.Namespace, ui_language: str) -> PaperSpineConfig:
workflow = args.workflow or "rewrite_existing"
scene = args.scene or "journal"
output_language = args.output_language or default_language(scene)
translation_package = args.translation_package
if output_language != "en":
translation_package = "none"
config = PaperSpineConfig(
workflow=workflow,
scene=scene,
tier=args.tier or "flash",
output_language=output_language,
target_name=args.target_name,
materials_dir=args.materials_dir,
draft_path=args.draft_path,
user_motivation=args.user_motivation,
official_urls=list(args.official_url),
reference_mode=args.reference_mode,
reference_paths=list(args.reference_path) or ["."],
citation_target_count=max(1, args.citation_target_count),
special_requirements=list(args.special_requirement),
word_output=args.word_output,
translation_package=translation_package,
humanize_tier=args.humanize_tier,
detection_platform=args.detection_platform,
ui_language=ui_language,
)
if not args.no_interactive:
auto_config_project(config, args)
return config
def build_config(args: argparse.Namespace) -> PaperSpineConfig:
global_config = load_global_config()
ui_language = args.ui_language or global_config.get("ui_language", "zh")
use_keyboard = (
not args.classic_input
and not args.no_interactive
and (args.keyboard_ui or sys.stdin.isatty())
and can_use_keyboard_ui(force=args.keyboard_ui)
)
if args.setup_global and not args.no_interactive:
if use_keyboard:
config = base_config_from_args(args, ui_language)
print_welcome_screen(config.ui_language, wait=True)
fields = ("ui_language", "save")
index = 0
while True:
clear_screen()
print_banner(config.ui_language)
print(tr(config.ui_language, "keyboard_help"))
for idx, field in enumerate(fields):
marker = ">" if idx == index else " "
label = tr(config.ui_language, field) if field != "save" else tr(config.ui_language, "save")
value = config.ui_language if field == "ui_language" else ""
print(f"{marker} {label:<18} {value}")
key = read_key()
if key in {"up", "down"}:
index = (index + 1) % len(fields)
elif key in {"left", "right"} and fields[index] == "ui_language":
set_choice_value(config, "ui_language", 1 if key == "right" else -1)
elif key in {"enter", "save"}:
break
elif key == "quit":
raise KeyboardInterrupt(tr(config.ui_language, "quit"))
ui_language = config.ui_language
else:
print_banner(ui_language)
ui_language = choose("ui_language", UI_LANGUAGES, ui_language, default=ui_language)
save_global_config({"ui_language": ui_language})
elif args.setup_global:
save_global_config({"ui_language": ui_language})
config = base_config_from_args(args, ui_language)
if args.no_interactive:
return config
if use_keyboard:
print_welcome_screen(config.ui_language, wait=True)
return keyboard_editor(config)
print_welcome_screen(ui_language, wait=False)
print_banner(ui_language)
config.workflow = args.workflow or choose("workflow", WORKFLOWS, ui_language)
config.scene = args.scene or choose("scene", SCENES, ui_language)
config.tier = args.tier or choose("tier", TIERS, ui_language, default=config.tier)
config.output_language = args.output_language or choose(
"output_language", LANGUAGES, ui_language, default=config.output_language
)
config.word_output = choose("word_output", WORD_OUTPUTS, ui_language, default=config.word_output)
if config.output_language == "en":
config.translation_package = choose(
"translation_package", TRANSLATION_PACKAGES, ui_language, default=config.translation_package
)
config.target_name = ask_text("target_name", ui_language, config.target_name)
if config.workflow == "rewrite_existing":
config.draft_path = ask_text("draft_path", ui_language, config.draft_path)
else:
config.materials_dir = ask_text("materials_dir", ui_language, config.materials_dir)
config.user_motivation = ask_text("user_motivation", ui_language, config.user_motivation)
config.official_urls.extend(split_items(ask_text("official_urls", ui_language)))
config.reference_mode = choose("reference_mode", REFERENCE_MODES, ui_language, default=config.reference_mode)
config.reference_paths = split_items(ask_text("reference_paths", ui_language, "; ".join(config.reference_paths)))
raw_count = ask_text("citation_target_count", ui_language, str(config.citation_target_count))
try:
config.citation_target_count = int(raw_count)
except ValueError:
config.citation_target_count = 20
config.special_requirements.extend(split_items(ask_text("special_requirements", ui_language)))
normalize_config(config)
return edit_config(config)
def markdown_config(config: PaperSpineConfig) -> str:
data = asdict(config)
lines = ["# PaperSpine Config", ""]
for key, value in data.items():
rendered = ", ".join(value) if isinstance(value, list) else value
lines.append(f"- **{key}**: {rendered}")
lines.append("")
return "\n".join(lines)
def main() -> int:
configure_windows_console()
args = parse_args()
if args.preview_keyboard_frame:
ui_language = args.ui_language or load_global_config().get("ui_language", "zh")
config = base_config_from_args(args, ui_language)
auto_config_project(config, args)
print("\n".join(render_keyboard_frame(config, index=0, width=args.preview_width, color=False)))
return 0
config = build_config(args)
output_dir = Path(args.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
json_path = output_dir / "paper_spine_config.json"
md_path = output_dir / "paper_spine_config.md"
json_path.write_text(json.dumps(asdict(config), ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
md_path.write_text(markdown_config(config), encoding="utf-8")
print(f"{tr(config.ui_language, 'wrote')} {json_path}")
print(f"{tr(config.ui_language, 'wrote')} {md_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
param(
[string]$OutputDir = "paper_rewriting_output",
[switch]$InPlace
)
$ErrorActionPreference = "Stop"
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$wizard = Join-Path $scriptDir "intake_wizard.py"
if (-not (Test-Path -LiteralPath $wizard)) {
throw "PaperSpine intake wizard not found: $wizard"
}
if ($InPlace) {
chcp 65001 > $null
$env:PYTHONUTF8 = "1"
$OutputEncoding = [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new()
python $wizard --keyboard-ui --output-dir $OutputDir
exit $LASTEXITCODE
}
$cwd = (Get-Location).Path
$escapedCwd = $cwd.Replace("'", "''")
$escapedWizard = $wizard.Replace("'", "''")
$escapedOutput = $OutputDir.Replace("'", "''")
$command = @"
Set-Location -LiteralPath '$escapedCwd'
chcp 65001 > `$null
`$env:PYTHONUTF8 = '1'
`$OutputEncoding = [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new()
python '$escapedWizard' --keyboard-ui --output-dir '$escapedOutput'
Write-Host ''
Write-Host 'PaperSpine intake finished. Config files are in: $escapedOutput'
Write-Host 'Close this window after checking the result.'
"@
Start-Process -FilePath "powershell.exe" -ArgumentList @(
"-NoExit",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-Command",
$command
)
#!/usr/bin/env bash
# Launch the PaperSpine intake TUI in an external terminal window.
# Cross-platform: macOS (Terminal.app), Linux (gnome-terminal / xterm / konsole).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
WIZARD="$SCRIPT_DIR/intake_wizard.py"
OUTPUT_DIR="${1:-paper_rewriting_output}"
if [ ! -f "$WIZARD" ]; then
echo "PaperSpine intake wizard not found: $WIZARD" >&2
exit 1
fi
PYTHON=""
for candidate in python3 python; do
if command -v "$candidate" &>/dev/null; then
PYTHON="$candidate"
break
fi
done
if [ -z "$PYTHON" ]; then
echo "Python 3 not found. Install Python and retry." >&2
exit 1
fi
CMD="$PYTHON \"$WIZARD\" --keyboard-ui --output-dir \"$OUTPUT_DIR\"; echo ''; echo 'PaperSpine intake finished. Config files are in: $OUTPUT_DIR'; echo 'Close this window after checking the result.'; exec bash"
case "$(uname -s)" in
Darwin)
osascript -e "tell application \"Terminal\" to do script \"$CMD\""
;;
Linux)
if command -v gnome-terminal &>/dev/null; then
gnome-terminal -- bash -c "$CMD"
elif command -v konsole &>/dev/null; then
konsole -e bash -c "$CMD"
elif command -v xfce4-terminal &>/dev/null; then
xfce4-terminal -e "bash -c '$CMD'"
elif command -v xterm &>/dev/null; then
xterm -e bash -c "$CMD" &
else
echo "No supported terminal found. Run directly:" >&2
echo " $PYTHON $WIZARD --keyboard-ui --output-dir $OUTPUT_DIR" >&2
exit 1
fi
;;
*)
echo "Unsupported OS: $(uname -s)" >&2
exit 1
;;
esac
#!/usr/bin/env python3
"""Inventory a PaperSpine materials folder without parsing document content."""
from __future__ import annotations
import argparse
import json
from dataclasses import asdict, dataclass
from pathlib import Path
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".svg", ".webp", ".tif", ".tiff"}
PDF_EXTS = {".pdf"}
WORD_TEXT_EXTS = {".docx", ".doc", ".txt", ".md", ".rtf"}
LATEX_EXTS = {".tex", ".bib", ".bst", ".cls", ".sty"}
DATA_EXTS = {".csv", ".tsv", ".xlsx", ".xls", ".json", ".yaml", ".yml"}
CODE_EXTS = {".py", ".r", ".m", ".ipynb", ".sh", ".ps1"}
@dataclass
class InventoryItem:
path: str
extension: str
file_type: str
role_hint: str
size_bytes: int
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Inventory a materials folder.")
parser.add_argument("materials_dir", help="Folder containing user materials.")
parser.add_argument(
"--output-dir",
default="paper_rewriting_output",
help="Directory for source_inventory.md and .json",
)
parser.add_argument("--markdown", action="store_true", help="Print markdown to stdout.")
parser.add_argument("--json", action="store_true", help="Print JSON to stdout.")
return parser.parse_args()
def classify_type(path: Path) -> str:
suffix = path.suffix.lower()
if suffix in IMAGE_EXTS:
return "image"
if suffix in PDF_EXTS:
return "pdf"
if suffix in WORD_TEXT_EXTS:
return "word_text"
if suffix in LATEX_EXTS:
return "latex"
if suffix in DATA_EXTS:
return "data"
if suffix in CODE_EXTS:
return "code"
return "other"
def infer_role(path: Path) -> str:
name = path.name.lower()
checks = [
("draft", ("draft", "manuscript", "paper", "initial")),
("result", ("result", "figure", "table", "experiment", "metric", "plot")),
("research", ("survey", "literature", "reference", "review", "background")),
("method", ("method", "protocol", "setting", "setup", "pipeline", "workflow")),
("requirement", ("rubric", "guideline", "requirement", "rules", "template")),
]
for role, tokens in checks:
if any(token in name for token in tokens):
return role
return "material"
def iter_files(root: Path) -> list[Path]:
return sorted(path for path in root.rglob("*") if path.is_file())
def inventory(root: Path) -> list[InventoryItem]:
items: list[InventoryItem] = []
for path in iter_files(root):
rel = path.relative_to(root).as_posix()
items.append(
InventoryItem(
path=rel,
extension=path.suffix.lower(),
file_type=classify_type(path),
role_hint=infer_role(path),
size_bytes=path.stat().st_size,
)
)
return items
def to_markdown(items: list[InventoryItem], root: Path) -> str:
lines = [
"# Source Inventory",
"",
f"- Materials directory: `{root}`",
f"- Files found: {len(items)}",
"",
"| Path | Type | Role Hint | Size Bytes |",
"|---|---|---|---:|",
]
for item in items:
lines.append(
f"| `{item.path}` | {item.file_type} | {item.role_hint} | {item.size_bytes} |"
)
lines.append("")
return "\n".join(lines)
def main() -> int:
args = parse_args()
root = Path(args.materials_dir)
if not root.exists() or not root.is_dir():
raise SystemExit(f"Materials directory not found: {root}")
items = inventory(root)
output_dir = Path(args.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
data = [asdict(item) for item in items]
md = to_markdown(items, root)
(output_dir / "source_inventory.json").write_text(
json.dumps(data, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
(output_dir / "source_inventory.md").write_text(md, encoding="utf-8")
if args.json:
print(json.dumps(data, ensure_ascii=False, indent=2))
if args.markdown or not args.json:
print(md)
return 0
if __name__ == "__main__":
raise SystemExit(main())