Commit 188d65b3 authored by 郭阔's avatar 郭阔

feat(panel): multi-run window semantics + configurable global gap

- freq>=2 with a preferred window now means: first run in the overnight
  deadline batch + remaining runs spread in the window (reuters-world
  2×/day+morning → 04:00 overnight + 07:00 morning)
- global min gap is a dropdown on the schedule board (10..120 min),
  saved via /schedule-gap and immediately re-planned
- applied: gap 30min, reuters-world 04:00/07:00
parent fa73eb4c
......@@ -61,12 +61,19 @@ def plan(conf):
day = timedelta(days=1)
fixed = conf.get("fixed_times", {})
windows = conf.get("windows", {}) # {"<source>": "morning|afternoon|evening"}
# 分组:固定时刻 / 时段偏好(在偏好时段内均布)/ 常规(晨间批次+白天均布)
# 分组:固定时刻 / 时段偏好 / 常规(晨间批次+白天均布)
# 时段偏好在多次场景的语义:首跑留在凌晨交付批次,其余跑进偏好时段均布
# (例:每日2次+上午 = 凌晨1次(6点前交付) + 上午1次)
fixed_src = [s for s in conf["frequencies"] if s in fixed]
win_src = [s for s in conf["frequencies"]
if s not in fixed and windows.get(s) in WINDOW_PRESETS]
sources = sorted((s for s in conf["frequencies"]
if s not in fixed and s not in win_src),
single_win = [s for s in conf["frequencies"]
if s not in fixed and windows.get(s) in WINDOW_PRESETS
and conf["frequencies"][s] == 1]
multi_win = [s for s in conf["frequencies"]
if s not in fixed and windows.get(s) in WINDOW_PRESETS
and conf["frequencies"][s] > 1]
sources = sorted([s for s in conf["frequencies"]
if s not in fixed and s not in single_win and s not in multi_win]
+ multi_win, # 多次偏好源参与晨间批次拿首跑槽
key=lambda s: -conf["frequencies"][s])
# 晨间槽:deadline-safety 为最后起始槽,逆序铺(多次源拿到最新鲜的槽);
# 负时间归一化到前晚(22:10 等合法:早于次日交付线)
......@@ -77,16 +84,20 @@ def plan(conf):
for s in sources:
freq = conf["frequencies"][s]
times = [morning[s]]
if freq > 1:
if s in multi_win:
# 首跑在凌晨批次,其余均布进偏好时段
w0, w1 = (hm(x) for x in WINDOW_PRESETS[windows[s]])
extra = freq - 1
span = (w1 - w0) / extra if extra > 1 else timedelta()
times += [w0 if extra == 1 else w0 + span * k for k in range(extra)]
elif freq > 1:
span = (dw1 - dw0) / freq
times += [dw0 + span * (k + 1) for k in range(freq - 1)]
result[s] = times
# 时段偏好源:全部运行落在其偏好时段内均布(不占晨间批次,交付线由偏好方自行权衡)
for s in win_src:
freq = conf["frequencies"][s]
w0, w1 = (hm(x) for x in WINDOW_PRESETS[windows[s]])
span = (w1 - w0) / freq if freq > 1 else timedelta()
result[s] = [w0 if freq == 1 else w0 + span * k for k in range(freq)]
# 单次时段偏好源:唯一一次运行落在偏好时段开头
for s in single_win:
w0, _ = (hm(x) for x in WINDOW_PRESETS[windows[s]])
result[s] = [w0]
for s, ts in fixed.items():
result[s] = [hm(t) for t in ts]
avoid = [(hm(k) - timedelta(minutes=v), hm(k) + timedelta(minutes=v))
......
......@@ -1332,7 +1332,8 @@ def schedule_board():
"window": wins.get(n, "")}
return render_template("schedules.html", rows=rows, now=now,
digest_next=_next_fire(digest_times, now),
digest_times=digest_times)
digest_times=digest_times,
plan_gap=plan_conf.get("min_gap_min", 40))
def _valid_times(raw):
......@@ -1419,6 +1420,33 @@ def schedule_intent():
return redirect(url_for("schedule_board"))
@app.route("/schedule-gap", methods=["POST"])
@require_auth
def schedule_gap():
"""设置全局最小间隔(分钟),保存后自动重排。"""
try:
g = int(request.form.get("gap", "40"))
except ValueError:
g = 40
if g not in (10, 15, 20, 25, 30, 40, 45, 60, 90, 120):
flash("间隔值不合法(可选 10/15/20/25/30/40/45/60/90/120 分钟)", "error")
return redirect(url_for("schedule_board"))
conf_path = pl.default_conf_dir(pl.default_work_dir()) / "schedule-plan.json"
try:
conf = json.loads(Path(conf_path).read_text(encoding="utf-8"))
except (OSError, ValueError):
conf = {}
conf["min_gap_min"] = g
Path(conf_path).write_text(json.dumps(conf, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8")
r = subprocess.run(
["python3", str(Path(__file__).resolve().parent.parent / "bin" / "plan_schedules.py")],
capture_output=True, text=True, timeout=300)
flash(f"最小间隔已设为 {g} 分钟,全部时间已重排\n" + (r.stdout or "")[-2000:],
"success" if r.returncode == 0 else "error")
return redirect(url_for("schedule_board"))
@app.route("/schedule-plan-apply", methods=["POST"])
@require_auth
def schedule_plan_apply():
......
......@@ -28,10 +28,18 @@
每日汇总 digest:{{ digest_times|join(', ') if digest_times else '未排' }}{% if digest_next %},下次 {{ digest_next.strftime('%m-%d %H:%M') }}{% endif %}。
时间格式 HH:MM,多个用逗号分隔(如 05:00,17:00 = 每日两次);删除仅移除调度,技能源与历史保留。
</p>
<form method="POST" action="{{ url_for('schedule_plan_apply') }}" style="margin:6px 0 12px"
<form method="POST" action="{{ url_for('schedule_gap') }}" style="margin:6px 0 4px;display:inline">
全局最小间隔:
<select name="gap" style="background:#1a1d27;border:1px solid #2a2d37;border-radius:4px;color:#d4d4d4;padding:3px 6px">
{% for g in [10,15,20,25,30,40,45,60,90,120] %}
<option value="{{ g }}" {% if plan_gap == g %}selected{% endif %}>{{ g }} 分钟</option>
{% endfor %}
</select>
<button type="submit" class="btn btn-sm">保存并重排</button>
</form>
<form method="POST" action="{{ url_for('schedule_plan_apply') }}" style="margin:6px 0 12px;display:inline"
onsubmit="return confirm('按 schedule-plan.json(频率/交付线/间隔)重排全部源时间?将覆盖现有排程。')">
<button type="submit" class="btn btn-primary">⚙ 按计划重排全部排程</button>
<span style="color:#888;font-size:12px;margin-left:8px">配置:~/NewsHarvester/etc/news-harvester/schedule-plan.json(频率/交付线/间隔可改,改完点此按钮生效)</span>
</form>
{% with messages = get_flashed_messages() %}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment