fix: require manual summary fields before registration

This commit is contained in:
Codex
2026-06-15 16:35:46 +08:00
parent 0f854cc185
commit ea203c4195
4 changed files with 174 additions and 79 deletions
+81 -72
View File
@@ -975,34 +975,6 @@ def extract_course_summary_from_text(text: str, index: int = 0, known_students:
} }
def manual_review_summary(raw: dict, error: Exception) -> dict:
body = str(raw.get("body") or raw.get("content") or "").strip()
source_id = str(raw.get("source_id") or "").strip() or f"manual:{sha1_text(body, 24)}"
return {
"source_id": source_id,
"student": canonical_name(str(raw.get("student") or "").strip()) or "待核对学生",
"date_iso": str(raw.get("date_iso") or raw.get("date") or "").strip(),
"time_range": str(raw.get("time_range") or raw.get("time") or "").strip(),
"duration_minutes": raw.get("duration_minutes"),
"duration": str(raw.get("duration") or "").strip(),
"teacher": canonical_name(str(raw.get("teacher") or "").strip()),
"subject": parse_subject_code(str(raw.get("subject") or "").strip()),
"group": str(raw.get("group") or "").strip(),
"sender": str(raw.get("sender") or "管理后台").strip(),
"sender_id": str(raw.get("sender_id") or "").strip(),
"message_time": str(raw.get("message_time") or "").strip(),
"message_date": str(raw.get("message_date") or "").strip(),
"db": str(raw.get("db") or "").strip(),
"local_id": str(raw.get("local_id") or "").strip(),
"title": str(raw.get("title") or "手工登记课程小结").strip(),
"body": body or str(error),
"recognition_source": "manual_admin",
"confidence": "manual_review",
"teacher_trusted": True,
"remark": f"手工登记待审核:{error}",
}
def normalize_course_summary(raw: dict) -> dict: def normalize_course_summary(raw: dict) -> dict:
student = canonical_name(str(raw.get("student") or "").strip()) student = canonical_name(str(raw.get("student") or "").strip())
teacher = canonical_name(str(raw.get("teacher") or "").strip()) teacher = canonical_name(str(raw.get("teacher") or "").strip())
@@ -1510,8 +1482,6 @@ def register_course_summary_texts(
now = datetime.now().isoformat(timespec="seconds") now = datetime.now().isoformat(timespec="seconds")
accounts = read_accounts(accounts_path) accounts = read_accounts(accounts_path)
known_students = [account.student for account in accounts] known_students = [account.student for account in accounts]
summaries: list[dict] = []
manual_review_items: list[dict] = []
result = { result = {
"received": len(texts), "received": len(texts),
"saved": 0, "saved": 0,
@@ -1523,60 +1493,99 @@ def register_course_summary_texts(
"items": [], "items": [],
} }
for index, text in enumerate(texts): for index, text in enumerate(texts):
raw = extract_course_summary_from_text(text, index, known_students=known_students) raw: dict | None = None
normalized: dict | None = None
try: try:
normalize_course_summary(raw) raw = extract_course_summary_from_text(text, index, known_students=known_students)
summaries.append(raw) normalized = normalize_course_summary(raw)
except ValueError as exc: source_id = normalized["source_id"]
summary = manual_review_summary(raw, exc) semantic_key = course_summary_semantic_key(normalized)
saved = save_course_summary_markdown(summaries_root, summary) state = read_course_summary_state(state_path)
task = create_course_summary_review_task( seen_source_ids = set(str(item) for item in state.get("seen_source_ids", []))
tasks_path, seen_semantic_keys = set(str(item) for item in state.get("seen_semantic_keys", []))
summary, if source_id in seen_source_ids or semantic_key in seen_semantic_keys:
"", result["duplicates"] += 1
[str(exc), "手工登记课程小结需人工补全"],
saved_path=str(saved.get("path") or ""),
)
log_id = append_operation_log( log_id = append_operation_log(
operation_logs_path, operation_logs_path,
"course_summary_ingest", "course_summary_manual_register",
"review", "duplicate",
batch_id=f"manual-{datetime.now().strftime('%Y%m%d%H%M%S')}", source_id=source_id,
source_id=summary["source_id"], student=normalized["student"],
student=summary["student"], )
reasons=[str(exc), "手工登记课程小结需人工补全"], result["operation_log_ids"].append(log_id)
task_id=task.get("id"), result["items"].append({"source_id": source_id, "status": "duplicate"})
continue
reasons, proposed_line = auto_register_reasons(normalized, classnotes_path, accounts_path)
if reasons:
raise ValueError("".join(reasons))
saved = save_course_summary_markdown(summaries_root, normalized)
register_result = register_class_record_lines(classnotes_path, accounts_path, line=proposed_line)
seen_source_ids.add(source_id)
seen_semantic_keys.add(semantic_key)
state["seen_source_ids"] = sorted(seen_source_ids)
state["seen_semantic_keys"] = sorted(seen_semantic_keys)
state.setdefault("batches", []).append(
{
"batch_id": f"manual-{datetime.now().strftime('%Y%m%d%H%M%S')}-{sha1_text(source_id, 8)}",
"received_at": now,
"window": {"source": "admin_register", "submitted_at": now},
"students": [normalized["student"]],
"result": {
"received": 1,
"saved": 1 if saved.get("added") else 0,
"auto_registered": 1,
"review_pending": 0,
"duplicates": 0,
"rejected": 0,
},
}
)
state["batches"] = state["batches"][-200:]
write_course_summary_state(state_path, state)
log_id = append_operation_log(
operation_logs_path,
"course_summary_manual_register",
"auto_registered",
source_id=source_id,
student=normalized["student"],
teacher=normalized.get("teacher", ""),
subject=normalized.get("subject", ""),
proposed_line=proposed_line,
backup_id=str(register_result.get("backup_id") or ""),
saved_path=str(saved.get("path") or ""), saved_path=str(saved.get("path") or ""),
) )
result["saved"] += 1 if saved.get("added") else 0 result["saved"] += 1 if saved.get("added") else 0
result["review_pending"] += 1 result["auto_registered"] += 1
result["operation_log_ids"].append(log_id) result["operation_log_ids"].append(log_id)
result["items"].append( result["items"].append(
{ {
"source_id": summary["source_id"], "source_id": source_id,
"status": "review", "status": "auto_registered",
"task_id": task.get("id"), "backup_id": str(register_result.get("backup_id") or ""),
"reasons": [str(exc), "手工登记课程小结需人工补全"],
} }
) )
if not summaries: except ValueError as exc:
return result source_id = str((normalized or raw or {}).get("source_id") or f"manual:{sha1_text(text, 24)}")
ingest_result = ingest_course_summaries( student = str((normalized or raw or {}).get("student") or "")
classnotes_path=classnotes_path, log_id = append_operation_log(
accounts_path=accounts_path, operation_logs_path,
tasks_path=tasks_path, "course_summary_manual_register",
summaries_root=summaries_root, "rejected",
state_path=state_path, source_id=source_id,
operation_logs_path=operation_logs_path, student=student,
batch_id=f"manual-{datetime.now().strftime('%Y%m%d%H%M%S')}-{sha1_text('|'.join(texts), 8)}", error=str(exc),
window={"source": "admin_register", "submitted_at": now}, )
students=[], result["rejected"] += 1
summaries=summaries, result["operation_log_ids"].append(log_id)
result["items"].append(
{
"source_id": source_id,
"status": "rejected",
"error": str(exc),
}
) )
for key in ("saved", "auto_registered", "review_pending", "duplicates", "rejected"):
result[key] += int(ingest_result.get(key) or 0)
result["operation_log_ids"].extend(ingest_result.get("operation_log_ids") or [])
result["items"].extend(ingest_result.get("items") or [])
return result return result
+1 -1
View File
@@ -319,6 +319,6 @@
</section> </section>
</main> </main>
<script src="/static/admin.js?v=20260615-summary-register-delete"></script> <script src="/static/admin.js?v=20260615-summary-register-complete"></script>
</body> </body>
</html> </html>
+67 -6
View File
@@ -75,6 +75,14 @@ let currentSummaryReviews = [];
let activeSummaryReview = null; let activeSummaryReview = null;
let summaryRegisterItemSeq = 0; let summaryRegisterItemSeq = 0;
const SUMMARY_REQUIRED_FIELDS = [
["student", "学生"],
["date", "日期"],
["time", "时间"],
["teacher", "老师"],
["subject", "科目"],
];
function fmtHours(value) { function fmtHours(value) {
return Number(value || 0).toLocaleString("zh-CN", { maximumFractionDigits: 2 }); return Number(value || 0).toLocaleString("zh-CN", { maximumFractionDigits: 2 });
} }
@@ -596,20 +604,73 @@ function addSummaryRegisterItem(value = "") {
const item = document.createElement("div"); const item = document.createElement("div");
item.className = "summary-register-item"; item.className = "summary-register-item";
item.innerHTML = `<label for="${itemId}">课程小结</label> item.innerHTML = `<label for="${itemId}">课程小结</label>
<textarea id="${itemId}" rows="7" placeholder="每个输入框填写一条课程小结">${escapeHtml(value)}</textarea> <textarea id="${itemId}" data-summary-field="body" rows="7" placeholder="每个输入框填写一条课程小结">${escapeHtml(value)}</textarea>
<div class="summary-register-fields">
<input data-summary-field="student" autocomplete="off" placeholder="学生" />
<input data-summary-field="date" autocomplete="off" placeholder="日期 2026.06.15" />
<input data-summary-field="time" autocomplete="off" placeholder="时间 08:00-09:00" />
<input data-summary-field="teacher" autocomplete="off" placeholder="老师" />
<input data-summary-field="subject" autocomplete="off" placeholder="科目" />
</div>
<p class="summary-register-missing" hidden></p>
<button class="small-button summary-register-remove" type="button">删除本框</button>`; <button class="small-button summary-register-remove" type="button">删除本框</button>`;
summaryRegisterItems.appendChild(item); summaryRegisterItems.appendChild(item);
} }
function inferSummaryField(body, field) {
const patterns = {
student: /(?:学生|学员)[:]\s*([^\n;,]+)/,
date: /(?:日期|上课日期)[:]\s*(\d{4}[./-]\d{1,2}[./-]\d{1,2})|(\d{4}[./-]\d{1,2}[./-]\d{1,2})/,
time: /(?:时间|上课时间)[:]\s*(\d{1,2}:\d{2}-\d{1,2}:\d{2})|(\d{1,2}:\d{2}-\d{1,2}:\d{2})/,
teacher: /(?:老师|教师)[:]\s*([^\n;,]+)|([\u4e00-\u9fa5A-Za-z0-9]{1,8}老师)/,
subject: /(?:科目|课程)[:]\s*([^\n;,]+)|(数学|语文|英语|物理|化学|生物|历史|地理|政治|道法)/,
};
const match = String(body || "").match(patterns[field]);
if (!match) return "";
return (match[1] || match[2] || "").trim();
}
function summaryRegisterItemPayload(item) {
const body = item.querySelector("[data-summary-field='body']").value.trim();
const values = { body };
SUMMARY_REQUIRED_FIELDS.forEach(([field]) => {
const input = item.querySelector(`[data-summary-field='${field}']`);
values[field] = input.value.trim() || inferSummaryField(body, field);
if (!input.value.trim() && values[field]) input.value = values[field];
});
const missing = SUMMARY_REQUIRED_FIELDS.filter(([field]) => !values[field]).map(([, label]) => label);
const missingNode = item.querySelector(".summary-register-missing");
missingNode.hidden = missing.length === 0;
missingNode.textContent = missing.length ? `请补齐:${missing.join("、")};无法补齐请删除本框,本条不会提交。` : "";
item.classList.toggle("has-missing", missing.length > 0);
if (!body || missing.length) return null;
return [
`学生:${values.student}`,
`日期:${values.date}`,
`时间:${values.time}`,
`老师:${values.teacher}`,
`科目:${values.subject}`,
"小结:",
body,
].join("\n");
}
function summaryRegisterLines() { function summaryRegisterLines() {
return Array.from(summaryRegisterItems.querySelectorAll("textarea")) const items = Array.from(summaryRegisterItems.querySelectorAll(".summary-register-item"));
.map((textarea) => textarea.value.trim()) const lines = items.map(summaryRegisterItemPayload).filter(Boolean);
.filter(Boolean); return {
lines,
blocked: items.some((item) => item.classList.contains("has-missing")),
};
} }
async function submitSummaryRegister(event) { async function submitSummaryRegister(event) {
event.preventDefault(); event.preventDefault();
const lines = summaryRegisterLines(); const { lines, blocked } = summaryRegisterLines();
if (blocked) {
summaryRegisterStatus.textContent = "还有课程小结缺字段,请补齐后再提交;无法补齐的请删除本框。";
return;
}
if (!lines.length) { if (!lines.length) {
summaryRegisterStatus.textContent = "请至少填写一条课程小结"; summaryRegisterStatus.textContent = "请至少填写一条课程小结";
return; return;
@@ -621,7 +682,7 @@ async function submitSummaryRegister(event) {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ lines }), body: JSON.stringify({ lines }),
}); });
summaryRegisterStatus.textContent = `已接收 ${data.received} 条;自动登记 ${data.auto_registered} 条;待审核 ${data.review_pending} 条;重复 ${data.duplicates} 条;失败 ${data.rejected}`; summaryRegisterStatus.textContent = `已接收 ${data.received} 条;自动登记 ${data.auto_registered} 条;重复 ${data.duplicates} 条;舍弃 ${data.rejected}`;
summaryRegisterItems.innerHTML = ""; summaryRegisterItems.innerHTML = "";
addSummaryRegisterItem(); addSummaryRegisterItem();
await loadAdminHealth(); await loadAdminHealth();
+25
View File
@@ -383,12 +383,33 @@ textarea:focus {
gap: 7px; gap: 7px;
} }
.summary-register-item.has-missing {
padding: 10px;
border: 1px solid #fecdca;
border-radius: 6px;
background: #fff7f6;
}
.summary-register-item label { .summary-register-item label {
color: #344054; color: #344054;
font-size: 13px; font-size: 13px;
font-weight: 700; font-weight: 700;
} }
.summary-register-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.summary-register-missing {
margin: 0;
color: var(--danger);
font-size: 13px;
font-weight: 700;
line-height: 1.45;
}
.summary-register-remove { .summary-register-remove {
justify-self: start; justify-self: start;
} }
@@ -1003,6 +1024,10 @@ td {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.summary-register-fields {
grid-template-columns: 1fr;
}
.admin-tabs { .admin-tabs {
overflow-x: auto; overflow-x: auto;
} }