精确撤回上课记录并改为脚本登记

This commit is contained in:
Codex
2026-06-18 00:58:41 +08:00
parent 5163714853
commit baa54def66
9 changed files with 297 additions and 260 deletions
+110 -227
View File
@@ -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 ""
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,14 +559,9 @@ 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,
@@ -630,63 +569,7 @@ def preview_register(
normalized_type,
local_fields,
missing,
ai_used=True,
timed_out=timed_out,
error=str(exc),
summary="模型未及时生成可靠预览,请补齐字段后再生成",
summary="脚本无法补齐全部字段,请补充信息后再次生成预览",
draft_line=_draft_from_fields(normalized_type, local_fields),
),
}
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",
}
-4
View File
@@ -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
View File
@@ -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
View File
@@ -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": ("老师", "教师"),
+3
View File
@@ -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
+9 -9
View File
@@ -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
View File
@@ -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) {
+42
View File
@@ -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));
-6
View File
@@ -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