OCSxAI/server.py

227 lines
7.0 KiB
Python
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.

import os
import re
import yaml
import logging
from contextlib import asynccontextmanager
from typing import Optional
import json
from fastapi import FastAPI, Query, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from version import __version__
from ai_client import AIClient, QuestionInfo
# ── 日志 ──────────────────────────────────────────────
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger("ocs_ai")
# ── 配置加载 ──────────────────────────────────────────
def _resolve_env(value: str) -> str:
"""解析 ${ENV_VAR} 格式的环境变量"""
pattern = re.compile(r"\$\{([^}]+)\}")
matches = pattern.findall(value)
for var in matches:
env_val = os.environ.get(var, "")
value = value.replace(f"${{{var}}}", env_val)
return value
def load_config(path: str = "config.yaml") -> dict:
with open(path, "r", encoding="utf-8") as f:
config = yaml.safe_load(f)
config["ai"]["api_key"] = _resolve_env(config["ai"]["api_key"])
config["ai"]["api_base"] = _resolve_env(config["ai"]["api_base"])
return config
CONFIG = load_config()
ai_client: Optional[AIClient] = None
@asynccontextmanager
async def lifespan(app: FastAPI):
global ai_client
ai_cfg = CONFIG["ai"]
ai_client = AIClient(
api_key=ai_cfg["api_key"],
api_base=ai_cfg["api_base"],
model=ai_cfg["model"],
vision_model=ai_cfg.get("vision_model", ""),
temperature=ai_cfg.get("temperature", 0.1),
max_tokens=ai_cfg.get("max_tokens", 500),
timeout=ai_cfg.get("timeout", 60),
thinking=ai_cfg.get("thinking", False),
provider=ai_cfg.get("provider", "deepseek"),
)
logger.info(
f"AI 客户端已初始化 | 文字模型: {ai_cfg['model']} | 视觉模型: {ai_cfg.get('vision_model') or '未配置'}"
f" | 提供商: {ai_cfg.get('provider', 'deepseek')} | Thinking: {ai_cfg.get('thinking', False)}"
)
yield
app = FastAPI(title="OCS × AI 中间件", version=__version__, lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.head("/")
@app.get("/")
async def root():
return {"status": "ok", "service": "OCS AI Middleware"}
# ── 认证 ──────────────────────────────────────────────
def verify_api_key(api_key: Optional[str]) -> None:
"""验证服务端 API Key(如果配置了的话)"""
required = CONFIG["server"].get("api_key", "")
if required and api_key != required:
raise HTTPException(status_code=403, detail="API Key 无效")
# ── 接口 ──────────────────────────────────────────────
def _sanitize_body(body: dict) -> dict:
"""日志用:把 base64 图片、超长文本压缩掉,避免刷爆日志"""
safe: dict = {}
if not isinstance(body, dict):
return {"_raw": str(body)[:200]}
for key, value in body.items():
if key == "images" and isinstance(value, list):
safe[key] = f"<{len(value)} 张 base64 图片>"
elif isinstance(value, str) and len(value) > 300:
safe[key] = value[:300] + f"...(len={len(value)})"
else:
safe[key] = value
return safe
@app.get("/api/search")
async def search_get(
title: str = Query(..., description="题目标题"),
type: str = Query(default="single", description="题目类型: single/multiple/judgement/completion"),
options: str = Query(default="", description="题目选项,\\n 分隔"),
api_key: Optional[str] = Query(default=None, description="服务端 API Key"),
):
"""GET 方式搜题 —— OCS 题库配置 method: get 时使用"""
return await _do_search(title, type, options, api_key)
@app.post("/api/search")
async def search_post(request: Request):
"""POST 方式搜题 —— OCS 题库配置 method: post 时使用(图片题优化需要 POST)"""
try:
body = await request.json()
except Exception:
body = {}
if not isinstance(body, dict):
body = {}
if CONFIG["server"].get("log_requests", True):
logger.info(
f"POST 请求体: {json.dumps(_sanitize_body(body), ensure_ascii=False)}"
)
title = str(body.get("title", "") or "")
qtype = str(body.get("type", "") or "") or "single"
options = str(body.get("options", "") or "")
api_key = body.get("api_key", None)
# OCS 5.0 图片题优化字段
images = body.get("images")
if not isinstance(images, list):
images = []
suggestion_title = str(body.get("suggestion_title", "") or "")
suggestion_options = str(body.get("suggestion_options", "") or "")
return await _do_search(
title,
qtype,
options,
api_key,
images=images,
suggestion_title=suggestion_title,
suggestion_options=suggestion_options,
)
ANSWER_SEPARATOR = "#"
async def _do_search(
title: str,
qtype: str,
options: str,
api_key: Optional[str],
images: Optional[list] = None,
suggestion_title: str = "",
suggestion_options: str = "",
):
verify_api_key(api_key)
images = images or []
if CONFIG["server"].get("log_requests", True):
logger.info(
f"收到题目 | 类型: {qtype} | 图片: {len(images)} 张 | "
f"标题: {title[:80]}{'...' if len(title) > 80 else ''}"
)
question = QuestionInfo(
title=title.strip(),
type=qtype.strip(),
options=options.strip(),
images=images,
suggestion_title=suggestion_title.strip(),
suggestion_options=suggestion_options.strip(),
)
try:
raw_answer = await ai_client.ask(question)
if raw_answer is None or not raw_answer.strip():
return {"code": 0, "msg": "AI 未返回有效答案"}
answer = raw_answer.strip()
logger.info(f"最终答案: {answer[:100]}")
return {
"code": 1,
"question": title,
"answer": answer,
}
except Exception as e:
logger.error(f"AI 请求异常: {e}")
return {"code": 0, "msg": str(e)}
# ── 健康检查 ──────────────────────────────────────────
@app.get("/health")
async def health():
return {"status": "ok", "model": CONFIG["ai"]["model"]}
if __name__ == "__main__":
import uvicorn
svr = CONFIG["server"]
logger.info(f"OCS × AI 中间件 v{__version__} → http://{svr['host']}:{svr['port']}")
uvicorn.run(app, host=svr["host"], port=svr["port"])