gaokao-site/gaokao-chat-server-v2.py.bak1

534 lines
24 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
高考AI服务 v3 — 数据库驱动推荐 + 概率计算器
"""
import sqlite3, os, json, re, uuid
from flask import Flask, request, jsonify, send_file
from datetime import datetime
import urllib.request
import urllib.error
app = Flask(__name__)
# ===== 配置 =====
DEEPSEEK_API_URL = "https://api.deepseek.com/v1/chat/completions"
DEEPSEEK_API_KEY = os.environ.get('DEEPSEEK_API_KEY', '')
DB_PATH = os.environ.get('GAOKAO_DB', '/www/wwwroot/gaokao/gaokao_henan.db')
STATIC_DIR = "/www/wwwroot/gaokao"
SESSIONS = {}
# ===== SYSTEM PROMPT =====
SYSTEM_PROMPT = """你是一个专业、温暖的高考志愿填报助手,专门为河南省考生服务。
【重要说明】
- 当前年份2026年2025年河南高考投档已完成以下数据是最有价值的参考。
- 科目改革2025年起河南新高考采用「3+1+2」模式物理/历史分开招生。
- 2025年河南分数线物理类特控535/本科427历史类特控552/本科471专科185。
【数据来源】
你有真实的院校和专业录取数据2022-2025年包括
- 院校最低分 / 最低位次(按选科分组)
- 专业录取分
- 招生计划数
- 一分一段表2025年
当考生提供分数时,你应该结合这些真实数据给出推荐,而非凭空估算。
【推荐输出格式】每组9项缺一不可
1. 院校名称:(选科要求)| 分数 | 位次 | 批次线差
2. 推荐理由:为什么适合考生(具体、个性化)
3. 报考策略:志愿梯度建议(冲/稳/保的具体填法)
4. 推荐专业:该院校的王牌专业及考生意向专业的录取情况说明
5. 城市实况校区所在城市的气候、交通、经济发展水平1-2句
6. 食堂餐饮食堂数量、菜系丰富度、消费水平1-2句
7. 住宿条件宿舍几人间、有无空调热水、翻新情况1-2句
8. 就读体验:学风氛围、升学/就业资源、学术氛围1-2句
9. 风险提示:该院校/专业需要特别注意的风险招生批次、专业分流、选科限制等1-2句
【分组规则】
- 冲考生分数比学校最低分低0~15分踩线热档志愿
- 稳考生分数比学校最低分高0~20分志愿组合核心区
- 保考生分数比学校最低分高20分以上安全垫
概率参考冲≈30%、稳≈65%、保≈90%
【注意事项】
1. 优先参考2025年投档数据2024年数据作为趋势参考。
2. 选科要求必须匹配,否则该院校不适合该考生。
3. 新高考「1+2」选科组合直接影响可报院校范围务必确认选科是否满足。
4. 对于临床医学等长学制专业,提醒考生关注学制年限。
5. 对话风格:直接给出推荐,不要反问,不要列举选项,不要说"以下是参考"。
"""
# ===== 学费辅助 =====
def get_school_tuition(school_name, cur):
"""查询学校的学费信息,优先从 tuition 列取,兜底用 tuition_data 模块"""
# 优先从 DB 字段取
row = cur.execute(
"SELECT tuition FROM schools WHERE school_name=? AND tuition IS NOT NULL AND tuition!='' LIMIT 1",
(school_name,)
).fetchone()
if row and row[0]:
try:
import json as _json
return _json.loads(row[0])
except Exception:
pass
# 兜底:用 tuition_data 模块(内嵌,不依赖外部文件)
return _get_tuition_fallback(school_name)
def _get_tuition_fallback(school_name):
"""内嵌版学费计算逻辑(与 tuition_data.py 保持一致)"""
import json as _json
sn = school_name or ""
# 精确匹配表
EXACT = {
"郑州大学": ("普通类(理科)", 5000),
"郑州大学国际学院": ("国际学院", 25000),
"郑州大学(中外合作)": ("中外合作办学(普通专业)", 18000),
"郑州大学医学院": ("医学类", 5500),
"河南大学": ("普通类(文科)", 4400),
"河南大学(中外合作)": ("中外合作办学(普通专业)", 18000),
"河南师范大学": ("普通类(文科)", 4400),
"河南师范大学(中外合作)": ("中外合作办学(普通专业)", 18000),
"河南开封科技传媒学院": ("独立学院(普通专业)", 12000),
"河南大学民生学院": ("独立学院(普通专业)", 12000),
"河南师范大学新联学院": ("独立学院(普通专业)", 12000),
"中原工学院信息商务学院": ("独立学院(普通专业)", 12000),
"新乡医学院三全学院": ("独立学院(医学类)", 13000),
"河南理工大学万方科技学院": ("独立学院(普通专业)", 12000),
"郑州大学西亚斯国际学院": ("中外合作办学(普通专业)", 18000),
"河南科技大学": ("普通类(理科)", 5000),
"河南理工大学": ("普通类(理科)", 5000),
"河南农业大学": ("农林类", 4000),
"河南工业大学": ("普通类(理科)", 5000),
"华北水利水电大学": ("普通类(理科)", 5000),
"郑州轻工业大学": ("普通类(理科)", 5000),
"中原工学院": ("普通类(理科)", 5000),
"河南财经政法大学": ("普通类(文科)", 4400),
"郑州航空工业管理学院": ("普通类(文科)", 4400),
"河南中医药大学": ("医学类", 5500),
"新乡医学院": ("医学类", 5500),
"河南警察学院": ("普通类(文科)", 4400),
"郑州师范学院": ("普通类(文科)", 4400),
"洛阳师范学院": ("普通类(文科)", 4400),
"信阳师范学院": ("普通类(文科)", 4400),
"南阳师范学院": ("普通类(文科)", 4400),
"商丘师范学院": ("普通类(文科)", 4400),
"安阳师范学院": ("普通类(文科)", 4400),
"周口师范学院": ("普通类(文科)", 4400),
"许昌学院": ("普通类(文科)", 4400),
"洛阳理工学院": ("普通类(理科)", 5000),
"河南工程学院": ("普通类(理科)", 5000),
"南阳理工学院": ("普通类(理科)", 5000),
"河南城建学院": ("普通类(理科)", 5000),
"平顶山学院": ("普通类(文科)", 4400),
"河南牧业经济学院": ("农林类", 4000),
"黄河科技学院": ("独立学院(普通专业)", 12000),
"商丘学院": ("独立学院(普通专业)", 12000),
"郑州科技学院": ("独立学院(普通专业)", 12000),
"郑州升达经贸管理学院": ("独立学院(普通专业)", 12000),
"新乡工程学院": ("独立学院(普通专业)", 12000),
}
if sn in EXACT:
cat, amt = EXACT[sn]
return {"category": cat, "annual": amt, "remark": f"{cat} | 河南省发改委标准", "source": "fallback"}
# 关键字匹配
kw_joint = ["中外合作", "国际学院", "合作办学", "香港", "台湾"]
kw_private = ["独立学院", "民生学院", "信息商务学院", "应用技术"]
kw_arts = ["艺术", "音乐", "美术", "设计", "舞蹈", "传媒", "戏剧", "影视"]
kw_medical = ["医学", "药学", "护理", "临床", "口腔", "中医", "医科", "医大", "军医"]
kw_agri = ["农业", "林业", "园艺", "畜牧", "兽医", "水产"]
for kw in kw_joint:
if kw in sn:
if any(a in sn for a in kw_arts):
return {"category": "中外合作办学(艺术/医学类)", "annual": 22000, "remark": "中外合作办学(艺术/医学类) | 河南省发改委标准", "source": "fallback"}
return {"category": "中外合作办学(普通专业)", "annual": 18000, "remark": "中外合作办学 | 河南省发改委标准", "source": "fallback"}
for kw in kw_private:
if kw in sn:
if any(a in sn for a in kw_arts):
return {"category": "独立学院(艺术类)", "annual": 15000, "remark": "独立学院(艺术类) | 河南省发改委标准", "source": "fallback"}
return {"category": "独立学院(普通专业)", "annual": 12000, "remark": "独立学院 | 河南省发改委标准", "source": "fallback"}
for kw in kw_arts:
if kw in sn:
return {"category": "艺术类", "annual": 8000, "remark": "艺术类 | 河南省发改委标准", "source": "fallback"}
for kw in kw_medical:
if kw in sn:
return {"category": "医学类", "annual": 5500, "remark": "医学类 | 河南省发改委标准", "source": "fallback"}
for kw in kw_agri:
if kw in sn:
return {"category": "农林类", "annual": 4000, "remark": "农林类 | 河南省发改委标准", "source": "fallback"}
if "师范" in sn:
return {"category": "普通类(文科)", "annual": 4400, "remark": "师范类 | 河南省发改委标准", "source": "fallback"}
if "体育" in sn:
return {"category": "体育类", "annual": 5000, "remark": "体育类 | 河南省发改委标准", "source": "fallback"}
# 高职/专科
if any(k in sn for k in ["职业", "技术", "专科", "高职"]):
return {"category": "高职(普通专业)", "annual": 4200, "remark": "高职 | 河南省发改委标准", "source": "fallback"}
# 默认
return {"category": "普通类(理科)", "annual": 5000, "remark": "公办本科 | 河南省发改委标准", "source": "fallback"}
def get_db():
return sqlite3.connect(DB_PATH)
def score_to_rank(conn, subject, score):
cur = conn.execute(
'SELECT rank FROM yiyi WHERE year=2025 AND subject=? AND score<=? ORDER BY score DESC LIMIT 1',
(subject, score))
r = cur.fetchone()
return r[0] if r else None
def recommend(score, subject, batch='本科批', top_n=4):
conn = get_db()
student_rank = score_to_rank(conn, subject, score)
if not student_rank:
conn.close()
return None
cur = conn.execute('''
SELECT school_name, subject_req, min_score, min_rank, batch_diff
FROM schools
WHERE year=2025 AND subject=? AND batch=? AND min_rank IS NOT NULL AND min_score IS NOT NULL
ORDER BY min_score DESC
''', (subject, batch))
chong, wen, bao = [], [], []
for row in cur.fetchall():
sch_name, req, sch_score, sch_rank, diff = row
score_gap = sch_score - score
entry = {'学校': sch_name, '选科': req, '分数': sch_score, '位次': sch_rank, '线差': diff}
if 0 <= score_gap <= 15:
chong.append(entry)
elif -20 <= score_gap < 0:
wen.append(entry)
elif score_gap < -20:
bao.append(entry)
def dedup(items):
seen = {}
for it in items:
key = (it['学校'], it['分数'])
if key not in seen:
seen[key] = it
return list(seen.values())
chong, wen, bao = dedup(chong), dedup(wen), dedup(bao)
chong.sort(key=lambda x: x['分数'], reverse=True)
wen.sort(key=lambda x: x['分数'], reverse=True)
bao.sort(key=lambda x: x['分数'])
conn.close()
return {'student_rank': student_rank, 'student_score': score,
'subject': subject, 'batch': batch,
'chong': chong[:top_n], 'wen': wen[:top_n], 'bao': bao[:top_n]}
def format_recommend(r):
if not r:
return "(数据库暂无该分数段推荐数据,请结合往年经验分析)"
parts = []
parts.append(f"【数据库实时推荐】考生{r['student_score']}分(位次{r['student_rank']}{r['subject']}类)推荐如下:")
for label, items in [('冲志愿考生分数比学校最低分低0-15分风险较高', r['chong']),
('稳志愿考生分数比学校最低分高0-20分稳妥之选', r['wen']),
('保志愿考生分数比学校最低分高20+,安全垫充足)', r['bao'])]:
parts.append(f"\n{label}")
for it in items:
parts.append(f" - {it['学校']}{it['选科']}| {it['分数']}分 | 位次{it['位次']} | 批次线差{it['线差']}")
return '\n'.join(parts)
def call_deepseek(messages, timeout=90):
data = json.dumps({
"model": "deepseek-v4-flash",
"messages": messages,
"max_tokens": 2500,
"temperature": 0.3,
}).encode("utf-8")
req = urllib.request.Request(
DEEPSEEK_API_URL, data=data,
headers={"Authorization": f"Bearer {DEEPSEEK_API_KEY}", "Content-Type": "application/json"},
method="POST"
)
with urllib.request.urlopen(req, timeout=timeout) as resp:
result = json.loads(resp.read().decode("utf-8"))
return result["choices"][0]["message"]["content"]
def extract_score_subject(text):
m = re.search(r'(\d{2,3})\s*[分]', text)
score = int(m.group(1)) if m else None
subject = '物理类' if any(k in text for k in ['物理', '理科', '物理类']) else \
'历史类' if any(k in text for k in ['历史', '文科', '历史类']) else None
return score, subject
def build_reply(user_message, history_msgs):
score, subject = extract_score_subject(user_message)
db_context = ""
if score and subject:
r = recommend(score, subject)
if r:
db_context = "\n\n" + format_recommend(r)
prompt = SYSTEM_PROMPT + db_context
messages = [{"role": "system", "content": prompt}]
for h in history_msgs[-10:]:
messages.append({"role": "user", "content": h.get("user", "")})
if h.get("assistant"):
messages.append({"role": "assistant", "content": h.get("assistant")})
messages.append({"role": "user", "content": user_message})
return call_deepseek(messages), bool(db_context)
# ===== 路由 =====
@app.route("/health")
def health():
return jsonify({"status": "ok", "time": datetime.now().isoformat()})
@app.route("/chat", methods=["GET", "POST"])
@app.route("/ai-tool-chat", methods=["GET", "POST"])
def chat():
if request.method == "GET":
return send_file(f"{STATIC_DIR}/ai-chat.html")
body = request.get_json() or {}
user_message = body.get("message", "")
if not user_message:
for m in body.get("messages", []):
if isinstance(m, dict) and m.get("role") == "user":
user_message = m.get("content", "")
break
history = body.get("history", [])
try:
reply, db_used = build_reply(user_message, history)
except Exception as e:
return jsonify({"error": str(e), "reply": f"错误:{e}"}), 500
return jsonify({"reply": reply, "db_used": db_used})
@app.route("/api/chat/start", methods=["POST"])
def chat_start():
sid = str(uuid.uuid4())
SESSIONS[sid] = []
welcome = ("您好!我是高考志愿填报 AI 助手 🎓\n\n"
"请告诉我以下信息,我来帮你推荐院校:\n\n"
"1. **高考分数**600分\n"
"2. **科类**(物理类 / 历史类)\n"
"3. **想学的专业**(如:计算机、医学、法律等)\n\n"
"您也可以简单说:*物理类 600 分想学计算机*,我会直接给出推荐方案。")
SESSIONS[sid].append({"role": "assistant", "content": welcome})
return jsonify({"session_id": sid, "message": welcome})
@app.route("/api/chat/respond", methods=["POST"])
def chat_respond():
body = request.get_json() or {}
sid = body.get("session_id", "")
user_message = body.get("message", "")
if sid not in SESSIONS:
return jsonify({"error": "会话已过期,请刷新页面重新开始。"}), 400
history = SESSIONS[sid]
history.append({"role": "user", "content": user_message})
try:
reply, db_used = build_reply(user_message, history)
except Exception as e:
SESSIONS[sid].append({"role": "assistant", "content": f"抱歉,出了点问题:{e}"})
return jsonify({"message": f"抱歉,出了点问题:{e}", "done": False}), 200
history.append({"role": "assistant", "content": reply})
return jsonify({"message": reply, "done": False, "db_used": db_used})
@app.route("/api/prob", methods=["POST"])
def prob_calc():
body = request.get_json() or {}
score = int(body.get('score', 0))
subject = body.get('subject', '物理类')
major = body.get('major', '不限')
region = body.get('region', '不限')
conn = get_db()
student_rank = score_to_rank(conn, subject, score)
if not student_rank:
conn.close()
return jsonify({'error': '该分数超出数据库范围'}), 400
# 专业/地区过滤:先从 majors 表查出符合条件的学校列表
allowed_schools = None
if major != '不限' or region != '不限':
# 城市→省份映射majors表只有省份字段
city_to_province = {'郑州':'河南','开封':'河南','洛阳':'河南','新乡':'河南',
'南阳':'河南','安阳':'河南','焦作':'河南','平顶山':'河南',
'信阳':'河南','周口':'河南','驻马店':'河南','许昌':'河南',
'漯河':'河南','三门峡':'河南','商丘':'河南','鹤壁':'河南',
'济源':'河南','省内':'河南','河南':'河南'}
prov = city_to_province.get(region, region) if region != '不限' else None
filter_clauses = []
params = []
if major != '不限':
kw_map = {'计算机':'计算机','电子信息':'电子信息','临床医学':'临床医学',
'经济金融':'经济','法学':'法学','师范':'师范'}
kw = kw_map.get(major, major)
filter_clauses.append("major LIKE ?")
params.append('%' + kw + '%')
if prov and prov != '不限':
filter_clauses.append("location = ?")
params.append(prov)
if filter_clauses:
cur2 = conn.execute(
"SELECT DISTINCT school_name FROM majors WHERE year=2025 AND subject=? AND " + " AND ".join(filter_clauses),
[subject] + params)
allowed_schools = set(r[0] for r in cur2.fetchall())
cur = conn.execute(
"SELECT school_name, subject_req, min_score, min_rank, batch_diff "
"FROM schools WHERE year=2025 AND subject=? AND batch='本科批' "
"AND min_rank IS NOT NULL AND min_score IS NOT NULL "
"ORDER BY min_score DESC",
(subject,))
rows = cur.fetchall()
conn.close()
chong, wen, bao = [], [], []
for row in rows:
sch_name, req, sch_score, sch_rank, diff = row
if allowed_schools is not None and sch_name not in allowed_schools:
continue
gap = sch_score - score
entry = {'school': sch_name, 'req': req, 'score': sch_score,
'rank': sch_rank, 'diff': diff}
if 0 <= gap <= 15:
chong.append(entry)
elif -20 <= gap < 0:
wen.append(entry)
elif gap < -20:
bao.append(entry)
def top(items, n=6, reverse=True):
return sorted(items, key=lambda x: x['score'], reverse=reverse)[:n]
def with_prob(items, score):
result = []
for it in items:
score_gap = it['score'] - score # 学校最低分 - 考生分数,正=冲,负=保
# 概率基于分差:直接反映风险,摆脱 rank 换算失真
if score_gap >= 15:
prob = 15 + min(15, score_gap - 15) * 1.0 # 15-30%
elif score_gap >= 0:
prob = 15 + score_gap * 1.0 # 冲区 15-30%
elif score_gap >= -20:
prob = 30 + (-score_gap) * 1.75 # 稳区 30-65%
else:
prob = 65 + min(25, -score_gap - 20) * 0.5 # 保区 65-90%
prob = min(95, max(5, prob))
tui = _get_tuition_fallback(it['school'])
result.append({
**it,
'prob': round(prob, 1),
'tuition_annual': tui.get('annual', 5000),
'tuition_category': tui.get('category', '普通类(理科)'),
'tuition_source': tui.get('source', 'fallback'),
})
return result
return jsonify({
'score': score, 'subject': subject,
'rank': student_rank,
'major_filter': major if major != '不限' else None,
'region_filter': region if region != '不限' else None,
'chong': with_prob(top(chong), score),
'wen': with_prob(top(wen), score),
'bao': with_prob(top(bao), score),
})
@app.route("/api/school/search", methods=["GET"])
def school_search():
q = request.args.get("q", "").strip()
subject = request.args.get("subject", "物理类")
if not q:
return jsonify({"schools": []})
cur = get_db().cursor()
rows = cur.execute("""
SELECT school_name, subject, min_score, min_rank, plan_count, plan_type, subject_req, subject_group
FROM schools
WHERE school_name LIKE ? AND subject = ?
ORDER BY min_score DESC LIMIT 20
""", ("%" + q + "%", subject)).fetchall()
schools = []
for r in rows:
tui = get_school_tuition(r[0], cur)
schools.append({
"school": r[0],
"subject": r[1],
"score": r[2],
"rank": r[3] or 0,
"num": r[4],
"type": r[5] or "普通类",
"req": r[6] or "",
"group": r[7] or "",
"tuition_annual": tui.get("annual", 5000),
"tuition_category": tui.get("category", "普通类(理科)"),
})
return jsonify({"schools": schools})
@app.route("/api/major/search", methods=["GET"])
def major_search():
school = request.args.get("school", "").strip()
major = request.args.get("major", "").strip()
subject = request.args.get("subject", "")
year = request.args.get("year", "2025")
batch = request.args.get("batch", "")
limit = min(int(request.args.get("limit", 50)), 100)
offset = int(request.args.get("offset", 0))
if not school and not major:
return jsonify({"majors": []})
cur = get_db().cursor()
conditions = []
params = []
if school:
conditions.append("school_name LIKE ?")
params.append("%" + school + "%")
if major:
conditions.append("major LIKE ?")
params.append("%" + major + "%")
if subject:
conditions.append("subject = ?")
params.append(subject)
if year:
conditions.append("year = ?")
params.append(year)
if batch:
conditions.append("batch = ?")
params.append(batch)
where = " AND ".join(conditions) if conditions else "1=1"
params.extend([limit, offset])
rows = cur.execute(f"""
SELECT school_name, major, major_code, subject_group, subject_req,
plan_count, min_score, min_rank, location, school_type,
is_985, is_211, subject, batch, year
FROM majors
WHERE {where}
ORDER BY min_score DESC
LIMIT ? OFFSET ?
""", params).fetchall()
majors = []
for r in rows:
majors.append({
"school": r[0],
"major": r[1],
"major_code": r[2] or "",
"group": r[3] or "",
"req": r[4] or "",
"plan": r[5] or 0,
"score": r[6] or 0,
"rank": r[7] or 0,
"location": r[8] or "",
"school_type": r[9] or "",
"is_985": bool(r[10]) if r[10] is not None else False,
"is_211": bool(r[11]) if r[11] is not None else False,
"subject": r[12],
"batch": r[13],
"year": r[14]
})
return jsonify({"majors": majors, "total": len(majors)})
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8080, threaded=True)