555 lines
19 KiB
Python
555 lines
19 KiB
Python
#!/usr/bin/env python3
|
||
"""记录 MAOMOMO 文章配图任务状态。
|
||
|
||
本脚本用于把图片生成进度写入状态文件,避免只用聊天记录声称已完成。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
from datetime import datetime, timezone
|
||
import json
|
||
from pathlib import Path
|
||
import shutil
|
||
import sys
|
||
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
||
|
||
|
||
JOBS_FILE = "slide_jobs.json"
|
||
STATE_FILE = "slide_run_state.json"
|
||
FINAL_MANIFEST_FILE = "final_manifest.json"
|
||
GITHUB_BATCHES_FILE = "github_batches.json"
|
||
|
||
|
||
def now_iso() -> str:
|
||
return datetime.now(timezone.utc).isoformat()
|
||
|
||
|
||
def die(message: str, code: int = 1) -> None:
|
||
print(f"错误:{message}", file=sys.stderr)
|
||
raise SystemExit(code)
|
||
|
||
|
||
def read_text(path: Path) -> str:
|
||
return path.read_text(encoding="utf-8")
|
||
|
||
|
||
def write_json(path: Path, data: Any) -> None:
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
with path.open("w", encoding="utf-8", newline="\n") as handle:
|
||
json.dump(data, handle, ensure_ascii=False, indent=2)
|
||
handle.write("\n")
|
||
|
||
|
||
def read_json(path: Path) -> Any:
|
||
try:
|
||
return json.loads(read_text(path))
|
||
except FileNotFoundError:
|
||
die(f"找不到文件:{path}")
|
||
except json.JSONDecodeError as exc:
|
||
die(f"JSON 格式错误:{path}: {exc}")
|
||
|
||
|
||
def project_path(project: str) -> Path:
|
||
path = Path(project).expanduser().resolve()
|
||
if not path.exists():
|
||
die(f"项目目录不存在:{path}")
|
||
if not path.is_dir():
|
||
die(f"不是项目目录:{path}")
|
||
return path
|
||
|
||
|
||
def resolve_in_project(project: Path, value: str) -> Path:
|
||
path = Path(value)
|
||
if not path.is_absolute():
|
||
path = project / path
|
||
return path.resolve()
|
||
|
||
|
||
def relative_path_string(project: Path, path: Path) -> str:
|
||
try:
|
||
return str(path.resolve().relative_to(project))
|
||
except ValueError:
|
||
return str(path.resolve())
|
||
|
||
|
||
def load_job_doc(project: Path) -> Dict[str, Any]:
|
||
raw = read_json(project / JOBS_FILE)
|
||
if isinstance(raw, list):
|
||
return {"version": 1, "jobs": raw}
|
||
if not isinstance(raw, dict):
|
||
die(f"{JOBS_FILE} 必须是对象或数组。")
|
||
jobs = raw.get("jobs")
|
||
if not isinstance(jobs, list):
|
||
die(f"{JOBS_FILE} 缺少 jobs 数组。")
|
||
return raw
|
||
|
||
|
||
def load_state_doc(project: Path) -> Dict[str, Any]:
|
||
path = project / STATE_FILE
|
||
if not path.exists():
|
||
return {"version": 1, "events": []}
|
||
raw = read_json(path)
|
||
if not isinstance(raw, dict):
|
||
die(f"{STATE_FILE} 必须是对象。")
|
||
if not isinstance(raw.get("events"), list):
|
||
raw["events"] = []
|
||
return raw
|
||
|
||
|
||
def load_batches_doc(project: Path) -> Dict[str, Any]:
|
||
path = project / GITHUB_BATCHES_FILE
|
||
if not path.exists():
|
||
return {"version": 1, "batches": []}
|
||
raw = read_json(path)
|
||
if not isinstance(raw, dict):
|
||
die(f"{GITHUB_BATCHES_FILE} 必须是对象。")
|
||
if not isinstance(raw.get("batches"), list):
|
||
raw["batches"] = []
|
||
return raw
|
||
|
||
|
||
def save_docs(project: Path, job_doc: Dict[str, Any], state_doc: Dict[str, Any]) -> None:
|
||
write_json(project / JOBS_FILE, job_doc)
|
||
write_json(project / STATE_FILE, state_doc)
|
||
|
||
|
||
def job_key(job: Dict[str, Any]) -> Optional[str]:
|
||
for key in ("id", "job_id", "slide", "name"):
|
||
value = job.get(key)
|
||
if isinstance(value, str) and value:
|
||
return value
|
||
return None
|
||
|
||
|
||
def find_job(job_doc: Dict[str, Any], wanted: str) -> Dict[str, Any]:
|
||
for job in job_doc["jobs"]:
|
||
if isinstance(job, dict) and job_key(job) == wanted:
|
||
return job
|
||
die(f"未找到图片 job:{wanted}")
|
||
return {}
|
||
|
||
|
||
def event(state_doc: Dict[str, Any], kind: str, job_id: str, payload: Dict[str, Any]) -> None:
|
||
state_doc.setdefault("events", []).append(
|
||
{
|
||
"time": now_iso(),
|
||
"event": kind,
|
||
"job": job_id,
|
||
**payload,
|
||
}
|
||
)
|
||
|
||
|
||
def infer_origin_out(prompt_path: Path, data: Dict[str, Any]) -> str:
|
||
for key in ("origin_out", "out", "output", "selected_output"):
|
||
value = data.get(key)
|
||
if isinstance(value, str) and value:
|
||
return value
|
||
file_name = data.get("file_name")
|
||
if isinstance(file_name, str) and file_name:
|
||
return str(Path("origin_image") / Path(file_name).name)
|
||
return str(Path("origin_image") / f"{prompt_path.stem}.png")
|
||
|
||
|
||
def infer_asset_out(data: Dict[str, Any], origin_out: str) -> str:
|
||
value = data.get("asset_out")
|
||
if isinstance(value, str) and value:
|
||
return value
|
||
return str(Path("assets") / Path(origin_out).name)
|
||
|
||
|
||
def cmd_init(args: argparse.Namespace) -> int:
|
||
project = project_path(args.project)
|
||
prompts_dir = resolve_in_project(project, args.prompts_dir)
|
||
if not prompts_dir.exists():
|
||
die(f"prompts 目录不存在:{prompts_dir}")
|
||
|
||
jobs: List[Dict[str, Any]] = []
|
||
for prompt_path in sorted(prompts_dir.glob("*.json")):
|
||
data = read_json(prompt_path)
|
||
if not isinstance(data, dict):
|
||
die(f"prompt job 必须是 JSON 对象:{prompt_path}")
|
||
job_id = str(data.get("id") or data.get("job_id") or prompt_path.stem)
|
||
origin_out = infer_origin_out(prompt_path, data)
|
||
jobs.append(
|
||
{
|
||
"id": job_id,
|
||
"prompt_file": str(prompt_path.relative_to(project)),
|
||
"origin_out": origin_out,
|
||
"asset_out": infer_asset_out(data, origin_out),
|
||
"status": "pending",
|
||
"selected_backend": args.selected_backend,
|
||
"aspect_ratio": data.get("aspect_ratio", ""),
|
||
"visual_direction": data.get("visual_direction", ""),
|
||
"delivery_batch": data.get("delivery_batch", ""),
|
||
"qa_status": "pending",
|
||
"entered_assets": False,
|
||
"final": False,
|
||
"github_pushed": False,
|
||
"github_batch": "",
|
||
"created_at": now_iso(),
|
||
}
|
||
)
|
||
|
||
if not jobs:
|
||
die(f"没有找到 prompt JSON:{prompts_dir}")
|
||
|
||
job_doc = {"version": 1, "jobs": jobs}
|
||
state_doc = {"version": 1, "events": [{"time": now_iso(), "event": "init", "job_count": len(jobs)}]}
|
||
save_docs(project, job_doc, state_doc)
|
||
print(json.dumps({"status": "initialized", "jobs": len(jobs)}, ensure_ascii=False, indent=2))
|
||
return 0
|
||
|
||
|
||
def cmd_status(args: argparse.Namespace) -> int:
|
||
project = project_path(args.project)
|
||
job_doc = load_job_doc(project)
|
||
counts: Dict[str, int] = {}
|
||
for job in job_doc["jobs"]:
|
||
if not isinstance(job, dict):
|
||
continue
|
||
status = str(job.get("status") or "unknown")
|
||
counts[status] = counts.get(status, 0) + 1
|
||
|
||
print(json.dumps({"project": str(project), "counts": counts}, ensure_ascii=False, indent=2))
|
||
for job in job_doc["jobs"]:
|
||
if isinstance(job, dict):
|
||
github = "github" if job.get("github_pushed") else "local"
|
||
qa = job.get("qa_status", "")
|
||
print(f"{job_key(job) or '<unknown>'}\t{job.get('status', 'unknown')}\t{qa}\t{github}\t{job.get('origin_out', '')}")
|
||
return 0
|
||
|
||
|
||
def cmd_dispatch(args: argparse.Namespace) -> int:
|
||
project = project_path(args.project)
|
||
job_doc = load_job_doc(project)
|
||
state_doc = load_state_doc(project)
|
||
job = find_job(job_doc, args.job)
|
||
prompt_file = args.prompt_file or job.get("prompt_file")
|
||
if not isinstance(prompt_file, str) or not prompt_file:
|
||
die("缺少 prompt 文件路径。")
|
||
|
||
job["status"] = "dispatched"
|
||
job["dispatch"] = {
|
||
"agent_id": args.agent_id,
|
||
"agent_nickname": args.agent_nickname,
|
||
"prompt_file": prompt_file,
|
||
"time": now_iso(),
|
||
}
|
||
event(state_doc, "dispatch", args.job, job["dispatch"])
|
||
save_docs(project, job_doc, state_doc)
|
||
print(json.dumps({"status": "dispatched", "job": args.job}, ensure_ascii=False, indent=2))
|
||
return 0
|
||
|
||
|
||
def copy_if_needed(source: Path, target: Path) -> None:
|
||
if not source.exists():
|
||
die(f"结果图片不存在:{source}")
|
||
target.parent.mkdir(parents=True, exist_ok=True)
|
||
try:
|
||
if source.resolve() == target.resolve():
|
||
return
|
||
except FileNotFoundError:
|
||
pass
|
||
shutil.copyfile(source, target)
|
||
|
||
|
||
def cmd_result(args: argparse.Namespace) -> int:
|
||
project = project_path(args.project)
|
||
job_doc = load_job_doc(project)
|
||
state_doc = load_state_doc(project)
|
||
job = find_job(job_doc, args.job)
|
||
source = resolve_in_project(project, args.selected_source)
|
||
|
||
origin_value = args.origin_out or job.get("origin_out")
|
||
if not isinstance(origin_value, str) or not origin_value:
|
||
die("缺少 origin_out。")
|
||
origin_out = resolve_in_project(project, origin_value)
|
||
copy_if_needed(source, origin_out)
|
||
|
||
asset_value = args.asset_out or job.get("asset_out")
|
||
entered_assets = False
|
||
if isinstance(asset_value, str) and asset_value:
|
||
copy_if_needed(origin_out, resolve_in_project(project, asset_value))
|
||
entered_assets = True
|
||
|
||
result = {
|
||
"agent_id": args.agent_id,
|
||
"backend_used": args.backend_used,
|
||
"selected_source": str(source),
|
||
"origin_out": relative_path_string(project, origin_out),
|
||
"asset_out": asset_value,
|
||
"qa_status": "passed",
|
||
"entered_assets": entered_assets,
|
||
"qa_note": args.qa_note,
|
||
"time": now_iso(),
|
||
}
|
||
job["status"] = "recorded"
|
||
job["qa_status"] = "passed"
|
||
job["entered_assets"] = entered_assets
|
||
job["result"] = result
|
||
event(state_doc, "result", args.job, result)
|
||
save_docs(project, job_doc, state_doc)
|
||
print(json.dumps({"status": "recorded", "job": args.job, "origin_out": result["origin_out"]}, ensure_ascii=False, indent=2))
|
||
return 0
|
||
|
||
|
||
def cmd_blocker(args: argparse.Namespace) -> int:
|
||
project = project_path(args.project)
|
||
job_doc = load_job_doc(project)
|
||
state_doc = load_state_doc(project)
|
||
job = find_job(job_doc, args.job)
|
||
blocker = {
|
||
"agent_id": args.agent_id,
|
||
"reason": args.reason,
|
||
"evidence": args.evidence,
|
||
"time": now_iso(),
|
||
}
|
||
job["status"] = "blocked"
|
||
job["blocker"] = blocker
|
||
event(state_doc, "blocker", args.job, blocker)
|
||
save_docs(project, job_doc, state_doc)
|
||
print(json.dumps({"status": "blocked", "job": args.job, "reason": args.reason}, ensure_ascii=False, indent=2))
|
||
return 0
|
||
|
||
|
||
def cmd_final_manifest(args: argparse.Namespace) -> int:
|
||
project = project_path(args.project)
|
||
job_doc = load_job_doc(project)
|
||
state_doc = load_state_doc(project)
|
||
assets: List[Dict[str, Any]] = []
|
||
incomplete: List[str] = []
|
||
|
||
for job in job_doc["jobs"]:
|
||
if not isinstance(job, dict):
|
||
continue
|
||
key = job_key(job) or "<unknown>"
|
||
if job.get("status") != "recorded":
|
||
incomplete.append(f"{key}: status={job.get('status', 'unknown')}")
|
||
continue
|
||
if job.get("qa_status") != "passed":
|
||
incomplete.append(f"{key}: qa_status={job.get('qa_status', 'unknown')}")
|
||
continue
|
||
if job.get("entered_assets") is not True:
|
||
incomplete.append(f"{key}: entered_assets=false")
|
||
continue
|
||
|
||
result = job.get("result") if isinstance(job.get("result"), dict) else {}
|
||
asset_value = result.get("asset_out") or job.get("asset_out")
|
||
if not isinstance(asset_value, str) or not asset_value:
|
||
incomplete.append(f"{key}: 缺少 asset_out")
|
||
continue
|
||
|
||
asset_path = resolve_in_project(project, asset_value)
|
||
if not asset_path.exists():
|
||
incomplete.append(f"{key}: assets 文件不存在:{asset_value}")
|
||
continue
|
||
|
||
job["final"] = True
|
||
assets.append(
|
||
{
|
||
"id": key,
|
||
"prompt_file": job.get("prompt_file", ""),
|
||
"origin_out": result.get("origin_out") or job.get("origin_out", ""),
|
||
"asset_out": relative_path_string(project, asset_path),
|
||
"qa_status": job.get("qa_status", ""),
|
||
"qa_note": result.get("qa_note", ""),
|
||
"aspect_ratio": job.get("aspect_ratio", ""),
|
||
"visual_direction": job.get("visual_direction", ""),
|
||
"delivery_batch": job.get("delivery_batch", ""),
|
||
"github_pushed": bool(job.get("github_pushed")),
|
||
"github_batch": job.get("github_batch", ""),
|
||
}
|
||
)
|
||
|
||
if incomplete and not args.allow_incomplete:
|
||
die("仍有未进入最终 manifest 的图片 job:\n" + "\n".join(incomplete))
|
||
if not assets:
|
||
die("没有可写入 final_manifest.json 的最终图片。")
|
||
|
||
manifest_path = resolve_in_project(project, args.out)
|
||
manifest = {
|
||
"version": 1,
|
||
"generated_at": now_iso(),
|
||
"project": project.name,
|
||
"asset_count": len(assets),
|
||
"assets": assets,
|
||
}
|
||
write_json(manifest_path, manifest)
|
||
event(
|
||
state_doc,
|
||
"final_manifest",
|
||
"*",
|
||
{
|
||
"path": relative_path_string(project, manifest_path),
|
||
"asset_count": len(assets),
|
||
"allow_incomplete": args.allow_incomplete,
|
||
},
|
||
)
|
||
save_docs(project, job_doc, state_doc)
|
||
print(
|
||
json.dumps(
|
||
{
|
||
"status": "final_manifest_written",
|
||
"path": relative_path_string(project, manifest_path),
|
||
"assets": len(assets),
|
||
},
|
||
ensure_ascii=False,
|
||
indent=2,
|
||
)
|
||
)
|
||
return 0
|
||
|
||
|
||
def cmd_github_batch(args: argparse.Namespace) -> int:
|
||
project = project_path(args.project)
|
||
job_doc = load_job_doc(project)
|
||
state_doc = load_state_doc(project)
|
||
batches_doc = load_batches_doc(project)
|
||
|
||
rel_paths: List[str] = []
|
||
for value in args.paths:
|
||
path = resolve_in_project(project, value)
|
||
if not path.exists():
|
||
die(f"批次文件不存在:{value}")
|
||
rel_paths.append(relative_path_string(project, path))
|
||
|
||
path_set = set(rel_paths)
|
||
pushed_jobs: List[str] = []
|
||
for job in job_doc["jobs"]:
|
||
if not isinstance(job, dict):
|
||
continue
|
||
result = job.get("result") if isinstance(job.get("result"), dict) else {}
|
||
candidate_values = [job.get("asset_out"), result.get("asset_out")]
|
||
normalized = {
|
||
relative_path_string(project, resolve_in_project(project, value))
|
||
for value in candidate_values
|
||
if isinstance(value, str) and value
|
||
}
|
||
if normalized.intersection(path_set):
|
||
job["github_pushed"] = True
|
||
job["github_batch"] = args.batch
|
||
if isinstance(job.get("result"), dict):
|
||
job["result"]["github_pushed"] = True
|
||
job["result"]["github_batch"] = args.batch
|
||
pushed_jobs.append(job_key(job) or "<unknown>")
|
||
|
||
batch_record = {
|
||
"batch": args.batch,
|
||
"commit": args.commit,
|
||
"url": args.url,
|
||
"release": args.release,
|
||
"paths": rel_paths,
|
||
"pushed_jobs": pushed_jobs,
|
||
"note": args.note,
|
||
"time": now_iso(),
|
||
}
|
||
batches = [item for item in batches_doc["batches"] if not (isinstance(item, dict) and item.get("batch") == args.batch)]
|
||
batches.append(batch_record)
|
||
batches_doc["batches"] = batches
|
||
|
||
manifest_path = project / FINAL_MANIFEST_FILE
|
||
if manifest_path.exists():
|
||
manifest = read_json(manifest_path)
|
||
if isinstance(manifest, dict) and isinstance(manifest.get("assets"), list):
|
||
for item in manifest["assets"]:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
asset_value = item.get("asset_out")
|
||
if not isinstance(asset_value, str) or not asset_value:
|
||
continue
|
||
normalized = relative_path_string(project, resolve_in_project(project, asset_value))
|
||
if normalized in path_set:
|
||
item["github_pushed"] = True
|
||
item["github_batch"] = args.batch
|
||
item["github_commit"] = args.commit
|
||
item["github_url"] = args.url
|
||
write_json(manifest_path, manifest)
|
||
|
||
write_json(project / GITHUB_BATCHES_FILE, batches_doc)
|
||
event(state_doc, "github_batch", "*", batch_record)
|
||
save_docs(project, job_doc, state_doc)
|
||
print(
|
||
json.dumps(
|
||
{
|
||
"status": "github_batch_recorded",
|
||
"batch": args.batch,
|
||
"paths": len(rel_paths),
|
||
"pushed_jobs": pushed_jobs,
|
||
},
|
||
ensure_ascii=False,
|
||
indent=2,
|
||
)
|
||
)
|
||
return 0
|
||
|
||
|
||
def build_parser() -> argparse.ArgumentParser:
|
||
parser = argparse.ArgumentParser(description="Record MAOMOMO article image job state.")
|
||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||
|
||
init = subparsers.add_parser("init", help="Create slide_jobs.json from prompts/*.json.")
|
||
init.add_argument("project")
|
||
init.add_argument("--prompts-dir", default="prompts")
|
||
init.add_argument("--selected-backend", required=True)
|
||
init.set_defaults(func=cmd_init)
|
||
|
||
status = subparsers.add_parser("status", help="Print job status summary.")
|
||
status.add_argument("project")
|
||
status.set_defaults(func=cmd_status)
|
||
|
||
dispatch = subparsers.add_parser("dispatch", help="Record a dispatched image job.")
|
||
dispatch.add_argument("project")
|
||
dispatch.add_argument("--job", required=True)
|
||
dispatch.add_argument("--agent-id", required=True)
|
||
dispatch.add_argument("--agent-nickname", default="")
|
||
dispatch.add_argument("--prompt-file")
|
||
dispatch.set_defaults(func=cmd_dispatch)
|
||
|
||
result = subparsers.add_parser("result", help="Record a generated image result.")
|
||
result.add_argument("project")
|
||
result.add_argument("--job", required=True)
|
||
result.add_argument("--agent-id", required=True)
|
||
result.add_argument("--backend-used", required=True)
|
||
result.add_argument("--selected-source", required=True)
|
||
result.add_argument("--origin-out")
|
||
result.add_argument("--asset-out")
|
||
result.add_argument("--qa-note", default="")
|
||
result.set_defaults(func=cmd_result)
|
||
|
||
blocker = subparsers.add_parser("blocker", help="Record a blocked image job.")
|
||
blocker.add_argument("project")
|
||
blocker.add_argument("--job", required=True)
|
||
blocker.add_argument("--agent-id", required=True)
|
||
blocker.add_argument("--reason", required=True)
|
||
blocker.add_argument("--evidence", default="")
|
||
blocker.set_defaults(func=cmd_blocker)
|
||
|
||
final_manifest = subparsers.add_parser("final-manifest", help="Write final_manifest.json from recorded assets.")
|
||
final_manifest.add_argument("project")
|
||
final_manifest.add_argument("--out", default=FINAL_MANIFEST_FILE)
|
||
final_manifest.add_argument("--allow-incomplete", action="store_true")
|
||
final_manifest.set_defaults(func=cmd_final_manifest)
|
||
|
||
github_batch = subparsers.add_parser("github-batch", help="Record a GitHub delivery batch.")
|
||
github_batch.add_argument("project")
|
||
github_batch.add_argument("--batch", required=True)
|
||
github_batch.add_argument("--commit", required=True)
|
||
github_batch.add_argument("--url", required=True)
|
||
github_batch.add_argument("--release", default="")
|
||
github_batch.add_argument("--paths", nargs="+", required=True)
|
||
github_batch.add_argument("--note", default="")
|
||
github_batch.set_defaults(func=cmd_github_batch)
|
||
return parser
|
||
|
||
|
||
def main(argv: Optional[Iterable[str]] = None) -> int:
|
||
parser = build_parser()
|
||
args = parser.parse_args(list(argv) if argv is not None else None)
|
||
return args.func(args)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|