fix(cma): KR 名称污染存量清理 + 透传点护栏 (cma-kr-name-pollution-cleanup-20260916)
ci/woodpecker/push/main Pipeline was canceled

- kr_name.py/krName.ts: sanitize_kr_name 判据(尾部≥/≤数字≠目标值→截断;==目标值→保留)
- maps.py/okr.py/okr_templates.py: 模板名/JSON→krs.title 透传点护栏
- test: 28 passed(独立复核)
- 原会话因迭代上限停手,本提交代收口(工作已复核完成)
This commit is contained in:
Hermes CI Fix
2026-09-16 14:48:06 +08:00
parent 09fb11d86f
commit 06c23927aa
8 changed files with 300 additions and 3 deletions
+14
View File
@@ -7,7 +7,11 @@ from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role
from app.models import StrategicMap, OperationLog, MapObjective, Objective, KR, KPIDefinition, ReviewRecord, User
from app.utils.kr_name import sanitize_kr_name
import json
import logging
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/cma/maps", tags=["战略地图"],
dependencies=[Depends(require_role("ceo", "finance"))],
@@ -481,6 +485,16 @@ def _sync_objectives_krs(m, db):
if kpi and kpi.threshold_green:
op2, tgt2 = _parse_kr_operator_target(kpi.threshold_green)
op, tgt = op2, (tgt2 if tgt is None else tgt)
# 护栏(2026-09-16):名称尾部「≥/≤+数字」与目标值不一致 = 历史污染截断值,
# 在此透传点(JSON/模板名 → krs.title)规整,防止污染继续传染(判据见 app/utils/kr_name.py)
clean_title, _polluted = sanitize_kr_name(title, kr_item.get("target_value"), tgt)
if _polluted:
logger.warning(
"KR名称污染已规整: map=%s objective=%s %r -> %r (target=%s)",
m.id, objective.id, title, clean_title, tgt,
)
title = clean_title
kr_item["name"] = clean_title # 同步回写 JSON,保持展示口径=落库口径
kr = existing_krs.get(title)
if kr is None:
kr = KR(
+12
View File
@@ -11,6 +11,10 @@ from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_role
from app.models import Objective, ActionPlan, KR, ObjectiveKPI, KPIDefinition
from app.utils.kr_name import sanitize_kr_name
import logging
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/cma/okr", tags=["OKR目标管理"],
dependencies=[Depends(require_role("ceo", "finance", "it"))],
@@ -329,6 +333,14 @@ def sync_krs(objective_id: int, data: dict, db: Session = Depends(get_db),
kpi_id = kpi.id if kpi else None
operator, target_value = _inherit_kpi_direction(db, kpi_id, item.get("operator"), item.get("target_value"))
# 护栏(2026-09-16):画布节点/接口传来的 KR 名若带历史污染尾巴(尾部「≥/≤+被截断的数字」)
# 在此规整,防污染经 krs.title 继续传染(判据见 app/utils/kr_name.py)
_tv = item.get("target_value")
title, _polluted = sanitize_kr_name(title, _tv if _tv is not None else target_value)
if _polluted:
logger.warning("KR名称污染已规整: objective=%s %r -> %r (target=%s)",
objective_id, item.get("title") or item.get("name"), title, _tv)
kr = None
if item.get("id") and int(item["id"]) in existing:
kr = existing[int(item["id"])]
+3 -1
View File
@@ -7,6 +7,7 @@ from typing import Optional
from app.database import get_db
from app.auth_middleware import require_role
from app.models import OKRTemplate, StrategicMap
from app.utils.kr_name import sanitize_kr_name
router = APIRouter(prefix="/api/cma/okr-templates", tags=["OKR模板库"],
dependencies=[Depends(require_role("ceo", "finance", "it"))],
@@ -273,7 +274,8 @@ def apply_okr_template(template_id: int, data: dict, db: Session = Depends(get_d
"kpis": [],
"krs": [
{
"name": kr.get("name", ""),
# 护栏(2026-09-16):模板名 → 新地图 KR 名 透传点,污染名(尾部符号+截断数字)在此规整
"name": sanitize_kr_name(kr.get("name", ""), kr.get("target_value"))[0],
"target_value": kr.get("target_value", ""),
"weight": kr.get("weight", 33),
"metric_kpi_id": kr.get("metric_kpi_id"),
+54
View File
@@ -0,0 +1,54 @@
"""KR 名称规整(历史名称污染防护)。
背景:2026-09-11 前 `MapCanvasDialogs.vue` 的逐键写入点会把「方向符号 + 已输入的首位数字」
拼进 KR 名称(输入 780 → 名称变 `营业收入≥7`)。写入口已删,但**存量数据仍在传染**:
对话框用模板 `preset_krs` 预填 KR 名 → 沿用到新目标/新地图。
判据(勿一刀切):
- 名称尾部「≥/≤ + 数字」且该数字 **≠** 目标值 → 截断值(污染)→ 去掉尾部片段;
- 数字 **==** 目标值 → 有意命名(如 `渠道留存率≥90` / `新客户数≥10` / `A级渠道占比≥30%`)→ 原样保留;
- 目标值里取不到数字(如 `为正` / `TOP3` / 空值)→ 无法判定 → 原样保留(不擅改)。
用法(护栏放在「JSON/模板名 → krs.title」的透传点,而不是用户输入点):
from app.utils.kr_name import sanitize_kr_name
clean, changed = sanitize_kr_name(name, target_value)
"""
import re
from typing import Any, Optional, Tuple
_NUM = re.compile(r"[-+]?\d*\.?\d+")
_TAIL = re.compile(r"^(?P<base>.*?)\s*(?P<sym>[≥≤])\s*(?P<num>\d+(?:\.\d+)?)\s*$")
def target_number(target_value: Any) -> Optional[float]:
"""从目标值里取数字(兼容 780 / '780' / '≥780' / '≤45天' / '≥20%');取不到返回 None。"""
if target_value is None:
return None
if isinstance(target_value, bool):
return None
if isinstance(target_value, (int, float)):
return float(target_value)
m = _NUM.search(str(target_value))
return float(m.group(0)) if m else None
def sanitize_kr_name(
name: Any,
target_value: Any = None,
target_number_value: Optional[float] = None,
) -> Tuple[str, bool]:
"""返回 (规整后名称, 是否发生变更)。target_number_value 可显式传入已解析的目标数(优先)。"""
if not isinstance(name, str):
return ("" if name is None else str(name)), False
m = _TAIL.match(name)
if not m:
return name, False
tn = target_number_value if target_number_value is not None else target_number(target_value)
if tn is None:
return name, False
if abs(float(m.group("num")) - tn) < 1e-9:
return name, False
clean = m.group("base").strip()
if not clean:
return name, False
return clean, True