@dsh-gis/dsh-video-studio-skill 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -12
- package/docs/styles/demo-results.json +76 -0
- package/docs/styles/sheet-8-styles.png +0 -0
- package/docs/styles/sheet-ai.png +0 -0
- package/docs/styles/sheet-craft.png +0 -0
- package/docs/styles/sheet-night.png +0 -0
- package/package.json +11 -3
- package/schemas/film.schema.json +528 -0
- package/skills/video-studio/SKILL.md +166 -14
- package/skills/video-studio/assets/film.json +84 -23
- package/skills/video-studio/assets/scenes.py +892 -34
- package/skills/video-studio/references/audio.md +29 -1
- package/skills/video-studio/references/change-impact.md +54 -0
- package/skills/video-studio/references/quality-gates.md +1 -1
- package/skills/video-studio/references/style-packs.md +178 -0
- package/skills/video-studio/requirements.txt +11 -0
- package/skills/video-studio/scripts/filmcheck.py +656 -0
- package/skills/video-studio/scripts/filmkit.py +682 -58
- package/skills/video-studio/scripts/gates.py +446 -0
- package/skills/video-studio/scripts/render.py +562 -34
- package/skills/video-studio/scripts/scorekit.py +1642 -25
- package/skills/video-studio/scripts/selfcheck.py +346 -0
- package/skills/video-studio/scripts/stagewatch.py +295 -0
- package/skills/video-studio/scripts/stylekit.py +924 -0
- package/skills/video-studio/scripts/voicekit.py +872 -0
- package/tools/ci_check.py +339 -0
- package/tools/estimate.py +578 -0
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""gates.py — 门禁账本 / 机读交付报告 / 人工签核(v0.6.0)。
|
|
3
|
+
|
|
4
|
+
解决三件事(都是"文档承诺了、代码没做"):
|
|
5
|
+
|
|
6
|
+
1) **门禁账本**:每个 stage 把判据结果记成一条 {id, title, status, detail},
|
|
7
|
+
status 只允许 pass / fail / skipped 三档。"没执行"必须独立成 skipped,
|
|
8
|
+
不许混进 pass——否则"看起来干净、其实没在查"会一路带进交付。
|
|
9
|
+
2) **机读交付报告** out/QC-REPORT.md:把账本渲染成 gate matrix + 实测值 +
|
|
10
|
+
人工签核 + 耗时台账。在此之前,质检结论只活在 stdout 里,会话一断就没了。
|
|
11
|
+
3) **人工签核** out/signoff.json:风格/母版/事实/交付四道"必须由人看"的闸。
|
|
12
|
+
签核绑 contentHash(剧本 + scenes.py + 引擎),内容一变旧签核自动失效。
|
|
13
|
+
|
|
14
|
+
设计约束:
|
|
15
|
+
* 纯标准库(hashlib/json/time/os),不引第三方。
|
|
16
|
+
* 账本与签核写 work/ 下,报告写 out/——渲染产物可整目录搬走。
|
|
17
|
+
* 不改变既有退出码语义:默认 skipped 只告警,--strict-gates 才升级为 error
|
|
18
|
+
(与 QA_REQUIRE_SYNC 同一套语义)。
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import hashlib
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import time
|
|
26
|
+
|
|
27
|
+
SCHEMA = 1
|
|
28
|
+
|
|
29
|
+
# 人工签核的四道闸:id -> 人话标题
|
|
30
|
+
GATES = {
|
|
31
|
+
"style": "风格与声线已确认(看 work/style.json / 试听声线)",
|
|
32
|
+
"stills": "母版静帧已亲眼看过(work/qa/sheet.png)",
|
|
33
|
+
"facts": "上屏数字已核对出处(FACTS.md)",
|
|
34
|
+
"release": "成片已验收(音画 / 字幕 / 响度)",
|
|
35
|
+
}
|
|
36
|
+
GATE_ORDER = ("style", "stills", "facts", "release")
|
|
37
|
+
|
|
38
|
+
OK, FAIL, SKIP = "pass", "fail", "skipped"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _sha1_text(s):
|
|
42
|
+
return hashlib.sha1(str(s).encode("utf-8")).hexdigest()[:16]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _sha1_file(path):
|
|
46
|
+
"""文件内容指纹;缺文件返回 'missing',不抛异常。"""
|
|
47
|
+
try:
|
|
48
|
+
h = hashlib.sha1()
|
|
49
|
+
with open(str(path), "rb") as fh:
|
|
50
|
+
for blk in iter(lambda: fh.read(1 << 20), b""):
|
|
51
|
+
h.update(blk)
|
|
52
|
+
return h.hexdigest()[:16]
|
|
53
|
+
except OSError:
|
|
54
|
+
return "missing"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def content_hash(script=None, scenes_path="", script_path="", engine="pil"):
|
|
58
|
+
"""本次渲染的"内容身份":剧本 + 场景代码 + 引擎。
|
|
59
|
+
|
|
60
|
+
只取与"这一版画面/声音"相关的量:
|
|
61
|
+
* 剧本:去掉渲染期注入的 _path/_dir/_style 等私有键后规范化 json;
|
|
62
|
+
* scenes.py:文件内容(画面逻辑变了,旧签核就得失效);
|
|
63
|
+
* engine:pil 与 web 是两条渲染路径,产物不可互换。
|
|
64
|
+
"""
|
|
65
|
+
body = {}
|
|
66
|
+
if isinstance(script, dict):
|
|
67
|
+
for k, v in script.items():
|
|
68
|
+
if str(k).startswith("_"):
|
|
69
|
+
continue
|
|
70
|
+
body[k] = v
|
|
71
|
+
payload = {
|
|
72
|
+
"script": _sha1_text(json.dumps(body, sort_keys=True, ensure_ascii=False, default=str)),
|
|
73
|
+
"script_file": _sha1_file(script_path) if script_path else "",
|
|
74
|
+
"scenes": _sha1_file(scenes_path) if scenes_path else "",
|
|
75
|
+
"engine": str(engine or "pil"),
|
|
76
|
+
}
|
|
77
|
+
return _sha1_text(json.dumps(payload, sort_keys=True))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class GateLedger:
|
|
81
|
+
"""一次运行内的门禁账本(多 stage 共用;--stage all 时是同一个对象)。"""
|
|
82
|
+
|
|
83
|
+
def __init__(self, work, script=None, scenes_path="", script_path="", engine="pil"):
|
|
84
|
+
self.work = str(work or "work")
|
|
85
|
+
self.items = []
|
|
86
|
+
self.t0 = time.time()
|
|
87
|
+
self.script = script or {}
|
|
88
|
+
self.scenes_path = str(scenes_path or "")
|
|
89
|
+
self.script_path = str(script_path or "")
|
|
90
|
+
self.engine = str(engine or "pil")
|
|
91
|
+
self.content_hash = content_hash(script, self.scenes_path, self.script_path, self.engine)
|
|
92
|
+
self.bypassed = [] # --no-gate 绕过的记录(必须留痕)
|
|
93
|
+
|
|
94
|
+
# ---- 记账 ----
|
|
95
|
+
def add(self, gid, title, status, detail="", at=None):
|
|
96
|
+
"""记一条门禁。同一个 gid 重复记时**后写覆盖**(stage 可重跑)。"""
|
|
97
|
+
if status not in (OK, FAIL, SKIP):
|
|
98
|
+
raise ValueError("门禁状态只能是 pass/fail/skipped,收到 %r" % (status,))
|
|
99
|
+
item = dict(id=str(gid), title=str(title), status=status,
|
|
100
|
+
detail=str(detail or ""), at=float(at if at is not None else time.time()))
|
|
101
|
+
for i, old in enumerate(self.items):
|
|
102
|
+
if old["id"] == item["id"]:
|
|
103
|
+
self.items[i] = item
|
|
104
|
+
return item
|
|
105
|
+
self.items.append(item)
|
|
106
|
+
return item
|
|
107
|
+
|
|
108
|
+
def ok(self, gid, title, detail=""):
|
|
109
|
+
return self.add(gid, title, OK, detail)
|
|
110
|
+
|
|
111
|
+
def fail(self, gid, title, detail=""):
|
|
112
|
+
return self.add(gid, title, FAIL, detail)
|
|
113
|
+
|
|
114
|
+
def skip(self, gid, title, detail=""):
|
|
115
|
+
return self.add(gid, title, SKIP, detail)
|
|
116
|
+
|
|
117
|
+
def bypass(self, what, why=""):
|
|
118
|
+
self.bypassed.append(dict(what=str(what), why=str(why or ""), at=time.time()))
|
|
119
|
+
|
|
120
|
+
# ---- 判定 ----
|
|
121
|
+
def fails(self):
|
|
122
|
+
return [x for x in self.items if x["status"] == FAIL]
|
|
123
|
+
|
|
124
|
+
def skips(self):
|
|
125
|
+
return [x for x in self.items if x["status"] == SKIP]
|
|
126
|
+
|
|
127
|
+
def counts(self):
|
|
128
|
+
c = {OK: 0, FAIL: 0, SKIP: 0}
|
|
129
|
+
for x in self.items:
|
|
130
|
+
c[x["status"]] = c.get(x["status"], 0) + 1
|
|
131
|
+
return c
|
|
132
|
+
|
|
133
|
+
def exit_errors(self, strict=False):
|
|
134
|
+
"""该让命令失败吗:任何 fail 都失败;--strict-gates 下 skipped 也算失败。"""
|
|
135
|
+
errs = ["[%s] %s %s" % (x["id"], x["title"],
|
|
136
|
+
("— " + x["detail"]) if x["detail"] else "")
|
|
137
|
+
for x in self.items if x["status"] == FAIL]
|
|
138
|
+
if strict:
|
|
139
|
+
errs += ["[%s] %s:本次**没有执行**(--strict-gates 下算失败)"
|
|
140
|
+
% (x["id"], x["title"]) for x in self.items if x["status"] == SKIP]
|
|
141
|
+
return errs
|
|
142
|
+
|
|
143
|
+
# ---- 落盘 ----
|
|
144
|
+
def to_dict(self):
|
|
145
|
+
return dict(schema=SCHEMA, at=self.t0, contentHash=self.content_hash,
|
|
146
|
+
engine=self.engine, counts=self.counts(),
|
|
147
|
+
items=list(self.items), bypassed=list(self.bypassed))
|
|
148
|
+
|
|
149
|
+
def write_json(self):
|
|
150
|
+
"""work/gates.json —— 给程序读(QC-REPORT.md 只是它的渲染层)。"""
|
|
151
|
+
os.makedirs(self.work, exist_ok=True)
|
|
152
|
+
p = os.path.join(self.work, "gates.json")
|
|
153
|
+
d = self.to_dict()
|
|
154
|
+
d["wall_s"] = round(time.time() - self.t0, 2)
|
|
155
|
+
_atomic_json(p, d)
|
|
156
|
+
return p
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ---------------------------------------------------------------- 签核账本
|
|
160
|
+
def signoff_path(work):
|
|
161
|
+
return os.path.join(str(work), "signoff.json")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def read_signoff(work):
|
|
165
|
+
"""读签核账本;坏 JSON 当作"没有签核",但把原因带出来(不静默吞掉)。"""
|
|
166
|
+
p = signoff_path(work)
|
|
167
|
+
if not os.path.exists(p):
|
|
168
|
+
return {"schema": SCHEMA, "signed": {}}, "没有 %s(还没人签核过)" % p
|
|
169
|
+
try:
|
|
170
|
+
d = json.load(open(p, encoding="utf-8"))
|
|
171
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
172
|
+
return {"schema": SCHEMA, "signed": {}}, "%s 读不了(%s)——当作没有签核" % (p, e)
|
|
173
|
+
if not isinstance(d, dict) or not isinstance(d.get("signed"), dict):
|
|
174
|
+
return {"schema": SCHEMA, "signed": {}}, "%s 结构不对——当作没有签核" % p
|
|
175
|
+
return d, ""
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def write_signoff(work, gid, content_hash, verdict="approved", by="", note="", artifacts=None):
|
|
179
|
+
"""写一条人工签核。verdict=approved / rejected;rejected 视为没签。"""
|
|
180
|
+
if str(gid) not in GATES:
|
|
181
|
+
raise ValueError("未知的门禁 id %r(可选:%s)" % (gid, " / ".join(GATE_ORDER)))
|
|
182
|
+
d, _why = read_signoff(work)
|
|
183
|
+
d.setdefault("schema", SCHEMA)
|
|
184
|
+
d.setdefault("signed", {})
|
|
185
|
+
d["signed"][str(gid)] = dict(
|
|
186
|
+
verdict=str(verdict), at=time.time(),
|
|
187
|
+
atText=time.strftime("%Y-%m-%d %H:%M:%S"), by=str(by or ""),
|
|
188
|
+
contentHash=str(content_hash or ""), note=str(note or ""),
|
|
189
|
+
artifacts=[str(x) for x in (artifacts or [])],
|
|
190
|
+
)
|
|
191
|
+
os.makedirs(str(work), exist_ok=True)
|
|
192
|
+
_atomic_json(signoff_path(work), d)
|
|
193
|
+
return d["signed"][str(gid)]
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def signoff_state(work, current_hash):
|
|
197
|
+
"""返回 (每道闸的判定, 因内容变化而失效的闸列表)。
|
|
198
|
+
|
|
199
|
+
判定只有三档:pass(签了且内容一致)/ fail(明确 rejected)/
|
|
200
|
+
skipped(没签过,或内容已变——"这一版还没签",不是"签坏了")。
|
|
201
|
+
"""
|
|
202
|
+
d, why = read_signoff(work)
|
|
203
|
+
signed = (d.get("signed") or {}) if isinstance(d, dict) else {}
|
|
204
|
+
state, stale = {}, []
|
|
205
|
+
for gid in GATE_ORDER:
|
|
206
|
+
rec = signed.get(gid)
|
|
207
|
+
if not isinstance(rec, dict):
|
|
208
|
+
state[gid] = dict(status=SKIP, detail=why or "没签核过")
|
|
209
|
+
continue
|
|
210
|
+
if str(rec.get("verdict")) != "approved":
|
|
211
|
+
state[gid] = dict(status=FAIL, detail="签核结论是 %r" % rec.get("verdict"))
|
|
212
|
+
continue
|
|
213
|
+
if str(rec.get("contentHash") or "") != str(current_hash or ""):
|
|
214
|
+
state[gid] = dict(status=SKIP,
|
|
215
|
+
detail="内容已变(签核时 %s / 现在 %s),需重新签"
|
|
216
|
+
% (str(rec.get("contentHash"))[:8], str(current_hash)[:8]))
|
|
217
|
+
stale.append(gid)
|
|
218
|
+
continue
|
|
219
|
+
bits = [str(rec.get("atText") or "")]
|
|
220
|
+
if rec.get("by"):
|
|
221
|
+
bits.append(str(rec["by"]))
|
|
222
|
+
if rec.get("note"):
|
|
223
|
+
bits.append(str(rec["note"]))
|
|
224
|
+
state[gid] = dict(status=OK, detail=" 已签(%s)" % " · ".join(bits))
|
|
225
|
+
return state, stale
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# ---------------------------------------------------------------- 耗时台账
|
|
229
|
+
def append_cost(work, stage, wall_s, **extra):
|
|
230
|
+
"""work/cost.jsonl —— 每 stage 一行实测(排期公式从没被验证过,这是它的对照)。"""
|
|
231
|
+
os.makedirs(str(work), exist_ok=True)
|
|
232
|
+
row = dict(at=time.time(), atText=time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
233
|
+
stage=str(stage), wall_s=round(float(wall_s), 3))
|
|
234
|
+
for k, v in extra.items():
|
|
235
|
+
if v is not None:
|
|
236
|
+
row[k] = v
|
|
237
|
+
p = os.path.join(str(work), "cost.jsonl")
|
|
238
|
+
try:
|
|
239
|
+
with open(p, "a", encoding="utf-8") as fh:
|
|
240
|
+
fh.write(json.dumps(row, ensure_ascii=False) + "\n")
|
|
241
|
+
except OSError:
|
|
242
|
+
pass
|
|
243
|
+
return p
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def read_cost(work):
|
|
247
|
+
p = os.path.join(str(work), "cost.jsonl")
|
|
248
|
+
rows = []
|
|
249
|
+
try:
|
|
250
|
+
with open(p, encoding="utf-8") as fh:
|
|
251
|
+
for line in fh:
|
|
252
|
+
line = line.strip()
|
|
253
|
+
if not line:
|
|
254
|
+
continue
|
|
255
|
+
try:
|
|
256
|
+
rows.append(json.loads(line))
|
|
257
|
+
except json.JSONDecodeError:
|
|
258
|
+
continue
|
|
259
|
+
except OSError:
|
|
260
|
+
return []
|
|
261
|
+
return rows
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# ---------------------------------------------------------------- 报告
|
|
265
|
+
def _fmt_s(sec):
|
|
266
|
+
try:
|
|
267
|
+
sec = float(sec)
|
|
268
|
+
except (TypeError, ValueError):
|
|
269
|
+
return "-"
|
|
270
|
+
if sec < 60:
|
|
271
|
+
return "%.1fs" % sec
|
|
272
|
+
m, s = divmod(sec, 60)
|
|
273
|
+
if m < 60:
|
|
274
|
+
return "%dm%02ds" % (int(m), int(s))
|
|
275
|
+
h, m = divmod(m, 60)
|
|
276
|
+
return "%dh%02dm" % (int(h), int(m))
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def render_report(work, out=None):
|
|
280
|
+
"""把 work/gates.json + signoff + cost.jsonl 渲染成 out/QC-REPORT.md。"""
|
|
281
|
+
gj = os.path.join(str(work), "gates.json")
|
|
282
|
+
g = {}
|
|
283
|
+
if os.path.exists(gj):
|
|
284
|
+
try:
|
|
285
|
+
g = json.load(open(gj, encoding="utf-8"))
|
|
286
|
+
except (json.JSONDecodeError, OSError):
|
|
287
|
+
g = {}
|
|
288
|
+
ch = str(g.get("contentHash") or "")
|
|
289
|
+
items = list(g.get("items") or [])
|
|
290
|
+
counts = {OK: 0, FAIL: 0, SKIP: 0}
|
|
291
|
+
for x in items:
|
|
292
|
+
st = x.get("status", SKIP)
|
|
293
|
+
counts[st] = counts.get(st, 0) + 1
|
|
294
|
+
state, stale = signoff_state(work, ch)
|
|
295
|
+
signed_n = sum(1 for v in state.values() if v["status"] == OK)
|
|
296
|
+
|
|
297
|
+
L = []
|
|
298
|
+
L.append("# QC 报告(成片交付凭据)")
|
|
299
|
+
L.append("")
|
|
300
|
+
L.append("> 本文件由 `render.py --stage qa` 自动生成,是**机器可读的交付结论**。")
|
|
301
|
+
L.append("> 口径:`pass` = 本次真的查过且通过;`fail` = 查了没过;"
|
|
302
|
+
"`skipped` = **本次没执行**(不等于通过)。")
|
|
303
|
+
L.append("")
|
|
304
|
+
L.append("| 项 | 值 |")
|
|
305
|
+
L.append("|---|---|")
|
|
306
|
+
L.append("| 内容指纹 | `%s` |" % (ch or "未生成"))
|
|
307
|
+
L.append("| 引擎 | `%s` |" % (g.get("engine") or "-"))
|
|
308
|
+
L.append("| 生成时间 | %s |"
|
|
309
|
+
% time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(g.get("at") or time.time())))
|
|
310
|
+
L.append("| 门禁合计 | pass %d · fail %d · skipped %d |"
|
|
311
|
+
% (counts.get(OK, 0), counts.get(FAIL, 0), counts.get(SKIP, 0)))
|
|
312
|
+
L.append("| 人工签核 | %d/%d 有效 |" % (signed_n, len(GATE_ORDER)))
|
|
313
|
+
L.append("")
|
|
314
|
+
L.append("## 一、门禁矩阵")
|
|
315
|
+
L.append("")
|
|
316
|
+
if items:
|
|
317
|
+
L.append("| 门禁 | 判据 | 结果 | 实测 / 说明 |")
|
|
318
|
+
L.append("|---|---|---|---|")
|
|
319
|
+
for x in items:
|
|
320
|
+
L.append("| `%s` | %s | **%s** | %s |"
|
|
321
|
+
% (x.get("id", "?"), x.get("title", ""), x.get("status", "?"),
|
|
322
|
+
(x.get("detail") or "-").replace("|", "\\|")))
|
|
323
|
+
else:
|
|
324
|
+
L.append("(没有门禁记录:先跑 `--stage qa`)")
|
|
325
|
+
L.append("")
|
|
326
|
+
L.append("## 二、人工签核")
|
|
327
|
+
L.append("")
|
|
328
|
+
L.append("| 闸 | 要求 | 结果 | 说明 |")
|
|
329
|
+
L.append("|---|---|---|---|")
|
|
330
|
+
for gid in GATE_ORDER:
|
|
331
|
+
v = state.get(gid, {})
|
|
332
|
+
L.append("| `%s` | %s | **%s** | %s |"
|
|
333
|
+
% (gid, GATES[gid], v.get("status", "?"),
|
|
334
|
+
(v.get("detail") or "-").replace("|", "\\|")))
|
|
335
|
+
if stale:
|
|
336
|
+
L.append("")
|
|
337
|
+
L.append("> 内容已变的签核:%s(改了剧本 / scenes.py / 引擎,旧签核自动失效,需重新签)"
|
|
338
|
+
% "、".join("`%s`" % x for x in stale))
|
|
339
|
+
L.append("")
|
|
340
|
+
L.append("## 三、耗时台账(`work/cost.jsonl` 实测,非估算)")
|
|
341
|
+
L.append("")
|
|
342
|
+
rows = read_cost(work)
|
|
343
|
+
if rows:
|
|
344
|
+
L.append("| stage | 墙钟 | 备注 |")
|
|
345
|
+
L.append("|---|---|---|")
|
|
346
|
+
total = 0.0
|
|
347
|
+
for r in rows:
|
|
348
|
+
total += float(r.get("wall_s") or 0)
|
|
349
|
+
note = " · ".join("%s=%s" % (k, r[k])
|
|
350
|
+
for k in ("frames", "n_tts", "n_cues", "rework")
|
|
351
|
+
if r.get(k) not in (None, ""))
|
|
352
|
+
L.append("| `%s` | %s | %s |"
|
|
353
|
+
% (r.get("stage", "?"), _fmt_s(r.get("wall_s")), note or "-"))
|
|
354
|
+
L.append("| **合计** | **%s** | |" % _fmt_s(total))
|
|
355
|
+
else:
|
|
356
|
+
L.append("(还没有台账:首次运行或未接入 cost.jsonl)")
|
|
357
|
+
L.append("")
|
|
358
|
+
if g.get("bypassed"):
|
|
359
|
+
L.append("## 四、本次绕过的门禁(留痕,不是通过)")
|
|
360
|
+
L.append("")
|
|
361
|
+
for b in g["bypassed"]:
|
|
362
|
+
L.append("- %s%s" % (b.get("what"), (":" + b["why"]) if b.get("why") else ""))
|
|
363
|
+
L.append("")
|
|
364
|
+
nxt = "五"
|
|
365
|
+
else:
|
|
366
|
+
nxt = "四"
|
|
367
|
+
L.append("## %s、结论(照抄,别自己发挥)" % nxt)
|
|
368
|
+
L.append("")
|
|
369
|
+
if counts.get(FAIL):
|
|
370
|
+
L.append("- **未通过**:fail %d 项,按上表逐条修,别宣称完成。" % counts.get(FAIL))
|
|
371
|
+
elif counts.get(SKIP):
|
|
372
|
+
L.append("- **有条件通过**:skipped %d 项(明确没执行),交付时必须逐项说明原因。"
|
|
373
|
+
% counts.get(SKIP))
|
|
374
|
+
else:
|
|
375
|
+
L.append("- **通过**:所有门禁本次都执行且通过。")
|
|
376
|
+
L.append("- 报告:`%s`;机器可读源:`%s`。"
|
|
377
|
+
% (out or os.path.join("out", "QC-REPORT.md"), gj))
|
|
378
|
+
L.append("")
|
|
379
|
+
return "\n".join(L)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def write_report(work, out_path=None):
|
|
383
|
+
p = str(out_path or os.path.join(os.path.dirname(str(work)) or ".", "out", "QC-REPORT.md"))
|
|
384
|
+
d = os.path.dirname(os.path.abspath(p))
|
|
385
|
+
if d:
|
|
386
|
+
os.makedirs(d, exist_ok=True)
|
|
387
|
+
text = render_report(work, out=p)
|
|
388
|
+
with open(p, "w", encoding="utf-8") as fh:
|
|
389
|
+
fh.write(text)
|
|
390
|
+
return p
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
# ---------------------------------------------------------------- 原子写
|
|
394
|
+
def atomic_write_json(path, obj):
|
|
395
|
+
"""先写 .tmp 再 os.replace:断电 / 被杀不会留下半截 JSON。"""
|
|
396
|
+
d = os.path.dirname(os.path.abspath(str(path)))
|
|
397
|
+
if d:
|
|
398
|
+
os.makedirs(d, exist_ok=True)
|
|
399
|
+
tmp = "%s.%d.tmp" % (str(path), os.getpid())
|
|
400
|
+
with open(tmp, "w", encoding="utf-8") as fh:
|
|
401
|
+
json.dump(obj, fh, ensure_ascii=False, indent=1)
|
|
402
|
+
os.replace(tmp, str(path))
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _atomic_json(path, obj):
|
|
406
|
+
return atomic_write_json(path, obj)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
if __name__ == "__main__":
|
|
410
|
+
import argparse
|
|
411
|
+
import sys
|
|
412
|
+
ap = argparse.ArgumentParser(description="门禁账本 / 签核 / QC 报告")
|
|
413
|
+
ap.add_argument("--work", default="work")
|
|
414
|
+
ap.add_argument("--out", default="")
|
|
415
|
+
ap.add_argument("--accept", default="",
|
|
416
|
+
help="签核一道闸:%s" % " / ".join(GATE_ORDER))
|
|
417
|
+
ap.add_argument("--reject", default="", help="否决一道闸(同样记一条,但不算通过)")
|
|
418
|
+
ap.add_argument("--note", default="")
|
|
419
|
+
ap.add_argument("--by", default="")
|
|
420
|
+
ap.add_argument("--artifacts", default="", help="逗号分隔的证据文件路径")
|
|
421
|
+
ap.add_argument("--json", action="store_true", help="打印账本 JSON")
|
|
422
|
+
a = ap.parse_args()
|
|
423
|
+
try:
|
|
424
|
+
if a.accept or a.reject:
|
|
425
|
+
gid = (a.accept or a.reject).strip()
|
|
426
|
+
d, _why = read_signoff(a.work)
|
|
427
|
+
ch = ""
|
|
428
|
+
gj = os.path.join(a.work, "gates.json")
|
|
429
|
+
if os.path.exists(gj):
|
|
430
|
+
try:
|
|
431
|
+
ch = str(json.load(open(gj, encoding="utf-8")).get("contentHash") or "")
|
|
432
|
+
except (json.JSONDecodeError, OSError):
|
|
433
|
+
ch = ""
|
|
434
|
+
rec = write_signoff(a.work, gid, ch,
|
|
435
|
+
verdict="approved" if a.accept else "rejected",
|
|
436
|
+
by=a.by, note=a.note,
|
|
437
|
+
artifacts=[x.strip() for x in a.artifacts.split(",") if x.strip()])
|
|
438
|
+
print("[签核] %s = %s(内容指纹 %s)" % (gid, rec["verdict"], ch or "未知"))
|
|
439
|
+
p = write_report(a.work, a.out or None)
|
|
440
|
+
print("[QC 报告]", p)
|
|
441
|
+
if a.json:
|
|
442
|
+
print(json.dumps(GateLedger(a.work).to_dict(), ensure_ascii=False, indent=1))
|
|
443
|
+
sys.exit(0)
|
|
444
|
+
except (ValueError, OSError) as e:
|
|
445
|
+
print("[ERROR] %s" % e, file=sys.stderr)
|
|
446
|
+
sys.exit(2)
|