作业6:搭建「自动周报生成器」(AIOps 最小闭环 · 续)
作业目标:Python 脚本自动拉取 Langfuse 最近 7 天 Token、GPU 利用率,用 Qwen 生成自然语言周报,并推送到 钉钉群。
验收标准:在 207 执行脚本 → 生成 Markdown 周报文件 → 钉钉群收到消息(
errcode=0)。环境:
192.168.110.207(Langfuse + Dify)+116.62.10.183(vLLM / GPU H20)。HTML 成品:
docs/html_cursor/作业6_自动周报生成器_cursor.html
运行环境:程序与文档在 Windows 与 207 完全同步;只在 207 执行,Python 为
/usr/bin/python3。
目录
做完长什么样
| 项 | 说明 |
|---|---|
| Langfuse | /api/public/metrics/daily?limit=7 汇总 Token / 成本 |
| GPU | 优先 Prometheus query_range;本环境降级为 SSH nvidia-smi + 本地 7 日滚动样本 |
| 大模型 | qwen3.6-27b @ 116.62.10.183:8000/v1 生成周报正文 |
| 钉钉 | 自定义机器人 Webhook(Markdown 消息) |
| 产物目录 | /opt/workbuddy/data/homework6/weekly_report_*.md |
第一部分 原理
作业6_01_生成并推送周报.py
├─ Langfuse metrics/daily → 7 日 Token / traces / cost
├─ Prometheus query_range → GPU 平均利用率(可选)
│ └─ 无 Prometheus 时:SSH nvidia-smi 采样 → gpu_daily.json
├─ Qwen chat/completions → 自然语言周报 Markdown
└─ POST 钉钉 robot/send → 群消息Langfuse 公共 API 用 Basic Auth(LANGFUSE_PK / LANGFUSE_SK)。
钉钉机器人要求 POST JSON(GET 会返回 43002 需要POST请求)。
第二部分 前置条件
| # | 条件 | 检查 |
|---|---|---|
| 1 | Langfuse 可用 | curl -u pk:sk http://127.0.0.1:3000/api/public/health |
| 2 | 207 能 SSH GPU 机 | 116.62.10.183 |
| 3 | vLLM 可用 | curl -H "Authorization: Bearer 123456" http://116.62.10.183:8000/v1/models |
| 4 | 钉钉 Webhook 已配置 | _config.py 的 HOMEWORK6_DINGTALK_WEBHOOK |
| 5 | /opt/workbuddy 已同步 | python scripts_cursor/00_部署_workbuddy到207.py |
第三部分 详细操作步骤
学习建议:本作业 完全不经过 Dify,是纯 Python 流水线。先读 3.0 全局图景,再按 00→01→02 跑通。每个脚本含四要素说明。
3.0 全局图景:一条流水线,四个数据源
| 阶段 | 脚本 | 频率 | 目的 |
|---|---|---|---|
| 自检 | 00 | 改代码后 | 本地解析函数单测,不连外网 |
| 生产 | 01 | 每周或 cron | 拉数据 → Qwen 写周报 → 推钉钉 |
| 验收 | 02 | 部署后 | 分步验证 Langfuse/GPU/Qwen/钉钉 |
| 定时 | cron + shell | 可选 | 每周一自动跑 01 |
数据流(理解这个就懂整个作业):
Langfuse API(207:3000)
└─ 近 7 日 Token / 费用 / 模型分布
GPU SSH(116.62.10.183)
└─ nvidia-smi 采样 + 可选 Prometheus 7 日均值
│
▼
build_facts() 拼结构化事实
│
▼
Qwen(116:8000)生成 Markdown 周报正文
│
▼
钉钉 Webhook(正文必须含关键词「周报」)与作业 7 的区别:作业 6 无 Agent、无 OpenAPI;作业 7 才需要 Dify 调工具。
3.1 一键顺序(207)
/usr/bin/python3 /opt/workbuddy/scripts_cursor/作业6_自动周报生成器/作业6_00_解析自测.py
/usr/bin/python3 /opt/workbuddy/scripts_cursor/作业6_自动周报生成器/作业6_01_生成并推送周报.py只生成、不推送钉钉:
/usr/bin/python3 /opt/workbuddy/scripts_cursor/作业6_自动周报生成器/作业6_01_生成并推送周报.py --dry-run分步验收(推荐首次部署时跑):
/usr/bin/python3 /opt/workbuddy/scripts_cursor/作业6_自动周报生成器/作业6_02_验收周报.py3.1.1 作业6_00_解析自测.py — 本地解析单测
| 项 | 说明 |
|---|---|
| 干什么 | 用假 JSON / 假 nvidia-smi 行验证 parse_langfuse_daily、parse_nvidia_sample、Prometheus 解析、GPU 历史 rollup |
| 触发流程 | 纯内存 assert,不写文件、不调 HTTP |
| 成功标志 | [PASS] 作业6 解析自测 |
| 典型失败 | AssertionError → 作业6_weekly_report.py 被改坏 |
3.1.2 作业6_01_生成并推送周报.py — 完整周报流水线
| 项 | 说明 |
|---|---|
| 干什么 | 调用 run_weekly():Langfuse → GPU → Qwen 总结 → 钉钉(或 --dry-run 只落盘) |
| 触发流程 | 见 3.0 数据流;报告写入 data/homework6/weekly_report_*.md |
| 成功标志 | [PASS] 周报已推送钉钉 或 dry-run 时 [OK] dry-run 完成,报告已写入 ... |
| 典型失败 | Langfuse 401 → pk/sk 错或 URL 错 |
GPU SSH 失败 → GPU_HOST / 密码 | |
| Qwen 超时 → vLLM 未起或模型名错 | |
| 钉钉 310000 → 正文未含关键词「周报」(不能只放 title) | |
[FAIL] 钉钉返回 errcode → Webhook 被限流或关键词不匹配 |
3.1.3 作业6_02_验收周报.py — 分步硬验收
| 项 | 说明 |
|---|---|
| 干什么 | 不经过 run_weekly 封装,逐步调用 fetch_langfuse_tokens → fetch_gpu_weekly → qwen_weekly_report → send_dingtalk_markdown,任一步失败即退出 |
| 触发流程 | 四步串行,便于定位哪一环坏了 |
| 成功标志 | [PASS] Langfuse=... tokens, GPU avg=...%, 钉钉 errcode=0 |
| 典型失败 | [FAIL] Langfuse 无 daily 数据 → 7 日内无 trace,先跑几次 Agent 对话 |
[FAIL] GPU 采样失败 → SSH 或 nvidia-smi | |
[FAIL] Qwen 周报过短 → max_tokens 太小或模型空回复 |
3.2 挂 cron(可选)
# 每周一 09:00
0 9 * * 1 bash /opt/workbuddy/scripts_bash_cursor/作业6_自动周报生成器/作业6_运行周报.sh| 项 | 说明 |
|---|---|
| 干什么 | 定时执行 01,无需人工 |
| 注意 | cron 环境变量少,shell 脚本内应 cd /opt/workbuddy 并用绝对路径 python3 |
| 典型失败 | 邮件里无输出但钉钉没收到 → 看 /tmp/homework6_cron.log(若配置了) |
3.3 常见问题速查
| 症状 | 原因 | 处理 |
|---|---|---|
| Token 统计为 0 | Langfuse 无数据 | 确认作业 3 tracing 开启且有对话 |
| GPU 利用率 N/A | 未 SSH 到 GPU | 检查 _config.py GPU 凭据 |
| 周报无 Prometheus 段 | 未配 HOMEWORK6_PROMETHEUS_URL | 可选;不配则只用 nvidia-smi 快照 |
| dry-run 正常、推送失败 | 钉钉关键词 | 正文首段加「周报」 |
第四部分 CPU 与 GPU
| 组件 | 机器 | 说明 |
|---|---|---|
| Langfuse / 脚本 | 207 | 拉 Token、调 Qwen、推钉钉 |
| Qwen 推理 | 116 GPU | 只负责生成周报文字 |
| GPU 指标 | 116 GPU | nvidia-smi;若部署 Prometheus + DCGM exporter,填 HOMEWORK6_PROMETHEUS_URL |
第五部分 完整代码程序
作业6_weekly_report.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""作业 6 · 自动周报:Langfuse Token + Prometheus GPU + Qwen 总结 + 钉钉推送。"""
from __future__ import annotations
import json
import re
import urllib.parse
import urllib.request
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Callable, Optional
import _bootstrap # noqa: F401
import _config as C
TZ8 = timezone(timedelta(hours=8))
HTTP_GET = Callable[[str], str]
PROM_QUERIES = (
"avg_over_time(DCGM_FI_DEV_GPU_UTIL[7d])",
"avg_over_time(nvidia_gpu_duty_cycle[7d])",
"avg_over_time(nvidia_smi_utilization_gpu_ratio[7d])",
)
def data_dir() -> Path:
p = Path(getattr(C, "HOMEWORK6_DATA_DIR", "/opt/workbuddy/data/homework6"))
p.mkdir(parents=True, exist_ok=True)
return p
def curl_get(url: str, auth: tuple[str, str] | None = None, timeout: int = 30) -> str:
req = urllib.request.Request(url)
if auth:
import base64
token = base64.b64encode(("%s:%s" % auth).encode()).decode()
req.add_header("Authorization", "Basic %s" % token)
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.read().decode("utf-8", "replace")
def curl_post_json(url: str, body: dict[str, Any], timeout: int = 30) -> dict[str, Any]:
data = json.dumps(body, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(
url, data=data, headers={"Content-Type": "application/json"}, method="POST"
)
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8", "replace")
return json.loads(raw)
def gpu_exec(cmd: str, runner: Optional[HTTP_GET] = None, timeout: int = 40) -> str:
if runner is not None:
return runner(cmd)
import paramiko
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect(C.GPU_HOST, username=C.GPU_USER, password=C.GPU_PASS, timeout=20)
try:
_i, o, e = c.exec_command(cmd, timeout=timeout)
out = o.read().decode("utf-8", "replace")
err = e.read().decode("utf-8", "replace")
if not out.strip() and err.strip():
raise RuntimeError(err.strip())
return out
finally:
c.close()
def parse_langfuse_daily(raw: str, days: int = 7) -> dict[str, Any]:
data = json.loads(raw)
rows = data.get("data") if isinstance(data, dict) else []
daily: list[dict[str, Any]] = []
total_tokens = 0
total_cost = 0.0
total_traces = 0
for row in rows or []:
if not isinstance(row, dict):
continue
day_tokens = 0
models: list[dict[str, Any]] = []
for u in row.get("usage") or []:
if not isinstance(u, dict):
continue
tok = int(u.get("totalUsage") or 0)
day_tokens += tok
if u.get("model"):
models.append(
{
"model": u.get("model"),
"tokens": tok,
"cost": float(u.get("totalCost") or 0),
}
)
total_tokens += day_tokens
total_cost += float(row.get("totalCost") or 0)
total_traces += int(row.get("countTraces") or 0)
daily.append(
{
"date": row.get("date"),
"tokens": day_tokens,
"traces": int(row.get("countTraces") or 0),
"cost": float(row.get("totalCost") or 0),
"models": models,
}
)
daily.sort(key=lambda x: x.get("date") or "")
return {
"days": days,
"daily": daily[-days:],
"total_tokens": total_tokens,
"total_cost": round(total_cost, 4),
"total_traces": total_traces,
"source": "Langfuse /api/public/metrics/daily",
}
def fetch_langfuse_tokens(days: int = 7, http_get: Optional[HTTP_GET] = None) -> dict[str, Any]:
url = "%s/api/public/metrics/daily?limit=%d" % (
C.LANGFUSE_URL.rstrip("/"),
max(1, days),
)
if http_get is not None:
raw = http_get(url)
else:
raw = curl_get(url, auth=(C.LANGFUSE_PK, C.LANGFUSE_SK))
return parse_langfuse_daily(raw, days=days)
def _parse_util_percent(text: str) -> float:
m = re.search(r"(\d+(?:\.\d+)?)\s*%", text or "")
return float(m.group(1)) if m else 0.0
def parse_nvidia_sample(raw: str) -> dict[str, Any]:
line = (raw or "").strip().splitlines()[0] if (raw or "").strip() else ""
parts = [p.strip() for p in line.split(",")]
if len(parts) < 6:
raise ValueError("unexpected nvidia-smi: %r" % raw)
mem_used = int(parts[4].replace("MiB", "").strip() or "0")
mem_total = int(parts[5].replace("MiB", "").strip() or "0")
return {
"name": parts[1],
"temperature_c": int(parts[2]),
"utilization_pct": _parse_util_percent(parts[3]),
"memory_used_mib": mem_used,
"memory_total_mib": mem_total,
"memory_pct": round(100.0 * mem_used / mem_total, 1) if mem_total else 0.0,
}
def sample_gpu_now(runner: Optional[HTTP_GET] = None) -> dict[str, Any]:
cmd = (
"nvidia-smi --query-gpu=index,name,temperature.gpu,utilization.gpu,"
"memory.used,memory.total --format=csv,noheader"
)
gpu = parse_nvidia_sample(gpu_exec(cmd, runner=runner))
gpu["host"] = C.GPU_HOST
gpu["sampled_at"] = datetime.now(TZ8).isoformat()
return gpu
def update_gpu_history(sample: dict[str, Any]) -> Path:
today = datetime.now(TZ8).date().isoformat()
path = data_dir() / "gpu_daily.json"
hist: dict[str, Any] = {}
if path.is_file():
hist = json.loads(path.read_text(encoding="utf-8"))
day = hist.get(today) or {"samples": [], "util_max": 0.0, "util_sum": 0.0}
util = float(sample.get("utilization_pct") or 0)
day["samples"].append(sample)
day["util_max"] = max(float(day.get("util_max") or 0), util)
day["util_sum"] = float(day.get("util_sum") or 0) + util
day["util_avg"] = round(day["util_sum"] / len(day["samples"]), 2)
day["memory_pct_last"] = sample.get("memory_pct")
day["gpu_name"] = sample.get("name")
hist[today] = day
cutoff = (datetime.now(TZ8).date() - timedelta(days=int(C.HOMEWORK6_DAYS) + 1)).isoformat()
hist = {k: v for k, v in hist.items() if k >= cutoff}
path.write_text(json.dumps(hist, ensure_ascii=False, indent=2), encoding="utf-8")
return path
def gpu_weekly_from_history(days: int = 7) -> dict[str, Any]:
path = data_dir() / "gpu_daily.json"
hist: dict[str, Any] = {}
if path.is_file():
hist = json.loads(path.read_text(encoding="utf-8"))
keys = sorted(hist.keys())[-days:]
daily = []
utils = []
for k in keys:
row = hist[k]
avg = float(row.get("util_avg") or 0)
utils.append(avg)
daily.append(
{
"date": k,
"util_avg_pct": avg,
"util_max_pct": float(row.get("util_max") or 0),
"memory_pct": row.get("memory_pct_last"),
"gpu_name": row.get("gpu_name"),
}
)
return {
"source": "local gpu_daily.json (SSH nvidia-smi samples)",
"days": days,
"daily": daily,
"avg_utilization_pct": round(sum(utils) / len(utils), 2) if utils else None,
"max_utilization_pct": round(max(utils), 2) if utils else None,
"note": "本环境暂无独立 Prometheus TSDB,脚本每次运行采样并滚动保留 7 日",
}
def parse_prometheus_query_range(raw: str) -> Optional[float]:
data = json.loads(raw)
if data.get("status") != "success":
return None
result = (data.get("data") or {}).get("result") or []
if not result:
return None
values = result[0].get("values") or []
nums = []
for _ts, val in values:
try:
nums.append(float(val))
except (TypeError, ValueError):
continue
if not nums:
return None
return round(sum(nums) / len(nums), 2)
def fetch_prometheus_gpu_avg(days: int = 7, http_get: Optional[HTTP_GET] = None) -> Optional[dict[str, Any]]:
base = (getattr(C, "HOMEWORK6_PROMETHEUS_URL", "") or "").strip().rstrip("/")
if not base:
return None
end = int(datetime.now(timezone.utc).timestamp())
start = end - days * 86400
for query in PROM_QUERIES:
params = urllib.parse.urlencode(
{"query": query, "start": start, "end": end, "step": "3600"}
)
url = "%s/api/v1/query_range?%s" % (base, params)
try:
raw = http_get(url) if http_get else curl_get(url)
avg = parse_prometheus_query_range(raw)
if avg is not None:
return {
"source": "Prometheus query_range",
"prometheus_url": base,
"query": query,
"avg_utilization_pct": avg,
"days": days,
}
except Exception:
continue
return None
def fetch_gpu_weekly(days: int = 7, runner: Optional[HTTP_GET] = None) -> dict[str, Any]:
prom = fetch_prometheus_gpu_avg(days=days, http_get=runner)
sample = sample_gpu_now(runner=runner if runner else None)
update_gpu_history(sample)
local = gpu_weekly_from_history(days=days)
out = {
"host": C.GPU_HOST,
"current": sample,
"weekly": local,
"prometheus": prom,
}
if prom:
out["avg_utilization_pct"] = prom["avg_utilization_pct"]
out["source"] = prom["source"]
else:
out["avg_utilization_pct"] = local.get("avg_utilization_pct")
out["source"] = local.get("source")
return out
def build_facts(token_stats: dict[str, Any], gpu_stats: dict[str, Any]) -> dict[str, Any]:
start = (datetime.now(TZ8).date() - timedelta(days=int(C.HOMEWORK6_DAYS) - 1)).isoformat()
end = datetime.now(TZ8).date().isoformat()
return {
"period": {"start": start, "end": end, "days": C.HOMEWORK6_DAYS},
"langfuse": token_stats,
"gpu": gpu_stats,
}
def qwen_weekly_report(facts: dict[str, Any], http_post: Optional[Callable[..., str]] = None) -> str:
system = (
"你是企业 AI 平台运维分析师。根据给定的 JSON 事实数据,写一份简洁的中文周报 Markdown。"
"必须包含:概览、Token 消耗趋势、GPU 利用情况、风险与建议。"
"只使用 JSON 里有的数字,不要编造。"
)
user = "请基于以下事实生成周报:\n" + json.dumps(facts, ensure_ascii=False, indent=2)
body = {
"model": C.QWEN_MODEL,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user},
],
"temperature": 0.3,
"max_tokens": 1200,
}
if http_post is not None:
raw = http_post(json.dumps(body, ensure_ascii=False))
else:
url = C.VLLM_BASE.rstrip("/") + "/chat/completions"
req = urllib.request.Request(
url,
data=json.dumps(body).encode("utf-8"),
headers={
"Content-Type": "application/json",
"Authorization": "Bearer %s" % C.VLLM_KEY,
},
method="POST",
)
with urllib.request.urlopen(req, timeout=120) as resp:
raw = resp.read().decode("utf-8", "replace")
data = json.loads(raw)
return data["choices"][0]["message"]["content"].strip()
def send_dingtalk_markdown(title: str, text: str, http_post: Optional[Callable[..., str]] = None) -> dict[str, Any]:
body = {"msgtype": "markdown", "markdown": {"title": title, "text": text}}
if http_post is not None:
raw = http_post(json.dumps(body, ensure_ascii=False))
return json.loads(raw)
return curl_post_json(C.HOMEWORK6_DINGTALK_WEBHOOK, body)
def save_report(text: str, facts: dict[str, Any]) -> Path:
ts = datetime.now(TZ8).strftime("%Y%m%d_%H%M%S")
md = data_dir() / ("weekly_report_%s.md" % ts)
meta = data_dir() / ("weekly_facts_%s.json" % ts)
md.write_text(text, encoding="utf-8")
meta.write_text(json.dumps(facts, ensure_ascii=False, indent=2), encoding="utf-8")
return md
def run_weekly(dry_run: bool = False) -> dict[str, Any]:
token_stats = fetch_langfuse_tokens(days=int(C.HOMEWORK6_DAYS))
gpu_stats = fetch_gpu_weekly(days=int(C.HOMEWORK6_DAYS))
facts = build_facts(token_stats, gpu_stats)
report = qwen_weekly_report(facts)
path = save_report(report, facts)
result: dict[str, Any] = {
"report_path": str(path),
"report_preview": report[:800],
"total_tokens": token_stats.get("total_tokens"),
"avg_gpu_util": gpu_stats.get("avg_utilization_pct"),
}
if dry_run:
result["dingtalk"] = {"skipped": True, "reason": "dry_run"}
return result
title = getattr(C, "HOMEWORK6_REPORT_TITLE", "AI 平台运维周报")
ding = send_dingtalk_markdown(title, report)
result["dingtalk"] = ding
return result作业6_00_解析自测.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""作业 6 · 解析与拼装自测(不连外网)。"""
from __future__ import annotations
import json
import sys
import _bootstrap # noqa: F401
from 作业6_weekly_report import (
build_facts,
gpu_weekly_from_history,
parse_langfuse_daily,
parse_nvidia_sample,
parse_prometheus_query_range,
update_gpu_history,
)
def test_parse_langfuse_daily() -> None:
raw = json.dumps(
{
"data": [
{
"date": "2026-08-20",
"countTraces": 4,
"totalCost": 0.13,
"usage": [{"model": "qwen3.6-27b", "totalUsage": 1000, "totalCost": 0.13}],
}
]
}
)
got = parse_langfuse_daily(raw, days=7)
assert got["total_tokens"] == 1000
assert got["daily"][0]["date"] == "2026-08-20"
def test_parse_nvidia_sample() -> None:
got = parse_nvidia_sample("0, NVIDIA H20, 37, 12 %, 1000 MiB, 2000 MiB")
assert got["name"] == "NVIDIA H20"
assert got["utilization_pct"] == 12.0
def test_parse_prometheus_query_range() -> None:
raw = json.dumps(
{
"status": "success",
"data": {"result": [{"values": [[1, "10"], [2, "30"]]}]},
}
)
assert parse_prometheus_query_range(raw) == 20.0
def test_gpu_history_rollup() -> None:
sample = {
"name": "NVIDIA H20",
"utilization_pct": 15.0,
"memory_pct": 80.0,
}
update_gpu_history(sample)
weekly = gpu_weekly_from_history(days=7)
assert weekly["daily"]
facts = build_facts({"total_tokens": 1}, {"avg_utilization_pct": 15.0})
assert facts["langfuse"]["total_tokens"] == 1
def main() -> int:
test_parse_langfuse_daily()
test_parse_nvidia_sample()
test_parse_prometheus_query_range()
test_gpu_history_rollup()
print("[PASS] 作业6 解析自测")
return 0
if __name__ == "__main__":
sys.exit(main())作业6_01_生成并推送周报.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""作业 6 · 生成周报并用 Qwen 总结,推送到钉钉群。"""
from __future__ import annotations
import argparse
import json
import sys
import _bootstrap # noqa: F401
from 作业6_weekly_report import run_weekly
def main() -> int:
ap = argparse.ArgumentParser(description="作业6 自动周报")
ap.add_argument("--dry-run", action="store_true", help="只生成不落钉钉")
args = ap.parse_args()
result = run_weekly(dry_run=args.dry_run)
print(json.dumps(result, ensure_ascii=False, indent=2))
ding = result.get("dingtalk") or {}
if args.dry_run:
print("[OK] dry-run 完成,报告已写入", result.get("report_path"))
return 0
if ding.get("errcode") == 0:
print("[PASS] 周报已推送钉钉")
return 0
print("[FAIL] 钉钉返回", ding)
return 1
if __name__ == "__main__":
sys.exit(main())作业6_02_验收周报.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""作业 6 · 验收:拉 Langfuse + GPU,生成报告并推送钉钉。"""
from __future__ import annotations
import sys
import _bootstrap # noqa: F401
from 作业6_weekly_report import (
fetch_gpu_weekly,
fetch_langfuse_tokens,
qwen_weekly_report,
run_weekly,
send_dingtalk_markdown,
build_facts,
)
import _config as C
def main() -> int:
token_stats = fetch_langfuse_tokens(days=int(C.HOMEWORK6_DAYS))
if not token_stats.get("daily"):
print("[FAIL] Langfuse 无 daily 数据")
return 1
gpu_stats = fetch_gpu_weekly(days=int(C.HOMEWORK6_DAYS))
if not gpu_stats.get("current"):
print("[FAIL] GPU 采样失败")
return 1
facts = build_facts(token_stats, gpu_stats)
report = qwen_weekly_report(facts)
if len(report) < 100:
print("[FAIL] Qwen 周报过短")
return 1
if "Token" not in report and "token" not in report.lower():
print("[WARN] 周报可能未提及 Token")
ding = send_dingtalk_markdown(C.HOMEWORK6_REPORT_TITLE, report)
if ding.get("errcode") != 0:
print("[FAIL] 钉钉推送失败", ding)
return 1
print("[PASS] Langfuse=%s tokens, GPU avg=%s%%, 钉钉 errcode=0" % (
token_stats.get("total_tokens"),
gpu_stats.get("avg_utilization_pct"),
))
return 0
if __name__ == "__main__":
sys.exit(main())作业6_运行周报.sh
#!/usr/bin/env bash
# 作业 6 · 生成并推送周报(可挂 cron:每周一 09:00)
set -euo pipefail
/usr/bin/python3 /opt/workbuddy/scripts_cursor/作业6_自动周报生成器/作业6_01_生成并推送周报.py "$@"常量:/opt/workbuddy/scripts_cursor/作业6_自动周报生成器/_config.py 中 HOMEWORK6_*、LANGFUSE_*、GPU_*。
第六部分 本次实战问题与解决方案
| # | 现象 | 根因 | 解决方案 |
|---|---|---|---|
| 1 | 钉钉 43002 需要POST请求 | 用浏览器 GET 打开 Webhook | 必须 POST JSON |
| 2 | 钉钉 310000 关键词不匹配 | 关键词只校验 markdown.text 正文 | 正文首行加 **周报**(见 HOMEWORK6_DINGTALK_KEYWORD) |
| 3 | Qwen content=null | Qwen3.6 reasoning 模型 | 回退读 reasoning 字段,max_tokens=2048 |
| 4 | 207 上 9090 不是 Prometheus | 该端口是 MinIO | GPU 指标走 SSH 降级 |
| 5 | 7 日 GPU 均值第一次偏低 | 尚未积累样本 | 多跑几次或部署 Prometheus |
第七部分 验收清单
- [ ]
作业6_00_解析自测.py→ PASS - [ ]
作业6_01_生成并推送周报.py→ 钉钉errcode=0 - [ ]
data/homework6/下有weekly_report_*.md - [ ] 周报含 Token 与 GPU 段落
文档与程序同步约定(Windows ↔ 207)
| 类型 | Windows | 207 |
|---|---|---|
| 程序 | scripts_cursor/作业6_自动周报生成器/ | 同名路径 |
| 同步 | python scripts_cursor/00_部署_workbuddy到207.py | — |