CareerBot/app/services/llm_service.py
ln0422 96997daed0 Initial commit: CareerBot full-stack career showcase with AI chatbot
- 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>
2026-04-07 20:36:38 +08:00

153 lines
5.5 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 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)}"