#!/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()