add: gaokao_chat_server_v2.py symlink (gunicorn compat) + backup

This commit is contained in:
2026-06-18 19:19:12 +08:00
parent 465370c2e2
commit 80fab2fa6f
2 changed files with 534 additions and 0 deletions
+533
View File
@@ -0,0 +1,533 @@
"""
高考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)
+1
View File
@@ -0,0 +1 @@
gaokao-chat-server-v2.py