Compare commits

...

2 Commits

Author SHA1 Message Date
Codex 77e31bd64a 优化登记补充预览 2026-06-18 10:46:36 +08:00
Codex fb1cfb1bfc 重复课程小结进入审核 2026-06-18 10:07:49 +08:00
6 changed files with 690 additions and 58 deletions
+149 -18
View File
@@ -66,6 +66,7 @@ TEACHER_RE = re.compile(r"(?P<teacher>[\u4e00-\u9fa5A-Za-z0-9]{1,8}老师)")
HOURS_RE = re.compile(r"(?P<hours>\d+(?:\.\d+)?)\s*(?:课时|小时)") HOURS_RE = re.compile(r"(?P<hours>\d+(?:\.\d+)?)\s*(?:课时|小时)")
MAX_SESSION_AGE_SECONDS = 30 * 60 MAX_SESSION_AGE_SECONDS = 30 * 60
MAX_SESSIONS = 100 MAX_SESSIONS = 100
WAITING_PLACEHOLDER = "[待补充]"
_SESSIONS: dict[str, dict[str, Any]] = {} _SESSIONS: dict[str, dict[str, Any]] = {}
@@ -160,19 +161,23 @@ def _normalize_date(value: object) -> str:
text = str(value or "").strip() text = str(value or "").strip()
if not text: if not text:
return "" return ""
match = DATE_RE.search(text) for match in DATE_RE.finditer(text):
if match:
year = int(match.group("y")) year = int(match.group("y"))
month = int(match.group("m")) month = int(match.group("m"))
day = int(match.group("d")) day = int(match.group("d"))
datetime(year, month, day) try:
datetime(year, month, day)
except ValueError:
continue
return f"{year:04d}-{month:02d}-{day:02d}" return f"{year:04d}-{month:02d}-{day:02d}"
match = MONTH_DAY_RE.search(text) for match in MONTH_DAY_RE.finditer(text):
if match:
year = date.today().year year = date.today().year
month = int(match.group("m")) month = int(match.group("m"))
day = int(match.group("d")) day = int(match.group("d"))
datetime(year, month, day) try:
datetime(year, month, day)
except ValueError:
continue
return f"{year:04d}-{month:02d}-{day:02d}" return f"{year:04d}-{month:02d}-{day:02d}"
return "" return ""
@@ -309,14 +314,46 @@ def _merge_answers(fields: dict[str, str], answers: dict[str, str]) -> dict[str,
text = str(value or "").strip() text = str(value or "").strip()
if not text: if not text:
continue continue
if key == "followup": if key == "followup" or key.startswith("items."):
continue continue
merged[key] = text merged[key] = text
return merged return merged
def _answers_for_item(answers: dict[str, str], index: int, *, include_legacy: bool = False) -> dict[str, str]:
prefix = f"items.{index}."
item_answers = {
key.removeprefix(prefix): value
for key, value in answers.items()
if key.startswith(prefix)
}
if include_legacy:
for key, value in answers.items():
if key == "followup" or key.startswith("items."):
continue
item_answers.setdefault(key, value)
return item_answers
def _field_is_missing(register_type: str, field: str, fields: dict[str, str]) -> bool:
value = str(fields.get(field) or "").strip()
if not value:
return True
if field == "date":
return not _normalize_date(value)
if register_type == "class_record" and field == "time":
return not _normalize_time_range(value)
if register_type == "payment" and field == "hours":
hours = re.sub(r"\s*(?:课时|小时)$", "", value)
try:
return float(hours) <= 0
except ValueError:
return True
return False
def _missing_fields(register_type: str, fields: dict[str, str]) -> list[str]: def _missing_fields(register_type: str, fields: dict[str, str]) -> list[str]:
return [field for field in REQUIRED_FIELDS[register_type] if not str(fields.get(field) or "").strip()] return [field for field in REQUIRED_FIELDS[register_type] if _field_is_missing(register_type, field, fields)]
def _needs_info_response( def _needs_info_response(
@@ -328,6 +365,7 @@ def _needs_info_response(
timed_out: bool = False, timed_out: bool = False,
error: str = "", error: str = "",
draft_line: str = "", draft_line: str = "",
draft_items: list[dict[str, Any]] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
return { return {
"status": "needs_info", "status": "needs_info",
@@ -342,6 +380,7 @@ def _needs_info_response(
"recognition_source": "partial_local", "recognition_source": "partial_local",
"error": error, "error": error,
"draft_line": draft_line, "draft_line": draft_line,
"draft_items": draft_items or [],
} }
@@ -349,6 +388,8 @@ def _standard_from_fields(register_type: str, fields: dict[str, str]) -> str:
if register_type == "class_record": if register_type == "class_record":
date_value = _normalize_date(fields.get("date")) date_value = _normalize_date(fields.get("date"))
time_range = _normalize_time_range(fields.get("time")) time_range = _normalize_time_range(fields.get("time"))
if not date_value or not time_range:
raise ValueError("上课记录日期或时间缺失")
duration = _duration_from_text(fields.get("duration")) or _duration_from_time_range(time_range) duration = _duration_from_text(fields.get("duration")) or _duration_from_time_range(time_range)
return ( return (
f"{_record_date(date_value)}-{_weekday(date_value)}-{time_range}-" f"{_record_date(date_value)}-{_weekday(date_value)}-{time_range}-"
@@ -356,6 +397,8 @@ def _standard_from_fields(register_type: str, fields: dict[str, str]) -> str:
) )
if register_type == "payment": if register_type == "payment":
date_value = _normalize_date(fields.get("date")) date_value = _normalize_date(fields.get("date"))
if not date_value:
raise ValueError("缴费日期缺失")
hours = str(fields.get("hours") or "").strip() hours = str(fields.get("hours") or "").strip()
hours = re.sub(r"\s*(?:课时|小时)$", "", hours) hours = re.sub(r"\s*(?:课时|小时)$", "", hours)
return f"{fields['student']}-{date_value}:{hours}" return f"{fields['student']}-{date_value}:{hours}"
@@ -376,17 +419,21 @@ def _standard_from_fields(register_type: str, fields: dict[str, str]) -> str:
def _draft_from_fields(register_type: str, fields: dict[str, str]) -> str: def _draft_from_fields(register_type: str, fields: dict[str, str]) -> str:
if register_type == "class_record": if register_type == "class_record":
student = fields.get("student") or "【学生】" date_iso = _normalize_date(fields.get("date"))
date_value = _record_date(_normalize_date(fields.get("date"))) if _normalize_date(fields.get("date")) else "【日期】" time_range = _normalize_time_range(fields.get("time"))
time_range = _normalize_time_range(fields.get("time")) or "【时间】" student = fields.get("student") or WAITING_PLACEHOLDER
teacher = _normalize_teacher_hint(fields.get("teacher")) or "【老师】" date_value = _record_date(date_iso) if date_iso else WAITING_PLACEHOLDER
duration = _duration_from_text(fields.get("duration")) or "【时长】" weekday = _weekday(date_iso) if date_iso else WAITING_PLACEHOLDER
subject = fields.get("subject") or "【科目】" teacher = _normalize_teacher_hint(fields.get("teacher")) or WAITING_PLACEHOLDER
return f"{date_value}-{_weekday(_normalize_date(fields.get('date'))) if _normalize_date(fields.get('date')) else '星期?'}-{time_range}-{student}-{duration}-{teacher}-{subject}" duration = _duration_from_text(fields.get("duration")) or (
_duration_from_time_range(time_range) if time_range else WAITING_PLACEHOLDER
)
subject = fields.get("subject") or WAITING_PLACEHOLDER
return f"{date_value}-{weekday}-{time_range or WAITING_PLACEHOLDER}-{student}-{duration}-{teacher}-{subject}"
if register_type == "payment": if register_type == "payment":
student = fields.get("student") or "【学生】" student = fields.get("student") or WAITING_PLACEHOLDER
date_value = _normalize_date(fields.get("date")) or "【日期】" date_value = _normalize_date(fields.get("date")) or WAITING_PLACEHOLDER
hours = str(fields.get("hours") or "").strip() or "【课时数】" hours = str(fields.get("hours") or "").strip() or WAITING_PLACEHOLDER
return f"{student}-{date_value}:{hours}" return f"{student}-{date_value}:{hours}"
student = fields.get("student") or "【学生】" student = fields.get("student") or "【学生】"
date_value = _normalize_date(fields.get("date")) or "【日期】" date_value = _normalize_date(fields.get("date")) or "【日期】"
@@ -464,6 +511,83 @@ def extract_local_fields(register_type: str, lines: list[str]) -> dict[str, str]
return _extract_course_summary_fields(text) return _extract_course_summary_fields(text)
def extract_line_fields(register_type: str, line: str) -> dict[str, str]:
if register_type == "class_record":
return _extract_class_record_fields(line)
if register_type == "payment":
return _extract_payment_fields(line)
return _extract_course_summary_fields(line)
def multi_line_fields_preview(register_type: str, lines: list[str], answers: dict[str, str]) -> dict[str, Any]:
draft_items: list[dict[str, Any]] = []
standard_lines: list[str] = []
all_ready = True
questions: list[str] = []
union_missing: list[str] = []
for index, line in enumerate(lines):
fields = extract_line_fields(register_type, line)
fields = _merge_answers(fields, _answers_for_item(answers, index, include_legacy=len(lines) == 1))
missing = _missing_fields(register_type, fields)
error = ""
standard_line = ""
if missing:
all_ready = False
else:
try:
standard_line = validate_standard_lines(register_type, [_standard_from_fields(register_type, fields)])[0]
standard_lines.append(standard_line)
except (ValueError, KeyError) as exc:
all_ready = False
error = str(exc)
for field in missing:
if field not in union_missing:
union_missing.append(field)
labels = _field_labels(register_type)
questions.extend(f"{index + 1} 条请补充{labels.get(field, field)}" for field in missing)
if error and not missing:
questions.append(f"{index + 1}{_question_for_error(register_type, error)}")
draft_items.append(
{
"index": index,
"source_line": line,
"fields": fields,
"missing_fields": missing,
"questions": _questions_for_missing(register_type, missing),
"draft_line": standard_line or _draft_from_fields(register_type, fields),
"error": error,
}
)
if all_ready:
return {
"status": "ready",
"standard_lines": standard_lines,
"questions": [],
"summary": "已通过本地规则生成预览",
"ai_used": False,
"fields": {},
"missing_fields": [],
"field_labels": _field_labels(register_type),
"timed_out": False,
"recognition_source": "local",
"draft_items": [],
}
return _needs_info_response(
register_type,
draft_items[0]["fields"] if draft_items else {},
union_missing,
summary="请逐条补齐缺失字段后再次生成预览",
error="",
draft_line="\n".join(str(item.get("draft_line") or "") for item in draft_items),
draft_items=draft_items,
)
def fields_preview(register_type: str, fields: dict[str, str]) -> dict[str, Any] | None: def fields_preview(register_type: str, fields: dict[str, str]) -> dict[str, Any] | None:
missing = _missing_fields(register_type, fields) missing = _missing_fields(register_type, fields)
if missing: if missing:
@@ -563,6 +687,13 @@ def preview_register(
if local is not None: if local is not None:
return {"conversation_id": conversation_id, "type": normalized_type, **local} return {"conversation_id": conversation_id, "type": normalized_type, **local}
if normalized_type in {"class_record", "payment"}:
return {
"conversation_id": conversation_id,
"type": normalized_type,
**multi_line_fields_preview(normalized_type, source_lines, merged_answers),
}
local_fields = _merge_answers(extract_local_fields(normalized_type, source_lines), merged_answers) local_fields = _merge_answers(extract_local_fields(normalized_type, source_lines), merged_answers)
local_ready = fields_preview(normalized_type, local_fields) local_ready = fields_preview(normalized_type, local_fields)
if local_ready is not None: if local_ready is not None:
+331 -18
View File
@@ -2201,6 +2201,111 @@ def course_summary_duplicate_candidate(item: dict) -> dict:
} }
def summary_as_duplicate_candidate(summary: dict, saved_path: str = "") -> dict:
body = str(summary.get("body") or "")
return {
"id": str(summary.get("source_id") or sha1_text(json.dumps(summary, ensure_ascii=False, sort_keys=True), 20)),
"title": str(summary.get("title") or ""),
"student": str(summary.get("student") or ""),
"teacher": str(summary.get("teacher") or ""),
"subject": str(summary.get("subject") or ""),
"date_iso": str(summary.get("date_iso") or ""),
"time_range": str(summary.get("time_range") or ""),
"group": str(summary.get("group") or ""),
"source_path": saved_path,
"relative_path": "",
"source_id": str(summary.get("source_id") or ""),
"message_time": str(summary.get("message_time") or ""),
"sender": str(summary.get("sender") or ""),
"body": body,
"body_preview": body[:260] + ("..." if len(body) > 260 else ""),
}
def normalize_semantic_key_text(value: str) -> str:
return re.sub(r"\s+", "", value.strip())
def find_course_summary_duplicate_conflicts(
root: Path,
summary: dict,
*,
source_id_duplicate: bool,
semantic_duplicate: bool,
) -> list[dict]:
conflicts: list[dict] = []
seen_ids: set[str] = set()
summary_source_id = str(summary.get("source_id") or "")
summary_body_key = normalize_semantic_key_text(str(summary.get("body") or ""))
try:
summary_key = course_summary_duplicate_key(summary)
except ValueError:
summary_key = ("", "", "", "", "")
for item in iter_course_summary_markdown(root):
matched = False
if source_id_duplicate and summary_source_id and str(item.get("source_id") or "") == summary_source_id:
matched = True
if semantic_duplicate:
item_body_key = normalize_semantic_key_text(str(item.get("body") or ""))
try:
item_key = course_summary_duplicate_key(item)
except ValueError:
item_key = ("", "", "", "", "")
matched = matched or (
item_key == summary_key
and item_body_key == summary_body_key
)
if not matched:
continue
candidate = course_summary_duplicate_candidate(item)
candidate_id = str(candidate.get("id") or candidate.get("source_id") or "")
if candidate_id in seen_ids:
continue
seen_ids.add(candidate_id)
conflicts.append(candidate)
return conflicts
def course_summary_duplicate_review_context(
summaries_root: Path,
summary: dict,
seen_source_ids: set[str],
seen_semantic_keys: set[str],
) -> tuple[list[str], list[dict], bool, bool]:
source_id = str(summary.get("source_id") or "")
semantic_key = course_summary_semantic_key(summary)
source_id_duplicate = source_id in seen_source_ids
semantic_duplicate = semantic_key in seen_semantic_keys
reasons: list[str] = []
if source_id_duplicate:
reasons.append("来源ID重复,新增课程小结需人工复核")
if semantic_duplicate:
reasons.append("学生、日期、老师、科目、时间和正文均重复,新增课程小结需人工复核")
conflicts = find_course_summary_duplicate_conflicts(
summaries_root,
summary,
source_id_duplicate=source_id_duplicate,
semantic_duplicate=semantic_duplicate,
)
if not conflicts:
if source_id_duplicate:
reasons.append("状态文件中已存在相同来源ID,但正式小结库未找到对应文件")
if semantic_duplicate:
reasons.append("状态文件中已存在相同语义指纹,但正式小结库未找到对应文件")
return reasons, conflicts, source_id_duplicate, semantic_duplicate
def is_duplicate_course_summary_review_task(task: dict) -> bool:
return bool(
task.get("pending_summary_save")
or task.get("duplicate_source_id")
or task.get("duplicate_semantic_key")
or task.get("duplicate_reasons")
or task.get("duplicate_conflicts")
)
def course_summary_quality_score(item: dict) -> tuple[int, int, int, str]: def course_summary_quality_score(item: dict) -> tuple[int, int, int, str]:
title = str(item.get("title") or "") title = str(item.get("title") or "")
body = str(item.get("body") or "") body = str(item.get("body") or "")
@@ -2507,17 +2612,21 @@ def course_summary_heading(summary: dict, existing_headings: set[str]) -> str:
return f"{base}{sha1_text(str(summary.get('source_id') or '') + str(summary.get('body') or ''), 8)}" return f"{base}{sha1_text(str(summary.get('source_id') or '') + str(summary.get('body') or ''), 8)}"
def save_course_summary_markdown(root: Path, summary: dict) -> dict: def course_summary_markdown_text(
path = course_summary_path(root, summary) existing: str,
existing = path.read_text(encoding="utf-8") if path.exists() else "" path: Path,
summary: dict,
*,
allow_same_source_id: bool = False,
) -> tuple[str, bool, str]:
matches = list(COURSE_SUMMARY_HEADING_RE.finditer(existing)) matches = list(COURSE_SUMMARY_HEADING_RE.finditer(existing))
existing_headings = {match.group("title").strip() for match in matches} existing_headings = {match.group("title").strip() for match in matches}
body = str(summary.get("body") or "").rstrip() body = str(summary.get("body") or "").rstrip()
group_name = str(summary.get("group") or summary["student"]) group_name = str(summary.get("group") or summary["student"])
heading = course_summary_heading(summary, existing_headings) heading = course_summary_heading(summary, existing_headings)
source_id = str(summary.get("source_id") or "").strip() source_id = str(summary.get("source_id") or "").strip()
if source_id and f"来源ID`{source_id}`" in existing: if not allow_same_source_id and source_id and f"来源ID`{source_id}`" in existing:
return {"path": str(path), "added": False} return existing, False, heading
existing_blocks: set[tuple[str, str]] = set() existing_blocks: set[tuple[str, str]] = set()
for index, match in enumerate(matches): for index, match in enumerate(matches):
start = match.end() start = match.end()
@@ -2526,7 +2635,7 @@ def save_course_summary_markdown(root: Path, summary: dict) -> dict:
block_body = re.sub(r"^(?:>\s+.*\n)+\s*", "", block_body).strip() block_body = re.sub(r"^(?:>\s+.*\n)+\s*", "", block_body).strip()
existing_blocks.add((match.group("title").strip(), re.sub(r"\s+", "", block_body))) existing_blocks.add((match.group("title").strip(), re.sub(r"\s+", "", block_body)))
if (heading, re.sub(r"\s+", "", body)) in existing_blocks: if (heading, re.sub(r"\s+", "", body)) in existing_blocks:
return {"path": str(path), "added": False} return existing, False, heading
lines: list[str] = [] lines: list[str] = []
if not existing.strip(): if not existing.strip():
lines.extend([f"# {path.stem}", "", f"## {group_name}", ""]) lines.extend([f"# {path.stem}", "", f"## {group_name}", ""])
@@ -2544,11 +2653,20 @@ def save_course_summary_markdown(root: Path, summary: dict) -> dict:
"", "",
] ]
) )
prefix = existing
if existing and not existing.endswith("\n"):
prefix += "\n"
return prefix + "\n".join(lines).rstrip() + "\n", True, heading
def save_course_summary_markdown(root: Path, summary: dict) -> dict:
path = course_summary_path(root, summary)
existing = path.read_text(encoding="utf-8") if path.exists() else ""
new_text, added, heading = course_summary_markdown_text(existing, path, summary)
if not added:
return {"path": str(path), "added": False, "heading": heading}
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a", encoding="utf-8") as handle: atomic_write_text(path, new_text)
if existing and not existing.endswith("\n"):
handle.write("\n")
handle.write("\n".join(lines).rstrip() + "\n")
return {"path": str(path), "added": True, "heading": heading} return {"path": str(path), "added": True, "heading": heading}
@@ -2621,6 +2739,7 @@ def create_course_summary_review_task(
proposed_line: str, proposed_line: str,
reasons: list[str], reasons: list[str],
saved_path: str = "", saved_path: str = "",
extra_fields: dict | None = None,
) -> dict: ) -> dict:
tasks = read_admin_tasks(tasks_path) tasks = read_admin_tasks(tasks_path)
now = datetime.now().isoformat(timespec="seconds") now = datetime.now().isoformat(timespec="seconds")
@@ -2637,6 +2756,8 @@ def create_course_summary_review_task(
"reasons": reasons, "reasons": reasons,
"saved_path": saved_path, "saved_path": saved_path,
} }
if extra_fields:
task.update(extra_fields)
tasks["next_id"] = int(tasks["next_id"]) + 1 tasks["next_id"] = int(tasks["next_id"]) + 1
tasks["items"].append(task) tasks["items"].append(task)
write_admin_tasks(tasks_path, tasks) write_admin_tasks(tasks_path, tasks)
@@ -2701,6 +2822,8 @@ def editable_course_summary_payload(summary: dict, updates: dict) -> dict:
def update_course_summary_review_task( def update_course_summary_review_task(
tasks_path: Path, tasks_path: Path,
summaries_root: Path,
state_path: Path,
classnotes_path: Path, classnotes_path: Path,
accounts_path: Path, accounts_path: Path,
task_id: int, task_id: int,
@@ -2715,6 +2838,39 @@ def update_course_summary_review_task(
summary = task.get("summary") or {} summary = task.get("summary") or {}
updated_summary = editable_course_summary_payload(summary, updates) updated_summary = editable_course_summary_payload(summary, updates)
reasons, proposed_line = auto_register_reasons(updated_summary, classnotes_path, accounts_path) reasons, proposed_line = auto_register_reasons(updated_summary, classnotes_path, accounts_path)
if is_duplicate_course_summary_review_task(task):
state = read_course_summary_state(state_path)
source_ids = {str(item) for item in state.get("seen_source_ids", [])}
semantic_keys = {str(item) for item in state.get("seen_semantic_keys", [])}
source_ids.discard(str(summary.get("source_id") or ""))
if task.get("semantic_key"):
semantic_keys.discard(str(task.get("semantic_key") or ""))
semantic_keys.discard(course_summary_semantic_key(summary))
duplicate_reasons, duplicate_conflicts, source_id_duplicate, semantic_duplicate = course_summary_duplicate_review_context(
summaries_root,
updated_summary,
source_ids,
semantic_keys,
)
merged_reasons = [*duplicate_reasons]
merged_reasons.extend(reason for reason in reasons if reason not in merged_reasons)
reasons = merged_reasons
task["duplicate_reasons"] = duplicate_reasons
task["duplicate_conflicts"] = duplicate_conflicts
task["duplicate_source_id"] = source_id_duplicate
task["duplicate_semantic_key"] = semantic_duplicate
task["duplicate_source"] = summary_as_duplicate_candidate(
updated_summary,
str(course_summary_path(summaries_root, updated_summary)),
)
task["semantic_key"] = course_summary_semantic_key(updated_summary)
task["pending_summary_save"] = True
task["saved_path"] = str(course_summary_path(summaries_root, updated_summary))
source_ids.add(str(updated_summary.get("source_id") or ""))
semantic_keys.add(task["semantic_key"])
state["seen_source_ids"] = sorted(source_ids)
state["seen_semantic_keys"] = sorted(semantic_keys)
write_course_summary_state(state_path, state)
now = datetime.now().isoformat(timespec="seconds") now = datetime.now().isoformat(timespec="seconds")
task["status"] = "pending" task["status"] = "pending"
task["updated_at"] = now task["updated_at"] = now
@@ -2752,6 +2908,7 @@ def link_existing_course_summary_task(tasks_path: Path, classnotes_path: Path, t
def approve_course_summary_task( def approve_course_summary_task(
tasks_path: Path, tasks_path: Path,
summaries_root: Path,
classnotes_path: Path, classnotes_path: Path,
accounts_path: Path, accounts_path: Path,
task_id: int, task_id: int,
@@ -2764,13 +2921,40 @@ def approve_course_summary_task(
raise ValueError("该任务已处理,不能重复批准") raise ValueError("该任务已处理,不能重复批准")
proposed_line = str(task.get("proposed_line") or "").strip() proposed_line = str(task.get("proposed_line") or "").strip()
summary = task.get("summary") or {}
if not proposed_line: if not proposed_line:
raise ValueError("课程小结信息未补齐,不能直接批准入账") try:
proposed_line = course_summary_to_class_record_line(summary)
except ValueError as exc:
task["status"] = "conflict"
task["updated_at"] = datetime.now().isoformat(timespec="seconds")
task["message"] = str(exc)
write_admin_tasks(tasks_path, tasks)
raise
if not summary:
raise ValueError("课程小结信息缺失,不能批准入账")
try: try:
original_classnotes = classnotes_path.read_text(encoding="utf-8") original_classnotes = classnotes_path.read_text(encoding="utf-8")
original_accounts = accounts_path.read_text(encoding="utf-8") original_accounts = accounts_path.read_text(encoding="utf-8")
original_tasks = json.dumps(tasks, ensure_ascii=False, indent=2) + "\n" original_tasks = json.dumps(tasks, ensure_ascii=False, indent=2) + "\n"
should_save_summary = bool(task.get("pending_summary_save"))
summary_path: Path | None = None
original_summary_text = ""
summary_existed = False
new_summary_text = ""
summary_added = False
summary_heading = ""
if should_save_summary:
summary_path = course_summary_path(summaries_root, summary)
summary_existed = summary_path.exists()
original_summary_text = summary_path.read_text(encoding="utf-8") if summary_existed else ""
new_summary_text, summary_added, summary_heading = course_summary_markdown_text(
original_summary_text,
summary_path,
summary,
allow_same_source_id=True,
)
record = parse_class_record_line(proposed_line) record = parse_class_record_line(proposed_line)
record_line = class_record_to_line(record) record_line = class_record_to_line(record)
existing_lines = {raw.strip() for raw in original_classnotes.splitlines()} existing_lines = {raw.strip() for raw in original_classnotes.splitlines()}
@@ -2792,12 +2976,17 @@ def approve_course_summary_task(
task["reviewed_at"] = task["updated_at"] task["reviewed_at"] = task["updated_at"]
task["proposed_line"] = record_line task["proposed_line"] = record_line
task["registered_line"] = record_line task["registered_line"] = record_line
if summary_path is not None:
task["saved_path"] = str(summary_path)
if summary_heading:
task["summary_heading"] = summary_heading
backup_dir = create_data_backup( backup_dir = create_data_backup(
"admin-approve-course-summary", "admin-approve-course-summary",
{ {
accounts_path: original_accounts, accounts_path: original_accounts,
classnotes_path: original_classnotes, classnotes_path: original_classnotes,
tasks_path: original_tasks, tasks_path: original_tasks,
**({summary_path: original_summary_text} if summary_path is not None else {}),
}, },
[record_line], [record_line],
) )
@@ -2806,11 +2995,18 @@ def approve_course_summary_task(
try: try:
atomic_write_text(accounts_path, new_accounts) atomic_write_text(accounts_path, new_accounts)
atomic_write_text(classnotes_path, new_classnotes) atomic_write_text(classnotes_path, new_classnotes)
if summary_path is not None and summary_added:
atomic_write_text(summary_path, new_summary_text)
atomic_write_text(tasks_path, new_tasks) atomic_write_text(tasks_path, new_tasks)
except Exception: except Exception:
atomic_write_text(accounts_path, original_accounts) atomic_write_text(accounts_path, original_accounts)
atomic_write_text(classnotes_path, original_classnotes) atomic_write_text(classnotes_path, original_classnotes)
atomic_write_text(tasks_path, original_tasks) atomic_write_text(tasks_path, original_tasks)
if summary_path is not None:
if summary_existed:
atomic_write_text(summary_path, original_summary_text)
elif summary_path.exists():
summary_path.unlink()
raise raise
except ValueError as exc: except ValueError as exc:
task["status"] = "conflict" task["status"] = "conflict"
@@ -2828,6 +3024,7 @@ def approve_course_summary_task(
def approve_admin_task( def approve_admin_task(
tasks_path: Path, tasks_path: Path,
summaries_root: Path,
classnotes_path: Path, classnotes_path: Path,
accounts_path: Path, accounts_path: Path,
task_id: int, task_id: int,
@@ -2838,7 +3035,7 @@ def approve_admin_task(
if task.get("type") == "class_record_deletion": if task.get("type") == "class_record_deletion":
return approve_deletion_task(tasks_path, classnotes_path, accounts_path, task_id) return approve_deletion_task(tasks_path, classnotes_path, accounts_path, task_id)
if task.get("type") == "course_summary_review": if task.get("type") == "course_summary_review":
return approve_course_summary_task(tasks_path, classnotes_path, accounts_path, task_id) return approve_course_summary_task(tasks_path, summaries_root, classnotes_path, accounts_path, task_id)
raise ValueError("不支持的审核任务类型") raise ValueError("不支持的审核任务类型")
@@ -2879,16 +3076,84 @@ def register_course_summary_texts(
seen_source_ids = set(str(item) for item in state.get("seen_source_ids", [])) seen_source_ids = set(str(item) for item in state.get("seen_source_ids", []))
seen_semantic_keys = set(str(item) for item in state.get("seen_semantic_keys", [])) seen_semantic_keys = set(str(item) for item in state.get("seen_semantic_keys", []))
if source_id in seen_source_ids or semantic_key in seen_semantic_keys: if source_id in seen_source_ids or semantic_key in seen_semantic_keys:
result["duplicates"] += 1 duplicate_reasons, duplicate_conflicts, source_id_duplicate, semantic_duplicate = course_summary_duplicate_review_context(
summaries_root,
normalized,
seen_source_ids,
seen_semantic_keys,
)
reasons, proposed_line = auto_register_reasons(normalized, classnotes_path, accounts_path)
if source_id_duplicate:
duplicate_reason = "来源ID重复,已转入审核"
else:
duplicate_reason = "课程内容与已有课程小结重复,已转入审核"
review_reasons = [duplicate_reason, *duplicate_reasons]
review_reasons.extend(reason for reason in reasons if reason not in review_reasons)
task = create_course_summary_review_task(
tasks_path,
normalized,
proposed_line,
review_reasons,
str(course_summary_path(summaries_root, normalized)),
extra_fields={
"duplicate_source": summary_as_duplicate_candidate(normalized),
"duplicate_reasons": duplicate_reasons,
"duplicate_conflicts": duplicate_conflicts,
"duplicate_source_id": source_id_duplicate,
"duplicate_semantic_key": semantic_duplicate,
"semantic_key": semantic_key,
"pending_summary_save": True,
},
)
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": 0,
"auto_registered": 0,
"review_pending": 1,
"duplicates": 1,
"rejected": 0,
},
}
)
state["batches"] = state["batches"][-200:]
write_course_summary_state(state_path, state)
log_id = append_operation_log( log_id = append_operation_log(
operation_logs_path, operation_logs_path,
"课程小结登记", "课程小结登记",
"重复", "待审核",
source_id=source_id, source_id=source_id,
student=normalized["student"], student=normalized["student"],
teacher=normalized.get("teacher", ""),
subject=normalized.get("subject", ""),
proposed_line=proposed_line,
reasons=review_reasons,
task_id=str(task.get("id") or ""),
saved_path=str(course_summary_path(summaries_root, normalized)),
duplicate_source_id=source_id_duplicate,
duplicate_semantic_key=semantic_duplicate,
) )
result["review_pending"] += 1
result["duplicates"] += 1
result["operation_log_ids"].append(log_id) result["operation_log_ids"].append(log_id)
result["items"].append({"source_id": source_id, "status": "duplicate"}) result["items"].append(
{
"source_id": source_id,
"status": "review_pending",
"task_id": task.get("id"),
"reasons": review_reasons,
"duplicate_conflicts": duplicate_conflicts,
}
)
continue continue
reasons, proposed_line = auto_register_reasons(normalized, classnotes_path, accounts_path) reasons, proposed_line = auto_register_reasons(normalized, classnotes_path, accounts_path)
@@ -2963,7 +3228,6 @@ def register_course_summary_texts(
} }
) )
continue continue
saved = save_course_summary_markdown(summaries_root, normalized) saved = save_course_summary_markdown(summaries_root, normalized)
duplicate_tasks = create_course_summary_duplicate_review_tasks( duplicate_tasks = create_course_summary_duplicate_review_tasks(
tasks_path, tasks_path,
@@ -3088,17 +3352,66 @@ def ingest_course_summaries(
source_id = normalized["source_id"] source_id = normalized["source_id"]
semantic_key = course_summary_semantic_key(normalized) semantic_key = course_summary_semantic_key(normalized)
if source_id in seen_source_ids or semantic_key in seen_semantic_keys: if source_id in seen_source_ids or semantic_key in seen_semantic_keys:
duplicate_reasons, duplicate_conflicts, source_id_duplicate, semantic_duplicate = course_summary_duplicate_review_context(
summaries_root,
normalized,
seen_source_ids,
seen_semantic_keys,
)
reasons, proposed_line = auto_register_reasons(normalized, classnotes_path, accounts_path)
if source_id_duplicate:
duplicate_reason = "来源ID重复,已转入审核"
else:
duplicate_reason = "课程内容与已有课程小结重复,已转入审核"
review_reasons = [duplicate_reason, *duplicate_reasons]
review_reasons.extend(reason for reason in reasons if reason not in review_reasons)
task = create_course_summary_review_task(
tasks_path,
normalized,
proposed_line,
review_reasons,
str(course_summary_path(summaries_root, normalized)),
extra_fields={
"duplicate_source": summary_as_duplicate_candidate(normalized),
"duplicate_reasons": duplicate_reasons,
"duplicate_conflicts": duplicate_conflicts,
"duplicate_source_id": source_id_duplicate,
"duplicate_semantic_key": semantic_duplicate,
"semantic_key": semantic_key,
"pending_summary_save": True,
},
)
seen_source_ids.add(source_id)
seen_semantic_keys.add(semantic_key)
result["review_pending"] += 1
result["duplicates"] += 1 result["duplicates"] += 1
log_id = append_operation_log( log_id = append_operation_log(
operation_logs_path, operation_logs_path,
"课程小结接收", "课程小结接收",
"重复", "待审核",
batch_id=batch_id, batch_id=batch_id,
source_id=source_id, source_id=source_id,
student=normalized["student"], student=normalized["student"],
teacher=normalized.get("teacher", ""),
subject=normalized.get("subject", ""),
proposed_line=proposed_line,
reasons=review_reasons,
task_id=task.get("id"),
saved_path=str(course_summary_path(summaries_root, normalized)),
duplicate_source_id=source_id_duplicate,
duplicate_semantic_key=semantic_duplicate,
) )
result["operation_log_ids"].append(log_id) result["operation_log_ids"].append(log_id)
result["items"].append({"source_id": source_id, "status": "duplicate"}) result["items"].append(
{
"source_id": source_id,
"status": "待审核",
"task_id": task.get("id"),
"backup_id": "",
"reasons": review_reasons,
"duplicate_conflicts": duplicate_conflicts,
}
)
continue continue
saved = save_course_summary_markdown(summaries_root, normalized) saved = save_course_summary_markdown(summaries_root, normalized)
+5 -1
View File
@@ -10,6 +10,7 @@ from ..config import (
ADMIN_TASKS_PATH, ADMIN_TASKS_PATH,
CLASSNOTES_PATH, CLASSNOTES_PATH,
COURSE_SUMMARIES_ROOT, COURSE_SUMMARIES_ROOT,
COURSE_SUMMARY_STATE_PATH,
OPERATION_LOGS_PATH, OPERATION_LOGS_PATH,
write_lock, write_lock,
) )
@@ -129,7 +130,7 @@ def admin_course_summaries(
def admin_approve_task(task_id: int, _user: str = Depends(verify_admin_auth)): def admin_approve_task(task_id: int, _user: str = Depends(verify_admin_auth)):
try: try:
with write_lock: with write_lock:
result = approve_admin_task(ADMIN_TASKS_PATH, CLASSNOTES_PATH, ACCOUNTS_PATH, task_id) result = approve_admin_task(ADMIN_TASKS_PATH, COURSE_SUMMARIES_ROOT, CLASSNOTES_PATH, ACCOUNTS_PATH, task_id)
task = result.get("task", {}) task = result.get("task", {})
append_operation_log( append_operation_log(
OPERATION_LOGS_PATH, OPERATION_LOGS_PATH,
@@ -140,6 +141,7 @@ def admin_approve_task(task_id: int, _user: str = Depends(verify_admin_auth)):
student=str(task.get("student") or task.get("corrected", {}).get("student") or ""), student=str(task.get("student") or task.get("corrected", {}).get("student") or ""),
source_id=str(task.get("source_id") or ""), source_id=str(task.get("source_id") or ""),
backup_id=str(result.get("backup_id") or task.get("backup_id") or ""), backup_id=str(result.get("backup_id") or task.get("backup_id") or ""),
saved_path=str(task.get("saved_path") or ""),
) )
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc raise HTTPException(status_code=409, detail=str(exc)) from exc
@@ -179,6 +181,8 @@ def admin_update_course_summary_review(task_id: int, payload: dict, _user: str =
with write_lock: with write_lock:
result = update_course_summary_review_task( result = update_course_summary_review_task(
ADMIN_TASKS_PATH, ADMIN_TASKS_PATH,
COURSE_SUMMARIES_ROOT,
COURSE_SUMMARY_STATE_PATH,
CLASSNOTES_PATH, CLASSNOTES_PATH,
ACCOUNTS_PATH, ACCOUNTS_PATH,
task_id, task_id,
+6 -2
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>管理后台</title> <title>管理后台</title>
<link rel="stylesheet" href="/static/styles.css?v=20260618-summary-search-expand" /> <link rel="stylesheet" href="/static/styles.css?v=20260618-summary-duplicate-review" />
</head> </head>
<body> <body>
<header class="topbar"> <header class="topbar">
@@ -300,6 +300,10 @@
<div class="drawer-label">审核原因</div> <div class="drawer-label">审核原因</div>
<div id="summaryReviewDrawerReasons" class="drawer-text"></div> <div id="summaryReviewDrawerReasons" class="drawer-text"></div>
</div> </div>
<div id="summaryReviewConflictSection" class="drawer-section" hidden>
<div class="drawer-label">冲突信息</div>
<div id="summaryReviewDrawerConflicts" class="summary-conflict-list"></div>
</div>
<div class="drawer-section"> <div class="drawer-section">
<div class="drawer-label">小结原文</div> <div class="drawer-label">小结原文</div>
<div id="summaryReviewDrawerBody" class="drawer-body"></div> <div id="summaryReviewDrawerBody" class="drawer-body"></div>
@@ -450,6 +454,6 @@
</section> </section>
</main> </main>
<script src="/static/admin.js?v=20260618-summary-search-expand"></script> <script src="/static/admin.js?v=20260618-summary-duplicate-review"></script>
</body> </body>
</html> </html>
+139 -19
View File
@@ -64,6 +64,8 @@ const summaryReviewEditTeacher = document.querySelector("#summaryReviewEditTeach
const summaryReviewEditSubject = document.querySelector("#summaryReviewEditSubject"); const summaryReviewEditSubject = document.querySelector("#summaryReviewEditSubject");
const summaryReviewDrawerLine = document.querySelector("#summaryReviewDrawerLine"); const summaryReviewDrawerLine = document.querySelector("#summaryReviewDrawerLine");
const summaryReviewDrawerReasons = document.querySelector("#summaryReviewDrawerReasons"); const summaryReviewDrawerReasons = document.querySelector("#summaryReviewDrawerReasons");
const summaryReviewConflictSection = document.querySelector("#summaryReviewConflictSection");
const summaryReviewDrawerConflicts = document.querySelector("#summaryReviewDrawerConflicts");
const summaryReviewDrawerBody = document.querySelector("#summaryReviewDrawerBody"); const summaryReviewDrawerBody = document.querySelector("#summaryReviewDrawerBody");
const summaryReviewDrawerSource = document.querySelector("#summaryReviewDrawerSource"); const summaryReviewDrawerSource = document.querySelector("#summaryReviewDrawerSource");
const summaryReviewDrawerSave = document.querySelector("#summaryReviewDrawerSave"); const summaryReviewDrawerSave = document.querySelector("#summaryReviewDrawerSave");
@@ -523,6 +525,7 @@ async function loadReviews() {
function summaryReviewKind(item) { function summaryReviewKind(item) {
if (item.type === "course_summary_duplicate_review") return "重复小结"; if (item.type === "course_summary_duplicate_review") return "重复小结";
if (hasDuplicateReviewContext(item)) return "重复待审";
const sourceId = String(item.source_id || ""); const sourceId = String(item.source_id || "");
const reasons = Array.isArray(item.reasons) ? item.reasons.join(" ") : ""; const reasons = Array.isArray(item.reasons) ? item.reasons.join(" ") : "";
if (sourceId.startsWith("history-missing:") || reasons.includes("历史 classnotes缺失")) { if (sourceId.startsWith("history-missing:") || reasons.includes("历史 classnotes缺失")) {
@@ -557,18 +560,33 @@ function isDuplicateSummaryReview(item) {
return item && item.type === "course_summary_duplicate_review"; return item && item.type === "course_summary_duplicate_review";
} }
function renderDuplicateCandidate(candidate, canReview) { function hasDuplicateReviewContext(item) {
return Boolean(
item
&& !isDuplicateSummaryReview(item)
&& (
item.duplicate_source_id
|| item.duplicate_semantic_key
|| (Array.isArray(item.duplicate_reasons) && item.duplicate_reasons.length)
|| (Array.isArray(item.duplicate_conflicts) && item.duplicate_conflicts.length)
)
);
}
function renderDuplicateCandidate(candidate, canReview, options = {}) {
const showDelete = options.showDelete !== false;
const source = [ const source = [
candidate.source_id ? `来源ID${candidate.source_id}` : "", candidate.source_id ? `来源ID${candidate.source_id}` : "",
candidate.message_time ? `发送时间:${candidate.message_time}` : "", candidate.message_time ? `发送时间:${candidate.message_time}` : "",
candidate.sender ? `发送者:${candidate.sender}` : "", candidate.sender ? `发送者:${candidate.sender}` : "",
candidate.relative_path ? `文件:${candidate.relative_path}` : "", candidate.relative_path ? `文件:${candidate.relative_path}` : "",
candidate.source_path && !candidate.relative_path ? `文件:${candidate.source_path}` : "",
].filter(Boolean); ].filter(Boolean);
return `<div class="record-summary-item duplicate-summary-candidate"> return `<div class="record-summary-item duplicate-summary-candidate">
<div class="record-summary-title">${escapeHtml(candidate.title || "课程小结")}</div> <div class="record-summary-title">${escapeHtml(candidate.title || "课程小结")}</div>
<div class="drawer-text">${source.map(escapeHtml).join("<br>") || "暂无来源信息"}</div> <div class="drawer-text">${source.map(escapeHtml).join("<br>") || "暂无来源信息"}</div>
<div class="summary-body">${escapeHtml(candidate.body || candidate.body_preview || "暂无正文")}</div> <div class="summary-body">${escapeHtml(candidate.body || candidate.body_preview || "暂无正文")}</div>
<button class="small-button duplicate-summary-delete" type="button" data-summary-id="${escapeHtml(candidate.id)}" ${canReview ? "" : "disabled"}>删除这一条</button> ${showDelete ? `<button class="small-button duplicate-summary-delete" type="button" data-summary-id="${escapeHtml(candidate.id)}" ${canReview ? "" : "disabled"}>删除这一条</button>` : ""}
</div>`; </div>`;
} }
@@ -578,6 +596,34 @@ function renderDuplicateCandidates(item, canReview) {
return candidates.map((candidate) => renderDuplicateCandidate(candidate, canReview)).join(""); return candidates.map((candidate) => renderDuplicateCandidate(candidate, canReview)).join("");
} }
function renderSummaryDuplicateConflicts(item) {
const conflicts = Array.isArray(item.duplicate_conflicts) ? item.duplicate_conflicts : [];
const reasonLines = Array.isArray(item.duplicate_reasons) ? item.duplicate_reasons : [];
const source = item.duplicate_source;
const parts = [];
if (reasonLines.length) {
parts.push(`<div class="summary-conflict-reasons">${reasonLines.map(escapeHtml).join("<br>")}</div>`);
}
if (source) {
parts.push(`<div class="summary-conflict-source">
<div class="record-summary-title">本次提交</div>
<div class="drawer-text">${[
source.source_id ? `来源ID${source.source_id}` : "",
source.message_time ? `发送时间:${source.message_time}` : "",
source.sender ? `发送者:${source.sender}` : "",
].filter(Boolean).map(escapeHtml).join("<br>") || "暂无来源信息"}</div>
<div class="summary-body">${escapeHtml(source.body || source.body_preview || "暂无正文")}</div>
</div>`);
}
if (conflicts.length) {
parts.push(`<div class="summary-conflict-heading">命中的已有小结</div>`);
parts.push(conflicts.map((candidate) => renderDuplicateCandidate(candidate, false, { showDelete: false })).join(""));
} else {
parts.push("<span class=\"muted\">状态文件命中重复,但正式小结库未找到对应小结。</span>");
}
return parts.join("");
}
function findSummaryReview(taskId) { function findSummaryReview(taskId) {
return currentSummaryReviews.find((item) => String(item.id) === String(taskId)); return currentSummaryReviews.find((item) => String(item.id) === String(taskId));
} }
@@ -627,6 +673,8 @@ function openSummaryReviewDrawer(taskId) {
const kind = summaryReviewKind(item); const kind = summaryReviewKind(item);
const canReview = canReviewSummary(item); const canReview = canReviewSummary(item);
const duplicateReview = isDuplicateSummaryReview(item); const duplicateReview = isDuplicateSummaryReview(item);
const duplicateContext = hasDuplicateReviewContext(item);
const pendingSummarySave = Boolean(item.pending_summary_save);
activeSummaryReview = item; activeSummaryReview = item;
summaryReviewDrawerTitle.textContent = `课程小结详情 #${item.id}`; summaryReviewDrawerTitle.textContent = `课程小结详情 #${item.id}`;
@@ -647,9 +695,11 @@ function openSummaryReviewDrawer(taskId) {
} else { } else {
summaryReviewDrawerBody.textContent = summary.body || "暂无原文"; summaryReviewDrawerBody.textContent = summary.body || "暂无原文";
} }
summaryReviewConflictSection.hidden = !duplicateContext;
summaryReviewDrawerConflicts.innerHTML = duplicateContext ? renderSummaryDuplicateConflicts(item) : "";
summaryReviewDrawerSource.innerHTML = [ summaryReviewDrawerSource.innerHTML = [
item.source_id ? `来源ID${escapeHtml(item.source_id)}` : "", item.source_id ? `来源ID${escapeHtml(item.source_id)}` : "",
item.saved_path ? `保存文件${escapeHtml(item.saved_path)}` : "", item.saved_path ? `${duplicateContext || pendingSummarySave ? "目标文件" : "保存文件"}${escapeHtml(item.saved_path)}` : "",
item.batch_id ? `批次:${escapeHtml(item.batch_id)}` : "", item.batch_id ? `批次:${escapeHtml(item.batch_id)}` : "",
].filter(Boolean).join("<br>") || "暂无来源信息"; ].filter(Boolean).join("<br>") || "暂无来源信息";
summaryReviewDrawerSave.hidden = duplicateReview; summaryReviewDrawerSave.hidden = duplicateReview;
@@ -668,6 +718,8 @@ function closeSummaryReviewDrawer() {
summaryReviewDrawerSave.hidden = false; summaryReviewDrawerSave.hidden = false;
summaryReviewDrawerLink.hidden = true; summaryReviewDrawerLink.hidden = true;
summaryReviewDrawerApprove.hidden = false; summaryReviewDrawerApprove.hidden = false;
summaryReviewConflictSection.hidden = true;
summaryReviewDrawerConflicts.innerHTML = "";
summaryReviewDrawerBackdrop.hidden = true; summaryReviewDrawerBackdrop.hidden = true;
} }
@@ -692,6 +744,7 @@ async function loadSummaryReviews() {
const duplicateReview = isDuplicateSummaryReview(item); const duplicateReview = isDuplicateSummaryReview(item);
const summary = item.summary || {}; const summary = item.summary || {};
const reasons = Array.isArray(item.reasons) ? item.reasons : []; const reasons = Array.isArray(item.reasons) ? item.reasons : [];
const approveLabel = duplicateReview ? "详情选择" : "批准";
return `<tr> return `<tr>
<td>#${escapeHtml(item.id)}</td> <td>#${escapeHtml(item.id)}</td>
<td><span class="status ${taskStatusClass(item.status)}">${escapeHtml(item.status)}</span>${item.message ? `<br><small>${escapeHtml(item.message)}</small>` : ""}</td> <td><span class="status ${taskStatusClass(item.status)}">${escapeHtml(item.status)}</span>${item.message ? `<br><small>${escapeHtml(item.message)}</small>` : ""}</td>
@@ -702,7 +755,7 @@ async function loadSummaryReviews() {
<td class="record-action-cell"> <td class="record-action-cell">
<div class="record-actions"> <div class="record-actions">
<button class="small-button summary-detail" type="button" data-task-id="${escapeHtml(item.id)}">查看详情</button> <button class="small-button summary-detail" type="button" data-task-id="${escapeHtml(item.id)}">查看详情</button>
<button class="small-button summary-approve" type="button" data-task-id="${escapeHtml(item.id)}" ${canReview && !duplicateReview ? "" : "disabled"}>${duplicateReview ? "详情选择" : "批准"}</button> <button class="small-button summary-approve" type="button" data-task-id="${escapeHtml(item.id)}" ${canReview && !duplicateReview ? "" : "disabled"}>${approveLabel}</button>
<button class="small-button summary-reject" type="button" data-task-id="${escapeHtml(item.id)}" ${canReview ? "" : "disabled"}>驳回</button> <button class="small-button summary-reject" type="button" data-task-id="${escapeHtml(item.id)}" ${canReview ? "" : "disabled"}>驳回</button>
</div> </div>
</td> </td>
@@ -1107,21 +1160,19 @@ function renderRegisterPreview(type, data, statusNode) {
const fields = data.fields || {}; const fields = data.fields || {};
const labels = data.field_labels || {}; const labels = data.field_labels || {};
const missing = new Set(data.missing_fields || []); const missing = new Set(data.missing_fields || []);
const fieldNames = Object.keys(labels); const usesReadonlyDraft = type === "class_record" || type === "payment";
const draft = renderRegisterDraft(type, fields, missing); const draftItems = data.draft_items || [];
const fieldInputs = fieldNames const draft = usesReadonlyDraft && draftItems.length
.map((name) => { ? renderReadonlyDraftItems(type, draftItems, labels)
const label = labels[name] || name; : usesReadonlyDraft
const value = fields[name] || ""; ? renderReadonlyRegisterDraft(type, fields, data.draft_line || "")
const requiredMark = missing.has(name) ? " *" : ""; : renderRegisterDraft(type, fields, missing);
const input = const fieldInputs = usesReadonlyDraft && draftItems.length
name === "body" ? ""
? `<textarea data-ai-field="${escapeHtml(name)}" rows="4">${escapeHtml(value)}</textarea>` : renderFieldInputs(usesReadonlyDraft ? data.missing_fields || [] : Object.keys(labels), fields, labels, missing);
: `<input data-ai-field="${escapeHtml(name)}" value="${escapeHtml(value)}">`; const questionBlock = questions ? `<ul>${questions}</ul>` : "";
return `<label><span>${escapeHtml(label)}${requiredMark}</span>${input}</label>`; const inputBlock = fieldInputs ? `<div class="register-field-grid">${fieldInputs}</div>` : "";
}) previewNode.innerHTML = `<strong>需要补充信息</strong>${questionBlock}${draft}${inputBlock}`;
.join("");
previewNode.innerHTML = `<strong>需要补充信息</strong><ul>${questions}</ul>${draft}<div class="register-field-grid">${fieldInputs}</div>`;
confirmButton.hidden = true; confirmButton.hidden = true;
registerPreviewState[type] = { registerPreviewState[type] = {
conversationId: data.conversation_id, conversationId: data.conversation_id,
@@ -1140,6 +1191,75 @@ function renderRegisterPreview(type, data, statusNode) {
statusNode.textContent = `预览失败:${data.error || "请修改原文后重试"}`; statusNode.textContent = `预览失败:${data.error || "请修改原文后重试"}`;
} }
function waitingText(value) {
return value || "[待补充]";
}
function previewPart(value) {
const text = waitingText(value);
const className = text === "[待补充]" ? ` class="pending-field"` : "";
return `<span${className}>${escapeHtml(text)}</span>`;
}
function renderDraftLine(draftLine) {
return escapeHtml(waitingText(draftLine));
}
function renderReadonlyRegisterDraft(type, fields, draftLine = "") {
if (draftLine) {
return `<div class="register-draft readonly-register-draft"><pre>${renderDraftLine(draftLine)}</pre></div>`;
}
if (type === "class_record") {
const line = [
waitingText(fields.date),
"[待补充]",
waitingText(fields.time),
waitingText(fields.student),
waitingText(fields.duration),
waitingText(fields.teacher),
waitingText(fields.subject),
].join("-");
return `<div class="register-draft readonly-register-draft"><pre>${renderDraftLine(line)}</pre></div>`;
}
if (type === "payment") {
const line = `${waitingText(fields.student)}-${waitingText(fields.date)}:${waitingText(fields.hours)}`;
return `<div class="register-draft readonly-register-draft"><pre>${renderDraftLine(line)}</pre></div>`;
}
return `<div class="register-draft readonly-register-draft"><pre>${renderDraftLine(draftLine)}</pre></div>`;
}
function renderFieldInputs(fieldNames, fields, labels, missing, prefix = "") {
return fieldNames
.map((name) => {
const label = labels[name] || name;
const value = fields[name] || "";
const requiredMark = missing.has(name) ? " *" : "";
const fieldName = `${prefix}${name}`;
const input =
name === "body"
? `<textarea data-ai-field="${escapeHtml(fieldName)}" rows="4">${escapeHtml(value)}</textarea>`
: `<input data-ai-field="${escapeHtml(fieldName)}" value="${escapeHtml(value)}">`;
return `<label><span>${escapeHtml(label)}${requiredMark}</span>${input}</label>`;
})
.join("");
}
function renderReadonlyDraftItems(type, items, labels) {
const rows = items
.map((item, index) => {
const itemIndex = Number.isInteger(item.index) ? item.index : index;
const fields = item.fields || {};
const missingFields = item.missing_fields || [];
const missing = new Set(missingFields);
const inputs = renderFieldInputs(missingFields, fields, labels, missing, `items.${itemIndex}.`);
const inputBlock = inputs ? `<div class="register-field-grid">${inputs}</div>` : "";
const error = item.error ? `<small class="muted">第 ${itemIndex + 1} 条:${escapeHtml(item.error)}</small>` : "";
return `<div class="register-draft-item">${renderReadonlyRegisterDraft(type, fields, item.draft_line || "")}${inputBlock}${error}</div>`;
})
.join("");
return `<div class="register-draft-list">${rows}</div>`;
}
function inlineDraftPart(name, fields, missing, fallback) { function inlineDraftPart(name, fields, missing, fallback) {
const value = fields[name] || ""; const value = fields[name] || "";
if (!missing.has(name) && value) return `<span>${escapeHtml(value)}</span>`; if (!missing.has(name) && value) return `<span>${escapeHtml(value)}</span>`;
+60
View File
@@ -504,6 +504,36 @@ textarea:focus {
min-height: 22px; min-height: 22px;
} }
.register-draft .pending-field {
display: inline-block;
padding: 0 6px;
border-radius: 999px;
background: #eef2ff;
color: #475569;
font-weight: 700;
}
.readonly-register-draft {
display: block;
}
.readonly-register-draft pre {
margin: 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
font: inherit;
}
.register-draft-list {
display: grid;
gap: 10px;
}
.register-draft-item {
display: grid;
gap: 8px;
}
.register-draft input, .register-draft input,
.register-draft textarea { .register-draft textarea {
width: 100%; width: 100%;
@@ -658,6 +688,36 @@ textarea:focus {
margin-top: 8px; margin-top: 8px;
} }
.summary-conflict-list {
display: grid;
gap: 10px;
}
.summary-conflict-list .record-summary-item,
.summary-conflict-source,
.summary-conflict-reasons {
padding: 12px;
border: 1px solid var(--line);
border-radius: 6px;
background: #fff;
}
.summary-conflict-list .summary-body {
max-width: none;
}
.summary-conflict-heading {
color: var(--accent-strong);
font-size: 13px;
font-weight: 700;
}
.summary-conflict-reasons {
color: #344054;
font-size: 13px;
line-height: 1.5;
}
.summary-search-result-row { .summary-search-result-row {
cursor: pointer; cursor: pointer;
} }