feat: make fix-title agentic
This commit is contained in:
parent
7e19041048
commit
d69baca746
|
|
@ -14,7 +14,7 @@ Existing CCPE content is not migrated here.
|
|||
| Skill | Purpose | Source | Status | Install Policy | Installed Path | CCPE Registration |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `bundle-zip` | Create zip archives from explicit file lists while preserving source-relative paths and validating entries by readback. | `C:\Users\wangq\Documents\Codex\skills-vault\skills\bundle-zip` | `installed` | `default` | `C:\Users\wangq\.agents\skills\bundle-zip` | none |
|
||||
| `fix-title` | Shift Markdown ATX heading depth for pasted LLM replies before insertion under parent sections. | `C:\Users\wangq\Documents\Codex\knowledge-vault\skills\fix-title` | `installed` | `default` | `C:\Users\wangq\.agents\skills\fix-title` | none |
|
||||
| `fix-title` | Agentic semantic repair for copied GPT/LLM Markdown heading hierarchy across one or more discussion or artifact files, with fixed copies, heading plans, and reports. | `C:\Users\wangq\Documents\Codex\skills-vault\skills\fix-title` | `installed` | `default` | `C:\Users\wangq\.agents\skills\fix-title` | none |
|
||||
| `lifecycle-status-guard-scan` | Scan configured Markdown, JSON, YAML, and text files for lifecycle/status overclaim candidates and missing local evidence markers. | `C:\Users\wangq\Documents\Codex\skills-vault\skills\lifecycle-status-guard-scan` | `installed` | `default` | `C:\Users\wangq\.agents\skills\lifecycle-status-guard-scan` | none |
|
||||
| `repair-markdown-citations` | Repair ChatGPT/Deep Research Markdown citation tokens into standard footnotes and a deduplicated reference section using exact turn-ID metadata mapping. | `C:\Users\wangq\Documents\Codex\skills-vault\skills\repair-markdown-citations` | `installed` | `default` | `C:\Users\wangq\.agents\skills\repair-markdown-citations` | none |
|
||||
| `regression-validation-gate-runner` | Dry-run or execute project-declared regression and validation gates from a manifest while capturing logs, exit codes, durations, skipped gates, and changed-file notes. | `C:\Users\wangq\Documents\Codex\skills-vault\skills\regression-validation-gate-runner` | `installed` | `default` | `C:\Users\wangq\.agents\skills\regression-validation-gate-runner` | none |
|
||||
|
|
|
|||
|
|
@ -0,0 +1,76 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Quick validation for a Codex skill directory."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
MAX_SKILL_NAME_LENGTH = 64
|
||||
|
||||
|
||||
def validate_skill(skill_path: Path) -> tuple[bool, str]:
|
||||
skill_md = skill_path / "SKILL.md"
|
||||
if not skill_md.exists():
|
||||
return False, "SKILL.md not found"
|
||||
|
||||
content = skill_md.read_text(encoding="utf-8")
|
||||
if not content.startswith("---"):
|
||||
return False, "No YAML frontmatter found"
|
||||
|
||||
match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL)
|
||||
if not match:
|
||||
return False, "Invalid frontmatter format"
|
||||
|
||||
try:
|
||||
frontmatter = yaml.safe_load(match.group(1))
|
||||
except yaml.YAMLError as exc:
|
||||
return False, f"Invalid YAML in frontmatter: {exc}"
|
||||
|
||||
if not isinstance(frontmatter, dict):
|
||||
return False, "Frontmatter must be a YAML dictionary"
|
||||
|
||||
allowed_keys = {"name", "description"}
|
||||
unexpected_keys = set(frontmatter) - allowed_keys
|
||||
if unexpected_keys:
|
||||
return False, "Unexpected frontmatter key(s): " + ", ".join(
|
||||
sorted(unexpected_keys)
|
||||
)
|
||||
|
||||
name = frontmatter.get("name")
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
return False, "Missing or invalid 'name' in frontmatter"
|
||||
if not re.fullmatch(r"[a-z0-9-]+", name):
|
||||
return False, "Name must use lowercase letters, digits, and hyphens only"
|
||||
if name.startswith("-") or name.endswith("-") or "--" in name:
|
||||
return False, "Name cannot start/end with hyphen or contain consecutive hyphens"
|
||||
if len(name) > MAX_SKILL_NAME_LENGTH:
|
||||
return False, f"Name exceeds {MAX_SKILL_NAME_LENGTH} characters"
|
||||
|
||||
description = frontmatter.get("description")
|
||||
if not isinstance(description, str) or not description.strip():
|
||||
return False, "Missing or invalid 'description' in frontmatter"
|
||||
if "<" in description or ">" in description:
|
||||
return False, "Description cannot contain angle brackets"
|
||||
if len(description) > 1024:
|
||||
return False, "Description exceeds 1024 characters"
|
||||
|
||||
return True, "Skill is valid!"
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
if len(argv) != 1:
|
||||
print("Usage: python scripts/quick_validate.py <skill-directory>", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
valid, message = validate_skill(Path(argv[0]))
|
||||
print(message)
|
||||
return 0 if valid else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
|
|
@ -20,7 +20,7 @@ conda activate skills-vault
|
|||
## Test
|
||||
|
||||
```powershell
|
||||
python -m unittest discover -s skills\fix-title\tests -v
|
||||
conda run -n skills-vault python -B -m unittest discover -s skills\fix-title\tests -v
|
||||
```
|
||||
|
||||
## Install To Public Skill Surface
|
||||
|
|
|
|||
|
|
@ -1,52 +1,148 @@
|
|||
---
|
||||
name: fix-title
|
||||
description: Use when a pasted ChatGPT or LLM Markdown reply has heading levels that need to be shifted down before insertion under a parent section such as "## GPT"; also use for Markdown title repair, heading nesting fixes, ATX header adjustment, and adding one or more # levels to headings in a file.
|
||||
description: "Use when copied ChatGPT/LLM Markdown has semantically wrong heading hierarchy, especially discussion records with round headings and GPT reply blocks, or intake/artifact drafts with repeated accidental top-level headings. Agentic Markdown heading repair for one or more files: accept a file path array plus mode discussion or artifact, prefer subagent/thread isolation for large repairs, preserve originals, and produce fixed copies, heading plans, and reports."
|
||||
---
|
||||
|
||||
# Fix Title
|
||||
|
||||
## Overview
|
||||
## Agentic Input
|
||||
|
||||
Fix Markdown heading depth for copied LLM replies before inserting them into a discussion record. Use the bundled script for deterministic file edits instead of asking the model to rewrite the document.
|
||||
Use this skill from an agent prompt, not as a Python CLI.
|
||||
|
||||
The default operation adds two heading levels, turning `# Title` into `### Title`, which fits content placed under a parent heading like `## GPT`.
|
||||
Required inputs:
|
||||
|
||||
## Workflow
|
||||
- `files`: one or more Markdown file paths.
|
||||
- `mode`: `discussion` or `artifact`.
|
||||
|
||||
1. Confirm the input is a Markdown file containing the raw reply to adjust.
|
||||
2. Choose the number of levels to add. Default to `2` unless the user specifies another value.
|
||||
3. Run `scripts/fix_markdown_titles.py` on the file.
|
||||
4. If the target is important or the requested level shift is unusual, use `--dry-run` or `--output` first and inspect the result.
|
||||
5. Report the path modified and the level shift used.
|
||||
Optional inputs:
|
||||
|
||||
## Commands
|
||||
- `output_dir`: output folder. If omitted, use repo-local `tmp/fix-title-output` when available; otherwise use `fix-title-output` beside the first source file.
|
||||
- `execution`: `auto`, `subagent`, `thread`, or `inline`. Default to `auto`.
|
||||
|
||||
```bash
|
||||
python scripts/fix_markdown_titles.py path/to/reply.md
|
||||
python scripts/fix_markdown_titles.py path/to/reply.md --levels 1
|
||||
python scripts/fix_markdown_titles.py path/to/reply.md --levels 3 --dry-run
|
||||
python scripts/fix_markdown_titles.py path/to/reply.md --levels 2 --output path/to/fixed.md
|
||||
Example user-facing request:
|
||||
|
||||
```text
|
||||
Use $fix-title with mode=artifact on:
|
||||
- C:\path\one.md
|
||||
- C:\path\two.md
|
||||
Output to C:\path\tmp\fix-title-output
|
||||
```
|
||||
|
||||
On Windows PowerShell, quote paths that contain spaces:
|
||||
## Execution Isolation
|
||||
|
||||
Prefer isolation because semantic repair consumes context and does not depend on the caller's broader conversation.
|
||||
|
||||
- `subagent`: Use when the host supports delegated agents. Pass only the skill path, input files, mode, output directory, and output contract.
|
||||
- `thread`: Use when the host supports child/background threads and the user explicitly asks for Thread/sub-session isolation or the batch is large enough to risk bloating the main context.
|
||||
- `inline`: Use only when no subagent/thread mechanism is available or the file is small.
|
||||
- `auto`: Choose `thread` when explicitly requested, otherwise choose `subagent` when available, otherwise `inline`.
|
||||
|
||||
The caller must verify returned files and reports. Do not treat a subagent/thread message alone as proof of repair.
|
||||
|
||||
## Delegation Prompt
|
||||
|
||||
Send this shape to the isolated worker:
|
||||
|
||||
```text
|
||||
Use $fix-title at <skill-path> to repair Markdown heading hierarchy.
|
||||
|
||||
Mode: <discussion|artifact>
|
||||
Output directory: <output-dir>
|
||||
Files:
|
||||
- <absolute-path-1>
|
||||
- <absolute-path-2>
|
||||
|
||||
Rules:
|
||||
- Read source files as UTF-8.
|
||||
- Do not overwrite originals.
|
||||
- Preserve non-heading content.
|
||||
- Build heading maps, write explicit JSON heading plans, apply only planned heading edits, and write reports.
|
||||
- Return the fixed file, heading-map, heading-plan, and report paths for every input file.
|
||||
- Flag ambiguous appended artifacts or uncertain heading relationships instead of silently making creative decisions.
|
||||
```
|
||||
|
||||
For Codex specifically, use `spawn_agent` for subagent isolation. Use a Codex Thread only when the user requests thread/sub-session execution or host policy permits it for the current task.
|
||||
|
||||
## Modes
|
||||
|
||||
### Discussion
|
||||
|
||||
Use when a file records conversation rounds, such as `# 1`, `# 2`, `## 任务`, `## 指令`, `## GPT`, or `## GPT的回复`.
|
||||
|
||||
- Preserve outer record structure.
|
||||
- Treat each GPT reply block as a child of its marker heading.
|
||||
- Repair headings inside the GPT reply so top-level GPT content starts one level deeper than the GPT marker, usually `###`.
|
||||
- Repair descendants recursively by semantic relationship, not by raw hash count.
|
||||
|
||||
Example: under `## GPT`, raw `# 1.xxxx`, raw `# 2.xxx`, and raw `## 3.xxx` may all become `###` when they are peer sections in the GPT reply.
|
||||
|
||||
### Artifact
|
||||
|
||||
Use when copied GPT output is stored as a standalone planning/intake/artifact document.
|
||||
|
||||
- Treat the first visible `#` heading as the document title by default.
|
||||
- Repair later accidental top-level headings under that root.
|
||||
- Preserve one clear document title.
|
||||
- If a later heading is actually an appended second artifact, do not silently merge it; mark it in the report for human review.
|
||||
|
||||
Example: after a root title, raw sibling headings `# 1.xxxx`, `# 2.xxx`, and `## 3.xxx` may all become `##` when they are peer sections.
|
||||
|
||||
## Output Contract
|
||||
|
||||
For each source file, write:
|
||||
|
||||
```text
|
||||
<stem>.heading-map.json
|
||||
<stem>.heading-plan.json
|
||||
<stem>.fixed.md
|
||||
<stem>.heading-report.md
|
||||
```
|
||||
|
||||
For batch runs, also write:
|
||||
|
||||
```text
|
||||
fix-title-batch-report.md
|
||||
```
|
||||
|
||||
The plan JSON is the audit boundary. Every changed heading must include:
|
||||
|
||||
```json
|
||||
{
|
||||
"line": 25,
|
||||
"from_level": 1,
|
||||
"to_level": 3,
|
||||
"reason": "This is a top-level section inside the GPT reply block under ## GPT."
|
||||
}
|
||||
```
|
||||
|
||||
## Internal Helper
|
||||
|
||||
Use `scripts/semantic_heading_repair.py` only as an internal importable helper. It is not a user-facing CLI.
|
||||
|
||||
Useful helper API:
|
||||
|
||||
- `inspect_payload(path)`
|
||||
- `write_inspection(source, output, output_format="json")`
|
||||
- `prepare_batch_scaffold(files, mode, output_dir)`
|
||||
- `apply_plan(source, plan, output, report)`
|
||||
|
||||
The helper protects fenced code blocks, quoted headings, and indented code. It does not make semantic decisions.
|
||||
|
||||
## Repair Rules
|
||||
|
||||
- Never overwrite the source file unless the user explicitly asks for replacement.
|
||||
- Do not mechanically add or subtract the same number of heading levels across a whole file.
|
||||
- Do not change non-heading content.
|
||||
- Do not change headings inside fenced code blocks, block quotes, or indented code.
|
||||
- Do not treat Markdown numbering as decisive. `## 3.xxx` can be a sibling of `# 1.xxx` when context shows they are peers.
|
||||
- When a parent heading is repaired, re-evaluate descendants recursively.
|
||||
- If unsure, preserve content and record the ambiguity in the report.
|
||||
|
||||
## Validation
|
||||
|
||||
Run after editing this skill:
|
||||
|
||||
```powershell
|
||||
python .\scripts\fix_markdown_titles.py "C:\path with spaces\reply.md" --levels 2
|
||||
conda run -n skills-vault python -B -m unittest discover -s skills\fix-title\tests -v
|
||||
conda run -n skills-vault python -B scripts\quick_validate.py skills\fix-title
|
||||
```
|
||||
|
||||
## Behavior
|
||||
|
||||
| Input | `--levels 2` output |
|
||||
| --- | --- |
|
||||
| `# A` | `### A` |
|
||||
| `## B` | `#### B` |
|
||||
| `###### C` | `###### C` |
|
||||
|
||||
The script only changes ATX headings: lines beginning with up to three spaces followed by `#` through `######` and then whitespace. It does not change headings inside fenced code blocks, quoted lines such as `> # text`, or indented code.
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
- Do not use a broad replace like `#` to `###`; it will corrupt code blocks, quoted content, tags, and inline text.
|
||||
- Do not increase headings past level six; Markdown ATX headings stop at `######`.
|
||||
- Do not change the recorder's outer headings such as `# 1` or `## GPT` unless the user explicitly points the skill at the recorder file and requests that behavior.
|
||||
- Do not use prompt rewriting for this task when the script can perform the edit directly.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
display_name: Fix Markdown Titles
|
||||
short_description: Shift Markdown headings down before inserting pasted chat replies.
|
||||
default_prompt: Fix the Markdown heading levels in this file so it can be pasted under a parent section.
|
||||
display_name: "Fix Title"
|
||||
short_description: "Agentic repair for Markdown heading trees."
|
||||
default_prompt: "Use $fix-title with files=[...] and mode=artifact or mode=discussion to repair Markdown heading hierarchy without overwriting sources."
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
# Manual Test Input
|
||||
# Manual Test
|
||||
|
||||
Place one raw Markdown file here for manual validation.
|
||||
Use this directory for small manual validation files when needed.
|
||||
|
||||
Recommended filename:
|
||||
Recommended files:
|
||||
|
||||
```text
|
||||
raw-reply.md
|
||||
raw-discussion.md
|
||||
raw-artifact.md
|
||||
heading-plan.json
|
||||
```
|
||||
|
||||
The file should be an original pasted LLM reply before heading repair.
|
||||
The raw files should preserve original copied LLM/GPT Markdown before heading repair.
|
||||
|
||||
After the file is added, run the Skill against a copy or with `--output` first, then compare the result before updating the installed version.
|
||||
Invoke the Skill through an agent prompt with `files=[...]`, `mode=discussion|artifact`, and an explicit `output_dir`. Do not overwrite the source during manual validation.
|
||||
|
|
|
|||
|
|
@ -1,116 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Shift Markdown ATX headings down by a fixed number of levels."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
HEADING_RE = re.compile(r"^( {0,3})(#{1,6})([ \t]+.*)$")
|
||||
FENCE_RE = re.compile(r"^( {0,3})(`{3,}|~{3,})")
|
||||
|
||||
|
||||
def shift_headings(text: str, levels: int) -> str:
|
||||
if levels <= 0:
|
||||
raise ValueError("levels must be a positive integer")
|
||||
|
||||
lines = text.splitlines(keepends=True)
|
||||
in_fence = False
|
||||
fence_marker = ""
|
||||
output: list[str] = []
|
||||
|
||||
for line in lines:
|
||||
body = line[:-1] if line.endswith("\n") else line
|
||||
newline = "\n" if line.endswith("\n") else ""
|
||||
if body.endswith("\r"):
|
||||
body = body[:-1]
|
||||
newline = "\r" + newline
|
||||
|
||||
fence_match = FENCE_RE.match(body)
|
||||
if fence_match:
|
||||
marker = fence_match.group(2)
|
||||
marker_char = marker[0]
|
||||
if not in_fence:
|
||||
in_fence = True
|
||||
fence_marker = marker_char * len(marker)
|
||||
elif marker_char == fence_marker[0] and len(marker) >= len(fence_marker):
|
||||
in_fence = False
|
||||
fence_marker = ""
|
||||
output.append(line)
|
||||
continue
|
||||
|
||||
if in_fence:
|
||||
output.append(line)
|
||||
continue
|
||||
|
||||
heading_match = HEADING_RE.match(body)
|
||||
if heading_match:
|
||||
indent, hashes, rest = heading_match.groups()
|
||||
new_level = min(6, len(hashes) + levels)
|
||||
output.append(f"{indent}{'#' * new_level}{rest}{newline}")
|
||||
else:
|
||||
output.append(line)
|
||||
|
||||
return "".join(output)
|
||||
|
||||
|
||||
def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Shift Markdown ATX headings down by adding # characters."
|
||||
)
|
||||
parser.add_argument("path", type=pathlib.Path, help="Markdown file to rewrite")
|
||||
parser.add_argument(
|
||||
"-l",
|
||||
"--levels",
|
||||
type=int,
|
||||
default=2,
|
||||
help="Number of heading levels to add. Default: 2",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-o",
|
||||
"--output",
|
||||
type=pathlib.Path,
|
||||
help="Write to this file instead of modifying the source file",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Print converted content to stdout without writing files",
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
args = parse_args(argv)
|
||||
|
||||
if args.levels <= 0:
|
||||
print("error: --levels must be a positive integer", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
try:
|
||||
source = args.path.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
print(f"error: cannot read {args.path}: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
fixed = shift_headings(source, args.levels)
|
||||
|
||||
if args.dry_run:
|
||||
sys.stdout.write(fixed)
|
||||
return 0
|
||||
|
||||
destination = args.output or args.path
|
||||
try:
|
||||
destination.write_text(fixed, encoding="utf-8")
|
||||
except OSError as exc:
|
||||
print(f"error: cannot write {destination}: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
|
|
@ -0,0 +1,354 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Internal helpers for semantic Markdown heading repair plans.
|
||||
|
||||
The semantic decision belongs to the agent using this skill. This script keeps
|
||||
the file rewrite narrow: it only changes ATX heading marker depth at explicit
|
||||
line numbers from a reviewed plan.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
HEADING_RE = re.compile(r"^( {0,3})(#{1,6})([ \t]+)(.*)$")
|
||||
FENCE_RE = re.compile(r"^( {0,3})(`{3,}|~{3,})")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Heading:
|
||||
line: int
|
||||
level: int
|
||||
text: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Edit:
|
||||
line: int
|
||||
from_level: int | None
|
||||
to_level: int
|
||||
reason: str
|
||||
|
||||
|
||||
def _split_body_newline(line: str) -> tuple[str, str]:
|
||||
if line.endswith("\r\n"):
|
||||
return line[:-2], "\r\n"
|
||||
if line.endswith("\n"):
|
||||
return line[:-1], "\n"
|
||||
if line.endswith("\r"):
|
||||
return line[:-1], "\r"
|
||||
return line, ""
|
||||
|
||||
|
||||
def _clean_heading_text(raw: str) -> str:
|
||||
text = raw.strip()
|
||||
closing = re.match(r"^(.*?)(?:[ \t]+#{1,}[ \t]*)$", text)
|
||||
if closing:
|
||||
text = closing.group(1).rstrip()
|
||||
return text
|
||||
|
||||
|
||||
def iter_headings(lines: list[str]) -> list[Heading]:
|
||||
headings: list[Heading] = []
|
||||
in_fence = False
|
||||
fence_char = ""
|
||||
fence_len = 0
|
||||
|
||||
for index, line in enumerate(lines, start=1):
|
||||
body, _newline = _split_body_newline(line)
|
||||
fence_match = FENCE_RE.match(body)
|
||||
if fence_match:
|
||||
marker = fence_match.group(2)
|
||||
marker_char = marker[0]
|
||||
if not in_fence:
|
||||
in_fence = True
|
||||
fence_char = marker_char
|
||||
fence_len = len(marker)
|
||||
elif marker_char == fence_char and len(marker) >= fence_len:
|
||||
in_fence = False
|
||||
fence_char = ""
|
||||
fence_len = 0
|
||||
continue
|
||||
|
||||
if in_fence:
|
||||
continue
|
||||
|
||||
heading_match = HEADING_RE.match(body)
|
||||
if not heading_match:
|
||||
continue
|
||||
|
||||
hashes = heading_match.group(2)
|
||||
text = _clean_heading_text(heading_match.group(4))
|
||||
headings.append(Heading(line=index, level=len(hashes), text=text))
|
||||
|
||||
return headings
|
||||
|
||||
|
||||
def inspect_payload(path: pathlib.Path) -> dict[str, Any]:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
lines = text.splitlines(keepends=True)
|
||||
return {
|
||||
"source": str(path),
|
||||
"heading_count": len(iter_headings(lines)),
|
||||
"headings": [
|
||||
{"line": item.line, "level": item.level, "text": item.text}
|
||||
for item in iter_headings(lines)
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def render_inspection(payload: dict[str, Any], output_format: str) -> str:
|
||||
if output_format == "json":
|
||||
return json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
|
||||
if output_format == "markdown":
|
||||
return format_markdown_inspection(payload)
|
||||
raise ValueError("output_format must be 'json' or 'markdown'")
|
||||
|
||||
|
||||
def write_inspection(
|
||||
source: pathlib.Path,
|
||||
output: pathlib.Path,
|
||||
output_format: str = "markdown",
|
||||
) -> None:
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_text(
|
||||
render_inspection(inspect_payload(source), output_format),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def format_markdown_inspection(payload: dict[str, Any]) -> str:
|
||||
output = [
|
||||
"# Markdown Heading Inspection",
|
||||
"",
|
||||
f"- Source: `{payload['source']}`",
|
||||
f"- Heading count: {payload['heading_count']}",
|
||||
"",
|
||||
"| Line | Level | Text |",
|
||||
"| --- | --- | --- |",
|
||||
]
|
||||
for heading in payload["headings"]:
|
||||
output.append(
|
||||
f"| {heading['line']} | {heading['level']} | {heading['text']} |"
|
||||
)
|
||||
output.append("")
|
||||
return "\n".join(output)
|
||||
|
||||
|
||||
def _unique_stem(path: pathlib.Path, seen: dict[str, int]) -> str:
|
||||
base = path.stem
|
||||
count = seen.get(base, 0) + 1
|
||||
seen[base] = count
|
||||
if count == 1:
|
||||
return base
|
||||
return f"{base}-{count}"
|
||||
|
||||
|
||||
def _batch_item_paths(
|
||||
source: pathlib.Path,
|
||||
mode: str,
|
||||
output_dir: pathlib.Path,
|
||||
stem: str,
|
||||
) -> dict[str, str]:
|
||||
return {
|
||||
"source": str(source),
|
||||
"mode": mode,
|
||||
"heading_map": str(output_dir / f"{stem}.heading-map.json"),
|
||||
"plan": str(output_dir / f"{stem}.heading-plan.json"),
|
||||
"fixed": str(output_dir / f"{stem}.fixed.md"),
|
||||
"report": str(output_dir / f"{stem}.heading-report.md"),
|
||||
}
|
||||
|
||||
|
||||
def prepare_batch_scaffold(
|
||||
files: list[pathlib.Path],
|
||||
mode: str,
|
||||
output_dir: pathlib.Path,
|
||||
) -> list[dict[str, str]]:
|
||||
if mode not in {"discussion", "artifact"}:
|
||||
raise ValueError("mode must be 'discussion' or 'artifact'")
|
||||
if not files:
|
||||
raise ValueError("files must contain at least one Markdown path")
|
||||
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
seen: dict[str, int] = {}
|
||||
items: list[dict[str, str]] = []
|
||||
|
||||
for source in files:
|
||||
source = pathlib.Path(source)
|
||||
if not source.exists():
|
||||
raise ValueError(f"source file does not exist: {source}")
|
||||
stem = _unique_stem(source, seen)
|
||||
item = _batch_item_paths(source, mode, output_dir, stem)
|
||||
write_inspection(source, pathlib.Path(item["heading_map"]), "json")
|
||||
pathlib.Path(item["plan"]).write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"mode": mode,
|
||||
"source": item["source"],
|
||||
"output": item["fixed"],
|
||||
"report": item["report"],
|
||||
"edits": [],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
items.append(item)
|
||||
|
||||
write_batch_report(items, output_dir / "fix-title-batch-report.md")
|
||||
return items
|
||||
|
||||
|
||||
def write_batch_report(items: list[dict[str, str]], output: pathlib.Path) -> None:
|
||||
lines = [
|
||||
"# Fix Title Batch Report",
|
||||
"",
|
||||
"| Source | Mode | Heading Map | Plan | Fixed | Report |",
|
||||
"| --- | --- | --- | --- | --- | --- |",
|
||||
]
|
||||
for item in items:
|
||||
lines.append(
|
||||
"| {source} | {mode} | {heading_map} | {plan} | {fixed} | {report} |".format(
|
||||
**item
|
||||
)
|
||||
)
|
||||
lines.append("")
|
||||
output.write_text("\n".join(lines), encoding="utf-8")
|
||||
|
||||
|
||||
def parse_plan(plan_path: pathlib.Path) -> tuple[str, list[Edit]]:
|
||||
try:
|
||||
payload = json.loads(plan_path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"invalid JSON plan {plan_path}: {exc}") from exc
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("plan must be a JSON object")
|
||||
|
||||
mode = str(payload.get("mode", "unspecified"))
|
||||
raw_edits = payload.get("edits")
|
||||
if not isinstance(raw_edits, list):
|
||||
raise ValueError("plan must contain an edits list")
|
||||
|
||||
edits: list[Edit] = []
|
||||
seen_lines: set[int] = set()
|
||||
for index, raw_edit in enumerate(raw_edits, start=1):
|
||||
if not isinstance(raw_edit, dict):
|
||||
raise ValueError(f"edit {index} must be an object")
|
||||
line = raw_edit.get("line")
|
||||
from_level = raw_edit.get("from_level")
|
||||
to_level = raw_edit.get("to_level")
|
||||
reason = raw_edit.get("reason", "")
|
||||
|
||||
if not isinstance(line, int) or line <= 0:
|
||||
raise ValueError(f"edit {index} has invalid line")
|
||||
if line in seen_lines:
|
||||
raise ValueError(f"duplicate edit for line {line}")
|
||||
seen_lines.add(line)
|
||||
|
||||
if from_level is not None and (
|
||||
not isinstance(from_level, int) or not 1 <= from_level <= 6
|
||||
):
|
||||
raise ValueError(f"edit {index} has invalid from_level")
|
||||
if not isinstance(to_level, int) or not 1 <= to_level <= 6:
|
||||
raise ValueError(f"edit {index} has invalid to_level")
|
||||
if not isinstance(reason, str):
|
||||
raise ValueError(f"edit {index} has invalid reason")
|
||||
|
||||
edits.append(
|
||||
Edit(
|
||||
line=line,
|
||||
from_level=from_level,
|
||||
to_level=to_level,
|
||||
reason=reason.strip(),
|
||||
)
|
||||
)
|
||||
|
||||
return mode, edits
|
||||
|
||||
|
||||
def apply_plan(
|
||||
source: pathlib.Path,
|
||||
plan: pathlib.Path,
|
||||
output: pathlib.Path,
|
||||
report: pathlib.Path | None,
|
||||
) -> int:
|
||||
lines = source.read_text(encoding="utf-8").splitlines(keepends=True)
|
||||
mode, edits = parse_plan(plan)
|
||||
headings_by_line = {heading.line: heading for heading in iter_headings(lines)}
|
||||
changed: list[dict[str, Any]] = []
|
||||
|
||||
for edit in edits:
|
||||
heading = headings_by_line.get(edit.line)
|
||||
if heading is None:
|
||||
raise ValueError(f"line {edit.line} is not an editable ATX heading")
|
||||
if edit.from_level is not None and heading.level != edit.from_level:
|
||||
raise ValueError(
|
||||
f"line {edit.line} expected level {edit.from_level}, "
|
||||
f"found level {heading.level}"
|
||||
)
|
||||
|
||||
body, newline = _split_body_newline(lines[edit.line - 1])
|
||||
heading_match = HEADING_RE.match(body)
|
||||
if heading_match is None:
|
||||
raise ValueError(f"line {edit.line} is not an editable ATX heading")
|
||||
|
||||
indent, _hashes, spacing, rest = heading_match.groups()
|
||||
lines[edit.line - 1] = f"{indent}{'#' * edit.to_level}{spacing}{rest}{newline}"
|
||||
changed.append(
|
||||
{
|
||||
"line": edit.line,
|
||||
"from_level": heading.level,
|
||||
"to_level": edit.to_level,
|
||||
"text": heading.text,
|
||||
"reason": edit.reason,
|
||||
}
|
||||
)
|
||||
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_text("".join(lines), encoding="utf-8")
|
||||
|
||||
if report is not None:
|
||||
report.parent.mkdir(parents=True, exist_ok=True)
|
||||
report.write_text(
|
||||
format_report(source, output, plan, mode, changed),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
return len(changed)
|
||||
|
||||
|
||||
def format_report(
|
||||
source: pathlib.Path,
|
||||
output: pathlib.Path,
|
||||
plan: pathlib.Path,
|
||||
mode: str,
|
||||
changed: list[dict[str, Any]],
|
||||
) -> str:
|
||||
lines = [
|
||||
"# Semantic Heading Repair Report",
|
||||
"",
|
||||
f"- Source: `{source}`",
|
||||
f"- Output: `{output}`",
|
||||
f"- Plan: `{plan}`",
|
||||
f"- Mode: `{mode}`",
|
||||
f"- Changed headings: {len(changed)}",
|
||||
"",
|
||||
"| Line | From | To | Heading | Reason |",
|
||||
"| --- | --- | --- | --- | --- |",
|
||||
]
|
||||
for item in changed:
|
||||
lines.append(
|
||||
"| {line} | {from_level} | {to_level} | {text} | {reason} |".format(
|
||||
**item
|
||||
)
|
||||
)
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
import pathlib
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
SCRIPT = pathlib.Path(__file__).resolve().parents[1] / "scripts" / "fix_markdown_titles.py"
|
||||
|
||||
|
||||
def run_cli(*args):
|
||||
return subprocess.run(
|
||||
[sys.executable, str(SCRIPT), *args],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
class FixMarkdownTitlesTest(unittest.TestCase):
|
||||
def test_shifts_atx_headings_and_leaves_code_blocks(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
target = pathlib.Path(tmp) / "reply.md"
|
||||
target.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"Intro",
|
||||
"",
|
||||
"# One",
|
||||
"## Two ##",
|
||||
"```md",
|
||||
"# Not a heading",
|
||||
"```",
|
||||
"> # quoted content",
|
||||
" # indented code",
|
||||
"###### Six",
|
||||
"",
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = run_cli(str(target), "--levels", "2")
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(
|
||||
target.read_text(encoding="utf-8"),
|
||||
"\n".join(
|
||||
[
|
||||
"Intro",
|
||||
"",
|
||||
"### One",
|
||||
"#### Two ##",
|
||||
"```md",
|
||||
"# Not a heading",
|
||||
"```",
|
||||
"> # quoted content",
|
||||
" # indented code",
|
||||
"###### Six",
|
||||
"",
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
def test_dry_run_prints_without_writing(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
target = pathlib.Path(tmp) / "reply.md"
|
||||
target.write_text("# One\n", encoding="utf-8")
|
||||
|
||||
result = run_cli(str(target), "--levels", "1", "--dry-run")
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(result.stdout, "## One\n")
|
||||
self.assertEqual(target.read_text(encoding="utf-8"), "# One\n")
|
||||
|
||||
def test_output_writes_to_separate_file(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
source = pathlib.Path(tmp) / "reply.md"
|
||||
output = pathlib.Path(tmp) / "fixed.md"
|
||||
source.write_text("# One\n", encoding="utf-8")
|
||||
|
||||
result = run_cli(str(source), "--levels", "3", "--output", str(output))
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(source.read_text(encoding="utf-8"), "# One\n")
|
||||
self.assertEqual(output.read_text(encoding="utf-8"), "#### One\n")
|
||||
|
||||
def test_rejects_non_positive_levels(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
target = pathlib.Path(tmp) / "reply.md"
|
||||
target.write_text("# One\n", encoding="utf-8")
|
||||
|
||||
result = run_cli(str(target), "--levels", "0")
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("positive integer", result.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -0,0 +1,192 @@
|
|||
import importlib.util
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
SCRIPT = (
|
||||
pathlib.Path(__file__).resolve().parents[1]
|
||||
/ "scripts"
|
||||
/ "semantic_heading_repair.py"
|
||||
)
|
||||
|
||||
SPEC = importlib.util.spec_from_file_location("semantic_heading_repair", SCRIPT)
|
||||
repair = importlib.util.module_from_spec(SPEC)
|
||||
assert SPEC.loader is not None
|
||||
sys.modules[SPEC.name] = repair
|
||||
SPEC.loader.exec_module(repair)
|
||||
|
||||
|
||||
class SemanticHeadingRepairTest(unittest.TestCase):
|
||||
def test_inspect_reports_only_real_atx_headings(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
source = pathlib.Path(tmp) / "source.md"
|
||||
source.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"# Title",
|
||||
"",
|
||||
"## Section",
|
||||
"```md",
|
||||
"# Not a heading",
|
||||
"```",
|
||||
"> # quoted content",
|
||||
" # indented code",
|
||||
"### Child",
|
||||
"",
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
payload = repair.inspect_payload(source)
|
||||
|
||||
self.assertEqual(
|
||||
[(item["line"], item["level"], item["text"]) for item in payload["headings"]],
|
||||
[(1, 1, "Title"), (3, 2, "Section"), (9, 3, "Child")],
|
||||
)
|
||||
|
||||
def test_write_inspection_file_uses_utf8(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
source = pathlib.Path(tmp) / "source.md"
|
||||
output = pathlib.Path(tmp) / "heading-map.json"
|
||||
source.write_text("# 标题\n", encoding="utf-8")
|
||||
|
||||
repair.write_inspection(source, output, output_format="json")
|
||||
|
||||
payload = json.loads(output.read_text(encoding="utf-8"))
|
||||
self.assertEqual(payload["headings"][0]["text"], "标题")
|
||||
|
||||
def test_apply_plan_rewrites_only_requested_heading_levels(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
source = pathlib.Path(tmp) / "source.md"
|
||||
output = pathlib.Path(tmp) / "fixed.md"
|
||||
report = pathlib.Path(tmp) / "report.md"
|
||||
plan = pathlib.Path(tmp) / "plan.json"
|
||||
source.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"# 1",
|
||||
"",
|
||||
"## GPT",
|
||||
"",
|
||||
"# Wrong parent",
|
||||
"",
|
||||
"## Wrong child",
|
||||
"",
|
||||
"```md",
|
||||
"# Not a heading",
|
||||
"```",
|
||||
"",
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
plan.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"mode": "discussion",
|
||||
"edits": [
|
||||
{
|
||||
"line": 5,
|
||||
"from_level": 1,
|
||||
"to_level": 3,
|
||||
"reason": "GPT reply content belongs under ## GPT.",
|
||||
},
|
||||
{
|
||||
"line": 7,
|
||||
"from_level": 2,
|
||||
"to_level": 4,
|
||||
"reason": "Child heading follows the repaired parent.",
|
||||
},
|
||||
],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
changed = repair.apply_plan(source, plan, output, report)
|
||||
|
||||
self.assertEqual(changed, 2)
|
||||
self.assertEqual(
|
||||
output.read_text(encoding="utf-8"),
|
||||
"\n".join(
|
||||
[
|
||||
"# 1",
|
||||
"",
|
||||
"## GPT",
|
||||
"",
|
||||
"### Wrong parent",
|
||||
"",
|
||||
"#### Wrong child",
|
||||
"",
|
||||
"```md",
|
||||
"# Not a heading",
|
||||
"```",
|
||||
"",
|
||||
]
|
||||
),
|
||||
)
|
||||
report_text = report.read_text(encoding="utf-8")
|
||||
self.assertIn("| 5 | 1 | 3 | Wrong parent |", report_text)
|
||||
self.assertIn("GPT reply content belongs under ## GPT.", report_text)
|
||||
|
||||
def test_apply_plan_rejects_stale_or_wrong_source_plans(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
source = pathlib.Path(tmp) / "source.md"
|
||||
plan = pathlib.Path(tmp) / "plan.json"
|
||||
source.write_text("# Title\n\n## Section\n", encoding="utf-8")
|
||||
plan.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"edits": [
|
||||
{
|
||||
"line": 3,
|
||||
"from_level": 1,
|
||||
"to_level": 3,
|
||||
"reason": "This plan was made for another source.",
|
||||
}
|
||||
]
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "expected level 1"):
|
||||
repair.apply_plan(source, plan, pathlib.Path(tmp) / "fixed.md", None)
|
||||
|
||||
def test_prepare_batch_scaffold_accepts_file_array(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = pathlib.Path(tmp)
|
||||
first = root / "one.md"
|
||||
second = root / "two.md"
|
||||
output_dir = root / "out"
|
||||
first.write_text("# One\n\n# Bad\n", encoding="utf-8")
|
||||
second.write_text("# Two\n\n## Good\n", encoding="utf-8")
|
||||
|
||||
items = repair.prepare_batch_scaffold(
|
||||
[first, second],
|
||||
mode="artifact",
|
||||
output_dir=output_dir,
|
||||
)
|
||||
|
||||
self.assertEqual(len(items), 2)
|
||||
for item in items:
|
||||
self.assertEqual(item["mode"], "artifact")
|
||||
self.assertTrue(pathlib.Path(item["heading_map"]).exists())
|
||||
self.assertTrue(pathlib.Path(item["plan"]).exists())
|
||||
self.assertTrue(str(item["fixed"]).endswith(".fixed.md"))
|
||||
plan_payload = json.loads(pathlib.Path(item["plan"]).read_text(encoding="utf-8"))
|
||||
self.assertEqual(plan_payload["mode"], "artifact")
|
||||
self.assertEqual(plan_payload["edits"], [])
|
||||
|
||||
batch_report = output_dir / "fix-title-batch-report.md"
|
||||
self.assertIn("one.md", batch_report.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Reference in New Issue