- FastAPI backend with SQLAlchemy ORM and SQLite - AI chatbot with OpenAI-compatible LLM integration (SSE streaming) - Admin panel for content management, LLM config, token management - Anonymous access with 3-question limit, token-based access control - Recruiter intent detection with admin notification - Resume generator (JD-based, Markdown to Word export) - Chinese localized public interface Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
153 lines
5.5 KiB
Python
153 lines
5.5 KiB
Python
import json
|
||
from typing import AsyncGenerator
|
||
|
||
import httpx
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.models import LLMConfig
|
||
|
||
|
||
def get_active_config(db: Session) -> LLMConfig | None:
|
||
return db.query(LLMConfig).filter(LLMConfig.is_active == True).first()
|
||
|
||
|
||
def _build_url_and_headers(config: LLMConfig) -> tuple[str, dict]:
|
||
url = config.api_url.rstrip("/")
|
||
if not url.endswith("/v1/chat/completions"):
|
||
url += "/v1/chat/completions"
|
||
headers = {
|
||
"Content-Type": "application/json",
|
||
"Authorization": f"Bearer {config.api_key}",
|
||
}
|
||
return url, headers
|
||
|
||
|
||
def _build_body(config: LLMConfig, messages: list[dict], stream: bool = False) -> dict:
|
||
body = {
|
||
"model": config.model_name,
|
||
"messages": messages,
|
||
"max_tokens": config.max_tokens,
|
||
"temperature": config.temperature,
|
||
}
|
||
if stream:
|
||
body["stream"] = True
|
||
return body
|
||
|
||
|
||
def _has_image_content(messages: list[dict]) -> bool:
|
||
for msg in messages:
|
||
content = msg.get("content")
|
||
if isinstance(content, list):
|
||
for part in content:
|
||
if isinstance(part, dict) and part.get("type") == "image_url":
|
||
return True
|
||
return False
|
||
|
||
|
||
def _strip_images(messages: list[dict]) -> list[dict]:
|
||
"""Remove image content, keep text, add a note about the limitation."""
|
||
result = []
|
||
for msg in messages:
|
||
content = msg.get("content")
|
||
if isinstance(content, list):
|
||
text_parts = [p["text"] for p in content if isinstance(p, dict) and p.get("type") == "text"]
|
||
text = "\n".join(text_parts) if text_parts else ""
|
||
text += "\n\n[系统提示:用户上传了一张图片作为职位描述(JD),但当前配置的模型不支持图片识别。请告知用户改为上传文本(.txt)、Word(.docx)或PDF(.pdf)格式的JD文件,以便进行详细的匹配度分析。]"
|
||
result.append({"role": msg["role"], "content": text})
|
||
else:
|
||
result.append(msg)
|
||
return result
|
||
|
||
|
||
async def _do_stream(client: httpx.AsyncClient, url: str, headers: dict, body: dict):
|
||
"""Execute a streaming request and yield content chunks."""
|
||
async with client.stream("POST", url, json=body, headers=headers) as resp:
|
||
resp.raise_for_status()
|
||
async for line in resp.aiter_lines():
|
||
if not line.startswith("data: "):
|
||
continue
|
||
data_str = line[6:].strip()
|
||
if data_str == "[DONE]":
|
||
break
|
||
try:
|
||
chunk = json.loads(data_str)
|
||
content = chunk["choices"][0]["delta"].get("content", "")
|
||
if content:
|
||
yield content
|
||
except (json.JSONDecodeError, KeyError, IndexError):
|
||
continue
|
||
|
||
|
||
async def chat_completion(messages: list[dict], db: Session) -> str:
|
||
"""Non-streaming chat completion with auto vision fallback."""
|
||
config = get_active_config(db)
|
||
if not config:
|
||
return "AI助手尚未配置,请联系管理员。"
|
||
|
||
url, headers = _build_url_and_headers(config)
|
||
body = _build_body(config, messages)
|
||
|
||
try:
|
||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||
resp = await client.post(url, json=body, headers=headers)
|
||
resp.raise_for_status()
|
||
return resp.json()["choices"][0]["message"]["content"]
|
||
except httpx.HTTPStatusError as e:
|
||
if e.response.status_code == 400 and _has_image_content(messages):
|
||
return await _fallback_no_image(config, messages)
|
||
return f"AI服务调用失败: {str(e)}"
|
||
except Exception as e:
|
||
return f"AI服务调用失败: {str(e)}"
|
||
|
||
|
||
async def _fallback_no_image(config: LLMConfig, messages: list[dict]) -> str:
|
||
url, headers = _build_url_and_headers(config)
|
||
body = _build_body(config, _strip_images(messages))
|
||
try:
|
||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||
resp = await client.post(url, json=body, headers=headers)
|
||
resp.raise_for_status()
|
||
return resp.json()["choices"][0]["message"]["content"]
|
||
except Exception as e:
|
||
return f"AI服务调用失败: {str(e)}"
|
||
|
||
|
||
async def chat_completion_stream(
|
||
messages: list[dict], db: Session
|
||
) -> AsyncGenerator[str, None]:
|
||
"""Streaming chat completion with auto vision fallback."""
|
||
config = get_active_config(db)
|
||
if not config:
|
||
yield "AI助手尚未配置,请联系管理员。"
|
||
return
|
||
|
||
url, headers = _build_url_and_headers(config)
|
||
body = _build_body(config, messages, stream=True)
|
||
|
||
try:
|
||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||
async for chunk in _do_stream(client, url, headers, body):
|
||
yield chunk
|
||
return
|
||
except httpx.HTTPStatusError as e:
|
||
if e.response.status_code == 400 and _has_image_content(messages):
|
||
pass # Fall through to retry without images
|
||
else:
|
||
yield f"\n\nAI服务调用失败: {str(e)}"
|
||
return
|
||
except Exception as e:
|
||
if _has_image_content(messages):
|
||
pass # Fall through to retry
|
||
else:
|
||
yield f"\n\nAI服务调用失败: {str(e)}"
|
||
return
|
||
|
||
# Fallback: retry without image content
|
||
fallback_body = _build_body(config, _strip_images(messages), stream=True)
|
||
try:
|
||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||
async for chunk in _do_stream(client, url, headers, fallback_body):
|
||
yield chunk
|
||
except Exception as e:
|
||
yield f"\n\nAI服务调用失败: {str(e)}"
|