模板在线编辑与导出链路重构
This commit is contained in:
@@ -22,6 +22,7 @@ async def get_db():
|
||||
async def init_db():
|
||||
from models.template import Template
|
||||
from models.paragraph import Paragraph
|
||||
from models.template_block import TemplateBlock
|
||||
from models.ai_model import AiModel
|
||||
from models.document import Document
|
||||
from models.generation_log import GenerationLog
|
||||
@@ -53,3 +54,6 @@ async def init_db():
|
||||
await conn.execute(text("UPDATE paragraphs SET anchor_title = title WHERE anchor_title = '' OR anchor_title IS NULL"))
|
||||
if "write_mode" not in paragraph_columns:
|
||||
await conn.execute(text("ALTER TABLE paragraphs ADD COLUMN write_mode VARCHAR(30) DEFAULT 'replace_section'"))
|
||||
block_tables = await conn.run_sync(lambda sync_conn: inspect(sync_conn).get_table_names())
|
||||
if "template_blocks" not in block_tables:
|
||||
await conn.run_sync(lambda sync_conn: TemplateBlock.__table__.create(sync_conn))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from models.template import Template
|
||||
from models.paragraph import Paragraph
|
||||
from models.template_block import TemplateBlock
|
||||
from models.ai_model import AiModel
|
||||
from models.document import Document
|
||||
from models.generation_log import GenerationLog
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text, func
|
||||
|
||||
from database import Base
|
||||
|
||||
|
||||
class TemplateBlock(Base):
|
||||
__tablename__ = "template_blocks"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
template_id = Column(Integer, ForeignKey("templates.id"), nullable=False)
|
||||
source_paragraph_id = Column(Integer, ForeignKey("paragraphs.id"), nullable=True)
|
||||
parent_block_id = Column(Integer, ForeignKey("template_blocks.id"), nullable=True)
|
||||
sort_index = Column(Integer, default=0, comment="排序")
|
||||
block_type = Column(String(30), default="text", comment="heading/text/table/ai_slot/variable")
|
||||
anchor_ref = Column(String(500), default="", comment="原始锚点引用")
|
||||
title = Column(String(500), default="", comment="块标题")
|
||||
content_json = Column(Text, default="{}", comment="块内容 JSON")
|
||||
style_json = Column(Text, default="{}", comment="块样式 JSON")
|
||||
edit_mode = Column(String(20), default="manual", comment="manual/ai")
|
||||
placeholder_key = Column(String(120), default="", comment="AI 占位键")
|
||||
variable_key = Column(String(120), default="", comment="变量键")
|
||||
default_value = Column(Text, default="", comment="默认值")
|
||||
model_id = Column(Integer, ForeignKey("ai_models.id"), nullable=True, comment="指定模型")
|
||||
need_prompt = Column(Boolean, default=True, comment="是否需要提示词")
|
||||
prompt_text = Column(Text, default="", comment="预设提示词")
|
||||
need_file = Column(Boolean, default=False, comment="是否需要上传参考文件")
|
||||
file_note = Column(Text, default="", comment="参考文件说明")
|
||||
output_format = Column(String(20), default="text", comment="text/table/mixed/chart")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
+81
-13
@@ -15,6 +15,7 @@ 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 services.document_export import export_document_bytes
|
||||
from services.minio_client import (
|
||||
download_object_bytes,
|
||||
@@ -26,6 +27,46 @@ from services.minio_client import (
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
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 {}
|
||||
|
||||
|
||||
def _build_block_export_content(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
|
||||
|
||||
|
||||
@router.get("/{document_id}/docx")
|
||||
async def export_docx(document_id: int, db: AsyncSession = Depends(get_db)):
|
||||
document = await db.get(Document, document_id)
|
||||
@@ -39,22 +80,49 @@ async def export_docx(document_id: int, db: AsyncSession = Depends(get_db)):
|
||||
template_bucket, template_object = split_bucket_path(template.file_path)
|
||||
template_bytes = await asyncio.to_thread(download_object_bytes, template_bucket, template_object)
|
||||
|
||||
result = await db.execute(
|
||||
select(GenerationLog, Paragraph)
|
||||
.join(Paragraph, Paragraph.id == GenerationLog.paragraph_id)
|
||||
.where(GenerationLog.document_id == document_id)
|
||||
.order_by(Paragraph.sort_index.asc(), Paragraph.id.asc())
|
||||
block_result = await db.execute(
|
||||
select(TemplateBlock)
|
||||
.where(TemplateBlock.template_id == template.id)
|
||||
.order_by(TemplateBlock.sort_index.asc(), TemplateBlock.id.asc())
|
||||
)
|
||||
blocks = block_result.scalars().all()
|
||||
|
||||
log_result = await db.execute(
|
||||
select(GenerationLog).where(GenerationLog.document_id == document_id)
|
||||
)
|
||||
generation_logs = log_result.scalars().all()
|
||||
log_map = {item.paragraph_id: json.loads(item.content) if item.content else {"content": []} for item in generation_logs}
|
||||
|
||||
logs = []
|
||||
for log, paragraph in result.all():
|
||||
logs.append(
|
||||
{
|
||||
"anchor_title": paragraph.anchor_title or paragraph.title,
|
||||
"title": paragraph.title,
|
||||
"write_mode": paragraph.write_mode,
|
||||
"content": json.loads(log.content) if log.content else {"content": []},
|
||||
}
|
||||
if blocks:
|
||||
write_modes = _resolve_block_write_modes(blocks)
|
||||
for block, write_mode in zip(blocks, write_modes):
|
||||
generated_content = log_map.get(block.source_paragraph_id) if block.source_paragraph_id else None
|
||||
content = generated_content if (block.edit_mode == "ai" or block.block_type == "ai_slot") and generated_content else _build_block_export_content(block)
|
||||
logs.append(
|
||||
{
|
||||
"anchor_title": block.anchor_ref or block.title,
|
||||
"title": block.title,
|
||||
"write_mode": write_mode,
|
||||
"content": content,
|
||||
}
|
||||
)
|
||||
else:
|
||||
result = await db.execute(
|
||||
select(GenerationLog, Paragraph)
|
||||
.join(Paragraph, Paragraph.id == GenerationLog.paragraph_id)
|
||||
.where(GenerationLog.document_id == document_id)
|
||||
.order_by(Paragraph.sort_index.asc(), Paragraph.id.asc())
|
||||
)
|
||||
for log, paragraph in result.all():
|
||||
logs.append(
|
||||
{
|
||||
"anchor_title": paragraph.anchor_title or paragraph.title,
|
||||
"title": paragraph.title,
|
||||
"write_mode": paragraph.write_mode,
|
||||
"content": json.loads(log.content) if log.content else {"content": []},
|
||||
}
|
||||
)
|
||||
|
||||
exported_bytes = await asyncio.to_thread(export_document_bytes, template_bytes, logs)
|
||||
object_name = f"{datetime.now().strftime('%Y%m%d')}/{uuid.uuid4().hex}.docx"
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -43,8 +43,32 @@ class ParagraphConfig(BaseModel):
|
||||
file_note: str = ""
|
||||
output_format: str = "text"
|
||||
|
||||
|
||||
class TemplateBlockConfig(BaseModel):
|
||||
id: int = 0
|
||||
source_paragraph_id: Optional[int] = None
|
||||
parent_block_id: Optional[int] = None
|
||||
sort_index: int = 0
|
||||
block_type: str = "text"
|
||||
anchor_ref: str = ""
|
||||
title: str = ""
|
||||
content_json: dict[str, Any] = Field(default_factory=dict)
|
||||
style_json: str = "{}"
|
||||
edit_mode: str = "manual"
|
||||
placeholder_key: str = ""
|
||||
variable_key: str = ""
|
||||
default_value: str = ""
|
||||
model_id: Optional[int] = None
|
||||
need_prompt: bool = True
|
||||
prompt_text: str = ""
|
||||
need_file: bool = False
|
||||
file_note: str = ""
|
||||
output_format: str = "text"
|
||||
|
||||
class TemplateSave(BaseModel):
|
||||
save_mode: str = "paragraph"
|
||||
paragraphs: list[ParagraphConfig] = []
|
||||
blocks: list[TemplateBlockConfig] = []
|
||||
|
||||
# 模型
|
||||
class AiModelCreate(BaseModel):
|
||||
|
||||
@@ -69,6 +69,77 @@ def _remove_unreferenced_headings(document: DocumentObject, referenced_anchors:
|
||||
_delete_block(block)
|
||||
|
||||
|
||||
def _ordered_unique_anchors(logs: list[dict]) -> list[str]:
|
||||
ordered: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for item in logs:
|
||||
anchor = (item.get("anchor_title") or item.get("title") or "").strip()
|
||||
if not anchor or anchor in seen:
|
||||
continue
|
||||
seen.add(anchor)
|
||||
ordered.append(anchor)
|
||||
return ordered
|
||||
|
||||
|
||||
def _reorder_heading_sections(document: DocumentObject, ordered_anchors: list[str]):
|
||||
body = document.element.body
|
||||
elements = list(body.iterchildren())
|
||||
pre_heading: list = []
|
||||
sections: list[tuple[str, list]] = []
|
||||
found_heading = False
|
||||
index = 0
|
||||
|
||||
while index < len(elements):
|
||||
child = elements[index]
|
||||
if isinstance(child, CT_P):
|
||||
paragraph = Paragraph(child, document)
|
||||
if _is_heading(paragraph):
|
||||
found_heading = True
|
||||
anchor = paragraph.text.strip()
|
||||
section_elements = [child]
|
||||
index += 1
|
||||
while index < len(elements):
|
||||
current = elements[index]
|
||||
if isinstance(current, CT_P):
|
||||
current_paragraph = Paragraph(current, document)
|
||||
if _is_heading(current_paragraph):
|
||||
break
|
||||
section_elements.append(current)
|
||||
index += 1
|
||||
sections.append((anchor, section_elements))
|
||||
continue
|
||||
if not found_heading:
|
||||
pre_heading.append(child)
|
||||
index += 1
|
||||
|
||||
if not sections:
|
||||
return
|
||||
|
||||
section_map: dict[str, list[list]] = {}
|
||||
for anchor, section_elements in sections:
|
||||
section_map.setdefault(anchor, []).append(section_elements)
|
||||
|
||||
all_section_elements = [element for _, section_elements in sections for element in section_elements]
|
||||
for element in all_section_elements:
|
||||
parent = element.getparent()
|
||||
if parent is not None:
|
||||
parent.remove(element)
|
||||
|
||||
sect_pr = None
|
||||
for child in list(body.iterchildren()):
|
||||
if not isinstance(child, (CT_P, CT_Tbl)):
|
||||
sect_pr = child
|
||||
break
|
||||
|
||||
for anchor in ordered_anchors:
|
||||
for section_elements in section_map.pop(anchor, []):
|
||||
for element in section_elements:
|
||||
if sect_pr is not None:
|
||||
sect_pr.addprevious(element)
|
||||
else:
|
||||
body.append(element)
|
||||
|
||||
|
||||
def _clear_paragraph(paragraph: Paragraph):
|
||||
element = paragraph._element
|
||||
for child in list(element):
|
||||
@@ -369,6 +440,8 @@ def _replace_section_group(
|
||||
|
||||
def export_document_bytes(template_bytes: bytes, logs: list[dict]) -> bytes:
|
||||
document = Document(BytesIO(template_bytes))
|
||||
ordered_anchors = _ordered_unique_anchors(logs)
|
||||
_reorder_heading_sections(document, ordered_anchors)
|
||||
|
||||
referenced_anchors: set[str] = set()
|
||||
for item in logs:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
|
||||
@@ -22,6 +23,12 @@ class ParsedParagraph:
|
||||
is_table: bool
|
||||
table_json: str
|
||||
write_mode: str
|
||||
block_type: str = "text"
|
||||
placeholder_key: str = ""
|
||||
variable_key: str = ""
|
||||
default_value: str = ""
|
||||
edit_mode: str = "manual"
|
||||
output_format: str = "text"
|
||||
|
||||
|
||||
def _iter_block_items(document: DocumentObject) -> Iterator[Paragraph | Table]:
|
||||
@@ -159,11 +166,35 @@ def _extract_table_data(table: Table) -> dict:
|
||||
}
|
||||
|
||||
|
||||
PLACEHOLDER_PATTERN = re.compile(r"^\{\{\s*([a-zA-Z0-9_\-\.]+)\s*\}\}$")
|
||||
|
||||
|
||||
def _build_block_title(text: str, fallback: str) -> str:
|
||||
normalized = " ".join((text or "").split())
|
||||
if not normalized:
|
||||
return fallback
|
||||
return normalized[:24] + ("..." if len(normalized) > 24 else "")
|
||||
|
||||
|
||||
def _classify_placeholder(text: str) -> tuple[str, str, str]:
|
||||
matched = PLACEHOLDER_PATTERN.match(text.strip())
|
||||
if not matched:
|
||||
return "text", "", ""
|
||||
key = matched.group(1)
|
||||
lowered = key.lower()
|
||||
if any(token in lowered for token in ("summary", "opening", "section", "content", "analysis")):
|
||||
return "ai_slot", key, ""
|
||||
return "variable", "", key
|
||||
|
||||
|
||||
def parse_template(file_path: str) -> list[ParsedParagraph]:
|
||||
document = Document(file_path)
|
||||
parsed: list[ParsedParagraph] = []
|
||||
current_item: ParsedParagraph | None = None
|
||||
current_heading: str | None = None
|
||||
current_heading_style_json = "{}"
|
||||
loose_table_count = 0
|
||||
body_block_count = 0
|
||||
preface_count = 0
|
||||
|
||||
for block in _iter_block_items(document):
|
||||
if isinstance(block, Paragraph):
|
||||
@@ -173,52 +204,79 @@ def parse_template(file_path: str) -> list[ParsedParagraph]:
|
||||
|
||||
level = _heading_level(block.style.name if block.style is not None else "")
|
||||
if level is not None:
|
||||
current_item = ParsedParagraph(
|
||||
current_heading = text
|
||||
current_heading_style_json = json.dumps(_capture_paragraph_style(block, level), ensure_ascii=False)
|
||||
body_block_count = 0
|
||||
parsed.append(ParsedParagraph(
|
||||
sort_index=len(parsed) + 1,
|
||||
anchor_title=text,
|
||||
title=text,
|
||||
content="",
|
||||
style_json=json.dumps(_capture_paragraph_style(block, level), ensure_ascii=False),
|
||||
style_json=current_heading_style_json,
|
||||
is_table=False,
|
||||
table_json="{}",
|
||||
write_mode="replace_section",
|
||||
)
|
||||
parsed.append(current_item)
|
||||
write_mode="replace_heading_only",
|
||||
block_type="heading",
|
||||
edit_mode="manual",
|
||||
output_format="text",
|
||||
))
|
||||
continue
|
||||
|
||||
if current_item is None:
|
||||
current_item = ParsedParagraph(
|
||||
sort_index=len(parsed) + 1,
|
||||
anchor_title="未命名段落",
|
||||
title="未命名段落",
|
||||
content=text,
|
||||
style_json=json.dumps(_capture_paragraph_style(block, 0), ensure_ascii=False),
|
||||
is_table=False,
|
||||
table_json="{}",
|
||||
write_mode="replace_section",
|
||||
)
|
||||
parsed.append(current_item)
|
||||
block_type, placeholder_key, variable_key = _classify_placeholder(text)
|
||||
if current_heading is None:
|
||||
preface_count += 1
|
||||
anchor_title = f"文档起始_{preface_count}"
|
||||
title = _build_block_title(text, anchor_title)
|
||||
write_mode = "replace_section"
|
||||
else:
|
||||
current_item.content = "\n".join(filter(None, [current_item.content, text]))
|
||||
body_block_count += 1
|
||||
anchor_title = current_heading
|
||||
title = _build_block_title(text, f"{current_heading}-正文{body_block_count}")
|
||||
write_mode = "append_after_heading"
|
||||
|
||||
parsed.append(ParsedParagraph(
|
||||
sort_index=len(parsed) + 1,
|
||||
anchor_title=anchor_title,
|
||||
title=title,
|
||||
content=text,
|
||||
style_json=json.dumps(_capture_paragraph_style(block, 0), ensure_ascii=False),
|
||||
is_table=False,
|
||||
table_json="{}",
|
||||
write_mode=write_mode,
|
||||
block_type=block_type,
|
||||
placeholder_key=placeholder_key,
|
||||
variable_key=variable_key,
|
||||
default_value="" if variable_key else text,
|
||||
edit_mode="ai" if block_type == "ai_slot" else "manual",
|
||||
output_format="text",
|
||||
))
|
||||
else:
|
||||
table_data = _extract_table_data(block)
|
||||
table_text = f"[表格] {table_data['rows']} 行 {table_data['cols']} 列"
|
||||
if current_item is None:
|
||||
if current_heading is None:
|
||||
loose_table_count += 1
|
||||
current_item = ParsedParagraph(
|
||||
sort_index=len(parsed) + 1,
|
||||
anchor_title=f"表格_{loose_table_count}",
|
||||
title=f"表格_{loose_table_count}",
|
||||
content=table_text,
|
||||
style_json="{}",
|
||||
is_table=True,
|
||||
table_json=json.dumps(table_data, ensure_ascii=False),
|
||||
write_mode="replace_section",
|
||||
)
|
||||
parsed.append(current_item)
|
||||
anchor_title = f"表格_{loose_table_count}"
|
||||
title = anchor_title
|
||||
write_mode = "replace_section"
|
||||
else:
|
||||
current_item.is_table = True
|
||||
current_item.table_json = json.dumps(table_data, ensure_ascii=False)
|
||||
current_item.content = "\n".join(filter(None, [current_item.content, table_text]))
|
||||
body_block_count += 1
|
||||
anchor_title = current_heading
|
||||
title = f"{current_heading}-表格{body_block_count}"
|
||||
write_mode = "append_after_heading"
|
||||
|
||||
parsed.append(ParsedParagraph(
|
||||
sort_index=len(parsed) + 1,
|
||||
anchor_title=anchor_title,
|
||||
title=title,
|
||||
content=table_text,
|
||||
style_json=current_heading_style_json if current_heading else "{}",
|
||||
is_table=True,
|
||||
table_json=json.dumps(table_data, ensure_ascii=False),
|
||||
write_mode=write_mode,
|
||||
block_type="table",
|
||||
default_value=table_text,
|
||||
edit_mode="manual",
|
||||
output_format="table",
|
||||
))
|
||||
|
||||
return parsed
|
||||
|
||||
Reference in New Issue
Block a user