Initial commit

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
This commit is contained in:
xxy 2026-06-05 18:45:29 +08:00
commit aa98ea2623
47 changed files with 15524 additions and 0 deletions

24
.env.example Normal file
View File

@ -0,0 +1,24 @@
# 复制为 .env 后按实际环境填写。
# 数据库MySQL与原 eval_report 共用同一库)
DATABASE_URL=mysql+pymysql://root:123456@127.0.0.1:3306/post_eval_report?charset=utf8mb4
# 文档存储根目录(附图提取按 DOC_PAT/{project_uuid}/<相对路径> 定位 .docx
DOC_PAT=./docpath
# LLMOpenAI 兼容接口)
LLM_API_BASE=
LLM_API_KEY=
LLM_MODEL_NAME=
# 报告章节单次 chat 读超时(秒),长章节建议 600+
REPORT_LLM_HTTP_TIMEOUT_SEC=600
# Embedding / Milvus向量检索证据
EMBEDDING_API_BASE=
EMBEDDING_API_KEY=
MILVUS_DB_URL=
# 服务监听
HOST=0.0.0.0
PORT=8099
RELOAD=false

21
.gitignore vendored Normal file
View File

@ -0,0 +1,21 @@
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# Virtual environments
.venv
# Environment / secrets
.env
# Local artifacts
*.log
.DS_Store
comp/
docpath/
docs/
logs/

52
README.md Normal file
View File

@ -0,0 +1,52 @@
# 报告生成服务(独立抽取版)
`eval_report` 中抽取出的「后评价报告核心生成」链路,作为独立 FastAPI 服务运行。
保留原有的证据装配(要素表 + Milvus 向量检索)、分章 LLM 生成、表格修复、报告合并与 SSE 流式进度,
连接与原项目相同的 MySQL / Milvus / LLM 服务。
## 范围
- 包含异步分章生成任务、进度查询、结果获取、SSE 实时事件、章节重试、任务取消。
- 不含:鉴权、知识库 worker、模板/范文管理、Word(docx) 导出(这些仍在原 `eval_report` 中)。
## 目录结构
```
report_generation/
main.py FastAPI 入口
config.py 配置DB / LLM / Embedding / Milvus / DOC_PAT
database/ SQLAlchemy 引擎、Session、ORM 模型、建表
schemas/ Pydantic 模型
services/ 报告生成核心逻辑(含瘦身版 kb_service / docx_export_service / project_service
function/vector_store.py Milvus 向量库封装
prompts/report_generation/ 提示词模板与章节合同
routers/report.py 报告生成 HTTP 端点
```
## 快速开始
```bash
pip install -r requirements.txt
cp .env.example .env # 按需填写 DATABASE_URL / LLM_* / EMBEDDING_* / MILVUS_DB_URL
uvicorn main:app --reload
```
启动后访问 `http://127.0.0.1:8099/docs` 查看接口文档,`/health` 做健康检查。
## 主要接口(前缀 `/api/v1/write`
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/projects/{project_id}/generate-sections` | 预览模板章节提示词清单 |
| POST | `/projects/{project_id}/generate-report-job` | 创建分章异步报告生成任务 |
| GET | `/projects/{project_id}/generate-report-job/{job_id}` | 查询任务进度 |
| GET | `/projects/{project_id}/generate-report-job/{job_id}/result` | 获取任务结果 |
| GET | `/projects/{project_id}/generate-report-job/{job_id}/events` | 订阅实时事件SSE |
| POST | `/projects/{project_id}/generate-report-job/{job_id}/retry-chapter` | 重试指定章节 |
| POST | `/projects/{project_id}/generate-report-job/{job_id}/cancel` | 取消任务 |
## 依赖的外部数据
报告生成依赖原库中已有的项目数据:`projects``element_tables` / `element_cells`(要素表)、
`report_templates` / `report_template_sections`(模板章节)、可选的 `report_section_references`(参考范文),
以及 Milvus 中按项目 UUID 写入的文档向量。请确保新服务连接到已包含这些数据的 MySQL 与 Milvus。

70
config.py Normal file
View File

@ -0,0 +1,70 @@
"""
config.py
全局配置项可通过 .env 文件或环境变量覆盖
本项目为报告生成独立服务仅保留报告生成链路所需配置
数据库(MySQL) / LLM / Embedding / Milvus / 文档存储路径
"""
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
# 应用基本信息
APP_TITLE: str = "智能报告生成服务 API"
APP_VERSION: str = "0.1.0"
APP_DESCRIPTION: str = "后评价报告分章异步生成后端服务(独立抽取版)"
# 服务监听
HOST: str = "0.0.0.0"
PORT: int = 8099
RELOAD: bool = False
# CORS 允许的前端源(开发阶段放开,生产环境改为具体域名)
CORS_ORIGINS: list[str] = ["*"]
# 数据库MySQL
DATABASE_URL: str = "mysql+pymysql://root:123456@127.0.0.1:3306/post_eval_report?charset=utf8mb4"
DB_POOL_SIZE: int = 15
DB_MAX_OVERFLOW: int = 25
DB_POOL_TIMEOUT: int = 60
DB_POOL_PRE_PING: bool = True
# 文档存储根目录(附图提取时按 DOC_PAT/{project_uuid}/<相对路径> 定位 .docx
DOC_PAT: str = "./docpath"
# LLMOpenAI 兼容接口)
LLM_API_BASE: str = ""
LLM_API_KEY: str = ""
LLM_MODEL_NAME: str = ""
LLM_HTTP_TIMEOUT_SEC: int = 120
LLM_CONNECT_TIMEOUT_SEC: int = 30
LLM_RETRY_COUNT: int = 3
LLM_RETRY_BACKOFF_SEC: float = 1.0
LLM_RETRY_BACKOFF_MAX_SEC: float = 12.0
# 报告章节单次 chat 读超时。0 表示沿用 LLM_HTTP_TIMEOUT_SEC长章节建议 600+
REPORT_LLM_HTTP_TIMEOUT_SEC: int = 600
# 某章 LLM 仍失败时写入占位正文并继续后续章节,避免整份任务失败
REPORT_LLM_CONTINUE_ON_TIMEOUT: bool = True
# 表格抽取延迟补抽(首轮失败后进入队列,按轮次延迟重试)
LLM_TABLE_DELAY_RETRY_ROUNDS: int = 2
LLM_TABLE_DELAY_RETRY_SEC: float = 8.0
LLM_TABLE_DELAY_RETRY_BACKOFF: float = 2.0
LLM_TABLE_DELAY_RETRY_MAX_SEC: float = 60.0
# Embedding / Milvus向量检索证据 L2/L3
EMBEDDING_API_KEY: str = ""
EMBEDDING_API_BASE: str = ""
EMBEDDING_BATCH_MAX_DOCS: int = 4
EMBEDDING_BATCH_MAX_CHARS: int = 12000
EMBEDDING_MAX_CHUNK_CHARS: int = 4000
MILVUS_DB_URL: str = ""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
settings = Settings()

27
database/__init__.py Normal file
View File

@ -0,0 +1,27 @@
"""
database
数据库连接与 Session 管理
使用方式
from database import get_db, SessionLocal, init_database
# 依赖注入FastAPI 路由)
@router.get("/items")
def list_items(db: Session = Depends(get_db)):
...
# 上下文管理器脚本、worker
with SessionLocal() as db:
...
"""
from database.core import engine, SessionLocal
from database.dependencies import get_db
from database.init_db import init_database
__all__ = [
"engine",
"SessionLocal",
"get_db",
"init_database",
]

42
database/core.py Normal file
View File

@ -0,0 +1,42 @@
"""
database/core.py
SQLAlchemy 引擎与 Session 工厂
- 同步引擎默认连接池QueuePool
- 后续可替换为 create_async_engine 实现异步
"""
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, Session
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, # 1 小时回收空闲连接,避免 MySQL wait_timeout
connect_args={
"charset": "utf8mb4",
"use_unicode": True,
"init_command": "SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci",
},
echo=False, # 开发时可设为 True 打印 SQL
)
# -----------------------------------------------------------------------------
# Session 工厂
# -----------------------------------------------------------------------------
SessionLocal = sessionmaker(
bind=engine,
autocommit=False,
autoflush=False,
expire_on_commit=False, # 提交后对象仍可访问属性,便于返回响应
)

28
database/dependencies.py Normal file
View File

@ -0,0 +1,28 @@
"""
database/dependencies.py
FastAPI 依赖注入获取数据库 Session
每个请求创建新 Session请求结束后自动关闭
"""
from collections.abc import Generator
from sqlalchemy.orm import Session
from database.core import SessionLocal
def get_db() -> Generator[Session, None, None]:
"""
获取数据库 Session用于 FastAPI Depends()
用法
@router.get("/items")
def list_items(db: Session = Depends(get_db)):
...
"""
db = SessionLocal()
try:
yield db
finally:
db.close()

464
database/init.sql Normal file
View File

@ -0,0 +1,464 @@
-- 智能报告生成平台 - 数据库初始化脚本
-- 数据库名建议post_eval_report
-- 适用于 MySQL
-- 创建数据库(可选)
-- CREATE DATABASE IF NOT EXISTS post_eval_report DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- USE post_eval_report;
-- 项目(统一:知识库 + 撰写)
-- uuid 由应用层生成,避免 MySQL 8/9 对生成列函数限制导致初始化失败
CREATE TABLE IF NOT EXISTS projects (
id INT AUTO_INCREMENT PRIMARY KEY,
uuid VARCHAR(32) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL,
description TEXT,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
doc_count INT DEFAULT 0,
eval_reports_count INT DEFAULT 0,
total_size VARCHAR(32) DEFAULT '0 B',
tags TEXT,
status VARCHAR(16) DEFAULT 'active',
color VARCHAR(16) DEFAULT '#3b82f6',
sync_suppressed_table_names LONGTEXT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE INDEX idx_projects_created_at ON projects(created_at);
CREATE INDEX idx_projects_updated_at ON projects(updated_at);
CREATE INDEX idx_projects_status ON projects(status);
-- 知识库目录表project_id 关联 projects.uuidparent_id 形成目录树
CREATE TABLE IF NOT EXISTS kb_directories (
id VARCHAR(64) PRIMARY KEY,
project_id VARCHAR(32) NOT NULL,
parent_id VARCHAR(64) NULL,
name VARCHAR(255) NOT NULL,
full_path VARCHAR(1024) NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
FOREIGN KEY (project_id) REFERENCES projects(uuid) ON DELETE CASCADE,
FOREIGN KEY (parent_id) REFERENCES kb_directories(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE INDEX idx_kb_dirs_project ON kb_directories(project_id);
CREATE INDEX idx_kb_dirs_parent ON kb_directories(parent_id);
-- 知识库文档status: 0=失败 2=排队中 3=处理中 4=可用)
CREATE TABLE IF NOT EXISTS kb_documents (
id VARCHAR(64) PRIMARY KEY,
project_id VARCHAR(32) NOT NULL,
directory_id VARCHAR(64) NULL,
name VARCHAR(255) NOT NULL,
size VARCHAR(32) NOT NULL,
file_path VARCHAR(512),
storage_rel_path VARCHAR(512) NULL COMMENT '项目内完整相对路径(含文件名)',
word_count INT DEFAULT 0,
uploaded_at DATETIME NOT NULL,
status INT DEFAULT 2,
error_message TEXT NULL,
factor JSON NULL COMMENT '文档要素 JSON 数组',
category VARCHAR(32) NULL DEFAULT NULL COMMENT '文件分类',
FOREIGN KEY (project_id) REFERENCES projects(uuid) ON DELETE CASCADE,
FOREIGN KEY (directory_id) REFERENCES kb_directories(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE INDEX idx_kb_docs_project ON kb_documents(project_id);
CREATE INDEX idx_kb_docs_directory ON kb_documents(directory_id);
-- 若已有 kb_documents 表,执行以下语句添加 word_count 字段:
-- ALTER TABLE kb_documents ADD COLUMN word_count INT DEFAULT 0 AFTER file_path;
-- 撰写文档project_id 关联 projects.uuid与 kb_documents 一致)
CREATE TABLE IF NOT EXISTS write_documents (
id VARCHAR(64) PRIMARY KEY,
project_id VARCHAR(32) NOT NULL,
title VARCHAR(255) NOT NULL,
content LONGTEXT,
word_count INT DEFAULT 0,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
status VARCHAR(16) DEFAULT 'draft',
sort_order INT DEFAULT 0,
FOREIGN KEY (project_id) REFERENCES projects(uuid) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE INDEX idx_write_docs_project ON write_documents(project_id);
-- 文档版本
CREATE TABLE IF NOT EXISTS doc_versions (
id VARCHAR(64) PRIMARY KEY,
document_id VARCHAR(64) NOT NULL,
version VARCHAR(32) NOT NULL,
content LONGTEXT NOT NULL,
citation_payload LONGTEXT NULL,
saved_at DATETIME NOT NULL,
author VARCHAR(64) NOT NULL,
note TEXT,
FOREIGN KEY (document_id) REFERENCES write_documents(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE INDEX idx_versions_doc ON doc_versions(document_id);
-- 要素表定义(全局/时间)
CREATE TABLE IF NOT EXISTS element_tables (
id VARCHAR(64) PRIMARY KEY,
project_id VARCHAR(32) NOT NULL,
table_type VARCHAR(32) NOT NULL,
table_name VARCHAR(255) NOT NULL,
year INT NULL,
is_time_dimension TINYINT(1) DEFAULT 0,
sort_order INT DEFAULT 0,
sync_suppressed_row_keys LONGTEXT NULL,
custom_row_order LONGTEXT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
FOREIGN KEY (project_id) REFERENCES projects(uuid) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE INDEX idx_element_tables_project ON element_tables(project_id);
CREATE INDEX idx_element_tables_type_year ON element_tables(table_type, year);
CREATE INDEX idx_element_tables_name ON element_tables(table_name);
-- 要素单元格
CREATE TABLE IF NOT EXISTS element_cells (
id VARCHAR(64) PRIMARY KEY,
table_id VARCHAR(64) NOT NULL,
project_id VARCHAR(32) NOT NULL,
row_key VARCHAR(255) NOT NULL,
col_key VARCHAR(255) NULL,
year INT NULL,
value LONGTEXT NULL,
source_document_id VARCHAR(64) NULL,
source_line_no INT NULL,
source_line_end INT NULL,
source_quote TEXT NULL,
confidence FLOAT NULL,
extraction_batch_id VARCHAR(64) NULL,
extraction_model VARCHAR(128) NULL,
source_type VARCHAR(16) NULL COMMENT 'extract=文档抽取, manual=手工输入',
conflict_status VARCHAR(16) DEFAULT 'none',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
FOREIGN KEY (table_id) REFERENCES element_tables(id) ON DELETE CASCADE,
FOREIGN KEY (project_id) REFERENCES projects(uuid) ON DELETE CASCADE,
FOREIGN KEY (source_document_id) REFERENCES kb_documents(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE INDEX idx_element_cells_project ON element_cells(project_id);
CREATE INDEX idx_element_cells_row_col ON element_cells(row_key, col_key);
CREATE INDEX idx_element_cells_year ON element_cells(year);
-- 抽取结果留存table/element
CREATE TABLE IF NOT EXISTS extraction_results (
id VARCHAR(64) PRIMARY KEY,
project_id VARCHAR(32) NOT NULL,
document_id VARCHAR(64) NOT NULL,
batch_id VARCHAR(64) NOT NULL,
result_type VARCHAR(16) NOT NULL,
table_type VARCHAR(32) NULL,
table_name VARCHAR(255) NULL,
year INT NULL,
item_key VARCHAR(255) NOT NULL,
item_value LONGTEXT NULL,
source_line_no INT NULL,
source_line_end INT NULL,
confidence FLOAT NULL,
raw_payload JSON NULL,
extracted_at DATETIME NULL,
created_at DATETIME NOT NULL,
FOREIGN KEY (project_id) REFERENCES projects(uuid) ON DELETE CASCADE,
FOREIGN KEY (document_id) REFERENCES kb_documents(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE INDEX idx_extraction_project_doc ON extraction_results(project_id, document_id);
CREATE INDEX idx_extraction_batch ON extraction_results(batch_id);
CREATE INDEX idx_extraction_table_name ON extraction_results(table_name);
CREATE INDEX idx_extraction_key ON extraction_results(item_key);
-- 要素抽取结果明细(面向“细则章节/小节提示词 -> 项目材料”)
CREATE TABLE IF NOT EXISTS element_extraction_results (
id VARCHAR(64) PRIMARY KEY,
project_id VARCHAR(32) NOT NULL,
table_type VARCHAR(32) NOT NULL,
year INT NULL,
table_name VARCHAR(255) NOT NULL,
extracted_at DATETIME NOT NULL,
item_key VARCHAR(255) NOT NULL,
item_value LONGTEXT NULL,
source_document_id VARCHAR(64) NULL,
source_line_no INT NULL,
source_line_end INT NULL,
created_at DATETIME NOT NULL,
FOREIGN KEY (project_id) REFERENCES projects(uuid) ON DELETE CASCADE,
FOREIGN KEY (source_document_id) REFERENCES kb_documents(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE INDEX idx_el_ext_project ON element_extraction_results(project_id);
CREATE INDEX idx_el_ext_table ON element_extraction_results(table_type, year, table_name);
CREATE INDEX idx_el_ext_key ON element_extraction_results(item_key);
CREATE INDEX idx_el_ext_source_doc ON element_extraction_results(source_document_id);
-- 冲突记录
CREATE TABLE IF NOT EXISTS element_conflicts (
id VARCHAR(64) PRIMARY KEY,
project_id VARCHAR(32) NOT NULL,
table_id VARCHAR(64) NULL,
cell_id VARCHAR(64) NULL,
item_key VARCHAR(255) NOT NULL,
old_value LONGTEXT NULL,
new_value LONGTEXT NULL,
selected_value LONGTEXT NULL,
source_document_id VARCHAR(64) NULL,
source_line_no INT NULL,
status VARCHAR(16) DEFAULT 'pending',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
FOREIGN KEY (project_id) REFERENCES projects(uuid) ON DELETE CASCADE,
FOREIGN KEY (table_id) REFERENCES element_tables(id) ON DELETE SET NULL,
FOREIGN KEY (cell_id) REFERENCES element_cells(id) ON DELETE SET NULL,
FOREIGN KEY (source_document_id) REFERENCES kb_documents(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE INDEX idx_element_conflicts_project ON element_conflicts(project_id);
CREATE INDEX idx_element_conflicts_status ON element_conflicts(status);
-- 文档 markdown 落库
CREATE TABLE IF NOT EXISTS document_markdowns (
id VARCHAR(64) PRIMARY KEY,
project_id VARCHAR(32) NOT NULL,
document_id VARCHAR(64) NOT NULL,
extracted_filename VARCHAR(255) NULL,
markdown_content LONGTEXT NOT NULL,
content_hash VARCHAR(64) NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
FOREIGN KEY (project_id) REFERENCES projects(uuid) ON DELETE CASCADE,
FOREIGN KEY (document_id) REFERENCES kb_documents(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE INDEX idx_markdowns_project_doc ON document_markdowns(project_id, document_id);
-- 文档段落切分
CREATE TABLE IF NOT EXISTS document_chunks (
id VARCHAR(64) PRIMARY KEY,
project_id VARCHAR(32) NOT NULL,
document_id VARCHAR(64) NOT NULL,
markdown_id VARCHAR(64) NULL,
heading VARCHAR(512) NULL,
chunk_text LONGTEXT NOT NULL,
chunk_index INT DEFAULT 0,
source_line_start INT NULL,
source_line_end INT NULL,
vector_id VARCHAR(128) NULL,
created_at DATETIME NOT NULL,
FOREIGN KEY (project_id) REFERENCES projects(uuid) ON DELETE CASCADE,
FOREIGN KEY (document_id) REFERENCES kb_documents(id) ON DELETE CASCADE,
FOREIGN KEY (markdown_id) REFERENCES document_markdowns(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE INDEX idx_chunks_project_doc ON document_chunks(project_id, document_id);
CREATE INDEX idx_chunks_heading ON document_chunks(heading(255));
-- 独立后台任务pdf2md 文件处理与 element-agent 要素抽取
CREATE TABLE IF NOT EXISTS tasks (
id VARCHAR(64) PRIMARY KEY,
project VARCHAR(64) NOT NULL,
task_type INT NOT NULL,
file_id VARCHAR(64) NULL,
file_path VARCHAR(1024) NULL,
status INT NOT NULL DEFAULT 1,
payload_json JSON NULL,
result_path VARCHAR(1024) NULL,
error_message LONGTEXT NULL,
add_time DATETIME NOT NULL,
finish_time DATETIME NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE INDEX idx_tasks_status_type_time ON tasks(status, task_type, add_time);
CREATE INDEX idx_tasks_project ON tasks(project);
CREATE INDEX idx_tasks_file_id ON tasks(file_id);
-- 模板管理
CREATE TABLE IF NOT EXISTS report_templates (
id VARCHAR(64) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT NULL,
is_default TINYINT(1) DEFAULT 0,
is_active TINYINT(1) DEFAULT 1,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE INDEX idx_templates_default ON report_templates(is_default);
CREATE TABLE IF NOT EXISTS report_template_sections (
id VARCHAR(64) PRIMARY KEY,
template_id VARCHAR(64) NOT NULL,
section_key VARCHAR(64) NOT NULL,
section_title VARCHAR(255) NOT NULL,
section_prompt LONGTEXT NULL,
section_output_contract LONGTEXT NULL,
section_order INT DEFAULT 0,
examples LONGTEXT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
FOREIGN KEY (template_id) REFERENCES report_templates(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE INDEX idx_template_sections_template ON report_template_sections(template_id);
-- 报告生成任务7章分章异步
CREATE TABLE IF NOT EXISTS report_generation_jobs (
id VARCHAR(64) PRIMARY KEY,
project_id VARCHAR(32) NOT NULL,
template_id VARCHAR(64) NULL,
status VARCHAR(16) DEFAULT 'pending',
progress INT DEFAULT 0,
current_section_key VARCHAR(64) NULL,
error_message TEXT NULL,
requested_by VARCHAR(64) NULL,
options JSON NULL,
snapshot JSON NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
completed_at DATETIME NULL,
FOREIGN KEY (project_id) REFERENCES projects(uuid) ON DELETE CASCADE,
FOREIGN KEY (template_id) REFERENCES report_templates(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE INDEX idx_report_jobs_project ON report_generation_jobs(project_id);
CREATE INDEX idx_report_jobs_status ON report_generation_jobs(status);
CREATE TABLE IF NOT EXISTS report_generation_chapters (
id VARCHAR(64) PRIMARY KEY,
job_id VARCHAR(64) NOT NULL,
section_key VARCHAR(64) NOT NULL,
section_title VARCHAR(255) NOT NULL,
section_order INT DEFAULT 0,
status VARCHAR(16) DEFAULT 'pending',
content LONGTEXT NULL,
prompt_text LONGTEXT NULL,
evidence_payload JSON NULL,
validation_payload JSON NULL,
error_message TEXT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
completed_at DATETIME NULL,
FOREIGN KEY (job_id) REFERENCES report_generation_jobs(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE INDEX idx_report_chapters_job ON report_generation_chapters(job_id);
CREATE INDEX idx_report_chapters_status ON report_generation_chapters(status);
-- 最小 RBAC
CREATE TABLE IF NOT EXISTS departments (
id VARCHAR(64) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT NULL,
parent_id VARCHAR(64) NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
FOREIGN KEY (parent_id) REFERENCES departments(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS users (
id VARCHAR(64) PRIMARY KEY,
username VARCHAR(64) NOT NULL UNIQUE,
password_hash VARCHAR(255) NULL,
department_id VARCHAR(64) NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
FOREIGN KEY (department_id) REFERENCES departments(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE INDEX idx_users_department ON users(department_id);
CREATE TABLE IF NOT EXISTS roles (
id VARCHAR(64) PRIMARY KEY,
name VARCHAR(64) NOT NULL UNIQUE,
description TEXT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS permissions (
id VARCHAR(64) PRIMARY KEY,
perm_key VARCHAR(128) NOT NULL UNIQUE,
perm_type VARCHAR(32) NOT NULL,
description TEXT NULL,
created_at DATETIME NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE INDEX idx_permissions_type ON permissions(perm_type);
CREATE TABLE IF NOT EXISTS role_permissions (
id VARCHAR(64) PRIMARY KEY,
role_id VARCHAR(64) NOT NULL,
permission_id VARCHAR(64) NOT NULL,
created_at DATETIME NOT NULL,
FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE,
FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS user_roles (
id VARCHAR(64) PRIMARY KEY,
user_id VARCHAR(64) NOT NULL,
role_id VARCHAR(64) NOT NULL,
created_at DATETIME NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS project_members (
id VARCHAR(64) PRIMARY KEY,
project_id VARCHAR(32) NOT NULL,
user_id VARCHAR(64) NOT NULL,
role VARCHAR(32) DEFAULT 'editor',
created_at DATETIME NOT NULL,
FOREIGN KEY (project_id) REFERENCES projects(uuid) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE INDEX idx_project_members_project ON project_members(project_id);
CREATE TABLE IF NOT EXISTS project_departments (
id VARCHAR(64) PRIMARY KEY,
project_id VARCHAR(32) NOT NULL,
department_id VARCHAR(64) NOT NULL,
created_at DATETIME NOT NULL,
FOREIGN KEY (project_id) REFERENCES projects(uuid) ON DELETE CASCADE,
FOREIGN KEY (department_id) REFERENCES departments(id) ON DELETE CASCADE,
UNIQUE KEY uq_project_department (project_id, department_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE INDEX idx_project_departments_project ON project_departments(project_id);
-- 回填记录:每次要素回填均留痕,支持证据追溯
CREATE TABLE IF NOT EXISTS fill_records (
id VARCHAR(64) PRIMARY KEY,
project_id VARCHAR(32) NOT NULL,
cell_id VARCHAR(64) NULL,
table_id VARCHAR(64) NULL,
row_key VARCHAR(255) NOT NULL,
col_key VARCHAR(255) NULL,
year INT NULL,
filled_value LONGTEXT NULL,
previous_value LONGTEXT NULL,
source_document_id VARCHAR(64) NULL,
source_document_name VARCHAR(255) NULL COMMENT '冗余存储文档名,文档删除后仍可追溯',
source_line_no INT NULL,
source_line_end INT NULL,
source_quote TEXT NULL COMMENT '原文摘录片段,作为回填依据',
confidence FLOAT NULL,
extraction_batch_id VARCHAR(64) NULL,
extraction_model VARCHAR(128) NULL COMMENT '使用的 LLM 模型标识',
fill_type VARCHAR(16) NOT NULL DEFAULT 'auto' COMMENT 'auto=抽取回填, manual=人工编辑, resolve=冲突解决',
created_at DATETIME NOT NULL,
FOREIGN KEY (project_id) REFERENCES projects(uuid) ON DELETE CASCADE,
FOREIGN KEY (cell_id) REFERENCES element_cells(id) ON DELETE SET NULL,
FOREIGN KEY (table_id) REFERENCES element_tables(id) ON DELETE SET NULL,
FOREIGN KEY (source_document_id) REFERENCES kb_documents(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE INDEX idx_fill_records_project ON fill_records(project_id);
CREATE INDEX idx_fill_records_cell ON fill_records(cell_id);
CREATE INDEX idx_fill_records_batch ON fill_records(extraction_batch_id);
CREATE INDEX idx_fill_records_source_doc ON fill_records(source_document_id);
CREATE INDEX idx_fill_records_created ON fill_records(created_at);
-- ============================================================
-- report_section_references章节参考范文
-- ============================================================
CREATE TABLE IF NOT EXISTS report_section_references (
id VARCHAR(64) PRIMARY KEY,
template_id VARCHAR(64) NULL COMMENT '关联模板IDreport_templates.id按模板过滤参考范文',
source_file VARCHAR(255) NOT NULL COMMENT '来源文件名',
section_key VARCHAR(64) NOT NULL COMMENT '章节标识,如 1.1、2.1.1',
section_title VARCHAR(255) NOT NULL COMMENT '章节标题',
section_order INT DEFAULT 0 COMMENT '章节序号',
content TEXT NOT NULL COMMENT '该章节的参考范文 Markdown',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE INDEX idx_ref_source_file ON report_section_references(source_file);
CREATE INDEX idx_ref_section_key ON report_section_references(section_key);
CREATE INDEX idx_ref_template_id ON report_section_references(template_id);

764
database/init_db.py Normal file
View File

@ -0,0 +1,764 @@
"""
database/init_db.py
应用启动时初始化数据库表结构
执行 init.sql 中的 DDL使用 IF NOT EXISTS 保证幂等
"""
import re
from pathlib import Path
from sqlalchemy import text
from database.core import engine
# DDL 与 init_db.py 同目录database/init.sql
INIT_SQL_PATH = Path(__file__).resolve().parent / "init.sql"
INIT_TABLES = [
"projects",
"kb_directories",
"kb_documents",
"write_documents",
"doc_versions",
"element_tables",
"element_cells",
"extraction_results",
"element_extraction_results",
"element_conflicts",
"document_markdowns",
"document_chunks",
"report_templates",
"report_template_sections",
"report_generation_jobs",
"report_generation_chapters",
"departments",
"users",
"roles",
"permissions",
"role_permissions",
"user_roles",
"project_members",
"project_departments",
"fill_records",
"report_section_references",
]
_TARGET_TABLE_COLLATION = "utf8mb4_unicode_ci"
def _existing_tables(conn) -> set[str]:
return {
row[0]
for row in conn.execute(
text(
"SELECT TABLE_NAME FROM information_schema.TABLES "
"WHERE TABLE_SCHEMA = DATABASE()"
)
).fetchall()
}
def _table_collation(conn, table_name: str) -> str | None:
row = conn.execute(
text(
"SELECT TABLE_COLLATION FROM information_schema.TABLES "
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :table_name"
),
{"table_name": table_name},
).first()
return str(row[0]).strip() if row and row[0] else None
def _column_collation(conn, table_name: str, column_name: str) -> str | None:
row = conn.execute(
text(
"SELECT COLLATION_NAME FROM information_schema.COLUMNS "
"WHERE TABLE_SCHEMA = DATABASE() "
"AND TABLE_NAME = :table_name AND COLUMN_NAME = :column_name"
),
{"table_name": table_name, "column_name": column_name},
).first()
return str(row[0]).strip() if row and row[0] else None
def _normalize_projects_table(conn) -> None:
"""
将历史库表/列统一为 utf8mb4_unicode_ci仅在实际不一致时执行 ALTER
切勿在每次启动时对已迁移库重复 CONVERT会长时间持有 metadata lock
阻塞所有对 projects 等表的读写并导致连接池耗尽
"""
existing = _existing_tables(conn)
tables_to_convert = [
name
for name in INIT_TABLES
if name in existing and _table_collation(conn, name) != _TARGET_TABLE_COLLATION
]
projects_uuid_needs_fix = (
"projects" in existing
and _column_collation(conn, "projects", "uuid") != _TARGET_TABLE_COLLATION
)
if not tables_to_convert and not projects_uuid_needs_fix:
return
conn.execute(text("SET FOREIGN_KEY_CHECKS=0"))
try:
for table_name in tables_to_convert:
conn.execute(
text(
f"ALTER TABLE `{table_name}` "
"CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"
)
)
if projects_uuid_needs_fix:
conn.execute(
text(
"ALTER TABLE projects "
"MODIFY uuid VARCHAR(32) CHARACTER SET utf8mb4 "
"COLLATE utf8mb4_unicode_ci NOT NULL"
)
)
conn.commit()
finally:
conn.execute(text("SET FOREIGN_KEY_CHECKS=1"))
def _split_sql_statements(content: str) -> list[str]:
"""
按分号拆分 SQL 语句忽略注释和空行
简单实现不处理字符串内的分号
"""
# 移除单行注释
content = re.sub(r"--[^\n]*", "", content)
# 移除多行注释
content = re.sub(r"/\*.*?\*/", "", content, flags=re.DOTALL)
statements = [
s.strip()
for s in content.split(";")
if s.strip() and not s.strip().startswith("--")
]
return statements
def init_database() -> None:
"""
执行 init.sql创建表结构并按需执行缺失字段迁移
注意init.sql 里使用了 `CREATE TABLE IF NOT EXISTS`因此对已存在但缺列的旧库
需要额外执行对应迁移脚本例如补齐 `kb_documents.factor`
"""
if not INIT_SQL_PATH.exists():
return
sql_text = INIT_SQL_PATH.read_text(encoding="utf-8")
statements = _split_sql_statements(sql_text)
with engine.connect() as conn:
for stmt in statements:
stmt = stmt.strip()
if not stmt:
continue
try:
conn.execute(text(stmt))
conn.commit()
except Exception as e:
# 表/索引已存在时忽略Duplicate key name、already exists
err_msg = str(e).lower()
# 历史库可能缺列,导致 CREATE INDEX 报 "Key column ... doesn't exist in table"。
# 这里先跳过,后续 migrate_extraction_results.sql 会补齐列并建索引。
if (
"already exists" in err_msg
or "duplicate" in err_msg
or ("key column" in err_msg and "doesn't exist in table" in err_msg)
or "error 1072" in err_msg
):
conn.rollback()
continue
conn.rollback()
raise
# 仅在字符集未达标时执行 ALTER勿在每次 CREATE TABLE projects 后重复调用)
_normalize_projects_table(conn)
# ------------------------------------------------------------------
# Missing-column migrations (idempotent via "duplicate column" ignore)
# ------------------------------------------------------------------
factor_migrate_path = Path(__file__).resolve().parent / "migrate_kb_documents_factor.sql"
if factor_migrate_path.exists():
factor_sql_text = factor_migrate_path.read_text(encoding="utf-8")
factor_statements = _split_sql_statements(factor_sql_text)
with engine.connect() as conn:
for stmt in factor_statements:
stmt = stmt.strip()
if not stmt:
continue
try:
conn.execute(text(stmt))
conn.commit()
except Exception as e:
# MySQL: Error 1060 "Duplicate column name 'factor'"
err_msg = str(e).lower()
if "duplicate column" in err_msg or "error 1060" in err_msg:
conn.rollback()
continue
conn.rollback()
raise
# ------------------------------------------------------------------
# Missing tables/columns migrations (kb_directories + directory_id)
# ------------------------------------------------------------------
kb_dirs_migrate_path = Path(__file__).resolve().parent / "migrate_kb_directories.sql"
if kb_dirs_migrate_path.exists():
kb_dirs_sql_text = kb_dirs_migrate_path.read_text(encoding="utf-8")
kb_dirs_statements = _split_sql_statements(kb_dirs_sql_text)
with engine.connect() as conn:
for stmt in kb_dirs_statements:
stmt = stmt.strip()
if not stmt:
continue
try:
conn.execute(text(stmt))
conn.commit()
except Exception as e:
err_msg = str(e).lower()
# MySQL 常见“已存在/重复”错误:忽略以保证幂等
if (
"duplicate column" in err_msg
or "error 1060" in err_msg
or "already exists" in err_msg
or "duplicate" in err_msg
):
conn.rollback()
continue
conn.rollback()
raise
# ------------------------------------------------------------------
# Missing tables/columns migrations (extraction_results legacy schema)
# ------------------------------------------------------------------
extraction_migrate_path = Path(__file__).resolve().parent / "migrate_extraction_results.sql"
if extraction_migrate_path.exists():
extraction_sql_text = extraction_migrate_path.read_text(encoding="utf-8")
extraction_statements = _split_sql_statements(extraction_sql_text)
with engine.connect() as conn:
for stmt in extraction_statements:
stmt = stmt.strip()
if not stmt:
continue
try:
conn.execute(text(stmt))
conn.commit()
except Exception as e:
err_msg = str(e).lower()
if (
"duplicate column" in err_msg
or "error 1060" in err_msg
or "already exists" in err_msg
or "duplicate" in err_msg
or "check that column/key exists" in err_msg
or "error 1072" in err_msg
or "doesn't exist" in err_msg
):
conn.rollback()
continue
conn.rollback()
raise
# ------------------------------------------------------------------
# extraction_results移除历史 run_id 外键/字段(收敛到 batch_id
# ------------------------------------------------------------------
extraction_drop_run_id_path = (
Path(__file__).resolve().parent / "migrate_extraction_results_drop_run_id.sql"
)
if extraction_drop_run_id_path.exists():
extraction_drop_run_id_sql = extraction_drop_run_id_path.read_text(encoding="utf-8")
extraction_drop_run_id_statements = _split_sql_statements(extraction_drop_run_id_sql)
with engine.connect() as conn:
for stmt in extraction_drop_run_id_statements:
stmt = stmt.strip()
if not stmt:
continue
try:
conn.execute(text(stmt))
conn.commit()
except Exception as e:
err_msg = str(e).lower()
if (
"already exists" in err_msg
or "duplicate" in err_msg
or "doesn't exist" in err_msg
or "check that column/key exists" in err_msg
or "error 1091" in err_msg
):
conn.rollback()
continue
conn.rollback()
raise
# ------------------------------------------------------------------
# element_conflicts补齐 table_id / cell_id旧库缺列导致 ORM 查询 500
# ------------------------------------------------------------------
ec_migrate_path = Path(__file__).resolve().parent / "migrate_element_conflicts.sql"
if ec_migrate_path.exists():
ec_sql_text = ec_migrate_path.read_text(encoding="utf-8")
ec_statements = _split_sql_statements(ec_sql_text)
with engine.connect() as conn:
for stmt in ec_statements:
stmt = stmt.strip()
if not stmt:
continue
try:
conn.execute(text(stmt))
conn.commit()
except Exception as e:
err_msg = str(e).lower()
if (
"duplicate column" in err_msg
or "error 1060" in err_msg
or "already exists" in err_msg
or "errno 1060" in err_msg
):
conn.rollback()
continue
conn.rollback()
raise
# ------------------------------------------------------------------
# element_conflicts兼容历史 project_element_id NOT NULL改为 NULL
# ------------------------------------------------------------------
ec_project_element_id_path = (
Path(__file__).resolve().parent / "migrate_element_conflicts_project_element_id_nullable.sql"
)
if ec_project_element_id_path.exists():
ec_peid_sql = ec_project_element_id_path.read_text(encoding="utf-8")
ec_peid_stmts = _split_sql_statements(ec_peid_sql)
with engine.connect() as conn:
for stmt in ec_peid_stmts:
stmt = stmt.strip()
if not stmt:
continue
try:
conn.execute(text(stmt))
conn.commit()
except Exception:
conn.rollback()
raise
# ------------------------------------------------------------------
# element_conflicts兼容历史 extraction_result_id NOT NULL改为 NULL
# ------------------------------------------------------------------
ec_extraction_result_id_path = (
Path(__file__).resolve().parent / "migrate_element_conflicts_extraction_result_id_nullable.sql"
)
if ec_extraction_result_id_path.exists():
ec_erid_sql = ec_extraction_result_id_path.read_text(encoding="utf-8")
ec_erid_stmts = _split_sql_statements(ec_erid_sql)
with engine.connect() as conn:
for stmt in ec_erid_stmts:
stmt = stmt.strip()
if not stmt:
continue
try:
conn.execute(text(stmt))
conn.commit()
except Exception:
conn.rollback()
raise
# ------------------------------------------------------------------
# extraction_resultsextracted_at / source_line_end
# ------------------------------------------------------------------
ext_time_path = Path(__file__).resolve().parent / "migrate_extraction_results_extracted_at.sql"
if ext_time_path.exists():
ext_sql = ext_time_path.read_text(encoding="utf-8")
ext_stmts = _split_sql_statements(ext_sql)
with engine.connect() as conn:
for stmt in ext_stmts:
stmt = stmt.strip()
if not stmt:
continue
try:
conn.execute(text(stmt))
conn.commit()
except Exception as e:
err_msg = str(e).lower()
if (
"duplicate column" in err_msg
or "error 1060" in err_msg
or "already exists" in err_msg
):
conn.rollback()
continue
conn.rollback()
raise
# ------------------------------------------------------------------
# element_extraction_results要素抽取结果明细表若旧库缺表则补齐
# ------------------------------------------------------------------
el_ext_path = Path(__file__).resolve().parent / "migrate_element_extraction_results.sql"
if el_ext_path.exists():
el_ext_sql = el_ext_path.read_text(encoding="utf-8")
el_ext_stmts = _split_sql_statements(el_ext_sql)
with engine.connect() as conn:
for stmt in el_ext_stmts:
stmt = stmt.strip()
if not stmt:
continue
try:
conn.execute(text(stmt))
conn.commit()
except Exception as e:
err_msg = str(e).lower()
if (
"already exists" in err_msg
or "duplicate" in err_msg
):
conn.rollback()
continue
conn.rollback()
raise
# ------------------------------------------------------------------
# project_departments项目可见部门
# ------------------------------------------------------------------
proj_dept_path = Path(__file__).resolve().parent / "migrate_project_departments.sql"
if proj_dept_path.exists():
proj_dept_sql = proj_dept_path.read_text(encoding="utf-8")
proj_dept_stmts = _split_sql_statements(proj_dept_sql)
with engine.connect() as conn:
for stmt in proj_dept_stmts:
stmt = stmt.strip()
if not stmt:
continue
try:
conn.execute(text(stmt))
conn.commit()
except Exception as e:
err_msg = str(e).lower()
if "already exists" in err_msg or "duplicate" in err_msg:
conn.rollback()
continue
conn.rollback()
raise
# ------------------------------------------------------------------
# report_template_sections章节输出合同section_output_contract
# ------------------------------------------------------------------
template_section_contract_path = (
Path(__file__).resolve().parent / "migrations" / "add_section_output_contract.sql"
)
if template_section_contract_path.exists():
template_section_contract_sql = template_section_contract_path.read_text(encoding="utf-8")
template_section_contract_stmts = _split_sql_statements(template_section_contract_sql)
with engine.connect() as conn:
for stmt in template_section_contract_stmts:
stmt = stmt.strip()
if not stmt:
continue
try:
conn.execute(text(stmt))
conn.commit()
except Exception as e:
err_msg = str(e).lower()
if "duplicate column" in err_msg or "error 1060" in err_msg or "already exists" in err_msg:
conn.rollback()
continue
conn.rollback()
raise
# ------------------------------------------------------------------
# report_section_references补齐 template_id按模板过滤参考范文
# ------------------------------------------------------------------
ref_template_id_path = (
Path(__file__).resolve().parent / "migrations" / "add_ref_template_id.sql"
)
if ref_template_id_path.exists():
ref_template_id_sql = ref_template_id_path.read_text(encoding="utf-8")
ref_template_id_stmts = _split_sql_statements(ref_template_id_sql)
with engine.connect() as conn:
for stmt in ref_template_id_stmts:
stmt = stmt.strip()
if not stmt:
continue
try:
conn.execute(text(stmt))
conn.commit()
except Exception as e:
err_msg = str(e).lower()
if (
"duplicate column" in err_msg
or "error 1060" in err_msg
or "duplicate key name" in err_msg
or "error 1061" in err_msg
or "already exists" in err_msg
):
conn.rollback()
continue
conn.rollback()
raise
# ------------------------------------------------------------------
# users补齐 password_hash登录注册
# ------------------------------------------------------------------
users_pwd_path = Path(__file__).resolve().parent / "migrate_users_password_hash.sql"
if users_pwd_path.exists():
users_pwd_sql = users_pwd_path.read_text(encoding="utf-8")
users_pwd_stmts = _split_sql_statements(users_pwd_sql)
with engine.connect() as conn:
for stmt in users_pwd_stmts:
stmt = stmt.strip()
if not stmt:
continue
try:
conn.execute(text(stmt))
conn.commit()
except Exception as e:
err_msg = str(e).lower()
if "duplicate column" in err_msg or "error 1060" in err_msg or "already exists" in err_msg:
conn.rollback()
continue
conn.rollback()
raise
# ------------------------------------------------------------------
# departments补齐 description部门描述
# ------------------------------------------------------------------
dept_desc_path = Path(__file__).resolve().parent / "migrate_departments_description.sql"
if dept_desc_path.exists():
dept_desc_sql = dept_desc_path.read_text(encoding="utf-8")
dept_desc_stmts = _split_sql_statements(dept_desc_sql)
with engine.connect() as conn:
for stmt in dept_desc_stmts:
stmt = stmt.strip()
if not stmt:
continue
try:
conn.execute(text(stmt))
conn.commit()
except Exception as e:
err_msg = str(e).lower()
if "duplicate column" in err_msg or "error 1060" in err_msg or "already exists" in err_msg:
conn.rollback()
continue
conn.rollback()
raise
# ------------------------------------------------------------------
# projects用户删除的标准模版表不再被「同步模版」回补
# ------------------------------------------------------------------
proj_sup_path = Path(__file__).resolve().parent / "migrate_projects_sync_suppressed_tables.sql"
if proj_sup_path.exists():
proj_sup_sql = proj_sup_path.read_text(encoding="utf-8")
proj_sup_stmts = _split_sql_statements(proj_sup_sql)
with engine.connect() as conn:
for stmt in proj_sup_stmts:
stmt = stmt.strip()
if not stmt:
continue
try:
conn.execute(text(stmt))
conn.commit()
except Exception as e:
err_msg = str(e).lower()
if "duplicate column" in err_msg or "error 1060" in err_msg or "already exists" in err_msg:
conn.rollback()
continue
conn.rollback()
raise
# ------------------------------------------------------------------
# element_tables用户删行后不再被「同步模版」回补
# ------------------------------------------------------------------
et_sup_path = Path(__file__).resolve().parent / "migrate_element_tables_sync_suppressed.sql"
if et_sup_path.exists():
et_sup_sql = et_sup_path.read_text(encoding="utf-8")
et_sup_stmts = _split_sql_statements(et_sup_sql)
with engine.connect() as conn:
for stmt in et_sup_stmts:
stmt = stmt.strip()
if not stmt:
continue
try:
conn.execute(text(stmt))
conn.commit()
except Exception as e:
err_msg = str(e).lower()
if "duplicate column" in err_msg or "error 1060" in err_msg or "already exists" in err_msg:
conn.rollback()
continue
conn.rollback()
raise
# ------------------------------------------------------------------
# element_tables自定义行顺序加行插在选中行下刷新后仍保持
# ------------------------------------------------------------------
et_row_order_path = Path(__file__).resolve().parent / "migrate_element_tables_custom_row_order.sql"
if et_row_order_path.exists():
et_row_order_sql = et_row_order_path.read_text(encoding="utf-8")
et_row_order_stmts = _split_sql_statements(et_row_order_sql)
with engine.connect() as conn:
for stmt in et_row_order_stmts:
stmt = stmt.strip()
if not stmt:
continue
try:
conn.execute(text(stmt))
conn.commit()
except Exception as e:
err_msg = str(e).lower()
if "duplicate column" in err_msg or "error 1060" in err_msg or "already exists" in err_msg:
conn.rollback()
continue
conn.rollback()
raise
# ------------------------------------------------------------------
# kb_documentsstatus 语义 v20/2/3/4仅旧库仍有 status=1 且无 status=4时执行
# ------------------------------------------------------------------
status_v2_path = Path(__file__).resolve().parent / "migrate_kb_doc_status_v2.sql"
if status_v2_path.exists():
with engine.connect() as conn:
try:
probe = conn.execute(
text(
"""
SELECT
SUM(CASE WHEN status = 1 THEN 1 ELSE 0 END) AS s1,
SUM(CASE WHEN status = 4 THEN 1 ELSE 0 END) AS s4
FROM kb_documents
"""
)
).fetchone()
s1 = int((probe[0] if probe else 0) or 0)
s4 = int((probe[1] if probe else 0) or 0)
if s1 > 0 and s4 == 0:
status_v2_sql = status_v2_path.read_text(encoding="utf-8")
for stmt in _split_sql_statements(status_v2_sql):
stmt = stmt.strip()
if not stmt:
continue
conn.execute(text(stmt))
conn.commit()
except Exception as e:
err_msg = str(e).lower()
if "doesn't exist" in err_msg and "kb_documents" in err_msg:
conn.rollback()
else:
conn.rollback()
raise
# ------------------------------------------------------------------
# kb_documentsstorage_rel_path + error_message
# ------------------------------------------------------------------
kb_storage_path = Path(__file__).resolve().parent / "migrate_kb_doc_storage_path.sql"
if kb_storage_path.exists():
kb_storage_sql = kb_storage_path.read_text(encoding="utf-8")
kb_storage_stmts = _split_sql_statements(kb_storage_sql)
with engine.connect() as conn:
for stmt in kb_storage_stmts:
stmt = stmt.strip()
if not stmt:
continue
try:
conn.execute(text(stmt))
conn.commit()
except Exception as e:
err_msg = str(e).lower()
if "duplicate column" in err_msg or "error 1060" in err_msg or "already exists" in err_msg:
conn.rollback()
continue
conn.rollback()
raise
# ------------------------------------------------------------------
# kb_documentscategory (文件分类)
# ------------------------------------------------------------------
category_migrate_path = Path(__file__).resolve().parent / "migrate_kb_documents_category.sql"
if category_migrate_path.exists():
category_sql_text = category_migrate_path.read_text(encoding="utf-8")
category_statements = _split_sql_statements(category_sql_text)
with engine.connect() as conn:
for stmt in category_statements:
stmt = stmt.strip()
if not stmt:
continue
try:
conn.execute(text(stmt))
conn.commit()
except Exception as e:
err_msg = str(e).lower()
if "duplicate column" in err_msg or "error 1060" in err_msg or "already exists" in err_msg:
conn.rollback()
continue
conn.rollback()
raise
# ------------------------------------------------------------------
# kb_documents旧版分类 → 资料清单六大类
# ------------------------------------------------------------------
category_checklist_path = Path(__file__).resolve().parent / "migrate_kb_category_checklist.sql"
if category_checklist_path.exists():
checklist_sql_text = category_checklist_path.read_text(encoding="utf-8")
checklist_statements = _split_sql_statements(checklist_sql_text)
with engine.connect() as conn:
for stmt in checklist_statements:
stmt = stmt.strip()
if not stmt:
continue
try:
conn.execute(text(stmt))
conn.commit()
except Exception:
conn.rollback()
# ------------------------------------------------------------------
# kb_documentsupload_filename上传/解压原始文件名)
# ------------------------------------------------------------------
upload_fn_path = Path(__file__).resolve().parent / "migrate_kb_documents_upload_filename.sql"
if upload_fn_path.exists():
upload_fn_sql = upload_fn_path.read_text(encoding="utf-8")
upload_fn_stmts = _split_sql_statements(upload_fn_sql)
with engine.connect() as conn:
for stmt in upload_fn_stmts:
stmt = stmt.strip()
if not stmt:
continue
try:
conn.execute(text(stmt))
conn.commit()
except Exception as e:
err_msg = str(e).lower()
if "duplicate column" in err_msg or "error 1060" in err_msg or "already exists" in err_msg:
conn.rollback()
continue
conn.rollback()
raise
# ------------------------------------------------------------------
# element_cellssource_type文档抽取 / 手工输入)
# ------------------------------------------------------------------
ec_source_type_path = Path(__file__).resolve().parent / "migrate_element_cells_source_type.sql"
if ec_source_type_path.exists():
ec_source_sql = ec_source_type_path.read_text(encoding="utf-8")
ec_source_stmts = _split_sql_statements(ec_source_sql)
with engine.connect() as conn:
for stmt in ec_source_stmts:
stmt = stmt.strip()
if not stmt:
continue
try:
conn.execute(text(stmt))
conn.commit()
except Exception as e:
err_msg = str(e).lower()
if "duplicate column" in err_msg or "error 1060" in err_msg or "already exists" in err_msg:
conn.rollback()
continue
conn.rollback()
raise

View File

@ -0,0 +1,3 @@
-- 为 report_section_references 增加 template_id按模板过滤参考范文
ALTER TABLE report_section_references ADD COLUMN template_id VARCHAR(64) NULL COMMENT '关联模板IDreport_templates.id按模板过滤参考范文';
CREATE INDEX idx_ref_template_id ON report_section_references(template_id);

503
database/models.py Normal file
View File

@ -0,0 +1,503 @@
"""
database/models.py
SQLAlchemy ORM 模型 db.md / init.sql 对应
"""
from datetime import datetime
from typing import Optional
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, JSON, String, Text, UniqueConstraint
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
class Base(DeclarativeBase):
pass
class Project(Base):
"""项目表(统一:知识库 + 撰写)"""
__tablename__ = "projects"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
uuid: Mapped[str] = mapped_column(
String(32),
unique=True,
nullable=False,
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: 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)
doc_count: Mapped[int] = mapped_column(Integer, default=0)
eval_reports_count: Mapped[int] = mapped_column(Integer, default=0)
total_size: Mapped[str] = mapped_column(String(32), default="0 B")
tags: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(String(16), default="active")
color: Mapped[str] = mapped_column(String(16), default="#3b82f6")
sync_suppressed_table_names: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
kb_documents: Mapped[list["KbDocument"]] = relationship(
"KbDocument", back_populates="project", cascade="all, delete-orphan"
)
kb_directories: Mapped[list["KbDirectory"]] = relationship(
"KbDirectory", back_populates="project", cascade="all, delete-orphan"
)
write_documents: Mapped[list["WriteDocumentModel"]] = relationship(
"WriteDocumentModel", back_populates="project", cascade="all, delete-orphan"
)
class WriteDocumentModel(Base):
"""撰写文档表后评价报告。project_id 关联 projects.uuid"""
__tablename__ = "write_documents"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
project_id: Mapped[str] = mapped_column(ForeignKey("projects.uuid", ondelete="CASCADE"), nullable=False)
title: Mapped[str] = mapped_column(String(255), nullable=False)
content: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
word_count: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
status: Mapped[str] = mapped_column(String(16), default="draft")
sort_order: Mapped[int] = mapped_column(Integer, default=0)
project: Mapped["Project"] = relationship("Project", back_populates="write_documents")
doc_versions: Mapped[list["DocumentVersion"]] = relationship(
"DocumentVersion", back_populates="document", cascade="all, delete-orphan"
)
class DocumentVersion(Base):
"""撰写文档版本表(对应 doc_versions"""
__tablename__ = "doc_versions"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
document_id: Mapped[str] = mapped_column(
ForeignKey("write_documents.id", ondelete="CASCADE"), nullable=False
)
version: Mapped[str] = mapped_column(String(32), nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False)
citation_payload: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
saved_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
author: Mapped[str] = mapped_column(String(64), nullable=False)
note: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
document: Mapped["WriteDocumentModel"] = relationship("WriteDocumentModel", back_populates="doc_versions")
class KbDocument(Base):
"""知识库文档表。project_id 关联 projects.uuid。status: 0=失败 2=排队中 3=处理中 4=可用"""
__tablename__ = "kb_documents"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
project_id: Mapped[str] = mapped_column(ForeignKey("projects.uuid", ondelete="CASCADE"), nullable=False)
directory_id: Mapped[Optional[str]] = mapped_column(
ForeignKey("kb_directories.id", ondelete="SET NULL"), nullable=True
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
upload_filename: Mapped[Optional[str]] = mapped_column(
String(255), nullable=True
) # 上传/解压时的原始文件名(含扩展名),与智能展示名 name 区分
size: Mapped[str] = mapped_column(String(32), nullable=False)
file_path: Mapped[Optional[str]] = mapped_column(String(512), nullable=True) # 仅目录路径,不含文件名
storage_rel_path: Mapped[Optional[str]] = mapped_column(
String(512), nullable=True
) # 项目内完整相对路径(含文件名),用于精确定位磁盘文件
word_count: Mapped[int] = mapped_column(Integer, default=0)
uploaded_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
status: Mapped[int] = mapped_column(Integer, default=2) # 0=失败 2=排队中 3=处理中 4=可用
error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
category: Mapped[Optional[str]] = mapped_column(String(32), nullable=True, default=None)
project: Mapped["Project"] = relationship("Project", back_populates="kb_documents")
directory: Mapped[Optional["KbDirectory"]] = relationship("KbDirectory", back_populates="documents")
class KbDirectory(Base):
"""知识库目录表。project_id 关联 projects.uuidparent_id 形成目录树。"""
__tablename__ = "kb_directories"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
project_id: Mapped[str] = mapped_column(ForeignKey("projects.uuid", ondelete="CASCADE"), nullable=False)
parent_id: Mapped[Optional[str]] = mapped_column(
ForeignKey("kb_directories.id", ondelete="CASCADE"), nullable=True
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
full_path: Mapped[str] = mapped_column(String(1024), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
project: Mapped["Project"] = relationship("Project", back_populates="kb_directories")
documents: Mapped[list["KbDocument"]] = relationship("KbDocument", back_populates="directory")
class Task(Base):
"""独立后台任务表pdf2md 转换和 element-agent 要素抽取。"""
__tablename__ = "tasks"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
project: Mapped[str] = mapped_column(String(64), nullable=False)
task_type: Mapped[int] = mapped_column(Integer, nullable=False)
file_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
file_path: Mapped[Optional[str]] = mapped_column(String(1024), nullable=True)
status: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
payload_json: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
result_path: Mapped[Optional[str]] = mapped_column(String(1024), nullable=True)
error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
add_time: Mapped[datetime] = mapped_column(DateTime, nullable=False)
finish_time: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
class ElementTable(Base):
__tablename__ = "element_tables"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
project_id: Mapped[str] = mapped_column(ForeignKey("projects.uuid", ondelete="CASCADE"), nullable=False)
table_type: Mapped[str] = mapped_column(String(32), nullable=False) # global/time
table_name: Mapped[str] = mapped_column(String(255), nullable=False)
year: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
is_time_dimension: Mapped[bool] = mapped_column(Boolean, default=False)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
# JSON 数组字符串row_key 列表sync 模版时跳过为这些行补格子,避免用户删行后一同步又出现
sync_suppressed_row_keys: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
# JSON 数组:界面行键展示顺序(含用户加行)
custom_row_order: 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 ElementCell(Base):
__tablename__ = "element_cells"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
table_id: Mapped[str] = mapped_column(ForeignKey("element_tables.id", ondelete="CASCADE"), nullable=False)
project_id: Mapped[str] = mapped_column(ForeignKey("projects.uuid", ondelete="CASCADE"), nullable=False)
row_key: Mapped[str] = mapped_column(String(255), nullable=False)
col_key: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
year: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
value: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
source_document_id: Mapped[Optional[str]] = mapped_column(
ForeignKey("kb_documents.id", ondelete="SET NULL"), nullable=True
)
source_line_no: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
source_line_end: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
source_quote: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
confidence: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
extraction_batch_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
extraction_model: Mapped[Optional[str]] = mapped_column(String(128), nullable=True)
source_type: Mapped[Optional[str]] = mapped_column(String(16), nullable=True) # extract | manual
conflict_status: Mapped[str] = mapped_column(String(16), default="none")
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
class ExtractionResult(Base):
__tablename__ = "extraction_results"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
project_id: Mapped[str] = mapped_column(ForeignKey("projects.uuid", ondelete="CASCADE"), nullable=False)
document_id: Mapped[str] = mapped_column(ForeignKey("kb_documents.id", ondelete="CASCADE"), nullable=False)
batch_id: Mapped[str] = mapped_column(String(64), nullable=False)
result_type: Mapped[str] = mapped_column(String(16), nullable=False) # table/element
table_type: Mapped[Optional[str]] = mapped_column(String(32), nullable=True)
table_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
year: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
item_key: Mapped[str] = mapped_column(String(255), nullable=False)
item_value: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
source_line_no: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
source_line_end: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
confidence: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
raw_payload: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
extracted_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) # 抽取业务时间(旧库迁移前可为空)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
class ElementExtractionResult(Base):
"""
要素抽取结果明细表面向细则章节/小节提示词 -> 项目材料抽取
字段对齐用户侧语义
- 表类型 -> table_type
- 年份 -> year
- 表名称 -> table_name
- 时间 -> extracted_at
- -> item_key
- -> item_value
- 来源文档ID -> source_document_id
- 来源行数 -> source_line_no / source_line_end
"""
__tablename__ = "element_extraction_results"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
project_id: Mapped[str] = mapped_column(ForeignKey("projects.uuid", ondelete="CASCADE"), nullable=False)
table_type: Mapped[str] = mapped_column(String(32), nullable=False)
year: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
table_name: Mapped[str] = mapped_column(String(255), nullable=False)
extracted_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
item_key: Mapped[str] = mapped_column(String(255), nullable=False)
item_value: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
source_document_id: Mapped[Optional[str]] = mapped_column(
ForeignKey("kb_documents.id", ondelete="SET NULL"), nullable=True
)
source_line_no: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
source_line_end: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
class ElementConflict(Base):
__tablename__ = "element_conflicts"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
project_id: Mapped[str] = mapped_column(ForeignKey("projects.uuid", ondelete="CASCADE"), nullable=False)
table_id: Mapped[Optional[str]] = mapped_column(ForeignKey("element_tables.id", ondelete="SET NULL"), nullable=True)
cell_id: Mapped[Optional[str]] = mapped_column(ForeignKey("element_cells.id", ondelete="SET NULL"), nullable=True)
item_key: Mapped[str] = mapped_column(String(255), nullable=False)
old_value: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
new_value: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
selected_value: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
source_document_id: Mapped[Optional[str]] = mapped_column(
ForeignKey("kb_documents.id", ondelete="SET NULL"), nullable=True
)
source_line_no: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
status: Mapped[str] = mapped_column(String(16), default="pending")
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
class DocumentMarkdown(Base):
__tablename__ = "document_markdowns"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
project_id: Mapped[str] = mapped_column(ForeignKey("projects.uuid", ondelete="CASCADE"), nullable=False)
document_id: Mapped[str] = mapped_column(ForeignKey("kb_documents.id", ondelete="CASCADE"), nullable=False)
extracted_filename: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
markdown_content: Mapped[str] = mapped_column(Text, nullable=False)
content_hash: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
class DocumentChunk(Base):
__tablename__ = "document_chunks"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
project_id: Mapped[str] = mapped_column(ForeignKey("projects.uuid", ondelete="CASCADE"), nullable=False)
document_id: Mapped[str] = mapped_column(ForeignKey("kb_documents.id", ondelete="CASCADE"), nullable=False)
markdown_id: Mapped[Optional[str]] = mapped_column(ForeignKey("document_markdowns.id", ondelete="CASCADE"), nullable=True)
heading: Mapped[Optional[str]] = mapped_column(String(512), nullable=True)
chunk_text: Mapped[str] = mapped_column(Text, nullable=False)
chunk_index: Mapped[int] = mapped_column(Integer, default=0)
source_line_start: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
source_line_end: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
vector_id: Mapped[Optional[str]] = mapped_column(String(128), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
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: 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 ReportGenerationJob(Base):
__tablename__ = "report_generation_jobs"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
project_id: Mapped[str] = mapped_column(ForeignKey("projects.uuid", ondelete="CASCADE"), nullable=False)
template_id: Mapped[Optional[str]] = mapped_column(
ForeignKey("report_templates.id", ondelete="SET NULL"), nullable=True
)
status: Mapped[str] = mapped_column(String(16), default="pending") # pending/running/completed/failed
progress: Mapped[int] = mapped_column(Integer, default=0)
current_section_key: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
requested_by: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
options: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
snapshot: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
completed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
class ReportGenerationChapter(Base):
__tablename__ = "report_generation_chapters"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
job_id: Mapped[str] = mapped_column(
ForeignKey("report_generation_jobs.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_order: Mapped[int] = mapped_column(Integer, default=0)
status: Mapped[str] = mapped_column(String(16), default="pending") # pending/running/completed/failed
content: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
prompt_text: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
evidence_payload: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
validation_payload: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
error_message: 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)
completed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
class ReportSectionReference(Base):
"""章节参考范文(独立于模板配置,用于报告生成时拼入 prompt"""
__tablename__ = "report_section_references"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
template_id: Mapped[Optional[str]] = mapped_column(
ForeignKey("report_templates.id", ondelete="CASCADE"), nullable=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)
class Department(Base):
__tablename__ = "department"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
parent_id: Mapped[Optional[str]] = mapped_column(ForeignKey("departments.id", ondelete="SET NULL"), nullable=True)
description: 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 User(Base):
__tablename__ = "users"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
username: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
password_hash: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
department_id: Mapped[Optional[str]] = mapped_column(ForeignKey("departments.id", ondelete="SET NULL"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
class Role(Base):
__tablename__ = "roles"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
name: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
description: 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 Permission(Base):
__tablename__ = "permissions"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
perm_key: Mapped[str] = mapped_column(String(128), nullable=False, unique=True)
perm_type: Mapped[str] = mapped_column(String(32), nullable=False) # menu/project
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
class RolePermission(Base):
__tablename__ = "role_permissions"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
role_id: Mapped[str] = mapped_column(ForeignKey("roles.id", ondelete="CASCADE"), nullable=False)
permission_id: Mapped[str] = mapped_column(ForeignKey("permissions.id", ondelete="CASCADE"), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
class UserRole(Base):
__tablename__ = "user_roles"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
role_id: Mapped[str] = mapped_column(ForeignKey("roles.id", ondelete="CASCADE"), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
class ProjectMember(Base):
__tablename__ = "project_members"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
project_id: Mapped[str] = mapped_column(ForeignKey("projects.uuid", ondelete="CASCADE"), nullable=False)
user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
role: Mapped[str] = mapped_column(String(32), default="editor")
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
class ProjectDepartment(Base):
"""项目可见部门:绑定后,仅这些部门下的用户可访问(另有管理员与 project_members 例外)。"""
__tablename__ = "project_departments"
__table_args__ = (UniqueConstraint("project_id", "department_id", name="uq_project_department"),)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
project_id: Mapped[str] = mapped_column(ForeignKey("projects.uuid", ondelete="CASCADE"), nullable=False)
department_id: Mapped[str] = mapped_column(ForeignKey("departments.id", ondelete="CASCADE"), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
class FillRecord(Base):
"""回填记录:每次要素回填均留痕,支持证据追溯。"""
__tablename__ = "fill_records"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
project_id: Mapped[str] = mapped_column(ForeignKey("projects.uuid", ondelete="CASCADE"), nullable=False)
cell_id: Mapped[Optional[str]] = mapped_column(
ForeignKey("element_cells.id", ondelete="SET NULL"), nullable=True
)
table_id: Mapped[Optional[str]] = mapped_column(
ForeignKey("element_tables.id", ondelete="SET NULL"), nullable=True
)
row_key: Mapped[str] = mapped_column(String(255), nullable=False)
col_key: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
year: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
filled_value: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
previous_value: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
source_document_id: Mapped[Optional[str]] = mapped_column(
ForeignKey("kb_documents.id", ondelete="SET NULL"), nullable=True
)
source_document_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
source_line_no: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
source_line_end: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
source_quote: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
confidence: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
extraction_batch_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
extraction_model: Mapped[Optional[str]] = mapped_column(String(128), nullable=True)
fill_type: Mapped[str] = mapped_column(String(16), nullable=False, default="auto")
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)

1
function/__init__.py Normal file
View File

@ -0,0 +1 @@
# function 包

550
function/vector_store.py Normal file
View File

@ -0,0 +1,550 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
function/vector_store.py
向量库模块 - kb_service 项目集成
已修改drop_old 全部 = False不会删除已有集合
已修复 413 超长 token 问题语义友好版
"""
import re
import json
import logging
from typing import Dict, List, Optional, Tuple
from pathlib import Path
from langchain_core.documents import Document
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings
from langchain_milvus import Milvus, BM25BuiltInFunction
from pymilvus import MilvusClient, connections
from config import settings
logger = logging.getLogger(__name__)
# ============================================================================
# 配置
# ============================================================================
COLLECTION_NAME = "eval_report"
EMBEDDING_API_BASE = settings.EMBEDDING_API_BASE
EMBEDDING_API_KEY = settings.EMBEDDING_API_KEY
MILVUS_DB_URL = settings.MILVUS_DB_URL
CONSISTENCY_LEVEL = "Bounded"
AUTO_ID = True
METRIC_TYPE = "COSINE"
INDEX_TYPE = "AUTOINDEX"
SPARSE_METRIC_TYPE = "BM25"
SPARSE_INDEX_TYPE = "SPARSE_INVERTED_INDEX"
def _embedding_batch_limits() -> tuple[int, int, int]:
max_docs = max(1, int(getattr(settings, "EMBEDDING_BATCH_MAX_DOCS", 4) or 4))
max_chars = max(512, int(getattr(settings, "EMBEDDING_BATCH_MAX_CHARS", 12000) or 12000))
max_chunk = max(512, int(getattr(settings, "EMBEDDING_MAX_CHUNK_CHARS", 4000) or 4000))
return max_docs, max_chars, max_chunk
def _is_embedding_backend_oom(exc: BaseException) -> bool:
msg = str(exc).lower()
return (
"out of memory" in msg
or "npu out of memory" in msg
or "cuda out of memory" in msg
or "error code: 424" in msg
or "'code': 424" in msg
)
def _add_documents_batch_with_retry(vs: Milvus, batch: List[Document]) -> List[str]:
"""写入一批文档;远端 embedding OOM 时自动拆半重试。"""
if not batch:
return []
try:
return list(vs.add_documents(batch))
except Exception as e:
if not _is_embedding_backend_oom(e) or len(batch) <= 1:
raise
mid = max(1, len(batch) // 2)
logger.warning(
"embedding 批次 OOM拆分为 %s + %s 重试",
mid,
len(batch) - mid,
)
ids: List[str] = []
ids.extend(_add_documents_batch_with_retry(vs, batch[:mid]))
ids.extend(_add_documents_batch_with_retry(vs, batch[mid:]))
return ids
def _register_milvus_client_for_orm(client: MilvusClient) -> None:
"""pymilvus 2.6+ MilvusClient uses ConnectionManager; ORM Collection still resolves
pymilvus.orm.connections by client._using. langchain-milvus touches Collection during
Milvus.__init__, so register before constructing Milvus (bootstrap client)."""
alias = client._using
if connections.has_connection(alias):
return
cfg = client._config
connections._alias_handlers[alias] = client._handler
connections._alias_config[alias] = {
"address": cfg.address,
"user": "",
"db_name": cfg.db_name or "default",
}
# ============================================================================
# VectorStore 类(已全部改为 drop_old=False
# ============================================================================
class VectorStore:
def __init__(
self,
collection_name: str = COLLECTION_NAME,
drop_old: bool = False,
chunk_size: int = 500,
chunk_overlap: int = 50
):
self.collection_name = collection_name
self.chunk_size = chunk_size
self.chunk_overlap = chunk_overlap
self._drop_old = drop_old
self._milvus = None
def _get_embeddings(self):
return OpenAIEmbeddings(
base_url=EMBEDDING_API_BASE,
api_key=EMBEDDING_API_KEY,
model="bge-m3",
check_embedding_ctx_length=False,
)
def _get_milvus(self, drop_old: bool = False) -> Milvus:
logger.info("【VectorStore】初始化 Milvus 混合向量存储dense + sparse")
if self._milvus is not None and not drop_old:
logger.info("【VectorStore】复用已有 Milvus 实例")
return self._milvus
if not MILVUS_DB_URL:
raise ValueError("MILVUS_DB_URL 未配置,请在 .env 中设置")
embeddings = self._get_embeddings()
logger.info("【VectorStore】Embedding 模型 bge-m3 初始化完成")
try:
# 与 langchain 内 MilvusClient 共享 ConnectionManager先注册 ORM alias否则 __init__ 内访问 Collection 会报错
_register_milvus_client_for_orm(MilvusClient(uri=MILVUS_DB_URL))
self._milvus = Milvus(
embedding_function=embeddings,
builtin_function=BM25BuiltInFunction(),
vector_field=["dense", "sparse"],
connection_args={"uri": MILVUS_DB_URL},
collection_name=self.collection_name,
consistency_level=CONSISTENCY_LEVEL,
auto_id=AUTO_ID,
drop_old=False,
index_params=[
{"metric_type": METRIC_TYPE, "index_type": INDEX_TYPE},
{"metric_type": SPARSE_METRIC_TYPE, "index_type": SPARSE_INDEX_TYPE},
],
)
_register_milvus_client_for_orm(self._milvus.client)
logger.info("✅ Milvus 混合向量存储初始化成功")
except Exception as e:
logger.error(f"❌ Milvus 初始化失败: {str(e)}", exc_info=True)
raise
return self._milvus
# ========================================================================
# ✅ 修复版 add_documents语义友好不破坏段落不触发413
# ========================================================================
def add_documents(self, documents: List[Document]) -> List[str]:
if not documents:
logger.info("【add_documents】无文档可写入")
return []
max_docs_per_batch, max_chars_per_batch, max_chunk_chars = _embedding_batch_limits()
# ---------------------- 语义安全切分(只修问题,不破坏结构)----------------------
# 只处理【真的超长】的段落,在句子/段落边界分割,绝不乱切
safe_splitter = RecursiveCharacterTextSplitter(
chunk_size=max_chunk_chars,
chunk_overlap=min(200, max(0, max_chunk_chars // 20)),
separators=["\n\n", "\n", "", "", "", "", "", ""]
)
safe_documents = []
for doc in documents:
# 超过限制才切分
if len(doc.page_content) > max_chunk_chars:
chunks = safe_splitter.split_text(doc.page_content)
for chunk in chunks:
if chunk.strip():
safe_documents.append(Document(
page_content=chunk,
metadata=doc.metadata.copy()
))
else:
safe_documents.append(doc)
# --------------------------------------------------------------------------------
# Milvus 现有集合要求部分 metadata 字段必填;历史调用方未必都传这些字段,这里统一兜底补齐。
for idx, doc in enumerate(safe_documents):
metadata = doc.metadata or {}
if not metadata.get("doc_id"):
project_uuid = metadata.get("project_uuid") or "unknown_project"
heading = metadata.get("heading") or "chunk"
metadata["doc_id"] = f"{project_uuid}:{heading}:{idx}"
if "original_title" not in metadata:
metadata["original_title"] = metadata.get("heading") or ""
if "path" not in metadata:
metadata["path"] = ""
if "project_uuid" not in metadata:
metadata["project_uuid"] = "unknown_project"
doc.metadata = metadata
logger.info(f"【add_documents】预处理后准备写入 {len(safe_documents)} 条文档")
vs = self._get_milvus(drop_old=self._drop_old)
self._drop_old = False
ids = []
current_batch: List[Document] = []
current_batch_chars = 0
batch_num = 1
def _flush_batch() -> None:
nonlocal current_batch, current_batch_chars, batch_num
if not current_batch:
return
logger.info(
"【add_documents】写入批次 %s,数量:%s,约 %s 字符",
batch_num,
len(current_batch),
current_batch_chars,
)
try:
res = _add_documents_batch_with_retry(vs, current_batch)
ids.extend(res)
logger.info("✅ 批次写入成功,返回 ID 数:%s", len(res))
except Exception as e:
logger.error("❌ 批次写入失败: %s", e, exc_info=True)
batch_num += 1
current_batch = []
current_batch_chars = 0
for doc in safe_documents:
doc_chars = len(doc.page_content or "")
would_exceed_docs = bool(current_batch) and len(current_batch) >= max_docs_per_batch
would_exceed_chars = bool(current_batch) and (
current_batch_chars + doc_chars > max_chars_per_batch
)
if would_exceed_docs or would_exceed_chars:
_flush_batch()
current_batch.append(doc)
current_batch_chars += doc_chars
_flush_batch()
logger.info(f"【add_documents】全部完成总写入 ID 数:{len(ids)}")
return ids
def similarity_search_with_score(
self, query: str, k: int = 10, filter: Optional[str] = None
) -> List[Tuple[Document, float]]:
vs = self._get_milvus(drop_old=False)
query = query[:5000]
if filter:
return vs.similarity_search_with_score(query, k=k, filter=filter)
return vs.similarity_search_with_score(query, k=k)
def similarity_search_dense_filtered(
self,
query: str,
k: int,
filter_expr: str,
) -> List[Tuple[Document, float]]:
"""
使用 dense 向量 ANN + Milvus 标量过滤检索
hybriddense+sparse集合上 langchain_milvus filter 可能不生效抽取侧召回用此路径保证 doc_id 隔离
"""
from pymilvus import MilvusClient
q = (query or "")[:5000]
if not q.strip():
return []
emb = self._get_embeddings().embed_query(q)
client = MilvusClient(uri=MILVUS_DB_URL)
try:
raw = client.search(
collection_name=self.collection_name,
data=[emb],
anns_field="dense",
limit=max(1, int(k)),
filter=filter_expr,
output_fields=[
"text",
"heading",
"heading_level",
"doc_id",
"project_uuid",
"original_title",
"path",
],
)
finally:
client.close()
hits = raw[0] if raw else []
out: List[Tuple[Document, float]] = []
for hit in hits:
ent = hit.get("entity") or {}
doc = Document(
page_content=str(ent.get("text") or ""),
metadata={
"heading": ent.get("heading"),
"heading_level": ent.get("heading_level"),
"doc_id": ent.get("doc_id"),
"project_uuid": ent.get("project_uuid"),
"original_title": ent.get("original_title"),
"path": ent.get("path"),
},
)
dist = hit.get("distance")
try:
score = float(dist) if dist is not None else 0.0
except (TypeError, ValueError):
score = 0.0
out.append((doc, score))
return out
def delete_by_filter(self, filter_expr: str) -> int:
try:
from pymilvus import MilvusClient
client = MilvusClient(uri=MILVUS_DB_URL)
if not client.has_collection(self.collection_name):
return 0
# 某些集合主键字段名不叫 id例如 langchain-milvus 可能使用自定义 PK/auto_id
# 先从集合描述里找主键字段,再用于 query 计数。
pk_field = None
describe = client.describe_collection(self.collection_name)
for f in describe.get("fields", []) or []:
# 兼容不同返回结构is_primary / isPrimary / primary
if f.get("is_primary") or f.get("isPrimary") or f.get("primary"):
pk_field = f.get("name")
break
count = 0
try:
if pk_field:
res = client.query(
self.collection_name,
filter=filter_expr,
output_fields=[pk_field],
)
count = len(res)
else:
# 找不到主键字段名时也不阻断删除
count = 0
except Exception:
# 仅计数失败不影响删除
count = 0
client.delete(self.collection_name, filter=filter_expr)
client.close()
return count
except Exception as e:
logger.error(f"删除失败: {e}")
return 0
# ============================================================================
# Markdown 拆分
# ============================================================================
def split_markdown(text: str, chunk_size: int = 500, chunk_overlap: int = 50) -> List[str]:
if not text: return []
splitter = RecursiveCharacterTextSplitter(
chunk_size=chunk_size, chunk_overlap=chunk_overlap,
separators=["\n\n", "", "", "", "\n", "", "", ""]
)
return splitter.split_text(text)
def split_markdown_by_headings(content: str, chunk_size=300, chunk_overlap=40) -> List[Document]:
if not content: return []
docs = []
lines = content.split("\n")
current_heading = ""
current_level = 0
current_lines = []
def flush():
nonlocal current_lines, current_heading, current_level
txt = "\n".join(current_lines).strip()
if txt:
docs.append(Document(
page_content=txt,
metadata={"heading": current_heading, "heading_level": current_level}
))
current_lines = []
for line in lines:
line = line.rstrip()
m = re.match(r"^(#{1,6})\s+(.+)$", line)
if m:
flush()
current_level = len(m.group(1))
current_heading = m.group(2).strip()
else:
current_lines.append(line)
flush()
if not docs:
chunks = split_markdown(content, chunk_size, chunk_overlap)
for i, c in enumerate(chunks):
docs.append(
Document(
page_content=c,
metadata={"chunk_index": i, "heading": "", "heading_level": 0},
)
)
return docs
def process_document_to_vector_store(
doc_id: str, title: str, content: str, path: str, project_uuid: str, collection_name=COLLECTION_NAME
) -> bool:
try:
vs = VectorStore(collection_name=collection_name, drop_old=False)
docs = split_markdown_by_headings(content)
for d in docs:
d.metadata["doc_id"] = doc_id
d.metadata["original_title"] = title
d.metadata["path"] = path
d.metadata["project_uuid"] = project_uuid
vs.add_documents(docs)
return True
except Exception as e:
logger.error(f"处理文档失败: {e}")
return False
# ============================================================================
# 数据预处理
# ============================================================================
INPUT_FILE = "data/articles.jsonl"
OUTPUT_CHUNK_FILE = "data/processed/eval_chunks.jsonl"
def load_jsonl(filename: str, encoding="utf-8"):
with open(filename, encoding=encoding) as f:
for line in f:
if line.strip():
yield json.loads(line)
def write_jsonl(data, filename, append=False, ensure_ascii=False):
mode = "a" if append else "w"
with open(filename, mode, encoding="utf-8") as f:
for item in data:
f.write(json.dumps(item, ensure_ascii=ensure_ascii) + "\n")
def clean_text(text: str) -> str:
if not isinstance(text, str): return ""
text = re.sub(r"[\x00-\x09\x0B-\x1F\x7F]", "", text)
text = re.sub(r"[\u200b-\u200f\u2028\u2029]", "", text)
text = re.sub(r"[:’“”•…–—]", "", text)
text = re.sub(r"<[^>]+>", "\n", text)
text = re.sub(r"\n+", "\n", text)
text = re.sub(r" +", " ", text)
text = re.sub(r"^[。,?!;:]", "", text)
text = re.sub(r'[^\u4e00-\u9fff_a-zA-Z0-9\s《》【】""''·!@#$%^&*()_+=[]{}|;:\'",./<>?-]', "", text)
return text.strip()
def concat_metadata_to_content(title: str, content: str, metadata: dict):
parts = [
f"标题:{title}",
f"发布时间:{metadata.get('publish_time')}",
f"作者:{metadata.get('author')}",
f"来源:{metadata.get('source')}",
]
parts = [p for p in parts if p.split("")[-1]]
return " | ".join(parts) + "\n---\n" + content.strip()
def process_all_documents(input_file, output_file, chunk_size=500, overlap=50):
docs = load_jsonl(input_file)
splitter = RecursiveCharacterTextSplitter(chunk_size=chunk_size, chunk_overlap=overlap,
separators=["\n\n", "", "", "", "\n", "", "", ""])
all_chunks = []
num_docs = 0
for doc in docs:
num_docs +=1
content = clean_text(doc["content"])
chunks = splitter.split_text(content)
for i, chunk in chunks:
clean_c = clean_text(chunk)
if len(clean_c) <10: continue
all_chunks.append({
"id": f"{doc['id']}_chunk_{i}",
"doc_id": doc["id"],
"title": doc["title"],
"content": concat_metadata_to_content(doc["title"], clean_c, doc.get("metadata",{})),
"chunk_index": i,
"url": doc.get("metadata",{}).get("url","")
})
write_jsonl(all_chunks, output_file)
return {"num_docs":num_docs, "num_chunks":len(all_chunks)}
def load_chunk_jsonl(path):
res = []
with open(path, encoding="utf-8") as f:
for line in f:
if line.strip():
res.append(json.loads(line))
return res
def build_index(data, vs: VectorStore):
docs: List[Document] = []
for row in data:
c = row.pop("content", "").strip()
if len(c) < 10:
continue
docs.append(Document(page_content=c, metadata=row))
if docs:
vs.add_documents(docs)
def get_vector_store(drop_old=False):
vs = VectorStore(collection_name=COLLECTION_NAME, drop_old=drop_old)
return vs._get_milvus(drop_old=drop_old)
def search_eval(query, top_k=10):
from time import time
vs = VectorStore(drop_old=False)
st = time()
results = vs.similarity_search_with_score(query, k=top_k)
print(f"检索耗时: {time()-st:.2f}s")
return results
# ============================================================================
# 运行入口
# ============================================================================
if __name__ == "__main__":
logger.info("="*60)
logger.info("【Milvus 混合向量索引构建启动】dense + sparse(BM25)")
logger.info("="*60)
process_all_documents(INPUT_FILE, OUTPUT_CHUNK_FILE)
logger.info("✅ 文本分块处理完成")
chunk_data = load_chunk_jsonl(OUTPUT_CHUNK_FILE)
logger.info(f"✅ 加载分块数据:{len(chunk_data)}")
vs = VectorStore(drop_old=False)
build_index(chunk_data, vs)
logger.info("✅ 索引构建完成(增量写入)")
res = search_eval("测试检索内容")
logger.info(f"✅ 检索完成,命中数量:{len(res)}")
for doc, score in res:
logger.info(f"score={score:.4f} | content={doc.page_content[:80]}...")
logger.info("="*60)
logger.info("【全部执行完成】")

3
log/__init__.py Normal file
View File

@ -0,0 +1,3 @@
from .logger import configure_logging, get_logger
__all__ = ["configure_logging", "get_logger"]

185
log/logger.py Normal file
View File

@ -0,0 +1,185 @@
from __future__ import annotations
import logging
from logging.handlers import RotatingFileHandler
from pathlib import Path
_CONFIGURED = False
_FILE_PROCESSING_PREFIXES = (
"worker.document_processing",
"services.kb_service",
"services.es_docs",
"services.element_llm_extract_service",
"routers.extract",
"function.documents",
"function.vector_store",
"repo.kb_documents",
"routers.reference",
"services.doc_convert_service",
"services.reference_service",
)
_DOCUMENT_GENERATION_PREFIXES = (
"services.write_service",
"services.report_generation_service",
"services.markdown_stream_service",
"services.llm_client",
"services.llm_runner",
"services.report_prompt_service",
"services.report_runtime_store",
)
# 生成全过程追踪:完整记录输入 prompt / 调用模型 / 模型输出
_GENERATION_TRACE_PREFIXES = (
"generation.trace",
)
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 == prefix or name.startswith(prefix + ".") for prefix in self.prefixes)
class _OtherFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
name = str(record.name or "")
if any(name == prefix or name.startswith(prefix + ".") for prefix in _FILE_PROCESSING_PREFIXES):
return False
if any(name == prefix or name.startswith(prefix + ".") for prefix in _DOCUMENT_GENERATION_PREFIXES):
return False
if any(name == prefix or name.startswith(prefix + ".") for prefix in _GENERATION_TRACE_PREFIXES):
return False
return True
def configure_logging(
*,
log_dir: str | Path = "logs",
level: int = logging.INFO,
) -> Path:
global _CONFIGURED
target_dir = Path(log_dir).resolve()
target_dir.mkdir(parents=True, exist_ok=True)
other_log_path = target_dir / "other.log"
if _CONFIGURED:
return other_log_path
formatter = logging.Formatter(
"%(asctime)s | %(levelname)s | %(name)s | %(message)s"
)
root_logger = logging.getLogger()
root_logger.setLevel(level)
file_processing_handler = RotatingFileHandler(
target_dir / "file_processing.log",
maxBytes=10 * 1024 * 1024,
backupCount=5,
encoding="utf-8",
)
file_processing_handler.setLevel(level)
file_processing_handler.setFormatter(formatter)
file_processing_handler.addFilter(_PrefixFilter(_FILE_PROCESSING_PREFIXES))
document_generation_handler = RotatingFileHandler(
target_dir / "document_generation.log",
maxBytes=10 * 1024 * 1024,
backupCount=5,
encoding="utf-8",
)
document_generation_handler.setLevel(level)
document_generation_handler.setFormatter(formatter)
document_generation_handler.addFilter(_PrefixFilter(_DOCUMENT_GENERATION_PREFIXES))
other_handler = RotatingFileHandler(
other_log_path,
maxBytes=10 * 1024 * 1024,
backupCount=5,
encoding="utf-8",
)
other_handler.setLevel(level)
other_handler.setFormatter(formatter)
other_handler.addFilter(_OtherFilter())
# ── 要素抽取独立日志 ─────────────────────────────────────────────
element_extract_handler = RotatingFileHandler(
target_dir / "element_extract.log",
maxBytes=10 * 1024 * 1024,
backupCount=10,
encoding="utf-8",
)
element_extract_handler.setLevel(level)
element_extract_handler.setFormatter(formatter)
element_extract_handler.addFilter(_PrefixFilter(("services.element_llm_extract_service", "routers.extract")))
# ── 文件上传/解析独立日志 ─────────────────────────────────────────
file_upload_handler = RotatingFileHandler(
target_dir / "file_upload.log",
maxBytes=10 * 1024 * 1024,
backupCount=10,
encoding="utf-8",
)
file_upload_handler.setLevel(level)
file_upload_handler.setFormatter(formatter)
file_upload_handler.addFilter(_PrefixFilter(("routers.reference", "routers.template", "services.doc_convert_service", "services.reference_service", "services.kb_service", "routers.kb")))
# ── 报告生成独立日志 ──────────────────────────────────────────────
report_generation_handler = RotatingFileHandler(
target_dir / "report_generation.log",
maxBytes=10 * 1024 * 1024,
backupCount=10,
encoding="utf-8",
)
report_generation_handler.setLevel(level)
report_generation_handler.setFormatter(formatter)
report_generation_handler.addFilter(_PrefixFilter(("services.report_generation_service", "services.report_prompt_service", "services.report_runtime_store", "services.markdown_stream_service")))
# ── LLM 调用独立日志 ──────────────────────────────────────────────
llm_handler = RotatingFileHandler(
target_dir / "llm.log",
maxBytes=10 * 1024 * 1024,
backupCount=10,
encoding="utf-8",
)
llm_handler.setLevel(level)
llm_handler.setFormatter(formatter)
llm_handler.addFilter(_PrefixFilter(("services.llm_client", "services.llm_runner")))
# ── 生成全过程追踪日志(输入 prompt / 模型 / 输出,单条可能较大)────────
generation_trace_handler = RotatingFileHandler(
target_dir / "generation_trace.log",
maxBytes=50 * 1024 * 1024,
backupCount=10,
encoding="utf-8",
)
generation_trace_handler.setLevel(level)
generation_trace_handler.setFormatter(formatter)
generation_trace_handler.addFilter(_PrefixFilter(_GENERATION_TRACE_PREFIXES))
stream_handler = logging.StreamHandler()
stream_handler.setLevel(level)
stream_handler.setFormatter(formatter)
root_logger.handlers.clear()
root_logger.addHandler(file_processing_handler)
root_logger.addHandler(document_generation_handler)
root_logger.addHandler(other_handler)
root_logger.addHandler(element_extract_handler)
root_logger.addHandler(file_upload_handler)
root_logger.addHandler(report_generation_handler)
root_logger.addHandler(llm_handler)
root_logger.addHandler(generation_trace_handler)
root_logger.addHandler(stream_handler)
_CONFIGURED = True
return other_log_path
def get_logger(name: str) -> logging.Logger:
return logging.getLogger(name)

66
main.py Normal file
View File

@ -0,0 +1,66 @@
"""
main.py
报告生成独立服务 FastAPI 入口
启动方式
uvicorn main:app --reload
python main.py
"""
import logging
from contextlib import asynccontextmanager
import uvicorn
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from config import settings
from database import engine, init_database
from log import configure_logging
from routers import report
configure_logging()
_log = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""应用启动与关闭时执行。"""
init_database()
yield
engine.dispose()
app = FastAPI(
lifespan=lifespan,
title=settings.APP_TITLE,
version=settings.APP_VERSION,
description=settings.APP_DESCRIPTION,
docs_url="/docs",
redoc_url="/redoc",
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(report.router, prefix="/api/v1")
@app.get("/health", tags=["系统"], summary="健康检查")
def health_check():
"""确认服务存活,返回版本信息。"""
return {"status": "ok", "version": settings.APP_VERSION}
if __name__ == "__main__":
uvicorn.run(
"main:app",
host=settings.HOST,
port=settings.PORT,
reload=settings.RELOAD,
)

1
prompts/__init__.py Normal file
View File

@ -0,0 +1 @@
# prompts 包

View File

@ -0,0 +1 @@
# report_generation prompts 包

View File

@ -0,0 +1,52 @@
"""Fixed markdown templates used by report generation."""
def markdown_hashes_for_section_no(section_no: str) -> str:
"""与前端 markdownHashesForSectionNo / _heading_level_and_class 对齐。"""
parts = str(section_no or "").strip().split(".")
if len(parts) == 1:
return "##"
if len(parts) == 2:
return "###"
return "####"
def missing_child_heading_markdown(heading_no: str) -> str:
hashes = markdown_hashes_for_section_no(heading_no)
return f"\n\n{hashes} {heading_no} 待补充\n\n待补充"
# 兼容旧引用;新代码请用 missing_child_heading_markdown(heading_no)
MISSING_CHILD_HEADING_TEMPLATE = "\n\n### {heading_no} 待补充\n\n待补充"
MINIMAL_MISSING_TABLE_TEMPLATE = (
"\n\n### {table_name}\n\n"
"| 项目 | 内容 |\n"
"| --- | --- |\n"
"| 关键数据 | 待补充 |\n"
)
APPENDIX8_PARAMETER_COMPARISON_TABLE = (
"| 序号 | 项目名称 | 单位 | 可研报告 | 后评价报告 | 备注 |\n"
"| --- | --- | --- | --- | --- | --- |\n"
"| 一 | 成本参数 | | | | |\n"
"| 1 | 原料价格 | | | | |\n"
"| 1.1 | 氢气 | 元/吨 | 待补充 | 待补充 | |\n"
"| 2 | 催化剂和化学药剂 | 万元 | 待补充 | 待补充 | |\n"
"| 3 | 燃料动力价格 | | | | |\n"
"| 3.1 | 除盐水价格 | 元/吨 | 待补充 | 待补充 | |\n"
"| …… | …… | | | | |\n"
"| 二 | 营业收入参数 | | | | |\n"
"| 2.1 | 98#汽油 | 元/吨 | 待补充 | 待补充 | |\n"
"| …… | …… | | | | |\n"
"| 三 | 税收参数 | | | | |\n"
"| | 增值税税率 | | | | |\n"
"| | 汽油各品种产品 | % | 待补充 | 待补充 | |\n"
"| …… | …… | | | | |\n"
"| 四 | 基准收益率 | % | 待补充 | 待补充 | |"
)
APPENDIX_FIGURE_TARGETS: list[tuple[str, str]] = [
("附图1", "全厂物料平衡图"),
("附图2", "烷基化装置物料平衡图"),
]

View File

@ -0,0 +1 @@
你是后评价报告撰写助手。严格基于证据输出,禁止编造。示例仅可用于写作风格参考,禁止复用示例中的任何事实数据与结论。禁止输出与当前小节无关的表号/表题清单及跨节“详见表/参见表”引用。必须返回 JSON 对象,字段为 content/missingInfo/qualityChecks。

View File

@ -0,0 +1,67 @@
你正在编写后评价报告章节:{{section_title}}
【章节细则描述】
{{section_prompt}}
【章节模板】
{{section_title}}
【模板必需表格】
{{required_tables_text}}
【结构化表格证据(必须优先采用)】
{{structured_tables_text}}
【字段级已抽取结果(强约束)】
{{canonical_fields_text}}
【章节示例】
{{selected_example}}
【参考范文】
{{section_reference_block}}
【示例使用约束】
1. 以《模版.doc》同章节结构为第一优先段落顺序、表格标题、表头字段尽量保持一致
2. 参考范文仅用于格式与结构参考,严禁复用示例中的项目名称、年份、金额、比例、指标值与结论;
3. 所有数值必须来自证据包;如需表格,表头可沿用模板,表内数据必须替换为当前项目证据;
4. 若模板字段无证据,按字段粒度写"待补充",不得整段空泛描述。
【输出硬约束】
1. 若存在【模板必需表格】,正文必须出现同名(或同编号)表格标题;
2. 若【结构化表格证据】中存在对应必需表,必须原样使用该 Markdown 表格,不得自行生成或改写表头/数值;
3. 仅在单元格级别缺失时写"待补充",避免整段反复"待补充"
4. 若【字段级已抽取结果】中某字段为非"待补充"值,正文该字段不得写"待补充",必须使用该抽取值;
5. content 字段只允许写章节正文,严禁出现"【缺失信息说明】""【质量检查】"及其任何条目;
6. 禁止输出与本节无关的表号/表题清单,禁止出现跨节表格引用(如"详见表X-X/参见表X-X/见表X-X/如表X-X所示");仅当【章节输出结构约束】明确要求时,才允许引用或输出对应表。
{{heading_rule}}7. 禁止使用"关键里程碑时间线""建设/投资执行情况"等突兀标签式标题。
【表格严格管控——必须遵守】
1. **禁止凭空生成表格**:只有当【章节输出结构约束】中明确包含"【表格强制要求】"标签时,本节才允许输出 Markdown 表格;
2. **无"表格强制要求"的章节一律禁止输出任何 Markdown 表格**(即不得输出含 | 分隔符的表格行),即使证据包中有结构化表格数据也不得在正文中嵌入;
3. **"见附表N"仅为引用语**:若合同要求写"项目建设工作程序见附表1。"等引用句,只需输出该引用句文本,附表本体在报告末尾统一输出,严禁在本节正文中展开附表的完整 Markdown 表格;
4. 表格数据必须严格来自要素管理element_tables/element_cells不得自行编造表格内容
5. 每个 Markdown 表格前须有独立一行表题形如「表1 …」「表2-3 …」「附表8 …」等);表题紧挨表格上方单独成段,表题与表格之间最多空一行或一行注释;前端会将表题居中排版。
6. **表号与表名间距**表题中表号如「表2-4」「附表8」与表名之间须空两个全角空格U+3000例如「表2-4  原料数量及组成对比表」。
7. **表头栏单位**凡含计量单位的列名名称写第一行、单位加括号写在第二行且在同一表头单元格内Markdown 可用 `<br>`,如 `新鲜水<br>m³/h`);表题与表头均勿使用 `**` 加粗;勿将单位单独占一列,勿把「名称(单位)」横挤在同一行。
8. **公共单位写表题**若整张表各数据列所用单位相同单位应加括号写在表题行末尾如「表3 ××公司储罐能力 (m³)」),表头栏内不再重复该单位;若各列单位不一致,则仍按列在表头内分行写单位。
9. **表格序号列**:用阿拉伯数字,层次与正文一致(如 1、1.1、1.2、2、2.1);行键或表体第一列已带层次编号时可与之对齐;否则自上而下用 1、2、3…「合计」「总计」行可用「—」。
10. **表体与数字**:表内文字、数字宜水平与垂直居中;若单元格内需换行或分段(含 `<br>`),宜左齐排列以便阅读。同一表内、同列的小数、百分比等宜保留相同的小数位数。
【检索顺序约束】
1. 优先使用要素抽取结果;
2. 要素不足时补充文档段落;
3. 最后使用关键词检索到的补充材料;
4. 无证据时写"待补充",禁止编造。
{{prior_sibling_sections_block}}
{{prior_chapters_block}}
【章节输出结构约束】
{{section_contract}}
【证据包(JSON)】
{{evidence_json}}
请仅返回 JSON{"content":"章节Markdown正文","missingInfo":["缺失项"],"qualityChecks":["校验结论"]}

View File

@ -0,0 +1,88 @@
你正在编写后评价报告章节:{{section_title}}
本次任务:以【章节细则描述】和【参考范文】共同作为本节的写作模板,以【事实证据】作为唯一数据来源。核心原则是:**细则与范文决定写什么、怎么写;证据只负责提供可填入模板的真实数据**。生成时必须先搭模板,再填证据,严禁脱离模板自由发挥,严禁复用范文数据或自行改写证据数据。
========================= 第一部分 · 写作模板(最高优先级:决定内容范围、结构和文风)=========================
【标题编号规则】
{{heading_rule}}
【章节细则描述】
{{section_prompt}}
【参考范文(内容范围、论述维度、段落结构和行文风格的主要模板)】
{{section_reference_block}}
========================= 第二部分 · 事实证据(唯一数据来源,仅用于支撑和填充模板)=========================
【模板必需表格】
{{required_tables_text}}
【结构化表格证据(必须优先采用)】
{{structured_tables_text}}
【字段级已抽取结果(强约束)】
{{canonical_fields_text}}
【证据包(JSON)】
{{evidence_json}}
========================= 第三部分 · 上文已生成内容(只用于一致性校验,不改变本节模板)=========================
{{prior_sibling_sections_block}}
{{prior_chapters_block}}
========================= 第四部分 · 写作与输出要求(务必逐条遵守)=========================
【生成步骤】
1. 先读取【章节细则描述】和【参考范文】,抽取本节应覆盖的内容主题、论述维度、段落顺序、子标题层级、表格/列举形式和结论方式;
2. 再读取【章节输出结构约束】,确认本节是否允许/必须输出表格、附表引用或特定结构;
3. 然后只从【事实证据】中选择可支撑上述模板的数据,把证据数据填入对应位置;
4. 最后输出正文。若模板要求的某项内容在证据中没有对应数据,写"待补充",不得跳过、猜测、编造或用范文数据顶替。
【模板遵循要求——细则与范文共同决定“写什么”和“怎么写”】
1. "写什么"由【章节细则描述】与【参考范文】共同决定:细则列出的要点、子项及顺序为必写项;参考范文实际写到的内容主题、论述维度和信息点(如背景、依据、目标、措施、问题、结论等)也应覆盖。二者取并集,不得遗漏,也不得另起炉灶写无关内容;
2. "怎么写"以【参考范文】为主要模板:段落数量、段落顺序、每段主题、论述推进、句式结构、专业术语、连接词、语气口吻、详略程度和结论表达都应高度贴合范文;
3. 若【章节细则描述】与【参考范文】存在差异,优先保证细则要求完整覆盖,再用范文的结构和笔法组织表达;若二者均未要求,正文不要主动扩展。
【证据使用要求——数据必须来自证据且保持原值】
1. 所有项目名称、时间、金额、数量、比例、指标值、单位、结论依据等事实性内容,只能来自第二部分事实证据;
2. 数据必须原值引用,严禁自行修改、估算、换算单位、四舍五入、增减、归纳为新数值或编造。证据是多少就写多少;证据未给出的数据写"待补充"
3. 若【字段级已抽取结果】中某字段为非"待补充"值,正文必须原样使用该抽取值,不得写"待补充",也不得改动、换算或重新表述其数值;
4. 内容来源优先级:结构化表格证据 / 字段级已抽取结果 > 证据包(JSON)中的章节文档 > 关键词检索补充材料;
5. 禁止复用【参考范文】或【章节示例】中的任何项目名称、年份、金额、指标值、比例、结论等事实数据。
【参考范文贴合要求——高度相似但严禁照抄】
1. 逐段对照:范文有几段就尽量写几段,每段主题、先后顺序、论述角度与起承转合须与范文对应;
2. 句式与笔法对齐:尽量沿用范文的段首引导方式、常用表达、收束方式和专业语气,使本节读起来与范文出自同一类报告;
3. 篇幅与颗粒度对齐:每段篇幅、信息密度和展开程度与范文相当,不得明显更短、更空泛,也不得无端扩写;
4. 形式对齐:范文采用分条、分项、描述性子标题或表格呈现的,本节也尽量采用同类形式,但必须满足【章节输出结构约束】和下方表格规则;
5. 禁止逐字照抄不得出现与范文连续相同超过15字的句子或成段文字应在保持结构和笔法相似的前提下用本项目证据重新表述。
【输出硬约束】
1. content字段只允许写章节正文严禁出现"【缺失信息说明】""【质量检查】"及其任何条目;
2. 若存在【模板必需表格】,正文必须出现同名(或同编号)表格标题;
3. 若【结构化表格证据】中存在对应必需表必须原样使用该Markdown表格不得自行生成或改写表头/数值;
4. 仅在单元格级别缺失时写"待补充",避免整段反复"待补充"
5. 禁止输出与本节无关的表号/表题清单,禁止出现跨节表格引用(如"详见表X-X/参见表X-X/见表X-X/如表X-X所示");仅当【章节输出结构约束】明确要求时,才允许引用或输出对应表;
6. 禁止使用"关键里程碑时间线""建设/投资执行情况"等突兀标签式标题;
7. 数字与汉字之间不留空格:阿拉伯数字、百分比、金额、年份等与相邻汉字之间不得插入半角或全角空格,例如写"投资1.2亿元""2023年12月""产能达95%",不得写"投资 1.2 亿元""2023 年 12 月""产能达 95 %";数字与计量单位之间也不留空格,如"30万吨"而非"30 万吨"
8. 子标题形式约束:正文段落允许使用描述性小标题,但只能采用"一、""(一)""1."或加粗短语单独成行等中文公文层级形式严禁使用Markdown标题语法`#``##``###`等)充当子标题。表格上方的表题不属于子标题;
9. 计量单位须规范:面积写"m²"不得写"m2",体积写"m³"不得写"m3",流量写"m³/h"不得写"m3/h";温度写"℃",千分号写"‰",科学计数可写"×10⁴"。正文与表格中的单位均须规范。
【表格严格管控】
1. 只有当【章节输出结构约束】中明确包含"【表格强制要求】"标签时本节才允许输出Markdown表格
2. 无"表格强制要求"的章节一律禁止输出任何Markdown表格不得输出含`|`分隔符的表格行),即使证据包中有结构化表格数据也不得在正文中嵌入;
3. "见附表N"仅为引用语:若结构约束要求写"项目建设工作程序见附表1。"等引用句只输出引用句文本附表本体在报告末尾统一输出严禁在本节展开完整Markdown表格
4. 表格数据必须严格来自要素管理element_tables/element_cells或结构化表格证据不得自行编造、换算或改写表格内容
5. 每个Markdown表格前须有独立一行表题如「表1  ××表」「表2-3  ××表」「附表8  ××表」表题紧挨表格上方单独成段
6. 表号与表名之间须空两个全角空格U+3000例如「表2-4  原料数量及组成对比表」
7. 含计量单位的表头名称写第一行、单位加括号写第二行且在同一表头单元格内Markdown可用`<br>`,如`新鲜水<br>m³/h`);勿将单位单独占一列;
8. 若整张表各数据列所用单位相同,单位写在表题行末尾,表头栏内不再重复;若各列单位不一致,则按列在表头内分行写单位;
9. 表格序号列用阿拉伯数字,层次与正文一致;"合计""总计"行可用"—"
10. 同一表内、同列的小数、百分比等宜保留相同的小数位数,但不得因此改动证据原值。
【输出格式】
请仅返回JSON{"content":"章节Markdown正文","missingInfo":["缺失项"],"qualityChecks":["校验结论"]}
你正在编写后评价报告章节:{{section_title}}

View File

@ -0,0 +1,14 @@
"""Heading rule prompt variables for report generation."""
DEFAULT_HEADING_RULE = (
"5. 各章节内部小标题须使用规范层级格式(如“### 1.2.1 …”);"
"若在同一节内使用并列条目必须统一写作“1… 2… 3…”"
"禁止使用“一、二、三、”“”或“1.”“1.2.”“3.1”等序号形式;\n"
)
SECTION_HEADING_RULES: dict[str, str] = {
"1.2": (
"5. 本节1.2)必须严格遵循【章节输出结构约束】给定的纯文本编号体结构;"
"不得使用“###”等 Markdown 小标题语法不得将“1.2.1/1.2.2”改写为“1/2”。\n"
),
}

View File

@ -0,0 +1,4 @@
"""Fallback prompt fragments for report generation."""
DEFAULT_SECTION_PROMPT_FALLBACK = "按后评价细则规范撰写本章节。"
DEFAULT_SELECTED_EXAMPLE_FALLBACK = "无示例,按规范输出。"

View File

@ -0,0 +1 @@
你是后评价报告撰写助手。任务是对既有章节做最小修改补齐缺表,禁止删除事实性内容,禁止编造。返回 JSON{"content":"..."}

View File

@ -0,0 +1,19 @@
你正在修订章节:{{section_title}}
目标:在不删除原有有效内容的前提下,补齐缺失表格。
必须出现的表标识:{{missing_tables}}
要求:
1) 每个缺失表都必须在正文中出现,并使用 Markdown 表格;
2) 若证据不足,单元格可写“待补充”;
3) 表标题必须包含对应表标识如“表2-1”
4) 仅输出修订后的完整章节 Markdown。
【原章节内容】
{{content}}
【原始章节提示词】
{{original_prompt}}
【证据包(JSON)】
{{evidence_json}}

View File

@ -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'
'(要求:先用 24 句完整书面语概括动因与结论,再视需要附表)\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烷基化装置工艺技术方案”作为小标题小标题下先用一段完整文字写可研报告对不同烷基化工艺的比选过程、比选维度、拟选工艺及最终技术供应商随后必须使用“123…”逐条列出可研推荐工艺的先进性与适应性条目内容可覆盖辛烷值、选择性、酸耗、反应器与传热方式、安全环保、可靠性、运行费用等方面。\n'
'2再固定输出“2废酸再生单元工艺技术方案”作为小标题小标题下先用一段完整文字写可研报告对不同废酸再生工艺的比选过程、比选对象、拟选工艺及最终技术供应商随后必须使用“12…”逐条列出推荐工艺的主要特点条目内容可覆盖流程简洁性、运行成本、尾气排放、二次污染、操作弹性、工艺适用性等方面。\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'
' 5EPC总承包管理组织成立\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标题后空一行再写一段连续文字24句概括本章将依次从主要经济指标实现程度、投资与执行情况、经济效益、不确定性分析到本章结论展开仅可使用证据包缺失处写“待补充”。\n'
'3不得输出 5.15.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': '本章为综合评价结论必须基于【前序章节正文第16章】归纳提炼是对前六章内容的总结升华不得脱离前文另起论述。按章节标题组织先事实后结论结论与数据须与前文一致缺失项写“待补充”。',
'7.1': '本节是对第16章的归纳性评价必须基于【前序章节正文】撰写。须使用“事实依据—评价判断—问题与建议”三段式结构。正文不得出现“【事实依据】”“【评价判断】”“【问题与建议】”标题标签。并按顺序完整覆盖下级小节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'
'须优先依据【前序章节正文第16章】归纳各条目不得与前面章节结论矛盾不得新增无关小标题不得改变上述标题与编号顺序不得合并或拆分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表格之后用12段自然段给出本项目成功度综合评价结论可与前文结论一致、作归纳证据不足处写“待补充”。\n'
'\n'
'【表格强制要求】\n'
'1表格必须直接使用“要素管理”中的表格element_tables/element_cells不得自行新造表不得用正文推断补表。\n'
'2必须优先使用要素管理中对应“表7-1 项目综合评价评分表”的结构化表;若存在对应表格,须直出其表头、行项目和单元格内容,不得改列名、不得替换成其他表。\n'
'3表7-1不得省略如要素管理中未命中对应表格也必须按细则列结构输出占位表单元格可填“待补充”\n'
'\n'
'【写作约束】\n'
'须优先依据【前序章节正文第16章】归纳成功度结论不得新增无关小标题不得省略四级标准、表题与表格表内数值以要素管理直出为准证据不足处写“待补充”不得在正文编造与表内数据相矛盾的评分、综合得分或等级结论。',
'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'
'须优先依据【前序章节正文第16章】提炼主要经验与前文已述事实一致不得新增无关小标题不得改变上述标题与分项顺序不得合并或拆分固定分项证据不足处写“待补充”不得编造负荷、能耗、工期或整改数量数据。',
'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'
'须优先依据【前序章节正文第16章】归纳问题与建议与前文已述问题一致不得新增无关小标题不得改变上述标题、条目与分项顺序不得合并或拆分固定条目证据不足处写“待补充”不得编造时间节点、投资金额比例、酸耗或整改结论。'}
DEFAULT_SECTION_OUTPUT_CONTRACT = '按章节标题自然组织内容,围绕证据包先事实后结论,缺失项写“待补充”。'

View File

@ -0,0 +1 @@
你是后评价报告格式修订助手。仅做格式对齐修订:章节标题、表名、表头。禁止新增未证据支持的数据。返回 JSON{"content":"..."}

View File

@ -0,0 +1,25 @@
你正在修订章节:{{section_title}}
目标:对齐模板格式,不改变事实结论。
请仅修订“章节标题、表名、表头”,正文事实描述尽量保持原样。
【模板表规格(JSON)】
{{table_specs_json}}
【当前章节】
{{content}}
【证据包(JSON)】
{{evidence_json}}
修订规则:
1) 章节首行必须为标准章节标题;
2) 表名必须与模板表规格中的 token/title 对齐表题中表号与表名之间须空两个全角空格如「表2-4  原料数量及组成对比表」
3) 表头字段优先与模板一致,表内数据来自证据包,无值写待补充;
4) 必须使用 Markdown 表格;
5) 表头栏排版指标名称与计量单位分两行写在同一表头单元格内单位须加括号并写在名称正下方Markdown 可用 `<br>`,如 `新鲜水<br>m³/h`);表题与表头均勿使用 `**` 加粗;勿将单位单独拆成一列表头列,勿把「名称(单位)」横挤在同一行;
6) 若整张表各数据列所用单位相同应将单位加括号写在表题末尾如「表3 ××公司储罐能力 (m³)」),表头栏内不再重复写该单位;
7) 表格「序号」列优先使用各行行键row_key首部已有的阿拉伯数字层次编号与正文 1、1.1、1.2、2、2.1 一致);若行键未带此类编号,则用自上而下连续阿拉伯数字 1、2、3…「合计」「总计」行序号可用「—」
8) 表体单元格内容宜居中;若有换行或分段,宜左齐。同列数值宜统一小数位数;
9) 禁止编造事实数据;
10) 仅返回修订后的完整章节 Markdown不要返回 JSON

23
requirements.txt Normal file
View File

@ -0,0 +1,23 @@
# Web 框架
fastapi
uvicorn[standard]
pydantic
pydantic-settings
# 数据库MySQL
sqlalchemy
pymysql
cryptography
# HTTPLLM / Embedding 调用)
requests
# 附图提取(解析项目 .docx 内嵌图片)
python-docx
# 向量检索Milvus + Embeddings + BM25
langchain-core
langchain-text-splitters
langchain-openai
langchain-milvus
pymilvus

0
routers/__init__.py Normal file
View File

204
routers/report.py Normal file
View File

@ -0,0 +1,204 @@
"""
routers/report.py
后评价报告核心生成路由独立抽取版
eval_report routers/write.py 摘取报告生成相关端点去除鉴权依赖
项目查询改用轻量的 services/project_service.get_project
业务逻辑在 services/report_generation_service.py
"""
from __future__ import annotations
import asyncio
import json
from typing import Optional
from fastapi import APIRouter, Depends, Header, HTTPException
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from database import SessionLocal, get_db
from database.models import ReportTemplate, ReportTemplateSection
from schemas.write import (
GenerateReportJobCreate,
GenerateReportJobItem,
GenerateReportResult,
)
from services.project_service import get_project
from services.report_generation_service import (
create_report_job,
get_report_job,
get_report_result,
get_report_stream_snapshot,
retry_report_chapter,
cancel_report_job,
)
router = APIRouter(prefix="/write", tags=["后评价报告生成"])
@router.get("/projects/{project_id}/generate-sections", summary="按章节智能体生成提示词清单")
def generate_sections_prompt(
project_id: str,
template_id: Optional[str] = None,
db: Session = Depends(get_db),
):
_ = get_project(project_id, db)
template = None
if template_id:
template = db.query(ReportTemplate).filter(ReportTemplate.id == template_id, ReportTemplate.is_active == True).first() # noqa: E712
if not template:
template = db.query(ReportTemplate).filter(ReportTemplate.is_default == True, ReportTemplate.is_active == True).first() # noqa: E712
if not template:
raise HTTPException(status_code=404, detail="未找到可用模板")
sections = (
db.query(ReportTemplateSection)
.filter(ReportTemplateSection.template_id == template.id)
.order_by(ReportTemplateSection.section_order.asc())
.all()
)
return {
"templateId": template.id,
"templateName": template.name,
"sections": [
{
"sectionKey": s.section_key,
"sectionTitle": s.section_title,
"prompt": (
"请基于2020后评价细则与本项目检索材料先查要素表再查文档段落最后生成本章节内容。\n"
+ (s.section_prompt or "")
),
"examples": s.examples or "",
}
for s in sections
],
}
@router.post(
"/projects/{project_id}/generate-report-job",
response_model=GenerateReportJobItem,
summary="创建分章异步报告生成任务",
)
def create_generate_report_job(
project_id: str,
body: GenerateReportJobCreate,
db: Session = Depends(get_db),
x_user_id: Optional[str] = Header(default=None, alias="X-User-Id"),
):
_ = get_project(project_id, db)
return create_report_job(
project_id,
db,
template_id=body.templateId,
top_k=body.topK,
requested_by=x_user_id,
)
@router.get(
"/projects/{project_id}/generate-report-job/{job_id}",
response_model=GenerateReportJobItem,
summary="查询分章异步报告任务进度",
)
def get_generate_report_job(
project_id: str,
job_id: str,
db: Session = Depends(get_db),
):
return get_report_job(project_id, job_id, db)
@router.get(
"/projects/{project_id}/generate-report-job/{job_id}/result",
response_model=GenerateReportResult,
summary="获取分章异步报告任务结果",
)
def get_generate_report_result(
project_id: str,
job_id: str,
include_debug: bool = False,
db: Session = Depends(get_db),
):
return get_report_result(project_id, job_id, db, include_debug=include_debug)
@router.get(
"/projects/{project_id}/generate-report-job/{job_id}/events",
summary="订阅分章异步报告任务实时事件SSE",
)
async def stream_generate_report_job_events(
project_id: str,
job_id: str,
include_debug: bool = False,
):
# 校验后立即释放连接SSE 循环中按需短连接查询,避免长连占满连接池
with SessionLocal() as db:
_ = get_report_job(project_id, job_id, db)
async def _event_stream():
last_payload = ""
idle_ticks = 0
while True:
snapshot = get_report_stream_snapshot(job_id, include_debug=include_debug)
if not snapshot:
with SessionLocal() as db:
job = get_report_job(project_id, job_id, db)
result = get_report_result(project_id, job_id, db, include_debug=include_debug)
snapshot = {
"job": job.model_dump(),
"result": result.model_dump(),
}
payload = json.dumps(snapshot, ensure_ascii=False, separators=(",", ":"))
if payload != last_payload:
last_payload = payload
idle_ticks = 0
yield f"event: snapshot\ndata: {payload}\n\n"
else:
idle_ticks += 1
if idle_ticks >= 20:
idle_ticks = 0
yield "event: keepalive\ndata: ping\n\n"
status = str(((snapshot.get("job") or {}).get("status") or "")).strip().lower()
if status in ("completed", "failed", "cancelled"):
yield f"event: end\ndata: {payload}\n\n"
break
await asyncio.sleep(0.25)
return StreamingResponse(
_event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@router.post(
"/projects/{project_id}/generate-report-job/{job_id}/retry-chapter",
response_model=GenerateReportJobItem,
summary="重试指定章节",
)
def retry_generate_report_chapter(
project_id: str,
job_id: str,
section_key: str,
db: Session = Depends(get_db),
):
return retry_report_chapter(project_id, job_id, section_key, db)
@router.post(
"/projects/{project_id}/generate-report-job/{job_id}/cancel",
response_model=GenerateReportJobItem,
summary="取消报告生成任务",
)
def cancel_generate_report_job(
project_id: str,
job_id: str,
db: Session = Depends(get_db),
):
return cancel_report_job(project_id, job_id, db)

0
schemas/__init__.py Normal file
View File

179
schemas/write.py Normal file
View File

@ -0,0 +1,179 @@
"""
schemas/write.py
后评价报告项目相关的 Pydantic 数据模型
"""
from __future__ import annotations
from typing import Any, List, Optional
from pydantic import BaseModel
# ---------- 版本 ----------
class DocVersion(BaseModel):
id: str
version: str
content: str
savedAt: str
author: str
note: Optional[str] = ""
citationPayload: Optional[dict[str, Any]] = None
# ---------- 文档 ----------
class WriteDocument(BaseModel):
id: str
title: str
content: str
wordCount: int
createdAt: str
updatedAt: str
projectId: str
status: str # draft | review | published
versions: List[DocVersion] = []
class WriteDocumentSummary(BaseModel):
"""列表页只返回摘要,不含 content 正文"""
id: str
title: str
wordCount: int
createdAt: str
updatedAt: str
projectId: str
status: str
# ---------- 项目 ----------
class WriteProject(BaseModel):
id: str
uuid: str # 项目唯一标识,与 kb 共用
name: str
description: Optional[str] = ""
createdAt: str
updatedAt: str
docCount: int
status: str # active | archived
kbProjectId: Optional[str] = None
color: str
documents: List[WriteDocument] = []
class WriteProjectSummary(BaseModel):
"""列表页摘要,不含 documents"""
id: str
uuid: Optional[str] = None # 项目唯一标识,用于 URL 参数;兼容旧数据
name: str
description: Optional[str] = ""
createdAt: str
updatedAt: str
docCount: int
status: str
kbProjectId: Optional[str] = None
color: str
# ---------- 创建 / 更新请求体 ----------
class WriteProjectCreate(BaseModel):
name: str
description: Optional[str] = ""
kbProjectId: Optional[str] = None
color: Optional[str] = "#3b82f6"
class WriteProjectUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
status: Optional[str] = None
kbProjectId: Optional[str] = None
color: Optional[str] = None
class WriteDocumentCreate(BaseModel):
title: str
content: Optional[str] = ""
class WriteDocumentUpdate(BaseModel):
title: Optional[str] = None
content: Optional[str] = None
status: Optional[str] = None
class DocVersionCreate(BaseModel):
version: Optional[str] = None
content: str
author: str
note: Optional[str] = ""
citationPayload: Optional[dict[str, Any]] = None
# ---------- 章节审查(智能体) ----------
class ChapterReviewRequest(BaseModel):
"""章节智能审查请求体:选择章节 + 输入待审查文本。"""
chapter: str # "1"~"6"
content: str
class ChapterReviewResponse(BaseModel):
"""章节智能审查响应体:返回 Markdown 审查报告。"""
success: bool = True
chapter: str
review: str
model: Optional[str] = None
message: Optional[str] = ""
class GenerateReportJobCreate(BaseModel):
templateId: Optional[str] = None
topK: int = 10
class GenerateReportChapterItem(BaseModel):
sectionKey: str
sectionTitle: str
sectionOrder: int
status: str
updatedAt: Optional[str] = None
errorMessage: Optional[str] = None
class GenerateReportJobItem(BaseModel):
jobId: str
projectId: str
templateId: Optional[str] = None
status: str
progress: int
currentSectionKey: Optional[str] = None
errorMessage: Optional[str] = None
createdAt: Optional[str] = None
updatedAt: Optional[str] = None
completedAt: Optional[str] = None
chapters: List[GenerateReportChapterItem] = []
class GenerateReportResultChapter(BaseModel):
sectionKey: str
sectionTitle: str
sectionOrder: int
status: str
content: Optional[str] = None
errorMessage: Optional[str] = None
promptText: Optional[str] = None
evidencePayload: Optional[dict] = None
validationPayload: Optional[dict] = None
class GenerateReportResult(BaseModel):
jobId: str
status: str
report: Optional[str] = None
consistency: List[str] = []
chapters: List[GenerateReportResultChapter] = []

0
services/__init__.py Normal file
View File

View File

@ -0,0 +1,199 @@
"""
从项目知识库 Word.docx中提取附图1/附图2嵌入图用于报告附录
细则常见版式附图标题段落与图在同一节或相邻段落解析时合并前/当前/后段文字做关键词匹配
"""
from __future__ import annotations
import base64
import logging
from pathlib import Path
from typing import Optional
from docx import Document
from docx.oxml.ns import qn
from docx.table import Table
from docx.text.paragraph import Paragraph
logger = logging.getLogger(__name__)
# 过滤装饰性小图logo 等)
_MIN_FIGURE_BYTES = 6000
R_EMBED = "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}embed"
_NS = {
"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
}
def _compact(s: str) -> str:
return "".join(str(s or "").split())
def _classify_slot(ctx: str) -> Optional[int]:
"""
返回 1=全厂物料平衡图2=装置如烷基化物料平衡图
"""
t = _compact(ctx)
if not t:
return None
# 附图编号(先判 2避免同段目录同时出现两个编号时误判
if "附图2" in t:
return 2
if "附图1" in t:
return 1
if "全厂" in t and "物料平衡" in t:
return 1
if "烷基化" in t and "物料平衡" in t:
return 2
if "装置" in t and "物料平衡" in t and "全厂" not in t:
return 2
return None
def _content_type_to_md_subtype(content_type: str) -> str:
ct = (content_type or "").lower()
if "jpeg" in ct or ct.endswith("jpg"):
return "jpeg"
if "png" in ct:
return "png"
if "gif" in ct:
return "gif"
if "emf" in ct:
return "x-emf"
if "wmf" in ct:
return "x-wmf"
return "png"
def _blob_to_data_uri(blob: bytes, content_type: str) -> str:
sub = _content_type_to_md_subtype(content_type)
b64 = base64.standard_b64encode(blob).decode("ascii")
return f"data:image/{sub};base64,{b64}"
def _iter_paragraphs_deep(doc: Document):
body_el = doc.element.body
for el in body_el:
if el.tag == qn("w:p"):
yield Paragraph(el, doc._body)
elif el.tag == qn("w:tbl"):
table = Table(el, doc._body)
for row in table.rows:
for cell in row.cells:
for p in cell.paragraphs:
yield p
def extract_appendix_figure_candidates_from_docx(path: Path) -> dict[int, list[tuple[int, bytes, str]]]:
"""
从单个 docx 收集候选图slot -> [(size, blob, content_type), ...]
content_type 来自 OPC part用于拼 data URI
"""
candidates: dict[int, list[tuple[int, bytes, str]]] = {1: [], 2: []}
orphans_ordered: list[tuple[bytes, str]] = []
try:
doc = Document(str(path))
except Exception as exc:
logger.warning("appendix figure: open docx failed %s: %s", path, exc)
return candidates
paras = list(_iter_paragraphs_deep(doc))
texts = [p.text or "" for p in paras]
for i, p in enumerate(paras):
blobs_with_type: list[tuple[bytes, str]] = []
for blip in p._element.findall(".//a:blip", _NS):
embed = blip.get(R_EMBED)
if not embed:
continue
try:
rel = p.part.related_parts[embed]
except KeyError:
continue
blob = getattr(rel, "blob", None)
ct = getattr(rel, "content_type", "") or "image/png"
if blob and len(blob) >= _MIN_FIGURE_BYTES:
blobs_with_type.append((blob, ct))
if not blobs_with_type:
continue
prev_t = texts[i - 1] if i > 0 else ""
cur_t = texts[i]
next_t = texts[i + 1] if i + 1 < len(texts) else ""
ctx = f"{prev_t}\n{cur_t}\n{next_t}"
slot = _classify_slot(ctx)
if slot is None:
for blob, ct in blobs_with_type:
orphans_ordered.append((blob, ct))
continue
for blob, ct in blobs_with_type:
candidates[slot].append((len(blob), blob, ct))
def _dedupe_preserve_order(pairs: list[tuple[bytes, str]]) -> list[tuple[bytes, str]]:
seen: set[int] = set()
out: list[tuple[bytes, str]] = []
for blob, ct in pairs:
bid = id(blob)
if bid in seen:
continue
seen.add(bid)
out.append((blob, ct))
return out
orphans_ordered = _dedupe_preserve_order(orphans_ordered)
used_ids: set[int] = set()
for lst in candidates.values():
for _sz, blob, _ct in lst:
used_ids.add(id(blob))
orphans_ordered = [(b, c) for b, c in orphans_ordered if id(b) not in used_ids]
if not candidates[1] and orphans_ordered:
b, c = orphans_ordered.pop(0)
candidates[1].append((len(b), b, c))
if not candidates[2] and orphans_ordered:
b, c = orphans_ordered.pop(0)
candidates[2].append((len(b), b, c))
return candidates
def merge_best_appendix_figures(
per_doc: list[tuple[str, dict[int, list[tuple[int, bytes, str]]]]],
) -> dict[int, tuple[bytes, str, str]]:
"""
多文档合并每个 slot 只保留字节最大的一张更可能是主流程图而非小图标
返回 slot -> (blob, content_type, source_doc_name)
"""
best: dict[int, tuple[int, bytes, str, str]] = {}
for doc_name, cand in per_doc:
for slot in (1, 2):
for size, blob, ct in cand.get(slot) or []:
prev = best.get(slot)
if prev is None or size > prev[0]:
best[slot] = (size, blob, ct, doc_name)
return {k: (v[1], v[2], v[3]) for k, v in best.items()}
def appendix_figure_markdown_images(
resolved: dict[int, tuple[bytes, str, str]],
*,
label_title: list[tuple[str, str]],
) -> dict[int, str]:
"""slot -> markdown 片段(含 ### 标题与 ![](data:...)"""
out: dict[int, str] = {}
slot_to_title = {i + 1: lt for i, lt in enumerate(label_title)}
for slot, (blob, ct, src) in resolved.items():
if slot not in slot_to_title:
continue
label, title = slot_to_title[slot]
uri = _blob_to_data_uri(blob, ct)
cap = f"{label} {title}"
src_note = f"\n\n*(嵌入来源:{src}*" if src else ""
out[slot] = f"### {cap}\n\n![{cap}]({uri}){src_note}"
return out

View File

@ -0,0 +1,28 @@
"""
services/docx_export_service.py瘦身版
本独立服务不提供 Word 导出能力此处仅保留 report_generation_service
正文小节编号识别时懒加载依赖的 `_is_likely_section_number`以满足导入
"""
from __future__ import annotations
import re
def _is_likely_section_number(num: str) -> bool:
"""报告小节编号(如 2.1.1),非正文能耗数值(如 132.41)。"""
s = str(num or "").strip()
if not s or not re.fullmatch(r"\d+(?:\.\d+)*", s):
return False
parts = s.split(".")
if len(parts) > 4:
return False
for part in parts:
try:
n = int(part)
except ValueError:
return False
if n < 1 or n > 30:
return False
return True

80
services/kb_service.py Normal file
View File

@ -0,0 +1,80 @@
"""
services/kb_service.py瘦身版
仅保留报告生成附图提取所需的知识库文档磁盘路径解析助手
eval_report 的完整 kb_service.py 中抽取去除知识库 CRUD / 上传 / worker 等无关逻辑
"""
from __future__ import annotations
from pathlib import Path
from typing import List, Optional
from config import settings
from database.models import KbDocument as KbDocumentModel
def _normalize_rel_path(path: str) -> str:
"""'a\\b\\c' 规范为 'a/b/c',并去掉前导 '/'"""
s = str(path or "").replace("\\", "/").strip()
while s.startswith("./"):
s = s[2:]
return s.lstrip("/")
def _kb_doc_storage_rel_path(
file_path_dir: Optional[str],
basename: str,
storage_rel_path: Optional[str] = None,
) -> str:
"""项目目录下的相对存储路径(含文件名)。优先 storage_rel_pathconfirm 时写入)。"""
stored = _normalize_rel_path(str(storage_rel_path or ""))
if stored:
return stored
d = _normalize_rel_path(str(file_path_dir or ""))
bn = str(basename or "").strip()
if d and bn:
return f"{d}/{bn}"
return bn or d
def _kb_doc_path_candidates_for_model(doc_root: Path, doc: KbDocumentModel) -> List[Path]:
"""解析磁盘路径时的候选列表(按优先级)。"""
rel = _kb_doc_storage_rel_path(
doc.file_path,
doc.name,
getattr(doc, "storage_rel_path", None),
)
candidates: List[Path] = []
if rel:
candidates.append((doc_root / doc.project_id / rel).resolve())
name = str(doc.name or "").strip()
fp_dir = _normalize_rel_path(str(doc.file_path or ""))
if fp_dir and name:
candidates.append((doc_root / doc.project_id / fp_dir / name).resolve())
if name:
candidates.append((doc_root / doc.project_id / name).resolve())
if not candidates:
candidates.append((doc_root / doc.project_id / "_missing_").resolve())
deduped: List[Path] = []
seen: set[str] = set()
for p in candidates:
key = str(p)
if key in seen:
continue
seen.add(key)
deduped.append(p)
return deduped
def _kb_doc_absolute_file_path_for_model(doc_root: Path, doc: KbDocumentModel) -> Path:
for p in _kb_doc_path_candidates_for_model(doc_root, doc):
if p.is_file():
return p
return _kb_doc_path_candidates_for_model(doc_root, doc)[0]
def _kb_doc_file_exists_for_model(doc: KbDocumentModel) -> bool:
"""文档在磁盘上是否可读(多路径回退,兼容历史 file_path/name 组合)。"""
doc_root = Path(settings.DOC_PAT).resolve()
return any(p.is_file() for p in _kb_doc_path_candidates_for_model(doc_root, doc))

724
services/llm_client.py Normal file
View File

@ -0,0 +1,724 @@
from __future__ import annotations
import json
import logging
import random
import re
import time
import threading
from typing import Any, Optional
import requests
from requests import RequestException
from requests.exceptions import ChunkedEncodingError
from config import settings
_logger = logging.getLogger(__name__)
# 生成全过程追踪:完整记录输入 prompt / 调用模型 / 模型输出,写入 logs/generation_trace.log
_trace_logger = logging.getLogger("generation.trace")
_LLM_MAX_CONCURRENCY = 5
_llm_slots = threading.BoundedSemaphore(_LLM_MAX_CONCURRENCY)
class _RetryableLLMError(RuntimeError):
"""用于标记可安全重试的 LLM 调用异常。"""
class _ContentFieldStreamExtractor:
"""从流式 JSON 文本中增量提取 content 字段的已解码正文。"""
def __init__(self) -> None:
self._raw = ""
self._content_started = False
self._content_done = False
self._value_start = -1
self._consumed_pos = 0
def feed(self, chunk: str) -> tuple[str, bool]:
if not chunk:
return "", False
self._raw += chunk
emitted = ""
done_now = False
if not self._content_started:
marker = '"content"'
idx = self._raw.find(marker)
if idx == -1:
return "", False
colon = self._raw.find(":", idx + len(marker))
if colon == -1:
return "", False
quote = self._raw.find('"', colon + 1)
if quote == -1:
return "", False
self._content_started = True
self._value_start = quote + 1
self._consumed_pos = self._value_start
if self._content_started and not self._content_done:
emitted, consumed_pos, done_now = self._decode_partial_json_string(
self._raw,
self._consumed_pos,
)
self._consumed_pos = consumed_pos
if done_now:
self._content_done = True
return emitted, done_now
@staticmethod
def _decode_partial_json_string(src: str, start: int) -> tuple[str, int, bool]:
out: list[str] = []
i = start
n = len(src)
while i < n:
ch = src[i]
if ch == '"':
if i == 0 or src[i - 1] != "\\" or _ContentFieldStreamExtractor._is_escaped(src, i):
return "".join(out), i, True
if ch != "\\":
out.append(ch)
i += 1
continue
if i + 1 >= n:
break
esc = src[i + 1]
mapping = {
'"': '"',
"\\": "\\",
"/": "/",
"b": "\b",
"f": "\f",
"n": "\n",
"r": "\r",
"t": "\t",
}
if esc == "u":
if i + 6 > n:
break
hex_part = src[i + 2 : i + 6]
try:
out.append(chr(int(hex_part, 16)))
except Exception:
pass
i += 6
continue
if esc in mapping:
out.append(mapping[esc])
i += 2
continue
out.append(esc)
i += 2
return "".join(out), i, False
@staticmethod
def _is_escaped(src: str, quote_index: int) -> bool:
backslashes = 0
i = quote_index - 1
while i >= 0 and src[i] == "\\":
backslashes += 1
i -= 1
return backslashes % 2 == 0
def _format_exc_raw(e: Exception) -> str:
"""统一输出最直接的异常原文(类型 + repr"""
return f"{type(e).__name__}: {e!r}"
def _chat_completions_stream_text(
*,
api_base: str,
api_key: str,
model_name: str,
system_prompt: str,
user_prompt: str,
temperature: float,
max_tokens: int,
extra_payload: dict[str, Any],
connect_timeout_sec: int,
read_timeout_sec: int = 300,
on_content_delta: Optional[callable] = None,
) -> str:
"""
OpenAI-compat SSE 流式读取模型输出文本
- connect timeout 保留避免连接阶段长时间卡死
- read timeout 防止流式读取无限挂起默认 300s
"""
_logger.info(
"LLM 流式调用开始 | model=%s | temperature=%s | max_tokens=%s | timeout_connect=%s timeout_read=%s",
model_name, temperature, max_tokens, connect_timeout_sec, read_timeout_sec,
)
resp = requests.post(
f"{api_base}/chat/completions",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
json={
"model": model_name,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
"temperature": temperature,
"max_tokens": max_tokens,
"response_format": {"type": "json_object"},
"stream": True,
**extra_payload,
},
stream=True,
timeout=(connect_timeout_sec, max(60, read_timeout_sec)),
)
if resp.status_code in (408, 429, 500, 502, 503, 504):
raise _RetryableLLMError(f"LLM HTTP {resp.status_code}: {(resp.text or '')[:300]}")
if resp.status_code != 200:
raise RuntimeError(f"LLM HTTP {resp.status_code}: {(resp.text or '')[:800]}")
resp.encoding = "utf-8"
chunks: list[str] = []
extractor = _ContentFieldStreamExtractor()
try:
for line in resp.iter_lines(decode_unicode=True):
if not line:
continue
s = line.strip()
if not s.startswith("data:"):
continue
payload = s[5:].strip()
if not payload or payload == "[DONE]":
break
try:
obj = json.loads(payload)
except Exception:
continue
choices = obj.get("choices")
if not isinstance(choices, list) or not choices:
continue
first = choices[0] if isinstance(choices[0], dict) else {}
delta = first.get("delta") if isinstance(first.get("delta"), dict) else {}
content = delta.get("content")
if isinstance(content, str) and content:
chunks.append(content)
# print(content, end="", flush=True)
if on_content_delta:
delta_text, done_now = extractor.feed(content)
if delta_text:
try:
on_content_delta("delta", delta_text)
except Exception:
pass
if done_now:
try:
on_content_delta("finalizing", "")
except Exception:
pass
# 兼容部分实现把最终结果放在 message.content
message = first.get("message") if isinstance(first.get("message"), dict) else {}
msg_content = message.get("content")
if isinstance(msg_content, str) and msg_content:
chunks.append(msg_content)
# print(msg_content, end="", flush=True)
if on_content_delta:
delta_text, done_now = extractor.feed(msg_content)
if delta_text:
try:
on_content_delta("delta", delta_text)
except Exception:
pass
if done_now:
try:
on_content_delta("finalizing", "")
except Exception:
pass
except ChunkedEncodingError as e:
partial_text = "".join(chunks).strip()
# 若流提前结束但已收到完整 JSON直接使用避免无谓重试失败。
if partial_text:
try:
parse_json_object_from_text(partial_text)
return partial_text
except Exception:
pass
raise _RetryableLLMError(f"LLM 流中断: {_format_exc_raw(e)}") from e
text = "".join(chunks).strip()
if not text:
raise _RetryableLLMError("LLM 返回空内容")
print()
return text
def chat_completions_json(
*,
system_prompt: str,
user_prompt: str,
temperature: float = 0.2,
max_tokens: int = 4096,
timeout_sec: int = 180,
on_content_delta: Optional[callable] = None,
log_context: str = "",
) -> dict[str, Any]:
"""
统一的 OpenAI-compat chat/completions 调用强制返回 JSON object
复用项目现有配置LLM_API_BASE/LLM_API_KEY/LLM_MODEL_NAME
log_context: 调用来源标签如章节编号用于在 generation_trace.log 中区分各次生成调用
"""
api_base = (settings.LLM_API_BASE or "").rstrip("/")
api_key = settings.LLM_API_KEY or ""
model_name = settings.LLM_MODEL_NAME or ""
if not api_base or not api_key or not model_name:
raise RuntimeError("LLM 未配置:请设置 LLM_API_BASE/LLM_API_KEY/LLM_MODEL_NAME")
ctx = log_context or "-"
_trace_logger.info(
"[输入] context=%s | model=%s | temperature=%s | max_tokens=%s\n"
"----- SYSTEM PROMPT -----\n%s\n"
"----- USER PROMPT -----\n%s\n"
"----- END INPUT -----",
ctx, model_name, temperature, max_tokens, system_prompt, user_prompt,
)
extra_payload: dict[str, Any] = {}
# SiliconFlow 的部分 Qwen 模型默认把输出写到 reasoning_content导致 content 为空;
# 显式关闭 thinking确保最终输出进入 content避免下游解析失败。
if "siliconflow" in api_base.lower() and "qwen" in model_name.lower():
extra_payload["enable_thinking"] = False
final_timeout_sec = int(timeout_sec or 0)
if final_timeout_sec <= 0:
final_timeout_sec = int(getattr(settings, "LLM_HTTP_TIMEOUT_SEC", 90) or 90)
retry_count = int(getattr(settings, "LLM_RETRY_COUNT", 2) or 2)
if retry_count < 1:
retry_count = 1
retry_backoff = float(getattr(settings, "LLM_RETRY_BACKOFF_SEC", 1.0) or 1.0)
retry_backoff_max = float(getattr(settings, "LLM_RETRY_BACKOFF_MAX_SEC", 12.0) or 12.0)
connect_timeout_sec = int(getattr(settings, "LLM_CONNECT_TIMEOUT_SEC", 20) or 20)
if connect_timeout_sec <= 0:
connect_timeout_sec = 20
use_stream = True
_logger.info(
"chat_completions_json 调用 | model=%s | temperature=%s | max_tokens=%s | timeout=%s | retry=%s",
model_name, temperature, max_tokens, final_timeout_sec, retry_count,
)
with _llm_slots:
last_err: Optional[Exception] = None
for attempt in range(retry_count):
try:
if use_stream:
content = _chat_completions_stream_text(
api_base=api_base,
api_key=api_key,
model_name=model_name,
system_prompt=system_prompt,
user_prompt=user_prompt,
temperature=temperature,
max_tokens=max_tokens,
extra_payload=extra_payload,
connect_timeout_sec=connect_timeout_sec,
read_timeout_sec=final_timeout_sec,
on_content_delta=on_content_delta,
)
else:
# 分离连接超时与读超时:长生成阶段只应占用「读」时间,避免与连接握手混在一个上限里过早超时
read_timeout = max(int(connect_timeout_sec) + 5, int(final_timeout_sec))
resp = requests.post(
f"{api_base}/chat/completions",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
json={
"model": model_name,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
"temperature": temperature,
"max_tokens": max_tokens,
"response_format": {"type": "json_object"},
**extra_payload,
},
timeout=(connect_timeout_sec, read_timeout),
)
if resp.status_code in (408, 429, 500, 502, 503, 504):
raise _RetryableLLMError(
f"LLM HTTP {resp.status_code}: {(resp.text or '')[:300]}"
)
if resp.status_code != 200:
raise RuntimeError(f"LLM HTTP {resp.status_code}: {(resp.text or '')[:800]}")
data = resp.json()
content = (
(data.get("choices") or [{}])[0]
.get("message", {})
.get("content", "")
)
if not isinstance(content, str) or not content.strip():
raise _RetryableLLMError("LLM 返回空内容")
try:
obj = parse_json_object_from_text(content)
_logger.info(
"chat_completions_json 成功 | model=%s | attempt=%d/%d | content_len=%d | keys=%s",
model_name, attempt + 1, retry_count, len(content), list(obj.keys())[:8],
)
_trace_logger.info(
"[输出] context=%s | model=%s | attempt=%d/%d | output_len=%d\n"
"----- MODEL OUTPUT -----\n%s\n"
"----- END OUTPUT -----",
ctx, model_name, attempt + 1, retry_count, len(content), content,
)
return obj
except ValueError as e:
raise _RetryableLLMError(f"LLM JSON 解析失败: {e}") from e
except (
requests.ReadTimeout,
requests.ConnectTimeout,
requests.ConnectionError,
ChunkedEncodingError,
) as e:
last_err = e
if attempt >= retry_count - 1:
raise RuntimeError(
"LLM 请求超时/连接失败"
f"(已重试{retry_count}timeout={final_timeout_sec}s"
f"endpoint={api_base}/chat/completions"
f"model={model_name}"
f"raw={_format_exc_raw(e)}"
) from e
sleep_sec = min(retry_backoff * (2 ** attempt), retry_backoff_max)
sleep_sec += random.uniform(0, min(0.5, sleep_sec * 0.2))
time.sleep(sleep_sec)
except _RetryableLLMError as e:
last_err = e
if attempt >= retry_count - 1:
raise RuntimeError(
f"{e}(已重试{retry_count}timeout={final_timeout_sec}s"
f"endpoint={api_base}/chat/completions"
f"model={model_name}"
f"raw={_format_exc_raw(e)}"
) from e
sleep_sec = min(retry_backoff * (2 ** attempt), retry_backoff_max)
sleep_sec += random.uniform(0, min(0.5, sleep_sec * 0.2))
time.sleep(sleep_sec)
except RequestException as e:
resp = getattr(e, "response", None)
status = getattr(resp, "status_code", None)
body = ""
if resp is not None:
try:
body = (resp.text or "")[:800]
except Exception:
body = ""
raise RuntimeError(
"LLM 请求失败"
f"endpoint={api_base}/chat/completions"
f"model={model_name}"
f"status={status}"
+ (f"body={body}" if body else "")
+ f"raw={_format_exc_raw(e)}"
) from e
else:
raw = _format_exc_raw(last_err) if isinstance(last_err, Exception) else str(last_err)
raise RuntimeError(
"LLM 请求失败"
f"endpoint={api_base}/chat/completions"
f"model={model_name}"
f"raw={raw}"
)
def _repair_loose_json_object(s: str) -> str:
"""常见模型输出问题:尾随逗号(, 后紧跟 } 或 ])。"""
return re.sub(r",(\s*[}\]])", r"\1", s)
def _extract_balanced_json_prefix(s: str) -> str:
"""
提取以 `{` 开始的最长可能完整 JSON 对象前缀
会忽略字符串内的花括号避免误判
"""
start = s.find("{")
if start == -1:
return s
in_string = False
escaped = False
depth = 0
end_idx = -1
for i, ch in enumerate(s[start:], start=start):
if in_string:
if escaped:
escaped = False
elif ch == "\\":
escaped = True
elif ch == '"':
in_string = False
continue
if ch == '"':
in_string = True
elif ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
end_idx = i
break
if end_idx != -1:
return s[start : end_idx + 1]
return s[start:]
def _close_truncated_json_object(s: str) -> str:
"""
处理模型截断导致的 JSON 残缺
- 若字符串未闭合补一个 `"`
- 按栈补齐缺失的 `}` / `]`
"""
out: list[str] = []
stack: list[str] = []
in_string = False
escaped = False
for ch in s:
out.append(ch)
if in_string:
if escaped:
escaped = False
elif ch == "\\":
escaped = True
elif ch == '"':
in_string = False
continue
if ch == '"':
in_string = True
continue
if ch == "{":
stack.append("}")
elif ch == "[":
stack.append("]")
elif ch in ("}", "]"):
if stack and stack[-1] == ch:
stack.pop()
if in_string:
out.append('"')
while stack:
out.append(stack.pop())
return "".join(out)
def parse_json_object_from_text(text: str) -> dict[str, Any]:
"""从模型输出里提取并解析 { ... } JSON 对象。"""
s = (text or "").strip()
s = re.sub(r"```(?:json)?", "", s, flags=re.IGNORECASE).replace("```", "").strip()
start = s.find("{")
if start == -1:
raise ValueError("未找到 JSON 对象")
chunk = s[start:]
balanced_chunk = _extract_balanced_json_prefix(chunk)
decoder = json.JSONDecoder()
last_err: Optional[Exception] = None
for candidate in (
balanced_chunk,
_repair_loose_json_object(balanced_chunk),
_close_truncated_json_object(_repair_loose_json_object(balanced_chunk)),
_close_truncated_json_object(_repair_loose_json_object(chunk)),
):
try:
obj, _ = decoder.raw_decode(candidate)
if not isinstance(obj, dict):
raise ValueError("JSON 根节点不是对象(dict)")
return obj
except json.JSONDecodeError as e:
last_err = e
raise ValueError(f"JSON 解析失败:{last_err}") from last_err
def safe_get_str(v: Any) -> Optional[str]:
if v is None:
return None
s = str(v).strip()
return s if s else None
# ------------------------------------------------------------------
# Agent 多轮对话 + Tool Calling流式生成器
# ------------------------------------------------------------------
def _iter_chat_stream_events(
*,
api_base: str,
api_key: str,
model_name: str,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
temperature: float = 0.3,
max_tokens: int = 4096,
extra_payload: dict[str, Any] | None = None,
connect_timeout_sec: int = 20,
read_timeout_sec: int = 300,
):
"""
流式调用 OpenAI-compat /chat/completions逐步 yield 事件
("delta", str) 文本增量
("tool_calls", list) 完整 tool_calls 列表 [{id, function:{name, arguments}}]
("done", dict) 最终 usage 等元信息
"""
payload: dict[str, Any] = {
"model": model_name,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
"stream": True,
**(extra_payload or {}),
}
if tools:
payload["tools"] = tools
payload["tool_choice"] = "auto"
resp = requests.post(
f"{api_base}/chat/completions",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
json=payload,
stream=True,
timeout=(connect_timeout_sec, max(60, read_timeout_sec)),
)
if resp.status_code in (408, 429, 500, 502, 503, 504):
raise _RetryableLLMError(f"LLM HTTP {resp.status_code}: {(resp.text or '')[:300]}")
if resp.status_code != 200:
raise RuntimeError(f"LLM HTTP {resp.status_code}: {(resp.text or '')[:800]}")
resp.encoding = "utf-8"
content_parts: list[str] = []
tool_calls_map: dict[int, dict] = {}
for line in resp.iter_lines(decode_unicode=True):
if not line:
continue
s = line.strip()
if not s.startswith("data:"):
continue
data_str = s[5:].strip()
if not data_str or data_str == "[DONE]":
break
try:
obj = json.loads(data_str)
except Exception:
continue
choices = obj.get("choices")
if not isinstance(choices, list) or not choices:
continue
first = choices[0] if isinstance(choices[0], dict) else {}
delta = first.get("delta") if isinstance(first.get("delta"), dict) else {}
# 仅输出正文 content忽略 reasoning_content避免思考过程展示给用户
_ = delta.get("reasoning_content")
content = delta.get("content")
if isinstance(content, str) and content:
content_parts.append(content)
yield ("delta", content)
# tool_calls delta (streamed incrementally)
tc_deltas = delta.get("tool_calls")
if isinstance(tc_deltas, list):
for tc in tc_deltas:
if not isinstance(tc, dict):
continue
idx = tc.get("index", 0)
if idx not in tool_calls_map:
tool_calls_map[idx] = {
"id": tc.get("id", ""),
"type": "function",
"function": {"name": "", "arguments": ""},
}
entry = tool_calls_map[idx]
if tc.get("id"):
entry["id"] = tc["id"]
fn = tc.get("function") if isinstance(tc.get("function"), dict) else {}
if fn.get("name"):
entry["function"]["name"] += fn["name"]
if fn.get("arguments"):
entry["function"]["arguments"] += fn["arguments"]
# finish_reason
finish = first.get("finish_reason")
if finish == "tool_calls" and tool_calls_map:
ordered = [tool_calls_map[k] for k in sorted(tool_calls_map.keys())]
yield ("tool_calls", ordered)
tool_calls_map = {}
if tool_calls_map:
ordered = [tool_calls_map[k] for k in sorted(tool_calls_map.keys())]
yield ("tool_calls", ordered)
yield ("done", {"content": "".join(content_parts)})
def _default_disable_thinking_payload(model_name: str) -> dict[str, Any]:
"""Qwen 等推理模型:关闭 thinking仅将最终答案写入 content。"""
if not model_name or "qwen" not in str(model_name).lower():
return {}
return {
"enable_thinking": False,
# vLLM / 部分 OpenAI 兼容网关使用 chat_template_kwargs
"chat_template_kwargs": {"enable_thinking": False},
}
def chat_completions_with_tools(
*,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
temperature: float = 0.3,
max_tokens: int = 4096,
timeout_sec: int = 180,
extra_payload: dict[str, Any] | None = None,
):
"""
Agent 用多轮对话 + tool calling返回生成器yield 事件元组
调用方负责工具循环编排
"""
api_base = (settings.LLM_API_BASE or "").rstrip("/")
api_key = settings.LLM_API_KEY or ""
model_name = settings.LLM_MODEL_NAME or ""
if not api_base or not api_key or not model_name:
raise RuntimeError("LLM 未配置:请设置 LLM_API_BASE/LLM_API_KEY/LLM_MODEL_NAME")
merged_extra: dict[str, Any] = dict(_default_disable_thinking_payload(model_name))
if extra_payload:
merged_extra.update(extra_payload)
connect_timeout_sec = int(getattr(settings, "LLM_CONNECT_TIMEOUT_SEC", 20) or 20)
if connect_timeout_sec <= 0:
connect_timeout_sec = 20
final_timeout_sec = int(timeout_sec or 0)
if final_timeout_sec <= 0:
final_timeout_sec = int(getattr(settings, "LLM_HTTP_TIMEOUT_SEC", 90) or 90)
with _llm_slots:
yield from _iter_chat_stream_events(
api_base=api_base,
api_key=api_key,
model_name=model_name,
messages=messages,
tools=tools,
temperature=temperature,
max_tokens=max_tokens,
extra_payload=merged_extra or None,
connect_timeout_sec=connect_timeout_sec,
read_timeout_sec=final_timeout_sec,
)

View File

@ -0,0 +1,43 @@
"""
services/project_service.py
报告生成所需的最小项目查询替代 eval_report 中重型的 write_service
仅提供按 uuid / 数字 id 查询项目并返回 WriteProject用于校验项目存在性与取项目名
"""
from __future__ import annotations
from fastapi import HTTPException
from sqlalchemy.orm import Session
from database.models import Project
from schemas.write import WriteProject
def get_project(project_id: str, db: Session) -> WriteProject:
"""获取后评价报告项目详情。支持 uuid 或数字 id优先 uuid。"""
project = None
if project_id:
project = db.query(Project).filter(Project.uuid == project_id).first()
if not project:
try:
pid = int(project_id)
project = db.query(Project).filter(Project.id == pid).first()
except (ValueError, TypeError):
pass
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
return WriteProject(
id=str(project.id),
uuid=project.uuid,
name=project.name,
description=project.description or "",
createdAt=project.created_at.strftime("%Y-%m-%d") if project.created_at else "",
updatedAt=project.updated_at.strftime("%Y-%m-%d") if project.updated_at else "",
docCount=project.doc_count,
status=project.status,
kbProjectId=None,
color=project.color,
documents=[],
)

View File

@ -0,0 +1,28 @@
from __future__ import annotations
import re
from pathlib import Path
from typing import Any
PROMPT_ROOT = Path(__file__).resolve().parent.parent / "prompts"
_TOKEN_RE = re.compile(r"{{\s*([A-Za-z_][A-Za-z0-9_]*)\s*}}")
def load_prompt_template(relative_path: str) -> str:
path = (PROMPT_ROOT / relative_path).resolve()
if not path.is_relative_to(PROMPT_ROOT.resolve()):
raise ValueError(f"Invalid prompt path: {relative_path}")
return path.read_text(encoding="utf-8")
def render_prompt_template(template: str, **context: Any) -> str:
def _replace(match: re.Match[str]) -> str:
value = context.get(match.group(1), "")
return "" if value is None else str(value)
return _TOKEN_RE.sub(_replace, template)
def render_prompt(relative_path: str, **context: Any) -> str:
return render_prompt_template(load_prompt_template(relative_path), **context)

View File

@ -0,0 +1,292 @@
"""
services/reference_service.py
参考范文加载服务报告生成时按需加载对应章节参考范文
"""
from __future__ import annotations
import json
import logging
import re
from typing import Optional
from sqlalchemy.orm import Session
from database.models import ReportSectionReference
from services.llm_client import chat_completions_json
logger = logging.getLogger(__name__)
_DESENSITIZE_SYSTEM_PROMPT = """你是一个文档脱敏助手。你的任务是对后评价报告范文进行脱敏处理,只保留报告的结构骨架。
## 脱敏规则
### 必须保留的结构
1. Markdown 标题层级## 1.1、## 1.2、### 1.2.1 等)
2. 表格的表头行分隔行|--|--|
3. 段落/章节的组织顺序和逻辑关系
4. 文字的叙述逻辑先写什么再写什么
5. 表格的行数列数表头字段名"序号""项目名称""可研报告""实际值"
### 必须替换为 xxx 的内容
1. 所有具体数字金额年份百分比数量面积产能投资额等
2. 项目名称公司名称单位名称等专有名词书名号/引号内的内容
3. 表格中的数据单元格内容保留表头
4. 具体的日期时间节点
5. 财务指标的具体数值IRRNPV回收期等
### 特别注意
- 不要随意增删段落或改变段落顺序
- 不要删除整个表格只替换表格中的数据单元格
- 保持原 Markdown 格式不变
- "待补充""详见附表" 固定用语 不脱敏
- 书名号中的内容如果是不知名的规范/标准名称石油化工标准保留书名号但内容替换为 xxx"""
_DESENSITIZE_USER_PROMPT_TEMPLATE = """请对以下后评价报告章节进行脱敏处理,只保留结构骨架,所有具体数据替换为 xxx
```
{content}
```
请严格按照脱敏规则处理直接输出脱敏后的完整 Markdown 内容不要输出任何额外说明"""
def _desensitize_via_llm(content: str) -> str:
"""
调用大模型对参考范文进行脱敏处理
传入完整内容返回仅保留结构骨架具体数据替换为 xxx Markdown
LLM 调用失败退回原始内容不脱敏优于拒绝服务
"""
if not content or not content.strip():
return content
user_prompt = _DESENSITIZE_USER_PROMPT_TEMPLATE.format(content=content[:12000])
logger.info("参考范文脱敏 start | content_len=%s", len(content))
try:
result = chat_completions_json(
system_prompt=_DESENSITIZE_SYSTEM_PROMPT,
user_prompt=user_prompt,
temperature=0.0,
max_tokens=16384,
timeout_sec=120,
)
raw = result.get("content") or ""
if isinstance(raw, str) and raw.strip():
# 去掉可能的 ```markdown / ``` 包裹
cleaned = re.sub(r"^```(?:markdown)?\s*", "", raw.strip(), flags=re.IGNORECASE)
cleaned = re.sub(r"\s*```$", "", cleaned)
logger.info("参考范文脱敏 done | original_len=%s | desensitized_len=%s", len(content), len(cleaned))
return cleaned.strip()
except Exception as e:
logger.warning("LLM 脱敏失败,退回原文: %s", e)
return content
def load_section_reference(
db: Session,
section_key: str,
source_file: Optional[str] = None,
*,
max_chars: int = 8000,
) -> str:
"""
加载指定章节的参考范文内容
Args:
db: 数据库会话
section_key: 章节标识 "1.1", "2.1.1"
source_file: 来源文件名可选不指定时取该章节最新的一条
max_chars: 最大字符数超出截断
Returns:
参考范文 Markdown 文本未找到时返回空字符串
"""
query = db.query(ReportSectionReference).filter(
ReportSectionReference.section_key == section_key
)
if source_file:
query = query.filter(ReportSectionReference.source_file == source_file)
ref = (
query
.order_by(ReportSectionReference.updated_at.desc())
.first()
)
if not ref or not ref.content:
return ""
content = ref.content.strip()
if not content:
return ""
content = _desensitize_via_llm(content)
if len(content) > max_chars:
logger.info("参考范文 %s 超出 %d 字符限制,已截断", section_key, max_chars)
content = content[:max_chars] + "\n\n(参考范文超出长度限制,已截断)"
return content
def load_section_reference_by_title(
db: Session,
section_title: str,
source_file: Optional[str] = None,
*,
max_chars: int = 8000,
) -> str:
"""
按标题关键字匹配加载参考范文不精确匹配 section_key 时的兜底方案
"""
refs = db.query(ReportSectionReference)
if source_file:
refs = refs.filter(ReportSectionReference.source_file == source_file)
# 尝试精确匹配 section_key从标题中提取编号
import re
m = re.match(r"(\d+(?:\.\d+)*)", section_title.strip())
if m:
key = m.group(1)
exact = (
refs.filter(ReportSectionReference.section_key == key)
.order_by(ReportSectionReference.updated_at.desc())
.first()
)
if exact and exact.content:
content = exact.content.strip()
content = _desensitize_via_llm(content)
if len(content) > max_chars:
content = content[:max_chars] + "\n\n(参考范文超出长度限制,已截断)"
return content
# 按标题模糊匹配
ref = (
refs.filter(ReportSectionReference.section_title.contains(section_title[:20]))
.order_by(ReportSectionReference.updated_at.desc())
.first()
)
if not ref or not ref.content:
return ""
content = ref.content.strip()
if not content:
return ""
content = _desensitize_via_llm(content)
if len(content) > max_chars:
content = content[:max_chars] + "\n\n(参考范文超出长度限制,已截断)"
return content
def load_section_reference_raw(
db: Session,
section_key: str,
template_id: Optional[str] = None,
*,
max_chars: int = 8000,
) -> str:
"""
加载指定章节存储在数据库中的原始参考范文内容不做 LLM 脱敏
load_section_reference 的区别直接返回 report_section_references.content 原文
仅保留长度截断保护不再调用 _desensitize_via_llm
template_id: 选中模板的 ID传入后只注入与该模板关联的参考范文实现按模板过滤
为空则不做模板过滤取最新一条
"""
query = db.query(ReportSectionReference).filter(
ReportSectionReference.section_key == section_key
)
if template_id:
query = query.filter(ReportSectionReference.template_id == template_id)
ref = (
query
.order_by(ReportSectionReference.updated_at.desc())
.first()
)
if not ref or not ref.content:
return ""
content = ref.content.strip()
if not content:
return ""
if len(content) > max_chars:
logger.info("参考范文 %s 超出 %d 字符限制,已截断", section_key, max_chars)
content = content[:max_chars] + "\n\n(参考范文超出长度限制,已截断)"
return content
def load_section_reference_raw_by_title(
db: Session,
section_title: str,
template_id: Optional[str] = None,
*,
max_chars: int = 8000,
) -> str:
"""按标题匹配加载原始参考范文内容(不做 LLM 脱敏),用于 section_key 未命中时的兜底。"""
refs = db.query(ReportSectionReference)
if template_id:
refs = refs.filter(ReportSectionReference.template_id == template_id)
import re
m = re.match(r"(\d+(?:\.\d+)*)", section_title.strip())
if m:
key = m.group(1)
exact = (
refs.filter(ReportSectionReference.section_key == key)
.order_by(ReportSectionReference.updated_at.desc())
.first()
)
if exact and exact.content:
content = exact.content.strip()
if len(content) > max_chars:
content = content[:max_chars] + "\n\n(参考范文超出长度限制,已截断)"
return content
ref = (
refs.filter(ReportSectionReference.section_title.contains(section_title[:20]))
.order_by(ReportSectionReference.updated_at.desc())
.first()
)
if not ref or not ref.content:
return ""
content = ref.content.strip()
if not content:
return ""
if len(content) > max_chars:
content = content[:max_chars] + "\n\n(参考范文超出长度限制,已截断)"
return content
def list_available_source_files(db: Session) -> list[str]:
"""列出所有已上传的参考范文来源文件列表。"""
results = (
db.query(ReportSectionReference.source_file)
.distinct()
.order_by(ReportSectionReference.source_file)
.all()
)
return [r[0] for r in results if r[0]]

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,135 @@
from __future__ import annotations
from services.prompt_template_service import render_prompt
from prompts.report_generation.prompt_defaults import (
DEFAULT_SECTION_PROMPT_FALLBACK,
DEFAULT_SELECTED_EXAMPLE_FALLBACK,
)
def chapter_generation_system_prompt() -> str:
return render_prompt("report_generation/chapter_generation_system.md")
def repair_missing_tables_system_prompt() -> str:
return render_prompt("report_generation/repair_missing_tables_system.md")
def table_format_repair_system_prompt() -> str:
return render_prompt("report_generation/table_format_repair_system.md")
def _build_prior_sibling_sections_prompt_block(prior_sibling_sections_text: str) -> str:
body = str(prior_sibling_sections_text or "").strip()
if not body:
return ""
return (
"【同章前序小节正文(时间与金额须保持一致)】\n"
f"{body}\n\n"
"【同章一致性约束】\n"
"1. 竣工时间、开工/中交/投产/验收等关键里程碑日期,以及建设投资、总投资、营业收入、利润等各类金额数字,"
"须与本章前序小节已写明的口径完全一致(年月日表述可适度简化,但不得出现另一套矛盾日期或金额);\n"
"2. 若【证据包】或【字段级已抽取结果】中某日期/金额与前序小节矛盾,以前序小节为准写入本节,"
"不得在正文中另写一套矛盾数值;\n"
"3. 前序小节为「待补充」的字段,本节仍写「待补充」,不得自行编造;\n"
"4. 可补充本节新增信息,但不得改写或否定前序小节已确立的时间与金额。"
)
def _build_prior_chapters_prompt_block(prior_chapters_text: str) -> str:
body = str(prior_chapters_text or "").strip()
if not body:
return ""
return (
"【前序章节正文第16章本章须据此总结\n"
f"{body}\n\n"
"【前序章节使用约束】\n"
"1. 第7章各节是对第16章已生成正文的归纳、提炼与升华不得与前面章节结论矛盾\n"
"2. 可概括前文要点,禁止大段照搬;数据与结论须与前文一致;\n"
"3. 若前序章节某处为「待补充」,本节对应表述也应为「待补充」,不得编造;\n"
"4. 须由要素管理直出的表格如表7-1仍按【章节输出结构约束】执行不受本条限制。"
)
def _build_section_reference_block(section_reference: str) -> str:
body = str(section_reference or "").strip()
if not body:
return ""
return (
"【本章参考范文(本节写作蓝本:结构与行文风格须高度贴合;禁止复用数据、禁止照抄)】\n"
f"{body}\n\n"
"【参考范文使用约束】\n"
"1. 以范文为写作蓝本:段落数量与顺序、每段主题、论述逻辑、句式笔法与篇幅颗粒度均须与范文高度一致,做到逐段对应、同一笔法;\n"
"2. 严禁复用范文中的项目名称、时间、金额、指标值等任何事实数据,须全部替换为当前项目证据包的真实值;\n"
"3. 范文中的表格结构(表头、列顺序、行项)须沿用,但表内数据必须替换为当前项目证据包的值;\n"
"4. 禁止逐字照抄:不得出现与范文连续相同超过 15 字的文字,须改写措辞做到“形似而文不同”;\n"
"5. 若范文与证据包存在矛盾,以证据包为准。"
)
def build_report_chapter_prompt(
*,
section_title: str,
section_prompt: str,
required_tables_text: str,
structured_tables_text: str,
canonical_fields_text: str,
selected_example: str,
heading_rule: str,
section_contract: str,
evidence_json: str,
prior_sibling_sections_text: str = "",
prior_chapters_text: str = "",
section_reference: str = "",
) -> str:
return render_prompt(
"report_generation/chapter_generation_user_ref_aligned.md",
section_title=section_title,
section_prompt=section_prompt or DEFAULT_SECTION_PROMPT_FALLBACK,
required_tables_text=required_tables_text or "",
structured_tables_text=structured_tables_text,
canonical_fields_text=canonical_fields_text,
selected_example=selected_example or DEFAULT_SELECTED_EXAMPLE_FALLBACK,
heading_rule=heading_rule,
section_contract=section_contract,
evidence_json=evidence_json,
prior_sibling_sections_block=_build_prior_sibling_sections_prompt_block(
prior_sibling_sections_text
),
prior_chapters_block=_build_prior_chapters_prompt_block(prior_chapters_text),
section_reference_block=_build_section_reference_block(section_reference),
)
def build_repair_missing_tables_prompt(
*,
section_title: str,
original_prompt: str,
content: str,
missing_tables: list[str],
evidence_json: str,
) -> str:
return render_prompt(
"report_generation/repair_missing_tables_user.md",
section_title=section_title,
missing_tables=", ".join(missing_tables),
content=content,
original_prompt=original_prompt[:8000],
evidence_json=evidence_json[:12000],
)
def build_table_format_repair_prompt(
*,
section_title: str,
table_specs_json: str,
content: str,
evidence_json: str,
) -> str:
return render_prompt(
"report_generation/table_format_repair_user.md",
section_title=section_title,
table_specs_json=table_specs_json,
content=content,
evidence_json=evidence_json[:12000],
)

View File

@ -0,0 +1,145 @@
from __future__ import annotations
from copy import deepcopy
from datetime import datetime
import threading
from typing import Any, Optional
_RUNTIME_LOCK = threading.RLock()
_JOB_STATES: dict[str, dict[str, Any]] = {}
def _now_str() -> str:
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def _chapter_payload(
*,
section_key: str,
section_title: str,
section_order: int,
status: str = "pending",
) -> dict[str, Any]:
return {
"sectionKey": section_key,
"sectionTitle": section_title,
"sectionOrder": section_order,
"status": status,
"content": None,
"errorMessage": None,
"updatedAt": _now_str(),
"promptText": None,
"evidencePayload": None,
"validationPayload": None,
}
def init_job_state(
*,
job_id: str,
project_id: str,
template_id: Optional[str],
chapters: list[dict[str, Any]],
) -> None:
with _RUNTIME_LOCK:
_JOB_STATES[job_id] = {
"jobId": job_id,
"projectId": project_id,
"templateId": template_id,
"status": "pending",
"progress": 0,
"currentSectionKey": None,
"errorMessage": None,
"createdAt": _now_str(),
"updatedAt": _now_str(),
"completedAt": None,
"chapters": {
str(item["sectionKey"]): _chapter_payload(
section_key=str(item["sectionKey"]),
section_title=str(item["sectionTitle"]),
section_order=int(item["sectionOrder"]),
status=str(item.get("status") or "pending"),
)
for item in (chapters or [])
},
}
def get_job_state(job_id: str) -> Optional[dict[str, Any]]:
with _RUNTIME_LOCK:
state = _JOB_STATES.get(job_id)
return deepcopy(state) if state else None
def update_job_state(job_id: str, **fields: Any) -> None:
with _RUNTIME_LOCK:
state = _JOB_STATES.get(job_id)
if not state:
return
state.update(fields)
state["updatedAt"] = _now_str()
def update_chapter_state(
job_id: str,
section_key: str,
**fields: Any,
) -> None:
with _RUNTIME_LOCK:
state = _JOB_STATES.get(job_id)
if not state:
return
chapter = state.get("chapters", {}).get(section_key)
if not chapter:
return
chapter.update(fields)
chapter["updatedAt"] = _now_str()
state["updatedAt"] = _now_str()
def append_chapter_content(
job_id: str,
section_key: str,
delta_text: str,
*,
stream_phase: str,
) -> None:
if not delta_text:
return
with _RUNTIME_LOCK:
state = _JOB_STATES.get(job_id)
if not state:
return
chapter = state.get("chapters", {}).get(section_key)
if not chapter:
return
current = str(chapter.get("content") or "")
validation_payload = dict(chapter.get("validationPayload") or {})
validation_payload["streamPhase"] = stream_phase
chapter["content"] = current + delta_text
chapter["validationPayload"] = validation_payload
chapter["updatedAt"] = _now_str()
state["currentSectionKey"] = section_key
state["updatedAt"] = _now_str()
def set_chapter_stream_phase(job_id: str, section_key: str, stream_phase: str) -> None:
with _RUNTIME_LOCK:
state = _JOB_STATES.get(job_id)
if not state:
return
chapter = state.get("chapters", {}).get(section_key)
if not chapter:
return
validation_payload = dict(chapter.get("validationPayload") or {})
validation_payload["streamPhase"] = stream_phase
chapter["validationPayload"] = validation_payload
chapter["updatedAt"] = _now_str()
state["currentSectionKey"] = section_key
state["updatedAt"] = _now_str()
def remove_job_state(job_id: str) -> None:
with _RUNTIME_LOCK:
_JOB_STATES.pop(job_id, None)

View File

@ -0,0 +1,324 @@
"""
services/retrieval_service.py
后评价报告材料检索服务
用于从向量库中检索与后评价报告相关的材料
"""
from typing import List, Dict, Any, Optional
from langchain_core.documents import Document
from function.vector_store import VectorStore
class RetrievalService:
"""后评价报告材料检索服务"""
def __init__(self, collection_name: str = "eval_report"):
"""
初始化检索服务
Args:
collection_name: 向量库集合名称
"""
self.collection_name = collection_name
self.vector_store = VectorStore(collection_name=collection_name, drop_old=False)
def search_by_query(self, query: str, top_k: int = 10, filter_project: Optional[str] = None) -> List[Document]:
"""
根据查询语句检索相关材料
Args:
query: 查询语句例如"项目背景""财务评价""技术方案"
top_k: 返回结果数量
filter_project: 可选的项目 UUID 过滤
Returns:
检索到的文档列表
"""
# 构建查询语句
if filter_project:
full_query = f"{query} 项目 UUID:{filter_project}"
else:
full_query = query
# 执行检索
results = self.vector_store.similarity_search_with_score(full_query, k=top_k)
# 过滤并返回文档
docs = []
for doc, score in results:
# 如果指定了项目过滤,检查文档是否属于该项目
if filter_project and doc.metadata.get("project_uuid") != filter_project:
continue
docs.append(doc)
return docs
def search_by_category(self, category: str, project_uuid: str, top_k: int = 10) -> List[Dict[str, Any]]:
"""
根据类别检索材料
Args:
category: 类别"项目概况""技术方案""财务评价""效益分析"
project_uuid: 项目 UUID
top_k: 返回结果数量
Returns:
检索结果列表包含文档内容和元数据
"""
# 定义类别对应的检索关键词
category_keywords = {
"项目概况": ["项目背景", "建设内容", "项目规模", "建设地点", "建设单位", "项目决策", "立项依据"],
"技术方案": ["技术方案", "工艺技术", "设备选型", "工程设计", "施工安装", "调试运行", "专利技术"],
"财务评价": ["投资估算", "资金筹措", "财务分析", "现金流量", "利润计算", "成本分析", "经济效益"],
"效益分析": ["经济效益", "社会效益", "环境效益", "环境影响", "资源利用", "节能降耗"],
"风险分析": ["风险分析", "风险识别", "风险评价", "风险对策", "不确定性分析"],
"后评价结论": ["后评价结论", "经验教训", "建议措施", "综合评价"],
}
# 使用多个关键词进行检索
all_docs = []
for keyword in category_keywords.get(category, [category]):
docs = self.search_by_query(keyword, top_k=5, filter_project=project_uuid)
all_docs.extend(docs)
# 去重并返回
seen = set()
unique_docs = []
for doc in all_docs:
key = (doc.page_content[:100], doc.metadata.get("heading", ""))
if key not in seen:
seen.add(key)
unique_docs.append(doc)
# 转换为字典格式
result = []
for doc in unique_docs[:top_k]:
result.append({
"content": doc.page_content,
"heading": doc.metadata.get("heading", ""),
"heading_level": doc.metadata.get("heading_level", 0),
"doc_id": doc.metadata.get("doc_id", ""),
"path": doc.metadata.get("path", ""),
"score": doc.metadata.get("score", 0.0),
})
return result
def get_project_materials(self, project_uuid: str) -> Dict[str, Any]:
"""
获取项目的所有相关材料
Args:
project_uuid: 项目 UUID
Returns:
包含项目所有材料的字典
"""
# 检索项目基本信息
basic_info = self.search_by_query(
"项目概况 项目基本情况",
top_k=5,
filter_project=project_uuid
)
# 检索技术方案
tech_info = self.search_by_query(
"技术方案 工艺技术",
top_k=5,
filter_project=project_uuid
)
# 检索财务信息
finance_info = self.search_by_query(
"财务评价 经济效益",
top_k=5,
filter_project=project_uuid
)
# 检索效益分析
benefit_info = self.search_by_query(
"效益分析 社会效益",
top_k=5,
filter_project=project_uuid
)
return {
"basic_info": [doc.page_content for doc in basic_info],
"tech_info": [doc.page_content for doc in tech_info],
"finance_info": [doc.page_content for doc in finance_info],
"benefit_info": [doc.page_content for doc in benefit_info],
}
def search_similar_report(self, reference_content: str, top_k: int = 5) -> List[Document]:
"""
根据参考内容检索相似报告
Args:
reference_content: 参考报告内容
top_k: 返回结果数量
Returns:
相似报告列表
"""
# 提取关键信息用于检索
query = f"后评价报告 项目概况 技术方案 财务评价"
results = self.vector_store.similarity_search_with_score(query, k=top_k)
docs = []
for doc, score in results:
docs.append(doc)
return docs
def get_template_data(self, project_uuid: str, query: str = "项目概况 技术方案 财务评价", top_k: int = 15) -> Dict[str, Any]:
"""
获取符合模板要求的数据
Args:
project_uuid: 项目 UUID
query: 检索查询语句
top_k: 检索结果数量
Returns:
符合模板字段要求的数据字典
"""
from report_template import ReportTemplate
# 检索材料
materials = self.search_by_query(query, top_k=top_k, filter_project=project_uuid)
if not materials:
return {
"materials": [],
"template_data": {},
"key_info": {}
}
# 提取关键信息
key_info = ReportTemplate.extract_key_info([doc.page_content for doc in materials])
# 映射到模板字段
template_data = ReportTemplate.map_materials_to_template([doc.page_content for doc in materials])
return {
"materials": [doc for doc in materials],
"materials_text": [doc.page_content for doc in materials],
"template_data": template_data,
"key_info": key_info
}
def get_chapter_materials(self, project_uuid: str, chapter: str, top_k: int = 10) -> List[Dict[str, Any]]:
"""
获取指定章节的材料
Args:
project_uuid: 项目 UUID
chapter: 章节名称
top_k: 返回结果数量
Returns:
材料列表
"""
# 定义章节对应的检索关键词
chapter_keywords = {
"项目概况": ["项目背景", "建设内容", "项目规模", "建设地点", "建设单位", "项目决策", "立项依据"],
"技术方案": ["技术方案", "工艺技术", "设备选型", "工程设计", "施工安装", "调试运行", "专利技术"],
"项目全过程总结与管理评价": [
# ---- 强优先表1~表14 + 编号小节 ----
"2.1", "2.1.1", "2.1.1.3", "2.1.6", "2.2", "2.2.1", "2.2.10", "2.3", "2.3.1", "2.3.6",
"表1原料数量及组成对比表", "表2原料性质对比表",
"表3前期预测和2019年实际产品对比表",
"表4装置规模及实际运行负荷对比表",
"表5项目规模对比表",
"表6可研报告与基础设计阶段工程内容对比表",
"表7项目承包商的招投标情况表",
"表8项目设计主要进度控制情况表",
"表9施工图设计变更情况表",
"表10重大设计变更情况表",
"表11主要设备采购情况表",
"表12施工重要节点进度表",
"表13原料性质对比表",
"表14主要标定结果与设计指标对比表",
# ---- 次优先:结构性关键词 ----
"可行性研究", "可研编制", "可研报告", "评估会", "可研批复", "资源与原料评价",
"基础设计", "设计审查", "审查意见", "设计变更", "施工图设计", "招投标", "施工准备",
"工程监理", "HSE", "竣工验收",
"投产管理", "生产准备", "联合试运", "试生产", "生产运行评价", "原料供应评价", "标定结果",
"原料数量及组成对比", "装置规模", "负荷率",
],
"财务评价": ["投资估算", "资金筹措", "财务分析", "现金流量", "利润计算", "成本分析", "经济效益"],
"效益分析": ["经济效益", "社会效益", "环境效益", "环境影响", "资源利用", "节能降耗"],
"项目目标和可持续性评价": [
# 强优先:章节标题与编号
"5", "5.1", "5.1.1", "5.1.2", "5.1.3", "5.2", "5.3", "5.3.1", "5.3.2", "5.3.3", "5.3.4", "5.3.5",
"项目目标实现程度评价", "项目绩效对标分析", "项目持续性评价",
# 目标实现(工程/技术/经济)
"工程规模", "项目进度", "工程质量", "项目功能", "投资控制",
"加工量", "负荷", "产品产量", "产品质量", "技术指标", "标定", "设计值", "考核",
"主要经济指标", "IRR", "内部收益率", "净现值", "NPV", "投资回收期", "营业收入", "成本费用", "税后利润",
# 对标
"对标", "横向对比", "同类装置", "单位投资", "单位能耗", "蒸汽能耗", "综合能耗", "辛烷值", "收率", "烯烃",
# 持续性(资源/产品/内部/政策)
"资源分析", "原料供应", "资源保障",
"产品分析", "市场需求", "国Ⅵ", "国ⅥA", "国ⅥB",
"项目内部因素", "装置规模合理性", "工艺方案", "技术水平",
"国家政策", "产业政策", "质量标准",
# 若材料以安全/环保合规支撑持续性
"个人风险", "社会风险", "可接受", "风险曲线",
"非甲烷总烃", "无组织排放", "mg/m3", "标准值",
],
"风险分析": ["风险分析", "风险识别", "风险评价", "风险对策", "不确定性分析"],
"后评价结论": ["后评价结论", "经验教训", "建议措施", "综合评价"],
}
keywords = chapter_keywords.get(chapter, [chapter])
# 使用多个关键词进行检索
all_docs = []
for keyword in keywords:
docs = self.search_by_query(keyword, top_k=5, filter_project=project_uuid)
all_docs.extend(docs)
# 去重并返回
seen = set()
unique_docs = []
for doc in all_docs:
key = (doc.page_content[:100], doc.metadata.get("heading", ""))
if key not in seen:
seen.add(key)
unique_docs.append(doc)
# 转换为字典格式
result = []
for doc in unique_docs[:top_k]:
result.append({
"content": doc.page_content,
"heading": doc.metadata.get("heading", ""),
"heading_level": doc.metadata.get("heading_level", 0),
"doc_id": doc.metadata.get("doc_id", ""),
"path": doc.metadata.get("path", ""),
"score": doc.metadata.get("score", 0.0),
})
return result
# 检索示例
if __name__ == "__main__":
# 创建检索服务实例
service = RetrievalService()
# 示例 1搜索项目背景
print("示例 1搜索项目背景")
docs = service.search_by_query("项目背景 建设内容", top_k=3)
for doc in docs:
print(f"标题:{doc.metadata.get('heading', 'N/A')}")
print(f"内容:{doc.page_content[:200]}...\n")
# 示例 2搜索财务评价
print("示例 2搜索财务评价")
docs = service.search_by_query("财务评价 现金流量", top_k=3)
for doc in docs:
print(f"标题:{doc.metadata.get('heading', 'N/A')}")
print(f"内容:{doc.page_content[:200]}...\n")

File diff suppressed because it is too large Load Diff