模板在线编辑与导出链路重构
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import uuid
|
||||
@@ -15,8 +16,10 @@ from models.document import Document
|
||||
from models.generation_log import GenerationLog
|
||||
from models.paragraph import Paragraph
|
||||
from models.template import Template
|
||||
from models.template_block import TemplateBlock
|
||||
from schemas.schemas import Response, TemplateSave
|
||||
from services.minio_client import minio_client
|
||||
from services.document_export import export_document_bytes
|
||||
from services.minio_client import download_object_bytes, minio_client, split_bucket_path, upload_bytes
|
||||
from services.template_parser import parse_template
|
||||
|
||||
router = APIRouter()
|
||||
@@ -64,6 +67,270 @@ def _serialize_template(template: Template) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _build_block_from_paragraph(paragraph: Paragraph) -> dict:
|
||||
block_type = "heading" if paragraph.write_mode == "replace_heading_only" else ("ai_slot" if paragraph.edit_mode == "ai" else ("table" if paragraph.is_table else "text"))
|
||||
content_json = json.dumps({
|
||||
"text": paragraph.content or "",
|
||||
"table": json.loads(paragraph.table_json or "{}") if paragraph.is_table else None,
|
||||
}, ensure_ascii=False)
|
||||
return {
|
||||
"source_paragraph_id": paragraph.id,
|
||||
"parent_block_id": None,
|
||||
"sort_index": paragraph.sort_index,
|
||||
"block_type": block_type,
|
||||
"anchor_ref": paragraph.anchor_title or paragraph.title,
|
||||
"title": paragraph.title,
|
||||
"content_json": content_json,
|
||||
"style_json": paragraph.style_json or "{}",
|
||||
"edit_mode": paragraph.edit_mode,
|
||||
"placeholder_key": "",
|
||||
"variable_key": "",
|
||||
"default_value": paragraph.content or "",
|
||||
"model_id": paragraph.model_id,
|
||||
"need_prompt": paragraph.need_prompt,
|
||||
"prompt_text": paragraph.prompt_text,
|
||||
"need_file": paragraph.need_file,
|
||||
"file_note": paragraph.file_note,
|
||||
"output_format": paragraph.output_format,
|
||||
}
|
||||
|
||||
|
||||
def _build_block_from_parsed_item(item, source_paragraph_id: int | None) -> dict:
|
||||
content_json = json.dumps({
|
||||
"text": item.content or "",
|
||||
"table": json.loads(item.table_json or "{}") if item.is_table else None,
|
||||
}, ensure_ascii=False)
|
||||
return {
|
||||
"source_paragraph_id": source_paragraph_id,
|
||||
"parent_block_id": None,
|
||||
"sort_index": item.sort_index,
|
||||
"block_type": item.block_type,
|
||||
"anchor_ref": item.anchor_title or item.title,
|
||||
"title": item.title,
|
||||
"content_json": content_json,
|
||||
"style_json": item.style_json or "{}",
|
||||
"edit_mode": item.edit_mode,
|
||||
"placeholder_key": item.placeholder_key,
|
||||
"variable_key": item.variable_key,
|
||||
"default_value": item.default_value,
|
||||
"model_id": None,
|
||||
"need_prompt": True,
|
||||
"prompt_text": "",
|
||||
"need_file": False,
|
||||
"file_note": "",
|
||||
"output_format": item.output_format,
|
||||
}
|
||||
|
||||
|
||||
def _serialize_block(block: TemplateBlock) -> dict:
|
||||
try:
|
||||
content_json = json.loads(block.content_json or "{}")
|
||||
except Exception:
|
||||
content_json = {}
|
||||
return {
|
||||
"id": block.id,
|
||||
"template_id": block.template_id,
|
||||
"source_paragraph_id": block.source_paragraph_id,
|
||||
"parent_block_id": block.parent_block_id,
|
||||
"sort_index": block.sort_index,
|
||||
"block_type": block.block_type,
|
||||
"anchor_ref": block.anchor_ref,
|
||||
"title": block.title,
|
||||
"content_json": content_json,
|
||||
"style_json": block.style_json,
|
||||
"edit_mode": block.edit_mode,
|
||||
"placeholder_key": block.placeholder_key,
|
||||
"variable_key": block.variable_key,
|
||||
"default_value": block.default_value,
|
||||
"model_id": block.model_id,
|
||||
"need_prompt": block.need_prompt,
|
||||
"prompt_text": block.prompt_text,
|
||||
"need_file": block.need_file,
|
||||
"file_note": block.file_note,
|
||||
"output_format": block.output_format,
|
||||
}
|
||||
|
||||
|
||||
async def _load_blocks(db: AsyncSession, template_id: int) -> list[TemplateBlock]:
|
||||
result = await db.execute(
|
||||
select(TemplateBlock)
|
||||
.where(TemplateBlock.template_id == template_id)
|
||||
.order_by(TemplateBlock.sort_index.asc(), TemplateBlock.id.asc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def _sync_blocks_from_paragraphs(db: AsyncSession, template_id: int, paragraphs: list[Paragraph]):
|
||||
existing_blocks = await _load_blocks(db, template_id)
|
||||
for block in existing_blocks:
|
||||
await db.delete(block)
|
||||
await db.flush()
|
||||
|
||||
block_rows: list[TemplateBlock] = []
|
||||
for paragraph in paragraphs:
|
||||
block = TemplateBlock(template_id=template_id, **_build_block_from_paragraph(paragraph))
|
||||
db.add(block)
|
||||
block_rows.append(block)
|
||||
await db.flush()
|
||||
return block_rows
|
||||
|
||||
|
||||
async def _save_blocks(
|
||||
db: AsyncSession,
|
||||
template_id: int,
|
||||
blocks_payload,
|
||||
):
|
||||
existing_blocks = await _load_blocks(db, template_id)
|
||||
block_map = {item.id: item for item in existing_blocks}
|
||||
incoming_ids = {config.id for config in blocks_payload if config.id}
|
||||
|
||||
for block in existing_blocks:
|
||||
if block.id not in incoming_ids:
|
||||
await db.delete(block)
|
||||
|
||||
for index, config in enumerate(blocks_payload, start=1):
|
||||
block = block_map.get(config.id) if config.id else None
|
||||
if block is None:
|
||||
block = TemplateBlock(template_id=template_id)
|
||||
db.add(block)
|
||||
block.source_paragraph_id = config.source_paragraph_id
|
||||
block.parent_block_id = config.parent_block_id
|
||||
block.sort_index = index
|
||||
block.block_type = config.block_type
|
||||
block.anchor_ref = config.anchor_ref or config.title
|
||||
block.title = config.title
|
||||
block.content_json = json.dumps(config.content_json or {}, ensure_ascii=False)
|
||||
block.style_json = config.style_json or "{}"
|
||||
block.edit_mode = config.edit_mode
|
||||
block.placeholder_key = config.placeholder_key
|
||||
block.variable_key = config.variable_key
|
||||
block.default_value = config.default_value
|
||||
block.model_id = config.model_id
|
||||
block.need_prompt = config.need_prompt
|
||||
block.prompt_text = config.prompt_text
|
||||
block.need_file = config.need_file
|
||||
block.file_note = config.file_note
|
||||
block.output_format = config.output_format
|
||||
|
||||
await db.flush()
|
||||
|
||||
|
||||
def _block_text_content(block: TemplateBlock) -> str:
|
||||
try:
|
||||
payload = json.loads(block.content_json or "{}")
|
||||
except Exception:
|
||||
payload = {}
|
||||
return payload.get("text") or block.default_value or ""
|
||||
|
||||
|
||||
def _block_table_content(block: TemplateBlock) -> dict:
|
||||
try:
|
||||
payload = json.loads(block.content_json or "{}")
|
||||
except Exception:
|
||||
payload = {}
|
||||
return payload.get("table") or {}
|
||||
|
||||
|
||||
async def _sync_paragraphs_from_blocks(db: AsyncSession, template_id: int) -> list[Paragraph]:
|
||||
paragraph_result = await db.execute(
|
||||
select(Paragraph)
|
||||
.where(Paragraph.template_id == template_id)
|
||||
.order_by(Paragraph.sort_index.asc(), Paragraph.id.asc())
|
||||
)
|
||||
existing_paragraphs = paragraph_result.scalars().all()
|
||||
paragraph_map = {item.id: item for item in existing_paragraphs}
|
||||
blocks = await _load_blocks(db, template_id)
|
||||
write_modes = _resolve_block_write_modes(blocks)
|
||||
kept_paragraph_ids: set[int] = set()
|
||||
synced_rows: list[Paragraph] = []
|
||||
|
||||
for index, (block, write_mode) in enumerate(zip(blocks, write_modes), start=1):
|
||||
paragraph = paragraph_map.get(block.source_paragraph_id) if block.source_paragraph_id else None
|
||||
if paragraph is None:
|
||||
paragraph = Paragraph(template_id=template_id)
|
||||
db.add(paragraph)
|
||||
await db.flush()
|
||||
paragraph.sort_index = index
|
||||
paragraph.anchor_title = block.anchor_ref or block.title
|
||||
paragraph.title = block.title
|
||||
paragraph.content = _block_text_content(block)
|
||||
paragraph.style_json = block.style_json or "{}"
|
||||
paragraph.is_table = block.block_type == "table"
|
||||
paragraph.table_json = json.dumps(_block_table_content(block), ensure_ascii=False) if paragraph.is_table else "{}"
|
||||
paragraph.edit_mode = "ai" if block.block_type == "ai_slot" or block.edit_mode == "ai" else "manual"
|
||||
paragraph.write_mode = write_mode
|
||||
paragraph.model_id = block.model_id
|
||||
paragraph.need_prompt = block.need_prompt
|
||||
paragraph.prompt_text = block.prompt_text
|
||||
paragraph.need_file = block.need_file
|
||||
paragraph.file_note = block.file_note
|
||||
paragraph.output_format = block.output_format
|
||||
block.source_paragraph_id = paragraph.id
|
||||
kept_paragraph_ids.add(paragraph.id)
|
||||
synced_rows.append(paragraph)
|
||||
|
||||
for paragraph in existing_paragraphs:
|
||||
if paragraph.id in kept_paragraph_ids:
|
||||
continue
|
||||
await db.execute(delete(GenerationLog).where(GenerationLog.paragraph_id == paragraph.id))
|
||||
await db.delete(paragraph)
|
||||
|
||||
await db.flush()
|
||||
return synced_rows
|
||||
|
||||
|
||||
def _build_export_content_from_block(block: TemplateBlock) -> dict:
|
||||
if block.block_type == "table":
|
||||
table_data = _block_table_content(block)
|
||||
matrix = table_data.get("data") or []
|
||||
headers = matrix[0] if matrix else []
|
||||
rows = matrix[1:] if len(matrix) > 1 else []
|
||||
return {"content": [{"type": "table", "headers": headers, "rows": rows}]}
|
||||
return {"content": [{"type": "text", "text": _block_text_content(block)}]}
|
||||
|
||||
|
||||
def _resolve_block_write_modes(blocks: list[TemplateBlock]) -> list[str]:
|
||||
modes: list[str] = []
|
||||
anchor_counter: dict[str, int] = {}
|
||||
for block in blocks:
|
||||
if block.block_type == "heading":
|
||||
modes.append("replace_heading_only")
|
||||
continue
|
||||
anchor = (block.anchor_ref or block.title or "").strip()
|
||||
seen = anchor_counter.get(anchor, 0)
|
||||
modes.append("replace_section" if seen == 0 else "append_after_heading")
|
||||
anchor_counter[anchor] = seen + 1
|
||||
return modes
|
||||
|
||||
|
||||
async def _write_template_snapshot_to_docx(db: AsyncSession, template: Template):
|
||||
blocks = await _load_blocks(db, template.id)
|
||||
if not blocks:
|
||||
return
|
||||
write_modes = _resolve_block_write_modes(blocks)
|
||||
logs = []
|
||||
for block, write_mode in zip(blocks, write_modes):
|
||||
logs.append(
|
||||
{
|
||||
"anchor_title": block.anchor_ref or block.title,
|
||||
"title": block.title,
|
||||
"write_mode": write_mode,
|
||||
"content": _build_export_content_from_block(block),
|
||||
}
|
||||
)
|
||||
|
||||
template_bucket, template_object = split_bucket_path(template.file_path)
|
||||
template_bytes = await asyncio.to_thread(download_object_bytes, template_bucket, template_object)
|
||||
exported_bytes = await asyncio.to_thread(export_document_bytes, template_bytes, logs)
|
||||
await asyncio.to_thread(
|
||||
upload_bytes,
|
||||
template_bucket,
|
||||
template_object,
|
||||
exported_bytes,
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
)
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_templates(
|
||||
page: int = Query(1, ge=1),
|
||||
@@ -100,9 +367,16 @@ async def get_template(template_id: int, db: AsyncSession = Depends(get_db)):
|
||||
.where(Paragraph.template_id == template_id)
|
||||
.order_by(Paragraph.sort_index.asc(), Paragraph.id.asc())
|
||||
)
|
||||
paragraphs = [_serialize_paragraph(item) for item in result.scalars().all()]
|
||||
paragraph_rows = result.scalars().all()
|
||||
paragraphs = [_serialize_paragraph(item) for item in paragraph_rows]
|
||||
block_rows = await _load_blocks(db, template_id)
|
||||
if not block_rows and paragraph_rows:
|
||||
block_rows = await _sync_blocks_from_paragraphs(db, template_id, paragraph_rows)
|
||||
await db.commit()
|
||||
blocks = [_serialize_block(item) for item in block_rows]
|
||||
payload = _serialize_template(template)
|
||||
payload["paragraphs"] = paragraphs
|
||||
payload["blocks"] = blocks
|
||||
return Response(data=payload)
|
||||
|
||||
|
||||
@@ -161,19 +435,35 @@ async def upload_template(file: UploadFile = File(...), db: AsyncSession = Depen
|
||||
style_json=item.style_json,
|
||||
is_table=item.is_table,
|
||||
table_json=item.table_json,
|
||||
edit_mode="manual",
|
||||
edit_mode=item.edit_mode,
|
||||
write_mode=item.write_mode,
|
||||
need_prompt=item.edit_mode == "ai",
|
||||
output_format=item.output_format,
|
||||
)
|
||||
db.add(paragraph)
|
||||
paragraph_rows.append(paragraph)
|
||||
|
||||
await db.flush()
|
||||
existing_blocks = await _load_blocks(db, template.id)
|
||||
for block in existing_blocks:
|
||||
await db.delete(block)
|
||||
await db.flush()
|
||||
block_rows: list[TemplateBlock] = []
|
||||
for item, paragraph in zip(parsed_items, paragraph_rows):
|
||||
block = TemplateBlock(template_id=template.id, **_build_block_from_parsed_item(item, paragraph.id))
|
||||
db.add(block)
|
||||
block_rows.append(block)
|
||||
await db.flush()
|
||||
await db.commit()
|
||||
await db.refresh(template)
|
||||
for paragraph in paragraph_rows:
|
||||
await db.refresh(paragraph)
|
||||
for block in block_rows:
|
||||
await db.refresh(block)
|
||||
|
||||
payload = _serialize_template(template)
|
||||
payload["paragraphs"] = [_serialize_paragraph(item) for item in paragraph_rows]
|
||||
payload["blocks"] = [_serialize_block(item) for item in block_rows]
|
||||
return Response(data=payload)
|
||||
|
||||
|
||||
@@ -224,10 +514,24 @@ async def save_template_paragraphs(
|
||||
paragraph.file_note = config.file_note
|
||||
paragraph.output_format = config.output_format
|
||||
|
||||
await db.flush()
|
||||
refreshed_result = await db.execute(
|
||||
select(Paragraph)
|
||||
.where(Paragraph.template_id == template_id)
|
||||
.order_by(Paragraph.sort_index.asc(), Paragraph.id.asc())
|
||||
)
|
||||
refreshed_paragraphs = refreshed_result.scalars().all()
|
||||
if body.save_mode == "manual" and body.blocks:
|
||||
await _save_blocks(db, template_id, body.blocks)
|
||||
refreshed_paragraphs = await _sync_paragraphs_from_blocks(db, template_id)
|
||||
else:
|
||||
await _sync_blocks_from_paragraphs(db, template_id, refreshed_paragraphs)
|
||||
refreshed_paragraphs = await _sync_paragraphs_from_blocks(db, template_id)
|
||||
template.paragraph_count = len(refreshed_paragraphs)
|
||||
await _write_template_snapshot_to_docx(db, template)
|
||||
await db.commit()
|
||||
template.paragraph_count = len(body.paragraphs)
|
||||
await db.commit()
|
||||
return Response(data={"template_id": template_id, "saved": len(body.paragraphs)})
|
||||
blocks = [_serialize_block(item) for item in await _load_blocks(db, template_id)]
|
||||
return Response(data={"template_id": template_id, "saved": len(refreshed_paragraphs), "blocks": blocks})
|
||||
|
||||
|
||||
@router.delete("/{template_id}")
|
||||
|
||||
Reference in New Issue
Block a user