AI 生成: 一个基于 Flask 的 Web 应用,用户输入主题后由 AI 生成结构化的登记手册,包含封面、目录、正文与附录,并支持导出为 Markdown 文件。
This commit is contained in:
@@ -0,0 +1,59 @@
|
|||||||
|
# AI 生成登记手册
|
||||||
|
|
||||||
|
一个基于 Flask 的 Web 应用,输入主题即可由 AI 生成结构化的登记手册。
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
- 输入主题,生成完整登记手册(封面、目录、正文、附录)
|
||||||
|
- 支持在线预览与一键导出 Markdown
|
||||||
|
- 内置离线模板引擎(未配置 API Key 时使用)
|
||||||
|
- 可选接入 OpenAI 兼容接口
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
ai-registration-handbook/
|
||||||
|
├── app.py
|
||||||
|
├── requirements.txt
|
||||||
|
├── README.md
|
||||||
|
├── templates/
|
||||||
|
│ └── index.html
|
||||||
|
├── static/
|
||||||
|
│ ├── style.css
|
||||||
|
│ └── app.js
|
||||||
|
└── handbook/
|
||||||
|
├── __init__.py
|
||||||
|
├── generator.py
|
||||||
|
└── templates.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## 运行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
浏览器访问 http://127.0.0.1:5000
|
||||||
|
|
||||||
|
## 配置 AI(可选)
|
||||||
|
|
||||||
|
设置环境变量后启用真实 AI 生成:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export OPENAI_API_KEY=your-key
|
||||||
|
export OPENAI_BASE_URL=https://api.openai.com/v1
|
||||||
|
export OPENAI_MODEL=gpt-4o-mini
|
||||||
|
```
|
||||||
|
|
||||||
|
未配置时使用内置模板引擎生成示例手册。
|
||||||
|
|
||||||
|
## 接口
|
||||||
|
|
||||||
|
- `POST /api/generate` 请求体 `{"topic": "..."}` 返回 `{"markdown": "..."}`
|
||||||
|
- `POST /api/export` 请求体 `{"topic": "...", "markdown": "..."}` 下载 `.md` 文件
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import io
|
||||||
|
import os
|
||||||
|
|
||||||
|
from flask import Flask, jsonify, render_template, request, send_file
|
||||||
|
|
||||||
|
from handbook.generator import generate_handbook
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
def index():
|
||||||
|
return render_template("index.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/generate", methods=["POST"])
|
||||||
|
def api_generate():
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
topic = (data.get("topic") or "").strip()
|
||||||
|
if not topic:
|
||||||
|
return jsonify({"error": "主题不能为空"}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
markdown = generate_handbook(topic)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return jsonify({"error": f"生成失败: {exc}"}), 500
|
||||||
|
|
||||||
|
return jsonify({"topic": topic, "markdown": markdown})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/export", methods=["POST"])
|
||||||
|
def api_export():
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
topic = (data.get("topic") or "handbook").strip() or "handbook"
|
||||||
|
markdown = data.get("markdown") or ""
|
||||||
|
|
||||||
|
if not markdown:
|
||||||
|
return jsonify({"error": "内容不能为空"}), 400
|
||||||
|
|
||||||
|
buffer = io.BytesIO(markdown.encode("utf-8"))
|
||||||
|
buffer.seek(0)
|
||||||
|
safe_name = "".join(c for c in topic if c.isalnum() or c in "-_ ").strip() or "handbook"
|
||||||
|
return send_file(
|
||||||
|
buffer,
|
||||||
|
mimetype="text/markdown",
|
||||||
|
as_attachment=True,
|
||||||
|
download_name=f"{safe_name}-登记手册.md",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
port = int(os.environ.get("PORT", "5000"))
|
||||||
|
app.run(host="0.0.0.0", port=port, debug=True)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""AI 登记手册生成器包。"""
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""手册生成入口:优先调用 AI,失败则回退到离线模板。"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from handbook.templates import render_offline_handbook
|
||||||
|
|
||||||
|
SYSTEM_PROMPT = (
|
||||||
|
"你是一名专业的文档撰写助手,擅长编写结构清晰、条理分明的登记手册。"
|
||||||
|
"请使用 Markdown 输出,包含封面信息、目录、正文(总则、登记范围、登记流程、"
|
||||||
|
"登记信息字段、附则)以及附录。语言简洁、专业、可执行。"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _call_openai(topic: str) -> str:
|
||||||
|
api_key = os.environ.get("OPENAI_API_KEY")
|
||||||
|
base_url = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1").rstrip("/")
|
||||||
|
model = os.environ.get("OPENAI_MODEL", "gpt-4o-mini")
|
||||||
|
|
||||||
|
if not api_key:
|
||||||
|
raise RuntimeError("未配置 OPENAI_API_KEY")
|
||||||
|
|
||||||
|
resp = requests.post(
|
||||||
|
f"{base_url}/chat/completions",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"model": model,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": SYSTEM_PROMPT},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": f"请为主题「{topic}」生成一份完整的登记手册。",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"temperature": 0.7,
|
||||||
|
},
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
payload = resp.json()
|
||||||
|
return payload["choices"][0]["message"]["content"].strip()
|
||||||
|
|
||||||
|
|
||||||
|
def generate_handbook(topic: str) -> str:
|
||||||
|
"""生成登记手册 Markdown 文本。"""
|
||||||
|
try:
|
||||||
|
return _call_openai(topic)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return render_offline_handbook(topic)
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""内置离线模板,用于未配置 AI 时的示例生成。"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
|
||||||
|
def render_offline_handbook(topic: str) -> str:
|
||||||
|
"""根据主题生成结构化的登记手册 Markdown 文本。"""
|
||||||
|
today = date.today().isoformat()
|
||||||
|
|
||||||
|
sections = [
|
||||||
|
(
|
||||||
|
"一、总则",
|
||||||
|
[
|
||||||
|
f"本手册用于规范「{topic}」相关事项的登记流程,确保信息完整、准确、可追溯。",
|
||||||
|
"所有登记人员应按照本手册要求填写相关字段,不得遗漏或伪造信息。",
|
||||||
|
"本手册的解释权归登记管理部门所有。",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"二、登记范围",
|
||||||
|
[
|
||||||
|
f"凡涉及「{topic}」的新增、变更、注销等事项,均应进行登记。",
|
||||||
|
"登记对象包括但不限于:基本信息、责任主体、时间节点、附件材料。",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"三、登记流程",
|
||||||
|
[
|
||||||
|
"1. 申请人填写登记表并提交相关证明材料。",
|
||||||
|
"2. 登记人员核对材料完整性,必要时要求补充。",
|
||||||
|
"3. 审核通过后录入系统,生成唯一登记编号。",
|
||||||
|
"4. 登记结果通知申请人,并归档留存。",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"四、登记信息字段",
|
||||||
|
[
|
||||||
|
"| 字段 | 说明 | 是否必填 |",
|
||||||
|
"| --- | --- | --- |",
|
||||||
|
"| 登记编号 | 系统自动生成 | 是 |",
|
||||||
|
f"| 主题 | 与「{topic}」相关的事项名称 | 是 |",
|
||||||
|
"| 申请人 | 提交登记的主体 | 是 |",
|
||||||
|
"| 登记日期 | 登记完成日期 | 是 |",
|
||||||
|
"| 备注 | 补充说明 | 否 |",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"五、附则",
|
||||||
|
[
|
||||||
|
"本手册自发布之日起施行。",
|
||||||
|
"如遇政策调整,以最新规定为准。",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"# {topic} 登记手册",
|
||||||
|
"",
|
||||||
|
f"**生成日期:** {today}",
|
||||||
|
"",
|
||||||
|
"## 目录",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
for title, _ in sections:
|
||||||
|
lines.append(f"- {title}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
for title, paragraphs in sections:
|
||||||
|
lines.append(f"## {title}")
|
||||||
|
lines.append("")
|
||||||
|
for p in paragraphs:
|
||||||
|
lines.append(p)
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append("## 附录")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("- 登记表模板")
|
||||||
|
lines.append("- 常见问题解答")
|
||||||
|
lines.append("- 联系方式")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
Flask==3.0.3
|
||||||
|
requests==2.32.3
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
const topicInput = document.getElementById("topic");
|
||||||
|
const generateBtn = document.getElementById("generate-btn");
|
||||||
|
const exportBtn = document.getElementById("export-btn");
|
||||||
|
const statusEl = document.getElementById("status");
|
||||||
|
const outputEl = document.getElementById("output");
|
||||||
|
|
||||||
|
let current = { topic: "", markdown: "" };
|
||||||
|
|
||||||
|
function setStatus(text, isError = false) {
|
||||||
|
statusEl.textContent = text;
|
||||||
|
statusEl.classList.toggle("error", isError);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generate() {
|
||||||
|
const topic = topicInput.value.trim();
|
||||||
|
if (!topic) {
|
||||||
|
setStatus("请先输入主题", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
generateBtn.disabled = true;
|
||||||
|
exportBtn.disabled = true;
|
||||||
|
setStatus("生成中,请稍候…");
|
||||||
|
outputEl.textContent = "正在生成…";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/api/generate", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ topic }),
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new Error(data.error || "生成失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
current = { topic: data.topic, markdown: data.markdown };
|
||||||
|
outputEl.textContent = data.markdown;
|
||||||
|
exportBtn.disabled = false;
|
||||||
|
setStatus("生成完成");
|
||||||
|
} catch (err) {
|
||||||
|
outputEl.textContent = "生成失败";
|
||||||
|
setStatus(err.message, true);
|
||||||
|
} finally {
|
||||||
|
generateBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportMarkdown() {
|
||||||
|
if (!current.markdown) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/api/export", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(current),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
const data = await resp.json().catch(() => ({}));
|
||||||
|
throw new Error(data.error || "导出失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await resp.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${current.topic}-登记手册.md`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
setStatus("已开始下载");
|
||||||
|
} catch (err) {
|
||||||
|
setStatus(err.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
generateBtn.addEventListener("click", generate);
|
||||||
|
exportBtn.addEventListener("click", exportMarkdown);
|
||||||
|
topicInput.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Enter") generate();
|
||||||
|
});
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
|
||||||
|
"Microsoft YaHei", sans-serif;
|
||||||
|
background: #f5f7fb;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px 20px 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
margin: 0 0 24px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel label {
|
||||||
|
display: block;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"] {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 15px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"]:focus {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 10px 18px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #2563eb;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 15px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover:not(:disabled) {
|
||||||
|
background: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
background: #9ca3af;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.secondary {
|
||||||
|
background: #ffffff;
|
||||||
|
color: #2563eb;
|
||||||
|
border: 1px solid #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.secondary:hover:not(:disabled) {
|
||||||
|
background: #eff6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
min-height: 20px;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.error {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-head h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.output {
|
||||||
|
margin: 0;
|
||||||
|
padding: 16px;
|
||||||
|
background: #0f172a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
max-height: 520px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>AI 生成登记手册</title>
|
||||||
|
<link rel="stylesheet" href="static/style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="container">
|
||||||
|
<header class="header">
|
||||||
|
<h1>AI 生成登记手册</h1>
|
||||||
|
<p class="subtitle">输入主题,一键生成结构化的登记手册</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<label for="topic">手册主题</label>
|
||||||
|
<div class="row">
|
||||||
|
<input id="topic" type="text" placeholder="例如:设备借用登记" />
|
||||||
|
<button id="generate-btn">生成手册</button>
|
||||||
|
</div>
|
||||||
|
<p id="status" class="status"></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>预览</h2>
|
||||||
|
<button id="export-btn" class="secondary" disabled>导出 Markdown</button>
|
||||||
|
</div>
|
||||||
|
<pre id="output" class="output">生成的手册将显示在这里…</pre>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="static/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user