feat: 导出 SproutClaw .sproutclaw 配置
包含 extensions、skills、prompts、settings、auth、models、mcp 等配置。 排除 node_modules、npm 缓存、sessions 等运行时数据。
This commit is contained in:
@@ -0,0 +1,363 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Analyze a project and produce facts used by the copyright material workflow."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from common import COPYRIGHT_CODE_EXTS, FRONTEND_EXTS, count_text_lines, is_known_config_file, iter_project_files, normalize_title, read_json, read_text, rel, write_json
|
||||
|
||||
|
||||
DEPENDENCY_FRAMEWORKS = {
|
||||
"vue": "Vue",
|
||||
"@vue/runtime-core": "Vue",
|
||||
"react": "React",
|
||||
"next": "Next.js",
|
||||
"nuxt": "Nuxt",
|
||||
"svelte": "Svelte",
|
||||
"astro": "Astro",
|
||||
"@angular/core": "Angular",
|
||||
"vite": "Vite",
|
||||
"uni-app": "UniApp",
|
||||
"@dcloudio/uni-app": "UniApp",
|
||||
"electron": "Electron",
|
||||
"@tauri-apps/api": "Tauri",
|
||||
}
|
||||
|
||||
ENTRY_NAMES = {
|
||||
"main.ts",
|
||||
"main.js",
|
||||
"main.tsx",
|
||||
"main.jsx",
|
||||
"index.tsx",
|
||||
"index.jsx",
|
||||
"app.vue",
|
||||
"App.vue",
|
||||
"app.tsx",
|
||||
}
|
||||
|
||||
|
||||
def load_package(project: Path) -> tuple[dict[str, Any] | None, Path | None]:
|
||||
candidates = [
|
||||
project / "package.json",
|
||||
project / "frontend/package.json",
|
||||
project / "client/package.json",
|
||||
project / "web/package.json",
|
||||
project / "app/package.json",
|
||||
]
|
||||
for package_path in candidates:
|
||||
if not package_path.exists():
|
||||
continue
|
||||
try:
|
||||
return read_json(package_path), package_path
|
||||
except Exception:
|
||||
continue
|
||||
return None, None
|
||||
|
||||
|
||||
def detect_frameworks(package: dict[str, Any] | None, files: list[Path], project: Path) -> list[str]:
|
||||
found: set[str] = set()
|
||||
deps: dict[str, Any] = {}
|
||||
if package:
|
||||
for key in ("dependencies", "devDependencies", "peerDependencies"):
|
||||
deps.update(package.get(key) or {})
|
||||
for dep in deps:
|
||||
if dep in DEPENDENCY_FRAMEWORKS:
|
||||
found.add(DEPENDENCY_FRAMEWORKS[dep])
|
||||
suffixes = {p.suffix.lower() for p in files}
|
||||
if ".vue" in suffixes:
|
||||
found.add("Vue")
|
||||
if ".tsx" in suffixes or ".jsx" in suffixes:
|
||||
if "Vue" not in found:
|
||||
found.add("React")
|
||||
if (project / "vite.config.ts").exists() or (project / "vite.config.js").exists():
|
||||
found.add("Vite")
|
||||
if (project / "next.config.js").exists() or (project / "next.config.ts").exists() or any(p.name in {"next.config.js", "next.config.ts"} for p in files):
|
||||
found.add("Next.js")
|
||||
return sorted(found)
|
||||
|
||||
|
||||
def classify(path: Path, project: Path) -> str:
|
||||
r = rel(path, project).lower()
|
||||
name = path.name
|
||||
if name in ENTRY_NAMES or r in {"src/app/page.tsx", "src/app/layout.tsx", "app/page.tsx", "app/layout.tsx"} or r.endswith("/src/app/page.tsx") or r.endswith("/src/app/layout.tsx"):
|
||||
return "entry"
|
||||
if path.suffix.lower() in {".css", ".scss", ".sass", ".less"}:
|
||||
return "style"
|
||||
if any(part in r for part in ("/router/", "/routes/", "router.", "routes.")):
|
||||
return "route"
|
||||
if any(part in r for part in ("/pages/", "/views/", "/app/", "/screens/")):
|
||||
return "page"
|
||||
if "/components/" in r:
|
||||
return "component"
|
||||
if any(part in r for part in ("/api/", "/apis/", "/services/", "request.", "/request/", "/controllers/", "/handlers/", "/views.py")):
|
||||
return "api"
|
||||
if any(part in r for part in ("/models/", "/schemas/", "/entities/", "/repositories/", "/dao/")):
|
||||
return "model"
|
||||
if any(part in r for part in ("/store/", "/stores/", "/pinia/", "/redux/", "/zustand/")):
|
||||
return "state"
|
||||
if any(part in r for part in ("/utils/", "/lib/", "/hooks/", "/composables/", "/helpers/")):
|
||||
return "utility"
|
||||
return "source"
|
||||
|
||||
|
||||
def extract_route_paths(path: Path) -> list[str]:
|
||||
try:
|
||||
text = read_text(path, limit=200_000)
|
||||
except Exception:
|
||||
return []
|
||||
patterns = [
|
||||
r"path\s*:\s*['\"]([^'\"]+)['\"]",
|
||||
r"<Route[^>]+path=['\"]([^'\"]+)['\"]",
|
||||
r"href=['\"](/[^'\"]*)['\"]",
|
||||
]
|
||||
routes: list[str] = []
|
||||
for pattern in patterns:
|
||||
for match in re.findall(pattern, text):
|
||||
if match.startswith("/") and len(match) < 120 and "*" not in match:
|
||||
routes.append(match)
|
||||
return routes
|
||||
|
||||
|
||||
def summarize_readme(project: Path) -> str:
|
||||
for name in ("README.md", "README.zh.md", "readme.md", "Readme.md"):
|
||||
path = project / name
|
||||
if path.exists():
|
||||
text = read_text(path, limit=4000)
|
||||
return "\n".join(line.strip() for line in text.splitlines()[:60] if line.strip())
|
||||
return ""
|
||||
|
||||
|
||||
def analyze(project: Path) -> dict[str, Any]:
|
||||
project = project.resolve()
|
||||
package, package_path = load_package(project)
|
||||
source_files = [p for p in iter_project_files(project, COPYRIGHT_CODE_EXTS) if not is_known_config_file(p)]
|
||||
frontend_files = [p for p in source_files if p.suffix.lower() in FRONTEND_EXTS]
|
||||
class_counts: Counter[str] = Counter()
|
||||
extension_counts: Counter[str] = Counter()
|
||||
source_lines = 0
|
||||
total_source_lines = 0
|
||||
categorized: dict[str, list[str]] = {
|
||||
"entry": [],
|
||||
"route": [],
|
||||
"page": [],
|
||||
"component": [],
|
||||
"api": [],
|
||||
"model": [],
|
||||
"state": [],
|
||||
"utility": [],
|
||||
"style": [],
|
||||
"source": [],
|
||||
}
|
||||
route_paths: list[str] = ["/"]
|
||||
|
||||
for path in source_files:
|
||||
category = classify(path, project)
|
||||
class_counts[category] += 1
|
||||
extension_counts[path.suffix.lower()] += 1
|
||||
categorized[category].append(rel(path, project))
|
||||
source_lines += count_text_lines(path, skip_blank=False)
|
||||
if path.suffix.lower() in FRONTEND_EXTS and category in {"route", "page", "entry"}:
|
||||
route_paths.extend(extract_route_paths(path))
|
||||
|
||||
total_source_lines = source_lines
|
||||
|
||||
package_name = ""
|
||||
scripts: dict[str, str] = {}
|
||||
dependencies: dict[str, str] = {}
|
||||
if package:
|
||||
package_name = str(package.get("name") or "")
|
||||
scripts = {k: str(v) for k, v in (package.get("scripts") or {}).items()}
|
||||
for key in ("dependencies", "devDependencies"):
|
||||
dependencies.update({k: str(v) for k, v in (package.get(key) or {}).items()})
|
||||
|
||||
frameworks = detect_frameworks(package, frontend_files, project)
|
||||
language = infer_language(extension_counts, frameworks)
|
||||
route_paths = sorted(set(route_paths), key=lambda x: (x.count("/"), x))
|
||||
|
||||
return {
|
||||
"project_root": str(project),
|
||||
"project_name": project.name,
|
||||
"software_name_candidate": normalize_title(package_name or project.name),
|
||||
"package": {
|
||||
"name": package_name,
|
||||
"path": rel(package_path, project) if package_path else "",
|
||||
"version": str(package.get("version") or "V1.0") if package else "V1.0",
|
||||
"scripts": scripts,
|
||||
"dependency_names": sorted(dependencies),
|
||||
},
|
||||
"frameworks": frameworks,
|
||||
"language": language,
|
||||
"source": {
|
||||
"file_count": len(source_files),
|
||||
"line_count": source_lines,
|
||||
"total_file_count": len(source_files),
|
||||
"total_line_count": total_source_lines,
|
||||
"extension_counts": dict(sorted(extension_counts.items())),
|
||||
"category_counts": dict(sorted(class_counts.items())),
|
||||
"categorized_files": {k: v[:80] for k, v in categorized.items() if v},
|
||||
},
|
||||
"routes": route_paths[:80],
|
||||
"readme_excerpt": summarize_readme(project),
|
||||
"run_command_candidates": infer_run_commands(scripts),
|
||||
"feature_candidates": infer_features(categorized, route_paths),
|
||||
}
|
||||
|
||||
|
||||
def infer_workdir(out: Path) -> Path:
|
||||
if out.parent.name == "analysis":
|
||||
return out.parent.parent
|
||||
return out.parent
|
||||
|
||||
|
||||
def check_environment_gate(out: Path) -> None:
|
||||
workdir = infer_workdir(out)
|
||||
env_path = workdir / "环境检查.json"
|
||||
if not env_path.exists():
|
||||
return
|
||||
env = read_json(env_path)
|
||||
if not env.get("requires_user_input"):
|
||||
return
|
||||
confirmation_path = workdir / "环境确认.json"
|
||||
confirmed = False
|
||||
if confirmation_path.exists():
|
||||
confirmed = bool(read_json(confirmation_path).get("environment_confirmed"))
|
||||
if not confirmed:
|
||||
raise SystemExit(
|
||||
"STOP_FOR_USER\n"
|
||||
"NEXT_ACTION: 完整 DOCX 环境未确认。请先让用户选择安装完整环境或使用基础 DOCX 兜底继续,"
|
||||
"然后运行 `python3 <SKILL_DIR>/scripts/confirm_stage.py --workdir 软件著作权申请资料 --stage environment --note \"<用户选择>\"`。"
|
||||
)
|
||||
|
||||
|
||||
def infer_language(extension_counts: Counter[str], frameworks: list[str]) -> str:
|
||||
langs: list[str] = []
|
||||
if extension_counts.get(".ts") or extension_counts.get(".tsx"):
|
||||
langs.append("TypeScript")
|
||||
if extension_counts.get(".js") or extension_counts.get(".jsx"):
|
||||
langs.append("JavaScript")
|
||||
language_by_ext = {
|
||||
".py": "Python",
|
||||
".java": "Java",
|
||||
".go": "Go",
|
||||
".rs": "Rust",
|
||||
".cs": "C#",
|
||||
".php": "PHP",
|
||||
".rb": "Ruby",
|
||||
".kt": "Kotlin",
|
||||
".swift": "Swift",
|
||||
".sql": "SQL",
|
||||
".sh": "Shell",
|
||||
}
|
||||
for ext, label in language_by_ext.items():
|
||||
if extension_counts.get(ext):
|
||||
langs.append(label)
|
||||
if not langs:
|
||||
langs = [ext.lstrip(".").upper() for ext, _ in extension_counts.most_common(3) if ext]
|
||||
return "、".join(dict.fromkeys(langs)) or "待用户确认"
|
||||
|
||||
|
||||
def infer_run_commands(scripts: dict[str, str]) -> list[str]:
|
||||
preferred = ["dev", "start", "serve", "preview"]
|
||||
commands = []
|
||||
for name in preferred:
|
||||
if name in scripts:
|
||||
commands.append(f"npm run {name}")
|
||||
return commands
|
||||
|
||||
|
||||
def infer_features(categorized: dict[str, list[str]], routes: list[str]) -> list[str]:
|
||||
stop = {
|
||||
"index",
|
||||
"main",
|
||||
"app",
|
||||
"layout",
|
||||
"page",
|
||||
"globals",
|
||||
"providers",
|
||||
"loading",
|
||||
"error",
|
||||
"not-found",
|
||||
"template",
|
||||
"default",
|
||||
"button",
|
||||
"input",
|
||||
"label",
|
||||
"avatar",
|
||||
"card",
|
||||
"textarea",
|
||||
"scroll area",
|
||||
}
|
||||
names: list[str] = []
|
||||
for route in routes:
|
||||
cleaned = route.strip("/").replace("-", " ").replace("_", " ")
|
||||
if cleaned and not cleaned.startswith(":") and cleaned.lower() not in stop:
|
||||
names.append(cleaned)
|
||||
for file in categorized.get("page", [])[:60]:
|
||||
route_name = feature_from_page_path(file)
|
||||
if route_name and route_name.lower() not in stop:
|
||||
names.append(route_name)
|
||||
for category in ("api", "component"):
|
||||
for file in categorized.get(category, [])[:30]:
|
||||
lowered = file.lower()
|
||||
if "/ui/" in lowered or "/components/ui/" in lowered:
|
||||
continue
|
||||
stem = Path(file).stem
|
||||
normalized = stem.replace("-", " ").replace("_", " ").strip()
|
||||
if normalized.lower() not in stop:
|
||||
names.append(normalized)
|
||||
unique: list[str] = []
|
||||
for name in names:
|
||||
normalized = re.sub(r"\s+", " ", name).strip()
|
||||
if normalized and normalized not in unique:
|
||||
unique.append(normalized)
|
||||
return unique[:30]
|
||||
|
||||
|
||||
def feature_from_page_path(file: str) -> str:
|
||||
parts = Path(file).parts
|
||||
useful: list[str] = []
|
||||
for part in parts:
|
||||
if part in {"src", "app", "pages", "views", "screens", "frontend", "client", "web"}:
|
||||
continue
|
||||
if part.startswith("(") and part.endswith(")"):
|
||||
continue
|
||||
stem = Path(part).stem
|
||||
if stem in {"page", "layout", "index", "route", "loading", "error", "globals", "providers"}:
|
||||
continue
|
||||
if stem.startswith("[") and stem.endswith("]"):
|
||||
continue
|
||||
useful.append(stem)
|
||||
return " ".join(useful[-2:]).replace("-", " ").replace("_", " ").strip()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--project", required=True, help="Project root directory")
|
||||
parser.add_argument("--out", default="软件著作权申请资料/analysis/project.json")
|
||||
args = parser.parse_args()
|
||||
|
||||
project = Path(args.project)
|
||||
if not project.exists():
|
||||
raise SystemExit(f"Project not found: {project}")
|
||||
|
||||
out = Path(args.out)
|
||||
check_environment_gate(out)
|
||||
result = analyze(project)
|
||||
write_json(out, result)
|
||||
print(f"OK analysis: {out}")
|
||||
print(f"Project: {result['project_name']}")
|
||||
print(f"Frameworks: {', '.join(result['frameworks']) or 'unknown'}")
|
||||
print(f"Language: {result['language']}")
|
||||
print(f"Source files: {result['source']['file_count']}")
|
||||
print(f"Source lines: {result['source']['line_count']}")
|
||||
print(f"Total source files: {result['source']['total_file_count']}")
|
||||
print(f"Total source lines: {result['source']['total_line_count']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,861 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build final DOCX/TXT files from confirmed Markdown drafts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import html
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from common import ensure_dir, read_json, safe_filename
|
||||
|
||||
try:
|
||||
from docx import Document
|
||||
from docx.enum.section import WD_SECTION
|
||||
from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_LINE_SPACING
|
||||
from docx.oxml import OxmlElement
|
||||
from docx.oxml.ns import qn
|
||||
from docx.shared import Cm, Inches, Pt, RGBColor
|
||||
|
||||
DOCX_AVAILABLE = True
|
||||
except Exception:
|
||||
DOCX_AVAILABLE = False
|
||||
|
||||
|
||||
BLACK_RGB = "000000"
|
||||
|
||||
|
||||
def strip_markdown_links(text: str) -> str:
|
||||
text = re.sub(r"(?<!!)\[([^\]]+)\]\(([^)]+)\)", r"\1", text)
|
||||
text = re.sub(r"<(https?://[^>]+)>", r"\1", text)
|
||||
return text
|
||||
|
||||
|
||||
def parse_application_lines(md_path: Path) -> tuple[list[str], list[str]]:
|
||||
lines = md_path.read_text(encoding="utf-8").splitlines()
|
||||
fields = [line.strip() for line in lines if line.strip().startswith("➤")]
|
||||
warnings = [line for line in fields if "待用户确认" in line]
|
||||
return fields, warnings
|
||||
|
||||
|
||||
def parse_application_field(md_path: Path, field_name: str) -> str:
|
||||
if not md_path.exists():
|
||||
return ""
|
||||
prefix = f"➤{field_name}:"
|
||||
for line in md_path.read_text(encoding="utf-8").splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith(prefix):
|
||||
return stripped[len(prefix) :].strip()
|
||||
return ""
|
||||
|
||||
|
||||
def application_version(draft_dir: Path) -> str:
|
||||
version = parse_application_field(draft_dir / "申请表信息.md", "版本号")
|
||||
if "待用户确认" in version:
|
||||
return ""
|
||||
return version
|
||||
|
||||
|
||||
def application_software_name(draft_dir: Path) -> str:
|
||||
name = parse_application_field(draft_dir / "申请表信息.md", "软件全称")
|
||||
if "待用户确认" in name:
|
||||
return ""
|
||||
return name
|
||||
|
||||
|
||||
def write_application_txt(draft_dir: Path, out_dir: Path) -> tuple[Path | None, list[str]]:
|
||||
md_path = draft_dir / "申请表信息.md"
|
||||
if not md_path.exists():
|
||||
return None, ["缺少草稿/申请表信息.md"]
|
||||
fields, warnings = parse_application_lines(md_path)
|
||||
out_path = out_dir / "申请表信息.txt"
|
||||
out_path.write_text("\n".join(fields) + "\n", encoding="utf-8")
|
||||
return out_path, warnings
|
||||
|
||||
|
||||
def read_json_if_exists(path: Path) -> dict[str, Any]:
|
||||
if not path.exists():
|
||||
return {}
|
||||
return read_json(path)
|
||||
|
||||
|
||||
def confirmation_issues(workdir: Path) -> list[str]:
|
||||
draft_dir = workdir / "草稿"
|
||||
issues: list[str] = []
|
||||
|
||||
business = read_json_if_exists(draft_dir / "业务理解.json")
|
||||
if not business or not business.get("user_confirmed"):
|
||||
issues.append("业务理解尚未确认:请确认 草稿/业务理解.md 后记录 `business` 门禁")
|
||||
|
||||
selection = read_json_if_exists(draft_dir / "代码文件选择.json")
|
||||
if not selection or not selection.get("user_confirmed"):
|
||||
issues.append("代码文件选择尚未确认:请确认 草稿/代码文件选择.json 后记录 `code-selection` 门禁")
|
||||
|
||||
screenshot = read_json_if_exists(workdir / "截图方式确认.json")
|
||||
if not screenshot.get("screenshot_method_confirmed"):
|
||||
issues.append("截图方式尚未确认:请选择截图方式后记录 `screenshot-method` 门禁")
|
||||
|
||||
app_md = draft_dir / "申请表信息.md"
|
||||
if app_md.exists():
|
||||
_, warnings = parse_application_lines(app_md)
|
||||
if warnings:
|
||||
issues.append("申请表信息仍包含“待用户确认”字段")
|
||||
else:
|
||||
issues.append("缺少 草稿/申请表信息.md")
|
||||
|
||||
app_confirmation = read_json_if_exists(draft_dir / "申请表字段确认.json")
|
||||
if not app_confirmation.get("application_fields_confirmed"):
|
||||
issues.append("申请表字段尚未确认:请补全字段后记录 `application-fields` 门禁")
|
||||
|
||||
markdown_confirmation = read_json_if_exists(draft_dir / "最终生成确认.json")
|
||||
if not markdown_confirmation.get("markdown_confirmed"):
|
||||
issues.append("Markdown 草稿尚未最终确认:请确认全部草稿后记录 `markdown` 门禁")
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def parse_code_pages(md_path: Path) -> list[tuple[int, list[str]]]:
|
||||
pages: list[tuple[int, list[str]]] = []
|
||||
current_no: int | None = None
|
||||
current_lines: list[str] = []
|
||||
in_fence = False
|
||||
|
||||
for raw in md_path.read_text(encoding="utf-8").splitlines():
|
||||
page_match = re.match(r"^##\s+第\s*(\d+)\s*页", raw.strip())
|
||||
if page_match:
|
||||
if current_no is not None:
|
||||
pages.append((current_no, current_lines))
|
||||
current_no = int(page_match.group(1))
|
||||
current_lines = []
|
||||
in_fence = False
|
||||
continue
|
||||
if raw.strip().startswith("```"):
|
||||
in_fence = not in_fence
|
||||
continue
|
||||
if current_no is not None and in_fence:
|
||||
current_lines.append(raw)
|
||||
|
||||
if current_no is not None:
|
||||
pages.append((current_no, current_lines))
|
||||
return pages
|
||||
|
||||
|
||||
def set_run_font(run: Any, name: str, size_pt: float) -> None:
|
||||
run.font.name = name
|
||||
run.font.size = Pt(size_pt)
|
||||
try:
|
||||
run.font.color.rgb = RGBColor(0, 0, 0)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
run._element.rPr.rFonts.set(qn("w:eastAsia"), name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def set_normal_font(document: Any, name: str = "SimSun", size_pt: float = 10.5) -> None:
|
||||
style = document.styles["Normal"]
|
||||
style.font.name = name
|
||||
style.font.size = Pt(size_pt)
|
||||
try:
|
||||
style.font.color.rgb = RGBColor(0, 0, 0)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
style._element.rPr.rFonts.set(qn("w:eastAsia"), name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def set_style_black(document: Any) -> None:
|
||||
for style_name in ("Normal", "Heading 1", "Heading 2", "Heading 3", "List Bullet", "List Number"):
|
||||
try:
|
||||
document.styles[style_name].font.color.rgb = RGBColor(0, 0, 0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def force_black_document(document: Any) -> None:
|
||||
set_style_black(document)
|
||||
containers = [document]
|
||||
for section in document.sections:
|
||||
containers.extend([section.header, section.footer])
|
||||
for container in containers:
|
||||
for paragraph in container.paragraphs:
|
||||
for run in paragraph.runs:
|
||||
try:
|
||||
run.font.color.rgb = RGBColor(0, 0, 0)
|
||||
except Exception:
|
||||
pass
|
||||
for table in container.tables:
|
||||
for row in table.rows:
|
||||
for cell in row.cells:
|
||||
for paragraph in cell.paragraphs:
|
||||
for run in paragraph.runs:
|
||||
try:
|
||||
run.font.color.rgb = RGBColor(0, 0, 0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def configure_a4(document: Any) -> None:
|
||||
section = document.sections[0]
|
||||
section.page_width = Cm(21)
|
||||
section.page_height = Cm(29.7)
|
||||
section.top_margin = Cm(2.54)
|
||||
section.bottom_margin = Cm(2.54)
|
||||
section.left_margin = Cm(3.17)
|
||||
section.right_margin = Cm(2.54)
|
||||
|
||||
|
||||
def configure_code_a4(document: Any) -> None:
|
||||
section = document.sections[0]
|
||||
section.page_width = Cm(21)
|
||||
section.page_height = Cm(29.7)
|
||||
section.top_margin = Cm(2.0)
|
||||
section.bottom_margin = Cm(2.0)
|
||||
section.left_margin = Cm(2.5)
|
||||
section.right_margin = Cm(2.0)
|
||||
|
||||
|
||||
def add_page_field(paragraph: Any) -> None:
|
||||
begin = OxmlElement("w:fldChar")
|
||||
begin.set(qn("w:fldCharType"), "begin")
|
||||
instr = OxmlElement("w:instrText")
|
||||
instr.set("{http://www.w3.org/XML/1998/namespace}space", "preserve")
|
||||
instr.text = " PAGE "
|
||||
separate = OxmlElement("w:fldChar")
|
||||
separate.set(qn("w:fldCharType"), "separate")
|
||||
result = OxmlElement("w:t")
|
||||
result.text = "1"
|
||||
end = OxmlElement("w:fldChar")
|
||||
end.set(qn("w:fldCharType"), "end")
|
||||
|
||||
for element in (begin, instr, separate, result, end):
|
||||
run = paragraph.add_run()
|
||||
run._r.append(element)
|
||||
set_run_font(run, "SimSun", 8)
|
||||
|
||||
|
||||
def set_code_header(document: Any, software_name: str, version: str) -> None:
|
||||
section = document.sections[0]
|
||||
section.header.is_linked_to_previous = False
|
||||
header = section.header
|
||||
header.paragraphs[0].text = "" if header.paragraphs else None
|
||||
|
||||
# Build a two-column header: software name on the left, page number on the right.
|
||||
table = header.add_table(rows=1, cols=2, width=Cm(17.5))
|
||||
table.autofit = True
|
||||
left_cell = table.rows[0].cells[0]
|
||||
right_cell = table.rows[0].cells[1]
|
||||
|
||||
left_para = left_cell.paragraphs[0]
|
||||
left_para.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
||||
left_para.paragraph_format.space_before = Pt(0)
|
||||
left_para.paragraph_format.space_after = Pt(0)
|
||||
left_para.paragraph_format.line_spacing_rule = WD_LINE_SPACING.EXACTLY
|
||||
left_para.paragraph_format.line_spacing = Pt(12)
|
||||
left_run = left_para.add_run(f"{software_name} {version}")
|
||||
set_run_font(left_run, "SimSun", 8)
|
||||
|
||||
right_para = right_cell.paragraphs[0]
|
||||
right_para.alignment = WD_ALIGN_PARAGRAPH.RIGHT
|
||||
right_para.paragraph_format.space_before = Pt(0)
|
||||
right_para.paragraph_format.space_after = Pt(0)
|
||||
right_para.paragraph_format.line_spacing_rule = WD_LINE_SPACING.EXACTLY
|
||||
right_para.paragraph_format.line_spacing = Pt(12)
|
||||
prefix = right_para.add_run("第 ")
|
||||
set_run_font(prefix, "SimSun", 8)
|
||||
add_page_field(right_para)
|
||||
suffix = right_para.add_run(" 页")
|
||||
set_run_font(suffix, "SimSun", 8)
|
||||
|
||||
# Remove borders from the header table
|
||||
for cell in table.rows[0].cells:
|
||||
tc_pr = cell._tc.get_or_add_tcPr()
|
||||
tc_borders = OxmlElement("w:tcBorders")
|
||||
for border_name in ("top", "left", "bottom", "right"):
|
||||
border = OxmlElement(f"w:{border_name}")
|
||||
border.set(qn("w:val"), "nil")
|
||||
tc_borders.append(border)
|
||||
tc_pr.append(tc_borders)
|
||||
|
||||
|
||||
def build_code_docx_python(md_path: Path, out_path: Path, software_name: str, version: str) -> None:
|
||||
pages = parse_code_pages(md_path)
|
||||
if not pages:
|
||||
raise RuntimeError(f"No code pages parsed from {md_path}")
|
||||
|
||||
document = Document()
|
||||
configure_code_a4(document)
|
||||
set_normal_font(document, "Consolas", 7.2)
|
||||
set_style_black(document)
|
||||
set_code_header(document, software_name, version)
|
||||
|
||||
# 后30页文档页码从31开始,实现前后文档连续编号1-60
|
||||
start_page_no = pages[0][0] if pages else 1
|
||||
if start_page_no != 1:
|
||||
pg_num_type = OxmlElement("w:pgNumType")
|
||||
pg_num_type.set(qn("w:start"), str(start_page_no))
|
||||
document.sections[0]._sectPr.append(pg_num_type)
|
||||
|
||||
for index, (page_no, lines) in enumerate(pages):
|
||||
for line in lines:
|
||||
p = document.add_paragraph()
|
||||
p.paragraph_format.space_before = Pt(0)
|
||||
p.paragraph_format.space_after = Pt(0)
|
||||
p.paragraph_format.line_spacing_rule = WD_LINE_SPACING.EXACTLY
|
||||
p.paragraph_format.line_spacing = Pt(14)
|
||||
run = p.add_run(line if line else " ")
|
||||
set_run_font(run, "Consolas", 7.2)
|
||||
if index != len(pages) - 1:
|
||||
# 嵌入式分页符:避免 add_page_break() 产生多余空段落导致空白页
|
||||
run = p.add_run()
|
||||
br = OxmlElement('w:br')
|
||||
br.set(qn('w:type'), 'page')
|
||||
run._r.append(br)
|
||||
|
||||
force_black_document(document)
|
||||
document.save(out_path)
|
||||
|
||||
|
||||
def paragraph_xml(text: str, font: str = "SimSun", size_half_points: int = 21, align: str | None = None, line_twips: int = 240) -> str:
|
||||
align_xml = f'<w:jc w:val="{align}"/>' if align else ""
|
||||
escaped = html.escape(text)
|
||||
return (
|
||||
"<w:p>"
|
||||
f"<w:pPr>{align_xml}<w:spacing w:after=\"0\" w:line=\"{line_twips}\" w:lineRule=\"exact\"/></w:pPr>"
|
||||
"<w:r>"
|
||||
f"<w:rPr><w:rFonts w:ascii=\"{font}\" w:hAnsi=\"{font}\" w:eastAsia=\"{font}\"/>"
|
||||
f"<w:color w:val=\"{BLACK_RGB}\"/>"
|
||||
f"<w:sz w:val=\"{size_half_points}\"/><w:szCs w:val=\"{size_half_points}\"/></w:rPr>"
|
||||
f"<w:t xml:space=\"preserve\">{escaped}</w:t>"
|
||||
"</w:r>"
|
||||
"</w:p>"
|
||||
)
|
||||
|
||||
|
||||
def page_break_xml() -> str:
|
||||
return '<w:p><w:r><w:br w:type="page"/></w:r></w:p>'
|
||||
|
||||
|
||||
def page_field_runs_xml() -> str:
|
||||
return (
|
||||
'<w:r><w:rPr><w:rFonts w:ascii="SimSun" w:hAnsi="SimSun" w:eastAsia="SimSun"/>'
|
||||
f'<w:color w:val="{BLACK_RGB}"/><w:sz w:val="16"/><w:szCs w:val="16"/></w:rPr>'
|
||||
'<w:fldChar w:fldCharType="begin"/></w:r>'
|
||||
'<w:r><w:rPr><w:rFonts w:ascii="SimSun" w:hAnsi="SimSun" w:eastAsia="SimSun"/>'
|
||||
f'<w:color w:val="{BLACK_RGB}"/><w:sz w:val="16"/><w:szCs w:val="16"/></w:rPr>'
|
||||
'<w:instrText xml:space="preserve"> PAGE </w:instrText></w:r>'
|
||||
'<w:r><w:rPr><w:rFonts w:ascii="SimSun" w:hAnsi="SimSun" w:eastAsia="SimSun"/>'
|
||||
f'<w:color w:val="{BLACK_RGB}"/><w:sz w:val="16"/><w:szCs w:val="16"/></w:rPr>'
|
||||
'<w:fldChar w:fldCharType="separate"/></w:r>'
|
||||
'<w:r><w:rPr><w:rFonts w:ascii="SimSun" w:hAnsi="SimSun" w:eastAsia="SimSun"/>'
|
||||
f'<w:color w:val="{BLACK_RGB}"/><w:sz w:val="16"/><w:szCs w:val="16"/></w:rPr>'
|
||||
'<w:t>1</w:t></w:r>'
|
||||
'<w:r><w:rPr><w:rFonts w:ascii="SimSun" w:hAnsi="SimSun" w:eastAsia="SimSun"/>'
|
||||
f'<w:color w:val="{BLACK_RGB}"/><w:sz w:val="16"/><w:szCs w:val="16"/></w:rPr>'
|
||||
'<w:fldChar w:fldCharType="end"/></w:r>'
|
||||
)
|
||||
|
||||
|
||||
def header_xml(header_text: str) -> str:
|
||||
"""Build a two-column header: software name left, page number right."""
|
||||
escaped = html.escape(header_text)
|
||||
# Use a borderless table for left/right alignment in header
|
||||
return f"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<w:hdr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:tbl>
|
||||
<w:tblPr>
|
||||
<w:tblW w:w="5000" w:type="pct"/>
|
||||
<w:tblBorders>
|
||||
<w:top w:val="nil"/><w:left w:val="nil"/><w:bottom w:val="nil"/><w:right w:val="nil"/><w:insideH w:val="nil"/><w:insideV w:val="nil"/>
|
||||
</w:tblBorders>
|
||||
</w:tblPr>
|
||||
<w:tr>
|
||||
<w:tc>
|
||||
<w:p>
|
||||
<w:pPr><w:jc w:val="left"/><w:spacing w:after="0" w:line="240" w:lineRule="exact"/></w:pPr>
|
||||
<w:r><w:rPr><w:rFonts w:ascii="SimSun" w:hAnsi="SimSun" w:eastAsia="SimSun"/><w:color w:val="{BLACK_RGB}"/><w:sz w:val="16"/><w:szCs w:val="16"/></w:rPr><w:t xml:space="preserve">{escaped}</w:t></w:r>
|
||||
</w:p>
|
||||
</w:tc>
|
||||
<w:tc>
|
||||
<w:p>
|
||||
<w:pPr><w:jc w:val="right"/><w:spacing w:after="0" w:line="240" w:lineRule="exact"/></w:pPr>
|
||||
<w:r><w:rPr><w:rFonts w:ascii="SimSun" w:hAnsi="SimSun" w:eastAsia="SimSun"/><w:color w:val="{BLACK_RGB}"/><w:sz w:val="16"/><w:szCs w:val="16"/></w:rPr><w:t xml:space="preserve">第 </w:t></w:r>
|
||||
{page_field_runs_xml()}
|
||||
<w:r><w:rPr><w:rFonts w:ascii="SimSun" w:hAnsi="SimSun" w:eastAsia="SimSun"/><w:color w:val="{BLACK_RGB}"/><w:sz w:val="16"/><w:szCs w:val="16"/></w:rPr><w:t xml:space="preserve"> 页</w:t></w:r>
|
||||
</w:p>
|
||||
</w:tc>
|
||||
</w:tr>
|
||||
</w:tbl>
|
||||
</w:hdr>"""
|
||||
|
||||
|
||||
def minimal_docx(out_path: Path, body_xml: str, header_text: str | None = None, start_page: int = 1) -> None:
|
||||
content_types = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||
<Default Extension="xml" ContentType="application/xml"/>
|
||||
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
|
||||
<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>
|
||||
<Override PartName="/word/header1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml"/>
|
||||
</Types>"""
|
||||
rels = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
|
||||
</Relationships>"""
|
||||
header_rel = (
|
||||
'<Relationship Id="rIdHeader1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="header1.xml"/>'
|
||||
if header_text
|
||||
else ""
|
||||
)
|
||||
doc_rels = f"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">{header_rel}</Relationships>"""
|
||||
styles = f"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:style w:type="paragraph" w:default="1" w:styleId="Normal">
|
||||
<w:name w:val="Normal"/>
|
||||
<w:rPr><w:rFonts w:ascii="SimSun" w:hAnsi="SimSun" w:eastAsia="SimSun"/><w:color w:val="{BLACK_RGB}"/><w:sz w:val="21"/></w:rPr>
|
||||
</w:style>
|
||||
</w:styles>"""
|
||||
document = f"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
|
||||
<w:body>
|
||||
{body_xml}
|
||||
<w:sectPr>
|
||||
{'<w:headerReference w:type="default" r:id="rIdHeader1"/>' if header_text else ''}
|
||||
{'<w:pgNumType w:start="' + str(start_page) + '"/>' if start_page != 1 else ''}
|
||||
<w:pgSz w:w="11906" w:h="16838"/>
|
||||
<w:pgMar w:top="1134" w:right="1134" w:bottom="1134" w:left="1418" w:header="283" w:footer="283" w:gutter="0"/>
|
||||
</w:sectPr>
|
||||
</w:body>
|
||||
</w:document>"""
|
||||
with zipfile.ZipFile(out_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.writestr("[Content_Types].xml", content_types)
|
||||
zf.writestr("_rels/.rels", rels)
|
||||
zf.writestr("word/_rels/document.xml.rels", doc_rels)
|
||||
zf.writestr("word/styles.xml", styles)
|
||||
zf.writestr("word/document.xml", document)
|
||||
if header_text:
|
||||
zf.writestr("word/header1.xml", header_xml(header_text))
|
||||
|
||||
|
||||
def force_black_xml(xml: str) -> str:
|
||||
xml = re.sub(r"<w:hyperlink\b[^>]*>", "", xml)
|
||||
xml = xml.replace("</w:hyperlink>", "")
|
||||
xml = re.sub(r"<w:color\b[^>]*/>", f'<w:color w:val="{BLACK_RGB}"/>', xml)
|
||||
|
||||
def ensure_rpr_color(match: re.Match[str]) -> str:
|
||||
value = match.group(0)
|
||||
if "<w:color" in value:
|
||||
return value
|
||||
return value.replace("</w:rPr>", f'<w:color w:val="{BLACK_RGB}"/></w:rPr>')
|
||||
|
||||
xml = re.sub(r"<w:rPr\b[^>]*>.*?</w:rPr>", ensure_rpr_color, xml, flags=re.S)
|
||||
xml = re.sub(r"<w:r>(?!<w:rPr>)", f'<w:r><w:rPr><w:color w:val="{BLACK_RGB}"/></w:rPr>', xml)
|
||||
return xml
|
||||
|
||||
|
||||
def normalize_docx_text_color(docx_path: Path) -> None:
|
||||
tmp_path = docx_path.with_suffix(docx_path.suffix + ".tmp")
|
||||
color_xml_parts = (
|
||||
"word/document.xml",
|
||||
"word/styles.xml",
|
||||
"word/numbering.xml",
|
||||
"word/header",
|
||||
"word/footer",
|
||||
)
|
||||
with zipfile.ZipFile(docx_path, "r") as src, zipfile.ZipFile(tmp_path, "w", compression=zipfile.ZIP_DEFLATED) as dst:
|
||||
for item in src.infolist():
|
||||
data = src.read(item.filename)
|
||||
if item.filename.endswith(".xml") and item.filename.startswith(color_xml_parts):
|
||||
text = data.decode("utf-8")
|
||||
data = force_black_xml(text).encode("utf-8")
|
||||
elif item.filename.endswith(".rels"):
|
||||
text = data.decode("utf-8", errors="ignore")
|
||||
if "hyperlink" in text:
|
||||
text = re.sub(r'\s*<Relationship\b[^>]*Type="[^"]*/hyperlink"[^>]*/>', "", text)
|
||||
data = text.encode("utf-8")
|
||||
dst.writestr(item, data)
|
||||
tmp_path.replace(docx_path)
|
||||
|
||||
|
||||
def next_header_part(names: set[str]) -> tuple[str, str]:
|
||||
index = 1
|
||||
while f"word/header{index}.xml" in names:
|
||||
index += 1
|
||||
return f"word/header{index}.xml", f"header{index}.xml"
|
||||
|
||||
|
||||
def unique_relationship_id(rels_xml: str, base: str = "rIdManualHeader") -> str:
|
||||
if f'Id="{base}"' not in rels_xml:
|
||||
return base
|
||||
index = 2
|
||||
while f'Id="{base}{index}"' in rels_xml:
|
||||
index += 1
|
||||
return f"{base}{index}"
|
||||
|
||||
|
||||
def add_header_to_existing_docx(docx_path: Path, header_text: str) -> None:
|
||||
"""Add the same two-column header used by code materials to an existing DOCX."""
|
||||
tmp_path = docx_path.with_suffix(docx_path.suffix + ".tmp")
|
||||
with zipfile.ZipFile(docx_path, "r") as src:
|
||||
names = set(src.namelist())
|
||||
header_part, header_target = next_header_part(names)
|
||||
rels_xml = src.read("word/_rels/document.xml.rels").decode("utf-8")
|
||||
rel_id = unique_relationship_id(rels_xml)
|
||||
|
||||
with zipfile.ZipFile(tmp_path, "w", compression=zipfile.ZIP_DEFLATED) as dst:
|
||||
for item in src.infolist():
|
||||
data = src.read(item.filename)
|
||||
if item.filename == "[Content_Types].xml":
|
||||
text = data.decode("utf-8")
|
||||
override = (
|
||||
f'<Override PartName="/{header_part}" '
|
||||
'ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml"/>'
|
||||
)
|
||||
if f'PartName="/{header_part}"' not in text:
|
||||
text = text.replace("</Types>", f"{override}</Types>")
|
||||
data = text.encode("utf-8")
|
||||
elif item.filename == "word/_rels/document.xml.rels":
|
||||
text = data.decode("utf-8")
|
||||
relationship = (
|
||||
f'<Relationship Id="{rel_id}" '
|
||||
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" '
|
||||
f'Target="{header_target}"/>'
|
||||
)
|
||||
text = text.replace("</Relationships>", f"{relationship}</Relationships>")
|
||||
data = text.encode("utf-8")
|
||||
elif item.filename == "word/document.xml":
|
||||
text = data.decode("utf-8")
|
||||
if "xmlns:r=" not in text:
|
||||
text = text.replace(
|
||||
"<w:document ",
|
||||
'<w:document xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" ',
|
||||
1,
|
||||
)
|
||||
header_ref = f'<w:headerReference w:type="default" r:id="{rel_id}"/>'
|
||||
if "<w:headerReference" in text:
|
||||
text = re.sub(r"<w:headerReference\b[^>]*/>", header_ref, text, count=1)
|
||||
else:
|
||||
text = re.sub(r"(<w:sectPr\b[^>]*>)", rf"\1{header_ref}", text, count=1)
|
||||
data = text.encode("utf-8")
|
||||
dst.writestr(item, data)
|
||||
dst.writestr(header_part, header_xml(header_text))
|
||||
tmp_path.replace(docx_path)
|
||||
|
||||
|
||||
def build_code_docx_ooxml(md_path: Path, out_path: Path, software_name: str, version: str) -> None:
|
||||
pages = parse_code_pages(md_path)
|
||||
if not pages:
|
||||
raise RuntimeError(f"No code pages parsed from {md_path}")
|
||||
start_page_no = pages[0][0] if pages else 1
|
||||
body: list[str] = []
|
||||
for index, (page_no, lines) in enumerate(pages):
|
||||
for line in lines:
|
||||
body.append(paragraph_xml(line if line else " ", font="Consolas", size_half_points=14, line_twips=280))
|
||||
if index != len(pages) - 1:
|
||||
# 嵌入式分页符:嵌入最后一段的 run 避免多余空段落
|
||||
last = body.pop()
|
||||
last = last.replace('</w:r></w:p>', '<w:br w:type="page"/></w:r></w:p>')
|
||||
body.append(last)
|
||||
minimal_docx(out_path, "\n".join(body), header_text=f"{software_name} {version}", start_page=start_page_no)
|
||||
|
||||
|
||||
def add_markdown_table(document: Any, rows: list[list[str]]) -> None:
|
||||
if not rows:
|
||||
return
|
||||
table = document.add_table(rows=1, cols=len(rows[0]))
|
||||
table.style = "Table Grid"
|
||||
for idx, text in enumerate(rows[0]):
|
||||
table.rows[0].cells[idx].text = strip_markdown_links(text)
|
||||
for row in rows[1:]:
|
||||
cells = table.add_row().cells
|
||||
for idx, text in enumerate(row[: len(cells)]):
|
||||
cells[idx].text = strip_markdown_links(text)
|
||||
|
||||
|
||||
def parse_table_line(line: str) -> list[str]:
|
||||
return [cell.strip() for cell in line.strip().strip("|").split("|")]
|
||||
|
||||
|
||||
def add_image(document: Any, image_path: Path) -> None:
|
||||
if not image_path.exists():
|
||||
p = document.add_paragraph()
|
||||
run = p.add_run(f"[截图缺失:{image_path}]")
|
||||
set_run_font(run, "SimSun", 10.5)
|
||||
return
|
||||
try:
|
||||
document.add_picture(str(image_path), width=Inches(5.8))
|
||||
except Exception:
|
||||
p = document.add_paragraph()
|
||||
run = p.add_run(f"[截图无法插入:{image_path}]")
|
||||
set_run_font(run, "SimSun", 10.5)
|
||||
|
||||
|
||||
def build_manual_docx_python(md_path: Path, out_path: Path, base_dir: Path, software_name: str, version: str) -> None:
|
||||
document = Document()
|
||||
configure_a4(document)
|
||||
set_normal_font(document, "SimSun", 10.5)
|
||||
set_style_black(document)
|
||||
set_code_header(document, software_name, version)
|
||||
lines = md_path.read_text(encoding="utf-8").splitlines()
|
||||
table_buf: list[list[str]] = []
|
||||
in_fence = False
|
||||
|
||||
def flush_table() -> None:
|
||||
nonlocal table_buf
|
||||
if table_buf:
|
||||
data = [row for row in table_buf if not all(re.fullmatch(r":?-{3,}:?", cell) for cell in row)]
|
||||
add_markdown_table(document, data)
|
||||
table_buf = []
|
||||
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
stripped = strip_markdown_links(stripped)
|
||||
if stripped.startswith("```"):
|
||||
flush_table()
|
||||
in_fence = not in_fence
|
||||
continue
|
||||
if in_fence:
|
||||
continue
|
||||
if stripped.startswith("<!--") and "截图" in stripped:
|
||||
stripped = "【截图预留:请在此处插入当前功能页面或操作结果截图。】"
|
||||
if stripped.startswith("|") and stripped.endswith("|"):
|
||||
table_buf.append(parse_table_line(stripped))
|
||||
continue
|
||||
flush_table()
|
||||
if not stripped:
|
||||
continue
|
||||
image_match = re.search(r"!\[[^\]]*\]\(([^)]+)\)", stripped)
|
||||
if image_match:
|
||||
add_image(document, (base_dir / image_match.group(1)).resolve())
|
||||
continue
|
||||
heading = re.match(r"^(#{1,4})\s+(.+)$", stripped)
|
||||
if heading:
|
||||
level = min(len(heading.group(1)), 3)
|
||||
p = document.add_heading(heading.group(2), level=level)
|
||||
for run in p.runs:
|
||||
try:
|
||||
run.font.color.rgb = RGBColor(0, 0, 0)
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
if re.match(r"^[-*+]\s+", stripped):
|
||||
p = document.add_paragraph(style="List Bullet")
|
||||
run = p.add_run(re.sub(r"^[-*+]\s+", "", stripped))
|
||||
set_run_font(run, "SimSun", 10.5)
|
||||
continue
|
||||
if re.match(r"^\d+\.\s+", stripped):
|
||||
p = document.add_paragraph(style="List Number")
|
||||
run = p.add_run(re.sub(r"^\d+\.\s+", "", stripped))
|
||||
set_run_font(run, "SimSun", 10.5)
|
||||
continue
|
||||
p = document.add_paragraph()
|
||||
run = p.add_run(stripped)
|
||||
set_run_font(run, "SimSun", 10.5)
|
||||
flush_table()
|
||||
force_black_document(document)
|
||||
document.save(out_path)
|
||||
|
||||
|
||||
def pandoc_available() -> bool:
|
||||
return shutil.which("pandoc") is not None
|
||||
|
||||
|
||||
def build_with_pandoc(md_path: Path, out_path: Path, code_mode: bool = False) -> None:
|
||||
if not pandoc_available():
|
||||
raise RuntimeError("python-docx is unavailable and pandoc is not installed")
|
||||
source = md_path
|
||||
tmp_name: str | None = None
|
||||
original_text = md_path.read_text(encoding="utf-8")
|
||||
text = original_text
|
||||
text = re.sub(r"```text\s*\nSTOP_FOR_USER\n.*?```", "", text, flags=re.S)
|
||||
text = re.sub(r"<!--[^>]*截图[^>]*-->", "【截图预留:请在此处插入当前功能页面或操作结果截图。】", text)
|
||||
text = strip_markdown_links(text)
|
||||
if code_mode:
|
||||
text = re.sub(r"(?=^##\s+第\s*\d+\s*页)", r"\n\\newpage\n", text, flags=re.M)
|
||||
if code_mode or "STOP_FOR_USER" in original_text:
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False, encoding="utf-8") as tmp:
|
||||
tmp.write(text)
|
||||
tmp_name = tmp.name
|
||||
source = Path(tmp_name)
|
||||
try:
|
||||
subprocess.run(["pandoc", "-f", "markdown", "-t", "docx", str(source), "-o", str(out_path)], check=True)
|
||||
finally:
|
||||
if tmp_name:
|
||||
Path(tmp_name).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def build_code_docx(md_path: Path, out_path: Path, software_name: str, version: str) -> None:
|
||||
if DOCX_AVAILABLE:
|
||||
build_code_docx_python(md_path, out_path, software_name, version)
|
||||
else:
|
||||
build_code_docx_ooxml(md_path, out_path, software_name, version)
|
||||
normalize_docx_text_color(out_path)
|
||||
|
||||
|
||||
def build_manual_docx(md_path: Path, out_path: Path, base_dir: Path, software_name: str, version: str) -> None:
|
||||
if DOCX_AVAILABLE:
|
||||
build_manual_docx_python(md_path, out_path, base_dir, software_name, version)
|
||||
else:
|
||||
build_with_pandoc(md_path, out_path, code_mode=False)
|
||||
add_header_to_existing_docx(out_path, f"{software_name} {version}")
|
||||
normalize_docx_text_color(out_path)
|
||||
|
||||
|
||||
def run_command(command: list[str], cwd: Path | None = None, timeout: int = 60) -> tuple[int, str]:
|
||||
try:
|
||||
completed = subprocess.run(command, cwd=cwd, text=True, capture_output=True, timeout=timeout)
|
||||
return completed.returncode, (completed.stdout + completed.stderr).strip()
|
||||
except Exception as exc:
|
||||
return 99, str(exc)
|
||||
|
||||
|
||||
def docx_checks(skill_dir: Path, outputs: list[Path]) -> list[str]:
|
||||
notes: list[str] = []
|
||||
env_script = skill_dir / "vendor/docx-toolkit/scripts/env_check.sh"
|
||||
preview_script = skill_dir / "vendor/docx-toolkit/scripts/docx_preview.sh"
|
||||
if env_script.exists():
|
||||
code, output = run_command(["bash", str(env_script)], cwd=env_script.parent.parent, timeout=30)
|
||||
status = "READY" if code == 0 else "NOT READY"
|
||||
first_lines = "\n".join(output.splitlines()[:12])
|
||||
notes.append(f"DOCX env: {status}\n\n```text\n{first_lines}\n```")
|
||||
else:
|
||||
notes.append("DOCX env: vendor script missing")
|
||||
|
||||
if preview_script.exists():
|
||||
for out in outputs:
|
||||
code, output = run_command(["bash", str(preview_script), str(out)], timeout=45)
|
||||
first_lines = "\n".join(output.splitlines()[:8])
|
||||
notes.append(f"Preview {out.name}: exit={code}\n\n```text\n{first_lines}\n```")
|
||||
return notes
|
||||
|
||||
|
||||
def build_all(workdir: Path, software_name: str, version: str, skip_preview: bool) -> dict[str, Any]:
|
||||
workdir = ensure_dir(workdir)
|
||||
draft_dir = workdir / "草稿"
|
||||
final_dir = ensure_dir(workdir / "正式资料")
|
||||
app_name = application_software_name(draft_dir)
|
||||
app_version = application_version(draft_dir)
|
||||
final_software_name = app_name or software_name
|
||||
final_version = app_version or version
|
||||
safe_name = safe_filename(final_software_name)
|
||||
outputs: list[Path] = []
|
||||
warnings: list[str] = []
|
||||
if app_name and app_name != software_name:
|
||||
warnings.append(f"命令参数软件名称为 {software_name},正式资料已按申请表信息软件名称 {app_name} 生成")
|
||||
if app_version and app_version != version:
|
||||
warnings.append(f"命令参数版本号为 {version},正式资料已按申请表信息版本号 {app_version} 生成")
|
||||
screenshot_confirmation = read_json_if_exists(workdir / "截图方式确认.json")
|
||||
screenshot_method = screenshot_confirmation.get("screenshot_method")
|
||||
screenshot_manifest = workdir / "截图/截图清单.json"
|
||||
if screenshot_method == "skip":
|
||||
warnings.append("用户选择暂不截图;操作手册已保留截图预留位置")
|
||||
elif screenshot_method and not screenshot_manifest.exists():
|
||||
warnings.append("操作手册截图未生成或未插入;操作手册应保留截图预留位置")
|
||||
elif screenshot_manifest.exists():
|
||||
screenshots = read_json_if_exists(screenshot_manifest).get("screenshots") or []
|
||||
if not screenshots:
|
||||
warnings.append("操作手册截图清单为空;操作手册应保留截图预留位置")
|
||||
|
||||
app_txt, app_warnings = write_application_txt(draft_dir, final_dir)
|
||||
if app_txt:
|
||||
outputs.append(app_txt)
|
||||
warnings.extend(app_warnings)
|
||||
|
||||
code_specs = [
|
||||
("代码-前30页.md", f"{safe_name}-代码(前30页).docx"),
|
||||
("代码-后30页.md", f"{safe_name}-代码(后30页).docx"),
|
||||
("代码-全部.md", f"{safe_name}-代码(全部).docx"),
|
||||
]
|
||||
for md_name, docx_name in code_specs:
|
||||
md_path = draft_dir / md_name
|
||||
if md_path.exists():
|
||||
out_path = final_dir / docx_name
|
||||
build_code_docx(md_path, out_path, final_software_name, final_version)
|
||||
outputs.append(out_path)
|
||||
|
||||
manual_md = draft_dir / "操作手册.md"
|
||||
if manual_md.exists():
|
||||
manual_out = final_dir / f"{safe_name}_操作手册.docx"
|
||||
manual_source = manual_md
|
||||
tmp_manual: Path | None = None
|
||||
if app_name and app_name != software_name:
|
||||
text = manual_md.read_text(encoding="utf-8").replace(software_name, app_name)
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False, encoding="utf-8") as tmp:
|
||||
tmp.write(text)
|
||||
tmp_manual = Path(tmp.name)
|
||||
manual_source = tmp_manual
|
||||
try:
|
||||
build_manual_docx(manual_source, manual_out, draft_dir, final_software_name, final_version)
|
||||
finally:
|
||||
if tmp_manual:
|
||||
tmp_manual.unlink(missing_ok=True)
|
||||
outputs.append(manual_out)
|
||||
else:
|
||||
warnings.append("缺少草稿/操作手册.md")
|
||||
|
||||
skill_dir = Path(__file__).resolve().parents[1]
|
||||
notes = [] if skip_preview else docx_checks(skill_dir, [p for p in outputs if p.suffix.lower() == ".docx"])
|
||||
report = write_report(final_dir, outputs, warnings, notes)
|
||||
return {"outputs": [str(p) for p in outputs], "warnings": warnings, "report": str(report)}
|
||||
|
||||
|
||||
def write_report(workdir: Path, outputs: list[Path], warnings: list[str], notes: list[str]) -> Path:
|
||||
report = workdir / "生成报告.md"
|
||||
lines = ["# 生成报告", "", "## 输出文件", ""]
|
||||
for path in outputs:
|
||||
size = path.stat().st_size if path.exists() else 0
|
||||
lines.append(f"- `{path.name}` ({size} bytes)")
|
||||
lines.extend(["", "## 警告", ""])
|
||||
if warnings:
|
||||
lines.extend(f"- {warning}" for warning in warnings)
|
||||
else:
|
||||
lines.append("- 无")
|
||||
lines.extend(["", "## DOCX 校验", ""])
|
||||
if notes:
|
||||
lines.extend(notes)
|
||||
else:
|
||||
lines.append("- 已跳过预览校验")
|
||||
report.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
return report
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--workdir", default="软件著作权申请资料")
|
||||
parser.add_argument("--software-name", required=True)
|
||||
parser.add_argument("--version", default="V1.0")
|
||||
parser.add_argument("--skip-preview", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
workdir = Path(args.workdir)
|
||||
issues = confirmation_issues(workdir)
|
||||
if issues:
|
||||
print("STOP_FOR_USER")
|
||||
print("NEXT_ACTION: 正式 Word/TXT 生成前必须完成以下确认:")
|
||||
for issue in issues:
|
||||
print(f"- {issue}")
|
||||
raise SystemExit(2)
|
||||
|
||||
result = build_all(workdir, args.software_name, args.version, args.skip_preview)
|
||||
print(f"OK final materials: {Path(args.workdir) / '正式资料'}")
|
||||
for output in result["outputs"]:
|
||||
print(output)
|
||||
if result["warnings"]:
|
||||
print("Warnings:")
|
||||
for warning in result["warnings"]:
|
||||
print(f"- {warning}")
|
||||
print(f"Report: {result['report']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Best-effort screenshot helpers for operation manuals."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import shutil
|
||||
import re
|
||||
from pathlib import Path
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from common import ensure_dir, read_json, write_json
|
||||
|
||||
|
||||
def safe_name(path: str) -> str:
|
||||
value = path.strip("/") or "home"
|
||||
value = re.sub(r"[^A-Za-z0-9._-]+", "_", value)
|
||||
return value[:80] or "page"
|
||||
|
||||
|
||||
def collect_manual_screenshots(input_dir: Path, out_dir: Path) -> dict[str, object]:
|
||||
out_dir = ensure_dir(out_dir)
|
||||
screenshots = []
|
||||
errors = []
|
||||
allowed = {".png", ".jpg", ".jpeg", ".webp"}
|
||||
for index, path in enumerate(sorted(input_dir.iterdir()), start=1):
|
||||
if path.suffix.lower() not in allowed or not path.is_file():
|
||||
continue
|
||||
target = out_dir / f"{index:02d}-{safe_name(path.stem)}{path.suffix.lower()}"
|
||||
if path.resolve() != target.resolve():
|
||||
shutil.copy2(path, target)
|
||||
screenshots.append({"route": "", "url": "", "path": str(target), "source": str(path)})
|
||||
if not screenshots:
|
||||
errors.append({"error": f"no screenshot images found in {input_dir}"})
|
||||
manifest = {
|
||||
"status": "ok" if screenshots else "empty",
|
||||
"method": "user-supplied",
|
||||
"screenshots": screenshots,
|
||||
"errors": errors,
|
||||
}
|
||||
write_json(out_dir / "截图清单.json", manifest)
|
||||
return manifest
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--base-url")
|
||||
parser.add_argument("--analysis")
|
||||
parser.add_argument("--out-dir", default="软件著作权申请资料/截图")
|
||||
parser.add_argument("--max-pages", type=int, default=8)
|
||||
parser.add_argument("--manual-dir", help="Collect user-supplied screenshots from this directory")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.manual_dir:
|
||||
manifest = collect_manual_screenshots(Path(args.manual_dir), Path(args.out_dir))
|
||||
print(json.dumps(manifest, ensure_ascii=False, indent=2))
|
||||
if not manifest["screenshots"]:
|
||||
raise SystemExit(3)
|
||||
return
|
||||
|
||||
if not args.base_url or not args.analysis:
|
||||
raise SystemExit("Missing --base-url and --analysis unless --manual-dir is provided")
|
||||
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
except Exception as exc:
|
||||
print(json.dumps({"status": "error", "reason": f"playwright unavailable: {exc}"}, ensure_ascii=False))
|
||||
raise SystemExit(2)
|
||||
|
||||
analysis = read_json(Path(args.analysis))
|
||||
paths = analysis.get("routes") or ["/"]
|
||||
clean_paths = []
|
||||
for path in paths:
|
||||
if isinstance(path, str) and path.startswith("/") and path not in clean_paths:
|
||||
clean_paths.append(path)
|
||||
clean_paths = clean_paths[: args.max_pages] or ["/"]
|
||||
|
||||
out_dir = ensure_dir(Path(args.out_dir))
|
||||
screenshots = []
|
||||
errors = []
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch()
|
||||
page = browser.new_page(viewport={"width": 1440, "height": 1000})
|
||||
for route in clean_paths:
|
||||
url = urljoin(args.base_url.rstrip("/") + "/", route.lstrip("/"))
|
||||
file_path = out_dir / f"{safe_name(route)}.png"
|
||||
try:
|
||||
page.goto(url, wait_until="networkidle", timeout=15_000)
|
||||
page.screenshot(path=str(file_path), full_page=True)
|
||||
screenshots.append({"route": route, "url": url, "path": str(file_path)})
|
||||
except Exception as exc:
|
||||
errors.append({"route": route, "url": url, "error": str(exc)})
|
||||
browser.close()
|
||||
|
||||
manifest = {"status": "ok" if screenshots else "partial", "screenshots": screenshots, "errors": errors}
|
||||
write_json(out_dir / "截图清单.json", manifest)
|
||||
print(json.dumps(manifest, ensure_ascii=False, indent=2))
|
||||
if not screenshots:
|
||||
raise SystemExit(3)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check runtime capabilities at the beginning of the workflow."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib.util
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from common import ensure_dir, write_json
|
||||
|
||||
|
||||
def command_version(command: list[str]) -> tuple[bool, str]:
|
||||
if not shutil.which(command[0]):
|
||||
return False, "not found"
|
||||
try:
|
||||
completed = subprocess.run(command, text=True, capture_output=True, timeout=20)
|
||||
output = (completed.stdout or completed.stderr).strip().splitlines()
|
||||
return completed.returncode == 0, output[0] if output else "available"
|
||||
except Exception as exc:
|
||||
return False, str(exc)
|
||||
|
||||
|
||||
def run_docx_env(skill_dir: Path) -> tuple[bool, str]:
|
||||
env_script = skill_dir / "vendor/docx-toolkit/scripts/env_check.sh"
|
||||
if not env_script.exists():
|
||||
return False, "vendor/docx-toolkit/scripts/env_check.sh not found"
|
||||
try:
|
||||
completed = subprocess.run(["bash", str(env_script)], text=True, capture_output=True, timeout=40)
|
||||
return completed.returncode == 0, (completed.stdout + completed.stderr).strip()
|
||||
except Exception as exc:
|
||||
return False, str(exc)
|
||||
|
||||
|
||||
def module_available(name: str) -> bool:
|
||||
return importlib.util.find_spec(name) is not None
|
||||
|
||||
|
||||
def check_environment(skill_dir: Path) -> dict[str, Any]:
|
||||
python_docx = module_available("docx")
|
||||
pandoc_ok, pandoc_version = command_version(["pandoc", "--version"])
|
||||
dotnet_ok, dotnet_version = command_version(["dotnet", "--version"])
|
||||
docx_ready, docx_output = run_docx_env(skill_dir)
|
||||
|
||||
final_docx_mode = "docx-openxml" if docx_ready else ("python-docx" if python_docx else "basic-ooxml")
|
||||
requires_user_input = not docx_ready
|
||||
next_action = (
|
||||
"请选择:1) 安装完整 DOCX 环境;2) 使用基础 DOCX 兜底继续。回复选择后再进入项目分析。"
|
||||
if requires_user_input
|
||||
else "完整 DOCX 环境可用,可以进入项目分析。"
|
||||
)
|
||||
return {
|
||||
"output_directory": "当前目录/软件著作权申请资料",
|
||||
"capabilities": {
|
||||
"markdown_drafts": True,
|
||||
"application_txt": True,
|
||||
"basic_docx": python_docx or True,
|
||||
"python_docx": python_docx,
|
||||
"pandoc_preview": pandoc_ok,
|
||||
"docx_openxml_full": docx_ready,
|
||||
"dotnet_sdk": dotnet_ok,
|
||||
},
|
||||
"versions": {
|
||||
"pandoc": pandoc_version,
|
||||
"dotnet": dotnet_version,
|
||||
},
|
||||
"final_docx_mode": final_docx_mode,
|
||||
"recommendation": (
|
||||
"完整 DOCX OpenXML 环境已就绪,建议使用完整 Word 生成和校验流程。"
|
||||
if docx_ready
|
||||
else "完整 DOCX OpenXML 环境未就绪。可以继续使用兜底 DOCX 生成;如需更规范的 Word 结构和校验,请先安装 .NET SDK 并运行 vendor/docx-toolkit/scripts/setup.sh。"
|
||||
),
|
||||
"install_prompt": (
|
||||
"是否安装完整 DOCX 环境?安装后文档生成和校验更规范;不安装也可以继续生成 Markdown、TXT 和基础 DOCX。"
|
||||
if not docx_ready
|
||||
else "无需安装,完整环境可用。"
|
||||
),
|
||||
"requires_user_input": requires_user_input,
|
||||
"confirmation_stage": "environment" if requires_user_input else None,
|
||||
"next_action": next_action,
|
||||
"docx_env_output": docx_output,
|
||||
}
|
||||
|
||||
|
||||
def write_markdown(path: Path, data: dict[str, Any]) -> None:
|
||||
caps = data["capabilities"]
|
||||
lines = [
|
||||
"# 软著申请资料生成环境检查",
|
||||
"",
|
||||
f"- 输出目录:`{data['output_directory']}`",
|
||||
f"- 最终 Word 模式:`{data['final_docx_mode']}`",
|
||||
"",
|
||||
"## 能力状态",
|
||||
"",
|
||||
f"- Markdown 草稿:{'可用' if caps['markdown_drafts'] else '不可用'}",
|
||||
f"- 申请表 TXT:{'可用' if caps['application_txt'] else '不可用'}",
|
||||
f"- 基础 DOCX 生成:{'可用' if caps['basic_docx'] else '不可用'}",
|
||||
f"- python-docx:{'可用' if caps['python_docx'] else '不可用'}",
|
||||
f"- pandoc 预览:{'可用' if caps['pandoc_preview'] else '不可用'}({data['versions']['pandoc']})",
|
||||
f"- .NET SDK:{'可用' if caps['dotnet_sdk'] else '不可用'}({data['versions']['dotnet']})",
|
||||
f"- DOCX OpenXML 完整环境:{'可用' if caps['docx_openxml_full'] else '不可用'}",
|
||||
"",
|
||||
"## 建议",
|
||||
"",
|
||||
data["recommendation"],
|
||||
"",
|
||||
"## 用户选择",
|
||||
"",
|
||||
data["install_prompt"],
|
||||
"",
|
||||
"如果完整 DOCX 环境不可用,必须先等待用户选择,并记录 `environment` 门禁后再继续。",
|
||||
"",
|
||||
"```text" if data.get("requires_user_input") else "",
|
||||
"STOP_FOR_USER" if data.get("requires_user_input") else "",
|
||||
f"NEXT_ACTION: {data['next_action']}" if data.get("requires_user_input") else "",
|
||||
"```" if data.get("requires_user_input") else "",
|
||||
"",
|
||||
"## DOCX 环境输出摘要",
|
||||
"",
|
||||
"```text",
|
||||
"\n".join(data["docx_env_output"].splitlines()[:40]),
|
||||
"```",
|
||||
"",
|
||||
]
|
||||
path.write_text("\n".join(lines), encoding="utf-8")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--out-dir", default="软件著作权申请资料")
|
||||
args = parser.parse_args()
|
||||
|
||||
skill_dir = Path(__file__).resolve().parents[1]
|
||||
out_dir = ensure_dir(Path(args.out_dir))
|
||||
data = check_environment(skill_dir)
|
||||
write_json(out_dir / "环境检查.json", data)
|
||||
write_markdown(out_dir / "环境检查.md", data)
|
||||
print(f"OK environment check: {out_dir}")
|
||||
print(f"Final DOCX mode: {data['final_docx_mode']}")
|
||||
print(data["recommendation"])
|
||||
if data.get("requires_user_input"):
|
||||
print("STOP_FOR_USER")
|
||||
print(f"NEXT_ACTION: {data['next_action']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,258 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Shared helpers for the software copyright materials skill."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
|
||||
EXCLUDE_DIRS = {
|
||||
".git",
|
||||
".hg",
|
||||
".svn",
|
||||
".idea",
|
||||
".vscode",
|
||||
"__pycache__",
|
||||
"node_modules",
|
||||
"dist",
|
||||
"build",
|
||||
".next",
|
||||
".nuxt",
|
||||
".output",
|
||||
"coverage",
|
||||
"target",
|
||||
"vendor",
|
||||
"软件著作权申请资料",
|
||||
"software-copyright-materials",
|
||||
}
|
||||
|
||||
CODE_EXTS = {
|
||||
".vue",
|
||||
".ts",
|
||||
".tsx",
|
||||
".js",
|
||||
".jsx",
|
||||
".mjs",
|
||||
".cjs",
|
||||
".css",
|
||||
".scss",
|
||||
".sass",
|
||||
".less",
|
||||
".html",
|
||||
".svelte",
|
||||
".astro",
|
||||
".json",
|
||||
".md",
|
||||
}
|
||||
|
||||
KNOWN_CONFIG_FILES = {
|
||||
".babelrc",
|
||||
".eslintrc",
|
||||
".eslintrc.json",
|
||||
".eslintrc.yaml",
|
||||
".eslintrc.yml",
|
||||
".prettierrc",
|
||||
".prettierrc.json",
|
||||
".prettierrc.yaml",
|
||||
".prettierrc.yml",
|
||||
".swcrc",
|
||||
"angular.json",
|
||||
"app.json",
|
||||
"astro.config.mjs",
|
||||
"astro.config.ts",
|
||||
"babel.config.js",
|
||||
"babel.config.json",
|
||||
"Cargo.lock",
|
||||
"Cargo.toml",
|
||||
"composer.json",
|
||||
"docker-compose.yaml",
|
||||
"docker-compose.yml",
|
||||
"eslint.config.cjs",
|
||||
"eslint.config.js",
|
||||
"eslint.config.mjs",
|
||||
"go.mod",
|
||||
"go.sum",
|
||||
"jsconfig.json",
|
||||
"lerna.json",
|
||||
"manifest.json",
|
||||
"next.config.js",
|
||||
"next.config.mjs",
|
||||
"next.config.ts",
|
||||
"nuxt.config.js",
|
||||
"nuxt.config.ts",
|
||||
"nx.json",
|
||||
"package-lock.json",
|
||||
"package.json",
|
||||
"playwright.config.js",
|
||||
"playwright.config.ts",
|
||||
"postcss.config.cjs",
|
||||
"postcss.config.js",
|
||||
"prettier.config.cjs",
|
||||
"prettier.config.js",
|
||||
"prettier.config.mjs",
|
||||
"project.json",
|
||||
"pyproject.toml",
|
||||
"rollup.config.js",
|
||||
"rollup.config.mjs",
|
||||
"rollup.config.ts",
|
||||
"svelte.config.js",
|
||||
"stylelintrc.json",
|
||||
"tailwind.config.js",
|
||||
"tailwind.config.ts",
|
||||
"tsconfig.app.json",
|
||||
"tsconfig.json",
|
||||
"tsconfig.node.json",
|
||||
"tslint.json",
|
||||
"turbo.json",
|
||||
"vite.config.js",
|
||||
"vite.config.mjs",
|
||||
"vite.config.ts",
|
||||
"vitest.config.js",
|
||||
"vitest.config.ts",
|
||||
"webpack.config.js",
|
||||
"webpack.config.ts",
|
||||
"workspace.json",
|
||||
}
|
||||
|
||||
FRONTEND_EXTS = {
|
||||
".vue",
|
||||
".ts",
|
||||
".tsx",
|
||||
".js",
|
||||
".jsx",
|
||||
".mjs",
|
||||
".css",
|
||||
".scss",
|
||||
".sass",
|
||||
".less",
|
||||
".html",
|
||||
".svelte",
|
||||
".astro",
|
||||
}
|
||||
|
||||
SUPPLEMENT_CODE_EXTS = {
|
||||
".py",
|
||||
".java",
|
||||
".go",
|
||||
".rs",
|
||||
".cs",
|
||||
".php",
|
||||
".rb",
|
||||
".kt",
|
||||
".swift",
|
||||
".sql",
|
||||
".sh",
|
||||
".json",
|
||||
}
|
||||
|
||||
COPYRIGHT_CODE_EXTS = FRONTEND_EXTS | SUPPLEMENT_CODE_EXTS
|
||||
|
||||
LOCK_FILES = {
|
||||
"package-lock.json",
|
||||
"pnpm-lock.yaml",
|
||||
"yarn.lock",
|
||||
"bun.lockb",
|
||||
"bun.lock",
|
||||
}
|
||||
|
||||
|
||||
def repo_root_from_script() -> Path:
|
||||
return Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
def is_excluded(path: Path) -> bool:
|
||||
parts = set(path.parts)
|
||||
if parts & EXCLUDE_DIRS:
|
||||
return True
|
||||
name = path.name
|
||||
if name.startswith(".") and name not in {".env.example"}:
|
||||
return True
|
||||
if name in LOCK_FILES:
|
||||
return True
|
||||
if name.endswith(".map") or name.endswith(".min.js") or name.endswith(".min.css"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def iter_project_files(project: Path, exts: set[str] | None = None) -> Iterable[Path]:
|
||||
project = project.resolve()
|
||||
for root, dirs, files in os.walk(project):
|
||||
root_path = Path(root)
|
||||
dirs[:] = [d for d in dirs if not is_excluded(root_path / d)]
|
||||
for filename in files:
|
||||
path = root_path / filename
|
||||
if is_excluded(path):
|
||||
continue
|
||||
if exts is not None and path.suffix.lower() not in exts:
|
||||
continue
|
||||
yield path
|
||||
|
||||
|
||||
def rel(path: Path, root: Path) -> str:
|
||||
return path.resolve().relative_to(root.resolve()).as_posix()
|
||||
|
||||
|
||||
def read_text(path: Path, limit: int | None = None) -> str:
|
||||
data = path.read_bytes()
|
||||
if limit is not None:
|
||||
data = data[:limit]
|
||||
for encoding in ("utf-8", "utf-8-sig", "gb18030", "latin-1"):
|
||||
try:
|
||||
return data.decode(encoding)
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
return data.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def read_json(path: Path) -> dict[str, Any]:
|
||||
return json.loads(read_text(path))
|
||||
|
||||
|
||||
def write_json(path: Path, data: Any) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def count_text_lines(path: Path, skip_blank: bool = True) -> int:
|
||||
try:
|
||||
text = read_text(path)
|
||||
except Exception:
|
||||
return 0
|
||||
if not text:
|
||||
return 0
|
||||
if skip_blank:
|
||||
return sum(1 for line in text.splitlines() if line.strip())
|
||||
return len(text.splitlines())
|
||||
|
||||
|
||||
def is_known_config_file(path: Path) -> bool:
|
||||
"""Return True for well-known config files that shouldn't count as source code."""
|
||||
return path.name in KNOWN_CONFIG_FILES
|
||||
|
||||
|
||||
def looks_binary(path: Path) -> bool:
|
||||
try:
|
||||
chunk = path.read_bytes()[:4096]
|
||||
except Exception:
|
||||
return True
|
||||
return b"\x00" in chunk
|
||||
|
||||
|
||||
def normalize_title(value: str) -> str:
|
||||
value = re.sub(r"[-_]+", " ", value).strip()
|
||||
value = re.sub(r"\s+", " ", value)
|
||||
return value or "待命名软件"
|
||||
|
||||
|
||||
def safe_filename(value: str) -> str:
|
||||
value = re.sub(r'[\\/:*?"<>|]+', "_", value).strip()
|
||||
return value or "软件"
|
||||
|
||||
|
||||
def ensure_dir(path: Path) -> Path:
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Record explicit user confirmations for gated workflow stages."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from common import read_json, write_json
|
||||
|
||||
|
||||
def timestamp() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def load_json_or_empty(path: Path) -> dict[str, Any]:
|
||||
if not path.exists():
|
||||
return {}
|
||||
return read_json(path)
|
||||
|
||||
|
||||
def write_confirmation(path: Path, data: dict[str, Any], key: str, note: str) -> None:
|
||||
data[key] = True
|
||||
data["confirmation_note"] = note
|
||||
data["confirmed_at"] = timestamp()
|
||||
write_json(path, data)
|
||||
|
||||
|
||||
def pending_application_fields(md_path: Path) -> list[str]:
|
||||
if not md_path.exists():
|
||||
return [f"缺少 {md_path}"]
|
||||
return [line.strip() for line in md_path.read_text(encoding="utf-8").splitlines() if "待用户确认" in line]
|
||||
|
||||
|
||||
def confirm_environment(workdir: Path, note: str) -> Path:
|
||||
out_path = workdir / "环境确认.json"
|
||||
data = load_json_or_empty(out_path)
|
||||
write_confirmation(out_path, data, "environment_confirmed", note)
|
||||
return out_path
|
||||
|
||||
|
||||
def confirm_project(workdir: Path, note: str) -> Path:
|
||||
out_path = workdir / "项目确认.json"
|
||||
data = load_json_or_empty(out_path)
|
||||
write_confirmation(out_path, data, "project_confirmed", note)
|
||||
return out_path
|
||||
|
||||
|
||||
def confirm_business(workdir: Path, note: str) -> Path:
|
||||
path = workdir / "草稿/业务理解.json"
|
||||
if not path.exists():
|
||||
raise SystemExit("Missing 草稿/业务理解.json")
|
||||
data = read_json(path)
|
||||
write_confirmation(path, data, "user_confirmed", note)
|
||||
return path
|
||||
|
||||
|
||||
def confirm_code_selection(workdir: Path, note: str) -> Path:
|
||||
path = workdir / "草稿/代码文件选择.json"
|
||||
if not path.exists():
|
||||
raise SystemExit("Missing 草稿/代码文件选择.json")
|
||||
data = read_json(path)
|
||||
files = data.get("files") if isinstance(data, dict) else []
|
||||
selected = [item for item in files if isinstance(item, dict) and item.get("selected")]
|
||||
if not selected:
|
||||
raise SystemExit(
|
||||
"STOP_FOR_USER\n"
|
||||
"NEXT_ACTION: 代码文件选择尚未由模型填写。请先选择至少一个源码文件并填写选择理由,再让用户确认。"
|
||||
)
|
||||
missing_reason = [item.get("path") for item in selected if not str(item.get("model_reason") or "").strip()]
|
||||
if data.get("model_selection_required") and missing_reason:
|
||||
raise SystemExit(
|
||||
"STOP_FOR_USER\n"
|
||||
"NEXT_ACTION: 已选源码缺少模型选择理由,请补全 `model_reason` 后再确认。\n"
|
||||
+ "\n".join(f"- {item}" for item in missing_reason[:20])
|
||||
)
|
||||
write_confirmation(path, data, "user_confirmed", note)
|
||||
return path
|
||||
|
||||
|
||||
def parse_screenshot_method(method: str, note: str) -> str:
|
||||
value = (method or note or "").lower()
|
||||
if any(key in value for key in ("skip", "no-screenshot", "none", "不截图", "跳过", "暂不", "先不", "不要截图", "无需截图")):
|
||||
return "skip"
|
||||
if any(key in value for key in ("chrome", "devtools", "mcp")):
|
||||
return "chrome-devtools"
|
||||
if any(key in value for key in ("computer", "use", "电脑", "桌面")):
|
||||
return "computer-use"
|
||||
if any(key in value for key in ("user", "manual", "self", "手动", "自己", "用户")):
|
||||
return "user-supplied"
|
||||
raise SystemExit(
|
||||
"STOP_FOR_USER\n"
|
||||
"NEXT_ACTION: 请明确截图方式:chrome-devtools、computer-use、user-supplied 或 skip。"
|
||||
)
|
||||
|
||||
|
||||
def confirm_screenshot_method(workdir: Path, note: str, method: str) -> Path:
|
||||
selected = parse_screenshot_method(method, note)
|
||||
out_path = workdir / "截图方式确认.json"
|
||||
data = load_json_or_empty(out_path)
|
||||
data["screenshot_method"] = selected
|
||||
write_confirmation(out_path, data, "screenshot_method_confirmed", note)
|
||||
return out_path
|
||||
|
||||
|
||||
def confirm_application_fields(workdir: Path, note: str) -> Path:
|
||||
pending = pending_application_fields(workdir / "草稿/申请表信息.md")
|
||||
if pending:
|
||||
raise SystemExit(
|
||||
"STOP_FOR_USER\n"
|
||||
"NEXT_ACTION: 申请表信息仍包含“待用户确认”。请先补全字段,再重新确认。\n"
|
||||
+ "\n".join(f"- {item}" for item in pending[:20])
|
||||
)
|
||||
out_path = workdir / "草稿/申请表字段确认.json"
|
||||
data = load_json_or_empty(out_path)
|
||||
write_confirmation(out_path, data, "application_fields_confirmed", note)
|
||||
return out_path
|
||||
|
||||
|
||||
def confirm_markdown(workdir: Path, note: str) -> Path:
|
||||
issues = []
|
||||
business = workdir / "草稿/业务理解.json"
|
||||
selection = workdir / "草稿/代码文件选择.json"
|
||||
screenshot = workdir / "截图方式确认.json"
|
||||
fields = workdir / "草稿/申请表字段确认.json"
|
||||
|
||||
if not business.exists() or not read_json(business).get("user_confirmed"):
|
||||
issues.append("业务理解尚未确认")
|
||||
if not selection.exists() or not read_json(selection).get("user_confirmed"):
|
||||
issues.append("代码文件选择尚未确认")
|
||||
if not screenshot.exists() or not read_json(screenshot).get("screenshot_method_confirmed"):
|
||||
issues.append("截图方式尚未确认")
|
||||
if not fields.exists() or not read_json(fields).get("application_fields_confirmed"):
|
||||
issues.append("申请表字段尚未确认")
|
||||
pending = pending_application_fields(workdir / "草稿/申请表信息.md")
|
||||
if pending:
|
||||
issues.append("申请表信息仍包含“待用户确认”")
|
||||
|
||||
if issues:
|
||||
raise SystemExit(
|
||||
"STOP_FOR_USER\n"
|
||||
"NEXT_ACTION: Markdown 草稿确认前需要先处理以下事项:\n"
|
||||
+ "\n".join(f"- {item}" for item in issues)
|
||||
)
|
||||
|
||||
out_path = workdir / "草稿/最终生成确认.json"
|
||||
data = load_json_or_empty(out_path)
|
||||
write_confirmation(out_path, data, "markdown_confirmed", note)
|
||||
return out_path
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--workdir", default="软件著作权申请资料")
|
||||
parser.add_argument(
|
||||
"--stage",
|
||||
required=True,
|
||||
choices=[
|
||||
"environment",
|
||||
"project",
|
||||
"business",
|
||||
"code-selection",
|
||||
"screenshot-method",
|
||||
"application-fields",
|
||||
"markdown",
|
||||
],
|
||||
)
|
||||
parser.add_argument("--note", default="用户已确认")
|
||||
parser.add_argument(
|
||||
"--method",
|
||||
choices=["chrome-devtools", "computer-use", "user-supplied", "skip"],
|
||||
help="Screenshot capture method when --stage screenshot-method",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
workdir = Path(args.workdir)
|
||||
if args.stage == "environment":
|
||||
path = confirm_environment(workdir, args.note)
|
||||
elif args.stage == "project":
|
||||
path = confirm_project(workdir, args.note)
|
||||
elif args.stage == "business":
|
||||
path = confirm_business(workdir, args.note)
|
||||
elif args.stage == "code-selection":
|
||||
path = confirm_code_selection(workdir, args.note)
|
||||
elif args.stage == "screenshot-method":
|
||||
path = confirm_screenshot_method(workdir, args.note, args.method or "")
|
||||
elif args.stage == "application-fields":
|
||||
path = confirm_application_fields(workdir, args.note)
|
||||
else:
|
||||
path = confirm_markdown(workdir, args.note)
|
||||
|
||||
print(f"OK confirmation recorded: {args.stage}")
|
||||
print(path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,308 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract real source code and create Markdown draft pages."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from common import COPYRIGHT_CODE_EXTS, FRONTEND_EXTS, ensure_dir, is_known_config_file, iter_project_files, looks_binary, read_json, read_text, rel, safe_filename, write_json
|
||||
|
||||
|
||||
LINES_PER_PAGE = 50
|
||||
SPLIT_THRESHOLD_PAGES = 60
|
||||
|
||||
|
||||
def category_weight(path: Path, project: Path) -> tuple[int, str]:
|
||||
r = rel(path, project).lower()
|
||||
name = path.name.lower()
|
||||
priority = 80
|
||||
if name in {"main.ts", "main.js", "main.tsx", "main.jsx", "app.vue", "app.tsx"} or r in {
|
||||
"src/app/page.tsx",
|
||||
"src/app/layout.tsx",
|
||||
"app/page.tsx",
|
||||
"app/layout.tsx",
|
||||
} or r.endswith("/src/app/page.tsx") or r.endswith("/src/app/layout.tsx"):
|
||||
priority = 0
|
||||
elif path.suffix.lower() in {".css", ".scss", ".sass", ".less"}:
|
||||
priority = 90
|
||||
elif "/router/" in r or "/routes/" in r or "router." in r or "routes." in r:
|
||||
priority = 10
|
||||
elif "/pages/" in r or "/views/" in r or "/app/" in r or "/screens/" in r:
|
||||
priority = 20
|
||||
elif "/api/" in r or "/apis/" in r or "/services/" in r or "request." in r:
|
||||
priority = 30
|
||||
elif "/store/" in r or "/stores/" in r or "/pinia/" in r or "/redux/" in r:
|
||||
priority = 40
|
||||
elif "/components/" in r:
|
||||
priority = 50
|
||||
elif "/utils/" in r or "/lib/" in r or "/hooks/" in r or "/composables/" in r:
|
||||
priority = 60
|
||||
elif path.suffix.lower() not in FRONTEND_EXTS:
|
||||
if any(part in r for part in ("/backend/app/", "/server/", "/api/", "/services/", "/models/", "/schemas/", "/workers/")):
|
||||
priority = 70
|
||||
elif name in {"docker-compose.yml", "docker-compose.yaml", "pyproject.toml"} or path.suffix.lower() in {".toml", ".yml", ".yaml"}:
|
||||
priority = 95
|
||||
else:
|
||||
priority = 100
|
||||
return priority, r
|
||||
|
||||
|
||||
def should_skip_file(path: Path) -> bool:
|
||||
if path.suffix.lower() not in COPYRIGHT_CODE_EXTS:
|
||||
return True
|
||||
if is_known_config_file(path):
|
||||
return True
|
||||
if looks_binary(path):
|
||||
return True
|
||||
try:
|
||||
size = path.stat().st_size
|
||||
except OSError:
|
||||
return True
|
||||
if size <= 0 or size > 800_000:
|
||||
return True
|
||||
try:
|
||||
sample = read_text(path, limit=20_000)
|
||||
except Exception:
|
||||
return True
|
||||
lines = sample.splitlines()
|
||||
if any(len(line) > 3000 for line in lines[:80]):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def selected_line_estimate(item: dict[str, Any]) -> int:
|
||||
try:
|
||||
total = int(item.get("line_count") or 0)
|
||||
except (TypeError, ValueError):
|
||||
total = 0
|
||||
return total + 2 if total > 0 else 0
|
||||
|
||||
|
||||
def available_pages_from_selection(selection_path: Path | None, lines_per_page: int) -> tuple[int, int, int]:
|
||||
if selection_path is None or not selection_path.exists():
|
||||
return 0, 0, 0
|
||||
data = read_json(selection_path)
|
||||
items = data.get("files") if isinstance(data, dict) else []
|
||||
if not isinstance(items, list):
|
||||
return 0, 0, 0
|
||||
available_lines = sum(selected_line_estimate(item) for item in items if isinstance(item, dict))
|
||||
unselected = sum(1 for item in items if isinstance(item, dict) and not item.get("selected") and selected_line_estimate(item) > 0)
|
||||
pages = (available_lines + lines_per_page - 1) // lines_per_page if available_lines else 0
|
||||
return available_lines, pages, unselected
|
||||
|
||||
|
||||
def marker_for(path: Path, project: Path) -> str:
|
||||
return f"// File: {rel(path, project)}"
|
||||
|
||||
|
||||
def load_selected_files(project: Path, selection_path: Path | None) -> list[dict[str, Any]]:
|
||||
if selection_path is None:
|
||||
raise SystemExit(
|
||||
"STOP_FOR_USER\n"
|
||||
"NEXT_ACTION: 代码抽取必须先使用 propose_code_selection.py 生成并确认 草稿/代码文件选择.json。"
|
||||
)
|
||||
|
||||
data = read_json(selection_path)
|
||||
if isinstance(data, dict) and data.get("selection_required") and not data.get("user_confirmed"):
|
||||
raise SystemExit(
|
||||
"STOP_FOR_USER\n"
|
||||
"NEXT_ACTION: 代码文件选择尚未确认。请先确认或修改 草稿/代码文件选择.json,"
|
||||
"再运行 `python3 <SKILL_DIR>/scripts/confirm_stage.py --workdir 软件著作权申请资料 --stage code-selection --note \"<用户确认内容>\"`。"
|
||||
)
|
||||
items = data.get("files") if isinstance(data, dict) else data
|
||||
if not isinstance(items, list):
|
||||
raise SystemExit(f"Invalid selection file: {selection_path}")
|
||||
|
||||
selected = []
|
||||
for item in items:
|
||||
if not isinstance(item, dict) or not item.get("selected"):
|
||||
continue
|
||||
path_value = item.get("path")
|
||||
if not path_value:
|
||||
continue
|
||||
selected.append(
|
||||
{
|
||||
"path": str(path_value),
|
||||
"selected": True,
|
||||
}
|
||||
)
|
||||
return selected
|
||||
|
||||
|
||||
def collect_code_lines(project: Path, selection_path: Path | None) -> tuple[list[str], list[dict[str, Any]]]:
|
||||
selected_items = load_selected_files(project, selection_path)
|
||||
all_lines: list[str] = []
|
||||
manifest_files: list[dict[str, Any]] = []
|
||||
|
||||
for item in selected_items:
|
||||
path = (project / item["path"]).resolve()
|
||||
try:
|
||||
path.relative_to(project.resolve())
|
||||
except ValueError:
|
||||
raise SystemExit(f"Selected file is outside project: {path}")
|
||||
if should_skip_file(path):
|
||||
continue
|
||||
text = read_text(path)
|
||||
source_lines = text.splitlines()
|
||||
selected_lines = source_lines
|
||||
start = len(all_lines) + 1
|
||||
marker = marker_for(path, project)
|
||||
all_lines.append(marker)
|
||||
all_lines.extend(selected_lines)
|
||||
all_lines.append("")
|
||||
end = len(all_lines)
|
||||
source_end_line = len(source_lines)
|
||||
manifest_files.append(
|
||||
{
|
||||
"path": rel(path, project),
|
||||
"source_line_count": len(source_lines),
|
||||
"selected_line_start": 1,
|
||||
"selected_line_end": source_end_line,
|
||||
"selected_line_count": len(selected_lines),
|
||||
"material_line_start": start,
|
||||
"material_line_end": end,
|
||||
}
|
||||
)
|
||||
return all_lines, manifest_files
|
||||
|
||||
|
||||
def paginate(lines: list[str], lines_per_page: int) -> list[list[str]]:
|
||||
return [lines[i : i + lines_per_page] for i in range(0, len(lines), lines_per_page)]
|
||||
|
||||
|
||||
def write_pages_md(path: Path, title: str, software_name: str, version: str, pages: list[tuple[int, list[str]]]) -> None:
|
||||
chunks = [f"# {title}", "", f"软件名称:{software_name}", f"版本号:{version}", ""]
|
||||
for page_no, page_lines in pages:
|
||||
chunks.extend([f"## 第 {page_no} 页", "", "```text"])
|
||||
chunks.extend(page_lines)
|
||||
chunks.extend(["```", ""])
|
||||
path.write_text("\n".join(chunks), encoding="utf-8")
|
||||
|
||||
|
||||
def write_manifest_md(path: Path, manifest: dict[str, Any]) -> None:
|
||||
lines = [
|
||||
"# 代码提取清单",
|
||||
"",
|
||||
f"- 软件名称:{manifest['software_name']}",
|
||||
f"- 版本号:{manifest['version']}",
|
||||
f"- 项目目录:{manifest['project_root']}",
|
||||
f"- 源码文件数:{manifest['file_count']}",
|
||||
f"- 材料代码行数:{manifest['material_line_count']}",
|
||||
f"- 每页行数:{manifest['lines_per_page']}",
|
||||
f"- 总页数:{manifest['total_pages']}",
|
||||
f"- 目标页数:{manifest['target_pages']}",
|
||||
f"- 候选源码可生成页数:{manifest['available_candidate_pages']}",
|
||||
f"- 补充状态:{manifest['supplement_status']}",
|
||||
f"- 输出模式:{manifest['mode']}",
|
||||
"",
|
||||
"## 文件来源",
|
||||
"",
|
||||
"| 文件 | 源码行数 | 抽取源码范围 | 抽取行数 | 材料行范围 |",
|
||||
"| --- | ---: | --- | ---: | --- |",
|
||||
]
|
||||
for item in manifest["files"]:
|
||||
lines.append(
|
||||
f"| `{item['path']}` | {item['source_line_count']} | "
|
||||
f"{item['selected_line_start']}-{item['selected_line_end']} | "
|
||||
f"{item['selected_line_count']} | "
|
||||
f"{item['material_line_start']}-{item['material_line_end']} |"
|
||||
)
|
||||
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def extract(project: Path, out_dir: Path, software_name: str, version: str, lines_per_page: int, selection_path: Path | None) -> dict[str, Any]:
|
||||
ensure_dir(out_dir)
|
||||
code_lines, files = collect_code_lines(project, selection_path)
|
||||
if not code_lines:
|
||||
raise SystemExit("No selected frontend source code files found for extraction.")
|
||||
|
||||
pages = paginate(code_lines, lines_per_page)
|
||||
total_pages = len(pages)
|
||||
available_lines, available_pages, unselected_count = available_pages_from_selection(selection_path, lines_per_page)
|
||||
if total_pages < SPLIT_THRESHOLD_PAGES and available_pages >= SPLIT_THRESHOLD_PAGES and unselected_count > 0:
|
||||
raise SystemExit(
|
||||
"STOP_FOR_USER\n"
|
||||
f"NEXT_ACTION: 当前已选代码只有 {total_pages} 页,但候选源码足够补齐到 {SPLIT_THRESHOLD_PAGES} 页。"
|
||||
"请在 草稿/代码文件选择.json 中继续选择补充文件,重新记录 code-selection 门禁后再抽取。"
|
||||
)
|
||||
outputs: list[str] = []
|
||||
|
||||
if total_pages >= SPLIT_THRESHOLD_PAGES:
|
||||
front = list(enumerate(pages[:30], start=1))
|
||||
back = [(31 + i, page) for i, page in enumerate(pages[-30:])]
|
||||
front_path = out_dir / "代码-前30页.md"
|
||||
back_path = out_dir / "代码-后30页.md"
|
||||
write_pages_md(front_path, "代码材料(前30页)", software_name, version, front)
|
||||
write_pages_md(back_path, "代码材料(后30页)", software_name, version, back)
|
||||
outputs.extend([front_path.name, back_path.name])
|
||||
mode = "front30_back30"
|
||||
else:
|
||||
all_path = out_dir / "代码-全部.md"
|
||||
all_pages = list(enumerate(pages, start=1))
|
||||
write_pages_md(all_path, "代码材料(全部)", software_name, version, all_pages)
|
||||
outputs.append(all_path.name)
|
||||
mode = "all_under_60_pages"
|
||||
supplement_status = (
|
||||
"候选源码可达到前30页/后30页要求"
|
||||
if available_pages >= SPLIT_THRESHOLD_PAGES
|
||||
else "候选源码不足60页,按全部代码材料生成"
|
||||
)
|
||||
|
||||
manifest = {
|
||||
"software_name": software_name,
|
||||
"version": version,
|
||||
"project_root": str(project.resolve()),
|
||||
"file_count": len(files),
|
||||
"material_line_count": len(code_lines),
|
||||
"source_line_count": sum(item["source_line_count"] for item in files),
|
||||
"selected_source_line_count": sum(item["selected_line_count"] for item in files),
|
||||
"lines_per_page": lines_per_page,
|
||||
"total_pages": total_pages,
|
||||
"target_pages": SPLIT_THRESHOLD_PAGES,
|
||||
"available_candidate_line_count": available_lines,
|
||||
"available_candidate_pages": available_pages,
|
||||
"supplement_status": supplement_status,
|
||||
"mode": mode,
|
||||
"selection_file": str(selection_path) if selection_path else None,
|
||||
"outputs": outputs,
|
||||
"files": files,
|
||||
"safe_software_filename": safe_filename(software_name),
|
||||
}
|
||||
write_json(out_dir / "代码提取清单.json", manifest)
|
||||
write_manifest_md(out_dir / "代码提取清单.md", manifest)
|
||||
return manifest
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--project", required=True)
|
||||
parser.add_argument("--analysis", help="Optional project analysis JSON; retained for workflow traceability")
|
||||
parser.add_argument("--software-name", required=True)
|
||||
parser.add_argument("--version", default="V1.0")
|
||||
parser.add_argument("--out-dir", default="软件著作权申请资料/草稿")
|
||||
parser.add_argument("--lines-per-page", type=int, default=LINES_PER_PAGE)
|
||||
parser.add_argument("--selection", help="Editable JSON file created by propose_code_selection.py")
|
||||
args = parser.parse_args()
|
||||
|
||||
project = Path(args.project)
|
||||
if not project.exists():
|
||||
raise SystemExit(f"Project not found: {project}")
|
||||
if args.analysis and not Path(args.analysis).exists():
|
||||
raise SystemExit(f"Analysis JSON not found: {args.analysis}")
|
||||
|
||||
selection = Path(args.selection) if args.selection else None
|
||||
if selection and not selection.exists():
|
||||
raise SystemExit(f"Selection JSON not found: {selection}")
|
||||
|
||||
manifest = extract(project, Path(args.out_dir), args.software_name, args.version, args.lines_per_page, selection)
|
||||
print(f"OK code drafts: {args.out_dir}")
|
||||
print(f"Selected files: {manifest['file_count']}")
|
||||
print(f"Mode: {manifest['mode']}")
|
||||
print(f"Total pages: {manifest['total_pages']}")
|
||||
print(f"Outputs: {', '.join(manifest['outputs'])}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,719 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate the Markdown draft for application form information."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from common import ensure_dir, read_json, read_text
|
||||
|
||||
|
||||
MIN_MAIN_FUNCTION_CHARS = 500
|
||||
MAX_MAIN_FUNCTION_CHARS = 1300
|
||||
|
||||
|
||||
FIELD_ORDER = [
|
||||
"软件全称",
|
||||
"软件简称",
|
||||
"版本号",
|
||||
"软件分类",
|
||||
"开发完成日期",
|
||||
"开发方式",
|
||||
"软件说明",
|
||||
"发表状态",
|
||||
"首次发表日期",
|
||||
"著作权人",
|
||||
"权利范围",
|
||||
"权利取得方式",
|
||||
"开发的硬件环境",
|
||||
"运行的硬件环境",
|
||||
"开发该软件的操作系统",
|
||||
"软件开发环境 / 开发工具",
|
||||
"该软件的运行平台 / 操作系统",
|
||||
"软件运行支撑环境 / 支持软件",
|
||||
"编程语言",
|
||||
"源程序量",
|
||||
"开发目的",
|
||||
"面向领域 / 行业",
|
||||
"软件的主要功能",
|
||||
"软件的技术特点",
|
||||
"页数",
|
||||
]
|
||||
|
||||
|
||||
def effective_len(value: str) -> int:
|
||||
return len(str(value or "").replace(" ", "").replace("\n", ""))
|
||||
|
||||
|
||||
def clean_text(value: Any) -> str:
|
||||
text = str(value or "").strip()
|
||||
text = re.sub(r"\s+", " ", text)
|
||||
text = text.replace("`", "").replace("#", "").strip()
|
||||
return text
|
||||
|
||||
|
||||
def trim_effective(value: str, max_chars: int = MAX_MAIN_FUNCTION_CHARS) -> str:
|
||||
if effective_len(value) <= max_chars:
|
||||
return value
|
||||
result: list[str] = []
|
||||
count = 0
|
||||
for char in value:
|
||||
if char not in (" ", "\n"):
|
||||
count += 1
|
||||
if count > max_chars:
|
||||
break
|
||||
result.append(char)
|
||||
return "".join(result).rstrip(",。;、 ") + "。"
|
||||
|
||||
|
||||
def business_feature_pairs(business: dict[str, Any] | None) -> list[tuple[str, str]]:
|
||||
if not business:
|
||||
return []
|
||||
features = business.get("business_features") or []
|
||||
details = business.get("business_feature_details") or {}
|
||||
if not isinstance(features, list):
|
||||
return []
|
||||
if not isinstance(details, dict):
|
||||
details = {}
|
||||
pairs: list[tuple[str, str]] = []
|
||||
for feature in features:
|
||||
name = clean_text(feature)
|
||||
if not name:
|
||||
continue
|
||||
detail = clean_text(details.get(name))
|
||||
pairs.append((name, detail))
|
||||
return pairs
|
||||
|
||||
|
||||
def summarize_business_features(software_name: str, business: dict[str, Any] | None) -> str:
|
||||
pairs = business_feature_pairs(business)
|
||||
if not business or not pairs:
|
||||
return ""
|
||||
|
||||
industry = clean_text(business.get("industry"))
|
||||
target_users = business.get("target_users") or []
|
||||
if isinstance(target_users, list):
|
||||
target_text = "、".join(clean_text(item) for item in target_users if clean_text(item))
|
||||
else:
|
||||
target_text = clean_text(target_users)
|
||||
core_value = clean_text(business.get("core_value"))
|
||||
product_positioning = clean_text(business.get("product_positioning"))
|
||||
operation_flow = business.get("operation_flow") or []
|
||||
if isinstance(operation_flow, list):
|
||||
flow_steps = [clean_text(item).rstrip("。") for item in operation_flow if clean_text(item)]
|
||||
flow_text = ",再".join(flow_steps)
|
||||
else:
|
||||
flow_text = clean_text(operation_flow)
|
||||
|
||||
feature_names = "、".join(name for name, _ in pairs[:8])
|
||||
parts: list[str] = []
|
||||
if product_positioning:
|
||||
parts.append(product_positioning.rstrip("。") + "。")
|
||||
else:
|
||||
scope = f"面向{industry}" if industry else "面向实际业务场景"
|
||||
users = f",服务于{target_text}" if target_text else ""
|
||||
parts.append(f"{software_name}是一套{scope}{users}的应用软件。")
|
||||
parts.append(f"软件主要提供{feature_names}等功能。")
|
||||
if core_value:
|
||||
parts.append(core_value.rstrip("。") + "。")
|
||||
|
||||
for name, detail in pairs[:8]:
|
||||
if detail:
|
||||
detail = detail.rstrip("。")
|
||||
if detail.startswith(("用户", "系统")):
|
||||
parts.append(f"在{name}功能中,{detail}。")
|
||||
else:
|
||||
parts.append(f"{name}功能主要{detail}。")
|
||||
else:
|
||||
parts.append(f"{name}功能支持用户完成相关业务操作,并在处理完成后返回对应结果。")
|
||||
|
||||
if flow_text:
|
||||
parts.append(f"用户通常先{flow_text},系统在关键步骤提供状态反馈和结果展示。")
|
||||
|
||||
result = "".join(parts)
|
||||
while effective_len(result) < MIN_MAIN_FUNCTION_CHARS:
|
||||
result += (
|
||||
f"围绕{feature_names}等核心功能,软件将用户输入、过程处理、结果查看和资料管理组织在连续的操作流程中,"
|
||||
"用户可以根据页面提示逐步完成业务处理,系统保存必要的数据记录并提供清晰的反馈信息,"
|
||||
"便于用户后续继续查看、复核和调整相关内容。"
|
||||
)
|
||||
return trim_effective(result)
|
||||
|
||||
|
||||
def summarize_features(analysis: dict[str, Any], software_name: str, business: dict[str, Any] | None = None) -> str:
|
||||
"""Generate a substantive main-function description for the application form.
|
||||
|
||||
When business context JSON provides main_functions it will be used upstream; this
|
||||
function is a fallback that assembles the best available evidence into a multi-
|
||||
paragraph description targeting the 500-1300 character window required by the
|
||||
Chinese copyright office.
|
||||
"""
|
||||
business_summary = summarize_business_features(software_name, business)
|
||||
if business_summary:
|
||||
return business_summary
|
||||
|
||||
features = analysis.get("feature_candidates") or []
|
||||
readme = (analysis.get("readme_excerpt") or "").strip()
|
||||
routes = analysis.get("routes") or []
|
||||
|
||||
readable_features = []
|
||||
for feature in features:
|
||||
name = humanize_feature(str(feature))
|
||||
if re.search(r"[A-Za-z]", name):
|
||||
continue
|
||||
if name and name not in readable_features:
|
||||
readable_features.append(name)
|
||||
|
||||
parts: list[str] = []
|
||||
|
||||
# Opening overview paragraph
|
||||
feature_list = "、".join(readable_features[:12]) if readable_features else "信息展示、业务处理、数据管理和系统交互"
|
||||
parts.append(
|
||||
f"{software_name}是一套面向用户业务场景的综合软件系统,"
|
||||
f"主要提供{feature_list}等核心功能模块。"
|
||||
f"系统通过清晰的操作界面和合理的业务流程设计,帮助用户高效完成日常工作和业务协作。"
|
||||
)
|
||||
|
||||
# Module-by-module breakdown
|
||||
detail_parts: list[str] = []
|
||||
for name in readable_features[:8]:
|
||||
skip = {"软件登录", "用户注册", "用户认证", "首页", "数据看板", "系统设置"}
|
||||
if name in skip:
|
||||
continue
|
||||
detail_parts.append(f"{name}模块支持用户进行相关数据的查看、录入和管理操作,提供完整的业务处理能力和结果反馈。")
|
||||
|
||||
if not detail_parts:
|
||||
route_display = [r.strip("/") for r in routes[:6] if r != "/" and not r.startswith("/:")]
|
||||
if route_display:
|
||||
for route_name in route_display[:6]:
|
||||
label = route_name.replace("-", " ").replace("_", " ").title()
|
||||
detail_parts.append(f"{label}模块支持用户进行相关数据的查看、录入和管理操作,提供完整的业务处理能力和结果反馈。")
|
||||
else:
|
||||
detail_parts.append("用户可通过系统界面完成数据查询、信息录入、业务处理和结果导出等操作。")
|
||||
detail_parts.append("系统支持多角色用户的协同工作,不同权限用户可访问相应的功能模块。")
|
||||
detail_parts.append("系统提供数据持久化存储和历史记录追溯能力,保障业务数据的完整性和可审计性。")
|
||||
|
||||
# Limit detail parts to avoid exceeding max length
|
||||
combined = "".join(detail_parts)
|
||||
if len(combined) + len("".join(parts)) > MAX_MAIN_FUNCTION_CHARS:
|
||||
while detail_parts and len("".join(detail_parts)) + len("".join(parts)) > MAX_MAIN_FUNCTION_CHARS:
|
||||
detail_parts.pop()
|
||||
|
||||
parts.extend(detail_parts)
|
||||
|
||||
# Closing paragraph
|
||||
if readme:
|
||||
first_line = readme.splitlines()[0][:80]
|
||||
parts.append(f"系统核心业务围绕{first_line}展开,覆盖从信息采集到结果呈现的完整操作链路。")
|
||||
|
||||
result = "".join(parts)
|
||||
|
||||
while effective_len(result) < MIN_MAIN_FUNCTION_CHARS:
|
||||
padding = (
|
||||
"此外,系统还提供了配套的数据管理、用户操作记录、状态跟踪和系统配置等辅助功能模块,"
|
||||
"各个功能模块之间通过统一的界面布局和操作规范协同运行,用户可以在不同模块间灵活切换和处理跨模块的业务流程。"
|
||||
"系统整体设计注重业务完整性和操作连续性,能够满足用户日常工作中对信息处理和业务管理的核心需求。"
|
||||
"系统界面设计遵循清晰直观的原则,主要操作入口集中展示,用户无需复杂培训即可上手使用。"
|
||||
"系统的数据管理能力包括数据的录入、存储、查询、修改和删除等基本操作,同时支持数据批量处理和导入导出功能。"
|
||||
"在业务处理方面,系统支持多步骤业务流程的串联执行,各环节之间数据自动流转,减少用户重复录入。"
|
||||
"系统还提供了灵活的配置选项,管理员可以根据实际业务需求调整系统参数和功能开关。"
|
||||
)
|
||||
result += padding
|
||||
|
||||
return trim_effective(result)
|
||||
|
||||
|
||||
def humanize_feature(name: str) -> str:
|
||||
value = re.sub(r"([a-z])([A-Z])", r"\1 \2", name)
|
||||
value = value.replace("-", " ").replace("_", " ").strip()
|
||||
key = value.lower().replace(" ", "")
|
||||
mapping = {
|
||||
"login": "软件登录",
|
||||
"register": "用户注册",
|
||||
"auth": "用户认证",
|
||||
"home": "首页",
|
||||
"dashboard": "数据看板",
|
||||
"project": "项目管理",
|
||||
"projects": "项目管理",
|
||||
"projectsettings": "项目设置",
|
||||
"projectssettings": "项目设置",
|
||||
"settings": "系统设置",
|
||||
"asset": "资源管理",
|
||||
"assets": "资源管理",
|
||||
"assethub": "资源中心",
|
||||
"billing": "费用管理",
|
||||
"agentstatusbar": "智能体状态展示",
|
||||
"messagebubble": "消息展示",
|
||||
"chatpanel": "对话面板",
|
||||
"chatinput": "对话输入",
|
||||
"assetpanel": "资源面板",
|
||||
}
|
||||
return mapping.get(key, value.title() if re.search(r"[A-Za-z]", value) else value)
|
||||
|
||||
|
||||
def build_fields(
|
||||
analysis: dict[str, Any],
|
||||
manifest: dict[str, Any],
|
||||
software_name: str,
|
||||
version: str,
|
||||
answers: dict[str, str],
|
||||
business: dict[str, Any] | None = None,
|
||||
) -> dict[str, str]:
|
||||
frameworks = analysis.get("frameworks") or []
|
||||
framework_text = "、".join(frameworks) if frameworks else "前端工程化框架"
|
||||
language = analysis.get("language") or "待用户确认"
|
||||
project = Path(analysis.get("project_root") or ".")
|
||||
hardware_hint = current_hardware_environment()
|
||||
dev_os_hint = current_operating_system()
|
||||
version_hint = version_confirmation_hint(analysis, version)
|
||||
software_name_hint = f"待用户确认(建议:{software_name};请确认最终软件全称)"
|
||||
|
||||
defaults = {
|
||||
"软件全称": software_name_hint,
|
||||
"软件简称": "",
|
||||
"版本号": version_hint,
|
||||
"软件分类": (business.get("software_category") or "应用软件") if business else "应用软件",
|
||||
"开发完成日期": "待用户确认(YYYY-MM-DD)",
|
||||
"开发方式": (business.get("development_situation") or "单独开发") if business else "单独开发",
|
||||
"软件说明": "原创",
|
||||
"发表状态": "待用户确认(已发表/未发表)",
|
||||
"首次发表日期": "待用户确认(YYYY-MM-DD,未发表则留空)",
|
||||
"著作权人": "待用户确认",
|
||||
"权利范围": (business.get("rights_scope") or "全部权利") if business else "全部权利",
|
||||
"权利取得方式": (business.get("rights_acquisition") or "原始取得") if business else "原始取得",
|
||||
"开发的硬件环境": hardware_hint,
|
||||
"运行的硬件环境": hardware_hint,
|
||||
"开发该软件的操作系统": dev_os_hint,
|
||||
"软件开发环境 / 开发工具": f"开发环境: {dev_os_hint}/开发工具: {infer_ide_name(project)}",
|
||||
"该软件的运行平台 / 操作系统": infer_runtime_os(analysis),
|
||||
"软件运行支撑环境 / 支持软件": infer_runtime_support(analysis, project),
|
||||
"编程语言": language,
|
||||
"源程序量": str(manifest.get("source_line_count") or manifest.get("selected_source_line_count") or "待用户确认"),
|
||||
"开发目的": (business.get("application_purpose") or f"待用户确认(≤50字符,需说明开发目的,不能只写软件名称)") if business else "待用户确认(≤50字符,需说明开发目的,不能只写软件名称)",
|
||||
"面向领域 / 行业": (business.get("industry") or "待用户确认") if business else "待用户确认",
|
||||
"软件的主要功能": (business.get("main_functions") or summarize_features(analysis, software_name, business)) if business else summarize_features(analysis, software_name, business),
|
||||
"软件的技术特点": (business.get("technical_characteristics") or f"系统采用{framework_text}构建前端界面,结合模块化组件、路由组织、接口封装和状态管理实现业务功能") if business else f"系统采用{framework_text}构建前端界面,结合模块化组件、路由组织、接口封装和状态管理实现业务功能",
|
||||
"页数": str(manifest.get("total_pages") or "待用户确认"),
|
||||
}
|
||||
defaults.update({k: v for k, v in answers.items() if v})
|
||||
|
||||
# 未发表时首次发表日期应为空,不触发待确认门禁
|
||||
publish_status = defaults.get("发表状态", "")
|
||||
if "未发表" in publish_status and "已发表" not in publish_status:
|
||||
if "待用户确认" in defaults.get("首次发表日期", "") or not defaults.get("首次发表日期", "").strip():
|
||||
defaults["首次发表日期"] = ""
|
||||
|
||||
# 软件的主要功能:确保业务理解提供的内容也满足最低字数
|
||||
main_func = defaults.get("软件的主要功能", "")
|
||||
if main_func and "待用户确认" not in main_func and effective_len(main_func) < MIN_MAIN_FUNCTION_CHARS:
|
||||
defaults["软件的主要功能"] = summarize_features(analysis, software_name, business)
|
||||
|
||||
return defaults
|
||||
|
||||
|
||||
def version_numbers(value: str) -> tuple[int, ...]:
|
||||
raw = str(value or "").strip()
|
||||
raw = raw.lstrip("vV")
|
||||
parts = re.findall(r"\d+", raw)
|
||||
return tuple(int(part) for part in parts[:3])
|
||||
|
||||
|
||||
def version_less_than_1(value: str) -> bool:
|
||||
numbers = version_numbers(value)
|
||||
return bool(numbers) and numbers[0] < 1
|
||||
|
||||
|
||||
def normalize_version_label(value: str) -> str:
|
||||
raw = str(value or "").strip()
|
||||
if not raw:
|
||||
return ""
|
||||
return raw if raw.upper().startswith("V") else f"V{raw}"
|
||||
|
||||
|
||||
def project_version_candidate(analysis: dict[str, Any]) -> str:
|
||||
value = str((analysis.get("package") or {}).get("version") or "").strip()
|
||||
if value and value.upper() != "V1.0":
|
||||
return normalize_version_label(value)
|
||||
return ""
|
||||
|
||||
|
||||
def version_confirmation_hint(analysis: dict[str, Any], requested_version: str) -> str:
|
||||
project_version = project_version_candidate(analysis)
|
||||
requested = normalize_version_label(requested_version or "V1.0")
|
||||
if project_version and version_less_than_1(project_version):
|
||||
return (
|
||||
f"待用户确认(项目版本号为 {project_version},软著首次提交通常建议从 V1.0 开始;"
|
||||
f"请确认填写 V1.0 还是 {project_version})"
|
||||
)
|
||||
if not project_version and version_less_than_1(requested):
|
||||
return (
|
||||
f"待用户确认(当前建议版本号为 {requested},软著首次提交通常建议从 V1.0 开始;"
|
||||
f"请确认填写 V1.0 还是 {requested})"
|
||||
)
|
||||
if project_version and project_version != requested:
|
||||
return f"待用户确认(项目版本号为 {project_version},当前建议为 {requested};请确认最终申报版本号)"
|
||||
return f"待用户确认(建议:{requested};请确认最终版本号)"
|
||||
|
||||
|
||||
def format_gb(size: int | None) -> str:
|
||||
if not size:
|
||||
return ""
|
||||
return f"{size / (1024 ** 3):.0f}GB"
|
||||
|
||||
|
||||
def total_memory_bytes() -> int | None:
|
||||
try:
|
||||
return os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_PHYS_PAGES")
|
||||
except (AttributeError, OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def current_hardware_environment() -> str:
|
||||
parts: list[str] = []
|
||||
cpu_count = os.cpu_count()
|
||||
machine = platform.machine()
|
||||
processor = platform.processor()
|
||||
if processor and processor != machine and processor.lower() != "arm":
|
||||
parts.append(f"CPU {processor}")
|
||||
if cpu_count:
|
||||
parts.append(f"CPU {cpu_count}核")
|
||||
if machine:
|
||||
parts.append(f"架构 {machine}")
|
||||
memory = format_gb(total_memory_bytes())
|
||||
if memory:
|
||||
parts.append(f"内存 {memory}")
|
||||
try:
|
||||
disk = shutil.disk_usage(Path.home())
|
||||
disk_total = format_gb(disk.total)
|
||||
if disk_total:
|
||||
parts.append(f"硬盘 {disk_total}")
|
||||
except OSError:
|
||||
pass
|
||||
if parts:
|
||||
return "、".join(parts)
|
||||
return "待用户确认"
|
||||
|
||||
|
||||
def current_operating_system() -> str:
|
||||
system = platform.system()
|
||||
if system == "Darwin":
|
||||
version = platform.mac_ver()[0]
|
||||
label = f"macOS {version}" if version else f"macOS(Darwin {platform.release()})"
|
||||
elif system == "Windows":
|
||||
label = f"Windows {platform.release()}"
|
||||
elif system == "Linux":
|
||||
label = f"Linux {platform.release()}"
|
||||
else:
|
||||
label = f"{system} {platform.release()}".strip() or "待用户确认"
|
||||
return label
|
||||
|
||||
|
||||
def infer_ide_name(project: Path) -> str:
|
||||
if (project / ".idea").exists():
|
||||
return "WebStorm 或 IntelliJ IDEA"
|
||||
if (project / ".vscode").exists():
|
||||
return "Visual Studio Code"
|
||||
if list(project.glob("*.code-workspace")):
|
||||
return "Visual Studio Code"
|
||||
return "Visual Studio Code"
|
||||
|
||||
|
||||
def infer_runtime_os(analysis: dict[str, Any]) -> str:
|
||||
frameworks = set(analysis.get("frameworks") or [])
|
||||
deps = set((analysis.get("package") or {}).get("dependency_names") or [])
|
||||
if "Electron" in frameworks or "electron" in deps or "Tauri" in frameworks or "@tauri-apps/api" in deps:
|
||||
return "Windows 10/11 或 macOS 13及以上版本"
|
||||
if frameworks & {"Vue", "React", "Vite", "Next.js", "Nuxt", "Svelte", "Astro", "Angular"}:
|
||||
return "Windows 10/11 或 macOS 13及以上版本"
|
||||
return "Windows 10/11 或 macOS 13及以上版本"
|
||||
|
||||
|
||||
def project_file(project: Path, relative: str) -> Path | None:
|
||||
if not relative:
|
||||
return None
|
||||
path = project / relative
|
||||
return path if path.exists() else None
|
||||
|
||||
|
||||
def load_project_package(project: Path, analysis: dict[str, Any]) -> dict[str, Any]:
|
||||
package_path = project_file(project, (analysis.get("package") or {}).get("path") or "")
|
||||
if package_path:
|
||||
try:
|
||||
return read_json(package_path)
|
||||
except Exception:
|
||||
return {}
|
||||
return {}
|
||||
|
||||
|
||||
def read_readme(project: Path) -> str:
|
||||
for name in ("README.md", "README.zh.md", "readme.md", "Readme.md"):
|
||||
path = project / name
|
||||
if path.exists():
|
||||
try:
|
||||
return read_text(path, limit=12000)
|
||||
except Exception:
|
||||
return ""
|
||||
return ""
|
||||
|
||||
|
||||
def extract_requirement_bullets(text: str) -> list[str]:
|
||||
wanted = ("python", "node", "docker", "compose", "postgres", "redis", "chrome", "edge", "safari")
|
||||
# Patterns that indicate a feature description rather than a runtime requirement.
|
||||
_feature_start = re.compile(r"^(?:[一-鿿]|L\d|P\d|[A-Z]\d\s)")
|
||||
bullets: list[str] = []
|
||||
for line in text.splitlines():
|
||||
match = re.match(r"\s*[-*]\s+(.+)", line)
|
||||
if not match:
|
||||
continue
|
||||
item = match.group(1).strip()
|
||||
if any(key in item.lower() for key in wanted) and item not in bullets:
|
||||
if len(item) > 80:
|
||||
continue
|
||||
if _feature_start.match(item) and "(" in item:
|
||||
continue
|
||||
bullets.append(item)
|
||||
return bullets[:8]
|
||||
|
||||
|
||||
def detect_package_manager(project: Path, package_path: str) -> str:
|
||||
base = (project / package_path).parent if package_path else project
|
||||
checks = [
|
||||
("pnpm-lock.yaml", "pnpm"),
|
||||
("yarn.lock", "Yarn"),
|
||||
("bun.lock", "Bun"),
|
||||
("bun.lockb", "Bun"),
|
||||
("package-lock.json", "npm"),
|
||||
]
|
||||
for filename, manager in checks:
|
||||
if (base / filename).exists() or (project / filename).exists():
|
||||
return manager
|
||||
return "npm"
|
||||
|
||||
|
||||
def has_support_term(items: list[str], term: str) -> bool:
|
||||
return any(term.lower() in item.lower() for item in items)
|
||||
|
||||
|
||||
def infer_runtime_support(analysis: dict[str, Any], project: Path) -> str:
|
||||
"""Infer runtime support environment (≤50 chars plain text)."""
|
||||
package_info = load_project_package(project, analysis)
|
||||
package_path = (analysis.get("package") or {}).get("path") or ""
|
||||
deps = set((analysis.get("package") or {}).get("dependency_names") or [])
|
||||
frameworks = set(analysis.get("frameworks") or [])
|
||||
|
||||
support: list[str] = []
|
||||
readme_requirements = extract_requirement_bullets(read_readme(project))
|
||||
|
||||
if package_info or deps or frameworks & {"Vue", "React", "Vite", "Next.js", "Nuxt", "Svelte", "Astro", "Angular"}:
|
||||
if not has_support_term(support, "node"):
|
||||
node_engine = str((package_info.get("engines") or {}).get("node") or "").strip()
|
||||
support.append(f"Node.js {node_engine}".strip() if node_engine else "Node.js")
|
||||
support.append(detect_package_manager(project, package_path))
|
||||
support.append("现代浏览器")
|
||||
|
||||
if ((project / "pyproject.toml").exists() or any(project.glob("*/pyproject.toml"))) and not has_support_term(support, "python"):
|
||||
support.append("Python")
|
||||
if ((project / "requirements.txt").exists() or list(project.glob("*/requirements*.txt"))) and not has_support_term(support, "python"):
|
||||
support.append("Python")
|
||||
|
||||
if ((project / "docker-compose.yml").exists() or (project / "docker-compose.yaml").exists() or list(project.glob("docker-compose*.yml"))) and not has_support_term(support, "docker"):
|
||||
support.append("Docker")
|
||||
|
||||
compose_text = ""
|
||||
for compose in list(project.glob("docker-compose*.yml")) + list(project.glob("docker-compose*.yaml")):
|
||||
try:
|
||||
compose_text += "\n" + read_text(compose, limit=20000).lower()
|
||||
except Exception:
|
||||
continue
|
||||
if "postgres" in compose_text:
|
||||
support.append("PostgreSQL")
|
||||
if "redis" in compose_text:
|
||||
support.append("Redis")
|
||||
|
||||
# Also incorporate readme requirements into support software
|
||||
for req in readme_requirements:
|
||||
if not has_support_term(support, req.split()[0].lower()):
|
||||
support.append(req)
|
||||
|
||||
# Deduplicate
|
||||
unique: list[str] = []
|
||||
for item in support:
|
||||
clean = str(item).strip().rstrip(";;")
|
||||
if clean and clean not in unique:
|
||||
unique.append(clean)
|
||||
|
||||
if not unique:
|
||||
return "待用户确认"
|
||||
|
||||
# Enforce ≤50 char limit: trim items if needed
|
||||
result = "、".join(unique)
|
||||
if len(result) > 50:
|
||||
trimmed: list[str] = []
|
||||
for item in unique:
|
||||
candidate = "、".join(trimmed + [item])
|
||||
if len(candidate) <= 50:
|
||||
trimmed.append(item)
|
||||
else:
|
||||
break
|
||||
result = "、".join(trimmed) if trimmed else unique[0][:50]
|
||||
return result
|
||||
|
||||
|
||||
def write_application_md(path: Path, fields: dict[str, str], analysis: dict[str, Any], manifest: dict[str, Any], business: dict[str, Any] | None = None) -> None:
|
||||
# 兜底:未发表时首次发表日期不应输出"待用户确认",直接留空
|
||||
publish_status = fields.get("发表状态", "")
|
||||
if "未发表" in publish_status and "已发表" not in publish_status:
|
||||
if "待用户确认" in (fields.get("首次发表日期") or "") or not (fields.get("首次发表日期") or "").strip():
|
||||
fields["首次发表日期"] = ""
|
||||
|
||||
lines = ["# 申请表信息", ""]
|
||||
for field in FIELD_ORDER:
|
||||
lines.append(f"➤{field}:{fields.get(field, '待用户确认')}")
|
||||
pending = [field for field in FIELD_ORDER if "待用户确认" in (fields.get(field) or "")]
|
||||
|
||||
# Build warnings for common issues
|
||||
warnings: list[str] = []
|
||||
soft_name = fields.get("软件全称", "")
|
||||
clean_name = str(soft_name).strip()
|
||||
raw_name = clean_name
|
||||
if "待用户确认" in clean_name and "建议:" in clean_name:
|
||||
try:
|
||||
raw_name = clean_name.split("建议:")[1].rstrip(")").split(";")[0].strip()
|
||||
except (IndexError, ValueError):
|
||||
raw_name = clean_name
|
||||
for suffix in ["软件", "平台"]:
|
||||
if raw_name.endswith(suffix):
|
||||
warnings.append(f"软件全称以「{suffix}」结尾,存在被驳回风险。建议考虑去掉「{suffix}」后缀或改用其他命名方式。")
|
||||
|
||||
main_func = fields.get("软件的主要功能", "")
|
||||
if main_func and "待用户确认" not in main_func:
|
||||
func_len = len(str(main_func).replace(" ", "").replace("\n", ""))
|
||||
if func_len < MIN_MAIN_FUNCTION_CHARS:
|
||||
warnings.append(f"软件的主要功能仅有 {func_len} 字符,应不少于 {MIN_MAIN_FUNCTION_CHARS} 字符。请扩写功能说明。")
|
||||
elif func_len > MAX_MAIN_FUNCTION_CHARS:
|
||||
warnings.append(f"软件的主要功能共 {func_len} 字符,超过建议上限 {MAX_MAIN_FUNCTION_CHARS} 字符。请精简。")
|
||||
|
||||
# Check character limits for fields with ≤50 or ≤100 constraints
|
||||
char_limit_fields = {
|
||||
"开发的硬件环境": 50,
|
||||
"运行的硬件环境": 50,
|
||||
"开发该软件的操作系统": 50,
|
||||
"软件开发环境 / 开发工具": 50,
|
||||
"该软件的运行平台 / 操作系统": 50,
|
||||
"软件运行支撑环境 / 支持软件": 50,
|
||||
"开发目的": 50,
|
||||
"面向领域 / 行业": 50,
|
||||
"软件的技术特点": 100,
|
||||
}
|
||||
for field_name, limit in char_limit_fields.items():
|
||||
value = fields.get(field_name, "")
|
||||
if value and "待用户确认" not in value and len(value) > limit:
|
||||
warnings.append(f"{field_name}共 {len(value)} 字符,超过限制 {limit} 字符。请精简。")
|
||||
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"## 字段填写口径",
|
||||
"",
|
||||
"- 软件全称:必须由用户确认;最终正式资料文件名、代码页眉和操作手册中的软件名称均以本字段为准。",
|
||||
"- 软件简称:可选;如有常用简称则填写。",
|
||||
"- 版本号:必须由用户确认;如果项目版本小于 V1.0,软著首次提交通常建议使用 V1.0,也可按实际项目版本填写,最终以本字段为准。",
|
||||
"- 软件分类:应用软件/嵌入式软件/中间件/系统软件/其他。",
|
||||
"- 开发完成日期、首次发表日期:必须使用 YYYY-MM-DD 格式。",
|
||||
"- 开发方式:单独开发/合作开发/委托开发/下达任务开发。",
|
||||
"- 软件说明:原创 / 修改(含翻译软件、合成软件)。",
|
||||
"- 发表状态:已发表或未发表;已发表需附首次发表日期。",
|
||||
"- 软件开发环境 / 开发工具:≤50字符,格式 `开发环境: <OS>/开发工具: <IDE名称>`。",
|
||||
"- 开发该软件的操作系统:≤50字符,填写实际开发电脑的操作系统版本。",
|
||||
"- 该软件的运行平台 / 操作系统:≤50字符,填写软件运行所在的操作系统或浏览器环境。",
|
||||
"- 软件运行支撑环境 / 支持软件:≤50字符,直接列出运行依赖(如 Node.js、npm、浏览器),不加格式前缀。",
|
||||
"- 开发的硬件环境和运行的硬件环境:≤50字符,可使用检测到的电脑配置作为建议值。",
|
||||
"- 源程序量:纯数字(不含'行'字),指全部源程序的总行数。",
|
||||
"- 开发目的:≤50字符,用一句话说明目的,不能只写软件名称。",
|
||||
"- 面向领域 / 行业:≤50字符。",
|
||||
"- 软件的主要功能:500~1300字符。",
|
||||
"- 软件的技术特点:多选标签(APP/游戏软件/教育软件/金融软件/医疗软件/地理信息软件/云计算软件/信息安全软件/大数据软件/人工智能软件/VR软件/5G软件/小程序/物联网软件/智慧城市软件)+ 文本描述≤100字符。",
|
||||
"",
|
||||
"## 项目分析摘要",
|
||||
"",
|
||||
f"- 项目目录:{analysis.get('project_root', '')}",
|
||||
f"- 框架:{'、'.join(analysis.get('frameworks') or []) or '未识别'}",
|
||||
f"- 源码文件数:{analysis.get('source', {}).get('total_file_count', analysis.get('source', {}).get('file_count', 0))}",
|
||||
f"- 源程序量(含空行):{analysis.get('source', {}).get('total_line_count', analysis.get('source', {}).get('line_count', 0))}",
|
||||
f"- 代码材料页数:{manifest.get('total_pages', 0)}",
|
||||
f"- 代码输出模式:{manifest.get('mode', '')}",
|
||||
f"- 业务理解:{'已读取 草稿/业务理解.json' if business else '未提供,使用项目分析兜底'}",
|
||||
"",
|
||||
"## 待确认字段",
|
||||
"",
|
||||
]
|
||||
)
|
||||
if warnings:
|
||||
lines.append("## 字段提醒")
|
||||
lines.append("")
|
||||
lines.extend(f"- {w}" for w in warnings)
|
||||
lines.append("")
|
||||
if pending:
|
||||
lines.extend(f"- {field}" for field in pending)
|
||||
else:
|
||||
lines.append("- 无")
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"```text",
|
||||
"STOP_FOR_USER",
|
||||
"NEXT_ACTION: 请补全并确认申请表字段;确认后运行 confirm_stage.py --stage application-fields。",
|
||||
"```",
|
||||
]
|
||||
)
|
||||
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def require_confirmed_business(business: dict[str, Any] | None) -> None:
|
||||
if business is None:
|
||||
raise SystemExit(
|
||||
"STOP_FOR_USER\n"
|
||||
"NEXT_ACTION: 申请表信息必须基于已确认的业务理解生成。请先生成并确认 草稿/业务理解.md。"
|
||||
)
|
||||
if business.get("confirmation_required") and not business.get("user_confirmed"):
|
||||
raise SystemExit(
|
||||
"STOP_FOR_USER\n"
|
||||
"NEXT_ACTION: 业务理解尚未确认。请先确认 草稿/业务理解.md,"
|
||||
"再运行 `python3 <SKILL_DIR>/scripts/confirm_stage.py --workdir 软件著作权申请资料 --stage business --note \"<用户确认内容>\"`。"
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--analysis", required=True)
|
||||
parser.add_argument("--code-manifest", required=True)
|
||||
parser.add_argument("--software-name", required=True)
|
||||
parser.add_argument("--version", default="V1.0")
|
||||
parser.add_argument("--answers", help="Optional JSON object with confirmed field values")
|
||||
parser.add_argument("--business-context", help="Business context JSON generated before material drafting")
|
||||
parser.add_argument("--out-dir", default="软件著作权申请资料/草稿")
|
||||
args = parser.parse_args()
|
||||
|
||||
analysis = read_json(Path(args.analysis))
|
||||
manifest = read_json(Path(args.code_manifest))
|
||||
answers = read_json(Path(args.answers)) if args.answers else {}
|
||||
business = read_json(Path(args.business_context)) if args.business_context else None
|
||||
require_confirmed_business(business)
|
||||
out_dir = ensure_dir(Path(args.out_dir))
|
||||
|
||||
fields = build_fields(analysis, manifest, args.software_name, args.version, answers, business)
|
||||
out_path = out_dir / "申请表信息.md"
|
||||
write_application_md(out_path, fields, analysis, manifest, business)
|
||||
print(f"OK application draft: {out_path}")
|
||||
print("STOP_FOR_USER")
|
||||
print("NEXT_ACTION: 请补全并确认申请表字段;确认后运行 confirm_stage.py --stage application-fields。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,431 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Collect project evidence and write a model-authored business context."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from common import ensure_dir, iter_project_files, read_json, read_text, rel, write_json
|
||||
|
||||
|
||||
DOC_EXTS = {".md", ".txt", ".rst", ".adoc"}
|
||||
MAX_DOC_CHARS = 80_000
|
||||
MAX_DOCS = 40
|
||||
|
||||
|
||||
def normalize_space(text: str) -> str:
|
||||
return re.sub(r"\s+", " ", text).strip()
|
||||
|
||||
|
||||
def strip_md(text: str) -> str:
|
||||
text = re.sub(r"`{3}.*?`{3}", " ", text, flags=re.S)
|
||||
text = re.sub(r"\[(.*?)\]\(.*?\)", r"\1", text)
|
||||
text = re.sub(r"[>#*_`|]", " ", text)
|
||||
return normalize_space(text)
|
||||
|
||||
|
||||
def skip_doc(path: Path, project: Path) -> bool:
|
||||
r = rel(path, project).lower()
|
||||
skip_parts = (
|
||||
"node_modules",
|
||||
".git/",
|
||||
"dist/",
|
||||
"build/",
|
||||
".next/",
|
||||
"coverage/",
|
||||
"软件著作权申请资料",
|
||||
)
|
||||
return any(part in r for part in skip_parts)
|
||||
|
||||
|
||||
def extract_headings(text: str, limit: int = 24) -> list[str]:
|
||||
headings: list[str] = []
|
||||
for line in text.splitlines():
|
||||
clean = line.strip()
|
||||
if clean.startswith("#"):
|
||||
title = clean.lstrip("#").strip()
|
||||
if title and title not in headings:
|
||||
headings.append(title[:120])
|
||||
if len(headings) >= limit:
|
||||
break
|
||||
return headings
|
||||
|
||||
|
||||
def extract_opening(text: str, limit: int = 900) -> str:
|
||||
clean = strip_md(text)
|
||||
return clean[:limit].strip()
|
||||
|
||||
|
||||
def collect_documents(project: Path) -> list[dict[str, Any]]:
|
||||
docs: list[dict[str, Any]] = []
|
||||
for path in iter_project_files(project, DOC_EXTS):
|
||||
if skip_doc(path, project):
|
||||
continue
|
||||
try:
|
||||
text = read_text(path, limit=MAX_DOC_CHARS)
|
||||
except Exception:
|
||||
continue
|
||||
if not text.strip():
|
||||
continue
|
||||
docs.append(
|
||||
{
|
||||
"path": rel(path, project),
|
||||
"size": path.stat().st_size,
|
||||
"headings": extract_headings(text),
|
||||
"opening": extract_opening(text),
|
||||
}
|
||||
)
|
||||
docs.sort(key=lambda item: (item["path"].count("/"), item["path"]))
|
||||
return docs[:MAX_DOCS]
|
||||
|
||||
|
||||
def collect_code_evidence(analysis: dict[str, Any]) -> dict[str, Any]:
|
||||
source = analysis.get("source") or {}
|
||||
categorized = source.get("categorized_files") or {}
|
||||
return {
|
||||
"project_name": analysis.get("project_name"),
|
||||
"software_name_candidate": analysis.get("software_name_candidate"),
|
||||
"frameworks": analysis.get("frameworks") or [],
|
||||
"language": analysis.get("language"),
|
||||
"routes": analysis.get("routes") or [],
|
||||
"feature_name_candidates": analysis.get("feature_candidates") or [],
|
||||
"entry_files": categorized.get("entry") or [],
|
||||
"page_files": categorized.get("page") or [],
|
||||
"component_files": categorized.get("component") or [],
|
||||
"api_files": categorized.get("api") or [],
|
||||
"run_command_candidates": analysis.get("run_command_candidates") or [],
|
||||
"package": analysis.get("package") or {},
|
||||
}
|
||||
|
||||
|
||||
def build_evidence(project: Path, analysis: dict[str, Any], software_name: str, web_notes: str) -> dict[str, Any]:
|
||||
return {
|
||||
"software_name": software_name,
|
||||
"project_root": str(project.resolve()),
|
||||
"instruction": (
|
||||
"本文件只收集证据,不决定行业、功能或手册结构。"
|
||||
"请由模型阅读这些证据以及必要的项目源码后,另行编写业务理解模型稿。"
|
||||
),
|
||||
"documents": collect_documents(project),
|
||||
"code_evidence": collect_code_evidence(analysis),
|
||||
"external_research_notes": web_notes,
|
||||
}
|
||||
|
||||
|
||||
def write_evidence_md(path: Path, evidence: dict[str, Any]) -> None:
|
||||
lines = [
|
||||
"# 业务理解证据",
|
||||
"",
|
||||
f"- 软件名称:{evidence['software_name']}",
|
||||
f"- 项目目录:`{evidence['project_root']}`",
|
||||
"",
|
||||
"本文件只列出可供模型研判的项目证据,不代表最终申报口径。",
|
||||
"模型需要自行判断应阅读哪些文档、抽取哪些功能、采用什么操作手册结构。",
|
||||
"",
|
||||
"## 代码与页面证据",
|
||||
"",
|
||||
]
|
||||
code = evidence["code_evidence"]
|
||||
for key in ("frameworks", "language", "routes", "feature_name_candidates", "entry_files", "page_files", "component_files", "api_files"):
|
||||
value = code.get(key)
|
||||
if value:
|
||||
lines.append(f"- {key}:{value}")
|
||||
lines.extend(["", "## 文档证据", ""])
|
||||
for doc in evidence["documents"]:
|
||||
lines.extend(
|
||||
[
|
||||
f"### {doc['path']}",
|
||||
"",
|
||||
f"- 大小:{doc['size']} bytes",
|
||||
f"- 标题线索:{';'.join(doc['headings']) if doc['headings'] else '无'}",
|
||||
"",
|
||||
doc["opening"],
|
||||
"",
|
||||
]
|
||||
)
|
||||
if evidence.get("external_research_notes"):
|
||||
lines.extend(["## 外部调研摘要", "", evidence["external_research_notes"], ""])
|
||||
path.write_text("\n".join(lines), encoding="utf-8")
|
||||
|
||||
|
||||
def write_model_template(path: Path, evidence: dict[str, Any]) -> None:
|
||||
template = {
|
||||
"software_name": evidence["software_name"],
|
||||
"product_positioning": "",
|
||||
"industry": "",
|
||||
"target_users": [],
|
||||
"core_value": "",
|
||||
"business_features": [],
|
||||
"business_feature_details": {},
|
||||
"operation_flow": [],
|
||||
"application_purpose": "",
|
||||
"main_functions": "",
|
||||
"technical_characteristics": "",
|
||||
"software_technical_option": "应用软件",
|
||||
"software_category": "应用软件",
|
||||
"manual_sections": [
|
||||
{
|
||||
"title": "模型自行命名章节",
|
||||
"intent": "说明该章节为什么适合当前项目。",
|
||||
"paragraphs": [],
|
||||
"include_feature_overview": False,
|
||||
"include_operation_modules": False,
|
||||
"include_operation_flow": False,
|
||||
}
|
||||
],
|
||||
"manual_modules": [
|
||||
{
|
||||
"title": "真实页面或核心流程名称",
|
||||
"evidence": ["页面、路由、组件、README 或需求文档路径"],
|
||||
"purpose": "该页面或流程在当前软件中的用途。",
|
||||
"usage": "用户在什么业务场景下会使用该页面,正在处理什么具体事务。",
|
||||
"entry": "用户从哪里进入该页面或流程。",
|
||||
"visible_elements": ["用户实际能看到的输入框、按钮、列表、状态或结果区域"],
|
||||
"operation_steps": ["按真实页面顺序描述用户动作,不写代码实现。"],
|
||||
"validation_rules": ["输入限制、必填项、额度、权限、异常提示等规则;没有则留空数组。"],
|
||||
"feedback": ["操作完成后用户能看到的结果、提示或状态变化。"],
|
||||
"screenshot": "截图预留说明",
|
||||
}
|
||||
],
|
||||
"system_requirements": [
|
||||
{"item": "操作系统", "minimum": "按项目实际填写", "recommended": "按项目实际填写"},
|
||||
{"item": "浏览器或客户端", "minimum": "按项目实际填写", "recommended": "按项目实际填写"},
|
||||
],
|
||||
"faq": [
|
||||
{"question": "按当前软件真实使用场景填写常见问题", "answer": "给出面向普通用户的处理方法。"}
|
||||
],
|
||||
"glossary": [
|
||||
{"term": "当前软件中的业务术语", "definition": "用普通中文解释含义。"}
|
||||
],
|
||||
"model_review_notes": [
|
||||
"操作手册应采用软著审核友好的通用骨架:相关文档、说明、功能特点、系统要求、按真实页面/流程逐章操作、常见问题、术语表。",
|
||||
"manual_modules 要按当前项目真实页面、导航入口、按钮、输入限制、系统反馈和截图位置编写,不能只写抽象功能名。",
|
||||
"不要照抄范本文案;范本只说明手册需要具体、可操作、能给审核员看懂。",
|
||||
"不要用关键词表决定行业和功能;必须能从项目证据或用户补充中解释来源。",
|
||||
],
|
||||
}
|
||||
write_json(path, template)
|
||||
|
||||
|
||||
def load_model_context(path: Path) -> dict[str, Any]:
|
||||
data = read_json(path)
|
||||
if not isinstance(data, dict):
|
||||
raise SystemExit(f"Invalid model context JSON: {path}")
|
||||
return data
|
||||
|
||||
|
||||
def required_list(value: Any, field: str) -> list[str]:
|
||||
if not isinstance(value, list):
|
||||
raise SystemExit(f"Model context field must be a list: {field}")
|
||||
items = [str(item).strip() for item in value if str(item).strip()]
|
||||
if not items:
|
||||
raise SystemExit(f"Model context field cannot be empty: {field}")
|
||||
return items
|
||||
|
||||
|
||||
def required_text(data: dict[str, Any], field: str) -> str:
|
||||
value = str(data.get(field) or "").strip()
|
||||
if not value:
|
||||
raise SystemExit(f"Model context field cannot be empty: {field}")
|
||||
return value
|
||||
|
||||
|
||||
def normalize_model_context(model: dict[str, Any], evidence: dict[str, Any], web_notes: str) -> dict[str, Any]:
|
||||
features = required_list(model.get("business_features"), "business_features")
|
||||
details = model.get("business_feature_details") or {}
|
||||
if not isinstance(details, dict):
|
||||
raise SystemExit("Model context field must be an object: business_feature_details")
|
||||
missing = [feature for feature in features if not str(details.get(feature) or "").strip()]
|
||||
if missing:
|
||||
raise SystemExit("Model context missing feature details: " + "、".join(missing[:12]))
|
||||
sections = model.get("manual_sections") or []
|
||||
if sections and not isinstance(sections, list):
|
||||
raise SystemExit("Model context field must be a list: manual_sections")
|
||||
manual_modules = model.get("manual_modules") or []
|
||||
if manual_modules and not isinstance(manual_modules, list):
|
||||
raise SystemExit("Model context field must be a list: manual_modules")
|
||||
if not manual_modules:
|
||||
raise SystemExit("Model context field cannot be empty: manual_modules")
|
||||
for index, module in enumerate(manual_modules, start=1):
|
||||
if not isinstance(module, dict):
|
||||
raise SystemExit(f"manual_modules item {index} must be an object")
|
||||
title = str(module.get("title") or module.get("feature") or "").strip()
|
||||
for field in ("purpose", "usage", "entry", "operation_steps", "feedback"):
|
||||
value = module.get(field)
|
||||
if field == "usage" and not str(value or "").strip():
|
||||
value = module.get("usage_scenario")
|
||||
if isinstance(value, list):
|
||||
missing_value = not any(str(item).strip() for item in value)
|
||||
else:
|
||||
missing_value = not str(value or "").strip()
|
||||
if missing_value:
|
||||
raise SystemExit(f"manual_modules item {index} ({title or 'untitled'}) missing field: {field}")
|
||||
system_requirements = model.get("system_requirements") or []
|
||||
if system_requirements and not isinstance(system_requirements, list):
|
||||
raise SystemExit("Model context field must be a list: system_requirements")
|
||||
if not system_requirements:
|
||||
raise SystemExit("Model context field cannot be empty: system_requirements")
|
||||
faq = model.get("faq") or []
|
||||
if faq and not isinstance(faq, list):
|
||||
raise SystemExit("Model context field must be a list: faq")
|
||||
if not faq:
|
||||
raise SystemExit("Model context field cannot be empty: faq")
|
||||
glossary = model.get("glossary") or []
|
||||
if glossary and not isinstance(glossary, list):
|
||||
raise SystemExit("Model context field must be a list: glossary")
|
||||
if not glossary:
|
||||
raise SystemExit("Model context field cannot be empty: glossary")
|
||||
context = {
|
||||
"software_name": evidence["software_name"],
|
||||
"business_understanding_required": True,
|
||||
"source_documents": [{"path": doc["path"], "size": doc["size"]} for doc in evidence["documents"]],
|
||||
"project_evidence_file": "业务理解证据.md",
|
||||
"product_positioning": required_text(model, "product_positioning"),
|
||||
"industry": required_text(model, "industry"),
|
||||
"target_users": required_list(model.get("target_users"), "target_users"),
|
||||
"core_value": required_text(model, "core_value"),
|
||||
"business_features": features,
|
||||
"business_feature_details": {feature: str(details.get(feature)).strip() for feature in features},
|
||||
"operation_flow": required_list(model.get("operation_flow"), "operation_flow"),
|
||||
"application_purpose": required_text(model, "application_purpose"),
|
||||
"main_functions": required_text(model, "main_functions"),
|
||||
"technical_characteristics": required_text(model, "technical_characteristics"),
|
||||
"software_technical_option": str(model.get("software_technical_option") or "应用软件"),
|
||||
"software_category": str(model.get("software_category") or "应用软件"),
|
||||
"manual_sections": sections,
|
||||
"manual_modules": manual_modules,
|
||||
"system_requirements": system_requirements,
|
||||
"faq": faq,
|
||||
"glossary": glossary,
|
||||
"model_authored": True,
|
||||
"external_research_notes": web_notes,
|
||||
"confirmation_required": True,
|
||||
"user_confirmed": False,
|
||||
"confirmation_stage": "business",
|
||||
"next_action": "请确认 草稿/业务理解.md 中的软件用途、行业、目标用户、核心功能、手册结构和申请口径;确认后运行 confirm_stage.py --stage business。",
|
||||
"review_notes": [
|
||||
"请确认模型判断的行业领域、目标用户和主要功能是否符合实际申报口径。",
|
||||
"请确认操作手册结构是否按真实页面和流程展开,而不是套用抽象功能列表。",
|
||||
],
|
||||
}
|
||||
return context
|
||||
|
||||
|
||||
def write_context_md(path: Path, context: dict[str, Any]) -> None:
|
||||
lines = [
|
||||
"# 业务理解",
|
||||
"",
|
||||
f"- 软件名称:{context['software_name']}",
|
||||
f"- 产品定位:{context['product_positioning']}",
|
||||
f"- 面向领域 / 行业:{context['industry']}",
|
||||
f"- 核心价值:{context['core_value']}",
|
||||
f"- 证据文件:`{context['project_evidence_file']}`",
|
||||
"",
|
||||
"## 目标用户",
|
||||
"",
|
||||
]
|
||||
lines.extend(f"- {item}" for item in context["target_users"])
|
||||
lines.extend(["", "## 主要业务功能", ""])
|
||||
lines.extend(f"- {item}" for item in context["business_features"])
|
||||
lines.extend(["", "## 功能说明", ""])
|
||||
for item in context["business_features"]:
|
||||
lines.append(f"- {item}:{context['business_feature_details'].get(item, '')}")
|
||||
lines.extend(["", "## 典型操作流程", ""])
|
||||
lines.extend(f"{i}. {item}" for i, item in enumerate(context["operation_flow"], start=1))
|
||||
if context.get("manual_sections"):
|
||||
lines.extend(["", "## 操作手册结构建议", ""])
|
||||
for i, section in enumerate(context["manual_sections"], start=1):
|
||||
if isinstance(section, dict):
|
||||
title = section.get("title") or f"章节 {i}"
|
||||
intent = section.get("intent") or ""
|
||||
else:
|
||||
title = str(section)
|
||||
intent = ""
|
||||
lines.append(f"{i}. {title}" + (f":{intent}" if intent else ""))
|
||||
if context.get("manual_modules"):
|
||||
lines.extend(["", "## 操作手册页面/流程模块", ""])
|
||||
for i, module in enumerate(context["manual_modules"], start=1):
|
||||
if not isinstance(module, dict):
|
||||
lines.append(f"{i}. {module}")
|
||||
continue
|
||||
title = module.get("title") or module.get("feature") or f"模块 {i}"
|
||||
usage = module.get("usage") or module.get("usage_scenario") or ""
|
||||
entry = module.get("entry") or ""
|
||||
steps = module.get("operation_steps") or module.get("steps") or []
|
||||
lines.append(f"{i}. {title}" + (f":{entry}" if entry else ""))
|
||||
if usage:
|
||||
lines.append(f" - 使用场景:{usage}")
|
||||
if steps:
|
||||
lines.append(f" - 操作要点:{';'.join(str(item) for item in steps[:4])}")
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"## 申请表建议口径",
|
||||
"",
|
||||
f"- 开发目的:{context['application_purpose']}",
|
||||
f"- 软件的主要功能:{context['main_functions']}",
|
||||
f"- 技术特点:{context['technical_characteristics']}",
|
||||
f"- 软件的技术特点选项:{context['software_technical_option']}",
|
||||
f"- 软件分类:{context['software_category']}",
|
||||
"",
|
||||
"## 证据来源",
|
||||
"",
|
||||
]
|
||||
)
|
||||
lines.extend(f"- `{item['path']}`" for item in context["source_documents"])
|
||||
lines.extend(["", "## 待确认", ""])
|
||||
lines.extend(f"- {item}" for item in context["review_notes"])
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"```text",
|
||||
"STOP_FOR_USER",
|
||||
f"NEXT_ACTION: {context['next_action']}",
|
||||
"```",
|
||||
]
|
||||
)
|
||||
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--project", required=True)
|
||||
parser.add_argument("--analysis", required=True)
|
||||
parser.add_argument("--software-name", required=True)
|
||||
parser.add_argument("--out-dir", default="软件著作权申请资料/草稿")
|
||||
parser.add_argument("--web-notes", help="Optional plain-text notes from external/competitor research")
|
||||
parser.add_argument("--model-context", help="Model-authored business context JSON")
|
||||
args = parser.parse_args()
|
||||
|
||||
project = Path(args.project)
|
||||
analysis = read_json(Path(args.analysis))
|
||||
web_notes = read_text(Path(args.web_notes)) if args.web_notes else ""
|
||||
out_dir = ensure_dir(Path(args.out_dir))
|
||||
|
||||
evidence = build_evidence(project, analysis, args.software_name, web_notes)
|
||||
write_json(out_dir / "业务理解证据.json", evidence)
|
||||
write_evidence_md(out_dir / "业务理解证据.md", evidence)
|
||||
|
||||
if not args.model_context:
|
||||
write_model_template(out_dir / "业务理解模型稿模板.json", evidence)
|
||||
print(f"OK business evidence: {out_dir / '业务理解证据.md'}")
|
||||
print(f"OK model template: {out_dir / '业务理解模型稿模板.json'}")
|
||||
print("NEXT_ACTION: 模型需要阅读业务理解证据和项目源码,自行编写业务理解模型稿 JSON,然后用 --model-context 生成业务理解.md/json。")
|
||||
return
|
||||
|
||||
model = load_model_context(Path(args.model_context))
|
||||
context = normalize_model_context(model, evidence, web_notes)
|
||||
write_json(out_dir / "业务理解.json", context)
|
||||
write_context_md(out_dir / "业务理解.md", context)
|
||||
print(f"OK business context: {out_dir / '业务理解.md'}")
|
||||
print(f"Features: {len(context['business_features'])}")
|
||||
print("STOP_FOR_USER")
|
||||
print(f"NEXT_ACTION: {context['next_action']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,837 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate a reviewer-oriented operation manual Markdown draft."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from common import ensure_dir, read_json
|
||||
|
||||
|
||||
def join_items(items: list[str], limit: int = 4) -> str:
|
||||
values = [str(item) for item in items if str(item).strip()]
|
||||
if not values:
|
||||
return "业务用户"
|
||||
return "、".join(values[:limit])
|
||||
|
||||
|
||||
def feature_summary(feature: str, detail: str, software_name: str) -> str:
|
||||
clean_detail = normalize_detail(feature, detail)
|
||||
return clean_detail
|
||||
|
||||
|
||||
def plain_manual_text(text: str) -> str:
|
||||
value = text
|
||||
replacements = {
|
||||
"多 Agent": "多智能体",
|
||||
"多 agent": "多智能体",
|
||||
"业务逻辑": "使用过程",
|
||||
"前端页面": "软件页面",
|
||||
"前端": "界面",
|
||||
"后端服务": "系统服务",
|
||||
"后端": "系统服务",
|
||||
"接口": "数据通道",
|
||||
"组件": "页面组成部分",
|
||||
"路由": "页面入口",
|
||||
"状态管理": "状态记录",
|
||||
"数据持久化": "数据保存",
|
||||
"异步任务": "后台处理任务",
|
||||
"任务队列": "任务处理服务",
|
||||
"模型": "智能服务",
|
||||
"调度中心": "协调中心",
|
||||
"结构化依据": "后续说明",
|
||||
"高成本生成": "耗时较长的内容生成",
|
||||
}
|
||||
for source, target in replacements.items():
|
||||
value = value.replace(source, target)
|
||||
value = re.sub(r"(?<![A-Za-z])Agent(?![A-Za-z])", "智能体", value)
|
||||
value = re.sub(r"(?<![A-Za-z])agent(?![A-Za-z])", "智能体", value)
|
||||
value = re.sub(r"\b(?!Node\.js\b)[A-Za-z]+\.js\b", "相关软件能力", value)
|
||||
value = re.sub(r"\bReact\b|\bVue\b|\bVite\b|\bNext\b|\bNext\.js\b|\bFastAPI\b|\bLangGraph\b|\bCelery\b|\bSSE\b", "相关软件能力", value)
|
||||
value = re.sub(r"相关软件能力、相关软件能力", "相关软件能力", value)
|
||||
value = re.sub(r"多智能体\s+协作", "多智能体协作", value)
|
||||
return value
|
||||
|
||||
|
||||
def plain_feature_name(name: str) -> str:
|
||||
value = plain_manual_text(str(name))
|
||||
value = value.replace("Chat", "对话")
|
||||
return value.strip() or "核心功能"
|
||||
|
||||
|
||||
def normalize_detail(feature: str, detail: str) -> str:
|
||||
value = plain_manual_text(detail or "").strip()
|
||||
value = re.sub(rf"^{re.escape(feature)}(模块|功能)?用于", "", value)
|
||||
value = re.sub(rf"^{re.escape(feature)}主要用于", "", value)
|
||||
value = re.sub(r"^主要用于", "", value)
|
||||
value = re.sub(rf"^{re.escape(feature)}[::,, ]*", "", value)
|
||||
value = re.sub(rf"^用户使用{re.escape(feature)}时,可以", "", value)
|
||||
value = re.sub(rf"^进入{re.escape(feature)}后,用户可以", "", value)
|
||||
value = re.sub(rf"^在{re.escape(feature)}中,用户可以", "", value)
|
||||
value = re.sub(rf"^用户通过{re.escape(feature)}可以", "", value)
|
||||
value = re.sub(rf"^在{re.escape(feature)}环节,用户可以", "", value)
|
||||
value = re.sub(rf"^通过{re.escape(feature)},用户可以", "", value)
|
||||
value = value.strip("。;; ,,")
|
||||
if not value or value == feature:
|
||||
value = "支撑软件中的相关业务处理,帮助用户完成信息查看、内容填写、结果确认或资料维护"
|
||||
return value + ("。" if not value.endswith("。") else "")
|
||||
|
||||
|
||||
TECHNICAL_TERMS = [
|
||||
"技术实现",
|
||||
"代码",
|
||||
"框架",
|
||||
"接口封装",
|
||||
"状态管理",
|
||||
"异步任务",
|
||||
"任务队列",
|
||||
"数据持久化",
|
||||
"业务逻辑",
|
||||
"React",
|
||||
"Next.js",
|
||||
"FastAPI",
|
||||
"LangGraph",
|
||||
"Celery",
|
||||
]
|
||||
|
||||
TEMPLATE_MARKERS = [
|
||||
"重要功能之一",
|
||||
"通过清晰的页面入口、信息展示和结果反馈",
|
||||
"对应操作环节",
|
||||
"审核时可重点查看",
|
||||
"审核人员可通过",
|
||||
"按照页面提示填写内容、选择资料、确认方案或点击提交按钮",
|
||||
"系统处理完成后显示结果或提示信息",
|
||||
"帮助用户用户",
|
||||
"帮助用户系统",
|
||||
"主要用于在",
|
||||
"项目管理或资产中心项目管理",
|
||||
"进入方式:",
|
||||
"页面内容:",
|
||||
"操作步骤:",
|
||||
"操作规则:",
|
||||
"操作结果与反馈:",
|
||||
"功能特点根据当前项目资料",
|
||||
"软件围绕",
|
||||
]
|
||||
|
||||
AI_TONE_MARKERS = [
|
||||
"旨在",
|
||||
"赋能",
|
||||
"一站式",
|
||||
"智能化",
|
||||
"高效便捷",
|
||||
"显著提升",
|
||||
"强大能力",
|
||||
"丰富功能",
|
||||
"极大地",
|
||||
"全方位",
|
||||
"多维度",
|
||||
"闭环",
|
||||
"降本增效",
|
||||
"优化体验",
|
||||
"提升效率",
|
||||
]
|
||||
|
||||
|
||||
def manual_section_body(text: str, title: str) -> str:
|
||||
number_pattern = r"(?:\(\d+\)、|[零一二三四五六七八九十百]+、)"
|
||||
pattern = re.compile(rf"^##\s+{number_pattern}\s*{re.escape(title)}\s*$", flags=re.M)
|
||||
match = pattern.search(text)
|
||||
if not match:
|
||||
return ""
|
||||
next_match = re.search(rf"^##\s+{number_pattern}", text[match.end() :], flags=re.M)
|
||||
end = match.end() + next_match.start() if next_match else len(text)
|
||||
return text[match.end() : end].strip()
|
||||
|
||||
|
||||
def manual_quality_issues(text: str, modules: list[dict[str, Any]]) -> list[str]:
|
||||
issues: list[str] = []
|
||||
required_sections = ["相关文档", "说明", "功能特点", "系统要求", "常见问题解答", "术语表"]
|
||||
for title in required_sections:
|
||||
if not manual_section_body(text, title):
|
||||
issues.append(f"缺少通用手册章节:{title}")
|
||||
if re.search(r"^##\s+\(\d+\)、", text, flags=re.M):
|
||||
issues.append("章节标题仍使用括号数字,应使用中文大写序号")
|
||||
related_body = manual_section_body(text, "相关文档")
|
||||
if related_body and "| 文档名称 |" not in related_body:
|
||||
issues.append("相关文档章节应使用表格指向配套文档")
|
||||
for term in TECHNICAL_TERMS:
|
||||
if term in text:
|
||||
issues.append(f"存在偏技术表达:{term}")
|
||||
for marker in TEMPLATE_MARKERS:
|
||||
if marker in text:
|
||||
issues.append(f"存在模板化表达:{marker}")
|
||||
for marker in AI_TONE_MARKERS:
|
||||
if marker in text:
|
||||
issues.append(f"存在疑似 AI 味/空泛表达:{marker}")
|
||||
if text.count("【截图预留:") < len(modules):
|
||||
issues.append("截图预留数量少于核心模块数量")
|
||||
list_lines = [
|
||||
line.strip()
|
||||
for line in text.splitlines()
|
||||
if re.match(r"^(?:[-*+]\s+|\d+\.\s+)", line.strip())
|
||||
]
|
||||
if list_lines:
|
||||
issues.append(f"正文仍存在项目符号或编号列表:{list_lines[0][:40]}")
|
||||
for module in modules:
|
||||
title = str(module.get("feature") or "").strip()
|
||||
if not title:
|
||||
continue
|
||||
body = manual_section_body(text, title)
|
||||
if not body:
|
||||
issues.append(f"缺少核心模块章节:{title}")
|
||||
continue
|
||||
if len(body) < 390:
|
||||
issues.append(f"模块内容偏薄:{title}")
|
||||
for label in ("进入方式:", "页面内容:", "操作步骤:", "操作规则:", "操作结果与反馈:"):
|
||||
if label in body:
|
||||
issues.append(f"模块仍使用制式小标题:{title} / {label}")
|
||||
return issues
|
||||
|
||||
|
||||
def clean_field(value: str, default: str) -> str:
|
||||
text = plain_manual_text(str(value or "")).strip()
|
||||
if not text or text == "待用户确认":
|
||||
return default
|
||||
return text + ("。" if not text.endswith(("。", "!", "?")) else "")
|
||||
|
||||
|
||||
def as_text_list(value: Any) -> list[str]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
return [plain_manual_text(str(item)).strip() for item in value if str(item).strip()]
|
||||
text = plain_manual_text(str(value)).strip()
|
||||
if not text:
|
||||
return []
|
||||
return [item.strip() for item in re.split(r"[;;\n]+", text) if item.strip()]
|
||||
|
||||
|
||||
def required_module_text(item: dict[str, Any], field: str, title: str) -> str:
|
||||
value = plain_manual_text(str(item.get(field) or "")).strip()
|
||||
if not value:
|
||||
raise SystemExit(
|
||||
"STOP_FOR_USER\n"
|
||||
f"NEXT_ACTION: 操作手册页面模块“{title}”缺少 `{field}`。请回到业务理解阶段,"
|
||||
"由模型根据真实页面证据补全 manual_modules 后再生成操作手册。"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def required_module_list(item: dict[str, Any], field: str, title: str) -> list[str]:
|
||||
values = as_text_list(item.get(field))
|
||||
if not values:
|
||||
raise SystemExit(
|
||||
"STOP_FOR_USER\n"
|
||||
f"NEXT_ACTION: 操作手册页面模块“{title}”缺少 `{field}`。请回到业务理解阶段,"
|
||||
"由模型根据真实页面证据补全 manual_modules 后再生成操作手册。"
|
||||
)
|
||||
return values
|
||||
|
||||
|
||||
def normalize_manual_modules(
|
||||
business: dict[str, Any] | None,
|
||||
fallback_modules: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
manual_modules = business.get("manual_modules") if business else []
|
||||
if not manual_modules:
|
||||
raise SystemExit(
|
||||
"STOP_FOR_USER\n"
|
||||
"NEXT_ACTION: 业务理解缺少 `manual_modules`。不要由脚本按 auth/query/form 等模板猜测操作手册。"
|
||||
"请模型阅读项目真实页面、路由、按钮、输入项、提示和结果反馈,补全 manual_modules 后再生成操作手册。"
|
||||
)
|
||||
|
||||
modules: list[dict[str, Any]] = []
|
||||
for index, item in enumerate(manual_modules, start=1):
|
||||
if not isinstance(item, dict):
|
||||
raise SystemExit(
|
||||
"STOP_FOR_USER\n"
|
||||
f"NEXT_ACTION: manual_modules 第 {index} 项不是对象,无法生成真实操作手册。请补全 title、purpose、entry、operation_steps、feedback 等字段。"
|
||||
)
|
||||
title = plain_feature_name(item.get("title") or item.get("feature") or f"功能模块 {index}")
|
||||
purpose = required_module_text(item, "purpose", title)
|
||||
entry = required_module_text(item, "entry", title)
|
||||
usage = plain_manual_text(
|
||||
str(item.get("usage") or item.get("usage_scenario") or item.get("description") or "")
|
||||
).strip()
|
||||
if not usage:
|
||||
raise SystemExit(
|
||||
"STOP_FOR_USER\n"
|
||||
f"NEXT_ACTION: 操作手册页面模块“{title}”缺少 `usage` 或 `usage_scenario`。请回到业务理解阶段,"
|
||||
"补充用户在什么场景下会使用该页面、处理什么具体事务,再生成操作手册。"
|
||||
)
|
||||
steps = required_module_list(item, "operation_steps", title)
|
||||
feedback = required_module_list(item, "feedback", title)
|
||||
screenshot_note = plain_manual_text(str(item.get("screenshot") or "")).strip()
|
||||
if not screenshot_note:
|
||||
screenshot_note = f"请在此处插入“{title}”页面或操作结果截图"
|
||||
modules.append(
|
||||
{
|
||||
"feature": title,
|
||||
"raw_feature": title,
|
||||
"purpose": purpose + ("。" if not purpose.endswith(("。", "!", "?")) else ""),
|
||||
"entry": entry + ("。" if not entry.endswith(("。", "!", "?")) else ""),
|
||||
"usage": usage,
|
||||
"visible_elements": as_text_list(item.get("visible_elements") or item.get("page_elements")),
|
||||
"steps": steps,
|
||||
"validation_rules": as_text_list(item.get("validation_rules") or item.get("rules") or item.get("limits")),
|
||||
"feedback": feedback,
|
||||
"result": ";".join(feedback),
|
||||
"screenshot": f"【截图预留:{screenshot_note.strip('。')}。】",
|
||||
}
|
||||
)
|
||||
return modules
|
||||
|
||||
|
||||
def normalize_system_requirements(business: dict[str, Any] | None) -> list[dict[str, str]]:
|
||||
raw_items = business.get("system_requirements") if business else None
|
||||
rows: list[dict[str, str]] = []
|
||||
if isinstance(raw_items, list):
|
||||
for item in raw_items:
|
||||
if isinstance(item, dict):
|
||||
name = str(item.get("item") or item.get("name") or "").strip()
|
||||
minimum = str(item.get("minimum") or item.get("min") or "").strip()
|
||||
recommended = str(item.get("recommended") or item.get("recommend") or "").strip()
|
||||
if name:
|
||||
rows.append(
|
||||
{
|
||||
"item": plain_manual_text(name),
|
||||
"minimum": plain_manual_text(minimum or "按实际部署环境配置"),
|
||||
"recommended": plain_manual_text(recommended or minimum or "按实际部署环境配置"),
|
||||
}
|
||||
)
|
||||
if not rows:
|
||||
raise SystemExit(
|
||||
"STOP_FOR_USER\n"
|
||||
"NEXT_ACTION: 业务理解缺少 `system_requirements`。请根据真实项目运行形态和已确认申请表环境补全后再生成操作手册。"
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def normalize_faq(business: dict[str, Any] | None, software_name: str) -> list[dict[str, str]]:
|
||||
raw_items = business.get("faq") if business else None
|
||||
items: list[dict[str, str]] = []
|
||||
if isinstance(raw_items, list):
|
||||
for item in raw_items:
|
||||
if isinstance(item, dict):
|
||||
question = str(item.get("question") or item.get("q") or "").strip()
|
||||
answer = str(item.get("answer") or item.get("a") or "").strip()
|
||||
if question and answer:
|
||||
items.append({"question": plain_manual_text(question), "answer": plain_manual_text(answer)})
|
||||
if not items:
|
||||
raise SystemExit(
|
||||
"STOP_FOR_USER\n"
|
||||
"NEXT_ACTION: 业务理解缺少 `faq`。请根据当前软件真实使用场景补全常见问题后再生成操作手册。"
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
def normalize_glossary(business: dict[str, Any] | None, modules: list[dict[str, Any]], software_name: str) -> list[dict[str, str]]:
|
||||
raw_items = business.get("glossary") if business else None
|
||||
items: list[dict[str, str]] = []
|
||||
if isinstance(raw_items, list):
|
||||
for item in raw_items:
|
||||
if isinstance(item, dict):
|
||||
term = str(item.get("term") or item.get("name") or "").strip()
|
||||
definition = str(item.get("definition") or item.get("description") or "").strip()
|
||||
if term and definition:
|
||||
items.append({"term": plain_manual_text(term), "definition": plain_manual_text(definition)})
|
||||
if items:
|
||||
return items
|
||||
raise SystemExit(
|
||||
"STOP_FOR_USER\n"
|
||||
"NEXT_ACTION: 业务理解缺少 `glossary`。请根据当前软件真实业务对象和页面术语补全术语表后再生成操作手册。"
|
||||
)
|
||||
|
||||
|
||||
def feature_phrase(modules: list[dict[str, Any]], limit: int = 5) -> str:
|
||||
names = [module["feature"] for module in modules if module.get("feature")]
|
||||
return "、".join(names[:limit]) if names else "主要业务处理"
|
||||
|
||||
|
||||
def chinese_number(value: int) -> str:
|
||||
digits = "零一二三四五六七八九"
|
||||
if value <= 0:
|
||||
return str(value)
|
||||
if value < 10:
|
||||
return digits[value]
|
||||
if value == 10:
|
||||
return "十"
|
||||
if value < 20:
|
||||
return "十" + digits[value % 10]
|
||||
if value < 100:
|
||||
tens, ones = divmod(value, 10)
|
||||
return digits[tens] + "十" + (digits[ones] if ones else "")
|
||||
return str(value)
|
||||
|
||||
|
||||
def section_heading(index: int, title: str) -> str:
|
||||
return f"## {chinese_number(index)}、{title}"
|
||||
|
||||
|
||||
def strip_sentence_punctuation(text: str) -> str:
|
||||
return str(text or "").strip().strip("。;;,, ")
|
||||
|
||||
|
||||
def natural_join(items: list[str], limit: int | None = None) -> str:
|
||||
values = [strip_sentence_punctuation(item) for item in items if strip_sentence_punctuation(item)]
|
||||
if limit is not None:
|
||||
values = values[:limit]
|
||||
if not values:
|
||||
return ""
|
||||
if len(values) == 1:
|
||||
return values[0]
|
||||
return "、".join(values[:-1]) + "和" + values[-1]
|
||||
|
||||
|
||||
def ensure_sentence(text: str) -> str:
|
||||
value = strip_sentence_punctuation(plain_manual_text(text))
|
||||
if not value:
|
||||
return ""
|
||||
return value + "。"
|
||||
|
||||
|
||||
def remove_opening_definition(text: str, software_name: str) -> str:
|
||||
value = plain_manual_text(text).strip()
|
||||
if not value:
|
||||
return ""
|
||||
sentences = re.findall(r"[^。!?]+[。!?]?", value)
|
||||
if sentences and sentences[0].startswith(software_name) and "是一款" in sentences[0]:
|
||||
sentences = sentences[1:]
|
||||
return "".join(sentences).strip()
|
||||
|
||||
|
||||
def flow_summary(flow: list[str], modules: list[dict[str, Any]]) -> str:
|
||||
if flow:
|
||||
pieces = [strip_sentence_punctuation(plain_manual_text(item)) for item in flow[:4]]
|
||||
pieces = [item for item in pieces if item]
|
||||
if pieces:
|
||||
return ";".join(pieces) + "。"
|
||||
names = [module["feature"] for module in modules[:4]]
|
||||
if names:
|
||||
return f"用户可依次使用{natural_join(names)}等页面完成主要工作。"
|
||||
return "用户可按照页面提示完成主要业务操作。"
|
||||
|
||||
|
||||
def describe_related_doc(name: str) -> str:
|
||||
if "总体" in name:
|
||||
return "说明软件整体功能、页面组成、运行环境和业务边界。"
|
||||
if "详细" in name:
|
||||
return "说明各功能页面、输入输出、状态变化和处理规则。"
|
||||
if "测试" in name or "案例" in name:
|
||||
return "记录主要功能的操作场景、预期结果和异常提示。"
|
||||
return "记录与本软件功能、操作或验证相关的配套说明。"
|
||||
|
||||
|
||||
def normalize_related_documents(business: dict[str, Any] | None) -> list[dict[str, str]]:
|
||||
raw_items = business.get("related_documents") if business else None
|
||||
rows: list[dict[str, str]] = []
|
||||
if isinstance(raw_items, list):
|
||||
for item in raw_items:
|
||||
if isinstance(item, dict):
|
||||
name = str(item.get("name") or item.get("title") or item.get("document") or "").strip()
|
||||
target = str(item.get("target") or item.get("path") or item.get("file") or "").strip()
|
||||
description = str(item.get("description") or item.get("purpose") or "").strip()
|
||||
if name:
|
||||
rows.append(
|
||||
{
|
||||
"name": plain_manual_text(name),
|
||||
"target": plain_manual_text(target or f"《{name}》"),
|
||||
"description": plain_manual_text(description or describe_related_doc(name)),
|
||||
}
|
||||
)
|
||||
elif str(item).strip():
|
||||
name = str(item).strip()
|
||||
rows.append(
|
||||
{
|
||||
"name": plain_manual_text(name),
|
||||
"target": f"《{plain_manual_text(name)}》",
|
||||
"description": describe_related_doc(name),
|
||||
}
|
||||
)
|
||||
if not rows:
|
||||
for name in ("总体设计", "详细设计", "测试案例"):
|
||||
rows.append({"name": name, "target": f"《{name}》", "description": describe_related_doc(name)})
|
||||
return rows
|
||||
|
||||
|
||||
def clean_purpose_text(feature: str, purpose: str) -> str:
|
||||
value = strip_sentence_punctuation(plain_manual_text(purpose))
|
||||
value = re.sub(rf"^{re.escape(feature)}(页面|功能|模块|环节)?(主要)?用于", "", value)
|
||||
value = re.sub(r"^[^,。;;]{1,30}(页面|功能|模块|环节|状态栏|面板)?(主要)?用于", "", value)
|
||||
value = re.sub(r"^用于", "", value)
|
||||
value = value.strip("。;;,, ")
|
||||
return value or "完成本页面相关操作"
|
||||
|
||||
|
||||
def page_label(feature: str) -> str:
|
||||
value = strip_sentence_punctuation(feature)
|
||||
if value.startswith("用户") and len(value) > 2:
|
||||
value = value[2:]
|
||||
return value
|
||||
|
||||
|
||||
def purpose_core_sentence(feature: str, purpose: str) -> str:
|
||||
value = clean_purpose_text(feature, purpose)
|
||||
label = page_label(feature)
|
||||
if re.match(r"^(展示|集中展示|承载|提供|处理|保存|记录|辅助)", value):
|
||||
return f"{label}页面{value}"
|
||||
if value.startswith("让用户"):
|
||||
return f"{label}页面{value}"
|
||||
return f"用户可在{label}页面{value}"
|
||||
|
||||
|
||||
def purpose_sentence(feature: str, purpose: str) -> str:
|
||||
return purpose_core_sentence(feature, purpose) + "。"
|
||||
|
||||
|
||||
def entry_sentence(entry: str) -> str:
|
||||
value = strip_sentence_punctuation(plain_manual_text(entry))
|
||||
if not value:
|
||||
return ""
|
||||
if value.startswith("用户"):
|
||||
return value + "。"
|
||||
if re.match(r"^(登录|创建|进入|打开|点击|完成|选择|提交)", value):
|
||||
return f"用户{value}。"
|
||||
if value.startswith("当"):
|
||||
return value + "。"
|
||||
return f"用户可以通过{value}。"
|
||||
|
||||
|
||||
def visible_elements_sentence(items: list[str], feature: str, index: int) -> str:
|
||||
value = natural_join(items, limit=8)
|
||||
if not value:
|
||||
return ""
|
||||
variants = [
|
||||
f"页面上主要呈现{value}等内容,这些内容用于帮助用户确认当前位置和可执行操作。",
|
||||
f"用户在{feature}页面会看到{value}等信息,并可依据页面显示继续处理。",
|
||||
f"该部分提供{value}等页面内容,用户可据此查看状态、填写信息或选择下一步操作。",
|
||||
]
|
||||
return variants[(index - 1) % len(variants)]
|
||||
|
||||
|
||||
def steps_sentence(steps: list[str], module_index: int) -> str:
|
||||
values = [strip_sentence_punctuation(step) for step in steps if strip_sentence_punctuation(step)]
|
||||
if not values:
|
||||
return ""
|
||||
connectors = ["先", "随后", "接着", "之后", "再", "继续"]
|
||||
parts: list[str] = []
|
||||
for step_index, step in enumerate(values):
|
||||
if step_index == len(values) - 1 and len(values) > 1:
|
||||
connector = "最后"
|
||||
else:
|
||||
connector = connectors[min(step_index, len(connectors) - 1)]
|
||||
parts.append(f"{connector}{step}")
|
||||
prefixes = ["实际操作时,用户", "使用该功能时,用户", "在该页面中,用户"]
|
||||
return prefixes[(module_index - 1) % len(prefixes)] + ",".join(parts) + "。"
|
||||
|
||||
|
||||
def rules_feedback_sentence(rules: list[str], feedback: list[str], index: int) -> str:
|
||||
parts: list[str] = []
|
||||
rule_text = natural_join(rules, limit=6)
|
||||
if rule_text:
|
||||
rule_templates = [
|
||||
f"操作过程中需要注意{rule_text}。",
|
||||
f"页面会按照{rule_text}等规则限制或提示用户。",
|
||||
f"如果不满足{rule_text}等要求,用户需要根据页面提示调整后再继续。",
|
||||
]
|
||||
parts.append(rule_templates[(index - 1) % len(rule_templates)])
|
||||
feedback_text = natural_join(feedback, limit=6)
|
||||
if feedback_text:
|
||||
feedback_templates = [
|
||||
f"操作完成后,系统会显示{feedback_text}。",
|
||||
f"处理结束后,用户可以看到{feedback_text}。",
|
||||
f"页面反馈通常包括{feedback_text}。",
|
||||
]
|
||||
parts.append(feedback_templates[(index - 1) % len(feedback_templates)])
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def feature_paragraph(module: dict[str, Any], index: int) -> str:
|
||||
feature = module["feature"]
|
||||
purpose = clean_purpose_text(feature, module.get("purpose") or "")
|
||||
label = page_label(feature)
|
||||
core = purpose_core_sentence(feature, module.get("purpose") or "")
|
||||
elements = natural_join(as_text_list(module.get("visible_elements")), limit=5)
|
||||
feedback = natural_join(as_text_list(module.get("feedback")), limit=3)
|
||||
variants = [
|
||||
f"{core}。页面上的{elements or '相关业务信息'}会集中呈现当前可操作内容,用户处理完成后可以看到{feedback or '相应的处理结果'}。",
|
||||
f"在{label}页面中,用户主要处理{purpose}。系统把{elements or '页面显示内容'}放在当前操作区域,处理结束后会反馈{feedback or '处理结果'}。",
|
||||
f"{label}页面关注的是{purpose}。用户通过{elements or '必要的页面信息'}确认当前状态,并在操作结束后获得{feedback or '当前状态反馈'}。",
|
||||
]
|
||||
return variants[(index - 1) % len(variants)]
|
||||
|
||||
|
||||
def tidy_manual_output(text: str) -> str:
|
||||
replacements = {
|
||||
"用户主要处理处理": "用户主要处理",
|
||||
"主要处理承载一次": "主要围绕一次",
|
||||
"用户可以看到用户可以看到": "用户可以看到",
|
||||
"处理结束后会反馈空对话": "处理结束后会显示空对话",
|
||||
"在AI ": "在 AI ",
|
||||
"把StudioAgent": "把 StudioAgent",
|
||||
"页面上的StudioAgent": "页面上的 StudioAgent",
|
||||
"看到StudioAgent": "看到 StudioAgent",
|
||||
"提供StudioAgent": "提供 StudioAgent",
|
||||
"进入StudioAgent": "进入 StudioAgent",
|
||||
"保证StudioAgent": "保证 StudioAgent",
|
||||
}
|
||||
value = text
|
||||
for source, target in replacements.items():
|
||||
value = value.replace(source, target)
|
||||
value = re.sub(r"(?<=[\u4e00-\u9fff])([A-Za-z][A-Za-z0-9.+-]*)(?=[\u4e00-\u9fff])", r" \1 ", value)
|
||||
value = re.sub(r" {2,}", " ", value)
|
||||
return value
|
||||
|
||||
|
||||
def append_modules_canonical(lines: list[str], modules: list[dict[str, Any]], start_index: int) -> int:
|
||||
for i, module in enumerate(modules, start=start_index):
|
||||
visible_elements = as_text_list(module.get("visible_elements"))
|
||||
validation_rules = as_text_list(module.get("validation_rules"))
|
||||
feedback = as_text_list(module.get("feedback")) or [module["result"]]
|
||||
lines.extend(
|
||||
[
|
||||
section_heading(i, module["feature"]),
|
||||
"",
|
||||
purpose_sentence(module["feature"], module["purpose"]) + entry_sentence(module["entry"]),
|
||||
"",
|
||||
]
|
||||
)
|
||||
if module.get("usage"):
|
||||
lines.extend([ensure_sentence(module["usage"]), ""])
|
||||
element_text = visible_elements_sentence(visible_elements, module["feature"], i)
|
||||
if element_text:
|
||||
lines.extend([element_text, ""])
|
||||
step_text = steps_sentence(module["steps"], i)
|
||||
if step_text:
|
||||
lines.extend([step_text, ""])
|
||||
rule_feedback = rules_feedback_sentence(validation_rules, feedback, i)
|
||||
if rule_feedback:
|
||||
lines.extend([rule_feedback, ""])
|
||||
lines.extend(["", module["screenshot"], ""])
|
||||
return start_index + len(modules)
|
||||
|
||||
|
||||
def append_flow_canonical(lines: list[str], software_name: str, flow: list[str], start_index: int) -> int:
|
||||
lines.extend(
|
||||
[
|
||||
section_heading(start_index, "典型使用流程"),
|
||||
"",
|
||||
f"用户完成一次完整业务时,通常先进入{software_name},再选择或创建业务对象,随后按照页面提示处理内容并查看结果。",
|
||||
"",
|
||||
flow_summary(flow, []),
|
||||
"",
|
||||
]
|
||||
)
|
||||
return start_index + 1
|
||||
|
||||
|
||||
def render_manual_canonical(
|
||||
software_name: str,
|
||||
version: str,
|
||||
industry: str,
|
||||
users: list[str],
|
||||
positioning: str,
|
||||
core_value: str,
|
||||
modules: list[dict[str, Any]],
|
||||
operation_flow: list[str],
|
||||
manual_sections: list[Any] | None = None,
|
||||
business: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
industry_text = "相关业务" if not industry or industry == "待用户确认" else industry
|
||||
user_text = join_items([user for user in users if user != "待用户确认"]) or "实际使用人员"
|
||||
positioning_text = remove_opening_definition(positioning, software_name)
|
||||
core_value_text = clean_field(core_value, "软件可以帮助用户统一处理相关业务资料,并减少重复操作。")
|
||||
flow = operation_flow
|
||||
related_documents = normalize_related_documents(business)
|
||||
system_rows = normalize_system_requirements(business)
|
||||
faq_items = normalize_faq(business, software_name)
|
||||
glossary_items = normalize_glossary(business, modules, software_name)
|
||||
overview_paragraphs: list[str] = []
|
||||
for section in manual_sections or []:
|
||||
if isinstance(section, dict) and section.get("paragraphs") and len(overview_paragraphs) < 4:
|
||||
overview_paragraphs.extend(as_text_list(section.get("paragraphs"))[:2])
|
||||
|
||||
lines = [f"# {software_name}操作手册", "", section_heading(1, "相关文档"), ""]
|
||||
lines.extend(["| 文档名称 | 指向资料 | 说明 |", "| --- | --- | --- |"])
|
||||
for item in related_documents:
|
||||
lines.append(f"| {item['name']} | {item['target']} | {item['description']} |")
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
section_heading(2, "说明"),
|
||||
"",
|
||||
f"{software_name} {version}适用于{industry_text}场景。用户进入系统后,可以围绕实际工作内容完成账号进入、业务创建、过程查看、结果确认和资料管理等操作。",
|
||||
"",
|
||||
f"日常使用时,{user_text}可以按照页面提示从入口进入相应页面,查看当前业务状态,并根据页面中的按钮、输入框、列表或弹窗继续处理。{core_value_text}",
|
||||
"",
|
||||
]
|
||||
)
|
||||
if positioning_text:
|
||||
lines.extend([positioning_text, ""])
|
||||
for paragraph in overview_paragraphs:
|
||||
lines.extend([paragraph, ""])
|
||||
lines.extend(
|
||||
[
|
||||
"本手册用于说明软件的用途、功能特点、运行要求和页面操作流程。各功能章节按用户能够看到的页面、入口、按钮、输入项、提示信息和处理结果进行说明。",
|
||||
"",
|
||||
section_heading(3, "功能特点"),
|
||||
"",
|
||||
]
|
||||
)
|
||||
for i, module in enumerate(modules[:8], start=1):
|
||||
lines.extend([feature_paragraph(module, i), ""])
|
||||
lines.extend([section_heading(4, "系统要求"), "", "| 系统要求 | 最低配置 | 推荐配置 |", "| --- | --- | --- |"])
|
||||
for row in system_rows:
|
||||
lines.append(f"| {row['item']} | {row['minimum']} | {row['recommended']} |")
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
f"请确保实际运行环境满足以上要求,以保证{software_name}能够正常打开页面、提交操作和展示处理结果。若部署方式、客户端形态或服务器环境与本表不同,应以实际确认的申请表环境字段为准。",
|
||||
"",
|
||||
]
|
||||
)
|
||||
next_index = append_modules_canonical(lines, modules, start_index=5)
|
||||
if flow:
|
||||
next_index = append_flow_canonical(lines, software_name, flow, start_index=next_index)
|
||||
lines.extend([section_heading(next_index, "常见问题解答"), ""])
|
||||
for item in faq_items:
|
||||
lines.extend([f"问题:{item['question']}", f"解决方法:{item['answer']}", ""])
|
||||
next_index += 1
|
||||
lines.extend([section_heading(next_index, "术语表"), "", "| 术语 | 解释 |", "| --- | --- |"])
|
||||
for item in glossary_items:
|
||||
lines.append(f"| {item['term']} | {item['definition']} |")
|
||||
lines.append("")
|
||||
append_stop(lines)
|
||||
return tidy_manual_output("\n".join(lines))
|
||||
|
||||
|
||||
def append_stop(lines: list[str]) -> None:
|
||||
lines.extend(
|
||||
[
|
||||
"```text",
|
||||
"STOP_FOR_USER",
|
||||
"NEXT_ACTION: 请一次性确认完整操作手册草稿是否符合真实业务;必要时先统一修改段落内容,再运行 confirm_stage.py --stage markdown。",
|
||||
"```",
|
||||
"",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def build_manual_text(
|
||||
analysis: dict[str, Any],
|
||||
software_name: str,
|
||||
version: str,
|
||||
business: dict[str, Any] | None = None,
|
||||
) -> tuple[str, list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
positioning = plain_manual_text(business.get("product_positioning") if business else f"{software_name} {version}是一款基于项目实际功能整理的软件系统。")
|
||||
core_value = plain_manual_text(business.get("core_value") if business else "系统通过清晰的软件界面为用户提供主要业务入口,支持用户完成信息查看、业务处理、数据维护和结果反馈等操作。")
|
||||
users = business.get("target_users") if business else ["业务用户"]
|
||||
operation_flow = business.get("operation_flow") if business else []
|
||||
manual_sections = business.get("manual_sections") if business else []
|
||||
industry = business.get("industry") if business else "业务应用"
|
||||
if positioning.rstrip("。") == software_name.rstrip("。"):
|
||||
positioning = "用户可以根据项目资料中体现的业务场景完成相应操作。"
|
||||
elif not positioning.endswith("。"):
|
||||
positioning += "。"
|
||||
modules = normalize_manual_modules(business, [])
|
||||
records: list[dict[str, Any]] = []
|
||||
|
||||
text = render_manual_canonical(software_name, version, industry, users, positioning, core_value, modules, operation_flow, manual_sections, business)
|
||||
records.append({"round": 1, "action": "初稿生成", "issues": manual_quality_issues(text, modules)})
|
||||
|
||||
text = render_manual_canonical(software_name, version, industry, users, positioning, core_value, modules, operation_flow, manual_sections, business)
|
||||
records.append({"round": 2, "action": "真实页面字段复核", "issues": manual_quality_issues(text, modules)})
|
||||
|
||||
text = render_manual_canonical(software_name, version, industry, users, positioning, core_value, modules, operation_flow, manual_sections, business)
|
||||
records.append({"round": 3, "action": "制式模板和 AI 味复核", "issues": manual_quality_issues(text, modules)})
|
||||
|
||||
for round_no in range(4, 7):
|
||||
issues = records[-1]["issues"]
|
||||
if not issues:
|
||||
break
|
||||
text = render_manual_canonical(software_name, version, industry, users, positioning, core_value, modules, operation_flow, manual_sections, business)
|
||||
records.append(
|
||||
{
|
||||
"round": round_no,
|
||||
"action": "复核仍需模型回到业务理解补写",
|
||||
"issues": manual_quality_issues(text, modules),
|
||||
}
|
||||
)
|
||||
break
|
||||
return text, records, modules
|
||||
|
||||
|
||||
def write_review_records(out_dir: Path, records: list[dict[str, Any]], modules: list[dict[str, Any]]) -> None:
|
||||
(out_dir / "操作手册自检记录.json").write_text(
|
||||
json.dumps({"rounds": records, "module_count": len(modules)}, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
lines = ["# 操作手册自检记录", ""]
|
||||
for record in records:
|
||||
lines.extend([f"## 第 {record['round']} 轮:{record['action']}", ""])
|
||||
if record["issues"]:
|
||||
lines.extend(f"- {issue}" for issue in record["issues"])
|
||||
else:
|
||||
lines.append("- 未发现需继续修正的问题")
|
||||
lines.append("")
|
||||
lines.extend(["## 模块清单", ""])
|
||||
lines.extend(f"- {module['feature']}" for module in modules)
|
||||
(out_dir / "操作手册自检记录.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def write_manual(path: Path, analysis: dict[str, Any], software_name: str, version: str, business: dict[str, Any] | None = None) -> list[dict[str, Any]]:
|
||||
text, records, modules = build_manual_text(analysis, software_name, version, business)
|
||||
path.write_text(text, encoding="utf-8")
|
||||
write_review_records(path.parent, records, modules)
|
||||
return records
|
||||
|
||||
|
||||
def require_confirmed_business(business: dict[str, Any] | None) -> None:
|
||||
if business is None:
|
||||
raise SystemExit(
|
||||
"STOP_FOR_USER\n"
|
||||
"NEXT_ACTION: 操作手册必须基于已确认的业务理解生成。请先生成并确认 草稿/业务理解.md。"
|
||||
)
|
||||
if business.get("confirmation_required") and not business.get("user_confirmed"):
|
||||
raise SystemExit(
|
||||
"STOP_FOR_USER\n"
|
||||
"NEXT_ACTION: 业务理解尚未确认。请先确认 草稿/业务理解.md,"
|
||||
"再运行 `python3 <SKILL_DIR>/scripts/confirm_stage.py --workdir 软件著作权申请资料 --stage business --note \"<用户确认内容>\"`。"
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--analysis", required=True)
|
||||
parser.add_argument("--software-name", required=True)
|
||||
parser.add_argument("--version", default="V1.0")
|
||||
parser.add_argument("--business-context", help="Business context JSON generated before manual drafting")
|
||||
parser.add_argument("--out-dir", default="软件著作权申请资料/草稿")
|
||||
args = parser.parse_args()
|
||||
|
||||
analysis = read_json(Path(args.analysis))
|
||||
business = read_json(Path(args.business_context)) if args.business_context else None
|
||||
require_confirmed_business(business)
|
||||
out_dir = ensure_dir(Path(args.out_dir))
|
||||
out_path = out_dir / "操作手册.md"
|
||||
records = write_manual(out_path, analysis, args.software_name, args.version, business)
|
||||
print(f"OK manual draft: {out_path}")
|
||||
print(f"OK manual self-review: {out_dir / '操作手册自检记录.md'}")
|
||||
for record in records:
|
||||
print(f"Review round {record['round']}: {record['action']} issues={len(record['issues'])}")
|
||||
if records[-1]["issues"]:
|
||||
print("STOP_FOR_USER")
|
||||
print("NEXT_ACTION: 操作手册自检仍有问题。请回到业务理解阶段补全 manual_modules 中的真实页面内容、操作规则和结果反馈后再重新生成。")
|
||||
raise SystemExit(1)
|
||||
print("STOP_FOR_USER")
|
||||
print("NEXT_ACTION: 请一次性确认完整操作手册草稿是否符合真实业务;必要时先统一修改段落内容,再运行 confirm_stage.py --stage markdown。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create an editable source-file evidence list before code extraction."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from common import COPYRIGHT_CODE_EXTS, FRONTEND_EXTS, ensure_dir, is_known_config_file, iter_project_files, read_json, rel, write_json
|
||||
from extract_code_material import LINES_PER_PAGE, SPLIT_THRESHOLD_PAGES, category_weight, should_skip_file
|
||||
|
||||
|
||||
DEFAULT_MAX_FILES = 0
|
||||
|
||||
|
||||
def evidence_for(path: Path, project: Path) -> str:
|
||||
priority, _ = category_weight(path, project)
|
||||
if priority == 0:
|
||||
return "入口文件证据"
|
||||
if priority == 10:
|
||||
return "路由文件证据"
|
||||
if priority == 20:
|
||||
return "页面文件证据"
|
||||
if priority == 30:
|
||||
return "数据交互文件证据"
|
||||
if priority == 40:
|
||||
return "状态或数据文件证据"
|
||||
if priority == 50:
|
||||
return "页面组成文件证据"
|
||||
if priority == 60:
|
||||
return "通用能力文件证据"
|
||||
if priority == 90:
|
||||
return "样式文件证据"
|
||||
if path.suffix.lower() not in FRONTEND_EXTS:
|
||||
return "补充源码证据"
|
||||
return "普通源码文件"
|
||||
|
||||
|
||||
def build_candidates(project: Path) -> list[dict[str, Any]]:
|
||||
files = [p for p in iter_project_files(project, COPYRIGHT_CODE_EXTS) if not should_skip_file(p) and not is_known_config_file(p)]
|
||||
files.sort(key=lambda p: category_weight(p, project))
|
||||
candidates: list[dict[str, Any]] = []
|
||||
for path in files:
|
||||
try:
|
||||
line_count = len(path.read_text(encoding="utf-8", errors="replace").splitlines())
|
||||
except Exception:
|
||||
line_count = 0
|
||||
priority, _ = category_weight(path, project)
|
||||
candidates.append(
|
||||
{
|
||||
"path": rel(path, project),
|
||||
"selected": False,
|
||||
"line_count": line_count,
|
||||
"priority": priority,
|
||||
"selection_tier": "frontend" if path.suffix.lower() in FRONTEND_EXTS else "supplement",
|
||||
"evidence": evidence_for(path, project),
|
||||
"model_reason": "",
|
||||
}
|
||||
)
|
||||
return candidates
|
||||
|
||||
|
||||
def selected_line_estimate(item: dict[str, Any]) -> int:
|
||||
return int(item.get("line_count") or 0) + 2
|
||||
|
||||
|
||||
def selection_stats(candidates: list[dict[str, Any]]) -> dict[str, int]:
|
||||
selected_items = [item for item in candidates if item.get("selected")]
|
||||
return {
|
||||
"selected_count": len(selected_items),
|
||||
"selected_lines": sum(selected_line_estimate(item) for item in selected_items),
|
||||
}
|
||||
|
||||
|
||||
def all_candidate_lines(candidates: list[dict[str, Any]]) -> int:
|
||||
return sum(selected_line_estimate(item) for item in candidates)
|
||||
|
||||
|
||||
def write_selection_md(path: Path, data: dict[str, Any]) -> None:
|
||||
lines = [
|
||||
"# 代码文件候选清单",
|
||||
"",
|
||||
"请先确认要抽取哪些源码文件,再运行代码材料抽取。",
|
||||
"",
|
||||
"本清单只列出候选源码证据,不默认决定抽取文件。",
|
||||
"模型需要先理解项目业务、页面入口和源码职责,再填写 `selected/model_reason`。",
|
||||
f"当前已选约 {data['estimated_selected_pages']} 页,全部候选源码约 {data['estimated_all_candidate_pages']} 页。",
|
||||
"",
|
||||
"```text",
|
||||
"STOP_FOR_USER",
|
||||
"NEXT_ACTION: 请由模型先填写 草稿/代码文件选择.json 的抽取选择和选择理由,再让用户确认;确认后运行 confirm_stage.py --stage code-selection。",
|
||||
"```",
|
||||
"",
|
||||
"确认方式:",
|
||||
"",
|
||||
"1. 模型根据项目业务和代码入口选择最能体现软件功能的文件。",
|
||||
"2. 把需要抽取的文件设为 `selected: true`,并填写 `model_reason`。",
|
||||
"3. 代码材料按完整文件原样复制,不支持只抽取某个文件的中间行段。",
|
||||
"4. 用户确认模型选择后,再记录 `code-selection` 门禁。",
|
||||
"",
|
||||
"## 默认选中文件",
|
||||
"",
|
||||
"| 文件 | 行数 | 模型选择理由 |",
|
||||
"| --- | ---: | --- |",
|
||||
]
|
||||
for item in data["files"]:
|
||||
if item.get("selected"):
|
||||
lines.append(f"| `{item['path']}` | {item['line_count']} | {item.get('model_reason') or '待模型填写'} |")
|
||||
|
||||
lines.extend(["", "## 未选候选文件", "", "| 文件 | 行数 | 证据类型 |", "| --- | ---: | --- |"])
|
||||
for item in data["files"]:
|
||||
if not item.get("selected"):
|
||||
lines.append(f"| `{item['path']}` | {item['line_count']} | {item['evidence']} |")
|
||||
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--project", required=True)
|
||||
parser.add_argument("--analysis", help="Optional project analysis JSON; retained for workflow traceability")
|
||||
parser.add_argument("--out-dir", default="软件著作权申请资料/草稿")
|
||||
parser.add_argument("--max-files", type=int, default=DEFAULT_MAX_FILES, help="Only limits candidate inventory size; does not auto-select files")
|
||||
parser.add_argument("--target-pages", type=int, default=SPLIT_THRESHOLD_PAGES)
|
||||
parser.add_argument("--lines-per-page", type=int, default=LINES_PER_PAGE)
|
||||
args = parser.parse_args()
|
||||
|
||||
project = Path(args.project)
|
||||
if not project.exists():
|
||||
raise SystemExit(f"Project not found: {project}")
|
||||
if args.analysis and not Path(args.analysis).exists():
|
||||
raise SystemExit(f"Analysis JSON not found: {args.analysis}")
|
||||
|
||||
out_dir = ensure_dir(Path(args.out_dir))
|
||||
candidates = build_candidates(project)
|
||||
target_lines = max(1, args.target_pages) * max(1, args.lines_per_page)
|
||||
if args.max_files:
|
||||
candidates = candidates[: args.max_files]
|
||||
stats = selection_stats(candidates)
|
||||
candidate_lines = all_candidate_lines(candidates)
|
||||
selected_pages = (stats["selected_lines"] + args.lines_per_page - 1) // args.lines_per_page if stats["selected_lines"] else 0
|
||||
all_pages = (candidate_lines + args.lines_per_page - 1) // args.lines_per_page if candidate_lines else 0
|
||||
data = {
|
||||
"project_root": str(project.resolve()),
|
||||
"selection_required": True,
|
||||
"model_selection_required": True,
|
||||
"confirmation_required": True,
|
||||
"user_confirmed": False,
|
||||
"target_pages": args.target_pages,
|
||||
"lines_per_page": args.lines_per_page,
|
||||
"target_lines": target_lines,
|
||||
"estimated_selected_lines": stats["selected_lines"],
|
||||
"estimated_selected_pages": selected_pages,
|
||||
"estimated_all_candidate_lines": candidate_lines,
|
||||
"estimated_all_candidate_pages": all_pages,
|
||||
"supplement_rule": "模型优先选择能体现软件核心功能和真实运行逻辑的源码;不足60页时再从其他相关源码中补充;候选源码仍不足时才生成全部代码材料。",
|
||||
"confirmation_stage": "code-selection",
|
||||
"next_action": "请由模型填写 草稿/代码文件选择.json 的抽取选择和选择理由,再让用户确认;确认后运行 confirm_stage.py --stage code-selection。",
|
||||
"instructions": "The script only inventories source files. The model must choose selected/model_reason before user confirmation. Selected files are copied in full.",
|
||||
"files": candidates,
|
||||
}
|
||||
write_json(out_dir / "代码文件选择.json", data)
|
||||
write_selection_md(out_dir / "代码文件候选清单.md", data)
|
||||
selected_count = sum(1 for item in candidates if item.get("selected"))
|
||||
print(f"OK code selection draft: {out_dir}")
|
||||
print(f"Candidates: {len(candidates)}")
|
||||
print(f"Model selected: {selected_count}")
|
||||
print(f"Estimated selected pages: {selected_pages}")
|
||||
print(f"Estimated all candidate pages: {all_pages}")
|
||||
print("STOP_FOR_USER")
|
||||
print(f"NEXT_ACTION: {data['next_action']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user