227 lines
7.0 KiB
Python
227 lines
7.0 KiB
Python
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"])
|