完善模型测试与参考文件历史能力

This commit is contained in:
zwt13703
2026-07-02 17:37:35 +08:00
parent 8743a02110
commit d3530ab0b7
16 changed files with 697 additions and 32 deletions
+1 -1
View File
@@ -33,7 +33,7 @@ class Settings(BaseSettings):
# 文件上传限制
MAX_UPLOAD_SIZE: int = 50 * 1024 * 1024 # 50MB
ALLOWED_EXTENSIONS: list = [".docx", ".xlsx", ".xls", ".csv", ".pdf", ".txt", ".md"]
ALLOWED_EXTENSIONS: list = [".docx", ".doc", ".xlsx", ".xls", ".xlsm", ".csv", ".pdf", ".txt", ".md", ".json"]
# 加密(用于 API Key 加密)
ENCRYPTION_KEY: str = "change-this-to-a-32-byte-key-in-production!!"
+14
View File
@@ -1,4 +1,5 @@
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from sqlalchemy import inspect, text
from sqlalchemy.orm import DeclarativeBase
from config import settings
@@ -24,5 +25,18 @@ async def init_db():
from models.ai_model import AiModel
from models.document import Document
from models.generation_log import GenerationLog
from models.reference_file import ReferenceFile
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
dialect_name = conn.dialect.name
columns = await conn.run_sync(lambda sync_conn: [column["name"] for column in inspect(sync_conn).get_columns("ai_models")])
if "supports_streaming" not in columns:
if dialect_name == "sqlite":
await conn.execute(text("ALTER TABLE ai_models ADD COLUMN supports_streaming BOOLEAN DEFAULT 0"))
else:
await conn.execute(text("ALTER TABLE ai_models ADD COLUMN supports_streaming TINYINT(1) DEFAULT 0"))
if "enable_reasoning" not in columns:
if dialect_name == "sqlite":
await conn.execute(text("ALTER TABLE ai_models ADD COLUMN enable_reasoning BOOLEAN DEFAULT 0"))
else:
await conn.execute(text("ALTER TABLE ai_models ADD COLUMN enable_reasoning TINYINT(1) DEFAULT 0"))
+3 -1
View File
@@ -1,4 +1,4 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, func
from sqlalchemy import Boolean, Column, Integer, String, Text, DateTime, func
from database import Base
class AiModel(Base):
@@ -9,6 +9,8 @@ class AiModel(Base):
api_format = Column(String(20), default="openai", comment="anthropic/openai")
api_endpoint = Column(String(500), default="", comment="API接口地址")
api_key_encrypted = Column(Text, default="", comment="加密后的API Key")
supports_streaming = Column(Boolean, default=False, comment="是否支持流式传输")
enable_reasoning = Column(Boolean, default=False, comment="是否开启思考模式")
status = Column(String(20), default="enabled", comment="enabled/disabled")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
+17
View File
@@ -0,0 +1,17 @@
from datetime import datetime
from sqlalchemy import DateTime, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from database import Base
class ReferenceFile(Base):
__tablename__ = "reference_files"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
file_name: Mapped[str] = mapped_column(String(255), default="", comment="原始文件名")
file_path: Mapped[str] = mapped_column(String(500), default="", comment="MinIO 对象路径")
file_size: Mapped[int] = mapped_column(Integer, default=0, comment="文件大小")
content_type: Mapped[str] = mapped_column(String(120), default="", comment="文件类型")
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+136 -8
View File
@@ -15,9 +15,10 @@ from models.ai_model import AiModel
from models.document import Document
from models.generation_log import GenerationLog
from models.paragraph import Paragraph
from models.reference_file import ReferenceFile
from models.template import Template
from schemas.schemas import GenerateFullRequest, GenerateTestRequest, Response
from services.ai_service import call_ai
from services.ai_service import call_ai, stream_ai_preview
from services.file_summary import summarize_minio_files
from services.generation_runtime import (
build_mock_content,
@@ -45,6 +46,17 @@ def _serialize_document(document: Document) -> dict:
"updated_at": document.updated_at,
}
def _serialize_reference_file(file: ReferenceFile) -> dict:
return {
"id": file.id,
"file_name": file.file_name,
"file_path": file.file_path,
"file_size": file.file_size,
"content_type": file.content_type,
"created_at": file.created_at,
}
@router.post("/test")
async def generate_test(body: GenerateTestRequest, db: AsyncSession = Depends(get_db)):
paragraph = await db.get(Paragraph, body.paragraph_id)
@@ -64,6 +76,7 @@ async def generate_test(body: GenerateTestRequest, db: AsyncSession = Depends(ge
content = build_mock_content(paragraph)
message = "当前未找到可用模型,返回本地模拟生成结果。"
else:
setattr(paragraph, "enable_reasoning", bool(model.enable_reasoning))
result = await call_ai(paragraph, model, file_summaries)
content = result.content
message = f"已通过模型 {result.used_model} 生成。"
@@ -77,14 +90,101 @@ async def generate_test(body: GenerateTestRequest, db: AsyncSession = Depends(ge
)
@router.post("/test-stream")
async def generate_test_stream(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="段落不存在")
if body.prompt_text:
paragraph.prompt_text = body.prompt_text
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":
raise HTTPException(status_code=400, detail="当前段落未配置可用的流式模型")
if not model.supports_streaming:
raise HTTPException(status_code=400, detail="当前模型未开启流式传输")
file_summaries = await asyncio.to_thread(summarize_minio_files, body.file_paths or []) if body.file_paths else []
setattr(paragraph, "enable_reasoning", bool(model.enable_reasoning))
async def event_stream():
yield {
"event": "message",
"data": json.dumps(
{
"type": "meta",
"message": f"正在通过模型 {model.name} 流式生成...",
"file_summaries": file_summaries,
},
ensure_ascii=False,
),
}
try:
async for chunk in stream_ai_preview(paragraph, model, file_summaries):
yield {
"event": "message",
"data": json.dumps({"type": "delta", "content": chunk}, ensure_ascii=False),
}
yield {
"event": "message",
"data": json.dumps({"type": "done"}, ensure_ascii=False),
}
except Exception as error:
fallback_message = str(error)
if "503" in fallback_message or "temporarily unavailable" in fallback_message.lower():
try:
result = await call_ai(paragraph, model, file_summaries)
yield {
"event": "message",
"data": json.dumps(
{
"type": "meta",
"message": "流式通道暂时不可用,已自动回退为普通返回。",
"file_summaries": file_summaries,
},
ensure_ascii=False,
),
}
yield {
"event": "message",
"data": json.dumps({"type": "delta", "content": result.raw_text}, ensure_ascii=False),
}
yield {
"event": "message",
"data": json.dumps({"type": "done"}, ensure_ascii=False),
}
return
except Exception as fallback_error:
fallback_message = f"{fallback_message};普通调用回退也失败:{fallback_error}"
yield {
"event": "message",
"data": json.dumps({"type": "error", "message": fallback_message}, ensure_ascii=False),
}
return EventSourceResponse(
event_stream(),
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@router.post("/upload")
async def upload_reference_file(file: UploadFile = File(...)):
async def upload_reference_file(file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
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="文件类型不支持")
allowed = " / ".join(settings.ALLOWED_EXTENSIONS)
raise HTTPException(status_code=400, detail=f"文件类型不支持:{ext or '无扩展名'}。当前支持:{allowed}")
content = await file.read()
if not content:
@@ -100,12 +200,40 @@ async def upload_reference_file(file: UploadFile = File(...)):
content,
file.content_type or "application/octet-stream",
)
return Response(
data={
"file_name": file.filename,
"file_path": f"{settings.MINIO_BUCKET_UPLOADS}/{object_name}",
}
record = ReferenceFile(
file_name=file.filename,
file_path=f"{settings.MINIO_BUCKET_UPLOADS}/{object_name}",
file_size=len(content),
content_type=file.content_type or "application/octet-stream",
)
db.add(record)
await db.commit()
await db.refresh(record)
return Response(
data=_serialize_reference_file(record)
)
@router.get("/reference-files")
async def list_reference_files(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
keyword: str = Query("", description="按文件名搜索"),
db: AsyncSession = Depends(get_db),
):
stmt = select(ReferenceFile)
count_stmt = select(func.count(ReferenceFile.id))
if keyword:
like_keyword = f"%{keyword.strip()}%"
stmt = stmt.where(ReferenceFile.file_name.like(like_keyword))
count_stmt = count_stmt.where(ReferenceFile.file_name.like(like_keyword))
total = (await db.execute(count_stmt)).scalar_one()
result = await db.execute(
stmt.order_by(ReferenceFile.id.desc()).offset((page - 1) * page_size).limit(page_size)
)
items = [_serialize_reference_file(item) for item in result.scalars().all()]
return Response(data={"items": items, "total": total, "page": page, "page_size": page_size})
@router.post("/full")
+9
View File
@@ -20,6 +20,8 @@ def _serialize_model(model: AiModel) -> dict:
"api_format": model.api_format,
"api_endpoint": model.api_endpoint,
"api_key_preview": mask_secret(api_key),
"supports_streaming": bool(model.supports_streaming),
"enable_reasoning": bool(model.enable_reasoning),
"status": model.status,
"created_at": model.created_at,
}
@@ -40,6 +42,8 @@ async def create_model(body: AiModelCreate, db: AsyncSession = Depends(get_db)):
api_format=body.api_format,
api_endpoint=body.api_endpoint,
api_key_encrypted=encrypt_text(body.api_key),
supports_streaming=body.supports_streaming,
enable_reasoning=body.enable_reasoning,
status=body.status,
)
db.add(model)
@@ -62,6 +66,10 @@ async def update_model(model_id: int, body: AiModelUpdate, db: AsyncSession = De
model.api_format = body.api_format
if body.api_endpoint is not None:
model.api_endpoint = body.api_endpoint
if body.supports_streaming is not None:
model.supports_streaming = body.supports_streaming
if body.enable_reasoning is not None:
model.enable_reasoning = body.enable_reasoning
if body.status is not None:
model.status = body.status
if body.api_key:
@@ -95,6 +103,7 @@ async def test_model(model_id: int, db: AsyncSession = Depends(get_db)):
need_prompt = False
prompt_text = ""
output_format = "text"
enable_reasoning = bool(model.enable_reasoning)
try:
result = await call_ai(FakeParagraph(), model)
+6
View File
@@ -50,6 +50,8 @@ class AiModelCreate(BaseModel):
api_format: str = "openai"
api_endpoint: str = ""
api_key: str = ""
supports_streaming: bool = False
enable_reasoning: bool = False
status: str = "enabled"
@@ -59,6 +61,8 @@ class AiModelUpdate(BaseModel):
api_format: Optional[str] = None
api_endpoint: Optional[str] = None
api_key: str = ""
supports_streaming: Optional[bool] = None
enable_reasoning: Optional[bool] = None
status: Optional[str] = None
class AiModelOut(BaseModel):
@@ -68,6 +72,8 @@ class AiModelOut(BaseModel):
api_format: str = "openai"
api_endpoint: str = ""
api_key_preview: str = ""
supports_streaming: bool = False
enable_reasoning: bool = False
status: str = "enabled"
created_at: Optional[datetime] = None
+142 -4
View File
@@ -43,12 +43,23 @@ def _ensure_json_content(text: str) -> dict:
return {"content": [{"type": "text", "text": stripped}]}
def _format_file_context(file_summaries: list[dict]) -> str:
file_blocks: list[str] = []
for item in file_summaries:
file_name = item.get("file_name") or "未命名文件"
summary = item.get("summary") or "文件内容为空。"
file_blocks.append(f"文件:{file_name}\n内容:\n{summary}")
return "\n\n".join(file_blocks)
def _build_prompt(paragraph: Paragraph, file_summaries: list[dict] | None = None) -> tuple[str, str]:
system_prompt = (
"你是一个企业文档撰写助手。"
"请严格输出 JSON,不要输出 JSON 之外的说明。"
'格式为:{"content":[{"type":"text","text":"..."},{"type":"table","title":"...","headers":["..."],"rows":[["..."]]}]}。'
)
if getattr(paragraph, "enable_reasoning", False):
system_prompt += "你可以先进行充分思考,再给出最终答案,但最终只输出要求的结果内容。"
user_parts = [f"段落标题:{paragraph.title}"]
if paragraph.content:
@@ -56,10 +67,7 @@ def _build_prompt(paragraph: Paragraph, file_summaries: list[dict] | None = None
if paragraph.need_prompt and paragraph.prompt_text:
user_parts.append(f"附加要求:{paragraph.prompt_text}")
if file_summaries:
file_blocks = []
for item in file_summaries:
file_blocks.append(f"文件名:{item['file_name']}\n文件摘要:{item['summary']}")
user_parts.append("参考文件内容:\n" + "\n\n".join(file_blocks))
user_parts.append("参考文件内容:\n" + _format_file_context(file_summaries))
user_parts.append(f"输出格式:{paragraph.output_format}")
return system_prompt, "\n\n".join(user_parts)
@@ -136,6 +144,65 @@ async def _call_openai_compatible(model: AiModel, system_prompt: str, user_promp
return AiCallResult(content=_ensure_json_content(text), raw_text=text, used_model=model.name)
async def _stream_openai_compatible(model: AiModel, system_prompt: str, user_prompt: str):
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,
"stream": True,
}
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
last_error: Exception | None = None
for attempt in range(settings.AI_MAX_RETRIES):
try:
async with httpx.AsyncClient(timeout=settings.AI_REQUEST_TIMEOUT, trust_env=False) as client:
async with client.stream("POST", _normalize_openai_endpoint(model.api_endpoint), headers=headers, json=payload) as response:
if response.status_code in (429, 500, 502, 503, 504):
body = await response.aread()
raise httpx.HTTPStatusError(
f"上游模型流式响应异常: {response.status_code} - {body.decode('utf-8', errors='ignore')[:500]}",
request=response.request,
response=response,
)
response.raise_for_status()
async for line in response.aiter_lines():
if not line or not line.startswith("data:"):
continue
payload_line = line[5:].strip()
if payload_line == "[DONE]":
break
try:
chunk = json.loads(payload_line)
except json.JSONDecodeError:
continue
delta_payload = chunk.get("choices", [{}])[0].get("delta", {})
delta = delta_payload.get("content", "")
reasoning = delta_payload.get("reasoning_content", "")
if isinstance(delta, list):
delta = "".join(
item.get("text", "") if isinstance(item, dict) else str(item)
for item in delta
)
if delta:
yield delta
if reasoning:
yield reasoning
return
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_anthropic(model: AiModel, system_prompt: str, user_prompt: str) -> AiCallResult:
api_key = decrypt_text(model.api_key_encrypted)
if not api_key:
@@ -162,8 +229,79 @@ async def _call_anthropic(model: AiModel, system_prompt: str, user_prompt: str)
return AiCallResult(content=_ensure_json_content(text), raw_text=text, used_model=model.name)
async def _stream_anthropic(model: AiModel, system_prompt: str, user_prompt: str):
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}],
"stream": True,
}
headers = {
"x-api-key": api_key,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
}
last_error: Exception | None = None
for attempt in range(settings.AI_MAX_RETRIES):
try:
async with httpx.AsyncClient(timeout=settings.AI_REQUEST_TIMEOUT, trust_env=False) as client:
async with client.stream("POST", _normalize_anthropic_endpoint(model.api_endpoint), headers=headers, json=payload) as response:
if response.status_code in (429, 500, 502, 503, 504):
body = await response.aread()
raise httpx.HTTPStatusError(
f"上游模型流式响应异常: {response.status_code} - {body.decode('utf-8', errors='ignore')[:500]}",
request=response.request,
response=response,
)
response.raise_for_status()
async for line in response.aiter_lines():
if not line or not line.startswith("data:"):
continue
payload_line = line[5:].strip()
if payload_line == "[DONE]":
break
try:
chunk = json.loads(payload_line)
except json.JSONDecodeError:
continue
if chunk.get("type") == "content_block_delta":
delta = chunk.get("delta", {}).get("text", "")
if delta:
yield delta
return
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_ai(paragraph: Paragraph, model: AiModel, file_summaries: list[dict] | None = None) -> AiCallResult:
system_prompt, user_prompt = _build_prompt(paragraph, file_summaries)
if model.api_format == "anthropic":
return await _call_anthropic(model, system_prompt, user_prompt)
return await _call_openai_compatible(model, system_prompt, user_prompt)
def build_test_stream_prompt(paragraph: Paragraph, file_summaries: list[dict] | None = None) -> tuple[str, str]:
system_prompt = "你是一个企业文档撰写助手。请直接输出适合预览的正文内容或 Markdown 表格,不要输出 JSON。"
if getattr(paragraph, "enable_reasoning", False):
system_prompt += "你可以先进行充分思考,再持续输出最终可展示的内容。"
_, user_prompt = _build_prompt(paragraph, file_summaries or [])
return system_prompt, user_prompt
async def stream_ai_preview(paragraph: Paragraph, model: AiModel, file_summaries: list[dict] | None = None):
system_prompt, user_prompt = build_test_stream_prompt(paragraph, file_summaries)
if model.api_format == "anthropic":
async for chunk in _stream_anthropic(model, system_prompt, user_prompt):
yield chunk
return
async for chunk in _stream_openai_compatible(model, system_prompt, user_prompt):
yield chunk
+3 -1
View File
@@ -63,10 +63,12 @@ def summarize_file_bytes(file_name: str, content: bytes) -> str:
return _decode_text(content)[:4000]
if suffix == ".csv":
return _summarize_csv(content)
if suffix in {".xlsx", ".xls"}:
if suffix in {".xlsx", ".xls", ".xlsm"}:
return _summarize_excel(content, suffix)
if suffix == ".docx":
return _summarize_docx(content)
if suffix == ".doc":
return "当前暂不支持直接解析 .doc 旧版 Word 文件正文,建议先另存为 .docx 后再上传。"
if suffix == ".pdf":
return _summarize_pdf(content)
return f"暂不支持解析该文件内容:{file_name}"
+1
View File
@@ -117,6 +117,7 @@ async def run_generation(document_id: int, template_id: int):
if model is None:
content = build_mock_content(paragraph)
else:
setattr(paragraph, "enable_reasoning", bool(model.enable_reasoning))
result_data = await call_ai(paragraph, model)
content = result_data.content
status = "success"