ci/woodpecker/push/main Pipeline failed
P0-A(任务 fullstack-20261017-warning-capability-build-p0): - cash_thresholds.py:阈值迁 per-entity(复用 alert_rules.rule_type=cash_gap_threshold, 默认 20/10 万不动;无配置/非法/异常一律回落默认,fail-safe 不改预警行为) - cash_forecast_engine.py:阈值改从配置读取,响应新增 threshold_source 明写来源 - verify_cash_gap_replay.py:同输入双跑逐字段 diff=0 + 手算 3/3 + 端点逐字段对拍(--live) - cash_gap_narrative.py:复用 ai_brief.call_deepseek,只写原因/建议; 断言生成文本数字集合 ⊆ 规则层数字集合,违例即不带文案上线 - audit_warning_data_readiness.py:G0 只读核验(cma_reader SELECT only), 行数/期间/枚举 + 数据质量缺口(期初全 0、负期末、单批次、无员工维度表) 未 push(push 即部署,须项目侧书面放行);未改生产库/未动 cron。
409 lines
18 KiB
Python
409 lines
18 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""资金缺口「讲人话」— G2 验收件(P0-A)
|
||
|
||
任务:fullstack-20261017-warning-capability-build-p0(上游 yanxue-20261017-warning-capability-build-p0)
|
||
方案:`_reports/yanxue-20261017-warning-capability-build-p0.md` §G1/G2 + §四「数字零错误(可复算)」
|
||
|
||
纪律(本脚本存在的唯一理由):
|
||
1. **数字由规则层算死**:所有数字一律来自 `forecast_cash_flow_with_plans` 的返回值,
|
||
脚本把这份 payload 作为「白名单」喂给 LLM,并**事后断言**生成文本里的数字集合
|
||
⊆ 规则层数字集合(数值相等,非字符串相等;`20` 与 `20.0` 视为同一数字)。
|
||
2. **LLM 只写文字**:prompt 明确禁止四舍五入、估算、派生、新增数字、自造日期。
|
||
3. **断言失败即不带文案上线**:首次违例 → 收紧 prompt 重试一次;仍违例 → 退出码 1、
|
||
`assertion.pass=false`、`narrative` 置空(调用方不得把文案推给用户)。
|
||
|
||
用法:
|
||
# 规则层取数(只读,走 cma_reader)→ LLM 出文案 → 数字守恒断言
|
||
python3 backend/scripts/cash_gap_narrative.py --entity 1 --days 30
|
||
# 只跑断言自测(不连库、不连 LLM,离线可判)
|
||
python3 backend/scripts/cash_gap_narrative.py --selftest
|
||
# 用现成 payload 出文案(离线复算/回放)
|
||
python3 backend/scripts/cash_gap_narrative.py --payload-json p.json
|
||
退出码:0 = 断言通过且文案完整;1 = 数字违例 / 段落缺失 / 取数或调用失败
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import asyncio
|
||
import json
|
||
import os
|
||
import re
|
||
import sys
|
||
from datetime import datetime
|
||
from decimal import Decimal, InvalidOperation
|
||
|
||
BACKEND_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__))
|
||
for _p in (BACKEND_DIR, SCRIPTS_DIR):
|
||
if _p not in sys.path:
|
||
sys.path.insert(0, _p)
|
||
|
||
READER_ENV_DEFAULT = "/root/.hermes/profiles/wecom-fullstack/.secrets/cma-reader.env"
|
||
|
||
#: DeepSeek Key 来源候选(按序取第一个「非空」;脚本只记来源路径,不打印 Key)
|
||
#: 生产 CMA backend/.env 中的 Key 已被供应商判为 invalid(2026-10-17 实测 401),
|
||
#: 故 Bot 侧优先用 Hermes 通道 Key;生产 Key 轮换另走派单,本脚本不改生产配置。
|
||
KEY_ENV_CANDIDATES = (
|
||
"/root/.hermes/profiles/wecom-fullstack/.env",
|
||
"/root/.hermes/.env",
|
||
"/root/cma-management/backend/.env",
|
||
)
|
||
|
||
#: 文本里允许出现的非数字词表(避免把「TOP3」里的 3 当数字)——prompt 亦禁止
|
||
DATE_PATTERNS = (
|
||
re.compile(r"\d{4}-\d{2}-\d{2}"),
|
||
re.compile(r"\d{4}年\d{1,2}月\d{1,2}日"),
|
||
re.compile(r"\d{4}-\d{2}(?!\d)"),
|
||
re.compile(r"\d{4}年\d{1,2}月(?!\d)"),
|
||
)
|
||
NUMBER_PATTERN = re.compile(r"-?\d+(?:\.\d+)?")
|
||
|
||
REQUIRED_SECTIONS = ("原因", "建议")
|
||
|
||
|
||
# ────────────────────────── 数字集合工具 ──────────────────────────
|
||
def _dec(value) -> Decimal | None:
|
||
"""把任意数值转 Decimal(量化到 2 位,与规则层 round(2) 口径一致);非法返回 None。"""
|
||
try:
|
||
d = Decimal(str(value))
|
||
except (InvalidOperation, ValueError):
|
||
return None
|
||
if not d.is_finite():
|
||
return None
|
||
return d.quantize(Decimal("0.01"))
|
||
|
||
|
||
def collect_rule_numbers(payload) -> tuple[set[Decimal], set[str]]:
|
||
"""递归收集 payload 中所有数值与日期(白名单)。
|
||
|
||
· 数值同时收 x 与 -x:文本用「下降 37.81%」表达负趋势属同一数字,不算改数
|
||
· 布尔值不算数字;字符串里的日期单独收集,不计入数字白名单
|
||
"""
|
||
values: set[Decimal] = set()
|
||
dates: set[str] = set()
|
||
|
||
def walk(node):
|
||
if isinstance(node, bool) or node is None:
|
||
return
|
||
if isinstance(node, (int, float, Decimal)):
|
||
d = _dec(node)
|
||
if d is not None:
|
||
values.add(d)
|
||
values.add(-d)
|
||
return
|
||
if isinstance(node, str):
|
||
for pat in DATE_PATTERNS:
|
||
dates.update(pat.findall(node))
|
||
return
|
||
if isinstance(node, dict):
|
||
for v in node.values():
|
||
walk(v)
|
||
return
|
||
if isinstance(node, (list, tuple)):
|
||
for v in node:
|
||
walk(v)
|
||
|
||
walk(payload)
|
||
return values, dates
|
||
|
||
|
||
def extract_text_numbers(text: str) -> tuple[list[tuple[str, Decimal]], set[str], str]:
|
||
"""抽取文本中的日期与数字;返回 (数字token列表, 日期集合, 去日期后的文本)。"""
|
||
dates: set[str] = set()
|
||
stripped = text
|
||
for pat in DATE_PATTERNS:
|
||
for m in pat.findall(stripped):
|
||
dates.add(m)
|
||
stripped = pat.sub(" ", stripped)
|
||
|
||
numbers: list[tuple[str, Decimal]] = []
|
||
for raw in NUMBER_PATTERN.findall(stripped):
|
||
d = _dec(raw)
|
||
if d is not None:
|
||
numbers.append((raw, d))
|
||
return numbers, dates, stripped
|
||
|
||
|
||
def assert_number_conservation(text: str, payload) -> dict:
|
||
"""核心断言:文本数字 ⊆ 规则层数字;日期 ⊆ 规则层日期。返回结构化结果。"""
|
||
allowed_values, allowed_dates = collect_rule_numbers(payload)
|
||
numbers, dates, _ = extract_text_numbers(text)
|
||
|
||
value_violations = [
|
||
{"token": tok, "value": str(val)}
|
||
for tok, val in numbers
|
||
if val not in allowed_values
|
||
]
|
||
date_violations = sorted(d for d in dates if d not in allowed_dates)
|
||
|
||
return {
|
||
"pass": not value_violations and not date_violations,
|
||
"checked_numbers": len(numbers),
|
||
"allowed_numbers": len(allowed_values),
|
||
"value_violations": value_violations,
|
||
"date_violations": date_violations,
|
||
"allowed_values_sample": sorted(str(v) for v in allowed_values)[:40],
|
||
"allowed_dates": sorted(allowed_dates),
|
||
}
|
||
|
||
|
||
# ────────────────────────── 规则层取数 ──────────────────────────
|
||
def _dotenv(path: str) -> dict:
|
||
env = {}
|
||
for line in open(path, encoding="utf-8"):
|
||
line = line.strip()
|
||
if line and "=" in line and not line.startswith("#"):
|
||
k, v = line.split("=", 1)
|
||
env[k.strip()] = v.strip().strip('"').strip("'")
|
||
return env
|
||
|
||
|
||
def resolve_deepseek_key() -> tuple[str, str]:
|
||
"""按候选顺序取 DeepSeek Key;返回 (key, 来源路径)。只用于调用,不落盘不打印。"""
|
||
current = os.environ.get("DEEPSEEK_API_KEY", "").strip()
|
||
for path in KEY_ENV_CANDIDATES:
|
||
if not os.path.exists(path):
|
||
continue
|
||
try:
|
||
key = _dotenv(path).get("DEEPSEEK_API_KEY", "").strip()
|
||
except OSError:
|
||
continue
|
||
if key:
|
||
return key, path
|
||
return current, "environment" if current else ""
|
||
|
||
|
||
def load_rule_payload(entity_id: int, days: int) -> dict:
|
||
"""只读取数:cma_reader 账号(SELECT only)→ 引擎同一函数,与端点同源。"""
|
||
from sqlalchemy import create_engine
|
||
from sqlalchemy.orm import sessionmaker
|
||
|
||
from app.utils import cash_forecast_engine as engine
|
||
|
||
rd = _dotenv(os.environ.get("CMA_READER_ENV", READER_ENV_DEFAULT))
|
||
url = ("mysql+pymysql://%s:%s@%s:%s/%s?charset=utf8mb4"
|
||
% (rd["CMA_READER_DB_USER"], rd["CMA_READER_DB_PASS"],
|
||
rd["CMA_READER_DB_HOST"], rd["CMA_READER_DB_PORT"], rd["CMA_READER_DB_NAME"]))
|
||
eng = create_engine(url, pool_pre_ping=True)
|
||
db = sessionmaker(bind=eng)()
|
||
try:
|
||
payload = engine.forecast_cash_flow_with_plans(entity_id, db, days=days)
|
||
finally:
|
||
db.close()
|
||
return payload
|
||
|
||
|
||
# ────────────────────────── prompt ──────────────────────────
|
||
SYSTEM_PROMPT = (
|
||
"你是管理会计,只负责把已经算好的资金缺口结论写成老板能读懂的「原因」和「建议」。"
|
||
"硬性规则:"
|
||
"① 只能引用我给你的数字,逐字照抄,不得四舍五入、不得换算单位、不得估算、不得派生新数字;"
|
||
"② 禁止出现我未提供的任何新数字(包括数量、比例、天数、排名,如「TOP3」里的 3 不能用);"
|
||
"③ 日期只能照抄我给的日期;"
|
||
"④ 输出两段,段首分别为「原因:」与「建议:」,每段不超过 120 字;"
|
||
"⑤ 不要复述表格,不要下结论性预测以外的推断。"
|
||
)
|
||
|
||
|
||
def build_prompt(payload: dict) -> str:
|
||
"""把规则层 payload 压成「可引用数字白名单」,避免把整包原始明细灌给模型。"""
|
||
def r(v):
|
||
return None if v is None else round(float(v), 2)
|
||
|
||
first_gap = (payload.get("gap_dates") or [None])[0] or {}
|
||
first_level = {"red": "红灯", "yellow": "黄灯"}.get(str(first_gap.get("level") or ""), "无")
|
||
|
||
compact = {
|
||
"账套entity_id": payload.get("entity_id"),
|
||
"预测窗口天数": payload.get("days"),
|
||
"当前现金余额(万元)": r(payload.get("base_cash")),
|
||
"黄灯警戒线(万元)": r(payload.get("warning_line")),
|
||
"红灯警戒线(万元)": r(payload.get("critical_line")),
|
||
"阈值来源": (payload.get("threshold_source") or {}).get("source"),
|
||
"最低余额(万元)": r(payload.get("min_cash")),
|
||
"最低余额日期": payload.get("min_cash_date"),
|
||
"缺口天数": len(payload.get("gap_dates") or []),
|
||
"首个缺口": first_gap or None,
|
||
"首个缺口级别": first_level,
|
||
"月度经营现金流预算(万元/月)": r((payload.get("trends") or {}).get("budget_monthly_ocf")),
|
||
"经营现金流趋势(%)": r((payload.get("trends") or {}).get("operating_cash_flow_trend_pct")),
|
||
"应收趋势(%)": r((payload.get("trends") or {}).get("receivables_trend_pct")),
|
||
"应付趋势(%)": r((payload.get("trends") or {}).get("payables_trend_pct")),
|
||
"逾期应收笔数": (payload.get("summary") or {}).get("overdue_receive_count"),
|
||
"逾期应收金额(万元)": r((payload.get("summary") or {}).get("overdue_receive_amount")),
|
||
}
|
||
return (
|
||
"以下是规则层已经算好的资金缺口数据(唯一可引用数字来源):\n"
|
||
+ json.dumps(compact, ensure_ascii=False, indent=2)
|
||
+ "\n请只基于这些数字写「原因」与「建议」两段。"
|
||
)
|
||
|
||
|
||
def strict_retry_prompt(payload: dict, bad_text: str, violations: list) -> str:
|
||
return (
|
||
build_prompt(payload)
|
||
+ "\n\n上一次输出违反了数字纪律,出现了未授权的数字:"
|
||
+ json.dumps(violations, ensure_ascii=False)
|
||
+ "\n上一次输出为:\n" + bad_text
|
||
+ "\n请重写,删除上述未授权数字,其余规则不变。"
|
||
)
|
||
|
||
|
||
# ────────────────────────── 主流程 ──────────────────────────
|
||
def split_sections(text: str) -> dict:
|
||
"""把模型输出切成 原因 / 建议 两段(段首关键字)。"""
|
||
out = {"reason": "", "advice": ""}
|
||
reason = re.search(r"原因[::]\s*(.*?)(?=建议[::]|$)", text, re.S)
|
||
advice = re.search(r"建议[::]\s*(.*)$", text, re.S)
|
||
if reason:
|
||
out["reason"] = reason.group(1).strip()
|
||
if advice:
|
||
out["advice"] = advice.group(1).strip()
|
||
return out
|
||
|
||
|
||
async def generate(entity_id: int, days: int, payload: dict | None = None) -> dict:
|
||
key, key_source = resolve_deepseek_key()
|
||
if key:
|
||
# 必须在 import ai_brief 之前落位:ai_brief 模块级 load_dotenv 不覆盖已存在的环境变量,
|
||
# 否则会读到生产 .env 里已被判 invalid 的旧 Key(2026-10-17 实测 401)。
|
||
os.environ["DEEPSEEK_API_KEY"] = key
|
||
|
||
from ai_brief import call_deepseek # 复用既有 DeepSeek 通道(scripts/ai_brief.py:24)
|
||
|
||
payload = payload if payload is not None else load_rule_payload(entity_id, days)
|
||
prompt = build_prompt(payload)
|
||
text = await call_deepseek(prompt, SYSTEM_PROMPT)
|
||
assertion = assert_number_conservation(text or "", payload)
|
||
attempts = 1
|
||
|
||
if not assertion["pass"] and text:
|
||
text2 = await call_deepseek(
|
||
strict_retry_prompt(payload, text, assertion["value_violations"] + assertion["date_violations"]),
|
||
SYSTEM_PROMPT,
|
||
)
|
||
assertion2 = assert_number_conservation(text2 or "", payload)
|
||
attempts = 2
|
||
if assertion2["pass"]:
|
||
text, assertion = text2, assertion2
|
||
else:
|
||
assertion = assertion2 # 以最后一次为准(仍为 fail)
|
||
|
||
sections = split_sections(text or "")
|
||
missing = [s for s in REQUIRED_SECTIONS if not sections[{"原因": "reason", "建议": "advice"}[s]]]
|
||
ok = assertion["pass"] and not missing
|
||
|
||
return {
|
||
"task": "fullstack-20261017-warning-capability-build-p0",
|
||
"stage": "G2 cash gap narrative (number-conservation asserted)",
|
||
"generated_at": datetime.now().isoformat(timespec="seconds"),
|
||
"entity_id": entity_id,
|
||
"days": days,
|
||
"model": "deepseek-chat",
|
||
"llm_key_source": key_source,
|
||
"llm_empty_response": not bool((text or "").strip()),
|
||
"attempts": attempts,
|
||
"assertion": assertion,
|
||
"missing_sections": missing,
|
||
"pass": ok,
|
||
"narrative": ({"reason": sections["reason"], "advice": sections["advice"], "raw": text}
|
||
if ok else None),
|
||
"rule_layer": {
|
||
"base_cash": payload.get("base_cash"),
|
||
"warning_line": payload.get("warning_line"),
|
||
"critical_line": payload.get("critical_line"),
|
||
"min_cash": payload.get("min_cash"),
|
||
"min_cash_date": payload.get("min_cash_date"),
|
||
"threshold_source": payload.get("threshold_source"),
|
||
"gap_days": len(payload.get("gap_dates") or []),
|
||
},
|
||
}
|
||
|
||
|
||
# ────────────────────────── 断言自测(离线) ──────────────────────────
|
||
SELFTEST_PAYLOAD = {
|
||
"entity_id": 1, "days": 30, "base_cash": -9.3, "warning_line": 20.0, "critical_line": 10.0,
|
||
"min_cash": -8.74, "min_cash_date": "2026-10-18", "threshold_source": {"source": "default"},
|
||
"gap_dates": [{"date": "2026-10-18", "gap_amount": 28.74, "level": "red"}],
|
||
"trends": {"budget_monthly_ocf": 16.67, "receivables_trend_pct": -37.81},
|
||
"summary": {"overdue_receive_count": 18, "overdue_receive_amount": 71.68},
|
||
}
|
||
|
||
|
||
def selftest() -> int:
|
||
cases = [
|
||
("good_basic",
|
||
"原因:现金余额-9.3万元,2026-10-18 降至最低-8.74万元,低于警戒线20.0万元,缺口28.74万元;"
|
||
"逾期应收18笔共71.68万元。建议:优先催收逾期应收,暂停非必要支出。",
|
||
True),
|
||
("good_sign_abs",
|
||
"原因:应收趋势下降37.81%,月度经营现金流预算16.67万元/月,缺口1天。"
|
||
"建议:跟进2026-10-18 起的资金缺口。",
|
||
True),
|
||
("bad_rounding",
|
||
"原因:现金余额约-9万元,最低-8.7万元。建议:催收应收。",
|
||
False),
|
||
("bad_derived",
|
||
"原因:现金余额-9.3万元,缺口28.74万元,约为逾期应收的40%。建议:催收。",
|
||
False),
|
||
("bad_new_count",
|
||
"原因:现金余额-9.3万元。建议:优先催收TOP3客户。",
|
||
False),
|
||
("bad_fake_date",
|
||
"原因:现金余额-9.3万元,预计2026-11-01 断流。建议:催收。",
|
||
False),
|
||
]
|
||
failures = 0
|
||
print("== G2 数字守恒断言自测 ==")
|
||
for name, text, expect in cases:
|
||
res = assert_number_conservation(text, SELFTEST_PAYLOAD)
|
||
verdict = "PASS" if res["pass"] == expect else "FAIL"
|
||
if verdict == "FAIL":
|
||
failures += 1
|
||
print(" %-5s %-16s expect_pass=%-5s got_pass=%-5s value_violations=%s date_violations=%s"
|
||
% (verdict, name, expect, res["pass"],
|
||
[v["token"] for v in res["value_violations"]], res["date_violations"]))
|
||
total = len(cases)
|
||
print("RESULT: %s %d/%d" % ("PASS" if failures == 0 else "FAIL", total - failures, total))
|
||
return 0 if failures == 0 else 1
|
||
|
||
|
||
def main() -> int:
|
||
ap = argparse.ArgumentParser(description="资金缺口讲人话(数字守恒断言)")
|
||
ap.add_argument("--entity", type=int, default=1)
|
||
ap.add_argument("--days", type=int, default=30)
|
||
ap.add_argument("--payload-json", help="已有规则层 payload(跳过取数)")
|
||
ap.add_argument("--out", help="输出 JSON 路径")
|
||
ap.add_argument("--selftest", action="store_true", help="只跑断言自测(离线)")
|
||
args = ap.parse_args()
|
||
|
||
if args.selftest:
|
||
return selftest()
|
||
|
||
payload = None
|
||
if args.payload_json:
|
||
payload = json.load(open(args.payload_json, encoding="utf-8"))
|
||
|
||
result = asyncio.run(generate(args.entity, args.days, payload))
|
||
out = args.out or os.path.join(
|
||
"/root/.hermes/profiles/wecom-fullstack/pending-tasks/_evidence",
|
||
"fullstack-20261017-warning-capability-build-p0",
|
||
"g2-narrative-%s-e%d.json" % (datetime.now().strftime("%Y%m%d-%H%M%S"), args.entity))
|
||
os.makedirs(os.path.dirname(out), exist_ok=True)
|
||
with open(out, "w", encoding="utf-8") as fh:
|
||
json.dump(result, fh, ensure_ascii=False, indent=2)
|
||
|
||
print(json.dumps({k: result[k] for k in
|
||
("pass", "attempts", "assertion", "missing_sections", "rule_layer")},
|
||
ensure_ascii=False, indent=2))
|
||
if result["narrative"]:
|
||
print("原因:" + result["narrative"]["reason"])
|
||
print("建议:" + result["narrative"]["advice"])
|
||
print("OUT:", out)
|
||
return 0 if result["pass"] else 1
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|