init project
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
import os
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker, DeclarativeBase
|
||||
|
||||
DB_HOST = os.getenv("DB_HOST", "localhost")
|
||||
DB_PORT = os.getenv("DB_PORT", "3306")
|
||||
DB_USER = os.getenv("DB_USER", "root")
|
||||
DB_PASS = os.getenv("DB_PASS", "root123")
|
||||
DB_NAME = os.getenv("DB_NAME", "ai_doc_template")
|
||||
|
||||
DATABASE_URL = f"mysql+pymysql://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}?charset=utf8mb4"
|
||||
|
||||
engine = create_engine(DATABASE_URL, echo=False)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,17 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
app = FastAPI(title="AI 文档模板生成系统", version="0.1.0")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:5173", "http://localhost:3000"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok", "service": "ai-doc-template"}
|
||||
@@ -0,0 +1,24 @@
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, UniqueConstraint
|
||||
from ..database import Base
|
||||
|
||||
|
||||
class BlockConfig(Base):
|
||||
__tablename__ = "block_config"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("template_id", "block_id", name="uq_template_block"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
template_id = Column(Integer, ForeignKey("template.id", ondelete="CASCADE"), nullable=False)
|
||||
block_id = Column(String(50), nullable=False)
|
||||
region_name = Column(String(200), nullable=True, comment="区域名称")
|
||||
region_type = Column(String(30), default="ai_generate", comment="ai_generate/manual/fixed/table")
|
||||
data_sources = Column(Text, nullable=True, comment="数据来源 JSON 数组")
|
||||
prompt = Column(Text, nullable=True, comment="提示词")
|
||||
output_format = Column(String(30), default="formal_paragraph", comment="输出格式")
|
||||
need_review = Column(Integer, default=1, comment="1=需要审核")
|
||||
remark = Column(Text, nullable=True, comment="备注")
|
||||
enabled = Column(Integer, default=1, comment="1=启用")
|
||||
created_at = Column(DateTime, default=datetime.now)
|
||||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
@@ -0,0 +1,17 @@
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey
|
||||
from ..database import Base
|
||||
|
||||
|
||||
class Template(Base):
|
||||
__tablename__ = "template"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String(200), nullable=False, comment="模板名称")
|
||||
type = Column(String(50), default="report", comment="报告类/公文类/总结类/合同类")
|
||||
version = Column(String(20), default="v1.0.0", comment="版本号")
|
||||
original_file_path = Column(String(500), nullable=False, comment="原始 Word 文件路径")
|
||||
status = Column(Integer, default=1, comment="1=启用 0=停用")
|
||||
created_by = Column(String(50), comment="创建人")
|
||||
created_at = Column(DateTime, default=datetime.now)
|
||||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
@@ -0,0 +1,20 @@
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey
|
||||
from ..database import Base
|
||||
|
||||
|
||||
class TemplateBlock(Base):
|
||||
__tablename__ = "template_block"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
template_id = Column(Integer, ForeignKey("template.id", ondelete="CASCADE"), nullable=False, comment="所属模板")
|
||||
block_id = Column(String(50), nullable=False, comment="唯一标识 block_001")
|
||||
parent_block_id = Column(String(50), nullable=True, comment="父级 block_id")
|
||||
block_type = Column(String(20), nullable=False, comment="title/heading/paragraph/table")
|
||||
block_name = Column(String(200), nullable=True, comment="区域名称")
|
||||
text_preview = Column(String(500), nullable=True, comment="文本预览")
|
||||
level = Column(Integer, default=0, comment="0=文档标题 1=一级 2=二级")
|
||||
sort_order = Column(Integer, default=0, comment="排序号")
|
||||
table_rows = Column(Integer, nullable=True, comment="表格行数")
|
||||
table_cols = Column(Integer, nullable=True, comment="表格列数")
|
||||
created_at = Column(DateTime, default=datetime.now)
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Word 文档解析引擎"""
|
||||
from docx import Document
|
||||
from docx.paragraph import Paragraph
|
||||
from docx.table import Table as DocxTable
|
||||
from typing import List, Dict, Any
|
||||
|
||||
|
||||
def parse_document(file_path: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
解析 Word 文档,返回 block 列表(不含表格数据,仅标记位置)。
|
||||
每个 block 包含:type, text, level, style
|
||||
"""
|
||||
doc = Document(file_path)
|
||||
blocks = []
|
||||
|
||||
for para in doc.paragraphs:
|
||||
block = classify_paragraph(para)
|
||||
if block:
|
||||
blocks.append(block)
|
||||
|
||||
return blocks
|
||||
|
||||
|
||||
def classify_paragraph(para: Paragraph) -> Dict[str, Any] | None:
|
||||
"""识别段落类型"""
|
||||
text = para.text.strip()
|
||||
style_name = para.style.name if para.style else ""
|
||||
|
||||
if not text and "Heading" not in style_name:
|
||||
return None # 跳过空段落
|
||||
|
||||
if "Heading" in style_name:
|
||||
level = int(style_name.replace("Heading", "").strip()) if style_name.replace("Heading", "").strip().isdigit() else 1
|
||||
return {"type": "heading", "text": text, "level": level, "style": style_name}
|
||||
else:
|
||||
return {"type": "paragraph", "text": text, "level": 0, "style": style_name}
|
||||
|
||||
|
||||
def parse_tables(doc: Document) -> List[Dict[str, Any]]:
|
||||
"""解析 Word 表格"""
|
||||
tables = []
|
||||
for i, table in enumerate(doc.tables):
|
||||
headers = [cell.text.strip() for cell in table.rows[0].cells] if table.rows else []
|
||||
tables.append({
|
||||
"type": "table",
|
||||
"text": " | ".join(headers),
|
||||
"level": 0,
|
||||
"rows": len(table.rows),
|
||||
"cols": len(table.columns),
|
||||
"headers": headers,
|
||||
"table_index": i,
|
||||
})
|
||||
return tables
|
||||
|
||||
|
||||
def generate_block_id(index: int) -> str:
|
||||
"""生成 block_id,格式:block_001"""
|
||||
return f"block_{index:03d}"
|
||||
|
||||
|
||||
def build_tree(blocks: List[Dict]) -> Dict:
|
||||
"""构建层级树结构"""
|
||||
tree = []
|
||||
stack = [] # 存父级节点
|
||||
|
||||
for block in blocks:
|
||||
node = {"block_id": block["block_id"], "text": block["text"][:50], "children": []}
|
||||
level = block.get("level", 0)
|
||||
|
||||
# 回退栈
|
||||
while stack and stack[-1]["level"] >= level:
|
||||
stack.pop()
|
||||
|
||||
if stack:
|
||||
parent = stack[-1]["node"]
|
||||
parent["children"].append(node)
|
||||
block["parent_block_id"] = parent["block_id"]
|
||||
else:
|
||||
tree.append(node)
|
||||
block["parent_block_id"] = None
|
||||
|
||||
# 非叶子节点入栈
|
||||
if level < 2 or block["type"] == "heading":
|
||||
stack.append({"level": level, "node": node})
|
||||
|
||||
return {"blocks": tree}
|
||||
@@ -0,0 +1,27 @@
|
||||
"""HTML 预览生成服务"""
|
||||
from typing import List, Dict, Any
|
||||
|
||||
|
||||
def generate_preview_html(blocks: List[Dict[str, Any]]) -> str:
|
||||
"""
|
||||
将 block 列表转为带 data-block-id 的 HTML 预览字符串。
|
||||
每个元素都会带上 data-block-id 属性供前端点击交互。
|
||||
"""
|
||||
html_parts = ['<div class="doc-preview-content">']
|
||||
|
||||
for block in blocks:
|
||||
block_id = block.get("block_id", "")
|
||||
block_type = block.get("type", "")
|
||||
text = block.get("text", "")
|
||||
|
||||
if block_type == "heading":
|
||||
html = f'<h2 data-block-id="{block_id}" style="font-size:16px;font-weight:600;margin:20px 0 10px;padding-bottom:6px;border-bottom:2px solid #1a1d24">{text}</h2>'
|
||||
elif block_type == "table":
|
||||
html = f'<div data-block-id="{block_id}" style="margin:12px 0;padding:8px;background:#f7f8fa;border:1px dashed #ccc;border-radius:4px;color:#5b626e">📊 {text[:80]}</div>'
|
||||
else:
|
||||
html = f'<p data-block-id="{block_id}" style="text-indent:2em;margin:8px 0;line-height:1.8;text-align:justify">{text}</p>'
|
||||
|
||||
html_parts.append(html)
|
||||
|
||||
html_parts.append('</div>')
|
||||
return "\n".join(html_parts)
|
||||
@@ -0,0 +1,28 @@
|
||||
import os
|
||||
from io import BytesIO
|
||||
|
||||
# 先实现本地文件存储,MinIO 作为选项
|
||||
STORAGE_BASE = os.getenv("LOCAL_STORAGE_PATH", "./data/files")
|
||||
|
||||
|
||||
def save_file(content: bytes, file_path: str) -> str:
|
||||
"""保存文件到本地存储,返回完整路径"""
|
||||
full_path = os.path.join(STORAGE_BASE, file_path)
|
||||
os.makedirs(os.path.dirname(full_path), exist_ok=True)
|
||||
with open(full_path, "wb") as f:
|
||||
f.write(content)
|
||||
return full_path
|
||||
|
||||
|
||||
def read_file(file_path: str) -> bytes:
|
||||
"""读取文件内容"""
|
||||
full_path = os.path.join(STORAGE_BASE, file_path)
|
||||
with open(full_path, "rb") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def delete_file(file_path: str):
|
||||
"""删除文件"""
|
||||
full_path = os.path.join(STORAGE_BASE, file_path)
|
||||
if os.path.exists(full_path):
|
||||
os.remove(full_path)
|
||||
@@ -0,0 +1,30 @@
|
||||
"""模板相关业务逻辑"""
|
||||
from fastapi import UploadFile
|
||||
from sqlalchemy.orm import Session
|
||||
from ..models.template import Template
|
||||
|
||||
|
||||
ALLOWED_EXTENSIONS = {".docx"}
|
||||
MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB
|
||||
|
||||
|
||||
def validate_file(file: UploadFile):
|
||||
"""校验文件格式和大小"""
|
||||
ext = "." + file.filename.rsplit(".", 1)[-1].lower() if "." in file.filename else ""
|
||||
if ext not in ALLOWED_EXTENSIONS:
|
||||
raise ValueError(f"不支持的文件格式: {ext},仅支持 .docx")
|
||||
# 读取文件头校验大小
|
||||
content = file.file.read()
|
||||
file.file.seek(0)
|
||||
if len(content) > MAX_FILE_SIZE:
|
||||
raise ValueError(f"文件大小超过限制 (50MB)")
|
||||
return content
|
||||
|
||||
|
||||
def create_template_record(db: Session, name: str, file_path: str, type: str = "report") -> Template:
|
||||
"""创建模板记录"""
|
||||
tmpl = Template(name=name, type=type, original_file_path=file_path)
|
||||
db.add(tmpl)
|
||||
db.commit()
|
||||
db.refresh(tmpl)
|
||||
return tmpl
|
||||
Reference in New Issue
Block a user