fix: require manual summary fields before registration
This commit is contained in:
+81
-72
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user