diff --git a/app/app/ai_register.py b/app/app/ai_register.py index ca1432d..38f2847 100644 --- a/app/app/ai_register.py +++ b/app/app/ai_register.py @@ -66,6 +66,7 @@ TEACHER_RE = re.compile(r"(?P[\u4e00-\u9fa5A-Za-z0-9]{1,8}老师)") HOURS_RE = re.compile(r"(?P\d+(?:\.\d+)?)\s*(?:课时|小时)") MAX_SESSION_AGE_SECONDS = 30 * 60 MAX_SESSIONS = 100 +WAITING_PLACEHOLDER = "[待补充]" _SESSIONS: dict[str, dict[str, Any]] = {} @@ -160,19 +161,23 @@ def _normalize_date(value: object) -> str: text = str(value or "").strip() if not text: return "" - match = DATE_RE.search(text) - if match: + for match in DATE_RE.finditer(text): year = int(match.group("y")) month = int(match.group("m")) 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}" - match = MONTH_DAY_RE.search(text) - if match: + for match in MONTH_DAY_RE.finditer(text): year = date.today().year month = int(match.group("m")) 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 "" @@ -309,14 +314,46 @@ def _merge_answers(fields: dict[str, str], answers: dict[str, str]) -> dict[str, text = str(value or "").strip() if not text: continue - if key == "followup": + if key == "followup" or key.startswith("items."): continue merged[key] = text 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]: - 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( @@ -328,6 +365,7 @@ def _needs_info_response( timed_out: bool = False, error: str = "", draft_line: str = "", + draft_items: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: return { "status": "needs_info", @@ -342,6 +380,7 @@ def _needs_info_response( "recognition_source": "partial_local", "error": error, "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": date_value = _normalize_date(fields.get("date")) 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) return ( 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": date_value = _normalize_date(fields.get("date")) + if not date_value: + raise ValueError("缴费日期缺失") hours = str(fields.get("hours") or "").strip() hours = re.sub(r"\s*(?:课时|小时)$", "", 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: if register_type == "class_record": - student = fields.get("student") or "【学生】" - date_value = _record_date(_normalize_date(fields.get("date"))) if _normalize_date(fields.get("date")) else "【日期】" - time_range = _normalize_time_range(fields.get("time")) or "【时间】" - teacher = _normalize_teacher_hint(fields.get("teacher")) or "【老师】" - duration = _duration_from_text(fields.get("duration")) or "【时长】" - subject = fields.get("subject") or "【科目】" - return f"{date_value}-{_weekday(_normalize_date(fields.get('date'))) if _normalize_date(fields.get('date')) else '星期?'}-{time_range}-{student}-{duration}-{teacher}-{subject}" + date_iso = _normalize_date(fields.get("date")) + time_range = _normalize_time_range(fields.get("time")) + student = fields.get("student") or WAITING_PLACEHOLDER + date_value = _record_date(date_iso) if date_iso else WAITING_PLACEHOLDER + weekday = _weekday(date_iso) if date_iso else WAITING_PLACEHOLDER + teacher = _normalize_teacher_hint(fields.get("teacher")) or WAITING_PLACEHOLDER + 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": - student = fields.get("student") or "【学生】" - date_value = _normalize_date(fields.get("date")) or "【日期】" - hours = str(fields.get("hours") or "").strip() or "【课时数】" + student = fields.get("student") or WAITING_PLACEHOLDER + date_value = _normalize_date(fields.get("date")) or WAITING_PLACEHOLDER + hours = str(fields.get("hours") or "").strip() or WAITING_PLACEHOLDER return f"{student}-{date_value}:{hours}" student = fields.get("student") 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) +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: missing = _missing_fields(register_type, fields) if missing: @@ -563,6 +687,13 @@ def preview_register( if local is not None: 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_ready = fields_preview(normalized_type, local_fields) if local_ready is not None: diff --git a/app/app/static/admin.js b/app/app/static/admin.js index 4f1c8ae..eafe8fa 100644 --- a/app/app/static/admin.js +++ b/app/app/static/admin.js @@ -1160,21 +1160,19 @@ function renderRegisterPreview(type, data, statusNode) { const fields = data.fields || {}; const labels = data.field_labels || {}; const missing = new Set(data.missing_fields || []); - const fieldNames = Object.keys(labels); - const draft = renderRegisterDraft(type, fields, missing); - const fieldInputs = fieldNames - .map((name) => { - const label = labels[name] || name; - const value = fields[name] || ""; - const requiredMark = missing.has(name) ? " *" : ""; - const input = - name === "body" - ? `` - : ``; - return ``; - }) - .join(""); - previewNode.innerHTML = `需要补充信息${draft}
${fieldInputs}
`; + const usesReadonlyDraft = type === "class_record" || type === "payment"; + const draftItems = data.draft_items || []; + const draft = usesReadonlyDraft && draftItems.length + ? renderReadonlyDraftItems(type, draftItems, labels) + : usesReadonlyDraft + ? renderReadonlyRegisterDraft(type, fields, data.draft_line || "") + : renderRegisterDraft(type, fields, missing); + const fieldInputs = usesReadonlyDraft && draftItems.length + ? "" + : renderFieldInputs(usesReadonlyDraft ? data.missing_fields || [] : Object.keys(labels), fields, labels, missing); + const questionBlock = questions ? `` : ""; + const inputBlock = fieldInputs ? `
${fieldInputs}
` : ""; + previewNode.innerHTML = `需要补充信息${questionBlock}${draft}${inputBlock}`; confirmButton.hidden = true; registerPreviewState[type] = { conversationId: data.conversation_id, @@ -1193,6 +1191,75 @@ function renderRegisterPreview(type, data, statusNode) { statusNode.textContent = `预览失败:${data.error || "请修改原文后重试"}`; } +function waitingText(value) { + return value || "[待补充]"; +} + +function previewPart(value) { + const text = waitingText(value); + const className = text === "[待补充]" ? ` class="pending-field"` : ""; + return `${escapeHtml(text)}`; +} + +function renderDraftLine(draftLine) { + return escapeHtml(waitingText(draftLine)); +} + +function renderReadonlyRegisterDraft(type, fields, draftLine = "") { + if (draftLine) { + return `
${renderDraftLine(draftLine)}
`; + } + 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 `
${renderDraftLine(line)}
`; + } + if (type === "payment") { + const line = `${waitingText(fields.student)}-${waitingText(fields.date)}:${waitingText(fields.hours)}`; + return `
${renderDraftLine(line)}
`; + } + return `
${renderDraftLine(draftLine)}
`; +} + +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" + ? `` + : ``; + return ``; + }) + .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 ? `
${inputs}
` : ""; + const error = item.error ? `第 ${itemIndex + 1} 条:${escapeHtml(item.error)}` : ""; + return `
${renderReadonlyRegisterDraft(type, fields, item.draft_line || "")}${inputBlock}${error}
`; + }) + .join(""); + return `
${rows}
`; +} + function inlineDraftPart(name, fields, missing, fallback) { const value = fields[name] || ""; if (!missing.has(name) && value) return `${escapeHtml(value)}`; diff --git a/app/app/static/styles.css b/app/app/static/styles.css index 0af8403..6380111 100644 --- a/app/app/static/styles.css +++ b/app/app/static/styles.css @@ -504,6 +504,36 @@ textarea:focus { 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 textarea { width: 100%;