PythonLOLM自动生成复盘报告模型

这是 Python 脚本,把《英雄联盟手游》对局截图丢进去,理论能自动生成一份带数据面板、时间线和行动项的复盘报告。

```python

-*- coding: utf-8 -*-

"""

lolm_review.py ------ 《英雄联盟手游》对局截图 → 结构化复盘报告

用法:

python lolm_review.py 结算页.png 数据页.png --notes "本局对面打野频繁抓中"

python lolm_review.py *.png -o review.md --model gpt-4o

python lolm_review.py --from-json demo.json -o review.md # 离线调试,不调 API

依赖:

pip install openai

环境变量:

OPENAI_API_KEY / OPENAI_BASE_URL(兼容 OpenAI 协议的任意视觉模型均可)

"""

from future import annotations

import argparse

import base64

import json

import os

import re

import sys

from collections import Counter

from dataclasses import dataclass, field, asdict

from pathlib import Path

from typing import Any, Optional, Sequence

DASH = "---"

NA = "待补充"

--------------------------------------------------------------------------

1. 数据模型

--------------------------------------------------------------------------

@dataclass

class MatchInfo:

match_id: str = NA

date: str = NA

mode: str = NA

champion: str = NA

role: str = NA

opponent_champion: str = NA

duration: str = NA

result: str = NA

kda: str = NA

rating: str = NA

rank: str = NA

@classmethod

def from_dict(cls, d: dict | None) -> "MatchInfo":

d = d or {}

fields = {f for f in cls.dataclass_fields}

return cls(**{k: _s(v) for k, v in d.items() if k in fields})

@dataclass

class TeamStats:

total_gold: Optionalfloat = None # 总经济

gold_per_min: Optionalfloat = None # 分均经济

kill_participation: Optionalfloat = None # 参团率(0-1 或 0-100)

damage_per_min: Optionalfloat = None # 分均伤害

vision_score: Optionalfloat = None # 视野得分

towers: Optionalfloat = None # 推塔数

dragons: Optionalfloat = None # 小龙控制

barons: Optionalfloat = None # 男爵控制

@classmethod

def from_dict(cls, d: dict | None) -> "TeamStats":

d = d or {}

return cls(**{f: _n(d.get(f)) for f in cls.dataclass_fields})

@dataclass

class TimelineEvent:

time: str = NA

event: str = NA

our_decision: str = NA

enemy_decision: str = NA

result: str = NA

category: str = NA

@classmethod

def from_dict(cls, d: dict) -> "TimelineEvent":

return cls(**{f: _s(d.get(f)) for f in cls.dataclass_fields})

@dataclass

class Teamfight:

time: str = NA

position: str = NA

first_skill: str = NA

target: str = NA

hit_core: str = NA

result: str = NA

@classmethod

def from_dict(cls, d: dict) -> "Teamfight":

return cls(**{f: _s(d.get(f)) for f in cls.dataclass_fields})

@dataclass

class ProblemItem:

category: str = NA

detail: str = NA

frequency: str = NA

@classmethod

def from_dict(cls, d: dict) -> "ProblemItem":

return cls(**{f: _s(d.get(f)) for f in cls.dataclass_fields})

@dataclass

class ActionItem:

problem: str = NA

action: str = NA

scene: str = NA

@classmethod

def from_dict(cls, d: dict) -> "ActionItem":

return cls(

problem=_s(d.get("problem")),

action=_s(d.get("action")),

scene=_s(d.get("trigger") or d.get("scene")),

)

@dataclass

class MatchReport:

match: MatchInfo = field(default_factory=MatchInfo)

our_team: TeamStats = field(default_factory=TeamStats)

enemy_team: TeamStats = field(default_factory=TeamStats)

timeline: listTimelineEvent = field(default_factory=list)

teamfights: listTeamfight = field(default_factory=list)

problems: listProblemItem = field(default_factory=list)

actions: listActionItem = field(default_factory=list)

summary: str = ""

---------- 构建 ----------

@classmethod

def from_dict(cls, data: dict) -> "MatchReport":

report = cls(

match=MatchInfo.from_dict(data.get("match")),

our_team=TeamStats.from_dict(data.get("our_team")),

enemy_team=TeamStats.from_dict(data.get("enemy_team")),

timeline=TimelineEvent.from_dict(x) for x in data.get("timeline") or \[],

teamfights=Teamfight.from_dict(x) for x in data.get("teamfights") or \[],

problems=ProblemItem.from_dict(x) for x in data.get("problems") or \[],

actions=ActionItem.from_dict(x) for x in data.get("actions") or \[],

summary=_s(data.get("summary")),

)

report.post_process()

return report

---------- 后处理:补全模型没给的部分 ----------

def post_process(self) -> None:

1) 用时间线里的问题归类,反过来统计"高频程度"

counter = Counter(ev.category for ev in self.timeline if ev.category and ev.category != NA)

total = sum(counter.values())

freq_map = {k: f"{v}/{total} 个节点" for k, v in counter.items()}

2) 模型没给 problems 时,用时间线的 category 兜底生成

if not self.problems and counter:

self.problems = [

ProblemItem(category=cat, detail="见时间线关键节点", frequency=freq)

for cat, freq in counter.most_common()

]

else:

for p in self.problems:

if p.frequency in (NA, "", None) and p.category in freq_map:

p.frequency = freq_mapp.category

3) 模型没给行动项时,按问题关键词从规则库匹配

if not self.actions:

self.actions = _derive_actions(self.problems)

4) 总结兜底

if not self.summary:

top = self.problems0.category if self.problems else "无明显高频问题"

self.summary = f"本局核心问题集中在「{top}」,需在下一局优先验证改进动作是否生效。"

---------- 渲染 ----------

def to_markdown(self) -> str:

return _render(self)

def to_json(self) -> str:

return json.dumps(asdict(self), ensure_ascii=False, indent=2)

--------------------------------------------------------------------------

2. 小工具

--------------------------------------------------------------------------

def _s(v: Any) -> str:

"""转成展示用字符串,空值统一成 待补充。"""

if v is None:

return NA

if isinstance(v, str):

v = v.strip()

return v if v else NA

return str(v)

def _n(v: Any) -> Optionalfloat:

"""转成数字,失败返回 None。"""

if v is None or v == "" or v == "null":

return None

if isinstance(v, (int, float)):

return float(v)

m = re.search(r"-?\d+(?:\.\d+)?", str(v))

return float(m.group()) if m else None

def _fmt_num(v: Optionalfloat, suffix: str = "") -> str:

if v is None:

return DASH

if float(v).is_integer():

v = int(v)

return f"{v}{suffix}"

def _fmt_pct(v: Optionalfloat) -> str:

if v is None:

return DASH

if v <= 1.0: # 模型可能给 0.62

v *= 100

return f"{v:.0f}%"

def _parse_json(text: str) -> dict:

"""从模型输出里抠出 JSON,容忍 ```json 包裹和前后废话。"""

text = (text or "").strip()

if text.startswith("```"):

text = re.sub(r"^```a-zA-Z*\s*", "", text)

text = re.sub(r"\s*```$", "", text)

try:

return json.loads(text)

except json.JSONDecodeError:

start, end = text.find("{"), text.rfind("}")

if start == -1 or end == -1 or end <= start:

raise ValueError(f"模型未返回合法 JSON:\n{text:500}")

return json.loads(textstart : end + 1)

--------------------------------------------------------------------------

3. 行动项规则库(模型没给行动项时的兜底)

--------------------------------------------------------------------------

ACTION_RULES: listtuple\[str, str, str] = [

(r"视野|眼|草丛",

"资源刷新前 45 秒回城补给,并与辅助同步进入河道/龙坑口布控视野",

"小龙/大龙刷新倒计时 ≤ 45 秒时"),

(r"站位|脱节|被抓|落单",

"清完线后立即向辅助的视野范围靠拢,不单独穿越无视野野区",

"每次清完中线/边线后立即执行"),

(r"前排|控制.*(前排|坦克)|技能.*(前排|坦克)",

"团战开打前标记敌方核心输出,控制技能只对其释放",

"每次团战爆发前"),

(r"支援|游走|节奏",

"先推线再向河道靠拢支援,不裸游走丢塔皮",

"推完线且敌方中单消失时"),

(r"兵线|回城|控线",

"回城前把兵线推到敌方塔下,避免回城期间掉经验与塔皮",

"血量/蓝量低于 40% 准备回城时"),

(r"装备|出装",

"第一次回城后查看敌方出装面板,第二件大件前完成一次针对出装",

"第一次回城后"),

(r"命中|空技能|技能准度",

"关键技能等对方交出位移/闪现后再释放",

"敌方核心输出位交出位移技能后"),

]

def _derive_actions(problems: SequenceProblemItem) -> listActionItem:

actions: listActionItem = \[\]

seen: setstr = set()

for p in problems:

haystack = f"{p.category} {p.detail}"

for pattern, action, scene in ACTION_RULES:

if re.search(pattern, haystack) and action not in seen:

seen.add(action)

actions.append(ActionItem(problem=p.category, action=action, scene=scene))

break

if not actions:

actions.append(ActionItem(

problem="暂未识别到高频问题",

action="本局结束前选定 1 个想验证的改进点,下一局开局即执行",

scene="下一局进入游戏后 1 分钟内",

))

return actions

--------------------------------------------------------------------------

4. 截图 → 结构化数据(视觉模型)

--------------------------------------------------------------------------

SYSTEM_PROMPT = """你是一名《英雄联盟手游》对局数据分析师。用户会提供若干张对局截图(结算页、数据面板、对局详情、时间线截图等),你需要把图中可见的信息结构化提取出来。

严格只输出一个 JSON 对象,不要 markdown 代码块,不要任何解释文字。

JSON 结构:

{

"match": {

"match_id": "", "date": "", "mode": "", "champion": "", "role": "",

"opponent_champion": "", "duration": "", "result": "", "kda": "",

"rating": "", "rank": ""

},

"our_team": {

"total_gold": 0, "gold_per_min": 0, "kill_participation": 0,

"damage_per_min": 0, "vision_score": 0,

"towers": 0, "dragons": 0, "barons": 0

},

"enemy_team": {

"total_gold": 0, "gold_per_min": 0, "kill_participation": 0,

"damage_per_min": 0, "vision_score": 0,

"towers": 0, "dragons": 0, "barons": 0

},

"timeline": [

{"time": "10:15", "event": "第一条小龙团", "our_decision": "",

"enemy_decision": "", "result": "", "category": ""}

],

"teamfights": [

{"time": "", "position": "", "first_skill": "", "target": "",

"hit_core": "", "result": ""}

],

"problems": {"category": "", "detail": "", "frequency": ""},

"actions": {"problem": "", "action": "", "trigger": ""},

"summary": ""

}

提取规则:

  1. 截图中没有的信息一律填 null,绝对不要编造数字。

  2. our_team 指你所用英雄所在的一方;若截图无法区分敌我,按蓝色方=我方处理。

  3. 经济、伤害类数值只填纯数字,不带单位和千分位。

  4. kill_participation(参团率)填百分比数字,例如 62 表示 62%。

  5. timeline 只提取截图中能明确看到信息的节点(如小龙、大龙、击杀播报、推塔时间),看不到就返回空数组。

  6. timeline / problems 里的 category 只能从以下取值中选:

团战站位 / 技能释放 / 视野缺失 / 支援时机 / 资源交换 / 兵线处理 / 装备选择 / 生态意识 / 技能命中。

  1. teamfights 只在截图中确实存在团战信息时填写,否则返回空数组。

  2. summary 用一句话概括本局核心教训。"""

USER_PROMPT = """请阅读以下对局截图并提取数据。

【玩家补充说明】

{notes}

"""

def _encode_image(path: Path) -> tuplestr, str:

ext = path.suffix.lower().lstrip(".") or "png"

mime = {"jpg": "jpeg", "jpeg": "jpeg", "png": "png", "webp": "webp"}.get(ext, "png")

b64 = base64.b64encode(path.read_bytes()).decode("ascii")

return b64, f"data:image/{mime};base64,{b64}"

def extract_report_data(

images: SequencePath,

notes: str = "",

model: str = "gpt-4o",

api_key: str | None = None,

base_url: str | None = None,

) -> dict:

"""调用视觉模型,把截图解析成结构化 dict。"""

try:

from openai import OpenAI

except ImportError as exc: # pragma: no cover

raise SystemExit("缺少依赖,请先执行:pip install openai") from exc

client = OpenAI(

api_key=api_key or os.getenv("OPENAI_API_KEY"),

base_url=base_url or os.getenv("OPENAI_BASE_URL"),

)

content: listdict = [

{"type": "text", "text": USER_PROMPT.format(notes=notes.strip() or "无")}

]

for img in images:

_, data_url = _encode_image(img)

content.append({"type": "image_url", "image_url": {"url": data_url}})

resp = client.chat.completions.create(

model=model,

temperature=0.2,

messages=[

{"role": "system", "content": SYSTEM_PROMPT},

{"role": "user", "content": content},

],

)

return _parse_json(resp.choices0.message.content)

--------------------------------------------------------------------------

5. 报告渲染

--------------------------------------------------------------------------

def _comment(key: str, our: Optionalfloat, enemy: Optionalfloat) -> str:

"""给核心数据面板最后一列写一句人话评价。"""

if our is None or enemy is None:

return NA

if key == "gold_per_min":

if our < enemy * 0.9:

return "⚠ 经济落后,优先检查支援节奏与兵线处理"

if our > enemy * 1.1:

return "✅ 经济领先,节奏正常"

return "基本持平"

if key == "kill_participation":

if our < enemy - 10:

return "⚠ 参团偏低,回溯是「该团没去」还是「去了没作用」"

if our > enemy + 10:

return "✅ 参团积极"

return "基本持平"

if key == "damage_per_min":

if our < enemy * 0.85:

return "⚠ 输出不足,检查团战站位与技能命中"

if our > enemy * 1.15:

return "✅ 输出压制"

return "基本持平"

if key == "vision_score":

if our < enemy * 0.8:

return "⚠ 视野落后,游走与团战准备度存疑"

if our > enemy * 1.2:

return "✅ 视野占优"

return "基本持平"

if key in ("towers", "dragons", "barons"):

if our < enemy:

return "⚠ 资源控制落后"

if our > enemy:

return "✅ 资源控制领先"

return "持平"

if key == "total_gold":

if our < enemy:

return "⚠ 总经济落后"

if our > enemy:

return "✅ 总经济领先"

return "基本持平"

return "---"

def _render(r: MatchReport) -> str:

m, our, enemy = r.match, r.our_team, r.enemy_team

L: liststr = \[\]

---------- 标题 ----------

L.append("# 《英雄联盟手游》对局复盘报告")

L.append("")

---------- 一、基本信息 ----------

L.append("## 一、对局基本信息")

L.append("")

L.append(f"- 对局编号 / 日期:{m.match_id} / {m.date}")

L.append(f"- 游戏模式:{m.mode}")

L.append(f"- 使用英雄与位置:{m.champion} / {m.role}")

L.append(f"- 对位英雄:{m.opponent_champion}")

L.append(f"- 对局时长:{m.duration}")

L.append(f"- 胜负结果:{m.result}")

L.append(f"- 个人评分 / KDA:{m.rating} / {m.kda}")

L.append(f"- 段位环境:{m.rank}")

L.append("")

---------- 二、核心数据面板 ----------

L.append("## 二、核心数据面板")

L.append("")

L.append("| 指标 | 我方 | 敌方 | 差值 / 评价 |")

L.append("| --- | --- | --- | --- |")

rows = [

("总经济", _fmt_num(our.total_gold), _fmt_num(enemy.total_gold), "total_gold"),

("分均经济", _fmt_num(our.gold_per_min), _fmt_num(enemy.gold_per_min), "gold_per_min"),

("参团率", _fmt_pct(our.kill_participation), _fmt_pct(enemy.kill_participation), "kill_participation"),

("分均伤害", _fmt_num(our.damage_per_min), _fmt_num(enemy.damage_per_min), "damage_per_min"),

("视野得分", _fmt_num(our.vision_score), _fmt_num(enemy.vision_score), "vision_score"),

("推塔数", _fmt_num(our.towers), _fmt_num(enemy.towers), "towers"),

("小龙控制", _fmt_num(our.dragons), _fmt_num(enemy.dragons), "dragons"),

("男爵控制", _fmt_num(our.barons), _fmt_num(enemy.barons), "barons"),

]

for label, a, b, key in rows:

L.append(f"| {label} | {a} | {b} | {_comment(key, our.dictkey, enemy.dictkey)} |")

L.append("")

L.append("**关键指标解读:**")

L.append("")

L.append("- **分均经济**:中后期掉出全队前二,通常是支援节奏或兵线处理出了问题,而非操作失误。")

L.append("- **参团率**:低于同位置平均水平时,回溯是「该团的没去」还是「去了但没作用」。")

L.append("- **视野得分**:辅助位尤其关键,是判断游走效率与团战准备度的隐性指标。")

L.append("")

---------- 三、时间线关键节点 ----------

L.append("## 三、时间线关键节点复盘")

L.append("")

if r.timeline:

for i, ev in enumerate(r.timeline, 1):

L.append(f"**节点 {i}**")

L.append("")

L.append(f"- 时间:{ev.time}")

L.append(f"- 事件:{ev.event}")

L.append(f"- 我方决策:{ev.our_decision}")

L.append(f"- 敌方决策:{ev.enemy_decision}")

L.append(f"- 结果:{ev.result}")

L.append(f"- 问题归类:{ev.category}")

L.append("")

else:

L.append("> 截图中未捕获到可用的时间线信息。建议补充录制回放中的关键时间点截图(小龙、大龙、击杀播报)。")

L.append("")

---------- 四、关键团战逐帧拆解 ----------

L.append("## 四、关键团战逐帧拆解")

L.append("")

if r.teamfights:

L.append("| 团战时间 | 我的位置 | 首个关键技能 | 技能目标 | 是否命中核心 | 团战结果 |")

L.append("| --- | --- | --- | --- | --- | --- |")

for tf in r.teamfights:

L.append(

f"| {tf.time} | {tf.position} | {tf.first_skill} | "

f"{tf.target} | {tf.hit_core} | {tf.result} |"

)

L.append("")

else:

L.append("> 未提取到团战明细。复盘时请重点回看 **10 分钟后的大龙团** 与 **最后一波决定性团战**,只聚焦两个问题:")

L.append("> 1. 开团前站位:是否与队友脱节?是否在无视野区域脸探草丛?")

L.append("> 2. 技能释放顺序:控制技能是否交给了对方前排?核心输出技能是否被对方关键技能抵消?")

L.append("")

---------- 五、问题分类与高频统计 ----------

L.append("## 五、问题分类与高频统计")

L.append("")

L.append("| 类别 | 本局具体表现 | 高频程度 |")

L.append("| --- | --- | --- |")

if r.problems:

for p in r.problems:

L.append(f"| {p.category} | {p.detail} | {p.frequency} |")

else:

for cat in "生态意识", "技能命中", "兵线处理", "装备选择":

L.append(f"| {cat} | {DASH} | {DASH} |")

L.append("")

L.append("> 每打完 3 局统计一次出现频率最高的项,下一阶段集中针对性训练。")

L.append("")

---------- 六、改进方案 ----------

L.append("## 六、改进方案(行动项)")

L.append("")

L.append("| 问题 | 行动项 | 执行场景 |")

L.append("| --- | --- | --- |")

for a in r.actions:

L.append(f"| {a.problem} | {a.action} | {a.scene} |")

L.append("")

---------- 七、总结 ----------

L.append("## 七、总结")

L.append("")

L.append(r.summary or NA)

L.append("")

repeated = any(("重复" in (p.detail or "")) or ("连续" in (p.detail or "")) for p in r.problems)

L.append(f"- 是否属于重复性问题:{'是 ------ 建议升级为专项训练目标,优先于其他改进项处理' if repeated else '否(需结合历史复盘记录进一步确认)'}")

L.append("")

L.append("---")

L.append("")

L.append("*本报告由截图自动提取生成,数字如有偏差请以游戏内实际数据为准。*")

return "\n".join(L)

--------------------------------------------------------------------------

6. CLI

--------------------------------------------------------------------------

def main(argv: Sequencestr | None = None) -> int:

parser = argparse.ArgumentParser(description="《英雄联盟手游》截图 → 复盘报告")

parser.add_argument("images", nargs="*", type=Path, help="对局截图路径(可多张)")

parser.add_argument("--notes", default="", help="玩家补充说明,如「本局对面打野频繁抓中」")

parser.add_argument("--model", default=os.getenv("VISION_MODEL", "gpt-4o"), help="视觉模型名称")

parser.add_argument("--base-url", default=None, help="兼容 OpenAI 协议的 API 地址")

parser.add_argument("--api-key", default=None, help="API Key,默认读环境变量")

parser.add_argument("-o", "--out", type=Path, default=Path("review.md"), help="输出文件")

parser.add_argument("--json-out", type=Path, default=None, help="同时导出结构化 JSON")

parser.add_argument("--from-json", type=Path, default=None, help="跳过截图识别,直接用 JSON 生成报告")

args = parser.parse_args(argv)

分支一:离线用 JSON 生成

if args.from_json:

data = json.loads(args.from_json.read_text(encoding="utf-8"))

else:

if not args.images:

parser.error("请提供至少一张截图,或使用 --from-json")

missing = p for p in args.images if not p.exists()

if missing:

parser.error(f"文件不存在:{', '.join(str(p) for p in missing)}")

print(f"1/2 正在解析 {len(args.images)} 张截图 ...", file=sys.stderr)

data = extract_report_data(

args.images,

notes=args.notes,

model=args.model,

api_key=args.api_key,

base_url=args.base_url,

)

if args.json_out:

args.json_out.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")

print("2/2 正在生成报告 ...", file=sys.stderr)

report = MatchReport.from_dict(data)

markdown = report.to_markdown()

args.out.write_text(markdown, encoding="utf-8")

print(f"✅ 报告已写入:{args.out.resolve()}", file=sys.stderr)

return 0

if name == "main":

raise SystemExit(main())

```

截图到报告:从识别到行动项生成

整个脚本围绕"截图进,报告出"这条主线设计,把数据识别、结构化组装和报告渲染串成了一条流水线。

· 视觉识别入口:extract_report_data 函数把截图编码后发给视觉模型,配合系统提示词要求模型只返回一份严格的 JSON,包含对局信息、双方数据、时间线节点、团战明细和问题分类。

· 结构化组装与兜底:MatchReport.from_dict 把原始 JSON 转成数据类对象,接着 post_process 会检查模型是否漏掉了问题列表或行动项。如果缺了,就用时间线里的问题分类自动补齐问题项,再通过 ACTION_RULES 匹配出具体的行动项,确保报告不会因为模型漏答而空着。

· 报告渲染输出:to_markdown 按七个章节拼装 markdown 报告,从对局基本信息、核心数据面板,到时间线逐节点、团战表格、问题统计和行动项表格,最后附上总结。

· 离线调试路径:脚本支持 --from-json 参数,可以直接用一份 JSON 数据生成报告,方便你在不调用 API 的情况下反复调整报告格式或测试规则库。


优化建议: 如果你们常用的视觉模型不是 gpt-4o,可以在命令行里加 --model 模型名,或者先设置环境变量 VISION_MODEL;API 地址不同的话也可以用 --base-url 指定。

禁止一切转载或商用。

相关推荐
霸道流氓气质1 小时前
HashiCorp Vault 与云 KMS 入门实战:动态密钥、SecretLeaseContainer 与 Java 集成完整指南
java·开发语言
倔强的石头_1 小时前
从 KStudio 到 KSQL Developer:国产数据库的 SQL 编辑器,已经卷到这个程度了
数据库
水水不水啊1 小时前
告别切换N个窗口调试:我用 Python + WebView 做了款现代化串口助手(含 TCP/UDP、波形、协议解析)
python·tcp/ip·udp
u1301301 小时前
GitHub 热榜项目:日榜(2026-10-11)
人工智能·github
johnsong1 小时前
当AI在你看不到的地方狂奔:Karpathy的认知漏斗与人类理解的分裂
人工智能
AI日报派送佬1 小时前
2026年10月12日AI行业日报|GPT-6.1 Sol超长上下文免费开放,多智能体成本陷阱暴露,算力供需失衡持续发酵
大数据·人工智能·openai·苹果·ai日报·智能体技术·deepseekharness
论文复现现场1 小时前
DeepSeek V4.1 Flash 各平台价格怎么比?缓存、峰谷计费与 Python 成本计算
开发语言·python·缓存·大模型·成本优化·deepseek
xuxigifxfh1 小时前
牛客:HJ21 简单密码
java·开发语言·算法
星云低代码开发平台1 小时前
MES 延误归因的技术路径:计划、物料、报工与质量记录
人工智能·权限管理·系统集成