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"])