Skip to content
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions Appointment/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,16 @@ class AppointmentConfig(Config):
# 是否允许单人同一时段预约两个房间
allow_overlap = False

# AI 审核功能开启与否,使用的 API 地址和密钥
AI_Inspection_Enabled = LazySetting('AI_Inspection/enabled', type=bool)
AI_Inspection_Method = LazySetting(
'AI_Inspection/method', type=str, default="Ollama") # "Ollama" or "GLM"

# Ollama 参数
Ollama_ADDR = LazySetting('AI_Inspection/Ollama_ADDR', type=str)

# GLM 参数
GLM_API_KEY = LazySetting('AI_Inspection/GLM_API_Key', type=str)


appointment_config = AppointmentConfig(ROOT_CONFIG, 'underground')
132 changes: 132 additions & 0 deletions Appointment/utils/AI_Inspection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
from Appointment.config import appointment_config as CONFIG
import os
import json
import requests

# 模型人设:只返回“合规/不合规”,JSON格式
SYSTEM_PROMPT = (
"你是一个审查学生预约功能房间信息的老师,请审核以下预约信息,审核要求是预约事由和房间用途匹配,且不能包含政治敏感信息和色情内容。"
"你的输出必须严格为JSON,字段为:"
'{"decision": 0 或 1, "reason":"一句话理由"}'
"其中 0 代表不允许预约,1 代表允许预约"
)


def _call_glm_decision(text: str, timeout: int = 30) -> tuple[str, str]:
"""
调用智谱 HTTP 接口,返回 0 或 1,超时/网络错误报出异常。
"""
ENDPOINT = "https://open.bigmodel.cn/api/paas/v4/chat/completions"
MODEL = "glm-4.5-flash"
api_key = CONFIG.GLM_API_KEY
if not api_key:
raise RuntimeError("缺少参数 API_KEY")

headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
payload = {
"model": MODEL,
"temperature": 0.2, # 可调
"top_p": 0.7, # 可调
"response_format": {"type": "json_object"},
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": f"待审核文本:\n{text}"},
],
}

resp = requests.post(ENDPOINT, headers=headers,
json=payload, timeout=timeout)
if resp.status_code != 200:
# 把上游错误透出一小段,便于排查
if resp.status_code == 400:
return False, "请勿在预约理由中包含敏感词汇!"
raise RuntimeError(f"Upstream {resp.status_code}: {resp.text[:300]}")

data = resp.json()
try:
content_str = data["choices"][0]["message"]["content"]
obj = json.loads(content_str)
except Exception as e:
raise RuntimeError(f"Bad JSON content from provider: {e}; raw={data}")

decision = obj.get("decision")
reason = obj.get("reason", "")
if decision not in (0, 1):
raise ValueError(f"unexpected decision: {decision};obj={obj}")
if not isinstance(reason, str):
reason = str(reason)
if decision == 1:
return True, reason
else:
return False, reason


def Ollama_Inspection(room_name, reason) -> tuple[bool, str]:
# Ollama 审核功能接口,将预约房间(名称)和事由发送至 API,然后接收判断结果(合格/不合格)
# 完整的处理办法(需要在API调用失败时抛出错误)
try:
response = requests.post(
CONFIG.Ollama_ADDR + "/api/generate",
json={
"model": "gpt-oss:20b",
"prompt": f"你是一个审查学生预约功能房间信息的老师,请审核以下预约信息,审核要求是预约事由和房间用途匹配,且不能包含政治敏感信息和色情内容。房间名称:{room_name}。事由:{reason}。如果审核通过,只输出 1,不通过,则输出 0,然后接一个空格,在空格后输出不通过的原因,原因尽量简要;如无法判断,默认不通过",
"stream": False,
"think": False
}
) # GPT-OSS:20b 实测效果最好
response.raise_for_status() # 如果响应状态码不是 200,将抛出异常
result = response.json()
# 根据上述格式解析 response 部分,给出返回值
output = result.get("response", "").strip()
if output.startswith("1"):
return True, "Approved"
elif output.startswith("0"):
reason = output[1:].strip() # 获取不通过的原因
return False, "房间用途不合规:" + reason if reason else "Not approved"
else:
return False, "Unexpected response format"

except requests.RequestException as e:
# 处理请求异常
return False, str(e)


def GLM_Inspection(room_name, reason) -> tuple[bool, str]:
# GLM 审核
# 组装要审核的文本
content = f"房间:{room_name}\n事由:{reason}"
# 超时时间可从配置读取,默认30秒
timeout = 30
try:
decision, why = _call_glm_decision(content, timeout=timeout)
passed = (decision == 1)
# 返回是否通过和理由 True通过,False不通过
if passed:
return True, "合规"
else:
return False, f"房间用途不合规:{why or '无具体理由'}"
except Exception as e:
# API 调用失败:不通过并附带错误信息
return False, str(e)


def AI_Inspection(room_name, reason) -> tuple[bool, str]:
# AI 审核功能接口,将预约房间(名称)和事由发送至 API,然后接收判断结果(合格/不合格)
# Additional:最好能在不合格时附带理由

if not CONFIG.AI_Inspection_Enabled:
# 默认通过
return True, "AI Inspection is disabled"

# 在此处实现 API 的调用,并处理 API 调用失败的情况

if CONFIG.AI_Inspection_Method == "Ollama":
return Ollama_Inspection(room_name, reason)

if CONFIG.AI_Inspection_Method == "GLM":
return GLM_Inspection(room_name, reason)

return False, "AI Inspection method is not recognized"
57 changes: 34 additions & 23 deletions Appointment/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@
)
from Appointment.extern.wechat import MessageType, notify_appoint
from Appointment.utils.utils import (
get_conflict_appoints,
to_feedback_url,
get_total_appoint_time,
get_conflict_appoints,
to_feedback_url,
get_total_appoint_time,
get_overlap_appoints
)
from Appointment.utils.log import logger, get_user_logger
Expand All @@ -41,6 +41,7 @@
)
from Appointment import jobs
from Appointment.config import appointment_config as CONFIG
from Appointment.utils.AI_Inspection import AI_Inspection


# 一些固定值
Expand Down Expand Up @@ -148,7 +149,6 @@ def renewLongtermAppoint(request):
return redirect(message_url(context, reverse("Appointment:account")))



@identity_check(redirect_field_name='origin')
def account(request: HttpRequest):
"""
Expand Down Expand Up @@ -420,15 +420,16 @@ def arrange_time(request: HttpRequest):
has_longterm_permission = get_participant(request.user).longterm

# 用于前端使用
allow_overlap = CONFIG.allow_overlap
max_appoint_time = int(CONFIG.max_appoint_time.total_seconds() // 3600) # 以小时计算
allow_overlap = CONFIG.allow_overlap
max_appoint_time = int(
CONFIG.max_appoint_time.total_seconds() // 3600) # 以小时计算
is_person = request.user.is_person()

# 处理从URL参数传递过来的错误信息
# 处理从URL参数传递过来的错误信息
html_display = {}
html_display["warn_code"], html_display["warn_message"] = my_messages.get_request_message(
request)

# 获取房间编号
Rid = request.GET.get('Rid')
try:
Expand Down Expand Up @@ -465,7 +466,8 @@ def arrange_time(request: HttpRequest):
for day in [start_day + timedelta(days=i) for i in range(7)]:
used_time = get_total_appoint_time(get_participant(request.user), day)
available_hour = CONFIG.max_appoint_time - used_time
available_hours[day.strftime('%a')] = int(available_hour.total_seconds() // 3600) # 以小时计算
available_hours[day.strftime('%a')] = int(
available_hour.total_seconds() // 3600) # 以小时计算

# 获取预约时间的最大时间块id
max_stamp_id = web_func.get_time_id(room, room.Rfinish, mode="leftopen")
Expand Down Expand Up @@ -684,6 +686,7 @@ def _get_content_room(contents: dict) -> Room:
assert room is not None, f'房间{room_id}不存在!'
return room


def _get_content_students(contents: dict):
students_id = contents.get('students')
# TODO: 目前调用时一定存在,后续看情况是处理后调用本函数与否,修改检查方式
Expand All @@ -692,9 +695,10 @@ def _get_content_students(contents: dict):
assert len(students) == len(students_id), '预约人信息有误,请检查后重新发起预约!'
return students


def _add_appoint(contents: dict, start: datetime, finish: datetime, non_yp_num: int,
type: Appoint.Type = Appoint.Type.NORMAL,
notify_create: bool = True) -> tuple[Appoint | None, str]:
type: Appoint.Type = Appoint.Type.NORMAL,
notify_create: bool = True) -> tuple[Appoint | None, str]:
'''
创建一个预约,检查各种条件,屎山函数

Expand Down Expand Up @@ -881,7 +885,8 @@ def checkout_appoint(request: UserRequest):
appoint_params=appoint_params,
has_longterm_permission=has_longterm_permission,
has_interview_permission=has_interview_permission,
interview_max_count=CONFIG.interview_max_num)
interview_max_count=CONFIG.interview_max_num,
AI_Inspection_Enabled=CONFIG.AI_Inspection_Enabled)

# 提供搜索功能的数据
search_users = User.objects.filter_type(User.Type.PERSON)
Expand Down Expand Up @@ -915,16 +920,20 @@ def checkout_appoint(request: UserRequest):
# 检查是否未填写房间用途
if not contents['Ausage']:
wrong("请输入房间用途!", render_context)
# 自动化审核房间用途是否合规
is_valid, reason = AI_Inspection(room.Rtitle, contents['Ausage'])
if not is_valid:
wrong(f"预约失败! {reason}", render_context)
# 处理单人预约
if "students" not in contents.keys():
contents['students'] = [contents['Sid']]
else:
contents['students'].append(contents['Sid'])

# 不允许用非活跃用户凑数
# 虽然在生成搜索列表时已经排除了非活跃用户,但这里再检查一次,以防万一
contents['students'] = list(filter(
lambda sid: User.objects.get(username = sid).active,
lambda sid: User.objects.get(username=sid).active,
contents['students']
))
# 处理长期预约的times和interval参数 - 移到这里
Expand All @@ -941,7 +950,8 @@ def checkout_appoint(request: UserRequest):
selected_ids = [w['id']
for w in stu_list if w['id'] in selected_ids]
json_context.update(selected_ids=selected_ids)
render_context.update(contents=contents, show_clause=True, json_context=json_context)
render_context.update(
contents=contents, show_clause=True, json_context=json_context)
return render(request, 'Appointment/checkout.html', render_context)
# 检查长期预约周数是否填写
if is_longterm and not times:
Expand Down Expand Up @@ -976,26 +986,26 @@ def checkout_appoint(request: UserRequest):
# TODO: 隔周预约的处理可优化,根据start_week调整实际预约时间
start_time += timedelta(weeks=start_week)
end_time += timedelta(weeks=start_week)

# 预约时间检查
if (
applicant.Sid.is_person()
and not is_longterm
and not is_longterm
and not is_interview
and get_total_appoint_time(applicant, start_time.date()) + (end_time - start_time) > CONFIG.max_appoint_time
):
wrong('您预约的时长已超过每日最大预约时长', render_context)

# 检查预约者是否有同时段的预约
if (
applicant.Sid.is_person()
and not CONFIG.allow_overlap
and not is_longterm
and not is_interview
and not CONFIG.allow_overlap
and not is_longterm
and not is_interview
and get_overlap_appoints(applicant, start_time, end_time).exists()
):
wrong(f'您在该时间段已经有预约', render_context)

if my_messages.get_warning(render_context)[0] is None:
# 参数检查全部通过,下面开始创建预约
appoint_type = Appoint.Type.NORMAL
Expand Down Expand Up @@ -1054,7 +1064,8 @@ def checkout_appoint(request: UserRequest):
selected_ids = set(contents.pop('students'))
selected_ids = [w['id'] for w in stu_list if w['id'] in selected_ids]
json_context.update(selected_ids=selected_ids)
render_context.update(contents=contents, show_clause=True, json_context=json_context)
render_context.update(
contents=contents, show_clause=True, json_context=json_context)
return render(request, 'Appointment/checkout.html', render_context)


Expand Down
6 changes: 6 additions & 0 deletions config_template.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@
},
"semester_data": {
"semester_start": "2023-02-20"
},
"AI_Inspection": {
"enabled": false,
"method": "Ollama",
"Ollama_ADDR": "",
"GLM_API_Key": ""
}
},
"course": {
Expand Down
8 changes: 8 additions & 0 deletions templates/Appointment/checkout.html
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ <h4 class="breadcrumb-title">
</div>
<!-- /Breadcrumb -->
<!-- Page Content -->
{% if AI_Inspection_Enabled %}
<div class="alert alert-info alert-dismissible fade show" role="alert">
<strong>温馨提示:</strong> AI自动审核已启动,点击提交后请耐心等待审核完成!
<button type="button" class="close" data-dismiss="alert">
<span >&times;</span>
</button>
</div>
{% endif %}
<div class="content">
<div class="container">
{% if warn_code == 1 %}
Expand Down