精确撤回上课记录并改为脚本登记
This commit is contained in:
+119
-236
@@ -1,16 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, timedelta
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import secrets
|
||||
import tomllib
|
||||
from typing import Any
|
||||
from urllib import error, request
|
||||
|
||||
from .config import ACCOUNTS_PATH, AI_REGISTER_MODEL, AI_REGISTER_TIMEOUT_SECONDS, CODEX_AUTH_PATH, CODEX_CONFIG_PATH
|
||||
from .config import ACCOUNTS_PATH
|
||||
from .data import (
|
||||
SUBJECTS,
|
||||
canonical_teacher_name,
|
||||
@@ -61,6 +56,12 @@ TIME_RANGE_FLEX_RE = re.compile(
|
||||
r"(?:-|-|—|–|~|至|到)\s*"
|
||||
r"(?P<eh>\d{1,2})\s*(?:[::点.])\s*(?P<em>\d{1,2})?"
|
||||
)
|
||||
TIME_RANGE_HOUR_RE = re.compile(
|
||||
r"(?<![\d./-])(?P<sh>\d{1,2})\s*(?:-|-|—|–|~|至|到)\s*(?P<eh>\d{1,2})(?![\d./-])"
|
||||
)
|
||||
TIME_RANGE_COMPACT_RE = re.compile(
|
||||
r"(?<!\d)(?P<start>\d{3,4})\s*(?:-|-|—|–|~|至|到)\s*(?P<end>\d{3,4})(?!\d)"
|
||||
)
|
||||
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
|
||||
@@ -68,15 +69,6 @@ MAX_SESSIONS = 100
|
||||
_SESSIONS: dict[str, dict[str, Any]] = {}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CodexAiConfig:
|
||||
model_provider: str
|
||||
model: str
|
||||
base_url: str
|
||||
wire_api: str
|
||||
api_key: str
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now()
|
||||
|
||||
@@ -127,7 +119,7 @@ def normalize_register_type(value: str) -> str:
|
||||
}
|
||||
register_type = aliases.get(text, text)
|
||||
if register_type not in REGISTER_TYPES:
|
||||
raise ValueError("不支持的 AI 登记类型")
|
||||
raise ValueError("不支持的登记类型")
|
||||
return register_type
|
||||
|
||||
|
||||
@@ -142,32 +134,6 @@ def collect_input_lines(text: str | None = None, lines: list[str] | None = None)
|
||||
return normalize_lines(line=raw)
|
||||
|
||||
|
||||
def load_codex_ai_config(
|
||||
config_path: Path = CODEX_CONFIG_PATH,
|
||||
auth_path: Path = CODEX_AUTH_PATH,
|
||||
) -> CodexAiConfig:
|
||||
if not config_path.exists():
|
||||
raise ValueError(f"AI 配置文件不存在: {config_path}")
|
||||
if not auth_path.exists():
|
||||
raise ValueError(f"AI 授权文件不存在: {auth_path}")
|
||||
config = tomllib.loads(config_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()
|
||||
model = AI_REGISTER_MODEL or str(config.get("model") or "").strip()
|
||||
providers = config.get("model_providers") or {}
|
||||
provider = providers.get(provider_name) or {}
|
||||
base_url = str(provider.get("base_url") or "").strip().rstrip("/")
|
||||
wire_api = str(provider.get("wire_api") or "").strip()
|
||||
api_key = str(auth.get("OPENAI_API_KEY") or "").strip()
|
||||
if not provider_name or not model or not base_url or not wire_api:
|
||||
raise ValueError("AI 配置缺少 model_provider、model、base_url 或 wire_api")
|
||||
if wire_api != "responses":
|
||||
raise ValueError(f"暂不支持的 AI wire_api: {wire_api}")
|
||||
if not api_key:
|
||||
raise ValueError("AI 授权文件缺少 OPENAI_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, {})
|
||||
|
||||
@@ -218,20 +184,37 @@ 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)
|
||||
standard = re.search(r"\d{1,2}:\d{2}\s*(?:-|-|—|–|~|至|到)\s*\d{1,2}:\d{2}", text)
|
||||
if standard:
|
||||
raw = standard.group(0)
|
||||
raw = re.sub(r"\s*(?:-|—|–|~|至|到)\s*", "-", standard.group(0))
|
||||
raw = re.sub(r"\s*-\s*", "-", raw)
|
||||
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 match:
|
||||
sh = int(match.group("sh"))
|
||||
sm = int(match.group("sm") or 0)
|
||||
eh = int(match.group("eh"))
|
||||
em = int(match.group("em") or 0)
|
||||
else:
|
||||
compact = TIME_RANGE_COMPACT_RE.search(text)
|
||||
if compact:
|
||||
start = compact.group("start").zfill(4)
|
||||
end = compact.group("end").zfill(4)
|
||||
sh = int(start[:-2])
|
||||
sm = int(start[-2:])
|
||||
eh = int(end[:-2])
|
||||
em = int(end[-2:])
|
||||
else:
|
||||
hour_only = TIME_RANGE_HOUR_RE.search(text)
|
||||
if not hour_only:
|
||||
return ""
|
||||
sh = int(hour_only.group("sh"))
|
||||
sm = 0
|
||||
eh = int(hour_only.group("eh"))
|
||||
em = 0
|
||||
if sh > 23 or eh > 23 or sm > 59 or em > 59:
|
||||
return ""
|
||||
if eh * 60 + em <= sh * 60 + sm:
|
||||
@@ -274,11 +257,38 @@ def _extract_student(text: str) -> str:
|
||||
return match.group("student") if match else ""
|
||||
|
||||
|
||||
def _extract_class_record_student(text: str, teacher: str, subject: str) -> str:
|
||||
student = _extract_student(text)
|
||||
if student:
|
||||
return student
|
||||
remainder = text
|
||||
for pattern in (DATE_RE, MONTH_DAY_RE, TIME_RANGE_FLEX_RE, TIME_RANGE_COMPACT_RE, TIME_RANGE_HOUR_RE):
|
||||
remainder = pattern.sub(" ", remainder)
|
||||
remainder = re.sub(r"\d{1,2}:\d{2}\s*(?:-|-|—|–|~|至|到)\s*\d{1,2}:\d{2}", " ", remainder)
|
||||
remainder = re.sub(r"星期[一二三四五六日]", " ", remainder)
|
||||
remainder = re.sub(r"\d+(?:\.\d+)?\s*(?:课时|小时)", " ", remainder)
|
||||
remainder = re.sub(r"\d+\s*小时\s*\d*\s*分?", " ", remainder)
|
||||
if teacher:
|
||||
remainder = re.sub(rf"{re.escape(teacher)}(?:老师|教师)?", " ", remainder)
|
||||
if subject:
|
||||
remainder = remainder.replace(subject, " ")
|
||||
remainder = re.sub(r"(?:学生|学员|老师|教师|科目|课程|时间|日期|上课)", " ", remainder)
|
||||
for candidate in re.findall(r"[\u4e00-\u9fa5]{2,4}", remainder):
|
||||
if candidate not in SUBJECTS and candidate not in {"老师", "教师", "学生", "学员"}:
|
||||
return candidate
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_teacher(text: str) -> str:
|
||||
match = TEACHER_RE.search(text)
|
||||
return canonical_teacher_name(match.group("teacher")) if match else ""
|
||||
|
||||
|
||||
def _normalize_teacher_hint(value: object) -> str:
|
||||
text = canonical_teacher_name(str(value or "").strip())
|
||||
return re.sub(r"^以下都是", "", text).strip()
|
||||
|
||||
|
||||
def _extract_subject(text: str) -> str:
|
||||
for subject in SUBJECTS:
|
||||
if subject in text:
|
||||
@@ -299,13 +309,6 @@ def _merge_answers(fields: dict[str, str], answers: dict[str, str]) -> dict[str,
|
||||
return merged
|
||||
|
||||
|
||||
def _model_fields(payload: dict[str, Any]) -> dict[str, str]:
|
||||
fields = payload.get("fields") or {}
|
||||
if not isinstance(fields, dict):
|
||||
return {}
|
||||
return {str(key): str(value or "").strip() for key, value in fields.items() if str(value or "").strip()}
|
||||
|
||||
|
||||
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()]
|
||||
|
||||
@@ -316,22 +319,23 @@ def _needs_info_response(
|
||||
missing_fields: list[str],
|
||||
*,
|
||||
summary: str = "",
|
||||
ai_used: bool = False,
|
||||
timed_out: bool = False,
|
||||
error: str = "",
|
||||
draft_line: 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,
|
||||
"ai_used": False,
|
||||
"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",
|
||||
"recognition_source": "partial_local",
|
||||
"error": error,
|
||||
"draft_line": draft_line,
|
||||
}
|
||||
|
||||
|
||||
@@ -364,106 +368,37 @@ def _standard_from_fields(register_type: str, fields: dict[str, str]) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _json_from_text(text: str) -> dict[str, Any]:
|
||||
try:
|
||||
payload = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
match = re.search(r"\{.*\}", text, flags=re.S)
|
||||
if not match:
|
||||
raise ValueError("模型没有返回 JSON")
|
||||
payload = json.loads(match.group(0))
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("模型返回必须是 JSON 对象")
|
||||
return payload
|
||||
|
||||
|
||||
def _response_text(payload: dict[str, Any]) -> str:
|
||||
if isinstance(payload.get("output_text"), str):
|
||||
return str(payload["output_text"])
|
||||
parts: list[str] = []
|
||||
for item in payload.get("output") or []:
|
||||
for content in item.get("content") or []:
|
||||
if isinstance(content.get("text"), str):
|
||||
parts.append(content["text"])
|
||||
return "\n".join(parts).strip()
|
||||
|
||||
|
||||
def call_model_for_standardization(
|
||||
register_type: str,
|
||||
source_lines: list[str],
|
||||
answers: dict[str, str],
|
||||
local_fields: dict[str, str] | None = None,
|
||||
missing_fields: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
config = load_codex_ai_config()
|
||||
system_prompt = (
|
||||
"你是教务登记标准化助手。只返回 JSON,不要 Markdown。"
|
||||
"任务是把管理员输入转成现有系统可校验的标准登记文本,或提出中文追问。"
|
||||
"不能编造学生、日期、时间、老师、科目、课时。"
|
||||
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}"
|
||||
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 "【课时数】"
|
||||
return f"{student}-{date_value}:{hours}"
|
||||
student = fields.get("student") or "【学生】"
|
||||
date_value = _normalize_date(fields.get("date")) or "【日期】"
|
||||
time_range = _normalize_time_range(fields.get("time")) or "【时间】"
|
||||
teacher = _normalize_teacher_hint(fields.get("teacher")) or "【老师】"
|
||||
subject = fields.get("subject") or "【科目】"
|
||||
body = str(fields.get("body") or "").strip() or "【小结正文】"
|
||||
return "\n".join(
|
||||
[
|
||||
f"学生:{student}",
|
||||
f"日期:{date_value}",
|
||||
f"时间:{time_range}",
|
||||
f"老师:{teacher}",
|
||||
f"科目:{subject}",
|
||||
"小结:",
|
||||
body,
|
||||
]
|
||||
)
|
||||
user_prompt = {
|
||||
"type": register_type,
|
||||
"today": date.today().isoformat(),
|
||||
"formats": {
|
||||
"class_record": "YYYY.MM.DD-星期X-HH:MM-HH:MM-学生-N小时N分-老师-科目",
|
||||
"payment": "学生-YYYY-MM-DD:课时数,课时数只能是数字,例如 张三-2026-06-15:10",
|
||||
"course_summary": "学生:...\n日期:YYYY.MM.DD\n时间:HH:MM-HH:MM\n老师:...\n科目:...\n小结:\n正文",
|
||||
},
|
||||
"input": source_lines,
|
||||
"script_fields": local_fields or {},
|
||||
"script_missing_fields": missing_fields or [],
|
||||
"field_labels": _field_labels(register_type),
|
||||
"answers": answers,
|
||||
"response_schema": {
|
||||
"status": "ready 或 needs_info",
|
||||
"standard_lines": ["完整时提供"],
|
||||
"fields": {"缺信息时提供已能确认的字段"},
|
||||
"questions": ["缺信息时提供中文问题"],
|
||||
"summary": "简短中文预览",
|
||||
},
|
||||
}
|
||||
instructions = (
|
||||
"输出必须是紧凑 JSON。"
|
||||
"script_fields 是脚本已识别字段,script_missing_fields 是脚本缺失字段。"
|
||||
"如果能确认完整内容,必须返回 ready 和 standard_lines。"
|
||||
"如果不能确认,返回 needs_info、fields、questions。"
|
||||
"ready 示例:{\"status\":\"ready\",\"standard_lines\":[\"...\"],\"questions\":[],\"summary\":\"...\"}。"
|
||||
"needs_info 示例:{\"status\":\"needs_info\",\"standard_lines\":[],\"fields\":{\"student\":\"...\"},\"questions\":[\"请补充...\"],\"summary\":\"...\"}。"
|
||||
"不要解释,不要输出额外字段。"
|
||||
)
|
||||
body = json.dumps(
|
||||
{
|
||||
"model": config.model,
|
||||
"input": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": f"{instructions}\n{json.dumps(user_prompt, ensure_ascii=False)}"},
|
||||
],
|
||||
"max_output_tokens": 800,
|
||||
"store": False,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
).encode("utf-8")
|
||||
req = request.Request(
|
||||
f"{config.base_url}/v1/responses",
|
||||
data=body,
|
||||
headers={
|
||||
"Authorization": f"Bearer {config.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with request.urlopen(req, timeout=AI_REGISTER_TIMEOUT_SECONDS) as response:
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
except error.HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8", errors="replace")[:300]
|
||||
raise ValueError(f"模型调用失败: HTTP {exc.code} {detail}") from exc
|
||||
except (OSError, TimeoutError, json.JSONDecodeError) as exc:
|
||||
raise ValueError(f"模型调用失败: {exc}") from exc
|
||||
text = _response_text(payload)
|
||||
if not text:
|
||||
raise ValueError("模型响应为空")
|
||||
return _json_from_text(text)
|
||||
|
||||
|
||||
def _question_for_error(register_type: str, message: str) -> str:
|
||||
@@ -475,12 +410,14 @@ def _question_for_error(register_type: str, message: str) -> str:
|
||||
|
||||
|
||||
def _extract_class_record_fields(text: str) -> dict[str, str]:
|
||||
teacher = _extract_teacher(text)
|
||||
subject = _extract_subject(text)
|
||||
return {
|
||||
"student": _extract_student(text),
|
||||
"student": _extract_class_record_student(text, teacher, subject),
|
||||
"date": _normalize_date(text),
|
||||
"time": _normalize_time_range(text),
|
||||
"teacher": _extract_teacher(text),
|
||||
"subject": _extract_subject(text),
|
||||
"teacher": teacher,
|
||||
"subject": subject,
|
||||
"duration": _duration_from_text(text),
|
||||
}
|
||||
|
||||
@@ -506,7 +443,7 @@ def _extract_course_summary_fields(text: str) -> dict[str, str]:
|
||||
"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(),
|
||||
"teacher": _normalize_teacher_hint(raw.get("teacher") or _extract_teacher(text)),
|
||||
"subject": str(raw.get("subject") or _extract_subject(text)).strip(),
|
||||
"body": body or text.strip(),
|
||||
}
|
||||
@@ -524,11 +461,18 @@ def extract_local_fields(register_type: str, lines: list[str]) -> dict[str, str]
|
||||
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)
|
||||
return _needs_info_response(register_type, fields, missing, draft_line=_draft_from_fields(register_type, fields))
|
||||
try:
|
||||
standard_lines = validate_standard_lines(register_type, [_standard_from_fields(register_type, fields)])
|
||||
except (ValueError, KeyError):
|
||||
return None
|
||||
except (ValueError, KeyError) as exc:
|
||||
return _needs_info_response(
|
||||
register_type,
|
||||
fields,
|
||||
[],
|
||||
summary="脚本无法生成可校验预览,请修改字段后再试",
|
||||
error=str(exc),
|
||||
draft_line=_draft_from_fields(register_type, fields),
|
||||
)
|
||||
return {
|
||||
"status": "ready",
|
||||
"standard_lines": standard_lines,
|
||||
@@ -560,7 +504,7 @@ def standard_lines_preview(register_type: str, standard_lines: list[str], summar
|
||||
|
||||
def validate_standard_lines(register_type: str, lines: list[str]) -> list[str]:
|
||||
if not lines:
|
||||
raise ValueError("模型未返回标准行")
|
||||
raise ValueError("标准行不能为空")
|
||||
standard_lines: list[str] = []
|
||||
for index, line in enumerate(lines):
|
||||
text = str(line or "").strip()
|
||||
@@ -615,78 +559,17 @@ def preview_register(
|
||||
|
||||
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 and local_ready.get("status") == "ready":
|
||||
if local_ready is not None:
|
||||
return {"conversation_id": conversation_id, "type": normalized_type, **local_ready}
|
||||
missing = _missing_fields(normalized_type, local_fields)
|
||||
|
||||
try:
|
||||
model_payload = call_model_for_standardization(normalized_type, source_lines, merged_answers, local_fields, missing)
|
||||
except ValueError as exc:
|
||||
timed_out = "timed out" in str(exc).lower() or "timeout" in str(exc).lower()
|
||||
return {
|
||||
"conversation_id": conversation_id,
|
||||
"type": normalized_type,
|
||||
**_needs_info_response(
|
||||
normalized_type,
|
||||
local_fields,
|
||||
missing,
|
||||
ai_used=True,
|
||||
timed_out=timed_out,
|
||||
error=str(exc),
|
||||
summary="模型未及时生成可靠预览,请补齐字段后再生成",
|
||||
),
|
||||
}
|
||||
|
||||
status = str(model_payload.get("status") or "").strip()
|
||||
questions = [str(item).strip() for item in model_payload.get("questions") or [] if str(item).strip()]
|
||||
if status == "needs_info" or questions:
|
||||
session["questions"] = questions
|
||||
fields = _merge_answers(local_fields, _model_fields(model_payload))
|
||||
return {
|
||||
"conversation_id": conversation_id,
|
||||
"type": normalized_type,
|
||||
"status": "needs_info",
|
||||
"standard_lines": [],
|
||||
"questions": questions or ["请补齐缺失信息"],
|
||||
"summary": str(model_payload.get("summary") or ""),
|
||||
"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:
|
||||
standard_lines = validate_standard_lines(normalized_type, model_payload.get("standard_lines") or [])
|
||||
except ValueError as exc:
|
||||
fields = _merge_answers(local_fields, _model_fields(model_payload))
|
||||
return {
|
||||
"conversation_id": conversation_id,
|
||||
"type": normalized_type,
|
||||
"status": "needs_info",
|
||||
"standard_lines": [],
|
||||
"questions": [_question_for_error(normalized_type, str(exc))],
|
||||
"summary": str(model_payload.get("summary") or ""),
|
||||
"ai_used": True,
|
||||
"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 {
|
||||
"conversation_id": conversation_id,
|
||||
"type": normalized_type,
|
||||
"status": "ready",
|
||||
"standard_lines": standard_lines,
|
||||
"questions": [],
|
||||
"summary": str(model_payload.get("summary") or "已生成标准登记内容"),
|
||||
"ai_used": True,
|
||||
"fields": {},
|
||||
"missing_fields": [],
|
||||
"field_labels": _field_labels(normalized_type),
|
||||
"timed_out": False,
|
||||
"recognition_source": "model",
|
||||
**_needs_info_response(
|
||||
normalized_type,
|
||||
local_fields,
|
||||
missing,
|
||||
summary="脚本无法补齐全部字段,请补充信息后再次生成预览",
|
||||
draft_line=_draft_from_fields(normalized_type, local_fields),
|
||||
),
|
||||
}
|
||||
|
||||
@@ -15,10 +15,6 @@ ADMIN_TASKS_PATH = Path(os.getenv("ADMIN_TASKS_PATH", "/data/admin_tasks.json"))
|
||||
COURSE_SUMMARIES_ROOT = Path(os.getenv("COURSE_SUMMARIES_ROOT", "/data/course_summaries"))
|
||||
COURSE_SUMMARY_STATE_PATH = Path(os.getenv("COURSE_SUMMARY_STATE_PATH", "/data/course_summary_state.json"))
|
||||
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_AUTH_PATH = Path(os.getenv("CODEX_AUTH_PATH", "/run/codex/auth.json"))
|
||||
AI_REGISTER_TIMEOUT_SECONDS = float(os.getenv("AI_REGISTER_TIMEOUT_SECONDS", "8"))
|
||||
AI_REGISTER_MODEL = "gpt-5.4-mini"
|
||||
|
||||
BASIC_AUTH_PASSWORD = os.getenv("BASIC_AUTH_PASSWORD", "")
|
||||
ACCOUNTS_AUTH_PASSWORD = os.getenv("ACCOUNTS_AUTH_PASSWORD") or os.getenv("ACCOUNT_AUTH_PASSWORD", "")
|
||||
|
||||
+83
-2
@@ -1794,6 +1794,8 @@ def operation_log_rollback_state(
|
||||
return {"can_rollback": False, "rollback_block_reason": str(exc)}
|
||||
if not target_sources:
|
||||
return {"can_rollback": False, "rollback_block_reason": "备份没有文件"}
|
||||
if operation == "登记上课记录" and str(item.get("proposed_line") or "").strip():
|
||||
return {"can_rollback": True, "rollback_block_reason": "", "rollback_mode": "precise_class_record"}
|
||||
if metadata_items is None:
|
||||
_backup_dirs, metadata_items = backup_context(search_paths)
|
||||
for other_dir, other_metadata in metadata_items:
|
||||
@@ -1807,6 +1809,72 @@ def operation_log_rollback_state(
|
||||
return {"can_rollback": True, "rollback_block_reason": ""}
|
||||
|
||||
|
||||
def remove_one_class_record_line(original_text: str, target_line: str) -> str:
|
||||
target = target_line.strip()
|
||||
lines = original_text.splitlines()
|
||||
output: list[str] = []
|
||||
removed = False
|
||||
for line in lines:
|
||||
if not removed and line.strip() == target:
|
||||
removed = True
|
||||
continue
|
||||
output.append(line)
|
||||
if not removed:
|
||||
raise ValueError("要撤回的上课记录已不存在,不能精确撤回")
|
||||
trailing_newline = "\n" if original_text.endswith("\n") else ""
|
||||
return "\n".join(output) + trailing_newline
|
||||
|
||||
|
||||
def rollback_class_record_registration(
|
||||
target: dict,
|
||||
classnotes_path: Path,
|
||||
accounts_path: Path,
|
||||
) -> dict:
|
||||
proposed_line = str(target.get("proposed_line") or "").strip()
|
||||
if not proposed_line:
|
||||
raise ValueError("登记上课记录缺少 proposed_line,不能精确撤回")
|
||||
record = parse_class_record_line(proposed_line)
|
||||
original_classnotes = classnotes_path.read_text(encoding="utf-8")
|
||||
original_accounts = accounts_path.read_text(encoding="utf-8")
|
||||
new_classnotes = remove_one_class_record_line(original_classnotes, class_record_to_line(record))
|
||||
|
||||
accounts = read_accounts(accounts_path)
|
||||
updated_accounts = list(accounts)
|
||||
account_index = find_account_index(updated_accounts, record.student)
|
||||
updated_accounts[account_index] = update_account_remaining(updated_accounts[account_index], record.duration_hours)
|
||||
new_accounts = replace_account_lines(
|
||||
original_accounts,
|
||||
{updated_accounts[account_index].student_id: updated_accounts[account_index]},
|
||||
)
|
||||
|
||||
target_backup_id = str(target.get("backup_id") or "").strip()
|
||||
rollback_backup = create_data_backup(
|
||||
"rollback-operation",
|
||||
{
|
||||
accounts_path: original_accounts,
|
||||
classnotes_path: original_classnotes,
|
||||
},
|
||||
[str(target.get("id") or ""), target_backup_id, proposed_line],
|
||||
)
|
||||
try:
|
||||
atomic_write_text(accounts_path, new_accounts)
|
||||
atomic_write_text(classnotes_path, new_classnotes)
|
||||
except Exception:
|
||||
atomic_write_text(accounts_path, original_accounts)
|
||||
atomic_write_text(classnotes_path, original_classnotes)
|
||||
raise
|
||||
|
||||
return {
|
||||
"target_log_id": str(target.get("id") or ""),
|
||||
"target_backup_id": target_backup_id,
|
||||
"backup_id": rollback_backup.name,
|
||||
"rollback_mode": "precise_class_record",
|
||||
"removed_lines": [class_record_to_line(record)],
|
||||
"restored_hours": record.duration_hours,
|
||||
"restored_files": [str(accounts_path), str(classnotes_path)],
|
||||
}
|
||||
|
||||
|
||||
def list_operation_logs(
|
||||
path: Path,
|
||||
limit: int = 100,
|
||||
@@ -1856,6 +1924,19 @@ def rollback_operation_log(
|
||||
|
||||
target_backup_id = str(target.get("backup_id") or "").strip()
|
||||
backup_dir = find_backup_dir(target_backup_id, backup_paths)
|
||||
if state.get("rollback_mode") == "precise_class_record":
|
||||
metadata = read_backup_metadata(backup_dir)
|
||||
paths: dict[str, Path] = {}
|
||||
for file_meta in metadata.get("files") or []:
|
||||
if isinstance(file_meta, dict):
|
||||
path = backup_source_path(backup_dir, file_meta)
|
||||
paths[path.name] = path
|
||||
classnotes_path = paths.get("classnotes.txt")
|
||||
accounts_path = paths.get("学生课时账户.md")
|
||||
if classnotes_path is None or accounts_path is None:
|
||||
raise ValueError("登记上课记录备份缺少 classnotes.txt 或 学生课时账户.md")
|
||||
return rollback_class_record_registration(target, classnotes_path, accounts_path)
|
||||
|
||||
metadata = read_backup_metadata(backup_dir)
|
||||
file_contents: dict[Path, str] = {}
|
||||
restore_contents: list[tuple[Path, str]] = []
|
||||
@@ -2955,7 +3036,7 @@ def ingest_course_summaries(
|
||||
|
||||
reasons, proposed_line = auto_register_reasons(normalized, classnotes_path, accounts_path)
|
||||
for question in ai_questions:
|
||||
reason = f"模型追问:{question}"
|
||||
reason = f"脚本追问:{question}"
|
||||
if reason not in reasons:
|
||||
reasons.append(reason)
|
||||
if reasons:
|
||||
@@ -3023,7 +3104,7 @@ def ingest_course_summaries(
|
||||
if str(item).strip()
|
||||
]
|
||||
if ai_questions:
|
||||
reasons = [f"模型追问:{question}" for question in ai_questions]
|
||||
reasons = [f"脚本追问:{question}" for question in ai_questions]
|
||||
reasons.append(str(exc))
|
||||
task = create_incomplete_course_summary_review_task(tasks_path, raw, reasons)
|
||||
result["review_pending"] += 1
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ UNKNOWN_TEACHERS = {"", "待核对老师", "未知老师"}
|
||||
TEACHER_STATUSES = {"在岗", "离职"}
|
||||
UNKNOWN_SUBJECTS = {"", "待核对科目", "未知科目"}
|
||||
HIGH_CONFIDENCE_VALUES = {"high", "高", "高置信", "true", "1", "yes"}
|
||||
AUTO_RECOGNITION_SOURCES = {"keyword", "rule", "model", "model_high_confidence", "manual_admin", "大模型高置信识别", "关键词"}
|
||||
AUTO_RECOGNITION_SOURCES = {"keyword", "rule", "manual_admin", "关键词"}
|
||||
ROLE_WORDS = {
|
||||
"student": ("学生", "学员", "孩子", "同学"),
|
||||
"teacher": ("老师", "教师"),
|
||||
|
||||
@@ -88,6 +88,9 @@ def admin_rollback_operation_log(log_id: str, _user: str = Depends(verify_admin_
|
||||
target_backup_id=str(result.get("target_backup_id") or ""),
|
||||
backup_id=str(result.get("backup_id") or ""),
|
||||
restored_files=result.get("restored_files") or [],
|
||||
rollback_mode=str(result.get("rollback_mode") or ""),
|
||||
removed_lines=result.get("removed_lines") or [],
|
||||
restored_hours=result.get("restored_hours") or "",
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
|
||||
@@ -42,7 +42,7 @@ def _summary_text(raw: dict) -> str:
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _needs_ai_summary(raw: dict) -> bool:
|
||||
def _needs_script_summary(raw: dict) -> bool:
|
||||
required = ["student", "teacher", "subject"]
|
||||
if any(not str(raw.get(key) or "").strip() for key in required):
|
||||
return True
|
||||
@@ -61,34 +61,34 @@ def _standard_text_to_raw(text: str, original: dict) -> dict:
|
||||
return {**original, **normalized}
|
||||
|
||||
|
||||
def _preprocess_summaries_with_ai(summaries: list[dict]) -> list[dict]:
|
||||
def _preprocess_summaries_locally(summaries: list[dict]) -> list[dict]:
|
||||
processed: list[dict] = []
|
||||
for raw in summaries:
|
||||
if not _needs_ai_summary(raw):
|
||||
if not _needs_script_summary(raw):
|
||||
processed.append(raw)
|
||||
continue
|
||||
try:
|
||||
preview = preview_register(register_type="course_summary", text=_summary_text(raw))
|
||||
except ValueError as exc:
|
||||
processed.append({**raw, "ai_used": True, "ai_questions": [str(exc)]})
|
||||
processed.append({**raw, "ai_used": False, "ai_questions": [str(exc)]})
|
||||
continue
|
||||
if preview.get("status") == "ready" and preview.get("standard_lines"):
|
||||
try:
|
||||
updated = _standard_text_to_raw(str(preview["standard_lines"][0]), raw)
|
||||
processed.append({
|
||||
**updated,
|
||||
"ai_used": True,
|
||||
"ai_used": False,
|
||||
"ai_summary": str(preview.get("summary") or ""),
|
||||
})
|
||||
except ValueError as exc:
|
||||
processed.append({**raw, "ai_used": True, "ai_questions": [str(exc)]})
|
||||
processed.append({**raw, "ai_used": False, "ai_questions": [str(exc)]})
|
||||
continue
|
||||
questions = [str(item) for item in preview.get("questions") or [] if str(item)]
|
||||
processed.append({
|
||||
**raw,
|
||||
"ai_used": True,
|
||||
"ai_used": False,
|
||||
"ai_summary": str(preview.get("summary") or ""),
|
||||
"ai_questions": questions or ["模型未能补齐课程小结信息"],
|
||||
"ai_questions": questions or ["脚本未能补齐课程小结信息"],
|
||||
})
|
||||
return processed
|
||||
|
||||
@@ -96,7 +96,7 @@ def _preprocess_summaries_with_ai(summaries: list[dict]) -> list[dict]:
|
||||
@router.post("/api/ingest/course-summaries")
|
||||
def ingest_course_summary_batch(payload: CourseSummaryIngestPayload, _user: str = Depends(verify_ingest_token)):
|
||||
try:
|
||||
summaries = _preprocess_summaries_with_ai([item.dict() for item in payload.summaries])
|
||||
summaries = _preprocess_summaries_locally([item.dict() for item in payload.summaries])
|
||||
with write_lock:
|
||||
result = ingest_course_summaries(
|
||||
classnotes_path=CLASSNOTES_PATH,
|
||||
|
||||
+40
-2
@@ -869,6 +869,9 @@ function renderLogDetail(item) {
|
||||
if (item.backup_id) details.push(`备份:${item.backup_id}`);
|
||||
if (item.target_log_id) details.push(`撤回记录:${item.target_log_id}`);
|
||||
if (item.target_backup_id) details.push(`撤回备份:${item.target_backup_id}`);
|
||||
if (item.rollback_mode) details.push(`撤回方式:${item.rollback_mode}`);
|
||||
if (Array.isArray(item.removed_lines) && item.removed_lines.length) details.push(`删除记录:${item.removed_lines.join(";")}`);
|
||||
if (item.restored_hours !== undefined && item.restored_hours !== "") details.push(`返还课时:${item.restored_hours}`);
|
||||
if (Array.isArray(item.restored_files) && item.restored_files.length) details.push(`恢复文件:${item.restored_files.join(";")}`);
|
||||
if (item.saved_path) details.push(`文件:${item.saved_path}`);
|
||||
return `<div class="log-detail">${details.map(escapeHtml).join("<br>") || "暂无详情"}</div>`;
|
||||
@@ -988,7 +991,7 @@ function renderRegisterPreview(type, data, statusNode) {
|
||||
conversationId: data.conversation_id,
|
||||
standardLines: data.standard_lines || [],
|
||||
};
|
||||
statusNode.textContent = data.ai_used ? "模型已生成预览,请确认后写入" : "已通过本地标准格式校验,请确认后写入";
|
||||
statusNode.textContent = "已通过脚本生成预览,请确认后写入";
|
||||
return;
|
||||
}
|
||||
if (data.status === "needs_info") {
|
||||
@@ -997,6 +1000,7 @@ function renderRegisterPreview(type, data, statusNode) {
|
||||
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;
|
||||
@@ -1009,7 +1013,7 @@ function renderRegisterPreview(type, data, statusNode) {
|
||||
return `<label><span>${escapeHtml(label)}${requiredMark}</span>${input}</label>`;
|
||||
})
|
||||
.join("");
|
||||
previewNode.innerHTML = `<strong>需要补充信息</strong><ul>${questions}</ul><div class="register-field-grid">${fieldInputs}</div>`;
|
||||
previewNode.innerHTML = `<strong>需要补充信息</strong><ul>${questions}</ul>${draft}<div class="register-field-grid">${fieldInputs}</div>`;
|
||||
confirmButton.hidden = true;
|
||||
registerPreviewState[type] = {
|
||||
conversationId: data.conversation_id,
|
||||
@@ -1028,6 +1032,40 @@ function renderRegisterPreview(type, data, statusNode) {
|
||||
statusNode.textContent = `预览失败:${data.error || "请修改原文后重试"}`;
|
||||
}
|
||||
|
||||
function inlineDraftPart(name, fields, missing, fallback) {
|
||||
const value = fields[name] || "";
|
||||
if (!missing.has(name) && value) return `<span>${escapeHtml(value)}</span>`;
|
||||
return `<input data-ai-field="${escapeHtml(name)}" value="${escapeHtml(value)}" placeholder="${escapeHtml(fallback)}">`;
|
||||
}
|
||||
|
||||
function renderRegisterDraft(type, fields, missing) {
|
||||
if (type === "class_record") {
|
||||
const dateText = inlineDraftPart("date", fields, missing, "日期");
|
||||
const timeText = inlineDraftPart("time", fields, missing, "时间");
|
||||
const studentText = inlineDraftPart("student", fields, missing, "学生");
|
||||
const durationText = inlineDraftPart("duration", fields, missing, "时长");
|
||||
const teacherText = inlineDraftPart("teacher", fields, missing, "老师");
|
||||
const subjectText = inlineDraftPart("subject", fields, missing, "科目");
|
||||
return `<div class="register-draft"><span>${dateText}</span><span>星期?</span><span>${timeText}</span><span>${studentText}</span><span>${durationText}</span><span>${teacherText}</span><span>${subjectText}</span></div>`;
|
||||
}
|
||||
if (type === "payment") {
|
||||
const studentText = inlineDraftPart("student", fields, missing, "学生");
|
||||
const dateText = inlineDraftPart("date", fields, missing, "日期");
|
||||
const hoursText = inlineDraftPart("hours", fields, missing, "课时数");
|
||||
return `<div class="register-draft payment-draft"><span>${studentText}</span><b>-</b><span>${dateText}</span><b>:</b><span>${hoursText}</span></div>`;
|
||||
}
|
||||
const studentText = inlineDraftPart("student", fields, missing, "学生");
|
||||
const dateText = inlineDraftPart("date", fields, missing, "日期");
|
||||
const timeText = inlineDraftPart("time", fields, missing, "时间");
|
||||
const teacherText = inlineDraftPart("teacher", fields, missing, "老师");
|
||||
const subjectText = inlineDraftPart("subject", fields, missing, "科目");
|
||||
const bodyValue = fields.body || "";
|
||||
const bodyInput = missing.has("body")
|
||||
? `<textarea data-ai-field="body" rows="4" placeholder="小结正文">${escapeHtml(bodyValue)}</textarea>`
|
||||
: `<pre>${escapeHtml(bodyValue)}</pre>`;
|
||||
return `<div class="register-draft summary-draft"><span>学生:${studentText}</span><span>日期:${dateText}</span><span>时间:${timeText}</span><span>老师:${teacherText}</span><span>科目:${subjectText}</span>${bodyInput}</div>`;
|
||||
}
|
||||
|
||||
async function previewRegister(event, type, lines, statusNode) {
|
||||
event.preventDefault();
|
||||
if (!lines.length) {
|
||||
|
||||
@@ -431,6 +431,48 @@ textarea:focus {
|
||||
min-height: 72px;
|
||||
}
|
||||
|
||||
.register-draft {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: #344054;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.register-draft span {
|
||||
min-height: 22px;
|
||||
}
|
||||
|
||||
.register-draft input,
|
||||
.register-draft textarea {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.payment-draft {
|
||||
grid-template-columns: minmax(90px, 1fr) auto minmax(120px, 1fr) auto minmax(80px, 0.8fr);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.payment-draft b {
|
||||
color: #667085;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.summary-draft pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.register-field-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
|
||||
@@ -17,14 +17,8 @@ services:
|
||||
COURSE_SUMMARIES_ROOT: ${COURSE_SUMMARIES_ROOT:-/data/course_summaries}
|
||||
COURSE_SUMMARY_STATE_PATH: ${COURSE_SUMMARY_STATE_PATH:-/data/course_summary_state.json}
|
||||
OPERATION_LOGS_PATH: ${OPERATION_LOGS_PATH:-/data/operation_logs.jsonl}
|
||||
CODEX_CONFIG_PATH: ${CODEX_CONFIG_PATH:-/run/codex/config.toml}
|
||||
CODEX_AUTH_PATH: ${CODEX_AUTH_PATH:-/run/codex/auth.json}
|
||||
AI_REGISTER_TIMEOUT_SECONDS: ${AI_REGISTER_TIMEOUT_SECONDS:-8}
|
||||
AI_REGISTER_MODEL: gpt-5.4-mini
|
||||
INGEST_AUTH_TOKEN: ${INGEST_AUTH_TOKEN:-}
|
||||
ports:
|
||||
- "${APP_PORT:-18080}:8000"
|
||||
volumes:
|
||||
- ../data:/data
|
||||
- /root/.codex/config.toml:/run/codex/config.toml:ro
|
||||
- /root/.codex/auth.json:/run/codex/auth.json:ro
|
||||
|
||||
Reference in New Issue
Block a user