加快登记预览并结构化补项

This commit is contained in:
Codex
2026-06-17 20:47:29 +08:00
parent cc81f82139
commit 77b36a8dd8
5 changed files with 388 additions and 23 deletions
+338 -19
View File
@@ -10,8 +10,9 @@ import tomllib
from typing import Any from typing import Any
from urllib import error, request from urllib import error, request
from .config import AI_REGISTER_TIMEOUT_SECONDS, CODEX_AUTH_PATH, CODEX_CONFIG_PATH from .config import ACCOUNTS_PATH, AI_REGISTER_MODEL, AI_REGISTER_TIMEOUT_SECONDS, CODEX_AUTH_PATH, CODEX_CONFIG_PATH
from .data import ( from .data import (
SUBJECTS,
class_record_to_line, class_record_to_line,
course_summary_to_class_record_line, course_summary_to_class_record_line,
extract_course_summary_from_text, extract_course_summary_from_text,
@@ -19,10 +20,48 @@ from .data import (
normalize_lines, normalize_lines,
parse_class_record_line, parse_class_record_line,
parse_payment_line, parse_payment_line,
read_accounts,
) )
REGISTER_TYPES = {"class_record", "payment", "course_summary"} REGISTER_TYPES = {"class_record", "payment", "course_summary"}
FIELD_LABELS = {
"class_record": {
"student": "学生",
"date": "日期",
"time": "时间",
"teacher": "老师",
"subject": "科目",
"duration": "时长",
},
"payment": {
"student": "学生",
"date": "缴费日期",
"hours": "课时数",
},
"course_summary": {
"student": "学生",
"date": "日期",
"time": "时间",
"teacher": "老师",
"subject": "科目",
"body": "小结正文",
},
}
REQUIRED_FIELDS = {
"class_record": ["student", "date", "time", "teacher", "subject"],
"payment": ["student", "date", "hours"],
"course_summary": ["student", "date", "time", "teacher", "subject", "body"],
}
DATE_RE = re.compile(r"(?P<y>\d{4})[./年-]\s*(?P<m>\d{1,2})[./月-]\s*(?P<d>\d{1,2})")
MONTH_DAY_RE = re.compile(r"(?<!\d)(?P<m>\d{1,2})\s*[月./-]\s*(?P<d>\d{1,2})\s*(?:日|号)?")
TIME_RANGE_FLEX_RE = re.compile(
r"(?P<sh>\d{1,2})\s*(?:[::点.])\s*(?P<sm>\d{1,2})?\s*"
r"(?:-||—||~|至|到)\s*"
r"(?P<eh>\d{1,2})\s*(?:[::点.])\s*(?P<em>\d{1,2})?"
)
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_SESSION_AGE_SECONDS = 30 * 60
MAX_SESSIONS = 100 MAX_SESSIONS = 100
_SESSIONS: dict[str, dict[str, Any]] = {} _SESSIONS: dict[str, dict[str, Any]] = {}
@@ -113,7 +152,7 @@ def load_codex_ai_config(
config = tomllib.loads(config_path.read_text(encoding="utf-8")) config = tomllib.loads(config_path.read_text(encoding="utf-8"))
auth = json.loads(auth_path.read_text(encoding="utf-8")) auth = json.loads(auth_path.read_text(encoding="utf-8"))
provider_name = str(config.get("model_provider") or "").strip() provider_name = str(config.get("model_provider") or "").strip()
model = str(config.get("model") or "").strip() model = AI_REGISTER_MODEL or str(config.get("model") or "").strip()
providers = config.get("model_providers") or {} providers = config.get("model_providers") or {}
provider = providers.get(provider_name) or {} provider = providers.get(provider_name) or {}
base_url = str(provider.get("base_url") or "").strip().rstrip("/") base_url = str(provider.get("base_url") or "").strip().rstrip("/")
@@ -128,6 +167,195 @@ def load_codex_ai_config(
return CodexAiConfig(provider_name, model, base_url, wire_api, api_key) return CodexAiConfig(provider_name, model, base_url, wire_api, api_key)
def _field_labels(register_type: str) -> dict[str, str]:
return FIELD_LABELS.get(register_type, {})
def _questions_for_missing(register_type: str, missing_fields: list[str]) -> list[str]:
labels = _field_labels(register_type)
return [f"请补充{labels.get(field, field)}" for field in missing_fields]
def _known_students() -> list[str]:
try:
return [account.student for account in read_accounts(ACCOUNTS_PATH)]
except Exception:
return []
def _normalize_date(value: object) -> str:
text = str(value or "").strip()
if not text:
return ""
match = DATE_RE.search(text)
if match:
year = int(match.group("y"))
month = int(match.group("m"))
day = int(match.group("d"))
datetime(year, month, day)
return f"{year:04d}-{month:02d}-{day:02d}"
match = MONTH_DAY_RE.search(text)
if match:
year = date.today().year
month = int(match.group("m"))
day = int(match.group("d"))
datetime(year, month, day)
return f"{year:04d}-{month:02d}-{day:02d}"
return ""
def _record_date(value: str) -> str:
return value.replace("-", ".")
def _weekday(value: str) -> str:
weekdays = ["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"]
return weekdays[datetime.strptime(value, "%Y-%m-%d").date().weekday()]
def _normalize_time_range(value: object) -> str:
text = str(value or "").strip()
if not text:
return ""
standard = re.search(r"\d{1,2}:\d{2}-\d{1,2}:\d{2}", text)
if standard:
raw = standard.group(0)
start, end = raw.split("-", 1)
sh, sm = [int(part) for part in start.split(":", 1)]
eh, em = [int(part) for part in end.split(":", 1)]
else:
match = TIME_RANGE_FLEX_RE.search(text)
if not match:
return ""
sh = int(match.group("sh"))
sm = int(match.group("sm") or 0)
eh = int(match.group("eh"))
em = int(match.group("em") or 0)
if sh > 23 or eh > 23 or sm > 59 or em > 59:
return ""
if eh * 60 + em <= sh * 60 + sm:
return ""
return f"{sh:02d}:{sm:02d}-{eh:02d}:{em:02d}"
def _duration_from_time_range(time_range: str) -> str:
start, end = time_range.split("-", 1)
sh, sm = [int(part) for part in start.split(":", 1)]
eh, em = [int(part) for part in end.split(":", 1)]
minutes = eh * 60 + em - sh * 60 - sm
return f"{minutes // 60}小时{minutes % 60}"
def _duration_from_text(value: object) -> str:
text = str(value or "").strip()
if not text:
return ""
if re.fullmatch(r"\d+小时\d+分", text):
return text
match = re.search(r"(?P<h>\d+)\s*小时\s*(?P<m>\d+)?\s*分?", text)
if match:
return f"{int(match.group('h'))}小时{int(match.group('m') or 0)}"
match = re.search(r"(?P<hours>\d+(?:\.\d+)?)\s*(?:课时|小时)", text)
if match:
total = int(round(float(match.group("hours")) * 60))
return f"{total // 60}小时{total % 60}"
return ""
def _extract_student(text: str) -> str:
match = re.search(r"(?:学生|学员)[:\s]*(?P<student>[\u4e00-\u9fa5A-Za-z0-9]{2,8})", text)
if match:
return match.group("student")
for student in _known_students():
if student and student in text:
return student
match = re.search(r"^\s*(?P<student>[\u4e00-\u9fa5]{2,4})(?=\s|[,,。;;:]|\d)", text)
return match.group("student") if match else ""
def _extract_teacher(text: str) -> str:
match = TEACHER_RE.search(text)
return match.group("teacher") if match else ""
def _extract_subject(text: str) -> str:
for subject in SUBJECTS:
if subject in text:
return subject
match = re.search(r"(?:科目|课程)[:\s]*(?P<subject>[\u4e00-\u9fa5A-Za-z0-9]{1,12})", text)
return match.group("subject") if match else ""
def _merge_answers(fields: dict[str, str], answers: dict[str, str]) -> dict[str, str]:
merged = dict(fields)
for key, value in answers.items():
text = str(value or "").strip()
if not text:
continue
if key == "followup":
continue
merged[key] = text
return merged
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()]
def _needs_info_response(
register_type: str,
fields: dict[str, str],
missing_fields: list[str],
*,
summary: str = "",
ai_used: bool = False,
timed_out: bool = False,
error: str = "",
) -> dict[str, Any]:
return {
"status": "needs_info",
"standard_lines": [],
"questions": _questions_for_missing(register_type, missing_fields) or [_question_for_error(register_type, error or "信息不完整")],
"summary": summary or "请补齐缺失字段后再次生成预览",
"ai_used": ai_used,
"fields": fields,
"missing_fields": missing_fields,
"field_labels": _field_labels(register_type),
"timed_out": timed_out,
"recognition_source": "partial_local" if not ai_used else "model",
"error": error,
}
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"))
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}-"
f"{fields['student']}-{duration}-{fields['teacher']}-{fields['subject']}"
)
if register_type == "payment":
date_value = _normalize_date(fields.get("date"))
hours = str(fields.get("hours") or "").strip()
hours = re.sub(r"\s*(?:课时|小时)$", "", hours)
return f"{fields['student']}-{date_value}:{hours}"
date_value = _normalize_date(fields.get("date"))
time_range = _normalize_time_range(fields.get("time"))
return "\n".join(
[
f"学生:{fields['student']}",
f"日期:{date_value}",
f"时间:{time_range}",
f"老师:{fields['teacher']}",
f"科目:{fields['subject']}",
"小结:",
str(fields.get("body") or "").strip(),
]
)
def _json_from_text(text: str) -> dict[str, Any]: def _json_from_text(text: str) -> dict[str, Any]:
try: try:
payload = json.loads(text) payload = json.loads(text)
@@ -229,6 +457,75 @@ def _question_for_error(register_type: str, message: str) -> str:
return f"请补齐或修改课程小结信息:{message}" return f"请补齐或修改课程小结信息:{message}"
def _extract_class_record_fields(text: str) -> dict[str, str]:
return {
"student": _extract_student(text),
"date": _normalize_date(text),
"time": _normalize_time_range(text),
"teacher": _extract_teacher(text),
"subject": _extract_subject(text),
"duration": _duration_from_text(text),
}
def _extract_payment_fields(text: str) -> dict[str, str]:
hours_match = HOURS_RE.search(text)
return {
"student": _extract_student(text),
"date": _normalize_date(text),
"hours": hours_match.group("hours") if hours_match else "",
}
def _extract_course_summary_fields(text: str) -> dict[str, str]:
try:
raw = extract_course_summary_from_text(text, known_students=_known_students())
except ValueError:
raw = {}
body = str(raw.get("body") or "").strip()
if body == text.strip() and any(label in text for label in ("小结", "正文", "内容")):
body = re.split(r"(?:小结|正文|内容)[:]?", text, maxsplit=1)[-1].strip()
return {
"student": str(raw.get("student") or _extract_student(text)).strip(),
"date": _normalize_date(raw.get("date_iso") or text),
"time": _normalize_time_range(raw.get("time_range") or text),
"teacher": str(raw.get("teacher") or _extract_teacher(text)).strip(),
"subject": str(raw.get("subject") or _extract_subject(text)).strip(),
"body": body or text.strip(),
}
def extract_local_fields(register_type: str, lines: list[str]) -> dict[str, str]:
text = "\n".join(lines).strip()
if register_type == "class_record":
return _extract_class_record_fields(text)
if register_type == "payment":
return _extract_payment_fields(text)
return _extract_course_summary_fields(text)
def fields_preview(register_type: str, fields: dict[str, str]) -> dict[str, Any] | None:
missing = _missing_fields(register_type, fields)
if missing:
return _needs_info_response(register_type, fields, missing)
try:
standard_lines = validate_standard_lines(register_type, [_standard_from_fields(register_type, fields)])
except (ValueError, KeyError):
return None
return {
"status": "ready",
"standard_lines": standard_lines,
"questions": [],
"summary": "已通过本地规则生成预览",
"ai_used": False,
"fields": fields,
"missing_fields": [],
"field_labels": _field_labels(register_type),
"timed_out": False,
"recognition_source": "local",
}
def validate_standard_lines(register_type: str, lines: list[str]) -> list[str]: def validate_standard_lines(register_type: str, lines: list[str]) -> list[str]:
if not lines: if not lines:
raise ValueError("模型未返回标准行") raise ValueError("模型未返回标准行")
@@ -257,17 +554,23 @@ def validate_standard_lines(register_type: str, lines: list[str]) -> list[str]:
return standard_lines return standard_lines
def local_preview(register_type: str, lines: list[str]) -> dict[str, Any] | None: def local_preview(register_type: str, lines: list[str], answers: dict[str, str] | None = None) -> dict[str, Any] | None:
try: try:
standard_lines = validate_standard_lines(register_type, lines) standard_lines = validate_standard_lines(register_type, lines)
except ValueError: except ValueError:
return None fields = _merge_answers(extract_local_fields(register_type, lines), answers or {})
return fields_preview(register_type, fields)
return { return {
"status": "ready", "status": "ready",
"standard_lines": standard_lines, "standard_lines": standard_lines,
"questions": [], "questions": [],
"summary": "已按标准格式通过本地校验", "summary": "已按标准格式通过本地校验",
"ai_used": False, "ai_used": False,
"fields": {},
"missing_fields": [],
"field_labels": _field_labels(register_type),
"timed_out": False,
"recognition_source": "local",
} }
@@ -286,35 +589,35 @@ def preview_register(
merged_answers = {**dict(session.get("answers") or {}), **(answers or {})} merged_answers = {**dict(session.get("answers") or {}), **(answers or {})}
session["answers"] = merged_answers session["answers"] = merged_answers
local = local_preview(normalized_type, source_lines) local = local_preview(normalized_type, source_lines, merged_answers)
if local is not None and not merged_answers: if local is not None:
return {"conversation_id": conversation_id, "type": normalized_type, **local} return {"conversation_id": conversation_id, "type": normalized_type, **local}
try: try:
model_payload = call_model_for_standardization(normalized_type, source_lines, merged_answers) model_payload = call_model_for_standardization(normalized_type, source_lines, merged_answers)
except ValueError as exc: except ValueError as exc:
if local is not None: fields = _merge_answers(extract_local_fields(normalized_type, source_lines), merged_answers)
return { missing = _missing_fields(normalized_type, fields)
"conversation_id": conversation_id, timed_out = "timed out" in str(exc).lower() or "timeout" in str(exc).lower()
"type": normalized_type,
**local,
"warning": "模型不可用,已使用本地标准格式校验预览",
}
return { return {
"conversation_id": conversation_id, "conversation_id": conversation_id,
"type": normalized_type, "type": normalized_type,
"status": "error", **_needs_info_response(
"standard_lines": [], normalized_type,
"questions": [_question_for_error(normalized_type, str(exc))], fields,
"summary": "", missing,
"ai_used": True, ai_used=True,
"error": str(exc), timed_out=timed_out,
error=str(exc),
summary="模型未及时生成可靠预览,请补齐字段后再生成",
),
} }
status = str(model_payload.get("status") or "").strip() status = str(model_payload.get("status") or "").strip()
questions = [str(item).strip() for item in model_payload.get("questions") or [] if str(item).strip()] questions = [str(item).strip() for item in model_payload.get("questions") or [] if str(item).strip()]
if status == "needs_info" or questions: if status == "needs_info" or questions:
session["questions"] = questions session["questions"] = questions
fields = _merge_answers(extract_local_fields(normalized_type, source_lines), merged_answers)
return { return {
"conversation_id": conversation_id, "conversation_id": conversation_id,
"type": normalized_type, "type": normalized_type,
@@ -323,11 +626,17 @@ def preview_register(
"questions": questions or ["请补齐缺失信息"], "questions": questions or ["请补齐缺失信息"],
"summary": str(model_payload.get("summary") or ""), "summary": str(model_payload.get("summary") or ""),
"ai_used": True, "ai_used": True,
"fields": fields,
"missing_fields": _missing_fields(normalized_type, fields),
"field_labels": _field_labels(normalized_type),
"timed_out": False,
"recognition_source": "model",
} }
try: try:
standard_lines = validate_standard_lines(normalized_type, model_payload.get("standard_lines") or []) standard_lines = validate_standard_lines(normalized_type, model_payload.get("standard_lines") or [])
except ValueError as exc: except ValueError as exc:
fields = _merge_answers(extract_local_fields(normalized_type, source_lines), merged_answers)
return { return {
"conversation_id": conversation_id, "conversation_id": conversation_id,
"type": normalized_type, "type": normalized_type,
@@ -337,6 +646,11 @@ def preview_register(
"summary": str(model_payload.get("summary") or ""), "summary": str(model_payload.get("summary") or ""),
"ai_used": True, "ai_used": True,
"error": str(exc), "error": str(exc),
"fields": fields,
"missing_fields": _missing_fields(normalized_type, fields),
"field_labels": _field_labels(normalized_type),
"timed_out": False,
"recognition_source": "model",
} }
return { return {
"conversation_id": conversation_id, "conversation_id": conversation_id,
@@ -346,4 +660,9 @@ def preview_register(
"questions": [], "questions": [],
"summary": str(model_payload.get("summary") or "已生成标准登记内容"), "summary": str(model_payload.get("summary") or "已生成标准登记内容"),
"ai_used": True, "ai_used": True,
"fields": {},
"missing_fields": [],
"field_labels": _field_labels(normalized_type),
"timed_out": False,
"recognition_source": "model",
} }
+2 -1
View File
@@ -17,7 +17,8 @@ COURSE_SUMMARY_STATE_PATH = Path(os.getenv("COURSE_SUMMARY_STATE_PATH", "/data/c
OPERATION_LOGS_PATH = Path(os.getenv("OPERATION_LOGS_PATH", "/data/operation_logs.jsonl")) OPERATION_LOGS_PATH = Path(os.getenv("OPERATION_LOGS_PATH", "/data/operation_logs.jsonl"))
CODEX_CONFIG_PATH = Path(os.getenv("CODEX_CONFIG_PATH", "/run/codex/config.toml")) CODEX_CONFIG_PATH = Path(os.getenv("CODEX_CONFIG_PATH", "/run/codex/config.toml"))
CODEX_AUTH_PATH = Path(os.getenv("CODEX_AUTH_PATH", "/run/codex/auth.json")) CODEX_AUTH_PATH = Path(os.getenv("CODEX_AUTH_PATH", "/run/codex/auth.json"))
AI_REGISTER_TIMEOUT_SECONDS = float(os.getenv("AI_REGISTER_TIMEOUT_SECONDS", "90")) AI_REGISTER_TIMEOUT_SECONDS = float(os.getenv("AI_REGISTER_TIMEOUT_SECONDS", "8"))
AI_REGISTER_MODEL = os.getenv("AI_REGISTER_MODEL", "").strip()
BASIC_AUTH_PASSWORD = os.getenv("BASIC_AUTH_PASSWORD", "") BASIC_AUTH_PASSWORD = os.getenv("BASIC_AUTH_PASSWORD", "")
ACCOUNTS_AUTH_PASSWORD = os.getenv("ACCOUNTS_AUTH_PASSWORD") or os.getenv("ACCOUNT_AUTH_PASSWORD", "") ACCOUNTS_AUTH_PASSWORD = os.getenv("ACCOUNTS_AUTH_PASSWORD") or os.getenv("ACCOUNT_AUTH_PASSWORD", "")
+23 -2
View File
@@ -993,7 +993,23 @@ function renderRegisterPreview(type, data, statusNode) {
} }
if (data.status === "needs_info") { if (data.status === "needs_info") {
const questions = (data.questions || []).map((item) => `<li>${escapeHtml(item)}</li>`).join(""); const questions = (data.questions || []).map((item) => `<li>${escapeHtml(item)}</li>`).join("");
previewNode.innerHTML = `<strong>需要补充信息</strong><ul>${questions}</ul><textarea data-ai-answer rows="3" placeholder="在这里补充缺失信息后再次生成预览"></textarea>`; const fields = data.fields || {};
const labels = data.field_labels || {};
const missing = new Set(data.missing_fields || []);
const fieldNames = Object.keys(labels);
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><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,
@@ -1025,8 +1041,13 @@ async function previewRegister(event, type, lines, statusNode) {
course_summary: summaryRegisterPreview, course_summary: summaryRegisterPreview,
}[type]; }[type];
const answerNode = previewNode ? previewNode.querySelector("[data-ai-answer]") : null; const answerNode = previewNode ? previewNode.querySelector("[data-ai-answer]") : null;
const fieldNodes = previewNode ? previewNode.querySelectorAll("[data-ai-field]") : [];
const state = registerPreviewState[type] || {}; const state = registerPreviewState[type] || {};
const answers = answerNode && answerNode.value.trim() ? { followup: answerNode.value.trim() } : {}; const answers = {};
if (answerNode && answerNode.value.trim()) answers.followup = answerNode.value.trim();
fieldNodes.forEach((node) => {
if (node.value.trim()) answers[node.dataset.aiField] = node.value.trim();
});
clearRegisterPreview(type, !state.conversationId); clearRegisterPreview(type, !state.conversationId);
statusNode.textContent = "正在生成预览"; statusNode.textContent = "正在生成预览";
try { try {
+23
View File
@@ -431,6 +431,29 @@ textarea:focus {
min-height: 72px; min-height: 72px;
} }
.register-field-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 10px;
}
.register-field-grid label {
display: grid;
gap: 4px;
color: #52606d;
font-size: 13px;
}
.register-field-grid input,
.register-field-grid textarea {
width: 100%;
box-sizing: border-box;
}
.register-field-grid label:has(textarea) {
grid-column: 1 / -1;
}
.line-code { .line-code {
display: block; display: block;
max-width: 420px; max-width: 420px;
+2 -1
View File
@@ -19,7 +19,8 @@ services:
OPERATION_LOGS_PATH: ${OPERATION_LOGS_PATH:-/data/operation_logs.jsonl} OPERATION_LOGS_PATH: ${OPERATION_LOGS_PATH:-/data/operation_logs.jsonl}
CODEX_CONFIG_PATH: ${CODEX_CONFIG_PATH:-/run/codex/config.toml} CODEX_CONFIG_PATH: ${CODEX_CONFIG_PATH:-/run/codex/config.toml}
CODEX_AUTH_PATH: ${CODEX_AUTH_PATH:-/run/codex/auth.json} CODEX_AUTH_PATH: ${CODEX_AUTH_PATH:-/run/codex/auth.json}
AI_REGISTER_TIMEOUT_SECONDS: ${AI_REGISTER_TIMEOUT_SECONDS:-90} AI_REGISTER_TIMEOUT_SECONDS: ${AI_REGISTER_TIMEOUT_SECONDS:-8}
AI_REGISTER_MODEL: ${AI_REGISTER_MODEL:-}
INGEST_AUTH_TOKEN: ${INGEST_AUTH_TOKEN:-} INGEST_AUTH_TOKEN: ${INGEST_AUTH_TOKEN:-}
ports: ports:
- "${APP_PORT:-18080}:8000" - "${APP_PORT:-18080}:8000"