Skill: spzwin/cms-bp-org-monthly-report-reviser (version 1.0.6)

--- name: cms-bp-org-monthly-report-reviser description: 组织 BP 月报修订工具。用于初始化本地或远端组织 BP 月报、按章节边界生成精确修订、执行确认后的安全写入、修改灯色、追加组织自身证据、解释口径，最终交付干净的组织月报 Markdown 目录。 --- # 组织 BP 月报修订器 本 skill 的主流程与个人月报修订一致：在 `bp/report-reviser/{reportMonth}-月报-{reportRecordId}/` 生成干净 Markdown；AI 只按章节规则提出修改方案；用户确认后由脚本精确写入。不要把临时过程文件作为最终正文来源。 ## 核心边界 - **月份目录是唯一正文来源**：最终可见 Markdown 只能放在 `bp/report-reviser/{reportMonth}-月报-{reportRecordId}/` 下。 - **目录月份必须标准化**：`reportMonth` 必须使用 `YYYY-MM`，例如 `2026-05-月报-2058815281387614209`；不要使用 `2026年5月-月报-...` 或 `2026-05-组织月报-...` 作为正式工作目录。 - **默认不写文件**：用户只提出修改意图时，只输出修改方案；只有明确确认后才调用脚本写入。 - **普通正文修订只用精确替换**：使用 `apply_chapter_patch`，必须提供 `targetFile + locator.originalText`，禁止整章重写或模糊替换。 - **专门动作不能混进正文 patch**：灯色走 `update_lamp_color`；证据追加走 `add_evidence_ref`；普通正文替换不得直接改灯色、证据编号、汇报链接或系统事实。 - **灯色原因必须可展示**：允许用户只用明确业务原因修改灯色；如用户提供真实汇报，先用 `add_evidence_ref` 登记，再在原因中引用 R/RP 编号或该证据汇报 ID。用户可见原因必须显示为 `[R1001](huibao://view?id=...)` 这类 Markdown 证据链接，禁止裸写 `huibao://...`，也禁止把当前月报自身 reportId 当证据。 - **组织目标灯色按 KR 聚合并联动**：目标灯色不能直接自由修改，必须等于当前 KR 灯色聚合结果；修改 KR 灯色后脚本必须自动聚合并同步目标明细、2.1 目标清单总览和管理摘要目标灯色。 - **下级承接 BP 内容锁定**：第 2.2 章中的“下级承接BP内容”、下级进展摘要、下级目标灯、下级判断理由，以及举措下的下级证据摘要，不能通过普通正文修订修改。 - **第 7/8 章完全不可改**；附录证据索引只允许脚本追加组织自身证据。 - **第 3/4/5/6 章可普通修订**；允许写入用户明确提供的新事实数字，但不能新增无来源目标、证据、组织承诺或汇报链接。 - **ID 全程字符串**：禁止对 reportId、groupId、periodId、taskId、huibao id 等做数值转换。 ## 按需读取 | 场景 | 必读文件 | |------|----------| | 初始化、灯色、证据、交付命令 | `references/workflows.md` | | 判断章节能否修改 | `references/rules/editable-boundary-rules.md` | | 普通正文 patch | `references/rules/patch-rules.md` 和 `references/templates/chapter_patch_schema.json` | | 写入失败或校验争议 | `references/rules/validation-rules.md` | ## 主流程 ### 1. 初始化 远端 reportId： - 执行以下脚本。若未安装 `requests`，**必须先安装**，再运行脚本。 - **禁止**在未实际运行脚本的情况下，根据代码、经验或历史输出模拟/推断/口述脚本结果。 ```bash python3 scripts/org_monthly_report_reviser.py prepare_revision_workspace --report_id {reportId} ``` 在 sandbox exec / tool runner 中执行远端初始化时，`BP_OPEN_API_APP_KEY` 必须通过执行工具的 `env` 参数传入，禁止写成 `export ... && python3 ...` 或 `BP_OPEN_API_APP_KEY=... python3 ...` 这类 shell 前缀。 ```json { "command": "python3 scripts/org_monthly_report_reviser.py prepare_revision_workspace --report_id {reportId}", "env": { "BP_OPEN_API_APP_KEY": "{个人AppKey（用于访问业务接口）}" } } ``` 初始化完成后，只向用户展示 `bp/report-reviser/{reportMonth}-月报-{reportRecordId}/`。 ### 2. 定位和判断 根据用户意图定位月份目录下的 Markdown 文件： 1. 章节编号/章节名精确匹配 2. 目标编号/目标名称匹配 3. 全文关键词搜索 定位目标/KR/举措编码时，优先使用脚本解析路径，避免手写含中文、空格、括号或 `｜` 的文件名： ```bash python3 scripts/org_monthly_report_reviser.py resolve_code_file \ --report_id {reportId} \ --code A8B8-1.1 ``` 然后判断能否改： - 可普通修订：提出具体替换方案和目标文件。 - 需要专门动作：灯色、证据追加走脚本动作。 - 禁止直接改：说明边界，不生成可执行 patch。 ### 3. 用户确认前只给方案 确认表达包括：`确认写入`、`可以，写进去`、`就这样改`、`帮我保存`、`按这个改`。 确认前输出类似： ```text 我会修改： 3_问题_偏差与原因分析.md 修改点： 1. ... 2. ... 请确认是否写入。 ``` 如果方案包含灯色修改，必须在确认前明确列出脚本将联动的位置： - 修改 KR 灯色：目标明细中的 KR 灯色、目标级综合灯色结论、`2.1_目标清单总览.md`、`1_管理摘要.md`。 - 修改目标灯色：目标明细中的目标级综合灯色结论、`2.1_目标清单总览.md`、`1_管理摘要.md`。 - 修改举措灯色：只修改举措本身；不得把举措灯色直接当作 KR/目标聚合结果。 涉及含中文、空格、括号、`｜` 等特殊字符的路径时，不要手敲拼接路径；必须使用 `resolve_code_file`、`chapter_revision_manifest.json` 或脚本返回值中的精确路径。 ### 4. 确认后写入 普通正文修订： ```bash python3 scripts/org_monthly_report_reviser.py apply_chapter_patch \ --report_id {reportId} \ --patch_file {patch_path} ``` 灯色修改： ```bash python3 scripts/org_monthly_report_reviser.py update_lamp_color \ --report_id {reportId} \ --code A8B8-1.1 \ --new_lamp yellow \ --reason "用户确认后的灯色判断依据；涉及证据 R1001" ``` 灯色原因规则： - 没有外部汇报证据时，`--reason` 写用户确认的业务解释，脚本允许执行并把解释写入判断理由。 - 有外部汇报证据时，先执行 `add_evidence_ref`，再在 `--reason` 中引用 R/RP 编号或对应汇报 ID；脚本会统一展示为 `[R1001](huibao://view?id=...)`。 - 不允许在 `--reason` 中使用当前月报自身 reportId 的 `huibao://` 链接；不允许把未登记的裸汇报链接直接写入判断理由。 追加组织自身证据： ```bash python3 scripts/org_monthly_report_reviser.py add_evidence_ref \ --report_id {reportId} \ --evidence_file {evidence_json_path} ``` 脚本成功后会刷新 `report_manifest.json`、`chapter_revision_manifest.json`，并追加 `revision_history.jsonl`。 ## 交付 交付给用户或前端时，只交付 `bp/report-reviser/{reportMonth}-月报-{reportRecordId}/`： - Markdown 是正文。 - JSON 是索引/校验/历史。 - 不读取、不展示、不依赖临时过程文件。

path: scripts/org_monthly_report_reviser.py
content: #!/usr/bin/env python3
"""Organization Monthly Report Reviser CLI.

Usage:
    python org_monthly_report_reviser.py <action> [options]
"""

import argparse
import hashlib
import json
import os
import re
import shutil
import sys
import uuid
from datetime import datetime

import requests


BASE_URL = os.environ.get(
    "BP_OPEN_API_BASE_URL",
    "https://sg-al-cwork-web.mediportal.com.cn/open-api",
)
APP_KEY = os.environ.get("BP_OPEN_API_APP_KEY", "")
TIMEOUT = 30

SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
SKILL_DIR = os.path.dirname(SCRIPT_DIR)
SKILL_PARENT_DIR = os.path.dirname(SKILL_DIR)
SKILL_GRANDPARENT_DIR = os.path.dirname(SKILL_PARENT_DIR)
WORK_ROOT_RELATIVE_PARTS = ("bp", "report-reviser")
DEFAULT_WORK_ROOT = os.path.join(SKILL_GRANDPARENT_DIR, *WORK_ROOT_RELATIVE_PARTS)
REPORT_MONTH_HINTS = {}

FLN_RE = re.compile(r"\b(?:P\d+|[A-Z][A-Z0-9]*)-\d+(?:\.\d+)*\b")
R_CODE_RE = re.compile(r"\b(R\d+|RP\d+)\b")
HUIBAO_LINK_RE = re.compile(r"huibao://view\?id=\d+")
EVIDENCE_LINK_RE = re.compile(r"\[(R\d+|RP\d+)\]\(huibao://view\?id=(\d+)\)")
EVIDENCE_TABLE_LINK_RE = re.compile(r"\|\s*(R\d+|RP\d+)\s*\|[^\n]*?\[[^\]]+\]\(huibao://view\?id=(\d+)\)")
NUMBER_RE = re.compile(r"\d+(?:\.\d+)?%?")
LAMP_WORD_RE = re.compile(r"[红黄绿黑]灯")
LAMP_ANY_RE = re.compile(r"[🟢🟡🔴⚫]")
LAMP_ANY_SPAN_RE = re.compile(
    r'<span style="color:#[0-9a-f]{6};\s*font-weight:700;">([^<]*?(🟢|🟡|🔴|⚫)[^<]*?)</span>'
)
SPAN_COLOR_RE = re.compile(r"color:#[0-9a-f]{6};\s*font-weight:700;")
HTML_TAG_RE = re.compile(r"<[^>]+>")

VALID_LAMPS = {"green", "yellow", "red", "black"}
LAMP_COLOR_MAP = {
    "green": {"emoji": "🟢", "css": "#2e7d32", "word": "绿灯"},
    "yellow": {"emoji": "🟡", "css": "#b26a00", "word": "黄灯"},
    "red": {"emoji": "🔴", "css": "#c62828", "word": "红灯"},
    "black": {"emoji": "⚫", "css": "#212121", "word": "黑灯"},
}
LAMP_EMOJI_TO_COLOR = {"🟢": "green", "🟡": "yellow", "🔴": "red", "⚫": "black"}

CHAPTER_POLICIES = {
    "0": {
        "name": "报告头",
        "editable": False,
        "allowedOps": [],
        "forbiddenEdits": ["report_meta", "period", "organization"],
    },
    "1": {
        "name": "管理摘要",
        "editable": True,
        "allowedOps": ["replace_text"],
        "forbiddenEdits": ["unknown_goal_codes", "evidence_refs", "huibao_links", "lamp_text", "fact_numbers"],
    },
    "2": {
        "name": "目标级自查明细",
        "editable": True,
        "allowedOps": ["replace_text"],
        "specialOps": ["update_lamp", "add_evidence_ref"],
        "forbiddenEdits": [
            "goal_code", "goal_name", "evidence_refs", "huibao_links", "lamp_text",
            "downstream_bp_content", "downstream_evidence_summary",
        ],
    },
    "3": {
        "name": "问题、偏差与原因分析",
        "editable": True,
        "allowedOps": ["replace_text"],
        "forbiddenEdits": ["unknown_goal_codes", "unknown_evidence_refs", "huibao_links", "lamp_text"],
    },
    "4": {
        "name": "风险预警与资源需求",
        "editable": True,
        "allowedOps": ["replace_text"],
        "forbiddenEdits": ["unknown_goal_codes", "unknown_evidence_refs", "huibao_links", "lamp_text"],
    },
    "5": {
        "name": "下月重点安排",
        "editable": True,
        "allowedOps": ["replace_text"],
        "forbiddenEdits": ["unknown_goal_codes", "unknown_evidence_refs", "huibao_links", "lamp_text"],
    },
    "6": {
        "name": "需决策需协同事项",
        "editable": True,
        "allowedOps": ["replace_text"],
        "forbiddenEdits": ["unknown_goal_codes", "unknown_evidence_refs", "huibao_links", "lamp_text"],
    },
    "7": {
        "name": "年度结果预判评分",
        "editable": False,
        "allowedOps": [],
        "forbiddenEdits": ["links", "entry_text"],
    },
    "8": {
        "name": "月度汇报入口",
        "editable": False,
        "allowedOps": [],
        "forbiddenEdits": ["links", "entry_text"],
    },
    "9": {
        "name": "附录证据索引",
        "editable": False,
        "allowedOps": [],
        "specialOps": ["add_evidence_ref"],
        "forbiddenEdits": ["evidence_refs", "huibao_links", "evidence_stats"],
    },
}


def _log(msg):
    print(f"[org-reviser] {msg}", file=sys.stderr)


def normalize_for_hash(text):
    text = text.replace("\r\n", "\n").strip()
    text = re.sub(r"\n{3,}", "\n\n", text)
    return text + "\n"


def compute_hash(text):
    return "sha256:" + hashlib.sha256(normalize_for_hash(text).encode("utf-8")).hexdigest()


def _strip_html(text):
    return HTML_TAG_RE.sub("", text or "").strip()


def _safe_filename(text):
    name = _strip_html(text or "").strip()
    name = re.sub(r'[\\/:*?"<>|#`]', "", name)
    name = re.sub(r"\s+", " ", name).strip()
    return name[:120] or "未命名"


def _fln_sort_key(fln):
    parts = re.split(r"[-.]", fln or "")
    result = []
    for part in parts:
        result.append((0, int(part)) if part.isdigit() else (1, part))
    return tuple(result)


def _month_report_file_sort_key(rel_path):
    first = rel_path.split("/", 1)[0]
    match = re.match(r"^(\d+)_", first)
    chapter_no = int(match.group(1)) if match else 99
    subsection = 0
    if rel_path.startswith("2_目标级自查明细/2.1_"):
        subsection = 1
    elif rel_path.startswith("2_目标级自查明细/2.2_"):
        subsection = 2
    elif rel_path.startswith("2_目标级自查明细/2.3_"):
        subsection = 3
    code_match = FLN_RE.search(rel_path)
    if code_match:
        return (chapter_no, subsection, _fln_sort_key(code_match.group(0)), rel_path)
    return (chapter_no, subsection, (), rel_path)


def _normalize_report_month(value):
    value = str(value or "").strip()
    match = re.search(r"(20\d{2})[-年/\.]?\s*(\d{1,2})", value)
    if match:
        month = int(match.group(2))
        if 1 <= month <= 12:
            return f"{match.group(1)}-{month:02d}"
    return ""


def _infer_report_month_from_text(text):
    patterns = [
        r"(20\d{2})年\s*(\d{1,2})月",
        r"(20\d{2})-(\d{1,2})",
        r"(20\d{2})(\d{2})",
    ]
    for pattern in patterns:
        match = re.search(pattern, text or "")
        if match:
            month = int(match.group(2))
            if 1 <= month <= 12:
                return f"{match.group(1)}-{month:02d}"
    return ""


def _infer_report_month_from_report_id(report_id):
    match = re.search(r"(20\d{2})(\d{2})", str(report_id or ""))
    if match:
        month = int(match.group(2))
        if 1 <= month <= 12:
            return f"{match.group(1)}-{month:02d}"
    return ""


def _set_report_month_hint(report_id, report_month):
    normalized = _normalize_report_month(report_month)
    if normalized:
        REPORT_MONTH_HINTS[str(report_id)] = normalized
    return normalized


def _work_root():
    return DEFAULT_WORK_ROOT


def _work_dir_for_month(report_id, report_month):
    month = _normalize_report_month(report_month)
    if not month:
        return ""
    return os.path.join(_work_root(), f"{month}-月报-{report_id}")


def _canonical_work_dir_name(report_id, name):
    suffix = f"-月报-{report_id}"
    if not name.endswith(suffix):
        return ""
    month_text = name[:-len(suffix)]
    month = _normalize_report_month(month_text)
    if not month:
        return ""
    return f"{month}{suffix}"


def _discover_work_dir(report_id):
    root = _work_root()
    if not os.path.isdir(root):
        return ""
    matches = [
        os.path.join(root, name)
        for name in os.listdir(root)
        if os.path.isdir(os.path.join(root, name))
        and name == _canonical_work_dir_name(report_id, name)
    ]
    return sorted(matches)[-1] if matches else ""


def _work_dir(report_id):
    discovered = _discover_work_dir(report_id)
    if discovered:
        return discovered
    hinted = REPORT_MONTH_HINTS.get(str(report_id), "")
    if hinted:
        return _work_dir_for_month(report_id, hinted)
    inferred = _infer_report_month_from_report_id(report_id)
    if inferred:
        return _work_dir_for_month(report_id, inferred)
    return os.path.join(_work_root(), f"revision_{report_id}")


def _request(method, path, *, params=None, json_body=None):
    if not APP_KEY:
        return {"error": "BP_OPEN_API_APP_KEY is required"}
    headers = {"appKey": APP_KEY, "Content-Type": "application/json"}
    url = BASE_URL.rstrip("/") + path
    try:
        resp = requests.request(method, url, params=params, json=json_body, headers=headers, timeout=TIMEOUT)
        resp.raise_for_status()
        payload = resp.json()
    except Exception as exc:
        return {"error": f"request failed: {exc}"}
    if isinstance(payload, dict) and payload.get("success") is False:
        return {"error": payload.get("message") or payload.get("msg") or "remote api returned failure", "response": payload}
    data = payload.get("data", payload) if isinstance(payload, dict) else payload
    return {"success": True, "data": data}


def _write_file(root, rel_path, content):
    path = os.path.join(root, rel_path)
    os.makedirs(os.path.dirname(path), exist_ok=True)
    with open(path, "w", encoding="utf-8") as f:
        f.write((content or "").strip() + "\n")
    return path


def _dedupe_rel_path(root, rel_path):
    base, ext = os.path.splitext(rel_path)
    candidate = rel_path
    idx = 2
    while os.path.exists(os.path.join(root, candidate)):
        candidate = f"{base}_{idx}{ext}"
        idx += 1
    return candidate


def _heading_positions(lines):
    positions = []
    for idx, line in enumerate(lines):
        stripped = line.strip()
        match = re.match(r"^#{2,3}\s+(\d+)\.\s+(.+)$", stripped)
        if match:
            chapter = match.group(1)
            title = match.group(2).replace(" ", "")
            if chapter in {"1", "2", "3", "4", "5", "7", "8"}:
                positions.append((idx, chapter))
            elif chapter == "6" and "需决策" in title and "协同事项" in title:
                positions.append((idx, "6"))
            continue
        if re.match(r"^#{2,3}\s+附录[：:]\s*证据索引", stripped):
            positions.append((idx, "9"))
    return positions


def _split_top_sections(content):
    lines = content.splitlines()
    positions = _heading_positions(lines)
    sections = {}
    if not positions:
        sections["0"] = content.strip()
        return sections
    if positions[0][0] > 0:
        sections["0"] = "\n".join(lines[:positions[0][0]]).strip()
    for idx, (start, chapter) in enumerate(positions):
        end = positions[idx + 1][0] if idx + 1 < len(positions) else len(lines)
        sections[chapter] = "\n".join(lines[start:end]).strip()
    return sections


def _extract_subsection(section, title):
    title_without_no = re.sub(r"^\d+(?:\.\d+)?\s*", "", title).strip()
    lines = section.splitlines()
    starts = []
    for idx, line in enumerate(lines):
        stripped = line.strip()
        match = re.match(r"^#{3,4}\s+(.+)$", stripped)
        if not match:
            continue
        heading = match.group(1).strip()
        heading_without_no = re.sub(r"^\d+(?:\.\d+)?\s*", "", heading).strip()
        if heading == title or heading_without_no == title_without_no:
            starts.append(idx)
    if not starts:
        return ""
    start = starts[0]
    start_level = len(re.match(r"^(#+)", lines[start].strip()).group(1))
    end = len(lines)
    for idx in range(start + 1, len(lines)):
        next_heading = re.match(r"^(#{3,4})\s+", lines[idx].strip())
        if next_heading and len(next_heading.group(1)) <= start_level:
            end = idx
            break
    return "\n".join(lines[start:end]).strip()


def _extract_goal_blocks(goal_detail_section):
    lines = goal_detail_section.splitlines()
    starts = [
        idx for idx, line in enumerate(lines)
        if re.match(r"^#{4,5}\s+", line) and FLN_RE.search(line)
    ]
    goals = []
    for pos, start in enumerate(starts):
        end = starts[pos + 1] if pos + 1 < len(starts) else len(lines)
        for idx in range(start + 1, end):
            if re.match(r"^#{4,5}\s+未参与自查目标说明", lines[idx].strip()):
                end = idx
                break
        block = "\n".join(lines[start:end]).strip()
        heading = lines[start].strip().lstrip("#").strip()
        code_match = FLN_RE.search(heading)
        code = code_match.group(0) if code_match else f"goal_{pos + 1:02d}"
        title = heading
        goals.append({"code": code, "title": title, "content": block})
    return goals


def _goal_details_dir(report_dir):
    return os.path.join(report_dir, "2_目标级自查明细", "2.2_目标明细")


def _iter_markdown_files(report_dir):
    transient = {"original_report.md"}
    items = []
    for root, _dirs, files in os.walk(report_dir):
        for filename in files:
            if not filename.endswith(".md") or filename in transient:
                continue
            rel_path = os.path.relpath(os.path.join(root, filename), report_dir)
            items.append(rel_path)
    return sorted(items, key=_month_report_file_sort_key)


def _chapter_id_for_file(rel_path):
    first = rel_path.split("/", 1)[0]
    if first.startswith("9_") or first.startswith("附录"):
        return "9"
    match = re.match(r"^(\d+)_", first)
    return match.group(1) if match else ""


def _policy_for_file(rel_path):
    if rel_path == "2_目标级自查明细/2.1_目标清单总览.md":
        return {
            "name": "目标清单总览",
            "editable": False,
            "allowedOps": [],
            "forbiddenEdits": ["goal_code", "goal_name", "commitment", "evidence_refs", "huibao_links", "lamp_text"],
        }
    if rel_path == "2_目标级自查明细/2.3_未参与自查目标说明.md":
        return {
            "name": "未参与自查目标说明",
            "editable": False,
            "allowedOps": [],
            "forbiddenEdits": ["goal_code", "goal_name", "self_check_status", "plan_date_range"],
        }
    chapter = _chapter_id_for_file(rel_path)
    return CHAPTER_POLICIES.get(chapter, {
        "name": "未知章节",
        "editable": False,
        "allowedOps": [],
        "forbiddenEdits": ["unknown_file"],
    })


def _known_codes(report_dir):
    codes = set()
    for rel_path in _iter_markdown_files(report_dir):
        with open(os.path.join(report_dir, rel_path), "r", encoding="utf-8") as f:
            codes.update(FLN_RE.findall(f.read()))
    return codes


def _known_evidence_codes(report_dir):
    codes = set()
    for rel_path in _iter_markdown_files(report_dir):
        with open(os.path.join(report_dir, rel_path), "r", encoding="utf-8") as f:
            codes.update(R_CODE_RE.findall(f.read()))
    return codes


def _evidence_maps(report_dir):
    by_code = {}
    by_report_id = {}
    for rel_path in _iter_markdown_files(report_dir):
        with open(os.path.join(report_dir, rel_path), "r", encoding="utf-8") as f:
            content = f.read()
        for code, report_id in EVIDENCE_LINK_RE.findall(content) + EVIDENCE_TABLE_LINK_RE.findall(content):
            link = f"huibao://view?id={report_id}"
            by_code[code] = link
            by_report_id.setdefault(report_id, code)
    return by_code, by_report_id


def _load_month_manifest(report_dir):
    path = os.path.join(report_dir, "report_manifest.json")
    if not os.path.isfile(path):
        return {}, path
    with open(path, "r", encoding="utf-8") as f:
        return json.load(f), path


def _append_history(report_dir, record):
    path = os.path.join(report_dir, "revision_history.jsonl")
    record = dict(record)
    record.setdefault("revisionId", str(uuid.uuid4()))
    record.setdefault("createdAt", datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
    with open(path, "a", encoding="utf-8") as f:
        f.write(json.dumps(record, ensure_ascii=False) + "\n")
    return path


def _build_report_manifest(report_dir, report_id):
    files = []
    for rel_path in _iter_markdown_files(report_dir):
        with open(os.path.join(report_dir, rel_path), "r", encoding="utf-8") as f:
            content = f.read()
        policy = _policy_for_file(rel_path)
        files.append({
            "path": rel_path,
            "hash": compute_hash(content),
            "chapter": _chapter_id_for_file(rel_path),
            "editable": bool(policy.get("editable")),
            "allowedOps": policy.get("allowedOps", []),
        })
    manifest = {
        "reportId": str(report_id),
        "reportDir": report_dir,
        "generatedAt": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
        "contentSource": "markdown_files",
        "readRule": "Read markdown files by report_manifest.files order. If unavailable, sort chapter files by chapter number and target files by P-code natural order.",
        "files": files,
    }
    path = os.path.join(report_dir, "report_manifest.json")
    with open(path, "w", encoding="utf-8") as f:
        json.dump(manifest, f, ensure_ascii=False, indent=2)
    return path, files


def _refresh_metadata(args, report_dir):
    chapter = build_chapter_manifest(args)
    report_manifest_file, files = _build_report_manifest(report_dir, args.report_id)
    return {
        "chapterManifest": chapter,
        "reportManifestFile": report_manifest_file,
        "reportManifestFileCount": len(files),
    }


def _clean_report_dir(report_dir):
    if not os.path.isdir(report_dir):
        return
    for name in os.listdir(report_dir):
        path = os.path.join(report_dir, name)
        if os.path.isdir(path):
            shutil.rmtree(path)
        elif os.path.isfile(path):
            os.remove(path)


def _initialize_from_content(report_id, content):
    report_month = _infer_report_month_from_text(content) or _infer_report_month_from_report_id(report_id)
    if not report_month:
        return {"error": "reportMonth could not be inferred from content or report_id."}
    _set_report_month_hint(report_id, report_month)
    report_dir = _work_dir(report_id)
    os.makedirs(report_dir, exist_ok=True)
    _clean_report_dir(report_dir)

    sections = _split_top_sections(content)
    generated = []
    file_map = {
        "0": "0_报告头.md",
        "1": "1_管理摘要.md",
        "3": "3_问题_偏差与原因分析.md",
        "4": "4_风险预警与资源需求.md",
        "5": "5_下月重点安排.md",
        "6": "6_需决策需协同事项.md",
        "7": "7_年度结果预判评分.md",
        "8": "8_月度汇报入口.md",
        "9": "9_附录_证据索引.md",
    }
    for chapter, rel_path in file_map.items():
        if sections.get(chapter):
            _write_file(report_dir, rel_path, sections[chapter])
            generated.append(rel_path)

    section2 = sections.get("2", "")
    if section2:
        overview = _extract_subsection(section2, "2.1 目标清单总览") or _extract_subsection(section2, "目标清单总览")
        detail = _extract_subsection(section2, "2.2 目标明细") or _extract_subsection(section2, "目标明细")
        not_participated = _extract_subsection(section2, "未参与自查目标说明")
        if overview:
            rel = "2_目标级自查明细/2.1_目标清单总览.md"
            _write_file(report_dir, rel, overview)
            generated.append(rel)
        if detail:
            for goal in _extract_goal_blocks(detail):
                rel = f"2_目标级自查明细/2.2_目标明细/{_safe_filename(goal['title'])}.md"
                rel = _dedupe_rel_path(report_dir, rel)
                _write_file(report_dir, rel, goal["content"])
                generated.append(rel)
        if not_participated:
            rel = "2_目标级自查明细/2.3_未参与自查目标说明.md"
            _write_file(report_dir, rel, not_participated)
            generated.append(rel)

    report_meta = {
        "reportId": str(report_id),
        "reportMonth": _normalize_report_month(report_month),
        "generatedAt": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
        "mode": "org_monthly_report_revision",
    }
    with open(os.path.join(report_dir, "report_meta.json"), "w", encoding="utf-8") as f:
        json.dump(report_meta, f, ensure_ascii=False, indent=2)

    _build_report_manifest(report_dir, report_id)
    chapter_manifest = build_chapter_manifest(type("Args", (), {"report_id": report_id})())
    return {
        "success": True,
        "reportDir": report_dir,
        "fileCount": len(generated),
        "files": generated,
        "chapterManifestFile": chapter_manifest.get("manifestFile"),
    }


def fetch_report_content(args):
    if not args.report_id:
        return {"error": "report_id is required"}
    result = _request("GET", "/work-report/report/info", params={"reportId": args.report_id})
    if not result.get("success"):
        return result
    data = result.get("data") or {}
    content = data.get("content") or data.get("contentHtml") or ""
    report_month = data.get("reportMonth") or data.get("month") or data.get("reportDate") or _infer_report_month_from_text(content)
    _set_report_month_hint(args.report_id, report_month)
    wd = _work_dir(args.report_id)
    os.makedirs(wd, exist_ok=True)
    content_path = os.path.join(wd, "original_report.md")
    with open(content_path, "w", encoding="utf-8") as f:
        f.write(content)
    return {"success": True, "contentFile": content_path, "charCount": len(content)}


def init_local_report(args):
    if not args.report_id:
        return {"error": "report_id is required"}
    if not args.content_file:
        return {"error": "content_file is required"}
    if not os.path.isfile(args.content_file):
        return {"error": f"content_file not found: {args.content_file}"}
    with open(args.content_file, "r", encoding="utf-8") as f:
        content = f.read()
    result = _initialize_from_content(args.report_id, content)
    return result


def prepare_revision_workspace(args):
    if not args.report_id:
        return {"error": "report_id is required"}
    fetched = fetch_report_content(args)
    if fetched.get("error") or fetched.get("success") is False:
        return {"success": False, "failedStep": "fetch_report_content", "result": fetched}
    with open(fetched["contentFile"], "r", encoding="utf-8") as f:
        content = f.read()
    initialized = _initialize_from_content(args.report_id, content)
    if initialized.get("error") or initialized.get("success") is False:
        return {"success": False, "failedStep": "initialize", "fetchResult": fetched, "initializeResult": initialized}
    return {"success": True, "fetchResult": fetched, "initializeResult": initialized}


def print_work_dir(args):
    if not args.report_id:
        return {"error": "report_id is required"}
    wd = _work_dir(args.report_id)
    return {
        "success": True,
        "workDir": wd,
        "workRoot": os.path.dirname(wd),
        "workRootRelative": "/".join(WORK_ROOT_RELATIVE_PARTS),
        "relativePattern": "bp/report-reviser/{reportMonth}-月报-{reportRecordId}/",
        "skillDir": SKILL_DIR,
    }


def build_chapter_manifest(args):
    if not args.report_id:
        return {"error": "report_id is required"}
    report_dir = _work_dir(args.report_id)
    if not os.path.isdir(report_dir):
        return {"error": "month-report directory not found. Run init_local_report first."}
    files = []
    for rel_path in _iter_markdown_files(report_dir):
        with open(os.path.join(report_dir, rel_path), "r", encoding="utf-8") as f:
            content = f.read()
        policy = _policy_for_file(rel_path)
        files.append({
            "path": rel_path,
            "chapter": _chapter_id_for_file(rel_path),
            "chapterName": policy.get("name", ""),
            "editable": bool(policy.get("editable")),
            "allowedOps": policy.get("allowedOps", []),
            "specialOps": policy.get("specialOps", []),
            "forbiddenEdits": policy.get("forbiddenEdits", []),
            "hash": compute_hash(content),
        })
    manifest = {
        "reportId": str(args.report_id),
        "reportDir": report_dir,
        "generatedAt": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
        "mode": "org_chapter_safe_revision",
        "files": files,
        "chapterPolicies": CHAPTER_POLICIES,
    }
    path = os.path.join(report_dir, "chapter_revision_manifest.json")
    with open(path, "w", encoding="utf-8") as f:
        json.dump(manifest, f, ensure_ascii=False, indent=2)
    return {"success": True, "manifestFile": path, "reportDir": report_dir, "fileCount": len(files)}


def list_chapter_policies(args):
    report_dir = _work_dir(args.report_id) if args.report_id else ""
    files = []
    if report_dir and os.path.isdir(report_dir):
        for rel_path in _iter_markdown_files(report_dir):
            policy = _policy_for_file(rel_path)
            files.append({
                "path": rel_path,
                "chapter": _chapter_id_for_file(rel_path),
                "chapterName": policy.get("name", ""),
                "editable": bool(policy.get("editable")),
                "allowedOps": policy.get("allowedOps", []),
                "forbiddenEdits": policy.get("forbiddenEdits", []),
            })
    return {"success": True, "policies": CHAPTER_POLICIES, "reportDir": report_dir, "files": files}


def resolve_code_file(args):
    if not args.report_id:
        return {"error": "report_id is required"}
    if not args.code:
        return {"error": "code is required"}
    report_dir = _work_dir(args.report_id)
    if not os.path.isdir(report_dir):
        return {"error": "month-report directory not found. Run init_local_report first."}
    code = str(args.code).strip()
    target_file, content = _find_goal_file(report_dir, code)
    if not target_file:
        return {"error": f"Goal file not found or ambiguous for code: {code}"}
    rel_path = os.path.relpath(target_file, report_dir)
    policy = _policy_for_file(rel_path)
    result = {
        "success": True,
        "reportId": str(args.report_id),
        "code": code,
        "goalCode": code.split(".", 1)[0],
        "reportDir": report_dir,
        "relativePath": rel_path,
        "absolutePath": target_file,
        "chapter": _chapter_id_for_file(rel_path),
        "chapterName": policy.get("name", ""),
        "editable": bool(policy.get("editable")),
        "specialOps": policy.get("specialOps", []),
        "forbiddenEdits": policy.get("forbiddenEdits", []),
        "hash": compute_hash(content),
    }
    if "." in code:
        result["lampSyncOnUpdate"] = [
            rel_path,
            "2_目标级自查明细/2.1_目标清单总览.md",
            "1_管理摘要.md",
        ]
    return result


def _locate_patch_text(content, locator):
    strategy = (locator or {}).get("strategy", "exact_text")
    if strategy != "exact_text":
        return None, None, f"Unsupported locator strategy: {strategy}. Only exact_text is supported."
    original_text = (locator or {}).get("originalText", "")
    if not original_text:
        return None, None, "locator.originalText is required."
    matches = [m for m in re.finditer(re.escape(original_text), content)]
    if not matches:
        return None, None, "originalText not found in target file."
    if len(matches) > 1:
        return None, None, "originalText is not unique in target file; please provide a longer exact text."
    return matches[0].start(), matches[0].end(), ""


def _touches_downstream_content(text):
    markers = [
        "下级承接BP内容",
        "下级承接 BP 内容",
        "| 责任人 | 个人BP目标 | 当前进展摘要 | 目标灯 | 判断理由 |",
        "|--------|-----------|------------|--------|---------|",
    ]
    if any(marker in text for marker in markers):
        return True
    for line in text.splitlines():
        stripped = line.strip()
        if stripped.startswith("> |") and ("个人BP目标" in stripped or FLN_RE.search(stripped) or LAMP_ANY_RE.search(stripped)):
            return True
        if re.match(r"^-\s*证据：", stripped):
            return True
    return False


def _validate_forbidden_content(old_text, new_text, policy, report_dir):
    errors = []
    forbidden = set(policy.get("forbiddenEdits", []))
    old_format_lines = [
        line.strip() for line in old_text.splitlines()
        if line.strip().startswith(("<details", "</details", "<summary", "</summary"))
    ]
    new_format_lines = [
        line.strip() for line in new_text.splitlines()
        if line.strip().startswith(("<details", "</details", "<summary", "</summary"))
    ]
    if old_format_lines != new_format_lines:
        errors.append("不允许通过普通正文修订修改 <details>/<summary> 折叠格式。")

    old_links = set(HUIBAO_LINK_RE.findall(old_text))
    new_links = set(HUIBAO_LINK_RE.findall(new_text))
    if "huibao_links" in forbidden and old_links != new_links:
        errors.append("不允许新增、删除或替换 huibao:// 汇报链接。")

    old_evidence = set(R_CODE_RE.findall(old_text))
    new_evidence = set(R_CODE_RE.findall(new_text))
    known_evidence = _known_evidence_codes(report_dir)
    if ("evidence_refs" in forbidden or "unknown_evidence_refs" in forbidden) and old_evidence != new_evidence:
        errors.append("不允许通过正文替换新增、删除或替换 R/RP 证据编号。")
    unknown_evidence = sorted(new_evidence - known_evidence)
    if ("evidence_refs" in forbidden or "unknown_evidence_refs" in forbidden) and unknown_evidence:
        errors.append(f"不允许新增不存在的证据编号: {', '.join(unknown_evidence)}。")

    old_codes = set(FLN_RE.findall(old_text))
    new_codes = set(FLN_RE.findall(new_text))
    known_codes = _known_codes(report_dir)
    if "goal_code" in forbidden and old_codes != new_codes:
        errors.append("不允许新增、删除或替换目标/KR/举措编号。")
    unknown_codes = sorted(new_codes - known_codes)
    if "unknown_goal_codes" in forbidden and unknown_codes:
        errors.append(f"不允许新增不存在的目标/KR/举措编号: {', '.join(unknown_codes)}。")

    old_lamps = set(LAMP_WORD_RE.findall(old_text)) | set(LAMP_ANY_RE.findall(old_text))
    new_lamps = set(LAMP_WORD_RE.findall(new_text)) | set(LAMP_ANY_RE.findall(new_text))
    if "lamp_text" in forbidden and old_lamps != new_lamps:
        errors.append("不允许通过正文替换修改灯色表达；请走 update_lamp_color 专门动作。")

    if "fact_numbers" in forbidden:
        added_numbers = sorted(set(NUMBER_RE.findall(new_text)) - set(NUMBER_RE.findall(old_text)))
        if added_numbers:
            errors.append(f"不允许在章节修订中新增原文没有的事实数字: {', '.join(added_numbers)}。")

    if ("downstream_bp_content" in forbidden or "downstream_evidence_summary" in forbidden) and (
        _touches_downstream_content(old_text) or _touches_downstream_content(new_text)
    ):
        errors.append("不允许通过普通正文修订修改下级承接 BP 内容或举措下级证据摘要。")

    if "<!-- LOCK:" in new_text or "<!-- EDITABLE:" in new_text:
        errors.append("修订后的正文不允许包含内部 LOCK/EDITABLE 标记。")
    return errors


def apply_chapter_patch(args):
    if not args.report_id:
        return {"error": "report_id is required"}
    if not args.patch_file:
        return {"error": "patch_file is required"}
    if not os.path.isfile(args.patch_file):
        return {"error": f"Patch file not found: {args.patch_file}"}
    report_dir = _work_dir(args.report_id)
    if not os.path.isdir(report_dir):
        return {"error": "month-report directory not found. Run init_local_report first."}
    with open(args.patch_file, "r", encoding="utf-8") as f:
        patch = json.load(f)

    changes = patch.get("changes", [])
    if not isinstance(changes, list) or not changes:
        return {"error": "patch.changes must be a non-empty array."}

    applied = []
    rejected = []
    report_abs = os.path.abspath(report_dir)
    for change in changes:
        target_rel = str(change.get("targetFile", "")).strip().lstrip("/")
        operation = change.get("operation", "replace_text")
        new_text = change.get("newText", "")
        reason = change.get("reason", "")
        target_path = os.path.abspath(os.path.join(report_dir, target_rel))
        if not target_rel.endswith(".md") or not target_path.startswith(report_abs + os.sep) or not os.path.isfile(target_path):
            rejected.append({"change": change, "reason": f"targetFile not found inside month-report directory: {target_rel}"})
            continue
        policy = _policy_for_file(target_rel)
        if not policy.get("editable"):
            rejected.append({"change": change, "reason": f"章节不可直接修改: {policy.get('name', target_rel)}"})
            continue
        if operation not in policy.get("allowedOps", []):
            rejected.append({"change": change, "reason": f"operation {operation} is not allowed for chapter {policy.get('name', '')}."})
            continue
        with open(target_path, "r", encoding="utf-8") as f:
            content = f.read()
        start, end, error = _locate_patch_text(content, change.get("locator", {}))
        if error:
            rejected.append({"change": change, "reason": error})
            continue
        old_text = content[start:end]
        errors = _validate_forbidden_content(old_text, new_text, policy, report_dir)
        if errors:
            rejected.append({"change": change, "reason": "; ".join(errors)})
            continue
        with open(target_path, "w", encoding="utf-8") as f:
            f.write(content[:start] + new_text + content[end:])
        applied.append({
            "file": target_rel,
            "chapter": _chapter_id_for_file(target_rel),
            "operation": operation,
            "oldHash": compute_hash(old_text),
            "newHash": compute_hash(new_text),
            "reason": reason,
        })

    metadata = _refresh_metadata(args, report_dir)
    history = _append_history(report_dir, {
        "reportId": str(args.report_id),
        "revisionRequest": patch.get("revisionRequest", ""),
        "mode": "org_chapter_safe_revision",
        "applied": applied,
        "rejected": rejected,
        "manifestResult": metadata,
    })
    return {
        "success": True,
        "applied": applied,
        "rejected": rejected,
        "historyFile": history,
        "manifestFile": metadata.get("chapterManifest", {}).get("manifestFile"),
        "reportManifestFile": metadata.get("reportManifestFile"),
    }


def _find_goal_file(report_dir, code):
    goals_dir = _goal_details_dir(report_dir)
    if not os.path.isdir(goals_dir):
        return "", ""
    goal_code = code.split(".", 1)[0]
    exact_filename_candidates = []
    exact_content_candidates = []
    for filename in sorted(os.listdir(goals_dir)):
        if not filename.endswith(".md"):
            continue
        path = os.path.join(goals_dir, filename)
        with open(path, "r", encoding="utf-8") as f:
            content = f.read()
        if _line_contains_exact_code(os.path.splitext(filename)[0], goal_code):
            exact_filename_candidates.append((path, content))
            continue
        if _line_contains_exact_code(content, code):
            exact_content_candidates.append((path, content))
    candidates = exact_filename_candidates or exact_content_candidates
    return candidates[0] if len(candidates) == 1 else ("", "")


def _replace_lamp_span_in_line(line, new_lamp):
    info = LAMP_COLOR_MAP[new_lamp]
    match = LAMP_ANY_SPAN_RE.search(line)
    if match:
        old = LAMP_EMOJI_TO_COLOR.get(match.group(2))
        inner = re.sub(r"[🟢🟡🔴⚫]", info["emoji"], match.group(1), count=1)
        inner = LAMP_WORD_RE.sub(info["word"], inner, count=1)
        replacement = f'<span style="color:{info["css"]}; font-weight:700;">{inner}</span>'
        return line[:match.start()] + replacement + line[match.end():], old, True
    emoji_match = LAMP_ANY_RE.search(line)
    if emoji_match:
        old = LAMP_EMOJI_TO_COLOR.get(emoji_match.group(0))
        return LAMP_ANY_RE.sub(info["emoji"], line, count=1), old, True
    return line, None, False


def _huibao_ids(text):
    return re.findall(r"huibao://view\?id=(\d+)", text or "")


def _format_display_reason(reason, report_dir):
    reason = str(reason or "").strip()
    if not reason:
        return ""
    by_code, by_report_id = _evidence_maps(report_dir)
    protected_links = []

    def replace_markdown_link(match):
        code = match.group(1)
        report_id = match.group(2)
        protected_links.append(f"[{code}](huibao://view?id={report_id})")
        return f"__EVIDENCE_LINK_{len(protected_links) - 1}__"

    formatted = EVIDENCE_LINK_RE.sub(replace_markdown_link, reason)

    def replace_raw_link(match):
        report_id = match.group(1)
        code = by_report_id.get(report_id)
        if code:
            return f"[{code}](huibao://view?id={report_id})"
        return ""

    formatted = re.sub(r"huibao://view\?id=(\d+)", replace_raw_link, formatted)

    for code in sorted(by_code, key=len, reverse=True):
        link = by_code[code]
        formatted = re.sub(
            rf"(?<![\[\w]){re.escape(code)}(?![\]\w])",
            f"[{code}]({link})",
            formatted,
        )

    for idx, link in enumerate(protected_links):
        formatted = formatted.replace(f"__EVIDENCE_LINK_{idx}__", link)

    formatted = re.sub(r"\s+", " ", formatted).strip(" ；;，,")
    return formatted


def _extract_kr_lamps(content):
    lamps = {}
    current = ""
    auto_idx = 0
    for line in content.splitlines():
        if re.search(r"(?:关键成果|成果)\s+", line):
            code_match = FLN_RE.search(line)
            if code_match:
                current = code_match.group(0)
            else:
                auto_idx += 1
                current = f"KR{auto_idx}"
        if current and "四灯判断" in line:
            emoji = LAMP_ANY_RE.search(line)
            if emoji:
                lamps[current] = LAMP_EMOJI_TO_COLOR.get(emoji.group(0))
                current = ""
    return lamps


def _aggregate_lamps(lamps):
    normalized = [lamp for lamp in lamps if lamp in VALID_LAMPS]
    if not normalized:
        return "black"
    if "red" in normalized:
        return "red"
    if "yellow" in normalized:
        return "yellow"
    if "black" in normalized and "green" in normalized:
        return "yellow"
    if "black" in normalized:
        return "black"
    return "green"


def _line_contains_exact_code(line, code):
    return bool(re.search(rf"(?<![A-Za-z0-9_.-]){re.escape(code)}(?![A-Za-z0-9_.-])", line))


def _append_reason_to_next_reason_line(lines, idx, new_lamp, reason):
    info = LAMP_COLOR_MAP[new_lamp]
    suffix = f"（修改原因：{reason}）" if reason else ""
    for j in range(idx + 1, min(len(lines), idx + 4)):
        if "判断理由" in lines[j]:
            stripped = _strip_html(lines[j])
            reason_text = stripped.split("判断理由：", 1)[-1].strip()
            if suffix:
                reason_text = re.sub(r"（修改原因：.*?）\s*$", "", reason_text).rstrip()
                reason_text = reason_text + suffix
            indent = re.match(r"^(\s*)", lines[j]).group(1)
            lines[j] = f'{indent}<span style="color:{info["css"]}; font-weight:700;">判断理由：{reason_text}</span>'
            return True
    return False


def _replace_task_lamp(content, code, new_lamp, reason):
    lines = content.splitlines()
    old_lamp = None
    changed = False
    target_seen = False
    for idx, line in enumerate(lines):
        if _line_contains_exact_code(line, code) and (
            re.search(r"(?:关键成果|成果)\s+", line)
            or "支撑举措" in line
            or FLN_RE.search(line)
        ):
            target_seen = True
        if target_seen and "四灯判断" in line:
            lines[idx], old_lamp, changed = _replace_lamp_span_in_line(line, new_lamp)
            if changed:
                _append_reason_to_next_reason_line(lines, idx, new_lamp, reason)
                break
    return "\n".join(lines) + ("\n" if content.endswith("\n") else ""), old_lamp, changed


def _replace_goal_lamp(content, new_lamp, reason):
    lines = content.splitlines()
    old_lamp = None
    changed = False
    in_goal_conclusion = False
    for idx, line in enumerate(lines):
        if "目标级综合灯色结论" in line:
            in_goal_conclusion = True
        if in_goal_conclusion and "结论一句话" in line:
            lines[idx], old_lamp, changed = _replace_lamp_span_in_line(line, new_lamp)
            if changed:
                clean_line = re.sub(r"（修改原因：.*?）\s*$", "", lines[idx].rstrip())
                lines[idx] = clean_line + f"（修改原因：{reason}）"
                break
        if in_goal_conclusion and "四灯判断" in line:
            lines[idx], old_lamp, changed = _replace_lamp_span_in_line(line, new_lamp)
            if changed:
                _append_reason_to_next_reason_line(lines, idx, new_lamp, reason)
                break
    return "\n".join(lines) + ("\n" if content.endswith("\n") else ""), old_lamp, changed


def _extract_goal_lamp(content):
    in_goal_conclusion = False
    for line in content.splitlines():
        if "目标级综合灯色结论" in line:
            in_goal_conclusion = True
        if in_goal_conclusion and ("结论一句话" in line or "四灯判断" in line):
            emoji = LAMP_ANY_RE.search(line)
            if emoji:
                return LAMP_EMOJI_TO_COLOR.get(emoji.group(0))
    return None


def _refresh_overview_goal_lamp(report_dir, code, new_lamp):
    path = os.path.join(report_dir, "2_目标级自查明细", "2.1_目标清单总览.md")
    if not os.path.isfile(path):
        return False
    with open(path, "r", encoding="utf-8") as f:
        content = f.read()
    lines = []
    changed = False
    for line in content.splitlines():
        if re.match(rf"^\|\s*{re.escape(code)}\s*\|", line):
            line, _old, line_changed = _replace_lamp_span_in_line(line, new_lamp)
            changed = changed or line_changed
        lines.append(line)
    if changed:
        with open(path, "w", encoding="utf-8") as f:
            f.write("\n".join(lines) + ("\n" if content.endswith("\n") else ""))
    return changed


def _refresh_summary_goal_lamp(report_dir, code, new_lamp):
    path = os.path.join(report_dir, "1_管理摘要.md")
    if not os.path.isfile(path):
        return False
    with open(path, "r", encoding="utf-8") as f:
        content = f.read()
    lines = []
    changed = False
    for line in content.splitlines():
        if re.match(rf"^\|\s*{re.escape(code)}\s*\|", line):
            line, _old, line_changed = _replace_lamp_span_in_line(line, new_lamp)
            changed = changed or line_changed
        lines.append(line)
    if changed:
        with open(path, "w", encoding="utf-8") as f:
            f.write("\n".join(lines) + ("\n" if content.endswith("\n") else ""))
    return changed


def update_lamp_color(args):
    if not args.report_id:
        return {"error": "report_id is required"}
    if not args.code:
        return {"error": "code is required"}
    if args.new_lamp not in VALID_LAMPS:
        return {"error": "new_lamp must be green/yellow/red/black"}
    reason = (args.reason or "").strip()
    if not reason:
        return {"error": "修改灯色必须提供 --reason。"}
    report_dir = _work_dir(args.report_id)
    if not os.path.isdir(report_dir):
        return {"error": "month-report directory not found. Run init_local_report first."}
    self_links = [rid for rid in _huibao_ids(reason) if rid == str(args.report_id)]
    if self_links:
        return {"error": "不能把当前月报自身的 huibao:// 链接作为灯色修改证据，请提供真实证据汇报 ID，或仅填写用户确认的业务原因。"}
    _, by_report_id = _evidence_maps(report_dir)
    unknown_links = sorted({rid for rid in _huibao_ids(reason) if rid not in by_report_id})
    if unknown_links:
        return {"error": f"汇报链接必须先通过 add_evidence_ref 追加为 R/RP 证据，再在灯色原因中引用；未登记的汇报 ID: {', '.join(unknown_links)}。"}
    display_reason = _format_display_reason(reason, report_dir)
    if not display_reason:
        return {"error": "修改灯色必须提供可展示的业务原因；如提供汇报链接，请先通过 add_evidence_ref 追加为 R/RP 证据。"}
    code = str(args.code).strip()
    target_file, content = _find_goal_file(report_dir, code)
    if not target_file:
        return {"error": f"Goal file not found for code: {code}"}

    goal_code = code.split(".", 1)[0]
    aggregated_goal_lamp = None
    old_goal_lamp = None
    goal_lamp_changed = False

    if "." not in code:
        kr_lamps = _extract_kr_lamps(content)
        aggregated = _aggregate_lamps(kr_lamps.values())
        if args.new_lamp != aggregated:
            return {
                "error": f"组织目标灯色聚合校验失败：{code} 当前 KR 灯色按规则应聚合为 {aggregated}，不能直接改为 {args.new_lamp}。",
                "constraintViolation": {
                    "code": code,
                    "requestedLamp": args.new_lamp,
                    "aggregatedLamp": aggregated,
                    "krLamps": kr_lamps,
                },
            }
        updated, old_lamp, changed = _replace_goal_lamp(content, args.new_lamp, display_reason)
        aggregated_goal_lamp = args.new_lamp
        old_goal_lamp = old_lamp
        goal_lamp_changed = changed
    else:
        updated, old_lamp, changed = _replace_task_lamp(content, code, args.new_lamp, display_reason)
        if changed:
            kr_lamps = _extract_kr_lamps(updated)
            aggregated_goal_lamp = _aggregate_lamps(kr_lamps.values())
            old_goal_lamp = _extract_goal_lamp(updated)
            if old_goal_lamp != aggregated_goal_lamp:
                updated, old_goal_lamp, goal_lamp_changed = _replace_goal_lamp(
                    updated,
                    aggregated_goal_lamp,
                    display_reason,
                )
                if not goal_lamp_changed:
                    return {
                        "error": f"KR 灯色已定位，但目标级综合灯色结论无法同步更新；为避免 2.2、2.1、管理摘要不一致，本次修改已阻断。code={code}",
                        "constraintViolation": {
                            "code": code,
                            "goalCode": goal_code,
                            "aggregatedGoalLamp": aggregated_goal_lamp,
                        },
                    }
    if not changed:
        return {"error": f"灯色判断行 not found for code: {code}"}

    with open(target_file, "w", encoding="utf-8") as f:
        f.write(updated)
    sync_updates = []
    sync_lamp = aggregated_goal_lamp or args.new_lamp
    if _refresh_overview_goal_lamp(report_dir, goal_code, sync_lamp):
        sync_updates.append("2_目标级自查明细/2.1_目标清单总览.md")
    if _refresh_summary_goal_lamp(report_dir, goal_code, sync_lamp):
        sync_updates.append("1_管理摘要.md")
    metadata = _refresh_metadata(args, report_dir)
    history = _append_history(report_dir, {
        "reportId": str(args.report_id),
        "revisionRequest": f"修改 {code} 灯色: {old_lamp} -> {args.new_lamp}",
        "mode": "update_lamp",
        "applied": [{
            "type": "update_lamp",
            "targetFile": os.path.relpath(target_file, report_dir),
            "code": code,
            "oldLamp": old_lamp,
            "newLamp": args.new_lamp,
            "reason": reason,
            "displayReason": display_reason,
            "goalCode": goal_code,
            "oldGoalLamp": old_goal_lamp,
            "aggregatedGoalLamp": aggregated_goal_lamp,
            "goalLampChanged": goal_lamp_changed,
        }],
        "rejected": [],
    })
    return {
        "success": True,
        "reportDir": report_dir,
        "targetFile": target_file,
        "code": code,
        "oldLamp": old_lamp,
        "newLamp": args.new_lamp,
        "syncUpdates": sync_updates,
        "historyFile": history,
        "manifestFile": metadata.get("chapterManifest", {}).get("manifestFile"),
        "reportManifestFile": metadata.get("reportManifestFile"),
    }


def add_evidence_ref(args):
    if not args.report_id:
        return {"error": "report_id is required"}
    if not args.evidence_file:
        return {"error": "evidence_file is required"}
    if not os.path.isfile(args.evidence_file):
        return {"error": f"Evidence file not found: {args.evidence_file}"}
    report_dir = _work_dir(args.report_id)
    appendix = os.path.join(report_dir, "9_附录_证据索引.md")
    if not os.path.isfile(appendix):
        return {"error": f"9_附录_证据索引.md not found under {report_dir}"}
    with open(args.evidence_file, "r", encoding="utf-8") as f:
        evidence = json.load(f)
    report_record_id = str(evidence.get("reportId", "")).strip()
    if not report_record_id:
        return {"error": "evidence.reportId is required"}
    related_codes = [str(c).strip() for c in evidence.get("relatedCodes", []) if str(c).strip()]
    if not related_codes:
        return {"error": "evidence.relatedCodes is required"}
    known = _known_codes(report_dir)
    unknown = [c for c in related_codes if known and c not in known]
    if unknown:
        return {"error": f"evidence.relatedCodes contains unknown task codes: {', '.join(unknown)}"}
    with open(appendix, "r", encoding="utf-8") as f:
        content = f.read()
    existing_link = f"huibao://view?id={report_record_id}"
    existing = re.search(rf"\|\s*(R\d+|RP\d+)\s*\|[^\n]*{re.escape(existing_link)}", content)
    if existing:
        return {
            "success": True,
            "existing": True,
            "evidenceCode": existing.group(1),
            "reportDir": report_dir,
            "appendixFile": appendix,
        }

    goal_suffix = "0"
    suffix_match = re.match(r"(?:P\d+|[A-Z][A-Z0-9]*)-(\d+)", related_codes[0])
    if suffix_match:
        goal_suffix = suffix_match.group(1)
    existing_nums = []
    for code in re.findall(r"\|\s*R(\d+)\s*\|", content):
        if code.startswith(goal_suffix) and len(code) == len(goal_suffix) + 3:
            existing_nums.append(int(code))
    next_num = (max(existing_nums) + 1) if existing_nums else int(f"{goal_suffix}001")
    evidence_code = f"R{next_num}"
    title = str(evidence.get("title", "")).strip() or f"汇报 {report_record_id}"
    level = str(evidence.get("evidenceLevel", "辅证")).strip() or "辅证"
    related_node = " / ".join(related_codes)
    row = f"| {evidence_code} | 《{title.strip('《》')}》 | {level} | [查看汇报](huibao://view?id={report_record_id}) | {related_node} |"
    marker = "#### A.3 上月参考"
    if marker in content:
        updated = content.replace(marker, row + "\n\n" + marker, 1)
    else:
        updated = content.rstrip() + "\n" + row + "\n"
    with open(appendix, "w", encoding="utf-8") as f:
        f.write(updated)
    metadata = _refresh_metadata(args, report_dir)
    history = _append_history(report_dir, {
        "reportId": str(args.report_id),
        "revisionRequest": f"追加组织自身证据 {evidence_code}: reportId={report_record_id}",
        "mode": "add_evidence_ref",
        "applied": [{
            "type": "add_evidence_ref",
            "targetFile": os.path.relpath(appendix, report_dir),
            "evidenceCode": evidence_code,
            "reportId": report_record_id,
            "title": title,
            "relatedCodes": related_codes,
        }],
        "rejected": [],
    })
    return {
        "success": True,
        "evidenceCode": evidence_code,
        "reportId": report_record_id,
        "title": title,
        "reportDir": report_dir,
        "appendixFile": appendix,
        "historyFile": history,
        "manifestFile": metadata.get("chapterManifest", {}).get("manifestFile"),
        "reportManifestFile": metadata.get("reportManifestFile"),
    }


def preview_report(args):
    if not args.report_id:
        return {"error": "report_id is required"}
    report_dir = _work_dir(args.report_id)
    if not os.path.isdir(report_dir):
        return {"error": "month-report directory not found. Run init_local_report first."}
    content_parts = []
    for rel_path in _iter_markdown_files(report_dir):
        with open(os.path.join(report_dir, rel_path), "r", encoding="utf-8") as f:
            content_parts.append(f.read().strip())
    result = {
        "success": True,
        "reportDir": report_dir,
        "fileCount": len(content_parts),
        "digest": compute_hash("\n\n".join(content_parts)),
    }
    if getattr(args, "include_content", False):
        result["content"] = "\n\n".join(content_parts).strip() + "\n"
    return result


ACTION_MAP = {
    "init_local_report": init_local_report,
    "prepare_revision_workspace": prepare_revision_workspace,
    "fetch_report_content": fetch_report_content,
    "print_work_dir": print_work_dir,
    "build_chapter_manifest": build_chapter_manifest,
    "list_chapter_policies": list_chapter_policies,
    "resolve_code_file": resolve_code_file,
    "apply_chapter_patch": apply_chapter_patch,
    "update_lamp_color": update_lamp_color,
    "add_evidence_ref": add_evidence_ref,
    "preview_report": preview_report,
}


def main():
    parser = argparse.ArgumentParser(description="Organization Monthly Report Reviser CLI")
    parser.add_argument("action", choices=ACTION_MAP.keys(), help="Action to perform")
    parser.add_argument("--report_id", help="Report ID")
    parser.add_argument("--content_file", help="Path to content file")
    parser.add_argument("--patch_file", help="Path to patch JSON")
    parser.add_argument("--evidence_file", help="Path to evidence JSON")
    parser.add_argument("--include_content", action="store_true", help="Include assembled markdown content")
    parser.add_argument("--code", help="Task code for update_lamp_color")
    parser.add_argument("--new_lamp", help="New lamp color: green/yellow/red/black")
    parser.add_argument("--reason", help="Reason for lamp change")
    args = parser.parse_args()
    result = ACTION_MAP[args.action](args)
    print(json.dumps(result, ensure_ascii=False, indent=2))
    if result.get("error"):
        sys.exit(1)


if __name__ == "__main__":
    main()


path: references/workflows.md
content: # 组织月报修订工作流

## 工作目录

默认工作目录：

```text
{cms-bp-org-monthly-report-reviser 目录的上上层}/bp/report-reviser/{reportMonth}-月报-{reportRecordId}/
```

查看实际路径：

```bash
python3 scripts/org_monthly_report_reviser.py print_work_dir --report_id {reportId}
```

正式目录月份必须使用 `YYYY-MM`，例如 `2026-05-月报-2058815281387614209`；不要把 `2026年5月-月报-...` 或 `2026-05-组织月报-...` 这类格式作为正式工作目录。

## 初始化

本地 Markdown：

```bash
python3 scripts/org_monthly_report_reviser.py init_local_report \
  --report_id local_202603 \
  --content_file "/absolute/path/org-report.md"
```

远端 reportId：

```bash
python3 scripts/org_monthly_report_reviser.py prepare_revision_workspace --report_id {reportId}
```

在 sandbox exec / tool runner 中，密钥必须通过执行入参的 `env` 字段传入，不要使用 `export ... && python3 ...` 或 `BP_OPEN_API_APP_KEY=... python3 ...`：

```json
{
  "command": "python3 scripts/org_monthly_report_reviser.py prepare_revision_workspace --report_id {reportId}",
  "env": {
    "BP_OPEN_API_APP_KEY": "{用户提供的密钥}"
  }
}
```

若运行脚本依赖的 `requests` 缺失，必须先安装依赖再执行；禁止在未实际运行脚本的情况下模拟脚本输出。

## 月份目录结构

```text
2026-03-月报-local_202603/
  0_报告头.md
  1_管理摘要.md
  2_目标级自查明细/
    2.1_目标清单总览.md
    2.2_目标明细/
      A8B8-1｜目标名称.md
    2.3_未参与自查目标说明.md
  3_问题_偏差与原因分析.md
  4_风险预警与资源需求.md
  5_下月重点安排.md
  6_需决策需协同事项.md
  7_年度结果预判评分.md
  8_月度汇报入口.md
  9_附录_证据索引.md
  report_manifest.json
  report_meta.json
  chapter_revision_manifest.json
  revision_history.jsonl
```

## 普通章节修订

定位目标/KR/举措编码对应文件时，先用脚本取精确路径：

```bash
python3 scripts/org_monthly_report_reviser.py resolve_code_file \
  --report_id {reportId} \
  --code A8B8-1.1
```

使用返回的 `relativePath` / `absolutePath`，不要手写含中文、空格、括号或 `｜` 的路径。

确认前，AI 只输出修改方案。确认后生成章节 patch：

```json
{
  "revisionRequest": "用户原始修订诉求",
  "changes": [
    {
      "targetFile": "4_风险预警与资源需求.md",
      "operation": "replace_text",
      "locator": {
        "strategy": "exact_text",
        "originalText": "原文中唯一的一小段内容"
      },
      "newText": "用户确认后的新内容",
      "reason": "用户确认优化风险表达",
      "factBasis": ["P4939-9"]
    }
  ]
}
```

执行：

```bash
python3 scripts/org_monthly_report_reviser.py apply_chapter_patch \
  --report_id {reportId} \
  --patch_file {patch_path}
```

## 灯色修改

普通正文 patch 不允许改灯色。确认判断依据后执行：

```bash
python3 scripts/org_monthly_report_reviser.py update_lamp_color \
  --report_id {reportId} \
  --code A8B8-1.1 \
  --new_lamp yellow \
  --reason "用户确认后的灯色判断依据"
```

规则：

1. 组织目标灯色按当前 KR 灯色聚合；合法变更后同步目标明细、`2.1` 目标清单总览和 `1.2` 整体目标灯色。
2. KR 灯色修改后，脚本必须重新聚合所属组织目标灯色，并同步目标明细、`2.1` 目标清单总览和 `1.2` 整体目标灯色；同步失败时必须阻断，不能留下部分更新。
3. KR / 举措灯色可按用户确认依据走专门动作；举措灯色修改不直接替代 KR 聚合规则。
4. 灯色修改必须有用户确认的可展示业务原因；没有外部汇报证据时，可以只写用户解释。
5. 用户提供真实汇报证据时，先执行 `add_evidence_ref`，再在原因中引用 R/RP 编号或对应汇报 ID；脚本会展示为 `[R1001](huibao://view?id=...)`，不得裸写 `huibao://...`。
6. 不得把当前月报自身 reportId 作为证据汇报链接；未登记的汇报链接必须先追加证据。
7. 普通正文修订不得修改灯色 emoji、灯色文字或灯色样式。

确认前的修改方案必须写明联动范围：

- KR 灯色：目标明细 KR 行、同文件目标级综合灯色结论、`2.1` 目标清单总览、`1.2` 整体目标灯色。
- 目标灯色：目标明细目标级综合灯色结论、`2.1` 目标清单总览、`1.2` 整体目标灯色。
- 举措灯色：只改举措自身；不自动替代 KR/目标聚合。

## 证据追加

证据文件示例：

```json
{
  "reportId": "2030000000000000001",
  "title": "汇报标题（可选）",
  "evidenceLevel": "辅证",
  "relatedCodes": ["A8B8-1.1"]
}
```

执行：

```bash
python3 scripts/org_monthly_report_reviser.py add_evidence_ref \
  --report_id {reportId} \
  --evidence_file {evidence_json_path}
```

附录只追加组织自身证据；下级目标报告只作为第 2.2 章下级承接 BP 内容和判灯支撑，不进入附录。证据编号规则与个人月报一致：按首个关联编码的目标后缀生成，例如 `A8B8-1.1` 下新增为 `R1001`，同一后缀已有证据则递增。


path: references/templates/chapter_patch_schema.json
content: {
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "title": "Org Chapter Safe Revision Patch",
  "type": "object",
  "required": ["revisionRequest", "changes"],
  "additionalProperties": false,
  "properties": {
    "revisionRequest": {
      "type": "string",
      "minLength": 1
    },
    "changes": {
      "type": "array",
      "minItems": 1,
      "items": {
        "type": "object",
        "required": ["targetFile", "operation", "locator", "newText", "reason", "factBasis"],
        "additionalProperties": false,
        "properties": {
          "targetFile": {
            "type": "string",
            "pattern": "^[^/].*\\.md$"
          },
          "operation": {
            "type": "string",
            "enum": ["replace_text"]
          },
          "locator": {
            "type": "object",
            "required": ["strategy", "originalText"],
            "additionalProperties": false,
            "properties": {
              "strategy": {
                "type": "string",
                "enum": ["exact_text"]
              },
              "originalText": {
                "type": "string",
                "minLength": 1
              }
            }
          },
          "newText": {
            "type": "string",
            "minLength": 1
          },
          "reason": {
            "type": "string",
            "minLength": 1
          },
          "factBasis": {
            "type": "array",
            "minItems": 1,
            "items": {
              "type": "string",
              "minLength": 1
            }
          }
        }
      }
    }
  }
}


path: references/rules/validation-rules.md
content: # 组织月报写入校验规则

写入失败时按以下顺序排查：

1. `targetFile` 是否是月份目录下的 Markdown 文件。
2. 目标章节是否允许普通修订。
3. `locator.originalText` 是否在目标文件中唯一出现。
4. `newText` 是否改变灯色、证据编号、汇报链接、目标/KR/举措编号。
5. 第 2.2 章修订是否触碰“下级承接BP内容”或举措下级证据摘要。
6. 第 7/8/9 章是否被直接正文修改。
7. 第 3/4/5/6 章新增事实数字时，是否来自用户明确输入；脚本不做语义判断，AI 必须在方案中写明来源。
8. 是否修改了 `<details>` / `<summary>` 折叠格式或 summary 文案。


path: references/rules/editable-boundary-rules.md
content: # 组织月报章节级可编辑边界

## 总原则

1. 月份目录是唯一正文来源。
2. Markdown 中不得写入内部 `LOCK/EDITABLE` 标记。
3. 用户确认前只给方案；确认后才调用脚本写入。
4. 普通正文修订必须使用 `apply_chapter_patch` 的精确替换。
5. 灯色、证据编号、汇报链接、系统入口、下级承接 BP 内容不能通过普通正文替换修改。
6. `<details>` / `<summary>` 折叠格式由模板和脚本保证，普通修订不能改标签或 summary 文案。

## 章节边界

| 章节 | 是否可直接改 | 可改内容 | 禁改内容 |
|------|--------------|----------|----------|
| 0 报告头 | 否 | 无 | 部门、月份、周期、标题等元信息 |
| 1 管理摘要 | 是 | 一句话摘要、核心进展、核心偏差、下月策略表达 | 无来源事实数字、目标编号、灯色、证据、链接 |
| 2 目标级自查明细 | 是 | 非硬锁定表达、偏差分析、结论说明 | 目标/KR/举措编号和名称、证据编号/链接、灯色、下级承接 BP 内容、下级证据摘要 |
| 3 问题、偏差与原因分析 | 是 | 管理分析表达、结构性原因措辞；用户明确提供的新事实数字 | 无来源目标、证据、链接 |
| 4 风险预警与资源需求 | 是 | 风险、资源、协同表达；用户明确提供的新事实数字 | 无来源目标、证据、链接 |
| 5 下月重点安排 | 是 | 下月目标、成果、举措、校准事项表达；用户明确提供的新事实数字 | 编造时间、负责人、指标、承诺、证据 |
| 6 需决策/需协同事项 | 是 | 决策诉求、协同事项表达；用户明确提供的新事实数字 | 编造组织、资源、承诺、证据或链接 |
| 7 年度结果预判评分 | 否 | 无 | 链接和固定格式 |
| 8 月度汇报入口 | 否 | 无 | 链接和固定格式 |
| 9 附录证据索引 | 否 | 无 | 证据编号、标题、链接、统计摘要；新增证据必须走专门动作 |

## 专门动作

1. 灯色修改：必须走 `update_lamp_color`。组织目标灯色必须等于当前 KR 灯色聚合结果；修改 KR 灯色后必须由脚本联动目标级综合灯色结论、`2.1` 目标清单总览和 `1.2` 整体目标灯色。
2. 新增证据：必须走 `add_evidence_ref`。附录只收组织自身证据。
3. 下级承接 BP 内容：不支持普通正文修订。


path: references/rules/patch-rules.md
content: # 组织月报章节 Patch 生成规则

## 基本原则

1. AI 先输出修改方案；用户明确确认后，才生成 patch 并调用 `apply_chapter_patch`。
2. 每个 change 必须定位到一个明确 Markdown 文件和一段唯一原文。
3. 禁止整章重写、模糊替换、按行号替换。
4. 灯色、证据索引、汇报链接、目标编号、系统入口、下级承接 BP 内容不能通过正文替换修改。
5. `targetFile` 必须来自 `resolve_code_file`、`chapter_revision_manifest.json` 或脚本返回值；不要手写拼接含中文、空格、括号或 `｜` 的文件路径。

## Patch JSON

```json
{
  "revisionRequest": "用户原始修订诉求",
  "changes": [
    {
      "targetFile": "3_问题_偏差与原因分析.md",
      "operation": "replace_text",
      "locator": {
        "strategy": "exact_text",
        "originalText": "原文中唯一的一小段内容"
      },
      "newText": "用户确认后的新内容",
      "reason": "用户确认优化偏差原因表达",
      "factBasis": ["P4939-9"]
    }
  ],
  "blockedChanges": [
    {
      "request": "用户要求修改下级承接 BP 表格",
      "reason": "下级承接 BP 内容不可通过普通正文替换修改"
    }
  ]
}
```

## 必须阻断

1. 修改第 7/8 章链接。
2. 直接编辑附录证据索引。
3. 通过正文替换改变灯色。
4. 修改 `huibao://` 链接或 R/RP 证据编号。
5. 修改下级承接 BP 内容、下级进展摘要、下级目标灯、下级判断理由。
6. 修改举措下“证据：”中的下级证据摘要。
7. 新增用户未明确输入或原文没有的日期、负责人、组织、承诺；第 3/4/5/6 章允许用户明确提供的新事实数字。
8. 修改 `<details>` / `<summary>` 折叠格式或 summary 文案。


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)