精确撤回上课记录并改为脚本登记
This commit is contained in:
+119
-236
@@ -1,16 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
import re
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
import tomllib
|
|
||||||
from typing import Any
|
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 (
|
from .data import (
|
||||||
SUBJECTS,
|
SUBJECTS,
|
||||||
canonical_teacher_name,
|
canonical_teacher_name,
|
||||||
@@ -61,6 +56,12 @@ TIME_RANGE_FLEX_RE = re.compile(
|
|||||||
r"(?:-|-|—|–|~|至|到)\s*"
|
r"(?:-|-|—|–|~|至|到)\s*"
|
||||||
r"(?P<eh>\d{1,2})\s*(?:[::点.])\s*(?P<em>\d{1,2})?"
|
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}老师)")
|
TEACHER_RE = re.compile(r"(?P<teacher>[\u4e00-\u9fa5A-Za-z0-9]{1,8}老师)")
|
||||||
HOURS_RE = re.compile(r"(?P<hours>\d+(?:\.\d+)?)\s*(?:课时|小时)")
|
HOURS_RE = re.compile(r"(?P<hours>\d+(?:\.\d+)?)\s*(?:课时|小时)")
|
||||||
MAX_SESSION_AGE_SECONDS = 30 * 60
|
MAX_SESSION_AGE_SECONDS = 30 * 60
|
||||||
@@ -68,15 +69,6 @@ MAX_SESSIONS = 100
|
|||||||
_SESSIONS: dict[str, dict[str, Any]] = {}
|
_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:
|
def _now() -> datetime:
|
||||||
return datetime.now()
|
return datetime.now()
|
||||||
|
|
||||||
@@ -127,7 +119,7 @@ def normalize_register_type(value: str) -> str:
|
|||||||
}
|
}
|
||||||
register_type = aliases.get(text, text)
|
register_type = aliases.get(text, text)
|
||||||
if register_type not in REGISTER_TYPES:
|
if register_type not in REGISTER_TYPES:
|
||||||
raise ValueError("不支持的 AI 登记类型")
|
raise ValueError("不支持的登记类型")
|
||||||
return register_type
|
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)
|
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]:
|
def _field_labels(register_type: str) -> dict[str, str]:
|
||||||
return FIELD_LABELS.get(register_type, {})
|
return FIELD_LABELS.get(register_type, {})
|
||||||
|
|
||||||
@@ -218,20 +184,37 @@ def _normalize_time_range(value: object) -> str:
|
|||||||
text = str(value or "").strip()
|
text = str(value or "").strip()
|
||||||
if not text:
|
if not text:
|
||||||
return ""
|
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:
|
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)
|
start, end = raw.split("-", 1)
|
||||||
sh, sm = [int(part) for part in start.split(":", 1)]
|
sh, sm = [int(part) for part in start.split(":", 1)]
|
||||||
eh, em = [int(part) for part in end.split(":", 1)]
|
eh, em = [int(part) for part in end.split(":", 1)]
|
||||||
else:
|
else:
|
||||||
match = TIME_RANGE_FLEX_RE.search(text)
|
match = TIME_RANGE_FLEX_RE.search(text)
|
||||||
if not match:
|
if match:
|
||||||
return ""
|
sh = int(match.group("sh"))
|
||||||
sh = int(match.group("sh"))
|
sm = int(match.group("sm") or 0)
|
||||||
sm = int(match.group("sm") or 0)
|
eh = int(match.group("eh"))
|
||||||
eh = int(match.group("eh"))
|
em = int(match.group("em") or 0)
|
||||||
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:
|
if sh > 23 or eh > 23 or sm > 59 or em > 59:
|
||||||
return ""
|
return ""
|
||||||
if eh * 60 + em <= sh * 60 + sm:
|
if eh * 60 + em <= sh * 60 + sm:
|
||||||
@@ -274,11 +257,38 @@ def _extract_student(text: str) -> str:
|
|||||||
return match.group("student") if match else ""
|
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:
|
def _extract_teacher(text: str) -> str:
|
||||||
match = TEACHER_RE.search(text)
|
match = TEACHER_RE.search(text)
|
||||||
return canonical_teacher_name(match.group("teacher")) if match else ""
|
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:
|
def _extract_subject(text: str) -> str:
|
||||||
for subject in SUBJECTS:
|
for subject in SUBJECTS:
|
||||||
if subject in text:
|
if subject in text:
|
||||||
@@ -299,13 +309,6 @@ def _merge_answers(fields: dict[str, str], answers: dict[str, str]) -> dict[str,
|
|||||||
return merged
|
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]:
|
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 not str(fields.get(field) or "").strip()]
|
||||||
|
|
||||||
@@ -316,22 +319,23 @@ def _needs_info_response(
|
|||||||
missing_fields: list[str],
|
missing_fields: list[str],
|
||||||
*,
|
*,
|
||||||
summary: str = "",
|
summary: str = "",
|
||||||
ai_used: bool = False,
|
|
||||||
timed_out: bool = False,
|
timed_out: bool = False,
|
||||||
error: str = "",
|
error: str = "",
|
||||||
|
draft_line: str = "",
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"status": "needs_info",
|
"status": "needs_info",
|
||||||
"standard_lines": [],
|
"standard_lines": [],
|
||||||
"questions": _questions_for_missing(register_type, missing_fields) or [_question_for_error(register_type, error or "信息不完整")],
|
"questions": _questions_for_missing(register_type, missing_fields) or [_question_for_error(register_type, error or "信息不完整")],
|
||||||
"summary": summary or "请补齐缺失字段后再次生成预览",
|
"summary": summary or "请补齐缺失字段后再次生成预览",
|
||||||
"ai_used": ai_used,
|
"ai_used": False,
|
||||||
"fields": fields,
|
"fields": fields,
|
||||||
"missing_fields": missing_fields,
|
"missing_fields": missing_fields,
|
||||||
"field_labels": _field_labels(register_type),
|
"field_labels": _field_labels(register_type),
|
||||||
"timed_out": timed_out,
|
"timed_out": timed_out,
|
||||||
"recognition_source": "partial_local" if not ai_used else "model",
|
"recognition_source": "partial_local",
|
||||||
"error": error,
|
"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]:
|
def _draft_from_fields(register_type: str, fields: dict[str, str]) -> str:
|
||||||
try:
|
if register_type == "class_record":
|
||||||
payload = json.loads(text)
|
student = fields.get("student") or "【学生】"
|
||||||
except json.JSONDecodeError:
|
date_value = _record_date(_normalize_date(fields.get("date"))) if _normalize_date(fields.get("date")) else "【日期】"
|
||||||
match = re.search(r"\{.*\}", text, flags=re.S)
|
time_range = _normalize_time_range(fields.get("time")) or "【时间】"
|
||||||
if not match:
|
teacher = _normalize_teacher_hint(fields.get("teacher")) or "【老师】"
|
||||||
raise ValueError("模型没有返回 JSON")
|
duration = _duration_from_text(fields.get("duration")) or "【时长】"
|
||||||
payload = json.loads(match.group(0))
|
subject = fields.get("subject") or "【科目】"
|
||||||
if not isinstance(payload, dict):
|
return f"{date_value}-{_weekday(_normalize_date(fields.get('date'))) if _normalize_date(fields.get('date')) else '星期?'}-{time_range}-{student}-{duration}-{teacher}-{subject}"
|
||||||
raise ValueError("模型返回必须是 JSON 对象")
|
if register_type == "payment":
|
||||||
return payload
|
student = fields.get("student") or "【学生】"
|
||||||
|
date_value = _normalize_date(fields.get("date")) or "【日期】"
|
||||||
|
hours = str(fields.get("hours") or "").strip() or "【课时数】"
|
||||||
def _response_text(payload: dict[str, Any]) -> str:
|
return f"{student}-{date_value}:{hours}"
|
||||||
if isinstance(payload.get("output_text"), str):
|
student = fields.get("student") or "【学生】"
|
||||||
return str(payload["output_text"])
|
date_value = _normalize_date(fields.get("date")) or "【日期】"
|
||||||
parts: list[str] = []
|
time_range = _normalize_time_range(fields.get("time")) or "【时间】"
|
||||||
for item in payload.get("output") or []:
|
teacher = _normalize_teacher_hint(fields.get("teacher")) or "【老师】"
|
||||||
for content in item.get("content") or []:
|
subject = fields.get("subject") or "【科目】"
|
||||||
if isinstance(content.get("text"), str):
|
body = str(fields.get("body") or "").strip() or "【小结正文】"
|
||||||
parts.append(content["text"])
|
return "\n".join(
|
||||||
return "\n".join(parts).strip()
|
[
|
||||||
|
f"学生:{student}",
|
||||||
|
f"日期:{date_value}",
|
||||||
def call_model_for_standardization(
|
f"时间:{time_range}",
|
||||||
register_type: str,
|
f"老师:{teacher}",
|
||||||
source_lines: list[str],
|
f"科目:{subject}",
|
||||||
answers: dict[str, str],
|
"小结:",
|
||||||
local_fields: dict[str, str] | None = None,
|
body,
|
||||||
missing_fields: list[str] | None = None,
|
]
|
||||||
) -> dict[str, Any]:
|
|
||||||
config = load_codex_ai_config()
|
|
||||||
system_prompt = (
|
|
||||||
"你是教务登记标准化助手。只返回 JSON,不要 Markdown。"
|
|
||||||
"任务是把管理员输入转成现有系统可校验的标准登记文本,或提出中文追问。"
|
|
||||||
"不能编造学生、日期、时间、老师、科目、课时。"
|
|
||||||
)
|
)
|
||||||
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:
|
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]:
|
def _extract_class_record_fields(text: str) -> dict[str, str]:
|
||||||
|
teacher = _extract_teacher(text)
|
||||||
|
subject = _extract_subject(text)
|
||||||
return {
|
return {
|
||||||
"student": _extract_student(text),
|
"student": _extract_class_record_student(text, teacher, subject),
|
||||||
"date": _normalize_date(text),
|
"date": _normalize_date(text),
|
||||||
"time": _normalize_time_range(text),
|
"time": _normalize_time_range(text),
|
||||||
"teacher": _extract_teacher(text),
|
"teacher": teacher,
|
||||||
"subject": _extract_subject(text),
|
"subject": subject,
|
||||||
"duration": _duration_from_text(text),
|
"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(),
|
"student": str(raw.get("student") or _extract_student(text)).strip(),
|
||||||
"date": _normalize_date(raw.get("date_iso") or text),
|
"date": _normalize_date(raw.get("date_iso") or text),
|
||||||
"time": _normalize_time_range(raw.get("time_range") 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(),
|
"subject": str(raw.get("subject") or _extract_subject(text)).strip(),
|
||||||
"body": body or 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:
|
def fields_preview(register_type: str, fields: dict[str, str]) -> dict[str, Any] | None:
|
||||||
missing = _missing_fields(register_type, fields)
|
missing = _missing_fields(register_type, fields)
|
||||||
if missing:
|
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:
|
try:
|
||||||
standard_lines = validate_standard_lines(register_type, [_standard_from_fields(register_type, fields)])
|
standard_lines = validate_standard_lines(register_type, [_standard_from_fields(register_type, fields)])
|
||||||
except (ValueError, KeyError):
|
except (ValueError, KeyError) as exc:
|
||||||
return None
|
return _needs_info_response(
|
||||||
|
register_type,
|
||||||
|
fields,
|
||||||
|
[],
|
||||||
|
summary="脚本无法生成可校验预览,请修改字段后再试",
|
||||||
|
error=str(exc),
|
||||||
|
draft_line=_draft_from_fields(register_type, fields),
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"status": "ready",
|
"status": "ready",
|
||||||
"standard_lines": standard_lines,
|
"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]:
|
def validate_standard_lines(register_type: str, lines: list[str]) -> list[str]:
|
||||||
if not lines:
|
if not lines:
|
||||||
raise ValueError("模型未返回标准行")
|
raise ValueError("标准行不能为空")
|
||||||
standard_lines: list[str] = []
|
standard_lines: list[str] = []
|
||||||
for index, line in enumerate(lines):
|
for index, line in enumerate(lines):
|
||||||
text = str(line or "").strip()
|
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_fields = _merge_answers(extract_local_fields(normalized_type, source_lines), merged_answers)
|
||||||
local_ready = fields_preview(normalized_type, local_fields)
|
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}
|
return {"conversation_id": conversation_id, "type": normalized_type, **local_ready}
|
||||||
missing = _missing_fields(normalized_type, local_fields)
|
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 {
|
return {
|
||||||
"conversation_id": conversation_id,
|
"conversation_id": conversation_id,
|
||||||
"type": normalized_type,
|
"type": normalized_type,
|
||||||
"status": "ready",
|
**_needs_info_response(
|
||||||
"standard_lines": standard_lines,
|
normalized_type,
|
||||||
"questions": [],
|
local_fields,
|
||||||
"summary": str(model_payload.get("summary") or "已生成标准登记内容"),
|
missing,
|
||||||
"ai_used": True,
|
summary="脚本无法补齐全部字段,请补充信息后再次生成预览",
|
||||||
"fields": {},
|
draft_line=_draft_from_fields(normalized_type, local_fields),
|
||||||
"missing_fields": [],
|
),
|
||||||
"field_labels": _field_labels(normalized_type),
|
|
||||||
"timed_out": False,
|
|
||||||
"recognition_source": "model",
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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_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"))
|
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"))
|
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", "")
|
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", "")
|
||||||
|
|||||||
+83
-2
@@ -1794,6 +1794,8 @@ def operation_log_rollback_state(
|
|||||||
return {"can_rollback": False, "rollback_block_reason": str(exc)}
|
return {"can_rollback": False, "rollback_block_reason": str(exc)}
|
||||||
if not target_sources:
|
if not target_sources:
|
||||||
return {"can_rollback": False, "rollback_block_reason": "备份没有文件"}
|
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:
|
if metadata_items is None:
|
||||||
_backup_dirs, metadata_items = backup_context(search_paths)
|
_backup_dirs, metadata_items = backup_context(search_paths)
|
||||||
for other_dir, other_metadata in metadata_items:
|
for other_dir, other_metadata in metadata_items:
|
||||||
@@ -1807,6 +1809,72 @@ def operation_log_rollback_state(
|
|||||||
return {"can_rollback": True, "rollback_block_reason": ""}
|
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(
|
def list_operation_logs(
|
||||||
path: Path,
|
path: Path,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
@@ -1856,6 +1924,19 @@ def rollback_operation_log(
|
|||||||
|
|
||||||
target_backup_id = str(target.get("backup_id") or "").strip()
|
target_backup_id = str(target.get("backup_id") or "").strip()
|
||||||
backup_dir = find_backup_dir(target_backup_id, backup_paths)
|
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)
|
metadata = read_backup_metadata(backup_dir)
|
||||||
file_contents: dict[Path, str] = {}
|
file_contents: dict[Path, str] = {}
|
||||||
restore_contents: list[tuple[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)
|
reasons, proposed_line = auto_register_reasons(normalized, classnotes_path, accounts_path)
|
||||||
for question in ai_questions:
|
for question in ai_questions:
|
||||||
reason = f"模型追问:{question}"
|
reason = f"脚本追问:{question}"
|
||||||
if reason not in reasons:
|
if reason not in reasons:
|
||||||
reasons.append(reason)
|
reasons.append(reason)
|
||||||
if reasons:
|
if reasons:
|
||||||
@@ -3023,7 +3104,7 @@ def ingest_course_summaries(
|
|||||||
if str(item).strip()
|
if str(item).strip()
|
||||||
]
|
]
|
||||||
if ai_questions:
|
if ai_questions:
|
||||||
reasons = [f"模型追问:{question}" for question in ai_questions]
|
reasons = [f"脚本追问:{question}" for question in ai_questions]
|
||||||
reasons.append(str(exc))
|
reasons.append(str(exc))
|
||||||
task = create_incomplete_course_summary_review_task(tasks_path, raw, reasons)
|
task = create_incomplete_course_summary_review_task(tasks_path, raw, reasons)
|
||||||
result["review_pending"] += 1
|
result["review_pending"] += 1
|
||||||
|
|||||||
+1
-1
@@ -29,7 +29,7 @@ UNKNOWN_TEACHERS = {"", "待核对老师", "未知老师"}
|
|||||||
TEACHER_STATUSES = {"在岗", "离职"}
|
TEACHER_STATUSES = {"在岗", "离职"}
|
||||||
UNKNOWN_SUBJECTS = {"", "待核对科目", "未知科目"}
|
UNKNOWN_SUBJECTS = {"", "待核对科目", "未知科目"}
|
||||||
HIGH_CONFIDENCE_VALUES = {"high", "高", "高置信", "true", "1", "yes"}
|
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 = {
|
ROLE_WORDS = {
|
||||||
"student": ("学生", "学员", "孩子", "同学"),
|
"student": ("学生", "学员", "孩子", "同学"),
|
||||||
"teacher": ("老师", "教师"),
|
"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 ""),
|
target_backup_id=str(result.get("target_backup_id") or ""),
|
||||||
backup_id=str(result.get("backup_id") or ""),
|
backup_id=str(result.get("backup_id") or ""),
|
||||||
restored_files=result.get("restored_files") 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:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=409, detail=str(exc)) from 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)
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
def _needs_ai_summary(raw: dict) -> bool:
|
def _needs_script_summary(raw: dict) -> bool:
|
||||||
required = ["student", "teacher", "subject"]
|
required = ["student", "teacher", "subject"]
|
||||||
if any(not str(raw.get(key) or "").strip() for key in required):
|
if any(not str(raw.get(key) or "").strip() for key in required):
|
||||||
return True
|
return True
|
||||||
@@ -61,34 +61,34 @@ def _standard_text_to_raw(text: str, original: dict) -> dict:
|
|||||||
return {**original, **normalized}
|
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] = []
|
processed: list[dict] = []
|
||||||
for raw in summaries:
|
for raw in summaries:
|
||||||
if not _needs_ai_summary(raw):
|
if not _needs_script_summary(raw):
|
||||||
processed.append(raw)
|
processed.append(raw)
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
preview = preview_register(register_type="course_summary", text=_summary_text(raw))
|
preview = preview_register(register_type="course_summary", text=_summary_text(raw))
|
||||||
except ValueError as exc:
|
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
|
continue
|
||||||
if preview.get("status") == "ready" and preview.get("standard_lines"):
|
if preview.get("status") == "ready" and preview.get("standard_lines"):
|
||||||
try:
|
try:
|
||||||
updated = _standard_text_to_raw(str(preview["standard_lines"][0]), raw)
|
updated = _standard_text_to_raw(str(preview["standard_lines"][0]), raw)
|
||||||
processed.append({
|
processed.append({
|
||||||
**updated,
|
**updated,
|
||||||
"ai_used": True,
|
"ai_used": False,
|
||||||
"ai_summary": str(preview.get("summary") or ""),
|
"ai_summary": str(preview.get("summary") or ""),
|
||||||
})
|
})
|
||||||
except ValueError as exc:
|
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
|
continue
|
||||||
questions = [str(item) for item in preview.get("questions") or [] if str(item)]
|
questions = [str(item) for item in preview.get("questions") or [] if str(item)]
|
||||||
processed.append({
|
processed.append({
|
||||||
**raw,
|
**raw,
|
||||||
"ai_used": True,
|
"ai_used": False,
|
||||||
"ai_summary": str(preview.get("summary") or ""),
|
"ai_summary": str(preview.get("summary") or ""),
|
||||||
"ai_questions": questions or ["模型未能补齐课程小结信息"],
|
"ai_questions": questions or ["脚本未能补齐课程小结信息"],
|
||||||
})
|
})
|
||||||
return processed
|
return processed
|
||||||
|
|
||||||
@@ -96,7 +96,7 @@ def _preprocess_summaries_with_ai(summaries: list[dict]) -> list[dict]:
|
|||||||
@router.post("/api/ingest/course-summaries")
|
@router.post("/api/ingest/course-summaries")
|
||||||
def ingest_course_summary_batch(payload: CourseSummaryIngestPayload, _user: str = Depends(verify_ingest_token)):
|
def ingest_course_summary_batch(payload: CourseSummaryIngestPayload, _user: str = Depends(verify_ingest_token)):
|
||||||
try:
|
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:
|
with write_lock:
|
||||||
result = ingest_course_summaries(
|
result = ingest_course_summaries(
|
||||||
classnotes_path=CLASSNOTES_PATH,
|
classnotes_path=CLASSNOTES_PATH,
|
||||||
|
|||||||
+40
-2
@@ -869,6 +869,9 @@ function renderLogDetail(item) {
|
|||||||
if (item.backup_id) details.push(`备份:${item.backup_id}`);
|
if (item.backup_id) details.push(`备份:${item.backup_id}`);
|
||||||
if (item.target_log_id) details.push(`撤回记录:${item.target_log_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.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 (Array.isArray(item.restored_files) && item.restored_files.length) details.push(`恢复文件:${item.restored_files.join(";")}`);
|
||||||
if (item.saved_path) details.push(`文件:${item.saved_path}`);
|
if (item.saved_path) details.push(`文件:${item.saved_path}`);
|
||||||
return `<div class="log-detail">${details.map(escapeHtml).join("<br>") || "暂无详情"}</div>`;
|
return `<div class="log-detail">${details.map(escapeHtml).join("<br>") || "暂无详情"}</div>`;
|
||||||
@@ -988,7 +991,7 @@ function renderRegisterPreview(type, data, statusNode) {
|
|||||||
conversationId: data.conversation_id,
|
conversationId: data.conversation_id,
|
||||||
standardLines: data.standard_lines || [],
|
standardLines: data.standard_lines || [],
|
||||||
};
|
};
|
||||||
statusNode.textContent = data.ai_used ? "模型已生成预览,请确认后写入" : "已通过本地标准格式校验,请确认后写入";
|
statusNode.textContent = "已通过脚本生成预览,请确认后写入";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (data.status === "needs_info") {
|
if (data.status === "needs_info") {
|
||||||
@@ -997,6 +1000,7 @@ function renderRegisterPreview(type, data, statusNode) {
|
|||||||
const labels = data.field_labels || {};
|
const labels = data.field_labels || {};
|
||||||
const missing = new Set(data.missing_fields || []);
|
const missing = new Set(data.missing_fields || []);
|
||||||
const fieldNames = Object.keys(labels);
|
const fieldNames = Object.keys(labels);
|
||||||
|
const draft = renderRegisterDraft(type, fields, missing);
|
||||||
const fieldInputs = fieldNames
|
const fieldInputs = fieldNames
|
||||||
.map((name) => {
|
.map((name) => {
|
||||||
const label = labels[name] || name;
|
const label = labels[name] || name;
|
||||||
@@ -1009,7 +1013,7 @@ function renderRegisterPreview(type, data, statusNode) {
|
|||||||
return `<label><span>${escapeHtml(label)}${requiredMark}</span>${input}</label>`;
|
return `<label><span>${escapeHtml(label)}${requiredMark}</span>${input}</label>`;
|
||||||
})
|
})
|
||||||
.join("");
|
.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;
|
confirmButton.hidden = true;
|
||||||
registerPreviewState[type] = {
|
registerPreviewState[type] = {
|
||||||
conversationId: data.conversation_id,
|
conversationId: data.conversation_id,
|
||||||
@@ -1028,6 +1032,40 @@ function renderRegisterPreview(type, data, statusNode) {
|
|||||||
statusNode.textContent = `预览失败:${data.error || "请修改原文后重试"}`;
|
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) {
|
async function previewRegister(event, type, lines, statusNode) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!lines.length) {
|
if (!lines.length) {
|
||||||
|
|||||||
@@ -431,6 +431,48 @@ textarea:focus {
|
|||||||
min-height: 72px;
|
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 {
|
.register-field-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||||
|
|||||||
@@ -17,14 +17,8 @@ services:
|
|||||||
COURSE_SUMMARIES_ROOT: ${COURSE_SUMMARIES_ROOT:-/data/course_summaries}
|
COURSE_SUMMARIES_ROOT: ${COURSE_SUMMARIES_ROOT:-/data/course_summaries}
|
||||||
COURSE_SUMMARY_STATE_PATH: ${COURSE_SUMMARY_STATE_PATH:-/data/course_summary_state.json}
|
COURSE_SUMMARY_STATE_PATH: ${COURSE_SUMMARY_STATE_PATH:-/data/course_summary_state.json}
|
||||||
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_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:-}
|
INGEST_AUTH_TOKEN: ${INGEST_AUTH_TOKEN:-}
|
||||||
ports:
|
ports:
|
||||||
- "${APP_PORT:-18080}:8000"
|
- "${APP_PORT:-18080}:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- ../data:/data
|
- ../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