Version: 0.7.2.dev.260322

feat(schedule-plan):  重构智能排程链路并修复粗排双节对齐问题

-  新增“对话级排程状态持久化”能力:引入 `agent_schedule_states` 模型/DAO,并接入启动迁移
-  智能排程图升级:补齐小幅微调(quick refine)分支,完善预算/并发/状态字段流转
-  预览链路增强:完善排程预览服务读写与桥接逻辑,新增本地预览页 `infra/schedule_preview_viewer.html`
- ♻️ 缓存治理统一:将相关缓存处理收口到 DAO + `cache_deleter` 联动清理,移除旧散落逻辑
- 🐛 修复粗排核心 bug:禁止单节降级,强制双节并按 `1-2/3-4/...` 对齐;修复结束日扫描边界问题
-  新增粗排回归测试:覆盖孤立单节、偶数起点双节、Filler 对齐等关键场景
This commit is contained in:
Losita
2026-03-22 13:50:10 +08:00
parent f3f9902e93
commit e5b27df80d
20 changed files with 1961 additions and 166 deletions

View File

@@ -0,0 +1,768 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SmartFlow 排程预览调试页</title>
<style>
:root {
--bg: #f4f6fb;
--panel: #ffffff;
--line: #dfe5f2;
--text: #1f2430;
--sub: #5b6477;
--accent: #2b6ef2;
--ok: #1f8f4f;
--warn: #d96b00;
--existing: #2962ff;
--suggested: #ff8a00;
--embedded: #1f8f4f;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif;
background: radial-gradient(circle at 0 0, #eef3ff 0, var(--bg) 45%);
color: var(--text);
min-height: 100vh;
}
.page {
display: grid;
grid-template-columns: 360px 1fr;
gap: 14px;
padding: 14px;
min-height: 100vh;
}
.panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 12px;
box-shadow: 0 8px 28px rgba(21, 40, 78, 0.08);
overflow: hidden;
}
.left {
display: flex;
flex-direction: column;
}
.panel-header {
padding: 12px 14px;
border-bottom: 1px solid var(--line);
background: linear-gradient(180deg, #f8faff 0%, #f5f8ff 100%);
}
.panel-header h1 {
margin: 0;
font-size: 16px;
font-weight: 700;
}
.panel-header p {
margin: 6px 0 0;
color: var(--sub);
font-size: 12px;
line-height: 1.45;
}
.input-wrap {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px;
min-height: 0;
flex: 1;
}
textarea {
width: 100%;
min-height: 420px;
resize: vertical;
border: 1px solid var(--line);
border-radius: 10px;
padding: 10px;
font-family: "Consolas", "Monaco", monospace;
font-size: 12px;
line-height: 1.45;
color: #1f2430;
background: #fbfcff;
}
.btn-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
button {
border: 1px solid var(--line);
border-radius: 8px;
padding: 8px 12px;
font-size: 13px;
cursor: pointer;
background: #fff;
color: var(--text);
}
button.primary {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.error {
color: #c62828;
font-size: 12px;
min-height: 16px;
}
.right {
display: flex;
flex-direction: column;
min-height: 0;
}
.meta {
padding: 12px 14px;
border-bottom: 1px solid var(--line);
display: grid;
gap: 8px;
}
.meta-row {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
color: var(--sub);
font-size: 12px;
}
.meta label {
font-weight: 600;
color: var(--text);
}
select {
border: 1px solid var(--line);
border-radius: 8px;
padding: 6px 10px;
background: #fff;
font-size: 13px;
}
.summary {
background: #f8fbff;
border: 1px dashed #c9d9ff;
border-radius: 10px;
padding: 8px 10px;
color: #2f3a4f;
font-size: 13px;
line-height: 1.45;
white-space: pre-wrap;
}
.grid-wrap {
overflow: auto;
padding: 12px;
flex: 1;
min-height: 0;
}
.week-grid {
min-width: 980px;
display: grid;
grid-template-columns: 74px repeat(7, minmax(120px, 1fr));
grid-template-rows: 38px repeat(12, 52px);
gap: 0;
border: 1px solid var(--line);
border-radius: 10px;
overflow: hidden;
background: #fff;
position: relative;
}
.cell {
border-right: 1px solid var(--line);
border-bottom: 1px solid var(--line);
padding: 6px;
font-size: 12px;
color: var(--sub);
background: #fff;
}
.head {
background: #f7f9ff;
font-weight: 700;
color: #344058;
display: flex;
align-items: center;
justify-content: center;
}
.section {
background: #fafbff;
text-align: center;
padding-top: 10px;
font-size: 11px;
color: #56607b;
}
.slot {
background: #fcfdff;
}
.event {
margin: 2px;
border-radius: 8px;
border: 1px solid transparent;
color: #fff;
padding: 6px 7px;
overflow: hidden;
font-size: 11px;
line-height: 1.35;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.12);
z-index: 2;
}
.event.existing {
background: linear-gradient(180deg, #4c80ff 0%, #2f63de 100%);
border-color: #1f53d7;
}
.event.suggested {
background: linear-gradient(180deg, #ff9f3b 0%, #f27d07 100%);
border-color: #dc6e00;
}
.event.task {
background: linear-gradient(180deg, #32b56b 0%, #1d924f 100%);
border-color: #177a42;
}
.event .title {
font-weight: 700;
margin-bottom: 3px;
word-break: break-all;
}
.event .meta-text {
opacity: 0.95;
}
.embedded-list {
margin-top: 4px;
display: grid;
gap: 3px;
}
.embedded-item {
background: rgba(255, 255, 255, 0.17);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 6px;
padding: 2px 4px;
font-size: 10px;
}
.legend {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
font-size: 12px;
color: var(--sub);
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
margin-right: 4px;
}
.unplaced {
margin-top: 10px;
border: 1px dashed var(--line);
border-radius: 10px;
padding: 8px;
font-size: 12px;
color: #37445f;
background: #fcfdff;
}
.empty {
padding: 18px;
text-align: center;
color: #7a849a;
font-size: 13px;
}
@media (max-width: 960px) {
.page {
grid-template-columns: 1fr;
}
textarea {
min-height: 280px;
}
}
</style>
</head>
<body>
<div class="page">
<div class="panel left">
<div class="panel-header">
<h1>排程预览 JSON 输入</h1>
<p>粘贴 <code>/api/v1/agent/schedule-preview</code> 响应,点击“解析并渲染”。</p>
</div>
<div class="input-wrap">
<textarea id="jsonInput" spellcheck="false"></textarea>
<div class="btn-row">
<button class="primary" id="renderBtn">解析并渲染</button>
<button id="exampleBtn">填入示例</button>
<button id="clearBtn">清空</button>
</div>
<div class="error" id="errorText"></div>
</div>
</div>
<div class="panel right">
<div class="meta">
<div class="meta-row">
<label for="weekSelect">当前周</label>
<select id="weekSelect"></select>
<span id="conversationMeta">conversation_id: -</span>
<span id="traceMeta">trace_id: -</span>
<span id="timeMeta">generated_at: -</span>
</div>
<div class="summary" id="summaryText">这里会显示排程摘要。</div>
<div class="legend">
<span><i class="dot" style="background:#2f63de"></i>existing课程/已存在安排)</span>
<span><i class="dot" style="background:#f27d07"></i>suggested建议任务</span>
<span><i class="dot" style="background:#1d924f"></i>task普通任务</span>
<span><i class="dot" style="background:#1f8f4f"></i>嵌入任务(显示在课程块内)</span>
</div>
</div>
<div class="grid-wrap" id="gridWrap">
<div class="empty">先粘贴 JSON 再渲染课表。</div>
</div>
</div>
</div>
<script>
const DAYS = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"];
const SECTION_TIME = {
1: ["08:00", "08:45"],
2: ["08:55", "09:40"],
3: ["10:15", "11:00"],
4: ["11:10", "11:55"],
5: ["14:00", "14:45"],
6: ["14:55", "15:40"],
7: ["16:15", "17:00"],
8: ["17:10", "17:55"],
9: ["19:00", "19:45"],
10: ["19:55", "20:40"],
11: ["20:50", "21:35"],
12: ["21:45", "22:30"]
};
const START_TIME_TO_SECTION = Object.fromEntries(
Object.entries(SECTION_TIME).map(([s, [start]]) => [start, Number(s)])
);
const state = {
raw: null,
weekMap: new Map()
};
const jsonInput = document.getElementById("jsonInput");
const renderBtn = document.getElementById("renderBtn");
const exampleBtn = document.getElementById("exampleBtn");
const clearBtn = document.getElementById("clearBtn");
const errorText = document.getElementById("errorText");
const weekSelect = document.getElementById("weekSelect");
const summaryText = document.getElementById("summaryText");
const conversationMeta = document.getElementById("conversationMeta");
const traceMeta = document.getElementById("traceMeta");
const timeMeta = document.getElementById("timeMeta");
const gridWrap = document.getElementById("gridWrap");
function setError(message) {
errorText.textContent = message || "";
}
function isObject(v) {
return v && typeof v === "object" && !Array.isArray(v);
}
function normalizeEvent(raw, index) {
const e = isObject(raw) ? raw : {};
const day = Number(e.day_of_week);
const span = Math.max(1, Number(e.span) || 1);
const guessedFrom = START_TIME_TO_SECTION[String(e.start_time || "").trim()];
const sectionFrom = Number.isFinite(guessedFrom) ? guessedFrom : null;
const sectionTo = sectionFrom !== null ? sectionFrom + span - 1 : null;
const embedded = isObject(e.embedded_task_info) ? e.embedded_task_info : null;
return {
__index: index,
id: e.id,
order: Number(e.order) || 0,
dayOfWeek: Number.isFinite(day) ? day : null,
name: String(e.name || "未命名"),
startTime: String(e.start_time || ""),
endTime: String(e.end_time || ""),
type: String(e.type || ""),
status: String(e.status || ""),
span,
sectionFrom,
sectionTo,
embeddedTaskInfo: embedded,
embeddedTasks: []
};
}
function parseInput(text) {
const root = JSON.parse(text);
if (!isObject(root)) {
throw new Error("根对象必须是 JSON Object。");
}
// 1. 兼容两种结构:
// 1.1 新结构:{ status, info, data: { ...真正字段... } }
// 1.2 旧结构:{ conversation_id, summary, candidate_plans, ... }
// 2. 若 data 存在则优先使用 data避免误读外层字段。
const payload = isObject(root.data) ? root.data : root;
const candidatePlans = Array.isArray(payload.candidate_plans) ? payload.candidate_plans : [];
if (!Array.isArray(payload.candidate_plans)) {
throw new Error("缺少 data.candidate_plans 数组。");
}
return {
conversationId: String(payload.conversation_id || ""),
traceId: String(payload.trace_id || ""),
generatedAt: String(payload.generated_at || ""),
summary: String(payload.summary || ""),
candidatePlans
};
}
function buildWeekMap(candidatePlans) {
const map = new Map();
for (const plan of candidatePlans) {
const weekNo = Number(plan?.week);
if (!Number.isFinite(weekNo)) continue;
const events = Array.isArray(plan?.events) ? plan.events : [];
map.set(
weekNo,
events.map((e, i) => normalizeEvent(e, i)).filter((e) => Number.isFinite(e.dayOfWeek) && e.dayOfWeek >= 1 && e.dayOfWeek <= 7)
);
}
return map;
}
function mergeEmbeddedEvents(events) {
// 1. 先按“同一天 + 同时间段”聚合,识别课程与任务重叠场景。
// 2. 如果同槽位同时出现 existing 课程 + 任务,则把任务并入课程卡片中显示。
// 3. 兜底:不满足合并条件的事件保持原样,避免误吞数据。
const slotMap = new Map();
for (const e of events) {
const key = `${e.dayOfWeek}_${e.sectionFrom}_${e.sectionTo}`;
if (!slotMap.has(key)) slotMap.set(key, []);
slotMap.get(key).push(e);
}
const merged = [];
for (const list of slotMap.values()) {
const course = list.find((e) => e.type === "course" || e.status === "existing");
const tasks = list.filter((e) => !course || e !== course);
if (course && tasks.length > 0) {
const taskLines = [];
if (course.embeddedTaskInfo?.name) {
taskLines.push({
id: course.embeddedTaskInfo.id,
name: course.embeddedTaskInfo.name,
type: course.embeddedTaskInfo.type || "task",
from: "embedded_task_info"
});
}
for (const t of tasks) {
taskLines.push({
id: t.id,
name: t.name,
type: t.type || "task",
status: t.status || "",
from: "overlap_event"
});
}
course.embeddedTasks = taskLines;
merged.push(course);
continue;
}
if (course && course.embeddedTaskInfo?.name) {
course.embeddedTasks = [{
id: course.embeddedTaskInfo.id,
name: course.embeddedTaskInfo.name,
type: course.embeddedTaskInfo.type || "task",
from: "embedded_task_info"
}];
merged.push(course);
continue;
}
merged.push(...list);
}
return merged;
}
function assignLanesByDay(events) {
// 1. 按“每天”独立分配 lane避免跨天互相影响宽度。
// 2. 用贪心法给重叠区间分轨:能复用轨道就复用,否则开新轨道。
// 3. 最后给每个事件附上 dayLaneCount渲染时按轨道等分宽度。
const byDay = new Map();
for (const e of events) {
if (!byDay.has(e.dayOfWeek)) byDay.set(e.dayOfWeek, []);
byDay.get(e.dayOfWeek).push(e);
}
for (const [, dayEvents] of byDay) {
dayEvents.sort((a, b) => {
if (a.sectionFrom !== b.sectionFrom) return a.sectionFrom - b.sectionFrom;
if (a.sectionTo !== b.sectionTo) return a.sectionTo - b.sectionTo;
return a.order - b.order;
});
const laneEnds = [];
for (const e of dayEvents) {
let lane = -1;
for (let i = 0; i < laneEnds.length; i += 1) {
if (e.sectionFrom > laneEnds[i]) {
lane = i;
break;
}
}
if (lane === -1) {
lane = laneEnds.length;
laneEnds.push(0);
}
laneEnds[lane] = e.sectionTo;
e.__lane = lane;
}
const laneCount = Math.max(1, laneEnds.length);
for (const e of dayEvents) e.__laneCount = laneCount;
}
}
function buildWeekGrid(events) {
const weekGrid = document.createElement("div");
weekGrid.className = "week-grid";
const topLeft = document.createElement("div");
topLeft.className = "cell head";
topLeft.textContent = "节次";
weekGrid.appendChild(topLeft);
for (let i = 0; i < 7; i += 1) {
const head = document.createElement("div");
head.className = "cell head";
head.style.gridColumn = String(i + 2);
head.style.gridRow = "1";
head.textContent = DAYS[i];
weekGrid.appendChild(head);
}
for (let section = 1; section <= 12; section += 1) {
const [start, end] = SECTION_TIME[section];
const label = document.createElement("div");
label.className = "cell section";
label.style.gridColumn = "1";
label.style.gridRow = String(section + 1);
label.innerHTML = `<div>${section}</div><div>${start}-${end}</div>`;
weekGrid.appendChild(label);
for (let day = 1; day <= 7; day += 1) {
const slot = document.createElement("div");
slot.className = "cell slot";
slot.style.gridColumn = String(day + 1);
slot.style.gridRow = String(section + 1);
weekGrid.appendChild(slot);
}
}
const unplaced = [];
for (const e of events) {
if (!Number.isFinite(e.sectionFrom) || !Number.isFinite(e.sectionTo)) {
unplaced.push(e);
continue;
}
const rowStart = e.sectionFrom + 1;
const rowEnd = e.sectionTo + 2;
const col = e.dayOfWeek + 1;
const laneCount = e.__laneCount || 1;
const lane = e.__lane || 0;
const gap = 4;
const width = `calc((100% - ${(laneCount - 1) * gap}px) / ${laneCount})`;
const left = `calc(${lane} * (${width} + ${gap}px))`;
const card = document.createElement("div");
card.className = "event";
if (e.status === "existing" || e.type === "course") {
card.classList.add("existing");
} else if (e.status === "suggested") {
card.classList.add("suggested");
} else {
card.classList.add("task");
}
card.style.gridColumn = String(col);
card.style.gridRow = `${rowStart}/${rowEnd}`;
card.style.width = width;
card.style.marginLeft = left;
const header = document.createElement("div");
header.className = "title";
header.textContent = e.name;
card.appendChild(header);
const detail = document.createElement("div");
detail.className = "meta-text";
detail.textContent = `${e.startTime || "-"} - ${e.endTime || "-"} ${e.type || "-"} ${e.status || "-"}`;
card.appendChild(detail);
if (Array.isArray(e.embeddedTasks) && e.embeddedTasks.length > 0) {
const list = document.createElement("div");
list.className = "embedded-list";
for (const task of e.embeddedTasks) {
const item = document.createElement("div");
item.className = "embedded-item";
item.textContent = `嵌入:${task.name}${task.status ? `${task.status}` : ""}`;
list.appendChild(item);
}
card.appendChild(list);
}
weekGrid.appendChild(card);
}
const container = document.createElement("div");
container.appendChild(weekGrid);
if (unplaced.length > 0) {
const box = document.createElement("div");
box.className = "unplaced";
box.innerHTML = `<strong>未定位到节次的事件(无法映射 start_time</strong><br>${unplaced.map((e) => `- D${e.dayOfWeek} ${e.name} (${e.startTime || "?"} - ${e.endTime || "?"})`).join("<br>")}`;
container.appendChild(box);
}
return container;
}
function renderWeek(weekNo) {
const weekEvents = state.weekMap.get(weekNo) || [];
const merged = mergeEmbeddedEvents(weekEvents.map((e) => ({ ...e })));
assignLanesByDay(merged.filter((e) => Number.isFinite(e.sectionFrom) && Number.isFinite(e.sectionTo)));
gridWrap.innerHTML = "";
if (merged.length === 0) {
gridWrap.innerHTML = `<div class="empty">第 ${weekNo} 周没有可显示的事件。</div>`;
return;
}
gridWrap.appendChild(buildWeekGrid(merged));
}
function fillMeta(parsed) {
conversationMeta.textContent = `conversation_id: ${parsed.conversationId || "-"}`;
traceMeta.textContent = `trace_id: ${parsed.traceId || "-"}`;
timeMeta.textContent = `generated_at: ${parsed.generatedAt || "-"}`;
summaryText.textContent = parsed.summary || "(无摘要)";
}
function fillWeekSelector() {
const weeks = Array.from(state.weekMap.keys()).sort((a, b) => a - b);
weekSelect.innerHTML = "";
for (const week of weeks) {
const op = document.createElement("option");
op.value = String(week);
op.textContent = `${week}`;
weekSelect.appendChild(op);
}
if (weeks.length > 0) renderWeek(weeks[0]);
}
function renderFromInput() {
setError("");
try {
const parsed = parseInput(jsonInput.value.trim());
const map = buildWeekMap(parsed.candidatePlans);
state.raw = parsed;
state.weekMap = map;
fillMeta(parsed);
if (map.size === 0) {
weekSelect.innerHTML = "";
gridWrap.innerHTML = `<div class="empty">candidate_plans 为空,当前没有可渲染的周课表。</div>`;
} else {
fillWeekSelector();
}
} catch (err) {
setError(`解析失败:${err.message || err}`);
}
}
function setExample() {
jsonInput.value = JSON.stringify({
status: "10000",
info: "success",
data: {
conversation_id: "demo-conv-001",
trace_id: "demo-trace-001",
summary: "本周建议将高强度任务集中在上午,课程空档用于轻量复习。",
generated_at: "2026-03-22T15:12:00+08:00",
candidate_plans: [
{
week: 13,
events: [
{ day_of_week: 1, name: "高等数学", start_time: "08:00", end_time: "09:40", type: "course", span: 2, status: "existing" },
{ day_of_week: 1, name: "数电错题整理", start_time: "08:00", end_time: "09:40", type: "task", span: 2, status: "suggested" },
{ day_of_week: 1, name: "数据结构复习", start_time: "10:15", end_time: "11:55", type: "task", span: 2, status: "suggested" },
{ day_of_week: 2, name: "英语阅读", start_time: "19:00", end_time: "20:40", type: "task", span: 2, status: "suggested" },
{
day_of_week: 3,
name: "离散数学",
start_time: "14:00",
end_time: "15:40",
type: "course",
span: 2,
status: "existing",
embedded_task_info: { id: 99, name: "离散小测回顾", type: "task" }
}
]
}
]
}
}, null, 2);
}
renderBtn.addEventListener("click", renderFromInput);
weekSelect.addEventListener("change", () => renderWeek(Number(weekSelect.value)));
exampleBtn.addEventListener("click", () => {
setExample();
renderFromInput();
});
clearBtn.addEventListener("click", () => {
jsonInput.value = "";
setError("");
state.raw = null;
state.weekMap = new Map();
summaryText.textContent = "这里会显示排程摘要。";
conversationMeta.textContent = "conversation_id: -";
traceMeta.textContent = "trace_id: -";
timeMeta.textContent = "generated_at: -";
weekSelect.innerHTML = "";
gridWrap.innerHTML = `<div class="empty">先粘贴 JSON 再渲染课表。</div>`;
});
setExample();
</script>
</body>
</html>