接入 AI 登记预览与追问
This commit is contained in:
@@ -0,0 +1,349 @@
|
||||
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 AI_REGISTER_TIMEOUT_SECONDS, CODEX_AUTH_PATH, CODEX_CONFIG_PATH
|
||||
from .data import (
|
||||
class_record_to_line,
|
||||
course_summary_to_class_record_line,
|
||||
extract_course_summary_from_text,
|
||||
normalize_course_summary,
|
||||
normalize_lines,
|
||||
parse_class_record_line,
|
||||
parse_payment_line,
|
||||
)
|
||||
|
||||
|
||||
REGISTER_TYPES = {"class_record", "payment", "course_summary"}
|
||||
MAX_SESSION_AGE_SECONDS = 30 * 60
|
||||
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()
|
||||
|
||||
|
||||
def _cleanup_sessions() -> None:
|
||||
cutoff = _now() - timedelta(seconds=MAX_SESSION_AGE_SECONDS)
|
||||
stale = [
|
||||
session_id
|
||||
for session_id, session in _SESSIONS.items()
|
||||
if session.get("updated_at", _now()) < cutoff
|
||||
]
|
||||
for session_id in stale:
|
||||
_SESSIONS.pop(session_id, None)
|
||||
if len(_SESSIONS) <= MAX_SESSIONS:
|
||||
return
|
||||
ordered = sorted(_SESSIONS.items(), key=lambda item: item[1].get("updated_at", _now()))
|
||||
for session_id, _session in ordered[: len(_SESSIONS) - MAX_SESSIONS]:
|
||||
_SESSIONS.pop(session_id, None)
|
||||
|
||||
|
||||
def _session_for(conversation_id: str | None, register_type: str, text: str) -> tuple[str, dict[str, Any]]:
|
||||
_cleanup_sessions()
|
||||
if conversation_id and conversation_id in _SESSIONS:
|
||||
session = _SESSIONS[conversation_id]
|
||||
else:
|
||||
conversation_id = secrets.token_urlsafe(16)
|
||||
session = {"type": register_type, "text": text, "answers": {}, "created_at": _now()}
|
||||
_SESSIONS[conversation_id] = session
|
||||
session["type"] = register_type
|
||||
if text:
|
||||
session["text"] = text
|
||||
session["updated_at"] = _now()
|
||||
return conversation_id, session
|
||||
|
||||
|
||||
def normalize_register_type(value: str) -> str:
|
||||
text = str(value or "").strip()
|
||||
aliases = {
|
||||
"class": "class_record",
|
||||
"class_records": "class_record",
|
||||
"class-records": "class_record",
|
||||
"上课记录": "class_record",
|
||||
"payments": "payment",
|
||||
"缴费": "payment",
|
||||
"course-summaries": "course_summary",
|
||||
"course_summaries": "course_summary",
|
||||
"课程小结": "course_summary",
|
||||
}
|
||||
register_type = aliases.get(text, text)
|
||||
if register_type not in REGISTER_TYPES:
|
||||
raise ValueError("不支持的 AI 登记类型")
|
||||
return register_type
|
||||
|
||||
|
||||
def collect_input_lines(text: str | None = None, lines: list[str] | None = None) -> list[str]:
|
||||
if lines is not None:
|
||||
return normalize_lines(lines=lines)
|
||||
raw = str(text or "")
|
||||
if "\n\n" in raw:
|
||||
chunks = [item.strip() for item in re.split(r"\n\s*\n", raw) if item.strip()]
|
||||
if chunks:
|
||||
return chunks
|
||||
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 = 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 _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],
|
||||
) -> 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,
|
||||
"answers": answers,
|
||||
"response_schema": {
|
||||
"status": "ready 或 needs_info",
|
||||
"standard_lines": ["完整时提供"],
|
||||
"questions": ["缺信息时提供中文问题"],
|
||||
"summary": "简短中文预览",
|
||||
},
|
||||
}
|
||||
instructions = (
|
||||
"输出必须是紧凑 JSON。"
|
||||
"ready 示例:{\"status\":\"ready\",\"standard_lines\":[\"...\"],\"questions\":[],\"summary\":\"...\"}。"
|
||||
"needs_info 示例:{\"status\":\"needs_info\",\"standard_lines\":[],\"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:
|
||||
if register_type == "class_record":
|
||||
return f"请按标准格式补齐或修改上课记录:{message}"
|
||||
if register_type == "payment":
|
||||
return f"请按“学生-YYYY-MM-DD:课时”补齐或修改缴费记录:{message}"
|
||||
return f"请补齐或修改课程小结信息:{message}"
|
||||
|
||||
|
||||
def validate_standard_lines(register_type: str, lines: list[str]) -> list[str]:
|
||||
if not lines:
|
||||
raise ValueError("模型未返回标准行")
|
||||
standard_lines: list[str] = []
|
||||
for index, line in enumerate(lines):
|
||||
text = str(line or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
try:
|
||||
if register_type == "class_record":
|
||||
standard_lines.append(class_record_to_line(parse_class_record_line(text)))
|
||||
elif register_type == "payment":
|
||||
text = re.sub(r":\s*(\d+(?:\.\d+)?)\s*(?:课时|小时)\s*$", r":\1", text)
|
||||
student, payment = parse_payment_line(text)
|
||||
hours = int(payment.hours) if float(payment.hours).is_integer() else payment.hours
|
||||
standard_lines.append(f"{student}-{payment.date}:{hours}")
|
||||
else:
|
||||
raw = extract_course_summary_from_text(text, index)
|
||||
normalized = normalize_course_summary(raw)
|
||||
course_summary_to_class_record_line(normalized)
|
||||
standard_lines.append(text)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"第 {index + 1} 条校验失败:{exc}") from exc
|
||||
if not standard_lines:
|
||||
raise ValueError("标准行不能为空")
|
||||
return standard_lines
|
||||
|
||||
|
||||
def local_preview(register_type: str, lines: list[str]) -> dict[str, Any] | None:
|
||||
try:
|
||||
standard_lines = validate_standard_lines(register_type, lines)
|
||||
except ValueError:
|
||||
return None
|
||||
return {
|
||||
"status": "ready",
|
||||
"standard_lines": standard_lines,
|
||||
"questions": [],
|
||||
"summary": "已按标准格式通过本地校验",
|
||||
"ai_used": False,
|
||||
}
|
||||
|
||||
|
||||
def preview_register(
|
||||
*,
|
||||
register_type: str,
|
||||
text: str | None = None,
|
||||
lines: list[str] | None = None,
|
||||
answers: dict[str, str] | None = None,
|
||||
conversation_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
normalized_type = normalize_register_type(register_type)
|
||||
source_lines = collect_input_lines(text=text, lines=lines)
|
||||
joined_text = "\n\n".join(source_lines)
|
||||
conversation_id, session = _session_for(conversation_id, normalized_type, joined_text)
|
||||
merged_answers = {**dict(session.get("answers") or {}), **(answers or {})}
|
||||
session["answers"] = merged_answers
|
||||
|
||||
local = local_preview(normalized_type, source_lines)
|
||||
if local is not None and not merged_answers:
|
||||
return {"conversation_id": conversation_id, "type": normalized_type, **local}
|
||||
|
||||
try:
|
||||
model_payload = call_model_for_standardization(normalized_type, source_lines, merged_answers)
|
||||
except ValueError as exc:
|
||||
if local is not None:
|
||||
return {
|
||||
"conversation_id": conversation_id,
|
||||
"type": normalized_type,
|
||||
**local,
|
||||
"warning": "模型不可用,已使用本地标准格式校验预览",
|
||||
}
|
||||
return {
|
||||
"conversation_id": conversation_id,
|
||||
"type": normalized_type,
|
||||
"status": "error",
|
||||
"standard_lines": [],
|
||||
"questions": [_question_for_error(normalized_type, str(exc))],
|
||||
"summary": "",
|
||||
"ai_used": True,
|
||||
"error": str(exc),
|
||||
}
|
||||
|
||||
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
|
||||
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,
|
||||
}
|
||||
|
||||
try:
|
||||
standard_lines = validate_standard_lines(normalized_type, model_payload.get("standard_lines") or [])
|
||||
except ValueError as exc:
|
||||
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),
|
||||
}
|
||||
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,
|
||||
}
|
||||
@@ -15,6 +15,9 @@ 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", "90"))
|
||||
|
||||
BASIC_AUTH_PASSWORD = os.getenv("BASIC_AUTH_PASSWORD", "")
|
||||
ACCOUNTS_AUTH_PASSWORD = os.getenv("ACCOUNTS_AUTH_PASSWORD") or os.getenv("ACCOUNT_AUTH_PASSWORD", "")
|
||||
|
||||
+78
-2
@@ -2047,6 +2047,39 @@ def create_course_summary_review_task(
|
||||
return task_to_dict(task)
|
||||
|
||||
|
||||
def create_incomplete_course_summary_review_task(
|
||||
tasks_path: Path,
|
||||
raw: dict,
|
||||
reasons: list[str],
|
||||
) -> dict:
|
||||
body = str(raw.get("body") or raw.get("content") or "").strip()
|
||||
source_id = str(raw.get("source_id") or sha1_text(json.dumps(raw, ensure_ascii=False, sort_keys=True), 24))
|
||||
summary = {
|
||||
"source_id": source_id,
|
||||
"student": canonical_name(str(raw.get("student") or "").strip()),
|
||||
"date_iso": str(raw.get("date_iso") or raw.get("date") or raw.get("class_date") or "").strip(),
|
||||
"time_range": str(raw.get("time_range") or raw.get("raw_time") or raw.get("time") or "").strip(),
|
||||
"duration_minutes": raw.get("duration_minutes"),
|
||||
"duration": str(raw.get("duration") or "").strip(),
|
||||
"teacher": canonical_name(str(raw.get("teacher") or "").strip()),
|
||||
"subject": str(raw.get("subject") or "").strip(),
|
||||
"group": str(raw.get("group") or "").strip(),
|
||||
"sender": str(raw.get("sender") or raw.get("sender_name") or "").strip(),
|
||||
"sender_id": str(raw.get("sender_id") or "").strip(),
|
||||
"message_time": str(raw.get("message_time") or "").strip(),
|
||||
"message_date": str(raw.get("message_date") or "").strip(),
|
||||
"db": str(raw.get("db") or "").strip(),
|
||||
"local_id": str(raw.get("local_id") or "").strip(),
|
||||
"title": str(raw.get("title") or "").strip(),
|
||||
"body": body,
|
||||
"recognition_source": str(raw.get("recognition_source") or raw.get("source") or "").strip(),
|
||||
"confidence": str(raw.get("confidence") or "").strip(),
|
||||
"teacher_trusted": payload_bool(raw.get("teacher_trusted") or raw.get("sender_teacher_trusted")),
|
||||
"remark": str(raw.get("remark") or "").strip(),
|
||||
}
|
||||
return create_course_summary_review_task(tasks_path, summary, "", reasons)
|
||||
|
||||
|
||||
def approve_course_summary_task(
|
||||
tasks_path: Path,
|
||||
classnotes_path: Path,
|
||||
@@ -2062,8 +2095,7 @@ def approve_course_summary_task(
|
||||
|
||||
proposed_line = str(task.get("proposed_line") or "").strip()
|
||||
if not proposed_line:
|
||||
summary = dict(task.get("summary") or {})
|
||||
proposed_line = course_summary_to_class_record_line(summary)
|
||||
raise ValueError("课程小结信息未补齐,不能直接批准入账")
|
||||
|
||||
try:
|
||||
result = register_class_record_lines(classnotes_path, accounts_path, line=proposed_line)
|
||||
@@ -2251,8 +2283,14 @@ def ingest_course_summaries(
|
||||
|
||||
for raw in summaries:
|
||||
normalized: dict | None = None
|
||||
backup_id = ""
|
||||
try:
|
||||
normalized = normalize_course_summary(raw)
|
||||
ai_questions = [
|
||||
str(item).strip()
|
||||
for item in (raw.get("ai_questions") or [])
|
||||
if str(item).strip()
|
||||
]
|
||||
source_id = normalized["source_id"]
|
||||
semantic_key = course_summary_semantic_key(normalized)
|
||||
if source_id in seen_source_ids or semantic_key in seen_semantic_keys:
|
||||
@@ -2273,6 +2311,10 @@ def ingest_course_summaries(
|
||||
result["saved"] += 1 if saved.get("added") else 0
|
||||
|
||||
reasons, proposed_line = auto_register_reasons(normalized, classnotes_path, accounts_path)
|
||||
for question in ai_questions:
|
||||
reason = f"模型追问:{question}"
|
||||
if reason not in reasons:
|
||||
reasons.append(reason)
|
||||
if reasons:
|
||||
task = create_course_summary_review_task(
|
||||
tasks_path,
|
||||
@@ -2304,6 +2346,8 @@ def ingest_course_summaries(
|
||||
subject=normalized.get("subject", ""),
|
||||
proposed_line=proposed_line,
|
||||
reasons=reasons,
|
||||
ai_used=bool(raw.get("ai_used")),
|
||||
ai_summary=str(raw.get("ai_summary") or ""),
|
||||
task_id=task_id,
|
||||
backup_id=backup_id,
|
||||
saved_path=str(saved.get("path") or ""),
|
||||
@@ -2319,6 +2363,38 @@ def ingest_course_summaries(
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
ai_questions = [
|
||||
str(item).strip()
|
||||
for item in ((raw if isinstance(raw, dict) else {}).get("ai_questions") or [])
|
||||
if str(item).strip()
|
||||
]
|
||||
if 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
|
||||
source_id = str(raw.get("source_id") or task.get("source_id") or "")
|
||||
log_id = append_operation_log(
|
||||
operation_logs_path,
|
||||
"课程小结接收",
|
||||
"待审核",
|
||||
batch_id=batch_id,
|
||||
source_id=source_id,
|
||||
student=str(raw.get("student") or ""),
|
||||
reasons=reasons,
|
||||
ai_used=bool(raw.get("ai_used")),
|
||||
ai_summary=str(raw.get("ai_summary") or ""),
|
||||
task_id=task.get("id"),
|
||||
)
|
||||
result["operation_log_ids"].append(log_id)
|
||||
result["items"].append({
|
||||
"source_id": source_id,
|
||||
"status": "待审核",
|
||||
"task_id": task.get("id"),
|
||||
"backup_id": "",
|
||||
"reasons": reasons,
|
||||
})
|
||||
continue
|
||||
result["rejected"] += 1
|
||||
source_id = str((normalized or raw).get("source_id") or "")
|
||||
log_id = append_operation_log(
|
||||
|
||||
+2
-1
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from .routers import accounts, admin, health, ingest, pages, records
|
||||
from .routers import accounts, admin, ai_register, health, ingest, pages, records
|
||||
|
||||
|
||||
app = FastAPI(title="新时空教务管理系统", version="1.0.0")
|
||||
@@ -19,4 +19,5 @@ app.include_router(health.router)
|
||||
app.include_router(records.router)
|
||||
app.include_router(accounts.router)
|
||||
app.include_router(admin.router)
|
||||
app.include_router(ai_register.router)
|
||||
app.include_router(ingest.router)
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from ..ai_register import preview_register
|
||||
from ..auth import verify_admin_auth
|
||||
from ..schemas import AiRegisterPreviewPayload
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/api/ai/register/preview")
|
||||
def ai_register_preview(payload: AiRegisterPreviewPayload, _user: str = Depends(verify_admin_auth)):
|
||||
try:
|
||||
return {
|
||||
"ok": True,
|
||||
**preview_register(
|
||||
register_type=payload.type,
|
||||
text=payload.text,
|
||||
lines=payload.lines,
|
||||
answers=payload.answers,
|
||||
conversation_id=payload.conversation_id,
|
||||
),
|
||||
}
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from ..auth import verify_ingest_token
|
||||
from ..ai_register import preview_register
|
||||
from ..config import (
|
||||
ACCOUNTS_PATH,
|
||||
ADMIN_TASKS_PATH,
|
||||
@@ -19,9 +20,83 @@ from ..schemas import CourseSummaryIngestPayload
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _summary_text(raw: dict) -> str:
|
||||
parts = []
|
||||
mapping = [
|
||||
("student", "学生"),
|
||||
("date_iso", "日期"),
|
||||
("date", "日期"),
|
||||
("time_range", "时间"),
|
||||
("raw_time", "时间"),
|
||||
("teacher", "老师"),
|
||||
("subject", "科目"),
|
||||
]
|
||||
for key, label in mapping:
|
||||
value = str(raw.get(key) or "").strip()
|
||||
if value:
|
||||
parts.append(f"{label}:{value}")
|
||||
body = str(raw.get("body") or raw.get("content") or "").strip()
|
||||
if body:
|
||||
parts.append("小结:")
|
||||
parts.append(body)
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _needs_ai_summary(raw: dict) -> bool:
|
||||
required = ["student", "teacher", "subject"]
|
||||
if any(not str(raw.get(key) or "").strip() for key in required):
|
||||
return True
|
||||
if not str(raw.get("date_iso") or raw.get("date") or raw.get("class_date") or "").strip():
|
||||
return True
|
||||
if not str(raw.get("time_range") or raw.get("raw_time") or raw.get("time") or "").strip():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _standard_text_to_raw(text: str, original: dict) -> dict:
|
||||
from ..data import extract_course_summary_from_text, normalize_course_summary
|
||||
|
||||
raw = extract_course_summary_from_text(text, known_students=[])
|
||||
normalized = normalize_course_summary({**original, **raw})
|
||||
return {**original, **normalized}
|
||||
|
||||
|
||||
def _preprocess_summaries_with_ai(summaries: list[dict]) -> list[dict]:
|
||||
processed: list[dict] = []
|
||||
for raw in summaries:
|
||||
if not _needs_ai_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)]})
|
||||
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_summary": str(preview.get("summary") or ""),
|
||||
})
|
||||
except ValueError as exc:
|
||||
processed.append({**raw, "ai_used": True, "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_summary": str(preview.get("summary") or ""),
|
||||
"ai_questions": questions or ["模型未能补齐课程小结信息"],
|
||||
})
|
||||
return processed
|
||||
|
||||
|
||||
@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])
|
||||
with write_lock:
|
||||
result = ingest_course_summaries(
|
||||
classnotes_path=CLASSNOTES_PATH,
|
||||
@@ -33,7 +108,7 @@ def ingest_course_summary_batch(payload: CourseSummaryIngestPayload, _user: str
|
||||
batch_id=payload.batch_id,
|
||||
window=payload.window,
|
||||
students=payload.students,
|
||||
summaries=[item.dict() for item in payload.summaries],
|
||||
summaries=summaries,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
+9
-1
@@ -8,6 +8,14 @@ class RegisterLinesPayload(BaseModel):
|
||||
lines: list[str] | None = Field(default=None, description="多条原始登记文本")
|
||||
|
||||
|
||||
class AiRegisterPreviewPayload(BaseModel):
|
||||
type: str
|
||||
text: str | None = None
|
||||
lines: list[str] | None = None
|
||||
answers: dict[str, str] = Field(default_factory=dict)
|
||||
conversation_id: str | None = None
|
||||
|
||||
|
||||
class PaymentPayload(BaseModel):
|
||||
date: str
|
||||
hours: float
|
||||
@@ -54,7 +62,7 @@ class DeletionSubmitPayload(BaseModel):
|
||||
|
||||
class CourseSummaryPayload(BaseModel):
|
||||
source_id: str = ""
|
||||
student: str
|
||||
student: str = ""
|
||||
date_iso: str = ""
|
||||
date: str = ""
|
||||
time_range: str = ""
|
||||
|
||||
@@ -386,32 +386,37 @@
|
||||
<form id="classRegisterForm" class="admin-form">
|
||||
<h3>上课记录登记</h3>
|
||||
<textarea id="classRegisterLines" rows="10" placeholder="每行一条上课记录"></textarea>
|
||||
<div id="classRegisterPreview" class="register-preview" hidden></div>
|
||||
<p id="classRegisterStatus" class="inline-account-loading"></p>
|
||||
<div class="modal-actions">
|
||||
<button type="submit">提交上课记录</button>
|
||||
<button type="submit">生成预览</button>
|
||||
<button id="classRegisterConfirm" class="secondary-button" type="button" hidden>确认写入</button>
|
||||
</div>
|
||||
</form>
|
||||
<form id="paymentRegisterForm" class="admin-form">
|
||||
<h3>缴费登记</h3>
|
||||
<textarea id="paymentRegisterLines" rows="10" placeholder="每行一条:学生-2026-06-01:53"></textarea>
|
||||
<div id="paymentRegisterPreview" class="register-preview" hidden></div>
|
||||
<p id="paymentRegisterStatus" class="inline-account-loading"></p>
|
||||
<div class="modal-actions">
|
||||
<button type="submit">提交缴费记录</button>
|
||||
<button type="submit">生成预览</button>
|
||||
<button id="paymentRegisterConfirm" class="secondary-button" type="button" hidden>确认写入</button>
|
||||
</div>
|
||||
</form>
|
||||
<form id="summaryRegisterForm" class="admin-form">
|
||||
<h3>课程小结登记</h3>
|
||||
<div id="summaryRegisterItems" class="summary-register-items"></div>
|
||||
<button id="addSummaryRegisterItemBtn" class="secondary-button" type="button">再填一条</button>
|
||||
<textarea id="summaryRegisterText" rows="12" placeholder="粘贴一条或多条课程小结原文,学生/日期/时间/老师/科目由系统自动提取"></textarea>
|
||||
<div id="summaryRegisterPreview" class="register-preview" hidden></div>
|
||||
<p id="summaryRegisterStatus" class="inline-account-loading"></p>
|
||||
<div class="modal-actions">
|
||||
<button type="submit">提交课程小结</button>
|
||||
<button type="submit">生成预览</button>
|
||||
<button id="summaryRegisterConfirm" class="secondary-button" type="button" hidden>确认写入</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/static/admin.js?v=20260615-summary-time-review"></script>
|
||||
<script src="/static/admin.js?v=20260616-summary-single-input"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+142
-124
@@ -79,14 +79,19 @@ const logMeta = document.querySelector("#logMeta");
|
||||
const logRows = document.querySelector("#logRows");
|
||||
const classRegisterForm = document.querySelector("#classRegisterForm");
|
||||
const classRegisterLines = document.querySelector("#classRegisterLines");
|
||||
const classRegisterPreview = document.querySelector("#classRegisterPreview");
|
||||
const classRegisterStatus = document.querySelector("#classRegisterStatus");
|
||||
const classRegisterConfirm = document.querySelector("#classRegisterConfirm");
|
||||
const paymentRegisterForm = document.querySelector("#paymentRegisterForm");
|
||||
const paymentRegisterLines = document.querySelector("#paymentRegisterLines");
|
||||
const paymentRegisterPreview = document.querySelector("#paymentRegisterPreview");
|
||||
const paymentRegisterStatus = document.querySelector("#paymentRegisterStatus");
|
||||
const paymentRegisterConfirm = document.querySelector("#paymentRegisterConfirm");
|
||||
const summaryRegisterForm = document.querySelector("#summaryRegisterForm");
|
||||
const summaryRegisterItems = document.querySelector("#summaryRegisterItems");
|
||||
const addSummaryRegisterItemBtn = document.querySelector("#addSummaryRegisterItemBtn");
|
||||
const summaryRegisterText = document.querySelector("#summaryRegisterText");
|
||||
const summaryRegisterPreview = document.querySelector("#summaryRegisterPreview");
|
||||
const summaryRegisterStatus = document.querySelector("#summaryRegisterStatus");
|
||||
const summaryRegisterConfirm = document.querySelector("#summaryRegisterConfirm");
|
||||
|
||||
let currentAccounts = [];
|
||||
let editingAccountId = "";
|
||||
@@ -94,15 +99,11 @@ let currentTeachers = [];
|
||||
let editingTeacherId = "";
|
||||
let currentSummaryReviews = [];
|
||||
let activeSummaryReview = null;
|
||||
let summaryRegisterItemSeq = 0;
|
||||
|
||||
const SUMMARY_REQUIRED_FIELDS = [
|
||||
["student", "学生"],
|
||||
["date", "日期"],
|
||||
["time", "时间"],
|
||||
["teacher", "老师"],
|
||||
["subject", "科目"],
|
||||
];
|
||||
const registerPreviewState = {
|
||||
class_record: null,
|
||||
payment: null,
|
||||
course_summary: null,
|
||||
};
|
||||
|
||||
function fmtHours(value) {
|
||||
const totalMinutes = Math.round(Number(value || 0) * 60);
|
||||
@@ -764,115 +765,127 @@ function linesFromTextarea(textarea) {
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function submitRegister(event, textarea, statusNode, url) {
|
||||
function clearRegisterPreview(type, resetState = true) {
|
||||
const previewNode = {
|
||||
class_record: classRegisterPreview,
|
||||
payment: paymentRegisterPreview,
|
||||
course_summary: summaryRegisterPreview,
|
||||
}[type];
|
||||
const confirmButton = {
|
||||
class_record: classRegisterConfirm,
|
||||
payment: paymentRegisterConfirm,
|
||||
course_summary: summaryRegisterConfirm,
|
||||
}[type];
|
||||
if (resetState) registerPreviewState[type] = null;
|
||||
if (previewNode) {
|
||||
previewNode.hidden = true;
|
||||
previewNode.innerHTML = "";
|
||||
}
|
||||
if (confirmButton) confirmButton.hidden = true;
|
||||
}
|
||||
|
||||
function renderRegisterPreview(type, data, statusNode) {
|
||||
const previewNode = {
|
||||
class_record: classRegisterPreview,
|
||||
payment: paymentRegisterPreview,
|
||||
course_summary: summaryRegisterPreview,
|
||||
}[type];
|
||||
const confirmButton = {
|
||||
class_record: classRegisterConfirm,
|
||||
payment: paymentRegisterConfirm,
|
||||
course_summary: summaryRegisterConfirm,
|
||||
}[type];
|
||||
previewNode.hidden = false;
|
||||
if (data.status === "ready") {
|
||||
const warning = data.warning ? `<p>${escapeHtml(data.warning)}</p>` : "";
|
||||
previewNode.innerHTML = `${warning}<strong>标准登记内容</strong><pre>${escapeHtml((data.standard_lines || []).join("\n\n"))}</pre>`;
|
||||
confirmButton.hidden = false;
|
||||
registerPreviewState[type] = {
|
||||
conversationId: data.conversation_id,
|
||||
standardLines: data.standard_lines || [],
|
||||
};
|
||||
statusNode.textContent = data.ai_used ? "模型已生成预览,请确认后写入" : "已通过本地标准格式校验,请确认后写入";
|
||||
return;
|
||||
}
|
||||
if (data.status === "needs_info") {
|
||||
const questions = (data.questions || []).map((item) => `<li>${escapeHtml(item)}</li>`).join("");
|
||||
previewNode.innerHTML = `<strong>需要补充信息</strong><ul>${questions}</ul><textarea data-ai-answer rows="3" placeholder="在这里补充缺失信息后再次生成预览"></textarea>`;
|
||||
confirmButton.hidden = true;
|
||||
registerPreviewState[type] = {
|
||||
conversationId: data.conversation_id,
|
||||
standardLines: [],
|
||||
};
|
||||
statusNode.textContent = "请补充信息后再次生成预览";
|
||||
return;
|
||||
}
|
||||
const questions = (data.questions || []).map((item) => `<li>${escapeHtml(item)}</li>`).join("");
|
||||
previewNode.innerHTML = `<strong>无法生成预览</strong>${questions ? `<ul>${questions}</ul>` : ""}`;
|
||||
confirmButton.hidden = true;
|
||||
registerPreviewState[type] = {
|
||||
conversationId: data.conversation_id,
|
||||
standardLines: [],
|
||||
};
|
||||
statusNode.textContent = `预览失败:${data.error || "请修改原文后重试"}`;
|
||||
}
|
||||
|
||||
async function previewRegister(event, type, lines, statusNode) {
|
||||
event.preventDefault();
|
||||
statusNode.textContent = "正在提交";
|
||||
if (!lines.length) {
|
||||
clearRegisterPreview(type);
|
||||
statusNode.textContent = "请先填写登记内容";
|
||||
return;
|
||||
}
|
||||
const previewNode = {
|
||||
class_record: classRegisterPreview,
|
||||
payment: paymentRegisterPreview,
|
||||
course_summary: summaryRegisterPreview,
|
||||
}[type];
|
||||
const answerNode = previewNode ? previewNode.querySelector("[data-ai-answer]") : null;
|
||||
const state = registerPreviewState[type] || {};
|
||||
const answers = answerNode && answerNode.value.trim() ? { followup: answerNode.value.trim() } : {};
|
||||
clearRegisterPreview(type, !state.conversationId);
|
||||
statusNode.textContent = "正在生成预览";
|
||||
try {
|
||||
const data = await fetchJson("/api/ai/register/preview", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
type,
|
||||
lines,
|
||||
answers,
|
||||
conversation_id: state.conversationId || null,
|
||||
}),
|
||||
});
|
||||
renderRegisterPreview(type, data, statusNode);
|
||||
} catch (error) {
|
||||
statusNode.textContent = `预览失败:${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRegister(type, statusNode, url, onSuccess) {
|
||||
const state = registerPreviewState[type];
|
||||
if (!state || !state.standardLines || !state.standardLines.length) {
|
||||
statusNode.textContent = "请先生成可确认的预览";
|
||||
return;
|
||||
}
|
||||
statusNode.textContent = "正在写入";
|
||||
try {
|
||||
const data = await fetchJson(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ lines: linesFromTextarea(textarea) }),
|
||||
body: JSON.stringify({ lines: state.standardLines }),
|
||||
});
|
||||
if (type === "course_summary") {
|
||||
statusNode.textContent = `已接收 ${data.received} 条;自动登记 ${data.auto_registered} 条;重复 ${data.duplicates} 条;舍弃 ${data.rejected} 条`;
|
||||
} else {
|
||||
statusNode.textContent = `已登记 ${data.registered} 条;备份 ${data.backup_id}`;
|
||||
textarea.value = "";
|
||||
}
|
||||
clearRegisterPreview(type);
|
||||
onSuccess();
|
||||
await loadAdminHealth();
|
||||
await loadAccounts();
|
||||
} catch (error) {
|
||||
statusNode.textContent = `提交失败:${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
function addSummaryRegisterItem(value = "") {
|
||||
summaryRegisterItemSeq += 1;
|
||||
const itemId = `summary-register-${summaryRegisterItemSeq}`;
|
||||
const item = document.createElement("div");
|
||||
item.className = "summary-register-item";
|
||||
item.innerHTML = `<label for="${itemId}">课程小结</label>
|
||||
<textarea id="${itemId}" data-summary-field="body" rows="7" placeholder="每个输入框填写一条课程小结">${escapeHtml(value)}</textarea>
|
||||
<div class="summary-register-fields">
|
||||
<input data-summary-field="student" autocomplete="off" placeholder="学生" />
|
||||
<input data-summary-field="date" autocomplete="off" placeholder="日期 2026.06.15" />
|
||||
<input data-summary-field="time" autocomplete="off" placeholder="时间 08:00-09:00" />
|
||||
<input data-summary-field="teacher" autocomplete="off" placeholder="老师" />
|
||||
<input data-summary-field="subject" autocomplete="off" placeholder="科目" />
|
||||
</div>
|
||||
<p class="summary-register-missing" hidden></p>
|
||||
<button class="small-button summary-register-remove" type="button">删除本框</button>`;
|
||||
summaryRegisterItems.appendChild(item);
|
||||
}
|
||||
|
||||
function inferSummaryField(body, field) {
|
||||
const patterns = {
|
||||
student: /(?:学生|学员)[::]\s*([^\n;;,,]+)/,
|
||||
date: /(?:日期|上课日期)[::]\s*(\d{4}[./-]\d{1,2}[./-]\d{1,2})|(\d{4}[./-]\d{1,2}[./-]\d{1,2})/,
|
||||
time: /(?:时间|上课时间)[::]\s*(\d{1,2}:\d{2}-\d{1,2}:\d{2})|(\d{1,2}:\d{2}-\d{1,2}:\d{2})/,
|
||||
teacher: /(?:老师|教师)[::]\s*([^\n;;,,]+)|([\u4e00-\u9fa5A-Za-z0-9]{1,8}老师)/,
|
||||
subject: /(?:科目|课程)[::]\s*([^\n;;,,]+)|(数学|语文|英语|物理|化学|生物|历史|地理|政治|道法)/,
|
||||
};
|
||||
const match = String(body || "").match(patterns[field]);
|
||||
if (!match) return "";
|
||||
return (match[1] || match[2] || "").trim();
|
||||
}
|
||||
|
||||
function summaryRegisterItemPayload(item) {
|
||||
const body = item.querySelector("[data-summary-field='body']").value.trim();
|
||||
const values = { body };
|
||||
SUMMARY_REQUIRED_FIELDS.forEach(([field]) => {
|
||||
const input = item.querySelector(`[data-summary-field='${field}']`);
|
||||
values[field] = input.value.trim() || inferSummaryField(body, field);
|
||||
if (!input.value.trim() && values[field]) input.value = values[field];
|
||||
});
|
||||
const missing = SUMMARY_REQUIRED_FIELDS.filter(([field]) => !values[field]).map(([, label]) => label);
|
||||
const missingNode = item.querySelector(".summary-register-missing");
|
||||
missingNode.hidden = missing.length === 0;
|
||||
missingNode.textContent = missing.length ? `请补齐:${missing.join("、")};无法补齐请删除本框,本条不会提交。` : "";
|
||||
item.classList.toggle("has-missing", missing.length > 0);
|
||||
if (!body || missing.length) return null;
|
||||
return [
|
||||
`学生:${values.student}`,
|
||||
`日期:${values.date}`,
|
||||
`时间:${values.time}`,
|
||||
`老师:${values.teacher}`,
|
||||
`科目:${values.subject}`,
|
||||
"小结:",
|
||||
body,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function summaryRegisterLines() {
|
||||
const items = Array.from(summaryRegisterItems.querySelectorAll(".summary-register-item"));
|
||||
const lines = items.map(summaryRegisterItemPayload).filter(Boolean);
|
||||
return {
|
||||
lines,
|
||||
blocked: items.some((item) => item.classList.contains("has-missing")),
|
||||
};
|
||||
}
|
||||
|
||||
async function submitSummaryRegister(event) {
|
||||
event.preventDefault();
|
||||
const { lines, blocked } = summaryRegisterLines();
|
||||
if (blocked) {
|
||||
summaryRegisterStatus.textContent = "还有课程小结缺字段,请补齐后再提交;无法补齐的请删除本框。";
|
||||
return;
|
||||
}
|
||||
if (!lines.length) {
|
||||
summaryRegisterStatus.textContent = "请至少填写一条课程小结";
|
||||
return;
|
||||
}
|
||||
summaryRegisterStatus.textContent = "正在提交";
|
||||
try {
|
||||
const data = await fetchJson("/api/register/course-summaries", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ lines }),
|
||||
});
|
||||
summaryRegisterStatus.textContent = `已接收 ${data.received} 条;自动登记 ${data.auto_registered} 条;重复 ${data.duplicates} 条;舍弃 ${data.rejected} 条`;
|
||||
summaryRegisterItems.innerHTML = "";
|
||||
addSummaryRegisterItem();
|
||||
await loadAdminHealth();
|
||||
await loadAccounts();
|
||||
} catch (error) {
|
||||
summaryRegisterStatus.textContent = `提交失败:${error.message}`;
|
||||
statusNode.textContent = `写入失败:${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -982,24 +995,29 @@ logFilterForm.addEventListener("submit", (event) => {
|
||||
loadOperationLogs();
|
||||
});
|
||||
classRegisterForm.addEventListener("submit", (event) => {
|
||||
submitRegister(event, classRegisterLines, classRegisterStatus, "/api/register/class-records");
|
||||
previewRegister(event, "class_record", linesFromTextarea(classRegisterLines), classRegisterStatus);
|
||||
});
|
||||
paymentRegisterForm.addEventListener("submit", (event) => {
|
||||
submitRegister(event, paymentRegisterLines, paymentRegisterStatus, "/api/register/payments");
|
||||
previewRegister(event, "payment", linesFromTextarea(paymentRegisterLines), paymentRegisterStatus);
|
||||
});
|
||||
classRegisterConfirm.addEventListener("click", () => {
|
||||
confirmRegister("class_record", classRegisterStatus, "/api/register/class-records", () => {
|
||||
classRegisterLines.value = "";
|
||||
});
|
||||
});
|
||||
paymentRegisterConfirm.addEventListener("click", () => {
|
||||
confirmRegister("payment", paymentRegisterStatus, "/api/register/payments", () => {
|
||||
paymentRegisterLines.value = "";
|
||||
});
|
||||
});
|
||||
summaryRegisterForm.addEventListener("submit", (event) => {
|
||||
previewRegister(event, "course_summary", linesFromTextarea(summaryRegisterText), summaryRegisterStatus);
|
||||
});
|
||||
summaryRegisterConfirm.addEventListener("click", () => {
|
||||
confirmRegister("course_summary", summaryRegisterStatus, "/api/register/course-summaries", () => {
|
||||
summaryRegisterText.value = "";
|
||||
});
|
||||
addSummaryRegisterItemBtn.addEventListener("click", () => addSummaryRegisterItem());
|
||||
summaryRegisterItems.addEventListener("click", (event) => {
|
||||
const button = event.target.closest(".summary-register-remove");
|
||||
if (!button) return;
|
||||
const items = summaryRegisterItems.querySelectorAll(".summary-register-item");
|
||||
if (items.length <= 1) {
|
||||
const textarea = button.closest(".summary-register-item").querySelector("textarea");
|
||||
if (textarea) textarea.value = "";
|
||||
return;
|
||||
}
|
||||
button.closest(".summary-register-item").remove();
|
||||
});
|
||||
summaryRegisterForm.addEventListener("submit", submitSummaryRegister);
|
||||
refreshBtn.addEventListener("click", () => {
|
||||
loadAdminHealth();
|
||||
if (!panels.accounts.hidden) loadAccounts();
|
||||
|
||||
+20
-35
@@ -397,45 +397,34 @@ textarea:focus {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.summary-register-items {
|
||||
.register-preview {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.summary-register-item {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.summary-register-item.has-missing {
|
||||
padding: 10px;
|
||||
border: 1px solid #fecdca;
|
||||
border-radius: 6px;
|
||||
background: #fff7f6;
|
||||
}
|
||||
|
||||
.summary-register-item label {
|
||||
color: #344054;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.summary-register-fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.summary-register-missing {
|
||||
margin: 0;
|
||||
color: var(--danger);
|
||||
.register-preview strong {
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.register-preview pre {
|
||||
margin: 0;
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
color: #344054;
|
||||
font-family: Arial, "Songti SC", SimSun, sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.summary-register-remove {
|
||||
justify-self: start;
|
||||
.register-preview textarea {
|
||||
min-height: 72px;
|
||||
}
|
||||
|
||||
.line-code {
|
||||
@@ -1158,10 +1147,6 @@ td {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.summary-register-fields {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.admin-tabs {
|
||||
overflow-x: auto;
|
||||
gap: 6px;
|
||||
|
||||
@@ -17,8 +17,13 @@ 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:-90}
|
||||
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