Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b746db452 | ||
|
|
d9abd0b86a | ||
|
|
282415fcb1 | ||
|
|
1ca2241a97 | ||
|
|
8b5095f70f | ||
|
|
f5a951f47a | ||
|
|
7ced30fa96 | ||
|
|
c78df3a6a6 | ||
|
|
69e3f35a43 | ||
|
|
de0b9f92be | ||
|
|
2797145104 | ||
|
|
4e19350115 | ||
|
|
e036301e1a | ||
|
|
da84dff9bb | ||
|
|
3a72d3a861 | ||
|
|
ac07cb2008 | ||
|
|
d077a5b930 | ||
|
|
c4dc344a31 | ||
|
|
cf5ccb3a37 | ||
|
|
d99d5ef74a | ||
|
|
4add9f176b | ||
|
|
1319328b85 | ||
|
|
2d0e3a4858 | ||
|
|
ea0f2b3fad | ||
|
|
19fa12eb39 | ||
|
|
11337a788f | ||
|
|
55410b663a | ||
|
|
dddb70ff6f | ||
|
|
649e66f41d | ||
|
|
42aeb8fa4c | ||
|
|
c747ebd77a | ||
|
|
5a71e29cf7 | ||
|
|
d43d30ee3f |
+25
-1
@@ -1,4 +1,4 @@
|
||||
# 运行时产物与环境
|
||||
# 运行时产物与环境
|
||||
node_modules
|
||||
dist/
|
||||
.venv/
|
||||
@@ -43,5 +43,29 @@ paper_writer_service/paper.db-shm
|
||||
paper_writer_service/paper.db-wal
|
||||
paper_writer_service/paper.log
|
||||
|
||||
# review_service 运行时产物(不入库)
|
||||
review_service/review.db
|
||||
review_service/review.db-wal
|
||||
review_service/review.db-shm
|
||||
review_service/review.log
|
||||
review_service/data/reformat_jobs/
|
||||
# 期刊种子 journals.json 是源码:顶层 **/data/ 会误伤 review_service/data/,显式放行
|
||||
!review_service/data/
|
||||
review_service/tools/tectonic
|
||||
review_service/data/tectonic-cache/
|
||||
review_service/data/format_cache/
|
||||
review_service/data/compile_jobs/
|
||||
review_service/data/compile_inspect/
|
||||
review_service/prewarm.log
|
||||
|
||||
# 部署方案文档(含主机凭据,不入库)
|
||||
docs/DEPLOY_PAPER_176.md
|
||||
|
||||
# 一次性诊断脚本(不入库)
|
||||
scripts/debug_compile.py
|
||||
|
||||
# P5 e2e 临时产物(compile_jobs/ 与 prewarm.log 已在上方「review_service 运行时产物」段,此处不重复)
|
||||
|
||||
# P5 ppt job 运行数据(大纲/pptx/真图/预览,不入库)
|
||||
review_service/data/ppt_jobs/
|
||||
review_service/data/review.db
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# AGENTS.md —— 协作约束(科研审稿模块)
|
||||
|
||||
## 项目背景
|
||||
|
||||
本项目(PlantAI-rag-new)是植物基因组学文献知识工作台,拥有自研 RAG 与知识库
|
||||
(Qdrant + Neo4j + vstore)。2026-10 起新增「科研投稿与智能审稿助手」功能
|
||||
(审稿周期/期刊分析/期刊推荐/投稿前自审/审稿意见辅助/格式检查)。
|
||||
|
||||
## 审稿模块(review_service)约束 —— 必须遵守
|
||||
|
||||
1. **所有审稿相关新代码只允许写在 `review_service/` 目录下新增**。
|
||||
- 服务自身:FastAPI 独立进程,端口 8100(模式同 `paper_writer_service` 8097 / `bioflow_service` 8098)。
|
||||
- 环境变量前缀统一 `REVIEW_`;LLM 凭据复用 `DOCGEN_LLM_*` 裸键(回退链见 `review_service/app/services/llm.py`)。
|
||||
2. **BFF(`sdk/`)层只允许条件注册代理路由**:
|
||||
- 允许的全部改动 = `sdk/app/config.py` 增加 `review_enabled` / `review_service_url` 等开关键
|
||||
+ `sdk/app/main.py` 按开关 `include_router(review_proxy.router)`
|
||||
+ 新文件 `sdk/app/routers/review_proxy.py`(反代实现)。
|
||||
- **严禁**修改 sdk 下其他 router/service/db 的任何既有逻辑。
|
||||
3. **严禁修改现有 RAG 核心与既有服务代码**,包括但不限于:
|
||||
- `agent/`(qa 8091 / methodology 8092 / legacy-rag 8093)
|
||||
- `vstore/`(8094,审稿服务只经 HTTP 只读消费其 `/api/vstore/*` 接口)
|
||||
- `paper_writer_service/`、`bioflow_service/`、`bioflow_kernel/`
|
||||
- `ui/`(前端入口另行按 UI_SPEC 流程评审)
|
||||
- `knowledge/`、`kb-libraries/`、`tooling/`(依赖锁另议)
|
||||
4. **依赖纪律**:审稿服务只使用统一 venv(`tooling/requirements.lock`)中已有的库
|
||||
(fastapi / httpx / pydantic-settings 等)。如确需新增全局依赖,
|
||||
须先更新 `tooling/requirements.lock` 并经人工确认。
|
||||
5. **上游对接只读**:PDF 解析走 vstore 8094(`POST /api/vstore/ingest` +
|
||||
`GET/DELETE /api/vstore/documents/{id}`);LLM 走 DeepSeek OpenAI 兼容接口;
|
||||
后续期刊数据/期刊推荐等子功能同样以新 service + 新路由在 `review_service/` 内扩展。
|
||||
6. **format_check 例外**:format_check 允许用 pymupdf 本地解档获取版式元数据
|
||||
(字体/边距/行距/页数),因为 vstore 只回文本不返回版式信息。正文提取仍走 vstore。
|
||||
|
||||
## 目录速览(审稿模块)
|
||||
|
||||
```
|
||||
review_service/
|
||||
├── app/main.py # FastAPI 入口(/health + 挂载业务路由)
|
||||
├── app/config.py # pydantic-settings(REVIEW_ 前缀)
|
||||
├── app/routers/presubmit.py # POST /api/review/presubmit(自审稿报告)
|
||||
├── app/routers/format_check.py # POST /api/review/format-check(版式与规范检查)
|
||||
├── app/services/llm.py # DeepSeek httpx 封装(complete_text/complete_json)
|
||||
├── app/services/paper_parse.py # PDF → 纯文本(经 vstore 解析接口)
|
||||
├── app/services/format_check.py# 版式规则引擎(pymupdf/docx 本地解档 + 规则检查)
|
||||
├── restart.sh # 8100 重启脚本
|
||||
└── pytest.ini
|
||||
```
|
||||
|
||||
## 启动
|
||||
|
||||
```bash
|
||||
cd review_service && bash restart.sh # 默认 8100,venv 从 ../plantai-rag-new-venv 推导
|
||||
# BFF 开关:sdk/.env 加 REVIEW_ENABLED=1 后 bff_restart(反代 /api/review/*)
|
||||
```
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>蜂群中枢 · PlantAI 管理后台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1147
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "plantai-admin",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "3.5.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "5.4.11",
|
||||
"@vitejs/plugin-vue": "5.2.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { getToken, setToken, api } from './api/admin.js';
|
||||
import Login from './views/Login.vue';
|
||||
import Users from './views/Users.vue';
|
||||
import Logs from './views/Logs.vue';
|
||||
import HubSettings from './components/HubSettings.vue';
|
||||
|
||||
const authed = ref(false);
|
||||
const page = ref('overview');
|
||||
const user = ref(null);
|
||||
const stats = ref({ users: 0, total: 0, denied: 0, p95: null });
|
||||
const hub = ref({ hub_id: '', hub_name: '蜂群中枢' });
|
||||
const settingsOpen = ref(false);
|
||||
|
||||
const PAGES = { overview: '概览', users: '用户管理', logs: '日志查看' };
|
||||
|
||||
function onUnauthorized() { setToken(''); authed.value = false; }
|
||||
|
||||
async function loadHub() {
|
||||
try {
|
||||
const r = await api.settings();
|
||||
hub.value = r.settings;
|
||||
} catch (e) {
|
||||
if (e.status === 401) onUnauthorized();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const [u, l] = await Promise.all([api.users(), api.logs({ page: 1, page_size: 1 })]);
|
||||
stats.value.users = u.users.length;
|
||||
stats.value.total = l.total;
|
||||
stats.value.denied = (l.aggregates && l.aggregates.denied) || 0;
|
||||
stats.value.p95 = (l.aggregates && l.aggregates.p95_latency_ms) || null;
|
||||
} catch (e) {
|
||||
if (e.status === 401) onUnauthorized();
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
if (!getToken()) { authed.value = false; return; }
|
||||
try {
|
||||
const r = await api.me();
|
||||
user.value = r.user;
|
||||
authed.value = true;
|
||||
loadHub();
|
||||
loadStats();
|
||||
} catch (e) {
|
||||
setToken('');
|
||||
authed.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onLogin(u) { user.value = u; authed.value = true; page.value = 'overview'; loadHub(); loadStats(); }
|
||||
async function onLogout() { try { await api.logout(); } catch {} setToken(''); authed.value = false; }
|
||||
function onSettingsSaved(s) { hub.value = s; settingsOpen.value = false; }
|
||||
|
||||
onMounted(init);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Login v-if="!authed" @login="onLogin" />
|
||||
<div v-else class="shell">
|
||||
<aside class="sidebar">
|
||||
<div class="side-brand">
|
||||
<div class="hex">蜂</div>
|
||||
<div><div class="brand-name">{{ hub.hub_name }}</div><div class="brand-sub">{{ hub.hub_id.slice(0, 8) }}</div></div>
|
||||
</div>
|
||||
<nav class="nav">
|
||||
<div class="nav-item" :class="{ active: page === 'overview' }" @click="page = 'overview'"><span>◈</span> 概览</div>
|
||||
<div class="nav-item" :class="{ active: page === 'users' }" @click="page = 'users'"><span>◉</span> 用户管理</div>
|
||||
<div class="nav-item" :class="{ active: page === 'logs' }" @click="page = 'logs'"><span>≣</span> 日志查看</div>
|
||||
</nav>
|
||||
<div class="side-foot">
|
||||
<div class="who">
|
||||
<div class="avatar">蜂</div>
|
||||
<div><div class="who-name">{{ user?.username }}</div><div class="who-role">系统管理员</div></div>
|
||||
<span class="logout" @click="onLogout">退出</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<main class="main">
|
||||
<div class="topbar">
|
||||
<h1>{{ PAGES[page] }}</h1>
|
||||
<span style="color: var(--text-3); font-size: 13px">{{ hub.hub_name }}</span>
|
||||
<button class="btn btn-ghost" style="margin-left: auto" @click="settingsOpen = true">⚙ 设置</button>
|
||||
</div>
|
||||
<div class="content">
|
||||
<section v-if="page === 'overview'">
|
||||
<div class="stats">
|
||||
<div class="stat"><div class="k">{{ stats.users }}</div><div class="l">注册用户</div></div>
|
||||
<div class="stat"><div class="k amber">{{ stats.total }}</div><div class="l">累计请求</div></div>
|
||||
<div class="stat"><div class="k">{{ stats.denied }}</div><div class="l">越权 / 失败</div></div>
|
||||
<div class="stat"><div class="k">{{ stats.p95 ?? '—' }}</div><div class="l">P95 耗时 ms</div></div>
|
||||
</div>
|
||||
</section>
|
||||
<Users v-else-if="page === 'users'" @unauthorized="onUnauthorized" />
|
||||
<Logs v-else @unauthorized="onUnauthorized" />
|
||||
</div>
|
||||
</main>
|
||||
<HubSettings :open="settingsOpen" :hub="hub" @close="settingsOpen = false" @saved="onSettingsSaved" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.shell { display: flex; height: 100vh; }
|
||||
.sidebar { width: 218px; flex: none; background: var(--bg-2); border-right: 1px solid var(--border); display: flex; flex-direction: column; }
|
||||
.side-brand { display: flex; align-items: center; gap: 10px; padding: 20px 18px 18px; }
|
||||
.hex { width: 30px; height: 30px; flex: none; background: linear-gradient(135deg, #f2b95a, #d98f22); clip-path: polygon(50% 0, 100% 25%, 100% 75%, 50% 100%, 0 75%, 0 25%); display: grid; place-items: center; color: #1a1206; font-weight: 700; font-size: 12px; }
|
||||
.brand-name { font-size: 15px; font-weight: 600; }
|
||||
.brand-sub { font-size: 11px; color: var(--text-3); }
|
||||
.nav { padding: 6px 10px; flex: 1; }
|
||||
.nav-item { display: flex; align-items: center; gap: 9px; padding: 9px 12px; margin-bottom: 3px; border-radius: var(--radius-sm); color: var(--text-2); font-size: 13.5px; cursor: pointer; }
|
||||
.nav-item:hover { background: var(--surface); color: var(--text); }
|
||||
.nav-item.active { background: var(--accent-soft); color: var(--accent); }
|
||||
.side-foot { padding: 14px 18px; border-top: 1px solid var(--border); }
|
||||
.who { display: flex; align-items: center; gap: 10px; }
|
||||
.avatar { width: 30px; height: 30px; border-radius: 50%; flex: none; background: linear-gradient(135deg, #3a4a5e, #243146); display: grid; place-items: center; font-size: 12px; color: var(--text); }
|
||||
.who-name { font-size: 13px; }
|
||||
.who-role { font-size: 11px; color: var(--text-3); }
|
||||
.logout { margin-left: auto; color: var(--text-3); cursor: pointer; font-size: 12px; }
|
||||
.logout:hover { color: var(--red); }
|
||||
.main { flex: 1; overflow-y: auto; display: flex; flex-direction: column; }
|
||||
.topbar { display: flex; align-items: center; gap: 14px; padding: 18px 28px; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: rgba(12, 14, 20, 0.9); backdrop-filter: blur(6px); z-index: 5; }
|
||||
.topbar h1 { font-size: 17px; margin: 0; font-weight: 600; }
|
||||
.content { padding: 26px 28px 40px; }
|
||||
.stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; }
|
||||
.stat { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 18px 20px; }
|
||||
.stat .k { font-size: 26px; font-weight: 600; letter-spacing: -0.5px; }
|
||||
.stat .k.amber { color: var(--accent); }
|
||||
.stat .l { color: var(--text-2); font-size: 12.5px; margin-top: 3px; }
|
||||
.toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; }
|
||||
.toolbar .spacer { flex: 1; }
|
||||
.search { display: flex; align-items: center; gap: 8px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 8px 12px; width: 240px; }
|
||||
.search input { background: none; border: none; outline: none; color: var(--text); font-size: 13px; width: 100%; }
|
||||
select { background: var(--surface); border: 1px solid var(--border); color: var(--text); border-radius: var(--radius-sm); padding: 8px 10px; font-size: 13px; outline: none; }
|
||||
.pag { display: flex; align-items: center; gap: 8px; padding: 12px 18px; border-top: 1px solid var(--border); color: var(--text-3); font-size: 12.5px; }
|
||||
.pag .spacer { flex: 1; }
|
||||
.pag button { background: var(--surface-2); border: 1px solid var(--border); color: var(--text-2); border-radius: 6px; padding: 5px 11px; font-size: 12.5px; }
|
||||
.pag button.on { color: var(--accent); border-color: var(--accent); }
|
||||
.pag button:disabled { opacity: 0.4; cursor: default; }
|
||||
</style>
|
||||
@@ -0,0 +1,48 @@
|
||||
const TOKEN_KEY = 'pa-admin-token';
|
||||
let token = localStorage.getItem(TOKEN_KEY) || '';
|
||||
|
||||
export function getToken() { return token; }
|
||||
export function setToken(t) {
|
||||
token = t || '';
|
||||
if (t) localStorage.setItem(TOKEN_KEY, t);
|
||||
else localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
async function request(path, { method = 'GET', body } = {}) {
|
||||
const res = await fetch('/api/admin' + path, {
|
||||
method,
|
||||
headers: {
|
||||
...(body ? { 'Content-Type': 'application/json' } : {}),
|
||||
...(token ? { Authorization: 'Bearer ' + token } : {}),
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
let data = null;
|
||||
try { data = await res.json(); } catch { data = null; }
|
||||
if (!res.ok) {
|
||||
const err = new Error((data && data.error && data.error.message) || ('请求失败 HTTP ' + res.status));
|
||||
err.status = res.status;
|
||||
err.code = data && data.error && data.error.code;
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
login: (username, password) => request('/login', { method: 'POST', body: { username, password } }),
|
||||
logout: () => request('/logout', { method: 'POST' }),
|
||||
me: () => request('/me'),
|
||||
units: () => request('/units'),
|
||||
users: () => request('/users'),
|
||||
createUser: (body) => request('/users', { method: 'POST', body }),
|
||||
updateUser: (id, body) => request('/users/' + id, { method: 'PATCH', body }),
|
||||
deleteUser: (id) => request('/users/' + id, { method: 'DELETE' }),
|
||||
logs: (params = {}) => {
|
||||
const q = new URLSearchParams();
|
||||
Object.entries(params).forEach(([k, v]) => { if (v !== undefined && v !== null && v !== '') q.set(k, v); });
|
||||
const qs = q.toString();
|
||||
return request('/logs' + (qs ? '?' + qs : ''));
|
||||
},
|
||||
settings: () => request('/settings'),
|
||||
updateSettings: (body) => request('/settings', { method: 'PUT', body }),
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { api } from '../api/admin.js';
|
||||
|
||||
const props = defineProps({ open: Boolean, hub: Object });
|
||||
const emit = defineEmits(['close', 'saved']);
|
||||
|
||||
const hubId = ref('');
|
||||
const hubName = ref('');
|
||||
const busy = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
watch(() => props.open, (open) => {
|
||||
if (open) {
|
||||
hubId.value = (props.hub && props.hub.hub_id) || '';
|
||||
hubName.value = (props.hub && props.hub.hub_name) || '';
|
||||
error.value = '';
|
||||
busy.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function save() {
|
||||
if (busy.value) return;
|
||||
if (!hubName.value.trim()) { error.value = '中枢名称不能为空'; return; }
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const r = await api.updateSettings({ hub_id: hubId.value.trim(), hub_name: hubName.value.trim() });
|
||||
emit('saved', r.settings);
|
||||
} catch (e) {
|
||||
error.value = e.message || '保存失败';
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="open" class="overlay" @click.self="emit('close')">
|
||||
<div class="modal">
|
||||
<div class="modal-head">中枢设置</div>
|
||||
<div class="modal-body">
|
||||
<div class="field"><label>中枢名称</label><input v-model="hubName" placeholder="例如:湘湖实验室中枢"></div>
|
||||
<div class="field" style="margin-bottom: 0"><label>中枢 ID</label><input v-model="hubId" class="mono" placeholder="唯一标识,留空则由系统生成"></div>
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button class="btn btn-ghost" @click="emit('close')">取消</button>
|
||||
<button class="btn btn-primary" :disabled="busy" @click="save">{{ busy ? '保存中…' : '保存' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.err { color: var(--red); font-size: 12px; margin: 12px 0 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,101 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { api } from '../api/admin.js';
|
||||
|
||||
const props = defineProps({
|
||||
open: Boolean,
|
||||
mode: { type: String, default: 'create' },
|
||||
user: Object,
|
||||
});
|
||||
const emit = defineEmits(['close', 'saved']);
|
||||
|
||||
const UNITS = [
|
||||
['qa', '问答'], ['kg', '知识图谱'], ['extraction', '文献抽取'], ['docgen', '文档生成'],
|
||||
['figures', '图片图库'], ['private_docs', '私有资料'], ['bioflow', '生信分析'],
|
||||
['paper', '论文撰写'], ['legacy', '旧版接口'],
|
||||
];
|
||||
|
||||
const username = ref('');
|
||||
const password = ref('');
|
||||
const role = ref('researcher');
|
||||
const authorizations = ref([]);
|
||||
const busy = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
watch(() => props.open, (open) => {
|
||||
if (open) {
|
||||
error.value = '';
|
||||
busy.value = false;
|
||||
if (props.mode === 'edit' && props.user) {
|
||||
username.value = props.user.username;
|
||||
password.value = '';
|
||||
role.value = props.user.role === 'admin' ? 'admin' : (props.user.role === 'guest' ? 'guest' : 'researcher');
|
||||
authorizations.value = [...(props.user.authorizations || [])];
|
||||
} else {
|
||||
username.value = '';
|
||||
password.value = '';
|
||||
role.value = 'researcher';
|
||||
authorizations.value = ['qa'];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function save() {
|
||||
if (busy.value) return;
|
||||
if (props.mode === 'create' && (!username.value.trim() || !password.value)) {
|
||||
error.value = '用户名和密码必填';
|
||||
return;
|
||||
}
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
if (props.mode === 'create') {
|
||||
await api.createUser({ username: username.value.trim(), password: password.value, role: role.value, authorizations: authorizations.value });
|
||||
} else {
|
||||
const body = { role: role.value, authorizations: authorizations.value };
|
||||
if (password.value) body.password = password.value;
|
||||
await api.updateUser(props.user.id, body);
|
||||
}
|
||||
emit('saved');
|
||||
} catch (e) {
|
||||
error.value = e.message || '保存失败';
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="open" class="overlay" @click.self="emit('close')">
|
||||
<div class="modal">
|
||||
<div class="modal-head">{{ mode === 'create' ? '新建用户' : '编辑用户' }}</div>
|
||||
<div class="modal-body">
|
||||
<div class="field"><label>用户名</label><input v-model="username" :disabled="mode === 'edit'"></div>
|
||||
<div class="field"><label>{{ mode === 'create' ? '初始密码' : '重置密码(留空不改)' }}</label><input v-model="password" type="password"></div>
|
||||
<div class="field">
|
||||
<label>角色</label>
|
||||
<div class="seg">
|
||||
<button :class="{ on: role === 'researcher' }" @click="role = 'researcher'">研究员</button>
|
||||
<button :class="{ on: role === 'guest' }" @click="role = 'guest'">访客</button>
|
||||
<button :class="{ on: role === 'admin' }" @click="role = 'admin'">管理员</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field" style="margin-bottom: 0">
|
||||
<label>授权清单</label>
|
||||
<div class="checks">
|
||||
<label v-for="[k, label] in UNITS" :key="k" class="check"><input type="checkbox" :value="k" v-model="authorizations"> {{ label }}</label>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button class="btn btn-ghost" @click="emit('close')">取消</button>
|
||||
<button class="btn btn-primary" :disabled="busy" @click="save">{{ busy ? '保存中…' : (mode === 'create' ? '创建用户' : '保存') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.err { color: var(--red); font-size: 12px; margin: 12px 0 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue';
|
||||
import App from './App.vue';
|
||||
import './style.css';
|
||||
|
||||
createApp(App).mount('#app');
|
||||
@@ -0,0 +1,96 @@
|
||||
:root {
|
||||
--bg: #0c0e14;
|
||||
--bg-2: #10131b;
|
||||
--surface: #151924;
|
||||
--surface-2: #1a1f2c;
|
||||
--border: #232a38;
|
||||
--border-2: #2e3648;
|
||||
--text: #e9edf5;
|
||||
--text-2: #9aa4b6;
|
||||
--text-3: #5f6a7d;
|
||||
--accent: #e8a33d;
|
||||
--accent-ink: #14100a;
|
||||
--accent-soft: rgba(232, 163, 61, 0.14);
|
||||
--green: #3ecf8e;
|
||||
--green-soft: rgba(62, 207, 142, 0.14);
|
||||
--red: #ef6b6b;
|
||||
--red-soft: rgba(239, 107, 107, 0.14);
|
||||
--blue: #6ea8ff;
|
||||
--radius: 10px;
|
||||
--radius-sm: 7px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #app { margin: 0; height: 100%; }
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", "Segoe UI", Roboto, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.mono { font-family: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace; }
|
||||
button { font-family: inherit; cursor: pointer; }
|
||||
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 7px;
|
||||
padding: 9px 15px; font-size: 13.5px; border-radius: var(--radius-sm);
|
||||
border: 1px solid transparent; transition: filter .15s; background: transparent;
|
||||
}
|
||||
.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.btn-primary { background: var(--accent); color: var(--accent-ink); font-weight: 600; }
|
||||
.btn-primary:hover { filter: brightness(1.06); }
|
||||
.btn-ghost { border-color: var(--border-2); color: var(--text-2); }
|
||||
.btn-ghost:hover { color: var(--text); border-color: var(--text-3); }
|
||||
.btn-danger { background: var(--red-soft); color: var(--red); }
|
||||
.btn-block { width: 100%; }
|
||||
|
||||
.field { margin-bottom: 15px; }
|
||||
.field label { display: block; font-size: 12px; color: var(--text-2); margin-bottom: 7px; }
|
||||
.field input, .field select {
|
||||
width: 100%; padding: 10px 12px; font-size: 14px; color: var(--text);
|
||||
background: var(--bg-2); border: 1px solid var(--border); border-radius: var(--radius-sm); outline: none;
|
||||
}
|
||||
.field input:focus, .field select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
|
||||
|
||||
.chip {
|
||||
display: inline-block; padding: 3px 9px; border-radius: 20px; font-size: 11.5px;
|
||||
border: 1px solid var(--border-2); color: var(--text-2); margin: 1.5px 3px 1.5px 0;
|
||||
}
|
||||
.chip.amber { border-color: transparent; background: var(--accent-soft); color: var(--accent); }
|
||||
.badge { display: inline-block; padding: 2px 8px; border-radius: 5px; font-size: 11.5px; font-family: ui-monospace, Menlo, monospace; }
|
||||
.badge.green { background: var(--green-soft); color: var(--green); }
|
||||
.badge.red { background: var(--red-soft); color: var(--red); }
|
||||
.badge.amber { background: var(--accent-soft); color: var(--accent); }
|
||||
.badge.muted { background: var(--surface-2); color: var(--text-3); }
|
||||
|
||||
.panel { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th { text-align: left; font-size: 11.5px; font-weight: 500; color: var(--text-3); padding: 10px 18px; border-bottom: 1px solid var(--border); }
|
||||
td { padding: 13px 18px; border-bottom: 1px solid var(--border); font-size: 13.5px; }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
tbody tr:hover { background: rgba(255,255,255,0.015); }
|
||||
.cell-id { color: var(--text-2); }
|
||||
.row-actions { display: flex; gap: 6px; }
|
||||
.row-actions .act { color: var(--blue); cursor: pointer; font-size: 13px; padding: 3px 6px; border-radius: 5px; }
|
||||
.row-actions .act:hover { background: rgba(110,168,255,0.1); }
|
||||
.row-actions .act.danger { color: var(--red); }
|
||||
.row-actions .act.danger:hover { background: var(--red-soft); }
|
||||
|
||||
.role { font-weight: 600; }
|
||||
.role.admin { color: var(--accent); }
|
||||
.role.researcher { color: var(--blue); }
|
||||
.role.guest { color: var(--text-2); }
|
||||
|
||||
.overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.55); display: flex; align-items: center; justify-content: center; z-index: 50; }
|
||||
.modal { width: 460px; max-width: calc(100vw - 32px); background: var(--surface); border: 1px solid var(--border-2); border-radius: 14px; }
|
||||
.modal-head { padding: 18px 22px 0; font-size: 15px; font-weight: 600; }
|
||||
.modal-body { padding: 18px 22px; }
|
||||
.modal-foot { padding: 14px 22px 20px; display: flex; justify-content: flex-end; gap: 10px; border-top: 1px solid var(--border); }
|
||||
.checks { display: grid; grid-template-columns: 1fr 1fr; gap: 9px 14px; margin-top: 8px; }
|
||||
.check { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--text-2); }
|
||||
.check input { accent-color: var(--accent); width: 15px; height: 15px; }
|
||||
.seg { display: flex; gap: 6px; }
|
||||
.seg button { background: transparent; border: 1px solid var(--border-2); color: var(--text-2); border-radius: 7px; padding: 7px 14px; font-size: 13px; }
|
||||
.seg button.on { background: var(--accent-soft); border-color: transparent; color: var(--accent); }
|
||||
@@ -0,0 +1,53 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { api, setToken } from '../api/admin.js';
|
||||
|
||||
const emit = defineEmits(['login']);
|
||||
const username = ref('');
|
||||
const password = ref('');
|
||||
const error = ref('');
|
||||
const busy = ref(false);
|
||||
|
||||
async function submit() {
|
||||
if (busy.value) return;
|
||||
if (!username.value || !password.value) { error.value = '请输入用户名和密码'; return; }
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const r = await api.login(username.value, password.value);
|
||||
setToken(r.token);
|
||||
emit('login', r.user);
|
||||
} catch (e) {
|
||||
error.value = e.message || '登录失败';
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login">
|
||||
<div class="card">
|
||||
<div class="brand">
|
||||
<div class="hex">蜂</div>
|
||||
<div><div class="brand-name">蜂群中枢</div><div class="brand-sub">PlantAI 管理后台</div></div>
|
||||
</div>
|
||||
<div class="field"><label>用户名</label><input v-model="username" autocomplete="username" @keyup.enter="submit"></div>
|
||||
<div class="field"><label>密码</label><input v-model="password" type="password" autocomplete="current-password" @keyup.enter="submit"></div>
|
||||
<button class="btn btn-primary btn-block" :disabled="busy" @click="submit">{{ busy ? '登录中…' : '登录' }}</button>
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
<div class="hint">仅限中枢管理员。登录与所有操作均会留痕。</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login { height: 100vh; display: flex; align-items: center; justify-content: center; background: radial-gradient(1200px 600px at 70% -10%, rgba(232, 163, 61, 0.08), transparent 60%), var(--bg); }
|
||||
.card { width: 360px; background: var(--surface); border: 1px solid var(--border); border-radius: 14px; padding: 34px 32px 30px; }
|
||||
.brand { display: flex; align-items: center; gap: 11px; margin-bottom: 26px; }
|
||||
.hex { width: 34px; height: 34px; flex: none; background: linear-gradient(135deg, #f2b95a, #d98f22); clip-path: polygon(50% 0, 100% 25%, 100% 75%, 50% 100%, 0 75%, 0 25%); display: grid; place-items: center; color: #1a1206; font-weight: 700; font-size: 14px; }
|
||||
.brand-name { font-size: 16px; font-weight: 600; }
|
||||
.brand-sub { color: var(--text-3); font-size: 12px; }
|
||||
.err { color: var(--red); font-size: 12px; margin: 10px 0 0; }
|
||||
.hint { margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--border); font-size: 12px; color: var(--text-3); }
|
||||
</style>
|
||||
@@ -0,0 +1,102 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { api } from '../api/admin.js';
|
||||
|
||||
const emit = defineEmits(['unauthorized']);
|
||||
|
||||
const from = ref('');
|
||||
const event = ref('');
|
||||
const q = ref('');
|
||||
const page = ref(1);
|
||||
const pageSize = 30;
|
||||
const items = ref([]);
|
||||
const total = ref(0);
|
||||
const aggregates = ref({});
|
||||
const loading = ref(false);
|
||||
|
||||
const EVENT_LABEL = {
|
||||
http_request: '调用', admin_login_ok: '登录成功', admin_login_fail: '登录失败',
|
||||
call_denied: '越权', user_created: '建用户', user_updated: '改用户', user_deleted: '删用户',
|
||||
};
|
||||
|
||||
function statusBadge(s) {
|
||||
if (!s) return 'muted';
|
||||
if (s >= 200 && s < 300) return 'green';
|
||||
if (s >= 500) return 'red';
|
||||
return 'amber';
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const r = await api.logs({
|
||||
from_date: from.value || undefined,
|
||||
event: event.value || undefined,
|
||||
q: q.value || undefined,
|
||||
page: page.value,
|
||||
page_size: pageSize,
|
||||
});
|
||||
items.value = r.items;
|
||||
total.value = r.total;
|
||||
aggregates.value = r.aggregates || {};
|
||||
} catch (e) {
|
||||
if (e.status === 401) emit('unauthorized');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function search() { page.value = 1; load(); }
|
||||
function prev() { if (page.value > 1) { page.value -= 1; load(); } }
|
||||
function next() { if (page.value * pageSize < total.value) { page.value += 1; load(); } }
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="stats" style="margin-bottom: 16px">
|
||||
<div class="stat"><div class="k">{{ total }}</div><div class="l">匹配日志</div></div>
|
||||
<div class="stat"><div class="k amber">{{ aggregates.denied ?? 0 }}</div><div class="l">越权 / 失败</div></div>
|
||||
<div class="stat"><div class="k">{{ aggregates.avg_latency_ms ?? '—' }}</div><div class="l">平均耗时 ms</div></div>
|
||||
<div class="stat"><div class="k">{{ aggregates.p95_latency_ms ?? '—' }}</div><div class="l">P95 耗时 ms</div></div>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<input class="date" v-model="from" type="date">
|
||||
<select v-model="event">
|
||||
<option value="">全部事件</option>
|
||||
<option v-for="(l, k) in EVENT_LABEL" :key="k" :value="k">{{ l }}</option>
|
||||
</select>
|
||||
<div class="search"><input v-model="q" placeholder="按用户 / 路径过滤…" @keyup.enter="search"></div>
|
||||
<button class="btn btn-ghost" @click="search">查询</button>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<table>
|
||||
<thead><tr><th>时间</th><th>用户</th><th>事件</th><th>方法</th><th>路径</th><th>状态</th><th>耗时</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-if="loading"><td colspan="7" class="cell-id">加载中…</td></tr>
|
||||
<tr v-else-if="!items.length"><td colspan="7" class="cell-id">暂无日志</td></tr>
|
||||
<tr v-for="(e, i) in items" :key="i">
|
||||
<td class="cell-id mono">{{ (e.ts || '').replace('T', ' ').slice(0, 19) }}</td>
|
||||
<td class="mono cell-id">{{ e.user_id ? e.user_id.slice(0, 8) : '—' }}</td>
|
||||
<td>{{ EVENT_LABEL[e.event] || e.event }}</td>
|
||||
<td class="cell-id mono">{{ e.method || '—' }}</td>
|
||||
<td class="mono">{{ e.path || '—' }}</td>
|
||||
<td><span v-if="e.status" class="badge" :class="statusBadge(e.status)">{{ e.status }}</span><span v-else class="badge muted">—</span></td>
|
||||
<td class="cell-id mono">{{ e.latency_ms != null ? e.latency_ms + 'ms' : '—' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="pag">
|
||||
<span>共 {{ total }} 条</span><span class="spacer"></span>
|
||||
<button :disabled="page <= 1" @click="prev">上一页</button>
|
||||
<button class="on">{{ page }}</button>
|
||||
<button :disabled="page * pageSize >= total" @click="next">下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
input.date { background: var(--surface); border: 1px solid var(--border); color: var(--text); border-radius: var(--radius-sm); padding: 8px 10px; font-size: 13px; outline: none; }
|
||||
</style>
|
||||
@@ -0,0 +1,72 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { api } from '../api/admin.js';
|
||||
import UserForm from '../components/UserForm.vue';
|
||||
|
||||
const emit = defineEmits(['unauthorized']);
|
||||
|
||||
const users = ref([]);
|
||||
const loading = ref(true);
|
||||
const formOpen = ref(false);
|
||||
const formMode = ref('create');
|
||||
const editing = ref(null);
|
||||
|
||||
const ROLE_LABEL = { admin: '管理员', researcher: '研究员', guest: '访客' };
|
||||
const UNIT_LABEL = { qa: '问答', kg: '知识图谱', extraction: '文献抽取', docgen: '文档生成', figures: '图片图库', private_docs: '私有资料', bioflow: '生信分析', paper: '论文撰写', legacy: '旧版接口' };
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const r = await api.users();
|
||||
users.value = r.users;
|
||||
} catch (e) {
|
||||
if (e.status === 401) emit('unauthorized');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function create() { formMode.value = 'create'; editing.value = null; formOpen.value = true; }
|
||||
function edit(u) { formMode.value = 'edit'; editing.value = u; formOpen.value = true; }
|
||||
async function del(u) {
|
||||
if (!confirm(`删除用户「${u.username}」?此操作不可撤销。`)) return;
|
||||
try { await api.deleteUser(u.id); load(); } catch (e) { alert(e.message || '删除失败'); }
|
||||
}
|
||||
function onSaved() { formOpen.value = false; load(); }
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="toolbar">
|
||||
<div class="spacer"></div>
|
||||
<button class="btn btn-primary" @click="create">+ 新建用户</button>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<table>
|
||||
<thead><tr><th>用户</th><th>角色</th><th>授权范围</th><th>创建时间</th><th style="width: 120px"></th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-if="loading"><td colspan="5" class="cell-id">加载中…</td></tr>
|
||||
<tr v-else-if="!users.length"><td colspan="5" class="cell-id">暂无用户</td></tr>
|
||||
<tr v-for="u in users" :key="u.id">
|
||||
<td>
|
||||
<div style="display: flex; align-items: center; gap: 10px">
|
||||
<div class="avatar">{{ u.username[0] }}</div>
|
||||
<div><div>{{ u.username }}</div><div class="cell-id" style="font-size: 12px">{{ u.id.slice(0, 8) }}</div></div>
|
||||
</div>
|
||||
</td>
|
||||
<td><span class="role" :class="u.role">{{ ROLE_LABEL[u.role] || u.role }}</span></td>
|
||||
<td>
|
||||
<span v-if="u.role === 'admin'" class="chip amber">全部权限</span>
|
||||
<template v-else><span v-for="a in u.authorizations" :key="a" class="chip">{{ UNIT_LABEL[a] || a }}</span></template>
|
||||
</td>
|
||||
<td class="cell-id mono">{{ (u.created_at || '').slice(0, 10) }}</td>
|
||||
<td><div class="row-actions"><span class="act" @click="edit(u)">编辑</span><span class="act danger" @click="del(u)">删除</span></div></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<UserForm :open="formOpen" :mode="formMode" :user="editing" @close="formOpen = false" @saved="onSaved" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
|
||||
export default defineConfig({
|
||||
base: '/admin/',
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
port: 5181,
|
||||
proxy: {
|
||||
'/api': { target: 'http://127.0.0.1:8090', changeOrigin: false },
|
||||
},
|
||||
},
|
||||
build: { outDir: 'dist', target: 'es2020' },
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
# feat(review): journal_directory 启动自动 seed(部署即运行)
|
||||
|
||||
## 背景
|
||||
|
||||
source_v4.xlsx(1056 条目录)此前需手动跑 import_journals.py 入库,部署后忘跑即出现"目录数据未导入"。本次实现启动时自动检测 + 自动导入。线上当前环境已由人工完成导入(review.db 内 1056 行),本功能消除的是新环境部署/重建库时的手动步骤风险。
|
||||
|
||||
## 修改内容
|
||||
|
||||
| 文件 | 变更 |
|
||||
|---|---|
|
||||
| `app/services/journal_seed.py`(新增) | 共享核心:schema/清洗/解析/upsert 从 CLI 原样迁入;`extract_rows_from_xlsx()`(口径 779/277 与 id 冲突闸门,不符不写库)、`upsert_to_db()`、`ensure_journal_directory_seeded()`(空表才导,四键 dict 契约,绝不抛异常) |
|
||||
| `scripts/import_journals.py` | 瘦身为薄 CLI(-431/+92 行):对账报告/退出码 0/1/2/3 全保留,核心复用 journal_seed |
|
||||
| `app/config.py` | +`seed_on_startup=True`(REVIEW_SEED_ON_STARTUP 可关)、+`journal_directory_seed_path` |
|
||||
| `app/main.py` | lifespan:开关门控 → `asyncio.to_thread` 导入(不堵 event loop)→ try/except 不阻塞启动;路径走 `db.path` 注入链 |
|
||||
| `tests/conftest.py`(新增) | 全局关 seed,防止单测触碰真库/拖慢套件 |
|
||||
| `tests/test_seed.py`(新增) | 6 用例(见下) |
|
||||
|
||||
不动:source_v4.xlsx、journal_directory 路由/表 schema、db.py、前端。
|
||||
|
||||
## 测试用例与结果(pytest 78 passed,新增 6 条)
|
||||
|
||||
1. 空库 → seed 成功,rows=1056(en=779/zh=277)✓
|
||||
2. 非空库(1 行)→ skip,count 不变 ✓
|
||||
3. xlsx 不存在 → 返回 error,不抛异常,不写库 ✓
|
||||
4. 幂等:连调两次,第二次 skip ✓
|
||||
5. 开关 off(lifespan)→ 表不创建 ✓
|
||||
6. 开关 on(lifespan)→ tmp 库 1056 行 ✓
|
||||
|
||||
## 部署验证
|
||||
|
||||
```bash
|
||||
bash review_service/restart.sh # 非空库:日志 "already has 1056 rows, skip"
|
||||
curl 'http://127.0.0.1:8100/api/review/journals/directory?lang=en' # total=779
|
||||
# 空库冷启动(本机已验证等价路径):日志 "seeded 1056 rows (en=779, zh=277, N.Ns)"
|
||||
# 特殊部署:REVIEW_SEED_ON_STARTUP=false → 日志 "seed skipped"
|
||||
```
|
||||
|
||||
本机已实测:真机重启走 skip 路径(`INFO:app.main:already has 1056 rows, skip`),CLI 薄壳回归 exit=0 / `en=779 zh=277 → ✓ 相符`。
|
||||
|
||||
## 是否需要立即部署
|
||||
|
||||
待管理员确认。线上已手动导入 1056 行,本改动属部署便利性补强(消除新环境忘跑导入脚本的风险),可随下次正常发版评估带上。
|
||||
@@ -0,0 +1,56 @@
|
||||
# fix(review): 冲突降级真影响 recommendation(major → minor 真降级)
|
||||
|
||||
- 分支:`feature/academic-review-assistant`
|
||||
- 日期:2026-10-12
|
||||
- 服务:`review_service`(:8100)/ 前端 `PresubmitPanel.vue`
|
||||
- 状态:**未 commit**,待验收
|
||||
|
||||
## 背景
|
||||
|
||||
s41588(已发表 Nature Genetics 论文)真实链路里,关键词扫描器报告"3 处冲突已降级为非硬性问题",但 `recommendation` 仍为大修、`major_weaknesses` 仍 5 条。诊断确认:原降级只做了"剥 hard_rule 标签 + 记 conflicts",**条目仍在 major 列表内**,`major_count` / 引擎输入未变,故判定不变。本次把"降级"从标签维度升级为**列表维度的真降级**(major → minor)。
|
||||
|
||||
## 变更
|
||||
|
||||
**后端**
|
||||
- `paper_scanner.reconcile_hard_rules`:命中冲突的 major 从列表**移出**,`hard_rule=None` + `scanner_downgraded=true`,落入 demoted 列表;`fatal=true` 条目例外(只记 conflict 不降级,须人工过目)。
|
||||
- `routers/presubmit.py`:demoted 追加进 `report.minor_weaknesses`;`major_weaknesses` 为过滤后列表;explanation 文案改"已降级为小修级问题"。
|
||||
- `self_review.WeaknessItem`:新增 `scanner_downgraded: bool = False`(coerce 不读、LLM 无法伪造)。
|
||||
- 引擎 `recommend_engine.decide_recommendation` **零改动**——计数因列表变短而自动真实。
|
||||
|
||||
**前端**
|
||||
- `PresubmitPanel.vue`:minor 卡片对 `scanner_downgraded` 显示黄徽标"冲突降级"(与 major 卡片"硬性"红徽标对仗);冲突明细块保留。
|
||||
|
||||
## 测试
|
||||
|
||||
- pytest **104 passed**(新增:`test_reconcile_fatal_not_demoted`、`test_http_partial_conflict_leaves_residual_soft`;改写 `test_reconcile_*` 与 `test_http_scanner_conflict_downgrades` 为真降级断言)。
|
||||
- vitest **382 passed / 5 failed**(5F 为既有他人 `benchpanel`+`Composer .mode-pill` labels.js 引发,与本改动无关);`npm run build` ✓。
|
||||
|
||||
## 验收(真 LLM,:8100)
|
||||
|
||||
| 样本 | rec | rule | M/S/minor | conflicts | downgraded→minor |
|
||||
|---|---|---|---|---|---|
|
||||
| diag_strong | minor_revision | R5 | 0/0/4 | 0 | 无 |
|
||||
| diag_weak | major_revision | R1 | 6/2/7 | 1(hic) | 有(真降级生效) |
|
||||
|
||||
弱稿仍大修:4 条硬性命中(busco/accession/voucher/abs)为真缺,扫描器无冲突、不降级——真缺陷不放行。
|
||||
|
||||
## 验收:s41588(真 LLM,:8100)
|
||||
|
||||
输入 `/tmp/opencode/s41588-025-02113-5.pdf`,target_journal=Genome Biology(tier 1 override)。
|
||||
|
||||
| 项 | 修复前 | 修复后 |
|
||||
|---|---|---|
|
||||
| recommendation | major_revision | **minor_revision**(R5) |
|
||||
| major_count / soft / minor | 5 / — / — | **0 / 0 / 6** |
|
||||
| hard_hits | 含 busco/accession… | **[]** |
|
||||
| scanner signals | — | accession_found=True, busco=partial, hic_found=True |
|
||||
| major_weaknesses 列表 | 5 条 | **0 条** |
|
||||
| scanner_conflicts | — | **2 条保留**(busco_missing 格式不完整、accession_missing 部分未给号)→ 均真降级进 minor(`scanner_downgraded=true`) |
|
||||
|
||||
判定链:扫描器证伪 2 条硬性 → 真降级 → 剩余为 minor → R5(tier1 accept 门槛 minor≤1,6 条 → minor_revision)。conflicts 透明保留、explanation 明示"已降级为小修级问题,请人工核对"。
|
||||
|
||||
注:本次 LLM 未再把"树图注未标数值 / GWAS 校正描述"判成 major(负向清单 Prompt 生效),故 major 从 5 归 0,优于预期的"降到 2"。
|
||||
|
||||
## 部署
|
||||
|
||||
待管理员确认。属判定正确性修复(真论文误判大修),建议随下次正常发版带上。
|
||||
@@ -0,0 +1,81 @@
|
||||
# fix(review): presubmit 四 Bug 修复——截断保真 / 分档 / 词表 / 扫描器兜底
|
||||
|
||||
## 背景
|
||||
|
||||
真实案例:已发表 Nature Genetics 论文(s41588-026-02682-z,黄瓜泛基因组)被判
|
||||
"大修",但论文实有 BUSCO 98.57%(P2)、94.18% sequences anchored(P2)、
|
||||
PRJCA038097 等 accession(P17 Data Availability)。诊断出四个独立缺陷:
|
||||
|
||||
1. **PDF 截断**:`max_text_chars=60000` 从头切,30 页论文 P17 的 Data Availability 被截掉;
|
||||
2. **期刊分档**:Nature Genetics 不在 5 本精选 → 默认 tier 2(应 tier 1);
|
||||
3. **硬性规则词表过窄**:缺 PRJCA/CRA(中国 NGDC/GSA)、Hi-C 同义词只认 anchoring rate、
|
||||
BUSCO 仅百分比也算缺失;
|
||||
4. **无程序层兜底**:hard_rule 全靠 LLM 输出,无关键词证伪机制。
|
||||
|
||||
## 修改内容
|
||||
|
||||
| 文件 | 变更 |
|
||||
|---|---|
|
||||
| `app/routers/presubmit.py` | **Bug 1**:段落感知装箱 `pack_feed_text`(超预算按
|
||||
Abstract→Data Availability 全文→Methods 前 60%→Results→Discussion 优先保留,
|
||||
dropped_sections 入 meta);无节标题回退头+尾窗(捞页尾 availability 块)。
|
||||
**双层保护**:估算 token(len/1.5)>48K → 自动降 100K 重装;LLM 400/413 → 降 80K
|
||||
重试一次。**Bug 4 接线**:coerce 后扫描器证伪 → rationale.scanner_conflicts |
|
||||
**Bug 2**:tier_source ∈ override/featured/jif/默认 |
|
||||
| `app/services/paper_scanner.py`(新增) | 关键词扫描器(纯 stdlib):accession
|
||||
词表 PRJNA/PRJEB/PRJCA/SRP/ERP/DRP/CRA/SRR/ERR/DRR/GCA_/GCF_ **只在 Data Availability
|
||||
段扫**(正文引用他人数据不误判);BUSCO 三级 none/partial/full;Hi-C 同义词
|
||||
(anchored to chromosome(s)/anchoring rate/pseudo-chromosome(s)/chromosome-level)。
|
||||
`reconcile_hard_rules`:LLM 报缺 X + 扫描器见 X → 剥硬性标签降 soft + 记冲突
|
||||
(只证伪不证实,不引入新误判) |
|
||||
| `app/services/recommend_engine.py` | **Bug 2**:tier 查找优先级 override
|
||||
(13 本顶刊硬编码,"The " 前缀归一化)→ featured(journals.json)→ jif 启发式
|
||||
(journal_directory 只读 jif 列;jif≥15→tier1;综述刊名含 Review/Trends/Annual
|
||||
Review 除外)→ 默认 tier 2 |
|
||||
| `app/services/self_review.py` | **Bug 3**:Prompt 词表扩充(PRJCA/CRA/SRR…);
|
||||
BUSCO 三级判定(仅百分比→minor 不打 hard_rule);Hi-C 同义词清单。
|
||||
**2026-10-12 追加 soft major 收紧**(s41588 复盘):维度三系统发育树分诊重写
|
||||
(完全未做→major / 已声明仅图未标数值→presentation / 附带树→minor);新增
|
||||
【负向清单】节(图未标统计量、标准方法描述不全、阈值缺数值、引用排版 →
|
||||
minor/presentation,严禁列 major;总纲"只有补实验/补数据/重跑分析/改核心论证
|
||||
才是 major";与各维度冲突时以本节为准) |
|
||||
| `app/config.py` | `max_text_chars` 60000 → **150000**(REVIEW_MAX_TEXT_CHARS 可覆盖) |
|
||||
| `ui/.../PresubmitPanel.vue` | 冲突黄标块(scanner_conflicts + 降级提示);截断
|
||||
dropped_sections 美化提示;截断文案改为"保摘要/数据可用性/方法/结果/讨论" |
|
||||
| `tests/test_presubmit.py` | 新增 15 用例(装箱/回退/token 守卫/400 重试/扫描器
|
||||
三类信号/DA 段限定/冲突降级/端到端/tier 优先级矩阵/Prompt 新词表) |
|
||||
|
||||
## 测试用例与结果
|
||||
|
||||
- **pytest:102 passed**(87 基线 + 15 新增)。
|
||||
- **vitest:379 passed / 5 failed**(5F 为既有他人 i18n labels 改动引发,与本改动
|
||||
无关;新增冲突黄标/截断提示 2 条全绿)。`npm run build` ✓。
|
||||
- **真实链路验收**(真 LLM,target_journal=Genome Biology → tier 1 override):
|
||||
|
||||
| 样本 | 修复前 | 修复后 | 扫描器 |
|
||||
|---|---|---|---|
|
||||
| 强稿 diag_strong.pdf | minor_revision | **minor_revision**(R5,0/0/5) | accession/busco=full/hic 全命中,0 冲突 ✓ |
|
||||
| 弱稿 diag_weak.pdf | major_revision | **major_revision**(R1,硬性 4 + soft 4 + minor 5) | accession/busco 未命中(真缺不降);Hi-C 冲突降级并记录 ✓ |
|
||||
| **真实案例 s41588**(NG 论文) | major_revision | 见下"验收"节 | — |
|
||||
|
||||
## 部署验证
|
||||
|
||||
```bash
|
||||
bash review_service/restart.sh
|
||||
curl -X POST http://127.0.0.1:8100/api/review/presubmit \
|
||||
-F "file=@paper.pdf" -F "target_journal=Nature Genetics"
|
||||
# 检查:meta.tier=1/tier_source=override;meta.dropped_sections;
|
||||
# report.recommendation_rationale.scanner_conflicts
|
||||
```
|
||||
|
||||
## 是否需要立即部署
|
||||
|
||||
待管理员确认。四修复均为判定正确性问题(真实发表论文误判大修),建议随下次正常发版带上。
|
||||
|
||||
## 遗留
|
||||
|
||||
- tier jif 启发式仅覆盖 journal_directory 已录入的 1056 刊;未收录刊名走默认 tier 2。
|
||||
- 扫描器 accession 仅扫 DA 段——若论文把 accession 放 Methods 数据声明处且无 DA 节,
|
||||
扫描器不证伪(保守方向,LLM 硬性判定仍生效,不会误放行)。
|
||||
- BUSCO full 判定要求 S: 与 D: 标记在 200 字符窗内相邻——极端排版可能判 partial
|
||||
(只会更严,不误放)。
|
||||
@@ -0,0 +1,61 @@
|
||||
# fix(review): presubmit recommendation 判定过严修复(配额制 → 规则引擎)
|
||||
|
||||
## 背景
|
||||
|
||||
用户反馈 AI 预审稿多次测试均返回 major_revision。2026-10-12 诊断确认根因是
|
||||
**配额制**而非"尺子紧":
|
||||
|
||||
- `self_review.py` 硬性规则要求 `major_weaknesses 3-8 条`(强制配额)→ accept/minor_revision 几乎不可达;
|
||||
- LLM 直接输出 recommendation,无程序层规则;JSON 示例锚定 `major_revision`、coerce 缺省回落 `major_revision`;
|
||||
- 对照实验:近 GB 完备强稿(BUSCO 96.8% / Hi-C 98.7% / PRJNA+GCA+figshare 齐 / UFBoot 1000)仍被凑出 6 条 major,与弱稿(7 条)几乎无区分度;
|
||||
- `target_journal` 传入但 Prompt 零分档标准。
|
||||
|
||||
## 修改内容
|
||||
|
||||
| 文件 | 变更 |
|
||||
|---|---|
|
||||
| `app/services/recommend_engine.py`(新增) | 纯函数规则引擎:R0 fatal→reject / R1 硬性 major→大修(不可协商,与 tier 无关)/ R2' tier3 仅 1-2 条 soft 可降小修 / R2 soft≥1→大修 / R4 0 major 且 minor≤tier 阈值→接收 / R5→小修;rationale 模板拼装(rule/hard_hits/计数/tier/explanation,含 tier 降级提示句);`resolve_tier` 按 journals.json `review_tier` 匹配,失败默认 tier 2 |
|
||||
| `app/services/self_review.py` | Prompt 重写:严重分级标准(major/minor/presentation),**三类均不设下限、可 0 条、严禁凑数**;硬性规则 5 项打标签(busco/hic/accession/voucher/abs_missing);删除 recommendation 输出字段与示例锚定;coerce 不再读 recommendation(防注入),hard_rule 白名单过滤 |
|
||||
| `app/routers/presubmit.py` | coerce 后接引擎;meta 增加 tier/tier_label/tier_source |
|
||||
| `data/journals/journals.json` | 精选 5 本加 `review_tier`(GB/MP=1;BMC PB/FPS/Rice=2)+ notes 一行 |
|
||||
| `ui/.../PresubmitPanel.vue` | 判定依据块(explanation + 硬性 chips);判定档位徽标;reject 红标"建议人工复核";顶部 AI 免责行;major 卡片"硬性/fatal"徽标 |
|
||||
| `tests/test_presubmit.py` | 用例 1-10(引擎分支全覆盖 + tier 对照 + 注入忽略 + rationale 字段) |
|
||||
|
||||
判定链与旧链路唯一对外契约变化:`report.recommendation` 语义不变(四档枚举),
|
||||
新增 `report.recommendation_rationale` 与 `meta.tier*`。
|
||||
|
||||
## 测试用例与结果
|
||||
|
||||
- **pytest:87 passed**(78 基线 + 9 新增/重写),含用例 1-10:
|
||||
强稿形态不判大修、弱稿形态判大修且 hard_hits 正确、0 major 0/多 minor 分支、
|
||||
soft major 分支、fatal→reject、tier3 降级与硬性豁免、tier1 accept 门槛、
|
||||
LLM recommendation 注入被忽略、hard_rule 白名单过滤、rationale 含 tier/explanation。
|
||||
- **vitest:377 passed / 5 failed**(5F 为既有他人 i18n labels 改动引发的 benchpanel
|
||||
失败,与本改动无关;新增 4 条 rationale/档位/fatal 展示用例全绿)。
|
||||
- **真实链路验收**(诊断同款样本、同 target_journal=Genome Biology,真 LLM 调用):
|
||||
|
||||
| 样本 | 修复前 | 修复后 | 依据 |
|
||||
|---|---|---|---|
|
||||
| 弱稿 diag_weak.pdf | major_revision(7 major) | **major_revision**(R1;硬性 5 命中:BUSCO/Hi-C/Accession/voucher/ABS + soft 2 + minor 6) | 真缺陷不漏判 ✓ |
|
||||
| 强稿 diag_strong.pdf | major_revision(凑出 6 major) | **minor_revision**(R5;0 major / 5 minor) | 区分度恢复 ✓ |
|
||||
|
||||
## 部署验证
|
||||
|
||||
```bash
|
||||
bash review_service/restart.sh
|
||||
# 上传强/弱样本对比(或任意 PDF):
|
||||
curl -X POST http://127.0.0.1:8100/api/review/presubmit \
|
||||
-F "file=@paper.pdf" -F "target_journal=Genome Biology"
|
||||
# 检查 report.recommendation_rationale.rule / meta.tier;前端看"判定依据"块
|
||||
```
|
||||
|
||||
## 是否需要立即部署
|
||||
|
||||
待管理员确认。属判定质量修复(误报大修影响用户体验与信任),建议随下次正常发版带上。
|
||||
|
||||
## 遗留(Phase 2 候选)
|
||||
|
||||
- 程序层兜底 `_sanity_check_missing_hard_rules`:正文无 PRJNA/GCA_/SRP 关键词但 LLM
|
||||
未报 accession_missing → 追加 warning(不进 recommendation 计算);先观察 LLM 是否
|
||||
"躺平"再决定。
|
||||
- tier 数据仅覆盖精选 5 本;扩展到 journal_directory 1056 条需人工核验后另行入库。
|
||||
@@ -0,0 +1,161 @@
|
||||
# PR 报告:Rebuttal Phase 1(审稿回复助手)
|
||||
|
||||
- 日期:2026-10-12
|
||||
- 分支:`feature/academic-review-assistant`(未 commit,按用户约定统一处理)
|
||||
- 范围:`review_service/` 新增 5 文件 + main 注册;`ui/` 新增面板与 store/api 扩展;无 sdk/、agent/、vstore/ 改动;无新增依赖。
|
||||
|
||||
## 功能概述
|
||||
|
||||
粘贴审稿意见文本(主入口)/ 上传批注版 PDF(次要入口)→ LLM+正则拆条(Reviewer→Comment)
|
||||
→ 逐条填回复要点(5 类下拉,无 auto)→ 生成草稿(反幻觉校验前置)
|
||||
→ 逐条勾确认 → 拼装 response letter → 下载 `.md`(human_ack 门槛)。docx 属 Phase 2。
|
||||
|
||||
## 10 个 API 端点(review_service:8100;BFF 8095 通用透传已验证可用)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| POST | /api/review/rebuttal/jobs | 文本粘贴主入口 |
|
||||
| POST | /api/review/rebuttal/jobs/pdf | PDF 次要入口(vstore 解析;非 PDF/超限→VALIDATION) |
|
||||
| GET | /api/review/rebuttal/jobs/{id} | 全量还原 |
|
||||
| PATCH | .../comments/{cid}/response | 保存 parts(改后旧 draft/validation 作废) |
|
||||
| POST | .../comments/{cid}/draft | 生成草稿(校验C前置;MISSING_RESPONSE 时不调 LLM) |
|
||||
| PATCH | .../comments/{cid}/structure | 手工 merge(≥2)/ split(≥2 非空子项) |
|
||||
| PATCH | .../comments/{cid}/confirm | 逐条勾;后端重算 human_ack(全勾才 true,空 job 不置位) |
|
||||
| POST | .../letter | 拼装 letter_md |
|
||||
| GET | .../letter.md | 下载(human_ack=false → 400 HUMAN_ACK_REQUIRED) |
|
||||
| DELETE | .../jobs/{id} | 手动删(替代 TTL) |
|
||||
|
||||
信封错误复用约定:HTTP 200 + `{ok:false,error:{code,message,degraded}}`。
|
||||
|
||||
## 数据模型
|
||||
|
||||
- Job:`{id,status,source:{kind,filename,raw_text},template:'generic',reviewers:[...],letter_md,validation,human_ack,created_at,updated_at}`
|
||||
- Comment:`{id,group_id,severity,title,body,sub_items[],cross_refs[],response:{parts[]},draft,validation,human_confirmed}`
|
||||
- part.type 枚举:`new_data | reanalysis | location | clarification | rebuttal`(决策 2:无 auto)
|
||||
- SQLite 表 `rebuttal_jobs` 落在既有 `review_service/review.db`(自含短连接,不动 app/db.py);
|
||||
启动幂等建表(main lifespan)。
|
||||
|
||||
## 关键实现决策(方案未写清处,按"保守、可测、不猜测")
|
||||
|
||||
1. **LLM 注入形态**:parse/draft 用 `async (messages, temperature) → dict` 回调,路由内适配
|
||||
`llm.complete_json(system, user)`;单测直接 monkeypatch `llm.complete_json`。
|
||||
2. **拆条双轨**:正则先切骨架(Reviewer 段头 + 编号行,兼容行内 `. 2.` 编号);
|
||||
LLM 逐段出 `{title,severity,sub_items[]}`。LLM 失败/结构非法 → 该段回退正则
|
||||
(title=首 60 字,severity=major)。`sub_items ≥2 且 max confidence ≥0.7` 才拆
|
||||
(决策 3),拆出条目共享 group_id;未达阈值多子问题并回一条且 **body 保留段原文**
|
||||
(不采信 LLM 改写)。
|
||||
3. **交叉引用**:`see comment N / 参见第 N 条 / 同上` → 解析目标 id;解析不到
|
||||
`target_id=null` 保留 raw(不猜)。
|
||||
4. **反幻觉校验(负向词表收窄版)**:
|
||||
- location:`shows that/as shown` 且无 `added/新增` → `LOCATION_EVIDENCE_WEAK` FAIL;
|
||||
- rebuttal:命中 `we agree/accept/concede/you are right/we were wrong` →
|
||||
`REBUTTAL_NEGATIVE` FAIL;无立场词(maintain/disagree/respectfully…)→
|
||||
`REBUTTAL_NO_STANCE` FAIL;
|
||||
- 其余三类 Phase 1 仅查非空。校验在 draft 端点前置,FAIL 不调 LLM。
|
||||
5. **human_ack**:confirm 端点改完后由 store 重算(所有 comment 全 true 才置位;
|
||||
空 job 不置位防平凡真)。前端无法直接置位。
|
||||
6. **letter.md 下载**:纯文本端点(非信封),门槛失败返回 400 + 明文
|
||||
`HUMAN_ACK_REQUIRED:…`;前端 `rebuttalDownloadLetterMd` 适配成 ApiError。
|
||||
7. **MISSING_RESPONSE**:draft 端点 parts 为空或全空内容 → 信封错误,LLM 调用次数=0
|
||||
(单测断言)。
|
||||
8. **PDF 入口**:复用 vstore `parse_pdf_bytes`;校验顺序 = 非 .pdf → 空文件 → 超
|
||||
`max_pdf_bytes`(50MB)→ 解析。与任务清单"PDF 超限/非 PDF"信封一致。
|
||||
9. **前端状态**:`store.rebuttal` 纯内存(切面板不丢、刷新丢——与任务"状态持久化"
|
||||
不同诉求,Phase 1 未要求 localStorage);draft 失败信封里的 job/validation 一并镜像。
|
||||
10. **不改禁区**:sdk/agent/vstore/未动;PresubmitPanel 等既有面板未动;无新增依赖。
|
||||
|
||||
## 测试
|
||||
|
||||
### 后端 pytest(9 例新增,全量 113 passed)
|
||||
|
||||
```
|
||||
tests/test_rebuttal.py::test_single_reviewer_single_comment PASSED
|
||||
tests/test_rebuttal.py::test_multi_reviewer_attribution PASSED
|
||||
tests/test_rebuttal.py::test_subitem_split_shares_group_id PASSED
|
||||
tests/test_rebuttal.py::test_location_validation_fail_and_pass PASSED
|
||||
tests/test_rebuttal.py::test_rebuttal_validation PASSED
|
||||
tests/test_rebuttal.py::test_missing_response_no_llm_call PASSED
|
||||
tests/test_rebuttal.py::test_cross_ref_unresolved_keeps_raw PASSED
|
||||
tests/test_rebuttal.py::test_envelope_errors PASSED
|
||||
tests/test_rebuttal.py::test_human_ack_gate_on_letter_download PASSED
|
||||
|
||||
全量: 113 passed in 10.05s(104 基线 + 9 新增)
|
||||
```
|
||||
|
||||
### 前端 vitest(5 例新增)
|
||||
|
||||
```
|
||||
rebuttal.panel.test.js 5 passed(5 类下拉无 auto / location hint 条件显示 /
|
||||
未填禁用生成 / 未全确认禁用下载+横幅 / merge-split 仅 active 表单)
|
||||
全量: 387 passed | 5 failed(失败全部为已知 labels.js 引发的 benchpanel×4 +
|
||||
Composer .mode-pill×1 预存失败,与本次改动无关;基线 382P/5F → 387P/5F)
|
||||
```
|
||||
|
||||
### build
|
||||
|
||||
`npm run build` ✓ built in 3.04s
|
||||
|
||||
## 手工冒烟(curl,8100 真 LLM + 真 SQLite)
|
||||
|
||||
```
|
||||
POST /jobs(真实双 Reviewer 意见)→ ok=True job=rbt_ed245cd63b3a reviewers=2 comments=4
|
||||
titles: 缺少FDR校正与q值报告 / Figure 3 blurry… / Missing coordinates… / Overstated novelty…
|
||||
POST draft(未填) → ok=False code=MISSING_RESPONSE(LLM 0 调用)
|
||||
PATCH response(location 弱) + POST draft
|
||||
→ ok=False VALIDATION_FAILED findings=[LOCATION_EVIDENCE_WEAK]
|
||||
PATCH response(location 合法) + POST draft → ok=True(真 LLM 草稿)
|
||||
PATCH response(rebuttal 让步词) + draft → VALIDATION_FAILED findings=[REBUTTAL_NEGATIVE]
|
||||
3 条合理回复(new_data/clarification/rebuttal)→ draft 全 ok=True
|
||||
GET letter.md(未全勾) → HTTP 400 HUMAN_ACK_REQUIRED
|
||||
PATCH confirm ×3 → human_ack=False(c0001 漏勾,恰好验证逐条门槛)
|
||||
PATCH confirm c0001 → human_ack=True
|
||||
POST letter → ok=True letter_md len=2731
|
||||
GET letter.md → HTTP 200(/tmp/opencode/letter.md 落盘,含
|
||||
Response to Reviewers + 4 条原文/回复)
|
||||
DELETE job → ok=True meta={deleted:…};GET → NOT_FOUND
|
||||
BFF 8095 探测 /rebuttal/jobs/nonexistent → HTTP 200(透传信封,REVIEW_ENABLED=1)
|
||||
```
|
||||
|
||||
## 交付物清单
|
||||
|
||||
| 文件 | 动作 |
|
||||
|---|---|
|
||||
| review_service/app/services/rebuttal_store.py | 新增(SQLite CRUD + human_ack 重算) |
|
||||
| review_service/app/services/rebuttal_parse.py | 新增(正则骨架 + LLM 拆分 + 交叉引用) |
|
||||
| review_service/app/services/rebuttal_draft.py | 新增(5 类 Prompt + 反幻觉校验 + letter) |
|
||||
| review_service/app/routers/rebuttal.py | 新增(10 端点) |
|
||||
| review_service/app/main.py | +import +include_router +lifespan 建表(约 8 行) |
|
||||
| review_service/tests/test_rebuttal.py | 新增(9 例) |
|
||||
| ui/src/api/review.js | +10 API 封装 + rebuttal 错误码未进 CODE_HINTS(后端原话直达) |
|
||||
| ui/src/stores/edu.js | +SECTIONS 'rebuttal' + rebuttal 状态块 + 10 actions |
|
||||
| ui/src/components/edu/panels/RebuttalPanel.vue | 新增(拆条树/逐条表单/校验红条/draft 预览/手工拆合/letter) |
|
||||
| ui/src/components/edu/EduSideNav.vue | +1 行菜单(审稿回复助手 💬) |
|
||||
| ui/src/components/edu/EduWorkspace.vue | +import +panelMap 挂载 |
|
||||
| tests/frontend/unit/edu/rebuttal.panel.test.js | 新增(5 例) |
|
||||
| docs/pr-reports/2026-10-12-rebuttal-phase1.md | 本报告 |
|
||||
|
||||
## git status --short(新增部分)
|
||||
|
||||
```
|
||||
?? docs/pr-reports/2026-10-12-rebuttal-phase1.md
|
||||
?? review_service/app/routers/rebuttal.py
|
||||
?? review_service/app/services/rebuttal_draft.py
|
||||
?? review_service/app/services/rebuttal_parse.py
|
||||
?? review_service/app/services/rebuttal_store.py
|
||||
?? review_service/tests/test_rebuttal.py
|
||||
?? tests/frontend/unit/edu/rebuttal.panel.test.js
|
||||
?? ui/src/components/edu/panels/RebuttalPanel.vue
|
||||
M review_service/app/main.py
|
||||
M ui/src/api/review.js
|
||||
M ui/src/components/edu/EduSideNav.vue
|
||||
M ui/src/components/edu/EduWorkspace.vue
|
||||
M ui/src/stores/edu.js
|
||||
(此前 AI 预审稿修复的未 commit 改动保持原样,未混入本报告范围)
|
||||
```
|
||||
|
||||
## 已知边界(Phase 1 明确不做)
|
||||
|
||||
- docx 导出(Phase 2,python-docx 已在 lock 备用);
|
||||
- job TTL 自动清理(决策 5:手动 DELETE 替代);
|
||||
- 跨 Reviewer 合并(merge 限同 Reviewer 内);
|
||||
- 刷新页面后 job 恢复(纯内存态;GET /jobs/{id} 已备,Phase 2 可接列表入口)。
|
||||
@@ -0,0 +1,111 @@
|
||||
# PR 报告:P3b 编译 PDF Tab(论文重排版面板)
|
||||
|
||||
- 日期:2026-10-13
|
||||
- 分支:`feature/p3b-compile-ui`(自最新 main 新开;未 commit,按用户约定统一处理)
|
||||
- 范围:`ui/` 面板 + store/api 扩展;`review_service/` 仅 restart.sh 调起预热脚本 + 新增
|
||||
scripts/prewarm_tectonic.sh。**后端 compile.py / latex_compile.py(P3a)零改动**;
|
||||
无 sdk/、agent/、vstore/ 改动;无新增依赖。
|
||||
|
||||
## 概要
|
||||
|
||||
P3b:论文重排版面板启用"编译 PDF" Tab,实现"上传 .tex → 服务器编译 → 下载 PDF"的完整闭环。
|
||||
|
||||
## 修改内容
|
||||
|
||||
| 文件 | 变更 |
|
||||
|---|---|
|
||||
| ui/src/components/edu/panels/ReformatPanel.vue | 编译 Tab 状态机:无 job_id 引导 / 编译按钮 / loading 动画 + 首次 1-3 分钟黄条(可关闭)/ 成功 blob URL iframe 预览 + 下载 + 耗时 + 重新编译 / 失败错误摘要 + log 尾 200 行折叠 + 完整 log 下载;按钮不 await,状态全由 store 驱动(切 Tab/切面板编译继续) |
|
||||
| ui/src/stores/edu.js | compileStatus/compileResult/compileError/compileLogTail + compilePdfUrl(blob URL,fetch 失败静默回退 pdf_url 直链)+ compileFirstHint;submitCompile(loading 重入保护)/ clearCompile(revoke blob);重新排版自动 clearCompile |
|
||||
| ui/src/api/review.js | CODE_HINTS 增 QUEUE_FULL 固定文案"编译队列已满(最多 5 个任务),请稍后重试"(后端 message 带动态 pending 数,规范统一口径);compileTex() 为 P3a 已有封装 |
|
||||
| review_service/restart.sh | 启动后后台调起 prewarm_tectonic.sh(nohup 非阻塞,日志 prewarm.log) |
|
||||
| review_service/scripts/prewarm_tectonic.sh(新增) | 最小 hello.tex 实编译以缓存 TeX 环境。**注**:tectonic v0.17.0 无 `download` 子命令(实测 `no subcommand download`),改用 tools/README 的最小 .tex 预编译法;失败仅告警不影响服务启动 |
|
||||
| tests/frontend/unit/edu/reformat.compile.test.js(新增) | 6 个用例(真实 reviewErrorMessage 经 importOriginal 保留,QUEUE_FULL 走 CODE_HINTS) |
|
||||
|
||||
## 关键实现决策
|
||||
|
||||
1. **无异步轮询**:后端编译同步阻塞返回(单槽队列,常规 10-30s,上限 `compile_queue_limit=5`),
|
||||
无 stage 可显示 → loading 只做不确定时长旋转动画 + 文案说明。
|
||||
2. **QUEUE_FULL**:HTTP 429 + 同款信封,http.js 抛 ApiError(code=QUEUE_FULL);
|
||||
前端 CODE_HINTS 固定文案,报错后"重新编译"按钮立即可用(store finally 解锁)。
|
||||
3. **PDF blob URL**:成功后异步 `fetch(pdf_url) → blob → createObjectURL`,
|
||||
预览/下载不暴露后端路径;jsdom/网络失败回退 pdf_url 直链,不阻塞渲染。
|
||||
4. **失败 log**:信封 detail = log 尾 200 行(折叠 `<pre>`);完整 log 下载兜底拼
|
||||
`/api/review/compile/{job_id}/log`(失败信封不带 log_url,但 job_id 即 anchor_id)。
|
||||
5. **首次编译提示条**:`compileFirstHint` 在首次 submitCompile 置位,loading 期间显示,
|
||||
可手动关闭;`clearCompile`(重新排版)复位。
|
||||
6. **预热命令修正**:任务原文 `./tools/tectonic -X download default` 在钉死版本
|
||||
v0.17.0 不存在——实测后改为最小 .tex 实编译预热(同 tools/README.md 建议)。
|
||||
|
||||
## 测试结果
|
||||
|
||||
- 后端 pytest(review_service,后端未动):**113 passed**
|
||||
- 前端 vitest 全量:**403 passed / 5 failed**(5F = 既有 labels.js 基线失败
|
||||
benchpanel×4 + Composer pill×1,与本 PR 无关)
|
||||
- vitest 新增 reformat.compile.test.js:**6/6 passed**
|
||||
(无 job_id 引导 / 按钮可点 / 编译中+黄条+重入保护 / 成功 iframe+下载+耗时 /
|
||||
失败 log 折叠 / QUEUE_FULL 文案+重试可用)
|
||||
- npm run build:✓ 3.12s
|
||||
|
||||
## 手工冒烟(8100 真实链路)
|
||||
|
||||
- reformat /tmp/opencode/reformat_sample.tex → ok=True job_id=b3631727af68
|
||||
- POST /compile {job_id=b3631727af68} → ok=True seconds=0.61 pdf_bytes=3596
|
||||
- GET /compile/{cid}/pdf → 200,3596 bytes,application/pdf(file: PDF 1.5)
|
||||
- GET /compile/{cid}/log → 200,3533 bytes
|
||||
- job_id 不存在 / 格式非法 → VALIDATION 信封
|
||||
- 坏 tex(\thisCommandDoesNotExist)→ COMPILE_FAILED + detail 尾 200 行
|
||||
(含 "! Undefined control sequence")
|
||||
- QUEUE_FULL → 429 + 信封(后端路径实测于 P3a;前端文案 vitest 6/6 覆盖)
|
||||
- scripts/prewarm_tectonic.sh → exit=0 OK,宏包缓存就绪
|
||||
|
||||
## EPS 兼容性修复 v2(2026-10-14 追加)
|
||||
|
||||
背景:真实 MDPI 官方 zip(v6.5a)上传编译在 XDV→PDF 阶段失败:
|
||||
`xdvipdfmx: image inclusion failed for "Definitions/logo-mdpi.eps"`。
|
||||
v1 回退逻辑 gate 在"zip 自带 `*-eps-converted-to.pdf` 预转换版"——真实官方 zip
|
||||
**不带**该文件(v1 本地 e2e 通过是因自造样本带了;测试样本与真实样本不一致,教训已记录)。
|
||||
|
||||
v2 方案:**不依赖扫描引用与 zip 自带预转换版**,解压后扫描项目内所有 `.eps`
|
||||
批量现场转同名 `.pdf`(`latex_compile.process_eps_images`):
|
||||
|
||||
1. 工具优先级 **gs → epstopdf → ps2pdf**(`shutil.which` 逐个 fallback);gs 命令
|
||||
约束1 强制 `-dSAFER -dNOPAUSE -dBATCH -sDEVICE=pdfwrite -dPDFSETTINGS=/prepress`
|
||||
(-dSAFER 防 EPS 内嵌任意 PostScript 执行);无新 Python 依赖(subprocess)
|
||||
2. 约束2:单文件转换 **15s 超时** → warning 行 `[eps-convert] xxx.eps 转换超时`
|
||||
并跳过该文件继续
|
||||
3. 约束3:单文件转换后 >20MB → warning;累计转换产物 >100MB →
|
||||
`InspectError("ZIP_TOO_LARGE")` 中止(信封原样透出该 code)
|
||||
4. 约束4:`[eps-convert] {src} → {dst} ({in} → {out}, {elapsed}s, {tool})`
|
||||
写入 `output.log`(正常/超时两条路径都落盘,引擎 log 之后、chatter 之前)
|
||||
5. 转换成功 → 显式 `X.eps` 引用字节替换为 `X.pdf`(.tex/.cls/.sty/.cfg/.def 全扫,
|
||||
不碰编码、不改正文);无扩展名引用 `{.../X}` 由 graphicx 命中新 `X.pdf`
|
||||
(解析顺序 .pdf→…→.eps,规避 .eps)
|
||||
6. 全工具失败 → log 行 `[eps-convert] 全部工具失败,图片 xxx.eps 未转换` →
|
||||
编译自然失败 → 前端提示「项目包含 EPS 图片,服务器转换失败,请转成 PDF/PNG 后重试」
|
||||
|
||||
真实官方 zip 实测(`scripts/debug_compile.py` 重打包 fba37ae3c853 现场 + 8100 HTTP 链路):
|
||||
|
||||
| 指标 | 结果 |
|
||||
|---|---|
|
||||
| 转换 EPS 数 | 2(logo-mdpi.eps、logo-updates.eps,均 gs) |
|
||||
| 每个耗时 | 563678→13057B 0.27s;287998→16588B 0.27s(合计 0.54s) |
|
||||
| 显式引用改写 | 1 个文件(Definitions/mdpi.cls) |
|
||||
| 编译 | 2.83s(debug 脚本)/ 2.99s(HTTP) |
|
||||
| 产出 PDF | 70688 / 70687 bytes,7 页,`%PDF-` ✓ |
|
||||
|
||||
测试:pytest **175 passed**(原 169 全保留,移除 3 个 v1 语义已变的 EPS 用例,
|
||||
新增 9 个 v2 用例:preset 免转换 / 真实 gs 转换 / 工具 fallback / 全失败 /
|
||||
超时 warning / 20MB warning / 100MB 中止 / 端到端成功 / 友好报错)。
|
||||
|
||||
教训(关键):**外部下载的模板类场景,e2e 必须用真实官方文件**——v1 的自造样本
|
||||
带了预转换版,真实官方 zip 不带,导致修复上线后失效。
|
||||
|
||||
## 部署验证(部署后执行)
|
||||
|
||||
```bash
|
||||
bash review_service/restart.sh
|
||||
# 观察日志:prewarm_tectonic.sh 后台跑(非阻塞),prewarm.log 出现 "[prewarm] OK"
|
||||
# 浏览器 → 教育科研 → 论文自动重排版 → 上传 .tex → 选期刊 → 重排版
|
||||
# → 切"编译 PDF" → 点编译
|
||||
# 期望:首次 1-3 分钟(若预热已完成则秒级)、后续秒回;失败可见 log 尾 200 行
|
||||
```
|
||||
@@ -0,0 +1,131 @@
|
||||
# P5 科研汇报助手 — PR 报告(2026-10-14,v4 更新 2026-10-15)
|
||||
|
||||
## 概要
|
||||
|
||||
新增「科研汇报助手」(P5):论文 PDF/粘贴文本 → vstore 解析 → map-reduce LLM 大纲
|
||||
(5 场景 × 4 时长 × **14 版式**,逐条证据可溯)→ 逐页编辑 → python-pptx 本地生成
|
||||
16:9 PPTX 初稿(论文真图卡片化嵌入、分隔页主副标题、讲者备注携带证据引用)。
|
||||
全程只新增 `review_service/` 内代码 + BFF 既有 `REVIEW_ENABLED` 条件反代,
|
||||
禁区(RAG 核心、既有服务、前端 router/db)零改动。
|
||||
|
||||
**v4 迭代(2026-10-15)**:① 副标题风格数改为动态读取 `GET /ppt/styles`(缺数据回落
|
||||
「多风格·图文混排」)② 章节分隔页加副标题(prompt 强制 `subtitle`、渲染 40pt 主标题 +
|
||||
20pt muted 副标题 + 底部 accent 装饰线、编辑器可改)③ 论文白底原图与页面底色融合
|
||||
(surface_2 圆角卡片 + 图注 + 宽/高上限 + 单图页居中留白)。
|
||||
|
||||
## 修改内容
|
||||
|
||||
### 后端(`review_service/`)
|
||||
|
||||
| 类别 | 文件 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 改 | `app/config.py` | +`ppt_jobs_dir` / `ppt_styles_dir` 及 abs 属性(+14 行) |
|
||||
| 改 | `app/main.py` | 导入 ppt 路由、lifespan `ppt_ensure_table()`、挂载(+8 行) |
|
||||
| 改 | `tooling/requirements.lock` | **+3 行**:`pillow==12.3.0` / `python-pptx==1.0.2` / `xlsxwriter==3.2.9`(三者均已实装于统一 venv) |
|
||||
| 改 | `.gitignore` | +9 行:`ppt_jobs/` 运行数据、`prewarm.log`、`data/compile_jobs/`、`scripts/`、`data/review.db` |
|
||||
| 新 | `app/routers/ppt.py`(317 行) | **9 端点**:`GET /styles`、`POST /jobs`、`POST /jobs/pdf`、`GET/PATCH /jobs/{id}`、`POST /jobs/{id}/build`、`GET /jobs/{id}/pptx`、`GET /jobs/{id}/preview/{page}`、`DELETE /jobs/{id}` |
|
||||
| 新 | `app/services/ppt_outline.py`(917 行) | map-reduce 大纲核心(索引/配额/重验/裁剪/膨胀回修/分隔页 subtitle 规则) |
|
||||
| 新 | `app/services/ppt_build.py`(1176 行) | python-pptx 生成器(14 版式 token、字体栈、真图卡片容器、分隔页主副标题、讲者备注) |
|
||||
| 新 | `app/services/ppt_figure_index.py`(264 行) | 论文真图索引(para/图号 → 图片落盘与状态) |
|
||||
| 新 | `app/services/ppt_preview.py`(230 行) | 逐页 PNG 预览(供面板缩略图,失败不阻断) |
|
||||
| 新 | `app/services/ppt_store.py`(155 行) | `ppt_jobs` SQLite 持久化(约定 DDL 逐字实现) |
|
||||
| 新 | `data/ppt_styles/*.json`(14 个) | 版式 token 源码资产(随 git 走,`GET /styles` 的唯一数据源) |
|
||||
| 新 | `tests/test_ppt_*.py`(6 个 / 102 例) | outline / build / figure_index / preview / router / store |
|
||||
|
||||
### 前端(`ui/` + `tests/`)
|
||||
|
||||
| 类别 | 文件 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 改 | `ui/src/api/review.js`(+50 行) | PPT API 段 + `pptStyles()` |
|
||||
| 改 | `ui/src/stores/edu.js`(+147 行) | `ppt` 状态块 + actions + `pptLoadStyles` + `SECTIONS` 增列 |
|
||||
| 改 | `ui/src/components/edu/EduSideNav.vue` / `EduWorkspace.vue` | 菜单项 + PptPanel 注册(各 +2 行) |
|
||||
| 新 | `ui/src/components/edu/panels/PptPanel.vue`(578 行) | 生成表单 + 编辑器(动态风格数、分隔页副标题输入、逐页预览) |
|
||||
| 新 | `ui/src/components/edu/panels/ppt-styles.css`(40 行) | 面板样式 |
|
||||
| 新 | `ui/src/assets/ppt-style-cards/*.png`(14 个,约 21KB/个) | 版式卡面截图 |
|
||||
| 新 | `tests/frontend/unit/edu/ppt.panel.test.js`(12 例)/ `ppt.store.test.js`(7 例) | P5 前端单测 |
|
||||
| 改 | `tests/frontend/unit/b2/components.test.js`、`b3/benchpanel.test.js`、`bioflow/benchpanel.bioflow.test.js`、`tests/e2e/bench.spec.js` | **预存失败清零**:① tab 文案「提取任务」→「上传文献」(对齐 `51532a3`)② 模式 pill 断言改为「已随 `43a7c6c` 移除」 |
|
||||
|
||||
**依赖偏差声明**:`python-pptx==1.0.2`(PyPI 最高 1.0.2;需求中 1.1.2 实为
|
||||
python-docx 版本,系记混,已在实现时更正)。`xlsxwriter==3.2.9` 当前无代码直接引用,
|
||||
属统一 venv `pip freeze` 的真实记录,一并同步锁文件——**合并前仍需人工确认这 3 行锁变更**。
|
||||
|
||||
**DDL**(与约定逐字一致):
|
||||
|
||||
```sql
|
||||
ppt_jobs(id, status /*outline_ready|building|done|error*/, source_kind, filename,
|
||||
raw_text, scenario, minutes, style_id, pages_json, pptx_path,
|
||||
warnings_json, created_at, updated_at)
|
||||
```
|
||||
|
||||
**API 前缀** `/api/review/ppt`(9 端点;业务错误一律 200 信封
|
||||
`{ok:false, error:{code,message,degraded}}`;二进制下载缺失时回同款信封 JSON)。
|
||||
|
||||
### 关键渲染约定(v4)
|
||||
|
||||
- **分隔页**:`section_bg` 底(缺则 `surface_2`→`cover_bg`→`bg`),40pt 居中粗体主标题 +
|
||||
20pt muted 副标题(`bullets[0]` → `subtitle` → 该章第一页 title 三级兜底,浅底自动换深字)+
|
||||
底部 30% 宽 accent 短线。
|
||||
- **真图卡片**:`surface_2` 圆角矩形(8px)容器 + 5mm 内边距,图居中、图注
|
||||
`Fig N(论文 S?P?)` 10–12pt muted、与图区留 3mm;混排页图区 ≤55% 页宽且 ≤60% 页高,
|
||||
单独图页居中、上下留白 ≥5%。
|
||||
|
||||
## 测试结果(2026-10-15)
|
||||
|
||||
```
|
||||
pytest(review_service 全库): 215 passed in 13.7s (P5 新增 102 → 113 + 102)
|
||||
vitest edu(tests/frontend/unit/edu): 43 passed (P5 新增 19:panel 12 + store 7)
|
||||
vitest 全量(tests/frontend/unit): 32 files / 421 passed / 0 failed
|
||||
—— 修掉 b2/b3/bioflow 共 5 个预存失败(文案改名 + 模式按钮移除)
|
||||
npm run build: ✓ built(chunk > 500kB 警告为预存)
|
||||
版式目检: 3 风格 × 10 页预览 PNG 渲染核对(分隔页主副标题/对比度回退、
|
||||
混排图卡 ≤55%、单独图页居中留白)均符合预期
|
||||
```
|
||||
|
||||
## 真实 e2e(v1 阶段记录:官方 MDPI 模板实例 PDF,16813 字符,真实 DeepSeek)
|
||||
|
||||
```
|
||||
PDF 模式创建: 21.77s → 12 页(目标 [11,14])warnings=0 llm_calls=7 unverified_evidence=0
|
||||
PATCH 编辑: 改写要点 + 新增无证据行 → evidence 同长重验、结构清洗 ✓
|
||||
build: 0.13s → 12 slides / 64157 B / 16:9(13.33×7.50in)逐页讲者备注 ✓
|
||||
python-pptx 回读: 12 页,S3 首条 = 人工改写文本 ✓
|
||||
编辑后下载: ok=false NOT_FOUND(盘上旧文件存在但拒发,pptx_path 清空生效)✓
|
||||
text 模式: 15.0s → 9 页(目标 [8,10])group_meeting / 10min / botanic-forest ✓
|
||||
短文本: 200 信封 VALIDATION「正文过短(<300 字),无法生成汇报大纲」✓
|
||||
清理: 双 DELETE → ppt_jobs 0 行、产物目录空 ✓
|
||||
```
|
||||
|
||||
## 部署验证步骤
|
||||
|
||||
1. 依赖:同步 `tooling/requirements.lock`(+3 行)并安装到统一 venv。
|
||||
2. 重启服务:`cd review_service && bash restart.sh`(8100)。
|
||||
3. 健康检查:`curl http://127.0.0.1:8100/health` → `status=ok`、`vstore connected=true`。
|
||||
4. 路由:`curl .../openapi.json` 确认 9 个 `/api/review/ppt/*` 路径在列(含 `/styles`)。
|
||||
5. BFF:`sdk/.env` 确认 `REVIEW_ENABLED=1`,走 8090 反代冒烟
|
||||
`POST /api/review/ppt/jobs`(短文本应得 200 VALIDATION 信封,不打 LLM)。
|
||||
6. 真实链路:上传一篇论文 PDF → 出大纲 → 编辑分隔页副标题并保存 → build → 下载 PPTX →
|
||||
用 python-pptx 核对页数、16:9 尺寸与分隔页副标题文本。
|
||||
7. 回归:`cd review_service && <venv>/bin/python -m pytest -q`;
|
||||
`cd ui && npx vitest run` + `npm run build`。
|
||||
|
||||
## 部署决策
|
||||
|
||||
**建议:只合并,下次批量部署。** 本次改动对存量服务零影响(纯新增模块 +
|
||||
开关化反代),无数据迁移、无 schema 破坏性变更;单点重启 8100 即可生效,
|
||||
不值得为它单独发版,随下一次批量部署一并上即可。
|
||||
|
||||
## 风险与回滚
|
||||
|
||||
| 风险 | 影响 | 缓解 / 回滚 |
|
||||
| --- | --- | --- |
|
||||
| `python-pptx==1.0.2` 版本与需求(1.1.2)不一致 | 依赖锁未按原文落库 | 已声明偏差,合并前人工确认 3 行锁变更;如需回退直接还原 lock |
|
||||
| `xlsxwriter==3.2.9` 无代码引用 | 锁文件带入未用依赖 | venv 实况记录,可保留;若要求最小化可单独剔除该行 |
|
||||
| LLM 大纲偶发超时/无效 JSON | 创建接口报 `LLM_ERROR`/`LLM_INVALID` | 已分片 reduce + 单次膨胀回修 + 警告降级;前端可重试,无脏数据落库 |
|
||||
| quote 反查失败残留 evidence(2 条) | 个别要点 `unverified` | **Phase 2**:最长公共子串(≥20 字符)匹配补 `para_id`,失败保持 unverified(边际价值低,v4 不做) |
|
||||
| 编辑后旧 PPTX 残留盘上 | 无(`pptx_path` 清空后拒发,仅占磁盘) | 删除 job 时 `rmtree` 一并清理 |
|
||||
| 回滚 | — | 反向还原 12 个已跟踪文件改动 + 删除 44 个新增文件(后端 26 + 前端 18)即可;`ppt_jobs` 表与产物目录可留可删,不被其他模块引用 |
|
||||
|
||||
## 与 `feature/p3b-compile-ui#4f9c077` 的关系
|
||||
|
||||
v1 版报告曾以同一路径提交于该分支(`docs: add P5 PR report`),内容停留在 3 版式 /
|
||||
7 端点 / 208 pytest 阶段。本文件为 v4 权威版本;若两分支先后合入,同路径会产生一次
|
||||
文本冲突,取本文件(v4)内容即可。
|
||||
@@ -0,0 +1,13 @@
|
||||
Reviewer #1:
|
||||
|
||||
1. The GWAS analysis lacks multiple-testing correction. Please re-run with FDR control and report q-values for all significant loci. In addition, the sample size justification is missing — please add a power analysis or at least discuss the detectable effect size.
|
||||
|
||||
2. Figure 3 is blurry and the axis labels are unreadable. As noted in Comment 3, the same issue applies to Figure S2. Please replace all figures with high-resolution versions.
|
||||
|
||||
3. Minor: please add the standard error estimates to Supplementary Table 2.
|
||||
|
||||
Reviewer #2:
|
||||
|
||||
1. The claim of novelty overstates the advance relative to Zhang et al. 2023. The introduction should more clearly delineate what is new.
|
||||
|
||||
2. How were the batch effects between the two sequencing centers corrected? This is essential to support the conclusions.
|
||||
@@ -0,0 +1,27 @@
|
||||
# review_service/.env.example —— 运行时配置模板
|
||||
# 用法: cp .env.example .env 后填写(.env 已被仓库 .gitignore 忽略,勿提交)
|
||||
# restart.sh 会 `set -a; source .env` 导出给 uvicorn 进程。
|
||||
|
||||
# ---- LLM(DeepSeek;REVIEW_LLM_* 优先 → DOCGEN_LLM_* 回退 → 内置默认,见 services/llm.py)----
|
||||
# 最简做法:从部署树抄一行即可复用同一把 key:
|
||||
# grep '^DOCGEN_LLM_API_KEY=' /home/xzq/5090/teamWorkspace/zh/PlantAI-rag-new/sdk/.env >> .env
|
||||
REVIEW_LLM_BASE_URL=https://api.deepseek.com/v1
|
||||
REVIEW_LLM_API_KEY=
|
||||
REVIEW_LLM_MODEL=deepseek-chat
|
||||
#REVIEW_LLM_TIMEOUT=300
|
||||
|
||||
# ---- 上游(只读消费)----
|
||||
#REVIEW_VSTORE_BASE_URL=http://127.0.0.1:8094
|
||||
#REVIEW_METHODOLOGY_BASE_URL=http://127.0.0.1:8092 # 预留(MinerU 重链路,当前未用)
|
||||
|
||||
# ---- PDF 解析(经 vstore)----
|
||||
#REVIEW_PARSE_TIMEOUT=180 # ingest→取文→清理 总读超时(秒)
|
||||
#REVIEW_PARSE_DELETE_AFTER=1 # 1=解析后删除 vstore 临时文档(0=保留便于排查)
|
||||
|
||||
# ---- 自审稿 ----
|
||||
#REVIEW_PRESUBMIT_READ_TIMEOUT=360 # LLM 长调用读超时(秒)
|
||||
#REVIEW_MAX_TEXT_CHARS=60000 # 送 LLM 的正文截断上限
|
||||
#REVIEW_MAX_PDF_BYTES=52428800 # 上传 PDF 上限 50MB
|
||||
|
||||
# ---- 服务(restart.sh 读取)----
|
||||
#REVIEW_BIND_HOST=127.0.0.1
|
||||
@@ -0,0 +1,7 @@
|
||||
"""review_service —— 科研投稿与智能审稿助手(8100)。
|
||||
|
||||
独立部署树(模式同 paper_writer_service 8097 / bioflow_service 8098):
|
||||
- BFF(sdk 8090) 经 routers/review_proxy.py 条件反代 /api/review/*(REVIEW_ENABLED)。
|
||||
- 上游只读消费:vstore 8094(PDF 解析)、DeepSeek(LLM,REVIEW_LLM_*/DOCGEN_LLM_*)。
|
||||
- 约束见根目录 AGENTS.md:审稿相关新代码只允许落在本目录。
|
||||
"""
|
||||
@@ -0,0 +1 @@
|
||||
"""review 服务(8100)应用包。"""
|
||||
@@ -0,0 +1,154 @@
|
||||
"""config.py —— review 服务(8100)环境变量驱动配置(pydantic-settings)。
|
||||
|
||||
全部键可用环境变量覆盖(前缀 REVIEW_)。LLM 沿用 docgen 的裸键模式:
|
||||
优先 REVIEW_LLM_*,回退 DOCGEN_LLM_*(与 paper_writer_service 共享同一套
|
||||
DeepSeek 凭据,由 services/llm.py 直接读 os.environ)。零硬编码宿主路径。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
env_prefix="REVIEW_",
|
||||
)
|
||||
|
||||
app_name: str = "plantai-review-service"
|
||||
version: str = "0.1.0"
|
||||
|
||||
# ---- 上游(只读消费;不修改 vstore/methodology 任何代码)----
|
||||
# vstore 8094:POST /api/vstore/ingest + GET/DELETE /api/vstore/documents/{id}
|
||||
# (pymupdf 段落解析,同步秒级;methodology 8092 的 MinerU 链路是
|
||||
# 分钟级异步 job,不适配同步自审稿,仅预留地址供后续扩展)
|
||||
vstore_base_url: str = "http://127.0.0.1:8094"
|
||||
methodology_base_url: str = "http://127.0.0.1:8092"
|
||||
|
||||
# ---- PDF 解析 ----
|
||||
parse_timeout: float = 180.0 # vstore ingest→fetch→delete 读超时(秒)
|
||||
parse_delete_after: bool = True # 解析完成后删除 vstore 临时文档(不污染私有库)
|
||||
|
||||
# ---- 自审稿(presubmit)----
|
||||
presubmit_read_timeout: float = 360.0 # LLM 长调用读超时(秒)
|
||||
max_text_chars: int = 150000 # 送 LLM 的正文截断上限(REVIEW_MAX_TEXT_CHARS 覆盖;
|
||||
# token 超 48K 自动降 100K,LLM 400/413 再降 80K 重试)
|
||||
max_pdf_bytes: int = 50 * 1024 * 1024 # 上传 PDF 上限 50MB(对齐 extraction FR-EXTRACT-004)
|
||||
|
||||
# ---- 期刊情报与投稿推荐(recommend)----
|
||||
db_path: str = "review.db" # 独立 SQLite(相对 review_service/;REVIEW_DB_PATH 覆盖)
|
||||
journals_seed_path: str = "data/journals/journals.json" # 种子(相对 review_service/)
|
||||
journal_directory_seed_path: str = "data/journals/source_v4.xlsx" # 目录源 xlsx(相对 review_service/)
|
||||
seed_on_startup: bool = True # 启动自动 seed journal_directory(空表才导;false 关闭)
|
||||
vstore_match_timeout: float = 30.0 # scope 入库/向量检索读超时(秒)
|
||||
recommend_read_timeout: float = 60.0 # LLM 重排读超时(秒)
|
||||
recommend_query_chars: int = 3000 # 送 vstore 的检索 query 截断(防嵌入模型超长)
|
||||
recommend_scope_top_k: int = 10 # search-chunks top_k(vstore 上限 20)
|
||||
|
||||
# ---- 论文重排版(reformat,P1:.tex 源码级变换,不编译)----
|
||||
max_tex_bytes: int = 2 * 1024 * 1024 # 上传 .tex 上限 2MB(PDF 的 50MB 不适用)
|
||||
reformat_dir: str = "data/reformat_jobs" # 产物落盘(相对 review_service/)
|
||||
|
||||
# ---- LaTeX 编译 PDF(compile,P3:tectonic 单文件二进制)----
|
||||
tectonic_path: str = "tools/tectonic" # 相对 review_service/;也可绝对路径
|
||||
tectonic_cache_dir: str = "data/tectonic-cache" # 宏包缓存(部署时预热,运行期只读命中)
|
||||
compile_timeout_s: float = 60.0 # 单次编译墙钟上限,超时整进程组 kill
|
||||
max_compile_pdf_bytes: int = 50 * 1024 * 1024 # 产物 PDF 上限 50MB
|
||||
compile_queue_limit: int = 5 # 等待槽位上限(>5 → QUEUE_FULL 429)
|
||||
compile_retention_days: int = 7 # 编译产物保留天数(后台清扫)
|
||||
|
||||
# ---- LaTeX 项目 zip 编译(P4:两段式 inspect + run)----
|
||||
max_zip_bytes: int = 50 * 1024 * 1024 # 上传 zip 上限 50MB
|
||||
max_extract_bytes: int = 200 * 1024 * 1024 # 单项目解压总大小硬顶 200MB(实写字节计数)
|
||||
max_zip_entries: int = 2000 # 条目数上限(预扫第一件事)
|
||||
max_zip_entry_bytes: int = 50 * 1024 * 1024 # 单条目解压上限 50MB
|
||||
max_zip_ratio: float = 100.0 # 单条目压缩比 file_size/compress_size 上限
|
||||
max_zip_path_depth: int = 16 # 路径深度上限(深层嵌套拒绝)
|
||||
max_zip_path_len: int = 200 # 单条目路径长度上限
|
||||
compile_project_timeout_s: float = 120.0 # 项目 zip 编译墙钟上限(单文件仍 compile_timeout_s)
|
||||
inspect_dir: str = "data/compile_inspect" # zip 解压暂存(1 小时 TTL,相对 review_service/)
|
||||
inspect_ttl_s: int = 3600 # inspect 暂存过期秒数
|
||||
inspect_sweep_interval_s: float = 300.0 # inspect 后台清扫周期(lifespan 定时任务)
|
||||
compile_jobs_dir: str = "data/compile_jobs" # 项目编译产物(pdf/log/meta,7 天 sweep)
|
||||
failed_project_ttl_s: int = 24 * 3600 # 编译失败保留 project/ 现场秒数(诊断用)
|
||||
keep_failed_projects: bool = False # 失败 project/ 永久保留(调试开关;
|
||||
# 亦读裸环境变量 KEEP_FAILED_PROJECTS=1)
|
||||
|
||||
# ---- 科研汇报 PPT(P5:map-reduce 大纲 + python-pptx 初稿)----
|
||||
ppt_jobs_dir: str = "data/ppt_jobs" # 产物(draft.pptx + meta,相对 review_service/)
|
||||
ppt_styles_dir: str = "data/ppt_styles" # 风格 tokens JSON(源码资产,随 git 走)
|
||||
|
||||
@property
|
||||
def _service_root(self) -> "Path":
|
||||
# review_service/app/config.py → review_service/(相对本文件定位,不依赖 cwd)
|
||||
return Path(__file__).resolve().parent.parent
|
||||
|
||||
@property
|
||||
def db_path_abs(self) -> "Path":
|
||||
# 绝对 db_path 会被 Path 除法原样返回;相对路径落在 review_service/ 下
|
||||
return self._service_root / self.db_path
|
||||
|
||||
@property
|
||||
def journals_seed_abs(self) -> "Path":
|
||||
return self._service_root / self.journals_seed_path
|
||||
|
||||
@property
|
||||
def journal_directory_seed_abs(self) -> "Path":
|
||||
return self._service_root / self.journal_directory_seed_path
|
||||
|
||||
@property
|
||||
def reformat_dir_abs(self) -> "Path":
|
||||
p = Path(self.reformat_dir)
|
||||
return p if p.is_absolute() else self._service_root / p
|
||||
|
||||
@property
|
||||
def ppt_jobs_abs(self) -> "Path":
|
||||
p = Path(self.ppt_jobs_dir)
|
||||
return p if p.is_absolute() else self._service_root / p
|
||||
|
||||
@property
|
||||
def ppt_styles_abs(self) -> "Path":
|
||||
p = Path(self.ppt_styles_dir)
|
||||
return p if p.is_absolute() else self._service_root / p
|
||||
|
||||
@property
|
||||
def tectonic_abs(self) -> "Path":
|
||||
p = Path(self.tectonic_path)
|
||||
return p if p.is_absolute() else self._service_root / p
|
||||
|
||||
@property
|
||||
def tectonic_cache_abs(self) -> Path:
|
||||
p = Path(self.tectonic_cache_dir)
|
||||
return p if p.is_absolute() else self._service_root / p
|
||||
|
||||
@property
|
||||
def inspect_dir_abs(self) -> Path:
|
||||
p = Path(self.inspect_dir)
|
||||
return p if p.is_absolute() else self._service_root / p
|
||||
|
||||
@property
|
||||
def compile_jobs_dir_abs(self) -> Path:
|
||||
p = Path(self.compile_jobs_dir)
|
||||
return p if p.is_absolute() else self._service_root / p
|
||||
|
||||
|
||||
@property
|
||||
def vstore_base(self) -> str:
|
||||
return self.vstore_base_url.rstrip("/")
|
||||
|
||||
@property
|
||||
def methodology_base(self) -> str:
|
||||
return self.methodology_base_url.rstrip("/")
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
@@ -0,0 +1,191 @@
|
||||
"""app/db.py —— review 服务独立 SQLite(aiosqlite + WAL,无 ORM):journals 表。
|
||||
|
||||
- 单连接 + asyncio 写锁(模式对齐 paper_writer_service/bioflow,但表结构与种子
|
||||
逻辑为本模块自研);路径默认 review_service/review.db(REVIEW_DB_PATH 覆盖)。
|
||||
- 种子:data/journals/journals.json → journals 表(幂等 upsert,vstore_doc_id 列
|
||||
永不被种子覆盖——该列记录 scope_desc 在 vstore 8094 的 document_id)。
|
||||
- 只在 review_service/ 内使用,不触碰 BFF/生产服务数据库。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
import aiosqlite
|
||||
|
||||
from .config import get_settings
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS journals (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
scope_desc TEXT NOT NULL,
|
||||
impact_factor REAL,
|
||||
review_time_weeks REAL,
|
||||
acceptance_rate REAL,
|
||||
submission_deadline TEXT,
|
||||
official_url TEXT,
|
||||
last_updated TEXT NOT NULL,
|
||||
vstore_doc_id TEXT
|
||||
);
|
||||
"""
|
||||
|
||||
# 种子 upsert 列(刻意不含 vstore_doc_id:种子刷新不得清掉向量库映射)
|
||||
_SEED_COLS = (
|
||||
"id",
|
||||
"name",
|
||||
"scope_desc",
|
||||
"impact_factor",
|
||||
"review_time_weeks",
|
||||
"acceptance_rate",
|
||||
"submission_deadline",
|
||||
"official_url",
|
||||
"last_updated",
|
||||
)
|
||||
_UPSERT_SQL = (
|
||||
"INSERT INTO journals (" + ", ".join(_SEED_COLS) + ") VALUES ("
|
||||
+ ", ".join("?" for _ in _SEED_COLS) + ") "
|
||||
+ "ON CONFLICT(id) DO UPDATE SET "
|
||||
+ ", ".join(f"{c}=excluded.{c}" for c in _SEED_COLS if c != "id")
|
||||
)
|
||||
|
||||
|
||||
class SeedError(Exception):
|
||||
"""种子文件缺失/损坏(journals.json 不可读)。"""
|
||||
|
||||
|
||||
class Database:
|
||||
"""aiosqlite 单连接封装(WAL;写串行化)。"""
|
||||
|
||||
def __init__(self, path: str | Path | None = None) -> None:
|
||||
self._path: Path | None = Path(path) if path is not None else None
|
||||
self._conn: aiosqlite.Connection | None = None
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
@property
|
||||
def path(self) -> Path:
|
||||
if self._path is None:
|
||||
self._path = get_settings().db_path_abs
|
||||
return self._path
|
||||
|
||||
def set_path(self, path: str | Path) -> None:
|
||||
"""测试注入:在进入 TestClient lifespan 之前预置库路径(须尚未 connect)。"""
|
||||
if self._conn is not None:
|
||||
raise RuntimeError("Database 已连接,无法再改路径(先 close_db())")
|
||||
self._path = Path(path)
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
return self._conn is not None
|
||||
|
||||
async def connect(self) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._conn = await aiosqlite.connect(self.path)
|
||||
self._conn.row_factory = aiosqlite.Row
|
||||
await self._conn.execute("PRAGMA journal_mode=WAL")
|
||||
await self._conn.execute("PRAGMA busy_timeout=5000")
|
||||
await self._conn.executescript(_SCHEMA)
|
||||
await self._conn.commit()
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._conn is not None:
|
||||
await self._conn.close()
|
||||
self._conn = None
|
||||
|
||||
@property
|
||||
def conn(self) -> aiosqlite.Connection:
|
||||
if self._conn is None:
|
||||
raise RuntimeError("Database 未初始化:先 await init_db()")
|
||||
return self._conn
|
||||
|
||||
async def fetchall(self, sql: str, params: Iterable[Any] = ()) -> list[dict[str, Any]]:
|
||||
async with self.conn.execute(sql, tuple(params)) as cur:
|
||||
rows = await cur.fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
async def fetchone(self, sql: str, params: Iterable[Any] = ()) -> dict[str, Any] | None:
|
||||
async with self.conn.execute(sql, tuple(params)) as cur:
|
||||
row = await cur.fetchone()
|
||||
return dict(row) if row is not None else None
|
||||
|
||||
async def execute(self, sql: str, params: Iterable[Any] = ()) -> int:
|
||||
async with self._lock:
|
||||
cur = await self.conn.execute(sql, tuple(params))
|
||||
await self._conn.commit() # type: ignore[union-attr]
|
||||
return cur.lastrowid or 0
|
||||
|
||||
async def executemany(self, sql: str, seq: list[tuple[Any, ...]]) -> None:
|
||||
async with self._lock:
|
||||
await self.conn.executemany(sql, seq)
|
||||
await self._conn.commit() # type: ignore[union-attr]
|
||||
|
||||
|
||||
db = Database()
|
||||
|
||||
|
||||
async def init_db(path: str | Path | None = None) -> None:
|
||||
"""幂等初始化:已连接则跳过(测试可在 lifespan 前 set_path 注入路径)。"""
|
||||
if path is not None:
|
||||
db.set_path(path)
|
||||
if db.connected:
|
||||
return
|
||||
await db.connect()
|
||||
|
||||
|
||||
async def close_db() -> None:
|
||||
await db.close()
|
||||
|
||||
|
||||
def set_path(path: str | Path) -> None:
|
||||
"""同步注入库路径(测试 fixture 用;须在 init_db 之前调用)。"""
|
||||
db.set_path(path)
|
||||
|
||||
|
||||
async def ensure_journals_seeded() -> int:
|
||||
"""装载种子期刊(幂等)。返回 journals 表行数。"""
|
||||
row = await db.fetchone("SELECT COUNT(*) AS c FROM journals")
|
||||
if row and int(row["c"]) > 0:
|
||||
return int(row["c"])
|
||||
|
||||
seed_file = get_settings().journals_seed_abs
|
||||
try:
|
||||
payload = json.loads(Path(seed_file).read_text(encoding="utf-8"))
|
||||
except FileNotFoundError as exc:
|
||||
raise SeedError(f"期刊种子文件不存在:{seed_file}") from exc
|
||||
except (OSError, ValueError) as exc:
|
||||
raise SeedError(f"期刊种子文件不可读:{exc}") from exc
|
||||
|
||||
journals = payload.get("journals") if isinstance(payload, dict) else None
|
||||
if not isinstance(journals, list) or not journals:
|
||||
raise SeedError(f"期刊种子文件缺少 journals 数组:{seed_file}")
|
||||
|
||||
rows: list[tuple[Any, ...]] = []
|
||||
for item in journals:
|
||||
if not isinstance(item, dict) or not item.get("id") or not item.get("name"):
|
||||
continue
|
||||
rows.append(tuple(item.get(c) for c in _SEED_COLS))
|
||||
if not rows:
|
||||
raise SeedError(f"期刊种子文件无有效条目:{seed_file}")
|
||||
|
||||
await db.executemany(_UPSERT_SQL, rows)
|
||||
return len(rows)
|
||||
|
||||
|
||||
async def list_journals() -> list[dict[str, Any]]:
|
||||
"""全部期刊(impact_factor 降序,NULL 靠后)。"""
|
||||
return await db.fetchall(
|
||||
"SELECT * FROM journals ORDER BY impact_factor DESC, name ASC"
|
||||
)
|
||||
|
||||
|
||||
async def get_journal(journal_id: str) -> dict[str, Any] | None:
|
||||
return await db.fetchone("SELECT * FROM journals WHERE id = ?", (journal_id,))
|
||||
|
||||
|
||||
async def set_vstore_doc_id(journal_id: str, doc_id: str) -> None:
|
||||
"""记录 scope_desc 在 vstore 的 document_id(向量白名单检索用)。"""
|
||||
await db.execute(
|
||||
"UPDATE journals SET vstore_doc_id = ? WHERE id = ?", (doc_id, journal_id)
|
||||
)
|
||||
@@ -0,0 +1,167 @@
|
||||
"""main.py —— review 服务应用入口(uvicorn 入口)。
|
||||
|
||||
- 独立部署树 review_service/(科研投稿与智能审稿助手,端口 8100)。
|
||||
- 端点:/health(GET,BFF 探活反代)+ /api/review/*(自审稿等业务路由)。
|
||||
- 不依赖 BFF/生产服务(8090 等绝不探测/连接);上游仅消费 vstore 8094。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from .config import get_settings
|
||||
from .db import close_db, db, init_db
|
||||
from .routers import (
|
||||
compile,
|
||||
conferences,
|
||||
format_check,
|
||||
journal_directory,
|
||||
journals,
|
||||
ppt,
|
||||
presubmit,
|
||||
reformat,
|
||||
rebuttal,
|
||||
)
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
settings = get_settings()
|
||||
app = FastAPI(title=settings.app_name, version=settings.version)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=False,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
@asynccontextmanager
|
||||
async def _lifespan(_app: FastAPI):
|
||||
# journals 推荐依赖独立 SQLite:启动即建表(幂等;测试可先 set_path 注入
|
||||
# tmp 路径,本函数沿用注入路径)
|
||||
await init_db()
|
||||
# rebuttal Phase 1:独立表,启动幂等建表(自含连接,不走 db.py)
|
||||
try:
|
||||
from .services.rebuttal_store import ensure_table
|
||||
ensure_table()
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("rebuttal 建表失败(忽略)", exc_info=True)
|
||||
# P5 科研汇报:ppt_jobs 独立表,启动幂等建表
|
||||
try:
|
||||
from .services.ppt_store import ensure_table as ppt_ensure_table
|
||||
ppt_ensure_table()
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("ppt 建表失败(忽略)", exc_info=True)
|
||||
# 编译产物过期清扫:启动跑一次(失败不阻塞启动)
|
||||
try:
|
||||
from .services.latex_compile import sweep_expired
|
||||
sweep_expired()
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("compile sweep 启动清扫失败(忽略)", exc_info=True)
|
||||
# P4:inspect 暂存(1h TTL)+ 项目编译产物(7 天)启动清扫
|
||||
try:
|
||||
from .services.latex_compile import (
|
||||
sweep_compile_jobs_expired,
|
||||
sweep_inspect_expired,
|
||||
)
|
||||
sweep_inspect_expired()
|
||||
sweep_compile_jobs_expired()
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("P4 sweep 启动清扫失败(忽略)", exc_info=True)
|
||||
# P4:inspect 暂存后台定时清扫(默认 300s;补充 1——上传不点编译不堆积)
|
||||
sweep_task: asyncio.Task[None] | None = None
|
||||
try:
|
||||
from .services.latex_compile import sweep_inspect_expired as _sweep_insp
|
||||
|
||||
async def _inspect_sweep_loop() -> None:
|
||||
interval = get_settings().inspect_sweep_interval_s
|
||||
while True:
|
||||
await asyncio.sleep(interval)
|
||||
try:
|
||||
await asyncio.to_thread(_sweep_insp)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("inspect 后台清扫失败(忽略)", exc_info=True)
|
||||
|
||||
sweep_task = asyncio.create_task(_inspect_sweep_loop())
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("inspect 后台清扫任务启动失败(忽略)", exc_info=True)
|
||||
# journal_directory 自动 seed:空表才导(xlsx 解析约 1-3s,不堵 event loop;
|
||||
# 路径沿用 db.path 注入链——生产=settings.db_path_abs,测试=tmp)
|
||||
if settings.seed_on_startup:
|
||||
try:
|
||||
from .services.journal_seed import ensure_journal_directory_seeded
|
||||
_t0 = time.time()
|
||||
_result = await asyncio.to_thread(
|
||||
ensure_journal_directory_seeded, db.path)
|
||||
if _result.get("seeded"):
|
||||
_c = _result.get("counts") or {}
|
||||
logger.info(
|
||||
"seeded %s rows (en=%s, zh=%s, %.1fs)",
|
||||
_result["rows"], _c.get("en"), _c.get("zh"),
|
||||
time.time() - _t0)
|
||||
elif _result.get("error"):
|
||||
logger.warning(
|
||||
"journal_directory 启动 seed 失败(忽略,不阻塞启动): %s",
|
||||
_result["error"])
|
||||
else:
|
||||
logger.info("%s", _result.get("reason") or "journal_directory seed skipped")
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("journal_directory 启动 seed 失败(忽略,不阻塞启动)", exc_info=True)
|
||||
else:
|
||||
logger.info("journal_directory seed skipped (REVIEW_SEED_ON_STARTUP=false)")
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if sweep_task is not None:
|
||||
sweep_task.cancel()
|
||||
try:
|
||||
await sweep_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
await close_db()
|
||||
|
||||
app.router.lifespan_context = _lifespan
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> dict[str, object]:
|
||||
vstore_ok = False
|
||||
vstore_error: str | None = None
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=2.0) as client:
|
||||
resp = await client.get(f"{settings.vstore_base}/api/vstore/health")
|
||||
resp.raise_for_status()
|
||||
vstore_ok = bool(resp.json().get("ok"))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
vstore_error = str(exc)
|
||||
return {
|
||||
"ok": True,
|
||||
"status": "ok" if vstore_ok else "degraded",
|
||||
"upstreams": {"vstore": {"connected": vstore_ok, "error": vstore_error}},
|
||||
"version": settings.version,
|
||||
}
|
||||
|
||||
app.include_router(presubmit.router)
|
||||
app.include_router(rebuttal.router)
|
||||
app.include_router(journals.router)
|
||||
app.include_router(format_check.router)
|
||||
app.include_router(conferences.router)
|
||||
app.include_router(journal_directory.router)
|
||||
app.include_router(reformat.router)
|
||||
app.include_router(compile.router)
|
||||
app.include_router(ppt.router)
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
@@ -0,0 +1 @@
|
||||
"""review 服务路由包。"""
|
||||
@@ -0,0 +1,342 @@
|
||||
"""routers/compile.py —— LaTeX 编译 PDF(P3a:tectonic)。
|
||||
|
||||
端点:
|
||||
POST /api/review/compile multipart:file(.tex 可选) | job_id(可选,二选一)
|
||||
→ {ok, compile_id, pdf_url, log_url, meta}
|
||||
GET /api/review/compile/{cid} 编译元信息(meta.json)
|
||||
GET /api/review/compile/{cid}/pdf output.pdf(FileResponse)
|
||||
GET /api/review/compile/{cid}/log output.log(text/plain)
|
||||
|
||||
信封:业务错误 = HTTP 200 + {ok:false,error:{code,message,detail?,degraded:false}}
|
||||
(QUEUE_FULL 例外:HTTP 429 + 同款信封体,前端仍按 ok/error 单一口径处理)。
|
||||
detail = log 尾 200 行(COMPILE_FAILED / TIMEOUT)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, File, Form, UploadFile
|
||||
from fastapi.responses import FileResponse, JSONResponse, PlainTextResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..config import get_settings
|
||||
from ..services import latex_compile as lc
|
||||
from ..services.latex_compile import (
|
||||
CompileError,
|
||||
QueueFullError,
|
||||
ToolUnavailableError,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/review", tags=["review-compile"])
|
||||
|
||||
_CID_RE = re.compile(r"^[0-9a-f]{12}$")
|
||||
|
||||
|
||||
class CompileResponse(BaseModel):
|
||||
ok: bool
|
||||
compile_id: str | None = None
|
||||
pdf_url: str | None = None
|
||||
log_url: str | None = None
|
||||
meta: dict[str, Any] | None = None
|
||||
error: dict[str, Any] | None = None
|
||||
|
||||
|
||||
def _error(code: str, message: str, detail: str = "") -> CompileResponse:
|
||||
err: dict[str, Any] = {"code": code, "message": message, "degraded": False}
|
||||
if detail:
|
||||
err["detail"] = detail
|
||||
return CompileResponse(ok=False, error=err)
|
||||
|
||||
|
||||
def _jobs_root() -> Path:
|
||||
d = get_settings().reformat_dir_abs
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
@router.post("/compile", response_model=CompileResponse)
|
||||
async def compile_tex(
|
||||
file: UploadFile | None = File(None, description="LaTeX 源码(.tex,与 job_id 二选一)"),
|
||||
job_id: str = Form("", description="P1 重排版任务 id(优先于 file)"),
|
||||
) -> CompileResponse:
|
||||
settings = get_settings()
|
||||
|
||||
# ---- 输入解析:job_id 优先,否则直接上传的 .tex ----
|
||||
jid = (job_id or "").strip()
|
||||
if jid:
|
||||
if not _CID_RE.fullmatch(jid):
|
||||
return _error("VALIDATION", "job_id 格式非法(应为 12 位 hex)")
|
||||
src = _jobs_root() / f"{jid}.tex"
|
||||
if not src.is_file():
|
||||
return _error("VALIDATION", "job_id 对应的重排版结果不存在,请重新排版")
|
||||
try:
|
||||
source = src.read_text("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return _error("VALIDATION", "任务源码不是合法 UTF-8")
|
||||
anchor_id = jid
|
||||
else:
|
||||
if file is None:
|
||||
return _error("VALIDATION", "必须提供 file(.tex)或 job_id")
|
||||
filename = file.filename or "paper.tex"
|
||||
if Path(filename).name != filename or ".." in filename:
|
||||
return _error("VALIDATION", "非法文件名")
|
||||
if not filename.lower().endswith(".tex"):
|
||||
return _error("VALIDATION", "仅支持 .tex 文件")
|
||||
content = await file.read()
|
||||
if not content:
|
||||
return _error("VALIDATION", "上传文件为空")
|
||||
if len(content) > settings.max_tex_bytes:
|
||||
return _error("VALIDATION", "源码超过 2MB 上限")
|
||||
try:
|
||||
source = content.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return _error("VALIDATION", "源码必须为 UTF-8 编码")
|
||||
anchor_id = uuid.uuid4().hex[:12]
|
||||
|
||||
# ---- 编译(隔离目录;失败信封带 log 尾)----
|
||||
lc.prepare_input(anchor_id, source)
|
||||
try:
|
||||
result = await lc.compile_tex(anchor_id)
|
||||
except ToolUnavailableError as exc:
|
||||
return _error("TOOL_UNAVAILABLE", str(exc))
|
||||
except QueueFullError as exc:
|
||||
return JSONResponse(
|
||||
status_code=429,
|
||||
content={
|
||||
"ok": False,
|
||||
"compile_id": None, "pdf_url": None, "log_url": None, "meta": None,
|
||||
"error": {"code": "QUEUE_FULL", "message": str(exc), "degraded": False},
|
||||
},
|
||||
)
|
||||
except CompileError as exc:
|
||||
return _error(exc.code, str(exc), detail=exc.log_tail)
|
||||
|
||||
meta_path = lc.compile_dir(anchor_id) / "meta.json"
|
||||
try:
|
||||
meta = json.loads(meta_path.read_text("utf-8"))
|
||||
except (OSError, ValueError):
|
||||
meta = {"compile_id": anchor_id, "seconds": result["seconds"]}
|
||||
return CompileResponse(
|
||||
ok=True,
|
||||
compile_id=anchor_id,
|
||||
pdf_url=f"/api/review/compile/{anchor_id}/pdf",
|
||||
log_url=f"/api/review/compile/{anchor_id}/log",
|
||||
meta=meta,
|
||||
)
|
||||
|
||||
|
||||
# ================================================================ P4:项目 zip(两段式)
|
||||
|
||||
def _p4_error(code: str, message: str, detail: str = "",
|
||||
compile_id: str | None = None,
|
||||
log_url: str | None = None) -> CompileResponse:
|
||||
"""P4 错误信封(补充 3:失败也带 compile_id + log_url,前端可下载完整 log)。"""
|
||||
err: dict[str, Any] = {"code": code, "message": message, "degraded": False}
|
||||
if detail:
|
||||
err["detail"] = detail
|
||||
return CompileResponse(
|
||||
ok=False, compile_id=compile_id, log_url=log_url, error=err)
|
||||
|
||||
|
||||
def _project_pdf_url(cid: str) -> str:
|
||||
return f"/api/review/compile/zip/{cid}/pdf"
|
||||
|
||||
|
||||
def _project_log_url(cid: str) -> str:
|
||||
return f"/api/review/compile/zip/{cid}/log"
|
||||
|
||||
|
||||
@router.post("/compile/inspect", response_model=CompileResponse)
|
||||
async def inspect_zip_project(
|
||||
file: UploadFile = File(..., description="LaTeX 项目 zip(含 main.tex/.cls/图片/.bib)"),
|
||||
) -> CompileResponse:
|
||||
"""两段式之①:安全解压 + 主文件识别 → job_id(1 小时 TTL,run 前不占编译队列)。
|
||||
|
||||
暂用 request.body() 整体缓冲(≤50MB);Phase 2 改流式上传
|
||||
"""
|
||||
settings = get_settings()
|
||||
filename = file.filename or "project.zip"
|
||||
if Path(filename).name != filename or ".." in filename:
|
||||
return _p4_error("VALIDATION", "非法文件名")
|
||||
if not filename.lower().endswith(".zip"):
|
||||
return _p4_error("VALIDATION", "仅支持 .zip 文件")
|
||||
# 暂用 request.body() 整体缓冲(≤50MB);Phase 2 改流式上传
|
||||
content = await file.read()
|
||||
if not content:
|
||||
return _p4_error("VALIDATION", "上传文件为空")
|
||||
if len(content) > settings.max_zip_bytes:
|
||||
return _p4_error(
|
||||
"VALIDATION",
|
||||
f"zip 超过 {settings.max_zip_bytes // (1024 * 1024)}MB 上限")
|
||||
try:
|
||||
meta = await lc.inspect_zip(content)
|
||||
except lc.InspectError as exc:
|
||||
return _p4_error(exc.code, exc.message)
|
||||
return CompileResponse(ok=True, meta=meta)
|
||||
|
||||
|
||||
class RunZipRequest(BaseModel):
|
||||
job_id: str
|
||||
|
||||
|
||||
@router.post("/compile/run", response_model=CompileResponse)
|
||||
async def run_zip_compile(payload: RunZipRequest) -> CompileResponse:
|
||||
"""两段式之②:原子消费 inspect(rename 防并发双跑)→ tectonic 编译 → pdf/log。
|
||||
|
||||
队列/工具失败发生在消费**之前** → inspect 保留可重试;
|
||||
编译失败(COMPILE_FAILED/TIMEOUT)信封仍带 compile_id + log_url(补充 3)。
|
||||
"""
|
||||
jid = (payload.job_id or "").strip()
|
||||
if not _CID_RE.fullmatch(jid):
|
||||
return _p4_error(
|
||||
"VALIDATION", "job_id 格式非法(应为 12 位 hex)")
|
||||
try:
|
||||
result = await lc.compile_project_job(jid)
|
||||
except lc.InspectError as exc:
|
||||
return _p4_error(exc.code, exc.message)
|
||||
except lc.ToolUnavailableError as exc:
|
||||
return _p4_error("TOOL_UNAVAILABLE", str(exc))
|
||||
except lc.QueueFullError as exc:
|
||||
# 队列满发生在 consume_inspect 之前 → job 未消费,前端可直接重试
|
||||
return JSONResponse(
|
||||
status_code=429,
|
||||
content={
|
||||
"ok": False,
|
||||
"compile_id": None, "pdf_url": None, "log_url": None, "meta": None,
|
||||
"error": {"code": "QUEUE_FULL", "message": str(exc),
|
||||
"degraded": False},
|
||||
},
|
||||
)
|
||||
except lc.CompileError as exc:
|
||||
# 补充 3:失败也带 compile_id + log_url(cid 由 compile_project_job 附加)
|
||||
cid = getattr(exc, "compile_id", None)
|
||||
return _p4_error(
|
||||
exc.code, str(exc), detail=exc.log_tail,
|
||||
compile_id=cid,
|
||||
log_url=_project_log_url(cid) if cid else None)
|
||||
return CompileResponse(
|
||||
ok=True,
|
||||
compile_id=result["compile_id"],
|
||||
pdf_url=_project_pdf_url(result["compile_id"]),
|
||||
log_url=_project_log_url(result["compile_id"]),
|
||||
meta=json.loads((lc.compile_job_dir(result["compile_id"]) / "meta.json")
|
||||
.read_text("utf-8"))
|
||||
if (lc.compile_job_dir(result["compile_id"]) / "meta.json").is_file()
|
||||
else {"compile_id": result["compile_id"], "seconds": result["seconds"]},
|
||||
)
|
||||
|
||||
|
||||
def _cid_dir(cid: str) -> Path | None:
|
||||
if not _CID_RE.fullmatch(cid or ""):
|
||||
return None
|
||||
d = lc.compile_dir(cid)
|
||||
return d if d.is_dir() else None
|
||||
|
||||
|
||||
def _zip_cid_dir(cid: str) -> Path | None:
|
||||
"""P4 项目产物目录 data/compile_jobs/{cid}/(独立于 P3a reformat_dir 树)。"""
|
||||
if not _CID_RE.fullmatch(cid or ""):
|
||||
return None
|
||||
d = lc.compile_job_dir(cid)
|
||||
return d if d.is_dir() else None
|
||||
|
||||
|
||||
@router.get("/compile/zip/{compile_id}", response_model=CompileResponse)
|
||||
async def get_zip_compile(compile_id: str) -> CompileResponse:
|
||||
cdir = _zip_cid_dir(compile_id)
|
||||
meta_path = cdir / "meta.json" if cdir else None
|
||||
if meta_path is None or not meta_path.is_file():
|
||||
return _p4_error("NOT_FOUND", "编译任务不存在或已过期,请重新编译")
|
||||
try:
|
||||
meta = json.loads(meta_path.read_text("utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return _p4_error("NOT_FOUND", "编译产物损坏,请重新编译")
|
||||
return CompileResponse(
|
||||
ok=True,
|
||||
compile_id=compile_id,
|
||||
pdf_url=_project_pdf_url(compile_id),
|
||||
log_url=_project_log_url(compile_id),
|
||||
meta=meta,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/compile/zip/{compile_id}/pdf", response_model=None)
|
||||
async def download_zip_pdf(compile_id: str) -> FileResponse | JSONResponse:
|
||||
cdir = _zip_cid_dir(compile_id)
|
||||
pdf = cdir / "output.pdf" if cdir else None
|
||||
if pdf is None or not pdf.is_file():
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={"ok": False, "error": {
|
||||
"code": "NOT_FOUND", "message": "PDF 不存在或已过期",
|
||||
"degraded": False}},
|
||||
)
|
||||
return FileResponse(pdf, media_type="application/pdf",
|
||||
filename=f"{compile_id}.pdf")
|
||||
|
||||
|
||||
@router.get("/compile/zip/{compile_id}/log", response_model=None)
|
||||
async def download_zip_log(compile_id: str) -> PlainTextResponse | JSONResponse:
|
||||
"""补充 3:成功/失败都可下载 output.log。"""
|
||||
cdir = _zip_cid_dir(compile_id)
|
||||
log = cdir / "output.log" if cdir else None
|
||||
if log is None or not log.is_file():
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={"ok": False, "error": {
|
||||
"code": "NOT_FOUND", "message": "日志不存在或已过期",
|
||||
"degraded": False}},
|
||||
)
|
||||
return PlainTextResponse(log.read_text("utf-8", errors="replace"))
|
||||
|
||||
|
||||
@router.get("/compile/{compile_id}", response_model=CompileResponse)
|
||||
async def get_compile(compile_id: str) -> CompileResponse:
|
||||
cdir = _cid_dir(compile_id)
|
||||
meta_path = cdir / "meta.json" if cdir else None
|
||||
if meta_path is None or not meta_path.is_file():
|
||||
return _error("NOT_FOUND", "编译任务不存在或已过期,请重新编译")
|
||||
try:
|
||||
meta = json.loads(meta_path.read_text("utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return _error("NOT_FOUND", "编译产物损坏,请重新编译")
|
||||
return CompileResponse(
|
||||
ok=True,
|
||||
compile_id=compile_id,
|
||||
pdf_url=f"/api/review/compile/{compile_id}/pdf",
|
||||
log_url=f"/api/review/compile/{compile_id}/log",
|
||||
meta=meta,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/compile/{compile_id}/pdf", response_model=None)
|
||||
async def download_compile_pdf(compile_id: str) -> FileResponse | JSONResponse:
|
||||
cdir = _cid_dir(compile_id)
|
||||
pdf = cdir / "output.pdf" if cdir else None
|
||||
if pdf is None or not pdf.is_file():
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={"ok": False, "error": {
|
||||
"code": "NOT_FOUND", "message": "PDF 不存在或已过期", "degraded": False}},
|
||||
)
|
||||
return FileResponse(pdf, media_type="application/pdf", filename=f"{compile_id}.pdf")
|
||||
|
||||
|
||||
@router.get("/compile/{compile_id}/log", response_model=None)
|
||||
async def download_compile_log(compile_id: str) -> PlainTextResponse | JSONResponse:
|
||||
cdir = _cid_dir(compile_id)
|
||||
log = cdir / "output.log" if cdir else None
|
||||
if log is None or not log.is_file():
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={"ok": False, "error": {
|
||||
"code": "NOT_FOUND", "message": "日志不存在或已过期", "degraded": False}},
|
||||
)
|
||||
return PlainTextResponse(log.read_text("utf-8", errors="replace"))
|
||||
@@ -0,0 +1,209 @@
|
||||
"""routers/conferences.py —— 会议投稿时间线(只读静态种子)。
|
||||
|
||||
- GET /api/review/conferences?domain=&ccf=&upcoming_days=180
|
||||
data/journals/conferences.json → 过滤/排序 → 列表 + meta。
|
||||
- 数据诚实性铁律:每条会议必须带 deadline_source ∈ {placeholder, official}、
|
||||
last_verified 与 deadline_confidence ∈ {high, medium, low},缺失/非法条目加载时
|
||||
跳过并记日志(宁缺毋造);本端点无任何生成日期的逻辑,日期只来自种子常量。
|
||||
v2 共 27/27 全 placeholder,meta.data_source_note 显式声明占位与置信度,
|
||||
由前端渲染「⚠ 日期待核验」及置信度提示。
|
||||
- 过滤语义:domain 对 domain_tags 精确匹配(大小写不敏感);ccf ∈ {A,B,C};
|
||||
upcoming_days=0 不过滤,>0 仅保留 deadline_full_paper ∈ [UTC now, now+N 天],
|
||||
缺失/不可解析截稿日期在过滤开启时排除。排序:deadline_full_paper 升序,
|
||||
缺失/不可解析恒排最后。
|
||||
- 只读无写接口;文件不可读 → ok=false + DATA_UNAVAILABLE 信封;参数非法 →
|
||||
VALIDATION 信封(均 HTTP 200,对齐本服务"错误信封永远 200"约定)。
|
||||
- 静态文件首次请求加载进内存(lru_cache),进程内 O(1);不触碰 SQLite/BFF。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/review", tags=["review-conferences"])
|
||||
|
||||
# review_service/app/routers/ → parents[2] = review_service(对齐 format_check 写法)
|
||||
_CONFERENCES_PATH = (
|
||||
Path(__file__).resolve().parents[2] / "data" / "journals" / "conferences.json"
|
||||
)
|
||||
|
||||
_CCF_OK = frozenset({"A", "B", "C"})
|
||||
_REQUIRED = ("id", "name", "deadline_source", "last_verified", "deadline_confidence")
|
||||
_CONFIDENCE_OK = frozenset({"high", "medium", "low"})
|
||||
_SOURCE_NOTE = (
|
||||
"会议截稿/通知日期首版均为占位数据(deadline_source=placeholder),"
|
||||
"接入官方渠道前须人工核验;deadline_confidence 标注日期置信度"
|
||||
"(high=官方已公布/历年极稳定,medium=按历年规律推算,low=待核验);"
|
||||
"所有时间按 AoE (UTC-12) 时区。"
|
||||
)
|
||||
|
||||
|
||||
# ---- 请求/响应模型 ----
|
||||
|
||||
class ConferenceItem(BaseModel):
|
||||
"""会议公开字段(种子全字段白名单;未知键丢弃)。"""
|
||||
id: str
|
||||
name: str
|
||||
full_name: str = ""
|
||||
domain_tags: list[str] = []
|
||||
application_tags: list[str] = []
|
||||
ccf_rank: str | None = None
|
||||
deadline_abstract: str | None = None
|
||||
deadline_full_paper: str | None = None
|
||||
notification_date: str | None = None
|
||||
conference_date: str | None = None
|
||||
location: str | None = None
|
||||
official_url: str | None = None
|
||||
timezone_note: str | None = None
|
||||
deadline_source: str
|
||||
last_verified: str
|
||||
deadline_confidence: str = ""
|
||||
|
||||
|
||||
class ConferencesResponse(BaseModel):
|
||||
ok: bool
|
||||
items: list[ConferenceItem] = []
|
||||
meta: dict[str, Any] | None = None
|
||||
error: dict[str, Any] | None = None
|
||||
|
||||
|
||||
def _error(code: str, message: str) -> ConferencesResponse:
|
||||
return ConferencesResponse(
|
||||
ok=False, items=[], meta=None,
|
||||
error={"code": code, "message": message, "degraded": False},
|
||||
)
|
||||
|
||||
|
||||
# ---- 种子加载(首次调用读盘,进程内缓存)----
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _load_conferences() -> list[dict[str, Any]] | None:
|
||||
"""读 conferences.json → 合法会议列表;文件不可读 → None(DATA_UNAVAILABLE)。"""
|
||||
try:
|
||||
payload = json.loads(_CONFERENCES_PATH.read_text("utf-8"))
|
||||
except (OSError, ValueError) as exc:
|
||||
logger.warning("conferences: 种子文件不可读 %s: %s", _CONFERENCES_PATH, exc)
|
||||
return None
|
||||
raw = payload.get("conferences") if isinstance(payload, dict) else None
|
||||
if not isinstance(raw, list):
|
||||
logger.warning("conferences: 种子缺少 conferences 数组:%s", _CONFERENCES_PATH)
|
||||
return None
|
||||
items: list[dict[str, Any]] = []
|
||||
for conf in raw:
|
||||
if not isinstance(conf, dict):
|
||||
continue
|
||||
missing = [k for k in _REQUIRED if not conf.get(k)]
|
||||
if missing:
|
||||
logger.warning("conferences: 条目缺字段 %s(id=%s),跳过", missing, conf.get("id"))
|
||||
continue
|
||||
if conf.get("deadline_source") not in ("placeholder", "official"):
|
||||
logger.warning(
|
||||
"conferences: deadline_source 非法(id=%s),跳过", conf.get("id")
|
||||
)
|
||||
continue
|
||||
if str(conf.get("deadline_confidence") or "").strip().lower() not in _CONFIDENCE_OK:
|
||||
logger.warning(
|
||||
"conferences: deadline_confidence 非法(id=%s),跳过", conf.get("id")
|
||||
)
|
||||
continue
|
||||
items.append(conf)
|
||||
return items
|
||||
|
||||
|
||||
def _parse_deadline(value: Any) -> datetime | None:
|
||||
"""截稿时间 → aware datetime(naive 视为 UTC);非法 → None。"""
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return None
|
||||
try:
|
||||
dt = datetime.fromisoformat(value.strip())
|
||||
except ValueError:
|
||||
return None
|
||||
return dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt
|
||||
|
||||
|
||||
# ---- GET /api/review/conferences ----
|
||||
|
||||
@router.get("/conferences", response_model=ConferencesResponse)
|
||||
async def conferences_list(
|
||||
domain: str = "",
|
||||
ccf: str = "",
|
||||
upcoming_days: int = 180,
|
||||
) -> ConferencesResponse:
|
||||
"""只读:会议投稿时间线(无写/删/改接口)。
|
||||
|
||||
- ?domain= 对 domain_tags 精确匹配(大小写不敏感),空=不过滤;
|
||||
- ?ccf= ccf_rank ∈ A/B/C,空=不过滤,非法值 → VALIDATION 信封;
|
||||
- ?upcoming_days= int≥0,默认 180;0=不过滤,>0 仅保留截稿在
|
||||
[UTC now, now+N 天] 内的会议,负数 → VALIDATION 信封;
|
||||
- 排序按 deadline_full_paper 升序,缺失/不可解析恒排最后;
|
||||
- 空结果 → items=[] + meta.count=0(HTTP 200,不报错)。
|
||||
"""
|
||||
ccf_norm = (ccf or "").strip().upper()
|
||||
if ccf_norm and ccf_norm not in _CCF_OK:
|
||||
return _error("VALIDATION", f"不支持的 ccf={ccf!r},可选:A / B / C")
|
||||
if upcoming_days < 0:
|
||||
return _error(
|
||||
"VALIDATION", f"upcoming_days 必须 ≥ 0,收到 {upcoming_days}"
|
||||
)
|
||||
|
||||
all_items = _load_conferences()
|
||||
if all_items is None:
|
||||
return _error("DATA_UNAVAILABLE", "会议种子数据不可读,请联系管理员")
|
||||
|
||||
items = list(all_items)
|
||||
|
||||
dom = (domain or "").strip().casefold()
|
||||
if dom:
|
||||
items = [
|
||||
c for c in items
|
||||
if any(
|
||||
str(t).strip().casefold() == dom
|
||||
for t in (c.get("domain_tags") or [])
|
||||
)
|
||||
]
|
||||
if ccf_norm:
|
||||
items = [
|
||||
c for c in items
|
||||
if str(c.get("ccf_rank") or "").strip().upper() == ccf_norm
|
||||
]
|
||||
if upcoming_days > 0:
|
||||
now = datetime.now(timezone.utc)
|
||||
limit = now + timedelta(days=upcoming_days)
|
||||
items = [
|
||||
c for c in items
|
||||
if (dl := _parse_deadline(c.get("deadline_full_paper"))) is not None
|
||||
and now <= dl <= limit
|
||||
]
|
||||
|
||||
def _sort_key(c: dict[str, Any]) -> tuple[bool, datetime]:
|
||||
dl = _parse_deadline(c.get("deadline_full_paper"))
|
||||
if dl is None:
|
||||
return (True, datetime.max.replace(tzinfo=timezone.utc))
|
||||
return (False, dl)
|
||||
|
||||
items.sort(key=_sort_key)
|
||||
|
||||
out = [ConferenceItem(**c) for c in items]
|
||||
verified = max(
|
||||
(str(c.get("last_verified") or "") for c in all_items), default=""
|
||||
)
|
||||
meta: dict[str, Any] = {
|
||||
"count": len(out),
|
||||
"last_verified": verified or None,
|
||||
"data_source_note": _SOURCE_NOTE,
|
||||
"query": {
|
||||
"domain": (domain or "").strip(),
|
||||
"ccf": ccf_norm,
|
||||
"upcoming_days": upcoming_days,
|
||||
},
|
||||
}
|
||||
return ConferencesResponse(ok=True, items=out, meta=meta, error=None)
|
||||
@@ -0,0 +1,81 @@
|
||||
"""routers/format_check.py —— 版式与规范检查(POST /api/review/format-check)。
|
||||
|
||||
纯规则引擎(services/format_check.py):PDF/Word 本地解档取版式元数据 +
|
||||
journals.json 规则集(format_rules_default + 每条 format_rules 带 rules_source)。
|
||||
无 LLM / 无 vstore,同步执行通常 <1s;恒 200 + {ok, checks, summary, meta|error} 信封。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, File, Form, UploadFile
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..config import get_settings
|
||||
from ..services import format_check
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/review", tags=["review-format"])
|
||||
|
||||
|
||||
class CheckItem(BaseModel):
|
||||
key: str
|
||||
name: str
|
||||
status: str # pass | warn | fail
|
||||
detail: str
|
||||
suggestion: str = ""
|
||||
|
||||
|
||||
class FormatCheckResponse(BaseModel):
|
||||
ok: bool
|
||||
checks: list[CheckItem] | None = None
|
||||
summary: dict[str, int] | None = None
|
||||
meta: dict[str, object] | None = None
|
||||
error: dict[str, object] | None = None
|
||||
|
||||
|
||||
def _error(code: str, message: str, *, degraded: bool = False) -> FormatCheckResponse:
|
||||
return FormatCheckResponse(
|
||||
ok=False, error={"code": code, "message": message, "degraded": degraded},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/format-check", response_model=FormatCheckResponse)
|
||||
async def format_check_endpoint(
|
||||
file: UploadFile = File(..., description="待检查的 PDF / Word(.docx) / LaTeX(.tex) 文档"),
|
||||
target_journal: str = Form("", description="目标期刊(可选,传 journals 的 id)"),
|
||||
) -> FormatCheckResponse:
|
||||
settings = get_settings()
|
||||
|
||||
filename = file.filename or ""
|
||||
low = filename.lower()
|
||||
if low.endswith(".pdf"):
|
||||
kind = "pdf"
|
||||
elif low.endswith(".docx"):
|
||||
kind = "docx"
|
||||
elif low.endswith(".tex"):
|
||||
kind = "tex"
|
||||
else:
|
||||
return _error("VALIDATION", "仅支持 PDF / Word(.docx)/ LaTeX(.tex)文件")
|
||||
|
||||
content = await file.read()
|
||||
if not content:
|
||||
return _error("VALIDATION", "上传文件为空")
|
||||
if len(content) > settings.max_pdf_bytes:
|
||||
return _error("VALIDATION", "上传文件超过 50MB 上限")
|
||||
|
||||
try:
|
||||
result = format_check.check_document(content, kind, (target_journal or "").strip())
|
||||
except format_check.FormatCheckError as exc:
|
||||
logger.warning("format-check: 解档失败:%s", exc)
|
||||
return _error("PARSE_FAILED", str(exc))
|
||||
|
||||
meta = dict(result.get("meta") or {})
|
||||
meta["filename"] = filename
|
||||
return FormatCheckResponse(
|
||||
ok=True,
|
||||
checks=[CheckItem(**c) for c in result["checks"]],
|
||||
summary=result["summary"],
|
||||
meta=meta,
|
||||
)
|
||||
@@ -0,0 +1,280 @@
|
||||
"""routers/journal_directory.py —— 期刊目录浏览(1056 本,只读分页)。
|
||||
|
||||
- GET /api/review/journals/directory:journal_directory 表(scripts/import_journals.py
|
||||
导入的 v4 目录:2025 中科院分区 + 2025 JIF(2026 年 JCR 发布)+ 年报 2025 版
|
||||
(中文统计年 2024)+ 官网网址/核对状态)→ 过滤/排序/分页 → 列表 + meta。
|
||||
- 与 GET /journals(journals.json 精选 5 本,供 FormatPanel 下拉/recommend 语义)物理
|
||||
分离(方案 A);本端点绝不读写 journals 表,recommend/journal_kb/journal_rank 零触碰。
|
||||
- 读路径只 SELECT;表由导入脚本拥有(db.py 零改动);表不存在 → data_imported=false
|
||||
优雅降级(HTTP 200 空列表)。
|
||||
|
||||
读时归一化(拍板细节 1-3,均在 API 层做,不动脚本/DB 原值):
|
||||
细节1 lang=zh → cas_partition 恒 null + cas_partition_note="不按中科院分区口径列示";
|
||||
细节2 jif_note 源文本映射(v4 值域已是用户可读文本,旧值映射保留兜底);
|
||||
细节3 缺值即矛盾清零:en 且 jif=null → jif_source_url=null。
|
||||
(zh 的 IF 载体是 composite/comprehensive,其 jif_source_url 本恒 null。)
|
||||
决策2:响应不下发"来源"文本字段(partition_source/jif_source/data_source_label
|
||||
已移除);jif_source_url/data_source_url 保留供展开行链接。
|
||||
决策4(来源展示优化):partition_source_url 不再下发——DB 原值分毫未动
|
||||
(仍 SELECT * 读出后被模型丢弃),响应改为常量字段 partition_source_note
|
||||
文案,前端以灰色说明文字替代原"分区来源"超链接。
|
||||
新增 official_check_status / jcr_release_year。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..db import db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/review", tags=["review-directory"])
|
||||
|
||||
_LANGS = frozenset({"en", "zh"})
|
||||
# 排序列白名单(列名不可参数化 → 白名单防注入)
|
||||
_FIELDS = frozenset({
|
||||
"name",
|
||||
"jif",
|
||||
"composite_impact_factor",
|
||||
"comprehensive_impact_factor",
|
||||
"research_direction",
|
||||
"cas_partition",
|
||||
})
|
||||
_ORDERS = frozenset({"asc", "desc"})
|
||||
_PARTS = {"1": "1区", "2": "2区", "3": "3区", "4": "4区"}
|
||||
_MAX_SIZE = 200
|
||||
|
||||
# 细节2:源核对状态 → 用户可读 note
|
||||
_JIF_NOTE_MAP = {
|
||||
"源表标N/A": "未被2024 JCR收录",
|
||||
"所用年度来源未检得": "本次汇编未查到,建议查阅最新 JCR",
|
||||
}
|
||||
_ZH_PART_NOTE = "不按中科院分区口径列示"
|
||||
# 决策4:分区来源改为常量说明文案(不下发 partition_source_url;DB 原值不动)
|
||||
_PARTITION_SOURCE_NOTE = (
|
||||
"2025 中科院官方表(2026 年起停更;官方入口 fenqubiao.com 需机构登录)"
|
||||
)
|
||||
|
||||
_NOTE_EN = (
|
||||
"分区:2025年中科院公开表(多表比对,2026年起停止更新);"
|
||||
"JIF:2025年度指标(2026年JCR发布,两份公开汇编一致或已核对出版社官方指标);"
|
||||
"整理日期2026-10-10。空白=来源未给值≠0;研究方向/期刊标记/文章类型为导航标签,非官方评级。"
|
||||
)
|
||||
_NOTE_ZH = (
|
||||
"指标:国内自然科学期刊年报2025版(统计年2024,official,含复合/综合IF两口径);"
|
||||
"中文期刊不按中科院分区口径列示;国内IF与JIF统计源不同,不可混排。"
|
||||
)
|
||||
_UNIMPORTED_NOTE = "目录数据未导入(请先运行 scripts/import_journals.py)"
|
||||
|
||||
|
||||
# ---- 响应模型 ----
|
||||
|
||||
class DirectoryItem(BaseModel):
|
||||
"""目录公开字段(DB 全列白名单;imported_at 等内部列丢弃)。"""
|
||||
id: str
|
||||
lang: str
|
||||
name: str
|
||||
research_direction: str | None = None
|
||||
cas_partition: str | None = None
|
||||
cas_partition_note: str | None = None
|
||||
cas_partition_year: int | None = None
|
||||
jif: float | None = None
|
||||
jif_year: int | None = None
|
||||
jif_note: str | None = None
|
||||
jif_check_status: str | None = None
|
||||
composite_impact_factor: float | None = None
|
||||
comprehensive_impact_factor: float | None = None
|
||||
metric_year: int | None = None
|
||||
metric_report_year: int | None = None
|
||||
metric_caliber: str | None = None
|
||||
annual_report_page: int | None = None
|
||||
top_flag: str | None = None
|
||||
publisher_series: str | None = None
|
||||
journal_mark: str | None = None
|
||||
article_type: str | None = None
|
||||
issn: str | None = None
|
||||
eissn: str | None = None
|
||||
jcr_disciplines: list[str] = []
|
||||
supplement_note: str | None = None
|
||||
evidence_note: str | None = None
|
||||
official_url: str | None = None
|
||||
official_check_status: str | None = None
|
||||
jcr_release_year: int | None = None
|
||||
jif_source_url: str | None = None
|
||||
partition_source_note: str = _PARTITION_SOURCE_NOTE # 决策4:常量,替代分区来源链接
|
||||
data_source_url: str | None = None
|
||||
|
||||
|
||||
class DirectoryResponse(BaseModel):
|
||||
ok: bool
|
||||
items: list[DirectoryItem] = []
|
||||
meta: dict[str, Any] | None = None
|
||||
error: dict[str, Any] | None = None
|
||||
|
||||
|
||||
def _error(code: str, message: str) -> DirectoryResponse:
|
||||
return DirectoryResponse(
|
||||
ok=False, items=[], meta=None,
|
||||
error={"code": code, "message": message, "degraded": False},
|
||||
)
|
||||
|
||||
|
||||
# ---- 读时归一化(拍板细节 1-3)----
|
||||
|
||||
def _present(raw: dict[str, Any], lang: str) -> dict[str, Any]:
|
||||
d = dict(raw)
|
||||
# jcr_disciplines:JSON 数组字符串 → list
|
||||
jcr = d.get("jcr_disciplines")
|
||||
if jcr:
|
||||
try:
|
||||
parsed = json.loads(jcr)
|
||||
d["jcr_disciplines"] = parsed if isinstance(parsed, list) else []
|
||||
except (TypeError, ValueError):
|
||||
d["jcr_disciplines"] = []
|
||||
else:
|
||||
d["jcr_disciplines"] = []
|
||||
|
||||
# 细节2:jif_note 源文本 → 用户可读
|
||||
note = d.get("jif_note")
|
||||
if note in _JIF_NOTE_MAP:
|
||||
d["jif_note"] = _JIF_NOTE_MAP[note]
|
||||
|
||||
# 细节1:zh 分区归一(DB 原值"不按此口径列示"只留在库里)
|
||||
if lang == "zh":
|
||||
d["cas_partition"] = None
|
||||
d["cas_partition_note"] = _ZH_PART_NOTE
|
||||
|
||||
# 细节3:缺值 → 来源链接清零(消除 null 值却挂来源链接的矛盾;
|
||||
# 分区来源链接已按决策4 移出响应,改为常量 partition_source_note)
|
||||
if lang == "en":
|
||||
if d.get("jif") is None:
|
||||
d["jif_source_url"] = None
|
||||
return d
|
||||
|
||||
|
||||
def _escape_like(s: str) -> str:
|
||||
return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
|
||||
|
||||
# ---- GET /api/review/journals/directory ----
|
||||
|
||||
@router.get("/journals/directory", response_model=DirectoryResponse)
|
||||
async def journals_directory(
|
||||
lang: str = "en",
|
||||
domain: str = "",
|
||||
cas_partition: str = "",
|
||||
q: str = "",
|
||||
page: int = 1,
|
||||
size: int = 50,
|
||||
field: str = "",
|
||||
order: str = "",
|
||||
) -> DirectoryResponse:
|
||||
"""只读:期刊目录分页浏览(无写接口)。
|
||||
|
||||
- ?lang=en|zh(默认 en;**强制单语,保证国际/中文永不混排**);
|
||||
- ?domain= 研究方向精确匹配;?cas_partition=1|2|3|4(仅 lang=en,zh 忽略);
|
||||
- ?q= 期刊名模糊(ASCII 大小写不敏感);?page≥1 ?size 1-200(默认 50);
|
||||
- ?field= 排序白名单(name/jif/composite_impact_factor/
|
||||
comprehensive_impact_factor/research_direction/cas_partition),?order=asc|desc;
|
||||
未传 field → 默认排序 en=jif 降序 / zh=复合IF 降序(NULL 恒排最后);
|
||||
- 参数非法 → VALIDATION 信封;表不存在 → data_imported=false 空列表(HTTP 200)。
|
||||
"""
|
||||
lang_n = (lang or "").strip().lower()
|
||||
if lang_n not in _LANGS:
|
||||
return _error("VALIDATION", f"不支持的 lang={lang!r},可选:en / zh")
|
||||
if page < 1:
|
||||
return _error("VALIDATION", f"page 必须 ≥ 1,收到 {page}")
|
||||
if size < 1 or size > _MAX_SIZE:
|
||||
return _error("VALIDATION", f"size 必须在 1-{_MAX_SIZE},收到 {size}")
|
||||
field_n = (field or "").strip()
|
||||
if field_n and field_n not in _FIELDS:
|
||||
return _error(
|
||||
"VALIDATION",
|
||||
f"不支持的 field={field!r},可选:{', '.join(sorted(_FIELDS))}",
|
||||
)
|
||||
order_n = (order or "").strip().lower()
|
||||
if order_n and order_n not in _ORDERS:
|
||||
return _error("VALIDATION", f"不支持的 order={order!r},可选:asc / desc")
|
||||
part_q = (cas_partition or "").strip()
|
||||
if part_q:
|
||||
part_q = _PARTS.get(part_q, _PARTS.get(part_q.rstrip("区").strip(), ""))
|
||||
if not part_q:
|
||||
return _error(
|
||||
"VALIDATION",
|
||||
f"不支持的 cas_partition={cas_partition!r},可选:1 / 2 / 3 / 4",
|
||||
)
|
||||
|
||||
# 过滤条件(白名单拼接;参数全部占位符)
|
||||
where = ["lang = ?"]
|
||||
params: list[Any] = [lang_n]
|
||||
domain_n = (domain or "").strip()
|
||||
if domain_n:
|
||||
where.append("research_direction = ?")
|
||||
params.append(domain_n)
|
||||
if part_q and lang_n == "en": # zh 不分区:忽略(meta 回显)
|
||||
where.append("cas_partition = ?")
|
||||
params.append(part_q)
|
||||
q_n = (q or "").strip()
|
||||
if q_n:
|
||||
where.append("name LIKE ? ESCAPE '\\'")
|
||||
params.append(f"%{_escape_like(q_n)}%")
|
||||
where_sql = " AND ".join(where)
|
||||
|
||||
# 排序(列名出自白名单;NULL 恒排最后)
|
||||
if field_n:
|
||||
dir_sql = "DESC" if order_n == "desc" else "ASC"
|
||||
order_sql = f"{field_n} IS NULL, {field_n} {dir_sql}"
|
||||
else:
|
||||
col = "jif" if lang_n == "en" else "composite_impact_factor"
|
||||
order_sql = f"{col} IS NULL, {col} DESC"
|
||||
|
||||
try:
|
||||
total_row = await db.fetchone(
|
||||
f"SELECT COUNT(*) AS c FROM journal_directory WHERE {where_sql}", params # noqa: S608
|
||||
)
|
||||
total = int(total_row["c"]) if total_row else 0
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM journal_directory WHERE {where_sql} " # noqa: S608
|
||||
f"ORDER BY {order_sql} LIMIT ? OFFSET ?",
|
||||
[*params, size, (page - 1) * size],
|
||||
)
|
||||
dom_rows = await db.fetchall(
|
||||
"SELECT research_direction AS d, COUNT(*) AS c FROM journal_directory "
|
||||
"WHERE lang = ? AND research_direction IS NOT NULL "
|
||||
"GROUP BY 1 ORDER BY c DESC, d ASC",
|
||||
[lang_n],
|
||||
)
|
||||
except sqlite3.OperationalError as exc:
|
||||
# 表不存在(脚本未跑)→ 优雅降级,HTTP 200
|
||||
logger.warning("journal_directory: 表不可用(目录未导入?): %s", exc)
|
||||
return DirectoryResponse(
|
||||
ok=True, items=[],
|
||||
meta={
|
||||
"total": 0, "page": page, "size": size, "lang": lang_n,
|
||||
"domain": domain_n, "cas_partition": part_q, "q": q_n,
|
||||
"domains": [], "data_source_note": _UNIMPORTED_NOTE,
|
||||
"data_imported": False,
|
||||
},
|
||||
error=None,
|
||||
)
|
||||
|
||||
items = [DirectoryItem(**_present(dict(r), lang_n)) for r in rows]
|
||||
meta: dict[str, Any] = {
|
||||
"total": total,
|
||||
"page": page,
|
||||
"size": size,
|
||||
"lang": lang_n,
|
||||
"domain": domain_n,
|
||||
"cas_partition": part_q if lang_n == "en" else "",
|
||||
"q": q_n,
|
||||
"domains": [r["d"] for r in dom_rows if r["d"]],
|
||||
"data_source_note": _NOTE_EN if lang_n == "en" else _NOTE_ZH,
|
||||
"data_imported": True,
|
||||
}
|
||||
return DirectoryResponse(ok=True, items=items, meta=meta, error=None)
|
||||
@@ -0,0 +1,354 @@
|
||||
"""routers/journals.py —— 期刊情报与智能投稿推荐。
|
||||
|
||||
- GET /api/review/journals 只读:journals 表全量列表(?field=/&order= 排序),
|
||||
meta 含 count / last_updated / data_source_note。无任何写接口;空表返回
|
||||
空数组 + meta.count=0(HTTP 200,绝不 500)。
|
||||
- POST /api/review/recommend 智能投稿推荐:vstore 向量匹配(摘要 vs
|
||||
scope_desc,白名单检索)→ LLM 按 id 白名单重排(幻觉期刊丢弃、缺漏规则
|
||||
补位)→ 三档输出:挑战型 / 适配型 / 稳妥型,附匹配理由与风险提示。
|
||||
|
||||
降级哲学(同 presubmit):向量失败 → 全量候选 + vector_ok=false;
|
||||
LLM 失败 → 规则分档 + llm_ok=false;推荐永不 5xx。仅入参非法返回
|
||||
VALIDATION 错误信封(HTTP 200,与本服务其余端点一致)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..config import get_settings
|
||||
from ..db import SeedError, ensure_journals_seeded, list_journals
|
||||
from ..services import journal_kb, journal_rank, llm
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/review", tags=["review-journals"])
|
||||
|
||||
|
||||
# ---- 请求/响应模型 ----
|
||||
|
||||
class Preferences(BaseModel):
|
||||
max_review_weeks: float | None = Field(default=None, ge=1, le=104)
|
||||
min_impact_factor: float | None = Field(default=None, ge=0, le=200)
|
||||
tier_focus: Literal["all", "challenge", "fit", "safe"] = "all"
|
||||
|
||||
|
||||
class RecommendRequest(BaseModel):
|
||||
abstract: str
|
||||
preferences: Preferences = Preferences()
|
||||
|
||||
|
||||
class JournalItem(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
impact_factor: float | None = None
|
||||
review_time_weeks: float | None = None
|
||||
acceptance_rate: float | None = None
|
||||
official_url: str | None = None
|
||||
similarity: float | None = None
|
||||
tier: str
|
||||
match_reason: str
|
||||
risk_note: str
|
||||
|
||||
|
||||
class TierBox(BaseModel):
|
||||
label: str
|
||||
items: list[JournalItem]
|
||||
|
||||
|
||||
class RecommendResponse(BaseModel):
|
||||
ok: bool
|
||||
tiers: dict[str, TierBox] | None = None
|
||||
meta: dict[str, Any] | None = None
|
||||
error: dict[str, Any] | None = None
|
||||
|
||||
|
||||
# ---- GET /api/review/journals(只读列表)----
|
||||
|
||||
class JournalBrief(BaseModel):
|
||||
"""期刊公开字段(journals 表全列;不含 vstore_doc_id 内部映射)。
|
||||
|
||||
标签/分区 6 字段不进 SQLite:GET 时按 id 从 journals.json 内存缓存合并;
|
||||
JSON 缺该 id → 空数组/null 默认值。
|
||||
"""
|
||||
id: str
|
||||
name: str
|
||||
scope_desc: str = ""
|
||||
impact_factor: float | None = None
|
||||
review_time_weeks: float | None = None
|
||||
acceptance_rate: float | None = None
|
||||
submission_deadline: str | None = None
|
||||
official_url: str | None = None
|
||||
last_updated: str = ""
|
||||
scope_tags: list[str] = []
|
||||
application_tags: list[str] = []
|
||||
cas_partition: str | None = None
|
||||
jcr_quartile: str | None = None
|
||||
partition_source: str | None = None
|
||||
cross_domain: list[str] = []
|
||||
|
||||
|
||||
class JournalsListResponse(BaseModel):
|
||||
ok: bool
|
||||
items: list[JournalBrief] = []
|
||||
meta: dict[str, Any] | None = None
|
||||
error: dict[str, Any] | None = None
|
||||
|
||||
|
||||
# ?field= 排序白名单(列名无法参数化,白名单防注入;非法值 → VALIDATION 信封)
|
||||
_SORT_FIELDS = frozenset({
|
||||
"id",
|
||||
"name",
|
||||
"impact_factor",
|
||||
"review_time_weeks",
|
||||
"acceptance_rate",
|
||||
"last_updated",
|
||||
})
|
||||
_DATA_SOURCE_NOTE = (
|
||||
"IF/审稿周期为公开近似值,acceptance_rate 为 null 表示暂无公开数据,需人工核验。"
|
||||
"CAS/JCR 分区为首版占位数据,需核验。"
|
||||
)
|
||||
|
||||
# journals.json 新增字段(标签/分区;刻意不进 SQLite,见 C-2 方案)
|
||||
_TAG_FIELDS = (
|
||||
"scope_tags",
|
||||
"application_tags",
|
||||
"cas_partition",
|
||||
"jcr_quartile",
|
||||
"partition_source",
|
||||
"cross_domain",
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _load_tag_extras() -> dict[str, dict[str, Any]]:
|
||||
"""journals.json 标签/分区字段 → {id: {6 新字段}}。
|
||||
|
||||
首次请求读盘后进程内缓存,后续 GET O(1) 按 id 合并(不做每请求磁盘 IO)。
|
||||
JSON 缺该 id → 合并时用空数组/null 默认;文件不可读 → 空 dict 降级(不 500)。
|
||||
"""
|
||||
seed = get_settings().journals_seed_abs
|
||||
try:
|
||||
payload = json.loads(Path(seed).read_text("utf-8"))
|
||||
except (OSError, ValueError) as exc:
|
||||
logger.warning("journals: 标签扩展文件不可读,合并降级为默认值:%s", exc)
|
||||
return {}
|
||||
raw = payload.get("journals") if isinstance(payload, dict) else None
|
||||
if not isinstance(raw, list):
|
||||
return {}
|
||||
out: dict[str, dict[str, Any]] = {}
|
||||
for j in raw:
|
||||
if isinstance(j, dict) and j.get("id"):
|
||||
out[str(j["id"])] = {k: j.get(k) for k in _TAG_FIELDS}
|
||||
return out
|
||||
|
||||
|
||||
def _tag_fields(extra: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""缓存条目 → JournalBrief 关键字参数(JSON 缺该 id → 空数组/null 默认)。"""
|
||||
ex = extra or {}
|
||||
return {
|
||||
"scope_tags": list(ex.get("scope_tags") or []),
|
||||
"application_tags": list(ex.get("application_tags") or []),
|
||||
"cas_partition": ex.get("cas_partition"),
|
||||
"jcr_quartile": ex.get("jcr_quartile"),
|
||||
"partition_source": ex.get("partition_source"),
|
||||
"cross_domain": list(ex.get("cross_domain") or []),
|
||||
}
|
||||
|
||||
|
||||
def _sort_rows(rows: list[dict[str, Any]], field: str, order: str) -> list[dict[str, Any]]:
|
||||
"""按白名单字段排序;NULL 恒排最后;次序不稳定时按 db 默认序(稳定排序保留)。"""
|
||||
present = [r for r in rows if r.get(field) is not None]
|
||||
missing = [r for r in rows if r.get(field) is None]
|
||||
present.sort(key=lambda r: r.get(field), reverse=(order == "desc"))
|
||||
return present + missing
|
||||
|
||||
|
||||
def _list_error(message: str) -> JournalsListResponse:
|
||||
return JournalsListResponse(
|
||||
ok=False, items=[], meta=None,
|
||||
error={"code": "VALIDATION", "message": message, "degraded": False},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/journals", response_model=JournalsListResponse)
|
||||
async def journals_list(
|
||||
field: str = "name",
|
||||
order: str = "asc",
|
||||
) -> JournalsListResponse:
|
||||
"""只读:journals 表全量列表(无写/删/改接口)。
|
||||
|
||||
- ?field= 排序字段白名单:id/name/impact_factor/review_time_weeks/
|
||||
acceptance_rate/last_updated,默认 name;
|
||||
- ?order=asc|desc,默认 asc;
|
||||
- 空表 → items=[] + meta.count=0(HTTP 200,不报错)。种子装载由
|
||||
POST /recommend 首次调用幂等完成,本端点严格只读。
|
||||
"""
|
||||
col = (field or "").strip()
|
||||
if col not in _SORT_FIELDS:
|
||||
return _list_error(
|
||||
f"不支持的排序字段 field={field!r},可选:{', '.join(sorted(_SORT_FIELDS))}"
|
||||
)
|
||||
ord_norm = (order or "asc").strip().lower()
|
||||
if ord_norm not in ("asc", "desc"):
|
||||
return _list_error(f"不支持的排序方向 order={order!r},可选:asc, desc")
|
||||
|
||||
rows = await list_journals()
|
||||
rows = _sort_rows(rows, col, ord_norm)
|
||||
|
||||
tag_extras = _load_tag_extras()
|
||||
items = [
|
||||
JournalBrief(
|
||||
id=str(r.get("id") or ""),
|
||||
name=str(r.get("name") or ""),
|
||||
scope_desc=str(r.get("scope_desc") or ""),
|
||||
impact_factor=r.get("impact_factor"),
|
||||
review_time_weeks=r.get("review_time_weeks"),
|
||||
acceptance_rate=r.get("acceptance_rate"),
|
||||
submission_deadline=r.get("submission_deadline"),
|
||||
official_url=r.get("official_url"),
|
||||
last_updated=str(r.get("last_updated") or ""),
|
||||
**_tag_fields(tag_extras.get(str(r.get("id") or ""))),
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
latest = max((str(r.get("last_updated") or "") for r in rows), default="")
|
||||
meta: dict[str, Any] = {
|
||||
"count": len(items),
|
||||
"last_updated": latest or None,
|
||||
"data_source_note": _DATA_SOURCE_NOTE,
|
||||
}
|
||||
return JournalsListResponse(ok=True, items=items, meta=meta, error=None)
|
||||
|
||||
|
||||
def _error(code: str, message: str, *, degraded: bool = False) -> RecommendResponse:
|
||||
return RecommendResponse(
|
||||
ok=False, tiers=None, meta=None,
|
||||
error={"code": code, "message": message, "degraded": degraded},
|
||||
)
|
||||
|
||||
|
||||
def _item_sort_key(it: JournalItem) -> tuple[int, float, float]:
|
||||
return (
|
||||
1 if it.similarity is None else 0,
|
||||
-(it.similarity or 0.0),
|
||||
-(it.impact_factor or 0.0),
|
||||
)
|
||||
|
||||
|
||||
def _apply_soft_preferences(item: JournalItem, prefs: Preferences) -> None:
|
||||
"""软偏好:不满足只加风险提示,不删条目。"""
|
||||
weeks = item.review_time_weeks
|
||||
if prefs.max_review_weeks is not None and weeks is not None and weeks > prefs.max_review_weeks:
|
||||
prefix = f"⚠ 审稿约 {weeks:g} 周,超出你 {prefs.max_review_weeks:g} 周的周期偏好;"
|
||||
item.risk_note = prefix + ("无" if item.risk_note == "无" else item.risk_note)
|
||||
inf = item.impact_factor
|
||||
if prefs.min_impact_factor is not None:
|
||||
if inf is None:
|
||||
item.risk_note = "⚠ 影响因子未收录,无法确认满足你的要求;" + item.risk_note
|
||||
elif inf < prefs.min_impact_factor:
|
||||
prefix = f"⚠ 影响因子 {inf:g} 低于你 {prefs.min_impact_factor:g} 的要求;"
|
||||
item.risk_note = prefix + ("无" if item.risk_note == "无" else item.risk_note)
|
||||
|
||||
|
||||
@router.post("/recommend", response_model=RecommendResponse)
|
||||
async def recommend(req: RecommendRequest) -> RecommendResponse:
|
||||
settings = get_settings()
|
||||
|
||||
# ---- 入参校验(手动,错误信封与本服务其余端点一致)----
|
||||
abstract = (req.abstract or "").strip()
|
||||
if len(abstract) < 50:
|
||||
return _error("VALIDATION", "abstract 至少需要 50 个字符(请提供摘要或正文片段)")
|
||||
if len(abstract) > 20000:
|
||||
return _error("VALIDATION", "abstract 超过 20000 字符上限")
|
||||
|
||||
prefs = req.preferences
|
||||
|
||||
# ---- 种子 + 期刊元数据 ----
|
||||
try:
|
||||
await ensure_journals_seeded()
|
||||
journals = await list_journals()
|
||||
except SeedError as exc:
|
||||
logger.error("recommend: 种子装载失败:%s", exc)
|
||||
return _error("SEED_UNAVAILABLE", str(exc), degraded=True)
|
||||
if not journals:
|
||||
return _error("SEED_UNAVAILABLE", "期刊库为空", degraded=True)
|
||||
|
||||
# ---- ① vstore 向量匹配(摘要 vs scope_desc)----
|
||||
try:
|
||||
candidates, vector_ok = await journal_kb.match(abstract, journals)
|
||||
except journal_kb.MatchError as exc:
|
||||
logger.warning("recommend: 向量匹配降级:%s", exc)
|
||||
candidates, vector_ok = [], False
|
||||
if not candidates:
|
||||
# 无有效语义分 → 全量候选(similarity=None),LLM 仍可按 scope 文本重排
|
||||
candidates = [{"journal": j, "similarity": None} for j in journals]
|
||||
vector_ok = False
|
||||
|
||||
# ---- ② LLM 重排(id 白名单硬校验)----
|
||||
prefs_dict = prefs.model_dump(exclude_none=True)
|
||||
llm_ok = True
|
||||
dropped = 0
|
||||
repaired = 0
|
||||
try:
|
||||
rankings, dropped, repaired = await journal_rank.rerank(abstract, candidates, prefs_dict)
|
||||
except (llm.LLMError, journal_rank.RankError, ValueError) as exc:
|
||||
# ValueError:complete_json 最终 json.loads 修复仍失败(同 presubmit 宽容口径)
|
||||
logger.warning("recommend: LLM 重排降级为规则分档:%s", exc)
|
||||
rankings = journal_rank.fallback_rankings(candidates)
|
||||
llm_ok = False
|
||||
|
||||
# ---- ③ 三档组装(名称/指标全部来自 DB,不采信 LLM 文本)----
|
||||
by_id = {str(c["journal"]["id"]): c for c in candidates}
|
||||
tiers: dict[str, list[JournalItem]] = {t: [] for t in journal_rank.TIERS}
|
||||
for r in rankings:
|
||||
cand = by_id.get(r.journal_id)
|
||||
if cand is None:
|
||||
continue
|
||||
j = cand["journal"]
|
||||
sim = cand.get("similarity")
|
||||
item = JournalItem(
|
||||
id=str(j["id"]),
|
||||
name=str(j.get("name") or j["id"]),
|
||||
impact_factor=j.get("impact_factor"),
|
||||
review_time_weeks=j.get("review_time_weeks"),
|
||||
acceptance_rate=j.get("acceptance_rate"),
|
||||
official_url=j.get("official_url"),
|
||||
similarity=round(float(sim), 4) if sim is not None else None,
|
||||
tier=r.tier,
|
||||
match_reason=r.match_reason,
|
||||
risk_note=r.risk_note,
|
||||
)
|
||||
_apply_soft_preferences(item, prefs)
|
||||
tiers.setdefault(r.tier, []).append(item)
|
||||
|
||||
# 档内排序:相似度(None 靠后)→ 影响因子
|
||||
for box in tiers.values():
|
||||
box.sort(key=_item_sort_key)
|
||||
|
||||
# tier_focus:软展示聚焦(默认 all;聚焦时其余档 items 置空,label 保留)
|
||||
out_tiers: dict[str, TierBox] = {}
|
||||
for t in journal_rank.TIERS:
|
||||
items = tiers.get(t, [])
|
||||
if prefs.tier_focus != "all" and t != prefs.tier_focus:
|
||||
items = []
|
||||
out_tiers[t] = TierBox(label=journal_rank.TIER_LABELS[t], items=items)
|
||||
|
||||
meta: dict[str, Any] = {
|
||||
"model": llm._llm_config()["model"],
|
||||
"vector_ok": vector_ok,
|
||||
"llm_ok": llm_ok,
|
||||
"seed_count": len(journals),
|
||||
"candidate_count": len(candidates),
|
||||
"dropped_hallucinated": dropped,
|
||||
"repaired": repaired,
|
||||
"preferences": prefs_dict,
|
||||
"abstract_chars": len(abstract),
|
||||
}
|
||||
return RecommendResponse(ok=True, tiers=out_tiers, meta=meta, error=None)
|
||||
@@ -0,0 +1,317 @@
|
||||
"""routers/ppt.py —— P5 科研汇报助手(9 端点,SQLite job 模式)。
|
||||
|
||||
信封与其余 review 端点一致:业务错误 = HTTP 200 + {ok:false,error:{code,message,degraded}}。
|
||||
- GET /styles:风格清单(data/ppt_styles/*.json 实际数量 → 副标题/风格卡动态渲染)
|
||||
- 创建(text 主入口 / pdf 次要入口)= 解析 + map-reduce 大纲生成(同步;
|
||||
map 并发 2 + 单调用超时收紧,总时长压在 BFF 读超时 360s 内)
|
||||
- PATCH /jobs/{id}:结构清洗(复用 coerce_outline)+ evidence 状态按 raw_text
|
||||
重验(编辑后 quote 真伪即时刷新);warnings 保留生成期记录(页数/拆页等
|
||||
结构提示不因编辑丢失)
|
||||
- POST /jobs/{id}/build:python-pptx 本地生成 → data/ppt_jobs/{id}/draft.pptx
|
||||
+ 逐页缩略图渲染 → preview/slide_{n}.png(失败仅 warning,不阻断下载)
|
||||
- GET /jobs/{id}/pptx:FileResponse 附件下载
|
||||
- GET /jobs/{id}/preview/{page}:第 page 页 PNG 缩略图(旧 job 缺图懒渲染)
|
||||
- DELETE 手动清行 + 产物目录(无 TTL,对齐 rebuttal 决策 5)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, File, Form, UploadFile
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..config import get_settings
|
||||
from ..services import paper_parse, ppt_build, ppt_outline, ppt_preview, ppt_store
|
||||
from ..services.ppt_build import BuildError
|
||||
from ..services.ppt_figure_index import extract_figures, scan_figure_index
|
||||
from ..services.ppt_outline import (
|
||||
OutlineError,
|
||||
build_index,
|
||||
coerce_outline,
|
||||
renumber,
|
||||
repair_evidence_para_ids,
|
||||
validate_evidence,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/review/ppt", tags=["review-ppt"])
|
||||
|
||||
_MAX_RAW_CHARS = 500_000 # 粘贴文本上限(论文正文通常 ≤150K,留编辑余量)
|
||||
|
||||
|
||||
# ---------- 信封 ----------
|
||||
class PptResponse(BaseModel):
|
||||
ok: bool
|
||||
job: dict[str, Any] | None = None
|
||||
error: dict[str, Any] | None = None
|
||||
meta: dict[str, Any] | None = None
|
||||
|
||||
|
||||
def _error(code: str, message: str) -> PptResponse:
|
||||
return PptResponse(ok=False, error={"code": code, "message": message, "degraded": False})
|
||||
|
||||
|
||||
def _ok(job: dict[str, Any], **meta: Any) -> PptResponse:
|
||||
return PptResponse(ok=True, job=job, meta=meta or None)
|
||||
|
||||
|
||||
class StylesResponse(BaseModel):
|
||||
"""GET /styles 信封(清单在 items,不用 job 字段)。"""
|
||||
|
||||
ok: bool
|
||||
items: list[dict[str, Any]] = Field(default_factory=list)
|
||||
meta: dict[str, Any] | None = None
|
||||
|
||||
|
||||
# ---------- 请求体 ----------
|
||||
class JobCreateBody(BaseModel):
|
||||
text: str = Field(min_length=1)
|
||||
scenario: str = "group_meeting"
|
||||
minutes: int = 15
|
||||
style_id: str = "academic-minimal"
|
||||
|
||||
|
||||
class PagesBody(BaseModel):
|
||||
pages: list[dict[str, Any]] = Field(min_length=1)
|
||||
|
||||
|
||||
# ---------- 内部工具 ----------
|
||||
def _pptx_path(job_id: str) -> Path:
|
||||
return get_settings().ppt_jobs_abs / job_id / "draft.pptx"
|
||||
|
||||
|
||||
def _preview_dir(job_id: str) -> Path:
|
||||
return get_settings().ppt_jobs_abs / job_id / "preview"
|
||||
|
||||
|
||||
def _preview_path(job_id: str, page: int) -> Path:
|
||||
return _preview_dir(job_id) / f"slide_{page:02d}.png"
|
||||
|
||||
|
||||
async def _create_job(
|
||||
text: str, kind: str, filename: str | None,
|
||||
scenario: str, minutes: int, style_id: str,
|
||||
*, job_id: str | None = None, fig_meta: dict[str, Any] | None = None,
|
||||
) -> PptResponse:
|
||||
if len(text) > _MAX_RAW_CHARS:
|
||||
return _error("VALIDATION", f"正文超过 {_MAX_RAW_CHARS} 字符上限")
|
||||
# v3 修复2:PDF 提取出的原图 label → 大纲生成只从这些图里选(build 必能嵌入)
|
||||
available_figures = None
|
||||
if fig_meta:
|
||||
fig_meta = dict(fig_meta)
|
||||
available_figures = fig_meta.pop("figure_labels", None) or None
|
||||
try:
|
||||
pages, warnings, meta = await ppt_outline.generate_outline(
|
||||
text, scenario, minutes, style_id,
|
||||
available_figures=available_figures,
|
||||
)
|
||||
except OutlineError as exc:
|
||||
return _error(exc.code, exc.message)
|
||||
job = {
|
||||
"id": job_id or ppt_store.new_id("ppt_"),
|
||||
"status": "outline_ready",
|
||||
"source": {"kind": kind, "filename": filename},
|
||||
"scenario": scenario,
|
||||
"minutes": minutes,
|
||||
"style_id": style_id,
|
||||
"pages": pages,
|
||||
"warnings": warnings,
|
||||
"pptx_path": None,
|
||||
}
|
||||
ppt_store.insert_job(job, raw_text=text)
|
||||
if fig_meta:
|
||||
meta = {**meta, **fig_meta}
|
||||
return _ok(ppt_store.get_job(job["id"]) or job, **meta)
|
||||
|
||||
|
||||
def _extract_job_figures(pdf_bytes: bytes, job_id: str) -> dict[str, Any]:
|
||||
"""2.3:PDF 原图 → data/ppt_jobs/{id}/figs/(失败仅告警,不阻塞建 job)。"""
|
||||
out_dir = get_settings().ppt_jobs_abs / job_id / "figs"
|
||||
try:
|
||||
figs = extract_figures(pdf_bytes, out_dir)
|
||||
except Exception as exc: # noqa: BLE001 - 提取属增强项,绝不 500
|
||||
logger.warning("图表原图提取失败(该 job 回落文字占位):%s", exc)
|
||||
return {"figures_extracted": 0, "figures_extract_error": str(exc)[:200]}
|
||||
# v3 修复2:已提取 label 供大纲只选「磁盘上有图」的条目
|
||||
return {
|
||||
"figures_extracted": len(figs),
|
||||
"figure_labels": [str(r.get("label") or "") for r in figs if r.get("label")],
|
||||
}
|
||||
|
||||
|
||||
def _not_found(job_id: str) -> PptResponse:
|
||||
return _error("NOT_FOUND", f"job {job_id} 不存在或已删除")
|
||||
|
||||
|
||||
# ---------- 端点 ----------
|
||||
@router.get("/styles", response_model=StylesResponse)
|
||||
async def get_styles() -> StylesResponse:
|
||||
"""风格清单(data/ppt_styles/*.json 实际数量 + 文案;副标题/风格卡按它渲染)。"""
|
||||
items = ppt_build.list_styles()
|
||||
return StylesResponse(ok=True, items=items, meta={"count": len(items)})
|
||||
|
||||
|
||||
@router.post("/jobs", response_model=PptResponse)
|
||||
async def create_job(body: JobCreateBody) -> PptResponse:
|
||||
"""文本粘贴主入口(论文纯文本 → 大纲)。"""
|
||||
return await _create_job(
|
||||
body.text, kind="text", filename=None,
|
||||
scenario=body.scenario, minutes=body.minutes, style_id=body.style_id,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/jobs/pdf", response_model=PptResponse)
|
||||
async def create_job_pdf(
|
||||
file: UploadFile = File(...),
|
||||
scenario: str = Form("group_meeting"),
|
||||
minutes: int = Form(15),
|
||||
style_id: str = Form("academic-minimal"),
|
||||
) -> PptResponse:
|
||||
"""PDF 次要入口(经 vstore 8094 解析 → 大纲)。"""
|
||||
settings = get_settings()
|
||||
filename = file.filename or "paper.pdf"
|
||||
if not filename.lower().endswith(".pdf"):
|
||||
return _error("VALIDATION", "仅支持 PDF 文件")
|
||||
data = await file.read()
|
||||
if not data:
|
||||
return _error("VALIDATION", "上传文件为空")
|
||||
if len(data) > settings.max_pdf_bytes:
|
||||
return _error("VALIDATION", f"PDF 超过 {settings.max_pdf_bytes // (1024 * 1024)}MB 上限")
|
||||
try:
|
||||
text = await paper_parse.parse_pdf_bytes(data, filename=filename)
|
||||
except Exception as exc: # noqa: BLE001 - ParseError/网络异常统一信封
|
||||
return _error("UPSTREAM_DOWN", f"PDF 解析失败:{exc}")
|
||||
# 2.3:先取 job_id 并提取原图(build 侧按 out 同级 figs/ 自动发现真图)
|
||||
job_id = ppt_store.new_id("ppt_")
|
||||
fig_meta = _extract_job_figures(data, job_id)
|
||||
return await _create_job(
|
||||
text, kind="pdf", filename=filename,
|
||||
scenario=scenario, minutes=minutes, style_id=style_id,
|
||||
job_id=job_id, fig_meta=fig_meta,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/jobs/{job_id}", response_model=PptResponse)
|
||||
async def get_job(job_id: str) -> PptResponse:
|
||||
job = ppt_store.get_job(job_id)
|
||||
if job is None:
|
||||
return _not_found(job_id)
|
||||
return _ok(job)
|
||||
|
||||
|
||||
@router.patch("/jobs/{job_id}", response_model=PptResponse)
|
||||
async def patch_pages(job_id: str, body: PagesBody) -> PptResponse:
|
||||
"""保存前端编辑后的完整 pages(结构清洗 + evidence 状态重验)。"""
|
||||
job = ppt_store.get_job(job_id)
|
||||
if job is None:
|
||||
return _not_found(job_id)
|
||||
try:
|
||||
pages = coerce_outline({"pages": body.pages})
|
||||
except OutlineError as exc:
|
||||
return _error("VALIDATION", exc.message)
|
||||
renumber(pages)
|
||||
raw_text = ppt_store.get_raw_text(job_id)
|
||||
if raw_text:
|
||||
_, index = build_index(raw_text)
|
||||
repair_evidence_para_ids(pages, index) # 编辑保存时同样反查修正(兼容旧 job)
|
||||
validate_evidence(pages, index, scan_figure_index(index))
|
||||
updated = ppt_store.update_job(job_id, pages=pages, pptx_path=None) # 编辑后旧 pptx 失效
|
||||
return _ok(updated or job)
|
||||
|
||||
|
||||
@router.post("/jobs/{job_id}/build", response_model=PptResponse)
|
||||
async def build_job(job_id: str) -> PptResponse:
|
||||
"""大纲 → draft.pptx(python-pptx 本地生成,覆盖重建)。"""
|
||||
job = ppt_store.get_job(job_id)
|
||||
if job is None:
|
||||
return _not_found(job_id)
|
||||
if not job.get("pages"):
|
||||
return _error("VALIDATION", "大纲为空,无法生成 PPT")
|
||||
ppt_store.update_job(job_id, status="building")
|
||||
try:
|
||||
stats = ppt_build.build_pptx(
|
||||
job["pages"], job.get("style_id") or "academic-minimal",
|
||||
_pptx_path(job_id),
|
||||
context={"scenario": job.get("scenario"), "minutes": job.get("minutes")},
|
||||
)
|
||||
except BuildError as exc:
|
||||
warnings = list(job.get("warnings") or []) + [f"PPT 生成失败:{exc.message}"]
|
||||
ppt_store.update_job(job_id, status="error", warnings=warnings)
|
||||
fresh = ppt_store.get_job(job_id)
|
||||
return PptResponse(
|
||||
ok=False, job=fresh,
|
||||
error={"code": exc.code, "message": exc.message, "degraded": False},
|
||||
)
|
||||
# 浏览器内预览:build 成功即渲染缩略图;失败仅告警,不阻断下载
|
||||
previews = ppt_preview.render_previews(_pptx_path(job_id), _preview_dir(job_id))
|
||||
stats["previews"] = len(previews)
|
||||
updated = ppt_store.update_job(job_id, status="done", pptx_path=str(_pptx_path(job_id)))
|
||||
if not previews:
|
||||
fresh_warnings = list((updated or job).get("warnings") or []) + ["预览图生成失败(可正常下载 PPTX)"]
|
||||
updated = ppt_store.update_job(job_id, warnings=fresh_warnings) or updated
|
||||
return _ok(updated or job, **stats)
|
||||
|
||||
|
||||
@router.get("/jobs/{job_id}/pptx", response_model=None)
|
||||
async def download_pptx(job_id: str) -> FileResponse | JSONResponse:
|
||||
"""下载 draft.pptx(二进制流;缺失时回同款信封 JSON,对齐 compile 下载端点)。"""
|
||||
job = ppt_store.get_job(job_id)
|
||||
# pptx_ready 同时看 DB 列与磁盘文件:编辑后列被清 → 盘上旧文件不再下发
|
||||
if job is None or not job.get("pptx_ready"):
|
||||
resp = _not_found(job_id)
|
||||
return JSONResponse(status_code=200, content=resp.model_dump())
|
||||
path = _pptx_path(job_id)
|
||||
filename = path.name
|
||||
src_name = (job.get("source") or {}).get("filename")
|
||||
if src_name:
|
||||
filename = Path(src_name).stem + ".pptx"
|
||||
return FileResponse(
|
||||
str(path),
|
||||
media_type="application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
filename=filename,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/jobs/{job_id}/preview/{page}", response_model=None)
|
||||
async def preview_slide(job_id: str, page: int) -> FileResponse | JSONResponse:
|
||||
"""第 page 页缩略图 PNG(1-based;对齐 download_pptx 的信封约定)。
|
||||
|
||||
缺 preview 但 pptx 在盘(旧 job,端点上线前构建的)→ 懒渲染一次再回图。
|
||||
job 不存在 / pptx 未就绪 / 页码越界 → HTTP 200 + 信封 JSON(对齐 pptx 下载)。
|
||||
"""
|
||||
job = ppt_store.get_job(job_id)
|
||||
if job is None or not job.get("pptx_ready"):
|
||||
return JSONResponse(status_code=200, content=_not_found(job_id).model_dump())
|
||||
png = _preview_path(job_id, page)
|
||||
if not png.is_file():
|
||||
# 懒渲染:首次访问补齐(渲染后仍不在 → 页码越界)
|
||||
written = ppt_preview.render_previews(_pptx_path(job_id), _preview_dir(job_id))
|
||||
png = _preview_path(job_id, page)
|
||||
if not png.is_file():
|
||||
if page < 1:
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content=_error("VALIDATION", "page 必须 ≥1").model_dump(),
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content=_error("NOT_FOUND", f"page {page} 不存在(共 {len(written)} 页)").model_dump(),
|
||||
)
|
||||
return FileResponse(str(png), media_type="image/png")
|
||||
|
||||
|
||||
@router.delete("/jobs/{job_id}", response_model=PptResponse)
|
||||
async def delete_job(job_id: str) -> PptResponse:
|
||||
job = ppt_store.get_job(job_id)
|
||||
if job is None:
|
||||
return _not_found(job_id)
|
||||
ppt_store.delete_job(job_id)
|
||||
job_dir = get_settings().ppt_jobs_abs / job_id
|
||||
if job_dir.exists():
|
||||
shutil.rmtree(job_dir, ignore_errors=True)
|
||||
return PptResponse(ok=True, meta={"deleted": job_id})
|
||||
@@ -0,0 +1,269 @@
|
||||
"""routers/presubmit.py —— 投稿前自审稿(POST /api/review/presubmit)。
|
||||
|
||||
流程:上传 PDF → paper_parse(vstore 8094 解析)→ 段落感知装箱 + token 双层保护
|
||||
→ LLM 结构化审稿(DeepSeek json_object,System Prompt 为植物基因组学六维专属
|
||||
清单,见 services/self_review.py)→ coerce 归一化 → 关键词扫描器证伪硬性缺项
|
||||
(services/paper_scanner.py)→ 规则引擎计算 recommendation。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
from fastapi import APIRouter, File, Form, UploadFile
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..config import get_settings
|
||||
from ..services import llm, paper_parse, paper_scanner, self_review
|
||||
from ..services.recommend_engine import decide_recommendation, resolve_tier
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/review", tags=["review-presubmit"])
|
||||
|
||||
# ---- token 双层保护(2026-10-12)----
|
||||
# 第一层:估算 token 超阈值 → 自动降到 TOKEN_GUARD_REPACK_CHARS 字符重装箱
|
||||
# 第二层:LLM 返回 HTTP 400/413 → 降到 LLM_REJECT_RETRY_CHARS 字符重试一次
|
||||
_EST_TOKENS_DIVISOR = 1.5 # 粗略上界:英文 ~4 字符/token,1.5 偏保守
|
||||
_TOKEN_GUARD_THRESHOLD = 48000
|
||||
_TOKEN_GUARD_REPACK_CHARS = 100000
|
||||
_LLM_REJECT_RETRY_CHARS = 80000
|
||||
# 无节标题可辨时的尾窗(捞页尾 Data Availability 块)
|
||||
_TAIL_FALLBACK_CHARS = 5000
|
||||
|
||||
# 节标题(行首、允许编号前缀、独占一行);键 → 装箱分组名
|
||||
_SECTION_HEADER_RE = re.compile(
|
||||
r"^[ \t]*(?:\d+(?:\.\d+)*\.?\s+)?"
|
||||
r"(Abstract|Summary|Introduction|Results|Discussion|Methods"
|
||||
r"|Materials\s+and\s+Methods"
|
||||
r"|Data\s+(?:and\s+(?:code\s+)?)?[Aa]vailability"
|
||||
r"|Availability\s+of\s+[Dd]ata"
|
||||
r"|Data\s+and\s+[Mm]aterials\s+[Aa]vailability"
|
||||
r"|References|Acknowledg(?:e)?ments?)\s*$",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_SECTION_GROUP = {
|
||||
"abstract": "abstract", "summary": "abstract",
|
||||
"introduction": "introduction",
|
||||
"results": "results",
|
||||
"discussion": "discussion",
|
||||
"methods": "methods", "materials and methods": "methods",
|
||||
"data availability": "data_availability",
|
||||
"data and code availability": "data_availability",
|
||||
"data availability statement": "data_availability",
|
||||
"availability of data": "data_availability",
|
||||
"data and materials availability": "data_availability",
|
||||
"references": "references", "acknowledgements": "acknowledgements",
|
||||
"acknowledgments": "acknowledgements",
|
||||
}
|
||||
# 装箱优先级(首条命中优先;methods 限 60% 预算——六维清单大量依赖 Methods)
|
||||
_PACK_PRIORITY = ("abstract", "data_availability", "methods", "results", "discussion")
|
||||
_METHODS_CAP_RATIO = 0.6
|
||||
|
||||
|
||||
def estimate_tokens(text: str) -> int:
|
||||
"""粗略 token 上界估计(len/1.5),无需 tokenizer 依赖。"""
|
||||
return int(len(text) / _EST_TOKENS_DIVISOR)
|
||||
|
||||
|
||||
def _split_segments(text: str) -> list[tuple[str, str]] | None:
|
||||
"""按节标题切段 → [(group, content)...]。无可辨标题 → None。"""
|
||||
matches = list(_SECTION_HEADER_RE.finditer(text))
|
||||
if not matches:
|
||||
return None
|
||||
segments: list[tuple[str, str]] = []
|
||||
if matches[0].start() > 0:
|
||||
segments.append(("preamble", text[:matches[0].start()]))
|
||||
for i, m in enumerate(matches):
|
||||
end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
|
||||
group = _SECTION_GROUP.get(m.group(1).casefold(), m.group(1).casefold())
|
||||
segments.append((group, text[m.start():end]))
|
||||
return segments
|
||||
|
||||
|
||||
def pack_feed_text(text: str, budget: int) -> tuple[str, list[str]]:
|
||||
"""段落感知装箱 → (feed_text, dropped_sections)。
|
||||
|
||||
预算内:原文返回。超预算:按优先级保留
|
||||
① Abstract ② Data Availability 全文 ③ Methods 前 60% ④ Results
|
||||
⑤ Discussion,其余按原序填满;未收段记入 dropped。
|
||||
无节标题可辨 → 头 + 尾窗拼接(捞页尾 availability 块),不优于现状亦不劣于。
|
||||
"""
|
||||
if len(text) <= budget:
|
||||
return text, []
|
||||
segments = _split_segments(text)
|
||||
if not segments:
|
||||
sep = "\n[...]\n"
|
||||
head = max(0, budget - _TAIL_FALLBACK_CHARS - len(sep))
|
||||
return (
|
||||
text[:head] + sep + text[-_TAIL_FALLBACK_CHARS:],
|
||||
["[无节标题回退:保留头+尾窗]"],
|
||||
)
|
||||
methods_cap = int(budget * _METHODS_CAP_RATIO)
|
||||
chosen: dict[int, str] = {}
|
||||
used = 0
|
||||
for group in _PACK_PRIORITY:
|
||||
for i, (g, content) in enumerate(segments):
|
||||
if g != group or i in chosen:
|
||||
continue
|
||||
room = budget - used
|
||||
if room <= 0:
|
||||
break
|
||||
piece = content[:methods_cap] if group == "methods" else content
|
||||
if len(piece) > room:
|
||||
piece = piece[:room]
|
||||
if not piece:
|
||||
break
|
||||
chosen[i] = piece
|
||||
used += len(piece)
|
||||
break
|
||||
for i, (_, content) in enumerate(segments):
|
||||
if i in chosen or used >= budget:
|
||||
continue
|
||||
room = budget - used
|
||||
chosen[i] = content if len(content) <= room else content[:room]
|
||||
used += len(chosen[i])
|
||||
feed = "".join(chosen[i] for i in sorted(chosen))
|
||||
dropped: list[str] = []
|
||||
for i, (g, _) in enumerate(segments):
|
||||
if i not in chosen and g not in dropped:
|
||||
dropped.append(g)
|
||||
return feed, dropped
|
||||
|
||||
|
||||
def _fit_feed_text(text: str, budget: int) -> tuple[str, list[str]]:
|
||||
"""装箱 + 第一层 token 保护(估算超阈值自动降到 100K 字符重装)。"""
|
||||
feed, dropped = pack_feed_text(text, budget)
|
||||
if estimate_tokens(feed) > _TOKEN_GUARD_THRESHOLD and len(feed) > _TOKEN_GUARD_REPACK_CHARS:
|
||||
logger.info(
|
||||
"presubmit: token 估算 %d > %d,降到 %d 字符重装箱",
|
||||
estimate_tokens(feed), _TOKEN_GUARD_THRESHOLD, _TOKEN_GUARD_REPACK_CHARS,
|
||||
)
|
||||
feed, dropped = pack_feed_text(text, _TOKEN_GUARD_REPACK_CHARS)
|
||||
return feed, dropped
|
||||
|
||||
|
||||
class PresubmitResponse(BaseModel):
|
||||
ok: bool
|
||||
report: self_review.SelfReviewReport | None = None
|
||||
error: dict[str, object] | None = None
|
||||
meta: dict[str, object] | None = None
|
||||
|
||||
|
||||
def _error(code: str, message: str, *, degraded: bool = False) -> PresubmitResponse:
|
||||
return PresubmitResponse(
|
||||
ok=False, report=None,
|
||||
error={"code": code, "message": message, "degraded": degraded},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/presubmit", response_model=PresubmitResponse)
|
||||
async def presubmit_review(
|
||||
file: UploadFile = File(..., description="待审稿 PDF"),
|
||||
target_journal: str = Form("", description="目标期刊(可选,影响格式类意见)"),
|
||||
) -> PresubmitResponse:
|
||||
settings = get_settings()
|
||||
|
||||
filename = file.filename or "paper.pdf"
|
||||
if not filename.lower().endswith(".pdf"):
|
||||
return _error("VALIDATION", "仅支持 PDF 文件")
|
||||
|
||||
content = await file.read()
|
||||
if not content:
|
||||
return _error("VALIDATION", "上传文件为空")
|
||||
if len(content) > settings.max_pdf_bytes:
|
||||
return _error("VALIDATION", "PDF 超过 50MB 上限")
|
||||
|
||||
# ---- 解析(vstore 8094)----
|
||||
try:
|
||||
text = await paper_parse.parse_pdf_bytes(content, filename=filename)
|
||||
except paper_parse.ParseError as exc:
|
||||
logger.warning("presubmit: 解析失败:%s", exc)
|
||||
return _error("PARSE_FAILED", str(exc), degraded=True)
|
||||
|
||||
truncated = len(text) > settings.max_text_chars
|
||||
feed_text, dropped_sections = _fit_feed_text(text, settings.max_text_chars)
|
||||
|
||||
# ---- LLM 结构化自审(植物基因组学六维清单)----
|
||||
# 第二层保护:HTTP 400/413(疑似超长/形态拒绝)→ 降 80K 字符重试一次
|
||||
user_prompt = self_review.build_user_prompt(feed_text, (target_journal or "").strip())
|
||||
try:
|
||||
try:
|
||||
payload = await llm.complete_json(
|
||||
self_review.SYSTEM_PROMPT,
|
||||
user_prompt,
|
||||
temperature=0.2,
|
||||
max_tokens=8000,
|
||||
timeout=settings.presubmit_read_timeout,
|
||||
)
|
||||
except llm.LLMError as exc:
|
||||
msg = str(exc)
|
||||
if ("HTTP 400" in msg or "HTTP 413" in msg) and len(feed_text) > _LLM_REJECT_RETRY_CHARS:
|
||||
logger.warning("presubmit: LLM 拒绝输入(%s),降到 %d 字符重试",
|
||||
msg[:120], _LLM_REJECT_RETRY_CHARS)
|
||||
feed_text, dropped_sections = pack_feed_text(text, _LLM_REJECT_RETRY_CHARS)
|
||||
truncated = True
|
||||
payload = await llm.complete_json(
|
||||
self_review.SYSTEM_PROMPT,
|
||||
self_review.build_user_prompt(feed_text, (target_journal or "").strip()),
|
||||
temperature=0.2,
|
||||
max_tokens=8000,
|
||||
timeout=settings.presubmit_read_timeout,
|
||||
)
|
||||
else:
|
||||
raise
|
||||
except llm.LLMError as exc:
|
||||
logger.error("presubmit: LLM 审稿失败:%s", exc)
|
||||
return _error("UPSTREAM_DOWN", f"审稿模型调用失败:{exc}", degraded=True)
|
||||
|
||||
report = self_review.coerce_report(payload)
|
||||
|
||||
# ---- 关键词扫描器:证伪 LLM 的硬性缺项(有 X 却报缺 X → 真降级至 minor)----
|
||||
# A2(2026-10-12):被冲突的 major 不再留在 major 列表里只剥标签,而是移出
|
||||
# major、append 到 minor(scanner_downgraded=True)——major_count/引擎输入真实
|
||||
# 减少;fatal 条目例外(只记冲突不降级,须人工过目)。
|
||||
signals = paper_scanner.scan_paper_signals(feed_text)
|
||||
kept_majors, demoted_minors, scanner_conflicts = paper_scanner.reconcile_hard_rules(
|
||||
report.major_weaknesses, signals
|
||||
)
|
||||
report.major_weaknesses = kept_majors
|
||||
if demoted_minors:
|
||||
report.minor_weaknesses = list(report.minor_weaknesses) + list(demoted_minors)
|
||||
|
||||
# recommendation 由规则引擎计算(LLM 输出的同名字段已被 coerce 忽略)
|
||||
tier, tier_source = resolve_tier((target_journal or "").strip())
|
||||
decision = decide_recommendation(
|
||||
majors=report.major_weaknesses,
|
||||
minors=report.minor_weaknesses,
|
||||
presentations=report.presentation_format,
|
||||
tier=tier,
|
||||
)
|
||||
rationale = decision["rationale"]
|
||||
rationale["scanner_conflicts"] = scanner_conflicts
|
||||
if scanner_conflicts:
|
||||
rationale["explanation"] += (
|
||||
f" 关键词扫描器与审稿意见存在 {len(scanner_conflicts)} 处冲突,"
|
||||
f"已降级为小修级问题,请人工核对。"
|
||||
)
|
||||
report.recommendation = decision["recommendation"]
|
||||
report.recommendation_rationale = rationale
|
||||
return PresubmitResponse(
|
||||
ok=True,
|
||||
report=report,
|
||||
meta={
|
||||
"filename": filename,
|
||||
"target_journal": (target_journal or "").strip(),
|
||||
"text_chars": len(feed_text),
|
||||
"truncated": truncated,
|
||||
"dropped_sections": dropped_sections,
|
||||
"scanner": {
|
||||
"accession_found": bool((signals.get("accession") or {}).get("found")),
|
||||
"busco_level": (signals.get("busco") or {}).get("level"),
|
||||
"hic_found": bool((signals.get("hic") or {}).get("found")),
|
||||
},
|
||||
"model": llm._llm_config()["model"],
|
||||
"tier": tier,
|
||||
"tier_label": decision["rationale"]["tier_label"],
|
||||
"tier_source": tier_source,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,288 @@
|
||||
"""routers/rebuttal.py —— Rebuttal Phase 1(审稿回复助手,10 端点)。
|
||||
|
||||
信封与其余 review 端点一致:业务错误 = HTTP 200 + {ok:false,error:{code,message,degraded}}。
|
||||
- 文本粘贴主入口 / PDF 次要入口(PDF 经 vstore 8094 解析);
|
||||
- 逐条 response → draft(校验C前置)→ confirm(后端重算 human_ack)→ letter 拼装;
|
||||
- letter.md 下载门槛 = human_ack;DELETE 手动清(无 TTL,决策 5)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Literal
|
||||
|
||||
from fastapi import APIRouter, File, UploadFile
|
||||
from fastapi.responses import PlainTextResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..config import get_settings
|
||||
from ..services import llm, paper_parse
|
||||
from ..services.rebuttal_draft import PART_TYPES, assemble_letter, generate_draft, validate_parts
|
||||
from ..services.rebuttal_parse import parse_review_text
|
||||
from ..services import rebuttal_store
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/review/rebuttal", tags=["review-rebuttal"])
|
||||
|
||||
_MAX_RAW_CHARS = 200_000 # 粘贴文本上限(审稿意见通常 <20K,保守给大)
|
||||
|
||||
|
||||
# ---------- 信封 ----------
|
||||
class RebuttalResponse(BaseModel):
|
||||
ok: bool
|
||||
job: dict[str, Any] | None = None
|
||||
error: dict[str, Any] | None = None
|
||||
meta: dict[str, Any] | None = None
|
||||
|
||||
|
||||
def _error(code: str, message: str) -> RebuttalResponse:
|
||||
return RebuttalResponse(ok=False, error={"code": code, "message": message, "degraded": False})
|
||||
|
||||
|
||||
def _ok(job: dict[str, Any], **meta: Any) -> RebuttalResponse:
|
||||
return RebuttalResponse(ok=True, job=job, meta=meta or None)
|
||||
|
||||
|
||||
# ---------- 请求体 ----------
|
||||
class JobCreateBody(BaseModel):
|
||||
text: str = Field(min_length=1)
|
||||
|
||||
|
||||
class PartBody(BaseModel):
|
||||
type: Literal["new_data", "reanalysis", "location", "clarification", "rebuttal"]
|
||||
content: str = ""
|
||||
location_hint: str | None = None
|
||||
|
||||
|
||||
class ResponseBody(BaseModel):
|
||||
parts: list[PartBody] = Field(default_factory=list)
|
||||
|
||||
|
||||
class StructureBody(BaseModel):
|
||||
action: Literal["merge", "split"]
|
||||
# merge: comment_ids(含当前 cid,≥2 条);split: items [{title, body}]
|
||||
comment_ids: list[str] | None = None
|
||||
items: list[dict[str, str]] | None = None
|
||||
|
||||
|
||||
class ConfirmBody(BaseModel):
|
||||
confirmed: bool
|
||||
|
||||
|
||||
# ---------- 内部工具 ----------
|
||||
async def _messages_llm(messages: list[dict[str, str]], *, temperature: float = 0.3) -> dict[str, Any]:
|
||||
"""适配 rebuttal_parse/draft 的 messages 形态 → llm.complete_json。"""
|
||||
system = next((m["content"] for m in messages if m.get("role") == "system"), "")
|
||||
user = next((m["content"] for m in messages if m.get("role") == "user"), "")
|
||||
return await llm.complete_json(system, user, temperature=temperature)
|
||||
|
||||
|
||||
def _find_comment(job: dict[str, Any], cid: str) -> tuple[dict, dict] | None:
|
||||
for r in job["reviewers"]:
|
||||
for c in r["comments"]:
|
||||
if c["id"] == cid:
|
||||
return r, c
|
||||
return None
|
||||
|
||||
|
||||
async def _build_job(raw_text: str, *, kind: str, filename: str | None) -> RebuttalResponse:
|
||||
raw_text = (raw_text or "").strip()
|
||||
if not raw_text:
|
||||
return _error("VALIDATION", "审稿意见文本为空")
|
||||
if len(raw_text) > _MAX_RAW_CHARS:
|
||||
return _error("VALIDATION", f"文本超过 {_MAX_RAW_CHARS} 字符上限")
|
||||
reviewers = await parse_review_text(raw_text, _messages_llm)
|
||||
if not reviewers:
|
||||
return _error("PARSE_FAILED", "未能从文本中解析出任何审稿意见条目")
|
||||
job = {
|
||||
"id": rebuttal_store.new_id("rbt_"),
|
||||
"status": "ready",
|
||||
"source": {"kind": kind, "filename": filename, "raw_text": raw_text},
|
||||
"template": "generic",
|
||||
"reviewers": reviewers,
|
||||
"letter_md": None,
|
||||
"validation": None,
|
||||
"human_ack": False,
|
||||
}
|
||||
rebuttal_store.insert_job(job)
|
||||
n_comments = sum(len(r["comments"]) for r in reviewers)
|
||||
return _ok(job, reviewers=len(reviewers), comments=n_comments)
|
||||
|
||||
|
||||
# ---------- 端点 ----------
|
||||
@router.post("/jobs", response_model=RebuttalResponse)
|
||||
async def create_job(body: JobCreateBody) -> RebuttalResponse:
|
||||
"""文本粘贴主入口。"""
|
||||
return await _build_job(body.text, kind="text", filename=None)
|
||||
|
||||
|
||||
@router.post("/jobs/pdf", response_model=RebuttalResponse)
|
||||
async def create_job_pdf(file: UploadFile = File(...)) -> RebuttalResponse:
|
||||
"""PDF 次要入口(批注版审稿 PDF;经 vstore 8094 解析)。"""
|
||||
settings = get_settings()
|
||||
filename = file.filename or "review.pdf"
|
||||
if not filename.lower().endswith(".pdf"):
|
||||
return _error("VALIDATION", "仅支持 PDF 文件(推荐直接粘贴文本)")
|
||||
data = await file.read()
|
||||
if not data:
|
||||
return _error("VALIDATION", "上传文件为空")
|
||||
if len(data) > settings.max_pdf_bytes:
|
||||
return _error("VALIDATION", f"PDF 超过 {settings.max_pdf_bytes // (1024 * 1024)}MB 上限")
|
||||
try:
|
||||
text = await paper_parse.parse_pdf_bytes(data, filename=filename)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return _error("UPSTREAM_DOWN", f"PDF 解析失败:{exc}")
|
||||
return await _build_job(text, kind="pdf", filename=filename)
|
||||
|
||||
|
||||
@router.get("/jobs/{job_id}", response_model=RebuttalResponse)
|
||||
async def get_job(job_id: str) -> RebuttalResponse:
|
||||
job = rebuttal_store.get_job(job_id)
|
||||
if job is None:
|
||||
return _error("NOT_FOUND", f"job {job_id} 不存在或已删除")
|
||||
return _ok(job)
|
||||
|
||||
|
||||
@router.patch("/jobs/{job_id}/comments/{cid}/response", response_model=RebuttalResponse)
|
||||
async def patch_response(job_id: str, cid: str, body: ResponseBody) -> RebuttalResponse:
|
||||
job = rebuttal_store.get_job(job_id)
|
||||
if job is None:
|
||||
return _error("NOT_FOUND", f"job {job_id} 不存在或已删除")
|
||||
found = _find_comment(job, cid)
|
||||
if found is None:
|
||||
return _error("NOT_FOUND", f"comment {cid} 不存在")
|
||||
_, comment = found
|
||||
for p in body.parts:
|
||||
if p.type not in PART_TYPES:
|
||||
return _error("VALIDATION", f"未知 part.type: {p.type}")
|
||||
comment["response"] = {"parts": [p.model_dump() for p in body.parts]}
|
||||
# 改回复 → 旧 draft/validation 作废(保守:不保留过期草稿)
|
||||
comment["draft"] = None
|
||||
comment["validation"] = None
|
||||
job = rebuttal_store.update_job(job_id, reviewers=job["reviewers"]) or job
|
||||
return _ok(job)
|
||||
|
||||
|
||||
@router.post("/jobs/{job_id}/comments/{cid}/draft", response_model=RebuttalResponse)
|
||||
async def post_draft(job_id: str, cid: str) -> RebuttalResponse:
|
||||
job = rebuttal_store.get_job(job_id)
|
||||
if job is None:
|
||||
return _error("NOT_FOUND", f"job {job_id} 不存在或已删除")
|
||||
found = _find_comment(job, cid)
|
||||
if found is None:
|
||||
return _error("NOT_FOUND", f"comment {cid} 不存在")
|
||||
_, comment = found
|
||||
parts = (comment.get("response") or {}).get("parts") or []
|
||||
if not parts or not any(str(p.get("content") or "").strip() for p in parts):
|
||||
# 决策 4:生成前必须填 user_response;LLM 调用次数=0
|
||||
return _error("MISSING_RESPONSE", "请先填写回复要点(user_response)再生成草稿")
|
||||
validation = validate_parts(parts)
|
||||
comment["validation"] = validation
|
||||
if not validation["passed"]:
|
||||
job = rebuttal_store.update_job(job_id, reviewers=job["reviewers"]) or job
|
||||
return RebuttalResponse(
|
||||
ok=False, job=job,
|
||||
error={"code": "VALIDATION_FAILED", "message": "反幻觉校验未通过",
|
||||
"degraded": False},
|
||||
meta={"validation": validation},
|
||||
)
|
||||
comment["draft"] = await generate_draft(_messages_llm, comment)
|
||||
job = rebuttal_store.update_job(job_id, reviewers=job["reviewers"]) or job
|
||||
return _ok(job, validation=validation)
|
||||
|
||||
|
||||
@router.patch("/jobs/{job_id}/comments/{cid}/structure", response_model=RebuttalResponse)
|
||||
async def patch_structure(job_id: str, cid: str, body: StructureBody) -> RebuttalResponse:
|
||||
job = rebuttal_store.get_job(job_id)
|
||||
if job is None:
|
||||
return _error("NOT_FOUND", f"job {job_id} 不存在或已删除")
|
||||
found = _find_comment(job, cid)
|
||||
if found is None:
|
||||
return _error("NOT_FOUND", f"comment {cid} 不存在")
|
||||
reviewer, comment = found
|
||||
comments = reviewer["comments"]
|
||||
idx = comments.index(comment)
|
||||
if body.action == "merge":
|
||||
ids = body.comment_ids or []
|
||||
if cid not in ids:
|
||||
ids = [cid] + ids
|
||||
if len(ids) < 2:
|
||||
return _error("VALIDATION", "merge 至少需要 2 条 comment")
|
||||
targets = [c for c in comments if c["id"] in ids]
|
||||
if len(targets) < 2:
|
||||
return _error("VALIDATION", "merge 目标中有效 comment 不足 2 条")
|
||||
merged_body = "\n\n".join(c["body"] for c in targets)
|
||||
comment["body"] = merged_body
|
||||
comment["title"] = f"{comment['title']} (+{len(targets) - 1} merged)"
|
||||
reviewer["comments"] = [
|
||||
c for c in comments
|
||||
if c is comment or c["id"] not in ids
|
||||
]
|
||||
else: # split
|
||||
items = body.items or []
|
||||
items = [i for i in items if str(i.get("body") or "").strip()]
|
||||
if len(items) < 2:
|
||||
return _error("VALIDATION", "split 至少需要 2 个非空子项")
|
||||
group_id = comment.get("group_id") or f"g_{cid}"
|
||||
new_comments = []
|
||||
for n, it in enumerate(items, start=1):
|
||||
c = dict(comment)
|
||||
c["id"] = rebuttal_store.new_id(f"{cid}s{n}")
|
||||
c["group_id"] = group_id
|
||||
c["title"] = str(it.get("title") or f"{comment['title']} ({n})")
|
||||
c["body"] = str(it.get("body") or "").strip()
|
||||
c["sub_items"] = []
|
||||
c["response"] = {"parts": []}
|
||||
c["draft"] = None
|
||||
c["validation"] = None
|
||||
c["human_confirmed"] = False
|
||||
new_comments.append(c)
|
||||
comments[idx:idx + 1] = new_comments
|
||||
job = rebuttal_store.update_job(job_id, reviewers=job["reviewers"]) or job
|
||||
return _ok(job)
|
||||
|
||||
|
||||
@router.patch("/jobs/{job_id}/comments/{cid}/confirm", response_model=RebuttalResponse)
|
||||
async def patch_confirm(job_id: str, cid: str, body: ConfirmBody) -> RebuttalResponse:
|
||||
job = rebuttal_store.get_job(job_id)
|
||||
if job is None:
|
||||
return _error("NOT_FOUND", f"job {job_id} 不存在或已删除")
|
||||
found = _find_comment(job, cid)
|
||||
if found is None:
|
||||
return _error("NOT_FOUND", f"comment {cid} 不存在")
|
||||
_, comment = found
|
||||
comment["human_confirmed"] = bool(body.confirmed)
|
||||
# human_ack 由 store 重算(决策 4:全勾才置位,不信前端)
|
||||
job = rebuttal_store.update_job(job_id, reviewers=job["reviewers"]) or job
|
||||
return _ok(job, human_ack=job["human_ack"])
|
||||
|
||||
|
||||
@router.post("/jobs/{job_id}/letter", response_model=RebuttalResponse)
|
||||
async def post_letter(job_id: str) -> RebuttalResponse:
|
||||
job = rebuttal_store.get_job(job_id)
|
||||
if job is None:
|
||||
return _error("NOT_FOUND", f"job {job_id} 不存在或已删除")
|
||||
job = rebuttal_store.update_job(job_id, letter_md=assemble_letter(job)) or job
|
||||
return _ok(job)
|
||||
|
||||
|
||||
@router.get("/jobs/{job_id}/letter.md")
|
||||
async def download_letter(job_id: str) -> PlainTextResponse:
|
||||
job = rebuttal_store.get_job(job_id)
|
||||
if job is None:
|
||||
return PlainTextResponse(
|
||||
"job not found", status_code=404, media_type="text/plain; charset=utf-8")
|
||||
if not job.get("human_ack"):
|
||||
# 信封口径与下载端点适配:GET 下载返回纯文本,业务拒绝用 400 + 明文
|
||||
return PlainTextResponse(
|
||||
"HUMAN_ACK_REQUIRED: 请逐条勾选确认后再下载 letter.md",
|
||||
status_code=400, media_type="text/plain; charset=utf-8")
|
||||
md = job.get("letter_md") or assemble_letter(job)
|
||||
return PlainTextResponse(md, media_type="text/markdown; charset=utf-8")
|
||||
|
||||
|
||||
@router.delete("/jobs/{job_id}", response_model=RebuttalResponse)
|
||||
async def delete_job(job_id: str) -> RebuttalResponse:
|
||||
if not rebuttal_store.delete_job(job_id):
|
||||
return _error("NOT_FOUND", f"job {job_id} 不存在或已删除")
|
||||
return RebuttalResponse(ok=True, job=None, meta={"deleted": job_id})
|
||||
@@ -0,0 +1,165 @@
|
||||
"""routers/reformat.py —— 论文自动排版 P1(.tex 源码级重排版,不编译)。
|
||||
|
||||
端点:
|
||||
POST /api/review/reformat multipart 上传 .tex + target_journal →
|
||||
修改后源码 + 逐行 diff 报告(job 落盘)
|
||||
GET /api/review/reformat/{id} 取完整报告(重启后仍在)
|
||||
GET /api/review/reformat/{id}/file 下载修改后的 .tex
|
||||
|
||||
信封与其余 review 端点一致:业务错误 = HTTP 200 + {ok:false,error:{code,message,degraded:false}},
|
||||
前端经 review.js unwrap 归一为 ApiError。本次改动无上游依赖(零 LLM/零 vstore),
|
||||
degraded 恒 false。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, File, Form, UploadFile
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..config import get_settings
|
||||
from ..services.reformat import ReformatError, reformat_tex, resolve_rules
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/review", tags=["review-reformat"])
|
||||
|
||||
_JOB_ID_RE = re.compile(r"^[0-9a-f]{12}$")
|
||||
_ENGINE = "reformat-p1/1.0"
|
||||
|
||||
|
||||
class ReformatResponse(BaseModel):
|
||||
ok: bool
|
||||
job_id: str | None = None
|
||||
modified_tex: str | None = None
|
||||
changes: list[dict[str, Any]] | None = None
|
||||
warnings: list[dict[str, Any]] | None = None
|
||||
meta: dict[str, Any] | None = None
|
||||
error: dict[str, Any] | None = None
|
||||
|
||||
|
||||
def _error(code: str, message: str) -> ReformatResponse:
|
||||
return ReformatResponse(
|
||||
ok=False,
|
||||
error={"code": code, "message": message, "degraded": False},
|
||||
)
|
||||
|
||||
|
||||
def _jobs_dir() -> Path:
|
||||
d = get_settings().reformat_dir_abs
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
@router.post("/reformat", response_model=ReformatResponse)
|
||||
async def reformat(
|
||||
file: UploadFile = File(..., description="LaTeX 源码(.tex 单文件)"),
|
||||
target_journal: str = Form("", description="目标期刊 id/名称,或 default=通用规则"),
|
||||
) -> ReformatResponse:
|
||||
settings = get_settings()
|
||||
|
||||
# ---- 入参校验(拍板 5:只收 .tex,docx/pdf 走 format-check 审核不重排)----
|
||||
filename = file.filename or "paper.tex"
|
||||
if not filename.lower().endswith(".tex"):
|
||||
return _error("VALIDATION", "仅支持 .tex 文件(docx/pdf 请走格式审核)")
|
||||
target = (target_journal or "").strip()
|
||||
if not target:
|
||||
return _error("VALIDATION", "必须指定目标期刊(或选择「通用规则」)")
|
||||
|
||||
content = await file.read()
|
||||
if not content:
|
||||
return _error("VALIDATION", "上传文件为空")
|
||||
if len(content) > settings.max_tex_bytes:
|
||||
return _error("VALIDATION", "源码超过 2MB 上限")
|
||||
try:
|
||||
source = content.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return _error("VALIDATION", "源码必须为 UTF-8 编码")
|
||||
|
||||
# ---- 规则解析(journals.json / format_cache / journal_directory 只读回链)----
|
||||
resolved = await resolve_rules(target)
|
||||
|
||||
# ---- 变换(纯规则引擎;结构畸形 → PARSER_FAILED)----
|
||||
try:
|
||||
result = reformat_tex(source, resolved["rules"])
|
||||
except ReformatError as exc:
|
||||
logger.warning("reformat: 结构守卫拒收 %s: %s", filename, exc)
|
||||
return _error(exc.code, str(exc))
|
||||
|
||||
# ---- 落盘(.tex + .json 报告;重启后 GET 仍可用)----
|
||||
job_id = uuid.uuid4().hex[:12]
|
||||
base = _jobs_dir()
|
||||
(base / f"{job_id}.tex").write_text(result["modified_tex"], encoding="utf-8")
|
||||
|
||||
meta: dict[str, Any] = {
|
||||
"journal_id": resolved["journal_id"],
|
||||
"journal_name": resolved["journal_name"],
|
||||
"rules": resolved["rules"],
|
||||
"rules_source": resolved["rules_source"],
|
||||
"rules_note": resolved["rules_note"],
|
||||
"filename": filename,
|
||||
"original_sha256": hashlib.sha256(content).hexdigest(),
|
||||
"engine": _ENGINE,
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
}
|
||||
payload = {
|
||||
"ok": True,
|
||||
"job_id": job_id,
|
||||
"modified_tex": result["modified_tex"],
|
||||
"changes": result["changes"],
|
||||
"warnings": result["warnings"],
|
||||
"meta": meta,
|
||||
}
|
||||
(base / f"{job_id}.json").write_text(
|
||||
json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
logger.info(
|
||||
"reformat: %s → job %s(%d 处修改, %d 条提示, 规则 %s)",
|
||||
filename, job_id,
|
||||
sum(1 for c in result["changes"] if c["action"] == "modified"),
|
||||
sum(1 for c in result["changes"] if c["action"] == "warning"),
|
||||
resolved["rules_source"],
|
||||
)
|
||||
return ReformatResponse(**payload)
|
||||
|
||||
|
||||
def _load_job(job_id: str) -> dict[str, Any] | None:
|
||||
if not _JOB_ID_RE.fullmatch(job_id or ""):
|
||||
return None
|
||||
path = _jobs_dir() / f"{job_id}.json"
|
||||
try:
|
||||
payload = json.loads(path.read_text("utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
return payload if isinstance(payload, dict) and payload.get("ok") else None
|
||||
|
||||
|
||||
_NOT_FOUND = {"ok": False, "error": {"code": "NOT_FOUND",
|
||||
"message": "任务不存在或已过期,请重新上传源码",
|
||||
"degraded": False}}
|
||||
|
||||
|
||||
@router.get("/reformat/{job_id}", response_model=ReformatResponse)
|
||||
async def get_reformat_job(job_id: str) -> ReformatResponse:
|
||||
payload = _load_job(job_id)
|
||||
if payload is None:
|
||||
return _error("NOT_FOUND", "任务不存在或已过期,请重新上传源码")
|
||||
return ReformatResponse(**payload)
|
||||
|
||||
|
||||
@router.get("/reformat/{job_id}/file", response_model=None)
|
||||
async def download_reformat_file(job_id: str) -> FileResponse | JSONResponse:
|
||||
"""下载修改后的 .tex;缺件/非法 id → 200 信封(前端单一错误口径)。"""
|
||||
valid = bool(_JOB_ID_RE.fullmatch(job_id or ""))
|
||||
tex = _jobs_dir() / f"{job_id}.tex" if valid else None
|
||||
if tex is None or not tex.is_file():
|
||||
return JSONResponse(status_code=200, content=_NOT_FOUND)
|
||||
return FileResponse(tex, media_type="application/x-tex", filename=f"{job_id}.tex")
|
||||
@@ -0,0 +1 @@
|
||||
"""review 服务服务层包。"""
|
||||
@@ -0,0 +1,788 @@
|
||||
"""services/format_check.py —— 版式与规范规则引擎(⑤ 论文排版与规范)。
|
||||
|
||||
纯规则、无 LLM、无 vstore、同步执行(通常 <1s):
|
||||
- PDF → pymupdf 本地解档(AGENTS.md 约束 6 例外:vstore 只回文本,字体/边距/
|
||||
行距/页数等版式元数据拿不到;正文提取仍走 vstore,本服务不碰正文语义);
|
||||
- Word → python-docx(页边距/行距精确,页数取 docProps/app.xml 的 <Pages>);
|
||||
- 规则集 → data/journals/journals.json:
|
||||
顶层 format_rules_default(通用默认值,rules_source="default")
|
||||
每条目可选 format_rules(按期刊覆盖,必须带 rules_source: placeholder|official|default)
|
||||
⚠ 诚实原则:不知道期刊真实投稿指南 → 该条只写 {"rules_source": "placeholder"},
|
||||
所有具体数字回退 default 通用值,绝不编造期刊专属数字。
|
||||
|
||||
输出:{checks[{name,key,status,detail,suggestion}], summary{pass,warn,fail}, meta{...}}
|
||||
status ∈ pass|warn|fail。参考文献样式宽容化:仅"明确不符"(如 [J] 标引但规则要求 APA)
|
||||
才 fail,"疑似但不确定/检测不到"一律 warn,避免误伤。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import statistics
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import fitz # pymupdf(venv 已装 1.28.x)
|
||||
from docx import Document
|
||||
from docx.shared import Length
|
||||
|
||||
# review_service/app/services/ → parents[2] = review_service
|
||||
_JOURNALS_PATH = Path(__file__).resolve().parents[2] / "data" / "journals" / "journals.json"
|
||||
|
||||
_MAX_SCAN_PAGES = 80 # 版式扫描页数上限(正文/字体/边距;稿件通常远小于此)
|
||||
_MAX_REFS_SCAN = 40 # 从文末向前找参考文献标题的页数窗口
|
||||
_MAX_PARAS = 8000 # docx 段落扫描上限
|
||||
_PT_TO_MM = 25.4 / 72.0
|
||||
_EMU_TO_MM = 1.0 / 36000.0
|
||||
|
||||
_HEADING_RE = re.compile(
|
||||
r"^\s*(references|bibliography|literature cited|works cited|参考文献|文献)\s*[::]?\s*$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_FIG_RE = re.compile(r"(?im)^\s*(figure|fig\.?|图)\s*\d+")
|
||||
_TAB_RE = re.compile(r"(?im)^\s*(table|表)\s*\d+")
|
||||
_GB_TAG_RE = re.compile(r"\[[A-Z]{1,3}\d*\]") # [J] [M] [D] [C] …(GB/T 7714 标引)
|
||||
_PAREN_YEAR_RE = re.compile(r"\((?:19|20)\d{2}\)") # (2024) 作者-年份制
|
||||
_BRACKET_NUM_RE = re.compile(r"^\[\d+\]") # [1] 编号制
|
||||
_DOI_RE = re.compile(r"doi:?\.?org|doi:\s*1\.", re.IGNORECASE)
|
||||
|
||||
_STYLE_LABEL = {
|
||||
"gb7714": "GB/T 7714",
|
||||
"author_year": "作者-年份制(类 APA)",
|
||||
"numbered": "编号制(顺序编码)",
|
||||
"unknown": "无法确定",
|
||||
}
|
||||
|
||||
|
||||
class FormatCheckError(Exception):
|
||||
"""解档/规则加载失败(路由器转 PARSE_FAILED)。"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 规则集加载
|
||||
|
||||
def load_ruleset(target_journal: str) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
"""返回 (rules, ruleset_meta)。target_journal 传 id 或 name 均可;未知 → default。"""
|
||||
try:
|
||||
data = json.loads(_JOURNALS_PATH.read_text("utf-8"))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise FormatCheckError(f"规则集加载失败:{exc}") from exc
|
||||
|
||||
default = data.get("format_rules_default") or {}
|
||||
jid = (target_journal or "").strip()
|
||||
entry = None
|
||||
if jid:
|
||||
for j in data.get("journals") or []:
|
||||
if j.get("id") == jid or j.get("name") == jid:
|
||||
entry = j
|
||||
break
|
||||
|
||||
if entry is None:
|
||||
rules = dict(default)
|
||||
meta = {
|
||||
"journal_id": None,
|
||||
"journal_name": None,
|
||||
"ruleset_source": "default",
|
||||
"rules_source": str(default.get("rules_source", "default")),
|
||||
}
|
||||
else:
|
||||
journal_rules = entry.get("format_rules") or {}
|
||||
rules = {**default, **journal_rules}
|
||||
meta = {
|
||||
"journal_id": entry.get("id"),
|
||||
"journal_name": entry.get("name"),
|
||||
"ruleset_source": "journal",
|
||||
# 占位规则(无官方核验)→ 前端黄字提示
|
||||
"rules_source": str(journal_rules.get("rules_source", "placeholder")),
|
||||
}
|
||||
rules.setdefault("rules_source", "default")
|
||||
return rules, meta
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- PDF 解档(pymupdf)
|
||||
|
||||
def _norm_font(name: str) -> str:
|
||||
n = (name or "").split("+")[-1].strip()
|
||||
return n or "unknown"
|
||||
|
||||
|
||||
def extract_profile_pdf(data: bytes) -> dict[str, Any]:
|
||||
try:
|
||||
doc = fitz.open(stream=data, filetype="pdf")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise FormatCheckError(f"PDF 解档失败:{exc}") from exc
|
||||
if getattr(doc, "needs_pass", False):
|
||||
doc.close()
|
||||
raise FormatCheckError("PDF 已加密,无法解析")
|
||||
|
||||
pages_total = doc.page_count
|
||||
scan = min(pages_total, _MAX_SCAN_PAGES)
|
||||
margin_rows: list[tuple[float, float, float, float]] = [] # t r b l (mm)
|
||||
font_w: dict[str, int] = {}
|
||||
size_w: dict[float, int] = {}
|
||||
ratios: list[float] = []
|
||||
img_ids: set[int] = set()
|
||||
fig_caps = tab_caps = 0
|
||||
|
||||
for pno in range(scan):
|
||||
page = doc[pno]
|
||||
rect = page.rect
|
||||
blocks = page.get_text("blocks")
|
||||
content = [b for b in blocks if b[4].strip()]
|
||||
if content:
|
||||
left = min(b[0] for b in content)
|
||||
top = min(b[1] for b in content)
|
||||
right = rect.width - max(b[2] for b in content)
|
||||
bottom = rect.height - max(b[3] for b in content)
|
||||
margin_rows.append(
|
||||
(
|
||||
max(0.0, top) * _PT_TO_MM,
|
||||
max(0.0, right) * _PT_TO_MM,
|
||||
max(0.0, bottom) * _PT_TO_MM,
|
||||
max(0.0, left) * _PT_TO_MM,
|
||||
)
|
||||
)
|
||||
|
||||
for img in page.get_images(full=True):
|
||||
img_ids.add(img[0])
|
||||
|
||||
text = page.get_text()
|
||||
fig_caps += len(_FIG_RE.findall(text))
|
||||
tab_caps += len(_TAB_RE.findall(text))
|
||||
|
||||
d = page.get_text("dict")
|
||||
for block in d.get("blocks", []):
|
||||
if block.get("type") != 0:
|
||||
continue
|
||||
line_meta: list[tuple[float, float]] = [] # (基线 y, 字号)
|
||||
for line in block.get("lines", []):
|
||||
spans = [s for s in line.get("spans", []) if s.get("text", "").strip()]
|
||||
if not spans:
|
||||
continue
|
||||
row_size = max(s.get("size", 0.0) for s in spans)
|
||||
if row_size <= 0:
|
||||
continue
|
||||
for s in spans:
|
||||
size = round(float(s.get("size", 0.0)), 1)
|
||||
chars = len(s.get("text", ""))
|
||||
if size > 0:
|
||||
norm = _norm_font(s.get("font", ""))
|
||||
font_w[norm] = font_w.get(norm, 0) + chars
|
||||
size_w[size] = size_w.get(size, 0) + chars
|
||||
# 行距基准用 span.origin 基线 y,不用 bbox 顶缘:bbox 顶随字形高低
|
||||
# 漂移,表格/图注等同基线并列文本会产出假性小 delta,混入中位数会把
|
||||
# 行距拉成 0.1x 的不可能值。
|
||||
origin = spans[0].get("origin")
|
||||
line_meta.append((origin[1] if origin else line["bbox"][1], row_size))
|
||||
for i in range(1, len(line_meta)):
|
||||
delta = line_meta[i][0] - line_meta[i - 1][0]
|
||||
size = line_meta[i][1]
|
||||
# 行距倍数 = 相邻行基线 y 差 / 当前行字号;
|
||||
# 过滤物理不可能值:<0.5(行重叠/同基线并列表格)、>4.0(异常结构)
|
||||
ratio = delta / size
|
||||
if 0.5 <= ratio <= 4.0:
|
||||
ratios.append(ratio)
|
||||
|
||||
refs_text, refs_found = _find_refs_pdf(doc, pages_total)
|
||||
doc.close()
|
||||
|
||||
if margin_rows:
|
||||
med = [statistics.median(col) for col in zip(*margin_rows)]
|
||||
margins = {"top": med[0], "right": med[1], "bottom": med[2], "left": med[3]}
|
||||
else:
|
||||
margins = None
|
||||
|
||||
return {
|
||||
"source_kind": "pdf",
|
||||
"pages": pages_total,
|
||||
"margins_mm": margins,
|
||||
"font_weights": font_w,
|
||||
"size_weights": size_w,
|
||||
"line_spacing_median": statistics.median(ratios) if ratios else None,
|
||||
"figures": max(len(img_ids), fig_caps),
|
||||
"tables": tab_caps,
|
||||
"refs_text": refs_text,
|
||||
"refs_found": refs_found,
|
||||
}
|
||||
|
||||
|
||||
def _find_refs_pdf(doc: "fitz.Document", pages_total: int) -> tuple[str | None, bool]:
|
||||
"""从文末向前找参考文献标题行;找到 → 标题行至文末文本;找不到 → (None, False)。"""
|
||||
buf: list[str] = []
|
||||
start = max(0, pages_total - _MAX_REFS_SCAN)
|
||||
for pno in range(pages_total - 1, start - 1, -1):
|
||||
page_text = doc[pno].get_text()
|
||||
lines = page_text.splitlines()
|
||||
for idx in range(len(lines) - 1, -1, -1):
|
||||
if _HEADING_RE.match(lines[idx].strip()):
|
||||
tail = "\n".join(lines[idx + 1:])
|
||||
joined = "\n".join([tail] + buf).strip()
|
||||
return (joined if len(joined) >= 50 else None), bool(joined)
|
||||
buf.insert(0, page_text)
|
||||
return None, False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- Word 解档(python-docx)
|
||||
|
||||
def _docx_app_pages(data: bytes) -> int | None:
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
||||
xml = zf.read("docProps/app.xml").decode("utf-8", "ignore")
|
||||
m = re.search(r"<Pages>(\d+)</Pages>", xml)
|
||||
return int(m.group(1)) if m else None
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
|
||||
|
||||
def extract_profile_docx(data: bytes) -> dict[str, Any]:
|
||||
try:
|
||||
doc = Document(io.BytesIO(data))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise FormatCheckError(f"Word 文件解析失败:{exc}") from exc
|
||||
|
||||
side_lists: dict[str, list[float]] = {"top": [], "right": [], "bottom": [], "left": []}
|
||||
for sec in doc.sections:
|
||||
for k, v in (
|
||||
("top", sec.top_margin),
|
||||
("right", sec.right_margin),
|
||||
("bottom", sec.bottom_margin),
|
||||
("left", sec.left_margin),
|
||||
):
|
||||
if v is not None:
|
||||
side_lists[k].append(float(v) * _EMU_TO_MM)
|
||||
margins = None
|
||||
if all(side_lists.values()):
|
||||
margins = {k: statistics.median(v) for k, v in side_lists.items()}
|
||||
|
||||
try:
|
||||
normal = doc.styles["Normal"].font
|
||||
default_name = normal.name
|
||||
default_size = float(normal.size.pt) if normal.size is not None else None
|
||||
except Exception: # noqa: BLE001
|
||||
default_name, default_size = None, None
|
||||
|
||||
font_w: dict[str, int] = {}
|
||||
size_w: dict[float, int] = {}
|
||||
ratios: list[float] = []
|
||||
fig_caps = tab_caps = 0
|
||||
paras = doc.paragraphs
|
||||
scan_count = min(len(paras), _MAX_PARAS)
|
||||
|
||||
for i in range(scan_count):
|
||||
para = paras[i]
|
||||
text = para.text
|
||||
if not text.strip():
|
||||
continue
|
||||
ls = para.paragraph_format.line_spacing
|
||||
if isinstance(ls, Length):
|
||||
# 精确行距(exactly)以 EMU 存储,Length 是 int 子类——直接当倍数会得出
|
||||
# 十万级的假值;用正文默认字号换算成倍数
|
||||
if default_size:
|
||||
r = float(ls.pt) / default_size
|
||||
if 0.5 <= r <= 4.0:
|
||||
ratios.append(r)
|
||||
elif isinstance(ls, (int, float)) and not isinstance(ls, bool) and ls > 0:
|
||||
r = float(ls) # multiple 行距本身就是倍数
|
||||
if 0.5 <= r <= 4.0:
|
||||
ratios.append(r)
|
||||
if _FIG_RE.match(text):
|
||||
fig_caps += 1
|
||||
elif _TAB_RE.match(text):
|
||||
tab_caps += 1
|
||||
for run in para.runs:
|
||||
if not run.text:
|
||||
continue
|
||||
name = run.font.name or default_name or "unknown"
|
||||
pt = run.font.size.pt if run.font.size is not None else default_size
|
||||
chars = len(run.text)
|
||||
font_w[str(name)] = font_w.get(str(name), 0) + chars
|
||||
if pt:
|
||||
size_w[round(float(pt), 1)] = size_w.get(round(float(pt), 1), 0) + chars
|
||||
|
||||
# 参考文献节:标题段落之后全部文本
|
||||
refs_text, refs_found = None, False
|
||||
for i, para in enumerate(paras):
|
||||
if _HEADING_RE.match((para.text or "").strip()):
|
||||
tail = "\n".join(p.text for p in paras[i + 1:] if (p.text or "").strip())
|
||||
if len(tail) >= 50:
|
||||
refs_text, refs_found = tail, True
|
||||
break
|
||||
|
||||
return {
|
||||
"source_kind": "docx",
|
||||
"pages": _docx_app_pages(data), # Word 未记录 → None(检查项降级为 warn)
|
||||
"margins_mm": margins,
|
||||
"font_weights": font_w,
|
||||
"size_weights": size_w,
|
||||
"line_spacing_median": statistics.median(ratios) if ratios else None,
|
||||
"figures": max(len(doc.inline_shapes), fig_caps),
|
||||
"tables": max(len(doc.tables), tab_caps),
|
||||
"refs_text": refs_text,
|
||||
"refs_found": refs_found,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- LaTeX 解档(正则+状态机,零新依赖)
|
||||
#
|
||||
# 决策前提:只做"解析 + 检查 + 建议代码片段",不自动改写 LaTeX(用户可能有自定义宏,
|
||||
# 自动改写会引错;LaTeX 应由用户自己编辑)。不引入 pylatexenc/texsoup。
|
||||
|
||||
# 会相互覆盖的字体类宏包:≥2 个同时引入 → warn(times + mathptmx 类冲突)
|
||||
_TEX_FONT_PKGS = {"times", "mathptmx", "newtx", "newpx", "txfonts", "pxfonts", "mathpazo"}
|
||||
|
||||
|
||||
def _strip_tex_comments(src: str) -> str:
|
||||
r"""去行内 % 注释(r-string,避免 \% 被解释为转义序列)。"""
|
||||
return re.sub(r"(?<!\\)%.*", "", src)
|
||||
|
||||
|
||||
def _parse_geometry(src: str) -> dict[str, float] | None:
|
||||
"""\\usepackage[...]{geometry} 或 \\geometry{...} → {边: mm}(可缺边,检查端容缺)。"""
|
||||
m = re.search(r"\\usepackage\s*\[([^\]]*)\]\s*\{\s*geometry\s*\}", src)
|
||||
if not m:
|
||||
m = re.search(r"\\geometry\s*\{([^}]*)\}", src)
|
||||
if not m:
|
||||
return None
|
||||
opts = m.group(1)
|
||||
|
||||
def to_mm(val: str) -> float | None:
|
||||
mm = re.match(r"\s*([\d.]+)\s*(mm|cm|in|pt)?\s*$", val)
|
||||
if not mm:
|
||||
return None
|
||||
x = float(mm.group(1))
|
||||
unit = mm.group(2) or "pt" # geometry 裸数字按 TeX 惯例为 pt
|
||||
return {"mm": x, "cm": x * 10, "in": x * 25.4, "pt": x * 25.4 / 72.0}[unit]
|
||||
|
||||
kv = {}
|
||||
for pair in opts.split(","):
|
||||
if "=" in pair:
|
||||
k, v = pair.split("=", 1)
|
||||
kv[k.strip().lower()] = v.strip()
|
||||
out: dict[str, float] = {}
|
||||
if "margin" in kv:
|
||||
v = to_mm(kv["margin"])
|
||||
if v is not None:
|
||||
return {"top": v, "right": v, "bottom": v, "left": v}
|
||||
for k in ("top", "right", "bottom", "left"):
|
||||
if k in kv:
|
||||
v = to_mm(kv[k])
|
||||
if v is not None:
|
||||
out[k] = v
|
||||
if "inner" in kv:
|
||||
v = to_mm(kv["inner"])
|
||||
if v is not None:
|
||||
out.setdefault("left", v)
|
||||
if "outer" in kv:
|
||||
v = to_mm(kv["outer"])
|
||||
if v is not None:
|
||||
out.setdefault("right", v)
|
||||
return out or None
|
||||
|
||||
|
||||
def extract_tex_profile(data: bytes) -> dict[str, Any]:
|
||||
"""正则 + 轻量状态机解析 .tex 源文件 → 与 DocProfile 同构(+tex 扩展字段)。"""
|
||||
try:
|
||||
text = data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
text = data.decode("latin-1", errors="replace")
|
||||
src = _strip_tex_comments(text)
|
||||
|
||||
# \documentclass[10pt,a4paper]{article}
|
||||
m_cls = re.search(r"\\documentclass\s*(?:\[[^\]]*\])?\s*\{\s*([\w*]+)\s*\}", src)
|
||||
cls = m_cls.group(1) if m_cls else None
|
||||
m_opt = re.search(r"\\documentclass\s*\[([^\]]*)\]", src)
|
||||
cls_opts = m_opt.group(1) if m_opt else ""
|
||||
m_pt = re.search(r"(\d+)\s*pt", cls_opts)
|
||||
# 标准类未写选项时 LaTeX 默认 10pt;无 documentclass → None(检查项降级 warn)
|
||||
fontsize = int(m_pt.group(1)) if m_pt else (10 if cls else None)
|
||||
paper = "a4paper" if "a4paper" in cls_opts else ("letterpaper" if "letterpaper" in cls_opts else None)
|
||||
|
||||
# \usepackage 列表(支持 {a,b} 逗号多包)
|
||||
packages: list[str] = []
|
||||
for m in re.finditer(r"\\usepackage\s*(?:\[[^\]]*\])?\s*\{\s*([^}]+?)\s*\}", src):
|
||||
packages += [p.strip().lower() for p in m.group(1).split(",") if p.strip()]
|
||||
|
||||
# 行距:\linespread{...} 或 \setstretch{...}(值直接按"行距倍数"口径评估——
|
||||
# 按步骤4 规格:低于 min_line_spacing 即 fail;LaTeX 实际倍数还含 baselinestretch
|
||||
# 的字号比,本引擎取显式设置值做保守判定)
|
||||
line_spread: float | None = None
|
||||
m = re.search(r"\\linespread\s*\{\s*([\d.]+)\s*\}", src)
|
||||
if not m:
|
||||
m = re.search(r"\\setstretch\s*\{\s*([\d.]+)\s*\}", src)
|
||||
if m:
|
||||
line_spread = float(m.group(1))
|
||||
|
||||
# 页边距(geometry)
|
||||
margins = _parse_geometry(src)
|
||||
|
||||
# 计数
|
||||
figures = len(re.findall(r"\\begin\s*\{\s*figure\*?\s*\}", src))
|
||||
tables = len(re.findall(r"\\begin\s*\{\s*table\*?\s*\}", src))
|
||||
cites = len(re.findall(r"\\cite\w*\s*\{", src))
|
||||
|
||||
# 参考文献:\bibliography{...}(BibTeX)/ \printbibliography(biblatex)/ thebibliography
|
||||
has_bibtex = bool(re.search(r"\\bibliography\s*\{", src))
|
||||
has_printbib = bool(re.search(r"\\printbibliography", src))
|
||||
has_thebib = bool(re.search(r"\\begin\s*\{\s*thebibliography\s*\}", src))
|
||||
has_bibstyle = bool(re.search(r"\\bibliographystyle\s*\{", src)) # BibTeX 必需;biblatex 不需要
|
||||
has_bib = has_bibtex or has_printbib or has_thebib
|
||||
refs_text: str | None = None
|
||||
m_bib = re.search(r"\\begin\s*\{\s*thebibliography\s*\}[\s\S]*?\\end\s*\{\s*thebibliography\s*\}", src)
|
||||
if m_bib:
|
||||
refs_text = m_bib.group(0)
|
||||
|
||||
# 宏表(\newcommand{\xxx} / \renewcommand)——用于解析准确性(识别自定义宏)
|
||||
macros = re.findall(r"\\(?:new|re)command\s*\{\s*\\(\w+)\s*\}", src)
|
||||
|
||||
# 匿名评审命令探测(规则集 anonymous:true 时检查)
|
||||
m_anon = re.search(r"\\(?:anonymi[sz]e|anon\b|hideauthor|ifdoubleblind)", src, re.IGNORECASE)
|
||||
|
||||
return {
|
||||
"source_kind": "tex",
|
||||
"pages": None, # LaTeX 源文件页数需编译后才有 → 检查项降级 warn(宽容化)
|
||||
"margins_mm": margins,
|
||||
"font_weights": {},
|
||||
"size_weights": {float(fontsize): 100} if fontsize else {},
|
||||
"line_spacing_median": line_spread,
|
||||
"figures": figures,
|
||||
"tables": tables,
|
||||
"refs_text": refs_text,
|
||||
"refs_found": has_bib,
|
||||
"tex": {
|
||||
"class": cls,
|
||||
"fontsize_pt": fontsize,
|
||||
"paper": paper,
|
||||
"packages": packages,
|
||||
"cites": cites,
|
||||
"macros": macros,
|
||||
"anon_cmd": m_anon.group(0) if m_anon else None,
|
||||
"bib_bibtex": has_bibtex,
|
||||
"bib_style": has_bibstyle,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 参考文献样式检测
|
||||
|
||||
def detect_refs_style(text: str | None) -> tuple[str, str]:
|
||||
"""宽容化检测:返回 (detected, sample_line)。含糊 → unknown(后续只 warn)。"""
|
||||
if not text:
|
||||
return "unknown", ""
|
||||
lines = [l.strip() for l in text.splitlines() if l.strip()][:40]
|
||||
if not lines:
|
||||
return "unknown", ""
|
||||
n = len(lines)
|
||||
gb = sum(1 for l in lines if _GB_TAG_RE.search(l))
|
||||
year = sum(1 for l in lines if _PAREN_YEAR_RE.search(l))
|
||||
num = sum(1 for l in lines if _BRACKET_NUM_RE.match(l))
|
||||
doi = sum(1 for l in lines if _DOI_RE.search(l))
|
||||
sample = lines[0][:80]
|
||||
|
||||
if gb / n >= 0.4:
|
||||
return "gb7714", sample
|
||||
if num / n >= 0.4 and year / n < 0.3:
|
||||
return "numbered", sample
|
||||
if year / n >= 0.3 or doi / n >= 0.3:
|
||||
return "author_year", sample
|
||||
return "unknown", sample
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 检查项构建
|
||||
|
||||
def _chk(key: str, name: str, status: str, detail: str, suggestion: str = "") -> dict[str, str]:
|
||||
return {"key": key, "name": name, "status": status, "detail": detail, "suggestion": suggestion}
|
||||
|
||||
|
||||
def _check_pages(profile: dict[str, Any], rules: dict[str, Any]) -> dict[str, str]:
|
||||
limit = int(rules.get("max_pages", 10))
|
||||
pages = profile.get("pages")
|
||||
kind = profile.get("source_kind") or "doc"
|
||||
if pages is None:
|
||||
# 宽容化:Word/LaTeX 页数缺失绝不 fail
|
||||
if kind == "tex":
|
||||
return _chk(
|
||||
"pages", "页数", "warn",
|
||||
f"LaTeX 源无法直接统计页数(规则上限 {limit} 页),需编译后核对",
|
||||
"编译后确认(如 xelatex main.tex)总页数 ≤ 上限",
|
||||
)
|
||||
return _chk(
|
||||
"pages", "页数", "warn",
|
||||
"Word 未记录页数,请自行核对",
|
||||
f"在 Word 中按 Ctrl+End 查看总页数,或导出 PDF 后重查(规则上限 {limit} 页)",
|
||||
)
|
||||
if pages <= limit:
|
||||
return _chk("pages", "页数", "pass", f"共 {pages} 页,规则上限 {limit} 页")
|
||||
return _chk(
|
||||
"pages", "页数", "fail", f"共 {pages} 页,超出规则上限 {limit} 页",
|
||||
f"压缩至 ≤{limit} 页,或将方法/图表移入补充材料",
|
||||
)
|
||||
|
||||
|
||||
def _check_fonts(profile: dict[str, Any], rules: dict[str, Any]) -> dict[str, str]:
|
||||
min_font = float(rules.get("min_font_pt", 9))
|
||||
# LaTeX:字号来自 \documentclass 选项(10/11/12pt,粒度粗)
|
||||
if profile.get("source_kind") == "tex":
|
||||
tex = profile.get("tex") or {}
|
||||
cls = tex.get("class") or "documentclass"
|
||||
fs = tex.get("fontsize_pt")
|
||||
pkgs = tex.get("packages") or []
|
||||
font_pkgs = sorted(p for p in pkgs if p in _TEX_FONT_PKGS or p in ("helvet", "courier", "charter", "fourier"))
|
||||
detail = (
|
||||
f"\\documentclass 选项字号 {fs}pt(类 {cls});"
|
||||
f"字体宏包:{'、'.join(font_pkgs) if font_pkgs else '未引入'}"
|
||||
+ (f";纸张 {tex['paper']}" if tex.get("paper") else "")
|
||||
)
|
||||
if fs is None:
|
||||
return _chk("fonts", "字体字号", "warn", detail + "\n建议:\n \\documentclass[11pt]{article}", "补充 \\documentclass 声明")
|
||||
if fs < min_font:
|
||||
# LaTeX 粒度粗 → 按步骤4 规格降为 warn(非 fail)
|
||||
return _chk(
|
||||
"fonts", "字体字号", "warn", detail + f"\n建议:\n \\documentclass[11pt]{{{cls}}}",
|
||||
f"正文字号低于下限 {min_font:.0f}pt,请改用 11pt/12pt 选项",
|
||||
)
|
||||
return _chk("fonts", "字体字号", "pass", detail)
|
||||
|
||||
size_w: dict[float, int] = profile.get("size_weights") or {}
|
||||
font_w: dict[str, int] = profile.get("font_weights") or {}
|
||||
if not size_w:
|
||||
return _chk("fonts", "字体字号", "warn", "未能提取字体信息", "确认文档含可选中文本(扫描件无法检查字体)")
|
||||
min_font = float(rules.get("min_font_pt", 9))
|
||||
max_fonts = int(rules.get("max_fonts", 6))
|
||||
total = sum(size_w.values())
|
||||
body_pt = max(size_w, key=size_w.get)
|
||||
body_ratio = int(size_w[body_pt] / total * 100)
|
||||
distinct = len(font_w)
|
||||
top_fonts = sorted(font_w.items(), key=lambda kv: -kv[1])[:3]
|
||||
top_text = "、".join(f"{k}({v}字)" for k, v in top_fonts)
|
||||
detail = f"主要字号 {body_pt}pt(约 {body_ratio}%),共 {distinct} 种字体;高频字体:{top_text}"
|
||||
if body_pt < min_font:
|
||||
return _chk(
|
||||
"fonts", "字体字号", "fail", detail,
|
||||
f"将正文字号提高至 ≥{min_font}pt(当前主字号 {body_pt}pt)",
|
||||
)
|
||||
if distinct > max_fonts:
|
||||
return _chk(
|
||||
"fonts", "字体字号", "warn", detail,
|
||||
f"字体种类偏多({distinct} > {max_fonts}),建议全文统一为 1–2 种正文字体",
|
||||
)
|
||||
return _chk("fonts", "字体字号", "pass", detail)
|
||||
|
||||
|
||||
def _check_margins(profile: dict[str, Any], rules: dict[str, Any]) -> dict[str, str]:
|
||||
limit = float(rules.get("min_margin_mm", 15))
|
||||
margins = profile.get("margins_mm")
|
||||
# LaTeX:页边距来自 geometry;低于下限按步骤4 规格给 warn(非 fail),附建议片段
|
||||
if profile.get("source_kind") == "tex":
|
||||
if not margins:
|
||||
return _chk(
|
||||
"margins", "页边距", "warn",
|
||||
"未通过 geometry 设置页边距(按类默认评估)"
|
||||
+ f"\n建议:\n \\usepackage[margin=25mm]{{geometry}}",
|
||||
"在导言区显式声明页边距",
|
||||
)
|
||||
lo = min(margins.values())
|
||||
detail = f"geometry 页边距最小 {lo:.0f}mm(" + "、".join(f"{k}{v:.0f}" for k, v in margins.items()) + f"),规则下限 {limit:.0f}mm"
|
||||
if lo < limit:
|
||||
return _chk(
|
||||
"margins", "页边距", "warn",
|
||||
detail + f"\n建议:\n \\usepackage[margin={limit:.0f}mm]{{geometry}}",
|
||||
f"调大页边距至 ≥{limit:.0f}mm",
|
||||
)
|
||||
return _chk("margins", "页边距", "pass", detail)
|
||||
if not margins:
|
||||
return _chk("margins", "页边距", "warn", "未能提取页边距信息", "检查文档是否含可定位文本")
|
||||
vals = [margins.get(k) for k in ("top", "right", "bottom", "left")]
|
||||
vals = [v for v in vals if v is not None]
|
||||
if not vals:
|
||||
return _chk("margins", "页边距", "warn", "未能提取页边距信息", "检查文档是否含可定位文本")
|
||||
t, r = margins.get("top"), margins.get("right")
|
||||
b, l = margins.get("bottom"), margins.get("left")
|
||||
lo = min(vals)
|
||||
sides = "、".join(
|
||||
f"{name}{v:.0f}" for name, v in (("上", t), ("右", r), ("下", b), ("左", l)) if v is not None
|
||||
)
|
||||
detail = f"四边最小 {lo:.0f}mm({sides}),规则下限 {limit:.0f}mm"
|
||||
if lo >= limit:
|
||||
return _chk("margins", "页边距", "pass", detail)
|
||||
if lo >= limit * 0.8:
|
||||
return _chk("margins", "页边距", "warn", detail, f"将页边距调大至 ≥{limit:.0f}mm")
|
||||
return _chk("margins", "页边距", "fail", detail, f"页边距过小,请调至 ≥{limit:.0f}mm(版式设置)")
|
||||
|
||||
|
||||
def _check_spacing(profile: dict[str, Any], rules: dict[str, Any]) -> dict[str, str]:
|
||||
lo = float(rules.get("min_line_spacing", 1.15))
|
||||
ratio = profile.get("line_spacing_median")
|
||||
# LaTeX:\linespread/\setstretch 显式设置值;低于下限按步骤4 规格直接 fail
|
||||
if profile.get("source_kind") == "tex":
|
||||
if ratio is None:
|
||||
return _chk(
|
||||
"spacing", "行距", "pass",
|
||||
"未显式设置 \\linespread/\\setstretch,LaTeX 类默认约 1.2 倍(如 10pt→12pt baselineskip)",
|
||||
)
|
||||
detail = f"\\linespread/\\setstretch = {ratio:.2f},规则下限 {lo:.2f}"
|
||||
if ratio < lo:
|
||||
return _chk(
|
||||
"spacing", "行距", "fail",
|
||||
detail + f"\n建议:\n \\linespread{{{lo:.2f}}} % 或 \\setstretch{{{lo:.2f}}}(导言区)",
|
||||
"在导言区调大行距设置",
|
||||
)
|
||||
return _chk("spacing", "行距", "pass", detail)
|
||||
if ratio is None:
|
||||
return _chk("spacing", "行距", "warn", "未能提取行距信息", "确认段落使用固定/多倍行距设置")
|
||||
lo = float(rules.get("min_line_spacing", 1.15))
|
||||
detail = f"正文中位行距约 {ratio:.2f} 倍,规则下限 {lo:.2f} 倍"
|
||||
if ratio >= lo:
|
||||
return _chk("spacing", "行距", "pass", detail)
|
||||
if ratio >= lo * 0.9:
|
||||
return _chk("spacing", "行距", "warn", detail, f"段落行距设为 ≥{lo:.2f} 倍(如 1.5 倍行距)")
|
||||
return _chk("spacing", "行距", "fail", detail, f"行距过密,请设为 ≥{lo:.2f} 倍行距")
|
||||
|
||||
|
||||
def _check_figures(profile: dict[str, Any], rules: dict[str, Any]) -> dict[str, str]:
|
||||
figures = int(profile.get("figures") or 0)
|
||||
tables = int(profile.get("tables") or 0)
|
||||
min_f = int(rules.get("min_figures", 1))
|
||||
detail = f"检测到图 {figures} 张、表 {tables} 个(图注/表注正则 + 内嵌对象启发式)"
|
||||
if figures >= min_f:
|
||||
return _chk("figures", "图表数量", "pass", detail)
|
||||
# 启发式检测不可靠 → 低于阈值只 warn 不 fail
|
||||
return _chk(
|
||||
"figures", "图表数量", "warn", detail,
|
||||
f"常规研究论文建议 ≥{min_f} 图;若为综述/短文可忽略(本项为启发式检测)",
|
||||
)
|
||||
|
||||
|
||||
# 仅"明确不符"才 fail 的 (expected, detected) 组合(宽容化:其余含糊组合一律 warn)
|
||||
_REF_FAIL_PAIRS = {
|
||||
("apa", "gb7714"), # 找到 [J] 标引但规则要求 APA
|
||||
("gb7714", "author_year"), # 无 [J] 标引的作者-年份制,但规则要求 GB/T 7714
|
||||
("apa", "numbered"), # 编号制引文但规则要求 APA
|
||||
}
|
||||
|
||||
|
||||
def _check_refs(profile: dict[str, Any], rules: dict[str, Any]) -> dict[str, str]:
|
||||
expected = str(rules.get("refs_style", "apa")).lower()
|
||||
exp_label = _STYLE_LABEL.get(expected, expected)
|
||||
if not profile.get("refs_found"):
|
||||
return _chk(
|
||||
"refs", "参考文献格式", "warn",
|
||||
"未找到参考文献节(References/参考文献 / \\bibliography / thebibliography)",
|
||||
"确认存在参考文献声明后重查",
|
||||
)
|
||||
if profile.get("source_kind") == "tex":
|
||||
tex = profile.get("tex") or {}
|
||||
# BibTeX:\bibliography 存在但缺 \bibliographystyle → warn(样式引擎未知)
|
||||
if tex.get("bib_bibtex") and not tex.get("bib_style"):
|
||||
return _chk(
|
||||
"refs", "参考文献格式", "warn",
|
||||
f"规则要求 {exp_label};检测到 \\bibliography 但缺少 \\bibliographystyle(BibTeX 无法确定引用样式)"
|
||||
+ "\n建议:\n \\bibliographystyle{plain} % 按期刊/模板指定实际样式",
|
||||
"在 \\bibliography 前补 \\bibliographystyle",
|
||||
)
|
||||
if not profile.get("refs_text"):
|
||||
# 条目在 .bbl/编译产物,源文件无文本 → 宽容化只 warn
|
||||
return _chk(
|
||||
"refs", "参考文献格式", "warn",
|
||||
f"规则要求 {exp_label};检测到参考文献声明(条目由编译生成,源文件无条目文本)",
|
||||
"编译后核对参考文献条目样式",
|
||||
)
|
||||
detected, sample = detect_refs_style(profile.get("refs_text"))
|
||||
det_label = _STYLE_LABEL[detected]
|
||||
detail = f"规则要求 {exp_label};检测到 {det_label};样例:{sample}"
|
||||
# 宽容化:仅"明确不符"(_REF_FAIL_PAIRS)才 fail;含糊/不确定一律 warn
|
||||
if (expected, detected) in _REF_FAIL_PAIRS:
|
||||
return _chk("refs", "参考文献格式", "fail", detail, f"按 {exp_label} 调整全部文献条目(可用文献管理工具一键转换)")
|
||||
if detected == "unknown":
|
||||
return _chk("refs", "参考文献格式", "warn", detail, f"请人工核对是否符合 {exp_label}")
|
||||
if expected == "gb7714" and detected == "numbered":
|
||||
return _chk("refs", "参考文献格式", "warn", detail, "GB/T 7714 顺序编码制也用编号,需人工确认条目含 [J] 等类型标引")
|
||||
if expected == "author-year-journal" and detected in ("gb7714", "numbered"):
|
||||
return _chk("refs", "参考文献格式", "warn", detail, f"请对照期刊投稿指南核对是否符合 {exp_label}")
|
||||
return _chk("refs", "参考文献格式", "pass", detail)
|
||||
|
||||
|
||||
def _check_font_packages(profile: dict[str, Any], rules: dict[str, Any]) -> dict[str, str]:
|
||||
"""LaTeX 特有:多个相互覆盖的字体宏包(times + mathptmx 类)→ warn。"""
|
||||
pkgs = set((profile.get("tex") or {}).get("packages") or [])
|
||||
clash = sorted(pkgs & _TEX_FONT_PKGS)
|
||||
if len(clash) >= 2:
|
||||
detail = (
|
||||
f"检测到多个相互覆盖的字体宏包:{'、'.join(clash)}(后加载者覆盖前者,易产生冲突)"
|
||||
+ "\n建议:\n %只保留一套字体方案,例如:\n \\usepackage{newtx} % newtxtext + newtxmath 一体"
|
||||
)
|
||||
return _chk("fontpkgs", "字体包兼容", "warn", detail, "移除重复/冲突的字体宏包,只保留一套")
|
||||
return _chk(
|
||||
"fontpkgs", "字体包兼容", "pass",
|
||||
"字体宏包:" + ("、".join(clash) if clash else "未引入冲突类字体包"),
|
||||
)
|
||||
|
||||
|
||||
def _check_anonymous(profile: dict[str, Any], rules: dict[str, Any]) -> dict[str, str]:
|
||||
"""LaTeX 特有且规则集 anonymous:true 时才出:源文件需含匿名处理命令。"""
|
||||
tex = profile.get("tex") or {}
|
||||
cmd = tex.get("anon_cmd")
|
||||
if cmd:
|
||||
return _chk("anonymous", "匿名评审", "pass", f"检测到匿名处理命令 {cmd}")
|
||||
return _chk(
|
||||
"anonymous", "匿名评审", "warn",
|
||||
"规则要求匿名评审,源文件未检测到匿名处理命令"
|
||||
+ "\n建议:\n %隐去作者/致谢/自引,或定义匿名宏:\n \\newcommand{\\anon}[1]{[ANON]}",
|
||||
"按期刊要求对作者信息做匿名处理",
|
||||
)
|
||||
|
||||
|
||||
def build_checks(profile: dict[str, Any], rules: dict[str, Any]) -> list[dict[str, str]]:
|
||||
checks = [
|
||||
_check_pages(profile, rules),
|
||||
_check_fonts(profile, rules),
|
||||
_check_margins(profile, rules),
|
||||
_check_spacing(profile, rules),
|
||||
]
|
||||
if profile.get("source_kind") == "tex":
|
||||
checks.append(_check_font_packages(profile, rules))
|
||||
checks.append(_check_figures(profile, rules))
|
||||
checks.append(_check_refs(profile, rules))
|
||||
if profile.get("source_kind") == "tex" and rules.get("anonymous"):
|
||||
checks.append(_check_anonymous(profile, rules))
|
||||
return checks
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 入口
|
||||
|
||||
def check_document(data: bytes, kind: str, target_journal: str) -> dict[str, Any]:
|
||||
"""kind ∈ pdf|docx|tex。返回 {checks, summary, meta}(filename 由路由器补入 meta)。"""
|
||||
if kind == "pdf":
|
||||
profile = extract_profile_pdf(data)
|
||||
elif kind == "docx":
|
||||
profile = extract_profile_docx(data)
|
||||
elif kind == "tex":
|
||||
profile = extract_tex_profile(data)
|
||||
else:
|
||||
raise FormatCheckError(f"不支持的文件类型:{kind}")
|
||||
|
||||
rules, ruleset_meta = load_ruleset(target_journal)
|
||||
checks = build_checks(profile, rules)
|
||||
summary = {"pass": 0, "warn": 0, "fail": 0}
|
||||
for c in checks:
|
||||
summary[c["status"]] = summary.get(c["status"], 0) + 1
|
||||
|
||||
detected, _ = detect_refs_style(profile.get("refs_text")) if profile.get("refs_found") else ("unknown", "")
|
||||
meta = {
|
||||
**ruleset_meta,
|
||||
"file_type": kind,
|
||||
"pages": profile.get("pages"),
|
||||
"refs_style_expected": str(rules.get("refs_style", "apa")).lower(),
|
||||
"refs_style_detected": detected,
|
||||
}
|
||||
if kind == "tex":
|
||||
tex = profile.get("tex") or {}
|
||||
meta["tex"] = {
|
||||
"class": tex.get("class"),
|
||||
"fontsize_pt": tex.get("fontsize_pt"),
|
||||
"paper": tex.get("paper"),
|
||||
"packages": tex.get("packages"),
|
||||
"cites": tex.get("cites"),
|
||||
"macros": (tex.get("macros") or [])[:10],
|
||||
}
|
||||
return {"checks": checks, "summary": summary, "meta": meta}
|
||||
@@ -0,0 +1,232 @@
|
||||
"""services/format_extractor.py —— 投稿指南 → 格式规则 JSON(LLM 抽取,第一批)。
|
||||
|
||||
- 调 services/llm.py 的 complete_json(DeepSeek json_object + 修复重试,零新依赖)
|
||||
- Prompt = 「格式规则抽取器」模板:目标字段与 journals.json format_rules_default
|
||||
同构(max_pages / min_font_pt / min_margin_mm / min_line_spacing / min_figures /
|
||||
max_fonts / refs_style / anonymous),强制每字段带 source_quotes + confidence
|
||||
- 诚实原则:正文没写 → null,绝不编造(与 format_check 诚实原则一致)
|
||||
- 解析/校验失败 → {"ok": False, "error": ...}
|
||||
|
||||
返回:
|
||||
成功 {"ok": True, "rules": {...}, "source_quotes": {...},
|
||||
"confidence": {...}, "notes": str, "raw": LLM原始JSON, "error": None}
|
||||
失败 {"ok": False, "error": "...", 其余键为 None}
|
||||
source_url 由调用方(scripts/fetch_format_rules.py)在落盘时补入结果文件。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from .llm import LLMError, complete_json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 「格式规则抽取器」模板(system prompt)
|
||||
_SYSTEM = """你是「格式规则抽取器」:从期刊官方投稿指南的 Markdown 正文中抽取格式规则,供投稿排版检查引擎使用。
|
||||
|
||||
# 目标字段(rules 对象,严格用以下字段名)
|
||||
- max_pages:正文页数上限(整数;原文无明确上限 → null)
|
||||
- min_font_pt:最小字号(数字,单位 pt;原文若用 cm/mm/inch 给出则换算为 pt)
|
||||
- min_margin_mm:最小页边距(数字,单位 mm;原文若用 inch 给出则 ×25.4 换算)
|
||||
- min_line_spacing:最小行距倍数(数字;如 "double-spaced" → 2.0,"1.5 spacing" → 1.5)
|
||||
- min_figures:研究论文最少图数(整数;无明确要求 → null)
|
||||
- max_fonts:全文最多字体种类(整数;无明确要求 → null)
|
||||
- refs_style:参考文献样式,枚举 apa | gb7714 | author_year | numbered | vancouver | mla | chicago | other(无法判断 → null)
|
||||
- anonymous:是否要求匿名/双盲评审(true / false / null)
|
||||
|
||||
# 凭证(强制)
|
||||
- 每个非 null 字段必须给 source_quotes[字段名]:从正文【逐字】摘录的 1-3 句原文,
|
||||
不改写、不翻译、不丢掉含关键数字的句子。
|
||||
- source_quotes["_general"]:整体最有凭证价值的 1-3 条原文摘录。
|
||||
- confidence:{"overall": 0-1, "per_field": {"字段名": 0-1}}。
|
||||
直接原文命中 ≥0.9;单位换算 0.6-0.9;靠上下文推断 <0.6。
|
||||
|
||||
# 诚实原则
|
||||
- 正文没写到的字段一律 null,绝不用常识或训练记忆补值。
|
||||
- 单位换算允许做,但该字段 confidence 不得超过 0.9。
|
||||
- 换算过程、拿不准的点、页面疑似不完整,写进 notes。
|
||||
|
||||
# 输出
|
||||
只输出一个 JSON 对象,不要代码块、不要解释文字:
|
||||
{"rules": {"max_pages": null, "min_font_pt": null, "min_margin_mm": null,
|
||||
"min_line_spacing": null, "min_figures": null, "max_fonts": null,
|
||||
"refs_style": null, "anonymous": null},
|
||||
"source_quotes": {"字段名": ["原文1", "..."], "_general": ["原文1", "..."]},
|
||||
"confidence": {"overall": 0.0, "per_field": {"字段名": 0.0}},
|
||||
"notes": "一句话说明抽取依据与不确定点"}"""
|
||||
|
||||
_USER_TMPL = """期刊:{journal_name}
|
||||
|
||||
=== 官方投稿指南正文(Markdown,已截断) ===
|
||||
{markdown}"""
|
||||
|
||||
_RULE_INT_KEYS = ("max_pages", "min_figures", "max_fonts")
|
||||
_REFS_ALLOWED = {"apa", "gb7714", "author_year", "numbered", "vancouver",
|
||||
"mla", "chicago", "other"}
|
||||
_REFS_ALIAS = {
|
||||
"apa-7": "apa", "apa7": "apa", "apa 7th": "apa", "apa_7th": "apa",
|
||||
"harvard": "author_year", "author-year": "author_year", "authoryear": "author_year",
|
||||
"gb/t 7714": "gb7714", "gb/t-7714": "gb7714", "gb7714-2015": "gb7714",
|
||||
"gb/t7714": "gb7714", "chicago author-date": "chicago",
|
||||
}
|
||||
|
||||
|
||||
# 字符串值带显式单位时的目标单位换算表(LLM 若回传原文单位的兜底;无单位=数值即目标单位)
|
||||
_UNIT_TO_MM = {"mm": 1.0, "cm": 10.0, "in": 25.4, "inch": 25.4, "inches": 25.4, "pt": 25.4 / 72}
|
||||
_UNIT_TO_PT = {"pt": 1.0, "mm": 72 / 25.4, "cm": 72 / 2.54, "in": 72.0, "inch": 72.0, "inches": 72.0}
|
||||
|
||||
|
||||
def _to_int(v: Any) -> int | None:
|
||||
if isinstance(v, bool) or v is None:
|
||||
return None
|
||||
if isinstance(v, int):
|
||||
return v
|
||||
if isinstance(v, float):
|
||||
return int(round(v))
|
||||
if isinstance(v, str):
|
||||
m = re.search(r"-?\d+", v)
|
||||
if m:
|
||||
return int(m.group(0))
|
||||
return None
|
||||
|
||||
|
||||
def _to_float(v: Any, units: dict[str, float] | None = None) -> float | None:
|
||||
if isinstance(v, bool) or v is None:
|
||||
return None
|
||||
if isinstance(v, (int, float)):
|
||||
return float(v)
|
||||
if isinstance(v, str):
|
||||
m = re.search(r"(-?\d+(?:\.\d+)?)\s*([a-zA-Z]+)?", v)
|
||||
if not m:
|
||||
return None
|
||||
x = float(m.group(1))
|
||||
unit = (m.group(2) or "").lower()
|
||||
if units and unit:
|
||||
factor = units.get(unit)
|
||||
if factor is None:
|
||||
return None # 未知单位不猜
|
||||
x *= factor
|
||||
return x
|
||||
return None
|
||||
|
||||
|
||||
def _to_bool(v: Any) -> bool | None:
|
||||
if isinstance(v, bool):
|
||||
return v
|
||||
if isinstance(v, (int, float)) and not isinstance(v, bool):
|
||||
if v in (0, 1):
|
||||
return bool(v)
|
||||
if isinstance(v, str):
|
||||
low = v.strip().lower()
|
||||
if low in ("true", "yes", "required"):
|
||||
return True
|
||||
if low in ("false", "no", "not required"):
|
||||
return False
|
||||
return None
|
||||
|
||||
|
||||
def _norm_refs(v: Any) -> str | None:
|
||||
if not isinstance(v, str) or not v.strip():
|
||||
return None
|
||||
low = v.strip().lower()
|
||||
low = _REFS_ALIAS.get(low, low)
|
||||
return low if low in _REFS_ALLOWED else "other"
|
||||
|
||||
|
||||
def _norm_conf(v: Any) -> float | None:
|
||||
if isinstance(v, bool) or not isinstance(v, (int, float)):
|
||||
return None
|
||||
return max(0.0, min(1.0, float(v)))
|
||||
|
||||
|
||||
def _normalize(raw: dict[str, Any]) -> dict[str, Any]:
|
||||
"""白名单归一:类型错的值置 null(宁缺勿错,raw 全量保留在结果里)。"""
|
||||
src = raw.get("rules")
|
||||
src = src if isinstance(src, dict) else {}
|
||||
rules: dict[str, Any] = {}
|
||||
for k in _RULE_INT_KEYS:
|
||||
rules[k] = _to_int(src.get(k))
|
||||
rules["min_font_pt"] = _to_float(src.get("min_font_pt"), _UNIT_TO_PT)
|
||||
rules["min_margin_mm"] = _to_float(src.get("min_margin_mm"), _UNIT_TO_MM)
|
||||
rules["min_line_spacing"] = _to_float(src.get("min_line_spacing"))
|
||||
rules["refs_style"] = _norm_refs(src.get("refs_style"))
|
||||
rules["anonymous"] = _to_bool(src.get("anonymous"))
|
||||
|
||||
sq_src = raw.get("source_quotes")
|
||||
sq_src = sq_src if isinstance(sq_src, dict) else {}
|
||||
quotes: dict[str, list[str]] = {}
|
||||
for key, val in sq_src.items():
|
||||
if key != "_general" and key not in rules:
|
||||
continue
|
||||
items = val if isinstance(val, list) else [val]
|
||||
cleaned = [str(x).strip() for x in items if isinstance(x, (str, int, float))
|
||||
and str(x).strip()]
|
||||
if cleaned:
|
||||
quotes[key] = cleaned
|
||||
|
||||
conf_src = raw.get("confidence")
|
||||
conf_src = conf_src if isinstance(conf_src, dict) else {}
|
||||
pf_src = conf_src.get("per_field")
|
||||
pf_src = pf_src if isinstance(pf_src, dict) else {}
|
||||
per_field: dict[str, float] = {}
|
||||
for k, v in pf_src.items():
|
||||
c = _norm_conf(v)
|
||||
if c is not None and (k in rules or k == "_general"):
|
||||
per_field[k] = c
|
||||
overall = _norm_conf(conf_src.get("overall"))
|
||||
if overall is None and per_field:
|
||||
overall = sum(per_field.values()) / len(per_field)
|
||||
confidence = {"overall": overall, "per_field": per_field}
|
||||
|
||||
notes = raw.get("notes")
|
||||
return {
|
||||
"rules": rules,
|
||||
"source_quotes": quotes,
|
||||
"confidence": confidence,
|
||||
"notes": str(notes) if notes else "",
|
||||
}
|
||||
|
||||
|
||||
def _validation_error(norm: dict[str, Any]) -> str | None:
|
||||
"""强制凭证:任一非 null 字段缺 source_quotes → 拒收(ok=False)。"""
|
||||
missing = [k for k, v in norm["rules"].items()
|
||||
if v is not None and k not in norm["source_quotes"]]
|
||||
if missing:
|
||||
return f"LLM 输出缺少非 null 字段的 source_quotes:{', '.join(missing)}"
|
||||
if not norm["source_quotes"].get("_general"):
|
||||
return "LLM 输出缺少 source_quotes._general 整体引证"
|
||||
if any(v is not None for v in norm["rules"].values()):
|
||||
return None
|
||||
return "LLM 未抽出任何非 null 字段(rules 全空)"
|
||||
|
||||
|
||||
async def extract_format_rules(markdown: str, journal_name: str) -> dict:
|
||||
"""markdown(≤30000 字符)+ 期刊名 → 抽取结果(schema 见模块 docstring)。"""
|
||||
if not markdown or not markdown.strip():
|
||||
return {"ok": False, "error": "markdown 为空", "rules": None,
|
||||
"source_quotes": None, "confidence": None, "notes": None, "raw": None}
|
||||
user = _USER_TMPL.format(journal_name=journal_name, markdown=markdown)
|
||||
try:
|
||||
raw = await complete_json(_SYSTEM, user, temperature=0.1, max_tokens=8000)
|
||||
except LLMError as exc:
|
||||
return {"ok": False, "error": f"LLM 调用失败:{exc}", "rules": None,
|
||||
"source_quotes": None, "confidence": None, "notes": None, "raw": None}
|
||||
if not isinstance(raw, dict):
|
||||
return {"ok": False, "error": f"LLM 输出非 JSON 对象:{type(raw).__name__}",
|
||||
"rules": None, "source_quotes": None, "confidence": None,
|
||||
"notes": None, "raw": None}
|
||||
|
||||
norm = _normalize(raw)
|
||||
err = _validation_error(norm)
|
||||
if err:
|
||||
logger.warning("[extract] %s 校验失败:%s", journal_name, err)
|
||||
return {"ok": False, "error": err, "rules": None,
|
||||
"source_quotes": norm["source_quotes"], "confidence": norm["confidence"],
|
||||
"notes": norm["notes"], "raw": raw}
|
||||
filled = sum(1 for v in norm["rules"].values() if v is not None)
|
||||
logger.info("[extract] %s → 8 字段中 %d 个非 null", journal_name, filled)
|
||||
return {"ok": True, "error": None, "rules": norm["rules"],
|
||||
"source_quotes": norm["source_quotes"],
|
||||
"confidence": norm["confidence"], "notes": norm["notes"], "raw": raw}
|
||||
@@ -0,0 +1,356 @@
|
||||
"""services/format_fetcher.py —— 投稿指南页面抓取(格式规则自动获取·第一批)。
|
||||
|
||||
约束(用户拍板 2026-10-10):
|
||||
- 零新依赖:httpx + beautifulsoup4(venv 已装 4.15.0,用户明确指定使用;
|
||||
lxml 6.1.2 在 requirements.lock 内,作 bs4 解析器)
|
||||
- 同域限速 ≥ 2s(robots.txt 与正文请求共用计时器)
|
||||
- 抓正文前先 GET /robots.txt,命中 Disallow → ok=False 跳过
|
||||
- UA: "PlantAI-RAG-Bot/1.0 (+contact: admin@example.com)"
|
||||
- 只提取正文(去导航/页脚/脚本),截断至 30000 字符
|
||||
|
||||
返回固定四键:{"ok": bool, "markdown": str, "error": str | None, "fetched_at": str}
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup, NavigableString
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
USER_AGENT = "PlantAI-RAG-Bot/1.0 (+contact: admin@example.com)"
|
||||
TIMEOUT = 30.0
|
||||
MAX_CHARS = 30_000
|
||||
MIN_INTERVAL_PER_HOST = 2.0 # 同域请求间隔 ≥ 2s(robots 与正文共用)
|
||||
_MIN_TEXT_CHARS = 200 # 正文短于此 → 疑似 JS 渲染/反爬页
|
||||
_HEADERS = {
|
||||
"User-Agent": USER_AGENT,
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
}
|
||||
|
||||
# 同域限速:host → 上次请求完成时刻(monotonic)
|
||||
_last_hit: dict[str, float] = {}
|
||||
_throttle_lock = asyncio.Lock()
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def _fail(error: str) -> dict:
|
||||
return {"ok": False, "markdown": "", "error": error, "fetched_at": _now_iso()}
|
||||
|
||||
|
||||
async def _throttle(host: str) -> None:
|
||||
"""同域串行 + ≥2s 间隔(全局锁:本批抓取量小,简单可靠优先)。"""
|
||||
async with _throttle_lock:
|
||||
now = time.monotonic()
|
||||
wait = _last_hit.get(host, now) + MIN_INTERVAL_PER_HOST - now
|
||||
if wait > 0:
|
||||
await asyncio.sleep(wait)
|
||||
_last_hit[host] = time.monotonic()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- robots.txt
|
||||
|
||||
def _parse_robots(text: str) -> list[tuple[list[str], list[tuple[str, str]]]]:
|
||||
"""→ [(agents, [(kind, pattern)...]), ...](连续 UA 行同组;规则行后出现 UA=新组)。"""
|
||||
groups: list[tuple[list[str], list[tuple[str, str]]]] = []
|
||||
agents: list[str] = []
|
||||
rules: list[tuple[str, str]] = []
|
||||
for raw in text.splitlines():
|
||||
line = raw.split("#", 1)[0].strip()
|
||||
if not line or ":" not in line:
|
||||
continue
|
||||
key, _, value = line.partition(":")
|
||||
key, value = key.strip().lower(), value.strip()
|
||||
if key == "user-agent":
|
||||
if agents and rules: # 上一组已出规则 → 当前 UA 行开新组
|
||||
groups.append((agents, rules))
|
||||
agents, rules = [], []
|
||||
agents.append(value)
|
||||
elif key in ("allow", "disallow"):
|
||||
if not agents: # 无前置 UA 的规则按 * 组处理
|
||||
agents = ["*"]
|
||||
rules.append((key, value))
|
||||
if agents:
|
||||
groups.append((agents, rules))
|
||||
return groups
|
||||
|
||||
|
||||
def _robots_path_allowed(groups, path: str) -> tuple[bool, str]:
|
||||
"""最长匹配决胜(同长 Allow 优先;`$` 结尾=精确匹配;空 Disallow 跳过)。"""
|
||||
|
||||
def matched_by(pat: str) -> bool:
|
||||
if pat.endswith("$"):
|
||||
return path == pat[:-1]
|
||||
return path.startswith(pat)
|
||||
|
||||
token = "plantai-rag-bot"
|
||||
matched = [rules for agents, rules in groups
|
||||
if any(token in a.lower() for a in agents)]
|
||||
if not matched:
|
||||
matched = [rules for agents, rules in groups
|
||||
if any(a.lower() == "*" for a in agents)]
|
||||
if not matched:
|
||||
return True, "robots.txt 无匹配组(本 bot 不受限)"
|
||||
|
||||
best_len, best_allow, best_pat = -1, False, ""
|
||||
for rules in matched:
|
||||
for kind, pat in rules:
|
||||
if not pat: # "Disallow:" 空值 = 放行一切(该组无需处理)
|
||||
continue
|
||||
if not matched_by(pat):
|
||||
continue
|
||||
length = len(pat)
|
||||
allow = kind == "allow"
|
||||
if length > best_len or (length == best_len and allow and not best_allow):
|
||||
best_len, best_allow, best_pat = length, allow, pat
|
||||
if best_len < 0:
|
||||
return True, "robots.txt 允许(无路径匹配)"
|
||||
if best_allow:
|
||||
return True, f"robots.txt Allow 命中({best_pat})"
|
||||
return False, f"robots.txt Disallow 命中({best_pat})"
|
||||
|
||||
|
||||
async def _robots_allows(client: httpx.AsyncClient, url: str) -> tuple[bool, str]:
|
||||
p = urlparse(url)
|
||||
robots_url = f"{p.scheme}://{p.netloc}/robots.txt"
|
||||
await _throttle(p.netloc.lower())
|
||||
try:
|
||||
resp = await client.get(robots_url, headers=_HEADERS)
|
||||
except httpx.HTTPError as exc:
|
||||
return True, f"robots.txt 获取失败({type(exc).__name__}),按允许处理"
|
||||
if resp.status_code != 200:
|
||||
return True, f"robots.txt HTTP {resp.status_code},按允许处理"
|
||||
groups = _parse_robots(resp.text)
|
||||
return _robots_path_allowed(groups, p.path or "/")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 正文提取
|
||||
|
||||
_STRIP_TAGS = ("script", "style", "noscript", "iframe", "form", "nav",
|
||||
"footer", "aside", "button", "select", "textarea", "input", "svg")
|
||||
_STRIP_ROLES = ("navigation", "contentinfo", "complementary", "search")
|
||||
# 正文容器候选(按优先级;首个文本 ≥500 字符者胜出)
|
||||
_CONTENT_SELECTORS = (
|
||||
"main", "article", '[role="main"]', "#content", "#main", "#main-content",
|
||||
".article-body", "#article-body", ".article__body", ".hub-article-body",
|
||||
".page-content", ".main-content", ".content-area",
|
||||
)
|
||||
_CHALLENGE_MARKERS = ("just a moment", "attention required", "captcha",
|
||||
"access denied", "ddos protection", "verify you are human")
|
||||
|
||||
|
||||
def _decode_html(resp: httpx.Response) -> str:
|
||||
"""header charset → <meta charset> → utf-8(errors=replace),防 OUP/Wiley 无 header 乱码。"""
|
||||
enc = None
|
||||
m = re.search(r"charset=([\w.-]+)", resp.headers.get("content-type", ""), re.I)
|
||||
if m:
|
||||
enc = m.group(1)
|
||||
if not enc:
|
||||
m = re.search(rb"""charset\s*=\s*["']?([\w.-]+)""", resp.content[:4096], re.I)
|
||||
if m:
|
||||
enc = m.group(1).decode("ascii", "ignore")
|
||||
try:
|
||||
return resp.content.decode(enc or "utf-8", errors="replace")
|
||||
except LookupError:
|
||||
return resp.content.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def _squeeze(s: str) -> str:
|
||||
"""行内空白压缩 + 去空行(保留单换行)。"""
|
||||
lines = [re.sub(r"[ \t\r\f\v]+", " ", line).strip() for line in s.split("\n")]
|
||||
return "\n".join(line for line in lines if line)
|
||||
|
||||
|
||||
def _inline(el) -> str:
|
||||
"""行内渲染:a→md链接 / strong→** / em→* / code→` / br→换行;跳过列表与图片。"""
|
||||
parts: list[str] = []
|
||||
for child in el.children:
|
||||
name = getattr(child, "name", None)
|
||||
if name is None: # NavigableString
|
||||
parts.append(str(child))
|
||||
continue
|
||||
if name in ("ul", "ol", "img", "picture", "figure"):
|
||||
continue
|
||||
if name == "a":
|
||||
href = (child.get("href") or "").strip()
|
||||
txt = _inline(child).strip()
|
||||
if not txt:
|
||||
continue
|
||||
parts.append(f"[{txt}]({href})" if href.startswith(("http://", "https://")) else txt)
|
||||
elif name in ("strong", "b"):
|
||||
parts.append(f"**{_inline(child)}**")
|
||||
elif name in ("em", "i"):
|
||||
parts.append(f"*{_inline(child)}*")
|
||||
elif name == "code":
|
||||
parts.append(f"`{_inline(child).strip()}`")
|
||||
elif name == "br":
|
||||
parts.append("\n")
|
||||
else:
|
||||
parts.append(_inline(child))
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _list_md(el, ordered: bool) -> str:
|
||||
lines: list[str] = []
|
||||
idx = 1
|
||||
for li in el.find_all("li", recursive=False):
|
||||
# li 文本不含嵌套列表(嵌套列表随后缩进输出)
|
||||
buf = []
|
||||
for child in li.children:
|
||||
if getattr(child, "name", None) in ("ul", "ol"):
|
||||
continue
|
||||
buf.append(_inline(child) if getattr(child, "name", None) else str(child))
|
||||
txt = " ".join("".join(buf).split())
|
||||
if txt:
|
||||
lines.append(f"{idx}. {txt}" if ordered else f"- {txt}")
|
||||
idx += 1
|
||||
for nested in li.find_all(("ul", "ol"), recursive=False):
|
||||
sub = _list_md(nested, nested.name == "ol")
|
||||
if sub:
|
||||
lines.extend(" " + line for line in sub.split("\n"))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _table_md(table) -> str | None:
|
||||
rows: list[list[str]] = []
|
||||
for tr in table.find_all("tr"):
|
||||
cells = [" ".join(_squeeze(_inline(c)).split())
|
||||
for c in tr.find_all(("td", "th"))]
|
||||
if cells:
|
||||
rows.append(cells)
|
||||
if not rows:
|
||||
return None
|
||||
out = ["| " + " | ".join(rows[0]) + " |"]
|
||||
if table.find("th"): # 首行含表头 → 补分隔行(合法 GFM 表)
|
||||
out.append("| " + " | ".join(["---"] * len(rows[0])) + " |")
|
||||
out += ["| " + " | ".join(r) + " |" for r in rows[1:]]
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def _blocks(el, out: list[str]) -> None:
|
||||
for child in el.children:
|
||||
name = getattr(child, "name", None)
|
||||
if name is None: # 裸文本
|
||||
txt = _squeeze(str(child))
|
||||
if txt:
|
||||
out.append(txt)
|
||||
continue
|
||||
if name in _STRIP_TAGS:
|
||||
continue
|
||||
if name in ("h1", "h2", "h3", "h4", "h5", "h6"):
|
||||
txt = " ".join(_squeeze(_inline(child)).split())
|
||||
if txt:
|
||||
out.append("#" * int(name[1]) + " " + txt)
|
||||
elif name == "p":
|
||||
txt = _squeeze(_inline(child))
|
||||
if txt:
|
||||
out.append(txt)
|
||||
elif name in ("ul", "ol"):
|
||||
md = _list_md(child, name == "ol")
|
||||
if md:
|
||||
out.append(md)
|
||||
elif name == "table":
|
||||
md = _table_md(child)
|
||||
if md:
|
||||
out.append(md)
|
||||
elif name == "blockquote":
|
||||
txt = _squeeze(_inline(child))
|
||||
if txt:
|
||||
out.append("\n".join("> " + line for line in txt.split("\n")))
|
||||
elif name == "pre":
|
||||
code = child.get_text().strip("\n")
|
||||
if code.strip():
|
||||
out.append("```\n" + code + "\n```")
|
||||
elif name == "hr":
|
||||
out.append("---")
|
||||
else: # div/section/article/main/figure/dl/details/… 容器递归
|
||||
_blocks(child, out)
|
||||
|
||||
|
||||
def _html_to_markdown(html: str) -> tuple[str, str | None]:
|
||||
"""→ (markdown, error)。选正文容器 → 去壳 → DOM 递归转 Markdown。"""
|
||||
soup = BeautifulSoup(html, "lxml") # lxml 在 lock 内;缺时 bs4 自动回退 html.parser
|
||||
|
||||
title = (soup.title.get_text() if soup.title else "") or ""
|
||||
for tag in soup.find_all(_STRIP_TAGS):
|
||||
tag.decompose()
|
||||
for tag in soup.find_all(attrs={"role": _STRIP_ROLES}):
|
||||
tag.decompose()
|
||||
|
||||
container = None
|
||||
fallback_hits = []
|
||||
for sel in _CONTENT_SELECTORS:
|
||||
for node in soup.select(sel):
|
||||
text_len = len(node.get_text(" ", strip=True))
|
||||
fallback_hits.append((text_len, node))
|
||||
if text_len >= 500:
|
||||
container = node
|
||||
break
|
||||
if container is not None:
|
||||
break
|
||||
if container is None:
|
||||
if fallback_hits:
|
||||
container = max(fallback_hits, key=lambda x: x[0])[1]
|
||||
else:
|
||||
container = soup.body or soup
|
||||
|
||||
blocks: list[str] = []
|
||||
_blocks(container, blocks)
|
||||
md = "\n\n".join(blocks)
|
||||
|
||||
probe = (title + " " + md[:500]).lower()
|
||||
marker = next((m for m in _CHALLENGE_MARKERS if m in probe), None)
|
||||
if marker:
|
||||
return "", f"疑似反爬/挑战页(命中 {marker!r},title={title[:80]!r})"
|
||||
if len(md) < _MIN_TEXT_CHARS:
|
||||
return "", f"正文提取过短({len(md)} < {_MIN_TEXT_CHARS} 字符),疑似 JS 渲染或空壳页"
|
||||
if len(md) > MAX_CHARS:
|
||||
md = md[:MAX_CHARS - 1] + "…"
|
||||
return md, None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 入口
|
||||
|
||||
async def fetch_submission_guidelines(url: str) -> dict:
|
||||
"""抓投稿指南页面 → 正文 Markdown。
|
||||
|
||||
流程:robots.txt 检查(≥2s 限速)→ 禁止即跳过 → GET 正文(≥2s 限速)
|
||||
→ 状态/类型校验 → 正文提取 → 截断 30000 字符。
|
||||
"""
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
||||
return _fail(f"非法 URL:{url!r}")
|
||||
host = parsed.netloc.lower()
|
||||
|
||||
async with httpx.AsyncClient(follow_redirects=True, timeout=TIMEOUT) as client:
|
||||
allowed, robots_reason = await _robots_allows(client, url)
|
||||
if not allowed:
|
||||
logger.info("[fetch] robots 禁止,跳过 %s(%s)", url, robots_reason)
|
||||
return _fail(robots_reason)
|
||||
|
||||
await _throttle(host)
|
||||
try:
|
||||
resp = await client.get(url, headers=_HEADERS)
|
||||
except httpx.HTTPError as exc:
|
||||
return _fail(f"请求失败({type(exc).__name__}):{exc}")
|
||||
if resp.status_code != 200:
|
||||
return _fail(f"HTTP {resp.status_code}")
|
||||
ctype = resp.headers.get("content-type", "")
|
||||
if "html" not in ctype and "xhtml" not in ctype:
|
||||
return _fail(f"非 HTML 响应:{ctype or '无 Content-Type'}")
|
||||
|
||||
markdown, err = _html_to_markdown(_decode_html(resp))
|
||||
if err:
|
||||
return _fail(f"{err}(robots: {robots_reason})")
|
||||
logger.info("[fetch] %s → %d 字符(robots: %s)", url, len(markdown), robots_reason)
|
||||
return {"ok": True, "markdown": markdown, "error": None, "fetched_at": _now_iso()}
|
||||
@@ -0,0 +1,223 @@
|
||||
"""services/journal_kb.py —— 期刊 scope_desc → vstore 入库 + 向量匹配(只读消费 8094)。
|
||||
|
||||
链路(全部复用 vstore 既有 API,零改动):
|
||||
1. ensure_scope_docs:各期刊 scope_desc 以 .txt 入库(category=journal_scope,
|
||||
name=期刊名),document_id 回写 journals.vstore_doc_id(幂等:已有 id 不重灌)。
|
||||
2. match:GET /api/vstore/search-chunks?q=<摘要截断>&doc_ids=<id 白名单>&top_k=N
|
||||
→ 按 doc_id 映射回期刊,取该期刊最高 chunk 分数为 similarity。
|
||||
3. 自愈:白名单命中 0 条 → GET /documents/{id} 探活 → 失效的重灌(vstore 去重
|
||||
语义下同内容重灌会换 doc_id,需回写)→ 重试一次 → 仍 0 视为语义低相似。
|
||||
|
||||
错误语义:连通性/上游异常抛 MatchError(路由降级为全量候选);
|
||||
返回 ([], False) 表示检索成功但无有效匹配(路由同样降级)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from ..config import get_settings
|
||||
from ..db import set_vstore_doc_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SCOPE_CATEGORY = "journal_scope"
|
||||
|
||||
|
||||
class MatchError(Exception):
|
||||
"""vstore 向量匹配失败(不可达/响应异常)。"""
|
||||
|
||||
|
||||
def _timeout() -> httpx.Timeout:
|
||||
t = get_settings().vstore_match_timeout
|
||||
return httpx.Timeout(connect=5.0, read=t, write=t, pool=5.0)
|
||||
|
||||
|
||||
def _err_body(resp: httpx.Response) -> str:
|
||||
try:
|
||||
payload = resp.json()
|
||||
except ValueError:
|
||||
return (resp.text or "").strip()[:500] or "(空响应体)"
|
||||
if isinstance(payload, dict):
|
||||
err = payload.get("error")
|
||||
if isinstance(err, dict) and err.get("message"):
|
||||
return str(err["message"])
|
||||
if isinstance(err, str):
|
||||
return err
|
||||
return str(payload)[:500]
|
||||
|
||||
|
||||
async def _ingest_scope(client: httpx.AsyncClient, base: str, journal: dict[str, Any]) -> str:
|
||||
"""单本期刊 scope_desc 入库,返回 document_id。"""
|
||||
content = str(journal.get("scope_desc") or "").strip()
|
||||
if not content:
|
||||
raise MatchError(f"期刊 scope_desc 为空:{journal.get('id')}")
|
||||
jid = str(journal.get("id") or "")
|
||||
try:
|
||||
resp = await client.post(
|
||||
f"{base}/api/vstore/ingest",
|
||||
files={"file": (f"{jid}.txt", content.encode("utf-8"), "text/plain")},
|
||||
data={
|
||||
"name": str(journal.get("name") or jid),
|
||||
"category": SCOPE_CATEGORY,
|
||||
"description": "review_service 期刊范围种子(scope_desc 向量匹配)",
|
||||
},
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
raise MatchError(f"vstore 不可达({base}):{exc}") from exc
|
||||
if resp.status_code >= 400:
|
||||
raise MatchError(f"vstore ingest 失败(HTTP {resp.status_code}):{_err_body(resp)}")
|
||||
try:
|
||||
payload = resp.json()
|
||||
except ValueError as exc:
|
||||
raise MatchError(f"vstore ingest 响应非 JSON:{exc}") from exc
|
||||
if not isinstance(payload, dict) or not payload.get("ok"):
|
||||
raise MatchError(
|
||||
str((payload.get("error") or {}).get("message") if isinstance(payload, dict) else "") or "vstore ingest 失败"
|
||||
)
|
||||
doc_id = str((payload.get("item") or {}).get("document_id") or "")
|
||||
if not doc_id:
|
||||
raise MatchError("vstore ingest 响应缺少 document_id")
|
||||
return doc_id
|
||||
|
||||
|
||||
async def ensure_scope_docs(journals: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""补齐缺失的 scope 入库映射(就地回写 vstore_doc_id 并持久到 DB)。"""
|
||||
settings = get_settings()
|
||||
missing = [j for j in journals if not j.get("vstore_doc_id")]
|
||||
if not missing:
|
||||
return journals
|
||||
async with httpx.AsyncClient(timeout=_timeout()) as client:
|
||||
for j in missing:
|
||||
try:
|
||||
doc_id = await _ingest_scope(client, settings.vstore_base, j)
|
||||
except MatchError as exc:
|
||||
# 单本失败不阻断其余(该期刊将不进白名单,由路由降级兜底)
|
||||
logger.warning("journal_kb: scope 入库失败(%s):%s", j.get("id"), exc)
|
||||
continue
|
||||
j["vstore_doc_id"] = doc_id
|
||||
await set_vstore_doc_id(str(j["id"]), doc_id)
|
||||
return journals
|
||||
|
||||
|
||||
async def _search(
|
||||
client: httpx.AsyncClient, base: str, query: str, doc_ids: list[str]
|
||||
) -> list[dict[str, Any]]:
|
||||
"""chunk 级白名单检索。连通/上游错误抛 MatchError。"""
|
||||
if not doc_ids:
|
||||
# doc_ids 为空时 vstore 侧等于不过滤 → 会命中用户私有库,必须拦死
|
||||
raise MatchError("白名单为空,拒绝全库检索")
|
||||
try:
|
||||
resp = await client.get(
|
||||
f"{base}/api/vstore/search-chunks",
|
||||
params={
|
||||
"q": query,
|
||||
"top_k": get_settings().recommend_scope_top_k,
|
||||
"doc_ids": ",".join(doc_ids),
|
||||
},
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
raise MatchError(f"vstore search-chunks 不可达:{exc}") from exc
|
||||
if resp.status_code >= 400:
|
||||
raise MatchError(f"vstore search-chunks 失败(HTTP {resp.status_code}):{_err_body(resp)}")
|
||||
try:
|
||||
payload = resp.json()
|
||||
except ValueError as exc:
|
||||
raise MatchError(f"vstore search-chunks 响应非 JSON:{exc}") from exc
|
||||
if not isinstance(payload, dict) or not payload.get("ok"):
|
||||
raise MatchError(
|
||||
str((payload.get("error") or {}).get("message") if isinstance(payload, dict) else "") or "vstore search-chunks 失败"
|
||||
)
|
||||
items = payload.get("items")
|
||||
return items if isinstance(items, list) else []
|
||||
|
||||
|
||||
async def _doc_alive(client: httpx.AsyncClient, base: str, doc_id: str) -> bool:
|
||||
try:
|
||||
resp = await client.get(f"{base}/api/vstore/documents/{doc_id}")
|
||||
except httpx.HTTPError:
|
||||
return False
|
||||
if resp.status_code != 200:
|
||||
return False
|
||||
try:
|
||||
payload = resp.json()
|
||||
except ValueError:
|
||||
return False
|
||||
return bool(isinstance(payload, dict) and payload.get("ok"))
|
||||
|
||||
|
||||
async def _refresh_dead_docs(
|
||||
client: httpx.AsyncClient, base: str, journals: list[dict[str, Any]]
|
||||
) -> None:
|
||||
"""白名单 0 命中时探活:失效文档重灌并回写新 id。"""
|
||||
for j in journals:
|
||||
doc_id = str(j.get("vstore_doc_id") or "")
|
||||
if not doc_id or await _doc_alive(client, base, doc_id):
|
||||
continue
|
||||
try:
|
||||
new_id = await _ingest_scope(client, base, j)
|
||||
except MatchError as exc:
|
||||
logger.warning("journal_kb: scope 重灌失败(%s):%s", j.get("id"), exc)
|
||||
continue
|
||||
j["vstore_doc_id"] = new_id
|
||||
await set_vstore_doc_id(str(j["id"]), new_id)
|
||||
|
||||
|
||||
async def match(
|
||||
abstract: str, journals: list[dict[str, Any]]
|
||||
) -> tuple[list[dict[str, Any]], bool]:
|
||||
"""摘要 vs scope_desc 向量匹配。
|
||||
|
||||
返回 (candidates, vector_ok);candidates 元素:
|
||||
{"journal": <journals 行>, "similarity": float}
|
||||
按 similarity 降序。MatchError → 路由降级;([], False) → 语义低相似。
|
||||
"""
|
||||
settings = get_settings()
|
||||
query = abstract.strip()[: settings.recommend_query_chars]
|
||||
if not query:
|
||||
return [], False
|
||||
|
||||
await ensure_scope_docs(journals)
|
||||
whitelisted = [j for j in journals if j.get("vstore_doc_id")]
|
||||
if not whitelisted:
|
||||
raise MatchError("无任何期刊 scope 入库成功(vstore_doc_id 为空)")
|
||||
|
||||
doc_map = {str(j["vstore_doc_id"]): j for j in whitelisted}
|
||||
timeout = _timeout()
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
items = await _search(client, settings.vstore_base, query, list(doc_map))
|
||||
if not items:
|
||||
await _refresh_dead_docs(client, settings.vstore_base, whitelisted)
|
||||
doc_map = {str(j["vstore_doc_id"]): j for j in whitelisted if j.get("vstore_doc_id")}
|
||||
if not doc_map:
|
||||
return [], False
|
||||
items = await _search(client, settings.vstore_base, query, list(doc_map))
|
||||
|
||||
# 聚合:每本期刊取最高 chunk 分
|
||||
best: dict[str, float] = {}
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
j = doc_map.get(str(item.get("doc_id") or ""))
|
||||
if j is None:
|
||||
continue
|
||||
try:
|
||||
score = float(item.get("score"))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
jid = str(j["id"])
|
||||
if jid not in best or score > best[jid]:
|
||||
best[jid] = score
|
||||
if not best:
|
||||
return [], False
|
||||
|
||||
by_id = {str(j["id"]): j for j in journals}
|
||||
candidates = [
|
||||
{"journal": by_id[jid], "similarity": score}
|
||||
for jid, score in best.items()
|
||||
if jid in by_id
|
||||
]
|
||||
candidates.sort(key=lambda c: c["similarity"], reverse=True)
|
||||
return candidates, bool(candidates)
|
||||
@@ -0,0 +1,210 @@
|
||||
"""services/journal_rank.py —— LLM 重排 + 幻觉防护 + 三档归类。
|
||||
|
||||
职责:
|
||||
1. rerank:把带语义分的候选期刊(来自 journal_kb)+ 用户偏好交给 LLM,
|
||||
要求按 id 白名单返回 {rankings:[{id, tier, match_reason, risk_note}]}。
|
||||
2. validate:硬校验——白名单外 id 丢弃(幻觉拦截);tier 非法/LLM 缺漏的
|
||||
期刊按规则补位(绝不因 LLM 输出丢失候选期刊);期刊名称/指标一律不采信
|
||||
LLM,最终展示全部用 DB 字段(防止改名型幻觉)。
|
||||
3. fallback_rankings:LLM 不可用时的确定性规则分档(按 impact_factor)。
|
||||
|
||||
三档语义:challenge 挑战型(高影响/慢审稿)、fit 适配型(均衡)、
|
||||
safe 稳妥型(快速/门槛低)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from ..config import get_settings
|
||||
from . import llm
|
||||
|
||||
TIERS = ("challenge", "fit", "safe")
|
||||
TIER_LABELS = {
|
||||
"challenge": "挑战型(高影响因子/慢审稿)",
|
||||
"fit": "适配型(中等影响/周期适中)",
|
||||
"safe": "稳妥型(快速发表/门槛较低)",
|
||||
}
|
||||
|
||||
# 规则分档阈值(近似值,可后续配置化)
|
||||
_CHALLENGE_MIN_IF = 9.0
|
||||
_SAFE_MAX_IF = 4.0
|
||||
|
||||
# tier 同义词归一(LLM 偶发中文标签)
|
||||
_TIER_ALIASES = {
|
||||
"挑战": "challenge",
|
||||
"挑战型": "challenge",
|
||||
"高风险": "challenge",
|
||||
"适配": "fit",
|
||||
"适配型": "fit",
|
||||
"稳妥": "safe",
|
||||
"稳妥型": "safe",
|
||||
"低风险": "safe",
|
||||
}
|
||||
|
||||
SYSTEM_PROMPT = """你是学术投稿推荐专家(植物基因组学方向)。根据给定候选期刊、论文摘要与用户偏好,为**每一个**候选期刊输出推荐分档与理由。
|
||||
|
||||
硬规则(违反即视为无效输出):
|
||||
1. id 白名单:只能使用输入 candidates 中出现的 id;绝对禁止添加、替换或臆造列表之外的任何期刊。
|
||||
2. 每个输入 id 恰好输出一次,不得遗漏、不得重复;期刊名称等元数据由系统填充,你只输出 id。
|
||||
3. tier 只能取 challenge | fit | safe:
|
||||
- challenge(挑战型):高影响因子、审稿较慢或竞争激烈的期刊
|
||||
- fit(适配型):影响力与发表周期均衡、与论文契合的期刊
|
||||
- safe(稳妥型):审稿较快、门槛较低、命中概率较高的期刊
|
||||
4. match_reason:中文,1-2 句,结合摘要的研究主题与该刊 scope_desc 及指标(如有 similarity 一并参考),说明为何匹配/不匹配。
|
||||
5. risk_note:中文,1-2 句,指出投稿风险(超出用户偏好、IF 门槛偏高、范围匹配偏弱、审稿周期长等);无明显风险写「无」。
|
||||
6. 用户 preferences 为软约束:不满足偏好的期刊仍必须输出,并在 risk_note 中明确说明。
|
||||
7. 不确定时宁可保守陈述,禁止编造统计数据(录用率、审稿天数等)。
|
||||
|
||||
输出:仅一个 JSON 对象,格式:
|
||||
{"rankings":[{"id":"...","tier":"challenge","match_reason":"...","risk_note":"..."}]}
|
||||
不要输出任何 JSON 之外的内容。"""
|
||||
|
||||
|
||||
class RankError(Exception):
|
||||
"""LLM 输出形态异常(无法按 JSON 解析/结构非法)。"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Ranking:
|
||||
journal_id: str
|
||||
tier: str
|
||||
match_reason: str
|
||||
risk_note: str
|
||||
|
||||
|
||||
def rule_tier(journal: dict[str, Any]) -> str:
|
||||
"""确定性分档规则(LLM 缺漏/不可用时兜底)。"""
|
||||
inf = journal.get("impact_factor")
|
||||
weeks = journal.get("review_time_weeks")
|
||||
if isinstance(inf, (int, float)):
|
||||
if inf >= _CHALLENGE_MIN_IF:
|
||||
return "challenge"
|
||||
if inf <= _SAFE_MAX_IF:
|
||||
return "safe"
|
||||
elif isinstance(weeks, (int, float)) and weeks <= 4:
|
||||
return "safe"
|
||||
return "fit"
|
||||
|
||||
|
||||
def build_user_prompt(
|
||||
abstract: str,
|
||||
candidates: list[dict[str, Any]],
|
||||
preferences: dict[str, Any],
|
||||
) -> str:
|
||||
"""候选 + 偏好 + 摘要 → 用户消息(JSON)。"""
|
||||
payload = {
|
||||
"abstract": abstract.strip()[:4000],
|
||||
"preferences": preferences or {},
|
||||
"candidates": [
|
||||
{
|
||||
"id": str(c["journal"]["id"]),
|
||||
"name": str(c["journal"].get("name") or ""),
|
||||
"impact_factor": c["journal"].get("impact_factor"),
|
||||
"review_time_weeks": c["journal"].get("review_time_weeks"),
|
||||
"acceptance_rate": c["journal"].get("acceptance_rate"),
|
||||
"similarity": c.get("similarity"),
|
||||
"scope_desc": str(c["journal"].get("scope_desc") or "")[:400],
|
||||
}
|
||||
for c in candidates
|
||||
],
|
||||
}
|
||||
return json.dumps(payload, ensure_ascii=False)
|
||||
|
||||
|
||||
def _normalize_tier(raw: Any) -> str | None:
|
||||
tier = str(raw or "").strip().lower()
|
||||
tier = _TIER_ALIASES.get(str(raw or "").strip(), tier)
|
||||
return tier if tier in TIERS else None
|
||||
|
||||
|
||||
def validate_rankings(
|
||||
payload: Any, candidates: list[dict[str, Any]]
|
||||
) -> tuple[list[Ranking], int, int]:
|
||||
"""校验 LLM 输出。
|
||||
|
||||
返回 (rankings, dropped_hallucinated, repaired):
|
||||
- 白名单外 id / 重复 id → 丢弃并计数(repaired 中重复不计);
|
||||
- tier 非法、LLM 缺漏的 id → 按规则补位(repaired 计数)。
|
||||
"""
|
||||
if not isinstance(payload, dict) or not isinstance(payload.get("rankings"), list):
|
||||
raise RankError("LLM 输出缺少 rankings 数组")
|
||||
|
||||
by_id = {str(c["journal"]["id"]): c["journal"] for c in candidates}
|
||||
input_ids = list(by_id)
|
||||
seen: set[str] = set()
|
||||
rankings: list[Ranking] = []
|
||||
dropped = 0
|
||||
repaired = 0
|
||||
|
||||
for entry in payload["rankings"]:
|
||||
if not isinstance(entry, dict):
|
||||
dropped += 1
|
||||
continue
|
||||
jid = str(entry.get("id") or "").strip()
|
||||
if jid in seen:
|
||||
continue
|
||||
if jid not in by_id:
|
||||
dropped += 1 # 幻觉期刊:绝不进输出
|
||||
continue
|
||||
seen.add(jid)
|
||||
tier = _normalize_tier(entry.get("tier"))
|
||||
if tier is None:
|
||||
tier = rule_tier(by_id[jid])
|
||||
repaired += 1
|
||||
reason = str(entry.get("match_reason") or "").strip() or "基于期刊范围与指标的匹配"
|
||||
risk = str(entry.get("risk_note") or "").strip() or "无"
|
||||
rankings.append(Ranking(journal_id=jid, tier=tier, match_reason=reason, risk_note=risk))
|
||||
|
||||
for jid in input_ids:
|
||||
if jid in seen:
|
||||
continue
|
||||
rankings.append(
|
||||
Ranking(
|
||||
journal_id=jid,
|
||||
tier=rule_tier(by_id[jid]),
|
||||
match_reason="LLM 未给出该期刊条目,已按影响因子/周期规则补位",
|
||||
risk_note="未提供(LLM 输出缺漏,规则兜底)",
|
||||
)
|
||||
)
|
||||
repaired += 1
|
||||
return rankings, dropped, repaired
|
||||
|
||||
|
||||
async def rerank(
|
||||
abstract: str,
|
||||
candidates: list[dict[str, Any]],
|
||||
preferences: dict[str, Any],
|
||||
) -> tuple[list[Ranking], int, int]:
|
||||
"""LLM 重排 + 校验。LLMError / RankError 上抛(路由降级为规则分档)。"""
|
||||
settings = get_settings()
|
||||
payload = await llm.complete_json(
|
||||
SYSTEM_PROMPT,
|
||||
build_user_prompt(abstract, candidates, preferences),
|
||||
temperature=0.2,
|
||||
max_tokens=3000,
|
||||
timeout=settings.recommend_read_timeout,
|
||||
)
|
||||
return validate_rankings(payload, candidates)
|
||||
|
||||
|
||||
def fallback_rankings(candidates: list[dict[str, Any]]) -> list[Ranking]:
|
||||
"""LLM 不可用时的确定性分档(不丢任何候选期刊)。"""
|
||||
rankings: list[Ranking] = []
|
||||
for c in candidates:
|
||||
j = c["journal"]
|
||||
inf = j.get("impact_factor")
|
||||
weeks = j.get("review_time_weeks")
|
||||
rankings.append(
|
||||
Ranking(
|
||||
journal_id=str(j["id"]),
|
||||
tier=rule_tier(j),
|
||||
match_reason=(
|
||||
f"规则分档:影响因子 {inf if inf is not None else '未收录'}、"
|
||||
f"审稿约 {weeks if weeks is not None else '未收录'} 周"
|
||||
),
|
||||
risk_note="LLM 不可用,本条按影响因子/周期规则估算",
|
||||
)
|
||||
)
|
||||
return rankings
|
||||
@@ -0,0 +1,520 @@
|
||||
"""app/services/journal_seed.py —— journal_directory 核心导入逻辑(CLI 与启动 seed 共享)。
|
||||
|
||||
数据流(schema 仍由本模块独家拥有,app 路由层只读不建表):
|
||||
openpyxl 非 read_only 加载(读 cell.hyperlink.target 来源链接)
|
||||
→ 「国际期刊」「中文期刊」两 sheet 按【列名】映射
|
||||
→ 行级清洗(空→None 绝不填 0;分区数字→"N区";JCR学科→JSON 数组)
|
||||
→ 按 id 幂等 upsert(ON CONFLICT DO UPDATE,重跑 count 不变)
|
||||
|
||||
两个入口:
|
||||
- scripts/import_journals.py(CLI):extract_rows_from_xlsx + upsert_to_db
|
||||
+ 对账报告(exit code 语义见脚本 docstring)
|
||||
- ensure_journal_directory_seeded():服务启动自动 seed(main.py lifespan),
|
||||
仅当表不存在或为空才导入;任何失败收敛为 dict 返回值,绝不抛异常。
|
||||
|
||||
v4 源(2026-10-10):JIF=2025 统计年/2026 JCR 发布;中文 IF=2024 统计年/
|
||||
年报 2025 版。源 xlsx 路径走 settings.journal_directory_seed_abs
|
||||
(REVIEW_JOURNAL_DIRECTORY_SEED_PATH 覆盖)。
|
||||
|
||||
id 策略:有 ISSN → "{lang}:{ISSN}"; 无 ISSN → "{lang}:{slug(name)}";
|
||||
同 lang 内冲突 → 抛 JournalSeedError(CLI 中止导入 / startup 返回 error)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
import unicodedata
|
||||
from collections import Counter
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import openpyxl
|
||||
|
||||
from ..config import get_settings
|
||||
|
||||
SHEET_INTL = "国际期刊"
|
||||
SHEET_ZH = "中文期刊"
|
||||
EXPECT_INTL = 779 # 口径 sheet 声明(v4 不变)
|
||||
EXPECT_ZH = 277
|
||||
|
||||
# 官网列(v4 实际列名「官网网址」在首位;其余为历史/备用别名)
|
||||
OFFICIAL_URL_HEADERS = ("官网网址", "官网", "官方网址", "期刊官网")
|
||||
# JCR 学科列别名:v4 改名「JCR学科(2024源表)」,v2 为「JCR学科(源表)」
|
||||
JCR_DISCIPLINE_HEADERS = ("JCR学科(2024源表)", "JCR学科(源表)")
|
||||
|
||||
# 既有表缺列时的幂等迁移(缺哪加哪,重复跑不报错)
|
||||
MIGRATE_COLS = (
|
||||
("official_url", "TEXT"),
|
||||
("official_check_status", "TEXT"),
|
||||
("jcr_release_year", "INTEGER"),
|
||||
)
|
||||
|
||||
SCHEMA_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS journal_directory (
|
||||
id TEXT PRIMARY KEY,
|
||||
lang TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
research_direction TEXT,
|
||||
cas_partition TEXT,
|
||||
cas_partition_note TEXT,
|
||||
cas_partition_year INTEGER,
|
||||
jif REAL,
|
||||
jif_year INTEGER,
|
||||
jif_note TEXT,
|
||||
jif_check_status TEXT,
|
||||
composite_impact_factor REAL,
|
||||
comprehensive_impact_factor REAL,
|
||||
metric_year INTEGER,
|
||||
metric_report_year INTEGER,
|
||||
metric_caliber TEXT,
|
||||
annual_report_page INTEGER,
|
||||
top_flag TEXT,
|
||||
publisher_series TEXT,
|
||||
journal_mark TEXT,
|
||||
article_type TEXT,
|
||||
issn TEXT,
|
||||
eissn TEXT,
|
||||
jcr_disciplines TEXT,
|
||||
supplement_note TEXT,
|
||||
evidence_note TEXT,
|
||||
official_url TEXT,
|
||||
official_check_status TEXT,
|
||||
jcr_release_year INTEGER,
|
||||
partition_source TEXT,
|
||||
jif_source TEXT,
|
||||
jif_source_url TEXT,
|
||||
partition_source_url TEXT,
|
||||
data_source_label TEXT,
|
||||
data_source_url TEXT,
|
||||
imported_at TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_dir_lang_name ON journal_directory(lang, name);
|
||||
CREATE INDEX IF NOT EXISTS idx_dir_lang_jif ON journal_directory(lang, jif);
|
||||
CREATE INDEX IF NOT EXISTS idx_dir_lang_cif ON journal_directory(lang, composite_impact_factor);
|
||||
CREATE INDEX IF NOT EXISTS idx_dir_lang_part ON journal_directory(lang, cas_partition);
|
||||
CREATE INDEX IF NOT EXISTS idx_dir_lang_domain ON journal_directory(lang, research_direction);
|
||||
"""
|
||||
|
||||
# upsert 列序(除 id 外全部 ON CONFLICT DO UPDATE)
|
||||
COLS = (
|
||||
"lang", "name", "research_direction", "cas_partition", "cas_partition_note",
|
||||
"cas_partition_year", "jif", "jif_year", "jif_note", "jif_check_status",
|
||||
"composite_impact_factor", "comprehensive_impact_factor", "metric_year",
|
||||
"metric_report_year", "metric_caliber", "annual_report_page", "top_flag",
|
||||
"publisher_series", "journal_mark", "article_type", "issn", "eissn",
|
||||
"jcr_disciplines", "supplement_note", "evidence_note", "official_url",
|
||||
"official_check_status", "jcr_release_year",
|
||||
"partition_source", "jif_source", "jif_source_url", "partition_source_url",
|
||||
"data_source_label", "data_source_url", "imported_at",
|
||||
)
|
||||
UPSERT_SQL = (
|
||||
"INSERT INTO journal_directory (id, " + ", ".join(COLS) + ") VALUES ("
|
||||
+ ", ".join("?" for _ in range(len(COLS) + 1)) + ") "
|
||||
+ "ON CONFLICT(id) DO UPDATE SET "
|
||||
+ ", ".join(f"{c}=excluded.{c}" for c in COLS)
|
||||
)
|
||||
|
||||
|
||||
class JournalSeedError(Exception):
|
||||
"""xlsx 缺失/结构错误/口径不符/id 冲突(不写库)。
|
||||
|
||||
exit_code 对齐 CLI 语义:1=id 冲突中止;2=行数与口径不符;3=文件/结构错误。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
exit_code: int = 3,
|
||||
stage: str = "读取失败",
|
||||
conflicts: list[str] | None = None,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.exit_code = exit_code
|
||||
self.stage = stage
|
||||
self.conflicts = conflicts or []
|
||||
|
||||
|
||||
def new_stats() -> dict[str, Any]:
|
||||
"""对账统计骨架(CLI 报告与解析共享)。"""
|
||||
return {
|
||||
"read_intl": 0, "read_zh": 0, "success": 0, "skipped": 0, "failed": 0,
|
||||
"jif_missing": [], "cif_missing": [], "part_blank": [], "part_notfound": [],
|
||||
"part_dist": Counter(), "hl_jif": 0, "hl_part": 0, "hl_zh": 0,
|
||||
"id_issn": 0, "id_slug": 0, "official_url_detected": False,
|
||||
"migrated_cols": [], "migrated_skipped": [],
|
||||
"official_total": 0, "official_filled": 0, "official_check_filled": 0,
|
||||
"jcr_filled": 0, "official_missing": [],
|
||||
}
|
||||
|
||||
|
||||
def migrate(conn: sqlite3.Connection) -> tuple[list[str], list[str]]:
|
||||
"""幂等加列:PRAGMA 探测现有列,缺哪个 ALTER ADD 哪个。"""
|
||||
existing = {r[1] for r in conn.execute("PRAGMA table_info(journal_directory)")}
|
||||
added: list[str] = []
|
||||
skipped: list[str] = []
|
||||
for col, decl in MIGRATE_COLS:
|
||||
if col in existing:
|
||||
skipped.append(col)
|
||||
else:
|
||||
conn.execute(f"ALTER TABLE journal_directory ADD COLUMN {col} {decl}")
|
||||
added.append(col)
|
||||
conn.commit()
|
||||
return added, skipped
|
||||
|
||||
|
||||
# ---- 清洗原语 ----
|
||||
|
||||
def text(v: Any) -> str | None:
|
||||
"""单元格 → strip 后的文本;空/纯空白 → None(绝不填 0/空串)。"""
|
||||
if v is None:
|
||||
return None
|
||||
s = str(v).strip()
|
||||
return s or None
|
||||
|
||||
|
||||
def to_float(v: Any) -> float | None:
|
||||
if v is None or (isinstance(v, str) and not v.strip()):
|
||||
return None
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def to_int(v: Any) -> int | None:
|
||||
f = to_float(v)
|
||||
if f is None:
|
||||
return None
|
||||
return int(f)
|
||||
|
||||
|
||||
def slugify(name: str) -> str:
|
||||
"""小写 + 非词字符折叠为连字符;\\w 含 CJK(中文期刊无 ISSN 时的 id 兜底)。"""
|
||||
s = unicodedata.normalize("NFC", name.strip().casefold())
|
||||
s = re.sub(r"[^\w]+", "-", s, flags=re.UNICODE)
|
||||
s = re.sub(r"-{2,}", "-", s)
|
||||
return s.strip("-")
|
||||
|
||||
|
||||
def make_id(lang: str, name: str, issn: str | None) -> str:
|
||||
if issn:
|
||||
return f"{lang}:{issn}"
|
||||
return f"{lang}:{slugify(name)}"
|
||||
|
||||
|
||||
def parse_partition(raw: Any) -> tuple[str | None, str | None]:
|
||||
"""分区 → (cas_partition, note)。数字 1-4 → 'N区';'未检得' → note;空白 → 双 None。"""
|
||||
if raw is None:
|
||||
return None, None
|
||||
if isinstance(raw, (int, float)) and not isinstance(raw, bool):
|
||||
n = int(raw)
|
||||
if 1 <= n <= 4:
|
||||
return f"{n}区", None
|
||||
return None, None
|
||||
s = str(raw).strip()
|
||||
if not s:
|
||||
return None, None
|
||||
if "未检得" in s:
|
||||
return None, "未检得"
|
||||
m = re.fullmatch(r"([1-4])\s*区?", s)
|
||||
if m:
|
||||
return f"{m.group(1)}区", None
|
||||
return s, None # 未知文本:原样保留(不丢信息)
|
||||
|
||||
|
||||
def split_disciplines(raw: Any) -> str | None:
|
||||
"""'A(SCIE);B(SCIE)' → JSON 数组字符串。"""
|
||||
s = text(raw)
|
||||
if not s:
|
||||
return None
|
||||
parts = [p.strip() for p in s.split(";") if p.strip()]
|
||||
return json.dumps(parts, ensure_ascii=False) if parts else None
|
||||
|
||||
|
||||
def hyperlink_target(cell) -> str | None:
|
||||
"""超链接单元格 → target URL;无链接 → None(需要非 read_only 模式)。"""
|
||||
try:
|
||||
target = cell.hyperlink.target if cell.hyperlink is not None else None
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
return text(target)
|
||||
|
||||
|
||||
# ---- sheet 解析 ----
|
||||
|
||||
def header_index(ws) -> dict[str, int]:
|
||||
headers = [text(c.value) for c in ws[1]]
|
||||
return {h: i for i, h in enumerate(headers) if h is not None}
|
||||
|
||||
|
||||
def read_intl(ws, imported_at: str, stats: dict) -> list[tuple]:
|
||||
"""国际期刊 sheet → 行元组(顺序同 COLS 前置 id)。"""
|
||||
ix = header_index(ws)
|
||||
for req in ("期刊名称", "研究方向", "中科院分区", "JIF影响因子"):
|
||||
if req not in ix:
|
||||
raise JournalSeedError(
|
||||
f"{SHEET_INTL} 缺列:{req}(实际列:{list(ix)})",
|
||||
exit_code=3, stage="结构错误",
|
||||
)
|
||||
url_col = next((ix[h] for h in OFFICIAL_URL_HEADERS if h in ix), None)
|
||||
jcr_hdr = next((h for h in JCR_DISCIPLINE_HEADERS if h in ix), None)
|
||||
stats["official_url_detected"] = url_col is not None
|
||||
if url_col is not None:
|
||||
stats["official_url_headers"] = next(h for h in OFFICIAL_URL_HEADERS if h in ix)
|
||||
|
||||
rows: list[tuple] = []
|
||||
for row in ws.iter_rows(min_row=2):
|
||||
if not any(c.value is not None for c in row):
|
||||
continue # 尾部幻影空行:不计入 skipped
|
||||
name = text(row[ix["期刊名称"]].value) if ix["期刊名称"] < len(row) else None
|
||||
if not name:
|
||||
stats["skipped"] += 1
|
||||
continue
|
||||
issn = text(row[ix["ISSN"]].value) if "ISSN" in ix else None
|
||||
jif = to_float(row[ix["JIF影响因子"]].value)
|
||||
check_status = text(row[ix["JIF核对状态"]].value) if "JIF核对状态" in ix else None
|
||||
official_check = text(row[ix["官网核对状态"]].value) if "官网核对状态" in ix else None
|
||||
jcr_year = to_int(row[ix["JCR发布年"]].value) if "JCR发布年" in ix else None
|
||||
part, part_note = parse_partition(row[ix["中科院分区"]].value)
|
||||
|
||||
# JIF 空 → note = 核对状态原文("未检得"/"源表标N/A" 等说明)
|
||||
jif_note = check_status if jif is None else None
|
||||
|
||||
jif_url = hyperlink_target(row[ix["JIF数据来源"]]) if "JIF数据来源" in ix else None
|
||||
part_url = hyperlink_target(row[ix["分区数据来源"]]) if "分区数据来源" in ix else None
|
||||
official = None
|
||||
if url_col is not None and url_col < len(row):
|
||||
official = text(row[url_col].value) or hyperlink_target(row[url_col])
|
||||
|
||||
rows.append((
|
||||
make_id("en", name, issn), "en", name,
|
||||
text(row[ix["研究方向"]].value),
|
||||
part, part_note,
|
||||
to_int(row[ix["分区版本年"]].value) if "分区版本年" in ix else None,
|
||||
jif,
|
||||
to_int(row[ix["JIF统计年"]].value) if "JIF统计年" in ix else None,
|
||||
jif_note, check_status,
|
||||
None, None, None, None, None, None,
|
||||
text(row[ix["Top(源表)"]].value) if "Top(源表)" in ix else None,
|
||||
text(row[ix["出版系列"]].value) if "出版系列" in ix else None,
|
||||
text(row[ix["期刊标记"]].value) if "期刊标记" in ix else None,
|
||||
text(row[ix["文章类型"]].value) if "文章类型" in ix else None,
|
||||
issn,
|
||||
text(row[ix["eISSN"]].value) if "eISSN" in ix else None,
|
||||
split_disciplines(row[ix[jcr_hdr]].value) if jcr_hdr else None,
|
||||
text(row[ix["补充说明"]].value) if "补充说明" in ix else None,
|
||||
text(row[ix["补充证据"]].value) if "补充证据" in ix else None,
|
||||
official,
|
||||
official_check,
|
||||
jcr_year,
|
||||
"official", "official",
|
||||
jif_url, part_url,
|
||||
text(row[ix["JIF数据来源"]].value) if "JIF数据来源" in ix else None,
|
||||
None, # data_source_url(国际表来源即 jif/partition 两链接)
|
||||
imported_at,
|
||||
))
|
||||
# 统计
|
||||
stats["official_total"] += 1
|
||||
stats["official_filled"] += 1 if official else 0
|
||||
stats["official_check_filled"] += 1 if official_check else 0
|
||||
stats["jcr_filled"] += 1 if jcr_year else 0
|
||||
if not official:
|
||||
stats["official_missing"].append(("en", name))
|
||||
if jif is None:
|
||||
stats["jif_missing"].append((name, check_status or ""))
|
||||
if part is None and part_note == "未检得":
|
||||
stats["part_notfound"].append(name)
|
||||
elif part is None:
|
||||
stats["part_blank"].append(name)
|
||||
stats["hl_jif"] += 1 if jif_url else 0
|
||||
stats["hl_part"] += 1 if part_url else 0
|
||||
stats["id_issn" if issn else "id_slug"] += 1
|
||||
return rows
|
||||
|
||||
|
||||
def read_zh(ws, imported_at: str, stats: dict) -> list[tuple]:
|
||||
"""中文期刊 sheet → 行元组。"""
|
||||
ix = header_index(ws)
|
||||
for req in ("中文期刊名称", "复合影响因子", "综合影响因子"):
|
||||
if req not in ix:
|
||||
raise JournalSeedError(
|
||||
f"{SHEET_ZH} 缺列:{req}(实际列:{list(ix)})",
|
||||
exit_code=3, stage="结构错误",
|
||||
)
|
||||
url_col = next((ix[h] for h in OFFICIAL_URL_HEADERS if h in ix), None)
|
||||
stats["official_url_detected_zh"] = url_col is not None
|
||||
|
||||
rows: list[tuple] = []
|
||||
for row in ws.iter_rows(min_row=2):
|
||||
if not any(c.value is not None for c in row):
|
||||
continue # 尾部幻影空行:不计入 skipped
|
||||
name = text(row[ix["中文期刊名称"]].value)
|
||||
if not name:
|
||||
stats["skipped"] += 1
|
||||
continue
|
||||
composite = to_float(row[ix["复合影响因子"]].value)
|
||||
comprehensive = to_float(row[ix["综合影响因子"]].value)
|
||||
check_status = text(row[ix["核对状态"]].value) if "核对状态" in ix else None
|
||||
|
||||
src_label = text(row[ix["数据来源"]].value) if "数据来源" in ix else None
|
||||
src_url = hyperlink_target(row[ix["数据来源"]]) if "数据来源" in ix else None
|
||||
official = None
|
||||
if url_col is not None and url_col < len(row):
|
||||
official = text(row[url_col].value) or hyperlink_target(row[url_col])
|
||||
official_check = text(row[ix["官网核对状态"]].value) if "官网核对状态" in ix else None
|
||||
|
||||
rows.append((
|
||||
make_id("zh", name, None), "zh", name,
|
||||
text(row[ix["研究方向"]].value),
|
||||
# 中文分区原样(常量"不按此口径列示"),不分区 → partition_source=None
|
||||
text(row[ix["中科院分区"]].value) if "中科院分区" in ix else None,
|
||||
None, # cas_partition_note:中文表无"未检得"语义(口径=不按此口径列示)
|
||||
None, # cas_partition_year
|
||||
None, None, None, check_status,
|
||||
composite, comprehensive,
|
||||
to_int(row[ix["指标统计年"]].value) if "指标统计年" in ix else None,
|
||||
to_int(row[ix["年报版本年"]].value) if "年报版本年" in ix else None,
|
||||
text(row[ix["指标口径"]].value) if "指标口径" in ix else None,
|
||||
to_int(row[ix["年报PDF页码"]].value) if "年报PDF页码" in ix else None,
|
||||
None, None, None, None, None, None, None,
|
||||
text(row[ix["备注"]].value) if "备注" in ix else None,
|
||||
None, # evidence_note(中文表无此列)
|
||||
official,
|
||||
official_check,
|
||||
None, # jcr_release_year:中文表无 JCR 发布年列(前端中文 Tab 不显示)
|
||||
None, # partition_source:中文不按中科院口径
|
||||
"official", # jif_source:国内年报(official)
|
||||
None, None,
|
||||
src_label, src_url,
|
||||
imported_at,
|
||||
))
|
||||
stats["official_total"] += 1
|
||||
stats["official_filled"] += 1 if official else 0
|
||||
stats["official_check_filled"] += 1 if official_check else 0
|
||||
if not official:
|
||||
stats["official_missing"].append(("zh", name))
|
||||
if composite is None:
|
||||
stats["cif_missing"].append((name, check_status or ""))
|
||||
stats["hl_zh"] += 1 if src_url else 0
|
||||
stats["id_slug"] += 1
|
||||
return rows
|
||||
|
||||
|
||||
# ---- 共享主流程 ----
|
||||
|
||||
def extract_rows_from_xlsx(
|
||||
source: Path,
|
||||
*,
|
||||
imported_at: str | None = None,
|
||||
stats: dict | None = None,
|
||||
) -> tuple[list[tuple], list[tuple], dict]:
|
||||
"""xlsx → (rows_en, rows_zh, stats)。口径不符/id 冲突抛 JournalSeedError(不写库)。"""
|
||||
stats = stats if stats is not None else new_stats()
|
||||
imported_at = imported_at or datetime.now().isoformat(timespec="seconds")
|
||||
|
||||
try:
|
||||
wb = openpyxl.load_workbook(source, data_only=True)
|
||||
except FileNotFoundError as exc:
|
||||
raise JournalSeedError(
|
||||
f"源文件不存在: {source}", exit_code=3, stage="读取失败"
|
||||
) from exc
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise JournalSeedError(
|
||||
f"无法打开 Excel: {exc}", exit_code=3, stage="读取失败"
|
||||
) from exc
|
||||
if SHEET_INTL not in wb.sheetnames or SHEET_ZH not in wb.sheetnames:
|
||||
raise JournalSeedError(
|
||||
f"缺少 sheet:需要 {SHEET_INTL}/{SHEET_ZH},实际 {wb.sheetnames}",
|
||||
exit_code=3, stage="结构错误",
|
||||
)
|
||||
|
||||
rows_en = read_intl(wb[SHEET_INTL], imported_at, stats)
|
||||
rows_zh = read_zh(wb[SHEET_ZH], imported_at, stats)
|
||||
stats["read_intl"], stats["read_zh"] = len(rows_en), len(rows_zh)
|
||||
for r in rows_en:
|
||||
stats["part_dist"][r[4] or ("未检得" if r[5] else "空白")] += 1
|
||||
|
||||
# 口径行数校验(不符 → 抛错,不写库)
|
||||
if len(rows_en) != EXPECT_INTL or len(rows_zh) != EXPECT_ZH:
|
||||
raise JournalSeedError(
|
||||
f"行数与口径不符:国际 {len(rows_en)}/{EXPECT_INTL}、"
|
||||
f"中文 {len(rows_zh)}/{EXPECT_ZH},中止导入",
|
||||
exit_code=2, stage="行数校验失败",
|
||||
)
|
||||
|
||||
# id 冲突检测(同 lang 内重复 → 抛错,不写库)
|
||||
ids = [r[0] for r in rows_en + rows_zh]
|
||||
dup = [i for i, c in Counter(ids).items() if c > 1]
|
||||
if dup:
|
||||
conflicts = [f"{i} × {Counter(ids)[i]}" for i in sorted(dup)]
|
||||
raise JournalSeedError(
|
||||
f"发现 {len(conflicts)} 个重复 id,中止导入(未写库)",
|
||||
exit_code=1, stage="id 冲突中止", conflicts=conflicts,
|
||||
)
|
||||
|
||||
return rows_en, rows_zh, stats
|
||||
|
||||
|
||||
def upsert_to_db(db_path: Path, rows_en: list[tuple], rows_zh: list[tuple]) -> dict[str, int]:
|
||||
"""建表(IF NOT EXISTS)+幂等加列+upsert → {"en","zh","total"}。"""
|
||||
db_path = Path(db_path)
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
try:
|
||||
conn.executescript(SCHEMA_SQL)
|
||||
migrate(conn)
|
||||
conn.executemany(UPSERT_SQL, list(rows_en) + list(rows_zh))
|
||||
conn.commit()
|
||||
d = dict(conn.execute(
|
||||
"SELECT lang, COUNT(*) FROM journal_directory GROUP BY lang").fetchall())
|
||||
return {"en": d.get("en", 0), "zh": d.get("zh", 0), "total": sum(d.values())}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def ensure_journal_directory_seeded(
|
||||
db_path: Path | None = None,
|
||||
source_xlsx: Path | None = None,
|
||||
) -> dict:
|
||||
"""启动自动 seed:空表才导入;任何失败收敛为 dict,绝不抛异常。
|
||||
|
||||
返回统一四键:seeded/reason/error/rows(另有 counts 仅成功时给出 en/zh 明细)。
|
||||
- 已有数据(只认空/非空,不做补差)→ seeded=False, reason="already has N rows, skip"
|
||||
- 空表导入成功 → seeded=True, rows=1056
|
||||
- xlsx 缺失/结构错误/口径不符/id 冲突 → seeded=False, error="..."
|
||||
"""
|
||||
settings = get_settings()
|
||||
db = Path(db_path) if db_path is not None else settings.db_path_abs
|
||||
source = Path(source_xlsx) if source_xlsx is not None else settings.journal_directory_seed_abs
|
||||
|
||||
try:
|
||||
if db.exists():
|
||||
conn = sqlite3.connect(str(db))
|
||||
try:
|
||||
row = conn.execute("SELECT COUNT(*) FROM journal_directory").fetchone()
|
||||
except sqlite3.OperationalError:
|
||||
row = None # 表不存在 → 视为空
|
||||
finally:
|
||||
conn.close()
|
||||
else:
|
||||
row = None
|
||||
if row is not None and int(row[0]) > 0:
|
||||
n = int(row[0])
|
||||
return {
|
||||
"seeded": False, "reason": f"already has {n} rows, skip",
|
||||
"error": None, "rows": n,
|
||||
}
|
||||
|
||||
rows_en, rows_zh, _ = extract_rows_from_xlsx(source)
|
||||
counts = upsert_to_db(db, rows_en, rows_zh)
|
||||
return {
|
||||
"seeded": True, "reason": None, "error": None,
|
||||
"rows": counts["total"], "counts": counts,
|
||||
}
|
||||
except JournalSeedError as exc:
|
||||
return {"seeded": False, "reason": None, "error": str(exc), "rows": None}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"seeded": False, "reason": None, "error": str(exc), "rows": None}
|
||||
@@ -0,0 +1,869 @@
|
||||
"""services/latex_compile.py —— tectonic 编译 .tex → PDF(P3a + P4 项目 zip)。
|
||||
|
||||
隔离与安全(对齐设计清单):
|
||||
1. 一编译一目录:{reformat_dir}/{anchor_id}/compile/(input.tex/output.pdf/output.log/meta.json)
|
||||
2. 文件名永不落盘:用户文件名只做后缀校验,写入一律 input.tex
|
||||
3. tectonic --untrusted(官方旗标:禁用已知不安全特性,含 shell escape 路径)
|
||||
4. 子进程 start_new_session + 超时 killpg(SIGKILL)(整进程组)
|
||||
5. 单槽队列:asyncio.Lock + pending 计数,超出 compile_queue_limit → QueueFullError
|
||||
6. 产物 PDF > max_compile_pdf_bytes → 删文件 + OutputTooLargeError
|
||||
7. 启动/每次编译前机会性清扫 retention_days 天前的 job 目录(只删 12 位 hex 锚点目录)
|
||||
|
||||
P4 项目 zip(两段式 inspect + run,与 P3a 共享单槽队列/清扫/错误类):
|
||||
- inspect_zip:安全解压(zip_safety)→ 主文件识别 → compile_inspect/{job_id}/(1h TTL)
|
||||
- compile_project_job:原子 rename 消费 inspect → compile_jobs/{cid}/ → tectonic →
|
||||
finally 删 project 树(只留 output.pdf/output.log/meta.json)
|
||||
- 失败也保证 output.log 落盘(补充 3);classify_compile_error 给出用户友好文案
|
||||
- P3a 单文件路径(prepare_input/compile_tex/_run_once)零改动
|
||||
|
||||
tectonic 二进制缺失 → ToolUnavailableError(路由层回安装指引)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_ANCHOR_ID_RE = re.compile(r"^[0-9a-f]{12}$")
|
||||
_ENGINE = "tectonic-p3/0.17.0"
|
||||
_LOG_TAIL_LINES = 200
|
||||
|
||||
# 单槽并发控制(进程内;重启归零可接受——本服务无持久队列需求)
|
||||
_lock = asyncio.Lock()
|
||||
_pending = 0
|
||||
|
||||
|
||||
class CompileError(Exception):
|
||||
"""编译失败(含 log 尾)。code ∈ {COMPILE_FAILED, TIMEOUT, OUTPUT_TOO_LARGE}。"""
|
||||
# compile_id:P4 项目编译失败时由 compile_project_job 附加(P3a 单文件为 None),
|
||||
# 路由层据此在失败信封里回 compile_id + log_url(补充 3)。
|
||||
compile_id: str | None = None
|
||||
|
||||
def __init__(self, code: str, message: str, log_tail: str = "") -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.log_tail = log_tail
|
||||
|
||||
|
||||
class QueueFullError(Exception):
|
||||
"""等待槽位超限。"""
|
||||
|
||||
|
||||
class ToolUnavailableError(Exception):
|
||||
"""tectonic 二进制缺失/不可执行。"""
|
||||
|
||||
|
||||
def _tectonic_bin() -> Path:
|
||||
return get_settings().tectonic_abs
|
||||
|
||||
|
||||
def _ensure_tool() -> Path:
|
||||
bin_path = _tectonic_bin()
|
||||
if not (bin_path.is_file() and os.access(bin_path, os.X_OK)):
|
||||
raise ToolUnavailableError(
|
||||
f"tectonic 未安装({bin_path})。"
|
||||
"请执行:cd review_service && bash tools/install_tectonic.sh"
|
||||
)
|
||||
return bin_path
|
||||
|
||||
|
||||
def _log_tail(path: Path, lines: int = _LOG_TAIL_LINES) -> str:
|
||||
try:
|
||||
text = path.read_text("utf-8", errors="replace")
|
||||
except OSError:
|
||||
return ""
|
||||
return "\n".join(text.splitlines()[-lines:])
|
||||
|
||||
|
||||
def compile_dir(anchor_id: str) -> Path:
|
||||
"""{reformat_dir}/{anchor_id}/compile/(存在性由调用方按需 mkdir)。"""
|
||||
return get_settings().reformat_dir_abs / anchor_id / "compile"
|
||||
|
||||
|
||||
def prepare_input(anchor_id: str, source: str) -> Path:
|
||||
"""写入隔离目录 input.tex;返回其路径。"""
|
||||
cdir = compile_dir(anchor_id)
|
||||
cdir.mkdir(parents=True, exist_ok=True)
|
||||
input_tex = cdir / "input.tex"
|
||||
input_tex.write_text(source, encoding="utf-8")
|
||||
return input_tex
|
||||
|
||||
|
||||
async def compile_tex(anchor_id: str) -> dict[str, Any]:
|
||||
"""同步编译 anchor 的 input.tex(须先 prepare_input)。
|
||||
|
||||
→ {compile_id: anchor_id, pdf_path, log_path, seconds, pdf_bytes}
|
||||
失败抛 CompileError / QueueFullError / ToolUnavailableError。
|
||||
"""
|
||||
global _pending
|
||||
settings = get_settings()
|
||||
_ensure_tool() # 快速失败:不占队列
|
||||
sweep_expired()
|
||||
|
||||
if _pending >= settings.compile_queue_limit:
|
||||
raise QueueFullError(
|
||||
f"编译队列已满({_pending} 个任务排队),请稍后重试"
|
||||
)
|
||||
_pending += 1
|
||||
try:
|
||||
async with _lock:
|
||||
return await _run_once(anchor_id)
|
||||
finally:
|
||||
_pending -= 1
|
||||
|
||||
|
||||
async def _run_once(anchor_id: str) -> dict[str, Any]:
|
||||
settings = get_settings()
|
||||
cdir = compile_dir(anchor_id)
|
||||
input_tex = cdir / "input.tex"
|
||||
pdf_path = cdir / "output.pdf"
|
||||
log_path = cdir / "output.log"
|
||||
if not input_tex.is_file():
|
||||
raise CompileError("COMPILE_FAILED", "编译输入不存在,请重新上传源码")
|
||||
|
||||
env = dict(os.environ)
|
||||
env["TECTONIC_CACHE_DIR"] = str(settings.tectonic_cache_abs)
|
||||
cmd = [
|
||||
str(_tectonic_bin()), "-X", "compile", input_tex.name,
|
||||
"--untrusted", "--keep-logs", "--outdir", ".",
|
||||
]
|
||||
started = time.monotonic()
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd, cwd=cdir, env=env,
|
||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT,
|
||||
start_new_session=True,
|
||||
)
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
proc.communicate(), timeout=settings.compile_timeout_s
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
_kill_group(proc)
|
||||
try:
|
||||
await asyncio.wait_for(proc.wait(), timeout=5.0)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
log_path.write_text(_log_tail(log_path) + "\n[timeout] 编译超时,进程已终止",
|
||||
encoding="utf-8")
|
||||
raise CompileError(
|
||||
"TIMEOUT",
|
||||
f"编译超过 {settings.compile_timeout_s:g} 秒未完成,已终止",
|
||||
_log_tail(log_path),
|
||||
) from None
|
||||
except FileNotFoundError as exc:
|
||||
raise ToolUnavailableError(f"tectonic 不可执行:{exc}") from exc
|
||||
finally:
|
||||
seconds = round(time.monotonic() - started, 2)
|
||||
|
||||
# tectonic --keep-logs 把日志写在 cwd 的 input.log
|
||||
produced_log = cdir / "input.log"
|
||||
if produced_log.is_file():
|
||||
shutil.move(str(produced_log), str(log_path))
|
||||
if not log_path.is_file():
|
||||
log_path.write_text("", encoding="utf-8")
|
||||
|
||||
produced_pdf = cdir / "input.pdf"
|
||||
if not produced_pdf.is_file():
|
||||
raise CompileError(
|
||||
"COMPILE_FAILED",
|
||||
"编译失败(未产出 PDF),请查看日志定位 LaTeX 错误",
|
||||
_log_tail(log_path),
|
||||
)
|
||||
pdf_bytes = produced_pdf.stat().st_size
|
||||
if pdf_bytes > settings.max_compile_pdf_bytes:
|
||||
produced_pdf.unlink(missing_ok=True)
|
||||
raise CompileError(
|
||||
"OUTPUT_TOO_LARGE",
|
||||
f"产出 PDF 超过 {settings.max_compile_pdf_bytes // (1024 * 1024)}MB 上限,已删除",
|
||||
_log_tail(log_path),
|
||||
)
|
||||
produced_pdf.rename(pdf_path)
|
||||
|
||||
meta = {
|
||||
"compile_id": anchor_id,
|
||||
"engine": _ENGINE,
|
||||
"seconds": seconds,
|
||||
"pdf_bytes": pdf_bytes,
|
||||
"returncode": 0,
|
||||
"compiled_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
}
|
||||
(cdir / "meta.json").write_text(
|
||||
json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
logger.info("compile: %s → PDF %d bytes, %.2fs", anchor_id, pdf_bytes, seconds)
|
||||
return {
|
||||
"compile_id": anchor_id,
|
||||
"pdf_path": pdf_path,
|
||||
"log_path": log_path,
|
||||
"seconds": seconds,
|
||||
"pdf_bytes": pdf_bytes,
|
||||
}
|
||||
|
||||
|
||||
def _kill_group(proc: asyncio.subprocess.Process) -> None:
|
||||
try:
|
||||
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
||||
except (ProcessLookupError, PermissionError, OSError):
|
||||
try:
|
||||
proc.kill()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
|
||||
def sweep_expired() -> int:
|
||||
"""删除 mtime 超 retention_days 的 12-hex 锚点目录(编译产物+P1 落盘一并清)。"""
|
||||
settings = get_settings()
|
||||
base = settings.reformat_dir_abs
|
||||
if not base.is_dir():
|
||||
return 0
|
||||
cutoff = time.time() - settings.compile_retention_days * 86400
|
||||
removed = 0
|
||||
for child in base.iterdir():
|
||||
if not child.is_dir() or not _ANCHOR_ID_RE.fullmatch(child.name):
|
||||
continue
|
||||
try:
|
||||
if child.stat().st_mtime < cutoff:
|
||||
shutil.rmtree(child, ignore_errors=True)
|
||||
removed += 1
|
||||
except OSError:
|
||||
continue
|
||||
if removed:
|
||||
logger.info("compile sweep: 清理 %d 个过期任务目录(>%d 天)",
|
||||
removed, settings.compile_retention_days)
|
||||
return removed
|
||||
|
||||
|
||||
# ================================================================ P4:项目 zip(inspect + run)
|
||||
#
|
||||
# 与 P3a 共用:_lock/_pending 单槽队列、_ensure_tool、CompileError/QueueFullError/
|
||||
# ToolUnavailableError、_kill_group、_log_tail、12-hex 目录与 mtime sweep 约定。
|
||||
# P3a 函数(prepare_input/compile_tex/_run_once)零改动。
|
||||
|
||||
|
||||
class InspectError(Exception):
|
||||
"""inspect/消费阶段业务失败。
|
||||
code ∈ {VALIDATION, ZIP_UNSAFE, ZIP_BOMB, ZIP_INVALID, ZIP_TOO_LARGE}。
|
||||
"""
|
||||
|
||||
def __init__(self, code: str, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.message = message
|
||||
|
||||
|
||||
_DOCCLASS_RE = re.compile(r"(?<!\\)\\documentclass\s*(\[|\{)")
|
||||
_IMG_LIKE_EXT = {".png", ".jpg", ".jpeg", ".pdf", ".eps", ".svg"}
|
||||
|
||||
|
||||
def inspect_root() -> Path:
|
||||
"""data/compile_inspect/(1 小时 TTL 暂存根,存在性由调用方按需 mkdir)。"""
|
||||
return get_settings().inspect_dir_abs
|
||||
|
||||
|
||||
def compile_job_dir(cid: str) -> Path:
|
||||
"""data/compile_jobs/{cid}/(产物根;不走 reformat_dir,P4 独立清扫)。"""
|
||||
return get_settings().compile_jobs_dir_abs / cid
|
||||
|
||||
|
||||
def _strip_tex_comments(text: str) -> str:
|
||||
"""粗剥注释:行内 % 截断(\\% 误伤可接受,MVP 设计决策)。"""
|
||||
return "\n".join(ln.split("%", 1)[0] for ln in text.splitlines())
|
||||
|
||||
|
||||
def _read_tex(path: Path) -> str:
|
||||
"""utf-8 → gb18030 → latin-1 降级读(中文模板常见 GBK)。"""
|
||||
raw = path.read_bytes()
|
||||
for enc in ("utf-8", "gb18030", "latin-1"):
|
||||
try:
|
||||
return raw.decode(enc)
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
return raw.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def find_main_tex(project_dir: Path) -> tuple[str | None, list[str], int]:
|
||||
"""主文件识别(补充 2)→ (main_rel | None, 含 documentclass 的相对路径清单, .tex 总数)。
|
||||
|
||||
场景:唯一候选 → main;0 个 .tex / 0 候选 / ≥2 候选 → None + 清单(调用方报错)。
|
||||
返回的相对路径用 posix 分隔符(与 zip 条目名一致)。
|
||||
"""
|
||||
tex_files = sorted(
|
||||
p for p in project_dir.rglob("*")
|
||||
if p.is_file() and p.suffix.lower() == ".tex"
|
||||
)
|
||||
if not tex_files:
|
||||
return None, [], 0
|
||||
candidates: list[str] = []
|
||||
for p in tex_files:
|
||||
if _DOCCLASS_RE.search(_strip_tex_comments(_read_tex(p))):
|
||||
candidates.append(p.relative_to(project_dir).as_posix())
|
||||
if len(candidates) == 1:
|
||||
return candidates[0], candidates, len(tex_files)
|
||||
return None, candidates, len(tex_files)
|
||||
|
||||
|
||||
_MISSING_FILE_RE = re.compile(
|
||||
r"LaTeX Error: File [`']([^`']+)' not found"
|
||||
r"|I can't find file [`']([^`']+)'"
|
||||
)
|
||||
_IMG_INCLUSION_RE = re.compile(r"""image inclusion failed for ["']([^"']+)["']""")
|
||||
|
||||
_EPS_CONVERTED_SUFFIX = "-eps-converted-to.pdf"
|
||||
_REF_TEXT_EXTS = {".tex", ".cls", ".sty", ".cfg", ".def"}
|
||||
|
||||
# EPS 现场转换 v2(约束 1-4):xdvipdfmx 无法内嵌 EPS → 解压后批量转同名 .pdf
|
||||
_EPS_CONVERT_TIMEOUT_S = 15 # 约束2:单文件转换超时(秒)
|
||||
_EPS_CONVERT_MAX_FILE = 20 * 1024 * 1024 # 约束3:单文件转换后上限(warning)
|
||||
_EPS_CONVERT_MAX_TOTAL = 100 * 1024 * 1024 # 约束3:转换产物总上限(中止)
|
||||
|
||||
|
||||
def _eps_tool_commands(eps: Path, pdf: Path) -> list[tuple[str, list[str]]]:
|
||||
"""转换工具优先级:gs(约束1 必须 -dSAFER)→ epstopdf → ps2pdf;只列 PATH 里可用的。"""
|
||||
tools: list[tuple[str, list[str]]] = []
|
||||
gs = shutil.which("gs")
|
||||
if gs:
|
||||
tools.append(("gs", [
|
||||
gs, "-dSAFER", "-dNOPAUSE", "-dBATCH", "-sDEVICE=pdfwrite",
|
||||
"-dPDFSETTINGS=/prepress",
|
||||
f"-sOutputFile={pdf}", str(eps),
|
||||
]))
|
||||
epstopdf = shutil.which("epstopdf")
|
||||
if epstopdf:
|
||||
# epstopdf 默认输出到同目录同名 .pdf(即 pdf 路径)
|
||||
tools.append(("epstopdf", [epstopdf, str(eps)]))
|
||||
ps2pdf = shutil.which("ps2pdf")
|
||||
if ps2pdf:
|
||||
tools.append(("ps2pdf", [ps2pdf, str(eps), str(pdf)]))
|
||||
return tools
|
||||
|
||||
|
||||
def _convert_eps_file(eps: Path, pdf: Path) -> tuple[str, str]:
|
||||
"""逐工具尝试把 eps 转成同名 pdf → ("ok", tool) | ("timeout", tool) | ("fail", "")。
|
||||
|
||||
约束2:单工具 15s 超时 → 放弃该文件(继续下一个 eps,不再试后续工具)。
|
||||
成功判定:退出后 pdf 存在且非空(防半成品)。
|
||||
"""
|
||||
tools = _eps_tool_commands(eps, pdf)
|
||||
if not tools:
|
||||
return "fail", ""
|
||||
for tool, cmd in tools:
|
||||
pdf.unlink(missing_ok=True) # 清上一个工具的半成品
|
||||
try:
|
||||
subprocess.run(
|
||||
cmd, timeout=_EPS_CONVERT_TIMEOUT_S,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
pdf.unlink(missing_ok=True)
|
||||
return "timeout", tool
|
||||
except OSError:
|
||||
continue
|
||||
if pdf.is_file() and pdf.stat().st_size > 0:
|
||||
return "ok", tool
|
||||
pdf.unlink(missing_ok=True)
|
||||
return "fail", ""
|
||||
|
||||
|
||||
def process_eps_images(project: Path) -> list[str]:
|
||||
"""EPS 兼容 v2:解压后**批量转换项目内所有 .eps**(不依赖 zip 自带预转换版)。
|
||||
|
||||
对每个 X.eps(原 .eps 保留):
|
||||
1. 已有同名 X.pdf → 跳过转换(引用照常切换为 .pdf);
|
||||
2. zip 自带 X-eps-converted-to.pdf 预转换版 → 复制为 X.pdf(免转换);
|
||||
3. 否则现场转换:gs(-dSAFER) → epstopdf → ps2pdf 逐个 fallback(约束1),
|
||||
单文件 15s 超时 → warning 后继续下一个(约束2)。
|
||||
转换成功 → 显式 `X.eps` 引用字节替换为 `X.pdf`(.tex/.cls/.sty/.cfg/.def 全扫,
|
||||
纯字节 ASCII/UTF-8 替换不碰编码);无扩展名引用 `{.../X}` 由 graphicx 直接
|
||||
命中新生成的 X.pdf(解析顺序 .pdf→…→.eps)。
|
||||
约束3:单文件转换后 >20MB → warning;转换产物累计 >100MB → 抛
|
||||
InspectError("ZIP_TOO_LARGE") 中止;约束4:返回值为写入 output.log 的
|
||||
"[eps-convert]" 日志行(由 _run_project 落盘)。
|
||||
→ 日志行列表(无 .eps 项目 → 空)。
|
||||
"""
|
||||
eps_files = sorted(
|
||||
p for p in project.rglob("*")
|
||||
if p.is_file() and p.suffix.lower() == ".eps"
|
||||
)
|
||||
if not eps_files:
|
||||
return []
|
||||
lines: list[str] = []
|
||||
handled: list[tuple[bytes, bytes]] = []
|
||||
total_out = 0
|
||||
|
||||
def _rel(p: Path) -> str:
|
||||
return p.relative_to(project).as_posix()
|
||||
|
||||
for eps in eps_files:
|
||||
pdf = eps.with_suffix(".pdf")
|
||||
src_rel, dst_rel = _rel(eps), _rel(pdf)
|
||||
if pdf.is_file():
|
||||
handled.append((eps.name.encode("utf-8"), pdf.name.encode("utf-8")))
|
||||
continue
|
||||
converted = eps.with_name(eps.stem + _EPS_CONVERTED_SUFFIX)
|
||||
if converted.is_file():
|
||||
t0 = time.monotonic()
|
||||
shutil.copyfile(converted, pdf)
|
||||
elapsed = time.monotonic() - t0
|
||||
tool = "preset"
|
||||
else:
|
||||
t0 = time.monotonic()
|
||||
status, tool = _convert_eps_file(eps, pdf)
|
||||
elapsed = time.monotonic() - t0
|
||||
if status == "timeout":
|
||||
lines.append(f"[eps-convert] {src_rel} 转换超时") # 约束2
|
||||
continue
|
||||
if status != "ok":
|
||||
lines.append(
|
||||
f"[eps-convert] 全部工具失败,图片 {src_rel} 未转换")
|
||||
continue
|
||||
# 约束4:转换日志(成功路径)
|
||||
lines.append(
|
||||
f"[eps-convert] {src_rel} → {dst_rel} "
|
||||
f"({eps.stat().st_size} → {pdf.stat().st_size}, "
|
||||
f"{elapsed:.2f}s, {tool})")
|
||||
size_out = pdf.stat().st_size
|
||||
if size_out > _EPS_CONVERT_MAX_FILE: # 约束3
|
||||
lines.append(
|
||||
f"[eps-convert] 警告:{dst_rel} 转换后 {size_out} 字节"
|
||||
f"超过 {_EPS_CONVERT_MAX_FILE // (1024 * 1024)}MB 上限")
|
||||
total_out += size_out
|
||||
if total_out > _EPS_CONVERT_MAX_TOTAL: # 约束3
|
||||
raise InspectError(
|
||||
"ZIP_TOO_LARGE",
|
||||
f"EPS 转换后总大小超过 {_EPS_CONVERT_MAX_TOTAL // (1024 * 1024)}MB 上限"
|
||||
f"(已达 {total_out // (1024 * 1024)}MB),请压缩图片后重试")
|
||||
handled.append((eps.name.encode("utf-8"), pdf.name.encode("utf-8")))
|
||||
|
||||
# 显式 .eps 引用 → .pdf(仅对转换成功者;失败的保持 .eps → 编译兜底友好报错)
|
||||
changed = 0
|
||||
if handled:
|
||||
for f in project.rglob("*"):
|
||||
if not (f.is_file() and f.suffix.lower() in _REF_TEXT_EXTS):
|
||||
continue
|
||||
data = f.read_bytes()
|
||||
new = data
|
||||
for old_b, new_b in handled:
|
||||
new = new.replace(old_b, new_b)
|
||||
if new != data:
|
||||
f.write_bytes(new)
|
||||
changed += 1
|
||||
if changed:
|
||||
lines.append(f"[eps-convert] {changed} 个文件的显式 .eps 引用已改写为 .pdf")
|
||||
if lines:
|
||||
logger.info("eps v2: %s", " | ".join(lines))
|
||||
return lines
|
||||
|
||||
|
||||
def _append_eps_lines(log_path: Path, lines: list[str] | None) -> None:
|
||||
"""约束4:把 [eps-convert] 转换日志写入 output.log(引擎 log 之后、chatter 之前)。"""
|
||||
if not lines:
|
||||
return
|
||||
with log_path.open("a", encoding="utf-8") as fh:
|
||||
fh.write("\n".join(lines) + "\n")
|
||||
|
||||
|
||||
def classify_compile_error(log_tail: str) -> str:
|
||||
"""COMPILE_FAILED 的日志 → 用户友好文案(Q4/§4.4)。"""
|
||||
m = _MISSING_FILE_RE.search(log_tail)
|
||||
if m:
|
||||
name = next(g for g in m.groups() if g)
|
||||
ext = Path(name).suffix.lower()
|
||||
if ext == ".cls":
|
||||
return f"项目缺少 {name},请从期刊官网下载模板后重新上传"
|
||||
if ext in (".sty", ".def", ".clo"):
|
||||
return f"缺少 LaTeX 宏包 {name},可能是模板不完整"
|
||||
if ext in _IMG_LIKE_EXT:
|
||||
return f"图片 {name} 未在 zip 里,请检查引用路径与大小写"
|
||||
if ext == ".bib":
|
||||
return f"缺少文献库 {name},请确认 .bib 已包含在 zip 中"
|
||||
return f"编译找不到文件 {name},请检查 zip 内文件路径"
|
||||
m = _IMG_INCLUSION_RE.search(log_tail)
|
||||
if m:
|
||||
name = m.group(1)
|
||||
if Path(name).suffix.lower() == ".eps":
|
||||
# EPS v2 已现场转换;仍失败 = 服务器转换没成功(工具缺失/超时/格式怪异)
|
||||
return "项目包含 EPS 图片,服务器转换失败,请转成 PDF/PNG 后重试"
|
||||
return f"图片 {name} 无法内嵌,请检查图片格式与路径(建议使用 PDF/PNG)"
|
||||
if re.search(r"Emergency stop|Undefined control sequence", log_tail):
|
||||
return "LaTeX 源码有语法错误,请展开日志定位出错行"
|
||||
return "编译失败,请查看日志定位 LaTeX 错误"
|
||||
|
||||
|
||||
async def inspect_zip(zip_bytes: bytes) -> dict[str, Any]:
|
||||
"""两段式之①:安全解压 + 主文件识别 + 落盘 inspect 暂存(1h TTL)。
|
||||
|
||||
→ {job_id, main, stats{files,tex,images,cls,bib,other,bytes}, skipped[], skipped_count}
|
||||
失败抛 InspectError(路由层转信封);任何失败 → rmtree 暂存目录,磁盘零残留。
|
||||
"""
|
||||
from .zip_safety import ZipSafetyError, safe_extract
|
||||
|
||||
settings = get_settings()
|
||||
sweep_inspect_expired() # 机会性清扫(同 P3a 模式)
|
||||
job_id = uuid.uuid4().hex[:12]
|
||||
jdir = inspect_root() / job_id
|
||||
project = jdir / "project"
|
||||
try:
|
||||
try:
|
||||
result = await asyncio.to_thread(safe_extract, zip_bytes, project, settings)
|
||||
except ZipSafetyError as exc:
|
||||
raise InspectError(exc.code, exc.message) from exc
|
||||
main, candidates, tex_total = find_main_tex(project)
|
||||
if tex_total == 0:
|
||||
raise InspectError("VALIDATION", "zip 中没有 .tex 文件")
|
||||
if not candidates:
|
||||
raise InspectError(
|
||||
"VALIDATION", "未找到含 \\documentclass 的 .tex 文件")
|
||||
if main is None:
|
||||
shown = ", ".join(Path(c).name for c in candidates)
|
||||
raise InspectError(
|
||||
"VALIDATION",
|
||||
f"检测到多个主文件({shown}),请合并为单一入口后重新上传")
|
||||
meta = {
|
||||
"job_id": job_id,
|
||||
"main": main,
|
||||
"stats": {
|
||||
"files": result.files, "tex": result.tex, "images": result.images,
|
||||
"cls": result.cls, "bib": result.bib, "other": result.other,
|
||||
"bytes": result.bytes,
|
||||
},
|
||||
"skipped": [{"name": s.name, "reason": s.reason} for s in result.skipped],
|
||||
"skipped_count": result.skipped_count,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
}
|
||||
jdir.mkdir(parents=True, exist_ok=True)
|
||||
(jdir / "inspect.json").write_text(
|
||||
json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return meta
|
||||
except BaseException:
|
||||
shutil.rmtree(jdir, ignore_errors=True)
|
||||
raise
|
||||
|
||||
|
||||
def consume_inspect(job_id: str, cid: str) -> tuple[Path, dict[str, Any]]:
|
||||
"""两段式之②前置:**原子 rename 消费** inspect 暂存 → compile_jobs/{cid}/project。
|
||||
|
||||
并发正确性(防同一 job 双跑):
|
||||
os.rename(src, dst) 在同一文件系统内是原子的,且 src 不存在 → FileNotFoundError。
|
||||
两个并发 run 拿同一 job_id 时,只有一个能成功 rename;后到者拿到
|
||||
FileNotFoundError → 路由层转 VALIDATION"已过期/不存在",**绝不可能双编译**。
|
||||
inspect 残壳(inspect.json)在 rename 成功后立即删除,也杜绝二跑读到半状态。
|
||||
"""
|
||||
settings = get_settings()
|
||||
if not _ANCHOR_ID_RE.fullmatch(job_id or ""):
|
||||
raise InspectError("VALIDATION", "job_id 格式非法(应为 12 位 hex)")
|
||||
src_root = inspect_root() / job_id
|
||||
meta_path = src_root / "inspect.json"
|
||||
src_project = src_root / "project"
|
||||
if not (meta_path.is_file() and src_project.is_dir()):
|
||||
raise InspectError(
|
||||
"VALIDATION", "上传任务不存在或已过期(1 小时),请重新上传 zip")
|
||||
try:
|
||||
meta = json.loads(meta_path.read_text("utf-8"))
|
||||
except (OSError, ValueError) as exc:
|
||||
raise InspectError(
|
||||
"VALIDATION", "上传任务数据损坏,请重新上传 zip") from exc
|
||||
dst_root = compile_job_dir(cid)
|
||||
dst_project = dst_root / "project"
|
||||
dst_root.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
os.rename(src_project, dst_project)
|
||||
except FileNotFoundError as exc:
|
||||
raise InspectError(
|
||||
"VALIDATION", "上传任务不存在或已过期(1 小时),请重新上传 zip") from exc
|
||||
except OSError as exc: # 跨设备等罕见失败 → 保持 inspect 原样可重试
|
||||
raise InspectError(
|
||||
"VALIDATION", "任务消费失败,请重试或重新上传") from exc
|
||||
shutil.rmtree(src_root, ignore_errors=True) # 消费成功 → 删残壳
|
||||
return dst_project, meta
|
||||
|
||||
|
||||
async def compile_project_job(job_id: str) -> dict[str, Any]:
|
||||
"""两段式之②:原子消费 inspect → tectonic 编译 → 产物留 pdf/log/meta。
|
||||
|
||||
队列/工具检查**先于消费**(inspect 保留 → 失败可直接重试);
|
||||
EPS v2:解压后批量转换所有 .eps → 同名 .pdf(gs -dSAFER → epstopdf → ps2pdf,
|
||||
单文件 15s 超时;产物累计 >100MB → ZIP_TOO_LARGE 中止),转换日志进 output.log;
|
||||
成功 → 立即删 project/;失败(COMPILE_FAILED/TIMEOUT)→ **保留 project/ 现场 24h**
|
||||
供诊断(sweep_compile_jobs_expired 按 failed_project_ttl_s 清理;
|
||||
KEEP_FAILED_PROJECTS=1 永久保留);
|
||||
失败必落完整 output.log(含 --print 的 XDV→PDF 阶段输出,补充 3)。
|
||||
|
||||
→ {compile_id, pdf_path, log_path, seconds, pdf_bytes}
|
||||
"""
|
||||
global _pending
|
||||
settings = get_settings()
|
||||
_ensure_tool() # 快速失败:不消费 inspect、不占队列
|
||||
sweep_inspect_expired()
|
||||
sweep_compile_jobs_expired()
|
||||
if _pending >= settings.compile_queue_limit:
|
||||
raise QueueFullError(
|
||||
f"编译队列已满({_pending} 个任务排队),请稍后重试")
|
||||
_pending += 1 # 占位先于消费:QUEUE_FULL 时 inspect 未动
|
||||
cid = uuid.uuid4().hex[:12]
|
||||
project: Path | None = None
|
||||
meta: dict[str, Any] = {}
|
||||
try:
|
||||
project, meta = await asyncio.to_thread(consume_inspect, job_id, cid)
|
||||
try:
|
||||
# EPS v2:解压后批量转换所有 .eps → 同名 .pdf 并切换显式引用
|
||||
# (不依赖 zip 自带预转换版;失败现场保留转换后的改写结果)
|
||||
eps_lines = await asyncio.to_thread(process_eps_images, project)
|
||||
async with _lock:
|
||||
result = await _run_project(
|
||||
cid, project, str(meta.get("main", "")), eps_lines)
|
||||
# 成功 → 立即清理 project 树(失败路径在 finally 保留现场)
|
||||
shutil.rmtree(project, ignore_errors=True)
|
||||
project = None
|
||||
return result
|
||||
except CompileError as exc:
|
||||
# 补充 3:失败信封也要能下载 log → 异常携带产物 cid(log 落盘于 compile_jobs/{cid}/)
|
||||
exc.compile_id = cid
|
||||
raise
|
||||
finally:
|
||||
_pending -= 1
|
||||
if project is not None:
|
||||
# 编译失败 → 保留 compile_jobs/{cid}/project/ 现场供诊断;
|
||||
# touch 目录使 24h 保留窗从失败时刻起算
|
||||
try:
|
||||
os.utime(project, None)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
async def _run_project(
|
||||
cid: str,
|
||||
project: Path,
|
||||
main_rel: str,
|
||||
eps_log_lines: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""单槽内执行项目编译:cwd=main 所在目录(相对路径解析),outdir=compile_jobs/{cid}。
|
||||
|
||||
eps_log_lines:EPS v2 的 [eps-convert] 转换日志(约束4),正常/超时两条路径
|
||||
都会写进 output.log(引擎 log 之后、chatter/[timeout] 之前)。
|
||||
"""
|
||||
settings = get_settings()
|
||||
outdir = compile_job_dir(cid)
|
||||
outdir.mkdir(parents=True, exist_ok=True)
|
||||
main_path = project / main_rel
|
||||
if not main_rel or not main_path.is_file():
|
||||
raise InspectError("VALIDATION", "主文件缺失,请重新上传 zip")
|
||||
cwd = main_path.parent
|
||||
stem = main_path.stem
|
||||
|
||||
env = dict(os.environ)
|
||||
env["TECTONIC_CACHE_DIR"] = str(settings.tectonic_cache_abs)
|
||||
# --print:打印引擎完整 chatter(含 XDV→PDF/xdvipdfmx 阶段输出);
|
||||
# 不加时 xdvipdfmx 报错只进 tectonic stderr、不落 {stem}.log,导致"LaTeX 成功但
|
||||
# 无 PDF"且 log 无错误行(MDPI 模板实测)。stdout/stderr 统一落盘 output.log。
|
||||
cmd = [
|
||||
str(_tectonic_bin()), "-X", "compile", main_path.name,
|
||||
"--untrusted", "--keep-logs", "--print", "--outdir", str(outdir),
|
||||
]
|
||||
timeout = settings.compile_project_timeout_s
|
||||
log_path = outdir / "output.log"
|
||||
started = time.monotonic()
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd, cwd=str(cwd), env=env,
|
||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT,
|
||||
start_new_session=True,
|
||||
)
|
||||
try:
|
||||
stdout_bytes, _ = await asyncio.wait_for(
|
||||
proc.communicate(), timeout=timeout)
|
||||
except asyncio.TimeoutError:
|
||||
_kill_group(proc)
|
||||
try:
|
||||
await asyncio.wait_for(proc.wait(), timeout=5.0)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
# 超时后 communicate 已取消,chatter 不可取;.log(--keep-logs)仍完整
|
||||
_ensure_project_log(outdir, stem, cwd)
|
||||
# 约束4:转换日志先落(转换发生在编译之前),再追加超时标记
|
||||
_append_eps_lines(log_path, eps_log_lines)
|
||||
# 追加而非覆盖:保留完整引擎输出(诊断 XDV→PDF 阶段必需)
|
||||
with log_path.open("a", encoding="utf-8") as fh:
|
||||
fh.write(f"\n[timeout] 编译超时({timeout:g}s),进程已终止\n")
|
||||
raise CompileError(
|
||||
"TIMEOUT",
|
||||
f"编译超过 {timeout:g} 秒未完成,已终止"
|
||||
"(项目首次编译需下载宏包,可能较慢,请重试)",
|
||||
_log_tail(log_path),
|
||||
) from None
|
||||
except FileNotFoundError as exc:
|
||||
raise ToolUnavailableError(f"tectonic 不可执行:{exc}") from exc
|
||||
finally:
|
||||
seconds = round(time.monotonic() - started, 2)
|
||||
|
||||
_ensure_project_log(outdir, stem, cwd)
|
||||
|
||||
# 产物归一:{stem}.pdf/.log → output.pdf/.log(补充 3:失败也必有 output.log)
|
||||
produced_pdf = outdir / f"{stem}.pdf"
|
||||
produced_log = outdir / f"{stem}.log"
|
||||
if produced_log.is_file():
|
||||
shutil.move(str(produced_log), str(log_path))
|
||||
if not log_path.is_file():
|
||||
log_path.write_text(
|
||||
"[no log] tectonic 未产出日志(进程异常退出)", encoding="utf-8")
|
||||
# 约束4:[eps-convert] 转换日志(引擎 log 之后、chatter 之前)
|
||||
_append_eps_lines(log_path, eps_log_lines)
|
||||
# --print 的进程输出(含 XDV→PDF 阶段)追加到 output.log 全文之后;
|
||||
# 信封 detail 仍只取尾 200 行,磁盘保留完整 log 供"查看完整 log"下载
|
||||
_append_chatter(log_path, stdout_bytes)
|
||||
|
||||
if not produced_pdf.is_file():
|
||||
raise CompileError(
|
||||
"COMPILE_FAILED",
|
||||
classify_compile_error(_log_tail(log_path)),
|
||||
_log_tail(log_path),
|
||||
)
|
||||
pdf_bytes = produced_pdf.stat().st_size
|
||||
if pdf_bytes > settings.max_compile_pdf_bytes:
|
||||
produced_pdf.unlink(missing_ok=True)
|
||||
raise CompileError(
|
||||
"OUTPUT_TOO_LARGE",
|
||||
f"产出 PDF 超过 {settings.max_compile_pdf_bytes // (1024 * 1024)}MB 上限,已删除",
|
||||
_log_tail(log_path),
|
||||
)
|
||||
produced_pdf.rename(outdir / "output.pdf")
|
||||
|
||||
meta = {
|
||||
"compile_id": cid,
|
||||
"project": True,
|
||||
"main": main_rel,
|
||||
"engine": _ENGINE,
|
||||
"seconds": seconds,
|
||||
"pdf_bytes": pdf_bytes,
|
||||
"returncode": 0,
|
||||
"compiled_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
}
|
||||
(outdir / "meta.json").write_text(
|
||||
json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
logger.info("compile(project): %s main=%s → PDF %d bytes, %.2fs",
|
||||
cid, main_rel, pdf_bytes, seconds)
|
||||
return {
|
||||
"compile_id": cid,
|
||||
"pdf_path": outdir / "output.pdf",
|
||||
"log_path": log_path,
|
||||
"seconds": seconds,
|
||||
"pdf_bytes": pdf_bytes,
|
||||
}
|
||||
|
||||
|
||||
def _ensure_project_log(outdir: Path, stem: str, cwd: Path) -> None:
|
||||
"""tectonic 落 log 到 outdir 或 cwd → 统一挪到 outdir/output.log;都没有则占位。"""
|
||||
log_path = outdir / "output.log"
|
||||
if log_path.is_file():
|
||||
return
|
||||
for cand in (outdir / f"{stem}.log", cwd / f"{stem}.log"):
|
||||
if cand.is_file():
|
||||
shutil.move(str(cand), str(log_path))
|
||||
return
|
||||
|
||||
|
||||
def _append_chatter(log_path: Path, stdout_bytes: bytes | None) -> None:
|
||||
"""把 tectonic --print 的进程 stdout(含 XDV→PDF/xdvipdfmx 阶段)追加到 output.log。
|
||||
|
||||
{stem}.log 只有 LaTeX 引擎内容(止于 "Output written on … .xdv");
|
||||
XDV→PDF 转换错误只出现在进程 chatter 里——不落盘则"无 PDF 且 log 无错误行"。
|
||||
"""
|
||||
if not stdout_bytes:
|
||||
return
|
||||
chatter = stdout_bytes.decode("utf-8", errors="replace").strip()
|
||||
if not chatter:
|
||||
return
|
||||
with log_path.open("a", encoding="utf-8") as fh:
|
||||
fh.write("\n\n===== tectonic --print(进程输出,含 XDV→PDF 阶段)=====\n")
|
||||
fh.write(chatter)
|
||||
fh.write("\n")
|
||||
|
||||
|
||||
def sweep_inspect_expired() -> int:
|
||||
"""删 mtime 超 inspect_ttl_s(默认 1h)的 12-hex inspect 暂存目录。"""
|
||||
settings = get_settings()
|
||||
base = settings.inspect_dir_abs
|
||||
if not base.is_dir():
|
||||
return 0
|
||||
cutoff = time.time() - settings.inspect_ttl_s
|
||||
removed = 0
|
||||
for child in base.iterdir():
|
||||
if not child.is_dir() or not _ANCHOR_ID_RE.fullmatch(child.name):
|
||||
continue
|
||||
try:
|
||||
if child.stat().st_mtime < cutoff:
|
||||
shutil.rmtree(child, ignore_errors=True)
|
||||
removed += 1
|
||||
except OSError:
|
||||
continue
|
||||
if removed:
|
||||
logger.info("inspect sweep: 清理 %d 个过期 inspect 暂存(>%d 秒)",
|
||||
removed, settings.inspect_ttl_s)
|
||||
return removed
|
||||
|
||||
|
||||
def _keep_failed_projects() -> bool:
|
||||
"""失败现场是否永久保留(调试):REVIEW_KEEP_FAILED_PROJECTS=1 或裸 KEEP_FAILED_PROJECTS=1。"""
|
||||
if get_settings().keep_failed_projects:
|
||||
return True
|
||||
return os.environ.get("KEEP_FAILED_PROJECTS", "").strip() == "1"
|
||||
|
||||
|
||||
def sweep_compile_jobs_expired() -> int:
|
||||
"""编译产物清扫,两级:
|
||||
① 失败现场 project/ 子目录:mtime 超 failed_project_ttl_s(默认 24h)→ 删子目录
|
||||
(compile_jobs/{cid} 本体与 output.log/meta 保留,供"查看完整 log");
|
||||
KEEP_FAILED_PROJECTS=1 跳过(永久保留,调试用)。
|
||||
② 整个 compile_jobs/{cid}:mtime 超 compile_retention_days(7 天)→ 整目录删。
|
||||
"""
|
||||
settings = get_settings()
|
||||
base = settings.compile_jobs_dir_abs
|
||||
if not base.is_dir():
|
||||
return 0
|
||||
removed = 0
|
||||
keep_failed = _keep_failed_projects()
|
||||
# ① 失败现场 24h 清理(成功编译的 project/ 已在 compile_project_job 立即删)
|
||||
if not keep_failed:
|
||||
proj_cutoff = time.time() - settings.failed_project_ttl_s
|
||||
for child in base.iterdir():
|
||||
if not child.is_dir() or not _ANCHOR_ID_RE.fullmatch(child.name):
|
||||
continue
|
||||
proj = child / "project"
|
||||
if not proj.is_dir():
|
||||
continue
|
||||
try:
|
||||
if proj.stat().st_mtime < proj_cutoff:
|
||||
shutil.rmtree(proj, ignore_errors=True)
|
||||
removed += 1
|
||||
except OSError:
|
||||
continue
|
||||
# ② 整目录 7 天清理
|
||||
cutoff = time.time() - settings.compile_retention_days * 86400
|
||||
for child in base.iterdir():
|
||||
if not child.is_dir() or not _ANCHOR_ID_RE.fullmatch(child.name):
|
||||
continue
|
||||
try:
|
||||
if child.stat().st_mtime < cutoff:
|
||||
shutil.rmtree(child, ignore_errors=True)
|
||||
removed += 1
|
||||
except OSError:
|
||||
continue
|
||||
if removed:
|
||||
logger.info("compile_jobs sweep: 清理 %d 个过期项目产物/失败现场", removed)
|
||||
return removed
|
||||
@@ -0,0 +1,203 @@
|
||||
"""services/llm.py —— LLM 调用(httpx 裸调 DeepSeek,模式同 paper_writer_service)。
|
||||
|
||||
- complete_text(system, user, *, temperature=0.3, max_tokens=8000, timeout=None) -> str
|
||||
- complete_json(...)(response_format json_object + 一次 JSON 修复重试)
|
||||
- 4 次尝试(1.5s / 3s / 6s 指数退避,429 / 5xx / 超时)
|
||||
- env 键(优先 REVIEW_LLM_*,回退 DOCGEN_LLM_*,再回退内置默认):
|
||||
*_LLM_BASE_URL / *_LLM_API_KEY / *_LLM_MODEL / *_LLM_TIMEOUT
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_LLM_BASE_URL = "https://api.deepseek.com/v1"
|
||||
DEFAULT_LLM_MODEL = "deepseek-chat"
|
||||
DEFAULT_LLM_TIMEOUT = 300.0
|
||||
BACKOFF_SECONDS = (1.5, 3.0, 6.0)
|
||||
MAX_ATTEMPTS = 4
|
||||
|
||||
|
||||
class LLMError(Exception):
|
||||
"""LLM 调用失败(重试尽/响应异常)。"""
|
||||
|
||||
|
||||
def _env(keys: tuple[str, ...], default: str = "") -> str:
|
||||
for key in keys:
|
||||
val = os.environ.get(key)
|
||||
if val:
|
||||
return val
|
||||
return default
|
||||
|
||||
|
||||
def _llm_config() -> dict[str, Any]:
|
||||
return {
|
||||
"base_url": _env(
|
||||
("REVIEW_LLM_BASE_URL", "DOCGEN_LLM_BASE_URL"), DEFAULT_LLM_BASE_URL
|
||||
).rstrip("/"),
|
||||
"api_key": _env(("REVIEW_LLM_API_KEY", "DOCGEN_LLM_API_KEY"), ""),
|
||||
"model": _env(("REVIEW_LLM_MODEL", "DOCGEN_LLM_MODEL"), DEFAULT_LLM_MODEL),
|
||||
"timeout": float(
|
||||
_env(("REVIEW_LLM_TIMEOUT", "DOCGEN_LLM_TIMEOUT"), str(DEFAULT_LLM_TIMEOUT))
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _extract_text(payload: dict[str, Any]) -> str:
|
||||
try:
|
||||
choices = payload.get("choices") or []
|
||||
if choices:
|
||||
content = choices[0].get("message", {}).get("content")
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
return "".join(
|
||||
str(c.get("text", "")) if isinstance(c, dict) else str(c) for c in content
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
async def _chat_once(
|
||||
client: httpx.AsyncClient,
|
||||
cfg: dict[str, Any],
|
||||
system: str,
|
||||
user: str,
|
||||
*,
|
||||
temperature: float,
|
||||
max_tokens: int,
|
||||
json_mode: bool,
|
||||
timeout: float | None,
|
||||
) -> str:
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if cfg["api_key"]:
|
||||
headers["Authorization"] = f"Bearer {cfg['api_key']}"
|
||||
body: dict[str, Any] = {
|
||||
"model": cfg["model"],
|
||||
"messages": [
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": user},
|
||||
],
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
}
|
||||
if json_mode:
|
||||
body["response_format"] = {"type": "json_object"}
|
||||
resp = await client.post(
|
||||
f"{cfg['base_url']}/chat/completions",
|
||||
headers=headers,
|
||||
json=body,
|
||||
timeout=timeout if timeout is not None else cfg["timeout"],
|
||||
)
|
||||
resp.raise_for_status()
|
||||
payload = resp.json()
|
||||
if not isinstance(payload, dict):
|
||||
raise LLMError("LLM 响应形态异常(非 JSON 对象)")
|
||||
text = _extract_text(payload)
|
||||
if not text:
|
||||
raise LLMError("LLM 响应缺少 choices[0].message.content")
|
||||
return text
|
||||
|
||||
|
||||
async def _complete(
|
||||
system: str,
|
||||
user: str,
|
||||
*,
|
||||
temperature: float,
|
||||
max_tokens: int,
|
||||
json_mode: bool,
|
||||
timeout: float | None,
|
||||
) -> str:
|
||||
cfg = _llm_config()
|
||||
last_exc: BaseException | None = None
|
||||
async with httpx.AsyncClient() as client:
|
||||
for attempt in range(MAX_ATTEMPTS):
|
||||
try:
|
||||
return await _chat_once(
|
||||
client, cfg, system, user,
|
||||
temperature=temperature, max_tokens=max_tokens, json_mode=json_mode,
|
||||
timeout=timeout,
|
||||
)
|
||||
except LLMError:
|
||||
raise
|
||||
except (httpx.HTTPStatusError, httpx.HTTPError, httpx.TimeoutException) as exc:
|
||||
last_exc = exc
|
||||
status = getattr(getattr(exc, "response", None), "status_code", None)
|
||||
retryable = status is None or status == 429 or status >= 500
|
||||
if not retryable or attempt == MAX_ATTEMPTS - 1:
|
||||
raise LLMError(f"LLM 调用失败(HTTP {status or '网络'}):{exc}") from exc
|
||||
await asyncio.sleep(BACKOFF_SECONDS[min(attempt, len(BACKOFF_SECONDS) - 1)])
|
||||
raise LLMError(f"LLM 调用失败:{last_exc}")
|
||||
|
||||
|
||||
async def complete_text(
|
||||
system: str,
|
||||
user: str,
|
||||
*,
|
||||
temperature: float = 0.3,
|
||||
max_tokens: int = 8000,
|
||||
timeout: float | None = None,
|
||||
) -> str:
|
||||
"""文本补全。失败抛 LLMError。"""
|
||||
return await _complete(
|
||||
system, user, temperature=temperature, max_tokens=max_tokens, json_mode=False,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
def _repair_json(text: str) -> str:
|
||||
text = text.strip()
|
||||
if text.startswith("```"):
|
||||
text = text.strip("`")
|
||||
if text.lower().startswith("json"):
|
||||
text = text[4:]
|
||||
text = text.strip()
|
||||
if not text:
|
||||
raise LLMError("LLM 返回空内容")
|
||||
if text.startswith("{"):
|
||||
return text
|
||||
start = text.find("{")
|
||||
end = text.rfind("}")
|
||||
if start >= 0 and end > start:
|
||||
return text[start:end + 1]
|
||||
raise LLMError("LLM 输出中未找到 JSON 对象")
|
||||
|
||||
|
||||
async def complete_json(
|
||||
system: str,
|
||||
user: str,
|
||||
*,
|
||||
temperature: float = 0.3,
|
||||
max_tokens: int = 8000,
|
||||
timeout: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""JSON 补全(json_object + 一次修复重试)。"""
|
||||
text = await _complete(
|
||||
system, user, temperature=temperature, max_tokens=max_tokens, json_mode=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
try:
|
||||
return json.loads(text)
|
||||
except ValueError:
|
||||
pass
|
||||
text2 = await _complete(
|
||||
system + "\n(重要:你的回答必须是单个合法 JSON 对象,不要输出其他任何内容)",
|
||||
user,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
json_mode=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
try:
|
||||
return json.loads(text2)
|
||||
except ValueError:
|
||||
repaired = _repair_json(text2)
|
||||
return json.loads(repaired)
|
||||
@@ -0,0 +1,139 @@
|
||||
"""paper_parse.py —— PDF → 纯文本(经 vstore 8094 现有解析接口,零改动复用)。
|
||||
|
||||
链路(全部只读消费 vstore 既有 API,不修改 vstore 代码):
|
||||
1. POST {vstore}/api/vstore/ingest multipart file(vstore 内部 pymupdf 解析)
|
||||
2. GET {vstore}/api/vstore/documents/{doc_id} → document.paragraphs 拼接纯文本
|
||||
3. DELETE {vstore}/api/vstore/documents/{doc_id} (临时解析件 best-effort 清理,
|
||||
REVIEW_PARSE_DELETE_AFTER=0 可关闭——保留文档便于人工排查)
|
||||
|
||||
为何不用 methodology 8092:其 /extract-upload 是 MinerU 分钟级异步 job
|
||||
(MinerU 子进程 + LLM 抽取两阶段),不适配同步自审稿;vstore 段落解析秒级
|
||||
返回,满足 presubmit 场景。methodology 地址在 config 中预留供后续扩展。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from ..config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ParseError(Exception):
|
||||
"""PDF 解析失败(上游不可达/响应异常/空文本)。"""
|
||||
|
||||
|
||||
async def parse_pdf(path: str) -> str:
|
||||
"""接收 PDF 文件路径,返回拼接后的纯文本。失败抛 ParseError。"""
|
||||
try:
|
||||
with open(path, "rb") as fh:
|
||||
file_bytes = fh.read()
|
||||
except OSError as exc:
|
||||
raise ParseError(f"读取 PDF 失败:{exc}") from exc
|
||||
return await parse_pdf_bytes(file_bytes, filename=str(path).rsplit("/", 1)[-1] or "paper.pdf")
|
||||
|
||||
|
||||
async def parse_pdf_bytes(file_bytes: bytes, filename: str = "paper.pdf") -> str:
|
||||
"""接收 PDF 字节流,返回拼接后的纯文本。失败抛 ParseError。"""
|
||||
if not file_bytes:
|
||||
raise ParseError("PDF 内容为空")
|
||||
|
||||
settings = get_settings()
|
||||
base = settings.vstore_base
|
||||
timeout = httpx.Timeout(
|
||||
connect=5.0, read=settings.parse_timeout, write=settings.parse_timeout, pool=5.0
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
# ---- 1) ingest(vstore 内部 pymupdf 解析 + 嵌入 + 加密落盘)----
|
||||
try:
|
||||
resp = await client.post(
|
||||
f"{base}/api/vstore/ingest",
|
||||
files={"file": (filename, file_bytes, "application/pdf")},
|
||||
data={
|
||||
"name": filename,
|
||||
"category": "review_presubmit",
|
||||
"description": "review_service 临时解析件",
|
||||
},
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
raise ParseError(f"vstore 不可达({base}):{exc}") from exc
|
||||
|
||||
if resp.status_code >= 400:
|
||||
# 读上游错误体(信封 JSON 或裸文本),区分“不可达”与“上游报错”
|
||||
raise ParseError(f"vstore ingest 失败(HTTP {resp.status_code}):{_err_body(resp)}")
|
||||
|
||||
try:
|
||||
payload = resp.json()
|
||||
except ValueError as exc:
|
||||
raise ParseError(f"vstore ingest 响应非 JSON:{exc}") from exc
|
||||
|
||||
if not isinstance(payload, dict) or not payload.get("ok"):
|
||||
message = _err_message(payload) or "vstore ingest 失败"
|
||||
raise ParseError(message)
|
||||
doc_id = str((payload.get("item") or {}).get("document_id") or "")
|
||||
if not doc_id:
|
||||
raise ParseError("vstore ingest 响应缺少 document_id")
|
||||
|
||||
# ---- 2) 取明文全文(paragraphs 拼接)+ 3) 清理临时件 ----
|
||||
try:
|
||||
text = await _fetch_text(client, base, doc_id)
|
||||
finally:
|
||||
if settings.parse_delete_after:
|
||||
await _best_effort_delete(client, base, doc_id)
|
||||
|
||||
text = text.strip()
|
||||
if not text:
|
||||
raise ParseError("PDF 解析结果为空(可能是扫描件/图片型 PDF,vstore 无 OCR)")
|
||||
return text
|
||||
|
||||
|
||||
async def _fetch_text(client: httpx.AsyncClient, base: str, doc_id: str) -> str:
|
||||
"""取 vstore 文档明文并按段落拼接纯文本。"""
|
||||
try:
|
||||
resp = await client.get(f"{base}/api/vstore/documents/{doc_id}")
|
||||
except httpx.HTTPError as exc:
|
||||
raise ParseError(f"vstore 取文档失败({doc_id}):{exc}") from exc
|
||||
if resp.status_code >= 400:
|
||||
raise ParseError(
|
||||
f"vstore 取文档失败({doc_id},HTTP {resp.status_code}):{_err_body(resp)}"
|
||||
)
|
||||
try:
|
||||
payload = resp.json()
|
||||
except ValueError as exc:
|
||||
raise ParseError(f"vstore 取文档响应非 JSON({doc_id}):{exc}") from exc
|
||||
if not isinstance(payload, dict) or not payload.get("ok"):
|
||||
raise ParseError(_err_message(payload) or f"vstore 取文档失败({doc_id})")
|
||||
|
||||
document: dict[str, Any] = payload.get("document") or {}
|
||||
paragraphs = document.get("paragraphs") or []
|
||||
return "\n".join(str(p) for p in paragraphs if str(p).strip())
|
||||
|
||||
|
||||
async def _best_effort_delete(client: httpx.AsyncClient, base: str, doc_id: str) -> None:
|
||||
try:
|
||||
await client.delete(f"{base}/api/vstore/documents/{doc_id}")
|
||||
except Exception: # noqa: BLE001 - 清理失败不阻断(文档级 secure_delete 已尽力)
|
||||
logger.warning("paper_parse: vstore 临时文档清理失败(%s)", doc_id, exc_info=True)
|
||||
|
||||
|
||||
def _err_message(payload: Any) -> str:
|
||||
if isinstance(payload, dict):
|
||||
err = payload.get("error")
|
||||
if isinstance(err, dict):
|
||||
return str(err.get("message") or "")
|
||||
if isinstance(err, str):
|
||||
return err
|
||||
return ""
|
||||
|
||||
|
||||
def _err_body(resp: httpx.Response) -> str:
|
||||
"""上游错误体:优先 JSON 信封 message,回退裸文本(截断 500 字符)。"""
|
||||
try:
|
||||
payload = resp.json()
|
||||
except ValueError:
|
||||
return (resp.text or "").strip()[:500] or "(空响应体)"
|
||||
return _err_message(payload) or str(payload)[:500]
|
||||
@@ -0,0 +1,226 @@
|
||||
"""services/paper_scanner.py —— 关键词兜底扫描器(纯 stdlib,零 LLM)。
|
||||
|
||||
背景(2026-10-12):hard_rule 标签由 LLM 输出,存在"正文明明有 accession/BUSCO/挂载率
|
||||
却报缺失"的漏检风险(真实案例 s41588 Nature Genetics:正文有 PRJCA038097 等 +
|
||||
BUSCO 98.57% + 94.18% anchored,仍被判 busco/accession/hic_missing → 大修)。
|
||||
本模块做**程序层证伪**:LLM 说缺某项、扫描器在文中找到该项 → 剥掉 hard_rule
|
||||
降级为非硬性 major,并在 rationale 记录冲突(前端黄色提示,请人工核对)。
|
||||
|
||||
边界(刻意保守,不引入新误判):
|
||||
- 只做"证伪硬性缺项",不做"证实"——扫不到不等于真缺(可能同义表述),不升级任何条目
|
||||
- accession 只在 Data Availability 段内扫(用户指令):正文引用他人数据的
|
||||
accession 不得误认为"自己有"
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
# ---- accession(只在 DA 段扫)----
|
||||
ACCESSION_RE = re.compile(
|
||||
r"\b(PRJNA|PRJEB|PRJCA|SRP|ERP|DRP|CRA|SRR|ERR|DRR|GCA_|GCF_)\s?(\d[\d._]*)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# ---- DA 段定位:标题多变体(行首允许编号前缀)----
|
||||
_DA_START_RE = re.compile(
|
||||
r"^[ \t]*(?:\d+(?:\.\d+)*\.?\s+)?"
|
||||
r"(?:Data\s+(?:and\s+(?:code\s+)?)?[Aa]vailability"
|
||||
r"|Availability\s+of\s+(?:data|Data)"
|
||||
r"|Data\s+and\s+[Mm]aterials\s+[Aa]vailability"
|
||||
r"|Data\s+availability\s+statement)",
|
||||
re.MULTILINE,
|
||||
)
|
||||
# DA 段结束 = 下一个一级节标题
|
||||
_NEXT_HEADER_RE = re.compile(
|
||||
r"^[ \t]*(?:\d+(?:\.\d+)*\.?\s+)?"
|
||||
r"(?:Abstract|Summary|Introduction|Results|Discussion|Methods"
|
||||
r"|Materials\s+and\s+Methods|References|Acknowledg(?:e)?ments?"
|
||||
r"|Author\s+[Ii]nformation|Rights\s+and\s+permissions|Change\s+history"
|
||||
r"|Data\s+(?:and\s+(?:code\s+)?)?[Aa]vailability"
|
||||
r"|Availability\s+of\s+[Dd]ata|Data\s+and\s+[Mm]aterials\s+[Aa]vailability)"
|
||||
r"\s*$",
|
||||
re.MULTILINE,
|
||||
)
|
||||
# 回退窗:DA 无行首标题时,短语出现处 + 上下文
|
||||
_DA_PHRASE_RE = re.compile(r"data\s+availability", re.IGNORECASE)
|
||||
_DA_FALLBACK_WINDOW = 3000
|
||||
|
||||
# ---- BUSCO(全文)----
|
||||
_BUSCO_MENTION_RE = re.compile(r"\bBUSCO\b", re.IGNORECASE)
|
||||
_BUSCO_FULL_RE = re.compile(
|
||||
r"(?:\bS\s*:\s*\d|\bSingle[- ]copy)"
|
||||
r".{0,200}?"
|
||||
r"(?:\bD\s*:\s*\d|\bDuplicat)",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
|
||||
# ---- Hi-C 挂载率(全文)----
|
||||
HIC_RE = re.compile(
|
||||
r"(anchored\s+to\s+chromosomes?"
|
||||
r"|anchoring\s+rate"
|
||||
r"|pseudo[- ]?chromosomes?"
|
||||
r"|chromosome[- ]level"
|
||||
r"|%[^.\n]{0,40}anchored)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
_MAX_HITS = 20
|
||||
|
||||
|
||||
def _extract_da_section(text: str) -> str:
|
||||
"""定位 Data Availability 段正文(标题后 → 下一个一级节标题)。
|
||||
|
||||
无行首标题时回退:短语出现处向后 3000 字符窗。均未命中 → ""。
|
||||
"""
|
||||
m = _DA_START_RE.search(text)
|
||||
if m:
|
||||
nxt = _NEXT_HEADER_RE.search(text, m.end())
|
||||
end = nxt.start() if nxt else min(len(text), m.end() + _DA_FALLBACK_WINDOW)
|
||||
return text[m.start():end]
|
||||
m2 = _DA_PHRASE_RE.search(text)
|
||||
if m2:
|
||||
return text[m2.start():min(len(text), m2.start() + _DA_FALLBACK_WINDOW)]
|
||||
return ""
|
||||
|
||||
|
||||
def _scan_accession(da_text: str) -> dict[str, Any]:
|
||||
if not da_text:
|
||||
return {"found": False, "hits": [], "in_da_section": False}
|
||||
hits: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for m in ACCESSION_RE.finditer(da_text):
|
||||
token = (m.group(1) + m.group(2)).upper()
|
||||
if token not in seen:
|
||||
seen.add(token)
|
||||
hits.append(token)
|
||||
if len(hits) >= _MAX_HITS:
|
||||
break
|
||||
return {"found": bool(hits), "hits": hits, "in_da_section": True}
|
||||
|
||||
|
||||
def _scan_busco(text: str) -> dict[str, Any]:
|
||||
m = _BUSCO_MENTION_RE.search(text)
|
||||
if not m:
|
||||
return {"level": "none", "excerpt": ""}
|
||||
excerpt = text[m.start():m.start() + 200]
|
||||
if _BUSCO_FULL_RE.search(text):
|
||||
level = "full"
|
||||
else:
|
||||
level = "partial" # 只有百分比、无 S/D 分项 → prompt 已定为 minor
|
||||
return {"level": level, "excerpt": excerpt}
|
||||
|
||||
|
||||
def _scan_hic(text: str) -> dict[str, Any]:
|
||||
m = HIC_RE.search(text)
|
||||
if not m:
|
||||
return {"found": False, "excerpt": ""}
|
||||
start = max(0, m.start() - 40)
|
||||
return {"found": True, "excerpt": text[start:m.end() + 80]}
|
||||
|
||||
|
||||
def scan_paper_signals(text: str) -> dict[str, Any]:
|
||||
"""全文 → 结构化信号。accession 只在 DA 段;busco/hic 全文。"""
|
||||
if not text:
|
||||
return {
|
||||
"accession": {"found": False, "hits": [], "in_da_section": False},
|
||||
"busco": {"level": "none", "excerpt": ""},
|
||||
"hic": {"found": False, "excerpt": ""},
|
||||
}
|
||||
return {
|
||||
"accession": _scan_accession(_extract_da_section(text)),
|
||||
"busco": _scan_busco(text),
|
||||
"hic": _scan_hic(text),
|
||||
}
|
||||
|
||||
|
||||
def _get_hr(item: Any) -> Any:
|
||||
if isinstance(item, dict):
|
||||
return item.get("hard_rule")
|
||||
return getattr(item, "hard_rule", None)
|
||||
|
||||
|
||||
def _item_fatal(item: Any) -> bool:
|
||||
if isinstance(item, dict):
|
||||
return bool(item.get("fatal"))
|
||||
return bool(getattr(item, "fatal", False))
|
||||
|
||||
|
||||
def _set_downgraded(item: Any) -> None:
|
||||
if isinstance(item, dict):
|
||||
item["scanner_downgraded"] = True
|
||||
else:
|
||||
try:
|
||||
item.scanner_downgraded = True
|
||||
except Exception: # pydantic 严格模式等极端场景:吞掉,冲突记录仍保留
|
||||
pass
|
||||
|
||||
|
||||
def _set_hr_none(item: Any) -> None:
|
||||
if isinstance(item, dict):
|
||||
item["hard_rule"] = None
|
||||
else:
|
||||
try:
|
||||
item.hard_rule = None
|
||||
except Exception: # pydantic 严格模式等极端场景:吞掉,冲突记录仍保留
|
||||
pass
|
||||
|
||||
|
||||
def _item_title(item: Any) -> str:
|
||||
if isinstance(item, dict):
|
||||
return str(item.get("title") or "")
|
||||
return str(getattr(item, "title", "") or "")
|
||||
|
||||
|
||||
# 冲突映射:hard_rule 标签 → (信号检查, 冲突描述)
|
||||
def _conflict_for(tag: str, signals: dict[str, Any]) -> str | None:
|
||||
acc = signals.get("accession") or {}
|
||||
busco = signals.get("busco") or {}
|
||||
hic = signals.get("hic") or {}
|
||||
if tag == "accession_missing" and acc.get("found"):
|
||||
hits = ", ".join(acc.get("hits") or [])[:120]
|
||||
return f"Data Availability 段已检出 accession({hits})"
|
||||
if tag == "busco_missing" and busco.get("level") in ("partial", "full"):
|
||||
return f"正文已提及 BUSCO({busco.get('level')} 格式)"
|
||||
if tag == "hic_missing" and hic.get("found"):
|
||||
excerpt = (hic.get("excerpt") or "").strip()[:80]
|
||||
return f"正文已检出挂载率描述({excerpt})"
|
||||
return None
|
||||
|
||||
|
||||
def reconcile_hard_rules(
|
||||
majors: list[Any], signals: dict[str, Any]
|
||||
) -> tuple[list[Any], list[Any], list[dict[str, Any]]]:
|
||||
"""对带 hard_rule 的 major 做扫描器证伪:命中 → 剥标签 + **移出 major 降级至
|
||||
minor**(A2 语义,2026-10-12)→ 记冲突。
|
||||
|
||||
返回 (kept_majors, demoted_minors, conflicts):
|
||||
- kept_majors:未冲突/未处理的 major(原序)
|
||||
- demoted_minors:被冲突降级的条目(hard_rule 置 None、scanner_downgraded=True)
|
||||
- conflicts:{hard_rule, title, reason}——fatal 条目只记冲突不降级
|
||||
(拒稿级问题须人工过目,扫描器证伪不了伦理判断)。
|
||||
"""
|
||||
kept: list[Any] = []
|
||||
demoted: list[Any] = []
|
||||
conflicts: list[dict[str, Any]] = []
|
||||
for m in majors:
|
||||
tag = _get_hr(m)
|
||||
if not isinstance(tag, str):
|
||||
kept.append(m)
|
||||
continue
|
||||
reason = _conflict_for(tag, signals)
|
||||
if reason is None:
|
||||
kept.append(m)
|
||||
continue
|
||||
conflicts.append({
|
||||
"hard_rule": tag,
|
||||
"title": _item_title(m),
|
||||
"reason": reason,
|
||||
})
|
||||
if _item_fatal(m):
|
||||
kept.append(m) # fatal 保留原位(含其 hard_rule 供引擎 R1/R0 使用)
|
||||
continue
|
||||
_set_hr_none(m)
|
||||
_set_downgraded(m)
|
||||
demoted.append(m)
|
||||
return kept, demoted, conflicts
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,264 @@
|
||||
"""services/ppt_figure_index.py —— 论文真实图表清单扫描(P5 增强 1)。
|
||||
|
||||
问题根因:LLM 不知道论文里真实图编号与位置,suggested_figures 全靠编。
|
||||
修复:用正则从论文全文扫描 Figure/Fig./图 + Table/表 编号 → figure_index
|
||||
([{label, para_id, caption, kind}]),注入 reduce prompt 供 LLM「只选不编」,
|
||||
代码侧再按清单校验:不在清单里的图引用直接丢弃(而非标 unverified)。
|
||||
|
||||
para_id 与 ppt_outline.build_index 的分段完全一致(复用同一套分段逻辑),
|
||||
因此 figure_index 的 para_id 可直接用于证据链与前端展示「Fig 1(论文 S1-P12)」。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
FIG_RE = re.compile(r"(?:Figure|Fig\.?|图)\s*(\d+)")
|
||||
TABLE_RE = re.compile(r"(?:Table|表)\s*(\d+)")
|
||||
_CAPTION_MARK = re.compile(r"\s*[::.]") # 紧跟编号的冒号/句点 → caption 形态
|
||||
_SENT_SPLIT = re.compile(r"(?<=[.!?。!?;;])\s*")
|
||||
_MAX_CAPTION = 120
|
||||
|
||||
# label → 规约键:("fig", n) / ("table", n)("Figure 2"/"Fig. 2"/"图 2"/"Fig 2" 同键)
|
||||
_LABEL_KIND = re.compile(r"^\s*(figure|fig|图|table|表)", re.IGNORECASE)
|
||||
|
||||
# 行首 caption(借 paper-skills pdf_snap 的分隔符全集:::. 与 |;pdf_snap MIT)
|
||||
_CAPTION_LINE = re.compile(
|
||||
r"^\s*(Figure|Fig\.?|Table|Tab\.?|图|表|Algorithm|算法)\s*"
|
||||
r"([0-9]+[a-z]?|[A-Z]\.?[0-9]+)\s*[::.|]\s*(\S.*)$", re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def canon_label(label: Any) -> tuple[str, int] | None:
|
||||
"""图/表 label 归一化 → (kind, number);无法解析返回 None。"""
|
||||
s = str(label or "").strip()
|
||||
m = _LABEL_KIND.match(s)
|
||||
digits = re.search(r"(\d+)", s)
|
||||
if not m or not digits:
|
||||
return None
|
||||
kind = "table" if m.group(1).lower() in ("table", "表") else "fig"
|
||||
return (kind, int(digits.group(1)))
|
||||
|
||||
|
||||
def figure_map(figure_index: list[dict[str, Any]] | None) -> dict[tuple[str, int], dict[str, Any]]:
|
||||
"""figure_index → {("fig",2): entry} 查找表(校验用)。"""
|
||||
out: dict[tuple[str, int], dict[str, Any]] = {}
|
||||
for entry in figure_index or []:
|
||||
key = canon_label(entry.get("label"))
|
||||
if key and key not in out:
|
||||
out[key] = entry
|
||||
return out
|
||||
|
||||
|
||||
def _sentence(paragraph: str, span: tuple[int, int]) -> str:
|
||||
"""取包含命中的句子(截 120 字符)作为 caption。"""
|
||||
start = 0
|
||||
best = paragraph
|
||||
for part in _SENT_SPLIT.split(paragraph):
|
||||
end = start + len(part)
|
||||
if start <= span[0] < end:
|
||||
best = part
|
||||
break
|
||||
start = end
|
||||
return best.strip()[:_MAX_CAPTION]
|
||||
|
||||
|
||||
def _scan(index: dict[str, str]) -> list[dict[str, Any]]:
|
||||
"""para_id→原文 索引 → 真实图表清单(按 (kind,编号) 去重)。"""
|
||||
found: dict[tuple[str, int], tuple[int, dict[str, Any]]] = {}
|
||||
for para_id, para_text in index.items():
|
||||
for regex, kind in ((FIG_RE, "figure"), (TABLE_RE, "table")):
|
||||
for m in regex.finditer(para_text):
|
||||
key = (kind, int(m.group(1)))
|
||||
# caption 形态("Figure 2: ...")优先于顺带提及;同分先到先得
|
||||
score = 2 if _CAPTION_MARK.match(para_text[m.end(): m.end() + 4]) else 1
|
||||
hit = {
|
||||
"label": f"Fig {m.group(1)}" if kind == "figure" else f"Table {m.group(1)}",
|
||||
"para_id": para_id,
|
||||
"caption": _sentence(para_text, m.span()),
|
||||
"kind": kind,
|
||||
}
|
||||
if key not in found or score > found[key][0]:
|
||||
found[key] = (score, hit)
|
||||
return [entry for _, entry in found.values()]
|
||||
|
||||
|
||||
def scan_figure_index(index: dict[str, str]) -> list[dict[str, Any]]:
|
||||
"""para_id→原文 索引 → 图表清单(纯函数,可单测)。"""
|
||||
return _scan(index)
|
||||
|
||||
|
||||
def build_figure_index(text: str) -> list[dict[str, Any]]:
|
||||
"""论文全文 → 图表清单(与证据链共用同一套段落分段,para_id 一致)。"""
|
||||
from .ppt_outline import build_index # 局部导入避免与 ppt_outline 循环依赖
|
||||
_, index = build_index(text)
|
||||
return _scan(index)
|
||||
|
||||
|
||||
# ---------- PDF 原图提取(P5 增强 2.3;AGENTS 例外:vstore 只回文本,图片须本地解档) ----------
|
||||
|
||||
_BAND_ABOVE_PT = 300.0 # caption 上方搜索带(图底边距 caption 的最大距离,pt)
|
||||
_BAND_PAD_X_PT = 40.0 # caption 左右扩展(横向重叠判定)
|
||||
_MIN_PX = (60, 40) # 裁切结果最小像素(过滤图标/装饰线)
|
||||
_DPI = 300
|
||||
|
||||
|
||||
def extract_figures(pdf_bytes: bytes, out_dir: Path) -> list[dict[str, Any]]:
|
||||
"""PDF → 与 caption 配对的原图 PNG(out_dir/Fig_1.png…)+ 清单。
|
||||
|
||||
策略(自 paper-skills pdf_snap「页渲染+裁图」与 paper-ppt-skill Step5 裁剪规则
|
||||
自动化而来):同页 caption 行上方 band 内「嵌入位图 ∪ 矢量绘图」联合包围盒
|
||||
→ clip 渲染 300dpi。Table 跳过(表格不出栅格图,由占位/表格版式承接)。
|
||||
配对失败/过小 → 跳过(build 侧回落文字占位),绝不抛错阻塞建 job。
|
||||
"""
|
||||
out = Path(out_dir)
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
results: dict[str, dict[str, Any]] = {}
|
||||
try:
|
||||
import pymupdf # lock 中已有(format_check 同款)
|
||||
except ImportError: # pragma: no cover
|
||||
logger.warning("pymupdf 不可用,跳过图表原图提取")
|
||||
return []
|
||||
try:
|
||||
doc = pymupdf.open(stream=pdf_bytes, filetype="pdf")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("PDF 打开失败,跳过图表原图提取:%s", exc)
|
||||
return []
|
||||
try:
|
||||
for pno in range(doc.page_count):
|
||||
page = doc[pno]
|
||||
caps = _caption_lines(page)
|
||||
if not caps:
|
||||
continue
|
||||
img_rects = _image_rects(page)
|
||||
for cap in caps:
|
||||
if cap["kind"] != "fig":
|
||||
continue
|
||||
key = f"Fig {cap['num']}"
|
||||
if key in results:
|
||||
continue
|
||||
box = _figure_box(page, cap, img_rects)
|
||||
if box is None:
|
||||
continue
|
||||
try:
|
||||
pix = page.get_pixmap(clip=box, dpi=_DPI)
|
||||
except Exception: # noqa: BLE001 - clip 越界等
|
||||
continue
|
||||
if pix.width < _MIN_PX[0] or pix.height < _MIN_PX[1]:
|
||||
continue
|
||||
fname = f"Fig_{cap['num']}.png"
|
||||
try:
|
||||
pix.save(str(out / fname))
|
||||
except OSError:
|
||||
continue
|
||||
results[key] = {
|
||||
"label": key, "file": fname, "page": pno + 1,
|
||||
"caption": cap["text"][:_MAX_CAPTION],
|
||||
"width": pix.width, "height": pix.height,
|
||||
}
|
||||
finally:
|
||||
doc.close()
|
||||
ordered = sorted(results.values(), key=lambda r: int(r["label"].split()[1]))
|
||||
logger.info("图表原图提取:%d 张 → %s", len(ordered), out)
|
||||
return ordered
|
||||
|
||||
|
||||
def _caption_lines(page: Any) -> list[dict[str, Any]]:
|
||||
"""页内行首 caption → [{kind, num, text, x0, x1, y0}](每编号取最先一行)。"""
|
||||
caps: dict[str, dict[str, Any]] = {}
|
||||
try:
|
||||
data = page.get_text("dict")
|
||||
except Exception: # noqa: BLE001
|
||||
return []
|
||||
for block in data.get("blocks", []):
|
||||
if block.get("type") != 0:
|
||||
continue
|
||||
for line in block.get("lines", []):
|
||||
text = "".join(sp.get("text", "") for sp in line.get("spans", []))
|
||||
m = _CAPTION_LINE.match(text)
|
||||
if not m:
|
||||
continue
|
||||
raw_kind = m.group(1).rstrip(".").lower() # "Fig." → "fig"(v3:剥尾点)
|
||||
if raw_kind in ("figure", "fig", "图"):
|
||||
kind = "fig"
|
||||
elif raw_kind in ("table", "tab", "表"):
|
||||
kind = "table"
|
||||
else:
|
||||
continue # Algorithm 等:清单不含,原图也跳过
|
||||
num = m.group(2)
|
||||
digits = re.match(r"(\d+)", num)
|
||||
if not digits:
|
||||
continue
|
||||
key = f"{kind}{digits.group(1)}"
|
||||
if key in caps:
|
||||
continue
|
||||
x0, y0, x1, y1 = line["bbox"]
|
||||
caps[key] = {
|
||||
"kind": kind, "num": int(digits.group(1)),
|
||||
"text": m.group(3).strip(),
|
||||
"x0": float(x0), "x1": float(x1), "y0": float(y0),
|
||||
}
|
||||
return list(caps.values())
|
||||
|
||||
|
||||
def _image_rects(page: Any) -> list[Any]:
|
||||
"""页内嵌入位图的 rect 列表(失败返回空)。"""
|
||||
rects = []
|
||||
try:
|
||||
for info in page.get_image_info(xrefs=True):
|
||||
xref = info.get("xref")
|
||||
if not xref:
|
||||
continue
|
||||
for r in page.get_image_rects(xref):
|
||||
rects.append(r)
|
||||
except Exception: # noqa: BLE001
|
||||
return []
|
||||
return rects
|
||||
|
||||
|
||||
def _figure_box(page: Any, cap: dict[str, Any], img_rects: list[Any]) -> Any | None:
|
||||
"""caption 上方 band 内「位图 ∪ 矢量绘图」联合包围盒(含 6pt 内边距)。"""
|
||||
import pymupdf # 局部导入(extract_figures 已保证可用)
|
||||
|
||||
page_rect = page.rect
|
||||
band_y0 = cap["y0"] - _BAND_ABOVE_PT
|
||||
band_y1 = cap["y0"] - 1.0
|
||||
bx0 = cap["x0"] - _BAND_PAD_X_PT
|
||||
bx1 = cap["x1"] + _BAND_PAD_X_PT
|
||||
|
||||
def _in_band(r: Any) -> bool:
|
||||
return band_y0 <= float(r.y1) <= band_y1 and not (float(r.x1) < bx0 or float(r.x0) > bx1)
|
||||
|
||||
union = None
|
||||
|
||||
def _merge(r: Any) -> None:
|
||||
nonlocal union
|
||||
r = r & page_rect
|
||||
if r.is_empty:
|
||||
return
|
||||
union = r if union is None else (union | r)
|
||||
|
||||
for r in img_rects:
|
||||
if _in_band(r):
|
||||
_merge(r)
|
||||
try:
|
||||
drawings = page.get_drawings()
|
||||
except Exception: # noqa: BLE001
|
||||
drawings = []
|
||||
for d in drawings:
|
||||
r = d.get("rect")
|
||||
if r is None or r.is_empty:
|
||||
continue
|
||||
# 细长规则线(页眉横线/表格线)不进联合,避免整页宽条
|
||||
if float(r.height) < 1.5 or (float(r.width) < 1.5):
|
||||
continue
|
||||
if _in_band(r):
|
||||
_merge(r)
|
||||
if union is None:
|
||||
return None
|
||||
box = pymupdf.Rect(union.x0 - 6, union.y0 - 6, union.x1 + 6, union.y1 + 6) & page_rect
|
||||
return None if box.is_empty else box
|
||||
@@ -0,0 +1,917 @@
|
||||
"""services/ppt_outline.py —— P5 科研汇报大纲生成(map-reduce + 证据程序化校验)。
|
||||
|
||||
管线(全 LLM 只负责引用与措辞,结构/页数/证据状态由代码裁决):
|
||||
纯文本 → 段落索引(para_id 形如 S4-P1,figure 引用 S4-P1-Fig2)
|
||||
→ 图表清单扫描(ppt_figure_index 正则扫 Figure/图/Table/表,非 LLM)
|
||||
→ map:分块(≤5 块)抽 claims/figures(quote 逐字 + para_id 标注,LLM;
|
||||
figures 先过真实清单,不在清单的丢弃)
|
||||
→ reduce:按 page_plan 配额生成页大纲(1 或 2 次调用;>14 页分两片,
|
||||
第二片带上一片页标题维持连贯;deepseek-chat 输出上限 8K,必须分片;
|
||||
真实图表清单注入 prompt,suggested_figures 只选不编)
|
||||
→ 后校验(纯函数、可单测):
|
||||
① 页数:PAGE_TARGETS 决策 2(10→8-10/15→11-14/20→15-18/30→22-26),
|
||||
超上限从尾部可删页裁掉(section/reference 先删);低于下限不凑页 + warning
|
||||
② bullets(补充 3):内容页 <2 条 → 一次按页扩充重试;>8 条 → 拆成两页
|
||||
③ evidence:quote 归一化后必须是某段落的子串 → 反查真实 para_id
|
||||
(repair_evidence_para_ids,修复 LLM 编造段落ID)→ verified;
|
||||
找不到段落 → unverified;result/conclusion 页未核实占比 >50% → warning
|
||||
④ suggested_figures:label 必须在真实图表清单内(否则丢弃而非标
|
||||
unverified);para_id 以清单扫描出的真实位置为准 → verified
|
||||
|
||||
错误:OutlineError(code=VALIDATION|LLM_ERROR|LLM_INVALID) → 路由映射信封。
|
||||
LLM 调用次数:map ≤5 + reduce 1~2 + expand ≤1 ≈ 4~8 次/篇。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import unicodedata
|
||||
from typing import Any
|
||||
|
||||
from ..config import get_settings
|
||||
from . import llm
|
||||
from .llm import LLMError
|
||||
from .ppt_figure_index import canon_label, figure_map, scan_figure_index
|
||||
|
||||
# ---------- 枚举与决策常量 ----------
|
||||
|
||||
SCENARIOS = (
|
||||
"group_meeting",
|
||||
"proposal",
|
||||
"conclusion_report",
|
||||
"defense",
|
||||
"conference_talk",
|
||||
)
|
||||
SCENARIO_LABELS = {
|
||||
"group_meeting": "组会",
|
||||
"proposal": "开题",
|
||||
"conclusion_report": "结题",
|
||||
"defense": "答辩",
|
||||
"conference_talk": "学术会议",
|
||||
}
|
||||
# P5 增强 2:风格 3 → 8 → 14(与 data/ppt_styles/*.json 一一对应)。
|
||||
# 其中 domestic/international/systems-talk/theory-beamer/jp-gothic/editorial
|
||||
# 移植自开源 paper-skills styles/tokens.json(MIT),色/字号映射进自有 schema。
|
||||
STYLE_IDS = (
|
||||
"academic-minimal", "botanic-forest", "classic-defense",
|
||||
"academic-blue", "nature-green", "tech-dark", "monochrome", "warm-earth",
|
||||
"domestic", "international", "systems-talk", "theory-beamer",
|
||||
"jp-gothic", "editorial",
|
||||
)
|
||||
|
||||
# section = 章节分隔页(P5 增强 3:大标题居中 + 色块背景,由代码定骨架、LLM 只起标题)
|
||||
PAGE_TYPES = (
|
||||
"title", "outline", "section", "background", "method", "result", "conclusion", "reference",
|
||||
)
|
||||
CONTENT_TYPES = ("background", "method", "result", "conclusion")
|
||||
|
||||
# 决策 2:时长 → [下限, 上限](含封面/目录,defense 含参考文献页)
|
||||
PAGE_TARGETS: dict[int, tuple[int, int]] = {
|
||||
10: (8, 10),
|
||||
15: (11, 14),
|
||||
20: (15, 18),
|
||||
30: (22, 26),
|
||||
}
|
||||
MINUTES_CHOICES = tuple(sorted(PAGE_TARGETS))
|
||||
|
||||
# 场景骨架:内容页权重(reference 只有答辩要);代码算配额,LLM 只填内容
|
||||
_SKELETONS: dict[str, dict[str, Any]] = {
|
||||
"group_meeting": {"weights": {"background": 0.25, "method": 0.40, "result": 0.35}, "reference": False},
|
||||
"proposal": {"weights": {"background": 0.40, "method": 0.45, "result": 0.15}, "reference": False},
|
||||
"conclusion_report": {"weights": {"background": 0.20, "method": 0.30, "result": 0.50}, "reference": False},
|
||||
"defense": {"weights": {"background": 0.25, "method": 0.35, "result": 0.40}, "reference": True},
|
||||
"conference_talk": {"weights": {"background": 0.25, "method": 0.35, "result": 0.40}, "reference": False},
|
||||
}
|
||||
|
||||
# 索引切块(正文 ≤150K 字符 → 块数 ≤5,控制 map 调用次数)
|
||||
_MAX_SECTIONS = 5
|
||||
_PARAS_PER_SECTION = 40
|
||||
_PARA_CHAR_CAP = 1000 # 单段送 map 的截断(pymupdf 块通常 <1000 字符)
|
||||
_SECTION_CHAR_CAP = 30000 # 单块送 map 的总预算
|
||||
_MAX_CLAIMS_TOTAL = 50
|
||||
_MAX_FIGURES_TOTAL = 15
|
||||
_REDUCE_PAGE_SLICE = 14 # 单次 reduce 页数上限(>14 页分两片,守住 8K 输出)
|
||||
|
||||
_MAP_TIMEOUT = 75.0
|
||||
_REDUCE_TIMEOUT = 200.0
|
||||
_EXPAND_TIMEOUT = 75.0
|
||||
_MAP_CONCURRENCY = 2 # BFF 读超时 360s 硬顶:map 并发压总时长
|
||||
|
||||
|
||||
class OutlineError(Exception):
|
||||
"""大纲生成失败(VALIDATION | LLM_ERROR | LLM_INVALID)。"""
|
||||
|
||||
def __init__(self, code: str, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.message = message
|
||||
|
||||
|
||||
# ---------- 决策 2:页数配额(纯函数) ----------
|
||||
|
||||
def page_plan(scenario: str, minutes: int) -> dict[str, tuple[int, int]]:
|
||||
"""时长目标页数 + 场景骨架 → 每 type 配额 (min, max)。
|
||||
|
||||
固定页:title=1、outline=1、conclusion=1(defense +reference=1);
|
||||
其余按场景权重最大余数法分配(每内容页保底 1)。LLM 只能在配额内填,
|
||||
总页数另外夹在 PAGE_TARGETS[minutes] 区间内。
|
||||
"""
|
||||
if scenario not in _SKELETONS:
|
||||
raise ValueError(f"未知场景: {scenario}")
|
||||
if minutes not in PAGE_TARGETS:
|
||||
raise ValueError(f"不支持的时长: {minutes}(可选 {list(MINUTES_CHOICES)})")
|
||||
lo, hi = PAGE_TARGETS[minutes]
|
||||
target = (lo + hi) // 2
|
||||
weights = _SKELETONS[scenario]["weights"]
|
||||
n_ref = 1 if _SKELETONS[scenario]["reference"] else 0
|
||||
# 章节分隔页:≥15 分钟(target≥11)每个内容组一张;10 分钟小 deck 不放分隔页
|
||||
n_sec = len(weights) if target >= 11 else 0
|
||||
rest = max(target - (3 + n_ref + n_sec), len(weights)) # 3 = title+outline+conclusion
|
||||
|
||||
raw = {t: w * rest for t, w in weights.items()}
|
||||
alloc = {t: int(v) for t, v in raw.items()}
|
||||
for t in sorted(raw, key=lambda k: raw[k] - alloc[k], reverse=True)[: rest - sum(alloc.values())]:
|
||||
alloc[t] += 1
|
||||
for t in alloc:
|
||||
alloc[t] = max(1, alloc[t]) # 内容页保底
|
||||
|
||||
plan: dict[str, tuple[int, int]] = {
|
||||
"title": (1, 1), "outline": (1, 1), "conclusion": (1, 1),
|
||||
}
|
||||
if n_sec:
|
||||
plan["section"] = (n_sec, n_sec)
|
||||
if n_ref:
|
||||
plan["reference"] = (1, 1)
|
||||
for t, a in alloc.items():
|
||||
plan[t] = (a, a + 1)
|
||||
return plan
|
||||
|
||||
|
||||
# ---------- 段落索引(para_id:S{块}-P{段}) ----------
|
||||
|
||||
def build_index(text: str) -> tuple[list[dict[str, Any]], dict[str, str]]:
|
||||
"""纯文本 → (blocks, index)。blocks 含段落ID标注文本(map 输入),index 为
|
||||
para_id → 原文(evidence 校验用)。块按固定段数均分(不做标题猜测,
|
||||
确定性可测;S 编号 = 块序号,P 编号 = 块内段序)。"""
|
||||
paras = [ln.strip() for ln in text.splitlines() if ln.strip()]
|
||||
paras = [p[:_PARA_CHAR_CAP] for p in paras]
|
||||
if not paras:
|
||||
return [], {}
|
||||
n_sections = max(1, min(_MAX_SECTIONS, math.ceil(len(paras) / _PARAS_PER_SECTION)))
|
||||
size = math.ceil(len(paras) / n_sections)
|
||||
blocks: list[dict[str, Any]] = []
|
||||
index: dict[str, str] = {}
|
||||
for si in range(0, len(paras), size):
|
||||
sec_no = si // size + 1
|
||||
lines = paras[si : si + size]
|
||||
paras_out = []
|
||||
for pi, p in enumerate(lines, 1):
|
||||
pid = f"S{sec_no}-P{pi}"
|
||||
paras_out.append({"para_id": pid, "text": p})
|
||||
index[pid] = p
|
||||
blocks.append({
|
||||
"section_id": f"S{sec_no}",
|
||||
"paras": paras_out,
|
||||
"text": "\n".join(f"[{v['para_id']}] {v['text']}" for v in paras_out),
|
||||
})
|
||||
return blocks, index
|
||||
|
||||
|
||||
# ---------- map:分块抽 claims / figures ----------
|
||||
|
||||
_MAP_SYSTEM = """你是植物学论文精读助手。输入是一个论文文本块,每段以 [段落ID] 标注。
|
||||
只输出单个 JSON 对象:
|
||||
{"claims": [{"claim": "一句话事实陈述", "para_id": "S1-P2", "quote": "支撑该事实的原文逐字引用"}],
|
||||
"figures": [{"label": "Fig 2", "para_id": "S1-P5-Fig2"}]}
|
||||
硬性要求:
|
||||
- claim 必须能被同条 quote 直接支撑;quote 必须逐字来自对应段落(≤60字、单句、不得跨段拼接、不得改写);
|
||||
- para_id 必须照抄输入标注;figure 的 para_id = 其 caption 所在段落ID + "-FigN"(N 为图序号);
|
||||
- 每块最多 8 条 claims、4 个 figures;没有实质内容就返回空数组;严禁编造引用、数据、图号;
|
||||
- 不要输出 JSON 以外的任何文字。"""
|
||||
|
||||
|
||||
async def _map_block(block: dict[str, Any]) -> tuple[list[dict], list[dict]]:
|
||||
payload = await llm.complete_json(
|
||||
_MAP_SYSTEM, _map_block_user(block), temperature=0.2,
|
||||
max_tokens=4000, timeout=_MAP_TIMEOUT,
|
||||
)
|
||||
claims = [c for c in (payload.get("claims") or []) if isinstance(c, dict)][:8]
|
||||
figures = [f for f in (payload.get("figures") or []) if isinstance(f, dict)][:4]
|
||||
return claims, figures
|
||||
|
||||
|
||||
def _map_block_user(block: dict[str, Any]) -> str:
|
||||
body = block["text"]
|
||||
if len(body) > _SECTION_CHAR_CAP:
|
||||
body = body[:_SECTION_CHAR_CAP]
|
||||
return f"论文文本块 {block['section_id']}:\n{body}"
|
||||
|
||||
|
||||
# ---------- reduce:页大纲(配额内填内容) ----------
|
||||
|
||||
_REDUCE_SYSTEM = """你是学术汇报策划。根据给出的论文摘要卡片(claims/figures 已带逐字引用)、
|
||||
论文真实图表清单与页数配额,生成 PPT 大纲。只输出单个 JSON 对象:
|
||||
{"pages": [{"type": "...", "title": "...", "subtitle": "...", "bullets": ["..."],
|
||||
"evidence": [{"quote": "..."}],
|
||||
"speaker_notes": "...", "suggested_figures": [{"label": "Fig 2", "para_id": "S1-P12"}],
|
||||
"highlight": {"value": "+0.82 cm", "label": "体长增长"},
|
||||
"table": {"headers": ["处理", "株高(cm)"], "rows": [["对照", "10.2"]]}}]}
|
||||
硬性要求:
|
||||
- type 只能取:title, outline, section, background, method, result, conclusion, reference;
|
||||
- 结构顺序固定:title → outline →(section → 对应内容组)×3 → conclusion(→ reference,若配额里有);
|
||||
type=section 表示章节分隔页:放在 background、method、result 各组第一张之前,
|
||||
title 只用组名(如「研究背景」,2~6 字,末尾不得带「是/的/了」等虚词,不要写成句子),
|
||||
subtitle 必给(分隔页副标题,渲染在主标题下方):照抄该章第一张内容页的 title
|
||||
(如「黄瓜遗传改良亟需高精度泛基因组」,≤20 字、一句话、与该页标题逐字一致),
|
||||
bullets 用空数组(副标题走 subtitle 字段,不要再塞进 bullets),
|
||||
evidence/speaker_notes 可省略;
|
||||
- 每个 type 的页数必须落在给定配额 [min,max] 内,不得自创结构;
|
||||
- 标题即结论:内容页 title 写成一句话结论或核心问题(≤20 字,看标题就懂这页讲什么),
|
||||
「背景/方法/结果」这类栏目词只允许出现在 section 分隔页;
|
||||
- 叙事:方法页讲机制与做法,结果页说明「证明了什么」而不是罗列做了什么;
|
||||
- bullets 每页 3-5 条(title 页放作者/单位/来源,outline 页列各部分标题,
|
||||
reference 页列 5-8 条文献);单条 ≤40 字、名词短语化、不写整句;
|
||||
关键词强调用 **双星号** 包裹(如 **盐胁迫**);子要点写成独立数组元素并以「- 」开头
|
||||
(如 "- 子要点",其 evidence 填 null);
|
||||
- 版面标记(生成端只按此写法,渲染端自动识别,勿在同一页混用多种):
|
||||
· 并列短要点:方法/结果/结论页若内容就是 2-4 个并列短语,每条 ≤16 字、不带任何标记,
|
||||
直接写成这样的短句条目(会渲染为卡片网格);
|
||||
· 关键词行:背景/方法概述页 ≥3 条统一用「关键词:说明」句式(如「靶基因:SOS1」);
|
||||
· 流程:步骤页 ≥3 条统一以「→ 」开头(如「→ 提取 RNA」),会渲染为流程卡;
|
||||
· 强调框:全篇最多 1-2 条最核心结论,以「> 」开头单独成条放在该页最后,
|
||||
其 evidence 填 null(会渲染为底部强调引用框);
|
||||
· 表格:结果对比页优先用 table 字段(headers ≤5 列、rows ≤4 行、单元格 ≤18 字)
|
||||
代替 bullets;该页 bullets 最多 2 条导读,表格里的数据引用写进 speaker_notes;
|
||||
- evidence 与 bullets 等长对齐(一一对齐,没有来源的位置填 null);
|
||||
每条 evidence 只需要 "quote" 字段:从摘要卡片里逐字照抄 quote 原文;
|
||||
**不需要写 para_id,后端会按 quote 自动反查真实段落ID**(你写错会干扰校验);
|
||||
卡片里找不到对应 quote 的一律填 null,严禁改写或编造;
|
||||
- speaker_notes 是讲稿口吻(80-160 字),可自然转述 quote 但不得出现段落ID;
|
||||
- suggested_figures 只能从用户消息给出的「论文真实图表清单」里照抄 label 与 para_id,
|
||||
清单为空则一律给空数组;你只决定"哪个图放哪页",绝不发明图号;
|
||||
- result 组最后 1-2 页若呈现对比数据,给该页加 "highlight": {"value": "≤12字符的数字",
|
||||
"label": "≤16字符的说明"}(如 {"value": "+0.82 cm", "label": "体长增长"});
|
||||
没有可突出的数字对比就省略 highlight 字段;
|
||||
- 严禁编造引用、数据、图表编号。不要输出 JSON 以外的任何文字。"""
|
||||
|
||||
|
||||
def _plan_table(plan: dict[str, tuple[int, int]], types: tuple[str, ...] | list[str]) -> str:
|
||||
return "\n".join(f"- {t}: {plan[t][0]}~{plan[t][1]} 页" for t in types)
|
||||
|
||||
|
||||
def _narrow_figure_index(
|
||||
figure_index: list[dict], available: list[str] | None,
|
||||
) -> list[dict]:
|
||||
"""v3 修复2:把注入 prompt 的图表清单收窄到「已提取原图」的 label。
|
||||
|
||||
available 为空/None → 不收窄(text 模式保持全清单占位框);
|
||||
交集为空(提取 label 与扫描不一致等意外)→ 回退全清单,绝不把图清空。
|
||||
匹配按 canon_label(kind+编号)进行,且把 entry.label 统一成磁盘文件名形态
|
||||
("Fig. 6b" → "Fig 6",子图后缀无对应文件),同编号去重只留首个。
|
||||
"""
|
||||
if not available or not figure_index:
|
||||
return figure_index
|
||||
by_key: dict[tuple[str, int], str] = {}
|
||||
for lbl in available:
|
||||
key = canon_label(lbl)
|
||||
if key and key not in by_key:
|
||||
by_key[key] = str(lbl).strip()
|
||||
if not by_key:
|
||||
return figure_index
|
||||
narrowed: list[dict] = []
|
||||
seen: set[tuple[str, int]] = set()
|
||||
for f in figure_index:
|
||||
key = canon_label(f.get("label"))
|
||||
if key in by_key and key not in seen:
|
||||
entry = dict(f)
|
||||
entry["label"] = by_key[key] # LLM 所见 = 磁盘可嵌形态
|
||||
narrowed.append(entry)
|
||||
seen.add(key)
|
||||
return narrowed or figure_index
|
||||
|
||||
|
||||
def _claims_block(claims: list[dict], figures: list[dict]) -> str:
|
||||
# para_id 不下发给 reduce/expand(LLM 会照抄编造;quote 由后端反查真实段落ID)
|
||||
compact = [
|
||||
{"claim": str(c.get("claim") or "")[:200],
|
||||
"quote": str(c.get("quote") or "")[:80]}
|
||||
for c in claims
|
||||
]
|
||||
figs = [
|
||||
{"label": str(f.get("label") or "")[:40], "para_id": str(f.get("para_id") or "")}
|
||||
for f in figures
|
||||
]
|
||||
return json.dumps({"claims": compact, "figures": figs}, ensure_ascii=False)
|
||||
|
||||
|
||||
def _reduce_user(
|
||||
scenario: str, minutes: int, plan: dict[str, tuple[int, int]],
|
||||
claims: list[dict], figures: list[dict],
|
||||
types: list[str], *, prior_titles: list[str] | None = None, total_hint: int | None = None,
|
||||
figure_index: list[dict] | None = None,
|
||||
) -> str:
|
||||
parts = [
|
||||
f"场景:{SCENARIO_LABELS[scenario]}({scenario}),汇报时长 {minutes} 分钟,"
|
||||
f"目标总页数 {PAGE_TARGETS[minutes][0]}~{PAGE_TARGETS[minutes][1]} 页。",
|
||||
f"本次生成的页类型与配额(按顺序):\n{_plan_table(plan, types)}",
|
||||
]
|
||||
if total_hint is not None:
|
||||
parts.append(f"本次需生成 {total_hint} 页。")
|
||||
if prior_titles:
|
||||
parts.append("前半部分已生成的页标题(保持连贯,不要重复):" + ";".join(prior_titles))
|
||||
parts.append(f"摘要卡片:\n{_claims_block(claims, figures)}")
|
||||
# P5 增强 1:论文真实图表清单注入(LLM 只选不编)
|
||||
if figure_index:
|
||||
compact = [
|
||||
{"label": f.get("label"), "para_id": f.get("para_id"),
|
||||
"caption": str(f.get("caption") or "")[:80]}
|
||||
for f in figure_index
|
||||
]
|
||||
parts.append(
|
||||
"论文中真实存在的图表清单(suggested_figures 只能从这里选,label 与 para_id 照抄):\n"
|
||||
+ json.dumps(compact, ensure_ascii=False)
|
||||
)
|
||||
else:
|
||||
parts.append("论文中未扫描到任何图表编号:suggested_figures 一律给空数组。")
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
# ---------- 结构裁决(纯函数) ----------
|
||||
|
||||
def coerce_outline(payload: Any) -> list[dict]:
|
||||
"""LLM 返回 → 规范化 pages(枚举/字段/bullets-evidence 对齐/slide_number)。"""
|
||||
pages_raw = payload.get("pages") if isinstance(payload, dict) else None
|
||||
if not isinstance(pages_raw, list):
|
||||
raise OutlineError("LLM_INVALID", "LLM 输出缺少 pages 列表")
|
||||
pages: list[dict] = []
|
||||
for raw in pages_raw:
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
ptype = str(raw.get("type") or "")
|
||||
if ptype not in PAGE_TYPES:
|
||||
continue
|
||||
title = str(raw.get("title") or "").strip() or ptype
|
||||
if ptype == "section":
|
||||
# v3 修复3:LLM 偶发把「是」等虚词缀进组名(如「研究方法是」)
|
||||
title = re.sub(r"[是的了]$", "", title) or title
|
||||
bullets = [
|
||||
str(b).strip() for b in (raw.get("bullets") or [])
|
||||
if isinstance(b, (str, int, float)) and str(b).strip()
|
||||
]
|
||||
ev_raw = raw.get("evidence")
|
||||
evidence: list[dict | None] = []
|
||||
for i in range(len(bullets)):
|
||||
item = ev_raw[i] if isinstance(ev_raw, list) and i < len(ev_raw) else None
|
||||
if isinstance(item, dict) and str(item.get("quote") or "").strip():
|
||||
evidence.append({
|
||||
"para_id": str(item.get("para_id") or ""),
|
||||
"quote": str(item.get("quote") or "").strip(),
|
||||
"status": "unverified",
|
||||
})
|
||||
else:
|
||||
evidence.append(None)
|
||||
figs_raw = raw.get("suggested_figures")
|
||||
figs = []
|
||||
if isinstance(figs_raw, list):
|
||||
for f in figs_raw[:6]:
|
||||
if isinstance(f, dict) and str(f.get("para_id") or "").strip():
|
||||
figs.append({
|
||||
"label": str(f.get("label") or "").strip(),
|
||||
"para_id": str(f.get("para_id") or "").strip(),
|
||||
"status": "unverified",
|
||||
})
|
||||
# P5 增强 3:大数字突出页标记(result 组对比数据页,可选)
|
||||
hl_raw = raw.get("highlight")
|
||||
highlight = None
|
||||
if isinstance(hl_raw, dict) and str(hl_raw.get("value") or "").strip():
|
||||
highlight = {
|
||||
"value": str(hl_raw.get("value") or "").strip()[:24],
|
||||
"label": str(hl_raw.get("label") or "").strip()[:32],
|
||||
}
|
||||
# P5 二次开发 2.2:对比表格(可选;rows 超限截断,坏结构直接丢弃)
|
||||
table = None
|
||||
tbl_raw = raw.get("table")
|
||||
if isinstance(tbl_raw, dict) and isinstance(tbl_raw.get("rows"), list):
|
||||
headers = [
|
||||
str(h).strip()[:24] for h in (tbl_raw.get("headers") or [])
|
||||
if isinstance(h, (str, int, float)) and str(h).strip()
|
||||
][:8]
|
||||
rows: list[list[str]] = []
|
||||
for r in tbl_raw["rows"][:12]:
|
||||
if not isinstance(r, (list, tuple)):
|
||||
continue
|
||||
cells = [
|
||||
str(c).strip()[:60] for c in r
|
||||
if isinstance(c, (str, int, float))
|
||||
][:8]
|
||||
if any(cells):
|
||||
rows.append(cells)
|
||||
if rows:
|
||||
table = {"headers": headers, "rows": rows}
|
||||
page = {
|
||||
"slide_number": 0,
|
||||
"type": ptype,
|
||||
"title": title,
|
||||
"bullets": bullets,
|
||||
"evidence": evidence,
|
||||
"speaker_notes": str(raw.get("speaker_notes") or "").strip(),
|
||||
"suggested_figures": figs,
|
||||
}
|
||||
if ptype == "section":
|
||||
# P5 改进 2:分隔页副标题独立字段(LLM 给 subtitle;旧大纲仍在 bullets[0],
|
||||
# 编辑器改 bullets[0] 也算数 → bullets 优先,避免编辑被旧 subtitle 覆盖)
|
||||
page["subtitle"] = (bullets[0] if bullets else "") or str(
|
||||
raw.get("subtitle") or ""
|
||||
).strip()
|
||||
if highlight:
|
||||
page["highlight"] = highlight
|
||||
if table:
|
||||
page["table"] = table
|
||||
pages.append(page)
|
||||
if not pages:
|
||||
raise OutlineError("LLM_INVALID", "LLM 输出中没有可用页(type 全部非法)")
|
||||
return pages
|
||||
|
||||
|
||||
# 超页裁剪优先级:section/reference 可整页删(floor 0),内容页保底 1 页;
|
||||
# title/outline/conclusion 永不删
|
||||
_DROP_PRIORITY = ("section", "reference", "background", "method", "result")
|
||||
_FLOOR_ZERO = ("section", "reference")
|
||||
|
||||
|
||||
def trim_to_max(
|
||||
pages: list[dict], hi: int, warnings: list[str],
|
||||
floors: dict[str, int] | None = None,
|
||||
) -> list[dict]:
|
||||
"""总页数超上限:按 _DROP_PRIORITY 优先级删该类型的最后一页;不凑下限。
|
||||
|
||||
floors(v3 修复3):按配额给类型设保底(如 section=(3,3) → 传 {"section": 3})。
|
||||
有保底时先删「超配额的内容页/文献页」,section 不再被一刀清空;
|
||||
全部 floor 都满足却仍超页 → 才回退到 section/reference 整页删(原语义兜底)。
|
||||
"""
|
||||
floors = floors or {}
|
||||
floor_of = lambda t: floors.get(t, 0 if t in _FLOOR_ZERO else 1) # noqa: E731
|
||||
while len(pages) > hi:
|
||||
counts: dict[str, int] = {}
|
||||
for p in pages:
|
||||
counts[p["type"]] = counts.get(p["type"], 0) + 1
|
||||
drop_type = next(
|
||||
(t for t in _DROP_PRIORITY if counts.get(t, 0) > floor_of(t)),
|
||||
None,
|
||||
)
|
||||
if drop_type is None:
|
||||
# 配额 floor 全满足仍超页 → 兜底砍可整删类型(section/reference 优先)
|
||||
drop_type = next(
|
||||
(t for t in _DROP_PRIORITY
|
||||
if t in _FLOOR_ZERO and counts.get(t, 0) > 0),
|
||||
None,
|
||||
)
|
||||
if drop_type is None:
|
||||
break
|
||||
idx = max(i for i, p in enumerate(pages) if p["type"] == drop_type)
|
||||
removed = pages.pop(idx)
|
||||
warnings.append(
|
||||
f"页数超过 {hi} 页上限,已移除 {removed['type']} 页「{removed['title']}」"
|
||||
)
|
||||
return pages
|
||||
|
||||
|
||||
def split_oversized(pages: list[dict], warnings: list[str]) -> list[dict]:
|
||||
"""内容页 bullets >8:拆成两页(续页 title+"(续)"),递归直到全 ≤8。"""
|
||||
out: list[dict] = []
|
||||
for page in pages:
|
||||
todo = [page]
|
||||
while todo:
|
||||
cur = todo.pop(0)
|
||||
bullets = cur.get("bullets") or []
|
||||
if cur["type"] in CONTENT_TYPES and len(bullets) > 8:
|
||||
k = math.ceil(len(bullets) / 2)
|
||||
ev_full = list(cur.get("evidence") or [])
|
||||
if len(ev_full) < len(bullets):
|
||||
ev_full += [None] * (len(bullets) - len(ev_full))
|
||||
nxt = json.loads(json.dumps(cur)) # 深拷贝
|
||||
nxt["bullets"] = bullets[k:]
|
||||
nxt["evidence"] = ev_full[k:]
|
||||
nxt["title"] = f"{cur['title']}(续)"
|
||||
nxt["suggested_figures"] = [] # 图占位只放首页
|
||||
nxt["speaker_notes"] = ""
|
||||
cur["bullets"] = bullets[:k]
|
||||
cur["evidence"] = ev_full[:k]
|
||||
warnings.append(f"「{cur['title'].removesuffix('(续)')}」要点超过 8 条,已拆成两页")
|
||||
todo.insert(0, nxt)
|
||||
todo.insert(0, cur)
|
||||
continue
|
||||
out.append(cur)
|
||||
return out
|
||||
|
||||
|
||||
def renumber(pages: list[dict]) -> list[dict]:
|
||||
for i, p in enumerate(pages, 1):
|
||||
p["slide_number"] = i
|
||||
return pages
|
||||
|
||||
|
||||
# ---------- 证据程序化校验(纯函数) ----------
|
||||
|
||||
_FIG_RE = re.compile(r"^(S\d+-P\d+)(?:-Fig(\d+))?$", re.IGNORECASE)
|
||||
|
||||
|
||||
def _norm(s: str) -> str:
|
||||
"""归一化:NFKC + 小写 + 去掉全部空白与标点(只留字母数字/CJK)。"""
|
||||
s = unicodedata.normalize("NFKC", s).lower()
|
||||
return re.sub(r"[\W_]+", "", s, flags=re.UNICODE)
|
||||
|
||||
|
||||
def _concat_segments(norm_index: dict[str, str]) -> tuple[str, dict[str, tuple[int, int]]]:
|
||||
"""按 document 顺序把各段归一化文本拼成一条,并给出每段在拼接串中的 [start, end)。
|
||||
|
||||
LLM 照抄的 quote 常横跨两个段落边界 → 任何单段都不含它,但整篇文档含。
|
||||
repair/validate 据此做跨段宽松反查(v3 修复4)。
|
||||
"""
|
||||
offsets: dict[str, tuple[int, int]] = {}
|
||||
chunks: list[str] = []
|
||||
pos = 0
|
||||
for pid, nt in norm_index.items():
|
||||
offsets[pid] = (pos, pos + len(nt))
|
||||
chunks.append(nt)
|
||||
pos += len(nt)
|
||||
return "".join(chunks), offsets
|
||||
|
||||
|
||||
def _locate_span(
|
||||
nq: str, concat: str, offsets: dict[str, tuple[int, int]],
|
||||
) -> str | None:
|
||||
"""跨段定位:nq 起点落在哪个段(取起点所在段为归属)。≥10 归一化字符才启用。"""
|
||||
if len(nq) < 10:
|
||||
return None
|
||||
i = concat.find(nq)
|
||||
if i < 0:
|
||||
return None
|
||||
for pid, (start, end) in offsets.items():
|
||||
if start <= i < end:
|
||||
return pid
|
||||
return None
|
||||
|
||||
|
||||
def repair_evidence_para_ids(pages: list[dict], index: dict[str, str]) -> int:
|
||||
"""按 quote 归一化子串反查真实 para_id(P5 修复:LLM 常编 para_id,quote 往往逐字正确)。
|
||||
|
||||
LLM reduce/expand 只需给 quote(prompt 已声明后端反查);这里按 document
|
||||
顺序找第一个包含该 quote 的段落填回 para_id,后续 validate_evidence 据此标
|
||||
verified。找不到 → 不动,留给 validate_evidence 标 unverified(真幻觉)。
|
||||
返回修复条数。
|
||||
"""
|
||||
norm_index = {pid: _norm(t) for pid, t in index.items()}
|
||||
concat, offsets = _concat_segments(norm_index)
|
||||
repaired = 0
|
||||
for page in pages:
|
||||
for ev in page.get("evidence") or []:
|
||||
if not isinstance(ev, dict):
|
||||
continue
|
||||
quote = str(ev.get("quote") or "")
|
||||
nq = _norm(quote)
|
||||
if not nq:
|
||||
continue
|
||||
pid = str(ev.get("para_id") or "")
|
||||
if pid and nq in norm_index.get(pid, ""):
|
||||
continue # 标注已正确,不动
|
||||
hit = next((p for p, nt in norm_index.items() if nq in nt), None)
|
||||
if not hit:
|
||||
# 跨段 quote:任何单段都不含,但在整篇拼接串里 → 归属起点所在段
|
||||
hit = _locate_span(nq, concat, offsets)
|
||||
if hit:
|
||||
ev["para_id"] = hit
|
||||
repaired += 1
|
||||
return repaired
|
||||
|
||||
|
||||
def validate_evidence(
|
||||
pages: list[dict], index: dict[str, str],
|
||||
figure_index: list[dict] | None = None,
|
||||
) -> list[str]:
|
||||
"""逐条校验 evidence / suggested_figures → status + warnings。
|
||||
|
||||
figure_index(论文真实图表清单)非空时,图引用按清单裁决:
|
||||
- label 不在清单 → 直接丢弃(不是标 unverified,结果里不会出现论文没有的图);
|
||||
- label 在清单 → 以清单里的真实 para_id 为准,status=verified。
|
||||
为 None 时保持旧路径(格式 + 原文 fig{N} 子串校验),兼容无清单的调用方。
|
||||
"""
|
||||
warnings: list[str] = []
|
||||
norm_index = {pid: _norm(t) for pid, t in index.items()}
|
||||
doc_all = "".join(norm_index.values())
|
||||
concat, offsets = _concat_segments(norm_index)
|
||||
fmap = figure_map(figure_index) if figure_index is not None else None
|
||||
for page in pages:
|
||||
sn = page["slide_number"]
|
||||
evs = page.get("evidence") or []
|
||||
ver = unver = 0
|
||||
for ev in evs:
|
||||
if ev is None:
|
||||
continue
|
||||
quote = str(ev.get("quote") or "")
|
||||
pid = str(ev.get("para_id") or "")
|
||||
nq, np_ = _norm(quote), norm_index.get(pid)
|
||||
if quote and np_ is not None and nq and nq in np_:
|
||||
ev["status"] = "verified"
|
||||
ver += 1
|
||||
elif quote and nq and pid and _locate_span(nq, concat, offsets) == pid:
|
||||
# 跨段 quote:repair 已把 para_id 指到起点段 → 等同逐字命中
|
||||
ev["status"] = "verified"
|
||||
ver += 1
|
||||
else:
|
||||
ev["status"] = "unverified"
|
||||
unver += 1
|
||||
if nq and nq in doc_all:
|
||||
warnings.append(
|
||||
f"第{sn}页:引用真实存在,但段落标注 {pid or '(空)'} 不符(已标 unverified)"
|
||||
)
|
||||
total = ver + unver
|
||||
if page["type"] in ("result", "conclusion") and total and unver / total > 0.5:
|
||||
warnings.append(
|
||||
f"第{sn}页({page['type']})证据未核实占比超过 50%({unver}/{total}),请人工复核"
|
||||
)
|
||||
kept: list[dict] = []
|
||||
for fig in page.get("suggested_figures") or []:
|
||||
if fmap is not None:
|
||||
key = canon_label(fig.get("label"))
|
||||
entry = fmap.get(key) if key else None
|
||||
if entry is None:
|
||||
warnings.append(
|
||||
f"第{sn}页:图引用 {fig.get('label') or '(空)'} 不在论文真实图表清单,已丢弃"
|
||||
)
|
||||
continue
|
||||
fig["para_id"] = entry["para_id"] # 以扫描出的真实位置为准
|
||||
fig["status"] = "verified"
|
||||
kept.append(fig)
|
||||
continue
|
||||
m = _FIG_RE.match(str(fig.get("para_id") or ""))
|
||||
if not m:
|
||||
fig["status"] = "unverified"
|
||||
warnings.append(f"第{sn}页:图引用 {fig.get('para_id')} 格式不合法")
|
||||
kept.append(fig)
|
||||
continue
|
||||
base, num = m.group(1).upper(), m.group(2)
|
||||
base_norm = norm_index.get(base)
|
||||
fig_ok = base_norm is not None and (
|
||||
num is None or f"fig{num}" in base_norm or f"figure{num}" in base_norm
|
||||
)
|
||||
fig["status"] = "verified" if fig_ok else "unverified"
|
||||
if not fig_ok:
|
||||
warnings.append(
|
||||
f"第{sn}页:图引用 {fig['para_id']} 未在原文对应段落找到(已标 unverified)"
|
||||
)
|
||||
kept.append(fig)
|
||||
page["suggested_figures"] = kept
|
||||
return warnings
|
||||
|
||||
|
||||
# ---------- 扩充重试(内容页 bullets <2 → 一次按页补齐) ----------
|
||||
|
||||
_EXPAND_SYSTEM = """你是学术汇报编辑。某几页幻灯片的要点不足(少于 2 条)。
|
||||
基于给出的页信息与摘要卡片(claims 已带逐字引用),为每页补齐到 3-6 条要点。
|
||||
只输出单个 JSON 对象:
|
||||
{"pages": [{"index": 0, "bullets": ["..."], "evidence": [{"quote": "..."}]}]}
|
||||
硬性要求:
|
||||
- index 是给定页在列表中的下标;bullets 保留原有要点(可精炼改写)并补充同主题新要点;
|
||||
- evidence 与 bullets 等长对齐;每条 evidence 只需要 "quote" 字段,从卡片里逐字照抄,
|
||||
**不需要写 para_id,后端会按 quote 自动反查**;没有依据填 null;
|
||||
- 单条 ≤60 字;严禁编造。不要输出 JSON 以外的任何文字。"""
|
||||
|
||||
|
||||
async def _expand_thin(
|
||||
pages: list[dict], thin_idx: list[int],
|
||||
claims: list[dict], figures: list[dict], warnings: list[str],
|
||||
) -> None:
|
||||
"""一次 LLM 调用补齐所有薄弱页;失败仅 warning,不阻断。"""
|
||||
page_info = [
|
||||
{
|
||||
"index": i,
|
||||
"type": pages[i]["type"],
|
||||
"title": pages[i]["title"],
|
||||
"bullets": pages[i]["bullets"],
|
||||
"speaker_notes": pages[i]["speaker_notes"],
|
||||
}
|
||||
for i in thin_idx
|
||||
]
|
||||
user = (
|
||||
f"待补齐页(下标即列表位置):\n{json.dumps(page_info, ensure_ascii=False)}\n\n"
|
||||
f"摘要卡片:\n{_claims_block(claims, figures)}"
|
||||
)
|
||||
try:
|
||||
payload = await llm.complete_json(
|
||||
_EXPAND_SYSTEM, user, temperature=0.3,
|
||||
max_tokens=4000, timeout=_EXPAND_TIMEOUT,
|
||||
)
|
||||
except LLMError as exc:
|
||||
warnings.append(f"薄弱页扩充重试失败(保留原状):{exc}")
|
||||
return
|
||||
for item in payload.get("pages") or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
idx = item.get("index")
|
||||
if not isinstance(idx, int) or not (0 <= idx < len(pages)) or idx not in thin_idx:
|
||||
continue
|
||||
page = pages[idx]
|
||||
bullets = [
|
||||
str(b).strip() for b in (item.get("bullets") or [])
|
||||
if isinstance(b, (str, int, float)) and str(b).strip()
|
||||
][:8]
|
||||
if len(bullets) < 2:
|
||||
continue
|
||||
ev_raw = item.get("evidence")
|
||||
evidence: list[dict | None] = []
|
||||
for i in range(len(bullets)):
|
||||
e = ev_raw[i] if isinstance(ev_raw, list) and i < len(ev_raw) else None
|
||||
if isinstance(e, dict) and str(e.get("quote") or "").strip():
|
||||
evidence.append({
|
||||
"para_id": str(e.get("para_id") or ""),
|
||||
"quote": str(e.get("quote") or "").strip(),
|
||||
"status": "unverified",
|
||||
})
|
||||
else:
|
||||
evidence.append(None)
|
||||
page["bullets"] = bullets
|
||||
page["evidence"] = evidence
|
||||
|
||||
|
||||
# ---------- 主编排 ----------
|
||||
|
||||
async def generate_outline(
|
||||
text: str, scenario: str, minutes: int, style_id: str,
|
||||
*,
|
||||
available_figures: list[str] | None = None,
|
||||
) -> tuple[list[dict], list[str], dict[str, Any]]:
|
||||
"""生成完整大纲。返回 (pages, warnings, meta);失败抛 OutlineError。
|
||||
|
||||
available_figures(v3 修复2):PDF 入口已提取到磁盘的原图 label 列表。
|
||||
非空时只把「已提取」的图注入 reduce/expand(LLM 选了 build 就能 add_picture
|
||||
嵌入,不再出虚线占位);None/空 → 保持全清单(text 模式占位框不变)。
|
||||
"""
|
||||
if scenario not in SCENARIOS:
|
||||
raise OutlineError("VALIDATION", f"未知场景 scenario={scenario}")
|
||||
if minutes not in PAGE_TARGETS:
|
||||
raise OutlineError("VALIDATION", f"不支持的时长 {minutes} 分钟(可选 {list(MINUTES_CHOICES)})")
|
||||
if style_id not in STYLE_IDS:
|
||||
raise OutlineError("VALIDATION", f"未知风格 style_id={style_id}")
|
||||
|
||||
text = (text or "").strip()
|
||||
if len(text) < 300:
|
||||
raise OutlineError("VALIDATION", "正文过短(<300 字),无法生成汇报大纲")
|
||||
settings = get_settings()
|
||||
if len(text) > settings.max_text_chars:
|
||||
text = text[: settings.max_text_chars]
|
||||
|
||||
blocks, index = build_index(text)
|
||||
if not blocks:
|
||||
raise OutlineError("VALIDATION", "正文解析为空,无法生成汇报大纲")
|
||||
|
||||
# P5 增强 1:论文真实图表清单(正则扫描,非 LLM)——供 reduce 只选不编
|
||||
figure_index = scan_figure_index(index)
|
||||
# v3 修复2:有已提取原图 → 注入清单收窄到「磁盘上有图」的条目(交集空则回退全清单)
|
||||
inject_index = _narrow_figure_index(figure_index, available_figures)
|
||||
fmap_pick = figure_map(inject_index)
|
||||
|
||||
plan = page_plan(scenario, minutes)
|
||||
lo, hi = PAGE_TARGETS[minutes]
|
||||
warnings: list[str] = []
|
||||
llm_calls = 0
|
||||
|
||||
# ---- map(并发 2,控总时长压在 BFF 360s 内) ----
|
||||
claims: list[dict] = []
|
||||
figures: list[dict] = []
|
||||
sem = asyncio.Semaphore(_MAP_CONCURRENCY)
|
||||
|
||||
async def _map_one(block: dict[str, Any]) -> tuple[list[dict], list[dict]]:
|
||||
async with sem:
|
||||
return await _map_block(block)
|
||||
|
||||
try:
|
||||
results = await asyncio.gather(*(_map_one(b) for b in blocks))
|
||||
except LLMError as exc:
|
||||
raise OutlineError("LLM_ERROR", f"章节摘要失败:{exc}") from exc
|
||||
llm_calls += len(results)
|
||||
for cs, fs in results:
|
||||
for c in cs:
|
||||
if len(claims) < _MAX_CLAIMS_TOTAL:
|
||||
claims.append(c)
|
||||
for f in fs:
|
||||
if len(figures) < _MAX_FIGURES_TOTAL:
|
||||
figures.append(f)
|
||||
# P5 增强 1:map 抽到的图先过一遍注入清单(不在清单 → 丢弃;在 → 用真实 para_id)
|
||||
if fmap_pick:
|
||||
filtered: list[dict] = []
|
||||
for f in figures:
|
||||
key = canon_label(f.get("label"))
|
||||
entry = fmap_pick.get(key) if key else None
|
||||
if entry:
|
||||
f = dict(f, para_id=entry["para_id"])
|
||||
filtered.append(f)
|
||||
if len(filtered) != len(figures):
|
||||
warnings.append(
|
||||
f"map 阶段 {len(figures) - len(filtered)} 个图引用不在论文真实图表清单,已丢弃"
|
||||
)
|
||||
figures = filtered
|
||||
else:
|
||||
if figures:
|
||||
warnings.append(f"论文未扫描到图表编号,丢弃 map 阶段 {len(figures)} 个图引用")
|
||||
figures = []
|
||||
if not claims:
|
||||
raise OutlineError("LLM_INVALID", "未能从正文抽取到任何可引用事实(claims 为空)")
|
||||
|
||||
# ---- reduce(>14 页分两片,第二片带上一片标题连贯) ----
|
||||
full_types = [t for t in PAGE_TYPES if t in plan]
|
||||
split_at = len(full_types)
|
||||
acc = 0
|
||||
for i, t in enumerate(full_types):
|
||||
acc += plan[t][1]
|
||||
if acc >= _REDUCE_PAGE_SLICE:
|
||||
split_at = i + 1
|
||||
break
|
||||
first_types, second_types = full_types[:split_at], full_types[split_at:]
|
||||
|
||||
def _types_quota(ts: list[str]) -> int:
|
||||
return sum(plan[t][0] for t in ts)
|
||||
|
||||
pages: list[dict] = []
|
||||
try:
|
||||
payload = await llm.complete_json(
|
||||
_REDUCE_SYSTEM,
|
||||
_reduce_user(scenario, minutes, plan, claims, figures, first_types,
|
||||
figure_index=inject_index),
|
||||
temperature=0.4, max_tokens=8000, timeout=_REDUCE_TIMEOUT,
|
||||
)
|
||||
llm_calls += 1
|
||||
pages = coerce_outline(payload)
|
||||
if second_types:
|
||||
prior_titles = [p["title"] for p in pages]
|
||||
payload2 = await llm.complete_json(
|
||||
_REDUCE_SYSTEM,
|
||||
_reduce_user(
|
||||
scenario, minutes, plan, claims, figures, second_types,
|
||||
prior_titles=prior_titles, total_hint=_types_quota(second_types),
|
||||
figure_index=inject_index,
|
||||
),
|
||||
temperature=0.4, max_tokens=8000, timeout=_REDUCE_TIMEOUT,
|
||||
)
|
||||
llm_calls += 1
|
||||
pages.extend(coerce_outline(payload2))
|
||||
except LLMError as exc:
|
||||
msg = str(exc)
|
||||
if "JSON" in msg or "json" in msg:
|
||||
raise OutlineError("LLM_INVALID", f"LLM 大纲输出无法解析:{msg}") from exc
|
||||
raise OutlineError("LLM_ERROR", f"大纲生成失败:{msg}") from exc
|
||||
|
||||
# ---- 后校验:拆页 → 夹页数 → 扩充 → 证据 → 重编号 ----
|
||||
pages = split_oversized(pages, warnings)
|
||||
renumber(pages)
|
||||
# v3 修复3:按配额给全类型设保底(先删超配额的页;section/reference 不再被一刀清空)
|
||||
floors = {t: mn for t, (mn, _mx) in plan.items() if mn}
|
||||
pages = trim_to_max(pages, hi, warnings, floors=floors)
|
||||
renumber(pages)
|
||||
if len(pages) < lo:
|
||||
warnings.append(
|
||||
f"生成 {len(pages)} 页,低于 {minutes} 分钟目标下限 {lo} 页(不凑页,请人工补足)"
|
||||
)
|
||||
# 类型顺序归一:保持 LLM 输出顺序即可,但至少保证 title 在首位
|
||||
pages.sort(key=lambda p: (0 if p["type"] == "title" else 1))
|
||||
renumber(pages)
|
||||
|
||||
thin_idx = [
|
||||
i for i, p in enumerate(pages)
|
||||
if p["type"] in CONTENT_TYPES and len(p["bullets"]) < 2
|
||||
]
|
||||
if thin_idx:
|
||||
await _expand_thin(pages, thin_idx, claims, figures, warnings)
|
||||
for i in thin_idx:
|
||||
if len(pages[i]["bullets"]) < 2:
|
||||
warnings.append(f"第{pages[i]['slide_number']}页要点不足 2 条(扩充后仍不足)")
|
||||
|
||||
# P5 修复:LLM 只给 quote(或编 para_id)→ 按 quote 反查真实段落ID,再进校验
|
||||
repaired = repair_evidence_para_ids(pages, index)
|
||||
if repaired:
|
||||
warnings.append(f"{repaired} 条 evidence 的段落标注已按 quote 反查修正")
|
||||
warnings.extend(validate_evidence(pages, index, inject_index))
|
||||
renumber(pages)
|
||||
|
||||
unverified_total = sum(
|
||||
1 for p in pages for e in (p.get("evidence") or []) if e and e.get("status") == "unverified"
|
||||
)
|
||||
meta = {
|
||||
"scenario": scenario,
|
||||
"minutes": minutes,
|
||||
"style_id": style_id,
|
||||
"paragraphs": len(index),
|
||||
"sections": len(blocks),
|
||||
"claims": len(claims),
|
||||
"figures": len(figure_index), # 真实图表清单规模(扫描所得)
|
||||
"figures_used": sum( # 大纲里实际引用且通过校验的图数
|
||||
1 for p in pages for f in (p.get("suggested_figures") or [])
|
||||
if f.get("status") == "verified"
|
||||
),
|
||||
"llm_calls": llm_calls,
|
||||
"target_pages": [lo, hi],
|
||||
"unverified_evidence": unverified_total,
|
||||
}
|
||||
return pages, warnings, meta
|
||||
@@ -0,0 +1,230 @@
|
||||
"""services/ppt_preview.py —— pptx → PNG 页预览(PIL 自渲染,读回 python-pptx 对象)。
|
||||
|
||||
移植自交付脚本 /tmp/opencode/render_pptx_preview.py(本项目自有验证脚本),
|
||||
用途:build 后给前端缩略图条 / 大图预览(浏览器内直接目检版式)。
|
||||
非 PowerPoint 截图:几何/颜色/字体色忠实还原,字形以 Noto CJK 近似(预览够用)。
|
||||
|
||||
依赖仅 Pillow + python-pptx(均已在 tooling/requirements.lock),零新依赖。
|
||||
约定:输出 out_dir/slide_{i:02d}.png(i 从 1 起),1280×720 @96dpi;
|
||||
损坏/空 pptx → 返回 [](调用方降级,不抛)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import tempfile
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from pptx import Presentation
|
||||
from pptx.enum.shapes import MSO_SHAPE_TYPE
|
||||
from pptx.util import Emu
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DPI = 96
|
||||
SLIDE_W_IN, SLIDE_H_IN = 13.333, 7.5
|
||||
|
||||
# 字体 fallback 链:Noto CJK(中英混排)→ DejaVu(兜底,缺 CJK 也不崩)
|
||||
_FONT_CANDIDATES_REG = (
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||
)
|
||||
_FONT_CANDIDATES_BOLD = (
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
||||
)
|
||||
|
||||
|
||||
def _first_existing(paths: tuple[str, ...]) -> str:
|
||||
for p in paths:
|
||||
if Path(p).is_file():
|
||||
return p
|
||||
return paths[-1]
|
||||
|
||||
|
||||
_FONT_REG = _first_existing(_FONT_CANDIDATES_REG)
|
||||
_FONT_BOLD = _first_existing(_FONT_CANDIDATES_BOLD)
|
||||
|
||||
|
||||
def px(emu: int) -> int:
|
||||
return int(Emu(emu).inches * DPI)
|
||||
|
||||
|
||||
def font_for(pt: float, bold: bool) -> ImageFont.FreeTypeFont:
|
||||
path = _FONT_BOLD if bold else _FONT_REG
|
||||
return ImageFont.truetype(path, max(int(pt * DPI / 72), 6))
|
||||
|
||||
|
||||
def run_style(run) -> tuple[float, bool, tuple[int, int, int]]:
|
||||
f = run.font
|
||||
pt = (f.size.pt if f.size else 18)
|
||||
bold = bool(f.bold)
|
||||
color = (0, 0, 0)
|
||||
try:
|
||||
if f.color and f.color.rgb is not None:
|
||||
color = tuple(f.color.rgb)
|
||||
except Exception: # noqa: BLE001 - 主题色等非直接 RGB,回退黑
|
||||
pass
|
||||
return pt, bold, color
|
||||
|
||||
|
||||
def wrap_text(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.ImageFont, max_w: float) -> list[str]:
|
||||
lines: list[str] = []
|
||||
cur = ""
|
||||
for ch in text:
|
||||
if ch == "\n":
|
||||
lines.append(cur)
|
||||
cur = ""
|
||||
continue
|
||||
if draw.textlength(cur + ch, font=font) > max_w and cur:
|
||||
lines.append(cur)
|
||||
cur = ch
|
||||
else:
|
||||
cur += ch
|
||||
if cur:
|
||||
lines.append(cur)
|
||||
return lines or [""]
|
||||
|
||||
|
||||
def draw_text_frame(draw, tf, box, *, v_anchor: str = "top") -> None:
|
||||
"""box = (x, y, w, h);按段落逐 run 画(粗体分段 / level 缩进 / 居中)。"""
|
||||
x, y, w, h = box
|
||||
lines_all: list[tuple[str, ImageFont.ImageFont, tuple, int, str]] = []
|
||||
for p in tf.paragraphs:
|
||||
runs = p.runs
|
||||
if not runs:
|
||||
lines_all.append(("", font_for(14, False), (0, 0, 0), 0, str(p.alignment or "left")))
|
||||
continue
|
||||
segs = []
|
||||
for r in runs:
|
||||
pt, bold, col = run_style(r)
|
||||
segs.append((r.text, pt, bold, col))
|
||||
# 预览够用:整段拼接,取最大字号 + 任一粗体,色随首 run
|
||||
joined = "".join(s[0] for s in segs)
|
||||
max_pt = max(s[1] for s in segs)
|
||||
any_bold = any(s[2] for s in segs)
|
||||
col = segs[0][3]
|
||||
indent = (p.level or 0) * 24
|
||||
align = str(p.alignment or "left")
|
||||
fnt = font_for(max_pt, any_bold)
|
||||
for line in wrap_text(draw, joined, fnt, max(w - indent - 8, 20)):
|
||||
lines_all.append((line, fnt, col, indent, align))
|
||||
total_h = sum(int(f.size * 1.35) for _, f, _, _, _ in lines_all)
|
||||
if v_anchor == "middle":
|
||||
y = y + max((h - total_h) // 2, 0)
|
||||
for line, fnt, col, indent, align in lines_all:
|
||||
lw = draw.textlength(line, font=fnt)
|
||||
if "CENTER" in align:
|
||||
lx = x + (w - lw) / 2
|
||||
elif "RIGHT" in align:
|
||||
lx = x + w - lw - 6
|
||||
else:
|
||||
lx = x + indent + 4
|
||||
draw.text((lx, y), line, font=fnt, fill=col)
|
||||
y += int(fnt.size * 1.35)
|
||||
|
||||
|
||||
def slide_bg_color(slide) -> tuple[int, int, int]:
|
||||
try:
|
||||
fill = slide.background.fill
|
||||
if fill.type is not None and fill.fore_color and fill.fore_color.rgb is not None:
|
||||
return tuple(fill.fore_color.rgb)
|
||||
except Exception: # noqa: BLE001 - 无背景填充 → 白底
|
||||
pass
|
||||
return (255, 255, 255)
|
||||
|
||||
|
||||
def render_slide(slide) -> Image.Image:
|
||||
img = Image.new("RGB", (int(SLIDE_W_IN * DPI), int(SLIDE_H_IN * DPI)), slide_bg_color(slide))
|
||||
d = ImageDraw.Draw(img)
|
||||
for sh in slide.shapes:
|
||||
try:
|
||||
x, y, w, h = px(sh.left), px(sh.top), px(sh.width), px(sh.height)
|
||||
except Exception: # noqa: BLE001 - 无定位形状(组合件等)跳过
|
||||
continue
|
||||
if sh.shape_type == MSO_SHAPE_TYPE.AUTO_SHAPE:
|
||||
# 色块(分隔页横幅 / 封面强调条 / 引用框)
|
||||
fc = None
|
||||
try:
|
||||
if sh.fill.type is not None and sh.fill.fore_color.rgb is not None:
|
||||
fc = tuple(sh.fill.fore_color.rgb)
|
||||
except Exception: # noqa: BLE001
|
||||
fc = None
|
||||
if fc:
|
||||
d.rectangle([x, y, x + w, y + h], fill=fc)
|
||||
else:
|
||||
d.rectangle([x, y, x + w, y + h], outline=(150, 150, 150), width=2)
|
||||
if sh.has_text_frame and sh.text_frame.text.strip():
|
||||
draw_text_frame(d, sh.text_frame, (x, y, w, h), v_anchor="middle")
|
||||
elif sh.shape_type == MSO_SHAPE_TYPE.PICTURE:
|
||||
# 真图(Tier B contain 插入的原图)
|
||||
try:
|
||||
pic = Image.open(BytesIO(sh.image.blob)).convert("RGB")
|
||||
img.paste(pic.resize((max(w, 1), max(h, 1))), (x, y))
|
||||
except Exception: # noqa: BLE001 - 损坏图 → 灰块占位
|
||||
d.rectangle([x, y, x + w, y + h], fill=(205, 205, 205))
|
||||
elif getattr(sh, "has_table", False):
|
||||
# 原生表格
|
||||
tbl = sh.table
|
||||
cw = [px(tbl.columns[c].width) for c in range(len(tbl.columns))]
|
||||
rh = [px(tbl.rows[r].height) for r in range(len(tbl.rows))]
|
||||
cy = y
|
||||
for r, row_h in enumerate(rh):
|
||||
cx = x
|
||||
for c, col_w in enumerate(cw):
|
||||
cell = tbl.cell(r, c)
|
||||
try:
|
||||
fc = tuple(cell.fill.fore_color.rgb)
|
||||
except Exception: # noqa: BLE001
|
||||
fc = (245, 245, 245)
|
||||
d.rectangle([cx, cy, cx + col_w, cy + row_h], fill=fc, outline=(170, 170, 170))
|
||||
draw_text_frame(d, cell.text_frame, (cx + 3, cy + 2, col_w - 6, row_h - 4))
|
||||
cx += col_w
|
||||
cy += row_h
|
||||
elif sh.has_text_frame:
|
||||
# 占位符 / 文本框:虚线图框判定
|
||||
txt = sh.text_frame.text
|
||||
if "请插入" in txt:
|
||||
d.rectangle([x, y, x + w, y + h], outline=(150, 150, 150), width=3)
|
||||
for i in range(x, x + w, 12): # 近似虚线
|
||||
d.line([i, y, min(i + 6, x + w), y], fill=(150, 150, 150), width=3)
|
||||
d.line([i, y + h, min(i + 6, x + w), y + h], fill=(150, 150, 150), width=3)
|
||||
d.line([x, y, x, y + h], fill=(150, 150, 150), width=3)
|
||||
d.line([x + w, y, x + w, y + h], fill=(150, 150, 150), width=3)
|
||||
anchor = "middle" if "请插入" in txt else "top"
|
||||
draw_text_frame(d, sh.text_frame, (x, y, w, h), v_anchor=anchor)
|
||||
return img
|
||||
|
||||
|
||||
def render_previews(pptx_path: Path, out_dir: Path) -> list[Path]:
|
||||
"""draft.pptx → out_dir/slide_{i:02d}.png;失败/空/损坏 → [](绝不抛)。"""
|
||||
if not pptx_path.is_file():
|
||||
return []
|
||||
try:
|
||||
prs = Presentation(str(pptx_path))
|
||||
except Exception as exc: # noqa: BLE001 - 坏文件降级(调用方 warning)
|
||||
logger.warning("预览渲染:pptx 打开失败 %s:%s", pptx_path, exc)
|
||||
return []
|
||||
try:
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
# 清旧页:重建后页数变少时不留 slide_{n}.png 残影(懒渲染 GET 同样受益)
|
||||
for stale in out_dir.glob("slide_*.png"):
|
||||
stale.unlink(missing_ok=True)
|
||||
written: list[Path] = []
|
||||
for i, slide in enumerate(prs.slides, 1):
|
||||
img = render_slide(slide)
|
||||
final = out_dir / f"slide_{i:02d}.png"
|
||||
# 原子写:先临时文件再 replace,避免并发 GET 读到半张图
|
||||
with tempfile.NamedTemporaryFile(dir=out_dir, suffix=".png", delete=False) as tmp:
|
||||
tmp_path = Path(tmp.name)
|
||||
try:
|
||||
img.save(tmp_path, format="PNG")
|
||||
tmp_path.replace(final)
|
||||
finally:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
written.append(final)
|
||||
return written
|
||||
except Exception as exc: # noqa: BLE001 - 渲染异常不阻断构建
|
||||
logger.warning("预览渲染失败(该 job 无缩略图):%s", exc)
|
||||
return []
|
||||
@@ -0,0 +1,155 @@
|
||||
"""services/ppt_store.py —— P5 科研汇报 job 持久化(SQLite,自含连接)。
|
||||
|
||||
模式同 rebuttal_store:表 ppt_jobs 落 review_service/review.db(同库不同表),
|
||||
自开短连接 + WAL;pages/warnings 全量 JSON 列整读整写(单进程串行足够)。
|
||||
raw_text 只落库不回 API(150K 正文进 job dict 会把 GET 撑爆;重生成场景
|
||||
才需要它,经 DB 直读)。DDL 按 2026-10-14 决策 3 定稿。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..config import get_settings
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS ppt_jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL,
|
||||
source_kind TEXT,
|
||||
filename TEXT,
|
||||
raw_text TEXT,
|
||||
scenario TEXT,
|
||||
minutes INTEGER,
|
||||
style_id TEXT,
|
||||
pages_json TEXT,
|
||||
pptx_path TEXT,
|
||||
warnings_json TEXT,
|
||||
created_at TEXT,
|
||||
updated_at TEXT
|
||||
);
|
||||
"""
|
||||
|
||||
_STATUS_VALUES = ("outline_ready", "building", "done", "error")
|
||||
_JSON_COLS = {"pages": "pages_json", "warnings": "warnings_json"}
|
||||
_SCALAR_COLS = ("status", "scenario", "minutes", "style_id", "pptx_path",
|
||||
"source_kind", "filename")
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def new_id(prefix: str = "") -> str:
|
||||
return prefix + uuid.uuid4().hex[:12]
|
||||
|
||||
|
||||
def _connect() -> sqlite3.Connection:
|
||||
db_path: Path = get_settings().db_path_abs
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(db_path), timeout=5)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
return conn
|
||||
|
||||
|
||||
def ensure_table() -> None:
|
||||
with _connect() as conn:
|
||||
conn.executescript(_SCHEMA)
|
||||
|
||||
|
||||
def _row_to_job(row: sqlite3.Row) -> dict[str, Any]:
|
||||
status = row["status"]
|
||||
pptx_path = row["pptx_path"]
|
||||
return {
|
||||
"id": row["id"],
|
||||
"status": status,
|
||||
"source": {
|
||||
"kind": row["source_kind"] or "text",
|
||||
"filename": row["filename"],
|
||||
"text_chars": len(row["raw_text"] or ""),
|
||||
},
|
||||
"scenario": row["scenario"],
|
||||
"minutes": row["minutes"],
|
||||
"style_id": row["style_id"],
|
||||
"pages": json.loads(row["pages_json"]) if row["pages_json"] else [],
|
||||
"warnings": json.loads(row["warnings_json"]) if row["warnings_json"] else [],
|
||||
"pptx_ready": bool(pptx_path) and Path(pptx_path).exists(),
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
}
|
||||
|
||||
|
||||
def insert_job(job: dict[str, Any], raw_text: str = "") -> None:
|
||||
now = job.get("created_at") or _now()
|
||||
status = job.get("status") or "outline_ready"
|
||||
if status not in _STATUS_VALUES:
|
||||
raise ValueError(f"非法 status: {status}")
|
||||
with _connect() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO ppt_jobs (id, status, source_kind, filename, raw_text,"
|
||||
" scenario, minutes, style_id, pages_json, pptx_path, warnings_json,"
|
||||
" created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
job["id"], status,
|
||||
(job.get("source") or {}).get("kind"),
|
||||
(job.get("source") or {}).get("filename"),
|
||||
raw_text,
|
||||
job.get("scenario"), job.get("minutes"), job.get("style_id"),
|
||||
json.dumps(job.get("pages") or [], ensure_ascii=False),
|
||||
job.get("pptx_path"),
|
||||
json.dumps(job.get("warnings") or [], ensure_ascii=False),
|
||||
now, now,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def get_job(job_id: str) -> dict[str, Any] | None:
|
||||
with _connect() as conn:
|
||||
row = conn.execute("SELECT * FROM ppt_jobs WHERE id=?", (job_id,)).fetchone()
|
||||
return _row_to_job(row) if row else None
|
||||
|
||||
|
||||
def update_job(job_id: str, **fields: Any) -> dict[str, Any] | None:
|
||||
"""局部更新(省略的列不动;pages/warnings 传对象自动序列化)。"""
|
||||
sets: list[str] = []
|
||||
vals: list[Any] = []
|
||||
for key, value in fields.items():
|
||||
if key in _JSON_COLS:
|
||||
sets.append(f"{_JSON_COLS[key]}=?")
|
||||
vals.append(json.dumps(value, ensure_ascii=False) if value is not None else None)
|
||||
elif key in _SCALAR_COLS:
|
||||
if key == "status" and value not in _STATUS_VALUES:
|
||||
raise ValueError(f"非法 status: {value}")
|
||||
sets.append(f"{key}=?")
|
||||
vals.append(value)
|
||||
else:
|
||||
raise TypeError(f"update_job 不支持的字段: {key}")
|
||||
if not sets:
|
||||
return get_job(job_id)
|
||||
sets.append("updated_at=?")
|
||||
vals.extend([_now(), job_id])
|
||||
with _connect() as conn:
|
||||
cur = conn.execute(f"UPDATE ppt_jobs SET {', '.join(sets)} WHERE id=?", vals)
|
||||
if cur.rowcount == 0:
|
||||
return None
|
||||
return get_job(job_id)
|
||||
|
||||
|
||||
def delete_job(job_id: str) -> bool:
|
||||
with _connect() as conn:
|
||||
cur = conn.execute("DELETE FROM ppt_jobs WHERE id=?", (job_id,))
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
def get_raw_text(job_id: str) -> str:
|
||||
"""raw_text 只在 evidence 重验时直读(不进 API job dict,避免 GET 撑爆)。"""
|
||||
with _connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT raw_text FROM ppt_jobs WHERE id=?", (job_id,)
|
||||
).fetchone()
|
||||
return (row["raw_text"] or "") if row else ""
|
||||
@@ -0,0 +1,153 @@
|
||||
"""services/rebuttal_draft.py —— 5 类回复片段 Prompt + 反幻觉校验 + letter 拼装。
|
||||
|
||||
- part.type 枚举(决策 2,无 auto):new_data | reanalysis | location | clarification | rebuttal。
|
||||
- 反幻觉校验(补充 2 收窄版,只做负向证伪,不做泛化打分):
|
||||
* location:出现 "shows that / as shown" 但无 "added/新增" 迹证 → FAIL
|
||||
("S3 shows that..." 红 FAIL;"we have added ... in S3" 过)。
|
||||
* rebuttal:命中负向词表 we agree / we accept / we concede / you are right /
|
||||
we were wrong → FAIL;无立场词(maintain/disagree/respectfully/坚持/不同意…)
|
||||
→ FAIL;"respectfully maintain" 过。
|
||||
* 其他三类:Phase 1 仅查非空。
|
||||
- 校验前置(决策:校验C前置):draft 生成前先跑校验,FAIL 则不调 LLM。
|
||||
- letter 拼装:按 reviewer → comment 顺序,原文 + 草稿;无草稿留 "[待补充]"。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Callable
|
||||
|
||||
LLMCallable = Callable[..., Any]
|
||||
|
||||
PART_TYPES = ("new_data", "reanalysis", "location", "clarification", "rebuttal")
|
||||
|
||||
_TYPE_FRAGMENTS: dict[str, str] = {
|
||||
"new_data": (
|
||||
"你的回复属于【补充新数据】类:说明新增了哪些实验/数据(图/表编号、关键数值),"
|
||||
"并明确回应原审稿关切。不得虚构未提供的数据——只用 user_response 给出的事实。"
|
||||
),
|
||||
"reanalysis": (
|
||||
"你的回复属于【重新分析】类:说明重跑了哪些分析、方法/参数变化与新结论要点。"
|
||||
"不得虚构未提供的结果——只用 user_response 给出的事实。"
|
||||
),
|
||||
"location": (
|
||||
"你的回复属于【定位/指出位置】类:明确指出修改或证据在稿件中的位置"
|
||||
"(如 Section 3 / Fig. S2 / Methods 段落),与 user_response 给出的位置一致。"
|
||||
"不得虚构未提供的位置。"
|
||||
),
|
||||
"clarification": (
|
||||
"你的回复属于【澄清说明】类:针对误解给出准确、简洁的解释,"
|
||||
"只用 user_response 与原评论内容,不引入新主张。"
|
||||
),
|
||||
"rebuttal": (
|
||||
"你的回复属于【礼貌反驳】类:礼貌但明确地表达不同立场(maintain/disagree),"
|
||||
"给出理由(基于 user_response 与原文),不得虚构证据。"
|
||||
),
|
||||
}
|
||||
|
||||
_BASELINE = (
|
||||
"通用底线:只基于原评论与 user_response 起草;不得编造数据、图表编号、"
|
||||
"统计结果或已做修改;不确定处用占位([XX])而非猜测。"
|
||||
"输出 JSON:{\"draft_md\": \"英文正式回复正文(markdown,≤200词)\"}。只输出 JSON。"
|
||||
)
|
||||
|
||||
_STANCE = re.compile(
|
||||
r"\b(maintain|disagree|respectfully|cannot\s+agree|dispute|stand\s+by|"
|
||||
r"hold\s+to)\b|坚持|不同意|保留|异议",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_NEGATIVE = re.compile(
|
||||
r"\bwe\s+agree\b|\bwe\s+accept\b|\bwe\s+concede\b|\byou\s+are\s+right\b|"
|
||||
r"\bwe\s+were\s+wrong\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_LOC_SHOWS = re.compile(r"shows?\s+that|as\s+shown|表明", re.IGNORECASE)
|
||||
_LOC_ADDED = re.compile(r"\badded\b|新增|have\s+added|we\s+have\s+added", re.IGNORECASE)
|
||||
|
||||
|
||||
def validate_parts(parts: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""反幻觉校验 → {passed: bool, findings: [{part_index, code, message}]}。"""
|
||||
findings: list[dict[str, Any]] = []
|
||||
for i, p in enumerate(parts):
|
||||
ptype = str(p.get("type") or "")
|
||||
content = str(p.get("content") or "").strip()
|
||||
if not content:
|
||||
findings.append({"part_index": i, "code": "EMPTY_CONTENT",
|
||||
"message": "回复内容不能为空"})
|
||||
continue
|
||||
if ptype == "location":
|
||||
if _LOC_SHOWS.search(content) and not _LOC_ADDED.search(content):
|
||||
findings.append({
|
||||
"part_index": i, "code": "LOCATION_EVIDENCE_WEAK",
|
||||
"message": "location 类回复出现“shows that/如所示”但未给出新增位置;"
|
||||
"请写明“we have added … in S{n}”式定位",
|
||||
})
|
||||
elif ptype == "rebuttal":
|
||||
if _NEGATIVE.search(content):
|
||||
findings.append({
|
||||
"part_index": i, "code": "REBUTTAL_NEGATIVE",
|
||||
"message": "rebuttal 类回复出现让步词(we agree/accept/concede…);"
|
||||
"该类应礼貌但明确地表达不同立场",
|
||||
})
|
||||
elif not _STANCE.search(content):
|
||||
findings.append({
|
||||
"part_index": i, "code": "REBUTTAL_NO_STANCE",
|
||||
"message": "rebuttal 类回复缺少立场词(maintain/disagree/respectfully…)",
|
||||
})
|
||||
# new_data / reanalysis / clarification:Phase 1 仅非空
|
||||
return {"passed": not findings, "findings": findings}
|
||||
|
||||
|
||||
def _draft_system(parts: list[dict[str, Any]]) -> str:
|
||||
frags = [_TYPE_FRAGMENTS.get(str(p.get("type") or ""), "") for p in parts]
|
||||
frags = [f for f in frags if f]
|
||||
return " ".join(frags) + " " + _BASELINE if frags else _BASELINE
|
||||
|
||||
|
||||
async def generate_draft(llm: LLMCallable, comment: dict[str, Any]) -> str:
|
||||
"""按 comment.response.parts 起草 draft_md(调用方须先过 validate_parts)。"""
|
||||
parts = (comment.get("response") or {}).get("parts") or []
|
||||
user_lines = []
|
||||
for p in parts:
|
||||
hint = str(p.get("location_hint") or "").strip()
|
||||
line = f"- [{p.get('type')}] {p.get('content')}"
|
||||
if hint:
|
||||
line += f" (location_hint: {hint})"
|
||||
user_lines.append(line)
|
||||
user_msg = (
|
||||
f"原审稿意见:\n{comment.get('body') or comment.get('title') or ''}\n\n"
|
||||
f"作者拟回复要点(user_response):\n" + "\n".join(user_lines)
|
||||
)
|
||||
try:
|
||||
data = await llm(
|
||||
[{"role": "system", "content": _draft_system(parts)},
|
||||
{"role": "user", "content": user_msg}],
|
||||
temperature=0.2,
|
||||
)
|
||||
draft = str((data or {}).get("draft_md") or "").strip()
|
||||
return draft or "[draft generation returned empty]"
|
||||
except Exception: # noqa: BLE001
|
||||
return "[draft generation failed — 请重试]"
|
||||
|
||||
|
||||
def assemble_letter(job: dict[str, Any]) -> str:
|
||||
"""拼装整封 response letter markdown(Phase 1 纯 .md,docx 放 Phase 2)。"""
|
||||
lines: list[str] = ["# Response to Reviewers", ""]
|
||||
lines.append(f"_Job: {job.get('id')} · template: {job.get('template') or 'generic'}_")
|
||||
lines.append("")
|
||||
for r in job.get("reviewers") or []:
|
||||
lines.append(f"## {r.get('label') or 'Reviewer'}")
|
||||
lines.append("")
|
||||
for c in r.get("comments") or []:
|
||||
title = c.get("title") or c.get("id") or "Comment"
|
||||
lines.append(f"### {title}")
|
||||
lines.append("")
|
||||
lines.append("**Original comment:**")
|
||||
lines.append("")
|
||||
lines.append(str(c.get("body") or "").strip())
|
||||
lines.append("")
|
||||
lines.append("**Our response:**")
|
||||
lines.append("")
|
||||
draft = str(c.get("draft") or "").strip()
|
||||
lines.append(draft if draft else "[待补充]")
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,207 @@
|
||||
"""services/rebuttal_parse.py —— 审稿意见文本 → 结构化 Comment 树。
|
||||
|
||||
流程(保守:正则先切骨架,LLM 只做分类/拆子条,行数交叉核对不匹配则回退正则):
|
||||
1. 归一化换行;正则识别 Reviewer 段头(Reviewer 1 / 审稿人 2 / Review A 等),
|
||||
无段头 → 单 Reviewer(label=Reviewer)。
|
||||
2. 每个 Reviewer 段内按编号行切 comment 段("1." "2)" "(3)" 等);
|
||||
切不出编号 → 整段作 1 条。
|
||||
3. LLM complete_json 对每段做:{title, severity, sub_items:[{title,body,confidence}]}。
|
||||
- LLM 返回段数 != 提交段数 → 全量回退正则(title=首 60 字,severity 默认 major)。
|
||||
- sub_items ≥ 2 且最高 confidence ≥ 0.7 → 拆多条(共享 group_id);否则单条。
|
||||
4. cross_refs:正文里 "see comment 3 / 参见第 2 条" → 解析目标;解析不到
|
||||
target_id=null 保留 raw(决策:不猜)。
|
||||
|
||||
不做:auto 分类(已砍);severity 以外的任何 LLM 自由发挥字段。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Callable
|
||||
|
||||
# LLM 依赖注入(测试打桩):async (messages, temperature=..) -> dict
|
||||
LLMCallable = Callable[..., Any]
|
||||
|
||||
_REVIEWER_HEADER = re.compile(
|
||||
r"^(?:reviewers?\s*#?\s*([0-9]+|[A-Z])|审稿人\s*#?\s*([0-9A-Z]+)|review\s+([A-Z]))\s*[::.))]?",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_COMMENT_NUM = re.compile(
|
||||
r"(?:(?<=\n)|(?<=\.\s)|^)\s*(?:[#((]?\s*(\d{1,2})\s*[)).:、]|[((]\s*([ivx]{1,4}|[a-z])\s*[))])\s+",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_CROSS_REF = re.compile(
|
||||
r"(?:see|noted\s+in|refer(?:ring)?\s+to|per)\s+comment\s*#?\s*(\d+)|"
|
||||
r"(?:please\s+)?refer\s+to\s+comment\s*#?\s*(\d+)|"
|
||||
r"参见\s*第?\s*(\d+)\s*条|见\s*comment\s*#?\s*(\d+)|同上(?:一条)?评论",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
_SPLIT_CONFIDENCE = 0.7 # 决策 3:confidence ≥ 0.7 才拆
|
||||
|
||||
|
||||
def _norm(text: str) -> str:
|
||||
return text.replace("\r\n", "\n").replace("\r", "\n").strip()
|
||||
|
||||
|
||||
def split_reviewers(text: str) -> list[tuple[str, str]]:
|
||||
"""→ [(label, block_text)];无段头 → [(Reviewer, 全文)]。"""
|
||||
text = _norm(text)
|
||||
matches = list(_REVIEWER_HEADER.finditer(text))
|
||||
if not matches:
|
||||
return [("Reviewer", text)]
|
||||
out: list[tuple[str, str]] = []
|
||||
for i, m in enumerate(matches):
|
||||
raw = next(g for g in m.groups() if g is not None)
|
||||
label = raw.upper() if raw.isalpha() else f"Reviewer {raw}"
|
||||
start = m.end()
|
||||
end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
|
||||
block = text[start:end].strip()
|
||||
if block:
|
||||
out.append((label, block))
|
||||
if not out: # 段头全空块 → 兜底
|
||||
return [("Reviewer", text)]
|
||||
# 段头前的引言(无段头内容)丢弃——保守:不猜归属
|
||||
return out
|
||||
|
||||
|
||||
def split_comments(block: str) -> list[str]:
|
||||
"""段内按编号行切 comment 块;无编号 → [整段]。"""
|
||||
block = _norm(block)
|
||||
matches = list(_COMMENT_NUM.finditer(block))
|
||||
if len(matches) < 2: # 单个编号或无编号 → 整段一条
|
||||
return [block] if block else []
|
||||
out: list[str] = []
|
||||
for i, m in enumerate(matches):
|
||||
start = m.end()
|
||||
end = matches[i + 1].start() if i + 1 < len(matches) else len(block)
|
||||
chunk = block[start:end].strip()
|
||||
if chunk:
|
||||
out.append(chunk)
|
||||
return out or [block]
|
||||
|
||||
|
||||
def _fallback_segment(seg: str) -> dict[str, Any]:
|
||||
title = re.sub(r"\s+", " ", seg)[:60]
|
||||
return {
|
||||
"title": title,
|
||||
"severity": "major",
|
||||
"sub_items": [{"title": title, "body": seg, "confidence": 1.0}],
|
||||
}
|
||||
|
||||
|
||||
_SEG_SYSTEM = (
|
||||
"你是学术审稿意见解析助手。输入是一段审稿意见原文。输出 JSON:"
|
||||
'{"title": "≤12词标题", "severity": "major|minor|question", '
|
||||
'"sub_items": [{"title": "...", "body": "该子问题完整原文", "confidence": 0.0-1.0}]}。'
|
||||
"severity:major=要求补实验/补数据/重分析;minor=格式/表述小问题;"
|
||||
"question=仅提问、无需修改稿件。"
|
||||
"若该段含多个可独立回复的子问题则拆成多个 sub_items(confidence 给拆分把握度),"
|
||||
"否则 1 个 sub_items(confidence=1.0)。只输出 JSON。"
|
||||
)
|
||||
|
||||
|
||||
async def _ask_llm_segment(llm: LLMCallable, seg: str) -> dict[str, Any] | None:
|
||||
try:
|
||||
data = await llm(
|
||||
[{"role": "system", "content": _SEG_SYSTEM},
|
||||
{"role": "user", "content": seg}],
|
||||
temperature=0.0,
|
||||
)
|
||||
if not isinstance(data, dict) or not isinstance(data.get("sub_items"), list):
|
||||
return None
|
||||
sev = str(data.get("severity", "")).lower()
|
||||
title = str(data.get("title") or "").strip() or seg[:60]
|
||||
subs: list[dict[str, Any]] = []
|
||||
for s in data["sub_items"][:8]:
|
||||
if not isinstance(s, dict):
|
||||
continue
|
||||
body = str(s.get("body") or "").strip()
|
||||
if not body:
|
||||
continue
|
||||
try:
|
||||
conf = float(s.get("confidence", 1.0))
|
||||
except (TypeError, ValueError):
|
||||
conf = 0.0
|
||||
subs.append({
|
||||
"title": str(s.get("title") or "").strip() or title,
|
||||
"body": body,
|
||||
"confidence": max(0.0, min(1.0, conf)),
|
||||
})
|
||||
if not subs:
|
||||
return None
|
||||
return {"title": title,
|
||||
"severity": sev if sev in ("major", "minor", "question") else "major",
|
||||
"sub_items": subs}
|
||||
except Exception: # noqa: BLE001 — LLM 失败即回退正则,不猜测
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_cross_refs(body: str, ordinal_map: dict[int, str]) -> list[dict[str, Any]]:
|
||||
refs: list[dict[str, Any]] = []
|
||||
for m in _CROSS_REF.finditer(body):
|
||||
raw = m.group(0).strip()
|
||||
num = next((int(g) for g in m.groups() if g and g.isdigit()), None)
|
||||
if num is None and "同上" in raw:
|
||||
num = _prev_ordinal(ordinal_map, m.start()) # 同上 → 上一条,保守仅前一条
|
||||
target = ordinal_map.get(num) if num is not None else None
|
||||
refs.append({"raw": raw, "target_id": target})
|
||||
return refs
|
||||
|
||||
|
||||
def _prev_ordinal(ordinal_map: dict[int, str], _pos: int) -> int | None:
|
||||
# 简化:同上 → 已知最大编号(即前一条)
|
||||
return max(ordinal_map) if ordinal_map else None
|
||||
|
||||
|
||||
async def parse_review_text(text: str, llm: LLMCallable) -> list[dict[str, Any]]:
|
||||
"""→ reviewers=[{id, label, ordinal, comments:[Comment]}](不含 job 包装)。"""
|
||||
reviewers_out: list[dict[str, Any]] = []
|
||||
cid_seq = 0
|
||||
for r_ord, (label, block) in enumerate(split_reviewers(text), start=1):
|
||||
segs = split_comments(block)
|
||||
if not segs:
|
||||
continue
|
||||
comments: list[dict[str, Any]] = []
|
||||
ordinal_map: dict[int, str] = {}
|
||||
parsed_seg: list[dict[str, Any]] = []
|
||||
for seg in segs:
|
||||
llm_res = await _ask_llm_segment(llm, seg)
|
||||
# 交叉核对:LLM 无法给出结构 → 该段回退正则
|
||||
parsed_seg.append(llm_res if llm_res else _fallback_segment(seg))
|
||||
for seg_idx, (seg, p) in enumerate(zip(segs, parsed_seg), start=1):
|
||||
subs = p["sub_items"]
|
||||
split = len(subs) >= 2 and max(s["confidence"] for s in subs) >= _SPLIT_CONFIDENCE
|
||||
group_id = f"g{r_ord}-{seg_idx}"
|
||||
items = subs if split else [subs[0]]
|
||||
if not split and len(subs) > 1:
|
||||
# 未达阈值 → 多子问题并回一条(body 用整段原文)
|
||||
items = [{"title": p["title"], "body": seg, "confidence": 1.0}]
|
||||
for it in items:
|
||||
cid_seq += 1
|
||||
cid = f"c{cid_seq:04d}"
|
||||
# 保守:不拆时 body 保留该段原文(不采信 LLM 改写);拆时用子项 body
|
||||
body = it["body"] if split else seg
|
||||
ordinal_map[seg_idx] = cid
|
||||
comments.append({
|
||||
"id": cid,
|
||||
"group_id": group_id if split else None,
|
||||
"severity": p["severity"],
|
||||
"title": it["title"],
|
||||
"body": body,
|
||||
"sub_items": [s["title"] for s in subs] if split else [],
|
||||
"cross_refs": _resolve_cross_refs(body, ordinal_map),
|
||||
"response": {"parts": []},
|
||||
"draft": None,
|
||||
"validation": None,
|
||||
"human_confirmed": False,
|
||||
"scanner_downgraded": False,
|
||||
})
|
||||
# cross_refs 需全量 comment id 后再扫一遍(前面扫时 map 未齐)
|
||||
for c in comments:
|
||||
c["cross_refs"] = _resolve_cross_refs(c["body"], ordinal_map)
|
||||
if comments:
|
||||
reviewers_out.append({
|
||||
"id": f"r{r_ord}", "label": label, "ordinal": r_ord,
|
||||
"comments": comments,
|
||||
})
|
||||
return reviewers_out
|
||||
@@ -0,0 +1,162 @@
|
||||
"""services/rebuttal_store.py —— Rebuttal Phase 1 任务持久化(SQLite,自含连接)。
|
||||
|
||||
设计(2026-10-12 定稿):
|
||||
- 表 rebuttal_jobs 落在 review_service/review.db(与 journals 同库不同表);
|
||||
本模块自开短连接(低频 CRUD,WAL 下足够),不改既有 app/db.py。
|
||||
- Job/Comment 全量以 JSON 存 reviewers_json 列(Phase 1 规模 ≤ 数十条,
|
||||
整读整写最简单且可测;并发写靠 updated_at + 单进程串行)。
|
||||
- human_ack 不信前端置位:confirm 端点改完 comment 后由本模块重算——
|
||||
所有 comment.human_confirmed 全 true 才置 1(决策 4)。
|
||||
- 不做 TTL 自动清理(决策 5);DELETE 端点手动删。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..config import get_settings
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS rebuttal_jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL,
|
||||
source_kind TEXT,
|
||||
filename TEXT,
|
||||
raw_text TEXT,
|
||||
template TEXT DEFAULT 'generic',
|
||||
reviewers_json TEXT NOT NULL,
|
||||
letter_md TEXT,
|
||||
validation_json TEXT,
|
||||
human_ack INTEGER DEFAULT 0,
|
||||
created_at TEXT,
|
||||
updated_at TEXT
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def new_id(prefix: str = "") -> str:
|
||||
return prefix + uuid.uuid4().hex[:12]
|
||||
|
||||
|
||||
def _connect() -> sqlite3.Connection:
|
||||
db_path: Path = get_settings().db_path_abs
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(db_path), timeout=5)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
return conn
|
||||
|
||||
|
||||
def ensure_table() -> None:
|
||||
with _connect() as conn:
|
||||
conn.executescript(_SCHEMA)
|
||||
|
||||
|
||||
def _row_to_job(row: sqlite3.Row) -> dict[str, Any]:
|
||||
return {
|
||||
"id": row["id"],
|
||||
"status": row["status"],
|
||||
"source": {
|
||||
"kind": row["source_kind"] or "text",
|
||||
"filename": row["filename"],
|
||||
"raw_text": row["raw_text"] or "",
|
||||
},
|
||||
"template": row["template"] or "generic",
|
||||
"reviewers": json.loads(row["reviewers_json"] or "[]"),
|
||||
"letter_md": row["letter_md"],
|
||||
"validation": json.loads(row["validation_json"]) if row["validation_json"] else None,
|
||||
"human_ack": bool(row["human_ack"]),
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
}
|
||||
|
||||
|
||||
def insert_job(job: dict[str, Any]) -> None:
|
||||
"""写入完整 job(reviewers 等结构由调用方构造好)。"""
|
||||
now = job.get("created_at") or _now()
|
||||
with _connect() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO rebuttal_jobs (id, status, source_kind, filename, raw_text,"
|
||||
" template, reviewers_json, letter_md, validation_json, human_ack,"
|
||||
" created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
job["id"], job.get("status") or "ready",
|
||||
(job.get("source") or {}).get("kind"),
|
||||
(job.get("source") or {}).get("filename"),
|
||||
(job.get("source") or {}).get("raw_text"),
|
||||
job.get("template") or "generic",
|
||||
json.dumps(job.get("reviewers") or [], ensure_ascii=False),
|
||||
job.get("letter_md"),
|
||||
json.dumps(job.get("validation"), ensure_ascii=False)
|
||||
if job.get("validation") is not None else None,
|
||||
1 if job.get("human_ack") else 0,
|
||||
now, now,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def get_job(job_id: str) -> dict[str, Any] | None:
|
||||
with _connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM rebuttal_jobs WHERE id=?", (job_id,)
|
||||
).fetchone()
|
||||
return _row_to_job(row) if row else None
|
||||
|
||||
|
||||
def update_job(job_id: str, *, reviewers: list[dict[str, Any]] | None = None,
|
||||
letter_md: str | None = None, status: str | None = None) -> dict[str, Any] | None:
|
||||
"""局部更新;reviewers 变更时重算 human_ack(全勾才 true)。"""
|
||||
job = get_job(job_id)
|
||||
if job is None:
|
||||
return None
|
||||
if reviewers is not None:
|
||||
job["reviewers"] = reviewers
|
||||
if letter_md is not None:
|
||||
job["letter_md"] = letter_md
|
||||
if status is not None:
|
||||
job["status"] = status
|
||||
job["human_ack"] = all_comments_confirmed(job["reviewers"])
|
||||
job["updated_at"] = _now()
|
||||
with _connect() as conn:
|
||||
conn.execute(
|
||||
"UPDATE rebuttal_jobs SET reviewers_json=?, letter_md=?, status=?,"
|
||||
" human_ack=?, updated_at=? WHERE id=?",
|
||||
(
|
||||
json.dumps(job["reviewers"], ensure_ascii=False),
|
||||
job.get("letter_md"),
|
||||
job["status"],
|
||||
1 if job["human_ack"] else 0,
|
||||
job["updated_at"],
|
||||
job_id,
|
||||
),
|
||||
)
|
||||
return job
|
||||
|
||||
|
||||
def delete_job(job_id: str) -> bool:
|
||||
with _connect() as conn:
|
||||
cur = conn.execute("DELETE FROM rebuttal_jobs WHERE id=?", (job_id,))
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
def all_comments(reviewers: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
out: list[dict[str, Any]] = []
|
||||
for r in reviewers or []:
|
||||
out.extend(r.get("comments") or [])
|
||||
return out
|
||||
|
||||
|
||||
def all_comments_confirmed(reviewers: list[dict[str, Any]]) -> bool:
|
||||
comments = all_comments(reviewers)
|
||||
# 空 job(未解析出任何 comment)不置位 human_ack,避免"全勾"平凡真
|
||||
if not comments:
|
||||
return False
|
||||
return all(bool(c.get("human_confirmed")) for c in comments)
|
||||
@@ -0,0 +1,232 @@
|
||||
"""services/recommend_engine.py —— presubmit recommendation 规则引擎(纯函数,零 LLM)。
|
||||
|
||||
背景(2026-10-12 诊断):旧链路由 LLM 直接输出 recommendation,且 SYSTEM_PROMPT 配额
|
||||
强制 "major_weaknesses 3-8 条",导致 accept/minor_revision 几乎不可达(对照实验中
|
||||
强稿——BUSCO 96.8%/Hi-C 98.7%/accession 齐/UFBoot 1000——仍被凑出 6 条 major)。
|
||||
新链路:LLM 只如实列 weakness(major 带 hard_rule/fatal 标签),本引擎按可测规则
|
||||
计算 recommendation + rationale(模板拼装,可复现,不经 LLM)。
|
||||
|
||||
判定链(自上而下首条命中):
|
||||
R0 任一 major 带 fatal=true → reject(须人工复核)
|
||||
R1 H = 硬性 major 数 ≥ 1 → major_revision(与 tier 无关,不可协商)
|
||||
R2' tier==3 且 1≤S≤2 且 H==0 → minor_revision(入门刊仅 soft 可降)
|
||||
R2 S = soft major 数 ≥ 1 → major_revision(S≥5 标 severe,仍非 reject)
|
||||
R4 S == 0 且 minor ≤ accept_max_minor[tier] → accept
|
||||
R5 S == 0 → minor_revision
|
||||
其中 S = 总 major 数 − 硬性标签数(标签漏打时 S 变大,只会更严不会漏放)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
from ..config import get_settings
|
||||
|
||||
# 硬性规则标签(SYSTEM_PROMPT 与 coerce 共用白名单;未知标签一律置 None)
|
||||
HARD_RULE_TAGS = (
|
||||
"busco_missing", # 组装类论文缺 BUSCO 完整格式
|
||||
"hic_missing", # 宣称 chromosome-level/Hi-C 但缺挂载率
|
||||
"accession_missing", # 缺数据 Accession("will be deposited" 视为缺)
|
||||
"voucher_missing", # 野生材料缺凭证标本
|
||||
"abs_missing", # 缺 ABS/名古屋合规声明
|
||||
)
|
||||
|
||||
HARD_RULE_LABELS = {
|
||||
"busco_missing": "BUSCO 完整度缺失",
|
||||
"hic_missing": "Hi-C 挂载率缺失",
|
||||
"accession_missing": "数据 Accession 缺失",
|
||||
"voucher_missing": "凭证标本缺失",
|
||||
"abs_missing": "ABS/名古屋合规声明缺失",
|
||||
}
|
||||
|
||||
TIER_LABELS = {1: "顶刊", 2: "主流期刊", 3: "入门期刊"}
|
||||
|
||||
# tier 参数:accept 门槛(minor 上限);tier3 额外允许 ≤2 条 soft major 降为小修
|
||||
TIER_ACCEPT_MAX_MINOR = {1: 1, 2: 2, 3: 3}
|
||||
TIER3_SOFT_MAJOR_AS_MINOR_MAX = 2
|
||||
|
||||
# ---- tier 查找优先级(2026-10-12):override → featured → jif → 默认 ----
|
||||
# ① 用户高频投稿顶刊硬编码(精确名匹配,不受 jif/综述刊启发式影响;
|
||||
# "The " 前缀归一化,"Plant Cell"/"The Plant Cell" 互认)
|
||||
TIER1_OVERRIDES = frozenset(
|
||||
re.sub(r"^the\s+", "", name.casefold()) for name in (
|
||||
"Nature", "Nature Genetics", "Nature Plants", "Nature Biotechnology",
|
||||
"Nature Methods", "Cell", "Science", "Genome Biology", "Molecular Plant",
|
||||
"The Plant Cell", "Nature Communications", "Science Advances", "Cell Research",
|
||||
)
|
||||
)
|
||||
# 综述刊不因高 jif 升 tier1(Annual Review of… / Trends in… / …Reviews)
|
||||
_REVIEW_JOURNAL_RE = re.compile(r"reviews?\b|\btrends\b|annual\s+review", re.IGNORECASE)
|
||||
_JIF_TIER1_MIN = 15.0 # jif >= 15 → tier1;5 ≤ jif < 15 → tier2;<5/NULL → 默认 tier2
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _tier_by_name() -> dict[str, int]:
|
||||
"""journals.json 精选期刊 name(casefold) → review_tier(读文件一次,缓存)。"""
|
||||
path = get_settings().journals_seed_abs
|
||||
try:
|
||||
payload = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
out: dict[str, int] = {}
|
||||
for j in payload.get("journals") or []:
|
||||
if not isinstance(j, dict):
|
||||
continue
|
||||
name = str(j.get("name") or "").strip().casefold()
|
||||
tier = j.get("review_tier")
|
||||
if name and isinstance(tier, int) and tier in (1, 2, 3):
|
||||
out[name] = tier
|
||||
return out
|
||||
|
||||
|
||||
@lru_cache(maxsize=256)
|
||||
def _jif_by_name(name_bare_cf: str) -> float | None:
|
||||
"""journal_directory 按名查 jif(只读一列;表缺失/查不到 → None)。"""
|
||||
db = get_settings().db_path_abs
|
||||
if not Path(db).exists():
|
||||
return None
|
||||
try:
|
||||
conn = sqlite3.connect(f"file:{db}?mode=ro", uri=True, timeout=2)
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT jif FROM journal_directory WHERE lang='en' "
|
||||
"AND (lower(name)=? OR lower(name)=?) "
|
||||
"ORDER BY jif DESC LIMIT 1",
|
||||
(name_bare_cf, "the " + name_bare_cf),
|
||||
).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
except sqlite3.Error:
|
||||
return None
|
||||
if not row or row[0] is None:
|
||||
return None
|
||||
try:
|
||||
return float(row[0])
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def resolve_tier(target_journal: str) -> tuple[int, str]:
|
||||
"""目标期刊 → (tier, source)。source ∈ override/featured/jif/默认。
|
||||
|
||||
查找优先级:① 硬编码 13 本顶刊 ② journals.json review_tier
|
||||
③ journal_directory jif 启发式(综述刊除外)④ 默认 tier 2。
|
||||
"""
|
||||
raw = (target_journal or "").strip()
|
||||
if not raw:
|
||||
return 2, "默认"
|
||||
key = raw.casefold()
|
||||
bare = re.sub(r"^the\s+", "", key)
|
||||
if bare in TIER1_OVERRIDES:
|
||||
return 1, "override"
|
||||
featured = _tier_by_name()
|
||||
tier = featured.get(key, featured.get(bare))
|
||||
if isinstance(tier, int) and tier in (1, 2, 3):
|
||||
return tier, "featured"
|
||||
if not _REVIEW_JOURNAL_RE.search(bare):
|
||||
jif = _jif_by_name(bare)
|
||||
if jif is not None:
|
||||
return (1 if jif >= _JIF_TIER1_MIN else 2), "jif"
|
||||
return 2, "默认"
|
||||
|
||||
|
||||
def _item_field(item: Any, key: str) -> Any:
|
||||
if isinstance(item, dict):
|
||||
return item.get(key)
|
||||
return getattr(item, key, None)
|
||||
|
||||
|
||||
def decide_recommendation(
|
||||
majors: Iterable[Any],
|
||||
minors: Iterable[Any] = (),
|
||||
presentations: Iterable[Any] = (),
|
||||
tier: int = 2,
|
||||
) -> dict[str, Any]:
|
||||
"""规则引擎入口。majors 项可为 dict 或带 hard_rule/fatal 属性的对象。
|
||||
|
||||
返回 {"recommendation": ..., "rationale": {...}};
|
||||
rationale 含 rule/hard_hits/计数/tier/tier_label/explanation(模板拼装)。
|
||||
"""
|
||||
del presentations # 呈现类不参与判定(仅归档展示)
|
||||
majors = list(majors)
|
||||
minors = list(minors)
|
||||
if tier not in (1, 2, 3):
|
||||
tier = 2
|
||||
|
||||
hard_hits: list[str] = []
|
||||
fatal = False
|
||||
for m in majors:
|
||||
if bool(_item_field(m, "fatal")):
|
||||
fatal = True
|
||||
hr = _item_field(m, "hard_rule")
|
||||
if hr in HARD_RULE_LABELS:
|
||||
hard_hits.append(hr)
|
||||
|
||||
m_count = len(majors)
|
||||
h_count = len(hard_hits)
|
||||
s_count = m_count - h_count # soft major(标签漏打 → 计入 S,只会更严)
|
||||
minor_count = len(minors)
|
||||
tier_label = TIER_LABELS[tier]
|
||||
|
||||
# tier3 降级通道提示(对作者可见:同稿投不同档结果不同)
|
||||
tier_note = ""
|
||||
if h_count == 0 and 1 <= s_count <= TIER3_SOFT_MAJOR_AS_MINOR_MAX and tier != 3:
|
||||
tier_note = f"当前判定基于 tier {tier} 档;tier 3 入门刊此稿可能判 minor_revision。"
|
||||
|
||||
def _result(rec: str, rule: str, explanation: str) -> dict[str, Any]:
|
||||
return {
|
||||
"recommendation": rec,
|
||||
"rationale": {
|
||||
"rule": rule,
|
||||
"hard_hits": hard_hits,
|
||||
"major_count": m_count,
|
||||
"hard_count": h_count,
|
||||
"soft_count": s_count,
|
||||
"minor_count": minor_count,
|
||||
"tier": tier,
|
||||
"tier_label": tier_label,
|
||||
"explanation": explanation + tier_note,
|
||||
},
|
||||
}
|
||||
|
||||
# R0:fatal → reject(前端须配"建议人工复核")
|
||||
if fatal:
|
||||
return _result(
|
||||
"reject", "R0",
|
||||
"存在 fatal 级问题(数据可靠性/伦理,须人工复核)→ 建议拒稿。")
|
||||
|
||||
# R1:硬性 major 不可协商
|
||||
if h_count >= 1:
|
||||
labels = "、".join(HARD_RULE_LABELS.get(t, t) for t in dict.fromkeys(hard_hits))
|
||||
return _result(
|
||||
"major_revision", "R1",
|
||||
f"命中硬性缺陷({labels})→ 大修(不可协商);"
|
||||
f"另有大修级 {s_count} 条、小修级 {minor_count} 条。")
|
||||
|
||||
# R2':入门刊 1-2 条 soft major 可降为小修
|
||||
if tier == 3 and 1 <= s_count <= TIER3_SOFT_MAJOR_AS_MINOR_MAX:
|
||||
return _result(
|
||||
"minor_revision", "R2'",
|
||||
f"无硬性缺陷,大修级(soft){s_count} 条;"
|
||||
f"入门刊(tier 3)可将 ≤{TIER3_SOFT_MAJOR_AS_MINOR_MAX} 条 soft 降为小修 → 建议小修。")
|
||||
|
||||
# R2/R3:soft major ≥ 1(S≥5 标 severe)
|
||||
if s_count >= 1:
|
||||
sev = ",severe" if s_count >= 5 else ""
|
||||
return _result(
|
||||
"major_revision", "R2",
|
||||
f"大修级问题 {s_count} 条(均非硬性{sev})→ 建议大修。")
|
||||
|
||||
# R4/R5:无 major
|
||||
accept_max = TIER_ACCEPT_MAX_MINOR[tier]
|
||||
if minor_count <= accept_max:
|
||||
return _result(
|
||||
"accept", "R4",
|
||||
f"无大修级问题,小修级 {minor_count} 条(≤{accept_max})→ 建议接收。")
|
||||
return _result(
|
||||
"minor_revision", "R5",
|
||||
f"无大修级问题,小修级 {minor_count} 条 → 建议小修。")
|
||||
@@ -0,0 +1,553 @@
|
||||
r"""services/reformat.py —— P1 论文重排版(.tex 源码级规则变换,不编译)。
|
||||
|
||||
链路:resolve_rules(三级规则解析)→ reformat_tex(4 变换器 + 结构守卫)
|
||||
→ 路由层落盘 data/reformat_jobs/{job_id}.tex/.json。
|
||||
纯规则、零 LLM、零执行用户内容、不碰 docx/pdf(那两个只在 FormatPanel 审核)。
|
||||
|
||||
拍板(用户 4 点确认,2026-10-10):
|
||||
1. changes[] 每条带 rule_key ∈ {font_pt, margin, line_spacing, bib_style}、
|
||||
action ∈ {modified, inserted, warning}(P1 只产生 modified/warning,
|
||||
inserted 留给后续阶段);前端按 rule_key 着色、按 action 计数。
|
||||
2. 保守插入策略:命令/选项缺失一律【不改源码】→ changes 里 action=warning,
|
||||
reason 附"建议手动添加"的代码片段;warning 不计入"已修改"计数。
|
||||
(warnings[] 顶层数组只放结构级提示,如多处 \documentclass。)
|
||||
3. target_journal 必填;"default"= 通用规则(min_font_pt=11, min_margin_mm=20,
|
||||
min_line_spacing=1.5, refs_style=apa),前端下拉首项即通用。
|
||||
4. P1 仅 .tex;结构畸形宁拒不猜(PARSER_FAILED)。
|
||||
|
||||
铁律:
|
||||
① 只在违反规则时改(合规零触碰 → 重跑幂等,changes=[])
|
||||
② 只收紧不放松:字号/行距/边距只调大、绝不调小
|
||||
③ 每条修改必须能回答"为什么":reason 带原值/目标值/规则
|
||||
|
||||
规则解析链(resolve_rules):
|
||||
"default" → 内置通用规则
|
||||
journals.json id/name 命中 → data/format_cache/{id}.json ok → fetched
|
||||
→ 否则 format_rules 非 placeholder → official
|
||||
→ 否则通用规则 + 占位说明
|
||||
形如 "en:xxx"/"zh:xxx"(journal_directory)→ 只读查目录拿 name → 回链上一步
|
||||
其他字符串(期刊名) → 回链 journals.json → 未中 → 通用 + 未匹配说明
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# review_service/app/services/ → parents[2] = review_service/
|
||||
_ROOT = Path(__file__).resolve().parents[2]
|
||||
_SEED_PATH = _ROOT / "data" / "journals" / "journals.json"
|
||||
_CACHE_DIR = _ROOT / "data" / "format_cache"
|
||||
|
||||
_EPS = 1e-9
|
||||
|
||||
# 通用规则(拍板 3:与 journals.json format_rules_default 的 9/15/1.15 不同,
|
||||
# 这是重排版自己的"通用规则"口径,勿混用)
|
||||
RULES_DEFAULT: dict[str, Any] = {
|
||||
"rules_source": "default",
|
||||
"max_pages": 10,
|
||||
"min_font_pt": 11,
|
||||
"min_margin_mm": 20.0,
|
||||
"min_line_spacing": 1.5,
|
||||
"min_figures": 1,
|
||||
"max_fonts": 6,
|
||||
"refs_style": "apa",
|
||||
"anonymous": None,
|
||||
}
|
||||
|
||||
# 参考文献样式 → 经典 .bst(均为发行版基础件)。
|
||||
# 修正(2026-10-10):author_year 旧映射 plain 是错的——plain.bst 是编号制 [1];
|
||||
# author-year 制应映射 apalike.bst(Author, 2020)。apa 同理。
|
||||
REFS_BST_MAP: dict[str, str] = {
|
||||
"apa": "apalike",
|
||||
"author_year": "apalike",
|
||||
"numbered": "unsrt",
|
||||
"vancouver": "unsrt",
|
||||
"gb7714": "gb7714",
|
||||
}
|
||||
# biblatex 工作流的 style= 建议名(与 .bst 是两套体系)
|
||||
BIBLATEX_STYLE_MAP: dict[str, str] = {
|
||||
"apa": "apa",
|
||||
"author_year": "authoryear",
|
||||
"numbered": "numeric",
|
||||
"vancouver": "numeric",
|
||||
"gb7714": "gb7714",
|
||||
}
|
||||
|
||||
_DOCCLASS_RE = re.compile(r"(\\documentclass)\s*(\[[^\]]*\])?\s*\{([^}]*)\}")
|
||||
_PKG_RE = re.compile(r"\\(?:usepackage|RequirePackage)\s*(\[[^\]]*\])?\s*\{([^}]*)\}")
|
||||
_SPACING_RE = re.compile(r"\\(?:linespread|setstretch)\s*\{\s*(\d+(?:\.\d+)?)\s*\}")
|
||||
_BIBSTYLE_RE = re.compile(r"\\bibliographystyle\s*\{\s*([^}]+?)\s*\}")
|
||||
_PT_RE = re.compile(r"(\d+(?:\.\d+)?)\s*pt\b")
|
||||
_BEGIN_DOC_RE = re.compile(r"\\begin\s*\{\s*document\s*\}")
|
||||
_BIBLATEX_RE = re.compile(r"\\(?:printbibliography|addbibresource)")
|
||||
|
||||
_MM_FACTORS = {"mm": 1.0, "cm": 10.0, "in": 25.4, "inch": 25.4, "pt": 25.4 / 72.0}
|
||||
_SIDE_KEYS = ("left", "right", "top", "bottom")
|
||||
_MARGIN_ATTRS = ("margin", "hmargin", "vmargin", "left", "right", "top", "bottom",
|
||||
"inner", "outer")
|
||||
|
||||
|
||||
class ReformatError(Exception):
|
||||
"""结构畸形无法安全处理(code: PARSER_FAILED)。"""
|
||||
|
||||
def __init__(self, code: str, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
# ================================================================ 规则解析链
|
||||
|
||||
def _seed_entries() -> list[dict[str, Any]]:
|
||||
try:
|
||||
data = json.loads(_SEED_PATH.read_text("utf-8"))
|
||||
entries = data.get("journals")
|
||||
return [e for e in entries if isinstance(e, dict)] if isinstance(entries, list) else []
|
||||
except (OSError, ValueError):
|
||||
return []
|
||||
|
||||
|
||||
def _find_seed(text: str) -> dict[str, Any] | None:
|
||||
low = text.strip().casefold()
|
||||
if not low:
|
||||
return None
|
||||
for e in _seed_entries():
|
||||
if str(e.get("id", "")).casefold() == low or str(e.get("name", "")).casefold() == low:
|
||||
return e
|
||||
return None
|
||||
|
||||
|
||||
def _cache_rules(journal_id: str) -> dict[str, Any] | None:
|
||||
"""data/format_cache/{id}.json(抓取+LLM 抽取产物);ok=false/缺失 → None。"""
|
||||
try:
|
||||
payload = json.loads((_CACHE_DIR / f"{journal_id}.json").read_text("utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
if not isinstance(payload, dict) or not payload.get("ok"):
|
||||
return None
|
||||
rules = payload.get("format_rules")
|
||||
return rules if isinstance(rules, dict) and rules.get("rules_source") else None
|
||||
|
||||
|
||||
def _merged(rules: dict[str, Any]) -> dict[str, Any]:
|
||||
"""专属规则叠加在通用规则上(缺失键/显式 null 保留——fetched 的 null 是诚实未知)。"""
|
||||
return {**RULES_DEFAULT, **rules}
|
||||
|
||||
|
||||
async def _directory_name(journal_id: str) -> str | None:
|
||||
"""journal_directory id → name(只读;表未连/不存在 → None,绝不抛错)。"""
|
||||
try:
|
||||
from ..db import db # 惰性导入:resolver 可在无 lifespan 的测试中工作
|
||||
row = await db.fetchone(
|
||||
"SELECT name FROM journal_directory WHERE id = ?", (journal_id,)
|
||||
)
|
||||
return str(row["name"]) if row and row.get("name") else None
|
||||
except Exception: # noqa: BLE001 —— 查不到/未连接都按未匹配处理
|
||||
return None
|
||||
|
||||
|
||||
async def resolve_rules(target: str) -> dict[str, Any]:
|
||||
"""→ {journal_id, journal_name, rules, rules_source, rules_note}(永不失败)。"""
|
||||
target = (target or "").strip()
|
||||
|
||||
if target == "default":
|
||||
return {
|
||||
"journal_id": "default",
|
||||
"journal_name": "通用规则",
|
||||
"rules": dict(RULES_DEFAULT),
|
||||
"rules_source": "default",
|
||||
"rules_note": "通用规则(APA + 通用论文规范)",
|
||||
}
|
||||
|
||||
display_name = target
|
||||
entry = _find_seed(target)
|
||||
if entry is None and ":" in target:
|
||||
# journal_directory id(en:0028-0836 / zh:slug)→ 目录查 name → 再回链种子
|
||||
name = await _directory_name(target)
|
||||
if name:
|
||||
display_name = name
|
||||
entry = _find_seed(name)
|
||||
elif entry is None:
|
||||
display_name = target # 直接当期刊名回链
|
||||
|
||||
if entry is not None:
|
||||
jid = str(entry.get("id") or "")
|
||||
jname = str(entry.get("name") or display_name)
|
||||
cached = _cache_rules(jid)
|
||||
if cached:
|
||||
return {
|
||||
"journal_id": jid, "journal_name": jname, "rules": _merged(cached),
|
||||
"rules_source": "fetched",
|
||||
"rules_note": "该刊规则来自官网抓取 + LLM 抽取(data/format_cache,待人工核验)",
|
||||
}
|
||||
fr = entry.get("format_rules") if isinstance(entry.get("format_rules"), dict) else {}
|
||||
if fr and fr.get("rules_source") not in (None, "placeholder", "default"):
|
||||
return {
|
||||
"journal_id": jid, "journal_name": jname, "rules": _merged(fr),
|
||||
"rules_source": str(fr.get("rules_source")),
|
||||
"rules_note": "journals.json 期刊专属规则",
|
||||
}
|
||||
return {
|
||||
"journal_id": jid, "journal_name": jname, "rules": dict(RULES_DEFAULT),
|
||||
"rules_source": "default",
|
||||
"rules_note": f"「{jname}」在 journals.json 中仅有占位规则(placeholder),已用通用规则",
|
||||
}
|
||||
|
||||
return {
|
||||
"journal_id": None,
|
||||
"journal_name": display_name or target,
|
||||
"rules": dict(RULES_DEFAULT),
|
||||
"rules_source": "default",
|
||||
"rules_note": f"未匹配到「{display_name or target}」的专属格式规则,已用通用规则",
|
||||
}
|
||||
|
||||
|
||||
# ================================================================ 变换器工具
|
||||
|
||||
def _change(line_no: int, original: str, modified: str, reason: str,
|
||||
rule_key: str, action: str) -> dict[str, Any]:
|
||||
return {
|
||||
"line": line_no,
|
||||
"original": original.rstrip("\n"),
|
||||
"modified": modified.rstrip("\n"),
|
||||
"reason": reason,
|
||||
"rule_key": rule_key,
|
||||
"action": action,
|
||||
}
|
||||
|
||||
|
||||
def _warning_change(line_no: int, original: str, reason: str, rule_key: str) -> dict[str, Any]:
|
||||
return _change(line_no, original, "", reason, rule_key, "warning")
|
||||
|
||||
|
||||
def _parse_mm(raw: str) -> float | None:
|
||||
"""geometry 长度值 → mm;裸数字按 TeX 惯例 pt(同 format_check 先例);未知 → None。"""
|
||||
m = re.fullmatch(r"\s*(\d+(?:\.\d+)?)\s*([A-Za-z]*)\s*", raw or "")
|
||||
if not m:
|
||||
return None
|
||||
unit = (m.group(2) or "pt").lower()
|
||||
factor = _MM_FACTORS.get(unit)
|
||||
if factor is None:
|
||||
return None
|
||||
return float(m.group(1)) * factor
|
||||
|
||||
|
||||
def _fmt(v: float) -> str:
|
||||
return f"{v:g}"
|
||||
|
||||
|
||||
# ================================================================ ① 字号 font_pt
|
||||
|
||||
def _transform_font(lines: list[str], orig: list[str], rules: dict[str, Any],
|
||||
doc_idx: int) -> list[dict[str, Any]]:
|
||||
min_pt = rules.get("min_font_pt")
|
||||
if min_pt is None:
|
||||
return []
|
||||
min_pt = float(min_pt)
|
||||
line = lines[doc_idx]
|
||||
m = _DOCCLASS_RE.search(line)
|
||||
if not m: # 守卫已保证,理论不可达
|
||||
return []
|
||||
opts = m.group(2) # "[...]" 或 None
|
||||
cls = m.group(3).strip()
|
||||
opts_inner = opts[1:-1] if opts else ""
|
||||
pt_m = _PT_RE.search(opts_inner)
|
||||
|
||||
if pt_m is None:
|
||||
# 拍板 2:选项缺失不自动加 → warning + 建议片段
|
||||
if opts_inner:
|
||||
suggestion = f"\\documentclass[{opts_inner}, {_fmt(min_pt)}pt]{{{cls}}}"
|
||||
else:
|
||||
suggestion = f"\\documentclass[{_fmt(min_pt)}pt]{{{cls}}}"
|
||||
return [_warning_change(
|
||||
doc_idx + 1, orig[doc_idx],
|
||||
f"未检测到字号选项,无法核验正文字号(目标下限 {_fmt(min_pt)}pt),"
|
||||
f"建议手动声明:{suggestion}",
|
||||
"font_pt",
|
||||
)]
|
||||
|
||||
cur = float(pt_m.group(1))
|
||||
if cur >= min_pt - _EPS:
|
||||
return []
|
||||
target = next((s for s in (10, 11, 12) if s >= min_pt - _EPS), min_pt)
|
||||
new_inner = opts_inner[:pt_m.start(1)] + _fmt(target) + opts_inner[pt_m.end(1):]
|
||||
new_line = line[:m.start(2) + 1] + new_inner + line[m.start(2) + 1 + len(opts_inner):]
|
||||
lines[doc_idx] = new_line
|
||||
return [_change(
|
||||
doc_idx + 1, orig[doc_idx], new_line,
|
||||
f"正文字号 {_fmt(cur)}pt 低于目标下限 {_fmt(min_pt)}pt,改为 {_fmt(target)}pt",
|
||||
"font_pt", "modified",
|
||||
)]
|
||||
|
||||
|
||||
# ================================================================ ② 页边距 margin
|
||||
|
||||
def _find_geometry(lines: list[str]) -> tuple[int, re.Match] | None:
|
||||
for i, line in enumerate(lines):
|
||||
for m in _PKG_RE.finditer(line):
|
||||
pkgs = [p.strip().lower() for p in m.group(2).split(",")]
|
||||
if "geometry" in pkgs:
|
||||
return i, m
|
||||
return None
|
||||
|
||||
|
||||
def _patch_attr(line: str, key: str, new_val: str) -> str:
|
||||
"""geometry 选项里 key=old → key=new(首个匹配)。
|
||||
|
||||
值止于 `,` 或 `]`——绝不能吞掉 `]{geometry}` 尾巴(否则下一轮就找不到
|
||||
geometry 宏包,破坏幂等)。key 带 \\b 防 hmargin/margin 互撞。
|
||||
"""
|
||||
return re.sub(rf"(\b{key}\s*=\s*)([^,\]]+)", lambda m: m.group(1) + new_val,
|
||||
line, count=1)
|
||||
|
||||
|
||||
def _transform_margin(lines: list[str], orig: list[str], rules: dict[str, Any],
|
||||
doc_idx: int) -> list[dict[str, Any]]:
|
||||
min_mm = rules.get("min_margin_mm")
|
||||
if min_mm is None:
|
||||
return []
|
||||
min_mm = float(min_mm)
|
||||
out: list[dict[str, Any]] = []
|
||||
found = _find_geometry(lines)
|
||||
|
||||
if found is None:
|
||||
return [_warning_change(
|
||||
doc_idx + 1, orig[doc_idx],
|
||||
f"未检测到 geometry 宏包,无法核验页边距(目标下限 {_fmt(min_mm)}mm),"
|
||||
f"建议手动添加:\\usepackage[margin={_fmt(min_mm)}mm]{{geometry}}"
|
||||
f"(置于 \\documentclass 之后)",
|
||||
"margin",
|
||||
)]
|
||||
|
||||
gi, gm = found
|
||||
opts = gm.group(1)
|
||||
if opts is None:
|
||||
# \usepackage{geometry} 裸用 → 建议加选项(拍板 2 不自动改)
|
||||
return [_warning_change(
|
||||
gi + 1, orig[gi],
|
||||
f"geometry 未设置边距选项,无法核验页边距(目标下限 {_fmt(min_mm)}mm),"
|
||||
f"建议手动改为:\\usepackage[margin={_fmt(min_mm)}mm]{{geometry}}",
|
||||
"margin",
|
||||
)]
|
||||
opts_inner = opts[1:-1]
|
||||
attrs: dict[str, str] = {}
|
||||
for part in opts_inner.split(","):
|
||||
if "=" in part:
|
||||
k, _, v = part.partition("=")
|
||||
attrs[k.strip().lower()] = v.strip()
|
||||
|
||||
present = [k for k in _MARGIN_ATTRS if k in attrs]
|
||||
if not present:
|
||||
return [_warning_change(
|
||||
gi + 1, orig[gi],
|
||||
f"geometry 未设置边距相关选项(目标下限 {_fmt(min_mm)}mm),"
|
||||
f"建议在现有选项中添加:margin={_fmt(min_mm)}mm",
|
||||
"margin",
|
||||
)]
|
||||
|
||||
# 逐边解析提供者(side > h/vmargin > margin;inner/outer 单独按自身长度评)
|
||||
def provider_for(side: str) -> str | None:
|
||||
if side in attrs:
|
||||
return side
|
||||
if side in ("left", "right") and "hmargin" in attrs:
|
||||
return "hmargin"
|
||||
if side in ("top", "bottom") and "vmargin" in attrs:
|
||||
return "vmargin"
|
||||
if "margin" in attrs:
|
||||
return "margin"
|
||||
if side in ("left", "right") and "inner" in attrs:
|
||||
return "inner" # 见下:inner/outer 单独评估,此处不按边处理
|
||||
if side in ("left", "right") and "outer" in attrs:
|
||||
return "outer"
|
||||
return None
|
||||
|
||||
patch_keys: dict[str, str] = {} # attr → 新值(同键只抬一次)
|
||||
reasons: list[str] = []
|
||||
unresolved: list[str] = []
|
||||
|
||||
for side in _SIDE_KEYS:
|
||||
pkey = provider_for(side)
|
||||
if pkey in ("inner", "outer"):
|
||||
continue # 由下方 inner/outer 评估负责
|
||||
if pkey is None:
|
||||
unresolved.append(side)
|
||||
continue
|
||||
mm = _parse_mm(attrs.get(pkey, ""))
|
||||
if mm is None:
|
||||
unresolved.append(side)
|
||||
continue
|
||||
if mm < min_mm - _EPS and pkey not in patch_keys:
|
||||
patch_keys[pkey] = f"{_fmt(min_mm)}mm"
|
||||
reasons.append(f"页边距 {side}({_fmt(mm)}mm) 低于下限 {_fmt(min_mm)}mm → {_fmt(min_mm)}mm")
|
||||
|
||||
for key in ("inner", "outer"):
|
||||
if key in attrs:
|
||||
mm = _parse_mm(attrs[key])
|
||||
if mm is None:
|
||||
unresolved.append(key)
|
||||
elif mm < min_mm - _EPS and key not in patch_keys:
|
||||
patch_keys[key] = f"{_fmt(min_mm)}mm"
|
||||
reasons.append(f"页边距 {key}({_fmt(mm)}mm) 低于下限 {_fmt(min_mm)}mm → {_fmt(min_mm)}mm")
|
||||
|
||||
out: list[dict[str, Any]] = []
|
||||
if patch_keys:
|
||||
new_line = lines[gi]
|
||||
for key, val in patch_keys.items():
|
||||
new_line = _patch_attr(new_line, key, val)
|
||||
lines[gi] = new_line
|
||||
out.append(_change(
|
||||
gi + 1, orig[gi], new_line, ";".join(reasons), "margin", "modified",
|
||||
))
|
||||
|
||||
if unresolved:
|
||||
uniq = "、".join(dict.fromkeys(unresolved))
|
||||
out.append(_warning_change(
|
||||
gi + 1, orig[gi],
|
||||
f"geometry 未设置/无法解析 {uniq} 边(沿用类默认,无法核验),"
|
||||
f"建议显式声明:{uniq}={_fmt(min_mm)}mm",
|
||||
"margin",
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
# ================================================================ ③ 行距 line_spacing
|
||||
|
||||
def _transform_spacing(lines: list[str], orig: list[str], rules: dict[str, Any],
|
||||
doc_idx: int) -> list[dict[str, Any]]:
|
||||
min_ls = rules.get("min_line_spacing")
|
||||
if min_ls is None:
|
||||
return []
|
||||
min_ls = float(min_ls)
|
||||
|
||||
# 只看导言区(\begin{document} 之前);正文中的 \setstretch 是局部覆盖,不碰
|
||||
end = len(lines)
|
||||
for i, line in enumerate(lines):
|
||||
if _BEGIN_DOC_RE.search(line):
|
||||
end = i
|
||||
break
|
||||
records: list[tuple[int, re.Match]] = []
|
||||
for i in range(end):
|
||||
for m in _SPACING_RE.finditer(lines[i]):
|
||||
records.append((i, m))
|
||||
|
||||
if not records:
|
||||
return [_warning_change(
|
||||
doc_idx + 1, orig[doc_idx],
|
||||
f"未在导言区检测到 \\linespread 或 \\setstretch,无法核验行距"
|
||||
f"(目标下限 {_fmt(min_ls)}),建议手动添加到导言区:"
|
||||
f"\\linespread{{{_fmt(min_ls)}}}",
|
||||
"line_spacing",
|
||||
)]
|
||||
|
||||
i, m = records[-1] # 最后一次出现者生效(LaTeX 后设覆盖前设)
|
||||
cur = float(m.group(1))
|
||||
if cur >= min_ls - _EPS:
|
||||
return []
|
||||
new_line = lines[i][:m.start(1)] + _fmt(min_ls) + lines[i][m.end(1):]
|
||||
lines[i] = new_line
|
||||
return [_change(
|
||||
i + 1, orig[i], new_line,
|
||||
f"行距 {_fmt(cur)} 低于目标下限 {_fmt(min_ls)},改为 {_fmt(min_ls)}",
|
||||
"line_spacing", "modified",
|
||||
)]
|
||||
|
||||
|
||||
# ================================================================ ④ 参考文献 bib_style
|
||||
|
||||
def _transform_bib(lines: list[str], orig: list[str], rules: dict[str, Any],
|
||||
doc_idx: int) -> list[dict[str, Any]]:
|
||||
style = str(rules.get("refs_style") or "").strip().lower()
|
||||
if not style:
|
||||
# 规则未提供已确认的 refs_style(如 Frontiers 两种制式并存待核实):
|
||||
# 绝不猜测改写用户样式——保持原样 + warning 提示(拍板 2026-10-10)
|
||||
return [_warning_change(
|
||||
doc_idx + 1, orig[doc_idx],
|
||||
"该刊引用风格未确认,未改动 \\bibliographystyle;"
|
||||
"建议人工核实期刊投稿指南后再调整",
|
||||
"bib_style",
|
||||
)]
|
||||
|
||||
m = next((mm for line in lines if (mm := _BIBSTYLE_RE.search(line))), None)
|
||||
if m is not None:
|
||||
i = next(idx for idx, line in enumerate(lines) if _BIBSTYLE_RE.search(line))
|
||||
cur = m.group(1).strip()
|
||||
if style not in REFS_BST_MAP:
|
||||
return [_warning_change(
|
||||
i + 1, orig[i],
|
||||
f"目标参考文献样式 {style} 无对应标准 .bst,未替换"
|
||||
f"(当前 \\bibliographystyle{{{cur}}}),请按目标期刊模板手动选择样式",
|
||||
"bib_style",
|
||||
)]
|
||||
target = REFS_BST_MAP[style]
|
||||
if cur.casefold() == target.casefold():
|
||||
return []
|
||||
new_line = lines[i][:m.start(1)] + target + lines[i][m.end(1):]
|
||||
lines[i] = new_line
|
||||
hint = "(需 gbt7714 宏包)" if target == "gb7714" else ""
|
||||
return [_change(
|
||||
i + 1, orig[i], new_line,
|
||||
f"参考文献样式要求 {style}:\\bibliographystyle{{{cur}}} → {{{target}}}{hint}",
|
||||
"bib_style", "modified",
|
||||
)]
|
||||
|
||||
has_biblatex = any(_BIBLATEX_RE.search(line) for line in lines)
|
||||
if has_biblatex:
|
||||
suggest = BIBLATEX_STYLE_MAP.get(style, style)
|
||||
return [_warning_change(
|
||||
doc_idx + 1, orig[doc_idx],
|
||||
f"检测到 biblatex 工作流(未找到 \\bibliographystyle),"
|
||||
f"请在 \\printbibliography 选项中手动配置:style={suggest}(对应 {style})",
|
||||
"bib_style",
|
||||
)]
|
||||
|
||||
if style in REFS_BST_MAP:
|
||||
target = REFS_BST_MAP[style]
|
||||
return [_warning_change(
|
||||
doc_idx + 1, orig[doc_idx],
|
||||
f"未检测到 \\bibliographystyle,建议手动添加:\\bibliographystyle{{{target}}}"
|
||||
f"(对应 {style})",
|
||||
"bib_style",
|
||||
)]
|
||||
return [_warning_change(
|
||||
doc_idx + 1, orig[doc_idx],
|
||||
f"未检测到 \\bibliographystyle,目标样式 {style} 无标准 .bst 映射,"
|
||||
f"请按期刊模板手动配置",
|
||||
"bib_style",
|
||||
)]
|
||||
|
||||
|
||||
# ================================================================ 入口
|
||||
|
||||
def reformat_tex(source: str, rules: dict[str, Any]) -> dict[str, Any]:
|
||||
"""源码 + 规则 → {modified_tex, changes[], warnings[]}。
|
||||
|
||||
结构守卫:0 处 \\documentclass → ReformatError(PARSER_FAILED);
|
||||
>1 处 → 只处理第一处 + warnings 补 MULTI_DOCUMENTCLASS。
|
||||
"""
|
||||
lines = source.splitlines(keepends=True)
|
||||
doc_idxs = [i for i, line in enumerate(lines) if _DOCCLASS_RE.search(line)]
|
||||
if not doc_idxs:
|
||||
raise ReformatError(
|
||||
"PARSER_FAILED",
|
||||
"未找到 \\documentclass{...}:无法确定主文档"
|
||||
"(P1 不支持由 \\input 拆分的多文件工程,请上传完整单文件源码)",
|
||||
)
|
||||
warnings: list[dict[str, str]] = []
|
||||
if len(doc_idxs) > 1:
|
||||
warnings.append({
|
||||
"code": "MULTI_DOCUMENTCLASS",
|
||||
"message": f"检测到 {len(doc_idxs)} 处 \\documentclass,"
|
||||
f"仅处理第一处(第 {doc_idxs[0] + 1} 行)",
|
||||
})
|
||||
doc_idx = doc_idxs[0]
|
||||
orig = list(lines) # 纯净快照:所有 change.original 取自这里
|
||||
|
||||
changes: list[dict[str, Any]] = []
|
||||
changes += _transform_font(lines, orig, rules, doc_idx)
|
||||
changes += _transform_margin(lines, orig, rules, doc_idx)
|
||||
changes += _transform_spacing(lines, orig, rules, doc_idx)
|
||||
changes += _transform_bib(lines, orig, rules, doc_idx)
|
||||
changes.sort(key=lambda c: c["line"])
|
||||
|
||||
return {"modified_tex": "".join(lines), "changes": changes, "warnings": warnings}
|
||||
@@ -0,0 +1,223 @@
|
||||
"""services/self_review.py —— 植物基因组学专属自审稿(System Prompt + 报告模型)。
|
||||
|
||||
六大领域维度检查清单硬编码在 SYSTEM_PROMPT(发给 DeepSeek):
|
||||
1. 基因组组装质量(N50/L50/BUSCO 完整格式/Hi-C 挂载率;硬性缺项打 hard_rule 标签)
|
||||
2. 基因注释可靠性(MAKER/AUGUSTUS 流程、RNA-seq 证据、基因数与已知范围一致性)
|
||||
3. 比较基因组学与进化(Bootstrap/后验概率、OrthoFinder 等直系同源策略)
|
||||
4. 数据可用性合规(SRA/BioProject + 组装/GFF3 Accession,缺失即硬性 major)
|
||||
5. 统计与实验设计(GWAS/QTL 样本量·多重校正·群体结构;转录组生物学重复)
|
||||
6. 伦理与合规·植物特异性(凭证标本 voucher、ABS 惠益分享声明——均为硬性 major)
|
||||
|
||||
2026-10-12 判定链重构:LLM 不再输出 recommendation(旧配额制 "major 3-8 条" 导致
|
||||
accept/minor 几乎不可达,见 recommend_engine.py 模块 docstring);只如实列 weakness
|
||||
并打 hard_rule/fatal 标签,recommendation 由规则引擎计算。Major/Minor 每条必须给
|
||||
Evidence(论文章节位置或具体指标数值),由 coerce 宽容归一化(缺失补空串,不丢条目)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .recommend_engine import HARD_RULE_TAGS
|
||||
|
||||
SYSTEM_PROMPT = """你是一位植物基因组学领域资深审稿人,长期为 Plant Cell、Nature Plants、
|
||||
Genome Biology、Molecular Plant、Plant Journal 等期刊审稿。请对用户提供的论文正文做
|
||||
投稿前自审(pre-submission self review),严格按以下六个植物基因组学专属维度逐项检查,
|
||||
输出结构化审稿意见。所有条目使用中文撰写(专业术语、指标名、工具名保留英文)。
|
||||
|
||||
【维度一:基因组组装质量】必查指标:
|
||||
- contig N50 / scaffold N50 / L50(报了哪个要写明哪个)
|
||||
- BUSCO 完整度:必须按完整格式报告——例如
|
||||
"Complete 95.3% [S:93.1%, D:2.2%], Missing 4.7%, n=1614 (embryophyta_odb10)"
|
||||
(含 Complete/Single-copy/Duplicated/Missing 四项与数据集版本、n 值)
|
||||
- Hi-C 挂载率(anchoring rate,chromosome-level 比例)及挂载到染色体的 scaffold 比例
|
||||
BUSCO 完整度三级判定:
|
||||
- 完全未提 BUSCO → Major Weakness,hard_rule="busco_missing"
|
||||
- 提了 BUSCO 但只有总百分比(如 98.57%),缺 Single-copy/Duplicated/Missing
|
||||
分项或数据集版本(如 n=1614, embryophyta_odb10)→ 列 Minor(不打 hard_rule)
|
||||
- 完整格式(如 Complete 95.3% [S:93.1%, D:2.2%], Missing 4.7%, n=1614)→ 通过,不列
|
||||
Hi-C/挂载率报告识别(命中任一且给出数值即视为已报告,不得判缺):
|
||||
anchoring rate / anchored to chromosome(s) / % of sequences anchored /
|
||||
pseudo-chromosome(s) / chromosome-level 挂载比例。
|
||||
判定规则:若组装类论文只报 N50 而未报 BUSCO 且未报 Hi-C 挂载率,列为
|
||||
Major Weakness 并打对应 hard_rule 标签(BUSCO 缺失→"busco_missing";宣称
|
||||
chromosome-level/Hi-C 挂载但未报挂载率→"hic_missing")。同时检查组装是否为
|
||||
pseudo-chromosome 级别、污染评估(如 k-mer/coverage 检查)与 gap 比例。
|
||||
|
||||
【维度二:基因注释可靠性】检查:
|
||||
- 是否使用标准注释流程(MAKER、AUGUSTUS、BRAKER、GeMoMa、EVidenceModeler 等),参数与版本是否交代
|
||||
- 是否有转录组证据支持:RNA-seq(组织/时期/物种内或近缘种)比对支撑比例,或蛋白同源证据
|
||||
- 基因数量是否与该物种已知基因数范围一致(如水稻 ~37-40k、拟南芥 ~27k、玉米 ~39-45k、
|
||||
小麦 ~107k、大豆 ~46k;近缘种偏差过大须说明);有无转座元件基因误注释的排查
|
||||
- 注释质量评估:BRAKER/AED 分值、BUSCO 在蛋白集上的复核
|
||||
|
||||
【维度三:比较基因组学与进化】检查:
|
||||
- 系统发育树:每个节点建议标注 Bootstrap 支持率(建议 ≥1000 次重复)或贝叶斯后验概率。
|
||||
分诊:
|
||||
* 完全未做节点支持率分析(方法与正文均无任何声明)且支撑核心进化结论 → Major
|
||||
* 方法/正文声明已做 bootstrap 或后验概率,仅图中未标注数值 → presentation_format
|
||||
(图注补标即可,不需重跑分析)
|
||||
* 附带性质的树无支持率 → Minor
|
||||
- 直系同源基因鉴定策略是否说明清楚:OrthoFinder/OrthoMCL/RBX 等工具、版本、参数、
|
||||
聚类阈值(inflation/coverage)、single-copy 标记基因的筛选方式
|
||||
- 分子进化分析是否规范:dN/dS 检验方法与多重校正、共线性(synteny)分析参数、
|
||||
分化时间估算是否校准(化石/二次校准点)
|
||||
|
||||
【维度四:数据可用性合规——极其重要】必查(在 Data Availability 节或
|
||||
Methods 数据声明处核对,不因正文引用他人数据的 accession 而误判):
|
||||
- 原始测序数据是否上传公共数据库并给出 Accession 号。前缀清单:
|
||||
PRJNA/PRJEB(NCBI/ENA BioProject)、PRJCA/CRA(中国 NGDC/GSA)、
|
||||
SRP/ERP/DRP(SRA 项目)、SRR/ERR/DRR(run 级号)——命中任一即视为已有
|
||||
原始数据 accession
|
||||
- 基因组组装是否有 GenBank/ENA accession(GCA_/GCF_),GFF3 注释文件、
|
||||
蛋白/CDS 文件是否有可公开获取的位置(仓库 accession 或补充数据链接)
|
||||
- 转录组数据是否同样有 SRA accession
|
||||
判定规则:组装/注释类论文若缺失上述任何一环(含 "will be deposited" 等未给号的
|
||||
承诺式表述),列为 Major Weakness 并打 hard_rule 标签 "accession_missing"。
|
||||
|
||||
【维度五:统计与实验设计】检查:
|
||||
- GWAS/QTL 定位:样本量/群体大小是否充分;多重检验校正是否明确(Bonferroni/FDR/
|
||||
permutation 阈值/PCA 显著性水平);群体结构控制(PCA、亲缘关系矩阵 K、分层分析)
|
||||
是否交代;表型测量描述是否充分
|
||||
- 转录组分析:生物学重复数量(≥3 为底线)、差异分析工具(DESeq2/edgeR 等)与
|
||||
校正方法、批次效应处理、|log2FC| 与 padj 阈值
|
||||
- 其他统计:t 检验/ANOVA 是否满足前提、重复类型(生物学 vs 技术重复)写明与否
|
||||
|
||||
【维度六:伦理与合规(植物特异性)】检查:
|
||||
- 野生植物材料:是否提供凭证标本(voucher specimen)信息——标本馆编号、存放机构、
|
||||
采集地点(GPS)与采集许可;无凭证标本的野生资源取样 → Major Weakness 并打
|
||||
hard_rule 标签 "voucher_missing"
|
||||
- 遗传资源获取与惠益分享(ABS)合规性:是否声明符合《名古屋议定书》/本国遗传资源
|
||||
获取条例(如中国《生物安全法》/《种子法》相关条款)、是否说明材料来源与授权;
|
||||
涉及遗传资源但缺 ABS/名古屋合规声明 → Major Weakness 并打 hard_rule 标签
|
||||
"abs_missing"
|
||||
- 农业转基因/基因编辑材料的合规声明(若涉及)
|
||||
- fatal 标签:仅当你发现明确的数据造假或伦理违规证据时,将该条 "fatal" 置 true
|
||||
(数据内部矛盾、明显编造迹象等;无明确证据绝不可打标——该标签会触发拒稿建议)。
|
||||
|
||||
输出要求(必须输出单个 JSON 对象,不要输出其他任何内容;不要输出 recommendation
|
||||
字段——推荐结论由程序规则引擎根据你列出的问题与标签计算):
|
||||
{
|
||||
"summary": "总体评价(3-5 句,须点明论文类型与所适用的维度)",
|
||||
"major_weaknesses": [{"title": "...", "detail": "...", "evidence": "...", "suggestion": "...",
|
||||
"hard_rule": "busco_missing|hic_missing|accession_missing|voucher_missing|abs_missing|null",
|
||||
"fatal": false}],
|
||||
"minor_weaknesses": [{"title": "...", "detail": "...", "evidence": "...", "suggestion": "..."}],
|
||||
"presentation_format": [{"title": "...", "detail": "...", "evidence": "...", "suggestion": "..."}],
|
||||
"questions_for_authors": ["..."]
|
||||
}
|
||||
|
||||
【严重程度分级——据此分类,不设条数配额】
|
||||
- major_weaknesses:问题的解决需要补充实验/补充数据/补充分析流程/大幅改写论证结构,
|
||||
或影响论文核心结论的可信度与数据合规性。列在这里意味着"不解决就不能发表"。
|
||||
- minor_weaknesses:在现有数据与实验范围内即可解决(补一段说明、补一个基于已有数据的
|
||||
统计、澄清方法描述、局部重写)。列在这里意味着"改了更好,不改也能审"。
|
||||
- presentation_format:仅呈现层面的问题(图表样式、图注格式、参考文献体例、语法)。
|
||||
严禁把呈现问题升格为 major。
|
||||
|
||||
【负向清单——以下情况严禁列为 major_weaknesses,按对应类别归档】
|
||||
- 图表未标注统计量(bootstrap 数值/误差棒/显著性标记/P 值),但方法或正文已声明
|
||||
做过该分析 → presentation_format 或 minor_weaknesses
|
||||
- 方法描述不够详细,但所用方法是领域标准做法且论证逻辑完整 → minor_weaknesses
|
||||
- 阈值/参数未给出具体数值,但逻辑完整且结论不依赖精确数值 → minor_weaknesses
|
||||
- 引用格式/排版/图注体例/语法 → presentation_format
|
||||
- 标准流程的合理省略(如 IQ-TREE/DESeq2 等工具默认参数未逐一列出)→ 不列
|
||||
总纲:只有"需要补做实验/补数据/重跑分析/改核心论证"的问题才允许列为 major;
|
||||
其余一律 minor 或 presentation。与上文各维度冲突时以本节为准。
|
||||
|
||||
分级与条数规则:
|
||||
1. major_weaknesses、minor_weaknesses、presentation_format 均【不设下限,可以为 0 条】。
|
||||
论文确实完善时输出空数组是正确答案;严禁为凑数而降低分级标准或制造问题。
|
||||
2. **每条 major_weaknesses 的 evidence 字段必填**:必须引用论文的具体章节位置
|
||||
(如 "Methods 2.1"、"Table 2"、"Results 第 3 段"、"Data Availability 一句话未见")
|
||||
或具体指标数值(如 "仅报 scaffold N50=42.6 Mb,未提供 BUSCO 与 Hi-C 挂载率")。
|
||||
evidence 严禁编造:引用不到具体位置时,写明"正文未见相关报告"。只列有 evidence
|
||||
的问题:列不出具体章节位置或指标数值的问题,不要列。
|
||||
3. minor_weaknesses/presentation_format 的 evidence 尽量给出,无法定位时可留空字符串。
|
||||
4. 论文不涉及的维度(如纯 RNA-seq 论文不谈组装)在 summary 中说明并跳过,不硬凑条目。
|
||||
5. 宁可把拿不准严重程度的问题放 minor_weaknesses(由作者判断),不要放 major。
|
||||
|
||||
硬性规则(命中必须列 major 并打 hard_rule 标签,不论其他方面多完善;规则不可协商):
|
||||
A. 组装类论文缺 BUSCO 完整格式报告 → hard_rule="busco_missing"
|
||||
B. 宣称 chromosome-level/Hi-C 挂载但未报挂载率 → hard_rule="hic_missing"
|
||||
C. 缺数据 Accession(SRA/GenBank/组装/GFF3;"will be deposited" 等承诺式表述视为缺)
|
||||
→ hard_rule="accession_missing"
|
||||
D. 野生植物材料缺凭证标本(voucher)→ hard_rule="voucher_missing"
|
||||
E. 涉及遗传资源但缺 ABS/名古屋合规声明 → hard_rule="abs_missing"
|
||||
(核心进化结论所依赖的系统发育树若**完全未做**节点支持率分析 → 列 major(非硬性,
|
||||
不打标签);若方法/正文已声明分析但图中未标数值 → presentation/minor;附带性质
|
||||
的树无支持率 → minor。GWAS 缺多重校正等统计问题按同样原则判级:阈值/方法完全
|
||||
缺失且影响结论 → major;已声明校正逻辑仅缺细节 → minor。)
|
||||
|
||||
presentation_format 覆盖:图表规范(图注含单位/误差棒说明)、参考文献格式、
|
||||
字数与结构、英文语法(若投英文刊)。
|
||||
"""
|
||||
|
||||
|
||||
class WeaknessItem(BaseModel):
|
||||
title: str
|
||||
detail: str = ""
|
||||
evidence: str = ""
|
||||
suggestion: str = ""
|
||||
hard_rule: str | None = None # 仅 major 可带;白名单外置 None(见 coerce)
|
||||
fatal: bool = False # 明确造假/伦理证据才可 true(触发 R0 拒稿)
|
||||
# 扫描器冲突降级标记(程序层置位,LLM 输出不读/coerce 不透传 → 不可伪造):
|
||||
# True 表示该条原为 major,因关键词扫描器证伪其 hard_rule 缺项声明被降级至 minor
|
||||
scanner_downgraded: bool = False
|
||||
|
||||
|
||||
class SelfReviewReport(BaseModel):
|
||||
summary: str = ""
|
||||
major_weaknesses: list[WeaknessItem] = Field(default_factory=list)
|
||||
minor_weaknesses: list[WeaknessItem] = Field(default_factory=list)
|
||||
presentation_format: list[WeaknessItem] = Field(default_factory=list)
|
||||
questions_for_authors: list[str] = Field(default_factory=list)
|
||||
# recommendation 由 recommend_engine 计算后写入;LLM 输出的同名字段一律忽略
|
||||
recommendation: str = ""
|
||||
recommendation_rationale: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
def build_user_prompt(text: str, target_journal: str) -> str:
|
||||
header = f"目标期刊:{target_journal}\n\n" if target_journal else ""
|
||||
return f"{header}论文正文:\n\n{text}"
|
||||
|
||||
|
||||
def _coerce_items(raw: object) -> list[WeaknessItem]:
|
||||
items: list[WeaknessItem] = []
|
||||
if not isinstance(raw, list):
|
||||
return items
|
||||
for entry in raw:
|
||||
if isinstance(entry, str) and entry.strip():
|
||||
items.append(WeaknessItem(title=entry.strip()))
|
||||
elif isinstance(entry, dict):
|
||||
title = str(entry.get("title") or "").strip()
|
||||
detail = str(entry.get("detail") or "").strip()
|
||||
if not title and not detail:
|
||||
continue
|
||||
hard_rule = str(entry.get("hard_rule") or "").strip() or None
|
||||
if hard_rule not in HARD_RULE_TAGS:
|
||||
hard_rule = None # 白名单外的标签丢弃(防 LLM 编造标签操纵引擎)
|
||||
items.append(WeaknessItem(
|
||||
title=title or "(未命名)",
|
||||
detail=detail,
|
||||
evidence=str(entry.get("evidence") or "").strip(),
|
||||
suggestion=str(entry.get("suggestion") or "").strip(),
|
||||
hard_rule=hard_rule,
|
||||
fatal=bool(entry.get("fatal")),
|
||||
))
|
||||
return items
|
||||
|
||||
|
||||
def coerce_report(payload: dict) -> SelfReviewReport:
|
||||
"""宽容归一化 LLM 输出。不读 recommendation 字段(引擎唯一来源,防注入操纵)。"""
|
||||
questions = payload.get("questions_for_authors") or []
|
||||
if not isinstance(questions, list):
|
||||
questions = []
|
||||
return SelfReviewReport(
|
||||
summary=str(payload.get("summary") or "").strip(),
|
||||
major_weaknesses=_coerce_items(payload.get("major_weaknesses")),
|
||||
minor_weaknesses=_coerce_items(payload.get("minor_weaknesses")),
|
||||
presentation_format=_coerce_items(payload.get("presentation_format")),
|
||||
questions_for_authors=[str(q).strip() for q in questions if str(q).strip()],
|
||||
)
|
||||
@@ -0,0 +1,288 @@
|
||||
r"""services/zip_safety.py —— LaTeX 项目 zip 安全校验与解压(P4,纯函数模块)。
|
||||
|
||||
安全清单(每条攻击 → 防御位置,对应设计 §2):
|
||||
#1 路径穿越 ../../etc/passwd → sanitize_entry_name:拒绝 .. 段/绝对路径/盘符/空段
|
||||
(\ 已先归一为 /,Windows 反斜杠变体同规则)
|
||||
#2 反斜杠变体 ..\\..\\x → 同上(name.replace("\\\\", "/") 后再校验)
|
||||
#3 符号链接条目 → _is_symlink:external_attr 的 S_IFMT == S_IFLNK → 整包拒绝
|
||||
#4 可执行扩展名 .exe/.sh/.so → classify_entry:DENY_EXT → 整包拒绝(点名条目)
|
||||
#5 Unix 可执行位 0o111 → _has_exec_bit:普通文件带 x 位 → 整包拒绝(目录 0o755 例外)
|
||||
#6 无害非白名单 .docx/.log/.zip → classify_entry:skip(记 skipped[];.zip 跳过=天然防嵌套递归)
|
||||
#7 zip bomb 压缩比 → 预扫 file_size/compress_size > max_zip_ratio → ZIP_BOMB
|
||||
#8 zip bomb 总量 → 预扫声明累计 > max_extract_bytes → ZIP_BOMB;
|
||||
流式写盘按**实际字节**再计数(不信头部声明)超限即中止
|
||||
#9 zip bomb 单条目 → 预扫 file_size > max_entry_bytes → ZIP_BOMB;实写计数兜底
|
||||
#10 zip bomb 条目数 → 预扫 len(infolist) > max_zip_entries → ZIP_BOMB(先于一切写盘)
|
||||
#11 深嵌套/超长路径 → 路径深度 > max_path_depth 或总长 > max_path_len → ZIP_UNSAFE
|
||||
#12 损坏/加密 zip → zipfile.BadZipFile / RuntimeError → ZIP_INVALID
|
||||
#13 头部谎报小文件 → 实写计数兜底(#8);CRC 错 → ZIP_INVALID
|
||||
#14 恶意 .tex shell escape → 不在本模块范围(tectonic --untrusted 防护,P3a 已实证)
|
||||
#15 解压阻塞事件循环 → 调用方 asyncio.to_thread 包裹;本模块上限 200MB/2000 条封顶
|
||||
|
||||
设计要点:
|
||||
- 两段式:①纯预扫(所有结构性检查,全部通过才开始写)→ ②流式写盘(8KB 块 + 实字节计数)。
|
||||
任何异常 → 调用方 rmtree(dest),磁盘零残留。
|
||||
- 绝不 extractall;绝不保留可执行位(写盘一律 0o644)。
|
||||
- 目录条目只建不校验扩展名;SKIP_DIRS 整目录跳过(补充 4 提示对应)。
|
||||
|
||||
纯函数:输入 bytes/路径/Settings,输出 ExtractResult 或抛 ZipSafetyError,无 FastAPI/asyncio 依赖。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import re
|
||||
import stat
|
||||
import zipfile
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Literal
|
||||
|
||||
# 危险类型(deny → 整包拒绝)
|
||||
DENY_EXT = {
|
||||
".exe", ".com", ".bat", ".cmd", ".sh", ".bash", ".ps1", ".vbs",
|
||||
".dll", ".so", ".dylib", ".msi", ".jar", ".py", ".rb", ".pl", ".php", ".js",
|
||||
}
|
||||
# LaTeX 项目白名单(allow → 解压)
|
||||
ALLOW_EXT = {
|
||||
".tex", ".cls", ".sty", ".bst", ".bib", ".bbl", ".blg", ".def", ".clo",
|
||||
".fd", ".cfg", ".ist", ".dtx", ".ins",
|
||||
".png", ".jpg", ".jpeg", ".pdf", ".eps", ".svg",
|
||||
".txt", ".md", ".csv", ".dat", ".ttf", ".otf",
|
||||
}
|
||||
# 无扩展名白名单
|
||||
ALLOW_BARE = {"README", "LICENSE", "Makefile", ".gitignore"}
|
||||
# 整目录跳过(不进 skipped 计数之外的拒绝路径,直接 skip)
|
||||
SKIP_DIRS = {".git", "node_modules", "__MACOSX"}
|
||||
|
||||
|
||||
class ZipSafetyError(Exception):
|
||||
"""zip 不安全/超限/损坏。code ∈ {ZIP_UNSAFE, ZIP_BOMB, ZIP_INVALID}。"""
|
||||
|
||||
def __init__(self, code: str, message: str, entry: str = "") -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.message = message
|
||||
self.entry = entry
|
||||
|
||||
|
||||
@dataclass
|
||||
class SkippedEntry:
|
||||
name: str
|
||||
reason: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExtractResult:
|
||||
files: int = 0
|
||||
tex: int = 0
|
||||
images: int = 0
|
||||
cls: int = 0
|
||||
bib: int = 0
|
||||
other: int = 0
|
||||
bytes: int = 0
|
||||
skipped: list[SkippedEntry] = field(default_factory=list)
|
||||
skipped_count: int = 0
|
||||
|
||||
|
||||
_IMG_EXT = {".png", ".jpg", ".jpeg", ".eps", ".svg"}
|
||||
_BARE_RE = re.compile(r"^[A-Za-z]:") # 盘符
|
||||
_CTRL_RE = re.compile(r"[\x00-\x1f]") # 控制字符
|
||||
|
||||
|
||||
def sanitize_entry_name(raw: str) -> str:
|
||||
"""归一 + 校验条目名,返回 posix 相对路径;不安全 → ZIP_UNSAFE。
|
||||
|
||||
拒绝:绝对路径(/ 或 \\ 开头)、盘符(C:)、任一段 `..`、空段、NUL/控制字符。
|
||||
"""
|
||||
name = raw.replace("\\", "/")
|
||||
if not name or name in ("/", "."):
|
||||
raise ZipSafetyError("ZIP_UNSAFE", f"zip 含空条目名:{raw!r}", raw)
|
||||
if name.startswith("/") or _BARE_RE.match(name):
|
||||
raise ZipSafetyError("ZIP_UNSAFE", f"zip 含绝对路径条目:{raw}", raw)
|
||||
if _CTRL_RE.search(name):
|
||||
raise ZipSafetyError("ZIP_UNSAFE", f"zip 条目名含控制字符:{raw!r}", raw)
|
||||
segs = name.split("/")
|
||||
if any(s == ".." for s in segs):
|
||||
raise ZipSafetyError("ZIP_UNSAFE", f"zip 含路径穿越条目:{raw}", raw)
|
||||
# 空段(//)拒绝;尾段为 ""(目录 / 结尾)在除末位外的段上已由 split 覆盖
|
||||
if any(s == "" for s in segs[:-1]) or (segs and segs[0] == ""):
|
||||
raise ZipSafetyError("ZIP_UNSAFE", f"zip 含非法路径条目:{raw}", raw)
|
||||
depth = len([s for s in segs if s])
|
||||
if depth > 16:
|
||||
raise ZipSafetyError("ZIP_UNSAFE", f"zip 条目路径过深({depth} 层):{raw}", raw)
|
||||
if len(name) > 200:
|
||||
raise ZipSafetyError("ZIP_UNSAFE", f"zip 条目路径过长({len(name)} 字符):{raw}", raw)
|
||||
return "/".join(s for s in segs if s)
|
||||
|
||||
|
||||
def classify_entry(name: str, is_dir: bool) -> Literal["allow", "skip", "deny"]:
|
||||
"""条目分类:目录 → allow(只建);DENY_EXT → deny(整包拒绝);白名单 → allow;其余 → skip。"""
|
||||
if is_dir:
|
||||
return "allow"
|
||||
segs = name.replace("\\", "/").split("/")
|
||||
# 中间目录段命中 SKIP_DIRS → skip(.git/node_modules 整树跳过)
|
||||
if any(seg in SKIP_DIRS for seg in segs[:-1]):
|
||||
return "skip"
|
||||
p = PurePosixPath(name)
|
||||
ext = p.suffix.lower()
|
||||
# 多重扩展如 .synctex.gz:取最后一段即可(gz 不在白名单 → skip)
|
||||
if ext in DENY_EXT:
|
||||
return "deny"
|
||||
if ext in ALLOW_EXT:
|
||||
return "allow"
|
||||
if p.name.upper() in {b.upper() for b in ALLOW_BARE}:
|
||||
return "allow"
|
||||
return "skip"
|
||||
|
||||
|
||||
def _is_symlink(info: zipfile.ZipInfo) -> bool:
|
||||
"""external_attr 高 16 位为 Unix mode;S_IFMT == S_IFLNK(0xA000) → 符号链接。"""
|
||||
mode = (info.external_attr >> 16) & 0xFFFF
|
||||
return stat.S_IFMT(mode) == stat.S_IFLNK
|
||||
|
||||
|
||||
def _has_exec_bit(info: zipfile.ZipInfo) -> bool:
|
||||
mode = (info.external_attr >> 16) & 0xFFFF
|
||||
# Windows 造的 zip(external_attr 无 Unix mode)mode 可能为 0 → 无 x 位
|
||||
return bool(mode & 0o111)
|
||||
|
||||
|
||||
def _prescan(zf: zipfile.ZipFile, settings) -> list[tuple[zipfile.ZipInfo, str]]:
|
||||
"""预扫中央目录:条目数 → 路径 → 符号链接/可执行位 → 分类 → 声明大小/压缩比。
|
||||
|
||||
全部通过才允许写盘(两段式第一段)。
|
||||
"""
|
||||
infos = zf.infolist()
|
||||
if len(infos) > settings.max_zip_entries:
|
||||
raise ZipSafetyError(
|
||||
"ZIP_BOMB", f"zip 条目数超限({len(infos)} > {settings.max_zip_entries})")
|
||||
|
||||
plan: list[tuple[zipfile.ZipInfo, str]] = []
|
||||
declared_total = 0
|
||||
for info in infos:
|
||||
is_dir = info.is_dir()
|
||||
name = sanitize_entry_name(info.filename)
|
||||
if is_dir:
|
||||
continue
|
||||
# #3 符号链接 → 整包拒绝
|
||||
if _is_symlink(info):
|
||||
raise ZipSafetyError("ZIP_UNSAFE", f"zip 含符号链接条目:{info.filename}", info.filename)
|
||||
# #4/#6 分类
|
||||
kind = classify_entry(name, is_dir=False)
|
||||
if kind == "deny":
|
||||
raise ZipSafetyError("ZIP_UNSAFE", f"zip 含危险条目:{info.filename}", info.filename)
|
||||
if kind == "skip":
|
||||
continue
|
||||
# #5 可执行位 → 整包拒绝(仅普通文件;目录已 continue)
|
||||
if _has_exec_bit(info):
|
||||
raise ZipSafetyError(
|
||||
"ZIP_UNSAFE", f"zip 含可执行权限条目:{info.filename}", info.filename)
|
||||
# #9 单条目声明大小
|
||||
if info.file_size > settings.max_zip_entry_bytes:
|
||||
raise ZipSafetyError(
|
||||
"ZIP_BOMB",
|
||||
f"zip 条目过大:{info.filename}({info.file_size} 字节)", info.filename)
|
||||
# #7 压缩比(compress_size=0 且 file_size>0 视为异常)
|
||||
if info.file_size > 0:
|
||||
cs = info.compress_size
|
||||
if cs == 0 or info.file_size / cs > settings.max_zip_ratio:
|
||||
raise ZipSafetyError(
|
||||
"ZIP_BOMB",
|
||||
f"zip 条目压缩比异常(疑似 zip bomb):{info.filename}", info.filename)
|
||||
declared_total += info.file_size
|
||||
# #8 声明累计
|
||||
if declared_total > settings.max_extract_bytes:
|
||||
raise ZipSafetyError(
|
||||
"ZIP_BOMB",
|
||||
f"zip 解压总大小超限(声明 {declared_total} > "
|
||||
f"{settings.max_extract_bytes} 字节)")
|
||||
plan.append((info, name))
|
||||
return plan
|
||||
|
||||
|
||||
def safe_extract(zip_bytes: bytes, dest: Path, settings) -> ExtractResult:
|
||||
"""安全解压 zip_bytes → dest(新建目录)。
|
||||
|
||||
两段式:①预扫(结构性检查全过)②流式写盘(8KB 块 + 实字节计数)。
|
||||
任何 ZipSafetyError → 调用方负责 rmtree(dest) 清残留。
|
||||
"""
|
||||
try:
|
||||
zf = zipfile.ZipFile(io.BytesIO(zip_bytes))
|
||||
except zipfile.BadZipFile as exc:
|
||||
raise ZipSafetyError("ZIP_INVALID", f"zip 文件损坏或不是有效 zip:{exc}") from exc
|
||||
|
||||
result = ExtractResult()
|
||||
with zf:
|
||||
# ---- 第一段:纯预扫 ----
|
||||
plan = _prescan(zf, settings)
|
||||
|
||||
# ---- 第二段:流式写盘 ----
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
written_total = 0
|
||||
for info, rel in plan:
|
||||
target = dest / rel
|
||||
# #1 双保险:resolve 后必须仍在 dest 内
|
||||
resolved_parent = target.parent.resolve()
|
||||
if not str(resolved_parent).startswith(str(dest.resolve())):
|
||||
raise ZipSafetyError(
|
||||
"ZIP_UNSAFE", f"zip 条目逃逸目标目录:{info.filename}", info.filename)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
src = zf.open(info)
|
||||
except RuntimeError as exc: # 加密条目
|
||||
raise ZipSafetyError("ZIP_INVALID", f"zip 条目受密码保护:{info.filename}") from exc
|
||||
try:
|
||||
with src, open(target, "wb") as out:
|
||||
while True:
|
||||
chunk = src.read(8192)
|
||||
if not chunk:
|
||||
break
|
||||
# #8/#13 实写字节计数(不信头部声明)
|
||||
written_total += len(chunk)
|
||||
if written_total > settings.max_extract_bytes:
|
||||
raise ZipSafetyError(
|
||||
"ZIP_BOMB",
|
||||
f"zip 实际解压超限(> {settings.max_extract_bytes} 字节),已中止")
|
||||
out.write(chunk)
|
||||
except zipfile.BadZipFile as exc: # CRC 校验失败(#12/#13)
|
||||
raise ZipSafetyError(
|
||||
"ZIP_INVALID", f"zip 条目损坏(CRC 校验失败):{info.filename}",
|
||||
info.filename) from exc
|
||||
# #5 绝不保留可执行位
|
||||
target.chmod(0o644)
|
||||
result.files += 1
|
||||
_tally(result, rel)
|
||||
result.bytes = written_total # 实写累计(#8 计数器终值)
|
||||
|
||||
# 跳过统计(限 20 条 + 总数)
|
||||
skipped: list[SkippedEntry] = []
|
||||
skipped_count = 0
|
||||
for info in zf.infolist():
|
||||
if info.is_dir():
|
||||
continue
|
||||
try:
|
||||
name = sanitize_entry_name(info.filename)
|
||||
except ZipSafetyError:
|
||||
continue # 预扫已拒绝,不会到这里
|
||||
if classify_entry(name, is_dir=False) == "skip":
|
||||
skipped_count += 1
|
||||
if len(skipped) < 20:
|
||||
skipped.append(SkippedEntry(name=name, reason="unsupported"))
|
||||
result.skipped = skipped
|
||||
result.skipped_count = skipped_count
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _tally(result: ExtractResult, rel: str) -> None:
|
||||
ext = PurePosixPath(rel).suffix.lower()
|
||||
if ext == ".tex":
|
||||
result.tex += 1
|
||||
elif ext in _IMG_EXT or ext == ".png":
|
||||
result.images += 1
|
||||
elif ext == ".cls":
|
||||
result.cls += 1
|
||||
elif ext == ".bib":
|
||||
result.bib += 1
|
||||
else:
|
||||
result.other += 1
|
||||
@@ -0,0 +1,493 @@
|
||||
{
|
||||
"version": 2,
|
||||
"updated": "2026-10-10",
|
||||
"notes": "会议种子数据 v2。deadline_source=placeholder 表示日期为首版占位数据(按各会议公开周期窗口/历年规律填写,未经官方渠道逐条核验),接入官方数据源前须人工核验;严禁以 AI 推测日期替代核验。deadline_confidence 标注日期置信度:high=官方已公布或历年极稳定;medium=按历年规律区间推算;low=新一届尚未公告或历届波动大,待核验。ccf_rank 按 CCF 推荐目录(第七版,2026-03 发布)标注,非目录内会议为 null(含全部生物信息/植物类会议)。timezone_note 默认 AoE (UTC-12)。首版不做自动同步。",
|
||||
"conferences": [
|
||||
{
|
||||
"id": "naacl-2027",
|
||||
"name": "NAACL 2027",
|
||||
"full_name": "North American Chapter of the Association for Computational Linguistics",
|
||||
"domain_tags": ["AI/ML", "自然语言处理"],
|
||||
"application_tags": ["交叉学科"],
|
||||
"ccf_rank": "B",
|
||||
"deadline_abstract": null,
|
||||
"deadline_full_paper": "2026-10-12T23:59:59+00:00",
|
||||
"notification_date": null,
|
||||
"conference_date": "2027-06-01 ~ 2027-06-05",
|
||||
"location": "San Francisco, California, USA",
|
||||
"official_url": "https://www.naacl.org/",
|
||||
"timezone_note": "AoE (UTC-12)",
|
||||
"deadline_source": "placeholder",
|
||||
"last_verified": "2026-10-10",
|
||||
"deadline_confidence": "high"
|
||||
},
|
||||
{
|
||||
"id": "coling-2027",
|
||||
"name": "COLING 2027",
|
||||
"full_name": "International Conference on Computational Linguistics",
|
||||
"domain_tags": ["AI/ML", "自然语言处理"],
|
||||
"application_tags": ["交叉学科"],
|
||||
"ccf_rank": "B",
|
||||
"deadline_abstract": null,
|
||||
"deadline_full_paper": "2026-10-12T23:59:59+00:00",
|
||||
"notification_date": null,
|
||||
"conference_date": "2027-05-09 ~ 2027-05-14",
|
||||
"location": "Macau SAR, China",
|
||||
"official_url": null,
|
||||
"timezone_note": "AoE (UTC-12)",
|
||||
"deadline_source": "placeholder",
|
||||
"last_verified": "2026-10-10",
|
||||
"deadline_confidence": "medium"
|
||||
},
|
||||
{
|
||||
"id": "www-2027",
|
||||
"name": "WWW 2027",
|
||||
"full_name": "The Web Conference (ACM WWW)",
|
||||
"domain_tags": ["AI/ML", "数据挖掘"],
|
||||
"application_tags": ["交叉学科"],
|
||||
"ccf_rank": "A",
|
||||
"deadline_abstract": "2026-10-18T23:59:59+00:00",
|
||||
"deadline_full_paper": "2026-10-25T23:59:59+00:00",
|
||||
"notification_date": "2027-01-04",
|
||||
"conference_date": "2027-05-10 ~ 2027-05-14",
|
||||
"location": "Dublin, Ireland",
|
||||
"official_url": "https://www2027.thewebconf.org/",
|
||||
"timezone_note": "AoE (UTC-12)",
|
||||
"deadline_source": "placeholder",
|
||||
"last_verified": "2026-10-10",
|
||||
"deadline_confidence": "high"
|
||||
},
|
||||
{
|
||||
"id": "recomb-2027",
|
||||
"name": "RECOMB 2027",
|
||||
"full_name": "International Conference on Research in Computational Molecular Biology",
|
||||
"domain_tags": ["生物信息学"],
|
||||
"application_tags": ["交叉学科"],
|
||||
"ccf_rank": null,
|
||||
"deadline_abstract": "2026-11-06T23:59:59+00:00",
|
||||
"deadline_full_paper": "2026-11-13T23:59:59+00:00",
|
||||
"notification_date": "2027-02-01",
|
||||
"conference_date": "2027-04-25 ~ 2027-04-28",
|
||||
"location": "TBD(待官方公布)",
|
||||
"official_url": "https://recomb.org/",
|
||||
"timezone_note": "AoE (UTC-12)",
|
||||
"deadline_source": "placeholder",
|
||||
"last_verified": "2026-10-10",
|
||||
"deadline_confidence": "medium"
|
||||
},
|
||||
{
|
||||
"id": "cvpr-2027",
|
||||
"name": "CVPR 2027",
|
||||
"full_name": "IEEE/CVF Conference on Computer Vision and Pattern Recognition",
|
||||
"domain_tags": ["AI/ML", "计算机视觉"],
|
||||
"application_tags": ["交叉学科"],
|
||||
"ccf_rank": "A",
|
||||
"deadline_abstract": "2026-11-10T23:59:59+00:00",
|
||||
"deadline_full_paper": "2026-11-16T23:59:59+00:00",
|
||||
"notification_date": "2027-02-25",
|
||||
"conference_date": "2027-06-20 ~ 2027-06-25",
|
||||
"location": "Seattle, USA",
|
||||
"official_url": "https://cvpr.thecvf.com/",
|
||||
"timezone_note": "AoE (UTC-12)",
|
||||
"deadline_source": "placeholder",
|
||||
"last_verified": "2026-10-10",
|
||||
"deadline_confidence": "high"
|
||||
},
|
||||
{
|
||||
"id": "wsdm-2027",
|
||||
"name": "WSDM 2027",
|
||||
"full_name": "International Conference on Web Search and Data Mining",
|
||||
"domain_tags": ["AI/ML", "数据挖掘"],
|
||||
"application_tags": ["交叉学科"],
|
||||
"ccf_rank": "B",
|
||||
"deadline_abstract": null,
|
||||
"deadline_full_paper": "2026-11-17T23:59:59+00:00",
|
||||
"notification_date": null,
|
||||
"conference_date": "2027-02-15 ~ 2027-02-19",
|
||||
"location": "Hong Kong SAR, China",
|
||||
"official_url": "https://www.wsdm-conference.org/",
|
||||
"timezone_note": "AoE (UTC-12)",
|
||||
"deadline_source": "placeholder",
|
||||
"last_verified": "2026-10-10",
|
||||
"deadline_confidence": "high"
|
||||
},
|
||||
{
|
||||
"id": "pag-2027",
|
||||
"name": "PAG 2027",
|
||||
"full_name": "Plant & Animal Genome Conference",
|
||||
"domain_tags": ["植物/农学", "基因组学"],
|
||||
"application_tags": ["种业", "分子育种"],
|
||||
"ccf_rank": null,
|
||||
"deadline_abstract": "2026-11-30T23:59:59+00:00",
|
||||
"deadline_full_paper": "2026-12-15T23:59:59+00:00",
|
||||
"notification_date": "2027-01-05",
|
||||
"conference_date": "2027-01-08 ~ 2027-01-12",
|
||||
"location": "San Diego, USA",
|
||||
"official_url": "https://www.intlpag.org/",
|
||||
"timezone_note": "AoE (UTC-12)",
|
||||
"deadline_source": "placeholder",
|
||||
"last_verified": "2026-10-10",
|
||||
"deadline_confidence": "medium"
|
||||
},
|
||||
{
|
||||
"id": "acl-2027",
|
||||
"name": "ACL 2027",
|
||||
"full_name": "Annual Meeting of the Association for Computational Linguistics",
|
||||
"domain_tags": ["AI/ML", "自然语言处理"],
|
||||
"application_tags": ["交叉学科"],
|
||||
"ccf_rank": "A",
|
||||
"deadline_abstract": null,
|
||||
"deadline_full_paper": "2027-01-04T23:59:59+00:00",
|
||||
"notification_date": null,
|
||||
"conference_date": "2027-08-20 ~ 2027-08-22",
|
||||
"location": "Kyoto, Japan",
|
||||
"official_url": "https://2027.aclweb.org/",
|
||||
"timezone_note": "AoE (UTC-12)",
|
||||
"deadline_source": "placeholder",
|
||||
"last_verified": "2026-10-10",
|
||||
"deadline_confidence": "high"
|
||||
},
|
||||
{
|
||||
"id": "plantbio-2027",
|
||||
"name": "Plant Biology 2027",
|
||||
"full_name": "ASPB Annual Meeting — Plant Biology",
|
||||
"domain_tags": ["植物/农学", "分子生物学"],
|
||||
"application_tags": ["分子育种"],
|
||||
"ccf_rank": null,
|
||||
"deadline_abstract": "2027-01-08T23:59:59+00:00",
|
||||
"deadline_full_paper": "2027-01-08T23:59:59+00:00",
|
||||
"notification_date": null,
|
||||
"conference_date": "2027-07(待官方公布)",
|
||||
"location": "Albuquerque, New Mexico, USA",
|
||||
"official_url": "https://plantbiology.aspb.org/",
|
||||
"timezone_note": "AoE (UTC-12)",
|
||||
"deadline_source": "placeholder",
|
||||
"last_verified": "2026-10-10",
|
||||
"deadline_confidence": "high"
|
||||
},
|
||||
{
|
||||
"id": "ijcai-2027",
|
||||
"name": "IJCAI 2027",
|
||||
"full_name": "International Joint Conference on Artificial Intelligence",
|
||||
"domain_tags": ["AI/ML"],
|
||||
"application_tags": ["交叉学科"],
|
||||
"ccf_rank": "B",
|
||||
"deadline_abstract": "2027-01-11T23:59:59+00:00",
|
||||
"deadline_full_paper": "2027-01-18T23:59:59+00:00",
|
||||
"notification_date": null,
|
||||
"conference_date": "2027-08-07 ~ 2027-08-17",
|
||||
"location": "Kyoto, Japan / Hengqin, China",
|
||||
"official_url": "https://www.ijcai.org/",
|
||||
"timezone_note": "AoE (UTC-12)",
|
||||
"deadline_source": "placeholder",
|
||||
"last_verified": "2026-10-10",
|
||||
"deadline_confidence": "medium"
|
||||
},
|
||||
{
|
||||
"id": "sigir-2027",
|
||||
"name": "SIGIR 2027",
|
||||
"full_name": "ACM SIGIR Conference on Research and Development in Information Retrieval",
|
||||
"domain_tags": ["AI/ML", "信息检索"],
|
||||
"application_tags": ["交叉学科"],
|
||||
"ccf_rank": "A",
|
||||
"deadline_abstract": "2027-01-14T23:59:59+00:00",
|
||||
"deadline_full_paper": "2027-01-21T23:59:59+00:00",
|
||||
"notification_date": "2027-04-05",
|
||||
"conference_date": "2027-07-20 ~ 2027-07-24",
|
||||
"location": "San Jose, California, USA",
|
||||
"official_url": "https://sigir2027.org/",
|
||||
"timezone_note": "AoE (UTC-12)",
|
||||
"deadline_source": "placeholder",
|
||||
"last_verified": "2026-10-10",
|
||||
"deadline_confidence": "high"
|
||||
},
|
||||
{
|
||||
"id": "icml-2027",
|
||||
"name": "ICML 2027",
|
||||
"full_name": "International Conference on Machine Learning",
|
||||
"domain_tags": ["AI/ML", "深度学习"],
|
||||
"application_tags": ["交叉学科"],
|
||||
"ccf_rank": "A",
|
||||
"deadline_abstract": "2027-01-15T23:59:59+00:00",
|
||||
"deadline_full_paper": "2027-01-22T23:59:59+00:00",
|
||||
"notification_date": "2027-05-01",
|
||||
"conference_date": "2027-07-19 ~ 2027-07-25",
|
||||
"location": "TBD(待官方公布)",
|
||||
"official_url": "https://icml.cc/",
|
||||
"timezone_note": "AoE (UTC-12)",
|
||||
"deadline_source": "placeholder",
|
||||
"last_verified": "2026-10-10",
|
||||
"deadline_confidence": "medium"
|
||||
},
|
||||
{
|
||||
"id": "chil-2027",
|
||||
"name": "CHIL 2027",
|
||||
"full_name": "ACM Conference on Health, Inference, and Learning",
|
||||
"domain_tags": ["AI/ML", "健康医疗"],
|
||||
"application_tags": ["交叉学科"],
|
||||
"ccf_rank": null,
|
||||
"deadline_abstract": "2027-01-15T23:59:59+00:00",
|
||||
"deadline_full_paper": "2027-01-29T23:59:59+00:00",
|
||||
"notification_date": null,
|
||||
"conference_date": "2027-06(待官方公布)",
|
||||
"location": "TBD(待官方公布)",
|
||||
"official_url": "https://dl.acm.org/conference/chil",
|
||||
"timezone_note": "AoE (UTC-12)",
|
||||
"deadline_source": "placeholder",
|
||||
"last_verified": "2026-10-10",
|
||||
"deadline_confidence": "low"
|
||||
},
|
||||
{
|
||||
"id": "kdd-2027",
|
||||
"name": "KDD 2027 (Cycle 2)",
|
||||
"full_name": "ACM SIGKDD Conference on Knowledge Discovery and Data Mining",
|
||||
"domain_tags": ["AI/ML", "数据挖掘"],
|
||||
"application_tags": ["交叉学科"],
|
||||
"ccf_rank": "A",
|
||||
"deadline_abstract": "2027-01-31T23:59:59+00:00",
|
||||
"deadline_full_paper": "2027-02-07T23:59:59+00:00",
|
||||
"notification_date": null,
|
||||
"conference_date": "2027-08-01 ~ 2027-08-05",
|
||||
"location": "San Jose, California, USA",
|
||||
"official_url": "https://kdd2027.kdd.org/",
|
||||
"timezone_note": "AoE (UTC-12)",
|
||||
"deadline_source": "placeholder",
|
||||
"last_verified": "2026-10-10",
|
||||
"deadline_confidence": "medium"
|
||||
},
|
||||
{
|
||||
"id": "ismb-eccb-2027",
|
||||
"name": "ISMB/ECCB 2027",
|
||||
"full_name": "Intelligent Systems for Molecular Biology / European Conference on Computational Biology",
|
||||
"domain_tags": ["生物信息学"],
|
||||
"application_tags": ["交叉学科"],
|
||||
"ccf_rank": null,
|
||||
"deadline_abstract": "2027-02-12T23:59:59+00:00",
|
||||
"deadline_full_paper": "2027-02-19T23:59:59+00:00",
|
||||
"notification_date": null,
|
||||
"conference_date": "2027-07-18 ~ 2027-07-22",
|
||||
"location": "Copenhagen, Denmark",
|
||||
"official_url": "https://www.iscb.org/ismbeccb2027/home",
|
||||
"timezone_note": "AoE (UTC-12)",
|
||||
"deadline_source": "placeholder",
|
||||
"last_verified": "2026-10-10",
|
||||
"deadline_confidence": "medium"
|
||||
},
|
||||
{
|
||||
"id": "ipmbc-2027",
|
||||
"name": "IPMB 2027",
|
||||
"full_name": "International Congress on Plant Molecular Biology (14th)",
|
||||
"domain_tags": ["植物/农学", "分子生物学"],
|
||||
"application_tags": ["分子育种"],
|
||||
"ccf_rank": null,
|
||||
"deadline_abstract": "2027-02-15T23:59:59+00:00",
|
||||
"deadline_full_paper": "2027-02-22T23:59:59+00:00",
|
||||
"notification_date": null,
|
||||
"conference_date": "2027-07(待官方公布)",
|
||||
"location": "Wuhan, China",
|
||||
"official_url": "https://www.ipmb2027.org/",
|
||||
"timezone_note": "AoE (UTC-12)",
|
||||
"deadline_source": "placeholder",
|
||||
"last_verified": "2026-10-10",
|
||||
"deadline_confidence": "low"
|
||||
},
|
||||
{
|
||||
"id": "uai-2027",
|
||||
"name": "UAI 2027",
|
||||
"full_name": "Conference on Uncertainty in Artificial Intelligence",
|
||||
"domain_tags": ["AI/ML"],
|
||||
"application_tags": ["交叉学科"],
|
||||
"ccf_rank": "B",
|
||||
"deadline_abstract": null,
|
||||
"deadline_full_paper": "2027-02-24T23:59:59+00:00",
|
||||
"notification_date": null,
|
||||
"conference_date": "2027-08-16 ~ 2027-08-20",
|
||||
"location": "TBD(待官方公布)",
|
||||
"official_url": "https://www.auai.org/",
|
||||
"timezone_note": "AoE (UTC-12)",
|
||||
"deadline_source": "placeholder",
|
||||
"last_verified": "2026-10-10",
|
||||
"deadline_confidence": "medium"
|
||||
},
|
||||
{
|
||||
"id": "miccai-2027",
|
||||
"name": "MICCAI 2027",
|
||||
"full_name": "International Conference on Medical Image Computing and Computer-Assisted Intervention",
|
||||
"domain_tags": ["AI/ML", "医学影像"],
|
||||
"application_tags": ["交叉学科"],
|
||||
"ccf_rank": null,
|
||||
"deadline_abstract": "2027-02-11T23:59:59+00:00",
|
||||
"deadline_full_paper": "2027-02-25T23:59:59+00:00",
|
||||
"notification_date": null,
|
||||
"conference_date": "2027-09-26 ~ 2027-10-01",
|
||||
"location": "Auckland, New Zealand",
|
||||
"official_url": "https://miccai.org/",
|
||||
"timezone_note": "AoE (UTC-12)",
|
||||
"deadline_source": "placeholder",
|
||||
"last_verified": "2026-10-10",
|
||||
"deadline_confidence": "medium"
|
||||
},
|
||||
{
|
||||
"id": "iccv-2027",
|
||||
"name": "ICCV 2027",
|
||||
"full_name": "IEEE/CVF International Conference on Computer Vision",
|
||||
"domain_tags": ["AI/ML", "计算机视觉"],
|
||||
"application_tags": ["交叉学科"],
|
||||
"ccf_rank": "A",
|
||||
"deadline_abstract": "2027-02-26T23:59:59+00:00",
|
||||
"deadline_full_paper": "2027-03-05T23:59:59+00:00",
|
||||
"notification_date": null,
|
||||
"conference_date": "2027-10-02 ~ 2027-10-08",
|
||||
"location": "Hong Kong SAR, China",
|
||||
"official_url": null,
|
||||
"timezone_note": "AoE (UTC-12)",
|
||||
"deadline_source": "placeholder",
|
||||
"last_verified": "2026-10-10",
|
||||
"deadline_confidence": "medium"
|
||||
},
|
||||
{
|
||||
"id": "bosc-2027",
|
||||
"name": "BOSC 2027",
|
||||
"full_name": "Bioinformatics Open Source Conference",
|
||||
"domain_tags": ["生物信息学"],
|
||||
"application_tags": ["交叉学科"],
|
||||
"ccf_rank": null,
|
||||
"deadline_abstract": null,
|
||||
"deadline_full_paper": "2027-03-26T23:59:59+00:00",
|
||||
"notification_date": null,
|
||||
"conference_date": "2027-07-18 ~ 2027-07-22",
|
||||
"location": "Copenhagen, Denmark",
|
||||
"official_url": "https://openbioinformatics.org/",
|
||||
"timezone_note": "AoE (UTC-12)",
|
||||
"deadline_source": "placeholder",
|
||||
"last_verified": "2026-10-10",
|
||||
"deadline_confidence": "low"
|
||||
},
|
||||
{
|
||||
"id": "acm-bcb-2027",
|
||||
"name": "ACM-BCB 2027",
|
||||
"full_name": "ACM Conference on Bioinformatics, Computational Biology and Health Informatics",
|
||||
"domain_tags": ["生物信息学", "AI/ML"],
|
||||
"application_tags": ["交叉学科"],
|
||||
"ccf_rank": null,
|
||||
"deadline_abstract": "2027-04-16T23:59:59+00:00",
|
||||
"deadline_full_paper": "2027-04-23T23:59:59+00:00",
|
||||
"notification_date": null,
|
||||
"conference_date": "2027-08(待官方公布)",
|
||||
"location": "TBD(待官方公布)",
|
||||
"official_url": "https://acm-bcb.org/",
|
||||
"timezone_note": "AoE (UTC-12)",
|
||||
"deadline_source": "placeholder",
|
||||
"last_verified": "2026-10-10",
|
||||
"deadline_confidence": "medium"
|
||||
},
|
||||
{
|
||||
"id": "ieee-bibm-2027",
|
||||
"name": "IEEE BIBM 2027",
|
||||
"full_name": "IEEE International Conference on Bioinformatics and Bioengineering",
|
||||
"domain_tags": ["生物信息学"],
|
||||
"application_tags": ["交叉学科"],
|
||||
"ccf_rank": null,
|
||||
"deadline_abstract": "2027-05-07T23:59:59+00:00",
|
||||
"deadline_full_paper": "2027-05-14T23:59:59+00:00",
|
||||
"notification_date": null,
|
||||
"conference_date": "2027-11(待官方公布)",
|
||||
"location": "TBD(待官方公布)",
|
||||
"official_url": null,
|
||||
"timezone_note": "AoE (UTC-12)",
|
||||
"deadline_source": "placeholder",
|
||||
"last_verified": "2026-10-10",
|
||||
"deadline_confidence": "low"
|
||||
},
|
||||
{
|
||||
"id": "emnlp-2027",
|
||||
"name": "EMNLP 2027",
|
||||
"full_name": "Conference on Empirical Methods in Natural Language Processing",
|
||||
"domain_tags": ["AI/ML", "自然语言处理"],
|
||||
"application_tags": ["交叉学科"],
|
||||
"ccf_rank": "B",
|
||||
"deadline_abstract": "2027-05-14T23:59:59+00:00",
|
||||
"deadline_full_paper": "2027-05-21T23:59:59+00:00",
|
||||
"notification_date": null,
|
||||
"conference_date": "2027-11(待官方公布)",
|
||||
"location": "TBD(待官方公布)",
|
||||
"official_url": null,
|
||||
"timezone_note": "AoE (UTC-12)",
|
||||
"deadline_source": "placeholder",
|
||||
"last_verified": "2026-10-10",
|
||||
"deadline_confidence": "low"
|
||||
},
|
||||
{
|
||||
"id": "neurips-2027",
|
||||
"name": "NeurIPS 2027",
|
||||
"full_name": "Conference on Neural Information Processing Systems",
|
||||
"domain_tags": ["AI/ML", "深度学习"],
|
||||
"application_tags": ["交叉学科"],
|
||||
"ccf_rank": "A",
|
||||
"deadline_abstract": "2027-05-14T23:59:59+00:00",
|
||||
"deadline_full_paper": "2027-05-21T23:59:59+00:00",
|
||||
"notification_date": null,
|
||||
"conference_date": "2027-12(待官方公布)",
|
||||
"location": "TBD(待官方公布)",
|
||||
"official_url": "https://neurips.cc/",
|
||||
"timezone_note": "AoE (UTC-12)",
|
||||
"deadline_source": "placeholder",
|
||||
"last_verified": "2026-10-10",
|
||||
"deadline_confidence": "medium"
|
||||
},
|
||||
{
|
||||
"id": "psb-2028",
|
||||
"name": "PSB 2028",
|
||||
"full_name": "Pacific Symposium on Biocomputing",
|
||||
"domain_tags": ["生物信息学"],
|
||||
"application_tags": ["交叉学科"],
|
||||
"ccf_rank": null,
|
||||
"deadline_abstract": null,
|
||||
"deadline_full_paper": "2027-07-01T23:59:59+00:00",
|
||||
"notification_date": null,
|
||||
"conference_date": "2028-01(待官方公布)",
|
||||
"location": "TBD(待官方公布)",
|
||||
"official_url": "https://psb.stanford.edu/",
|
||||
"timezone_note": "AoE (UTC-12)",
|
||||
"deadline_source": "placeholder",
|
||||
"last_verified": "2026-10-10",
|
||||
"deadline_confidence": "low"
|
||||
},
|
||||
{
|
||||
"id": "pag-europe-2027",
|
||||
"name": "PAG Europe 2027",
|
||||
"full_name": "Plant & Animal Genome Conference Europe",
|
||||
"domain_tags": ["植物/农学", "基因组学"],
|
||||
"application_tags": ["种业", "分子育种"],
|
||||
"ccf_rank": null,
|
||||
"deadline_abstract": null,
|
||||
"deadline_full_paper": "2027-07-09T23:59:59+00:00",
|
||||
"notification_date": null,
|
||||
"conference_date": "2027-10(待官方公布)",
|
||||
"location": "TBD(待官方公布)",
|
||||
"official_url": null,
|
||||
"timezone_note": "AoE (UTC-12)",
|
||||
"deadline_source": "placeholder",
|
||||
"last_verified": "2026-10-10",
|
||||
"deadline_confidence": "low"
|
||||
},
|
||||
{
|
||||
"id": "aaai-2028",
|
||||
"name": "AAAI 2028",
|
||||
"full_name": "AAAI Conference on Artificial Intelligence",
|
||||
"domain_tags": ["AI/ML"],
|
||||
"application_tags": ["交叉学科"],
|
||||
"ccf_rank": "A",
|
||||
"deadline_abstract": "2027-07-20T23:59:59+00:00",
|
||||
"deadline_full_paper": "2027-07-27T23:59:59+00:00",
|
||||
"notification_date": null,
|
||||
"conference_date": "2028-02(待官方公布)",
|
||||
"location": "TBD(待官方公布)",
|
||||
"official_url": "https://aaai.org/",
|
||||
"timezone_note": "AoE (UTC-12)",
|
||||
"deadline_source": "placeholder",
|
||||
"last_verified": "2026-10-10",
|
||||
"deadline_confidence": "medium"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
journals directory 导入对账报告
|
||||
生成时间: 2026-10-10T15:37:11
|
||||
源文件: /home/xzq/5090/teamWorkspace/wjq/PlantAI-RAG-new-latest/PlantAI-rag-new/review_service/data/journals/source_v4.xlsx(193386 bytes)
|
||||
阶段: 完成
|
||||
|
||||
=== 计数 ===
|
||||
读取有效行: 国际 779 / 中文 277 / 合计 1056
|
||||
成功: 1056 跳过(名称空): 0 失败: 0
|
||||
写入后 count(*): en=779 zh=277 total=1056
|
||||
期望: en=779 zh=277 total=1056 → ✓ 相符
|
||||
|
||||
=== 分区分布(国际)===
|
||||
1区 233 / 2区 214 / 3区 181 / 4区 134 / 空白 17
|
||||
分区空白(→null 无note): 17
|
||||
分区未检得(→null + note): 0
|
||||
|
||||
=== JIF 缺失 13 条(jif=null + note=核对状态)===
|
||||
- eLife [2025 JIF未检得/未给值]
|
||||
- Heliyon [2025 JIF未检得/未给值]
|
||||
- Australian Journal of Crop Science [2025 JIF未检得/未给值]
|
||||
- International Journal of Agriculture and Biology [2025 JIF未检得/未给值]
|
||||
- Journal of Agriculture and Rural Development in the Tropics and Subtropics [2025 JIF未检得/未给值]
|
||||
- Fruits [2025 JIF未检得/未给值]
|
||||
- RANGELAND JOURNAL [2025 JIF未检得/未给值]
|
||||
- Science of The Total Environment [2025 JIF未检得/未给值]
|
||||
- Chemosphere [2025 JIF未检得/未给值]
|
||||
- Environmental Science and Pollution Research [2025 JIF未检得/未给值]
|
||||
- Bioengineered [2025 JIF未检得/未给值]
|
||||
- Cell Biomaterials [2025 JIF未检得/未给值]
|
||||
- National Science Open [2025 JIF未检得/未给值]
|
||||
|
||||
=== 中文复合IF 缺失 0 条 ===
|
||||
|
||||
=== 分区未检得 期刊名 ===
|
||||
(无——源表以空白表示未检得,见上方'分区空白')
|
||||
|
||||
=== 重复 id 冲突(应为空)===
|
||||
(无)
|
||||
|
||||
=== id 策略 ===
|
||||
ISSN 型: 692 条 slug 型: 364 条
|
||||
|
||||
=== 来源超链接 ===
|
||||
国际 JIF来源: 779/779 分区来源: 762/779 中文数据来源: 277/277
|
||||
|
||||
=== 结构迁移(ALTER TABLE,幂等)===
|
||||
本次加列: official_check_status, jcr_release_year
|
||||
已存在跳过: official_url
|
||||
|
||||
=== 官网映射(决策:official_source 不入库)===
|
||||
识别列: 官网网址
|
||||
official_url 填充: 1053/1056 official_check_status 填充: 1056/1056
|
||||
jcr_release_year 填充: 779(仅国际=2026;中文表无此列→null)
|
||||
官网缺失 3 条(official_url=null,前端显示「—」不生成链接):
|
||||
- [zh] 中国食用菌
|
||||
- [zh] 水土保持应用技术
|
||||
- [zh] 粮食与饲料工业
|
||||
|
||||
=== 幂等性验证 ===
|
||||
第二次 upsert 后 count 不变: ✓ 通过
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
{
|
||||
"version": 1,
|
||||
"updated": "2026-10-10",
|
||||
"notes": "植物基因组学领域种子期刊。acceptance_rate 无公开权威数据一律为 null(不编造);impact_factor / review_time_weeks 为公开 JCR/期刊官网常识近似值,以 last_updated 标注刷新时间,接入真实数据源前需人工核验。scope_desc 为英文范围描述,供 vstore 向量匹配使用。 format_rules_default 为通用默认规则(rules_source=default);精选 5 本 format_rules 已于 2026-10-10 按各刊官网投稿指南人工核验填入(rules_source=official,附 source_url / last_verified / notes),官网未规定的字段一律 null 不猜测。 新增标签字段(不进 SQLite,接口按 id 从本文件合并):scope_tags / application_tags / cross_domain 按 scope_desc 归类的标签;cas_partition / jcr_quartile 为占位(partition_source=placeholder),核实不确定的一律 null、不猜测,接入真实分区数据前需人工核验。 精选列表已更换为本环境可访问官网的 5 本(Genome Biology / BMC Plant Biology / Molecular Plant / Frontiers in Plant Science / Rice);原 OUP/Wiley/Springer 4 本(The Plant Cell / The Plant Journal / Theoretical and Applied Genetics / The Plant Genome)移出精选,仍保留在 journal_directory 1056 条全量目录中。 [2026-10-12] 5 本精选期刊新增 review_tier(1=顶刊/2=主流,供 presubmit recommendation 分档判定;匹配失败默认 tier 2)。",
|
||||
"format_rules_default": {
|
||||
"rules_source": "default",
|
||||
"max_pages": 10,
|
||||
"min_font_pt": 9,
|
||||
"min_margin_mm": 15,
|
||||
"min_line_spacing": 1.15,
|
||||
"min_figures": 1,
|
||||
"max_fonts": 6,
|
||||
"refs_style": "apa"
|
||||
},
|
||||
"journals": [
|
||||
{
|
||||
"id": "genome-biology",
|
||||
"name": "Genome Biology",
|
||||
"scope_desc": "High-impact open-access journal for genome-scale research across all organisms, including genome assembly and annotation, comparative and evolutionary genomics, computational methods, single-cell and multi-omics studies, and reproducible bioinformatics.",
|
||||
"impact_factor": 10.1,
|
||||
"review_time_weeks": 4.0,
|
||||
"acceptance_rate": null,
|
||||
"submission_deadline": null,
|
||||
"official_url": "https://genomebiology.biomedcentral.com",
|
||||
"last_updated": "2026-10-09",
|
||||
"scope_tags": ["基因组学", "生物信息学", "多组学", "计算生物学"],
|
||||
"application_tags": ["基础研究", "算法与方法"],
|
||||
"cas_partition": "1区",
|
||||
"jcr_quartile": "Q1",
|
||||
"review_tier": 1,
|
||||
"partition_source": "placeholder",
|
||||
"cross_domain": ["进化生物学", "单细胞组学"],
|
||||
"format_rules": {
|
||||
"max_pages": null,
|
||||
"min_font_pt": null,
|
||||
"min_margin_mm": null,
|
||||
"min_line_spacing": 2.0,
|
||||
"max_fonts": null,
|
||||
"min_figures": null,
|
||||
"refs_style": "vancouver",
|
||||
"anonymous": false,
|
||||
"rules_source": "official",
|
||||
"source_url": "https://genomebiology.biomedcentral.com/submission-guidelines",
|
||||
"last_verified": "2026-10-10",
|
||||
"notes": "官网要求双倍行距('Use double-line spacing')、行页码编号;参考文献 'Journal abbreviations follow Index Medicus/MEDLINE'(顺序编号制→vancouver);BMC 出版社 policy 页明确 single-anonymous(审稿人知道作者身份,作者无需匿名);字号/边距/页数上限该页未规定。BMC editorial policy 辅助来源:https://www.biomedcentral.com/getpublished/editorial-policies"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "bmc-plant-biology",
|
||||
"name": "BMC Plant Biology",
|
||||
"scope_desc": "Open-access journal covering all aspects of plant biology, from molecular and cellular studies to the physiology and ecology of whole plants and communities, including plant-microbe interactions, biotechnology and applied plant science.",
|
||||
"impact_factor": null,
|
||||
"review_time_weeks": null,
|
||||
"acceptance_rate": null,
|
||||
"submission_deadline": null,
|
||||
"official_url": "https://bmcplantbiol.biomedcentral.com",
|
||||
"last_updated": "2026-10-10",
|
||||
"scope_tags": ["植物生物学", "分子生物学", "逆境生物学", "植物-微生物互作"],
|
||||
"application_tags": ["基础研究"],
|
||||
"cas_partition": null,
|
||||
"jcr_quartile": null,
|
||||
"review_tier": 2,
|
||||
"partition_source": "placeholder",
|
||||
"cross_domain": ["植物生物技术"],
|
||||
"format_rules": {
|
||||
"max_pages": null,
|
||||
"min_font_pt": null,
|
||||
"min_margin_mm": null,
|
||||
"min_line_spacing": 2.0,
|
||||
"max_fonts": null,
|
||||
"min_figures": null,
|
||||
"refs_style": "vancouver",
|
||||
"anonymous": false,
|
||||
"rules_source": "official",
|
||||
"source_url": "https://bmcplantbiol.biomedcentral.com/submission-guidelines",
|
||||
"last_verified": "2026-10-10",
|
||||
"notes": "同 BMC 系列模板页:双倍行距+行页码编号;参考文献 MEDLINE 缩写(→vancouver);BMC 系列 editorial policy 为 single-anonymous(作者无需匿名);字号/边距/页数上限该页未规定。"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "molecular-plant",
|
||||
"name": "Molecular Plant",
|
||||
"scope_desc": "High-impact journal for fundamental and translational plant science at the molecular level, covering genomics, cell and developmental biology, signal transduction, stress and metabolic responses, and plant biotechnology.",
|
||||
"impact_factor": null,
|
||||
"review_time_weeks": null,
|
||||
"acceptance_rate": null,
|
||||
"submission_deadline": null,
|
||||
"official_url": "https://www.cell.com/molecular-plant",
|
||||
"last_updated": "2026-10-10",
|
||||
"scope_tags": ["分子生物学", "基因组学", "多组学"],
|
||||
"application_tags": ["基础研究", "分子育种"],
|
||||
"cas_partition": null,
|
||||
"jcr_quartile": null,
|
||||
"review_tier": 1,
|
||||
"partition_source": "placeholder",
|
||||
"cross_domain": ["植物生物技术"],
|
||||
"format_rules": {
|
||||
"max_pages": null,
|
||||
"min_font_pt": 12,
|
||||
"min_margin_mm": null,
|
||||
"min_line_spacing": 1.5,
|
||||
"max_fonts": null,
|
||||
"min_figures": null,
|
||||
"refs_style": "author_year",
|
||||
"anonymous": null,
|
||||
"rules_source": "official",
|
||||
"source_url": "https://www.cell.com/molecular-plant/authors",
|
||||
"last_verified": "2026-10-10",
|
||||
"notes": "官网作者指南(本环境 cell.com 403,依据作者浏览器核对摘录):正文 12 point 字号、1.5 行距,参考文献 'cited in the text by name and year in parentheses'(作者-年份制→author_year);页数上限/边距/图表下限/匿名审稿政策该摘录未覆盖→null"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "frontiers-in-plant-science",
|
||||
"name": "Frontiers in Plant Science",
|
||||
"scope_desc": "Open-access journal spanning fundamental and applied plant sciences: molecular and cellular mechanisms, physiology, ecology, evolution, and applications in agriculture and biotechnology.",
|
||||
"impact_factor": null,
|
||||
"review_time_weeks": null,
|
||||
"acceptance_rate": null,
|
||||
"submission_deadline": null,
|
||||
"official_url": "https://www.frontiersin.org/journals/plant-science",
|
||||
"last_updated": "2026-10-10",
|
||||
"scope_tags": ["植物生物学", "分子生物学", "生理学", "生态学"],
|
||||
"application_tags": ["基础研究"],
|
||||
"cas_partition": null,
|
||||
"jcr_quartile": null,
|
||||
"review_tier": 2,
|
||||
"partition_source": "placeholder",
|
||||
"cross_domain": ["农业科学"],
|
||||
"format_rules": {
|
||||
"max_pages": null,
|
||||
"min_font_pt": null,
|
||||
"min_margin_mm": null,
|
||||
"min_line_spacing": 1.0,
|
||||
"max_fonts": null,
|
||||
"min_figures": null,
|
||||
"refs_style": null,
|
||||
"anonymous": null,
|
||||
"rules_source": "official",
|
||||
"source_url": "https://www.frontiersin.org/journals/plant-science/for-authors/author-guidelines",
|
||||
"last_verified": "2026-10-10",
|
||||
"notes": "官网 author-guidelines:'The entire document should be single-spaced and must contain page and line numbers'(单倍行距→1.0);页数按 Article types 页 word count 而非数字页限→max_pages=null;字号/边距/图表下限该页未规定。Frontiers 为 open peer review;官网未明文规定作者是否匿名,建议人工核实 review-guidelines 页。Frontiers 官网明确两种制式并存:Harvard (author-date) 或 Vancouver (numbered),具体到 Plant Science 需查 help center(当前抓取失败),建议人工核实 https://www.frontiersin.org/help/"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "rice",
|
||||
"name": "Rice",
|
||||
"scope_desc": "Open-access journal publishing research on all aspects of rice science, from molecular biology and genetics to breeding, physiology, ecology and production systems, with relevance to cereal crop improvement.",
|
||||
"impact_factor": null,
|
||||
"review_time_weeks": null,
|
||||
"acceptance_rate": null,
|
||||
"submission_deadline": null,
|
||||
"official_url": "https://thericejournal.springeropen.com",
|
||||
"last_updated": "2026-10-10",
|
||||
"scope_tags": ["水稻", "作物基因组学", "遗传育种"],
|
||||
"application_tags": ["基础研究", "分子育种", "种业"],
|
||||
"cas_partition": null,
|
||||
"jcr_quartile": null,
|
||||
"review_tier": 2,
|
||||
"partition_source": "placeholder",
|
||||
"cross_domain": ["作物科学"],
|
||||
"format_rules": {
|
||||
"max_pages": null,
|
||||
"min_font_pt": null,
|
||||
"min_margin_mm": null,
|
||||
"min_line_spacing": 2.0,
|
||||
"max_fonts": null,
|
||||
"min_figures": null,
|
||||
"refs_style": "vancouver",
|
||||
"anonymous": false,
|
||||
"rules_source": "official",
|
||||
"source_url": "https://thericejournal.springeropen.com/submission-guidelines",
|
||||
"last_verified": "2026-10-10",
|
||||
"notes": "SpringerOpen 系列模板页:'Use double-line spacing'(双倍行距→2.0)、行页码编号;参考文献 'Journal abbreviations follow Index Medicus/MEDLINE'(→vancouver);审稿明文 single-anonymous(审稿人知道作者身份,作者无需匿名);字号/边距/页数上限/图表下限该页未规定。"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,815 @@
|
||||
{
|
||||
"ok": true,
|
||||
"items": [
|
||||
{
|
||||
"id": "en:0028-0836",
|
||||
"lang": "en",
|
||||
"name": "NATURE",
|
||||
"research_direction": "综合与交叉",
|
||||
"cas_partition": "1区",
|
||||
"cas_partition_note": null,
|
||||
"cas_partition_year": 2025,
|
||||
"jif": 48.5,
|
||||
"jif_year": 2024,
|
||||
"jif_note": null,
|
||||
"jif_check_status": "已匹配公开汇编",
|
||||
"composite_impact_factor": null,
|
||||
"comprehensive_impact_factor": null,
|
||||
"metric_year": null,
|
||||
"metric_report_year": null,
|
||||
"metric_caliber": null,
|
||||
"annual_report_page": null,
|
||||
"top_flag": "是",
|
||||
"publisher_series": "Nature Portfolio(含合作刊)",
|
||||
"journal_mark": "经典专业刊",
|
||||
"article_type": "研究/方法/综合",
|
||||
"issn": "0028-0836",
|
||||
"eissn": "1476-4687",
|
||||
"jcr_disciplines": [
|
||||
"MULTIDISCIPLINARY SCIENCES(SCIE)"
|
||||
],
|
||||
"supplement_note": null,
|
||||
"evidence_note": null,
|
||||
"official_url": null,
|
||||
"partition_source": "official",
|
||||
"jif_source": "official",
|
||||
"jif_source_url": "https://github.com/yongqianxiao/share_repo/blob/master/JCR/2024JCR%E5%AE%8C%E6%95%B4%E7%89%88%EF%BC%882025.6.18%E5%8F%91%E5%B8%83%EF%BC%89.xlsx",
|
||||
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
|
||||
"data_source_label": "JIF来源",
|
||||
"data_source_url": null
|
||||
},
|
||||
{
|
||||
"id": "en:0036-8075",
|
||||
"lang": "en",
|
||||
"name": "SCIENCE",
|
||||
"research_direction": "综合与交叉",
|
||||
"cas_partition": "1区",
|
||||
"cas_partition_note": null,
|
||||
"cas_partition_year": 2025,
|
||||
"jif": 45.8,
|
||||
"jif_year": 2024,
|
||||
"jif_note": null,
|
||||
"jif_check_status": "已匹配公开汇编",
|
||||
"composite_impact_factor": null,
|
||||
"comprehensive_impact_factor": null,
|
||||
"metric_year": null,
|
||||
"metric_report_year": null,
|
||||
"metric_caliber": null,
|
||||
"annual_report_page": null,
|
||||
"top_flag": "是",
|
||||
"publisher_series": "Science / AAAS",
|
||||
"journal_mark": "经典专业刊",
|
||||
"article_type": "研究/方法/综合",
|
||||
"issn": "0036-8075",
|
||||
"eissn": "1095-9203",
|
||||
"jcr_disciplines": [
|
||||
"MULTIDISCIPLINARY SCIENCES(SCIE)"
|
||||
],
|
||||
"supplement_note": null,
|
||||
"evidence_note": null,
|
||||
"official_url": null,
|
||||
"partition_source": "official",
|
||||
"jif_source": "official",
|
||||
"jif_source_url": "https://github.com/yongqianxiao/share_repo/blob/master/JCR/2024JCR%E5%AE%8C%E6%95%B4%E7%89%88%EF%BC%882025.6.18%E5%8F%91%E5%B8%83%EF%BC%89.xlsx",
|
||||
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
|
||||
"data_source_label": "JIF来源",
|
||||
"data_source_url": null
|
||||
},
|
||||
{
|
||||
"id": "en:0092-8674",
|
||||
"lang": "en",
|
||||
"name": "CELL",
|
||||
"research_direction": "综合与交叉",
|
||||
"cas_partition": "1区",
|
||||
"cas_partition_note": null,
|
||||
"cas_partition_year": 2025,
|
||||
"jif": 42.5,
|
||||
"jif_year": 2024,
|
||||
"jif_note": null,
|
||||
"jif_check_status": "已匹配公开汇编",
|
||||
"composite_impact_factor": null,
|
||||
"comprehensive_impact_factor": null,
|
||||
"metric_year": null,
|
||||
"metric_report_year": null,
|
||||
"metric_caliber": null,
|
||||
"annual_report_page": null,
|
||||
"top_flag": "是",
|
||||
"publisher_series": "Cell Press(含合作刊)",
|
||||
"journal_mark": "经典专业刊",
|
||||
"article_type": "研究/方法/综合",
|
||||
"issn": "0092-8674",
|
||||
"eissn": "1097-4172",
|
||||
"jcr_disciplines": [
|
||||
"BIOCHEMISTRY & MOLECULAR BIOLOGY(SCIE)",
|
||||
"CELL BIOLOGY(SCIE)"
|
||||
],
|
||||
"supplement_note": null,
|
||||
"evidence_note": null,
|
||||
"official_url": null,
|
||||
"partition_source": "official",
|
||||
"jif_source": "official",
|
||||
"jif_source_url": "https://github.com/yongqianxiao/share_repo/blob/master/JCR/2024JCR%E5%AE%8C%E6%95%B4%E7%89%88%EF%BC%882025.6.18%E5%8F%91%E5%B8%83%EF%BC%89.xlsx",
|
||||
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
|
||||
"data_source_label": "JIF来源",
|
||||
"data_source_url": null
|
||||
},
|
||||
{
|
||||
"id": "en:2666-6758",
|
||||
"lang": "en",
|
||||
"name": "Innovation",
|
||||
"research_direction": "综合与交叉",
|
||||
"cas_partition": "1区",
|
||||
"cas_partition_note": null,
|
||||
"cas_partition_year": 2025,
|
||||
"jif": 25.7,
|
||||
"jif_year": 2024,
|
||||
"jif_note": null,
|
||||
"jif_check_status": "已匹配公开汇编",
|
||||
"composite_impact_factor": null,
|
||||
"comprehensive_impact_factor": null,
|
||||
"metric_year": null,
|
||||
"metric_report_year": null,
|
||||
"metric_caliber": null,
|
||||
"annual_report_page": null,
|
||||
"top_flag": "是",
|
||||
"publisher_series": "其他",
|
||||
"journal_mark": "领域期刊/新兴刊",
|
||||
"article_type": "研究/方法/综合",
|
||||
"issn": "2666-6758",
|
||||
"eissn": "2666-6758",
|
||||
"jcr_disciplines": [
|
||||
"MULTIDISCIPLINARY SCIENCES(ESCI)"
|
||||
],
|
||||
"supplement_note": null,
|
||||
"evidence_note": null,
|
||||
"official_url": null,
|
||||
"partition_source": "official",
|
||||
"jif_source": "official",
|
||||
"jif_source_url": "https://github.com/yongqianxiao/share_repo/blob/master/JCR/2024JCR%E5%AE%8C%E6%95%B4%E7%89%88%EF%BC%882025.6.18%E5%8F%91%E5%B8%83%EF%BC%89.xlsx",
|
||||
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
|
||||
"data_source_label": "JIF来源",
|
||||
"data_source_url": null
|
||||
},
|
||||
{
|
||||
"id": "en:2095-9273",
|
||||
"lang": "en",
|
||||
"name": "Science Bulletin",
|
||||
"research_direction": "综合与交叉",
|
||||
"cas_partition": "1区",
|
||||
"cas_partition_note": null,
|
||||
"cas_partition_year": 2025,
|
||||
"jif": 21.1,
|
||||
"jif_year": 2024,
|
||||
"jif_note": null,
|
||||
"jif_check_status": "已匹配公开汇编",
|
||||
"composite_impact_factor": null,
|
||||
"comprehensive_impact_factor": null,
|
||||
"metric_year": null,
|
||||
"metric_report_year": null,
|
||||
"metric_caliber": null,
|
||||
"annual_report_page": null,
|
||||
"top_flag": "是",
|
||||
"publisher_series": "其他",
|
||||
"journal_mark": "领域期刊/新兴刊",
|
||||
"article_type": "研究/方法/综合",
|
||||
"issn": "2095-9273",
|
||||
"eissn": "2095-9281",
|
||||
"jcr_disciplines": [
|
||||
"MULTIDISCIPLINARY SCIENCES(SCIE)"
|
||||
],
|
||||
"supplement_note": null,
|
||||
"evidence_note": null,
|
||||
"official_url": null,
|
||||
"partition_source": "official",
|
||||
"jif_source": "official",
|
||||
"jif_source_url": "https://github.com/yongqianxiao/share_repo/blob/master/JCR/2024JCR%E5%AE%8C%E6%95%B4%E7%89%88%EF%BC%882025.6.18%E5%8F%91%E5%B8%83%EF%BC%89.xlsx",
|
||||
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
|
||||
"data_source_label": "JIF来源",
|
||||
"data_source_url": null
|
||||
},
|
||||
{
|
||||
"id": "en:2095-5138",
|
||||
"lang": "en",
|
||||
"name": "National Science Review",
|
||||
"research_direction": "综合与交叉",
|
||||
"cas_partition": "1区",
|
||||
"cas_partition_note": null,
|
||||
"cas_partition_year": 2025,
|
||||
"jif": 17.1,
|
||||
"jif_year": 2024,
|
||||
"jif_note": null,
|
||||
"jif_check_status": "已匹配公开汇编",
|
||||
"composite_impact_factor": null,
|
||||
"comprehensive_impact_factor": null,
|
||||
"metric_year": null,
|
||||
"metric_report_year": null,
|
||||
"metric_caliber": null,
|
||||
"annual_report_page": null,
|
||||
"top_flag": "是",
|
||||
"publisher_series": "其他",
|
||||
"journal_mark": "领域期刊/新兴刊",
|
||||
"article_type": "研究/方法/综合",
|
||||
"issn": "2095-5138",
|
||||
"eissn": "2053-714X",
|
||||
"jcr_disciplines": [
|
||||
"MULTIDISCIPLINARY SCIENCES(SCIE)"
|
||||
],
|
||||
"supplement_note": null,
|
||||
"evidence_note": null,
|
||||
"official_url": null,
|
||||
"partition_source": "official",
|
||||
"jif_source": "official",
|
||||
"jif_source_url": "https://github.com/yongqianxiao/share_repo/blob/master/JCR/2024JCR%E5%AE%8C%E6%95%B4%E7%89%88%EF%BC%882025.6.18%E5%8F%91%E5%B8%83%EF%BC%89.xlsx",
|
||||
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
|
||||
"data_source_label": "JIF来源",
|
||||
"data_source_url": null
|
||||
},
|
||||
{
|
||||
"id": "en:nature-communications",
|
||||
"lang": "en",
|
||||
"name": "Nature Communications",
|
||||
"research_direction": "综合与交叉",
|
||||
"cas_partition": "1区",
|
||||
"cas_partition_note": null,
|
||||
"cas_partition_year": 2025,
|
||||
"jif": 15.7,
|
||||
"jif_year": 2024,
|
||||
"jif_note": null,
|
||||
"jif_check_status": "已匹配公开汇编",
|
||||
"composite_impact_factor": null,
|
||||
"comprehensive_impact_factor": null,
|
||||
"metric_year": null,
|
||||
"metric_report_year": null,
|
||||
"metric_caliber": null,
|
||||
"annual_report_page": null,
|
||||
"top_flag": "是",
|
||||
"publisher_series": "Nature Portfolio(含合作刊)",
|
||||
"journal_mark": "领域期刊/新兴刊",
|
||||
"article_type": "研究/方法/综合",
|
||||
"issn": null,
|
||||
"eissn": "2041-1723",
|
||||
"jcr_disciplines": [
|
||||
"MULTIDISCIPLINARY SCIENCES(SCIE)"
|
||||
],
|
||||
"supplement_note": null,
|
||||
"evidence_note": null,
|
||||
"official_url": null,
|
||||
"partition_source": "official",
|
||||
"jif_source": "official",
|
||||
"jif_source_url": "https://github.com/yongqianxiao/share_repo/blob/master/JCR/2024JCR%E5%AE%8C%E6%95%B4%E7%89%88%EF%BC%882025.6.18%E5%8F%91%E5%B8%83%EF%BC%89.xlsx",
|
||||
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
|
||||
"data_source_label": "JIF来源",
|
||||
"data_source_url": null
|
||||
},
|
||||
{
|
||||
"id": "en:advanced-science",
|
||||
"lang": "en",
|
||||
"name": "Advanced Science",
|
||||
"research_direction": "综合与交叉",
|
||||
"cas_partition": "1区",
|
||||
"cas_partition_note": null,
|
||||
"cas_partition_year": 2025,
|
||||
"jif": 14.1,
|
||||
"jif_year": 2024,
|
||||
"jif_note": null,
|
||||
"jif_check_status": "已匹配公开汇编",
|
||||
"composite_impact_factor": null,
|
||||
"comprehensive_impact_factor": null,
|
||||
"metric_year": null,
|
||||
"metric_report_year": null,
|
||||
"metric_caliber": null,
|
||||
"annual_report_page": null,
|
||||
"top_flag": "是",
|
||||
"publisher_series": "其他",
|
||||
"journal_mark": "领域期刊/新兴刊",
|
||||
"article_type": "研究/方法/综合",
|
||||
"issn": null,
|
||||
"eissn": "2198-3844",
|
||||
"jcr_disciplines": [
|
||||
"CHEMISTRY, MULTIDISCIPLINARY(SCIE)",
|
||||
"MATERIALS SCIENCE, MULTIDISCIPLINARY(SCIE)",
|
||||
"NANOSCIENCE & NANOTECHNOLOGY(SCIE)"
|
||||
],
|
||||
"supplement_note": null,
|
||||
"evidence_note": null,
|
||||
"official_url": null,
|
||||
"partition_source": "official",
|
||||
"jif_source": "official",
|
||||
"jif_source_url": "https://github.com/yongqianxiao/share_repo/blob/master/JCR/2024JCR%E5%AE%8C%E6%95%B4%E7%89%88%EF%BC%882025.6.18%E5%8F%91%E5%B8%83%EF%BC%89.xlsx",
|
||||
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
|
||||
"data_source_label": "JIF来源",
|
||||
"data_source_url": null
|
||||
},
|
||||
{
|
||||
"id": "en:2375-2548",
|
||||
"lang": "en",
|
||||
"name": "Science Advances",
|
||||
"research_direction": "综合与交叉",
|
||||
"cas_partition": "1区",
|
||||
"cas_partition_note": null,
|
||||
"cas_partition_year": 2025,
|
||||
"jif": 12.5,
|
||||
"jif_year": 2024,
|
||||
"jif_note": null,
|
||||
"jif_check_status": "已匹配公开汇编",
|
||||
"composite_impact_factor": null,
|
||||
"comprehensive_impact_factor": null,
|
||||
"metric_year": null,
|
||||
"metric_report_year": null,
|
||||
"metric_caliber": null,
|
||||
"annual_report_page": null,
|
||||
"top_flag": "是",
|
||||
"publisher_series": "Science / AAAS",
|
||||
"journal_mark": "领域期刊/新兴刊",
|
||||
"article_type": "研究/方法/综合",
|
||||
"issn": "2375-2548",
|
||||
"eissn": "2375-2548",
|
||||
"jcr_disciplines": [
|
||||
"MULTIDISCIPLINARY SCIENCES(SCIE)"
|
||||
],
|
||||
"supplement_note": null,
|
||||
"evidence_note": null,
|
||||
"official_url": null,
|
||||
"partition_source": "official",
|
||||
"jif_source": "official",
|
||||
"jif_source_url": "https://github.com/yongqianxiao/share_repo/blob/master/JCR/2024JCR%E5%AE%8C%E6%95%B4%E7%89%88%EF%BC%882025.6.18%E5%8F%91%E5%B8%83%EF%BC%89.xlsx",
|
||||
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
|
||||
"data_source_label": "JIF来源",
|
||||
"data_source_url": null
|
||||
},
|
||||
{
|
||||
"id": "en:2096-5168",
|
||||
"lang": "en",
|
||||
"name": "Research",
|
||||
"research_direction": "综合与交叉",
|
||||
"cas_partition": "1区",
|
||||
"cas_partition_note": null,
|
||||
"cas_partition_year": 2025,
|
||||
"jif": 10.9,
|
||||
"jif_year": 2024,
|
||||
"jif_note": null,
|
||||
"jif_check_status": "已按Clarivate勘误更新",
|
||||
"composite_impact_factor": null,
|
||||
"comprehensive_impact_factor": null,
|
||||
"metric_year": null,
|
||||
"metric_report_year": null,
|
||||
"metric_caliber": null,
|
||||
"annual_report_page": null,
|
||||
"top_flag": "是",
|
||||
"publisher_series": "Science Partner Journals(合作刊)",
|
||||
"journal_mark": "领域期刊/新兴刊",
|
||||
"article_type": "研究/方法/综合",
|
||||
"issn": "2096-5168",
|
||||
"eissn": "2639-5274",
|
||||
"jcr_disciplines": [
|
||||
"MULTIDISCIPLINARY SCIENCES(SCIE)"
|
||||
],
|
||||
"supplement_note": "2024 JIF采用2025-10-15官方重载修订值。",
|
||||
"evidence_note": null,
|
||||
"official_url": null,
|
||||
"partition_source": "official",
|
||||
"jif_source": "official",
|
||||
"jif_source_url": "https://journalcitationreports.zendesk.com/hc/en-gb/articles/36310787349521-2025",
|
||||
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
|
||||
"data_source_label": "JIF来源",
|
||||
"data_source_url": null
|
||||
},
|
||||
{
|
||||
"id": "en:0027-8424",
|
||||
"lang": "en",
|
||||
"name": "PROCEEDINGS OF THE NATIONAL ACADEMY OF SCIENCES OF THE UNITED STATES OF AMERICA",
|
||||
"research_direction": "综合与交叉",
|
||||
"cas_partition": "1区",
|
||||
"cas_partition_note": null,
|
||||
"cas_partition_year": 2025,
|
||||
"jif": 9.1,
|
||||
"jif_year": 2024,
|
||||
"jif_note": null,
|
||||
"jif_check_status": "已匹配公开汇编",
|
||||
"composite_impact_factor": null,
|
||||
"comprehensive_impact_factor": null,
|
||||
"metric_year": null,
|
||||
"metric_report_year": null,
|
||||
"metric_caliber": null,
|
||||
"annual_report_page": null,
|
||||
"top_flag": "是",
|
||||
"publisher_series": "其他",
|
||||
"journal_mark": "经典专业刊",
|
||||
"article_type": "研究/方法/综合",
|
||||
"issn": "0027-8424",
|
||||
"eissn": "1091-6490",
|
||||
"jcr_disciplines": [
|
||||
"MULTIDISCIPLINARY SCIENCES(SCIE)"
|
||||
],
|
||||
"supplement_note": null,
|
||||
"evidence_note": null,
|
||||
"official_url": null,
|
||||
"partition_source": "official",
|
||||
"jif_source": "official",
|
||||
"jif_source_url": "https://github.com/yongqianxiao/share_repo/blob/master/JCR/2024JCR%E5%AE%8C%E6%95%B4%E7%89%88%EF%BC%882025.6.18%E5%8F%91%E5%B8%83%EF%BC%89.xlsx",
|
||||
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
|
||||
"data_source_label": "JIF来源",
|
||||
"data_source_url": null
|
||||
},
|
||||
{
|
||||
"id": "en:scientific-data",
|
||||
"lang": "en",
|
||||
"name": "Scientific Data",
|
||||
"research_direction": "综合与交叉",
|
||||
"cas_partition": "2区",
|
||||
"cas_partition_note": null,
|
||||
"cas_partition_year": 2025,
|
||||
"jif": 6.9,
|
||||
"jif_year": 2024,
|
||||
"jif_note": null,
|
||||
"jif_check_status": "已匹配公开汇编",
|
||||
"composite_impact_factor": null,
|
||||
"comprehensive_impact_factor": null,
|
||||
"metric_year": null,
|
||||
"metric_report_year": null,
|
||||
"metric_caliber": null,
|
||||
"annual_report_page": null,
|
||||
"top_flag": "否",
|
||||
"publisher_series": "Nature Portfolio(含合作刊)",
|
||||
"journal_mark": "领域期刊/新兴刊",
|
||||
"article_type": "研究/方法/综合",
|
||||
"issn": null,
|
||||
"eissn": "2052-4463",
|
||||
"jcr_disciplines": [
|
||||
"MULTIDISCIPLINARY SCIENCES(SCIE)"
|
||||
],
|
||||
"supplement_note": null,
|
||||
"evidence_note": null,
|
||||
"official_url": null,
|
||||
"partition_source": "official",
|
||||
"jif_source": "official",
|
||||
"jif_source_url": "https://github.com/yongqianxiao/share_repo/blob/master/JCR/2024JCR%E5%AE%8C%E6%95%B4%E7%89%88%EF%BC%882025.6.18%E5%8F%91%E5%B8%83%EF%BC%89.xlsx",
|
||||
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
|
||||
"data_source_label": "JIF来源",
|
||||
"data_source_url": null
|
||||
},
|
||||
{
|
||||
"id": "en:iscience",
|
||||
"lang": "en",
|
||||
"name": "iScience",
|
||||
"research_direction": "综合与交叉",
|
||||
"cas_partition": "2区",
|
||||
"cas_partition_note": null,
|
||||
"cas_partition_year": 2025,
|
||||
"jif": 4.1,
|
||||
"jif_year": 2024,
|
||||
"jif_note": null,
|
||||
"jif_check_status": "已匹配公开汇编",
|
||||
"composite_impact_factor": null,
|
||||
"comprehensive_impact_factor": null,
|
||||
"metric_year": null,
|
||||
"metric_report_year": null,
|
||||
"metric_caliber": null,
|
||||
"annual_report_page": null,
|
||||
"top_flag": "否",
|
||||
"publisher_series": "Cell Press(含合作刊)",
|
||||
"journal_mark": "领域期刊/新兴刊",
|
||||
"article_type": "研究/方法/综合",
|
||||
"issn": null,
|
||||
"eissn": "2589-0042",
|
||||
"jcr_disciplines": [
|
||||
"MULTIDISCIPLINARY SCIENCES(SCIE)"
|
||||
],
|
||||
"supplement_note": null,
|
||||
"evidence_note": null,
|
||||
"official_url": null,
|
||||
"partition_source": "official",
|
||||
"jif_source": "official",
|
||||
"jif_source_url": "https://github.com/yongqianxiao/share_repo/blob/master/JCR/2024JCR%E5%AE%8C%E6%95%B4%E7%89%88%EF%BC%882025.6.18%E5%8F%91%E5%B8%83%EF%BC%89.xlsx",
|
||||
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
|
||||
"data_source_label": "JIF来源",
|
||||
"data_source_url": null
|
||||
},
|
||||
{
|
||||
"id": "en:2047-217X",
|
||||
"lang": "en",
|
||||
"name": "GigaScience",
|
||||
"research_direction": "综合与交叉",
|
||||
"cas_partition": "2区",
|
||||
"cas_partition_note": null,
|
||||
"cas_partition_year": 2025,
|
||||
"jif": 3.9,
|
||||
"jif_year": 2024,
|
||||
"jif_note": null,
|
||||
"jif_check_status": "已匹配公开汇编",
|
||||
"composite_impact_factor": null,
|
||||
"comprehensive_impact_factor": null,
|
||||
"metric_year": null,
|
||||
"metric_report_year": null,
|
||||
"metric_caliber": null,
|
||||
"annual_report_page": null,
|
||||
"top_flag": "否",
|
||||
"publisher_series": "其他",
|
||||
"journal_mark": "领域期刊/新兴刊",
|
||||
"article_type": "研究/方法/综合",
|
||||
"issn": "2047-217X",
|
||||
"eissn": "2047-217X",
|
||||
"jcr_disciplines": [
|
||||
"MULTIDISCIPLINARY SCIENCES(SCIE)"
|
||||
],
|
||||
"supplement_note": null,
|
||||
"evidence_note": null,
|
||||
"official_url": null,
|
||||
"partition_source": "official",
|
||||
"jif_source": "official",
|
||||
"jif_source_url": "https://github.com/yongqianxiao/share_repo/blob/master/JCR/2024JCR%E5%AE%8C%E6%95%B4%E7%89%88%EF%BC%882025.6.18%E5%8F%91%E5%B8%83%EF%BC%89.xlsx",
|
||||
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
|
||||
"data_source_label": "JIF来源",
|
||||
"data_source_url": null
|
||||
},
|
||||
{
|
||||
"id": "en:2045-2322",
|
||||
"lang": "en",
|
||||
"name": "Scientific Reports",
|
||||
"research_direction": "综合与交叉",
|
||||
"cas_partition": "3区",
|
||||
"cas_partition_note": null,
|
||||
"cas_partition_year": 2025,
|
||||
"jif": 3.9,
|
||||
"jif_year": 2024,
|
||||
"jif_note": null,
|
||||
"jif_check_status": "已匹配公开汇编",
|
||||
"composite_impact_factor": null,
|
||||
"comprehensive_impact_factor": null,
|
||||
"metric_year": null,
|
||||
"metric_report_year": null,
|
||||
"metric_caliber": null,
|
||||
"annual_report_page": null,
|
||||
"top_flag": "否",
|
||||
"publisher_series": "Nature Portfolio(含合作刊)",
|
||||
"journal_mark": "领域期刊/新兴刊",
|
||||
"article_type": "研究/方法/综合",
|
||||
"issn": "2045-2322",
|
||||
"eissn": "2045-2322",
|
||||
"jcr_disciplines": [
|
||||
"MULTIDISCIPLINARY SCIENCES(SCIE)"
|
||||
],
|
||||
"supplement_note": null,
|
||||
"evidence_note": null,
|
||||
"official_url": null,
|
||||
"partition_source": "official",
|
||||
"jif_source": "official",
|
||||
"jif_source_url": "https://github.com/yongqianxiao/share_repo/blob/master/JCR/2024JCR%E5%AE%8C%E6%95%B4%E7%89%88%EF%BC%882025.6.18%E5%8F%91%E5%B8%83%EF%BC%89.xlsx",
|
||||
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
|
||||
"data_source_label": "JIF来源",
|
||||
"data_source_url": null
|
||||
},
|
||||
{
|
||||
"id": "en:pnas-nexus",
|
||||
"lang": "en",
|
||||
"name": "PNAS Nexus",
|
||||
"research_direction": "综合与交叉",
|
||||
"cas_partition": "3区",
|
||||
"cas_partition_note": null,
|
||||
"cas_partition_year": 2025,
|
||||
"jif": 3.8,
|
||||
"jif_year": 2024,
|
||||
"jif_note": null,
|
||||
"jif_check_status": "已匹配公开汇编",
|
||||
"composite_impact_factor": null,
|
||||
"comprehensive_impact_factor": null,
|
||||
"metric_year": null,
|
||||
"metric_report_year": null,
|
||||
"metric_caliber": null,
|
||||
"annual_report_page": null,
|
||||
"top_flag": "否",
|
||||
"publisher_series": "其他",
|
||||
"journal_mark": "领域期刊/新兴刊",
|
||||
"article_type": "研究/方法/综合",
|
||||
"issn": null,
|
||||
"eissn": "2752-6542",
|
||||
"jcr_disciplines": [
|
||||
"MULTIDISCIPLINARY SCIENCES(ESCI)",
|
||||
"SOCIAL SCIENCES, INTERDISCIPLINARY(ESCI)"
|
||||
],
|
||||
"supplement_note": null,
|
||||
"evidence_note": null,
|
||||
"official_url": null,
|
||||
"partition_source": "official",
|
||||
"jif_source": "official",
|
||||
"jif_source_url": "https://github.com/yongqianxiao/share_repo/blob/master/JCR/2024JCR%E5%AE%8C%E6%95%B4%E7%89%88%EF%BC%882025.6.18%E5%8F%91%E5%B8%83%EF%BC%89.xlsx",
|
||||
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
|
||||
"data_source_label": "JIF来源",
|
||||
"data_source_url": null
|
||||
},
|
||||
{
|
||||
"id": "en:heliyon",
|
||||
"lang": "en",
|
||||
"name": "Heliyon",
|
||||
"research_direction": "综合与交叉",
|
||||
"cas_partition": "4区",
|
||||
"cas_partition_note": null,
|
||||
"cas_partition_year": 2025,
|
||||
"jif": 3.6,
|
||||
"jif_year": 2024,
|
||||
"jif_note": null,
|
||||
"jif_check_status": "已匹配公开汇编",
|
||||
"composite_impact_factor": null,
|
||||
"comprehensive_impact_factor": null,
|
||||
"metric_year": null,
|
||||
"metric_report_year": null,
|
||||
"metric_caliber": null,
|
||||
"annual_report_page": null,
|
||||
"top_flag": "否",
|
||||
"publisher_series": "其他",
|
||||
"journal_mark": "领域期刊/新兴刊",
|
||||
"article_type": "研究/方法/综合",
|
||||
"issn": null,
|
||||
"eissn": "2405-8440",
|
||||
"jcr_disciplines": [
|
||||
"MULTIDISCIPLINARY SCIENCES(SCIE)"
|
||||
],
|
||||
"supplement_note": null,
|
||||
"evidence_note": null,
|
||||
"official_url": null,
|
||||
"partition_source": "official",
|
||||
"jif_source": "official",
|
||||
"jif_source_url": "https://github.com/yongqianxiao/share_repo/blob/master/JCR/2024JCR%E5%AE%8C%E6%95%B4%E7%89%88%EF%BC%882025.6.18%E5%8F%91%E5%B8%83%EF%BC%89.xlsx",
|
||||
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
|
||||
"data_source_label": "JIF来源",
|
||||
"data_source_url": null
|
||||
},
|
||||
{
|
||||
"id": "en:2054-5703",
|
||||
"lang": "en",
|
||||
"name": "Royal Society Open Science",
|
||||
"research_direction": "综合与交叉",
|
||||
"cas_partition": "3区",
|
||||
"cas_partition_note": null,
|
||||
"cas_partition_year": 2025,
|
||||
"jif": 2.9,
|
||||
"jif_year": 2024,
|
||||
"jif_note": null,
|
||||
"jif_check_status": "已匹配公开汇编",
|
||||
"composite_impact_factor": null,
|
||||
"comprehensive_impact_factor": null,
|
||||
"metric_year": null,
|
||||
"metric_report_year": null,
|
||||
"metric_caliber": null,
|
||||
"annual_report_page": null,
|
||||
"top_flag": "否",
|
||||
"publisher_series": "其他",
|
||||
"journal_mark": "领域期刊/新兴刊",
|
||||
"article_type": "研究/方法/综合",
|
||||
"issn": "2054-5703",
|
||||
"eissn": "2054-5703",
|
||||
"jcr_disciplines": [
|
||||
"MULTIDISCIPLINARY SCIENCES(SCIE)"
|
||||
],
|
||||
"supplement_note": null,
|
||||
"evidence_note": null,
|
||||
"official_url": null,
|
||||
"partition_source": "official",
|
||||
"jif_source": "official",
|
||||
"jif_source_url": "https://github.com/yongqianxiao/share_repo/blob/master/JCR/2024JCR%E5%AE%8C%E6%95%B4%E7%89%88%EF%BC%882025.6.18%E5%8F%91%E5%B8%83%EF%BC%89.xlsx",
|
||||
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
|
||||
"data_source_label": "JIF来源",
|
||||
"data_source_url": null
|
||||
},
|
||||
{
|
||||
"id": "en:1932-6203",
|
||||
"lang": "en",
|
||||
"name": "PLoS One",
|
||||
"research_direction": "综合与交叉",
|
||||
"cas_partition": "3区",
|
||||
"cas_partition_note": null,
|
||||
"cas_partition_year": 2025,
|
||||
"jif": 2.6,
|
||||
"jif_year": 2024,
|
||||
"jif_note": null,
|
||||
"jif_check_status": "已匹配公开汇编",
|
||||
"composite_impact_factor": null,
|
||||
"comprehensive_impact_factor": null,
|
||||
"metric_year": null,
|
||||
"metric_report_year": null,
|
||||
"metric_caliber": null,
|
||||
"annual_report_page": null,
|
||||
"top_flag": "否",
|
||||
"publisher_series": "其他",
|
||||
"journal_mark": "领域期刊/新兴刊",
|
||||
"article_type": "研究/方法/综合",
|
||||
"issn": "1932-6203",
|
||||
"eissn": "1932-6203",
|
||||
"jcr_disciplines": [
|
||||
"MULTIDISCIPLINARY SCIENCES(SCIE)"
|
||||
],
|
||||
"supplement_note": null,
|
||||
"evidence_note": null,
|
||||
"official_url": null,
|
||||
"partition_source": "official",
|
||||
"jif_source": "official",
|
||||
"jif_source_url": "https://github.com/yongqianxiao/share_repo/blob/master/JCR/2024JCR%E5%AE%8C%E6%95%B4%E7%89%88%EF%BC%882025.6.18%E5%8F%91%E5%B8%83%EF%BC%89.xlsx",
|
||||
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
|
||||
"data_source_label": "JIF来源",
|
||||
"data_source_url": null
|
||||
},
|
||||
{
|
||||
"id": "en:2167-8359",
|
||||
"lang": "en",
|
||||
"name": "PeerJ",
|
||||
"research_direction": "综合与交叉",
|
||||
"cas_partition": "3区",
|
||||
"cas_partition_note": null,
|
||||
"cas_partition_year": 2025,
|
||||
"jif": 2.4,
|
||||
"jif_year": 2024,
|
||||
"jif_note": null,
|
||||
"jif_check_status": "已匹配公开汇编",
|
||||
"composite_impact_factor": null,
|
||||
"comprehensive_impact_factor": null,
|
||||
"metric_year": null,
|
||||
"metric_report_year": null,
|
||||
"metric_caliber": null,
|
||||
"annual_report_page": null,
|
||||
"top_flag": "否",
|
||||
"publisher_series": "其他",
|
||||
"journal_mark": "领域期刊/新兴刊",
|
||||
"article_type": "研究/方法/综合",
|
||||
"issn": "2167-8359",
|
||||
"eissn": "2167-8359",
|
||||
"jcr_disciplines": [
|
||||
"MULTIDISCIPLINARY SCIENCES(SCIE)"
|
||||
],
|
||||
"supplement_note": null,
|
||||
"evidence_note": null,
|
||||
"official_url": null,
|
||||
"partition_source": "official",
|
||||
"jif_source": "official",
|
||||
"jif_source_url": "https://github.com/yongqianxiao/share_repo/blob/master/JCR/2024JCR%E5%AE%8C%E6%95%B4%E7%89%88%EF%BC%882025.6.18%E5%8F%91%E5%B8%83%EF%BC%89.xlsx",
|
||||
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
|
||||
"data_source_label": "JIF来源",
|
||||
"data_source_url": null
|
||||
},
|
||||
{
|
||||
"id": "en:2050-084X",
|
||||
"lang": "en",
|
||||
"name": "eLife",
|
||||
"research_direction": "综合与交叉",
|
||||
"cas_partition": "1区",
|
||||
"cas_partition_note": null,
|
||||
"cas_partition_year": 2025,
|
||||
"jif": null,
|
||||
"jif_year": 2024,
|
||||
"jif_note": "未被2024 JCR收录",
|
||||
"jif_check_status": "源表标N/A",
|
||||
"composite_impact_factor": null,
|
||||
"comprehensive_impact_factor": null,
|
||||
"metric_year": null,
|
||||
"metric_report_year": null,
|
||||
"metric_caliber": null,
|
||||
"annual_report_page": null,
|
||||
"top_flag": "是",
|
||||
"publisher_series": "其他",
|
||||
"journal_mark": "领域期刊/新兴刊",
|
||||
"article_type": "研究/方法/综合",
|
||||
"issn": "2050-084X",
|
||||
"eissn": "2050-084X",
|
||||
"jcr_disciplines": [
|
||||
"BIOLOGY(ESCI)"
|
||||
],
|
||||
"supplement_note": "源表2024 JIF为N/A;不可用历史IF代填。",
|
||||
"evidence_note": null,
|
||||
"official_url": null,
|
||||
"partition_source": "official",
|
||||
"jif_source": null,
|
||||
"jif_source_url": null,
|
||||
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
|
||||
"data_source_label": "JIF来源",
|
||||
"data_source_url": null
|
||||
}
|
||||
],
|
||||
"meta": {
|
||||
"total": 21,
|
||||
"page": 1,
|
||||
"size": 50,
|
||||
"lang": "en",
|
||||
"domain": "综合与交叉",
|
||||
"cas_partition": "",
|
||||
"q": "",
|
||||
"domains": [
|
||||
"细胞分子发育与生理",
|
||||
"生态环境与保护",
|
||||
"微生物真菌与生物技术",
|
||||
"农学作物与种质",
|
||||
"遗传组学与计算生物",
|
||||
"植物科学与分子育种",
|
||||
"水产渔业与海洋生物",
|
||||
"食品营养与安全",
|
||||
"植物保护与昆虫",
|
||||
"畜牧动物营养与繁殖",
|
||||
"土壤肥料与地球生物化学",
|
||||
"林业木材与草业",
|
||||
"兽医与寄生虫",
|
||||
"综合与交叉",
|
||||
"园艺果树与采后"
|
||||
],
|
||||
"data_source_note": "分区:2025年中科院公开表(official);JIF:2024 JCR,由2025版JCR发布(official);整理日期2026-10-10。空白=来源未给值≠0;研究方向/期刊标记/文章类型为导航标签,非官方评级。",
|
||||
"data_imported": true
|
||||
},
|
||||
"error": null
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"ok": false,
|
||||
"items": [],
|
||||
"meta": null,
|
||||
"error": {
|
||||
"code": "VALIDATION",
|
||||
"message": "size 必须在 1-200,收到 500",
|
||||
"degraded": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"ok": false,
|
||||
"items": [],
|
||||
"meta": null,
|
||||
"error": {
|
||||
"code": "VALIDATION",
|
||||
"message": "不支持的 lang='xx',可选:en / zh",
|
||||
"degraded": false
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"id": "academic-blue",
|
||||
"display_name": "学术蓝",
|
||||
"label": "学术蓝",
|
||||
"description": "经典蓝白 · 答辩常用",
|
||||
"palette": {
|
||||
"bg": "#FFFFFF",
|
||||
"cover_bg": "#EFF6FF",
|
||||
"section_bg": "#DBEAFE",
|
||||
"section_text": "#FFFFFF",
|
||||
"primary": "#1D4ED8",
|
||||
"accent": "#3B82F6",
|
||||
"text": "#0F172A",
|
||||
"muted": "#64748B",
|
||||
"cover_text": "#1E3A8A",
|
||||
"box_line": "#93C5FD"
|
||||
},
|
||||
"fonts": {
|
||||
"title": "Microsoft YaHei, Source Han Sans SC, Noto Sans CJK SC, sans-serif",
|
||||
"body": "Microsoft YaHei, Source Han Sans SC, Noto Sans CJK SC, sans-serif"
|
||||
},
|
||||
"sizes": {
|
||||
"cover_title_pt": 40,
|
||||
"title_pt": 30,
|
||||
"bullet_pt": 18,
|
||||
"reference_pt": 14,
|
||||
"figure_pt": 13
|
||||
},
|
||||
"cover_style": { "title_align": "left", "accent_bar": true },
|
||||
"content_style": { "figure_split_pct": 60 }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"id": "academic-minimal",
|
||||
"display_name": "学术极简",
|
||||
"label": "学术极简",
|
||||
"description": "白底蓝黑 · 投屏通用",
|
||||
"palette": {
|
||||
"bg": "#FFFFFF",
|
||||
"cover_bg": "#FFFFFF",
|
||||
"section_bg": "#F3F4F6",
|
||||
"section_text": "#FFFFFF",
|
||||
"primary": "#111827",
|
||||
"accent": "#2563EB",
|
||||
"text": "#1F2937",
|
||||
"muted": "#6B7280",
|
||||
"cover_text": "#111827",
|
||||
"box_line": "#9CA3AF"
|
||||
},
|
||||
"fonts": {
|
||||
"title": "Microsoft YaHei, Source Han Sans SC, Noto Sans CJK SC, sans-serif",
|
||||
"body": "Microsoft YaHei, Source Han Sans SC, Noto Sans CJK SC, sans-serif"
|
||||
},
|
||||
"sizes": {
|
||||
"cover_title_pt": 40,
|
||||
"title_pt": 30,
|
||||
"bullet_pt": 18,
|
||||
"reference_pt": 14,
|
||||
"figure_pt": 13
|
||||
},
|
||||
"cover_style": { "title_align": "left", "accent_bar": false },
|
||||
"content_style": { "figure_split_pct": 60 }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"id": "botanic-forest",
|
||||
"display_name": "植研绿林",
|
||||
"label": "植研绿林",
|
||||
"description": "植物绿系 · 所内汇报",
|
||||
"palette": {
|
||||
"bg": "#F4FAF6",
|
||||
"cover_bg": "#14532D",
|
||||
"section_bg": "#DCFCE7",
|
||||
"section_text": "#FFFFFF",
|
||||
"primary": "#14532D",
|
||||
"accent": "#16A34A",
|
||||
"text": "#1A2E1A",
|
||||
"muted": "#4D7C57",
|
||||
"cover_text": "#FFFFFF",
|
||||
"box_line": "#86C79A"
|
||||
},
|
||||
"fonts": {
|
||||
"title": "Microsoft YaHei, Source Han Sans SC, Noto Sans CJK SC, sans-serif",
|
||||
"body": "Microsoft YaHei, Source Han Sans SC, Noto Sans CJK SC, sans-serif"
|
||||
},
|
||||
"sizes": {
|
||||
"cover_title_pt": 40,
|
||||
"title_pt": 30,
|
||||
"bullet_pt": 18,
|
||||
"reference_pt": 14,
|
||||
"figure_pt": 13
|
||||
},
|
||||
"cover_style": { "title_align": "left", "accent_bar": false },
|
||||
"content_style": { "figure_split_pct": 60 }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"id": "classic-defense",
|
||||
"display_name": "经典答辩",
|
||||
"label": "经典答辩",
|
||||
"description": "深蓝衬线 · 正式答辩",
|
||||
"palette": {
|
||||
"bg": "#FFFFFF",
|
||||
"cover_bg": "#1A365D",
|
||||
"section_bg": "#EEF2F7",
|
||||
"section_text": "#FFFFFF",
|
||||
"primary": "#1A365D",
|
||||
"accent": "#B7791F",
|
||||
"text": "#1F2937",
|
||||
"muted": "#64748B",
|
||||
"cover_text": "#FFFFFF",
|
||||
"box_line": "#94A3B8"
|
||||
},
|
||||
"fonts": {
|
||||
"title": "Times New Roman, Georgia, Noto Serif CJK SC, SimSun, serif",
|
||||
"body": "Times New Roman, Georgia, Noto Serif CJK SC, SimSun, serif"
|
||||
},
|
||||
"sizes": {
|
||||
"cover_title_pt": 40,
|
||||
"title_pt": 30,
|
||||
"bullet_pt": 18,
|
||||
"reference_pt": 14,
|
||||
"figure_pt": 13
|
||||
},
|
||||
"cover_style": { "title_align": "left", "accent_bar": false },
|
||||
"content_style": { "figure_split_pct": 60 }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"id": "domestic",
|
||||
"display_name": "国内组会",
|
||||
"label": "国内组会",
|
||||
"description": "深蓝红强调 · 国内组会 / 学术报告",
|
||||
"palette": {
|
||||
"bg": "#FFFFFF",
|
||||
"cover_bg": "#FFFFFF",
|
||||
"section_bg": "#1F4E79",
|
||||
"section_text": "#FFFFFF",
|
||||
"primary": "#1F4E79",
|
||||
"accent": "#C00000",
|
||||
"text": "#1A1A1A",
|
||||
"muted": "#6B6B6B",
|
||||
"cover_text": "#1F4E79",
|
||||
"box_line": "#F2F2F2"
|
||||
},
|
||||
"fonts": {
|
||||
"title": "Microsoft YaHei, PingFang SC, Source Han Sans SC, Noto Sans CJK SC, Arial, sans-serif",
|
||||
"body": "Microsoft YaHei, PingFang SC, Source Han Sans SC, Noto Sans CJK SC, Arial, sans-serif"
|
||||
},
|
||||
"sizes": {
|
||||
"cover_title_pt": 36,
|
||||
"title_pt": 28,
|
||||
"bullet_pt": 20,
|
||||
"reference_pt": 16,
|
||||
"figure_pt": 13
|
||||
},
|
||||
"cover_style": {
|
||||
"title_align": "left",
|
||||
"accent_bar": true
|
||||
},
|
||||
"content_style": {
|
||||
"figure_split_pct": 60
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"id": "editorial",
|
||||
"display_name": "杂志插画",
|
||||
"label": "杂志插画",
|
||||
"description": "米底紫衬线 · 插画杂志风",
|
||||
"palette": {
|
||||
"bg": "#FAF7F2",
|
||||
"cover_bg": "#FAF7F2",
|
||||
"section_bg": "#2B2B2B",
|
||||
"section_text": "#FFFFFF",
|
||||
"primary": "#8E7DBE",
|
||||
"accent": "#C8553D",
|
||||
"text": "#2B2B2B",
|
||||
"muted": "#7D776F",
|
||||
"cover_text": "#2B2B2B",
|
||||
"box_line": "#F1ECE4"
|
||||
},
|
||||
"fonts": {
|
||||
"title": "Palatino Linotype, Book Antiqua, Georgia, Source Han Serif SC, SimSun, Noto Serif SC, serif",
|
||||
"body": "Palatino Linotype, Book Antiqua, Georgia, Source Han Serif SC, SimSun, Noto Serif SC, serif"
|
||||
},
|
||||
"sizes": {
|
||||
"cover_title_pt": 40,
|
||||
"title_pt": 32,
|
||||
"bullet_pt": 20,
|
||||
"reference_pt": 16,
|
||||
"figure_pt": 13
|
||||
},
|
||||
"cover_style": {
|
||||
"title_align": "left",
|
||||
"accent_bar": true
|
||||
},
|
||||
"content_style": {
|
||||
"figure_split_pct": 60
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"id": "international",
|
||||
"display_name": "国际会议",
|
||||
"label": "国际会议",
|
||||
"description": "白底红点 · 海外组会 / 国际会议",
|
||||
"palette": {
|
||||
"bg": "#FFFFFF",
|
||||
"cover_bg": "#FFFFFF",
|
||||
"section_bg": "#111111",
|
||||
"section_text": "#FFFFFF",
|
||||
"primary": "#111111",
|
||||
"accent": "#C0392B",
|
||||
"text": "#111111",
|
||||
"muted": "#6B6B6B",
|
||||
"cover_text": "#111111",
|
||||
"box_line": "#E5E5E5"
|
||||
},
|
||||
"fonts": {
|
||||
"title": "Arial, Helvetica, Microsoft YaHei, PingFang SC, Source Han Sans SC, sans-serif",
|
||||
"body": "Arial, Helvetica, Microsoft YaHei, PingFang SC, Source Han Sans SC, sans-serif"
|
||||
},
|
||||
"sizes": {
|
||||
"cover_title_pt": 40,
|
||||
"title_pt": 32,
|
||||
"bullet_pt": 20,
|
||||
"reference_pt": 16,
|
||||
"figure_pt": 13
|
||||
},
|
||||
"cover_style": {
|
||||
"title_align": "left",
|
||||
"accent_bar": true
|
||||
},
|
||||
"content_style": {
|
||||
"figure_split_pct": 60
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"id": "jp-gothic",
|
||||
"display_name": "日韩发表",
|
||||
"label": "日韩发表",
|
||||
"description": "白底蓝黄贴纸 · 日韩技术发表",
|
||||
"palette": {
|
||||
"bg": "#FFFFFF",
|
||||
"cover_bg": "#FFFFFF",
|
||||
"section_bg": "#1E5BD8",
|
||||
"section_text": "#FFFFFF",
|
||||
"primary": "#1E5BD8",
|
||||
"accent": "#1E5BD8",
|
||||
"text": "#111111",
|
||||
"muted": "#6B6B6B",
|
||||
"cover_text": "#1E5BD8",
|
||||
"box_line": "#EFEFEF"
|
||||
},
|
||||
"fonts": {
|
||||
"title": "Noto Sans JP, Hiragino Sans, Yu Gothic, Microsoft YaHei, Noto Sans SC, sans-serif",
|
||||
"body": "Noto Sans JP, Hiragino Sans, Yu Gothic, Microsoft YaHei, Noto Sans SC, sans-serif"
|
||||
},
|
||||
"sizes": {
|
||||
"cover_title_pt": 46,
|
||||
"title_pt": 28,
|
||||
"bullet_pt": 20,
|
||||
"reference_pt": 16,
|
||||
"figure_pt": 13
|
||||
},
|
||||
"cover_style": {
|
||||
"title_align": "left",
|
||||
"accent_bar": true
|
||||
},
|
||||
"content_style": {
|
||||
"figure_split_pct": 60
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"id": "monochrome",
|
||||
"display_name": "极简黑白",
|
||||
"label": "极简黑白",
|
||||
"description": "黑白灰 · 正式严肃",
|
||||
"palette": {
|
||||
"bg": "#FFFFFF",
|
||||
"cover_bg": "#FFFFFF",
|
||||
"section_bg": "#F5F5F5",
|
||||
"section_text": "#FFFFFF",
|
||||
"primary": "#111111",
|
||||
"accent": "#444444",
|
||||
"text": "#1A1A1A",
|
||||
"muted": "#666666",
|
||||
"cover_text": "#111111",
|
||||
"box_line": "#999999"
|
||||
},
|
||||
"fonts": {
|
||||
"title": "Microsoft YaHei, Source Han Sans SC, Noto Sans CJK SC, sans-serif",
|
||||
"body": "Microsoft YaHei, Source Han Sans SC, Noto Sans CJK SC, sans-serif"
|
||||
},
|
||||
"sizes": {
|
||||
"cover_title_pt": 40,
|
||||
"title_pt": 30,
|
||||
"bullet_pt": 18,
|
||||
"reference_pt": 14,
|
||||
"figure_pt": 13
|
||||
},
|
||||
"cover_style": { "title_align": "center", "accent_bar": true },
|
||||
"content_style": { "figure_split_pct": 55 }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"id": "nature-green",
|
||||
"display_name": "自然绿",
|
||||
"label": "自然绿",
|
||||
"description": "自然生态绿 · 植物/生态领域",
|
||||
"palette": {
|
||||
"bg": "#FFFFFF",
|
||||
"cover_bg": "#F0FDF4",
|
||||
"section_bg": "#DCFCE7",
|
||||
"section_text": "#FFFFFF",
|
||||
"primary": "#15803D",
|
||||
"accent": "#22C55E",
|
||||
"text": "#14532D",
|
||||
"muted": "#4D7C57",
|
||||
"cover_text": "#14532D",
|
||||
"box_line": "#86EFAC"
|
||||
},
|
||||
"fonts": {
|
||||
"title": "Microsoft YaHei, Source Han Sans SC, Noto Sans CJK SC, sans-serif",
|
||||
"body": "Microsoft YaHei, Source Han Sans SC, Noto Sans CJK SC, sans-serif"
|
||||
},
|
||||
"sizes": {
|
||||
"cover_title_pt": 40,
|
||||
"title_pt": 30,
|
||||
"bullet_pt": 18,
|
||||
"reference_pt": 14,
|
||||
"figure_pt": 13
|
||||
},
|
||||
"cover_style": { "title_align": "left", "accent_bar": true },
|
||||
"content_style": { "figure_split_pct": 60 }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"id": "systems-talk",
|
||||
"display_name": "系统顶会",
|
||||
"label": "系统顶会",
|
||||
"description": "深蓝橙标 · 系统顶会报告",
|
||||
"palette": {
|
||||
"bg": "#FFFFFF",
|
||||
"cover_bg": "#FFFFFF",
|
||||
"section_bg": "#1E2633",
|
||||
"section_text": "#FFFFFF",
|
||||
"primary": "#1F3A5F",
|
||||
"accent": "#E4572E",
|
||||
"text": "#1A1A1A",
|
||||
"muted": "#6B6B6B",
|
||||
"cover_text": "#1F3A5F",
|
||||
"box_line": "#EEF1F4"
|
||||
},
|
||||
"fonts": {
|
||||
"title": "Arial, Helvetica, Microsoft YaHei, Source Han Sans SC, sans-serif",
|
||||
"body": "Arial, Helvetica, Microsoft YaHei, Source Han Sans SC, sans-serif"
|
||||
},
|
||||
"sizes": {
|
||||
"cover_title_pt": 44,
|
||||
"title_pt": 30,
|
||||
"bullet_pt": 20,
|
||||
"reference_pt": 16,
|
||||
"figure_pt": 13
|
||||
},
|
||||
"cover_style": {
|
||||
"title_align": "left",
|
||||
"accent_bar": true
|
||||
},
|
||||
"content_style": {
|
||||
"figure_split_pct": 60
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"id": "tech-dark",
|
||||
"display_name": "深色科技",
|
||||
"label": "深色科技",
|
||||
"description": "深色投影 · AI/工程领域",
|
||||
"palette": {
|
||||
"bg": "#0B1220",
|
||||
"cover_bg": "#0B1220",
|
||||
"section_bg": "#111827",
|
||||
"section_text": "#0B1220",
|
||||
"primary": "#38BDF8",
|
||||
"accent": "#22D3EE",
|
||||
"text": "#E2E8F0",
|
||||
"muted": "#94A3B8",
|
||||
"cover_text": "#F8FAFC",
|
||||
"box_line": "#475569"
|
||||
},
|
||||
"fonts": {
|
||||
"title": "Microsoft YaHei, Source Han Sans SC, Noto Sans CJK SC, sans-serif",
|
||||
"body": "Microsoft YaHei, Source Han Sans SC, Noto Sans CJK SC, sans-serif"
|
||||
},
|
||||
"sizes": {
|
||||
"cover_title_pt": 40,
|
||||
"title_pt": 30,
|
||||
"bullet_pt": 18,
|
||||
"reference_pt": 14,
|
||||
"figure_pt": 13
|
||||
},
|
||||
"cover_style": { "title_align": "center", "accent_bar": true },
|
||||
"content_style": { "figure_split_pct": 65 }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"id": "theory-beamer",
|
||||
"display_name": "理论报告",
|
||||
"label": "理论报告",
|
||||
"description": "灰绿橙点 · Metropolis 理论风",
|
||||
"palette": {
|
||||
"bg": "#FAFAFA",
|
||||
"cover_bg": "#FAFAFA",
|
||||
"section_bg": "#23373B",
|
||||
"section_text": "#FFFFFF",
|
||||
"primary": "#23373B",
|
||||
"accent": "#EB811B",
|
||||
"text": "#23373B",
|
||||
"muted": "#6B7378",
|
||||
"cover_text": "#23373B",
|
||||
"box_line": "#D3D6D8"
|
||||
},
|
||||
"fonts": {
|
||||
"title": "Fira Sans, Calibri, Microsoft YaHei, Source Han Sans SC, sans-serif",
|
||||
"body": "Fira Sans, Calibri, Microsoft YaHei, Source Han Sans SC, sans-serif"
|
||||
},
|
||||
"sizes": {
|
||||
"cover_title_pt": 38,
|
||||
"title_pt": 26,
|
||||
"bullet_pt": 20,
|
||||
"reference_pt": 16,
|
||||
"figure_pt": 13
|
||||
},
|
||||
"cover_style": {
|
||||
"title_align": "left",
|
||||
"accent_bar": false
|
||||
},
|
||||
"content_style": {
|
||||
"figure_split_pct": 60
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"id": "warm-earth",
|
||||
"display_name": "暖色土系",
|
||||
"label": "暖色土系",
|
||||
"description": "暖土色系 · 农业领域",
|
||||
"palette": {
|
||||
"bg": "#FFFBF5",
|
||||
"cover_bg": "#FFF7ED",
|
||||
"section_bg": "#FFEDD5",
|
||||
"section_text": "#FFFFFF",
|
||||
"primary": "#9A3412",
|
||||
"accent": "#EA580C",
|
||||
"text": "#431407",
|
||||
"muted": "#9A6A55",
|
||||
"cover_text": "#7C2D12",
|
||||
"box_line": "#FDBA74"
|
||||
},
|
||||
"fonts": {
|
||||
"title": "Microsoft YaHei, Source Han Sans SC, Noto Sans CJK SC, sans-serif",
|
||||
"body": "Microsoft YaHei, Source Han Sans SC, Noto Sans CJK SC, sans-serif"
|
||||
},
|
||||
"sizes": {
|
||||
"cover_title_pt": 40,
|
||||
"title_pt": 30,
|
||||
"bullet_pt": 18,
|
||||
"reference_pt": 14,
|
||||
"figure_pt": 13
|
||||
},
|
||||
"cover_style": { "title_align": "left", "accent_bar": true },
|
||||
"content_style": { "figure_split_pct": 60 }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
[pytest]
|
||||
python_files = test_*.py
|
||||
pythonpath = .
|
||||
markers =
|
||||
review_unit: 审稿服务单元测试
|
||||
asyncio_mode = auto
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
# restart.sh —— REVIEW 服务重启脚本(8100;不触碰生产服务 8090/8091/8092/8093/8094/8097/8098)。
|
||||
# 用法: restart.sh [python] [port]
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# 默认 venv:本机统一 venv 固定路径(原先 $REPO_ROOT/../ 推导在本工作区不存在)
|
||||
PYTHON="${1:-/home/xzq/5090/teamWorkspace/zh/plantai-rag-new-venv/bin/python}"
|
||||
PORT="${2:-8100}"
|
||||
HOST="${REVIEW_BIND_HOST:-127.0.0.1}"
|
||||
|
||||
[[ -x "$PYTHON" ]] || { echo "Python not found: $PYTHON" >&2; exit 2; }
|
||||
|
||||
APP_MODULE="app.main:app"
|
||||
if command -v lsof >/dev/null 2>&1; then
|
||||
PID="$(lsof -ti tcp:"$PORT" -sTCP:LISTEN 2>/dev/null || true)"
|
||||
if [[ -n "$PID" ]]; then
|
||||
echo "[restart] 停止 $PORT 旧进程 pid=$PID"
|
||||
kill $PID 2>/dev/null || true
|
||||
sleep 2
|
||||
fi
|
||||
fi
|
||||
|
||||
cd "$SCRIPT_DIR"
|
||||
# 加载运行时配置(.env 不入 git):services/llm.py 直接读 REVIEW_LLM_*/DOCGEN_LLM_*
|
||||
# 裸键,paper_parse.py 读 REVIEW_*——需 set -a 显式导出(同 paper_writer_service 先例)。
|
||||
set -a
|
||||
[ -f .env ] && source .env
|
||||
set +a
|
||||
echo "[restart] 启动审稿服务 :$PORT($PYTHON)"
|
||||
nohup "$PYTHON" -m uvicorn "$APP_MODULE" --host "$HOST" --port "$PORT" \
|
||||
> "$SCRIPT_DIR/review.log" 2>&1 &
|
||||
# P3b:后台预热 tectonic TeX bundle(非阻塞;仅首次需 1-3 分钟,失败不影响服务)
|
||||
nohup bash "$SCRIPT_DIR/scripts/prewarm_tectonic.sh" \
|
||||
> "$SCRIPT_DIR/prewarm.log" 2>&1 &
|
||||
echo "[restart] 完成;日志 review_service/review.log(预热 prewarm.log)"
|
||||
@@ -0,0 +1,17 @@
|
||||
# scripts/ —— 离线工具(不被 app/ 服务代码引用,独立 CLI)
|
||||
|
||||
## fetch_format_rules.py —— 投稿指南自动抓取(探索性,第一批)
|
||||
|
||||
- 依赖:venv 已装的 httpx + beautifulsoup4 4.15.0(用户拍板 2026-10-10);
|
||||
解析器 lxml 6.1.2 在 `tooling/requirements.lock` 内。
|
||||
- 用法与流程见 `fetch_format_rules.py` 模块 docstring(CLI 一次一本期刊)。
|
||||
- 产物:`data/format_cache/{journal_id}.json`(运行产物,已 gitignore,不入库)。
|
||||
|
||||
### 现状(2026-10-10)
|
||||
|
||||
OUP / Wiley / Cell 官网对本运行环境返回 403,自动抓取路线失败;
|
||||
Genome Biology / BMC Plant Biology / Frontiers / Rice 可抓但 LLM 抽取结果
|
||||
仍需人工对照官网。当前 `journals.json` 中 5 本精选期刊的 `format_rules`
|
||||
为**人工核验官网原文后写入**(`rules_source=official` + `source_url` +
|
||||
`last_verified`),**不是**本脚本产物。本工具保留作为后续换网络环境 /
|
||||
换可达站点后的重跑手段。
|
||||
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env python3
|
||||
"""scripts/fetch_format_rules.py —— 格式规则自动获取(第一批,人工核验用 CLI)。
|
||||
|
||||
用法(在 review_service/ 下):
|
||||
/home/xzq/5090/teamWorkspace/zh/plantai-rag-new-venv/bin/python \
|
||||
scripts/fetch_format_rules.py <journal_id> <guidelines_url>
|
||||
|
||||
流程:
|
||||
1. fetch_submission_guidelines(url) # robots.txt + 同域 ≥2s 限速 + 正文转 Markdown
|
||||
2. extract_format_rules(markdown, journal_name) # DeepSeek 抽取(llm.py)
|
||||
3. 存 data/format_cache/{journal_id}.json(失败也存,便于批量对账)
|
||||
4. 打印完整结果 JSON 供人工核验
|
||||
|
||||
凭证:结果文件顶层 source_url(步骤 1 的实际抓取地址)+ source_quotes(LLM
|
||||
逐字引证,按字段分组);非 null 字段缺引证 → 抽取判失败(ok=false)。
|
||||
|
||||
约束(第一批):只跑指定 3 本;不触 API / 前端 / journals.json(format_rules 只读
|
||||
查名,不写回)。journal_name 优先取 data/journals/journals.json 的 name(只读)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
_ROOT = Path(__file__).resolve().parents[1] # review_service/
|
||||
sys.path.insert(0, str(_ROOT))
|
||||
|
||||
_JOURNALS_JSON = _ROOT / "data" / "journals" / "journals.json"
|
||||
_CACHE_DIR = _ROOT / "data" / "format_cache"
|
||||
|
||||
|
||||
def _load_env() -> None:
|
||||
"""镜像 restart.sh 的 `set -a; source .env`:REVIEW_LLM_*/DOCGEN_LLM_* 裸键
|
||||
由 services/llm.py 在每次调用时读 os.environ(不缓存),故调用前灌入即可。"""
|
||||
env_path = _ROOT / ".env"
|
||||
if not env_path.is_file():
|
||||
return
|
||||
for raw in env_path.read_text("utf-8").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
if line.startswith("export "):
|
||||
line = line[7:].strip()
|
||||
key, _, val = line.partition("=")
|
||||
os.environ.setdefault(key.strip(), val.strip().strip("\"'"))
|
||||
|
||||
|
||||
def _journal_name(journal_id: str) -> str:
|
||||
"""journals.json 只读查名;查不到 → id 连字符转空格并首字母大写(LLM 上下文可读)。"""
|
||||
try:
|
||||
data = json.loads(_JOURNALS_JSON.read_text("utf-8"))
|
||||
for j in data.get("journals") or []:
|
||||
if j.get("id") == journal_id:
|
||||
return str(j.get("name") or journal_id)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return journal_id.replace("-", " ").title()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) != 3:
|
||||
print("用法: fetch_format_rules.py <journal_id> <guidelines_url>", file=sys.stderr)
|
||||
return 2
|
||||
journal_id = re.sub(r"[^\w.-]", "_", sys.argv[1].strip())
|
||||
url = sys.argv[2].strip()
|
||||
_load_env()
|
||||
|
||||
from app.services.format_extractor import extract_format_rules
|
||||
from app.services.format_fetcher import fetch_submission_guidelines
|
||||
|
||||
async def run() -> tuple[dict, dict]:
|
||||
fetched = await fetch_submission_guidelines(url)
|
||||
print(f"[1/2] 抓取 {url} → ok={fetched['ok']}"
|
||||
+ (f",markdown={len(fetched['markdown'])} 字符"
|
||||
if fetched["ok"] else f",error={fetched['error']}"))
|
||||
if not fetched["ok"]:
|
||||
return fetched, {"ok": False, "error": f"抓取失败:{fetched['error']}",
|
||||
"rules": None, "source_quotes": None,
|
||||
"confidence": None, "notes": None, "raw": None}
|
||||
extracted = await extract_format_rules(fetched["markdown"], _journal_name(journal_id))
|
||||
if extracted["ok"]:
|
||||
filled = sum(1 for v in extracted["rules"].values() if v is not None)
|
||||
print(f"[2/2] LLM 抽取 → ok=True,8 字段中 {filled} 个非 null")
|
||||
else:
|
||||
print(f"[2/2] LLM 抽取 → ok=False,error={extracted['error']}")
|
||||
return fetched, extracted
|
||||
|
||||
fetched, extracted = asyncio.run(run())
|
||||
|
||||
result = {
|
||||
"journal_id": journal_id,
|
||||
"journal_name": _journal_name(journal_id),
|
||||
"source_url": url,
|
||||
"fetched_at": fetched.get("fetched_at"),
|
||||
"extracted_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
"markdown_chars": len(fetched.get("markdown") or ""),
|
||||
"ok": bool(extracted.get("ok")),
|
||||
"error": extracted.get("error"),
|
||||
"format_rules": ({**extracted["rules"], "rules_source": "fetched"}
|
||||
if extracted.get("ok") else None),
|
||||
"source_quotes": extracted.get("source_quotes"),
|
||||
"confidence": extracted.get("confidence"),
|
||||
"notes": extracted.get("notes"),
|
||||
"raw_llm": extracted.get("raw"),
|
||||
}
|
||||
_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
out_path = _CACHE_DIR / f"{journal_id}.json"
|
||||
out_path.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", "utf-8")
|
||||
|
||||
print(f"[save] {out_path.relative_to(_ROOT)}")
|
||||
print("===== 完整结果(人工核验)=====")
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
return 0 if result["ok"] else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,208 @@
|
||||
#!/usr/bin/env python3
|
||||
"""scripts/import_journals.py —— 期刊目录导入 CLI(source_v4.xlsx → SQLite journal_directory)。
|
||||
|
||||
核心解析/upsert/schema 在 app/services/journal_seed.py(与启动自动 seed 共享);
|
||||
本 CLI 仅 import 该模块(sys.path bootstrap),零网络零 BFF,仍不碰 db.py。
|
||||
职责:口径校验 → 幂等 upsert → 对账报告 data/journals/import_report.txt。
|
||||
|
||||
数据流:
|
||||
openpyxl 非 read_only 加载(需要读 cell.hyperlink.target 来源链接)
|
||||
→ 「国际期刊」「中文期刊」两 sheet 按【列名】映射(不按索引,防列序漂移)
|
||||
→ 行级清洗(空→None 绝不填 0;分区数字→"N区";JCR学科→JSON 数组)
|
||||
→ 按 id 幂等 upsert(ON CONFLICT DO UPDATE,重跑 count 不变)
|
||||
→ 对账报告 data/journals/import_report.txt
|
||||
|
||||
v4 源(2026-10-10):JIF=2025 统计年/2026 JCR 发布;中文 IF=2024 统计年/
|
||||
年报 2025 版;新增官网三列 + JCR发布年;JCR学科列改名「JCR学科(2024源表)」。
|
||||
旧文件 source.xlsx(v2)原样保留为回退点,勿覆盖。
|
||||
|
||||
schema 迁移(幂等 + 备份):既有表缺列 → PRAGMA table_info 探测后 ALTER TABLE
|
||||
ADD COLUMN,缺哪加哪、重复跑不报错;跑前先 cp review.db review.db.bak-<日期>。
|
||||
|
||||
id 策略:有 ISSN → "{lang}:{ISSN原样(strip)}";无 ISSN → "{lang}:{slug(name)}";
|
||||
同 lang 内 id 冲突 → 报告冲突列表并【中止导入】(非零退出,不写库)。
|
||||
|
||||
用法:python scripts/import_journals.py (cwd 任意;路径按本文件位置推导)
|
||||
退出码:0 成功;1 id 冲突中止;2 源数据行数与口径不符;3 结构/文件错误。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import time
|
||||
from collections import Counter
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# ---- 路径(review_service/scripts/ → parents[1] = review_service)----
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT)) # 供 app.services.journal_seed import
|
||||
|
||||
from app.services.journal_seed import ( # noqa: E402
|
||||
UPSERT_SQL,
|
||||
EXPECT_INTL,
|
||||
EXPECT_ZH,
|
||||
SHEET_INTL,
|
||||
SHEET_ZH,
|
||||
JournalSeedError,
|
||||
extract_rows_from_xlsx,
|
||||
new_stats,
|
||||
upsert_to_db,
|
||||
)
|
||||
|
||||
SOURCE_XLSX = ROOT / "data" / "journals" / "source_v4.xlsx" # v4;v2=source.xlsx 保留回退
|
||||
REPORT_TXT = ROOT / "data" / "journals" / "import_report.txt"
|
||||
|
||||
|
||||
def build_report(
|
||||
stage: str,
|
||||
stats: dict,
|
||||
counts: dict[str, int] | None,
|
||||
conflicts: list[str],
|
||||
idempotent_ok: bool | None,
|
||||
exit_note: str = "",
|
||||
) -> str:
|
||||
L: list[str] = []
|
||||
L.append("journals directory 导入对账报告")
|
||||
L.append(f"生成时间: {datetime.now().isoformat(timespec='seconds')}")
|
||||
L.append(f"源文件: {SOURCE_XLSX}({SOURCE_XLSX.stat().st_size if SOURCE_XLSX.exists() else 0} bytes)")
|
||||
L.append(f"阶段: {stage}")
|
||||
L.append("")
|
||||
L.append("=== 计数 ===")
|
||||
L.append(f"读取有效行: 国际 {stats['read_intl']} / 中文 {stats['read_zh']} / 合计 {stats['read_intl'] + stats['read_zh']}")
|
||||
L.append(f"成功: {stats['success']} 跳过(名称空): {stats['skipped']} 失败: {stats['failed']}")
|
||||
if counts is not None:
|
||||
L.append(f"写入后 count(*): en={counts.get('en', 0)} zh={counts.get('zh', 0)} total={counts.get('total', 0)}")
|
||||
L.append(f"期望: en={EXPECT_INTL} zh={EXPECT_ZH} total={EXPECT_INTL + EXPECT_ZH} → {'✓ 相符' if counts.get('total') == EXPECT_INTL + EXPECT_ZH else '✗ 不符'}")
|
||||
L.append("")
|
||||
L.append("=== 分区分布(国际)===")
|
||||
L.append(" / ".join(f"{k} {v}" for k, v in sorted(stats["part_dist"].items())))
|
||||
L.append(f"分区空白(→null 无note): {len(stats['part_blank'])}")
|
||||
L.append(f"分区未检得(→null + note): {len(stats['part_notfound'])}")
|
||||
L.append("")
|
||||
L.append(f"=== JIF 缺失 {len(stats['jif_missing'])} 条(jif=null + note=核对状态)===")
|
||||
for n, s in stats["jif_missing"]:
|
||||
L.append(f" - {n} [{s}]")
|
||||
L.append("")
|
||||
L.append(f"=== 中文复合IF 缺失 {len(stats['cif_missing'])} 条 ===")
|
||||
for n, s in stats["cif_missing"]:
|
||||
L.append(f" - {n} [{s}]")
|
||||
L.append("")
|
||||
L.append("=== 分区未检得 期刊名 ===")
|
||||
if stats["part_notfound"]:
|
||||
for n in stats["part_notfound"]:
|
||||
L.append(f" - {n}")
|
||||
else:
|
||||
L.append(" (无——源表以空白表示未检得,见上方'分区空白')")
|
||||
L.append("")
|
||||
L.append("=== 重复 id 冲突(应为空)===")
|
||||
if conflicts:
|
||||
for c in conflicts:
|
||||
L.append(f" !! {c}")
|
||||
else:
|
||||
L.append(" (无)")
|
||||
L.append("")
|
||||
L.append("=== id 策略 ===")
|
||||
L.append(f"ISSN 型: {stats['id_issn']} 条 slug 型: {stats['id_slug']} 条")
|
||||
L.append("")
|
||||
L.append("=== 来源超链接 ===")
|
||||
L.append(f"国际 JIF来源: {stats['hl_jif']}/{stats['read_intl']} 分区来源: {stats['hl_part']}/{stats['read_intl']} 中文数据来源: {stats['hl_zh']}/{stats['read_zh']}")
|
||||
L.append("")
|
||||
L.append("=== 结构迁移(ALTER TABLE,幂等)===")
|
||||
added = stats.get("migrated_cols") or []
|
||||
skipped = stats.get("migrated_skipped") or []
|
||||
L.append(f" 本次加列: {', '.join(added) if added else '无(列已齐)'}")
|
||||
L.append(f" 已存在跳过: {', '.join(skipped) if skipped else '无'}")
|
||||
L.append("")
|
||||
L.append("=== 官网映射(决策:official_source 不入库)===")
|
||||
L.append(f" 识别列: {stats.get('official_url_headers', '未识别')}")
|
||||
tot = stats.get("official_total", 0)
|
||||
L.append(f" official_url 填充: {stats.get('official_filled', 0)}/{tot}"
|
||||
f" official_check_status 填充: {stats.get('official_check_filled', 0)}/{tot}")
|
||||
L.append(f" jcr_release_year 填充: {stats.get('jcr_filled', 0)}(仅国际=2026;中文表无此列→null)")
|
||||
missing = stats.get("official_missing") or []
|
||||
if missing:
|
||||
L.append(f" 官网缺失 {len(missing)} 条(official_url=null,前端显示「—」不生成链接):")
|
||||
for lg, n in missing:
|
||||
L.append(f" - [{lg}] {n}")
|
||||
else:
|
||||
L.append(" 官网缺失: (无)")
|
||||
L.append("")
|
||||
L.append("=== 幂等性验证 ===")
|
||||
if idempotent_ok is None:
|
||||
L.append(" 未执行")
|
||||
else:
|
||||
L.append(f" 第二次 upsert 后 count 不变: {'✓ 通过' if idempotent_ok else '✗ 失败'}")
|
||||
L.append("")
|
||||
if exit_note:
|
||||
L.append(f"!!! {exit_note}")
|
||||
return "\n".join(L) + "\n"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not SOURCE_XLSX.exists():
|
||||
print(f"[错误] 源文件不存在: {SOURCE_XLSX}", file=sys.stderr)
|
||||
return 3
|
||||
|
||||
stats: dict[str, Any] = new_stats()
|
||||
imported_at = datetime.now().isoformat(timespec="seconds")
|
||||
t0 = time.time()
|
||||
|
||||
# 1) 读 Excel + 解析 + 口径/id 校验(journal_seed 共享;失败不写库)
|
||||
try:
|
||||
rows_en, rows_zh, stats = extract_rows_from_xlsx(
|
||||
SOURCE_XLSX, imported_at=imported_at, stats=stats)
|
||||
except JournalSeedError as exc:
|
||||
REPORT_TXT.write_text(
|
||||
build_report(exc.stage, stats, None, exc.conflicts, None, str(exc)),
|
||||
encoding="utf-8")
|
||||
if exc.exit_code == 1:
|
||||
print(f"[中止] {exc}", file=sys.stderr)
|
||||
for c in exc.conflicts:
|
||||
print(f" !! {c}", file=sys.stderr)
|
||||
elif exc.exit_code == 2:
|
||||
print(f"[中止] {exc}", file=sys.stderr)
|
||||
else:
|
||||
print(f"[错误] {exc}", file=sys.stderr)
|
||||
return exc.exit_code
|
||||
|
||||
all_rows = rows_en + rows_zh
|
||||
|
||||
# 2) 写 SQLite(独立 stdlib 连接;表由 journal_seed 创建,db.py 零触碰)
|
||||
db_path = Path(os.environ.get("REVIEW_DB_PATH") or (ROOT / "review.db"))
|
||||
if not db_path.is_absolute():
|
||||
db_path = ROOT / db_path
|
||||
counts = upsert_to_db(db_path, rows_en, rows_zh)
|
||||
stats["success"] = len(all_rows)
|
||||
|
||||
# 3) 幂等验证:同数据第二次 upsert → count 不变
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
try:
|
||||
conn.executemany(UPSERT_SQL, all_rows)
|
||||
conn.commit()
|
||||
cur = conn.execute(
|
||||
"SELECT lang, COUNT(*) FROM journal_directory GROUP BY lang")
|
||||
d = {lang: n for lang, n in cur.fetchall()}
|
||||
idempotent_ok = {"en": d.get("en", 0), "zh": d.get("zh", 0),
|
||||
"total": sum(d.values())} == counts
|
||||
|
||||
# 分区分布来自实际库(防统计与写入脱节;未检得 note 单列)
|
||||
stats["part_dist"] = Counter(dict(conn.execute(
|
||||
"SELECT COALESCE(cas_partition, "
|
||||
"CASE WHEN cas_partition_note='未检得' THEN '未检得' ELSE '空白' END), COUNT(*) "
|
||||
"FROM journal_directory WHERE lang='en' GROUP BY 1").fetchall()))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
report = build_report("完成", stats, counts, [], idempotent_ok)
|
||||
REPORT_TXT.write_text(report, encoding="utf-8")
|
||||
print(report)
|
||||
print(f"[完成] 耗时 {time.time() - t0:.2f}s db={db_path} 报告={REPORT_TXT}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# prewarm_tectonic.sh —— P3b:预下载 tectonic TeX bundle(宏包缓存)
|
||||
# 目的:服务启动时预热 data/tectonic-cache/,用户首次编译不必等 1-3 分钟下载。
|
||||
# 用法: bash scripts/prewarm_tectonic.sh (后台跑,非阻塞;restart.sh 自动调起)
|
||||
# 注:tectonic v0.17.0 无 `download` 子命令——预热 = 用最小 hello.tex 实编译一次,
|
||||
# 让 bundle/宏包落进 TECTONIC_CACHE_DIR(同 tools/README.md 预热法)。
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
TECTONIC="$SCRIPT_DIR/tools/tectonic"
|
||||
[[ -x "$TECTONIC" ]] || { echo "[prewarm] tectonic 不存在,跳过:$TECTONIC"; exit 0; }
|
||||
|
||||
export TECTONIC_CACHE_DIR="${REVIEW_TECTONIC_CACHE_DIR:-$SCRIPT_DIR/data/tectonic-cache}"
|
||||
mkdir -p "$TECTONIC_CACHE_DIR"
|
||||
|
||||
TMPDIR_PREWARM="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMPDIR_PREWARM"' EXIT
|
||||
cat > "$TMPDIR_PREWARM/hello.tex" <<'EOF'
|
||||
\documentclass{article}
|
||||
\begin{document}
|
||||
prewarm
|
||||
\end{document}
|
||||
EOF
|
||||
|
||||
echo "[prewarm] 预下载 TeX bundle 至 $TECTONIC_CACHE_DIR(首次 1-3 分钟,仅一次)"
|
||||
if "$TECTONIC" -X compile "$TMPDIR_PREWARM/hello.tex" --untrusted \
|
||||
--outdir "$TMPDIR_PREWARM" >/dev/null 2>&1; then
|
||||
echo "[prewarm] OK:宏包缓存就绪,用户首编可秒级命中"
|
||||
else
|
||||
echo "[prewarm] 预热失败(网络/磁盘):不影响服务启动,首编时仍会现场拉取" >&2
|
||||
exit 0
|
||||
fi
|
||||
@@ -0,0 +1,11 @@
|
||||
"""tests/conftest.py —— 全局测试环境(不 import app,须先于测试模块执行)。
|
||||
|
||||
- REVIEW_SEED_ON_STARTUP=false:默认关闭启动自动 seed,防止单测触碰真实
|
||||
review.db、并避免每个 TestClient 都导入 1056 行 xlsx(开关自身由
|
||||
test_seed.py 显式覆盖验证)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("REVIEW_SEED_ON_STARTUP", "false")
|
||||
@@ -0,0 +1,228 @@
|
||||
"""tests/test_compile.py —— P3a LaTeX 编译单元/集成测试。
|
||||
|
||||
真实调用 tools/tectonic(宏包已预热缓存);二进制缺失时集成用例 skipif 跳过,
|
||||
校验/队列/清扫等纯逻辑用例照常运行。
|
||||
运行: cd review_service && <venv>/bin/python -m pytest -q
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.config import get_settings
|
||||
from app.main import app
|
||||
from app.services import latex_compile as lc
|
||||
|
||||
MINIMAL = "\\documentclass[11pt]{article}\n\\begin{document}\nhello p3\n\\end{document}\n"
|
||||
|
||||
_TECTONIC_OK = get_settings().tectonic_abs.is_file()
|
||||
|
||||
needs_tectonic = pytest.mark.skipif(
|
||||
not _TECTONIC_OK, reason="tools/tectonic 未安装(bash tools/install_tectonic.sh)"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(tmp_path, monkeypatch):
|
||||
settings = get_settings()
|
||||
monkeypatch.setattr(settings, "reformat_dir", str(tmp_path / "jobs"))
|
||||
# 缓存目录不注入 tmp:用部署预热好的 data/tectonic-cache(否则每次测试重拉 bundle)
|
||||
monkeypatch.setattr(settings, "compile_timeout_s", 60.0)
|
||||
monkeypatch.setattr(lc, "_pending", 0)
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
|
||||
|
||||
# ================================================================ 校验(无需 tectonic)
|
||||
|
||||
def test_validation_no_input(client: TestClient):
|
||||
r = client.post("/api/review/compile", data={})
|
||||
body = r.json()
|
||||
assert r.status_code == 200 and body["ok"] is False
|
||||
assert body["error"]["code"] == "VALIDATION"
|
||||
|
||||
|
||||
def test_validation_bad_job_id(client: TestClient):
|
||||
r = client.post("/api/review/compile", data={"job_id": "../evil"})
|
||||
assert r.json()["error"]["code"] == "VALIDATION"
|
||||
|
||||
|
||||
def test_validation_job_not_found(client: TestClient):
|
||||
r = client.post("/api/review/compile", data={"job_id": "0123456789ab"})
|
||||
assert r.json()["error"]["code"] == "VALIDATION"
|
||||
|
||||
|
||||
def test_validation_path_traversal_filename(client: TestClient):
|
||||
r = client.post(
|
||||
"/api/review/compile",
|
||||
files={"file": ("../evil.tex", b"x", "application/x-tex")},
|
||||
)
|
||||
assert r.json()["error"]["code"] == "VALIDATION"
|
||||
|
||||
|
||||
def test_validation_non_tex(client: TestClient):
|
||||
r = client.post(
|
||||
"/api/review/compile",
|
||||
files={"file": ("paper.pdf", b"x", "application/pdf")},
|
||||
)
|
||||
assert r.json()["error"]["code"] == "VALIDATION"
|
||||
|
||||
|
||||
def test_validation_oversize(client: TestClient, monkeypatch):
|
||||
monkeypatch.setattr(get_settings(), "max_tex_bytes", 10)
|
||||
r = client.post(
|
||||
"/api/review/compile",
|
||||
files={"file": ("paper.tex", b"01234567890", "application/x-tex")},
|
||||
)
|
||||
assert r.json()["error"]["code"] == "VALIDATION"
|
||||
|
||||
|
||||
# ================================================================ 编译(真实 tectonic)
|
||||
|
||||
@needs_tectonic
|
||||
def test_compile_happy_path(client: TestClient):
|
||||
r = client.post(
|
||||
"/api/review/compile",
|
||||
files={"file": ("paper.tex", MINIMAL.encode(), "application/x-tex")},
|
||||
)
|
||||
body = r.json()
|
||||
assert r.status_code == 200 and body["ok"] is True, body
|
||||
cid = body["compile_id"]
|
||||
assert len(cid) == 12
|
||||
|
||||
pdf = client.get(body["pdf_url"])
|
||||
assert pdf.status_code == 200
|
||||
assert pdf.headers["content-type"].startswith("application/pdf")
|
||||
assert pdf.content[:5] == b"%PDF-"
|
||||
|
||||
log = client.get(body["log_url"])
|
||||
assert log.status_code == 200
|
||||
assert "LaTeX2e" in log.text and "Output written" in log.text
|
||||
|
||||
meta = client.get(f"/api/review/compile/{cid}")
|
||||
assert meta.json()["ok"] is True
|
||||
assert meta.json()["meta"]["pdf_bytes"] > 0
|
||||
|
||||
|
||||
@needs_tectonic
|
||||
def test_compile_by_job_id(client: TestClient):
|
||||
tex = (
|
||||
"\\documentclass[11pt]{article}\n\\begin{document}\nvia job\n\\end{document}\n"
|
||||
)
|
||||
rf = client.post(
|
||||
"/api/review/reformat",
|
||||
files={"file": ("paper.tex", tex.encode(), "application/x-tex")},
|
||||
data={"target_journal": "default"},
|
||||
).json()
|
||||
assert rf["ok"] is True
|
||||
r = client.post("/api/review/compile", data={"job_id": rf["job_id"]})
|
||||
body = r.json()
|
||||
assert body["ok"] is True and body["compile_id"] == rf["job_id"]
|
||||
pdf = client.get(body["pdf_url"])
|
||||
assert pdf.content[:5] == b"%PDF-"
|
||||
|
||||
|
||||
@needs_tectonic
|
||||
def test_compile_malicious_write18(client: TestClient, tmp_path):
|
||||
canary = tmp_path / "canary_p3"
|
||||
evil = (
|
||||
"\\documentclass{article}\n"
|
||||
f"\\write18{{touch {canary}}}\n"
|
||||
"\\begin{document}\nno shell please\n\\end{document}\n"
|
||||
)
|
||||
r = client.post(
|
||||
"/api/review/compile",
|
||||
files={"file": ("evil.tex", evil.encode(), "application/x-tex")},
|
||||
)
|
||||
body = r.json()
|
||||
# 系统无损即通过(信封 ok 与否均可):canary 绝不能出现
|
||||
assert not canary.exists(), r"\write18 竟然执行了!"
|
||||
if body["ok"]:
|
||||
pdf = client.get(body["pdf_url"])
|
||||
assert pdf.content[:5] == b"%PDF-"
|
||||
else:
|
||||
assert body["error"]["code"] in ("COMPILE_FAILED", "TIMEOUT")
|
||||
|
||||
|
||||
@needs_tectonic
|
||||
def test_compile_failed_returns_log_tail(client: TestClient):
|
||||
bad = "\\documentclass{article}\n\\begin{document}\n\\undefinedcommandxyz\n"
|
||||
r = client.post(
|
||||
"/api/review/compile",
|
||||
files={"file": ("bad.tex", bad.encode(), "application/x-tex")},
|
||||
)
|
||||
body = r.json()
|
||||
assert body["ok"] is False
|
||||
assert body["error"]["code"] == "COMPILE_FAILED"
|
||||
assert "log" in body["error"]["message"].lower() or body["error"].get("detail")
|
||||
|
||||
|
||||
@needs_tectonic
|
||||
def test_compile_timeout(client: TestClient, monkeypatch):
|
||||
monkeypatch.setattr(get_settings(), "compile_timeout_s", 5.0)
|
||||
loop_tex = (
|
||||
"\\documentclass{article}\n\\begin{document}\n"
|
||||
"\\count255=0\n\\loop\\iftrue\\advance\\count255 by 1\\repeat\n"
|
||||
"\\end{document}\n"
|
||||
)
|
||||
started = time.monotonic()
|
||||
r = client.post(
|
||||
"/api/review/compile",
|
||||
files={"file": ("loop.tex", loop_tex.encode(), "application/x-tex")},
|
||||
)
|
||||
elapsed = time.monotonic() - started
|
||||
body = r.json()
|
||||
assert body["ok"] is False
|
||||
assert body["error"]["code"] == "TIMEOUT"
|
||||
assert elapsed < 15.0, f"超时回收过慢:{elapsed:.1f}s"
|
||||
|
||||
|
||||
# ================================================================ 队列 / 清扫
|
||||
|
||||
def test_queue_full(client: TestClient, monkeypatch):
|
||||
monkeypatch.setattr(lc, "_pending", 5)
|
||||
monkeypatch.setattr(get_settings(), "compile_queue_limit", 5)
|
||||
r = client.post(
|
||||
"/api/review/compile",
|
||||
files={"file": ("paper.tex", MINIMAL.encode(), "application/x-tex")},
|
||||
)
|
||||
assert r.status_code == 429
|
||||
body = r.json()
|
||||
assert body["ok"] is False and body["error"]["code"] == "QUEUE_FULL"
|
||||
|
||||
|
||||
def test_tool_unavailable(client: TestClient, monkeypatch):
|
||||
monkeypatch.setattr(get_settings(), "tectonic_path", "tools/definitely-missing")
|
||||
r = client.post(
|
||||
"/api/review/compile",
|
||||
files={"file": ("paper.tex", MINIMAL.encode(), "application/x-tex")},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["error"]["code"] == "TOOL_UNAVAILABLE"
|
||||
assert "install_tectonic" in r.json()["error"]["message"]
|
||||
|
||||
|
||||
def test_sweep_expired(tmp_path, monkeypatch):
|
||||
settings = get_settings()
|
||||
monkeypatch.setattr(settings, "reformat_dir", str(tmp_path / "jobs"))
|
||||
base = tmp_path / "jobs"
|
||||
stale = base / "aabbccddeeff"
|
||||
stale.mkdir(parents=True)
|
||||
(stale / "output.pdf").write_bytes(b"%PDF-fake")
|
||||
old = time.time() - 8 * 86400
|
||||
os.utime(stale, (old, old))
|
||||
|
||||
fresh = base / "112233445566"
|
||||
fresh.mkdir()
|
||||
noise = base / "not-a-job"
|
||||
noise.mkdir()
|
||||
|
||||
removed = lc.sweep_expired()
|
||||
assert removed == 1
|
||||
assert not stale.exists()
|
||||
assert fresh.exists()
|
||||
assert noise.exists()
|
||||
@@ -0,0 +1,639 @@
|
||||
"""tests/test_compile_zip.py —— P4 项目 zip 两段式编译集成测试。
|
||||
|
||||
真实调用 tools/tectonic(宏包已预热缓存);二进制缺失时集成用例 skipif 跳过,
|
||||
校验/消费/清扫等纯逻辑用例照常运行。
|
||||
运行: cd review_service && <venv>/bin/python -m pytest -q
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.config import get_settings
|
||||
from app.main import app
|
||||
from app.services import latex_compile as lc
|
||||
|
||||
MINIMAL = "\\documentclass[11pt]{article}\n\\begin{document}\nhello p4\n\\end{document}\n"
|
||||
|
||||
_TECTONIC_OK = get_settings().tectonic_abs.is_file()
|
||||
|
||||
needs_tectonic = pytest.mark.skipif(
|
||||
not _TECTONIC_OK, reason="tools/tectonic 未安装(bash tools/install_tectonic.sh)"
|
||||
)
|
||||
|
||||
|
||||
def _zip_bytes(entries: dict[str, str | bytes]) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for name, data in entries.items():
|
||||
zf.writestr(name, data)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _upload(client: TestClient, payload: bytes, name: str = "project.zip"):
|
||||
return client.post(
|
||||
"/api/review/compile/inspect",
|
||||
files={"file": (name, payload, "application/zip")},
|
||||
)
|
||||
|
||||
|
||||
def _tiny_pdf() -> bytes:
|
||||
"""极小合法 PDF(1 页 20x20 蓝色方块),供图片内嵌集成用例。"""
|
||||
stream = b"0 0 1 rg 2 2 10 10 re f\n"
|
||||
objs = [
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 20 20]"
|
||||
b" /Contents 4 0 R /Resources << >> >>",
|
||||
b"<< /Length " + str(len(stream)).encode() + b" >>\nstream\n"
|
||||
+ stream + b"endstream",
|
||||
]
|
||||
out = bytearray(b"%PDF-1.4\n")
|
||||
offsets = []
|
||||
for i, body in enumerate(objs, 1):
|
||||
offsets.append(len(out))
|
||||
out += f"{i} 0 obj\n".encode() + body + b"\nendobj\n"
|
||||
xref_pos = len(out)
|
||||
out += f"xref\n0 {len(objs) + 1}\n".encode()
|
||||
out += b"0000000000 65535 f \n"
|
||||
for off in offsets:
|
||||
out += f"{off:010d} 00000 n \n".encode()
|
||||
out += (f"trailer\n<< /Size {len(objs) + 1} /Root 1 0 R >>\n"
|
||||
f"startxref\n{xref_pos}\n%%EOF\n").encode()
|
||||
return bytes(out)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(tmp_path, monkeypatch):
|
||||
settings = get_settings()
|
||||
monkeypatch.setattr(settings, "reformat_dir", str(tmp_path / "jobs"))
|
||||
monkeypatch.setattr(settings, "inspect_dir", str(tmp_path / "inspect"))
|
||||
monkeypatch.setattr(settings, "compile_jobs_dir", str(tmp_path / "compile_jobs"))
|
||||
monkeypatch.setattr(settings, "compile_timeout_s", 60.0)
|
||||
monkeypatch.setattr(settings, "compile_project_timeout_s", 60.0)
|
||||
monkeypatch.setattr(lc, "_pending", 0)
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
|
||||
|
||||
# ================================================================ inspect 校验
|
||||
|
||||
def test_inspect_not_zip(client: TestClient):
|
||||
r = client.post(
|
||||
"/api/review/compile/inspect",
|
||||
files={"file": ("x.zip", b"notazip", "application/zip")},
|
||||
)
|
||||
assert r.status_code == 200 and r.json()["error"]["code"] == "ZIP_INVALID"
|
||||
|
||||
|
||||
def test_inspect_empty(client: TestClient):
|
||||
r = _upload(client, b"")
|
||||
assert r.json()["error"]["code"] == "VALIDATION"
|
||||
|
||||
|
||||
def test_inspect_oversize(client: TestClient, monkeypatch):
|
||||
monkeypatch.setattr(get_settings(), "max_zip_bytes", 10)
|
||||
r = _upload(client, _zip_bytes({"main.tex": MINIMAL}))
|
||||
assert r.json()["error"]["code"] == "VALIDATION"
|
||||
assert "上限" in r.json()["error"]["message"]
|
||||
|
||||
|
||||
def test_inspect_bad_filename(client: TestClient):
|
||||
r = _upload(client, _zip_bytes({"main.tex": MINIMAL}), name="../evil.zip")
|
||||
assert r.json()["error"]["code"] == "VALIDATION"
|
||||
|
||||
|
||||
def test_inspect_path_traversal(client: TestClient):
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr("main.tex", MINIMAL)
|
||||
info = zipfile.ZipInfo("../../evil.txt")
|
||||
zf.writestr(info, "x")
|
||||
r = _upload(client, buf.getvalue())
|
||||
assert r.json()["error"]["code"] == "ZIP_UNSAFE"
|
||||
|
||||
|
||||
def test_inspect_denied_ext(client: TestClient):
|
||||
r = _upload(client, _zip_bytes({"main.tex": MINIMAL, "evil.sh": "#!/bin/sh\n"}))
|
||||
assert r.json()["error"]["code"] == "ZIP_UNSAFE"
|
||||
assert "evil.sh" in r.json()["error"]["message"]
|
||||
|
||||
|
||||
def test_inspect_ratio_bomb(client: TestClient, monkeypatch):
|
||||
monkeypatch.setattr(get_settings(), "max_zip_ratio", 1.5)
|
||||
r = _upload(client, _zip_bytes({"main.tex": "a" * 100_000}))
|
||||
assert r.json()["error"]["code"] == "ZIP_BOMB"
|
||||
|
||||
|
||||
def test_inspect_no_tex(client: TestClient):
|
||||
r = _upload(client, _zip_bytes({"readme.txt": "hi"}))
|
||||
assert r.json()["error"]["code"] == "VALIDATION"
|
||||
assert "没有 .tex" in r.json()["error"]["message"]
|
||||
|
||||
|
||||
def test_inspect_no_documentclass(client: TestClient):
|
||||
r = _upload(client, _zip_bytes({"chapters/a.tex": "just text\n"}))
|
||||
assert r.json()["error"]["code"] == "VALIDATION"
|
||||
assert "documentclass" in r.json()["error"]["message"]
|
||||
|
||||
|
||||
def test_inspect_multiple_mains(client: TestClient):
|
||||
r = _upload(client, _zip_bytes({
|
||||
"main.tex": MINIMAL, "appendix.tex": MINIMAL}))
|
||||
body = r.json()
|
||||
assert body["error"]["code"] == "VALIDATION"
|
||||
assert "多个主文件" in body["error"]["message"]
|
||||
|
||||
|
||||
# ================================================================ inspect 正常 + 卫生
|
||||
|
||||
def test_inspect_happy_path(client: TestClient):
|
||||
payload = _zip_bytes({
|
||||
"main.tex": MINIMAL,
|
||||
"figs/fig1.png": b"\x89PNG\r\n\x1a\nfake",
|
||||
"refs.bib": "@article{a,\n title={t}\n}\n",
|
||||
"journal.cls": "% fake cls\n",
|
||||
"notes.docx": "skip me",
|
||||
".DS_Store": "junk",
|
||||
"__MACOSX/._main.tex": "resource fork junk",
|
||||
})
|
||||
r = _upload(client, payload)
|
||||
body = r.json()
|
||||
assert body["ok"] is True, body
|
||||
meta = body["meta"]
|
||||
assert meta["main"] == "main.tex"
|
||||
stats = meta["stats"]
|
||||
assert stats["tex"] == 1 and stats["images"] == 1
|
||||
assert stats["cls"] == 1 and stats["bib"] == 1
|
||||
assert meta["skipped_count"] >= 2
|
||||
names = {s["name"] for s in meta["skipped"]}
|
||||
assert any("notes.docx" in n for n in names)
|
||||
|
||||
job_id = meta["job_id"]
|
||||
assert len(job_id) == 12
|
||||
jdir = get_settings().inspect_dir_abs / job_id
|
||||
assert (jdir / "inspect.json").is_file()
|
||||
assert (jdir / "project" / "main.tex").is_file()
|
||||
|
||||
|
||||
def test_inspect_fail_leaves_no_residue(client: TestClient):
|
||||
r = _upload(client, _zip_bytes({"main.tex": MINIMAL, "evil.sh": "x"}))
|
||||
assert r.json()["ok"] is False
|
||||
base = get_settings().inspect_dir_abs
|
||||
if base.is_dir():
|
||||
assert not any(base.iterdir()), "inspect 失败后磁盘有残留"
|
||||
|
||||
|
||||
# ================================================================ run:校验 / 原子消费
|
||||
|
||||
def test_run_bad_job_id(client: TestClient):
|
||||
r = client.post("/api/review/compile/run", json={"job_id": "zz"})
|
||||
assert r.json()["error"]["code"] == "VALIDATION"
|
||||
|
||||
|
||||
def test_run_unknown_job(client: TestClient):
|
||||
r = client.post("/api/review/compile/run", json={"job_id": "0123456789ab"})
|
||||
assert r.json()["error"]["code"] == "VALIDATION"
|
||||
|
||||
|
||||
def test_run_double_consume_rejected(client: TestClient):
|
||||
"""补充:原子消费——同一 job 第二次 run 必须 VALIDATION(防并发双跑)。"""
|
||||
job_id = _upload(client, _zip_bytes({"main.tex": MINIMAL})).json()["meta"]["job_id"]
|
||||
first = client.post("/api/review/compile/run", json={"job_id": job_id}).json()
|
||||
first_err = first.get("error") or {}
|
||||
if first_err.get("code") == "TOOL_UNAVAILABLE":
|
||||
pytest.skip("tectonic 未安装,消费逻辑未触达")
|
||||
# 首次无论编译成败(消费已发生),二次必失败
|
||||
assert first["ok"] is True or first["error"]["code"] in (
|
||||
"COMPILE_FAILED", "TIMEOUT")
|
||||
second = client.post("/api/review/compile/run", json={"job_id": job_id}).json()
|
||||
assert second["ok"] is False
|
||||
assert second["error"]["code"] == "VALIDATION"
|
||||
# inspect 残壳也不应存在
|
||||
assert not (get_settings().inspect_dir_abs / job_id / "project").exists()
|
||||
|
||||
|
||||
def test_run_tool_unavailable_keeps_job(client: TestClient, monkeypatch):
|
||||
"""队列/工具检查先于消费 → TOOL_UNAVAILABLE 时 inspect 保留可重试。"""
|
||||
job_id = _upload(client, _zip_bytes({"main.tex": MINIMAL})).json()["meta"]["job_id"]
|
||||
with monkeypatch.context() as m:
|
||||
m.setattr(get_settings(), "tectonic_path", "tools/definitely-missing")
|
||||
r = client.post("/api/review/compile/run", json={"job_id": job_id}).json()
|
||||
assert r["error"]["code"] == "TOOL_UNAVAILABLE"
|
||||
assert (get_settings().inspect_dir_abs / job_id / "inspect.json").is_file()
|
||||
|
||||
|
||||
def test_run_queue_full_keeps_job(client: TestClient, monkeypatch):
|
||||
job_id = _upload(client, _zip_bytes({"main.tex": MINIMAL})).json()["meta"]["job_id"]
|
||||
monkeypatch.setattr(lc, "_pending", 5)
|
||||
monkeypatch.setattr(get_settings(), "compile_queue_limit", 5)
|
||||
r = client.post("/api/review/compile/run", json={"job_id": job_id})
|
||||
assert r.status_code == 429
|
||||
assert r.json()["error"]["code"] == "QUEUE_FULL"
|
||||
assert (get_settings().inspect_dir_abs / job_id / "inspect.json").is_file()
|
||||
|
||||
|
||||
# ================================================================ run:真实 tectonic
|
||||
|
||||
@needs_tectonic
|
||||
def test_run_project_happy_path(client: TestClient):
|
||||
payload = _zip_bytes({"main.tex": MINIMAL})
|
||||
job_id = _upload(client, payload).json()["meta"]["job_id"]
|
||||
r = client.post("/api/review/compile/run", json={"job_id": job_id})
|
||||
body = r.json()
|
||||
assert body["ok"] is True, body
|
||||
cid = body["compile_id"]
|
||||
assert len(cid) == 12
|
||||
|
||||
pdf = client.get(body["pdf_url"])
|
||||
assert pdf.status_code == 200
|
||||
assert pdf.headers["content-type"].startswith("application/pdf")
|
||||
assert pdf.content[:5] == b"%PDF-"
|
||||
|
||||
log = client.get(body["log_url"])
|
||||
assert log.status_code == 200
|
||||
assert "LaTeX2e" in log.text and "Output written" in log.text
|
||||
|
||||
meta = client.get(f"/api/review/compile/zip/{cid}")
|
||||
m = meta.json()
|
||||
assert m["ok"] is True
|
||||
assert m["meta"]["project"] is True
|
||||
assert m["meta"]["main"] == "main.tex"
|
||||
assert m["meta"]["pdf_bytes"] > 0
|
||||
|
||||
# project 树已删,只留产物
|
||||
cdir = get_settings().compile_jobs_dir_abs / cid
|
||||
assert not (cdir / "project").exists()
|
||||
assert (cdir / "output.pdf").is_file()
|
||||
|
||||
|
||||
@needs_tectonic
|
||||
def test_run_failed_envelope_has_log_url(client: TestClient):
|
||||
"""补充 3:编译失败信封带 compile_id + log_url,且 log 可下载。"""
|
||||
bad = "\\documentclass{article}\n\\begin{document}\n\\undefinedcommandxyz\n"
|
||||
job_id = _upload(client, _zip_bytes({"main.tex": bad})).json()["meta"]["job_id"]
|
||||
body = client.post("/api/review/compile/run", json={"job_id": job_id}).json()
|
||||
assert body["ok"] is False
|
||||
assert body["error"]["code"] == "COMPILE_FAILED"
|
||||
cid = body.get("compile_id")
|
||||
assert cid and len(cid) == 12
|
||||
assert body["log_url"] == f"/api/review/compile/zip/{cid}/log"
|
||||
log = client.get(body["log_url"])
|
||||
assert log.status_code == 200 and log.text.strip()
|
||||
|
||||
|
||||
@needs_tectonic
|
||||
def test_run_failed_keeps_project_scene(client: TestClient):
|
||||
"""失败保留现场(诊断):COMPILE_FAILED → compile_jobs/{cid}/project/ 不删。"""
|
||||
bad = "\\documentclass{article}\n\\begin{document}\n\\undefinedcommandxyz\n"
|
||||
job_id = _upload(client, _zip_bytes({"main.tex": bad})).json()["meta"]["job_id"]
|
||||
body = client.post("/api/review/compile/run", json={"job_id": job_id}).json()
|
||||
assert body["ok"] is False and body["error"]["code"] == "COMPILE_FAILED"
|
||||
cid = body["compile_id"]
|
||||
cdir = get_settings().compile_jobs_dir_abs / cid
|
||||
assert (cdir / "project" / "main.tex").is_file() # 现场保留
|
||||
assert (cdir / "output.log").is_file()
|
||||
|
||||
|
||||
@needs_tectonic
|
||||
def test_run_missing_cls_friendly_message(client: TestClient):
|
||||
"""classify_compile_error:缺 .cls → 期刊模板提示(Q4/§4.4)。"""
|
||||
tex = "\\documentclass{noexist-cls}\n\\begin{document}\nx\n"
|
||||
job_id = _upload(client, _zip_bytes({"main.tex": tex})).json()["meta"]["job_id"]
|
||||
body = client.post("/api/review/compile/run", json={"job_id": job_id}).json()
|
||||
assert body["ok"] is False and body["error"]["code"] == "COMPILE_FAILED"
|
||||
assert "noexist-cls" in body["error"]["message"]
|
||||
|
||||
|
||||
@needs_tectonic
|
||||
def test_run_project_malicious_write18(client: TestClient, tmp_path):
|
||||
canary = tmp_path / "canary_p4"
|
||||
evil = (
|
||||
"\\documentclass{article}\n"
|
||||
f"\\write18{{touch {canary}}}\n"
|
||||
"\\begin{document}\nno shell please\n\\end{document}\n"
|
||||
)
|
||||
job_id = _upload(client, _zip_bytes({"main.tex": evil})).json()["meta"]["job_id"]
|
||||
client.post("/api/review/compile/run", json={"job_id": job_id})
|
||||
assert not canary.exists(), r"\write18 竟然执行了!"
|
||||
|
||||
|
||||
@needs_tectonic
|
||||
def test_run_project_timeout(client: TestClient, monkeypatch):
|
||||
monkeypatch.setattr(get_settings(), "compile_project_timeout_s", 5.0)
|
||||
loop_tex = (
|
||||
"\\documentclass{article}\n\\begin{document}\n"
|
||||
"\\count255=0\n\\loop\\iftrue\\advance\\count255 by 1\\repeat\n"
|
||||
"\\end{document}\n"
|
||||
)
|
||||
job_id = _upload(client, _zip_bytes({"main.tex": loop_tex})).json()["meta"]["job_id"]
|
||||
started = time.monotonic()
|
||||
body = client.post("/api/review/compile/run", json={"job_id": job_id}).json()
|
||||
elapsed = time.monotonic() - started
|
||||
assert body["ok"] is False and body["error"]["code"] == "TIMEOUT"
|
||||
assert elapsed < 15.0, f"超时回收过慢:{elapsed:.1f}s"
|
||||
# 超时也必落 log(补充 3)
|
||||
log = client.get(body["log_url"])
|
||||
assert log.status_code == 200
|
||||
assert "[timeout]" in log.text
|
||||
|
||||
|
||||
# ================================================================ EPS 兼容 v2(批量现场转换)
|
||||
|
||||
_MIN_EPS = (
|
||||
b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 10 10\n"
|
||||
b"0 0 1 setrgbcolor 0 0 10 10 rectfill\nshowpage\n%%EOF\n"
|
||||
)
|
||||
|
||||
|
||||
def _eps_project(tmp_path, tex_body: str = "") -> Path:
|
||||
proj = tmp_path / "p"
|
||||
proj.mkdir()
|
||||
(proj / "fig.eps").write_bytes(_MIN_EPS)
|
||||
(proj / "main.tex").write_text(
|
||||
"\\documentclass{article}\n\\usepackage{graphicx}\n"
|
||||
"\\begin{document}\n" + tex_body + "\n\\end{document}\n",
|
||||
encoding="utf-8")
|
||||
return proj
|
||||
|
||||
|
||||
def test_eps_v2_preset_converted(tmp_path, monkeypatch):
|
||||
"""zip 自带 *-eps-converted-to.pdf → 免转换复制为 X.pdf + 显式引用改写。"""
|
||||
# other.eps 无转换版 → mock 工具为空使其失败,隔离 preset 逻辑(不真跑 gs)
|
||||
monkeypatch.setattr(lc, "_eps_tool_commands", lambda eps, pdf: [])
|
||||
proj = _eps_project(tmp_path)
|
||||
defs = proj / "Definitions"
|
||||
defs.mkdir()
|
||||
(defs / "logo-mdpi.eps").write_bytes(_MIN_EPS)
|
||||
(defs / "logo-mdpi-eps-converted-to.pdf").write_bytes(_tiny_pdf())
|
||||
(defs / "other.eps").write_bytes(b"%!PS\n") # 无转换版 → 走现场转换(被 mock 挡下)
|
||||
(defs / "mdpi.cls").write_text(
|
||||
"\\includegraphics{Definitions/logo-mdpi.eps}\n"
|
||||
"\\includegraphics{Definitions/other.eps}\n", encoding="utf-8")
|
||||
|
||||
lines = lc.process_eps_images(proj)
|
||||
|
||||
cls = (defs / "mdpi.cls").read_text(encoding="utf-8")
|
||||
assert "Definitions/logo-mdpi.pdf" in cls # 显式引用已改写为 .pdf
|
||||
assert "Definitions/other.eps" in cls # 转换失败的保持原样
|
||||
assert (defs / "logo-mdpi.eps").is_file() # 原文件保留
|
||||
assert (defs / "logo-mdpi.pdf").is_file() # 免转换复制
|
||||
assert not (defs / "other.pdf").exists()
|
||||
assert any(", preset)" in l for l in lines), lines
|
||||
assert any("全部工具失败,图片 Definitions/other.eps 未转换" in l
|
||||
for l in lines), lines
|
||||
|
||||
|
||||
def test_eps_v2_real_gs_convert(tmp_path):
|
||||
"""无预转换版 → 现场 gs 转换(真实工具链;测试点:显式 + 缺省扩展名引用都就位)。"""
|
||||
if not shutil.which("gs"):
|
||||
pytest.skip("gs 未安装")
|
||||
proj = _eps_project(tmp_path, "\\includegraphics{fig.eps}\n\\includegraphics{fig}")
|
||||
lines = lc.process_eps_images(proj)
|
||||
assert any("[eps-convert] fig.eps → fig.pdf" in l and ", gs)" in l for l in lines)
|
||||
pdf = proj / "fig.pdf"
|
||||
assert pdf.is_file() and pdf.read_bytes()[:5] == b"%PDF-"
|
||||
tex = (proj / "main.tex").read_text(encoding="utf-8")
|
||||
assert "{fig.pdf}" in tex # 显式 .eps 引用已改写
|
||||
assert "{fig}" in tex # 缺省扩展名引用由 X.pdf 命中
|
||||
assert (proj / "fig.eps").is_file() # 原文件保留
|
||||
|
||||
|
||||
def test_eps_v2_tool_fallback(tmp_path, monkeypatch):
|
||||
"""工具优先级 gs → epstopdf → ps2pdf:gs 失败 → epstopdf 成功。"""
|
||||
def fake_tools(eps: Path, pdf: Path):
|
||||
return [
|
||||
("gs", ["sh", "-c", "exit 1"]),
|
||||
("epstopdf", ["sh", "-c", f"echo %PDF-fake > {pdf}"]),
|
||||
]
|
||||
monkeypatch.setattr(lc, "_eps_tool_commands", fake_tools)
|
||||
proj = _eps_project(tmp_path, "\\includegraphics{fig.eps}")
|
||||
lines = lc.process_eps_images(proj)
|
||||
assert any(l.startswith("[eps-convert] fig.eps → fig.pdf") and ", epstopdf)" in l
|
||||
for l in lines), lines
|
||||
assert (proj / "fig.pdf").is_file()
|
||||
assert "{fig.pdf}" in (proj / "main.tex").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_eps_v2_all_tools_fail(tmp_path, monkeypatch):
|
||||
"""全工具失败 → log 行「全部工具失败…未转换」,引用保持 .eps。"""
|
||||
monkeypatch.setattr(
|
||||
lc, "_eps_tool_commands",
|
||||
lambda eps, pdf: [("gs", ["sh", "-c", "exit 1"])])
|
||||
proj = _eps_project(tmp_path, "\\includegraphics{fig.eps}")
|
||||
lines = lc.process_eps_images(proj)
|
||||
assert "[eps-convert] 全部工具失败,图片 fig.eps 未转换" in lines
|
||||
assert not (proj / "fig.pdf").exists()
|
||||
assert "{fig.eps}" in (proj / "main.tex").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_eps_v2_timeout_warns(tmp_path, monkeypatch):
|
||||
"""约束2:单文件转换 15s 超时 → warning 行,放弃该文件继续。"""
|
||||
monkeypatch.setattr(lc, "_EPS_CONVERT_TIMEOUT_S", 1)
|
||||
monkeypatch.setattr(
|
||||
lc, "_eps_tool_commands",
|
||||
lambda eps, pdf: [("gs", ["sh", "-c", "sleep 5"])])
|
||||
proj = _eps_project(tmp_path)
|
||||
lines = lc.process_eps_images(proj)
|
||||
assert "[eps-convert] fig.eps 转换超时" in lines
|
||||
assert not (proj / "fig.pdf").exists()
|
||||
|
||||
|
||||
def test_eps_v2_file_size_warning(tmp_path, monkeypatch):
|
||||
"""约束3a:单文件转换后超上限 → warning 行(不中止)。"""
|
||||
monkeypatch.setattr(lc, "_EPS_CONVERT_MAX_FILE", 10)
|
||||
monkeypatch.setattr(
|
||||
lc, "_eps_tool_commands",
|
||||
lambda eps, pdf: [("gs", ["sh", "-c", f"head -c 1024 /dev/zero > {pdf}"])])
|
||||
proj = _eps_project(tmp_path)
|
||||
lines = lc.process_eps_images(proj)
|
||||
assert any("警告" in l and "超过" in l for l in lines), lines
|
||||
|
||||
|
||||
def test_eps_v2_total_size_aborts(tmp_path, monkeypatch):
|
||||
"""约束3b:转换产物累计超上限 → InspectError(ZIP_TOO_LARGE) 中止。"""
|
||||
monkeypatch.setattr(lc, "_EPS_CONVERT_MAX_TOTAL", 100)
|
||||
monkeypatch.setattr(
|
||||
lc, "_eps_tool_commands",
|
||||
lambda eps, pdf: [("gs", ["sh", "-c", f"head -c 1024 /dev/zero > {pdf}"])])
|
||||
proj = _eps_project(tmp_path)
|
||||
with pytest.raises(lc.InspectError) as ei:
|
||||
lc.process_eps_images(proj)
|
||||
assert ei.value.code == "ZIP_TOO_LARGE"
|
||||
assert "MB" in ei.value.message
|
||||
|
||||
|
||||
@needs_tectonic
|
||||
def test_run_eps_with_converted_pdf_succeeds(client: TestClient):
|
||||
"""EPS + 预转换 PDF(preset 路径)→ 显式与缺省扩展名引用都编译成功。"""
|
||||
tex = (
|
||||
"\\documentclass{article}\n\\usepackage{graphicx}\n"
|
||||
"\\begin{document}\n"
|
||||
"\\includegraphics[width=1cm]{fig.eps}\n" # 显式 .eps → 文本替换
|
||||
"\\includegraphics[width=1cm]{fig}\n" # 缺省扩展名 → X.pdf 命中
|
||||
"\\end{document}\n"
|
||||
)
|
||||
payload = _zip_bytes({
|
||||
"main.tex": tex,
|
||||
"fig.eps": _MIN_EPS,
|
||||
"fig-eps-converted-to.pdf": _tiny_pdf(),
|
||||
})
|
||||
job_id = _upload(client, payload).json()["meta"]["job_id"]
|
||||
body = client.post("/api/review/compile/run", json={"job_id": job_id}).json()
|
||||
assert body["ok"] is True, body
|
||||
pdf = client.get(body["pdf_url"])
|
||||
assert pdf.content[:5] == b"%PDF-"
|
||||
|
||||
|
||||
@needs_tectonic
|
||||
def test_run_eps_real_gs_conversion_succeeds(client: TestClient):
|
||||
"""核心 v2 场景:真实 EPS 无预转换版 → 服务器现场转换 → 端到端编译成功。"""
|
||||
if not shutil.which("gs"):
|
||||
pytest.skip("gs 未安装")
|
||||
tex = (
|
||||
"\\documentclass{article}\n\\usepackage{graphicx}\n"
|
||||
"\\begin{document}\n"
|
||||
"\\includegraphics[width=1cm]{fig.eps}\n"
|
||||
"\\includegraphics[width=1cm]{fig}\n"
|
||||
"\\end{document}\n"
|
||||
)
|
||||
payload = _zip_bytes({"main.tex": tex, "fig.eps": _MIN_EPS})
|
||||
job_id = _upload(client, payload).json()["meta"]["job_id"]
|
||||
body = client.post("/api/review/compile/run", json={"job_id": job_id}).json()
|
||||
assert body["ok"] is True, body
|
||||
assert client.get(body["pdf_url"]).content[:5] == b"%PDF-"
|
||||
# 约束4:转换日志落 output.log
|
||||
log = client.get(body["log_url"]).text
|
||||
assert "[eps-convert] fig.eps → fig.pdf" in log and ", gs)" in log
|
||||
|
||||
|
||||
@needs_tectonic
|
||||
def test_run_eps_all_tools_fail_friendly_message(
|
||||
client: TestClient, monkeypatch):
|
||||
"""测试点2+3:全工具失败 → log 记「全部工具失败」→ 编译失败 → 前端友好提示。"""
|
||||
monkeypatch.setattr(lc, "_eps_tool_commands", lambda eps, pdf: [])
|
||||
tex = (
|
||||
"\\documentclass{article}\n\\usepackage{graphicx}\n"
|
||||
"\\begin{document}\n\\includegraphics[width=1cm]{fig.eps}\n\\end{document}\n"
|
||||
)
|
||||
payload = _zip_bytes({"main.tex": tex, "fig.eps": _MIN_EPS})
|
||||
job_id = _upload(client, payload).json()["meta"]["job_id"]
|
||||
body = client.post("/api/review/compile/run", json={"job_id": job_id}).json()
|
||||
assert body["ok"] is False and body["error"]["code"] == "COMPILE_FAILED"
|
||||
assert body["error"]["message"] == \
|
||||
"项目包含 EPS 图片,服务器转换失败,请转成 PDF/PNG 后重试"
|
||||
log = client.get(body["log_url"]).text
|
||||
assert "[eps-convert] 全部工具失败,图片 fig.eps 未转换" in log
|
||||
|
||||
|
||||
# ================================================================ zip 只读端点 404
|
||||
|
||||
def test_zip_meta_404(client: TestClient):
|
||||
r = client.get("/api/review/compile/zip/0123456789ab")
|
||||
assert r.json()["error"]["code"] == "NOT_FOUND"
|
||||
|
||||
|
||||
def test_zip_pdf_404(client: TestClient):
|
||||
r = client.get("/api/review/compile/zip/0123456789ab/pdf")
|
||||
assert r.json()["error"]["code"] == "NOT_FOUND"
|
||||
|
||||
|
||||
def test_zip_log_404(client: TestClient):
|
||||
r = client.get("/api/review/compile/zip/0123456789ab/log")
|
||||
assert r.json()["error"]["code"] == "NOT_FOUND"
|
||||
|
||||
|
||||
# ================================================================ 清扫
|
||||
|
||||
def test_sweep_inspect_expired(tmp_path, monkeypatch):
|
||||
settings = get_settings()
|
||||
monkeypatch.setattr(settings, "inspect_dir", str(tmp_path / "inspect"))
|
||||
base = tmp_path / "inspect"
|
||||
stale = base / "aabbccddeeff"
|
||||
stale.mkdir(parents=True)
|
||||
(stale / "inspect.json").write_text("{}", encoding="utf-8")
|
||||
(stale / "project").mkdir()
|
||||
old = time.time() - 2 * 3600
|
||||
os.utime(stale, (old, old))
|
||||
|
||||
fresh = base / "112233445566"
|
||||
fresh.mkdir()
|
||||
noise = base / "not-a-job"
|
||||
noise.mkdir()
|
||||
|
||||
removed = lc.sweep_inspect_expired()
|
||||
assert removed == 1
|
||||
assert not stale.exists()
|
||||
assert fresh.exists()
|
||||
assert noise.exists()
|
||||
|
||||
|
||||
def test_sweep_compile_jobs_expired(tmp_path, monkeypatch):
|
||||
settings = get_settings()
|
||||
monkeypatch.setattr(settings, "compile_jobs_dir", str(tmp_path / "cj"))
|
||||
base = tmp_path / "cj"
|
||||
stale = base / "aabbccddeeff"
|
||||
stale.mkdir(parents=True)
|
||||
(stale / "output.pdf").write_bytes(b"%PDF-fake")
|
||||
old = time.time() - 8 * 86400
|
||||
os.utime(stale, (old, old))
|
||||
|
||||
fresh = base / "112233445566"
|
||||
fresh.mkdir()
|
||||
(fresh / "output.pdf").write_bytes(b"%PDF-fake")
|
||||
noise = base / "not-a-job"
|
||||
noise.mkdir()
|
||||
|
||||
removed = lc.sweep_compile_jobs_expired()
|
||||
assert removed == 1
|
||||
assert not stale.exists()
|
||||
assert fresh.exists()
|
||||
assert noise.exists()
|
||||
|
||||
|
||||
def test_sweep_failed_project_ttl(tmp_path, monkeypatch):
|
||||
"""两级清扫①:失败现场 project/ 超 failed_project_ttl_s → 只删子目录,log 保留。"""
|
||||
settings = get_settings()
|
||||
monkeypatch.setattr(settings, "compile_jobs_dir", str(tmp_path / "cj"))
|
||||
monkeypatch.delenv("KEEP_FAILED_PROJECTS", raising=False)
|
||||
base = tmp_path / "cj"
|
||||
stale = base / "aabbccddeeff"
|
||||
(stale / "project").mkdir(parents=True)
|
||||
(stale / "output.log").write_text("log", encoding="utf-8")
|
||||
old = time.time() - settings.failed_project_ttl_s - 3600
|
||||
os.utime(stale / "project", (old, old))
|
||||
os.utime(stale, (old, old)) # 整目录也旧,但未超 7 天 → ② 不触发
|
||||
|
||||
fresh = base / "112233445566"
|
||||
(fresh / "project").mkdir(parents=True)
|
||||
(fresh / "output.log").write_text("log", encoding="utf-8")
|
||||
|
||||
removed = lc.sweep_compile_jobs_expired()
|
||||
assert removed == 1
|
||||
assert not (stale / "project").exists() # 现场删
|
||||
assert (stale / "output.log").is_file() # log 留(完整 log 下载)
|
||||
assert (fresh / "project").exists()
|
||||
|
||||
|
||||
def test_sweep_failed_project_kept_when_flag(tmp_path, monkeypatch):
|
||||
"""KEEP_FAILED_PROJECTS=1:失败现场永久保留(跳过①)。"""
|
||||
settings = get_settings()
|
||||
monkeypatch.setattr(settings, "compile_jobs_dir", str(tmp_path / "cj"))
|
||||
monkeypatch.setenv("KEEP_FAILED_PROJECTS", "1")
|
||||
base = tmp_path / "cj"
|
||||
stale = base / "aabbccddeeff"
|
||||
(stale / "project").mkdir(parents=True)
|
||||
old = time.time() - settings.failed_project_ttl_s - 3600
|
||||
os.utime(stale / "project", (old, old))
|
||||
|
||||
removed = lc.sweep_compile_jobs_expired()
|
||||
assert removed == 0
|
||||
assert (stale / "project").exists()
|
||||
@@ -0,0 +1,677 @@
|
||||
"""tests/test_ppt_build.py —— P5 python-pptx 初稿生成(本地 CPU,无网络)。
|
||||
|
||||
覆盖:三风格可用、版式分布(封面/内容/图占位)、notes 承载 evidence、
|
||||
多值字体栈首字体 latin+ea 双写、风格文件缺失信封码。
|
||||
运行: cd review_service && <venv>/bin/python -m pytest tests/test_ppt_build.py -q
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pptx import Presentation
|
||||
from pptx.enum.shapes import MSO_SHAPE, MSO_SHAPE_TYPE
|
||||
from pptx.enum.text import PP_ALIGN
|
||||
from pptx.oxml.ns import qn
|
||||
from pptx.util import Inches
|
||||
|
||||
from app.services.ppt_build import BuildError, build_pptx, load_style
|
||||
|
||||
PAGES = [
|
||||
{"slide_number": 1, "type": "title", "title": "水稻盐胁迫响应机制",
|
||||
"bullets": ["张三 等", "某大学植物科学学院"], "evidence": [],
|
||||
"speaker_notes": "各位老师好。", "suggested_figures": []},
|
||||
{"slide_number": 2, "type": "outline", "title": "汇报提纲",
|
||||
"bullets": ["背景", "方法", "结果", "结论"], "evidence": [None] * 4,
|
||||
"speaker_notes": "", "suggested_figures": []},
|
||||
{"slide_number": 3, "type": "background", "title": "研究背景",
|
||||
"bullets": ["盐胁迫是主要非生物胁迫", "机制不清"],
|
||||
"evidence": [{"para_id": "S1-P3", "quote": "salt stress tolerance",
|
||||
"status": "verified"}, None],
|
||||
"speaker_notes": "背景讲两点。", "suggested_figures": []},
|
||||
{"slide_number": 4, "type": "result", "title": "结果:表型",
|
||||
"bullets": ["根长下降"], "evidence": [None],
|
||||
"speaker_notes": "",
|
||||
"suggested_figures": [{"label": "Fig 2", "para_id": "S4-P1-Fig2",
|
||||
"status": "unverified"}]},
|
||||
{"slide_number": 5, "type": "conclusion", "title": "结论",
|
||||
"bullets": ["发现关键因子"], "evidence": [None],
|
||||
"speaker_notes": "总结。", "suggested_figures": []},
|
||||
]
|
||||
|
||||
|
||||
def test_load_style_all_ids():
|
||||
for sid in ("academic-minimal", "botanic-forest", "classic-defense"):
|
||||
style = load_style(sid)
|
||||
assert style["id"] == sid
|
||||
# 补充 2:字体是多值栈(CSS 语法,pptx 侧取首字体)
|
||||
assert "," in style["fonts"]["body"]
|
||||
assert style["sizes"]["bullet_pt"] > 0
|
||||
with pytest.raises(BuildError) as ei:
|
||||
load_style("neon-punk")
|
||||
assert ei.value.code == "VALIDATION"
|
||||
|
||||
|
||||
def test_load_style_missing_file(tmp_path, monkeypatch):
|
||||
from app.config import get_settings
|
||||
monkeypatch.setattr(get_settings(), "ppt_styles_dir", str(tmp_path))
|
||||
with pytest.raises(BuildError) as ei:
|
||||
load_style("academic-minimal")
|
||||
assert ei.value.code == "STYLE_NOT_FOUND"
|
||||
|
||||
|
||||
def test_build_all_styles(tmp_path):
|
||||
for sid in ("academic-minimal", "botanic-forest", "classic-defense"):
|
||||
out = tmp_path / f"{sid}.pptx"
|
||||
stats = build_pptx(PAGES, sid, out, context={"scenario": "defense", "minutes": 15})
|
||||
assert stats["slides"] == 5
|
||||
assert stats["bytes"] > 10_000
|
||||
assert out.exists()
|
||||
|
||||
|
||||
def test_built_deck_structure_and_notes(tmp_path):
|
||||
out = tmp_path / "draft.pptx"
|
||||
build_pptx(PAGES, "botanic-forest", out, context={"scenario": "defense", "minutes": 15})
|
||||
prs = Presentation(str(out))
|
||||
assert len(prs.slides._sldIdLst) == 5
|
||||
|
||||
# 封面:标题 + 副标题(bullets 拼接)
|
||||
s1 = prs.slides[0]
|
||||
assert "水稻盐胁迫" in s1.shapes.title.text_frame.text
|
||||
assert "张三 等 | 某大学植物科学学院" in s1.placeholders[1].text_frame.text
|
||||
assert "各位老师好" in s1.notes_slide.notes_text_frame.text
|
||||
|
||||
# 内容页:正文有 bullets,evidence 不进正文只进 notes
|
||||
s3 = prs.slides[2]
|
||||
body = s3.placeholders[1].text_frame.text
|
||||
assert "盐胁迫是主要非生物胁迫" in body
|
||||
assert "salt stress tolerance" not in body
|
||||
notes3 = s3.notes_slide.notes_text_frame.text
|
||||
assert "S1-P3" in notes3 and "verified" in notes3 and "背景讲两点" in notes3
|
||||
|
||||
# 图占位页:右侧虚线框(请插入 + 来源 para_id)
|
||||
s4 = prs.slides[3]
|
||||
boxes = [sh for sh in s4.shapes if sh.has_text_frame and "请插入" in sh.text_frame.text]
|
||||
assert boxes and "S4-P1-Fig2" in boxes[0].text_frame.text
|
||||
assert "Fig 2" in s4.notes_slide.notes_text_frame.text
|
||||
|
||||
# 多值字体栈 → 首字体 latin + a:ea 双写
|
||||
run = s3.shapes.title.text_frame.paragraphs[0].runs[0]
|
||||
latin = run.font._rPr.find(qn("a:latin")).get("typeface")
|
||||
ea = run.font._rPr.find(qn("a:ea")).get("typeface")
|
||||
assert latin == ea == "Microsoft YaHei"
|
||||
assert run.font.size.pt == 30
|
||||
|
||||
|
||||
def test_build_rejects_empty_and_unknown_style(tmp_path):
|
||||
with pytest.raises(BuildError) as ei:
|
||||
build_pptx([], "academic-minimal", tmp_path / "x.pptx")
|
||||
assert ei.value.code == "VALIDATION"
|
||||
with pytest.raises(BuildError):
|
||||
build_pptx(PAGES, "style-404", tmp_path / "x.pptx")
|
||||
|
||||
|
||||
def test_cover_fallback_without_bullets(tmp_path):
|
||||
pages = [dict(PAGES[0], bullets=[])]
|
||||
out = tmp_path / "cover.pptx"
|
||||
build_pptx(pages, "classic-defense", out, context={"scenario": "defense", "minutes": 30})
|
||||
prs = Presentation(str(out))
|
||||
assert "答辩 · 30 分钟" in prs.slides[0].placeholders[1].text_frame.text
|
||||
|
||||
|
||||
# ---------- P5 增强:8 风格 / 加粗与子项 / 分隔页 / 图 60-40 / 大数字 ----------
|
||||
|
||||
RICH_PAGES = PAGES + [
|
||||
{"slide_number": 6, "type": "section", "title": "主要结果", "bullets": [],
|
||||
"evidence": [], "speaker_notes": "进入结果部分。", "suggested_figures": []},
|
||||
{"slide_number": 7, "type": "result", "title": "株高显著提升",
|
||||
"bullets": ["处理组高于对照"], "evidence": [None], "speaker_notes": "",
|
||||
"suggested_figures": [], "highlight": {"value": "+0.82 cm", "label": "株高增长"}},
|
||||
{"slide_number": 8, "type": "method", "title": "方法要点",
|
||||
"bullets": ["测定 **盐离子** 含量", "- 电导法"], "evidence": [None, None],
|
||||
"speaker_notes": "", "suggested_figures": []},
|
||||
]
|
||||
|
||||
|
||||
def test_load_style_all_eight_ids():
|
||||
from app.services.ppt_outline import STYLE_IDS
|
||||
assert len(STYLE_IDS) == 14
|
||||
for sid in STYLE_IDS:
|
||||
style = load_style(sid)
|
||||
assert style["id"] == sid
|
||||
assert style["display_name"] and style["label"] and style["description"]
|
||||
assert "figure_split_pct" in style["content_style"]
|
||||
assert style["palette"].get("section_bg") and style["palette"].get("section_text")
|
||||
assert style["cover_style"]["title_align"] in ("left", "center")
|
||||
# 既有三风格视觉不变量(升级 schema 不改观感)
|
||||
for sid in ("academic-minimal", "botanic-forest", "classic-defense"):
|
||||
legacy = load_style(sid)
|
||||
assert legacy["cover_style"]["accent_bar"] is False
|
||||
assert legacy["cover_style"]["title_align"] == "left"
|
||||
|
||||
|
||||
def test_build_all_eight_styles(tmp_path):
|
||||
from app.services.ppt_outline import STYLE_IDS
|
||||
for sid in STYLE_IDS:
|
||||
out = tmp_path / f"{sid}.pptx"
|
||||
stats = build_pptx(RICH_PAGES, sid, out,
|
||||
context={"scenario": "defense", "minutes": 15})
|
||||
assert stats["slides"] == 8, sid
|
||||
assert stats["bytes"] > 10_000, sid
|
||||
|
||||
|
||||
def test_bold_runs_and_sub_bullet_level(tmp_path):
|
||||
out = tmp_path / "rich.pptx"
|
||||
build_pptx(RICH_PAGES, "academic-minimal", out,
|
||||
context={"scenario": "defense", "minutes": 15})
|
||||
prs = Presentation(str(out))
|
||||
body = prs.slides[7].placeholders[1] # method 页
|
||||
ps = body.text_frame.paragraphs
|
||||
# **盐离子** → 拆 3 run,中间 run bold,** 标记不落盘
|
||||
assert [r.text for r in ps[0].runs] == ["测定 ", "盐离子", " 含量"]
|
||||
assert ps[0].runs[1].font.bold is True
|
||||
assert ps[0].runs[0].font.bold is False
|
||||
assert "**" not in ps[0].text
|
||||
# 「- 电导法」→ 二级缩进,前缀剥掉
|
||||
assert ps[1].level == 1
|
||||
assert ps[1].text == "电导法"
|
||||
|
||||
|
||||
def test_section_divider_layout(tmp_path):
|
||||
"""改进 2:section_bg 底 + 40pt 主标题 + 20pt 副标题 + 底部 30% 装饰线。"""
|
||||
out = tmp_path / "rich.pptx"
|
||||
build_pptx(RICH_PAGES, "botanic-forest", out)
|
||||
prs = Presentation(str(out))
|
||||
s = prs.slides[5]
|
||||
assert s.slide_layout.name == "Blank"
|
||||
assert s.shapes.title is None # 分隔页无占位符
|
||||
# 背景 = tokens.section_bg(不再用 accent 横幅)
|
||||
assert str(s.background.fill.fore_color.rgb) == "DCFCE7"
|
||||
# 形状:主标题框 + 副标题框(bullets 空 → 该章第一页 title 兜底)+ 底部装饰线
|
||||
assert len(s.shapes) == 3
|
||||
title_run = s.shapes[0].text_frame.paragraphs[0].runs[0]
|
||||
assert title_run.text == "主要结果"
|
||||
assert title_run.font.bold is True and title_run.font.size.pt == 40
|
||||
assert s.shapes[0].text_frame.paragraphs[0].alignment == PP_ALIGN.CENTER
|
||||
sub_run = s.shapes[1].text_frame.paragraphs[0].runs[0]
|
||||
assert sub_run.text == "株高显著提升" # 该章(section 之后首张内容页)title
|
||||
assert sub_run.font.size.pt == 20
|
||||
assert s.shapes[1].top > s.shapes[0].top # 副标题在主标题下方
|
||||
# 底部装饰线:accent 色、宽 30% 页宽、水平居中
|
||||
line = s.shapes[2]
|
||||
assert abs(int(line.width) - int(Inches(13.333 * 0.3))) <= 2000
|
||||
assert abs(int(line.left) - int((Inches(13.333) - line.width) / 2)) <= 2000
|
||||
assert str(line.fill.fore_color.rgb) == "16A34A"
|
||||
assert "进入结果部分" in s.notes_slide.notes_text_frame.text
|
||||
|
||||
|
||||
def test_figure_page_split_geometry(tmp_path):
|
||||
out = tmp_path / "rich.pptx"
|
||||
build_pptx(RICH_PAGES, "academic-minimal", out)
|
||||
prs = Presentation(str(out))
|
||||
s = prs.slides[3] # result + Fig 2 占位
|
||||
body = s.placeholders[1]
|
||||
box = next(sh for sh in s.shapes
|
||||
if sh.has_text_frame and "请插入" in sh.text_frame.text)
|
||||
# 左侧正文不越界到右侧图框:60/40 分栏
|
||||
assert body.left + body.width <= box.left
|
||||
assert "Fig 2(论文 S4-P1-Fig2)" in box.text_frame.text
|
||||
# 分栏计算:body 宽 = (12.33-0.3)*0.6
|
||||
assert body.width == Inches((12.33 - 0.3) * 0.6)
|
||||
|
||||
|
||||
def test_highlight_big_number_page(tmp_path):
|
||||
out = tmp_path / "rich.pptx"
|
||||
build_pptx(RICH_PAGES, "tech-dark", out)
|
||||
prs = Presentation(str(out))
|
||||
s = prs.slides[6] # highlight result 页
|
||||
value_box = next(sh for sh in s.shapes
|
||||
if sh.has_text_frame and sh.text_frame.text == "+0.82 cm")
|
||||
vrun = value_box.text_frame.paragraphs[0].runs[0]
|
||||
assert vrun.font.size.pt == 60 and vrun.font.bold is True
|
||||
assert value_box.text_frame.paragraphs[0].alignment == PP_ALIGN.CENTER
|
||||
label_box = next(sh for sh in s.shapes
|
||||
if sh.has_text_frame and sh.text_frame.text == "株高增长")
|
||||
assert label_box.text_frame.paragraphs[0].runs[0].font.size.pt == 24
|
||||
# bullets 让位下移
|
||||
assert s.placeholders[1].top == Inches(4.35)
|
||||
|
||||
|
||||
def test_cover_accent_bar_variants(tmp_path):
|
||||
# 左对齐 + accent_bar → 左侧竖条
|
||||
out1 = tmp_path / "left.pptx"
|
||||
build_pptx([PAGES[0]], "academic-blue", out1)
|
||||
s1 = Presentation(str(out1)).slides[0]
|
||||
vbars = [sh for sh in s1.shapes if sh.width == Inches(0.18)]
|
||||
assert len(vbars) == 1 and vbars[0].height == Inches(7.5)
|
||||
# 居中 + accent_bar → 底部通栏
|
||||
out2 = tmp_path / "center.pptx"
|
||||
build_pptx([PAGES[0]], "monochrome", out2)
|
||||
s2 = Presentation(str(out2)).slides[0]
|
||||
hbars = [sh for sh in s2.shapes
|
||||
if sh.height == Inches(0.15) and sh.width == Inches(13.333)]
|
||||
assert len(hbars) == 1 and hbars[0].top == Inches(7.35)
|
||||
# 既有风格无强调条
|
||||
out3 = tmp_path / "plain.pptx"
|
||||
build_pptx([PAGES[0]], "academic-minimal", out3)
|
||||
s3 = Presentation(str(out3)).slides[0]
|
||||
assert not [sh for sh in s3.shapes
|
||||
if sh.width == Inches(0.18)
|
||||
or (sh.height == Inches(0.15) and sh.width == Inches(13.333))]
|
||||
|
||||
|
||||
# ---------- P5 二次开发 2.1/2.3:archetype 版式 + 真图 Tier B ----------
|
||||
|
||||
def _deck(tmp_path, pages, name="arch.pptx", style="academic-minimal"):
|
||||
out = tmp_path / name
|
||||
build_pptx(pages, style, out)
|
||||
return Presentation(str(out))
|
||||
|
||||
|
||||
def _textbox_texts(slide):
|
||||
return [sh.text_frame.text for sh in slide.shapes
|
||||
if sh.has_text_frame and sh.text_frame.text.strip()]
|
||||
|
||||
|
||||
def test_outline_chip_layout(tmp_path):
|
||||
prs = _deck(tmp_path, [PAGES[0], PAGES[1]])
|
||||
s = prs.slides[1]
|
||||
assert s.placeholders[1].text_frame.text == "" # body 让位
|
||||
chips = [sh for sh in s.shapes
|
||||
if sh.shape_type == MSO_SHAPE_TYPE.AUTO_SHAPE
|
||||
and sh.auto_shape_type == MSO_SHAPE.ROUNDED_RECTANGLE]
|
||||
assert [c.text_frame.text for c in chips] == ["1", "2", "3", "4"]
|
||||
texts = " ".join(_textbox_texts(s))
|
||||
for item in ("背景", "方法", "结果", "结论"):
|
||||
assert item in texts
|
||||
|
||||
|
||||
def test_takeaway_box_renders_and_leaves_body(tmp_path):
|
||||
page = {"slide_number": 1, "type": "background", "title": "研究背景",
|
||||
"bullets": ["靶基因:SOS1", "组织:根尖", "> **盐胁迫**下 SOS1 显著上调"],
|
||||
"evidence": [None] * 3, "speaker_notes": "", "suggested_figures": []}
|
||||
prs = _deck(tmp_path, [page])
|
||||
s = prs.slides[0]
|
||||
body = s.placeholders[1].text_frame.text
|
||||
assert "靶基因" in body and "组织" in body
|
||||
assert "> " not in body and "**" not in body
|
||||
box = next(t for t in _textbox_texts(s) if "显著上调" in t)
|
||||
assert "盐胁迫" in box and "**" not in box
|
||||
|
||||
|
||||
def test_kv_rows_label_bold(tmp_path):
|
||||
page = {"slide_number": 1, "type": "background", "title": "材料与条件",
|
||||
"bullets": ["靶基因:SOS1", "组织:根尖", "时间:24 h"],
|
||||
"evidence": [None] * 3, "speaker_notes": "", "suggested_figures": []}
|
||||
s = _deck(tmp_path, [page]).slides[0]
|
||||
label_box = next(sh for sh in s.shapes
|
||||
if sh.has_text_frame and sh.text_frame.text == "靶基因")
|
||||
assert label_box.text_frame.paragraphs[0].runs[0].font.bold is True
|
||||
assert any("根尖" in t for t in _textbox_texts(s))
|
||||
assert s.placeholders[1].text_frame.text == ""
|
||||
|
||||
|
||||
def test_flow_cards_with_arrows(tmp_path):
|
||||
page = {"slide_number": 1, "type": "method", "title": "分析流程",
|
||||
"bullets": ["→ 提取 RNA", "→ 建库测序", "→ 差异分析"],
|
||||
"evidence": [None] * 3, "speaker_notes": "", "suggested_figures": []}
|
||||
s = _deck(tmp_path, [page]).slides[0]
|
||||
arrows = [sh for sh in s.shapes
|
||||
if sh.shape_type == MSO_SHAPE_TYPE.AUTO_SHAPE
|
||||
and sh.auto_shape_type == MSO_SHAPE.RIGHT_ARROW]
|
||||
assert len(arrows) == 2
|
||||
texts = " ".join(_textbox_texts(s))
|
||||
for step in ("提取 RNA", "建库测序", "差异分析"):
|
||||
assert step in texts and "→" not in texts.split(step)[0][-3:]
|
||||
|
||||
|
||||
def test_method_short_bullets_become_cards(tmp_path):
|
||||
page = {"slide_number": 1, "type": "method", "title": "处理设计",
|
||||
"bullets": ["处理组 A", "处理组 B", "空白对照"],
|
||||
"evidence": [None] * 3, "speaker_notes": "", "suggested_figures": []}
|
||||
s = _deck(tmp_path, [page]).slides[0]
|
||||
cards = [sh for sh in s.shapes
|
||||
if sh.shape_type == MSO_SHAPE_TYPE.AUTO_SHAPE
|
||||
and sh.auto_shape_type == MSO_SHAPE.ROUNDED_RECTANGLE]
|
||||
assert len(cards) == 3
|
||||
texts = [c.text_frame.text for c in cards]
|
||||
assert texts == ["处理组 A", "处理组 B", "空白对照"]
|
||||
assert s.placeholders[1].text_frame.text == ""
|
||||
|
||||
|
||||
def test_table_page_native_table(tmp_path):
|
||||
page = {"slide_number": 1, "type": "result", "title": "处理组对比",
|
||||
"bullets": ["差异达显著水平"], "evidence": [None],
|
||||
"speaker_notes": "数据来自 Fig 2 统计。", "suggested_figures": [],
|
||||
"table": {"headers": ["处理", "株高(cm)"],
|
||||
"rows": [["对照", "10.2"], ["盐胁迫", "7.4"]]}}
|
||||
s = _deck(tmp_path, [page]).slides[0]
|
||||
tbl_shape = next(sh for sh in s.shapes if sh.has_table)
|
||||
tbl = tbl_shape.table
|
||||
assert tbl.cell(0, 0).text == "处理" and tbl.cell(0, 1).text == "株高(cm)"
|
||||
assert tbl.cell(1, 1).text == "10.2" and tbl.cell(2, 0).text == "盐胁迫"
|
||||
# 表头白字加粗
|
||||
hrun = tbl.cell(0, 0).text_frame.paragraphs[0].runs[0]
|
||||
assert hrun.font.bold is True
|
||||
assert "差异达显著水平" in s.placeholders[1].text_frame.text
|
||||
|
||||
|
||||
def test_conclusion_six_bullets_two_columns(tmp_path):
|
||||
page = {"slide_number": 1, "type": "conclusion", "title": "结论",
|
||||
"bullets": [f"要点 {i}" for i in range(6)],
|
||||
"evidence": [None] * 6, "speaker_notes": "", "suggested_figures": []}
|
||||
s = _deck(tmp_path, [page]).slides[0]
|
||||
assert s.placeholders[1].text_frame.text == ""
|
||||
joined = " ".join(_textbox_texts(s))
|
||||
for i in range(6):
|
||||
assert f"要点 {i}" in joined
|
||||
cols = [sh for sh in s.shapes if sh.shape_type == MSO_SHAPE_TYPE.TEXT_BOX]
|
||||
assert len(cols) >= 2
|
||||
|
||||
|
||||
def test_real_figure_tier_b_contain(tmp_path):
|
||||
from PIL import Image
|
||||
figs = tmp_path / "figs"
|
||||
figs.mkdir()
|
||||
img = figs / "Fig_2.png"
|
||||
Image.new("RGB", (400, 240), "#cc3333").save(img)
|
||||
out = tmp_path / "real.pptx"
|
||||
build_pptx(PAGES, "academic-minimal", out) # PAGES[3] 引用 Fig 2
|
||||
s = Presentation(str(out)).slides[3]
|
||||
pics = [sh for sh in s.shapes if sh.shape_type == MSO_SHAPE_TYPE.PICTURE]
|
||||
assert len(pics) == 1
|
||||
assert pics[0].width > 0 and pics[0].height > 0
|
||||
assert not any("请插入" in t for t in _textbox_texts(s)) # 不再是占位框
|
||||
assert any("Fig 2" in t and "S4-P1-Fig2" in t for t in _textbox_texts(s))
|
||||
|
||||
|
||||
def test_real_figure_missing_falls_back_placeholder(tmp_path):
|
||||
# figs 目录存在但无 Fig_2.png → 回落虚线占位(Tier A 几何不变)
|
||||
(tmp_path / "figs").mkdir()
|
||||
out = tmp_path / "fb.pptx"
|
||||
build_pptx(PAGES, "academic-minimal", out)
|
||||
s = Presentation(str(out)).slides[3]
|
||||
assert any("请插入" in t for t in _textbox_texts(s))
|
||||
|
||||
|
||||
# ---------- P5 改进 3:图卡容器(surface_2 + 圆角 + 5mm 内边距 + 图注) ----------
|
||||
|
||||
def _figure_shapes(slide):
|
||||
"""返回 (容器, 图片, 图注框)。容器 = 承载图片的圆角矩形。"""
|
||||
card = next(sh for sh in slide.shapes
|
||||
if sh.shape_type == MSO_SHAPE_TYPE.AUTO_SHAPE
|
||||
and sh.auto_shape_type == MSO_SHAPE.ROUNDED_RECTANGLE)
|
||||
pic = next(sh for sh in slide.shapes if sh.shape_type == MSO_SHAPE_TYPE.PICTURE)
|
||||
cap = next(sh for sh in slide.shapes
|
||||
if sh.shape_type == MSO_SHAPE_TYPE.TEXT_BOX and "论文" in sh.text_frame.text)
|
||||
return card, pic, cap
|
||||
|
||||
|
||||
def test_real_figure_card_container_geometry(tmp_path):
|
||||
"""改进 3:白底原图进浅色圆角卡片(surface_2、5mm 内边距、图在容器内居中)。"""
|
||||
from PIL import Image
|
||||
figs = tmp_path / "figs"
|
||||
figs.mkdir()
|
||||
Image.new("RGB", (400, 240), "#ffffff").save(figs / "Fig_2.png")
|
||||
out = tmp_path / "card.pptx"
|
||||
build_pptx(PAGES, "academic-minimal", out) # bg #FFFFFF → surface_2 推导 #F0F0F0
|
||||
s = Presentation(str(out)).slides[3]
|
||||
card, pic, cap = _figure_shapes(s)
|
||||
# 容器:圆角矩形、tokens.surface_2(无显式 token → 按 bg 压暗一档)、无边框
|
||||
from pptx.enum.dml import MSO_FILL
|
||||
assert str(card.fill.fore_color.rgb) == "F0F0F0"
|
||||
assert card.line.fill.type == MSO_FILL.BACKGROUND
|
||||
assert card.adjustments[0] < 0.05 # 圆角 8px,不是默认胶囊
|
||||
# 图四周 ≥5mm 内边距且水平/垂直都落在容器内
|
||||
pad = 914400 * 5 / 25.4
|
||||
assert pic.left >= card.left + pad - 500
|
||||
assert pic.top >= card.top + pad - 500
|
||||
assert pic.left + pic.width <= card.left + card.width - pad + 500
|
||||
assert pic.top + pic.height <= card.top + card.height - pad + 500
|
||||
# 图注:容器底部、10-12pt、tokens.muted、与图间距 ≥3mm
|
||||
assert cap.top >= pic.top + pic.height + 914400 * 3 / 25.4 - 500
|
||||
assert cap.top + cap.height <= card.top + card.height + 500
|
||||
run = cap.text_frame.paragraphs[0].runs[0]
|
||||
assert cap.text_frame.text == "Fig 2(论文 S4-P1-Fig2)"
|
||||
assert 10 <= run.font.size.pt <= 12
|
||||
assert str(run.font.color.rgb) == "6B7280" # tokens.muted(academic-minimal)
|
||||
# 图占位控制:右侧 ≤55% 页宽、≤60% 页高
|
||||
assert card.width <= Inches(13.333 * 0.55) + 500
|
||||
assert card.height <= Inches(7.5 * 0.6) + 500
|
||||
assert card.left >= s.placeholders[1].left + s.placeholders[1].width # 图在右侧不压正文
|
||||
|
||||
|
||||
def test_real_figure_standalone_page_centered(tmp_path):
|
||||
"""单独图页(有图无要点):图卡水平居中,上下留白 ≥5% 页高。"""
|
||||
from PIL import Image
|
||||
figs = tmp_path / "figs"
|
||||
figs.mkdir()
|
||||
Image.new("RGB", (300, 300), "#ffffff").save(figs / "Fig_1.png")
|
||||
page = {"slide_number": 1, "type": "result", "title": "图示基因结构",
|
||||
"bullets": [], "evidence": [], "speaker_notes": "",
|
||||
"suggested_figures": [{"label": "Fig 1", "para_id": "S1-P561",
|
||||
"status": "verified"}]}
|
||||
out = tmp_path / "solo.pptx"
|
||||
build_pptx([page], "academic-minimal", out)
|
||||
s = Presentation(str(out)).slides[0]
|
||||
card, pic, _ = _figure_shapes(s)
|
||||
assert abs(int(card.left) - int((Inches(13.333) - card.width) / 2)) <= 2000
|
||||
assert card.top >= Inches(7.5 * 0.05)
|
||||
assert card.top + card.height <= Inches(7.5 * 0.95)
|
||||
|
||||
|
||||
def test_split_figure_page_width_capped_at_55(tmp_path):
|
||||
"""图文混排页 figure_split_pct 最小值时图宽仍 ≤55% 页宽。"""
|
||||
page = {"slide_number": 1, "type": "result", "title": "结果",
|
||||
"bullets": ["要点一"], "evidence": [None], "speaker_notes": "",
|
||||
"suggested_figures": [{"label": "Fig 1", "para_id": "S1-P561",
|
||||
"status": "verified"}]}
|
||||
out = tmp_path / "cap.pptx"
|
||||
build_pptx([page], "academic-minimal", out) # 无 figs 目录 → Tier A 占位框同几何
|
||||
box = next(sh for sh in Presentation(str(out)).slides[0].shapes
|
||||
if sh.has_text_frame and "请插入" in sh.text_frame.text)
|
||||
assert box.width <= Inches(13.333 * 0.55) + 500
|
||||
assert box.height <= Inches(7.5 * 0.6) + 500
|
||||
|
||||
|
||||
# ---------- P5 改进 1/2:风格清单 + 分隔页副标题决议 ----------
|
||||
|
||||
def test_list_styles_returns_all_style_files():
|
||||
from app.services.ppt_outline import STYLE_IDS
|
||||
from app.services.ppt_build import list_styles
|
||||
items = list_styles()
|
||||
assert [i["id"] for i in items] == list(STYLE_IDS)
|
||||
assert len(items) == 14
|
||||
for it in items:
|
||||
assert it["label"] and it["description"].strip()
|
||||
assert it["bg"].startswith("#") and it["accent"].startswith("#")
|
||||
|
||||
|
||||
def test_section_subtitles_resolution_order():
|
||||
"""subtitle 字段 → bullets[0] → 该章第一页 title 兜底。"""
|
||||
from app.services.ppt_build import section_subtitles
|
||||
pages = [
|
||||
{"type": "section", "title": "研究背景", "bullets": [], "subtitle": "显式副标题"},
|
||||
{"type": "background", "title": "黄瓜遗传改良亟需高精度泛基因组", "bullets": []},
|
||||
{"type": "section", "title": "材料方法", "bullets": ["旧大纲 bullets 副标题"]},
|
||||
{"type": "method", "title": "泛基因组构建流程", "bullets": []},
|
||||
{"type": "section", "title": "结果分析", "bullets": []}, # 其后无内容页
|
||||
]
|
||||
subs = section_subtitles(pages)
|
||||
assert subs[0] == "显式副标题"
|
||||
assert subs[2] == "旧大纲 bullets 副标题"
|
||||
assert subs[4] == "" # 无内容页可兜底 → 空(只渲染主标题+线)
|
||||
|
||||
|
||||
# ---------- P5 v3:bullet 拆分 / 内容形态 archetype / 覆盖率 ----------
|
||||
|
||||
def test_split_merged_bullets():
|
||||
from app.services.ppt_build import _split_bullets
|
||||
# 任务样例:'- A - B - C' → 3 个独立 bullet
|
||||
assert _split_bullets(["- A - B - C"]) == ["A", "B", "C"]
|
||||
# LLM 真实坏例:4 条挤一行('- ' 无前导空白)
|
||||
merged = ["纳米孔测序:11个大豆品系- 基因组组装:高质量参考基因组"
|
||||
"- 变异检测:千份重测数据- 功能验证:候选基因定位"]
|
||||
assert _split_bullets(merged) == [
|
||||
"纳米孔测序:11个大豆品系", "基因组组装:高质量参考基因组",
|
||||
"变异检测:千份重测数据", "功能验证:候选基因定位",
|
||||
]
|
||||
# 单段 '- 子项' 不拆(留给 _SUB_RE 降级 level 1);无分隔不变;'3-5' 连字符不拆
|
||||
assert _split_bullets(["- 电导法"]) == ["- 电导法"]
|
||||
assert _split_bullets(["普通条目"]) == ["普通条目"]
|
||||
assert _split_bullets(["3-5 条范围"]) == ["3-5 条范围"]
|
||||
|
||||
|
||||
def test_split_bullets_applied_in_render(tmp_path):
|
||||
out = tmp_path / "split.pptx"
|
||||
page = {"slide_number": 1, "type": "result", "title": "四个发现",
|
||||
"bullets": ["这是一条足够长的常规发现描述确保它绝对不会落入卡片版式甲"
|
||||
"- 这是一条足够长的常规发现描述确保它绝对不会落入卡片版式乙"
|
||||
"- 这是一条足够长的常规发现描述确保它绝对不会落入卡片版式丙"
|
||||
"- 这是一条足够长的常规发现描述确保它绝对不会落入卡片版式丁"],
|
||||
"evidence": [None], "speaker_notes": "", "suggested_figures": []}
|
||||
build_pptx([page], "academic-minimal", out)
|
||||
body = Presentation(str(out)).slides[0].placeholders[1].text_frame
|
||||
texts = [p.text for p in body.paragraphs]
|
||||
assert len(texts) == 4
|
||||
assert [t[-1] for t in texts] == ["甲", "乙", "丙", "丁"]
|
||||
assert all(p.space_after.pt >= 10 for p in body.paragraphs) # spacious 行间距
|
||||
|
||||
|
||||
def test_auto_highlight_detects_single_big_number():
|
||||
from app.services.ppt_build import _auto_highlight
|
||||
v, label = _auto_highlight(["从头组装 1,518 个基因", "方法 A", "方法 B"])
|
||||
assert v == "1,518" and "1,518" not in label
|
||||
v2, _ = _auto_highlight(["BUSCO 完整度 95.3%", "另一条"])
|
||||
assert v2 == "95.3%"
|
||||
# 多条含数字 / 无数字 → 不触发(避免普通页误放大数字)
|
||||
assert _auto_highlight(["30% 上调", "50% 下调"]) is None
|
||||
assert _auto_highlight(["无数字条", "也无"]) is None
|
||||
|
||||
|
||||
def test_compare_texts_triggers():
|
||||
from app.services.ppt_build import _compare_texts
|
||||
assert _compare_texts(["处理组 vs 对照组", "表达量显著上调"]) is not None
|
||||
assert _compare_texts(["Enrei 与 Williams 82 对比", "基因数更多"]) is not None
|
||||
assert _compare_texts(["普通要点一", "普通要点二"]) is None
|
||||
assert _compare_texts(["单条 vs 对照"]) is None # 1 条页不触发
|
||||
|
||||
|
||||
def test_compare_layout_renders_vs_circle(tmp_path):
|
||||
page = {"slide_number": 1, "type": "result", "title": "组间对照",
|
||||
"bullets": ["处理组 vs 对照组", "盐处理后表达上调"],
|
||||
"evidence": [None] * 2, "speaker_notes": "", "suggested_figures": []}
|
||||
s = _deck(tmp_path, [page]).slides[0]
|
||||
ovals = [sh for sh in s.shapes
|
||||
if sh.shape_type == MSO_SHAPE_TYPE.AUTO_SHAPE
|
||||
and sh.auto_shape_type == MSO_SHAPE.OVAL]
|
||||
assert len(ovals) == 1 and ovals[0].text_frame.text == "VS"
|
||||
texts = " ".join(_textbox_texts(s))
|
||||
assert "处理组 vs 对照组" in texts and "表达上调" in texts
|
||||
assert s.placeholders[1].text_frame.text == ""
|
||||
|
||||
|
||||
def test_build_archetype_coverage_18pages(tmp_path):
|
||||
"""18 页混合大纲:正文特殊版式 ≥10(用户验收口径:非纯 bullets/reference)。"""
|
||||
def page(n, ptype, title, bullets, **kw):
|
||||
return {"slide_number": n, "type": ptype, "title": title, "bullets": bullets,
|
||||
"evidence": [None] * len(bullets), "speaker_notes": "", "suggested_figures": [],
|
||||
**kw}
|
||||
pages = [
|
||||
page(1, "title", "日本大豆基因组研究", ["张三", "组会汇报"]),
|
||||
page(2, "outline", "汇报提纲", ["背景", "方法", "结果", "结论"]),
|
||||
page(3, "section", "研究背景", []),
|
||||
page(4, "background", "研究意义", ["靶基因:SOS1", "组织:根尖",
|
||||
"> **盐胁迫**下耐性差异显著"]),
|
||||
page(5, "background", "材料与条件", ["材料:31 份大豆", "处理:200 mM NaCl",
|
||||
"时间:24 h"]),
|
||||
page(6, "method", "分析流程", ["→ 提取 RNA", "→ 建库测序", "→ 差异分析"]),
|
||||
page(7, "method", "处理设计", ["处理组 A", "处理组 B", "空白对照"]),
|
||||
page(8, "section", "主要结果", []),
|
||||
page(9, "result", "图示基因结构", ["结构域高度保守", "内含子位置一致"],
|
||||
suggested_figures=[{"label": "Fig 1", "para_id": "S1-P561", "status": "verified"}]),
|
||||
page(10, "result", "株高显著提升", ["盐处理后地上部生物量变化"],
|
||||
highlight={"value": "+0.82 cm", "label": "株高增长"}),
|
||||
page(11, "result", "基因家族扩张", ["从头组装完整 1,518 个基因",
|
||||
"其余两条常规陈述", "补充说明也较长一些"]),
|
||||
page(12, "result", "处理组对照", ["处理组 vs 对照组", "盐处理后表达上调"]),
|
||||
page(13, "result", "常规结果罗列", [f"这是一条很长的常规结论描述第 {i} 条字数偏多超出限制"
|
||||
for i in range(1, 6)]),
|
||||
page(14, "method", "四项分析", ["基因组组装", "变异检测", "共线分析", "功能注释"]),
|
||||
page(15, "section", "结论", []),
|
||||
page(16, "conclusion", "结论", [f"要点 {i}" for i in range(6)]),
|
||||
page(17, "conclusion", "展望", [f"后续研究方向需要进一步深入探讨其分子机制层面复杂问题 {i}"
|
||||
for i in range(3)]),
|
||||
page(18, "reference", "参考文献", ["1. Chen et al. 2024", "2. Li et al. 2023"]),
|
||||
]
|
||||
out = tmp_path / "cover.pptx"
|
||||
stats = build_pptx(pages, "academic-minimal", out,
|
||||
context={"scenario": "group_meeting", "minutes": 15})
|
||||
arch = stats["archetypes"]
|
||||
assert len(arch) == 18
|
||||
# 逐页触发正确
|
||||
assert arch[0] == "cover" and arch[1] == "chips" and arch[2] == "section"
|
||||
assert arch[3] == "takeaway" and arch[4] == "kv" and arch[5] == "flow"
|
||||
assert arch[6] == "cards" and arch[8] == "figure" and arch[9] == "highlight"
|
||||
assert arch[10] == "highlight_auto" and arch[11] == "compare" and arch[13] == "cards"
|
||||
assert arch[15] == "two_col"
|
||||
assert arch[12] == "bullets" and arch[16] == "bullets" and arch[17] == "reference"
|
||||
# 用户验收口径:正文(非 cover/chips/section/bullets/reference)特殊版式 ≥10
|
||||
body_special = [a for a in arch
|
||||
if a not in ("cover", "chips", "section", "bullets", "reference")]
|
||||
assert len(body_special) >= 10, arch
|
||||
# 全口径:18 页里非纯 bullets/reference ≥14
|
||||
assert len([a for a in arch if a not in ("bullets", "reference")]) >= 14
|
||||
|
||||
|
||||
def test_section_divider_with_subtitle(tmp_path):
|
||||
"""改进 2:section.subtitle 字段 → 主标题下方 20pt muted 副标题行。"""
|
||||
import copy
|
||||
pages = copy.deepcopy(RICH_PAGES)
|
||||
pages[5]["subtitle"] = "处理组与对照的产量差异解析"
|
||||
out = tmp_path / "sec_sub.pptx"
|
||||
build_pptx(pages, "botanic-forest", out)
|
||||
s = Presentation(str(out)).slides[5]
|
||||
assert len(s.shapes) == 3
|
||||
sub_run = s.shapes[1].text_frame.paragraphs[0].runs[0]
|
||||
assert sub_run.text == "处理组与对照的产量差异解析"
|
||||
assert sub_run.font.size.pt == 20
|
||||
assert str(sub_run.font.color.rgb) == "4D7C57" # tokens.muted
|
||||
assert s.shapes[1].top > s.shapes[0].top # 主标题下方
|
||||
# 主标题与装饰线不变
|
||||
assert s.shapes[0].text_frame.paragraphs[0].runs[0].text == "主要结果"
|
||||
assert int(s.shapes[2].width) == int(Inches(13.333 * 0.3))
|
||||
|
||||
|
||||
def test_section_divider_legacy_bullet_subtitle(tmp_path):
|
||||
"""旧大纲把副标题写在 bullets[0](无 subtitle 字段)→ 仍渲染(兼容)。"""
|
||||
import copy
|
||||
pages = copy.deepcopy(RICH_PAGES)
|
||||
pages[5]["bullets"] = ["盐处理下的产量差异解析"]
|
||||
out = tmp_path / "sec_legacy.pptx"
|
||||
build_pptx(pages, "botanic-forest", out)
|
||||
s = Presentation(str(out)).slides[5]
|
||||
texts = [sh.text_frame.text for sh in s.shapes if sh.has_text_frame]
|
||||
assert any("盐处理下的产量差异解析" in t for t in texts)
|
||||
|
||||
|
||||
def test_section_divider_without_resolvable_subtitle_adds_no_subtitle_box(tmp_path):
|
||||
"""既无 subtitle/bullets、其后也无内容页 → 只有主标题框 + 装饰线(无副标题框)。"""
|
||||
page = {"slide_number": 1, "type": "section", "title": "结论", "bullets": [],
|
||||
"evidence": [], "speaker_notes": "", "suggested_figures": []}
|
||||
out = tmp_path / "sec_plain.pptx"
|
||||
build_pptx([page], "botanic-forest", out)
|
||||
s = Presentation(str(out)).slides[0]
|
||||
assert len(s.shapes) == 2 # 主标题 + 装饰线
|
||||
assert s.shapes[0].text_frame.paragraphs[0].runs[0].text == "结论"
|
||||
|
||||
|
||||
def test_figure_image_falls_back_to_canon_key(tmp_path):
|
||||
"""v3 修复2 兜底:label 精确文件名不存在(子图 Fig. 6b)→ 按编号找 Fig_6.png。"""
|
||||
from app.services.ppt_build import _figure_image
|
||||
figs = tmp_path / "figs"
|
||||
figs.mkdir()
|
||||
(figs / "Fig_6.png").write_bytes(b"\x89PNG")
|
||||
ctx = {"figs_dir": figs}
|
||||
assert _figure_image(ctx, "Fig 6") is not None
|
||||
assert _figure_image(ctx, "Fig. 6b") is not None # canon("fig",6) 兜底命中
|
||||
assert _figure_image(ctx, "Fig 9") is None
|
||||
assert _figure_image(None, "Fig 6") is None
|
||||
@@ -0,0 +1,138 @@
|
||||
"""tests/test_ppt_figure_index.py —— P5 增强 1:论文真实图表清单扫描。
|
||||
|
||||
覆盖:Figure/Fig./图/Table/表 正则抽取、caption 形态优先、按编号去重、
|
||||
label 归一化(canon_label/figure_map)、build_figure_index 与证据链分段一致性。
|
||||
运行: cd review_service && <venv>/bin/python -m pytest tests/test_ppt_figure_index.py -q
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.ppt_figure_index import (
|
||||
build_figure_index,
|
||||
canon_label,
|
||||
figure_map,
|
||||
scan_figure_index,
|
||||
)
|
||||
from app.services.ppt_outline import build_index
|
||||
|
||||
|
||||
def make_text() -> str:
|
||||
return "\n".join([
|
||||
"Introduction paragraph without any figure.",
|
||||
"Growth increased under salt (see Figure 1).",
|
||||
"Earlier mention of Figure 2 appears here.",
|
||||
"Table 3 lists the primers used in this study.",
|
||||
"图 4 显示了根系形态的差异。",
|
||||
"Figure 2: Root length under salt stress treatments.", # caption 形态(后出现)
|
||||
"最后没有编号的段落。",
|
||||
])
|
||||
|
||||
|
||||
def test_scan_extracts_real_numbers_with_para_ids():
|
||||
_, index = build_index(make_text())
|
||||
figs = scan_figure_index(index)
|
||||
by_label = {f["label"]: f for f in figs}
|
||||
# 真实编号全中:Fig 1 / Fig 2 / Fig 4 / Table 3
|
||||
assert set(by_label) == {"Fig 1", "Fig 2", "Fig 4", "Table 3"}
|
||||
# para_id 必须存在于证据链索引里
|
||||
for f in figs:
|
||||
assert f["para_id"] in index
|
||||
assert by_label["Fig 1"]["kind"] == "figure"
|
||||
assert by_label["Table 3"]["kind"] == "table"
|
||||
|
||||
|
||||
def test_scan_prefers_caption_style_and_dedups():
|
||||
_, index = build_index(make_text())
|
||||
figs = {f["label"]: f for f in scan_figure_index(index)}
|
||||
# Fig 2 提及两次(顺带 + caption 形态)→ 去重且选中 caption 所在段
|
||||
assert "Figure 2: Root length" in figs["Fig 2"]["caption"]
|
||||
assert index[figs["Fig 2"]["para_id"]].startswith("Figure 2:")
|
||||
# caption 是包含命中的句子
|
||||
assert "salt (see Figure 1)" in figs["Fig 1"]["caption"]
|
||||
|
||||
|
||||
def test_canon_label_variants():
|
||||
assert canon_label("Fig 2") == ("fig", 2)
|
||||
assert canon_label("Figure 2") == ("fig", 2)
|
||||
assert canon_label("Fig. 3") == ("fig", 3)
|
||||
assert canon_label("图 4") == ("fig", 4)
|
||||
assert canon_label("Table 3") == ("table", 3)
|
||||
assert canon_label("表 3") == ("table", 3)
|
||||
assert canon_label("no number") is None
|
||||
assert canon_label("") is None
|
||||
|
||||
|
||||
def test_figure_map_lookup_keyed_by_kind_and_number():
|
||||
_, index = build_index(make_text())
|
||||
fmap = figure_map(scan_figure_index(index))
|
||||
assert fmap[("fig", 1)]["label"] == "Fig 1"
|
||||
assert fmap[("table", 3)]["label"] == "Table 3"
|
||||
# Fig 1 与 Table 1 同号不同类不串(清单里无 Table 1 → 缺)
|
||||
assert ("table", 1) not in fmap
|
||||
|
||||
|
||||
def test_build_figure_index_from_full_text_consistent_with_index():
|
||||
text = make_text()
|
||||
entries = build_figure_index(text)
|
||||
_, index = build_index(text)
|
||||
assert entries and all(e["para_id"] in index for e in entries)
|
||||
assert {e["label"] for e in entries} == {"Fig 1", "Fig 2", "Fig 4", "Table 3"}
|
||||
|
||||
|
||||
def test_scan_empty_when_no_figures():
|
||||
_, index = build_index("just some plain paragraphs without numbering at all\n" * 5)
|
||||
assert scan_figure_index(index) == []
|
||||
|
||||
|
||||
# ---------- P5 二次开发 2.3:PDF 原图提取 ----------
|
||||
|
||||
def test_extract_figures_real_pdf(tmp_path):
|
||||
from app.services.ppt_figure_index import extract_figures
|
||||
pdf = Path(__file__).resolve().parents[1] / "data" / "compile_jobs" / "78c49ed72b95" / "output.pdf"
|
||||
if not pdf.is_file():
|
||||
pytest.skip("示例 PDF 未就位")
|
||||
out_dir = tmp_path / "figs"
|
||||
res = extract_figures(pdf.read_bytes(), out_dir)
|
||||
assert [r["label"] for r in res] == ["Fig 1", "Fig 2"]
|
||||
assert res[0]["page"] == 3 and res[1]["page"] == 4
|
||||
assert (out_dir / "Fig_1.png").is_file() and (out_dir / "Fig_1.png").stat().st_size > 0
|
||||
assert (out_dir / "Fig_2.png").is_file()
|
||||
assert all(r["width"] >= 60 and r["height"] >= 40 for r in res)
|
||||
|
||||
|
||||
def test_extract_figures_garbage_bytes(tmp_path):
|
||||
from app.services.ppt_figure_index import extract_figures
|
||||
assert extract_figures(b"not a pdf at all", tmp_path / "figs") == []
|
||||
|
||||
|
||||
# ---------- P5 v3:caption 行「Fig. N |」尾点修复(用户 PDF 0 张根因) ----------
|
||||
|
||||
def test_caption_line_fig_dot_variant():
|
||||
import pymupdf
|
||||
from app.services.ppt_figure_index import _caption_lines
|
||||
doc = pymupdf.open()
|
||||
page = doc.new_page(width=612, height=792)
|
||||
page.insert_text((72, 120), "Fig. 1 | Synthetic caption for unit test", fontsize=11)
|
||||
caps = [c for c in _caption_lines(page) if c["kind"] == "fig"]
|
||||
assert caps and caps[0]["num"] == 1
|
||||
doc.close()
|
||||
|
||||
|
||||
def test_extract_figures_dot_caption_end_to_end(tmp_path):
|
||||
import pymupdf
|
||||
from app.services.ppt_figure_index import extract_figures
|
||||
doc = pymupdf.open()
|
||||
page = doc.new_page(width=612, height=792)
|
||||
pix = pymupdf.Pixmap(pymupdf.csRGB, pymupdf.IRect(0, 0, 300, 200))
|
||||
pix.clear_with(0x66AA66)
|
||||
page.insert_image(pymupdf.Rect(100, 100, 400, 300), pixmap=pix)
|
||||
page.insert_text((100, 330), "Fig. 1 | End to end synthetic figure", fontsize=11)
|
||||
pdf = doc.tobytes()
|
||||
doc.close()
|
||||
out_dir = tmp_path / "figs"
|
||||
res = extract_figures(pdf, out_dir)
|
||||
assert [r["label"] for r in res] == ["Fig 1"]
|
||||
assert (out_dir / "Fig_1.png").is_file() and (out_dir / "Fig_1.png").stat().st_size > 0
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user