优化登记补充预览
This commit is contained in:
+149
-18
@@ -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*(?:课时|小时)")
|
||||
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:
|
||||
|
||||
+82
-15
@@ -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"
|
||||
? `<textarea data-ai-field="${escapeHtml(name)}" rows="4">${escapeHtml(value)}</textarea>`
|
||||
: `<input data-ai-field="${escapeHtml(name)}" value="${escapeHtml(value)}">`;
|
||||
return `<label><span>${escapeHtml(label)}${requiredMark}</span>${input}</label>`;
|
||||
})
|
||||
.join("");
|
||||
previewNode.innerHTML = `<strong>需要补充信息</strong><ul>${questions}</ul>${draft}<div class="register-field-grid">${fieldInputs}</div>`;
|
||||
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 ? `<ul>${questions}</ul>` : "";
|
||||
const inputBlock = fieldInputs ? `<div class="register-field-grid">${fieldInputs}</div>` : "";
|
||||
previewNode.innerHTML = `<strong>需要补充信息</strong>${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 `<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) {
|
||||
const value = fields[name] || "";
|
||||
if (!missing.has(name) && value) return `<span>${escapeHtml(value)}</span>`;
|
||||
|
||||
@@ -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%;
|
||||
|
||||
Reference in New Issue
Block a user