From 43f3e0b7461b0a7203dbbda86e7ed5bcaeda53fc Mon Sep 17 00:00:00 2001 From: xxy Date: Fri, 5 Jun 2026 18:41:06 +0800 Subject: [PATCH] Initial commit Co-Authored-By: Claude Opus 4.8 --- .env.example | 21 + .gitignore | 14 + README.md | 98 ++ config.py | 92 ++ database/__init__.py | 7 + database/core.py | 33 + database/dependencies.py | 20 + database/init_db.py | 52 + database/models.py | 68 + log/__init__.py | 5 + log/logger.py | 142 ++ main.py | 101 ++ prompts/__init__.py | 0 prompts/report_generation/__init__.py | 0 .../section_output_contracts.py | 877 ++++++++++++ .../template_prompt_rules.py | 150 ++ requirements.txt | 8 + routers/__init__.py | 0 routers/template.py | 346 +++++ schemas/__init__.py | 0 schemas/template.py | 51 + services/__init__.py | 0 services/declaration_service.py | 126 ++ services/desensitize_service.py | 80 ++ services/file_parse_client.py | 194 +++ services/llm_client.py | 118 ++ services/section_extractor.py | 406 ++++++ services/template_prompt_mapper.py | 1239 +++++++++++++++++ services/template_service.py | 524 +++++++ 29 files changed, 4772 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config.py create mode 100644 database/__init__.py create mode 100644 database/core.py create mode 100644 database/dependencies.py create mode 100644 database/init_db.py create mode 100644 database/models.py create mode 100644 log/__init__.py create mode 100644 log/logger.py create mode 100644 main.py create mode 100644 prompts/__init__.py create mode 100644 prompts/report_generation/__init__.py create mode 100644 prompts/report_generation/section_output_contracts.py create mode 100644 prompts/report_generation/template_prompt_rules.py create mode 100644 requirements.txt create mode 100644 routers/__init__.py create mode 100644 routers/template.py create mode 100644 schemas/__init__.py create mode 100644 schemas/template.py create mode 100644 services/__init__.py create mode 100644 services/declaration_service.py create mode 100644 services/desensitize_service.py create mode 100644 services/file_parse_client.py create mode 100644 services/llm_client.py create mode 100644 services/section_extractor.py create mode 100644 services/template_prompt_mapper.py create mode 100644 services/template_service.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2c8c7c2 --- /dev/null +++ b/.env.example @@ -0,0 +1,21 @@ +# 复制为 .env 并按实际环境修改 + +# 远程 MySQL(章节内容入库目标) +DATABASE_URL=mysql+pymysql://root:Beidas0ft@192.168.4.177:3306/eval_report?charset=utf8mb4 +DB_AUTO_CREATE_TABLES=true + +# 远程文档解析服务(上传文档 → Markdown) +FILE_PARSE_API_URL=http://192.168.4.194:8000/convert +FILE_PARSE_FIELD_NAME=file +FILE_PARSE_ENGINE=auto +FILE_PARSE_HTTP_TIMEOUT_SEC=600 + +# LLM(可选):为每个目录生成"声明"。留空则使用确定性兜底模板。 +LLM_API_BASE=http://192.168.4.197:8086/v1 +LLM_API_KEY=sk-99999999991234 +LLM_MODEL_NAME=Qwen3.6-27B +DECLARATION_USE_LLM=true + +# 服务监听 +HOST=0.0.0.0 +PORT=8100 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..624ea35 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# Python +__pycache__/ +*.py[cod] +.venv/ +venv/ +*.egg-info/ + +# 环境与日志 +.env +logs/ + +# IDE +.idea/ +.vscode/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..d0b5018 --- /dev/null +++ b/README.md @@ -0,0 +1,98 @@ +# 报告模板管理模块 + +上传一个文档,自动完成: + +1. **远程解析**:调用 `http://192.168.4.194:8000/convert`(表单字段 `file` + `engine=auto`)将文档转换为 Markdown。 +2. **抽取目录**:从 Markdown 中识别章节标题层级(目录)。 +3. **生成声明**:为每个目录(章节)生成一段"章节声明"(撰写指引),存入模板。 +4. **脱敏入库**:按标题拆分正文,对每个章节正文**脱敏**(去掉精确数字/金额/日期/百分比等),再按远程 MySQL `report_section_references` 表格式写入,得到可复用的模板化范文。 + +解析、目录抽取、正文拆分逻辑参考 `eval_report/routers/template.py` 与 `routers/reference.py`。 + +## 目录结构 + +``` +config.py 全局配置(DB / 解析服务 / LLM) +main.py FastAPI 入口 +database/ 连接、ORM 模型、建表 + models.py report_templates / report_template_sections / report_section_references +schemas/template.py 接口出入参 +services/ + file_parse_client.py 调用远程 /convert → Markdown + section_extractor.py 目录抽取 + 正文按标题拆分(共用同一遍历) + desensitize_service.py 章节正文脱敏(去精确数字等) + declaration_service.py 为每个目录生成"声明"(LLM 可选 + 兜底模板) + llm_client.py OpenAI 兼容 Chat 客户端(可选) +routers/template.py 上传/列表/详情/删除 +``` + +## 配置 + +复制 `.env.example` 为 `.env` 并修改: + +- `DATABASE_URL`:远程 MySQL(章节内容入库目标)。 +- `FILE_PARSE_API_URL`:远程文档解析服务(默认 `http://192.168.4.194:8000/convert`,文件字段 `FILE_PARSE_FIELD_NAME=file`,引擎 `FILE_PARSE_ENGINE=auto`)。 +- `LLM_*`:可选。配置后用 LLM 生成更贴合的章节声明;留空则使用确定性兜底模板。 + +启动时会按需在远程库中创建本模块用到的三张表(`DB_AUTO_CREATE_TABLES=true`,已存在则跳过)。 + +## 运行 + +```bash +pip install -r requirements.txt +python main.py +# 或 +uvicorn main:app --host 0.0.0.0 --port 8100 +``` + +打开 `http://localhost:8100/docs` 查看接口文档。 + +## 主要接口 + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| POST | `/templates/upload` | 上传文档,解析为模板(目录+声明)并将章节内容入库 | +| GET | `/templates` | 模板列表 | +| GET | `/templates/{id}` | 模板详情(含目录与各章节声明) | +| DELETE | `/templates/{id}` | 删除模板 | +| GET | `/health` | 健康检查 | + +### 上传示例 + +```bash +curl -X POST "http://localhost:8100/templates/upload" \ + -F "file=@/path/to/报告.docx" +``` + +返回包含:模板信息(每个目录的 `sectionDeclaration` 即声明)、入库章节数与各章节摘要。 + +## 日志 + +启动即初始化日志系统(`log/logger.py`),输出到控制台(强制 UTF-8,避免 Windows 中文乱码)并写入 `logs/`: + +| 文件 | 内容 | +| --- | --- | +| `logs/app.log` | 全量日志(按大小轮转) | +| `logs/error.log` | WARNING 及以上 | +| `logs/upload.log` | 上传/解析/入库链路(`routers.template`、`services.*`) | + +- 每个 HTTP 请求会记录方法、路径、状态码、耗时,并在响应头返回 `X-Request-ID`。 +- uvicorn 的 access/error 日志也统一汇入上述文件。 +- 可在 `.env` 调整:`LOG_LEVEL`、`LOG_DIR`、`LOG_TO_CONSOLE`、`LOG_MAX_BYTES`、`LOG_BACKUP_COUNT`、`LOG_HTTP_ACCESS`。 + +## 数据落点 + +- `report_templates`:一条模板记录。 +- `report_template_sections`:每个目录一条,`section_prompt` 字段存放该目录的**声明**。 +- `report_section_references`:每个章节一条,存放该章节**脱敏后的正文内容**(与远程库现有格式一致)。 + +### 脱敏规则 + +`services/desensitize_service.py`: + +- 阿拉伯数字串(含小数/千分位/全角)→ 占位符(默认 `X`):`总投资10.5亿元` → `总投资X亿元`、`85.3%` → `X%`、`2020年3月` → `X年X月`。 +- 标题行(`#` 开头)整行保留,不动章节编号与标题。 +- 行首枚举序号(`1)`、`(2)` 等)保留,仅脱敏正文数字。 +- 表格分隔行保留;数据格数字默认脱敏(`DESENSITIZE_MASK_TABLE_NUMBERS`)。 +- 中文数字(一二三…)默认保留(多为序数/层级)。 +- 可在 `.env` 调整:`DESENSITIZE_ENABLED`、`DESENSITIZE_PLACEHOLDER`、`DESENSITIZE_MASK_TABLE_NUMBERS`。 diff --git a/config.py b/config.py new file mode 100644 index 0000000..3736079 --- /dev/null +++ b/config.py @@ -0,0 +1,92 @@ +""" +config.py +报告模板管理模块的全局配置。可通过 .env 或环境变量覆盖。 +""" + +from __future__ import annotations + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + # 应用基本信息 + APP_TITLE: str = "报告模板管理模块 API" + APP_VERSION: str = "0.1.0" + APP_DESCRIPTION: str = "上传文档 → 远程解析为 Markdown → 拆解目录/章节 → 入库远程 MySQL" + + # 服务监听 + HOST: str = "0.0.0.0" + PORT: int = 8100 + RELOAD: bool = False + + CORS_ORIGINS: list[str] = ["*"] + + # 日志 + LOG_LEVEL: str = "INFO" # DEBUG / INFO / WARNING / ERROR + LOG_DIR: str = "logs" # 日志目录(相对启动目录或绝对路径) + LOG_TO_CONSOLE: bool = True # 是否同时输出到控制台 + LOG_MAX_BYTES: int = 10 * 1024 * 1024 # 单文件最大字节数(轮转) + LOG_BACKUP_COUNT: int = 7 # 轮转保留份数 + LOG_HTTP_ACCESS: bool = True # 是否记录每个 HTTP 请求 + + # 远程 MySQL:mysql+pymysql://用户:密码@主机:端口/库名?charset=utf8mb4 + DATABASE_URL: str = ( + "mysql+pymysql://root:Beidas0ft@192.168.4.177:3306/eval_report?charset=utf8mb4" + ) + DB_POOL_SIZE: int = 10 + DB_MAX_OVERFLOW: int = 20 + DB_POOL_TIMEOUT: int = 60 + DB_POOL_PRE_PING: bool = True + # 启动时自动建表(仅创建本模块用到的表,已存在则跳过) + DB_AUTO_CREATE_TABLES: bool = True + + # 远程文档解析服务:上传文件 → Markdown + FILE_PARSE_API_URL: str = "http://192.168.4.194:8000/convert" + FILE_PARSE_FIELD_NAME: str = "file" + # 解析引擎(随 multipart 一起提交的表单字段 engine) + FILE_PARSE_ENGINE: str = "auto" + FILE_PARSE_HTTP_TIMEOUT_SEC: int = 600 + FILE_PARSE_RETRY_COUNT: int = 3 + FILE_PARSE_RETRY_BACKOFF_SEC: float = 15.0 + + # 章节正文:是否包含其下级小节内容(章/节聚合整棵子树正文,避免父章节正文为空) + SECTION_CONTENT_INCLUDE_SUBSECTIONS: bool = True + # 单章节正文入库字节上限(MySQL TEXT 列上限 65535 字节,留余量防止截断到半个字符) + SECTION_CONTENT_MAX_BYTES: int = 60000 + + # 章节内容脱敏:入库前过滤精确数据(数字/金额/日期/百分比等) + DESENSITIZE_ENABLED: bool = True + DESENSITIZE_PLACEHOLDER: str = "X" # 数字脱敏后的占位符 + # 是否把表格中的数字也脱敏(表格通常是精确数据,默认开启) + DESENSITIZE_MASK_TABLE_NUMBERS: bool = True + + # LLM(可选):为每个目录生成"声明"。未配置时使用确定性兜底模板。 + LLM_API_BASE: str = "" + LLM_API_KEY: str = "" + LLM_MODEL_NAME: str = "" + LLM_HTTP_TIMEOUT_SEC: int = 120 + # 关闭思考模型的思维链输出(vLLM/Qwen3 等:chat_template_kwargs.enable_thinking=false)。 + # 既避免"思考过程"混入正文,又减少 token、降低截断与耗时。 + LLM_DISABLE_THINKING: bool = True + # 是否调用 LLM 生成章节声明(关闭则始终使用兜底模板) + DECLARATION_USE_LLM: bool = True + # 上传模版时:用 LLM 匹配默认提示词 / 为无匹配章节生成提示词(复刻 eval_report) + TEMPLATE_UPLOAD_LLM_PROMPT_MAPPING: bool = True + # LLM 提示词匹配并发:把未匹配章节分批并行调用,缩短整体耗时。 + # 多卡 A100 + 连续批处理(vLLM/TGI,TP 或多副本)下,提高并发在飞请求数即可打满 GPU: + # - 调小 BATCH_SIZE:请求更多更短,确保批次数 ≥ 线程数,单请求尾延迟更低 + # - 调大 MAX_WORKERS:同时在飞的序列更多,填满推理服务的批,decode 吞吐接近峰值 + # - 调小 MAX_TOKENS:每序列 KV 缓存预留更少,调度器可纳入更多并发序列 + # 2×A100:并发目标约 16(较单卡的 8 翻倍);BATCH_SIZE=2 保证常见规模也能跑满 16 路。 + TEMPLATE_UPLOAD_LLM_BATCH_SIZE: int = 2 # 每批未匹配章节数量 + TEMPLATE_UPLOAD_LLM_MAX_WORKERS: int = 16 # 并行线程数上限(在飞请求数) + TEMPLATE_UPLOAD_LLM_MAX_TOKENS: int = 2048 # 单批最大输出 token + + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + ) + + +settings = Settings() diff --git a/database/__init__.py b/database/__init__.py new file mode 100644 index 0000000..b4de7fb --- /dev/null +++ b/database/__init__.py @@ -0,0 +1,7 @@ +"""database package:连接、模型与依赖注入。""" + +from database.core import SessionLocal, engine +from database.dependencies import get_db +from database.init_db import init_database + +__all__ = ["engine", "SessionLocal", "get_db", "init_database"] diff --git a/database/core.py b/database/core.py new file mode 100644 index 0000000..f26f964 --- /dev/null +++ b/database/core.py @@ -0,0 +1,33 @@ +""" +database/core.py +SQLAlchemy 引擎与 Session 工厂(同步引擎,连接远程 MySQL)。 +""" + +from __future__ import annotations + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from config import settings + +engine = create_engine( + settings.DATABASE_URL, + pool_size=settings.DB_POOL_SIZE, + max_overflow=settings.DB_MAX_OVERFLOW, + pool_timeout=settings.DB_POOL_TIMEOUT, + pool_pre_ping=settings.DB_POOL_PRE_PING, + pool_recycle=3600, + connect_args={ + "charset": "utf8mb4", + "use_unicode": True, + "init_command": "SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci", + }, + echo=False, +) + +SessionLocal = sessionmaker( + bind=engine, + autocommit=False, + autoflush=False, + expire_on_commit=False, +) diff --git a/database/dependencies.py b/database/dependencies.py new file mode 100644 index 0000000..ee027e6 --- /dev/null +++ b/database/dependencies.py @@ -0,0 +1,20 @@ +""" +database/dependencies.py +FastAPI 依赖注入:获取数据库 Session。 +""" + +from __future__ import annotations + +from typing import Generator + +from sqlalchemy.orm import Session + +from database.core import SessionLocal + + +def get_db() -> Generator[Session, None, None]: + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/database/init_db.py b/database/init_db.py new file mode 100644 index 0000000..2b507d1 --- /dev/null +++ b/database/init_db.py @@ -0,0 +1,52 @@ +""" +database/init_db.py +按需建表:仅创建本模块用到的三张表,已存在则跳过(checkfirst=True)。 +""" + +from __future__ import annotations + +import logging + +from sqlalchemy import inspect, text + +from database.core import engine +from database.models import Base + +logger = logging.getLogger(__name__) + + +def _ensure_reference_template_id_column() -> None: + """为已存在的 report_section_references 表补充 template_id 字段(幂等)。 + + create_all(checkfirst=True) 只建缺失的表,不会给已存在的表加列, + 因此这里对历史表做一次轻量级 ALTER(仅在缺列时执行)。 + """ + insp = inspect(engine) + if "report_section_references" not in insp.get_table_names(): + return + + columns = {c["name"] for c in insp.get_columns("report_section_references")} + if "template_id" in columns: + return + + with engine.begin() as conn: + conn.execute( + text( + "ALTER TABLE report_section_references " + "ADD COLUMN template_id VARCHAR(64) NULL" + ) + ) + conn.execute( + text( + "ALTER TABLE report_section_references " + "ADD INDEX ix_report_section_references_template_id (template_id)" + ) + ) + logger.info("init_database: report_section_references.template_id 字段已补充") + + +def init_database() -> None: + """在远程 MySQL 中创建本模块所需表(若不存在)。""" + Base.metadata.create_all(bind=engine, checkfirst=True) + _ensure_reference_template_id_column() + logger.info("init_database: report_templates / report_template_sections / report_section_references 已就绪") diff --git a/database/models.py b/database/models.py new file mode 100644 index 0000000..d86b170 --- /dev/null +++ b/database/models.py @@ -0,0 +1,68 @@ +""" +database/models.py +ORM 模型,与远程 MySQL(eval_report 库)现有表结构一致: + - report_templates 模板 + - report_template_sections 模板章节(目录 + 声明) + - report_section_references 章节参考范文(章节内容入库目标) +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Optional + +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class Base(DeclarativeBase): + pass + + +class ReportTemplate(Base): + __tablename__ = "report_templates" + + id: Mapped[str] = mapped_column(String(64), primary_key=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + is_default: Mapped[bool] = mapped_column(Boolean, default=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + + +class ReportTemplateSection(Base): + __tablename__ = "report_template_sections" + + id: Mapped[str] = mapped_column(String(64), primary_key=True) + template_id: Mapped[str] = mapped_column( + ForeignKey("report_templates.id", ondelete="CASCADE"), nullable=False + ) + section_key: Mapped[str] = mapped_column(String(64), nullable=False) + section_title: Mapped[str] = mapped_column(String(255), nullable=False) + # 本模块语义:section_prompt 即为该目录生成的"声明" + section_prompt: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + section_output_contract: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + section_order: Mapped[int] = mapped_column(Integer, default=0) + examples: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + + +class ReportSectionReference(Base): + """章节参考范文(章节内容入库目标,格式与远程 MySQL 现有表一致)。""" + + __tablename__ = "report_section_references" + + id: Mapped[str] = mapped_column(String(64), primary_key=True) + # 关联模板(与 report_template_sections.template_id 一致);历史数据可能为空 + template_id: Mapped[Optional[str]] = mapped_column( + ForeignKey("report_templates.id", ondelete="CASCADE"), nullable=True, index=True + ) + source_file: Mapped[str] = mapped_column(String(255), nullable=False) + section_key: Mapped[str] = mapped_column(String(64), nullable=False) + section_title: Mapped[str] = mapped_column(String(255), nullable=False) + section_order: Mapped[int] = mapped_column(Integer, default=0) + content: Mapped[str] = mapped_column(Text, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) diff --git a/log/__init__.py b/log/__init__.py new file mode 100644 index 0000000..37237af --- /dev/null +++ b/log/__init__.py @@ -0,0 +1,5 @@ +"""日志包:统一日志配置。""" + +from log.logger import configure_logging, get_logger + +__all__ = ["configure_logging", "get_logger"] diff --git a/log/logger.py b/log/logger.py new file mode 100644 index 0000000..f33b2f2 --- /dev/null +++ b/log/logger.py @@ -0,0 +1,142 @@ +""" +log/logger.py +统一日志配置: + +- 控制台输出(强制 UTF-8,修复 Windows 控制台中文乱码) +- logs/app.log 全量日志(按大小轮转) +- logs/error.log 仅 WARNING 及以上 +- logs/upload.log 上传/解析/入库链路(routers.template、services.*) +- 接管 uvicorn 的 access/error 日志,统一落盘 + +幂等:重复调用只配置一次。 +""" + +from __future__ import annotations + +import logging +import sys +from logging.handlers import RotatingFileHandler +from pathlib import Path + +_CONFIGURED = False + +_FORMAT = "%(asctime)s | %(levelname)-7s | %(name)s | %(message)s" +_DATEFMT = "%Y-%m-%d %H:%M:%S" + +# 上传/解析/入库链路相关的 logger 前缀(额外汇总到 upload.log) +_UPLOAD_PREFIXES = ( + "routers.template", + "services.file_parse_client", + "services.section_extractor", + "services.declaration_service", + "services.llm_client", +) + +# 交由 root 统一处理的第三方/框架 logger +_DELEGATED_LOGGERS = ("uvicorn", "uvicorn.error", "uvicorn.access") + + +class _PrefixFilter(logging.Filter): + def __init__(self, prefixes: tuple[str, ...]) -> None: + super().__init__() + self.prefixes = prefixes + + def filter(self, record: logging.LogRecord) -> bool: + name = str(record.name or "") + return any(name == p or name.startswith(p + ".") for p in self.prefixes) + + +def _force_utf8_stream(stream): + """让控制台以 UTF-8 输出,避免 Windows GBK 控制台中文乱码。""" + reconfigure = getattr(stream, "reconfigure", None) + if callable(reconfigure): + try: + reconfigure(encoding="utf-8", errors="replace") + except (ValueError, OSError): + pass + return stream + + +def configure_logging( + *, + log_dir: str | Path | None = None, + level: str | int | None = None, + to_console: bool | None = None, + max_bytes: int | None = None, + backup_count: int | None = None, +) -> Path: + """配置全局日志。返回 app.log 路径。""" + global _CONFIGURED + + # 延迟导入,避免与 config 形成循环依赖问题 + from config import settings + + log_dir = log_dir if log_dir is not None else settings.LOG_DIR + level = level if level is not None else settings.LOG_LEVEL + to_console = to_console if to_console is not None else settings.LOG_TO_CONSOLE + max_bytes = max_bytes if max_bytes is not None else settings.LOG_MAX_BYTES + backup_count = backup_count if backup_count is not None else settings.LOG_BACKUP_COUNT + + if isinstance(level, str): + level = getattr(logging, level.strip().upper(), logging.INFO) + + target_dir = Path(log_dir).resolve() + target_dir.mkdir(parents=True, exist_ok=True) + app_log_path = target_dir / "app.log" + + if _CONFIGURED: + return app_log_path + + formatter = logging.Formatter(_FORMAT, datefmt=_DATEFMT) + + def _rotating(name: str, *, backups: int | None = None) -> RotatingFileHandler: + h = RotatingFileHandler( + target_dir / name, + maxBytes=max_bytes, + backupCount=backups if backups is not None else backup_count, + encoding="utf-8", + ) + h.setFormatter(formatter) + return h + + # 全量日志 + app_handler = _rotating("app.log") + app_handler.setLevel(level) + + # 错误日志(WARNING+) + error_handler = _rotating("error.log") + error_handler.setLevel(logging.WARNING) + + # 上传/解析链路日志 + upload_handler = _rotating("upload.log", backups=max(backup_count, 10)) + upload_handler.setLevel(level) + upload_handler.addFilter(_PrefixFilter(_UPLOAD_PREFIXES)) + + handlers: list[logging.Handler] = [app_handler, error_handler, upload_handler] + + if to_console: + console_handler = logging.StreamHandler(_force_utf8_stream(sys.stdout)) + console_handler.setLevel(level) + console_handler.setFormatter(formatter) + handlers.append(console_handler) + + root = logging.getLogger() + root.setLevel(level) + root.handlers.clear() + for h in handlers: + root.addHandler(h) + + # 让 uvicorn 的日志走 root 统一落盘 + for name in _DELEGATED_LOGGERS: + lg = logging.getLogger(name) + lg.handlers.clear() + lg.propagate = True + lg.setLevel(level) + + _CONFIGURED = True + logging.getLogger(__name__).info("日志系统已初始化 | dir=%s | level=%s", target_dir, logging.getLevelName(level)) + return app_log_path + + +def get_logger(name: str) -> logging.Logger: + return logging.getLogger(name) diff --git a/main.py b/main.py new file mode 100644 index 0000000..63e373c --- /dev/null +++ b/main.py @@ -0,0 +1,101 @@ +""" +main.py +报告模板管理模块 FastAPI 应用入口。 + +启动: + uvicorn main:app --host 0.0.0.0 --port 8100 +或: + python main.py +""" + +from __future__ import annotations + +import time +import uuid +from contextlib import asynccontextmanager + +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware + +from config import settings +from database import init_database +from log import configure_logging, get_logger +from routers import template + +# 在创建应用前完成日志配置 +configure_logging() +logger = get_logger("app") +access_logger = get_logger("app.access") + + +@asynccontextmanager +async def lifespan(_app: FastAPI): + logger.info("应用启动 | %s v%s", settings.APP_TITLE, settings.APP_VERSION) + if settings.DB_AUTO_CREATE_TABLES: + try: + init_database() + except Exception as e: # noqa: BLE001 + logger.warning("启动建表失败(不影响已存在表的使用): %s", e) + yield + logger.info("应用关闭") + + +app = FastAPI( + title=settings.APP_TITLE, + version=settings.APP_VERSION, + description=settings.APP_DESCRIPTION, + lifespan=lifespan, +) + +app.add_middleware( + CORSMiddleware, + allow_origins=settings.CORS_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.middleware("http") +async def log_requests(request: Request, call_next): + if not settings.LOG_HTTP_ACCESS: + return await call_next(request) + + req_id = uuid.uuid4().hex[:8] + start = time.perf_counter() + client = request.client.host if request.client else "-" + access_logger.info("→ [%s] %s %s | client=%s", req_id, request.method, request.url.path, client) + try: + response = await call_next(request) + except Exception: + cost = (time.perf_counter() - start) * 1000 + access_logger.exception("✗ [%s] %s %s | %.1fms | 未处理异常", req_id, request.method, request.url.path, cost) + raise + cost = (time.perf_counter() - start) * 1000 + access_logger.info( + "← [%s] %s %s | %s | %.1fms", + req_id, request.method, request.url.path, response.status_code, cost, + ) + response.headers["X-Request-ID"] = req_id + return response + + +app.include_router(template.router) + + +@app.get("/health", tags=["健康检查"]) +def health() -> dict: + return {"status": "ok", "version": settings.APP_VERSION} + + +if __name__ == "__main__": + import uvicorn + + # log_config=None:沿用本模块 configure_logging() 的配置,避免被 uvicorn 覆盖 + uvicorn.run( + "main:app", + host=settings.HOST, + port=settings.PORT, + reload=settings.RELOAD, + log_config=None, + ) diff --git a/prompts/__init__.py b/prompts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/prompts/report_generation/__init__.py b/prompts/report_generation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/prompts/report_generation/section_output_contracts.py b/prompts/report_generation/section_output_contracts.py new file mode 100644 index 0000000..f7a9d0c --- /dev/null +++ b/prompts/report_generation/section_output_contracts.py @@ -0,0 +1,877 @@ +"""Section output contract prompt variables.""" + +SECTION_OUTPUT_CONTRACTS: dict[str, str] = {'1': '按章节标题自然组织内容,围绕证据包先事实后结论,缺失项写“待补充”。', + '1.1': '必须按以下顺序输出,不得缺项、不得改名:\n' + '1) 项目名称:...\n' + '2) 建设单位:...\n' + '3) 建设地点:...\n' + '4) 建设类型:...\n' + '5) 起止时间:...\n' + '6) 建设内容:...\n' + '7) 建设投资:...\n' + '8) 占地面积:...\n' + '规则:内容仅可来自证据包;缺失项写“待补充”;严禁复用示例中的事实数据。', + '1.2': '必须严格按以下固定结构输出(纯文本编号体),不得缺项、不得增项、不得使用“###”等 Markdown 标题语法:\n' + '项目决策要点\n' + '1.2.1项目背景\n' + '1)...\n' + '(要求:先用 2~4 句完整书面语概括动因与结论,再视需要附表)\n' + '2)...\n' + '(要求:同上)\n' + '3)...\n' + '(要求:第3条背景优先写“政策/标准/环保与质量升级”类动因,并给出可由证据包定位支撑的结论,但正文中不要输出“【证据依据:...】”标签)\n' + '综合上述因素,...\n' + '\n' + '1.2.2预期目标\n' + '项目实施后,...\n' + '\n' + '写作质量规则(必须遵守):\n' + '1) ' + '必须完全按上述行序与段落结构输出:只允许出现「项目决策要点」「1.2.1项目背景」「1)」「2)」「3)」「综合上述因素,...」「1.2.2预期目标」这些结构标识;不得输出额外小标题、不得输出项目之外的说明段。\n' + '2) 每条背景必须是连续自然段(可多段),禁止把证据包里的原始换行表直接粘贴成“多列对不齐”的纯文本块。\n' + '3) 若需引用对比表、物料平衡表等,必须使用 Markdown 表格(含表头分隔行),表内数字与证据包一致,可注明表号(如表1)。\n' + '4) 第3条背景:请检索证据包中国VI、汽油标准、环评、排放、清洁生产等相关表述,结论必须可由证据包定位到文档名或段落支撑,但正文中不要输出“【证据依据:...】”标签。\n' + '5) 「预期目标」必须写成一段或多段完整书面语(不要用“- 规模目标/质量目标/效益目标:”三行结构)。若证据包已出现装置规模、烷基化油产量/产能(万吨/年)、辛烷值、国VI、收入、利润、IRR ' + '等任一可核对信息,必须在该段落中明确写出对应数字/结论;不得在证据已含关键数字时仍全部写“待补充”。\n' + '6) 证据不足时,对应句子写“待补充”,不得编造数字。\n' + '7) 严禁复用【章节示例】中的项目名、金额与结论。', + '1.3': '必须严格按以下格式输出,不得缺项、不得改名:\n' + '第一行固定为标题:"1.3 项目实施情况"。\n' + '第二段:仅用一段连续文字,按时间顺序写项目实施关键节点,覆盖可研/初设批复、开工、中交、投产试运行、竣工验收等信息;时间与事件要一一对应,可用分号分隔,禁止拆成条目。\n' + '第三段:仅用一段连续文字写投资执行对比,至少包含批复可研估算、批复初设概算、竣工决算;并计算与表述节余金额及比例(若证据不足则对应项写“待补充”),金额与口径仅可使用证据包。\n' + '第四段固定写法:"项目建设工作程序见附表1。"(无证据冲突时必须保留原句)。\n' + '写作约束:正文不得使用“项目实施关键节点”“建设与投资执行情况”等标签式小标题,不得编造时间、金额或比例。\n' + '【禁止输出表格】本节禁止输出任何 Markdown 表格(含附表1在内),“见附表1”仅为文字引用,附表在报告末尾统一输出。', + '1.4': '必须严格按以下格式输出,不得缺项、不得改名:\n' + '第一行固定为标题:"1.4 项目运行情况"。\n' + '第二段:仅用一段连续文字写项目运行情况,需包含投产后运行状态、分阶段连续运行时长、停工原因(如有)、加工负荷与烷基化油产量等关键事实;按时间顺序组织,禁止拆成条目。\n' + '第三段:仅用一段连续文字写经营与财务表现,至少包含营业收入、总成本费用、利润总额等指标,并给出经营结论(如盈利能力判断);结论必须由证据支撑。\n' + '财务口径强约束:本节优先且原则上仅可使用投产后已实现的实际值(如某年实际营业收入/成本/利润);不得使用“预测值、后预测、测算值、年均值(生产期均值)”替代实际值。”。\n' + '写作约束:正文不得使用“运行负荷与产量”“经营表现/财务表现”“总体运行结论”等标签式小标题;涉及时间、负荷、产量、金额等数据时,仅可使用证据包口径,证据不足处写“待补充”,不得编造;禁止在同一段内重复抄写相同句子或同一年份财务数据。', + '2': '按章节标题自然组织内容,围绕证据包先事实后结论,缺失项写“待补充”。', + '2.1': '本节必须使用“事实依据—评价判断—问题与建议”三段式结构。正文不得出现“【事实依据】”“【评价判断】”“【问题与建议】”标题标签。并按顺序完整覆盖下级小节:2.1.1、2.1.2、2.1.3、2.1.4、2.1.5、2.1.6、2.1.7。', + '2.1.1': '必须严格按以下格式与顺序输出,不得缺项、不得改名、不得调换段落顺序。\n' + '正文必须贴近以下版式组织,使用连续自然段表达,不得再写“事实依据/评价判断/问题与建议”等标签:\n' + '1)先写一段原料来源及与可研一致性的判断,并以“原料数量及组成对比见下表”引出表1。\n' + '2)随后固定输出表题“表1原料数量及组成对比表”,并紧跟表格。\n' + '3)表1后单独一行输出“注1.……”;如果有注释就输出,无明确注释时不写。\n' + '4)表1后写一段,对可研报告、初步设计、实际生产的原料数量与组成进行对比并给出结论。\n' + '5)再写一段全厂或装置负荷、原油加工量、装置加工量等实际运行情况。\n' + '6)然后写一句“实际生产原料组成与性质与可研报告基本一致,满足装置进料要求,详见下表。”或同义表达,引出表2。\n' + '7)随后固定输出表题“表2原料性质对比表(醚后碳四)”,并紧跟表格。\n' + '8)表2后写一段,分析原料组成、性质、烷烯比等变化及其对生产的影响。\n' + '9)最后单独写“后评价认为:……”总结性结论,结论必须回扣前文和表格数据。\n' + '\n' + '【表格强制要求】\n' + '1)表格必须直接使用“要素管理”中的表格(element_tables/element_cells),不得自行新造表,不得用正文推断补表,不得用安评/工艺包比选表替代。\n' + '2)表1必须使用要素管理中对应的“原料数量及组成对比表”;表题固定为“表1原料数量及组成对比表”。\n' + '3)表2必须使用要素管理中对应的“原料性质对比表(醚后碳四)”;表题固定为“表2原料性质对比表(醚后碳四)”。\n' + '4)若要素管理中存在上述表格,则优先直出其表头、分组表头、行项目和单元格内容;不得改列名、不得合并为其他样式、不得替换成其他表。\n' + '5)表1字段含义必须覆盖:序号、原料名称、规格、可研报告(数量(万吨)/占比(%))、初步设计(数量(万吨)/占比(%))、实际生产(数量(万吨)/占比(%))、备注;须保留合计行。\n' + '6)表2字段含义必须覆盖:序号、名称、可研报告、初步设计、实际生产、备注;行至少包含“密度,kg/m3”“硫含量,ppm”“氮含量,ppm”,其余行按要素管理表格直出。\n' + '\n' + '【禁止】\n' + '不得使用“表2.6-1”“原料选择加氢工艺技术对比”等安评/工艺包比选表作为本节主体;不得出现与本节无关的附录标题;不得把表格改写成列表、条目或非表格文本;证据不足处写“待补充”,不得编造。', + '2.1.2': '本节必须使用“事实依据—评价判断—问题与建议”三段式结构。正文不得出现“【事实依据】”“【评价判断】”“【问题与建议】”标题标签。', + '2.1.2.1': '本节必须按“产品方案评价”目的组织内容,针对全厂性炼油项目或部分化工类项目,通过对项目投产后市场对产品种类、规格、标准等方面需求的实际情况与前期工作确定的产品方案进行对比,评价前期工作确定的产品方案是否与市场实际需求相适应,评价主要产品是否为高效厚利产品以及对项目成败的影响情况。\n' + '\n' + '必须严格按以下格式与顺序输出,不得缺项、不得改名、不得调换顺序:\n' + '1)先写一段总述,说明本节是通过产品种类、规格、标准、产量及市场实际需求的前后对比,评价产品方案适应性及其对项目成败的影响。\n' + '2)再写一段,对项目产品前后对比情况进行分析;如产品方案与实际需求相差较大,必须分析原因。\n' + '3)随后固定写一句“项目产品方案对比表见表2-3。”\n' + '4)紧接着固定输出表题“表2-3 产品方案对比表”,并紧跟表格。\n' + '5)表格后不输出任何模板性注释(如“注.表中内容可根据项目实际需要进行增减”等套话),直接进入后评价结论。\n' + '6)最后单独写“后评价认为:。”,并基于前文与表格数据补全评价结论。\n' + '\n' + '【表格强制要求】\n' + '1)表格必须直接使用“要素管理”中的表格(element_tables/element_cells),不得自行新造表,不得用正文推断补表,不得改写成列表或段落。\n' + '2)优先使用要素管理中对应“产品方案对比表”的结构化表;若存在对应表格,须直出其表头、分组表头、行项目和单元格内容,不得改列名、不得替换成其他表。\n' + '3)表题固定为“表2-3 产品方案对比表”。\n' + '4)表格字段含义必须覆盖:序号、产品、可研报告规格、可研报告数量(万吨/年)、实际生产规格、实际生产数量(万吨/年)、备注。\n' + '5)表内行项目可包括但不限于:汽油、航空煤油、柴油、XX化工品、XX润滑油、XXX、轻油产品率,%、综合商品率,%、柴汽比;具体行项目按要素管理中的表格直出,可根据项目实际需要增减。\n' + '\n' + '【写作约束】\n' + '正文不得出现“【事实依据】”“【评价判断】”“【问题与建议】”等标签;不得编造市场需求、产品规格、产量、比率或效益判断;证据不足处写“待补充”。', + '2.1.2.2': '本节必须按“产品市场评价”目的组织内容,围绕项目产品市场需求、销售渠道、产品流向、市场风险及产品结构改善情况展开分析,通过对可研报告预期与实际生产情况进行对比,评价前期工作对产品市场的预测是否合理。\n' + '\n' + '必须严格按以下格式与顺序输出,不得缺项、不得改名、不得调换顺序:\n' + '1)第一段写可研报告对产品市场的判断依据,需说明市场供需现状、预测、供需平衡或标准升级等因素如何支撑项目产品需求。\n' + '2)第二段写可研报告对产品消化路径、厂内平衡、销售渠道、市场风险的预测,可结合汽油调和、统一销售、内部消化等实际口径展开。\n' + '3)第三段写实际生产情况,需说明实际产品调入去向、销售方式、销售渠道,并与可研预期进行对比。\n' + '4)随后固定写一句“前期工作预测的主要产品产量、流向与实际生产产品产量及流向对比见下表。”\n' + '5)紧接着固定输出表题“表2-4 ××年项目主要产品流向状况”,并紧跟表格;若为多年数据,应按每年分别列表;投产时间较短时可按季度或几个月列表。\n' + '6)表格后(即表格最后一行之后)单独一行固定输出“注:指装置投产到后评价时点,按每年列表,投产时间短的也可以是季或几个月。”——注必须在表格外面,严禁将“注”写入表格的任何单元格(包括备注列)中\n' + '7)最后单独写“后评价认为:……”总结性结论,必须明确判断产品流向、销售渠道、市场风险、产品结构改善等方面与可研预测的一致性或差异。\n' + '\n' + '【表格强制要求】\n' + '1)表格必须直接使用“要素管理”中的表格(element_tables/element_cells),不得自行新造表,不得用正文推断补表,不得改写成列表或段落。\n' + '2)优先使用要素管理中对应“表2-4 ××年项目主要产品流向状况”或“主要产品流向状况”的结构化表;若存在对应表格,须直出其表头、分组表头、行项目和单元格内容,不得改列名、不得替换成其他表。\n' + '3)表题固定为“表2-4 ××年项目主要产品流向状况”;其中“××年”应替换为要素管理表对应年份。若存在多个年份,应逐年分别输出对应表题和表格。\n' + '4)表格字段含义必须覆盖:产品名称、规格、实际产量、销量、产品实际流向、可研报告产品流向、备注。\n' + '5)表内行项目按要素管理中的表格直出,可包含“×××”“小计”等行;“小计/合计”行应放在表格末尾。\n' + '6)严禁将“注:”或注释性文字写入表格行或任何单元格中;所有注释必须在表格 Markdown 结束后另起一行输出。\n' + '\n' + '【写作约束】\n' + '正文不得出现“【事实依据】”“【评价判断】”“【问题与建议】”等标签;不得编造市场需求、销量、流向、销售渠道、市场风险或产品结构改善情况;证据不足处写“待补充”。', + '2.1.3': '本节必须使用“事实依据—评价判断—问题与建议”三段式结构。正文不得出现“【事实依据】”“【评价判断】”“【问题与建议】”标题标签。', + '2.1.3.1': '本节必须按“总加工方案评价”目的组织内容,通过实际情况与前期工作的对比,评价整体规模、单线规模、产品方案是否一致;如存在较大差异,必须分析变动原因及其合理性,并结合项目实际运行情况,对总加工方案的合理性、适应性作出评价。\n' + '\n' + '必须严格按以下要求输出:\n' + '1)先写前期工作确定的总体加工方案,包括整体规模、单线规模、主要产品方案等核心内容。\n' + '2)再写项目实际建设和运行情况,与前期工作逐项对比,说明一致项与差异项。\n' + '3)如整体规模、单线规模、产品方案存在较大变化,必须写明变化内容、形成原因及是否合理,不得只写结论不写依据。\n' + '4)最后单独给出总结性评价,明确判断总加工方案是否合理、是否适应实际运行需要,以及相关调整是否合理。\n' + '\n' + '【写作约束】\n' + '正文不得出现“【事实依据】”“【评价判断】”“【问题与建议】”等标签;可使用连续自然段表达;证据不足处写“待补充”,不得编造规模、负荷、产品方案或变动原因。', + '2.1.3.2': '本节必须按“建设规模及工艺技术方案评价”目的组织内容,通过实际情况与前期工作的对比,评价建设规模、装置规模、运行负荷与工艺技术方案是否与可研一致;如存在较大差异,必须分析原因及合理性,并结合实际运行情况,对工艺技术方案的先进性、适应性、可靠性和环保性能作出评价。\n' + '\n' + '必须严格按以下格式与顺序输出,不得缺项、不得改名、不得调换顺序:\n' + '1)先固定输出“1)烷基化装置工艺技术方案”作为小标题;小标题下先用一段完整文字写可研报告对不同烷基化工艺的比选过程、比选维度、拟选工艺及最终技术供应商;随后必须使用“(1)…(2)…(3)…”逐条列出可研推荐工艺的先进性与适应性,条目内容可覆盖辛烷值、选择性、酸耗、反应器与传热方式、安全环保、可靠性、运行费用等方面。\n' + '2)再固定输出“2)废酸再生单元工艺技术方案”作为小标题;小标题下先用一段完整文字写可研报告对不同废酸再生工艺的比选过程、比选对象、拟选工艺及最终技术供应商;随后必须使用“(1)…(2)…”逐条列出推荐工艺的主要特点,条目内容可覆盖流程简洁性、运行成本、尾气排放、二次污染、操作弹性、工艺适用性等方面。\n' + '3)最后直接单独写“后评价认为:……”,不得再输出“3)后评价结论”或其他总结性小标题。结论必须综合评价烷基化与废酸再生工艺选用是否合理适用、技术是否先进可靠、环保性能是否良好,以及前期工作确定的装置规模和原料条件是否在实际运行中得到验证。\n' + '\n' + '【结构硬约束】\n' + '本节正文仅允许出现上述两个编号小标题与最后“后评价认为:……”结论,不得再新增“建设规模及装置规模对比”“配套单元及附属单元”等其他编号小标题;如需说明规模、负荷、原料来源等内容,只能写入“后评价认为:……”段内,不得单列成新标题。\n' + '\n' + '【写作约束】\n' + '正文不得出现“【事实依据】”“【评价判断】”“【问题与建议】”等标签;不得编造装置规模、单元规模、运行负荷、技术供应商、工艺优缺点或调整方向;证据不足处写“待补充”。', + '2.1.3.3': '本节必须按“主要设备方案评价”目的组织内容,通过对可研报告、初步设计与实际运行情况的对比,评价主要设备的选型、材质、结构形式及优化调整是否合理适用,是否满足装置长周期平稳运行需要。\n' + '\n' + '必须严格按以下格式与顺序输出,不得缺项、不得改名、不得调换顺序:\n' + '1)固定输出“1)反应器”作为小标题,写明反应器是否采用技术专利商专利设备、设备材质、结构形式、专利内件或关键设计特点,并说明其在传质效率、混合效果、安全生产可靠性等方面的作用。\n' + '2)固定输出“2)冷剂压缩机”作为小标题,写明压缩机类型、国产化或供货来源、设计制造成熟度、运行平稳性及选型合理性。\n' + '3)固定输出“3)塔类”作为小标题,写明主要塔器的塔型、筒体材质,以及初步设计与可研在塔型或材质上的一致性与变化情况;如存在优化调整,需说明调整内容及合理性。\n' + '4)最后单独写“后评价认为:……”总结性结论,需明确判断主要设备的选型、材质与可研是否基本一致,深化设计过程中的尺寸或结构优化是否合理,以及是否满足装置长周期平稳运行需要。\n' + '\n' + '【写作约束】\n' + '正文不得出现“【事实依据】”“【评价判断】”“【问题与建议】”等标签;不得编造设备名称、材质、塔型、专利设备、运行效果或优化原因;证据不足处写“待补充”。', + '2.1.4': '本节必须按“厂址选择及外部条件评价”目的组织内容,对比最终选择厂址与前期各阶段是否一致;如有变化,必须分析变化原因。并结合项目实际运行情况,评价厂址及外部条件是否满足项目要求,判断推荐厂址方案的合理性。\n' + '\n' + '必须严格按以下格式与顺序输出,不得缺项、不得改名、不得调换顺序:\n' + '1)固定输出小标题“1)前期工作厂址方案对比”,并在该标题下写一段或多段连续文字,对比最终选择厂址与可研、前评估、初设及相关决策程序中的厂址方案是否一致;可结合前期决策程序合规性、初步设计评价、前评估意见采纳落实、厂址结论等证据展开,但不得跑题写成程序评价主节。若厂址方案发生变化,必须明确写出变化内容、原因及合理性。\n' + '2)固定输出小标题“2)外部条件满足性评价”,并在该标题下写一段或多段连续文字,结合项目实际运行情况评价厂址及外部条件是否满足项目要求;可按“厂址选择、总图与配套工程、环境保护设施、风险防控、港口、码头、铁路、公路、管道、供水、供电等方面”组织内容。实际有证据的写具体情况,无证据的对应项可单独写“待补充”,但不得在完整段落末尾附加“待补充”。\n' + '3)最后直接单独写“后评价认为:……”,不得再输出“3)后评价结论”或其他总结性小标题。结论必须明确判断厂址选择是否合理、前期工作与厂址方案是否一致、外部条件是否满足项目建设与运行需要;对缺少运行数据支撑的外部条件,可在结论中指出仍需补充完善。\n' + '\n' + '【写作约束】\n' + '除“1)前期工作厂址方案对比”“2)外部条件满足性评价”外,不得输出其他自拟小标题;正文不得出现“【事实依据】”“【评价判断】”“【问题与建议】”等标签;可使用连续自然段表达;不得编造厂址变化原因、交通运输条件、管道条件、供水供电条件或运行适应性结论;证据不足处写“待补充”。', + '2.1.5': '本节必须按“总图及系统配套工程评价”目的组织内容,评价项目总图布置、公用工程、储运工程及辅助设施配置是否合理,并区分新建部分与依托部分进行对比分析。\n' + '\n' + '必须严格按以下格式与顺序输出,不得缺项、不得改名、不得调换顺序:\n' + '1)固定输出“1)总图布置”作为小标题,写明可研报告中装置工艺部分、配套单元、储罐、变配电室、机柜室等占地情况,说明装置总占地面积、布置位置、是否新征用地,并评价总平面布置的合理性。\n' + '2)对总图、储运、公用工程及辅助工程等主要建设内容与可行性研究报告进行对比,说明是否有变化,如有较大变化应说明原因,并评价其变化的合理性。\n' + '3)在“2)系统工程配套”相关文字之后,必须输出“表2-5 总图、储运、公用工程及辅助工程对比”,并紧跟表格,用于呈现新建部分对比。\n' + '4)随后必须输出“表2-6 储运、公用工程及辅助工程依托对比”,并紧跟表格,用于呈现依托部分对比。\n' + '5)最后单独写“后评价认为:……”总结性结论,明确判断总图布置是否合理、公用工程及辅助设施配置是否满足需要、利用现有设施是否节约投资并取得较好效果。\n' + '\n' + '【表格强制要求】\n' + '1)表格必须直接使用“要素管理”中的表格(element_tables/element_cells),不得自行新造表,不得用正文推断补表,不得改写成列表或段落。\n' + '2)必须优先使用要素管理中对应“表2-5 总图、储运、公用工程及辅助工程对比”和“表2-6 ' + '储运、公用工程及辅助工程依托对比”的结构化表;若存在对应表格,须直出其表头、行项目和单元格内容,不得改列名、不得替换成其他表。\n' + '3)表2-5字段含义必须覆盖:序号、项目名称、单位、可研报告、初步设计、实际实施、备注。\n' + '4)表2-6字段含义必须覆盖:序号、依托项目名称、单位、可研报告、初步设计、实际实施、备注。\n' + '5)两张表均不得省略;如要素管理中未命中对应表格,也必须按模板字段输出占位表,不得只写正文不写表。\n' + '6)表后不输出任何模板性注释(如"注.表中内容可根据项目实际需要进行增减"等套话),仅保留要素管理中有实质内容的原始注释。\n' + '\n' + '【写作约束】\n' + '正文不得出现“【事实依据】”“【评价判断】”“【问题与建议】”等标签;不得编造占地面积、储罐数量、容积、公用工程能力、依托关系或投资效果;证据不足处写“待补充”。', + '2.1.6': '本节必须按“主要技术指标评价”目的组织内容,围绕可研报告、初步设计与实际运行的主要技术指标进行对比,评价初步设计相对可研的优化效果以及实际运行达成情况。\n' + '\n' + '必须严格按以下格式与顺序输出,不得缺项、不得改名、不得调换顺序:\n' + '1)先写一段引导语,明确“项目可研报告和初步设计主要设计指标对比见下表”,并说明通过对比可观察到主要技术指标变化趋势。\n' + '2)随后必须输出表题“表2-7 主要设计指标对比表”,并紧跟表格。\n' + '3)表格后可写一段分析,至少覆盖烷基化油RON、酸耗、能耗等关键指标的变化方向;如证据显示初步设计优于可研,应说明这是设计优化、细化和深化的结果。\n' + '【表格强制要求】\n' + '1)表格必须直接使用“要素管理”中的表格(element_tables/element_cells),不得自行新造表,不得用正文推断补表,不得改写成列表或段落。\n' + '2)必须优先使用要素管理中对应“表2-7 主要设计指标对比表”的结构化表;若存在对应表格,须直出其表头、行项目和单元格内容,不得改列名、不得替换成其他表。\n' + '3)表头字段必须覆盖:序号、指标名称、可研报告、初步设计、实际运行、备注。\n' + '4)行项目可包括但不限于:原油加工量、综合商品率、全厂柴汽比、全厂新鲜水耗、全厂平均电耗、能耗、其它、常减压蒸馏装置能耗;具体按要素管理表格直出,可酌情增减。\n' + '5)表后注释需保留要素管理中的原始注释;若未提取到注释,不输出任何模板性注释(如“注:根据项目的情况,可酌情增减指标”等套话),仅保留要素管理中有实质内容的原始注释。\n' + '6)表2-7不得省略;如要素管理中未命中对应表格,也必须按模板字段输出占位表。\n' + '\n' + '【写作约束】\n' + '正文不得出现“【事实依据】”“【评价判断】”“【问题与建议】”等标签;不得编造RON、酸耗、能耗及其他技术指标数据;证据不足处写“待补充”。', + '2.1.7': '本节必须按“风险分析评价”目的组织内容,围绕可研报告对技术、设备、施工、社会、原料及产品、安全、环保、消防、职业卫生等风险的识别、分析及应对措施进行评价,并结合实际执行情况判断风险防控措施是否有效。\n' + '\n' + '必须严格按以下格式与顺序输出,不得缺项、不得改名、不得调换顺序:\n' + '1)正文只允许两段,不得再拆分为“1)/2)/3)/4)”等子标题、不得再加编号小节。\n' + '2)第一段为风险分析主体段:综合写可研报告对技术、设备、施工、社会、原料及产品、安全、环保、消防、职业卫生等风险的识别与应对,说明工艺技术选择、设备选型、防腐与伴热等措施是否将风险降到可控范围。\n' + '3)第二段必须以“后评价认为:”开头,给出总结性结论,明确评价前期风险防控措施在后续设计、施工和生产运行中的贯彻执行情况,以及对建设实施和生产运行安全的保障效果。\n' + '4)除“后评价认为:”外,不得输出其他总结性标题(如“后评价结论”“风险防控措施评价”“生产运行风险评价”等)。\n' + '\n' + '【写作约束】\n' + '正文不得出现“【事实依据】”“【评价判断】”“【问题与建议】”等标签;不得编造工艺技术来源、风险结论、防腐措施、环保安全执行效果或事故情况;证据不足处写“待补充”;有实质内容时不得在段尾附加“待补充”。', + '2.2': '本节必须使用“事实依据—评价判断—问题与建议”三段式结构。正文不得出现“【事实依据】”“【评价判断】”“【问题与建议】”标题标签。并按顺序完整覆盖下级小节:2.2.1、2.2.2、2.2.3、2.2.4。', + '2.2.1': '必须严格按以下格式输出,不得缺项、不得改名:\n' + '1)首行固定输出标题:"2.2.1 编制单位资质及选择方式评价"。\n' + '2)标题后仅输出一段连续文字,围绕“可研报告编制单位资质及选择方式评价”展开,不得再拆分小标题或编号条目。\n' + '3)该段至少应包含:编制单位全称(及简称,如有)、单位沿革或背景、资质等级与资质类别、区域/行业熟悉度、承担项目前期工作的能力评价。\n' + '4)如有明确依据,可补充选择该单位的原因(如不可替代专有技术、区域经验、既有装置熟悉度等);结尾需给出“具备承担项目前期工作的能力”或同义评价。\n' + '5)证据不足处写“待补充”,不得编造单位资质、历史沿革、能力结论或选择依据。', + '2.2.2': '必须严格按以下格式输出,不得缺项、不得改名:\n' + '1)首行固定输出标题:"2.2.2 编制进度评价"。\n' + '2)标题后仅输出一段连续文字,简要说明可行性研究报告编制历程,至少包含关键时间节点(如启动、提交、审查、批复等)或阶段性进展信息。\n' + '3)该段必须明确判断编制进度是否满足项目需要及建设单位要求,并给出简要依据。\n' + '4)不得拆分为条目或小标题;证据不足处写“待补充”,不得编造时间线、进度结论或满足性判断。', + '2.2.3': '必须严格按以下格式输出,不得缺项、不得改名:\n' + '1)首行固定输出标题:"2.2.3 与专项评价的结合情况"。\n' + '2)标题后仅输出一段连续文字,说明可行性研究编制与专项评价结论的结合情况。\n' + '3)该段应至少体现专项评价结论在可研中的采纳、衔接或落实情况,并给出是否结合充分、是否支撑项目决策的判断。\n' + '4)不得拆分为条目或小标题;证据不足处写“待补充”,不得编造专项评价结论或采纳落实情况。', + '2.2.4': '必须严格按以下格式输出,不得缺项、不得改名:\n' + '1)首行固定输出标题:"2.2.4 可行性研究报告的质量评价"。\n' + '2)标题后仅输出一段连续文字,结合前期工作的成效,评价可行性研究报告的质量。\n' + '3)该段应至少体现可研报告在完整性、深度、可实施性、与批复/初设衔接性或风险识别等方面的质量判断,并说明其对后续建设实施的支撑作用。\n' + '4)不得拆分为条目或小标题;证据不足处写“待补充”,不得编造质量结论或前期工作成效。', + '2.3': '必须严格按以下格式输出,不得缺项、不得改名:\n' + '1)首行固定输出标题:"2.3 前评估工作评价"。\n' + '2)标题后按连续自然段输出,不得拆分小标题或编号条目。\n' + '3)第一段写前评估组织单位及资质情况,明确其是否满足承担项目可研评估的资质要求。\n' + '4)第二段写前评估会议(或评估过程)时间、组织形式及评审范围,至少覆盖原料及产品方案、工艺技术、节能节水、厂址、公用工程、环保安全、投资经济性等方面。\n' + '5)第三段写评估主要结论,明确项目可行性判断依据。\n' + '6)第四段写评估意见与建议数量及落实情况,说明可研编制单位是否逐项答复、修改并采纳。\n' + '7)最后单独写“后评价认为:……”总结性结论,评价前评估结论的客观性、公正性,以及其对后续设计、建设实施和投产运行的支撑作用。\n' + '8)证据不足处写“待补充”,不得编造评估单位资质、评审时间、意见数量或落实结论。', + '2.4': '本节必须使用“事实依据—评价判断—问题与建议”三段式结构。正文不得出现“【事实依据】”“【评价判断】”“【问题与建议】”标题标签。并按顺序完整覆盖下级小节:2.4.1、2.4.2、2.4.3、2.4.4。', + '2.4.1': '必须严格按以下格式输出,不得缺项、不得改名:\n' + '1)首行固定输出标题:"2.4.1 设计单位资质及选择方式评价"。\n' + '2)标题后仅输出一段连续文字,说明初步设计承担单位及其分工关系(如主体装置设计与配套公用工程设计分工)。\n' + '3)该段至少应包含:各设计单位全称(及简称,如有)、主要业务范围或专业特长、资质等级或资质类型、与项目匹配性评价。\n' + '4)结尾需明确给出资质与选择方式评价结论(如“均具有与承担项目相适应的设计资质,符合资质要求”或同义表述)。\n' + '5)不得拆分为条目或小标题;证据不足处写“待补充”,不得编造设计单位资质、分工内容或结论。', + '2.4.2': '必须严格按以下格式输出,不得缺项、不得改名:\n' + '1)首行固定输出标题:"2.4.2 初步设计进度评价"。\n' + '2)标题后仅输出一段连续文字,按时间顺序写明初步设计关键节点,至少包括开始时间、完成时间、审查完成时间、批复时间。\n' + '3)该段结尾必须明确判断“初步设计进度满足合同和项目总体部署的工期要求”或同义评价。\n' + '4)不得拆分为条目或小标题;证据不足处写“待补充”,不得编造时间节点、审查时间或进度评价结论。', + '2.4.3': '必须严格按以下格式输出,不得缺项、不得改名:\n' + '1)首行固定输出标题:"2.4.3 初步设计质量评价"。\n' + '2)标题后仅输出一段连续文字,结合前期工作成效、要素评价内容和采取的设计手段,评价初步设计质量。\n' + '3)该段至少应覆盖:设计内容完整性、设计深度、技术水平、与相关规范/规定的符合性,并明确是否满足要求。\n' + '4)可结合优化设计、细化深化、可实施性和运行验证等信息支撑评价结论。\n' + '5)不得拆分为条目或小标题;证据不足处写“待补充”,不得编造设计质量结论、设计手段或规范符合性。', + '2.4.4': '必须严格按以下格式输出,不得缺项、不得改名:\n' + '1)首行固定输出标题:"2.4.4 初步设计审查工作评价"(与 2.4.3 等同级小节相同:纯文本编号标题,不得使用“###”等 Markdown 标题语法)。\n' + '2)标题须单独成行,正文另起一行;禁止标题与首段正文粘在同一行(如「…评价2017年12月……」)。\n' + '3)标题后先输出一段连续文字,写明初步设计审查工作的时间、组织单位、审查专业分组、意见数量、设计单位整改落实情况,以及未采纳意见数量与说明上报情况(如有)。\n' + '4)随后单独写“后评价认为:……”总结性结论,评价审查意见的客观性、公正性、指导作用,并说明未采纳少量意见的合理性判断(如有证据)。\n' + '5)不得拆分为条目或小标题;证据不足处写“待补充”,不得编造审查时间、意见数量、采纳落实情况或结论。', + '2.5': '必须严格按以下格式输出,不得缺项、不得改名:\n' + '1)首行固定输出标题:"2.5 前期决策程序评价"。\n' + '2)标题后仅输出一段连续文字,从可行性研究、初步设计等环节说明项目是否严格按国家基本建设程序运作。\n' + '3)该段应明确写出可研批复前专项评价/专项报告(如环境评价、职业病危害预评价、安全预评价等)的完成与批复情况,并给出“符合项目建设程序规定”或同义结论。\n' + '4)不得拆分为条目或小标题;证据不足处写“待补充”,不得编造批复时间、专项名称或程序合规结论。', + '2.6': '必须严格按以下格式输出,不得缺项、不得改名:\n' + '1)首行固定输出标题:"2.6 前期工作评价结论"。\n' + '2)标题后仅输出两段连续文字,不得拆分为条目、小标题或“主要结论/主要问题/改进建议”三段结构。\n' + '3)第一段应围绕前期工作总体过程与关键决策展开,至少覆盖:项目任务背景(如国Ⅵ质量升级目标)、可研研究结论与方案选择、工艺技术比选与确定、支持性报告与可研批复情况、初步设计阶段方案优化及其合理性。\n' + '4)第二段应给出总体程序与合规性结论,明确前期工作是否执行国家和集团基本建设制度、是否按基本建设程序运作、依据是否充分、决策程序是否合规。\n' + '5)证据不足处写“待补充”,不得编造工艺路线、审批流程、批复结论或合规性判断。', + '3': '按章节标题自然组织内容,围绕证据包先事实后结论,缺失项写“待补充”。', + '3.1': '必须严格按以下格式与顺序输出,不得缺项、不得改名:\n' + '1)首行固定输出标题:"3.1 工程建设管理模式评价"。\n' + '2)随后固定输出小标题“3.1.1管理模式”,并在该小标题下用连续文字说明项目管理模式(如“业主+监理+EPC”)、项目经理负责制、组织架构设置、职责分工及工程建设到投产试运行衔接效果。\n' + '3)再固定输出小标题“3.1.2管理效果”,并在该小标题下先写一段总体管理成效描述,再按顺序固定输出“1)加强设计、施工、采购管理,确保工程质量”“2)项目建设安全管理全面受控”“3)进度控制存在一定不足”三项内容。\n' + '4)上述三项中,前两项重点写质量管理、安全/HSE管理、体系运行与控制成效;第3项需如实写进度偏差、滞后原因及对目标进度的影响,不得回避问题。\n' + '5)不得输出与本节无关的小标题;证据不足处写“待补充”,不得编造管理模式、事故指标、验收合格率、进度偏差或原因。', + '3.10': '必须严格按以下格式与顺序输出,不得缺项、不得改名:\n' + '1)首行固定输出标题:"3.10 工程竣工验收评价"。\n' + '2)随后必须按顺序固定输出以下六个小标题并分别展开:\n' + ' 1)消防验收\n' + ' 2)环境保护验收\n' + ' 3)安全设施验收\n' + ' 4)职业病防护设施验收\n' + ' 5)档案验收\n' + ' 6)竣工决算审计\n' + '3)每个小标题下应写明组织方式(政府/企业自行组织)、时间节点、验收或审计结论(是否通过/同意投入使用)。\n' + '4)六个小标题后,必须再写一段总体情况说明,概述专项验收完成情况、竣工验收是否已完成、未完成原因及计划安排(如有)。\n' + '5)最后单独写“后评价认为:……”总结性结论,明确对专项验收组织情况、竣工验收进度滞后问题及改进方向的评价。\n' + '6)不得新增无关小标题或调整上述顺序;证据不足处写“待补充”,不得编造验收时间、验收结论、审计结论、未完成原因或计划节点。', + '3.11': '必须严格按以下格式与顺序输出,不得缺项、不得改名:\n' + '1)首行固定输出标题:"3.11 建设实施评价结论"。\n' + '2)标题后先写一段总体结论,概述建设管理模式、任务完成情况、质量/HSE/投资/目标达成情况。\n' + '3)随后固定输出小标题“3.11.1经验和好的做法”,并按顺序固定输出三项:\n' + ' 1)选用适宜管理模式,保证项目顺利实施\n' + ' 2)加强项目过程控制,高质量完成项目实施\n' + ' 3)生产人员提前介入,实现工程建设和投产的有效衔接\n' + '4)再固定输出小标题“3.11.2存在问题”,并按顺序固定输出两项:\n' + ' 1)施工图设计不优化,存在浪费和安全隐患\n' + ' 2)主体装置和配套单元建设不同步,进度控制待加强\n' + '5)每个分项下均应写连续文字,既要有事实依据,也要有评价判断;不得空写标题。\n' + '6)不得新增无关小标题或调整上述顺序;证据不足处写“待补充”,不得编造合格率、工期差值、问题数量、HSE结论或投资达成情况。', + '3.2': '必须严格按以下格式与顺序输出,不得缺项、不得改名:\n' + '1)首行固定输出标题:"3.2 招投标评价"。\n' + '2)标题后先输出连续文字,说明项目招投标总体执行情况,至少覆盖EPC总承包、监理承包商、无损检测承包商、工程质量监督单位等确定方式(招标/非招标/谈判)及合规性。\n' + '3)对于非招标确定的单位,应写明资质能力、选择理由及上报审批手续履行情况。\n' + '4)随后必须输出表题“表3-1 项目承包单位情况”,并紧跟表格。\n' + '5)表后单独写“后评价认为:……”总结性结论,明确招投标程序是否符合法律法规及公司管理制度,并如实评价合同金额与批复概算关系等不足。\n' + '\n' + '【表格强制要求】\n' + '1)表格必须直接使用“要素管理”中的表格(element_tables/element_cells),不得自行新造表,不得用正文推断补表。\n' + '2)必须优先使用要素管理中对应“表3-1 项目承包单位情况”的结构化表;若存在对应表格,须直出其表头、行项目和单元格内容,不得改列名、不得替换成其他表。\n' + '3)表头字段必须覆盖:序号、单元名称、承包单位、(合同金额)(万元)、是/否招标、资质情况。\n' + '4)表3-1不得省略;如要素管理中未命中对应表格,也必须按模板字段输出占位表。\n' + '\n' + '【写作约束】\n' + '不得拆分为与本节无关的小标题;证据不足处写“待补充”;不得编造承包单位名称、合同金额、招标方式、资质情况或审批结论。', + '3.3': '本节必须使用“事实依据—评价判断—问题与建议”三段式结构。正文不得出现“【事实依据】”“【评价判断】”“【问题与建议】”标题标签。并按顺序完整覆盖下级小节:3.3.1、3.3.2、3.3.3、3.3.4。', + '3.3.1': '必须严格按以下格式输出,不得缺项、不得改名:\n' + '1)首行固定输出标题:"3.3.1 与批复后初步设计符合性评价"。\n' + '2)标题后仅输出一段连续文字,围绕施工图设计与批复后初步设计在范围、内容、规模方面的一致性进行评价。\n' + '3)该段应至少包含:施工图设计与初步设计一致性结论、建设单位技术人员在前期与施工图阶段的参与情况、设计联络/图纸审查/交底会审等过程控制对符合性的保障作用。\n' + '4)结尾需明确给出符合性判断(如“与初步设计一致,符合性较好”或同义表述)。\n' + '5)正文禁止出现任何形式的表格交叉引用:不得写“详见表…”“参见表…”“见表…”“如表…所示”等,亦不得出现表3-2、表3-3、表3-4、表2-7等表号;相关进度与变更数据只在3.3.2、3.3.4以表呈现。\n' + '6)不得拆分为条目或小标题;证据不足处写“待补充”,不得编造一致性结论、参与过程或审查工作。', + '3.3.2': '必须严格按以下格式与顺序输出,不得缺项、不得改名:\n' + '1)首行固定输出标题:"3.3.2 设计进度评价"。\n' + '2)标题后先输出一段连续文字,说明设计管理模式(如以设计为龙头的EPC总承包)、主体装置与公用工程设计分工、总承包单位对设计-采购-施工衔接的进度协调机制、分批次按节点出图情况以及总体进度满足性判断。\n' + '3)该段中应体现施工图设计时间区间(如有证据),并以“施工图设计进度情况见表3-2”或同义句引出表格。\n' + '4)随后必须输出表题“表3-2 施工图设计进度情况”,并紧跟表格。\n' + '\n' + '【表格强制要求】\n' + '1)表格必须直接使用“要素管理”中的表格(element_tables/element_cells),不得自行新造表,不得用正文推断补表。\n' + '2)必须优先使用要素管理中对应“表3-2 施工图设计进度情况”的结构化表;若存在对应表格,须直出其表头、行项目和单元格内容,不得改列名、不得替换成其他表。\n' + '3)表头字段必须覆盖:序号、项目、设计单位、合同期限、实际执行情况、备注。\n' + '4)表3-2不得省略;如要素管理中未命中对应表格,也必须按模板字段输出占位表。\n' + '\n' + '【写作约束】\n' + '不得拆分为与本节无关的小标题;证据不足处写“待补充”;不得编造设计分工、进度节点、合同期限或执行情况。', + '3.3.3': '必须严格按以下格式与顺序输出,不得缺项、不得改名:\n' + '1)首行固定输出标题:"3.3.3 施工图设计水平及质量评价"。\n' + '2)标题后先写一段连续文字,说明建设单位设计管理与技术人员参与情况(前期方案比选、优化、总图与公用工程依托、设计联络、图纸审查、交底与会审等),并评价设计单位对可研评估与基础设计审查意见的采纳情况及设计满足性。\n' + '3)再写一段连续文字,客观描述后评价现场发现的设计问题与不足(如管廊/框架载荷考虑不足、设计保守、投资浪费、通行受阻或碰头等安全隐患),不得回避负面问题。\n' + '4)最后单独写“后评价认为:……”总结性结论,明确设计水平和质量总体判断,既要写成效,也要写不足及其影响。\n' + '5)本节为纯文字评价,禁止输出任何表题行或表号清单(不得以独立行/列表重复“表3-2…”“表3-3…”“表3-4…”“表2-7…”等);禁止表格交叉引用。\n' + '6)不得拆分为额外条目或无关小标题;证据不足处写“待补充”,不得编造现场问题、隐患结论或投资浪费判断。', + '3.3.4': '必须严格按以下格式与顺序输出,不得缺项、不得改名:\n' + '1)首行固定输出标题:"3.3.4 施工图设计变更管理评价"。\n' + '2)标题后先输出一段连续文字,说明设计变更管理机制与控制措施,至少覆盖EPC统筹协调、初设审查意见采纳、施工图会审、三维配管设计应用、现场设计代表配合等对减少变更的作用。\n' + '3)随后写一段连续文字,明确设计变更总量、变更费用、费用占比,并以“见表3-3~表3-5”或同义表达引出表格。\n' + '4)随后必须依次输出:\n' + ' - 表3-3 施工图设计变更情况(全厂性项目)\n' + ' - 表3-4 施工图设计变更情况(单装置项目)\n' + ' - 表3-5 影响投资或工期重(较)大设计变更及原因分析\n' + ' 每个表题后必须紧跟对应表格。\n' + '5)最后单独写“后评价认为:……”总结性结论,明确施工图设计变更管理总体评价,并对是否存在因设计原因导致的重大投资/工期影响作出判断。\n' + '\n' + '【表格强制要求】\n' + '1)三张表必须直接使用“要素管理”中的表格(element_tables/element_cells),不得自行新造表,不得用正文推断补表。\n' + '2)必须优先使用要素管理中对应“表3-3”“表3-4”“表3-5”的结构化表;若存在对应表格,须直出其表头、行项目和单元格内容,不得改列名、不得替换成其他表。\n' + '3)表3-3字段必须覆盖:序号、单元名称、设计变更(份数)、设计变更金额(万元)、备注(含合计行)。\n' + '4)表3-4字段必须覆盖:序号、专业、设计变更(份数)、设计变更金额(万元)、备注(含合计行)。\n' + '5)表3-5字段必须覆盖:序号、单元名称、变更内容、金额(万元)、原因、备注。\n' + '6)三张表均不得省略;如要素管理中未命中对应表格,也必须按模板字段输出占位表。\n' + '\n' + '【写作约束】\n' + '不得拆分为与本节无关的小标题;证据不足处写“待补充”;不得编造变更份数、费用金额、占比、重大变更结论或原因分析。', + '3.4': '本节必须使用“事实依据—评价判断—问题与建议”三段式结构。正文不得出现“【事实依据】”“【评价判断】”“【问题与建议】”标题标签。并按顺序完整覆盖下级小节:3.4.1、3.4.2。', + '3.4.1': '必须严格按以下格式与顺序输出,不得缺项、不得改名:\n' + '1)首行固定输出标题:"3.4.1 施工准备评价"。\n' + '2)标题后先写一段总述,说明项目开工前施工准备工作总体情况(如招投标组织、承包商选择、项目部成立、人员配备、施工物资准备等)。\n' + '3)随后必须按顺序固定输出以下六个小标题并分别展开:\n' + ' 1)项目部成立、人员配备\n' + ' 2)完成总体部署并获得批复\n' + ' 3)开工报告批准\n' + ' 4)“四通一平”工作完成\n' + ' 5)EPC总承包管理组织成立\n' + ' 6)资金已准备到位\n' + '4)每个小标题下均应写对应事实与完成情况,涉及时间节点或批复信息时应按证据给出。\n' + '5)末尾再写一段总结性文字,明确项目是否满足工程施工准备基本条件。\n' + '6)本节为施工准备文字评价,禁止出现任何表格交叉引用与表题清单:不得写“详见表…”“参见表…”“见表…”“如表…所示”,不得单独成行输出“表3-2/表3-3/表3-4/表2-7”等表号或表题。\n' + '7)不得新增无关小标题或调整上述顺序;证据不足处写“待补充”,不得编造批复时间、资金到位情况、组织机构或准备完成结论。', + '3.4.2': '必须严格按以下格式与顺序输出,不得缺项、不得改名:\n' + '1)首行固定输出标题:"3.4.2 施工计划的执行情况"。\n' + '2)标题后先写一段连续文字,简要说明工程建设进度控制目标与实际执行情况。\n' + '3)随后必须输出表题“表3-6 施工进度情况”,并紧跟表格。\n' + '4)表后再写一段简要评价:如工程进度有较大变化,必须分析原因;如无明显偏差,应明确说明总体执行情况。\n' + '\n' + '【表格强制要求】\n' + '1)表格必须直接使用“要素管理”中的表格(element_tables/element_cells),不得自行新造表,不得用正文推断补表。\n' + '2)必须优先使用要素管理中对应“表3-6 施工进度情况”的结构化表;若存在对应表格,须直出其表头、行项目和单元格内容,不得改列名、不得替换成其他表。\n' + '3)表头字段必须覆盖:序号、项目、施工单位、合同期限、实际执行情况、备注。\n' + '4)表3-6不得省略;如要素管理中未命中对应表格,也必须按模板字段输出占位表。\n' + '\n' + '【写作约束】\n' + '不得拆分为与本节无关的小标题;证据不足处写“待补充”;不得编造施工单位、合同期限、执行进度或偏差原因。', + '3.5': '必须严格按以下格式与顺序输出,不得缺项、不得改名:\n' + '1)首行固定输出标题:"3.5 采购工作评价"。\n' + '2)随后固定输出小标题“1)采购工作情况介绍”,并在该小标题下写连续文字,至少包括:采购分工(甲供/乙供或建设单位采购与EPC采购边界)、采购合同数量与金额、供应商审查与选商把关、入厂检验与质量控制、采购进度控制措施及关键设备采购完成节点。\n' + '3)在采购进度或关键设备采购描述后,必须以“主要设备及大型机组的采购计划见表3-7”或同义句引出表格。\n' + '4)随后必须输出表题“表3-7 采购工作情况”,并紧跟表格。\n' + '5)表格后必须保留注释:\n' + ' 注:1.采购工作评价指甲供主要材料、设备的评价;\n' + ' 2.应招标数量指合同数量,应招标金额指合同金额。\n' + '6)最后单独写“后评价认为:……”总结性结论,明确采购质量控制效果、交货进度与对总体建设部署的支撑情况。\n' + '\n' + '【表格强制要求】\n' + '1)表格必须直接使用“要素管理”中的表格(element_tables/element_cells),不得自行新造表,不得用正文推断补表。\n' + '2)必须优先使用要素管理中对应“表3-7 采购工作情况”的结构化表;若存在对应表格,须直出其表头、行项目和单元格内容,不得改列名、不得替换成其他表。\n' + '3)表头字段必须覆盖:序号、物资(类别)名称、采购方式、制造商、供货商、金额(万元)、未招标原因;并包含金额分项(单位、数量、单价、小计)以及“应招标数量/招标数量率”“应招标金额/招标金额率”等统计行。\n' + '4)表3-7不得省略;如要素管理中未命中对应表格,也必须按模板字段输出占位表。\n' + '\n' + '【写作约束】\n' + '不得拆分为与本节无关的小标题;证据不足处写“待补充”;不得编造采购合同数量、金额、完成节点、检验合格率、招标率或未招标原因。', + '3.6': '必须严格按以下格式输出,不得缺项、不得改名:\n' + '1)首行固定输出标题:"3.6 工程监理评价"。\n' + '2)标题后先输出一段连续文字,说明监理单位名称、资质等级、承担能力、监理组织配置(如总监、专业监理、资料员等)及人员配备是否满足合同与现场需求。\n' + '3)再输出一段连续文字,评价监理单位在进度、质量、安全、投资控制等方面的措施与执行效果;可结合监理通知单、暂停令、问题整改闭环、安全事故率等事实进行支撑。\n' + '4)结尾需明确给出总体评价结论,说明监理工作对建设目标实现的作用。\n' + '5)不得拆分为条目或无关小标题;证据不足处写“待补充”,不得编造监理人数、通知单数量、暂停令数量、事故率或控制成效。', + '3.7': '必须严格按以下格式与顺序输出,不得缺项、不得改名:\n' + '1)首行固定输出标题:"3.7 工程质量评价"。\n' + '2)随后固定输出小标题“1)工程质量责任主体评价”,并按顺序展开:\n' + ' (1)明确责任主体\n' + ' (2)确保质量保证体系建立及有效运行\n' + ' (3)制定质量计划\n' + ' (4)质量保证措施\n' + ' (5)监理承包商、质量检测单位的质量管理\n' + '3)再固定输出小标题“2)工程质量管控过程评价”,并按顺序展开设备安装、管道安装、建筑工程、钢结构/防腐保温/电气仪表、“三查四定和中间交接验收”等过程质量管控情况,需体现质监点、监督记录、通知书及整改闭环情况。\n' + '4)再固定输出小标题“3)工程质量验收结果评价”,明确质量控制点、单位工程、分项工程合格率及投料试车结果等验收结论。\n' + '5)最后单独写“后评价认为:……”总结性结论,明确工程质量责任落实、体系运行、过程整改与质量目标达成情况。\n' + '\n' + '【写作约束】\n' + '不得新增无关小标题或调整上述顺序;证据不足处写“待补充”;不得编造合格率、通知书数量、问题项数量、质监记录份数或质量事故结论。', + '3.8': '必须严格按以下格式与顺序输出,不得缺项、不得改名:\n' + '1)首行固定输出标题:"3.8 HSE管理评价"。\n' + '2)随后固定输出小标题“1)建立HSE管理组织,强化责任落实”,并在该小标题下结合证据写具体做法与执行效果。\n' + '3)再固定输出小标题“2)落实HSE管理制度,强化日常安全监督检查”,并在该小标题下结合证据写具体做法与执行效果。\n' + '4)再固定输出小标题“3)加强承包商管理,提升安全管理意识”,并在该小标题下结合证据写具体做法与执行效果。\n' + '5)再固定输出小标题“4)强化HSE过程管控,确保全过程受控”,并在该小标题下结合证据写具体做法与执行效果。\n' + '6)各小标题下应结合证据写具体做法与执行效果,可覆盖HSE组织设置、岗位责任、属地管理、制度修订、监督检查、教育培训、JSA活动、作业许可、隐患排查、违约处理等内容。\n' + '7)结尾需单独写一段总体成效结论,明确是否实现HSE目标(如“零事故、零伤害、零污染”)及安全工时等结果(如有证据)。\n' + '\n' + '【写作约束】\n' + '四个固定小节标题的输出体例须与上一节“3.7 工程质量评价”中“1)工程质量责任主体评价”“2)工程质量管控过程评价”“3)工程质量验收结果评价”等小节标题保持一致:单独成行;行首两个全角空格缩进;纯文本编号体,与正文同字号、不得整行加粗;禁止使用“##”“###”以及成对的“**…**”等 Markdown 标题或强调语法把小节标题做成副标题样式。\n' + '不得新增无关小标题或调整上述顺序;证据不足处写“待补充”,不得编造培训次数/人数、安全工时、事故结论或HSE目标达成情况。', + '3.9': '必须严格按以下格式输出,不得缺项、不得改名:\n' + '1)首行固定输出标题:"3.9 三查四定及中间交接"。\n' + '2)标题后仅输出一段连续文字,说明“三查四定”和中间交接验收监督检查情况。\n' + '3)该段应至少包含:组织时间(如有)、牵头单位与参与专业、问题发现数量、承包商整改销项机制、监理与业主联合复核确认情况。\n' + '4)不得拆分为条目或无关小标题;证据不足处写“待补充”,不得编造问题数量、整改闭环结论或参与单位。', + '4': '按章节标题自然组织内容,围绕证据包先事实后结论,缺失项写“待补充”。', + '4.1': '必须严格按以下格式与顺序输出,不得缺项、不得改名:\n' + '1)首行固定输出标题:"4.1 生产准备评价"。\n' + '2)随后必须按顺序固定输出以下五个小标题并分别展开:\n' + ' 4.1.1投产管理组织机构成立、生产人员配备情况\n' + ' 4.1.2生产人员培训\n' + ' 4.1.3岗位规章制度、操作规程、事故应急预案情况\n' + ' 4.1.4原料、燃料、动力的供给情况\n' + ' 4.1.5环保、消防、安全及职业卫生等方面的批准文件\n' + '3)在4.1.1中应写明生产准备组织设置、人员配置、职责分工及是否满足投产需求;可体现“三查四定”、中交验收、开工准备、HSE管理、试生产审批等职责。\n' + '4)在4.1.2中应按阶段写培训安排、外部学习/现场培训、考核与持证上岗情况,并给出能力达标结论。\n' + '5)在4.1.3中应写明试车方案、操作规程、制度体系和应急预案的建立与执行情况,明确其对试车与运行的支撑作用。\n' + '6)在4.1.4中应分“原料/辅助材料供给”和“公用工程供应”描述,明确供给来源、保障措施及满足性判断。\n' + '7)在4.1.5中应列出环保、消防、安全、职业卫生等批准/审查文件及关键信息(如文号、时间),证据不足处写“待补充”。\n' + '8)最后单独写“后评价认为:……”总结性结论,明确生产准备条件是否充分可靠、是否为投产试运行奠定基础。\n' + '9)不得新增无关小标题或调整上述顺序;不得编造人员数量、培训批次、文号时间、物料来源、公用工程供给或准备充分性结论。', + '4.2': '必须严格按以下格式与顺序输出,不得缺项、不得改名:\n' + '1)首行固定输出标题:"4.2 联合试运与试生产情况评价"。\n' + '2)随后必须按顺序固定输出以下三个小标题并分别展开:\n' + ' 4.2.1总体试车安排\n' + ' 4.2.2投料试车情况\n' + ' 4.2.3出现的问题及解决措施\n' + '3)在4.2.1中应写明试车组织机制、联动试车安排、投料组织逻辑、物料供给方式和产品质量控制要求。\n' + '4)在4.2.2中应按阶段描述试车过程(如三查四定/单机与中交、联动试车、投料试车),并分别写烷基化装置与废酸再生单元的关键时间节点和结果。\n' + '5)在4.2.3中应按问题-措施闭环方式描述问题,至少覆盖问题现象、原因或适配不足、整改措施与整改结果;不得只写问题不写处置。\n' + '6)最后单独写“后评价认为:……”总结性结论,明确联合试运与试生产组织成效、一次投产情况、问题责任归属及改进判断。\n' + '7)不得新增无关小标题或调整上述顺序;证据不足处写“待补充”;不得编造试车时间节点、产品合格结论、问题数量、责任归属或整改效果。', + '4.3': '本节必须使用“事实依据—评价判断—问题与建议”三段式结构。正文不得出现“【事实依据】”“【评价判断】”“【问题与建议】”标题标签。并按顺序完整覆盖下级小节:4.3.1、4.3.2、4.3.3、4.3.4、4.3.5、4.3.6。', + '4.3.1': '必须严格按以下格式输出,不得缺项、不得改名:\n' + '1)首行固定输出标题:"4.3.1 原料供应评价"。\n' + '2)标题后先写一段连续文字,说明装置原料来源(如上游MTBE、加氢裂化、轻烃回收等)及在上游正常生产情况下的供应稳定性与对装置平稳运行的支撑作用。\n' + '3)再写一段连续文字,分析原料组成与产量控制关系(如碳四烯烃与异丁烷配比、异丁烷补充来源、关键控制因素),并结合装置负荷率与未满负荷原因进行评价。\n' + '4)不得拆分为条目或无关小标题;证据不足处写“待补充”,不得编造来源装置、负荷率、原料组成变化或原因判断。', + '4.3.2': '必须严格按以下格式与顺序输出,不得缺项、不得改名:\n' + '1)首行固定输出标题:"4.3.2 生产运行总体情况评价"。\n' + '2)随后必须按顺序固定输出以下三个小标题并分别展开:\n' + ' 1)运行负荷逐步提高\n' + ' 2)精细管理,合理扩大原料来源\n' + ' 3)优化运行,装置能耗逐步降低\n' + '3)在“1)运行负荷逐步提高”中应写明投产以来装置运行天数、产量、负荷率及关键产品质量指标达成情况(如有证据)。\n' + '4)在“2)精细管理,合理扩大原料来源”中应写明原料受限背景、工艺优化措施(如引入替代原料)、试用过程中的问题与调整、以及对产量和创效能力的影响。\n' + '5)在“3)优化运行,装置能耗逐步降低”中应写明能耗优化措施(如酸烃比、异丁烷循环量、分馏压力、压缩机运行优化等)及优化前后能耗变化结果。\n' + '6)随后必须输出“后评价认为:……”总结性结论,明确装置运行负荷、原料优化与能耗改善的总体评价。\n' + '7)最后必须输出表题“表4-1 投产以来运行周期统计表”,并紧跟表格。\n' + '\n' + '【表格强制要求】\n' + '1)表格必须直接使用“要素管理”中的表格(element_tables/element_cells),不得自行新造表,不得用正文推断补表。\n' + '2)必须优先使用要素管理中对应“表4-1 投产以来运行周期统计表”的结构化表;若存在对应表格,须直出其表头、行项目和单元格内容,不得改列名、不得替换成其他表。\n' + '3)表头字段必须覆盖:序号、装置名称、本周期开工日期、本周期运行时间(天)、本周期非计划停工次数(次)、本周期非计划停工时数(时)、原因简要分析。\n' + '4)表4-1不得省略;如要素管理中未命中对应表格,也必须按模板字段输出占位表。\n' + '\n' + '【写作约束】\n' + '不得新增无关小标题或调整上述顺序;证据不足处写“待补充”;不得编造负荷率、产量、原料加工量、增产量、能耗值或优化效果。', + '4.3.3': '必须严格按以下格式与顺序输出,不得缺项、不得改名:\n' + '1)首行固定输出标题:"4.3.3 达标评价"。\n' + '2)随后必须按顺序固定输出以下三个小标题并分别展开:\n' + ' 1) 标定工作评价\n' + ' 2) 主要装置达标评价\n' + ' 3) 全厂达标评价\n' + '3)在“1) 标定工作评价”中应写明标定组织方式、方案审定流程、参与部门、标定时间及是否按规范执行。\n' + '4)在“2) 主要装置达标评价”中应基于设计值、标定值、实际值进行对比评价;如存在较大变化,必须分析原因。\n' + '5)在“2) 主要装置达标评价”内容后,必须输出表题“表4-2 烷基化装置运行分析表(考核时间:×年×月×日)”,并紧跟表格。\n' + '6)在“3) 全厂达标评价”中应针对全厂性项目,使用全年实际运行数据与设计值对比,评价全厂达标情况;如差距较大,应分析原因。\n' + '\n' + '【表格强制要求】\n' + '1)表格必须直接使用“要素管理”中的表格(element_tables/element_cells),不得自行新造表,不得用正文推断补表。\n' + '2)必须优先使用要素管理中对应“表4-2 烷基化装置运行分析表(考核时间:×年×月×日)”的结构化表;若存在对应表格,须直出其表头、行项目和单元格内容,不得改列名、不得替换成其他表。\n' + '3)表头字段必须覆盖:序号、项目、单位、设计值、标定值、实际值、备注。\n' + '4)表内行项目应覆盖生产能力、主要原材料、主要产品产量、公用工程消耗(水/蒸汽/电/燃料气)、综合能耗、现金加工成本、单位毛利等(按要素管理表格直出,可增减)。\n' + '5)表后不输出任何模板性注释(如“注:表中内容可根据项目不同进行增减”等套话),仅保留要素管理中有实质内容的原始注释。\n' + '6)本节要求的表4-2不得省略;如要素管理中未命中对应表格,也必须按模板字段输出占位表。\n' + '7)表4-2 在全节仅允许出现一次:必须在「2) 主要装置达标评价」对应文字之后输出表题与表格;不得在「3) 全厂达标评价」中再次输出表4-2,也不得重复输出同一张烷基化装置运行分析数据表。\n' + '\n' + '【写作约束】\n' + '不得新增无关小标题或调整上述顺序;证据不足处写“待补充”;不得编造标定时间、达标结论、对比差异或原因分析。', + '4.3.4': '必须严格按以下格式与顺序输出,不得缺项、不得改名:\n' + '1)首行固定输出标题:"4.3.4 生产工艺技术评价"。\n' + '2)随后必须按顺序固定输出两个小标题并分别展开:\n' + ' 1)烷基化工艺技术\n' + ' 2)废酸再生工艺技术\n' + '3)在“1)烷基化工艺技术”中应写明工艺路线、关键设备/关键机理、运行特征(如选择性、酸耗、可靠性、操作灵活性)及后评价对技术适配性的判断;可包含与同类工艺的横向比较(有证据时)。\n' + '4)在“2)废酸再生工艺技术”中应写明工艺来源与核心指标(如硫回收率、热回收、尾气排放、产品硫酸浓度等),并如实评价其与常规工艺相比的优缺点(如流程复杂度、运行复杂度、投资水平等)。\n' + '5)两部分均需体现“技术优势 + 局限性/代价”的平衡评价,不得只写优点不写不足。\n' + '6)不得新增无关小标题或调整上述顺序;证据不足处写“待补充”;不得编造工艺来源、技术参数、比较结论或投资高低判断。', + '4.3.5': '必须严格按以下格式与顺序输出,不得缺项、不得改名:\n' + '1)首行固定输出标题:"4.3.5 设备运行评价"。\n' + '2)标题后先写一段总体设备运行情况,至少包括设备总量及分类(如反应器、塔类、容器、换热、压缩机、空冷器、机泵等)和投产至后评价时点的总体运行结论。\n' + '3)随后按顺序固定输出两个小标题并分别展开:\n' + ' (1)静设备方面\n' + ' (2)动设备方面\n' + '4)在“(1)静设备方面”中应基于运行参数与设计参数对比,评价塔器/容器等静设备工况与长周期运行适应性。\n' + '5)在“(2)动设备方面”中应写明压缩机、机泵、阀门等关键动设备运行情况,以及开工初期问题、整改措施与整改结果(如材质更换、新增泵、安装方式调整、阀杆修复等)。\n' + '6)结尾应给出总体设备运行评价(如运行平稳、调节正常、满足长周期/安全运行要求等),若存在未完全满足项应如实写明。\n' + '7)不得新增无关小标题或调整上述顺序;证据不足处写“待补充”;不得编造设备数量、故障问题、整改措施或运行结论。', + '4.3.6': '必须严格按以下格式输出,不得缺项、不得改名:\n' + '1)首行固定输出标题:"4.3.6 公用工程及辅助设施合理性评价"。\n' + '2)标题后先写一段连续文字,结合实际生产及标定结果,验证公用工程及辅助设施是否满足生产运行需要。\n' + '3)若存在问题,必须再写一段连续文字,明确问题表现、影响及整改措施或优化建议;若无明显问题,应明确说明总体满足性结论。\n' + '4)不得拆分为无关小标题或条目;证据不足处写“待补充”,不得编造标定结果、公用工程能力、设施问题或整改建议。', + '4.4': '必须严格按以下格式与顺序输出,不得缺项、不得改名:\n' + '1)首行固定输出标题:"4.4 生产运行评价结论"。\n' + '2)标题后先写两段总体结论:\n' + ' - 第一段聚焦生产准备、试车投产、设备与公用工程保障、达标结果及预期目标实现情况;\n' + ' - 第二段聚焦投产以来原料供应、负荷变化、能耗变化、优化运行与创效提升情况。\n' + '3)随后固定输出小标题“1)主要经验”,并按顺序固定输出两项:\n' + ' (1)生产运行精细管理、合理优化,实现提质增效\n' + ' (2)结合生产实际情况优化运行,有效降低装置能耗\n' + '4)再固定输出小标题“2)存在问题”,并按顺序固定输出两项:\n' + ' (1)考核标定不规范\n' + ' (2)装置运行酸耗偏高\n' + '5)各分项下均应写连续文字,明确事实、原因和影响,不得只列标题。\n' + '6)不得新增无关小标题或调整上述顺序;证据不足处写“待补充”;不得编造负荷率、能耗值、酸耗值、标定时间或同类对标结论。', + '5': '必须严格按以下格式输出,不得缺项、不得改名:\n' + '1)首行固定输出章标题:"5 投资与经济效益评价"(仅此一行;与第1章「1 项目概况」同一编号体例,勿写「第5章」;勿使用 Markdown「#」前缀)。\n' + '2)标题后空一行,再写一段连续文字(2~4句),概括本章将依次从主要经济指标实现程度、投资与执行情况、经济效益、不确定性分析到本章结论展开;仅可使用证据包,缺失处写“待补充”。\n' + '3)不得输出 5.1~5.5 各小节正文或表格(各小节由后续章节单独生成)。\n' + '4)不得新增其他小标题或条目。', + '5.1': '必须严格按以下格式与顺序输出,不得缺项、不得改名:\n' + '1)首行固定输出标题:"5.1 主要经济指标实现程度评价"。\n' + '2)标题后先写一段连续文字,明确后评价时点(如2020年12月31日)下项目效益测算结果与可研报告对比情况。\n' + '3)该段应至少写出税后财务内部收益率(后评价值与可研值)及差值判断,并给出“项目效益目标实现程度较好”或同义结论。\n' + '4)段末需以“见表5-1”或同义表达引出表格。\n' + '5)随后必须输出表题“表5-1 主要经济指标对比表”,并紧跟表格。\n' + '\n' + '【表格强制要求】\n' + '1)表格必须直接使用“要素管理”中的表格(element_tables/element_cells),不得自行新造表,不得用正文推断补表。\n' + '2)必须优先使用要素管理中对应“表5-1 主要经济指标对比表”的结构化表;若存在对应表格,须直出其表头、行项目和单元格内容,不得改列名、不得替换成其他表。\n' + '3)表头字段必须覆盖:序号、项目名称、单位、可研值、后评价值、差值、比例(%)、备注,并保留“(1)/(2)/(2)-(1)”等分栏信息(如要素管理中存在)。\n' + '4)行项目应覆盖:项目报批总投资(含建设投资、建设期利息、铺底流动资金)、年均营业收入、年均总成本费用、年均流转税金及附加、年均利润总额、年均所得税金、年均税后利润、项目投资内部收益率、项目投资财务净现值、项目静态投资回收期。\n' + '5)表5-1不得省略;如要素管理中未命中对应表格,也必须按模板字段输出占位表。\n' + '\n' + '【写作约束】\n' + '不得新增无关小标题;证据不足处写“待补充”;不得编造IRR、净现值、回收期、投资金额、差值或比例。', + '5.2': '本节必须使用“事实依据—评价判断—问题与建议”三段式结构。正文不得出现“【事实依据】”“【评价判断】”“【问题与建议】”标题标签。并按顺序完整覆盖下级小节:5.2.1、5.2.2、5.2.3、5.2.4。', + '5.2.1': '必须严格按以下格式与顺序输出,不得缺项、不得改名:\n' + '1)首行固定输出标题:"5.2.1 投资控制及变动原因分析"。\n' + '2)标题后先写一段连续文字,说明可研批复投资、初设批复概算、竣工决算审计投资三者对比,明确与可研/初设相比的增减额及比例,并给出投资控制评价判断。\n' + '3)再写一段连续文字,说明投资变动主要原因分析及调概推进情况(如有证据)。\n' + '4)随后必须依次输出:\n' + ' - 表5-2 投资变动情况表(单位:万元、万美元)\n' + ' - 表5-3 工程费用变动情况表(万元、万美元)\n' + ' 每个表题后必须紧跟对应表格。\n' + '5)表后应补充工程费用及其他费用、建设期利息等变动原因分析(有证据时),并说明超支/结余的主要因素。\n' + '\n' + '【表格强制要求】\n' + '1)两张表必须直接使用“要素管理”中的表格(element_tables/element_cells),不得自行新造表,不得用正文推断补表。\n' + '2)必须优先使用要素管理中对应“表5-2”“表5-3”的结构化表;若存在对应表格,须直出其表头、行项目和单元格内容,不得改列名、不得替换成其他表。\n' + '3)表5-2应保留投资估算/初设概算/竣工决算及“决算与估算比较”“决算与概算比较”的差额、比例分栏结构,并保留批准单位/批准文号等信息行(如有)。\n' + '4)表5-3应保留工程费用分解结构(设备购置费、安装工程费、建筑工程费等)及比较分栏结构,并保留“其中:外汇”等信息行(如有)。\n' + '5)表5-2、表5-3均不得省略;如要素管理中未命中对应表格,也必须按模板字段输出占位表。\n' + '\n' + '【写作约束】\n' + '不得新增无关小标题;证据不足处写“待补充”;不得编造批复文号、投资金额、增减比例、超支结余原因或调概进展。', + '5.2.2': '必须严格按以下格式输出,不得缺项、不得改名:\n' + '1)首行固定输出标题:"5.2.2 投资水平分析"。\n' + '2)标题后仅输出一段连续文字,围绕单位加工能力投资(或单位投资)开展对标分析。\n' + '3)该段至少应包含:本项目烷基化装置单位加工能力投资值、与可比装置(如吉林石化、锦州石化、兰州石化等)对比结果、偏高/偏低判断;如有废酸单元投资数据,也应给出单位投资值及高低判断。\n' + '4)不得拆分为条目或小标题;证据不足处写“待补充”,不得编造单位投资数值、对标对象或高低结论。', + '5.2.3': '必须严格按以下格式输出,不得缺项、不得改名:\n' + '1)首行固定输出标题:"5.2.3 资金来源及到位评价"。\n' + '2)标题后仅输出一段连续文字,说明可研阶段资金来源结构(如企业自筹与债务资金比例)与实际建设期资金来源差异。\n' + '3)该段应写明投资计划下达金额与时间范围、资金到位及时性、资金使用合规性,并明确是否存在转移、侵占、挪用或损失浪费问题。\n' + '4)不得拆分为条目或小标题;证据不足处写“待补充”,不得编造资金比例、计划金额、到位情况或合规性结论。', + '5.2.4': '必须严格按以下格式输出,不得缺项、不得改名:\n' + '1)首行固定输出标题:"5.2.4 投资控制的经验和教训"。\n' + '2)标题后仅输出一段连续文字,结合初步设计概算批复值与竣工决算审计值的差异,归纳投资控制方面的经验和教训。\n' + '3)该段应至少体现:超概金额与比例、主要影响因素(如漏项、单方造价偏低、主材费偏低、工程量增加、材料设备涨价、设计变更、现场签证等)以及对前期工作和投资控制能力的反思。\n' + '4)结尾需提出改进方向(如梳理流程、强化费用控制、合理确定并有效控制投资)。\n' + '5)不得拆分为条目或小标题;证据不足处写“待补充”,不得编造超概金额比例、影响因素或改进结论。', + '5.3': '本节必须使用“事实依据—评价判断—问题与建议”三段式结构。正文不得出现“【事实依据】”“【评价判断】”“【问题与建议】”标题标签。并按顺序完整覆盖下级小节:5.3.1、5.3.2。', + '5.3.1': '必须严格按以下格式与顺序输出,不得缺项、不得改名:\n' + '1)首行固定输出标题:"5.3.1 项目投产以来生产经营及效益状况"。\n' + '2)标题后先写投产以来各年度(如2019、2020)的运行天数、加工量、主要产品产量、平均负荷率、产品率、税后利润等核心经营数据对比描述。\n' + '3)随后必须输出表题“表5-4 生产经营及效益情况对比表”,并紧跟表格。\n' + '4)表后必须按顺序固定输出三段分析:\n' + ' 1)营业收入变动分析\n' + ' 2)总成本费用变动分析\n' + ' 3)税后利润变动分析\n' + '5)三段分析中应体现与可研值的对比关系,说明增减幅度及主要原因。\n' + '\n' + '【表格强制要求】\n' + '1)表格必须直接使用“要素管理”中的表格(element_tables/element_cells),不得自行新造表,不得用正文推断补表。\n' + '2)必须优先使用要素管理中对应“表5-4 生产经营及效益情况对比表”的结构化表;若存在对应表格,须直出其表头、行项目和单元格内容,不得改列名、不得替换成其他表。\n' + '3)表头字段应覆盖项目、单位、分年度可研报告值/实际值/增减(%)对比结构。\n' + '4)行项目应覆盖运行情况、主要原料价格、主要产品价格、主要产品年产量、主要产品年销售量、主要原料和公用工程消耗量、主要经济指标(营业收入、成本费用、利润总额、税后利润)等(按要素管理表格直出,可增减)。\n' + '5)表5-4不得省略;如要素管理中未命中对应表格,也必须按模板字段输出占位表。\n' + '\n' + '【写作约束】\n' + '不得新增无关小标题或调整上述顺序;证据不足处写“待补充”;不得编造运行天数、负荷率、收入成本、利润或变动原因。', + '5.3.2': '必须严格按以下格式与顺序输出,不得缺项、不得改名:\n' + '1)首行固定输出标题:"5.3.2 项目经济效益后评价"。\n' + '2)随后必须按顺序固定输出以下六个小标题并分别展开:\n' + ' 1)计算范围及评价方法\n' + ' 2)主要生产经营指标\n' + ' 3)总成本费用\n' + ' 4)营业收入\n' + ' 5)销售税金及附加\n' + ' 6)盈利能力分析\n' + '3)在“1)计算范围及评价方法”中应写明与可研一致的评价口径、增量法、评价期、基准收益率、实绩与预测口径、价格体系分段假设等关键参数。\n' + '4)在“2)主要生产经营指标”中必须以“见表5-5”或同义表达引出并输出“表5-5 主要生产经营指标”。\n' + '5)在“3)总成本费用”中应按分项说明测算口径(原料产品价格、工资福利、折旧、修理费、其他制造费、摊销、安全生产费、安保基金、财务费用、营业费用等)。\n' + '6)在“4)营业收入”中应说明收入计算逻辑、价格选取与贴水假设,并给出评价期关键结果(有证据时)。\n' + '7)在“5)销售税金及附加”中应说明消费税、增值税、城建税、教育费附加等计税依据与税率口径。\n' + '8)在“6)盈利能力分析”中应至少包含IRR、NPV、投资回收期与可研对比结论,并以“填写表5-6”或同义表达引出并输出“表5-6 不同因素变化对项目内部收益率的影响”。\n' + '9)在“6)盈利能力分析”末尾需补一句:后评价财务报表见附表3~附表8(如有证据),不得遗漏;该句仅为文字引用,本节不得输出附表3~附表8任一张的 Markdown 表格(附表由全书「附表」区汇总)。\n' + '\n' + '【表格强制要求】\n' + '1)表5-5、表5-6必须直接使用“要素管理”中的表格(element_tables/element_cells),不得自行新造表,不得用正文推断补表。\n' + '2)必须优先使用要素管理中对应“表5-5”“表5-6”的结构化表;若存在对应表格,须直出其表头、行项目和单元格内容,不得改列名、不得替换成其他表。\n' + '3)表5-5应保留“后评价时点前实际值/后评价时点后预测值”的分年列结构;表5-6应保留“财务内部收益率、变化幅度、占比”列结构。\n' + '4)表5-5、表5-6均不得省略;如要素管理中未命中对应表格,也必须按模板字段输出占位表。\n' + '\n' + '【写作约束】\n' + '不得新增无关小标题或调整上述顺序;证据不足处写“待补充”;不得编造价格体系、税率、负荷率、IRR/NPV/回收期、附表引用或敏感性分析结论;除表5-5、表5-6外不得输出任何「附表」类 Markdown 表(尤其禁止附表8)。', + '5.4': '必须严格按以下格式与顺序输出,不得缺项、不得改名:\n' + '1)首行固定输出标题:"5.4 不确定性分析"。\n' + '2)标题后先写一段连续文字,说明后评价时影响项目财务效益的主要不确定因素(至少包括生产负荷、产品价格、原材料价格等)。\n' + '3)再写一段连续文字,说明定量分析方法:以项目投资财务内部收益率达到基准收益率为约束,计算相关因素的临界点(%)或临界值。\n' + '4)随后必须输出表题“表5-7 内部收益率为基准收益率时不确定因素临界点或临界值”,并紧跟表格。\n' + '5)表后再写一段连续文字,开展定性分析:判断不确定因素可能变化趋势、潜在风险及风险应对对策(减少风险/规避风险)。\n' + '\n' + '【表格强制要求】\n' + '1)表格必须直接使用“要素管理”中的表格(element_tables/element_cells),不得自行新造表,不得用正文推断补表。\n' + '2)必须优先使用要素管理中对应“表5-7 内部收益率为基准收益率时不确定因素临界点或临界值”的结构化表;若存在对应表格,须直出其表头、行项目和单元格内容,不得改列名、不得替换成其他表。\n' + '3)表头字段必须覆盖:序号、项目、单位、数值、备注;行项目至少覆盖生产负荷、产品价格、主要原材料价格、其它。\n' + '4)表5-7不得省略;如要素管理中未命中对应表格,也必须按模板字段输出占位表。\n' + '\n' + '【写作约束】\n' + '不得新增无关小标题;证据不足处写“待补充”;不得编造临界点、临界值、变化趋势或风险对策。', + '5.5': '必须严格按以下格式输出,不得缺项、不得改名:\n' + '1)首行固定输出标题:"5.5 投资与经济效益评价结论"。\n' + '2)标题后先写一段连续文字,聚焦投资控制结论:至少包含竣工决算与可研估算/初设概算的差异金额与比例、投资控制是否有效、以及与同类项目单位工程费投资水平对比判断。\n' + '3)再写一段连续文字,聚焦经济效益结论:明确评价方法与可研一致,并给出税后财务内部收益率与可研值对比、差值及是否实现预期效益目标。\n' + '4)最后写一段综合结论,说明在加工负荷、价格体系变化条件下盈利能力表现,并分析主要原因(如收率变化、原料与产品价格关系等)。\n' + '5)不得拆分为条目或无关小标题;证据不足处写“待补充”,不得编造投资差异、IRR数值、同类对标结论或原因分析。', + '6': '按章节标题自然组织内容,围绕证据包先事实后结论,缺失项写“待补充”。', + '6.1': '本节必须使用“事实依据—评价判断—问题与建议”三段式结构。正文不得出现“【事实依据】”“【评价判断】”“【问题与建议】”标题标签。并按顺序完整覆盖下级小节:6.1.1、6.1.2、6.1.3、6.1.4、6.1.5。', + '6.1.1': '必须严格按以下格式与顺序输出,不得缺项、不得改名:\n' + '1)首行固定输出标题:"6.1.1 环境影响评价"。\n' + '2)标题后先写1段总体评价,内容聚焦:是否落实国家/地方环保法律法规与环评批复要求、环保设施“三同时”落实情况、环保设施是否满足生产需要、污染物总量控制是否在地方指标以内。\n' + '3)固定输出小节标题:"6.1.1.1 环保措施",并在该小节下按顺序固定输出以下5个条目标题与正文:\n' + ' 1)废水处理措施\n' + ' 2)废气处理措施\n' + ' 3)固体废物处理措施\n' + ' 4)噪声处理措施\n' + ' 5)环境风险防范措施\n' + ' 每个条目均需结合项目实际设施、处理路径、依托系统或管理制度进行描述。\n' + '4)固定输出小节标题:"6.1.1.2 效果及影响",先写1句监测说明,再按顺序固定输出以下3个条目标题与正文:\n' + ' 1)废气监测结果\n' + ' 2)废水监测结果\n' + ' 3)噪声监测结果\n' + ' 各条目需说明监测结论与执行标准符合性(如有标准名称可写明)。\n' + '5)末尾必须以“后评价认为:”起1段结论,综合评价环保措施落实情况、废气废水噪声达标情况、固体废物处置与环境总体影响。\n' + '【写作约束】\n' + '不得新增无关小标题;不得改变上述标题与编号顺序;不得把固定条目合并或拆分;证据不足处写“待补充”,不得编造监测结果、达标结论或法规标准。', + '6.1.2': '必须严格按以下格式与顺序输出,不得缺项、不得改名:\n' + '1)首行固定输出标题:"6.1.2 安全影响评价"。\n' + '2)标题后先写1段总体评价,内容聚焦:是否符合相关规划要求,设计与施工是否执行国家法律法规、规章及技术标准,配套安全设施是否落实“三同时”(同时设计、同时施工、同时投入生产和使用)。\n' + '3)固定输出小节标题:"6.1.2.1 安全风险因素",写项目生产运行中的主要风险因素,至少覆盖:易燃易爆介质风险、火灾爆炸风险、腐蚀危害、噪声、机械伤害等。\n' + '4)固定输出小节标题:"6.1.2.2 防范措施",先写1句引导语“为将安全风险降到最低,采取的主要防范措施:”,再按顺序固定输出3个条目:\n' + ' 1)设计、施工过程中依法合规执行要求;\n' + ' 2)落实安全评价及安全设施设计专篇措施并落实“三同时”;\n' + ' 3)报警、联锁、快速切断等检测控制仪表与联锁设施投用情况。\n' + '5)固定输出小节标题:"6.1.2.3 效果及影响",写建设期与投产后的安全运行效果,至少包含:是否发生安全事故/人身伤害事故、运行平稳性、对周边单位与居民影响。\n' + '6)末尾必须以“后评价认为:”起1段结论,综合评价危险因素可控性、措施有效性及项目整体安全影响。\n' + '【写作约束】\n' + '不得新增无关小标题;不得改变上述标题与编号顺序;不得把固定条目合并或拆分;证据不足处写“待补充”,不得编造事故情况、联锁投用率或安全结论。', + '6.1.3': '必须严格按以下格式输出,不得缺项、不得改名:\n' + '1)首行固定输出标题:"6.1.3 科技进步影响"。\n' + '2)标题后写第1段,简要评价项目对科技进步的推动作用,至少覆盖:\n' + ' 对技术开发、技术创新、技术改造、技术引进的作用;\n' + ' 对高新技术产业化、商品化和国际化的作用;\n' + ' 对国家和地方科技进步的推动作用。\n' + '3)紧接着写第2段,从项目应用的引进技术、国产技术、自有技术等方面,总结项目对企业、公司、国家科技发展和技术推广的影响。\n' + '【写作约束】\n' + '全文仅保留标题+两段正文,不得新增小标题或条目;不得改写为多级结构;证据不足处写“待补充”,不得编造技术来源、推广范围或科技成效。', + '6.1.4': '必须严格按以下格式输出,不得缺项、不得改名:\n' + '1)首行固定输出标题:"6.1.4 项目社会影响评价"。\n' + '2)标题后写第1段,围绕项目社会效益进行评价,至少覆盖:\n' + ' 项目与国家及地方环保优先、油品质量提升政策导向的符合性;\n' + ' 对汽油品质改善、高标号汽油产量提升、区域机动车排放降低与空气质量改善的作用;\n' + ' 对当地及国家经济发展、社会稳定的促进作用。\n' + '3)紧接着写第2段,围绕项目实施后的成效,至少覆盖:\n' + ' 全厂汽油质量是否满足国VI B相关指标要求;\n' + ' 成品油质量升级任务完成情况;\n' + ' 对城市机动车尾气污染改善的环境效益判断。\n' + '【写作约束】\n' + '全文仅保留标题+两段正文,不得新增小标题或条目;不得改写为多级结构;证据不足处写“待补充”,不得编造油品指标、政策符合性或社会环境效益结论。', + '6.1.5': '必须严格按以下格式输出,不得缺项、不得改名:\n' + '1)首行固定输出标题:"6.1.5 项目影响评价结论"。\n' + '2)标题后写第1段,聚焦风险敏感性与管理要求,至少覆盖:\n' + ' 炼化项目高温高压、易燃易爆/有毒有害介质等风险特征;\n' + ' 项目区位敏感性与事故/环保事件潜在社会关注度;\n' + ' 企业责任意识、危机意识及落实法律法规、主管部门要求、“三同时”和“环保优先”理念的情况。\n' + '3)紧接着写第2段,聚焦综合影响结论,至少覆盖:\n' + ' 项目选址与总体规划符合性、清洁生产符合性、污染物达标与总量控制、事故防范与环境风险可接受性;\n' + ' 项目在选址、总图、工艺技术、设备设施方面对国家法规和技术标准的符合性;\n' + ' 项目投产后对油品质量升级(如国VI标准达成)与区域清洁油品供应、机动车尾气污染改善的环境效益。\n' + '【写作约束】\n' + '全文仅保留标题+两段正文,不得新增小标题或条目;不得改写为多级结构;证据不足处写“待补充”,不得编造合规性、风险可接受性、油品标准达成或环境效益结论。', + '6.2': '本节必须使用“事实依据—评价判断—问题与建议”三段式结构。正文不得出现“【事实依据】”“【评价判断】”“【问题与建议】”标题标签。并按顺序完整覆盖下级小节:6.2.1、6.2.2、6.2.3、6.2.4。', + '6.2.1': '必须严格按以下格式输出,不得缺项、不得改名:\n' + '1)首行固定输出标题:"6.2.1 资源分析"。\n' + '2)标题后仅输出1段连续正文,围绕资源保障与持续性进行分析,至少覆盖:\n' + ' 原料来源构成(如醚后碳四、饱和液化气等);\n' + ' 投产初期负荷与一次加工负荷关联影响;\n' + ' 通过上游优化及原料补充(如醚后碳五)后的负荷提升情况;\n' + ' 后评价时点运行负荷状态及后续原料稳定供应判断;\n' + ' 项目发展持续性结论。\n' + '【写作约束】\n' + '全文仅保留标题+1段正文,不得新增小标题、条目或表格;不得改写为多级结构;证据不足处写“待补充”,不得编造原料来源、负荷数据或持续性结论。', + '6.2.2': '必须严格按以下格式输出,不得缺项、不得改名:\n' + '1)首行固定输出标题:"6.2.2 产品分析"。\n' + '2)标题后写第1段,说明清洁汽油发展趋势与政策标准背景,至少覆盖:低硫、低烯烃、低芳烃、低苯、低蒸汽压方向,以及国VI A/国VI B实施时间与要求。\n' + '3)紧接着写第2段,分析烷基化油的产品属性与价值,至少覆盖:低芳烃/低苯/低烯烃/低硫/低蒸汽压等品质优势、对国VI ' + 'B汽油升级作用、高辛烷值带来的高标号汽油增产价值、以及液化气向高附加值产品转化对资源利用率和经济效益的提升。\n' + '4)最后写第3段,分析区域市场与持续性,至少覆盖:项目区位与市场需求特征、汽油消费占比、就近销售与成本优势、产品销路保障及项目持续性判断。\n' + '【写作约束】\n' + '全文仅保留标题+3段正文,不得新增小标题、条目或表格;不得改写为多级结构;证据不足处写“待补充”,不得编造实施时间、产品指标、市场占比或持续性结论。', + '6.2.3': '必须严格按以下格式与顺序输出,不得缺项、不得改名:\n' + '1)首行固定输出标题:"6.2.3 主要技术及经济指标对比"。\n' + '2)标题后先写1段引导性分析文字,说明需通过项目(装置)规模、能耗、投资及成本、工艺技术及设备等与国内外同类项目对比,判断项目运行是否达到同类水平及是否具有竞争优势,并明确写出“主要技术经济指标对比表见表6-1”。\n' + '3)紧接着必须输出表题:"表6-1 装置技术经济指标对比表"。\n' + '4)表6-1必须输出为Markdown表格(优先使用要素管理同名表),至少包含以下列:项目名称、技术来源、规模(万吨/年)、物耗(Wt)%、能耗(kgEo/t)、产品质量、产品收率(Wt)%、排名。\n' + '5)表后不输出任何模板性注释(如"注:可根据项目具体情况增减指标"等套话),仅保留要素管理中有实质内容的原始注释。\n' + '\n' + '【表格强制要求】\n' + '1)表格必须直接使用“要素管理”中的表格(element_tables/element_cells),不得自行新造表,不得用正文推断补表。\n' + '2)必须优先使用要素管理中对应“表6-1 装置技术经济指标对比表”的结构化表;若存在对应表格,须直出其表头、行项目和单元格内容,不得改列名、不得替换成其他表。\n' + '3)表头字段须覆盖合同正文所列列名;若要素管理表结构略有差异,以要素管理为准直出,不得省略整张表。\n' + '4)表6-1不得省略;如要素管理中未命中对应表格,也必须按上述列名输出占位表(单元格可填“待补充”)。\n' + '\n' + '【写作约束】\n' + '不得新增无关小标题;不得省略表题、表格或表注;证据不足处写“待补充”;不得编造对标对象数据、排名结果或竞争优势结论。', + '6.2.4': '必须严格按以下格式输出,不得缺项、不得改名:\n' + '1)首行固定输出标题:"6.2.4 项目持续性评价结论"。\n' + '2)标题后写第1段,聚焦市场与产销持续性,至少覆盖:\n' + ' 项目投产后油品质量升级与国家成品油质量升级任务完成情况;\n' + ' 原料来源(上游装置供给)保障情况;\n' + ' 成品油销售组织与区域流向(如省内占比)及销路保障;\n' + ' 对占领和巩固当地市场的优势判断。\n' + '3)紧接着写第2段,聚焦政策与运行持续性,至少覆盖:\n' + ' 项目与国家绿色发展、可持续发展产业政策符合性;\n' + ' 投产后通过优化措施在加工能力、产品质量、能耗、物耗等指标上的达成情况;\n' + ' 项目发展持续性总体结论。\n' + '【写作约束】\n' + '全文仅保留标题+2段正文,不得新增小标题、条目或表格;不得改写为多级结构;证据不足处写“待补充”,不得编造省内销售占比、指标达成值或持续性结论。', + '7': '本章为综合评价结论,必须基于【前序章节正文(第1~6章)】归纳提炼,是对前六章内容的总结升华,不得脱离前文另起论述。按章节标题组织,先事实后结论;结论与数据须与前文一致;缺失项写“待补充”。', + '7.1': '本节是对第1~6章的归纳性评价,必须基于【前序章节正文】撰写。须使用“事实依据—评价判断—问题与建议”三段式结构。正文不得出现“【事实依据】”“【评价判断】”“【问题与建议】”标题标签。并按顺序完整覆盖下级小节:7.1.1、7.1.2。', + '7.1.1': '必须严格按以下格式与顺序输出,不得缺项、不得改名:\n' + '1)首行固定输出标题:"7.1.1 总体评价结论"。\n' + '2)标题后先写1段总体结论,聚焦项目定位与总体成效,至少覆盖:\n' + ' 作为国VI汽油质量升级配套项目的作用;\n' + ' 以醚后碳四等液化气为原料生产高辛烷值调和组分;\n' + ' 对解决汽油池质量问题、完成国家质量升级任务、提升高标号汽油产量与附加值、经济效益表现的综合判断。\n' + '3)随后按顺序固定输出以下5个条目标题与正文:\n' + ' 1)前期工作规范有效,及时完成质量升级\n' + ' 2)建设实施管理规范,满足总体部署要求\n' + ' 3)投产一次成功,主要技术指标达到设计要求\n' + ' 4)竣工决算投资超批复,投资回报实现预期目标\n' + ' 5)产品适应市场需求,发展持续性较好\n' + '4)上述5个条目内容至少分别覆盖:\n' + ' 前期报批与立项及投产节点;\n' + ' 管理模式(业主+监理+EPC)及建设/HSE/进度成效;\n' + ' 生产准备、一次开车成功、长周期运行与关键技术指标达标;\n' + ' 竣工决算与批复差异及投资收益率对可研预期比较;\n' + ' 市场适应性、国VI需求匹配、运行安全与持续性判断。\n' + '【写作约束】\n' + '须优先依据【前序章节正文(第1~6章)】归纳各条目,不得与前面章节结论矛盾;不得新增无关小标题;不得改变上述标题与编号顺序;不得合并或拆分5个固定条目;证据不足处写“待补充”,不得编造时间节点、百分比数据或收益指标。', + '7.1.2': '必须严格按以下格式与顺序输出,不得缺项、不得改名:\n' + '1)首行固定输出标题:"7.1.2 成功度评价"。\n' + '2)标题后先写1段方法说明:说明采用要素成功度评价方法,基于前文各章评价要点形成项目综合评价结论;可写明要素评分与汇总见下表。\n' + '3)紧接着输出成功度等级划分说明,按以下顺序逐条给出4级标准:\n' + ' 优秀项目(评分≥9分):项目的各项指标都已全面实现或超过,且在经济上取得了较大效益和影响。\n' + ' 良好项目(9分>评分≥8分):项目的大部分指标都已全面实现,且在经济上取得了预期效益和影响。\n' + ' 中等项目(8分>评分≥6分):项目实现了原定的部分目标,且在经济上无明显效益和影响。\n' + ' 较差项目(评分<6分):项目实现的目标非常有限,且在经济上没有正效益和影响。\n' + '4)在上述等级标准之后,固定输出表题:"表7-1 项目综合评价评分表",并紧跟 Markdown 表格(列名、行键与要素管理一致)。\n' + '5)表格之后用1~2段自然段给出本项目成功度综合评价结论(可与前文结论一致、作归纳),证据不足处写“待补充”。\n' + '\n' + '【表格强制要求】\n' + '1)表格必须直接使用“要素管理”中的表格(element_tables/element_cells),不得自行新造表,不得用正文推断补表。\n' + '2)必须优先使用要素管理中对应“表7-1 项目综合评价评分表”的结构化表;若存在对应表格,须直出其表头、行项目和单元格内容,不得改列名、不得替换成其他表。\n' + '3)表7-1不得省略;如要素管理中未命中对应表格,也必须按细则列结构输出占位表(单元格可填“待补充”)。\n' + '\n' + '【写作约束】\n' + '须优先依据【前序章节正文(第1~6章)】归纳成功度结论;不得新增无关小标题;不得省略四级标准、表题与表格;表内数值以要素管理直出为准;证据不足处写“待补充”;不得在正文编造与表内数据相矛盾的评分、综合得分或等级结论。', + '7.2': '必须严格按以下格式与顺序输出,不得缺项、不得改名:\n' + '1)首行固定输出标题:"7.2 主要经验"。\n' + '2)随后固定输出主条目:"1)生产运行精细化管理,实现提质增效",并在其下按顺序固定输出2个分项:\n' + ' (1)合理扩大原料来源,提高装置运行负荷\n' + ' (2)优化生产运行,有效降低装置能耗\n' + '3)再固定输出主条目:"2)采用适宜的建设管理模式,有效实现建设目标",并在其下按顺序固定输出4个分项:\n' + ' (1)结合企业实际情况和项目特点采用“业主+监理+EPC”管理模式并完成建设任务\n' + ' (2)发挥EPC统筹管理作用,合理交叉设计采购施工并保障工期目标\n' + ' (3)加强建设过程管理,实现质量、安全、环保等事故控制目标\n' + ' (4)加强属地管理与“三查四定”问题整改,保障建设与投产衔接\n' + '4)各分项正文需结合项目已发生事实进行归纳,允许使用关键数据(如负荷、能耗、工期、问题项数)支撑经验结论。\n' + '【写作约束】\n' + '须优先依据【前序章节正文(第1~6章)】提炼主要经验,与前文已述事实一致;不得新增无关小标题;不得改变上述标题与分项顺序;不得合并或拆分固定分项;证据不足处写“待补充”,不得编造负荷、能耗、工期或整改数量数据。', + '7.3': '必须严格按以下格式与顺序输出,不得缺项、不得改名:\n' + '1)首行固定输出标题:"7.3 问题与建议"。\n' + '2)固定输出小节标题:"7.3.1主要问题"。\n' + '3)在“7.3.1主要问题”下按顺序固定输出以下3个主条目及分项:\n' + ' 1)施工图设计不优化,存在浪费和安全隐患\n' + ' 2)投资和进度控制存在不足,建设实施管理待加强\n' + ' (1)主体装置和配套单元建设不同步,进度控制存在不足\n' + ' (2)项目实际投资超批复,未能实现项目投资控制预期目标\n' + ' (3)招标管理待进一步规范\n' + ' 3)考核标定不规范,运行有待进一步优化\n' + ' (1)考核标定不及时,部分指标未按设计值标定\n' + ' (2)装置运行酸耗偏高,生产待进一步优化\n' + '4)随后固定输出小节标题:"7.3.2对策建议"。\n' + '5)在“7.3.2对策建议”下按顺序固定输出以下4个主条目及分项:\n' + ' 1)总结推广先进经验,提高集团公司同类项目运行水平\n' + ' 2)合理利用副产品开发新产品,进一步实现提质增效\n' + ' 3)上下游装置一体优化运行,实现降本增效\n' + ' (1)合理配置催化原料、提高负荷、优化运行\n' + ' (2)研究降低MTBE/轻汽油醚化装置二甲醚生成量\n' + ' (3)加强烷基化原料预处理,降低酸耗与再生负荷\n' + ' (4)优先利用低温热水伴热\n' + ' 4)尽快完成竣工验收,进入正式生产阶段\n' + '6)各条目与分项正文必须结合项目事实与数据展开,不得只写标题。\n' + '【写作约束】\n' + '须优先依据【前序章节正文(第1~6章)】归纳问题与建议,与前文已述问题一致;不得新增无关小标题;不得改变上述标题、条目与分项顺序;不得合并或拆分固定条目;证据不足处写“待补充”,不得编造时间节点、投资金额比例、酸耗或整改结论。'} + +DEFAULT_SECTION_OUTPUT_CONTRACT = '按章节标题自然组织内容,围绕证据包先事实后结论,缺失项写“待补充”。' diff --git a/prompts/report_generation/template_prompt_rules.py b/prompts/report_generation/template_prompt_rules.py new file mode 100644 index 0000000..5ce8e3e --- /dev/null +++ b/prompts/report_generation/template_prompt_rules.py @@ -0,0 +1,150 @@ +"""Default section prompt and example variables used by report templates.""" + +DEFAULT_SECTION_PROMPT = "按《炼油化工建设项目后评价报告编制细则(修订)》对应章节要求撰写,缺失信息标注“待补充”,禁止编造。" + +SECTION_PROMPT_RULES: list[tuple[str, str]] = [ + ( + "1.1 项目基本情况", + "严格按以下字段顺序输出:项目名称、建设单位、建设地点、建设类型、起止时间、建设内容、建设投资、占地面积。" + "所有事实均须来自证据材料,缺失写“待补充”,禁止编造,禁止复刻示例原文。", + ), + ( + "1.2 项目决策要点", + "按“项目背景(1)2)3)+ 预期目标(规模/质量/效益)”撰写。" + "证据依据用于内部校验,不在报告正文显示“【证据依据:...】”标记。" + "背景每条先写 2~4 句书面语再视需要附表;表格须用 Markdown,禁止粘贴未对齐的原始文本表。" + "预期目标须从证据归纳:证据中已有产能、产量(万吨/年)、辛烷值、国VI、收入、利润等时不得三条目标全写待补充。" + "改扩建项目应补充原装置问题与改造动因。", + ), + ( + "1.3 项目实施情况", + "说明实际建成内容及与批复方案的差异,给出关键里程碑时间线(立项、批复、开工、中交、投产等)。", + ), + ( + "1.4 项目运行情况", + "说明投产以来装置运行负荷、产量及主要财务运行情况(营业收入、利润等),并与预期目标对照。", + ), + ( + "2.1.1 资源与原料评价", + "必须与《模版.doc》中本节结构一致:先简述可研原料来源与实际一致性;" + "再给出「原料数量及组成对比表」(列含:序号、原料名称、规格、可研/初设/实际各自的「数量(万吨)」「占比(%)」、备注,须有合计行);" + "再给出运行负荷与加工量等对比叙述;" + "再给出「原料性质对比表(醚后碳四)」(列:序号、名称、可研报告、初步设计、实际生产、备注;行至少含密度、硫含量、氮含量等,可按项目增删);" + "最后给出组成/性质变化分析及后评价判断。" + "表格一律使用 Markdown 表头+分隔行,禁止粘贴未对齐的纯文本表。" + "本节第一张主表表题须固定为「表1 原料数量及组成对比表」(与章节输出合同及要素管理默认表题一致,与正文节内表序一致);" + "禁止用安评/专篇中「表2.6-1 原料选择加氢工艺技术对比」等同号异题表替代上述两张模版主表;" + "本节只保留模版主表,不输出附录与“非模版主表”字样。" + "数据来自证据包,缺失填待补充,禁止编造。", + ), + ( + "2.1.2.1 产品方案评价", + "按“事实依据—评价判断—问题与建议”组织内容,但不要在正文中显示" + "“【事实依据】”“【评价判断】”“【问题与建议】”这三个标题标签;" + "以自然段或编号表达即可。缺失信息写“待补充”,禁止编造。", + ), + ("2", "对照前期工作细则,评价可研、前评估、初设及决策程序的合规性、完整性与合理性。"), + ("3", "对照建设实施细则,评价建设管理、招投标、设计、采购、施工、监理、质量、HSE与竣工验收。"), + ("4", "对照生产运行细则,评价生产准备、联合试运、达标情况、工艺技术、设备与辅助系统运行效果。"), + ("5", "对照投资与经济效益细则,评价投资控制、资金到位、经营效益、后评价测算及不确定性分析。"), + ("6", "对照影响与持续性细则,评价环境、安全、科技、社会影响及资源、产品、技术经济竞争力持续性。"), + ("7", "给出综合评价结论、成功度评价、主要经验、问题与建议,结论须与前文证据保持一致。"), +] + + +SECTION_EXAMPLE_RULES: list[tuple[str, str]] = [ + ( + "1", + "项目名称:宁夏石化分公司16万吨/年烷基化装置建设项目独立后评价。" + "建设内容包括16万吨/年烷基化单元、1.5万吨/年废酸再生单元及配套公用工程。" + "批复可研估算22812万元,批复初设概算25079万元,竣工决算32486万元。" + "烷基化单元2018年11月投产,废酸再生单元2020年11月投运。\n" + "---EXAMPLE---\n" + "项目运行情况:投产后烷基化油收率保持在86%以上,高于设计值81.28%。" + "受原油加工负荷影响,阶段性加工负荷与可研预期存在偏差;" + "通过全厂优化运行后,烷基化装置加工负荷保持在90%以上," + "满足国VI汽油质量升级需要。", + ), + ( + "2", + "前期决策评价:项目可研由具备甲级资质单位编制,前评估提出57条意见并完成落实。" + "原料来源为MTBE装置醚后碳四,来源与实际生产一致;" + "产品全部调入汽油系统,由中石油西北销售公司统一销售,市场风险可控。" + "工艺路线采用中石油自主硫酸法烷基化技术并配套P&P湿法废酸再生技术。\n" + "---EXAMPLE---\n" + "前期工作结论:项目在可研、前评估、初设及决策程序方面总体规范," + "对国VI质量升级任务支撑明确;" + "后续应针对专利技术工程化经验不足、概算约束偏紧等问题," + "在施工图阶段加强工程量校核与投资风险预控。", + ), + ( + "3", + "建设实施评价:项目采用“业主+监理+E+PC”管理模式。" + "单位工程质量合格率100%,HSE总体受控。" + "但施工图设计进度与采购协同不足,废酸再生单元受工艺包与设备整改影响," + "中交与投产明显滞后。\n" + "---EXAMPLE---\n" + "招采与设计变更情况:共发生设计变更118份,变更费用约5033万元," + "对进度与投资控制产生不利影响。" + "经验上应优先采用以设计为龙头的EPC协同模式," + "并提前锁定关键设备选材和高温腐蚀工况边界条件。", + ), + ( + "4", + "生产运行评价:烷基化单元2018年11月一次投产成功," + "废酸再生单元经四次整改后于2020年11月投运。" + "运行期主要问题集中在原料杂质波动、局部腐蚀及部分指标偏离设计值," + "通过优化烷烯比、补酸策略及上游分离精度逐步改进。\n" + "---EXAMPLE---\n" + "达标评价结论:烷基化单元在标定中出现辛烷值、硫含量、酸耗偏差," + "废酸再生单元处理能力达标但能耗偏高。" + "总体上项目工艺可用、装置可稳态运行," + "需持续优化操作和设备防腐管理以提升长周期绩效。", + ), + ( + "5", + "主要经济指标对比示例(宁夏石化项目):\n\n" + "表5-1 主要经济指标对比表\n" + "| 指标 | 单位 | 可研值 | 后评价值 | 差值 |\n" + "| --- | --- | --- | --- | --- |\n" + "| 报批总投资 | 万元 | 22812 | 32486 | +9674 |\n" + "| 年均税后利润 | 万元 | 20652 | 13283 | -7369 |\n" + "| 税后内部收益率 | % | 85.12 | 35.46 | -49.66 |\n" + "| 静态投资回收期 | 年 | 2.29 | 4.38 | +2.09 |\n" + "\n" + "示例结论:项目收益率虽较可研明显下降,但仍高于基准收益率," + "基本实现效益目标;投资控制偏弱是主要短板。\n" + "---EXAMPLE---\n" + "5.2.2 投资水平分析正文参考(勿输出为表5-2;表5-2 仅用于 5.2.1 投资变动情况表):\n\n" + "同类烷基化装置单位工程费对标(撰写段落时参考,非模版表号):\n" + "| 项目 | 规模(万吨/年) | 工程费(万元) | 单位造价(元/吨) |\n" + "| --- | --- | --- | --- |\n" + "| 宁夏石化 | 16 | 15159 | 947 |\n" + "| 乌石化 | 20 | 21286 | 1064 |\n" + "| 锦州石化 | 25 | 23401 | 936 |\n" + "| 兰州石化 | 20 | 14377 | 719 |", + ), + ( + "6", + "影响评价示例:项目落实环评及安全“三同时”,废气、废水、噪声监测达标," + "投产以来未发生重大安全事故,环境与安全风险总体可控。\n" + "---EXAMPLE---\n" + "持续性评价示例(宁夏石化项目):\n\n" + "表6-1 装置技术经济指标对比表\n" + "| 项目名称 | 技术来源 | 规模(万吨/年) | 物耗(Wt)% | 能耗(kgEo/t) | 产品质量 | 产品收率(Wt)% | 排名 |\n" + "| --- | --- | --- | --- | --- | --- | --- | --- |\n" + "| 宁夏石化烷基化 | 自主硫酸法烷基化 | 16 | 待补充 | 待补充 | 国VI调和组分 | 86以上 | 待补充 |\n" + "| 同类装置A | … | … | … | … | … | … | … |", + ), + ( + "7", + "综合结论示例:宁夏石化16万吨/年烷基化项目完成了国VI汽油质量升级目标," + "生产运行总体平稳,效益指标虽低于可研预测但仍高于基准收益率。" + "项目综合评分8.62分,评级为“良”。\n" + "---EXAMPLE---\n" + "建议示例:" + "1)持续优化自主技术工程化能力,重点治理腐蚀与高温环节选材问题;" + "2)加强设计-采购-施工一体化协同,减少大额变更;" + "3)围绕原料品质与上游协同运行,进一步提升装置长期经济性。", + ), +] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2a06f59 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +fastapi>=0.115.6 +uvicorn[standard]>=0.34.0 +python-multipart>=0.0.20 +pydantic>=2.11 +pydantic-settings>=2.7.1 +SQLAlchemy>=2.0.36 +PyMySQL>=1.1.1 +requests>=2.32.3 diff --git a/routers/__init__.py b/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/routers/template.py b/routers/template.py new file mode 100644 index 0000000..a6054b9 --- /dev/null +++ b/routers/template.py @@ -0,0 +1,346 @@ +""" +routers/template.py +报告模板管理:上传文档 → 远程解析为 Markdown → 抽取目录并为每个目录生成声明 +→ 创建模板(目录 + 声明)→ 按章节拆分正文并入库远程 MySQL。 +""" + +from __future__ import annotations + +import asyncio +import logging +import os +import re +import tempfile +import uuid +from datetime import datetime + +from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile +from sqlalchemy import or_ +from sqlalchemy.orm import Session + +from database import get_db +from database.models import ( + ReportSectionReference, + ReportTemplate, + ReportTemplateSection, +) +from schemas.template import ( + SectionReferenceItem, + TemplateItem, + TemplateSectionItem, + UploadTemplateResult, +) +from services.template_prompt_mapper import resolve_uploaded_template_prompts +from services.desensitize_service import count_masked_numbers, desensitize_content +from services.file_parse_client import parse_file_to_markdown +from services.section_extractor import ( + clamp_text_bytes, + extract_sections_from_text, + normalize_section_key, + parse_section_order, + split_markdown_into_sections, +) + +from config import settings + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/templates", tags=["报告模板管理"]) + +ALLOWED_SUFFIXES = {".doc", ".docx", ".pdf", ".txt", ".md", ".html", ".htm", ".rtf"} + + +def _clamp_title(value: str | None) -> str: + return str(value or "").strip()[:255] + + +def _build_description(source_file: str | None) -> str: + sf = str(source_file or "").strip() + base = "通过文件上传导入" + return f"{base}\n来源文件:{sf}" if sf else base + + +def _find_duplicates(db: Session, filename: str) -> dict: + """按来源文件名查找已导入的模板与章节范文,用于重复检查。""" + template_ids = [ + t.id + for t in db.query(ReportTemplate.id) + .filter(ReportTemplate.description.like(f"%来源文件:{filename}%")) + .all() + ] + ref_count = ( + db.query(ReportSectionReference) + .filter(ReportSectionReference.source_file == filename) + .count() + ) + return {"template_ids": template_ids, "ref_count": ref_count} + + +def _delete_by_source(db: Session, filename: str, template_ids: list[str]) -> None: + """删除指定来源文件已导入的模板(含章节)与章节范文。""" + for tid in template_ids: + db.query(ReportTemplateSection).filter( + ReportTemplateSection.template_id == tid + ).delete(synchronize_session=False) + db.query(ReportTemplate).filter(ReportTemplate.id == tid).delete( + synchronize_session=False + ) + db.query(ReportSectionReference).filter( + ReportSectionReference.source_file == filename + ).delete(synchronize_session=False) + db.commit() + + +def _extract_source_file(description: str | None) -> str | None: + """从模板描述中解析来源文件名(上传时写入 '来源文件:xxx')。""" + m = re.search(r"来源文件\s*[::]\s*(.+)$", str(description or "")) + if not m: + return None + return (m.group(1) or "").strip() or None + + +def _serialize_template(db: Session, template_id: str) -> TemplateItem: + t = db.query(ReportTemplate).filter(ReportTemplate.id == template_id).first() + if not t: + raise HTTPException(status_code=404, detail="模板不存在") + sections = ( + db.query(ReportTemplateSection) + .filter(ReportTemplateSection.template_id == t.id) + .order_by(ReportTemplateSection.section_order.asc()) + .all() + ) + src = _extract_source_file(t.description) + return TemplateItem( + id=t.id, + name=t.name, + description=t.description, + sourceFile=src, + createdAt=t.created_at.strftime("%Y-%m-%d %H:%M:%S") if t.created_at else None, + updatedAt=t.updated_at.strftime("%Y-%m-%d %H:%M:%S") if t.updated_at else None, + isDefault=t.is_default, + isActive=t.is_active, + sections=[ + TemplateSectionItem( + id=s.id, + sectionKey=s.section_key, + sectionTitle=s.section_title, + sectionPrompt=s.section_prompt, + sectionOutputContract=s.section_output_contract, + sectionOrder=s.section_order, + examples=s.examples, + ) + for s in sections + ], + ) + + +@router.post("/upload", response_model=UploadTemplateResult, summary="上传文档并解析为模板(目录+声明)与章节内容") +async def upload_template_route( + file: UploadFile = File(...), + force: bool = Query(False, description="为 true 时覆盖同名来源文件的已有模板与章节后重新导入"), + db: Session = Depends(get_db), +): + filename = (file.filename or "").strip() + suffix = os.path.splitext(filename)[1].lower() + if suffix not in ALLOWED_SUFFIXES: + raise HTTPException( + status_code=400, + detail=f"不支持的文件格式({suffix or '未知'});支持:{', '.join(sorted(ALLOWED_SUFFIXES))}", + ) + + # 0) 重复检查:同名来源文件已导入则拒绝(force=true 则先删除旧数据再重导) + dup = _find_duplicates(db, filename) + if dup["template_ids"] or dup["ref_count"]: + if not force: + raise HTTPException( + status_code=409, + detail=( + f"文件「{filename}」已导入(模板 {len(dup['template_ids'])} 个、" + f"章节 {dup['ref_count']} 条),不可重复入库;" + f"如需覆盖请使用 force=true 重新上传。" + ), + ) + logger.info( + "重复导入覆盖 | file=%s | 删除旧模板=%s | 旧章节=%s", + filename, len(dup["template_ids"]), dup["ref_count"], + ) + _delete_by_source(db, filename, dup["template_ids"]) + + # 1) 落盘临时文件 + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp: + tmp_path = tmp.name + tmp.write(await file.read()) + + try: + # 2) 远程解析为 Markdown + try: + markdown = parse_file_to_markdown(tmp_path) + except Exception as e: # noqa: BLE001 + raise HTTPException(status_code=400, detail=f"文档解析失败:{e}") + + if not markdown or not markdown.strip(): + raise HTTPException(status_code=400, detail="解析结果为空") + + # 3) 抽取目录 + sections = extract_sections_from_text(markdown) + if not sections: + raise HTTPException(status_code=400, detail="未从文档中识别到章节标题(目录)") + + # 4) 按标题拆分正文,对每段正文脱敏(去精确数字等);跳过无正文的章节 + raw_sections = split_markdown_into_sections(markdown) + max_bytes = int(getattr(settings, "SECTION_CONTENT_MAX_BYTES", 60000) or 60000) + ref_sections: list[dict] = [] + masked_total = 0 + skipped_empty = 0 + truncated = 0 + for s in raw_sections: + filtered = desensitize_content(s["content"]) + if not filtered.strip(): + skipped_empty += 1 + continue + masked_total += max(count_masked_numbers(s["content"], filtered), 0) + clamped = clamp_text_bytes(filtered, max_bytes) + if clamped is not filtered and len(clamped) != len(filtered): + truncated += 1 + ref_sections.append({**s, "content": clamped}) + logger.info( + "解析结果 | md_len=%s | toc=%s | 入库章节=%s | 跳过空正文=%s | 截断超长=%s | 脱敏数字串=%s", + len(markdown), len(sections), len(ref_sections), skipped_empty, truncated, masked_total, + ) + + # 5) 复刻 eval_report:将上传目录匹配默认模板,得到每章节 提示词/输出合同/示例 + # 放到工作线程执行:内部含并行 LLM 调用,避免阻塞事件循环(上传期间仍可并发处理其它请求) + resolved = await asyncio.to_thread(resolve_uploaded_template_prompts, sections) + logger.info( + "提示词匹配完成 | 章节=%s | 命中提示词=%s", + len(resolved), sum(1 for r in resolved if (r.get("sectionPrompt") or "").strip()), + ) + + now = datetime.now() + + # 6) 创建模板(目录 + 提示词/输出合同/示例) + template = ReportTemplate( + id=uuid.uuid4().hex, + name=os.path.splitext(filename)[0] or "上传模板", + description=_build_description(filename), + is_default=False, + is_active=True, + created_at=now, + updated_at=now, + ) + db.add(template) + db.flush() + + for i, sec in enumerate(sections): + r = resolved[i] if i < len(resolved) else {} + db.add( + ReportTemplateSection( + id=uuid.uuid4().hex, + template_id=template.id, + section_key=normalize_section_key(sec["sectionKey"], sec["sectionTitle"]), + section_title=_clamp_title(sec["sectionTitle"]), + section_prompt=(r.get("sectionPrompt") or None), + section_output_contract=(r.get("sectionOutputContract") or None), + section_order=i, + examples="", + created_at=now, + updated_at=now, + ) + ) + + # 7) 章节内容入库(report_section_references 格式) + saved_refs: list[SectionReferenceItem] = [] + for sec in ref_sections: + ref = ReportSectionReference( + id=uuid.uuid4().hex, + template_id=template.id, + source_file=filename, + section_key=sec["section_key"], + section_title=_clamp_title(sec["section_title"]), + section_order=parse_section_order(sec["section_key"]), + content=sec["content"], + created_at=now, + updated_at=now, + ) + db.add(ref) + saved_refs.append( + SectionReferenceItem( + id=ref.id, + templateId=ref.template_id, + sourceFile=ref.source_file, + sectionKey=ref.section_key, + sectionTitle=ref.section_title, + sectionOrder=ref.section_order, + contentLength=len(ref.content or ""), + content=ref.content or "", + ) + ) + + db.commit() + logger.info( + "模板上传完成 | file=%s | toc=%s | refs=%s", + filename, len(sections), len(saved_refs), + ) + + return UploadTemplateResult( + template=_serialize_template(db, template.id), + sourceFile=filename, + markdownLength=len(markdown), + totalSections=len(sections), + totalReferences=len(saved_refs), + references=saved_refs, + parseWarnings=[], + ) + except HTTPException: + db.rollback() + raise + except Exception as e: # noqa: BLE001 + db.rollback() + raise HTTPException(status_code=400, detail=f"模板创建失败:{e}") + finally: + try: + os.remove(tmp_path) + except OSError: + pass + + +@router.get("", response_model=list[TemplateItem], summary="获取模板列表") +def list_templates_route(db: Session = Depends(get_db)): + rows = ( + db.query(ReportTemplate) + .order_by(ReportTemplate.created_at.desc()) + .all() + ) + return [_serialize_template(db, r.id) for r in rows] + + +@router.get("/{template_id}", response_model=TemplateItem, summary="获取模板详情") +def get_template_route(template_id: str, db: Session = Depends(get_db)): + return _serialize_template(db, template_id) + + +@router.delete("/{template_id}", status_code=204, summary="删除模板(含其来源文件的章节范文)") +def delete_template_route(template_id: str, db: Session = Depends(get_db)): + t = db.query(ReportTemplate).filter(ReportTemplate.id == template_id).first() + if not t: + raise HTTPException(status_code=404, detail="模板不存在") + source_file = _extract_source_file(t.description) + db.query(ReportTemplateSection).filter( + ReportTemplateSection.template_id == t.id + ).delete(synchronize_session=False) + db.delete(t) + # 同步删除该模板在 report_section_references 中的章节内容: + # 优先按 template_id 精确删除;同时按 source_file 兜底清理历史数据(template_id 为空的旧记录) + conditions = [ReportSectionReference.template_id == t.id] + if source_file: + conditions.append(ReportSectionReference.source_file == source_file) + ref_deleted = ( + db.query(ReportSectionReference) + .filter(or_(*conditions)) + .delete(synchronize_session=False) + ) + db.commit() + logger.info( + "模板删除完成 | template_id=%s | source_file=%s | 删除章节范文=%s", + template_id, source_file, ref_deleted, + ) diff --git a/schemas/__init__.py b/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/schemas/template.py b/schemas/template.py new file mode 100644 index 0000000..3824dc4 --- /dev/null +++ b/schemas/template.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from typing import List, Optional + +from pydantic import BaseModel + + +class TemplateSectionItem(BaseModel): + id: str + sectionKey: str + sectionTitle: str + # 复刻 eval_report:章节提示词 / 输出合同 / 示例 + sectionPrompt: Optional[str] = None + sectionOutputContract: Optional[str] = None + sectionOrder: int = 0 + examples: Optional[str] = None + + +class TemplateItem(BaseModel): + id: str + name: str + description: Optional[str] = None + sourceFile: Optional[str] = None + createdAt: Optional[str] = None + updatedAt: Optional[str] = None + isDefault: bool = False + isActive: bool = True + sections: List[TemplateSectionItem] = [] + + +class SectionReferenceItem(BaseModel): + id: str + templateId: Optional[str] = None + sourceFile: str + sectionKey: str + sectionTitle: str + sectionOrder: int = 0 + contentLength: int = 0 + content: str = "" + + +class UploadTemplateResult(BaseModel): + """上传解析结果:模板(目录 + 声明)+ 入库的章节内容。""" + + template: TemplateItem + sourceFile: str + markdownLength: int + totalSections: int + totalReferences: int + references: List[SectionReferenceItem] = [] + parseWarnings: List[str] = [] diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/declaration_service.py b/services/declaration_service.py new file mode 100644 index 0000000..ff33ee4 --- /dev/null +++ b/services/declaration_service.py @@ -0,0 +1,126 @@ +""" +services/declaration_service.py +为每个目录(章节)生成一个"声明"。 + +声明:一段说明该章节应写什么、结构与约束的撰写指引,存入 +report_template_sections.section_prompt。 + +优先用 LLM(结合章节标题 + 该章节正文)生成;未配置或失败时 +回退到确定性模板,保证流程稳定可用。 +""" + +from __future__ import annotations + +import logging +import re +from concurrent.futures import ThreadPoolExecutor, as_completed + +from config import settings +from services.llm_client import chat_completions_json, llm_configured + +logger = logging.getLogger(__name__) + +_SYSTEM_PROMPT = ( + "你是报告模板专家。任务:阅读给定章节的范文(参考正文),总结这一章应该怎么写," + "作为后续报告撰写该章节的写作指引。需提炼:①内容要点(写哪些事项);" + "②组织结构(应有的小节/条目顺序);③数据与口径要求(需引用的对比/指标/表格等);" + "④写作约束(先事实后评价、缺失写「待补充」、不得编造)。" + "严格要求:不要输出任何思考过程或解释;只输出 JSON 对象 {\"guide\": \"...\"};" + "guide 为 300 字以内的写作指引纯文本(不含 markdown 标题);" + "范文缺失或过短时,按章节标题给出通用写作指引。" +) + + +def _strip_number_prefix(title: str) -> str: + t = str(title or "").strip() + t = re.sub(r"^(?:\d+(?:\.\d+)*|[一二三四五六七八九十]+[、..])\s*", "", t).strip() + return t + + +def _fallback_declaration(section_title: str) -> str: + label = _strip_number_prefix(section_title) or "本章节" + return ( + f"本章节为「{label}」。撰写时应紧扣标题主题,先陈述事实与数据,再给出分析与评价;" + f"结构需与标题保持一致,条理清晰、用语规范;" + f"所有结论须有依据,缺失信息写「待补充」,禁止编造。" + ) + + +def _build_user_prompt(section_title: str, content: str) -> str: + body = (content or "").strip() + if len(body) > 2500: + body = body[:2500] + body_block = f"\n\n该章节范文(参考正文,节选):\n```\n{body}\n```" if body else "" + return ( + f"章节标题:{section_title}{body_block}\n\n" + f"请根据上述范文,总结该章节应该怎么写,并只返回 JSON:{{\"guide\": \"300字以内的写作指引\"}}。" + ) + + +def generate_declaration(section_title: str, content: str = "") -> str: + """根据范文为单个章节生成"怎么写"的写作指引(JSON 取 guide,自动剔除思考过程)。""" + use_llm = bool(getattr(settings, "DECLARATION_USE_LLM", True)) and llm_configured() + if not use_llm: + return _fallback_declaration(section_title) + try: + data = chat_completions_json( + system_prompt=_SYSTEM_PROMPT, + user_prompt=_build_user_prompt(section_title, content), + temperature=0.2, + max_tokens=2048, + ) + guide = str((data or {}).get("guide") or "").strip() + if guide: + return guide + except Exception as e: # noqa: BLE001 - 兜底,保证主流程不被 LLM 影响 + logger.warning("生成章节声明失败,使用兜底模板 | title=%s | err=%s", section_title, e) + return _fallback_declaration(section_title) + + +def _content_for_section(s: dict, content_by_key: dict[str, str]) -> str: + """目录键可能是 canonical 形式,优先用标题中的编号前缀去匹配正文。""" + title = str(s.get("sectionTitle") or "") + m = re.match(r"^(\d+(?:\.\d+)*)", title.strip()) + num = m.group(1) if m else "" + return content_by_key.get(num, "") or content_by_key.get(str(s.get("sectionKey") or ""), "") + + +def generate_declarations(sections: list[dict], content_by_key: dict[str, str] | None = None) -> list[str]: + """ + 为目录中每个章节并发生成"怎么写"的写作指引(基于范文)。 + sections: [{sectionKey, sectionTitle}, ...] + content_by_key: 章节编号/键 -> 范文正文,用于为指引提供上下文(可选)。 + + 每章一次 LLM 调用,多线程并发以打满 GPU(LLM 为网络 I/O,线程下真正并行)。 + """ + content_by_key = content_by_key or {} + tasks = [(str(s.get("sectionTitle") or ""), _content_for_section(s, content_by_key)) for s in sections] + if not tasks: + return [] + + use_llm = bool(getattr(settings, "DECLARATION_USE_LLM", True)) and llm_configured() + if not use_llm: + return [_fallback_declaration(title) for title, _ in tasks] + + max_workers = max(int(getattr(settings, "TEMPLATE_UPLOAD_LLM_MAX_WORKERS", 8) or 8), 1) + results: list[str] = [""] * len(tasks) + + if len(tasks) == 1: + results[0] = generate_declaration(*tasks[0]) + return results + + workers = min(max_workers, len(tasks)) + with ThreadPoolExecutor(max_workers=workers) as executor: + future_to_idx = { + executor.submit(generate_declaration, title, content): i + for i, (title, content) in enumerate(tasks) + } + for fut in as_completed(future_to_idx): + idx = future_to_idx[fut] + try: + results[idx] = fut.result() + except Exception as e: # noqa: BLE001 + logger.warning("章节声明并发生成失败,使用兜底 | idx=%s | err=%s", idx, e) + results[idx] = _fallback_declaration(tasks[idx][0]) + logger.info("章节声明生成 | 章节=%s | 线程=%s", len(tasks), workers) + return results diff --git a/services/desensitize_service.py b/services/desensitize_service.py new file mode 100644 index 0000000..a88f632 --- /dev/null +++ b/services/desensitize_service.py @@ -0,0 +1,80 @@ +""" +services/desensitize_service.py +章节内容脱敏:把范文正文中的"精确数据"过滤掉,得到可复用的模板化内容。 + +规则(默认): +- 阿拉伯数字串(含小数、千分位、全角数字)→ 占位符(默认 "X"), + 如 "总投资10.5亿元" → "总投资X亿元"、"2020年3月" → "X年X月"、"85.3%" → "X%"。 +- 标题行(以 # 开头)整行保留,不动章节编号/标题。 +- 行首的列表/枚举序号(如 "1)" "1." "(2)")保留,仅脱敏正文中的数字。 +- 单位与符号(万元/亿元/%/吨/年 等)保留,仅去掉其中的精确数值。 + +可通过 config 调整占位符、是否脱敏表格数字、是否启用。 +中文数字(一二三…)通常用于序数/层级,默认保留。 +""" + +from __future__ import annotations + +import logging +import re + +from config import settings + +logger = logging.getLogger(__name__) + +# 阿拉伯数字(含全角)串,允许小数点/千分位分隔 +_NUMBER_RE = re.compile(r"[0-90-9]+(?:[..,,][0-90-9]+)*") + +# 行首枚举序号:1) / 1. / (2) / 2、 等(这些是结构标记,保留) +_LEADING_ENUM_RE = re.compile(r"^(\s*(?:[((]\s*[0-90-9]+\s*[))]|[0-90-9]+\s*[)).、.]))") + +_HEADING_RE = re.compile(r"^\s*#{1,6}\s") +_TABLE_ROW_RE = re.compile(r"^\s*\|.*\|\s*$") +_TABLE_SEP_RE = re.compile(r"^\s*\|?[\s:\-|]+\|?\s*$") + + +def _mask_numbers(segment: str, placeholder: str) -> str: + return _NUMBER_RE.sub(placeholder, segment) + + +def _desensitize_line(line: str, placeholder: str, mask_table_numbers: bool) -> str: + # 标题行整行保留(不动章节编号/标题) + if _HEADING_RE.match(line): + return line + + # 表格行 + if _TABLE_ROW_RE.match(line): + if _TABLE_SEP_RE.match(line): # 分隔行 |---|---| + return line + if not mask_table_numbers: + return line + return _mask_numbers(line, placeholder) + + # 普通正文:保留行首枚举序号,仅脱敏其余部分 + m = _LEADING_ENUM_RE.match(line) + if m: + prefix = m.group(1) + rest = line[len(prefix):] + return prefix + _mask_numbers(rest, placeholder) + + return _mask_numbers(line, placeholder) + + +def desensitize_content(text: str) -> str: + """对单个章节正文脱敏。未启用时原样返回。""" + if not text: + return text + if not bool(getattr(settings, "DESENSITIZE_ENABLED", True)): + return text + + placeholder = str(getattr(settings, "DESENSITIZE_PLACEHOLDER", "X") or "X") + mask_table = bool(getattr(settings, "DESENSITIZE_MASK_TABLE_NUMBERS", True)) + + lines = text.splitlines() + out = [_desensitize_line(ln, placeholder, mask_table) for ln in lines] + return "\n".join(out) + + +def count_masked_numbers(original: str, filtered: str) -> int: + """粗略统计脱敏掉的数字串数量(用于日志)。""" + return len(_NUMBER_RE.findall(original or "")) - len(_NUMBER_RE.findall(filtered or "")) diff --git a/services/file_parse_client.py b/services/file_parse_client.py new file mode 100644 index 0000000..9ee1e1f --- /dev/null +++ b/services/file_parse_client.py @@ -0,0 +1,194 @@ +""" +services/file_parse_client.py +调用远程解析服务(默认 http://192.168.4.194:8000/convert): +上传文件(multipart:文件字段默认 "file",并附带 engine=auto 表单字段)→ 返回 Markdown。 + +响应解析:JSON 中按 results / md_content / mdcontent / markdown / content +逐层提取;若响应非 JSON 则整体作为 Markdown 返回。 +""" + +from __future__ import annotations + +import json +import logging +import mimetypes +import time +import uuid +from pathlib import Path +from typing import Any +from urllib import error as urlerror +from urllib import request as urlrequest + +from config import settings + +logger = logging.getLogger(__name__) + +MD_CONTENT_KEYS = ("md_content", "mdcontent", "markdown", "content") + + +class FileParseApiError(RuntimeError): + def __init__(self, message: str, *, status_code: int | None = None, api_url: str = "") -> None: + super().__init__(message) + self.status_code = status_code + self.api_url = api_url + + +def _build_multipart_body( + file_path: Path, + field_name: str, + extra_fields: dict[str, str] | None = None, +) -> tuple[bytes, str]: + boundary = uuid.uuid4().hex + mime_type = mimetypes.guess_type(file_path.name)[0] or "application/octet-stream" + file_bytes = file_path.read_bytes() + + parts: list[bytes] = [] + # 普通表单字段(如 engine=auto) + for key, value in (extra_fields or {}).items(): + if value is None or str(value).strip() == "": + continue + parts.append(f"--{boundary}\r\n".encode("utf-8")) + parts.append( + f'Content-Disposition: form-data; name="{key}"\r\n\r\n'.encode("utf-8") + ) + parts.append(f"{value}\r\n".encode("utf-8")) + + # 文件字段 + parts.append(f"--{boundary}\r\n".encode("utf-8")) + parts.append( + ( + f'Content-Disposition: form-data; name="{field_name}"; filename="{file_path.name}"\r\n' + f"Content-Type: {mime_type}\r\n\r\n" + ).encode("utf-8") + ) + parts.append(file_bytes) + parts.append(f"\r\n--{boundary}--\r\n".encode("utf-8")) + return b"".join(parts), boundary + + +def _extract_md_contents(payload: Any) -> list[str]: + if isinstance(payload, str): + return [payload] + if isinstance(payload, list): + out: list[str] = [] + for item in payload: + out.extend(_extract_md_contents(item)) + return out + if not isinstance(payload, dict): + return [] + + for key in MD_CONTENT_KEYS: + value = payload.get(key) + if isinstance(value, str): + return [value] + + results = payload.get("results") + if results is not None: + return _extract_md_contents(results) + + out = [] + for value in payload.values(): + out.extend(_extract_md_contents(value)) + return out + + +def _response_to_markdown(text: str) -> str: + try: + payload = json.loads(text) + except json.JSONDecodeError: + # 非 JSON 直接当作 Markdown 返回 + return text + contents = _extract_md_contents(payload) + if not contents: + raise ValueError("解析服务响应中未找到 md_content/markdown/content 字段") + return "\n\n".join(c.strip() for c in contents if c and c.strip()) + + +def _request_once( + api_url: str, + file_path: Path, + field_name: str, + *, + timeout_sec: int, + extra_fields: dict[str, str] | None = None, +) -> str: + body, boundary = _build_multipart_body(file_path, field_name, extra_fields) + req = urlrequest.Request( + api_url, + data=body, + method="POST", + headers={"content-type": f"multipart/form-data; boundary={boundary}"}, + ) + try: + with urlrequest.urlopen(req, timeout=timeout_sec) as resp: + raw = resp.read() + encoding = resp.headers.get_content_charset() or "utf-8" + return raw.decode(encoding, errors="replace") + except urlerror.HTTPError as exc: + body_text = "" + try: + body_text = (exc.read() or b"").decode("utf-8", errors="replace")[:1000] + except Exception: + pass + raise FileParseApiError( + f"解析服务 HTTP {exc.code}({api_url}):{body_text or exc.reason}", + status_code=int(exc.code or 0), + api_url=api_url, + ) from exc + except urlerror.URLError as exc: + raise FileParseApiError( + f"无法连接解析服务({api_url}):{exc.reason}", + status_code=0, + api_url=api_url, + ) from exc + + +def parse_file_to_markdown(file_path: str | Path) -> str: + """ + 将上传文件通过远程 file_parse 服务转换为 Markdown。 + 失败时对 5xx 做有限重试。 + """ + path = Path(file_path) + if not path.is_file(): + raise FileNotFoundError(f"文件不存在: {path}") + + api_url = str(settings.FILE_PARSE_API_URL or "").strip() + if not api_url: + raise ValueError("FILE_PARSE_API_URL 未配置") + + field_name = str(settings.FILE_PARSE_FIELD_NAME or "file").strip() or "file" + timeout_sec = max(int(settings.FILE_PARSE_HTTP_TIMEOUT_SEC or 600), 30) + retry_count = max(int(settings.FILE_PARSE_RETRY_COUNT or 1), 1) + backoff_sec = max(float(settings.FILE_PARSE_RETRY_BACKOFF_SEC or 1.0), 1.0) + retryable_status = {500, 502, 503, 504} + + extra_fields: dict[str, str] = {} + engine = str(getattr(settings, "FILE_PARSE_ENGINE", "") or "").strip() + if engine: + extra_fields["engine"] = engine + + last_error: Exception | None = None + for attempt in range(1, retry_count + 1): + try: + raw = _request_once( + api_url, path, field_name, timeout_sec=timeout_sec, extra_fields=extra_fields + ) + markdown = _response_to_markdown(raw) + if not markdown.strip(): + raise ValueError("解析服务返回的 Markdown 为空") + return markdown + except FileParseApiError as exc: + last_error = exc + status = int(exc.status_code or 0) + if attempt >= retry_count or status not in retryable_status: + raise + wait = backoff_sec * attempt + logger.warning( + "file_parse 重试 %s/%s status=%s wait=%ss file=%s", + attempt, retry_count, status, wait, path.name, + ) + time.sleep(wait) + + if last_error: + raise last_error + raise RuntimeError("file_parse 请求失败") diff --git a/services/llm_client.py b/services/llm_client.py new file mode 100644 index 0000000..019246b --- /dev/null +++ b/services/llm_client.py @@ -0,0 +1,118 @@ +""" +services/llm_client.py +极简 OpenAI 兼容 Chat Completions 客户端(仅用于生成章节声明,可选)。 +""" + +from __future__ import annotations + +import json +import logging +import re + +import requests + +from config import settings + +logger = logging.getLogger(__name__) + + +def llm_configured() -> bool: + return bool( + str(settings.LLM_API_BASE or "").strip() + and str(settings.LLM_API_KEY or "").strip() + and str(settings.LLM_MODEL_NAME or "").strip() + ) + + +_THINK_BLOCK_RE = re.compile(r".*?", re.DOTALL | re.IGNORECASE) + + +def _strip_reasoning(text: str) -> str: + """去掉思考模型的思维链:成对 ,以及截断/前导的残留标签。""" + s = text or "" + s = _THINK_BLOCK_RE.sub("", s) + # 仅剩结束标签时,说明前面是未配对的思考段,取最后一个 之后的正文 + if "" in s: + s = s.rsplit("", 1)[-1] + s = re.sub(r"", "", s, flags=re.IGNORECASE) + return s.strip() + + +def chat_completion_text( + *, + system_prompt: str, + user_prompt: str, + temperature: float = 0.2, + max_tokens: int = 512, + timeout_sec: int | None = None, +) -> str: + """调用 LLM 返回纯文本。失败抛出异常,由调用方决定是否兜底。""" + base = str(settings.LLM_API_BASE or "").strip().rstrip("/") + url = f"{base}/chat/completions" + headers = { + "Authorization": f"Bearer {settings.LLM_API_KEY}", + "Content-Type": "application/json", + } + payload = { + "model": settings.LLM_MODEL_NAME, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + "temperature": temperature, + "max_tokens": max_tokens, + } + # 关闭思考模型的思维链(vLLM/Qwen3 等支持该扩展字段;不支持的服务会忽略) + if bool(getattr(settings, "LLM_DISABLE_THINKING", False)): + payload["chat_template_kwargs"] = {"enable_thinking": False} + resp = requests.post( + url, + headers=headers, + data=json.dumps(payload, ensure_ascii=False).encode("utf-8"), + timeout=timeout_sec or int(settings.LLM_HTTP_TIMEOUT_SEC or 120), + ) + resp.raise_for_status() + data = resp.json() + return _strip_reasoning((data["choices"][0]["message"]["content"] or "").strip()) + + +def _extract_json(text: str) -> dict: + """从模型输出中解析 JSON object(容忍 ```json``` 代码块包裹)。""" + s = (text or "").strip() + if s.startswith("```"): + s = re.sub(r"^```[a-zA-Z]*\s*", "", s) + s = re.sub(r"\s*```$", "", s).strip() + try: + obj = json.loads(s) + except json.JSONDecodeError: + m = re.search(r"\{.*\}", s, flags=re.DOTALL) + if not m: + return {} + try: + obj = json.loads(m.group(0)) + except json.JSONDecodeError: + return {} + return obj if isinstance(obj, dict) else {} + + +def chat_completions_json( + *, + system_prompt: str, + user_prompt: str, + temperature: float = 0.1, + max_tokens: int = 4096, + timeout_sec: int | None = None, +) -> dict: + """调用 LLM 并将返回解析为 JSON object(dict)。失败返回 {}。""" + try: + text = chat_completion_text( + system_prompt=system_prompt, + user_prompt=user_prompt, + temperature=temperature, + max_tokens=max_tokens, + timeout_sec=timeout_sec, + ) + except Exception as e: # noqa: BLE001 + logger.warning("chat_completions_json 调用失败: %s", e) + return {} + return _extract_json(text) diff --git a/services/section_extractor.py b/services/section_extractor.py new file mode 100644 index 0000000..e3b026a --- /dev/null +++ b/services/section_extractor.py @@ -0,0 +1,406 @@ +""" +services/section_extractor.py +从 Markdown 中: + 1) 抽取目录(章节标题层级)-> 用于生成模板章节(目录) + 2) 按标题拆分正文 -> 每个章节的内容(用于入库 report_section_references) + +抽取/过滤逻辑参考 eval_report/routers/template.py 与 routers/reference.py。 +""" + +from __future__ import annotations + +import hashlib +import re + +_MAX_SECTION_TITLE_LEN = 200 + + +# ────────────────────────────── 通用过滤/清洗 ────────────────────────────── + + +def _segment_looks_like_year(segment: str) -> bool: + if not segment.isdigit() or len(segment) != 4: + return False + year = int(segment) + return 1900 <= year <= 2099 + + +def _is_valid_section_number(num: str) -> bool: + """章节编号形如 1 / 1.1 / 2.3.4,排除正文年份(2017、2019 等)。""" + parts = [p for p in str(num or "").strip().split(".") if p] + if not parts or not all(p.isdigit() for p in parts): + return False + if any(_segment_looks_like_year(p) for p in parts): + return False + if len(parts) == 1: + return 1 <= int(parts[0]) <= 20 + return all(1 <= int(p) <= 99 for p in parts) + + +def _heading_title_core(rest: str) -> str: + return re.sub(r"^\d+(?:\.\d+)*\s*", "", str(rest or "").strip()).strip() + + +def _rest_looks_like_body_text(rest: str) -> bool: + """过滤日期句、长段落、数据说明句等被误识别为标题的正文。""" + t = _heading_title_core(rest) or str(rest or "").strip() + if not t: + return True + if re.match(r"^[月日]", t): + return True + if re.search(r"月\d", t): + return True + if re.match(r"^\d{4}\s*年", t) or re.match(r"^\d{4}[、,]", t): + return True + if re.search(r"\d{4}\s*[-~~—至]\s*\d{4}", t): + return True + if t.count("。") >= 2 or t.count(";") >= 2: + return True + if len(t) > 80 and re.search(r"[,。;:]", t): + return True + if len(t) > 45 and any( + k in t + for k in ( + "运营数据", "预测数据", "实际运营", "根据公司", + "发展规划", "工况下", "万吨", "有项目", "无项目", + ) + ): + return True + if len(t) > 45 and not re.search( + r"(评价|分析|结论|概况|说明|措施|建议|对比|控制|实现|状况|情况|程序|模式|评价结论)$", + t.rstrip("。;,"), + ): + return True + return False + + +def _looks_like_real_heading_title(title: str) -> bool: + if not str(title or "").strip(): + return False + return not _rest_looks_like_body_text(title) + + +def _clean_heading_title(s: str) -> str: + t = str(s or "").strip() + t = re.sub(r"\s+", " ", t) + t = re.sub(r"\s+\d+$", "", t).strip() # 去掉目录行尾页码 + m_note = re.search(r"[((]([^))]{20,})[))]", t) + if m_note and re.search(r"[,。;:]", m_note.group(1)): + t = re.sub(r"\s*[((][^))]{20,}[))]\s*$", "", t).strip() + return t + + +def _section_dict(section_key: str, section_title: str) -> dict: + return {"sectionKey": section_key, "sectionTitle": section_title} + + +def _canonical_to_section_key(canonical: str, order: int) -> str: + return ( + re.sub(r"[^a-z0-9\u4e00-\u9fa5]+", "-", canonical).strip("-") + or f"section-{order}" + ) + + +def normalize_section_key(raw_key: str | None, title: str | None) -> str: + """生成稳定且可入库的 section_key(<=64),超长追加短哈希。""" + base = (raw_key or "").strip().lower() + if not base: + base = (title or "").strip().lower() + base = re.sub(r"[^a-z0-9\u4e00-\u9fa5]+", "-", base).strip("-") + if not base: + base = "section" + if len(base) <= 64: + return base + digest = hashlib.md5(base.encode("utf-8")).hexdigest()[:10] + prefix = base[:53].rstrip("-") + return f"{prefix}-{digest}" + + +# ────────────────────────────── 目录(TOC)抽取 ────────────────────────────── + + +def _walk_markdown_heading_sections(text: str) -> list[dict]: + """ + 单次遍历 Markdown,按标题(# ~ ######)切分章节并捕获正文(不含本节标题行)。 + 标题层级自动编号(## 项目概况 -> 1.1 项目概况),无显式编号也可处理。 + 被判定为"非真实标题"的 # 行视为正文内容,不另起章节。 + + 正文范围: + - 默认(SECTION_CONTENT_INCLUDE_SUBSECTIONS=True):聚合整棵子树, + 即本节标题之后、直到下一个"层级 <= 本节"的标题之前的全部内容 + (含下级小节标题与正文),保证父章节正文非空。 + - 关闭时:仅取到下一个任意标题之前(本节自身正文)。 + + 返回每节:{number, title, full_title, canonical, section_key(canonical), level, content} + 目录抽取与正文拆分共用此函数,确保目录与内容一一对应。 + """ + from config import settings + + include_sub = bool(getattr(settings, "SECTION_CONTENT_INCLUDE_SUBSECTIONS", True)) + + lines = str(text or "").splitlines() + counters: list[int] = [] + accepted: list[dict] = [] + seen: set[str] = set() + + for idx, raw in enumerate(lines): + m = re.match(r"^(#{1,6})\s+(.+)$", str(raw or "").strip()) + if not m: + continue + level = len(m.group(1)) + title = _clean_heading_title(m.group(2).strip()) + is_valid = ( + bool(title) + and len(title) <= _MAX_SECTION_TITLE_LEN + and _looks_like_real_heading_title(title) + ) + if not is_valid: + continue + if len(counters) < level: + counters.extend([0] * (level - len(counters))) + else: + counters = counters[:level] + counters[level - 1] += 1 + for i in range(level, len(counters)): + counters[i] = 0 + num = ".".join(str(counters[i]) for i in range(level)) + full_title = f"{num} {title}" + canonical = f"{num}|{title}".lower() + if canonical in seen: + continue + seen.add(canonical) + accepted.append( + { + "number": num, + "title": title, + "full_title": full_title, + "canonical": canonical, + "section_key": _canonical_to_section_key(canonical, len(accepted) + 1), + "level": level, + "start_idx": idx, + } + ) + + total = len(lines) + for i, sec in enumerate(accepted): + body_start = sec["start_idx"] + 1 # 排除本节标题行 + end = total + for j in range(i + 1, len(accepted)): + nxt = accepted[j] + if include_sub: + if nxt["level"] <= sec["level"]: + end = nxt["start_idx"] + break + else: + end = nxt["start_idx"] + break + sec["content"] = "\n".join(lines[body_start:end]).strip() + sec.pop("start_idx", None) + + return accepted + + +def _extract_sections_from_markdown_headings(text: str) -> list[dict]: + """ + 从 Markdown 标题(# / ## / ###)构建模板章节目录。 + 复刻 eval_report 报告模板管理模块 services/template_service.py 的同名逻辑: + 标题层级自动编号(## 项目概况 -> 1.1 项目概况),并过滤非真实标题行。 + """ + lines = str(text or "").splitlines() + counters: list[int] = [] + out: list[dict] = [] + seen: set[str] = set() + + for raw in lines: + m = re.match(r"^(#{1,6})\s+(.+)$", str(raw or "").strip()) + if not m: + continue + level = len(m.group(1)) + title = _clean_heading_title(m.group(2).strip()) + if not title or len(title) > _MAX_SECTION_TITLE_LEN: + continue + if not _looks_like_real_heading_title(title): + continue + if len(counters) < level: + counters.extend([0] * (level - len(counters))) + else: + counters = counters[:level] + counters[level - 1] += 1 + for i in range(level, len(counters)): + counters[i] = 0 + num = ".".join(str(counters[i]) for i in range(level)) + full_title = f"{num} {title}" + canonical = f"{num}|{title}".lower() + if canonical in seen: + continue + seen.add(canonical) + out.append( + _section_dict( + _canonical_to_section_key(canonical, len(out) + 1), + full_title, + ) + ) + return out + + +def extract_sections_from_text(text: str) -> list[dict]: + """抽取模板章节目录(入库 report_template_sections)。 + + 复刻 eval_report 报告模板管理模块的逻辑:优先按 Markdown 标题层级识别, + 命中数 >= 8 时直接采用;否则回退到目录/编号行识别。""" + md_sections = _extract_sections_from_markdown_headings(text) + if len(md_sections) >= 8: + return md_sections + + lines = str(text or "").splitlines() + out: list[dict] = [] + seen: set[str] = set() + candidates: list[dict] = [] + + for raw in lines: + line = str(raw or "").strip() + if not line: + continue + line = re.sub(r"^#{1,6}\s*", "", line).strip() + line = line.replace("\u3000", " ") + line = re.sub(r"\s+", " ", line).strip() + + if re.match(r"^20\d{2}\s*年\s*\d{1,2}\s*月$", line): + continue + if line in {"目次", "目录"}: + continue + if re.match(r"^\d+\s*[)\)]\s*.+$", line): + continue + + has_page_no = bool(re.search(r"\s+\d+\s*$", line)) + m = re.match(r"^((?:\d+(?:\.\d+){0,5}))\s*([^\s].*)$", line) + if m: + num = m.group(1).strip() + if not _is_valid_section_number(num): + continue + rest = _clean_heading_title(m.group(2).strip()) + if not rest or rest.startswith(")") or rest.startswith(")"): + continue + if _rest_looks_like_body_text(rest): + continue + if len(rest) > _MAX_SECTION_TITLE_LEN: + continue + full_title = f"{num} {rest}"[:_MAX_SECTION_TITLE_LEN].rstrip() + canonical = f"{num}|{rest}".lower() + else: + m2 = re.match(r"^([一二三四五六七八九十]+[、..])\s*([^\s].*)$", line) + if not m2: + continue + rest2 = _clean_heading_title(m2.group(2).strip()) + if not rest2 or _rest_looks_like_body_text(rest2) or len(rest2) > _MAX_SECTION_TITLE_LEN: + continue + full_title = f"{m2.group(1)} {rest2}"[:_MAX_SECTION_TITLE_LEN].rstrip() + canonical = f"{m2.group(1)}|{rest2}".lower() + candidates.append({"canonical": canonical, "title": full_title, "has_page_no": has_page_no}) + + use_toc_only = False + toc_rows = [c for c in candidates if c["has_page_no"]] + toc_nums = set() + for c in toc_rows: + m_num = re.match(r"^(\d+)", c["title"]) + if m_num: + toc_nums.add(m_num.group(1)) + if len(toc_rows) >= 20 and {"1", "2", "3", "4", "5", "6", "7"}.issubset(toc_nums): + use_toc_only = True + + picked = toc_rows if use_toc_only else candidates + for c in picked: + canonical = c["canonical"] + if canonical in seen: + continue + if not _looks_like_real_heading_title(c["title"]): + continue + seen.add(canonical) + out.append(_section_dict(_canonical_to_section_key(canonical, len(out) + 1), c["title"])) + return out + + +# ────────────────────────────── 正文按标题拆分 ────────────────────────────── + + +def split_markdown_into_sections(text: str) -> list[dict[str, str]]: + """ + 按 Markdown 标题切分正文,与目录抽取共用同一套标题识别与自动编号, + 保证每个目录章节都能拿到对应正文。section_key 为自动编号(如 1.1)。 + + 若文档不含 Markdown 标题,则回退到"带编号标题"的拆分方式。 + 返回 [{section_key, section_title, content}, ...]。 + """ + walk = _walk_markdown_heading_sections(text) + if walk: + return [ + { + "section_key": s["number"], + "section_title": s["full_title"], + "content": s["content"], + } + for s in walk + ] + return split_markdown_by_headings(text) + + +def split_markdown_by_headings(text: str) -> list[dict[str, str]]: + """ + 按 Markdown 标题(# ~ ####,且带章节编号,如 ## 1.1 标题)拆分正文。 + 返回 [{section_key, section_title, content}, ...],section_key 为编号(如 1.1)。 + """ + lines = str(text or "").splitlines() + heading_pattern = re.compile(r"^#{1,4}\s+(\d+(?:\.\d+)*)\s+(.+)") + + sections: list[dict[str, str]] = [] + current_key: str | None = None + current_title: str | None = None + current_lines: list[str] = [] + + for line in lines: + m = heading_pattern.match(line) + if m: + if current_key and current_lines: + sections.append({ + "section_key": current_key, + "section_title": current_title or "", + "content": "\n".join(current_lines).strip(), + }) + current_key = m.group(1) + current_title = m.group(2).strip() + current_lines = [line] + else: + if current_key: + current_lines.append(line) + + if current_key and current_lines: + sections.append({ + "section_key": current_key, + "section_title": current_title or "", + "content": "\n".join(current_lines).strip(), + }) + + return sections + + +def parse_section_order(section_key: str) -> int: + """将 '1.2.1' 转为整数 121 用于排序。""" + digits = str(section_key or "").replace(".", "") + return int(digits) if digits.isdigit() else 0 + + +def clamp_text_bytes(text: str, max_bytes: int, *, suffix: str = "\n…(内容过长,已截断)") -> str: + """ + 将文本按 UTF-8 字节数截断到 max_bytes 以内,且不会截断到半个字符。 + 用于适配 MySQL TEXT 列(最大 65535 字节)。 + """ + if not text or max_bytes <= 0: + return text + data = text.encode("utf-8") + if len(data) <= max_bytes: + return text + suffix_bytes = len(suffix.encode("utf-8")) + budget = max(max_bytes - suffix_bytes, 0) + # errors="ignore" 会丢弃末尾被切断的不完整字符,保证是合法 UTF-8 + truncated = data[:budget].decode("utf-8", errors="ignore").rstrip() + return truncated + suffix diff --git a/services/template_prompt_mapper.py b/services/template_prompt_mapper.py new file mode 100644 index 0000000..055377a --- /dev/null +++ b/services/template_prompt_mapper.py @@ -0,0 +1,1239 @@ +from __future__ import annotations + +import logging +import re +from concurrent.futures import ThreadPoolExecutor, as_completed +from typing import Any + +from config import settings +from prompts.report_generation.template_prompt_rules import DEFAULT_SECTION_PROMPT +from services.template_service import ( + _clean_section_title, + _core_title, + _extract_number_prefix, + _guideline_prompt_for, + _normalize_section_identity, + _section_key_to_number, + _title_match_score, + build_default_template_catalog, + default_section_output_contract, +) + +logger = logging.getLogger(__name__) + +_FUZZY_MATCH_THRESHOLD = 40 +_LLM_MATCH_CONFIDENCE = 0.55 + + +def resolve_uploaded_template_prompts( + uploaded_sections: list[dict[str, str]], + *, + use_llm: bool | None = None, +) -> list[dict[str, str]]: + """ + 为上传模版各章节解析 sectionPrompt / sectionOutputContract: + 1. 目录与默认模版完全一致 → 按位套用默认提示词; + 2. 仅编号序列一致、标题不同 → 按标题(结合编号)匹配默认章节并套用其提示词; + 3. 否则本地模糊匹配相同语义标题; + 4. 仍未匹配 → 调用大模型匹配或生成。 + """ + if not uploaded_sections: + return [] + + catalog = build_default_template_catalog() + default_by_key = {row["sectionKey"]: row for row in catalog} + default_by_title = {_normalize_section_identity(row["sectionTitle"]): row for row in catalog} + + uploaded_meta = [_section_meta(s, i) for i, s in enumerate(uploaded_sections)] + default_meta = [_catalog_meta(row, i) for i, row in enumerate(catalog)] + + results: list[dict[str, str] | None] = [None] * len(uploaded_meta) + match_sources: list[str] = [""] * len(uploaded_meta) + matched_default_numbers: list[str | None] = [None] * len(uploaded_meta) + used_default_keys: set[str] = set() + + # 1) 标题完全一致(顺序相同)→ 按索引套用 + if _titles_equal_in_order(uploaded_meta, default_meta): + for i, dm in enumerate(default_meta): + if i >= len(uploaded_meta): + break + results[i] = _prompt_bundle(dm) + match_sources[i] = "exact_title_order" + return _finalize_results( + uploaded_meta, results, match_sources, matched_default_numbers + ) + + # 2) 编号序列一致、仅标题不同 → 按标题(同编号约束)匹配默认节,不按列表位置硬套 + if _numbers_equal_in_order(uploaded_meta, default_meta): + for um in uploaded_meta: + matched = _match_default_by_title_and_number( + um, + default_meta, + used_default_keys=used_default_keys, + same_number_is_enough=True, + ) + if matched: + bundle, src_num = _bundle_from_match_with_inline( + um, matched, uploaded_meta, default_meta + ) + results[um["index"]] = bundle + match_sources[um["index"]] = "same_number_title_match" + matched_default_numbers[um["index"]] = src_num + _mark_default_used(um, matched, used_default_keys) + if all(r is not None for r in results): + return _finalize_results( + uploaded_meta, results, match_sources, matched_default_numbers + ) + + # 3) 本地模糊匹配(按标题 + 编号) + # for um in uploaded_meta: + # if results[um["index"]] is not None: + # continue + # matched = _local_match_default( + # um, + # default_meta, + # default_by_title, + # uploaded_meta, + # used_default_keys=used_default_keys, + # ) + # if matched: + # bundle, src_num = _bundle_from_match_with_inline( + # um, matched, uploaded_meta, default_meta + # ) + # results[um["index"]] = bundle + # match_sources[um["index"]] = "fuzzy_title" + # matched_default_numbers[um["index"]] = src_num + # _mark_default_used(um, matched, used_default_keys) + + # 4) LLM 匹配 / 生成 + unresolved = [um for um in uploaded_meta if results[um["index"]] is None] + llm_enabled = use_llm if use_llm is not None else bool( + getattr(settings, "TEMPLATE_UPLOAD_LLM_PROMPT_MAPPING", True) + ) + if unresolved and llm_enabled and _llm_configured(): + _apply_llm_mapping( + unresolved, + default_meta, + default_by_key, + results, + match_sources, + matched_default_numbers, + uploaded_meta, + ) + + # 5) 兜底:仅细则全文标题命中,否则按上传标题生成通用提示(避免按章号误套默认提示词) + for um in uploaded_meta: + if results[um["index"]] is not None: + continue + title = um["title"] + key = um["key"] + results[um["index"]] = { + "sectionPrompt": _fallback_prompt_for_unmatched(title, key), + "sectionOutputContract": _fallback_contract_for_unmatched(title, key), + } + match_sources[um["index"]] = "fallback_generic" + + return _finalize_results( + uploaded_meta, results, match_sources, matched_default_numbers + ) + + +def _finalize_results( + uploaded_meta: list[dict[str, Any]], + results: list[dict[str, str] | None], + match_sources: list[str], + matched_default_numbers: list[str | None] | None = None, +) -> list[dict[str, str]]: + defaults = matched_default_numbers or [None] * len(uploaded_meta) + + out: list[dict[str, str]] = [] + for um, src in zip(uploaded_meta, defaults): + idx = um["index"] + bundle = results[idx] or { + "sectionPrompt": DEFAULT_SECTION_PROMPT, + "sectionOutputContract": _fallback_contract_for_unmatched(um["title"], um["key"]), + } + prompt = bundle.get("sectionPrompt") or DEFAULT_SECTION_PROMPT + contract = bundle.get("sectionOutputContract") or "" + dst = um.get("number") or "" + if src and dst and src != dst: + leaf = _use_leaf_number_rewrite(contract, src, um, uploaded_meta) + prompt = _rewrite_numbers_and_tables(prompt, src, dst, leaf_slice=leaf) + contract = _rewrite_numbers_and_tables(contract, src, dst, leaf_slice=leaf) + prompt = _adapt_prompt_to_uploaded_structure(prompt, um, uploaded_meta) + contract = _adapt_prompt_to_uploaded_structure(contract, um, uploaded_meta) + out.append( + { + "sectionPrompt": prompt, + "sectionOutputContract": contract, + } + ) + matched = sum(1 for s in match_sources if s and not s.startswith("fallback")) + logger.info( + "template_prompt_mapper: sections=%s matched=%s sources=%s", + len(uploaded_meta), + matched, + {s: match_sources.count(s) for s in set(match_sources) if s}, + ) + return out + + +def _section_meta(section: dict[str, str], index: int) -> dict[str, Any]: + title = str(section.get("sectionTitle") or "").strip() + key = str(section.get("sectionKey") or "").strip() + number = _extract_number_prefix(title) or _section_key_to_number(key) + return { + "index": index, + "key": key, + "title": title, + "number": number, + "norm_title": _normalize_section_identity(title), + "core_title": _core_title(_clean_section_title(title) or title), + } + + +def _catalog_meta(row: dict[str, str], index: int) -> dict[str, Any]: + title = row["sectionTitle"] + key = row["sectionKey"] + return { + "index": index, + "key": key, + "title": title, + "number": row.get("sectionNumber") or _extract_number_prefix(title) or _section_key_to_number(key), + "norm_title": _normalize_section_identity(title), + "core_title": _core_title(_clean_section_title(title) or title), + "sectionPrompt": row["sectionPrompt"], + "sectionOutputContract": row["sectionOutputContract"], + } + + +def _prompt_bundle(dm: dict[str, Any]) -> dict[str, str]: + return { + "sectionPrompt": str(dm.get("sectionPrompt") or ""), + "sectionOutputContract": str(dm.get("sectionOutputContract") or ""), + } + + +def _build_section_remap(src: str, dst: str) -> dict[str, str]: + """单节编号替换(含子编号后缀,如 6.1.1 -> 4.1 则 6.1.1.1 -> 4.1.1)。""" + if not src or not dst or src == dst: + return {} + return {src: dst} + + +def _build_chapter_remap(src: str, dst: str) -> dict[str, str]: + """章级编号替换,用于 表5-1 -> 表3-1 这类表号。""" + if not src or not dst: + return {} + src_ch = src.split(".", 1)[0] + dst_ch = dst.split(".", 1)[0] + if not src_ch.isdigit() or not dst_ch.isdigit() or src_ch == dst_ch: + return {} + return {src_ch: dst_ch} + + +_TABLE_NUM_RE = re.compile(r"表(\d+)-(\d+)") + + +def _rewrite_table_numbers_in_text(text: str, chapter_remap: dict[str, str]) -> str: + if not text or not chapter_remap: + return text + + def _sub(match: re.Match[str]) -> str: + ch, seq = match.group(1), match.group(2) + new_ch = chapter_remap.get(ch) + if new_ch: + return f"表{new_ch}-{seq}" + return match.group(0) + + return _TABLE_NUM_RE.sub(_sub, text) + + +def _rewrite_numbers_and_tables(text: str, src: str, dst: str, *, leaf_slice: bool = False) -> str: + if not text or not src or not dst or src == dst: + return text + if leaf_slice: + text = _rewrite_leaf_subsection_numbers(text, src, dst) + else: + text = _rewrite_section_numbers_in_text(text, _build_section_remap(src, dst)) + chapter_remap = _build_chapter_remap(src, dst) + return _rewrite_table_numbers_in_text(text, chapter_remap) + + +def _top_chapter_number(section_number: str | None) -> int | None: + m = re.match(r"^(\d+)", str(section_number or "").strip()) + return int(m.group(1)) if m else None + + +def _section_number_tuple(section_number: str) -> tuple[int, ...]: + parts = [] + for p in str(section_number or "").strip().split("."): + if p.isdigit(): + parts.append(int(p)) + else: + return tuple() + return tuple(parts) + + +def _direct_child_sections( + all_uploaded: list[dict[str, Any]], parent_number: str +) -> list[dict[str, Any]]: + parent = str(parent_number or "").strip() + if not parent: + return [] + prefix = parent + "." + out: list[dict[str, Any]] = [] + for um in all_uploaded: + num = str(um.get("number") or "").strip() + if not num.startswith(prefix) or num == parent: + continue + suffix = num[len(prefix) :] + if suffix and "." not in suffix: + out.append(um) + out.sort(key=lambda u: _section_number_tuple(str(u.get("number") or ""))) + return out + + +def _preceding_chapters_label( + all_uploaded: list[dict[str, Any]], current_number: str | None +) -> tuple[str, int]: + """返回(第1~N章, N)用于替换默认合同里的「第1~6章」「前六章」。""" + cur_top = _top_chapter_number(current_number) + if cur_top is None: + return "前序章节", 0 + tops = sorted( + { + t + for um in all_uploaded + if (t := _top_chapter_number(um.get("number"))) is not None + } + ) + preced = [t for t in tops if t < cur_top] + if not preced: + return "前序章节", 0 + if len(preced) >= 2 and preced[-1] - preced[0] + 1 == len(preced): + return f"第{preced[0]}~{preced[-1]}章", len(preced) + return "、".join(f"第{t}章" for t in preced), len(preced) + + +_CN_COUNT = ("", "一", "二", "三", "四", "五", "六", "七", "八", "九", "十") + + +def _cn_count(n: int) -> str: + if 0 < n < len(_CN_COUNT): + return _CN_COUNT[n] + return str(n) + + +_CHILDREN_COVER_RE = re.compile(r"(并按顺序完整覆盖下级小节[::])\s*[^。\n;]+") +_PRECEDING_RANGE_RE = re.compile(r"第\d+~\d+章") + + +def _rewrite_preceding_chapter_refs(text: str, range_label: str, chapter_count: int) -> str: + if not text or not range_label: + return text + text = text.replace("【前序章节正文(第1~6章)】", f"【前序章节正文({range_label})】") + text = _PRECEDING_RANGE_RE.sub(range_label, text) + if chapter_count > 0: + cn = _cn_count(chapter_count) + text = re.sub(r"前[一二三四五六七八九十]+章", f"前{chapter_count}章", text) + text = text.replace("前六章", f"前{chapter_count}章") + text = text.replace(f"前{cn}章", f"前{chapter_count}章") + text = text.replace("是对前六章内容的总结", f"是对{range_label}内容的总结") + text = text.replace("是对前6章内容的总结", f"是对{range_label}内容的总结") + return text + + +def _rewrite_children_cover_clause(text: str, child_numbers: list[str]) -> str: + if not text or not child_numbers: + return text + listing = "、".join(child_numbers) + + def _repl(m: re.Match[str]) -> str: + return f"{m.group(1)}{listing}" + + return _CHILDREN_COVER_RE.sub(_repl, text, count=1) + + +def _rewrite_children_outline_block( + text: str, parent_number: str, children: list[dict[str, Any]] +) -> str: + """将提示词里枚举的下级小节列表改为上传模版中的实际子节。""" + if not text or not parent_number or len(children) < 2: + return text + parent = re.escape(parent_number) + line_re = re.compile(rf"^(\s*)(\d+))\s*{parent}\.\d+\s+.+$") + lines = text.splitlines() + out: list[str] = [] + i = 0 + replaced = False + while i < len(lines): + if line_re.match(lines[i].strip()) or ( + lines[i].strip() and re.match(rf"^\d+)\s*{parent}\.\d+", lines[i].strip()) + ): + if not replaced: + for j, um in enumerate(children, 1): + num = str(um.get("number") or "").strip() + label = _clean_section_title(um.get("title") or "") or um.get( + "core_title", "" + ) + out.append(f" {j}){num} {label}") + replaced = True + while i < len(lines) and ( + line_re.match(lines[i].strip()) + or re.match(rf"^\d+)\s*{parent}\.\d+", lines[i].strip()) + ): + i += 1 + continue + out.append(lines[i]) + i += 1 + return "\n".join(out) + + +def _contract_has_inline_child_list(contract: str, parent_num: str) -> bool: + """默认合同把子条写在父节内(如 4.1.1、4.1.2 列表),而非独立章节。""" + if not contract or not parent_num: + return False + if "按顺序固定输出以下" not in contract and "小标题并分别展开" not in contract: + return False + return bool(re.search(rf"{re.escape(parent_num)}\.\d+", contract)) + + +def _parse_inline_child_entries(contract: str, parent_num: str) -> list[dict[str, str]]: + entries: list[dict[str, str]] = [] + for line in str(contract or "").splitlines(): + stripped = line.strip() + m = re.match(rf"^{re.escape(parent_num)}\.(\d+)\s*(.+)$", stripped) + if m: + entries.append( + { + "suffix": m.group(1), + "default_num": f"{parent_num}.{m.group(1)}", + "label": m.group(2).strip(), + } + ) + return entries + + +def _extract_inline_child_guidance(contract: str, child_default_num: str) -> str: + needle = f"在{child_default_num}中" + lines = str(contract or "").splitlines() + for i, line in enumerate(lines): + compact = line.replace(" ", "") + if needle not in compact: + continue + chunk = re.sub(r"^\d+)\s*", "", line.strip()).strip() + for j in range(i + 1, len(lines)): + nxt = lines[j].strip() + if re.match(r"^\d+)", nxt): + break + if nxt: + chunk += nxt + return chunk + return "" + + +def _uploaded_parent_number( + uploaded_num: str | None, all_uploaded: list[dict[str, Any]] +) -> str | None: + parts = str(uploaded_num or "").strip().split(".") + if len(parts) < 2: + return None + parent = ".".join(parts[:-1]) + if any(str(o.get("number") or "").strip() == parent for o in all_uploaded): + return parent + return None + + +def _should_skip_whole_parent_match( + uploaded: dict[str, Any], dm: dict[str, Any], all_uploaded: list[dict[str, Any]] +) -> bool: + """上传模版已拆出子节时,不要把整段父节合同套到叶节上。""" + u_num = str(uploaded.get("number") or "").strip() + d_num = str(dm.get("number") or "").strip() + if not u_num or not d_num or u_num.count(".") <= d_num.count("."): + return False + if not _uploaded_parent_number(u_num, all_uploaded): + return False + return _contract_has_inline_child_list( + str(dm.get("sectionOutputContract") or ""), d_num + ) + + +def _try_inline_child_match( + uploaded: dict[str, Any], + default_meta: list[dict[str, Any]], + all_uploaded: list[dict[str, Any]], +) -> tuple[dict[str, Any], dict[str, str]] | None: + """上传叶节对应默认父节合同中的某一条(如 4.1.1 投产组织)。""" + parent_num = _uploaded_parent_number(uploaded.get("number"), all_uploaded) + if not parent_num: + return None + u_num = str(uploaded.get("number") or "").strip() + child_suffix = u_num[len(parent_num) + 1 :] + if not child_suffix or "." in child_suffix or not child_suffix.isdigit(): + return None + + parent_um = next( + (o for o in all_uploaded if str(o.get("number") or "").strip() == parent_num), + None, + ) + u_core = uploaded["core_title"] + + # 1) 上传父节标题 + 子节序号对齐(如 2.3.1.1 ↔ 4.1.1) + if parent_um: + for dm in default_meta: + pnum = str(dm.get("number") or "").strip() + contract = str(dm.get("sectionOutputContract") or "") + if not _contract_has_inline_child_list(contract, pnum): + continue + parent_score = _title_match_score(parent_um["core_title"], dm["core_title"]) + if parent_score < 35: + continue + for entry in _parse_inline_child_entries(contract, pnum): + if entry["suffix"] != child_suffix: + continue + child_score = _title_match_score(u_core, _core_title(entry["label"])) + if child_score >= 12 or parent_score >= 50: + return dm, entry + + # 2) 按子条标题模糊匹配(措辞略异时阈值放宽) + best_dm: dict[str, Any] | None = None + best_entry: dict[str, str] | None = None + best_score = -1 + for dm in default_meta: + pnum = str(dm.get("number") or "").strip() + contract = str(dm.get("sectionOutputContract") or "") + if not _contract_has_inline_child_list(contract, pnum): + continue + for entry in _parse_inline_child_entries(contract, pnum): + score = _title_match_score(u_core, _core_title(entry["label"])) + if entry["suffix"] == child_suffix: + score += 15 + if score > best_score: + best_score = score + best_dm = dm + best_entry = entry + if best_score < 20 or not best_dm or not best_entry: + return None + return best_dm, best_entry + + +def _build_inline_child_contract( + uploaded: dict[str, Any], + parent_dm: dict[str, Any], + entry: dict[str, str], +) -> str: + uploaded_num = str(uploaded.get("number") or "").strip() + label = _clean_section_title(uploaded.get("title") or "") or uploaded["core_title"] + guidance = _extract_inline_child_guidance( + str(parent_dm.get("sectionOutputContract") or ""), + entry["default_num"], + ) + body = guidance or f"围绕「{label}」撰写本段内容,依据证据材料,缺失写「待补充」,禁止编造。" + return ( + "必须严格按以下格式与顺序输出,不得缺项、不得改名:\n" + f'1)首行固定输出标题:"{uploaded_num} {label}"。\n' + f"2){body}\n" + "【写作约束】\n" + "不得新增无关小标题;不得写入同级其他小条目的内容;证据不足处写「待补充」,禁止编造。" + ) + + +def _adapt_prompt_to_uploaded_structure( + text: str, + section: dict[str, Any], + all_uploaded: list[dict[str, Any]], +) -> str: + if not text: + return text + num = str(section.get("number") or "").strip() + # 叶节合同不应再展开父节内嵌子条列表 + if _is_leaf_subsection_contract(text): + return text + children = _direct_child_sections(all_uploaded, num) + if children and "按顺序固定输出以下" in text: + child_nums = [str(c.get("number") or "") for c in children] + text = _rewrite_children_cover_clause(text, child_nums) + text = _rewrite_children_outline_block(text, num, children) + range_label, count = _preceding_chapters_label(all_uploaded, num) + if count > 0 and _top_chapter_number(num) is not None: + text = _rewrite_preceding_chapter_refs(text, range_label, count) + return text + + +def _bundle_from_match_with_inline( + uploaded: dict[str, Any], + matched: dict[str, Any], + all_uploaded: list[dict[str, Any]], + default_meta: list[dict[str, Any]], +) -> tuple[dict[str, str], str]: + inline_hit = _try_inline_child_match(uploaded, default_meta, all_uploaded) + inline_entry = inline_hit[1] if inline_hit else None + parent_dm = inline_hit[0] if inline_hit else matched + return _bundle_from_default_match( + uploaded, parent_dm, all_uploaded, inline_entry=inline_entry + ) + + +def _mark_default_used( + uploaded: dict[str, Any], + default: dict[str, Any], + used_default_keys: set[str], +) -> None: + """同一默认父节可被多个上传子节切片复用,仅整节独占时标记已用。""" + u_core = uploaded.get("core_title") or "" + d_core = default.get("core_title") or "" + if u_core == d_core or _title_match_score(u_core, d_core) >= 58: + used_default_keys.add(default["key"]) + + +def _bundle_from_default_match( + uploaded: dict[str, Any], + default: dict[str, Any], + all_uploaded: list[dict[str, Any]], + *, + inline_entry: dict[str, str] | None = None, +) -> tuple[dict[str, str], str]: + """按标题从默认节取提示词;子节从父节合同中切片,父节去掉已单独成节的内容。""" + src_num = str(default.get("number") or "") + if inline_entry: + contract = _build_inline_child_contract(uploaded, default, inline_entry) + child_src = inline_entry["default_num"] + return ( + { + "sectionPrompt": contract, + "sectionOutputContract": contract, + }, + child_src, + ) + + prompt = str(default.get("sectionPrompt") or "") + contract = str(default.get("sectionOutputContract") or "") + + u_core = uploaded["core_title"] + d_core = default["core_title"] + title_score = _title_match_score(u_core, d_core) if u_core and d_core else 0 + + subsection = _extract_subsection_from_contract(contract, u_core) + if subsection and u_core != d_core and title_score < 58: + label = _clean_section_title(uploaded["title"]) or u_core + num = uploaded.get("number") or "" + heading = f"{num} {label}".strip() if num else label + body = _strip_redundant_subsection_heading(subsection, u_core) + contract = ( + "必须严格按以下格式与顺序输出,不得缺项、不得改名:\n" + f'1)首行固定输出标题:"{heading}"。\n' + f"{body}" + ) + if not prompt.strip() or len(prompt) < 80: + prompt = contract + + if _has_uploaded_children(uploaded, all_uploaded): + contract = _trim_parent_contract_for_children(contract, src_num) + if prompt == str(default.get("sectionPrompt") or ""): + prompt = contract + + return ( + { + "sectionPrompt": prompt or DEFAULT_SECTION_PROMPT, + "sectionOutputContract": contract, + }, + src_num, + ) + + +def _has_uploaded_children(section: dict[str, Any], all_uploaded: list[dict[str, Any]]) -> bool: + prefix = str(section.get("number") or "").strip() + if not prefix: + return False + child_prefix = prefix + "." + for other in all_uploaded: + num = str(other.get("number") or "") + if num.startswith(child_prefix) and num != prefix: + return True + return False + + +def _extract_subsection_from_contract(contract: str, core_title: str) -> str | None: + if not contract or not core_title: + return None + core = str(core_title).strip() + if core not in contract: + return None + blocks = re.split(r"(?=\d+)固定输出小节标题)", contract) + matched: list[str] = [] + for block in blocks: + if core in block and "固定输出小节标题" in block: + matched.append(block.strip()) + if not matched: + return None + if len(matched) == 1: + return matched[0] + + # 多个同名子节(如「效果及影响」)取与环境/监测更相关的一块 + for block in matched: + if any(k in block for k in ("废气", "废水", "噪声监测", "环保措施")): + return block + return matched[0] + + +def _strip_redundant_subsection_heading(subsection: str, core_title: str) -> str: + """ + 去掉切片里与节标题重复的「固定输出小节标题」行,正文从 2)起编号。 + 例:3)固定输出小节标题:"x.x.x 环保措施",并在该小节下… → 2)并在该小节下… + """ + if not subsection: + return "" + core = str(core_title).strip() + out_lines: list[str] = [] + for line in subsection.splitlines(): + stripped = line.strip() + if not stripped: + if out_lines: + out_lines.append(line) + continue + if "固定输出小节标题" in stripped and (not core or core in stripped): + m = re.search( + r'固定输出小节标题\s*[::]\s*["\u201c][^"\u201d]+["\u201d]\s*[,,]?\s*(.*)$', + stripped, + ) + tail = (m.group(1) if m else "").strip() + if tail: + out_lines.append(f"2){tail}") + continue + if re.match(r"^\d+)固定输出小节标题", stripped): + continue + out_lines.append(line) + body = "\n".join(out_lines).strip() + body = _trim_parent_tail_from_subsection(body) + if body and not re.match(r"^\d+)", body): + body = f"2){body}" + return body + + +def _trim_parent_tail_from_subsection(body: str) -> str: + """去掉误带入的父节收尾条款(如整节「后评价认为」结论)。""" + if not body: + return "" + kept: list[str] = [] + for line in body.splitlines(): + stripped = line.strip() + if re.match(r"^5)末尾必须以", stripped): + break + if stripped.startswith("【写作约束】"): + break + kept.append(line) + return "\n".join(kept).strip() + + +def _is_leaf_subsection_contract(contract: str) -> bool: + """叶节合同:已有首行标题,且不再以「固定输出小节标题」开头。""" + text = (contract or "").lstrip() + if not text.startswith("必须严格"): + return False + if "按顺序固定输出以下" in text and "小标题并分别展开" in text: + return False + return "首行固定输出标题" in text[:200] and not re.search( + r"^2)固定输出小节标题", text, re.MULTILINE + ) + + +def _use_leaf_number_rewrite( + contract: str, + src: str, + uploaded: dict[str, Any], + all_uploaded: list[dict[str, Any]], +) -> bool: + """仅对真正叶节切片使用「整段替换为叶节编号」;含内嵌子条列表的父节不用。""" + if _contract_has_inline_child_list(contract, src): + return False + if _has_uploaded_children(uploaded, all_uploaded): + return False + return _is_leaf_subsection_contract(contract) + + +def _rewrite_leaf_subsection_numbers(text: str, src_root: str, dst_leaf: str) -> str: + """子节切片:将默认父节下所有编号(6.1.1.x)统一替换为上传叶节编号(4.1.1)。""" + if not text or not src_root or not dst_leaf: + return text + + def _sub(match: re.Match[str]) -> str: + num = match.group(1) + if num == src_root or num.startswith(src_root + "."): + return dst_leaf + return match.group(0) + + return _SECTION_NUM_IN_TEXT_RE.sub(_sub, text) + + +def _trim_parent_contract_for_children( + contract: str, parent_num: str | None = None +) -> str: + m = re.search(r"\d+)固定输出小节标题", contract) + if m: + trimmed = contract[: m.start()].rstrip() + return trimmed if trimmed else contract + if parent_num and _contract_has_inline_child_list(contract, parent_num): + return _trim_inline_parent_contract_for_children(contract, parent_num) + return contract + + +def _trim_inline_parent_contract_for_children(contract: str, parent_num: str) -> str: + """父节内嵌子条已单独成节时,去掉各子条撰写细则,保留总述与总结。""" + lines = str(contract or "").splitlines() + kept: list[str] = [] + for line in lines: + stripped = line.strip() + if re.match(r"^[3-7])", stripped) and f"在{parent_num}." in stripped.replace(" ", ""): + continue + if re.match(r"^[3-7])", stripped) and f"在{parent_num}中" in stripped.replace(" ", ""): + continue + kept.append(line) + return "\n".join(kept).strip() or contract + + +def _remap_single_number(num: str, remap: dict[str, str]) -> str: + if not num or not remap: + return num + if num in remap: + return remap[num] + parts = num.split(".") + for end in range(len(parts) - 1, 0, -1): + prefix = ".".join(parts[:end]) + if prefix in remap: + return remap[prefix] + num[len(prefix) :] + return num + + +_SECTION_NUM_IN_TEXT_RE = re.compile(r"(? str: + if not text or not remap: + return text + + def _sub(match: re.Match[str]) -> str: + num = match.group(1) + new_num = _remap_single_number(num, remap) + return new_num if new_num != num else match.group(0) + + return _SECTION_NUM_IN_TEXT_RE.sub(_sub, text) + + +def _titles_equal_in_order( + uploaded: list[dict[str, Any]], + default: list[dict[str, Any]], +) -> bool: + if len(uploaded) != len(default): + return False + for u, d in zip(uploaded, default): + if u["norm_title"] != d["norm_title"]: + return False + return True + + +def _numbers_equal_in_order( + uploaded: list[dict[str, Any]], + default: list[dict[str, Any]], +) -> bool: + if len(uploaded) != len(default): + return False + for u, d in zip(uploaded, default): + if (u["number"] or "") != (d["number"] or ""): + return False + return True + + +def _match_default_by_title_and_number( + uploaded: dict[str, Any], + default_meta: list[dict[str, Any]], + *, + used_default_keys: set[str] | None = None, + same_number_is_enough: bool = False, +) -> dict[str, Any] | None: + """ + 按上传标题中的章节编号定位默认目录中的对应节,再按标题语义择优。 + same_number_is_enough:编号序列已与默认一致时,同编号唯一默认节直接套用(标题仅措辞不同)。 + """ + used = used_default_keys or set() + u_num = uploaded["number"] + u_core = uploaded["core_title"] + + candidates = [ + dm + for dm in default_meta + if dm["key"] not in used and (not u_num or dm["number"] == u_num) + ] + if not candidates: + return None + + if len(candidates) == 1 and u_num and candidates[0]["number"] == u_num: + d_core = candidates[0]["core_title"] + if same_number_is_enough: + if _titles_topic_compatible(u_core, d_core): + return candidates[0] + return None + title_score = _title_match_score(u_core, d_core) + if title_score >= 20: + return candidates[0] + return None + + best: dict[str, Any] | None = None + best_score = -1 + for dm in candidates: + title_score = _title_match_score(u_core, dm["core_title"]) + if title_score < _FUZZY_MATCH_THRESHOLD: + continue + score = title_score + (20 if u_num and dm["number"] == u_num else 0) + if score > best_score: + best_score = score + best = dm + return best + + +def _local_match_default( + uploaded: dict[str, Any], + default_meta: list[dict[str, Any]], + default_by_title: dict[str, dict[str, str]], + all_uploaded: list[dict[str, Any]], + *, + used_default_keys: set[str] | None = None, +) -> dict[str, Any] | None: + if uploaded["norm_title"] in default_by_title: + row = default_by_title[uploaded["norm_title"]] + dm = _catalog_meta(row, -1) + if not used_default_keys or dm["key"] not in used_default_keys: + return dm + + inline_hit = _try_inline_child_match(uploaded, default_meta, all_uploaded) + if inline_hit: + parent_dm, _entry = inline_hit + return parent_dm + + subsection_parent = _find_subsection_parent(uploaded, default_meta, used_default_keys) + if subsection_parent: + return subsection_parent + + return _match_default_by_title_semantic( + uploaded, default_meta, all_uploaded, used_default_keys=used_default_keys + ) + + +def _find_subsection_parent( + uploaded: dict[str, Any], + default_meta: list[dict[str, Any]], + used_default_keys: set[str] | None, +) -> dict[str, Any] | None: + """上传节为子标题(如 环保措施),在默认父节合同中找到对应切片时匹配父节(可复用同一父节)。""" + u_core = uploaded["core_title"] + if not u_core: + return None + + best: dict[str, Any] | None = None + best_score = -1 + for dm in default_meta: + contract = str(dm.get("sectionOutputContract") or "") + subsection = _extract_subsection_from_contract(contract, u_core) + if not subsection: + continue + if _title_match_score(u_core, dm["core_title"]) >= 58: + continue + score = _title_match_score(u_core, dm["core_title"]) + if "环境" in u_core or "环保" in u_core: + if "环境" in dm["core_title"] or "环保" in dm["core_title"]: + score += 30 + if "安全" in dm["core_title"]: + score -= 25 + if "监测" in subsection or "废气" in subsection: + if "环境" in dm["core_title"]: + score += 15 + if score > best_score: + best_score = score + best = dm + return best + + +def _match_default_by_title_semantic( + uploaded: dict[str, Any], + default_meta: list[dict[str, Any]], + all_uploaded: list[dict[str, Any]], + *, + used_default_keys: set[str] | None = None, +) -> dict[str, Any] | None: + used = used_default_keys or set() + u_core = uploaded["core_title"] + if not u_core: + return None + + u_num = str(uploaded.get("number") or "") + min_score = 55 if u_num and "." not in u_num else _FUZZY_MATCH_THRESHOLD + + best: dict[str, Any] | None = None + best_score = -1 + for dm in default_meta: + if dm["key"] in used: + continue + d_core = dm["core_title"] + title_score = _title_match_score(u_core, d_core) + if u_core == d_core: + return dm + if _should_skip_whole_parent_match(uploaded, dm, all_uploaded): + continue + if title_score < 45 and not _titles_topic_compatible(u_core, d_core): + continue + if title_score < 45: + continue + if title_score > best_score: + best_score = title_score + best = dm + return best if best_score >= min_score else None + + +_GENERIC_TITLE_FRAGS = frozenset( + { + "评价", + "分析", + "结论", + "建议", + "概况", + "情况", + "说明", + "管理", + "工作", + } +) + + +def _titles_topic_compatible(uploaded_core: str, default_core: str) -> bool: + """判断两节标题是否同一主题(措辞略异为真,换题为假)。""" + if not uploaded_core or not default_core: + return False + if _title_match_score(uploaded_core, default_core) >= 12: + return True + tks_u = set(re.findall(r"[\u4e00-\u9fa5]{2,8}", uploaded_core)) - _GENERIC_TITLE_FRAGS + tks_d = set(re.findall(r"[\u4e00-\u9fa5]{2,8}", default_core)) - _GENERIC_TITLE_FRAGS + if tks_u & tks_d: + return True + for n in (4, 3, 2): + for i in range(len(uploaded_core) - n + 1): + frag = uploaded_core[i : i + n] + if frag in _GENERIC_TITLE_FRAGS: + continue + if frag in default_core: + return True + return False + + +def _fallback_prompt_for_unmatched(title: str, section_key: str | None) -> str: + guideline = _guideline_prompt_for(title, section_key) + if guideline: + return guideline + return _fallback_contract_for_unmatched(title, section_key) + + +def _fallback_contract_for_unmatched(title: str, section_key: str | None) -> str: + label = _clean_section_title(title) or str(title or "").strip() or "本章节" + num = _extract_number_prefix(title) or _section_key_to_number(section_key) + heading = f"{num} {label}".strip() if num else label + return ( + f"必须严格按以下要求输出:\n" + f'1)首行固定输出标题:"{heading}"。\n' + f"2)正文围绕「{label}」撰写,结构须与本节标题一致,先事实后评价。\n" + f"3)依据证据材料,缺失写「待补充」,禁止编造。" + ) + + +def _llm_configured() -> bool: + return bool( + (settings.LLM_API_BASE or "").strip() + and (settings.LLM_API_KEY or "").strip() + and (settings.LLM_MODEL_NAME or "").strip() + ) + + +_LLM_MAPPING_SYSTEM_PROMPT = ( + "你是炼油化工建设项目后评价报告模版专家。" + "任务:判断上传模版章节能否复用系统默认章节的撰写提示词,并为无法复用的章节生成简短提示词。" + "只输出 JSON object,不要解释。" +) + + +def _build_llm_mapping_user_prompt( + default_meta: list[dict[str, Any]], + default_lines: list[str], + batch: list[dict[str, Any]], +) -> str: + upload_lines = [ + f'- index={um["index"]} number={um["number"]} title={um["title"]} core={um["core_title"]}' + for um in batch + ] + return f"""系统默认模版共 {len(default_meta)} 节(节选提示词预览): +{chr(10).join(default_lines[:120])} + +待处理的上传章节(index 为上传列表下标): +{chr(10).join(upload_lines)} + +请返回 JSON: +{{ + "structure_compatible": true/false, + "matches": [ + {{"upload_index": 0, "default_key": "3-1", "confidence": 0.0-1.0}} + ], + "generated": [ + {{ + "upload_index": 5, + "section_prompt": "200字以内的章节撰写要求,面向后评价报告,缺失写待补充,禁止编造", + "section_output_contract": "可选,100字以内的输出结构约束;不需要可空字符串" + }} + ] +}} + +规则: +1. structure_compatible:上传模版与默认模版目录层级、编号体系一致且仅标题措辞略异时为 true。 +2. matches:语义与默认某节相同或高度相近时,填写 default_key(必须来自默认列表的 key);confidence>=0.55 才有效。 +3. generated:无法对应默认章节时,根据上传标题写 section_prompt;contract 可简述需含表格/小节等。 +4. 同一 upload_index 只出现在 matches 或 generated 之一;不要重复。 +5. 禁止编造与标题无关的细则内容。""" + + +def _apply_llm_mapping( + unresolved: list[dict[str, Any]], + default_meta: list[dict[str, Any]], + default_by_key: dict[str, dict[str, str]], + results: list[dict[str, str] | None], + match_sources: list[str], + matched_default_numbers: list[str | None], + all_uploaded_meta: list[dict[str, Any]], +) -> None: + """把未匹配章节分批并行调用 LLM,再统一合并结果。 + + 单次大请求的耗时随待生成条目数线性增长;分批后每个请求输出更小、可并行, + 显著缩短整体等待时间(LLM 调用为网络 I/O,多线程下真正并行)。 + """ + try: + from services.llm_client import chat_completions_json + except Exception as e: + logger.warning("template_prompt_mapper: llm import failed: %s", e) + return + + default_lines = [] + for dm in default_meta: + prompt_preview = re.sub(r"\s+", " ", str(dm.get("sectionPrompt") or ""))[:240] + default_lines.append( + f'- key={dm["key"]} number={dm["number"]} title={dm["title"]} ' + f'prompt_preview="{prompt_preview}"' + ) + + batch_size = max(int(getattr(settings, "TEMPLATE_UPLOAD_LLM_BATCH_SIZE", 8) or 8), 1) + max_workers = max(int(getattr(settings, "TEMPLATE_UPLOAD_LLM_MAX_WORKERS", 4) or 4), 1) + max_tokens = int(getattr(settings, "TEMPLATE_UPLOAD_LLM_MAX_TOKENS", 4096) or 4096) + timeout_sec = int(getattr(settings, "LLM_HTTP_TIMEOUT_SEC", 120) or 120) + + batches = [unresolved[i : i + batch_size] for i in range(0, len(unresolved), batch_size)] + + def _run_batch(batch: list[dict[str, Any]]) -> dict: + user_prompt = _build_llm_mapping_user_prompt(default_meta, default_lines, batch) + try: + return chat_completions_json( + system_prompt=_LLM_MAPPING_SYSTEM_PROMPT, + user_prompt=user_prompt, + temperature=0.1, + max_tokens=max_tokens, + timeout_sec=timeout_sec, + ) + except Exception as e: # noqa: BLE001 + logger.warning("template_prompt_mapper: llm batch call failed: %s", e) + return {} + + collected: list[dict] = [] + if len(batches) <= 1: + collected = [_run_batch(b) for b in batches] + else: + workers = min(max_workers, len(batches)) + with ThreadPoolExecutor(max_workers=workers) as executor: + futures = [executor.submit(_run_batch, b) for b in batches] + for fut in as_completed(futures): + collected.append(fut.result()) + logger.info( + "template_prompt_mapper: llm 并行匹配 | 待处理=%s | 批数=%s | 线程=%s", + len(unresolved), len(batches), workers, + ) + + for data in collected: + if isinstance(data, dict): + _merge_llm_mapping_response( + data, + unresolved, + default_by_key, + results, + match_sources, + matched_default_numbers, + all_uploaded_meta, + default_meta, + ) + + +def _merge_llm_mapping_response( + data: dict, + unresolved: list[dict[str, Any]], + default_by_key: dict[str, dict[str, str]], + results: list[dict[str, str] | None], + match_sources: list[str], + matched_default_numbers: list[str | None], + all_uploaded_meta: list[dict[str, Any]], + default_meta: list[dict[str, Any]], +) -> None: + for item in data.get("matches") or []: + if not isinstance(item, dict): + continue + try: + idx = int(item.get("upload_index")) + except (TypeError, ValueError): + continue + if idx < 0 or idx >= len(results) or results[idx] is not None: + continue + try: + conf = float(item.get("confidence") or 0) + except (TypeError, ValueError): + conf = 0.0 + if conf < _LLM_MATCH_CONFIDENCE: + continue + default_key = str(item.get("default_key") or "").strip() + row = default_by_key.get(default_key) + if not row: + continue + dm = _catalog_meta(row, -1) + um = next((u for u in unresolved if u["index"] == idx), None) + if um: + bundle, src_num = _bundle_from_match_with_inline( + um, dm, all_uploaded_meta, default_meta + ) + results[idx] = bundle + matched_default_numbers[idx] = src_num + else: + results[idx] = _prompt_bundle(dm) + matched_default_numbers[idx] = dm.get("number") or "" + match_sources[idx] = "llm_match" + + for item in data.get("generated") or []: + if not isinstance(item, dict): + continue + try: + idx = int(item.get("upload_index")) + except (TypeError, ValueError): + continue + if idx < 0 or idx >= len(results) or results[idx] is not None: + continue + prompt = str(item.get("section_prompt") or "").strip() + contract = str(item.get("section_output_contract") or "").strip() + if not prompt: + continue + um = next((u for u in unresolved if u["index"] == idx), None) + title = um["title"] if um else "" + key = um["key"] if um else "" + results[idx] = { + "sectionPrompt": prompt, + "sectionOutputContract": contract or default_section_output_contract(title, key), + } + match_sources[idx] = "llm_generated" diff --git a/services/template_service.py b/services/template_service.py new file mode 100644 index 0000000..7410312 --- /dev/null +++ b/services/template_service.py @@ -0,0 +1,524 @@ +""" +services/template_service.py +复刻自 eval_report:report_template_sections 数据的获取方式。 + +- DEFAULT_TEMPLATE_SECTIONS:系统默认后评价报告章节目录(key, title) +- default_section_prompt / default_section_output_contract / default_section_examples: + 按章节标题/编号取对应提示词、输出合同、示例 +- build_default_template_catalog:默认目录 + 提示词/合同(供上传模版匹配) + +说明:eval_report 会额外从《编制细则》与《模版》Word 文档抽取更细的提示词/示例; +本项目默认不含这两个 .doc 文件与 DocParser,故相关函数在缺文件时优雅降级, +回退到 SECTION_PROMPT_RULES / SECTION_EXAMPLE_RULES。 +""" + +from __future__ import annotations + +import re +import uuid +from datetime import datetime +from functools import lru_cache +from pathlib import Path + +from sqlalchemy.orm import Session + +from database.models import ReportTemplate, ReportTemplateSection +from prompts.report_generation.section_output_contracts import ( + DEFAULT_SECTION_OUTPUT_CONTRACT, + SECTION_OUTPUT_CONTRACTS, +) +from prompts.report_generation.template_prompt_rules import ( + DEFAULT_SECTION_PROMPT, + SECTION_EXAMPLE_RULES, + SECTION_PROMPT_RULES, +) + +SYSTEM_DEFAULT_TEMPLATE_NAME = "后评价默认模板" +GUIDELINE_BASENAME = "炼油化工建设项目后评价报告编制细则(修订)" +PROJECT_EXAMPLE_BASENAME = "模版" +MAX_SECTION_EXAMPLE_CHARS = 12000 + +DEFAULT_TEMPLATE_SECTIONS: list[tuple[str, str]] = [ + ("1", "1 项目概况"), + ("1-1", "1.1 项目基本情况"), + ("1-2", "1.2 项目决策要点"), + ("1-3", "1.3 项目实施情况"), + ("1-4", "1.4 项目运行情况"), + ("2", "2 前期工作评价"), + ("2-1", "2.1 项目要素评价"), + ("2-1-1", "2.1.1 资源与原料评价"), + ("2-1-2", "2.1.2 产品方案及市场评价"), + ("2-1-2-1", "2.1.2.1 产品方案评价"), + ("2-1-2-2", "2.1.2.2 产品市场评价"), + ("2-1-3", "2.1.3 工艺方案评价"), + ("2-1-3-1", "2.1.3.1 总加工方案评价"), + ("2-1-3-2", "2.1.3.2 建设规模及工艺技术方案评价"), + ("2-1-3-3", "2.1.3.3 主要设备方案评价"), + ("2-1-4", "2.1.4 厂址选择及外部条件评价"), + ("2-1-5", "2.1.5 总图及系统配套工程评价"), + ("2-1-6", "2.1.6 主要技术指标评价"), + ("2-1-7", "2.1.7 风险分析评价"), + ("2-2", "2.2 工作程序评价"), + ("2-2-1", "2.2.1 编制单位资质及选择方式评价"), + ("2-2-2", "2.2.2 编制进度评价"), + ("2-2-3", "2.2.3 与专项评价的结合情况"), + ("2-2-4", "2.2.4 可行性研究报告的质量评价"), + ("2-3", "2.3 前评估工作评价"), + ("2-4", "2.4 初步设计评价"), + ("2-4-1", "2.4.1 设计单位资质及选择方式评价"), + ("2-4-2", "2.4.2 初步设计进度评价"), + ("2-4-3", "2.4.3 初步设计质量评价"), + ("2-4-4", "2.4.4 初步设计审查工作评价"), + ("2-5", "2.5 前期决策程序评价"), + ("2-6", "2.6 前期工作评价结论"), + ("3", "3 建设实施评价"), + ("3-1", "3.1 工程建设管理模式评价"), + ("3-2", "3.2 招投标评价"), + ("3-3", "3.3 施工图设计评价"), + ("3-3-1", "3.3.1 与批复后初步设计符合性评价"), + ("3-3-2", "3.3.2 设计进度评价"), + ("3-3-3", "3.3.3 施工图设计水平及质量评价"), + ("3-3-4", "3.3.4 施工图设计变更管理评价"), + ("3-4", "3.4 工程承包商或施工单位评价"), + ("3-4-1", "3.4.1 施工准备评价"), + ("3-4-2", "3.4.2 施工计划的执行情况"), + ("3-5", "3.5 采购工作评价"), + ("3-6", "3.6 工程监理评价"), + ("3-7", "3.7 工程质量评价"), + ("3-8", "3.8 HSE管理评价"), + ("3-9", "3.9 三查四定及中间交接"), + ("3-10", "3.10 工程竣工验收评价"), + ("3-11", "3.11 建设实施评价结论"), + ("4", "4 生产运行评价"), + ("4-1", "4.1 生产准备评价"), + ("4-2", "4.2 联合试运与试生产情况评价"), + ("4-3", "4.3 生产运行评价"), + ("4-3-1", "4.3.1 原料供应评价"), + ("4-3-2", "4.3.2 生产运行总体情况评价"), + ("4-3-3", "4.3.3 达标评价"), + ("4-3-4", "4.3.4 生产工艺技术评价"), + ("4-3-5", "4.3.5 设备运行评价"), + ("4-3-6", "4.3.6 公用工程及辅助设施合理性评价"), + ("4-4", "4.4 生产运行评价结论"), + ("5", "5 投资与经济效益评价"), + ("5-1", "5.1 主要经济指标实现程度评价"), + ("5-2", "5.2 投资和执行情况评价"), + ("5-2-1", "5.2.1 投资控制及变动原因分析"), + ("5-2-2", "5.2.2 投资水平分析"), + ("5-2-3", "5.2.3 资金来源及到位评价"), + ("5-2-4", "5.2.4 投资控制的经验和教训"), + ("5-3", "5.3 经济效益分析"), + ("5-3-1", "5.3.1 项目投产以来生产经营及效益状况"), + ("5-3-2", "5.3.2 项目经济效益后评价"), + ("5-4", "5.4 不确定性分析"), + ("5-5", "5.5 投资与经济效益评价结论"), + ("6", "6 影响与持续性评价"), + ("6-1", "6.1 影响评价"), + ("6-1-1", "6.1.1 环境影响评价"), + ("6-1-2", "6.1.2 安全影响评价"), + ("6-1-3", "6.1.3 科技进步影响"), + ("6-1-4", "6.1.4 项目社会影响评价"), + ("6-1-5", "6.1.5 项目影响评价结论"), + ("6-2", "6.2 持续性评价"), + ("6-2-1", "6.2.1 资源分析"), + ("6-2-2", "6.2.2 产品分析"), + ("6-2-3", "6.2.3 主要技术及经济指标对比"), + ("6-2-4", "6.2.4 项目持续性评价结论"), + ("7", "7 综合评价结论"), + ("7-1", "7.1 综合评价结论"), + ("7-1-1", "7.1.1 总体评价结论"), + ("7-1-2", "7.1.2 成功度评价"), + ("7-2", "7.2 主要经验"), + ("7-3", "7.3 问题与建议"), +] + + +def default_section_output_contract(section_title: str, section_key: str | None = None) -> str: + section_no = _extract_number_prefix(section_title) or _section_key_to_number(section_key) + if section_no and section_no in SECTION_OUTPUT_CONTRACTS: + return SECTION_OUTPUT_CONTRACTS[section_no] + return DEFAULT_SECTION_OUTPUT_CONTRACT + + +def default_section_prompt(section_title: str, section_key: str | None = None) -> str: + guideline_prompt = _guideline_prompt_for(section_title, section_key) + if guideline_prompt: + return guideline_prompt + + title = _normalize_section_identity(section_title) + key = str(section_key or "").strip().lower() + for pattern, prompt in SECTION_PROMPT_RULES: + p = pattern.lower() + if title.startswith(p): + return prompt + if p.isdigit() and (title.startswith(f"{p} ") or key.startswith(f"{p}-") or key == p): + return prompt + return DEFAULT_SECTION_PROMPT + + +def build_default_template_catalog() -> list[dict[str, str]]: + """系统默认模板章节目录及对应提示词、输出合同(供上传模版匹配)。""" + out: list[dict[str, str]] = [] + for key, title in DEFAULT_TEMPLATE_SECTIONS: + out.append( + { + "sectionKey": key, + "sectionTitle": title, + "sectionNumber": _extract_number_prefix(title) or _section_key_to_number(key), + "sectionPrompt": default_section_prompt(title, key), + "sectionOutputContract": default_section_output_contract(title, key), + } + ) + return out + + +def default_section_examples(section_title: str, section_key: str | None = None) -> str: + project_example = _project_example_for(section_title, section_key) + if project_example: + return project_example + + title = _normalize_section_identity(section_title) + key = str(section_key or "").strip().lower() + num = _extract_number_prefix(section_title) or _section_key_to_number(section_key) + chapter_no = "" + if num: + chapter_no = num.split(".")[0] + elif key: + chapter_no = key.split("-")[0] + for prefix, examples in SECTION_EXAMPLE_RULES: + p = str(prefix).strip().lower() + if chapter_no == p: + return examples + if title.startswith(f"{p} "): + return examples + if key.startswith(f"{p}-") or key == p: + return examples + return "" + + +def _normalize_section_identity(value: str | None) -> str: + text = str(value or "").strip().lower() + text = text.replace(".", ".").replace("。", ".") + text = re.sub(r"\s+", " ", text) + return text + + +def _section_key_to_number(section_key: str | None) -> str: + key = str(section_key or "").strip() + if not key: + return "" + if re.fullmatch(r"\d+(?:-\d+)*", key): + return key.replace("-", ".") + return "" + + +def _extract_number_prefix(title: str) -> str: + m = re.match(r"^\s*(\d+(?:\.\d+)*)\s*", str(title or "")) + return m.group(1) if m else "" + + +def _normalize_heading_key(value: str) -> str: + s = str(value or "").strip().lower() + s = s.replace(".", ".").replace("。", ".") + s = re.sub(r"\s+", "", s) + return s + + +def _tuple_from_number(number_str: str) -> tuple[int, ...]: + if not number_str: + return tuple() + parts = [] + for p in number_str.split("."): + if p.isdigit(): + parts.append(int(p)) + else: + return tuple() + return tuple(parts) + + +def _read_doc_text(path: str) -> str: + """读取 .doc/.docx 文本。本项目无 DocParser 时返回空串(优雅降级)。""" + try: + from function.documents.doc_parser import DocParser # type: ignore + except Exception: + return "" + try: + return DocParser(path).read() + except Exception: + return "" + + +@lru_cache(maxsize=1) +def _guideline_section_prompt_map() -> dict[str, str]: + guideline_path = _resolve_guideline_path() + if not guideline_path: + return {} + raw_text = _read_doc_text(guideline_path) + if not raw_text: + return {} + return _build_guideline_prompt_map(raw_text) + + +def _resolve_guideline_path() -> str | None: + root = Path(__file__).resolve().parents[1] + candidates = [ + root / f"{GUIDELINE_BASENAME}.doc", + root / f"{GUIDELINE_BASENAME}.docx", + ] + for p in candidates: + if p.is_file(): + return str(p) + return None + + +def _resolve_project_example_path() -> str | None: + root = Path(__file__).resolve().parents[1] + candidates = [ + root / f"{PROJECT_EXAMPLE_BASENAME}.doc", + root / f"{PROJECT_EXAMPLE_BASENAME}.docx", + ] + for p in candidates: + if p.is_file(): + return str(p) + return None + + +@lru_cache(maxsize=1) +def _project_example_entries() -> list[tuple[str, str]]: + path = _resolve_project_example_path() + if not path: + return [] + raw_text = _read_doc_text(path) + if not raw_text: + return [] + return _build_project_example_entries(raw_text) + + +def _build_project_example_entries(text: str) -> list[tuple[str, str]]: + lines = str(text or "").splitlines() + headings: list[tuple[int, int, str]] = [] + for idx, raw in enumerate(lines): + line = str(raw or "").strip() + m = re.match(r"^\s*(#{1,6})\s*(.+?)\s*$", line) + if not m: + continue + level = len(m.group(1)) + heading_title = m.group(2).strip() + if not heading_title: + continue + headings.append((idx, level, heading_title)) + + out: list[tuple[str, str]] = [] + for i, (start_idx, level, title) in enumerate(headings): + end_idx = len(lines) + for j in range(i + 1, len(headings)): + next_idx, next_level, _ = headings[j] + if next_level <= level: + end_idx = next_idx + break + body = "\n".join(lines[start_idx + 1 : end_idx]).strip() + body = re.sub(r"\n{3,}", "\n\n", body) + if not body: + continue + out.append((title, body)) + return out + + +def _project_example_for(section_title: str, section_key: str | None = None) -> str: + entries = _project_example_entries() + if not entries: + return "" + + target_title = _clean_section_title(section_title) + target_key = _section_key_to_number(section_key) + target_core = _core_title(target_title or target_key) + if not target_core: + return "" + + best_title = "" + best_body = "" + best_score = -1 + for heading, body in entries: + heading_clean = _clean_section_title(heading) + heading_core = _core_title(heading_clean) + score = _title_match_score(target_core, heading_core) + if score > best_score: + best_score = score + best_title = heading_clean + best_body = body + + if best_score < 4 or not best_body: + return "" + + text = f"### {best_title}\n\n{best_body}".strip() + if len(text) > MAX_SECTION_EXAMPLE_CHARS: + text = text[:MAX_SECTION_EXAMPLE_CHARS].rstrip() + "\n\n(示例过长,已截断)" + return text + + +def _clean_section_title(value: str | None) -> str: + s = str(value or "").strip() + s = re.sub(r"^\s*\d+(?:[.\-]\d+)*\s*", "", s) + return s.strip() + + +def _core_title(value: str | None) -> str: + s = str(value or "").strip() + s = s.replace("(", "(").replace(")", ")") + s = re.sub(r"\([^)]*\)", "", s) + s = re.sub(r"[、,。;::()()\-\s]", "", s) + s = s.replace("项目", "") + s = s.replace("情况", "") + s = s.replace("工作", "") + return s.strip().lower() + + +def _title_match_score(target: str, candidate: str) -> int: + if not target or not candidate: + return 0 + if target == candidate: + return 100 + score = 0 + if target in candidate or candidate in target: + score += 40 + tks_t = re.findall(r"[\u4e00-\u9fa5]{2,8}|[a-z]{2,12}", target) + tks_c = re.findall(r"[\u4e00-\u9fa5]{2,8}|[a-z]{2,12}", candidate) + if tks_t and tks_c: + overlap = len(set(tks_t) & set(tks_c)) + score += overlap * 8 + ch_overlap = len(set(target) & set(candidate)) + score += min(ch_overlap, 20) + return score + + +def _build_guideline_prompt_map(text: str) -> dict[str, str]: + lines = str(text or "").splitlines() + headings: list[tuple[int, str, str, tuple[int, ...]]] = [] + for idx, raw in enumerate(lines): + line = str(raw or "").strip() + m = re.match(r"^\s*#{1,6}\s*(.+?)\s*$", line) + if not m: + continue + heading_title = m.group(1).strip() + number = _extract_number_prefix(heading_title) + number_tuple = _tuple_from_number(number) + if not number_tuple: + continue + headings.append((idx, heading_title, number, number_tuple)) + + prompt_map: dict[str, str] = {} + for i, (start_idx, heading_title, number, number_tuple) in enumerate(headings): + end_idx = len(lines) + for j in range(i + 1, len(headings)): + next_start, _, _, next_tuple = headings[j] + if len(next_tuple) < len(number_tuple) or next_tuple[: len(number_tuple)] != number_tuple: + end_idx = next_start + break + body = "\n".join(lines[start_idx + 1 : end_idx]).strip() + body = re.sub(r"\n{3,}", "\n\n", body) + if not body: + continue + key_title = _normalize_heading_key(heading_title) + key_number = _normalize_heading_key(number) + prompt_map[key_title] = body + prompt_map[key_number] = body + return prompt_map + + +def _guideline_prompt_for(section_title: str, section_key: str | None = None) -> str: + mapping = _guideline_section_prompt_map() + if not mapping: + return "" + title = str(section_title or "").strip() + number = _extract_number_prefix(title) or _section_key_to_number(section_key) + candidates = [ + _normalize_heading_key(title), + _normalize_heading_key(number), + ] + for key in candidates: + if key and key in mapping: + return mapping[key] + return "" + + +def list_templates(db: Session) -> list[ReportTemplate]: + return ( + db.query(ReportTemplate) + .order_by(ReportTemplate.is_default.desc(), ReportTemplate.updated_at.desc()) + .all() + ) + + +def ensure_default_template(db: Session) -> None: + now = datetime.now() + system_default = ( + db.query(ReportTemplate) + .filter(ReportTemplate.name == SYSTEM_DEFAULT_TEMPLATE_NAME) + .first() + ) + if not system_default: + system_default = ReportTemplate( + id=uuid.uuid4().hex, + name=SYSTEM_DEFAULT_TEMPLATE_NAME, + description="系统预置模板(细则完整章节)", + is_default=True, + is_active=True, + created_at=now, + updated_at=now, + ) + db.add(system_default) + db.flush() + + current_rows = ( + db.query(ReportTemplateSection) + .filter(ReportTemplateSection.template_id == system_default.id) + .order_by(ReportTemplateSection.section_order.asc()) + .all() + ) + current_pairs = [(r.section_key, r.section_title) for r in current_rows] + expected_pairs = list(DEFAULT_TEMPLATE_SECTIONS) + + db.query(ReportTemplate).update({ReportTemplate.is_default: False}) + system_default.is_default = True + system_default.is_active = True + system_default.updated_at = now + + if current_pairs == expected_pairs: + has_changed = False + for row in current_rows: + current_examples = str(row.examples or "").strip() + new_examples = default_section_examples(row.section_title, row.section_key).strip() + if new_examples and current_examples != new_examples: + row.examples = new_examples + row.updated_at = now + has_changed = True + current_out = str(getattr(row, "section_output_contract", None) or "").strip() + new_out = default_section_output_contract(row.section_title, row.section_key).strip() + if not current_out and new_out: + row.section_output_contract = new_out + row.updated_at = now + has_changed = True + if has_changed: + system_default.updated_at = now + db.commit() + return + + db.query(ReportTemplateSection).filter( + ReportTemplateSection.template_id == system_default.id + ).delete() + for i, (key, title) in enumerate(DEFAULT_TEMPLATE_SECTIONS): + db.add( + ReportTemplateSection( + id=uuid.uuid4().hex, + template_id=system_default.id, + section_key=key, + section_title=title, + section_prompt="", + section_output_contract=default_section_output_contract(title, key), + section_order=i, + examples=default_section_examples(title, key), + created_at=now, + updated_at=now, + ) + ) + db.commit()