接入真实模型调用与参考文件上传

This commit is contained in:
zwt13703
2026-07-02 15:16:34 +08:00
parent b313083766
commit 5a43cc70d5
9 changed files with 420 additions and 53 deletions
+86 -20
View File
@@ -1,17 +1,23 @@
import json
import time
import os
import uuid
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from config import settings
from database import get_db
from models.ai_model import AiModel
from models.document import Document
from models.generation_log import GenerationLog
from models.paragraph import Paragraph
from models.template import Template
from schemas.schemas import GenerateFullRequest, GenerateTestRequest, Response
from services.ai_service import call_ai
from services.minio_client import upload_bytes
router = APIRouter()
@@ -60,18 +66,72 @@ def _build_mock_content(paragraph: Paragraph) -> dict:
return {"content": blocks}
async def _get_effective_model(db: AsyncSession, paragraph: Paragraph) -> AiModel | None:
if paragraph.model_id:
model = await db.get(AiModel, paragraph.model_id)
if model is not None and model.status == "enabled":
return model
result = await db.execute(
select(AiModel).where(AiModel.status == "enabled").order_by(AiModel.id.asc()).limit(1)
)
return result.scalars().first()
@router.post("/test")
async def generate_test(body: GenerateTestRequest, db: AsyncSession = Depends(get_db)):
paragraph = await db.get(Paragraph, body.paragraph_id)
if paragraph is None or paragraph.template_id != body.template_id:
raise HTTPException(status_code=404, detail="段落不存在")
content = _build_mock_content(paragraph)
model = None
if body.model_id:
model = await db.get(AiModel, body.model_id)
elif paragraph.model_id:
model = await db.get(AiModel, paragraph.model_id)
if model is None or model.status != "enabled":
content = _build_mock_content(paragraph)
message = "当前未找到可用模型,返回本地模拟生成结果。"
else:
result = await call_ai(paragraph, model)
content = result.content
message = f"已通过模型 {result.used_model} 生成。"
return Response(
data={
"paragraph_id": paragraph.id,
"content": content,
"message": "当前返回本地模拟生成结果,便于前端联调。",
"message": message,
}
)
@router.post("/upload")
async def upload_reference_file(file: UploadFile = File(...)):
if not file.filename:
raise HTTPException(status_code=400, detail="文件名不能为空")
ext = os.path.splitext(file.filename)[1].lower()
if ext not in settings.ALLOWED_EXTENSIONS:
raise HTTPException(status_code=400, detail="文件类型不支持")
content = await file.read()
if not content:
raise HTTPException(status_code=400, detail="上传文件不能为空")
if len(content) > settings.MAX_UPLOAD_SIZE:
raise HTTPException(status_code=400, detail="文件大小超过限制")
object_name = f"{datetime.now().strftime('%Y%m%d')}/{uuid.uuid4().hex}{ext}"
await asyncio.to_thread(
upload_bytes,
settings.MINIO_BUCKET_UPLOADS,
object_name,
content,
file.content_type or "application/octet-stream",
)
return Response(
data={
"file_name": file.filename,
"file_path": f"{settings.MINIO_BUCKET_UPLOADS}/{object_name}",
}
)
@@ -104,40 +164,46 @@ async def generate_full(body: GenerateFullRequest, db: AsyncSession = Depends(ge
await db.flush()
done_count = 0
failed_count = 0
for paragraph in paragraphs:
if paragraph.edit_mode == "manual":
content = {"content": [{"type": "text", "text": paragraph.content or "该段落为人工编辑模式。"}]}
status = "success"
duration = 0
error_message = ""
else:
start = time.perf_counter()
content = _build_mock_content(paragraph)
model = await _get_effective_model(db, paragraph)
try:
if model is None:
content = _build_mock_content(paragraph)
else:
result = await call_ai(paragraph, model)
content = result.content
status = "success"
error_message = ""
except Exception as error:
content = _build_mock_content(paragraph)
status = "failed"
error_message = str(error)
failed_count += 1
duration = round(time.perf_counter() - start, 4)
log = GenerationLog(
document_id=document.id,
paragraph_id=paragraph.id,
model_id=paragraph.model_id,
status="success",
content=json.dumps(content, ensure_ascii=False),
duration=duration,
error_msg="",
)
db.add(log)
done_count += 1
continue
log = GenerationLog(
document_id=document.id,
paragraph_id=paragraph.id,
model_id=paragraph.model_id,
status="success",
status=status,
content=json.dumps(content, ensure_ascii=False),
duration=0,
error_msg="",
duration=duration,
error_msg=error_message,
)
db.add(log)
done_count += 1
document.para_count_done = done_count
document.status = "completed"
document.status = "completed" if failed_count == 0 else "failed"
document.error = "" if failed_count == 0 else f"{failed_count} 个段落生成失败,已回退为模拟结果。"
document.file_path = f"mock://document/{document.id}"
await db.commit()
await db.refresh(document)
+24 -7
View File
@@ -5,6 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db
from models.ai_model import AiModel
from schemas.schemas import AiModelCreate, AiModelUpdate, Response
from services.ai_service import call_ai
from services.security import decrypt_text, encrypt_text, mask_secret
router = APIRouter()
@@ -88,10 +89,26 @@ async def test_model(model_id: int, db: AsyncSession = Depends(get_db)):
if model is None:
raise HTTPException(status_code=404, detail="模型不存在")
return Response(
data={
"id": model.id,
"success": True,
"message": f"模型 {model.name} 配置校验通过(当前为本地模拟测试)",
}
)
class FakeParagraph:
title = "连接测试"
content = "请返回一段非常简短的测试文本。"
need_prompt = False
prompt_text = ""
output_format = "text"
try:
result = await call_ai(FakeParagraph(), model)
return Response(
data={
"id": model.id,
"success": True,
"message": f"模型 {model.name} 连接测试成功",
"preview": result.content,
}
)
except Exception as error:
return Response(
code=-1,
message=str(error),
data={"id": model.id, "success": False},
)
+156
View File
@@ -0,0 +1,156 @@
import asyncio
import json
import re
from dataclasses import dataclass
import httpx
from config import settings
from models.ai_model import AiModel
from models.paragraph import Paragraph
from services.security import decrypt_text
@dataclass
class AiCallResult:
content: dict
raw_text: str
used_model: str
def _ensure_json_content(text: str) -> dict:
stripped = text.strip()
if not stripped:
return {"content": [{"type": "text", "text": ""}]}
try:
parsed = json.loads(stripped)
if isinstance(parsed, dict) and "content" in parsed:
return parsed
except json.JSONDecodeError:
pass
code_block_match = re.search(r"```json\s*(.*?)\s*```", stripped, re.S)
if code_block_match:
try:
parsed = json.loads(code_block_match.group(1))
if isinstance(parsed, dict) and "content" in parsed:
return parsed
except json.JSONDecodeError:
pass
return {"content": [{"type": "text", "text": stripped}]}
def _build_prompt(paragraph: Paragraph) -> tuple[str, str]:
system_prompt = (
"你是一个企业文档撰写助手。"
"请严格输出 JSON,不要输出 JSON 之外的说明。"
'格式为:{"content":[{"type":"text","text":"..."},{"type":"table","title":"...","headers":["..."],"rows":[["..."]]}]}。'
)
user_parts = [f"段落标题:{paragraph.title}"]
if paragraph.content:
user_parts.append(f"模板上下文:{paragraph.content}")
if paragraph.need_prompt and paragraph.prompt_text:
user_parts.append(f"附加要求:{paragraph.prompt_text}")
user_parts.append(f"输出格式:{paragraph.output_format}")
return system_prompt, "\n\n".join(user_parts)
def _normalize_openai_endpoint(api_endpoint: str) -> str:
endpoint = api_endpoint.rstrip("/")
if endpoint.endswith("/chat/completions"):
return endpoint
if endpoint.endswith("/v1"):
return f"{endpoint}/chat/completions"
return f"{endpoint}/v1/chat/completions"
def _normalize_anthropic_endpoint(api_endpoint: str) -> str:
endpoint = api_endpoint.rstrip("/")
if endpoint.endswith("/messages"):
return endpoint
if endpoint.endswith("/v1"):
return f"{endpoint}/messages"
return f"{endpoint}/v1/messages"
async def _post_with_retry(
client: httpx.AsyncClient,
url: str,
headers: dict,
payload: dict,
) -> httpx.Response:
last_error: Exception | None = None
for attempt in range(settings.AI_MAX_RETRIES):
try:
response = await client.post(url, headers=headers, json=payload)
if response.status_code in (429, 500, 502, 503, 504):
raise httpx.HTTPStatusError(
f"上游模型响应异常: {response.status_code}",
request=response.request,
response=response,
)
response.raise_for_status()
return response
except (httpx.TimeoutException, httpx.HTTPStatusError, httpx.NetworkError) as error:
last_error = error
if attempt == settings.AI_MAX_RETRIES - 1:
break
await asyncio.sleep(2 ** attempt)
raise RuntimeError(f"模型调用失败:{last_error}")
async def _call_openai_compatible(model: AiModel, system_prompt: str, user_prompt: str) -> AiCallResult:
api_key = decrypt_text(model.api_key_encrypted)
if not api_key:
raise RuntimeError("模型 API Key 不可用")
payload = {
"model": model.name,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
"temperature": 0.3,
}
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
async with httpx.AsyncClient(timeout=settings.AI_REQUEST_TIMEOUT) as client:
response = await _post_with_retry(client, _normalize_openai_endpoint(model.api_endpoint), headers, payload)
body = response.json()
text = body["choices"][0]["message"]["content"]
return AiCallResult(content=_ensure_json_content(text), raw_text=text, used_model=model.name)
async def _call_anthropic(model: AiModel, system_prompt: str, user_prompt: str) -> AiCallResult:
api_key = decrypt_text(model.api_key_encrypted)
if not api_key:
raise RuntimeError("模型 API Key 不可用")
payload = {
"model": model.name,
"max_tokens": 2048,
"system": system_prompt,
"messages": [{"role": "user", "content": user_prompt}],
}
headers = {
"x-api-key": api_key,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
}
async with httpx.AsyncClient(timeout=settings.AI_REQUEST_TIMEOUT) as client:
response = await _post_with_retry(client, _normalize_anthropic_endpoint(model.api_endpoint), headers, payload)
body = response.json()
text = ""
for item in body.get("content", []):
if item.get("type") == "text":
text += item.get("text", "")
return AiCallResult(content=_ensure_json_content(text), raw_text=text, used_model=model.name)
async def call_ai(paragraph: Paragraph, model: AiModel) -> AiCallResult:
system_prompt, user_prompt = _build_prompt(paragraph)
if model.api_format == "anthropic":
return await _call_anthropic(model, system_prompt, user_prompt)
return await _call_openai_compatible(model, system_prompt, user_prompt)