Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b746db452 | ||
|
|
d9abd0b86a | ||
|
|
282415fcb1 | ||
|
|
1ca2241a97 | ||
|
|
8b5095f70f | ||
|
|
f5a951f47a | ||
|
|
7ced30fa96 | ||
|
|
c78df3a6a6 | ||
|
|
69e3f35a43 | ||
|
|
de0b9f92be | ||
|
|
2797145104 | ||
|
|
4e19350115 | ||
|
|
e036301e1a | ||
|
|
da84dff9bb | ||
|
|
3a72d3a861 | ||
|
|
ac07cb2008 |
@@ -54,6 +54,18 @@ review_service/data/reformat_jobs/
|
||||
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,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>
|
||||
@@ -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,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)内容即可。
|
||||
@@ -62,6 +62,27 @@ class Settings(BaseSettings):
|
||||
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)
|
||||
@@ -85,16 +106,37 @@ class Settings(BaseSettings):
|
||||
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":
|
||||
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("/")
|
||||
|
||||
@@ -23,6 +23,7 @@ from .routers import (
|
||||
format_check,
|
||||
journal_directory,
|
||||
journals,
|
||||
ppt,
|
||||
presubmit,
|
||||
reformat,
|
||||
rebuttal,
|
||||
@@ -55,12 +56,45 @@ def create_app() -> FastAPI:
|
||||
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:
|
||||
@@ -88,6 +122,14 @@ def create_app() -> FastAPI:
|
||||
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
|
||||
@@ -118,6 +160,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(journal_directory.router)
|
||||
app.include_router(reformat.router)
|
||||
app.include_router(compile.router)
|
||||
app.include_router(ppt.router)
|
||||
return app
|
||||
|
||||
|
||||
|
||||
@@ -132,6 +132,107 @@ async def compile_tex(
|
||||
)
|
||||
|
||||
|
||||
# ================================================================ 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
|
||||
@@ -139,6 +240,63 @@ def _cid_dir(cid: str) -> Path | None:
|
||||
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)
|
||||
|
||||
@@ -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})
|
||||
@@ -1,4 +1,4 @@
|
||||
"""services/latex_compile.py —— tectonic 编译 .tex → PDF(P3a)。
|
||||
"""services/latex_compile.py —— tectonic 编译 .tex → PDF(P3a + P4 项目 zip)。
|
||||
|
||||
隔离与安全(对齐设计清单):
|
||||
1. 一编译一目录:{reformat_dir}/{anchor_id}/compile/(input.tex/output.pdf/output.log/meta.json)
|
||||
@@ -9,6 +9,13 @@
|
||||
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
|
||||
@@ -20,7 +27,10 @@ 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
|
||||
@@ -40,6 +50,9 @@ _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)
|
||||
@@ -234,3 +247,623 @@ def sweep_expired() -> int:
|
||||
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,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,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,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 }
|
||||
}
|
||||
@@ -30,4 +30,7 @@ set +a
|
||||
echo "[restart] 启动审稿服务 :$PORT($PYTHON)"
|
||||
nohup "$PYTHON" -m uvicorn "$APP_MODULE" --host "$HOST" --port "$PORT" \
|
||||
> "$SCRIPT_DIR/review.log" 2>&1 &
|
||||
echo "[restart] 完成;日志 review_service/review.log"
|
||||
# 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,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,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
|
||||
@@ -0,0 +1,673 @@
|
||||
"""tests/test_ppt_outline.py —— P5 大纲服务离线单测(纯函数 + mock LLM 管线)。
|
||||
|
||||
覆盖:决策 2 页数表、段落索引、coerce 清洗、拆页/裁页、证据程序化校验
|
||||
(含 S4-P1-Fig2 图引用)、map-reduce 编排、薄弱页扩充重试、错误信封码。
|
||||
运行: cd review_service && <venv>/bin/python -m pytest tests/test_ppt_outline.py -q
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services import llm, ppt_outline
|
||||
from app.services.ppt_outline import (
|
||||
OutlineError,
|
||||
build_index,
|
||||
coerce_outline,
|
||||
generate_outline,
|
||||
page_plan,
|
||||
split_oversized,
|
||||
trim_to_max,
|
||||
validate_evidence,
|
||||
)
|
||||
|
||||
N_LINES = 160 # 160 段 / 每块 40 → 恰好 4 块(S1..S4)
|
||||
FIG_LINE = 120 # S4-P1(0-based)
|
||||
|
||||
|
||||
def make_text() -> str:
|
||||
lines = []
|
||||
for i in range(N_LINES):
|
||||
line = f"salt stress response line {i} describes ion transport and growth"
|
||||
if i == FIG_LINE:
|
||||
line += " . Figure 2: growth under salt stress."
|
||||
lines.append(line)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
LONG_TEXT = make_text()
|
||||
|
||||
|
||||
# ---------- 决策 2 / 索引 / coerce(纯函数) ----------
|
||||
|
||||
def test_page_plan_covers_all_scenarios_and_minutes():
|
||||
for sc in ppt_outline.SCENARIOS:
|
||||
for m in ppt_outline.MINUTES_CHOICES:
|
||||
plan = page_plan(sc, m)
|
||||
lo, hi = ppt_outline.PAGE_TARGETS[m]
|
||||
assert plan["title"] == (1, 1) and plan["outline"] == (1, 1)
|
||||
assert plan["conclusion"] == (1, 1)
|
||||
assert (plan.get("reference") is not None) == (sc == "defense")
|
||||
assert all(1 <= mn <= mx for mn, mx in plan.values())
|
||||
# 配额总量上限不超时长目标上限(超了靠 trim 兜底,这里断言设计值)
|
||||
assert sum(mx for _, mx in plan.values()) <= hi + 2
|
||||
assert sum(mn for mn, _ in plan.values()) >= lo - 3
|
||||
|
||||
|
||||
def test_page_plan_rejects_unknown_inputs():
|
||||
with pytest.raises(ValueError):
|
||||
page_plan("paper_writing", 15)
|
||||
with pytest.raises(ValueError):
|
||||
page_plan("defense", 45)
|
||||
|
||||
|
||||
def test_build_index_ids_and_section_cap():
|
||||
blocks, index = build_index(LONG_TEXT)
|
||||
assert list(index)[0] == "S1-P1"
|
||||
assert len(blocks) == 4
|
||||
assert len(index) == N_LINES
|
||||
assert "S4-P1" in index
|
||||
assert "Figure 2" in index["S4-P1"]
|
||||
|
||||
|
||||
def test_coerce_outline_alignment_and_type_filter():
|
||||
payload = {
|
||||
"pages": [
|
||||
{"type": "title", "title": "T", "bullets": ["a", "b"],
|
||||
"evidence": [{"para_id": "S1-P1", "quote": "q1"}]},
|
||||
{"type": "method", "title": "M", "bullets": ["x", "y", "z"],
|
||||
"evidence": []},
|
||||
{"type": "nope", "title": "bad", "bullets": []},
|
||||
"not-a-dict",
|
||||
]
|
||||
}
|
||||
pages = coerce_outline(payload)
|
||||
assert [p["type"] for p in pages] == ["title", "method"]
|
||||
# evidence 与 bullets 等长:缺的补 None,多余的截掉
|
||||
assert len(pages[0]["evidence"]) == 2 and pages[0]["evidence"][1] is None
|
||||
assert len(pages[1]["evidence"]) == 3
|
||||
assert pages[0]["evidence"][0]["status"] == "unverified"
|
||||
|
||||
|
||||
def test_coerce_outline_rejects_garbage():
|
||||
with pytest.raises(OutlineError) as ei:
|
||||
coerce_outline({"pages": "not a list"})
|
||||
assert ei.value.code == "LLM_INVALID"
|
||||
with pytest.raises(OutlineError):
|
||||
coerce_outline({"pages": [{"type": "alien"}]})
|
||||
|
||||
|
||||
# ---------- 拆页 / 裁页 ----------
|
||||
|
||||
def test_split_oversized_recursive_and_exempts_non_content():
|
||||
warnings = []
|
||||
big = {"slide_number": 1, "type": "method", "title": "M",
|
||||
"bullets": [f"b{i}" for i in range(17)], "evidence": [None] * 17,
|
||||
"speaker_notes": "", "suggested_figures": []}
|
||||
out = split_oversized([dict(big)], warnings)
|
||||
assert all(len(p["bullets"]) <= 8 for p in out)
|
||||
assert sum(len(p["bullets"]) for p in out) == 17
|
||||
assert out[1]["title"].endswith("(续)")
|
||||
# reference 不是内容页:9 条也不拆
|
||||
ref = dict(big, type="reference", title="R")
|
||||
out2 = split_oversized([ref], [])
|
||||
assert len(out2) == 1 and len(out2[0]["bullets"]) == 17
|
||||
|
||||
|
||||
def test_trim_priority_and_floors():
|
||||
warnings = []
|
||||
types = ["title", "outline", "background", "background", "method",
|
||||
"method", "result", "result", "result", "conclusion",
|
||||
"reference", "reference"]
|
||||
pages = [{"slide_number": i + 1, "type": t, "title": f"{t}{i}",
|
||||
"bullets": [], "evidence": [], "speaker_notes": "",
|
||||
"suggested_figures": []} for i, t in enumerate(types)]
|
||||
out = trim_to_max(pages, 9, warnings)
|
||||
ts = [p["type"] for p in out]
|
||||
assert len(out) == 9
|
||||
assert ts[0] == "title" and ts[1] == "outline"
|
||||
assert "conclusion" in ts and ts.count("background") == 1
|
||||
assert ts.count("reference") == 0 # reference 全删(floor 0,优先级最高)
|
||||
assert ts.count("method") == 2 and ts.count("result") == 3 # 内容页不再往下删
|
||||
assert warnings
|
||||
|
||||
|
||||
# ---------- 证据程序化校验 ----------
|
||||
|
||||
def test_validate_evidence_verified_unverified_ratio():
|
||||
index = {"S1-P1": "The yield increased by 30 percent in treatment A."}
|
||||
pages = [
|
||||
{"slide_number": 1, "type": "result", "title": "r", "bullets": [],
|
||||
"speaker_notes": "", "suggested_figures": [],
|
||||
"evidence": [
|
||||
{"para_id": "S1-P1", "quote": "yield increased by 30 percent", "status": ""},
|
||||
{"para_id": "S1-P1", "quote": "invented 99 percent claim", "status": ""},
|
||||
{"para_id": "S9-P9", "quote": "yield increased by 30 percent", "status": ""},
|
||||
]},
|
||||
{"slide_number": 2, "type": "conclusion", "title": "c", "bullets": [],
|
||||
"speaker_notes": "", "suggested_figures": [],
|
||||
"evidence": [{"para_id": "S1-P1", "quote": "no such sentence", "status": ""}]},
|
||||
]
|
||||
warnings = validate_evidence(pages, index)
|
||||
ev = pages[0]["evidence"]
|
||||
assert ev[0]["status"] == "verified"
|
||||
assert ev[1]["status"] == "unverified" # 编造,不在全文
|
||||
assert ev[2]["status"] == "unverified" # 引用真实但段落标注错
|
||||
assert any("段落标注" in w for w in warnings)
|
||||
# conclusion 页 1/1 未核实 >50% → warning
|
||||
assert any("conclusion" in w and "50%" in w for w in warnings)
|
||||
|
||||
|
||||
def test_validate_evidence_figures():
|
||||
index = {"S4-P1": "Figure 2: growth under salt stress.", "S1-P1": "intro text"}
|
||||
pages = [
|
||||
{"slide_number": 1, "type": "result", "title": "r", "bullets": [],
|
||||
"speaker_notes": "", "evidence": [],
|
||||
"suggested_figures": [
|
||||
{"label": "Fig 2", "para_id": "S4-P1-Fig2", "status": ""},
|
||||
{"label": "Fig 9", "para_id": "S1-P1-Fig9", "status": ""},
|
||||
{"label": "bad", "para_id": "nope", "status": ""},
|
||||
]},
|
||||
]
|
||||
warnings = validate_evidence(pages, index)
|
||||
figs = pages[0]["suggested_figures"]
|
||||
assert figs[0]["status"] == "verified"
|
||||
assert figs[1]["status"] == "unverified" # 段落存在但没有 Fig9
|
||||
assert figs[2]["status"] == "unverified" # 格式非法
|
||||
assert any("格式不合法" in w for w in warnings)
|
||||
|
||||
|
||||
# ---------- map-reduce 编排(mock LLM) ----------
|
||||
|
||||
def _page(t: str, i: int) -> dict:
|
||||
if t == "title":
|
||||
bullets = ["作者 甲 等", "某大学植物科学学院"]
|
||||
elif t == "outline":
|
||||
bullets = ["研究背景", "材料与方法", "主要结果", "结论"]
|
||||
elif t == "reference":
|
||||
bullets = [f"Ref {i}-{j}" for j in range(6)]
|
||||
else:
|
||||
bullets = [f"{t} 要点 {i}-{j}" for j in range(4)]
|
||||
ev = [{"para_id": "S1-P1", "quote": "salt stress response line 0"}] * len(bullets)
|
||||
return {
|
||||
"type": t, "title": f"{t} 第{i}节", "bullets": bullets,
|
||||
"evidence": ev, "speaker_notes": f"讲稿 {t} {i}。",
|
||||
"suggested_figures": [],
|
||||
}
|
||||
|
||||
|
||||
def _install_fake_llm(monkeypatch, *, thin_method: bool = False,
|
||||
map_fail: bool = False, expand_ok: bool = True,
|
||||
bogus_figures: bool = False):
|
||||
state = {"n": 0, "expand": 0}
|
||||
|
||||
async def fake(system: str, user: str, **kwargs):
|
||||
state["n"] += 1
|
||||
if "精读助手" in system:
|
||||
if map_fail:
|
||||
raise llm.LLMError("boom: upstream 500")
|
||||
figs = [{"label": "Fig 2", "para_id": "S4-P1-Fig2"}]
|
||||
if bogus_figures:
|
||||
figs.append({"label": "Fig 9", "para_id": "S1-P1-Fig9"})
|
||||
return {
|
||||
"claims": [{"claim": "salt 增加离子积累",
|
||||
"para_id": "S1-P1",
|
||||
"quote": "salt stress response line 0"}],
|
||||
"figures": figs,
|
||||
}
|
||||
if "学术汇报策划" in system:
|
||||
types = re.findall(r"^- (\w+): \d+~\d+ 页", user, re.M)
|
||||
assert types, user[:300]
|
||||
pages = []
|
||||
for t in types:
|
||||
n = 1 if t in ("title", "outline", "conclusion", "reference") else 3
|
||||
for i in range(n):
|
||||
p = _page(t, i)
|
||||
if thin_method and t == "method" and i == 0:
|
||||
p["bullets"] = ["只有一个要点"]
|
||||
p["evidence"] = [{"para_id": "S1-P1",
|
||||
"quote": "salt stress response line 0"}]
|
||||
if t == "result":
|
||||
p["suggested_figures"] = [
|
||||
{"label": "Fig 2", "para_id": "S4-P1-Fig2"}]
|
||||
if bogus_figures:
|
||||
p["suggested_figures"].append(
|
||||
{"label": "Fig 9", "para_id": "S1-P1-Fig9"})
|
||||
pages.append(p)
|
||||
return {"pages": pages}
|
||||
if "某几页幻灯片的要点不足" in system:
|
||||
state["expand"] += 1
|
||||
head = user.split("\n\n摘要卡片")[0]
|
||||
info = json.loads(head.split(":\n", 1)[1])
|
||||
if not expand_ok:
|
||||
return {"pages": []}
|
||||
return {"pages": [{
|
||||
"index": item["index"],
|
||||
"bullets": [f"扩充要点 {k}" for k in range(4)],
|
||||
"evidence": [{"para_id": "S1-P1",
|
||||
"quote": "salt stress response line 0"}] * 4,
|
||||
} for item in info]}
|
||||
raise AssertionError(f"未预期的 LLM system: {system[:60]}")
|
||||
|
||||
monkeypatch.setattr(llm, "complete_json", fake)
|
||||
return state
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_outline_happy_path(monkeypatch):
|
||||
state = _install_fake_llm(monkeypatch)
|
||||
pages, warnings, meta = await generate_outline(LONG_TEXT, "defense", 15, "academic-minimal")
|
||||
lo, hi = ppt_outline.PAGE_TARGETS[15]
|
||||
assert lo <= len(pages) <= hi, len(pages)
|
||||
assert pages[0]["type"] == "title"
|
||||
assert [p["slide_number"] for p in pages] == list(range(1, len(pages) + 1))
|
||||
# evidence 程序化校验已执行
|
||||
all_ev = [e for p in pages for e in (p["evidence"] or []) if e]
|
||||
assert all_ev and all(e["status"] in ("verified", "unverified") for e in all_ev)
|
||||
# 图引用 S4-P1-Fig2 verified
|
||||
figs = [f for p in pages for f in (p["suggested_figures"] or [])]
|
||||
assert figs and figs[0]["status"] == "verified", figs
|
||||
# llm_calls 只计 map+reduce;expand 单独计数(state.n 含 expand)
|
||||
assert meta["llm_calls"] == state["n"] - state["expand"] and meta["llm_calls"] >= 3
|
||||
assert meta["target_pages"] == [lo, hi]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_outline_expand_thin_page(monkeypatch):
|
||||
state = _install_fake_llm(monkeypatch, thin_method=True)
|
||||
pages, warnings, meta = await generate_outline(LONG_TEXT, "group_meeting", 20, "botanic-forest")
|
||||
assert state["expand"] == 1
|
||||
# 原薄弱页("只有一个要点")已被扩充为 4 条
|
||||
expanded = [p for p in pages
|
||||
if p["type"] == "method" and p["bullets"]
|
||||
and p["bullets"][0].startswith("扩充要点")]
|
||||
assert expanded and len(expanded[0]["bullets"]) == 4
|
||||
assert not any(p["bullets"] == ["只有一个要点"] for p in pages)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_outline_expand_failure_keeps_page(monkeypatch):
|
||||
_install_fake_llm(monkeypatch, thin_method=True, expand_ok=False)
|
||||
pages, warnings, meta = await generate_outline(LONG_TEXT, "group_meeting", 20, "botanic-forest")
|
||||
thin = [p for p in pages if p["bullets"] == ["只有一个要点"]]
|
||||
assert thin and len(thin[0]["bullets"]) == 1 # 扩充失败保留原状
|
||||
assert any("扩充后仍不足" in w for w in warnings)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_outline_map_failure(monkeypatch):
|
||||
_install_fake_llm(monkeypatch, map_fail=True)
|
||||
with pytest.raises(OutlineError) as ei:
|
||||
await generate_outline(LONG_TEXT, "defense", 15, "academic-minimal")
|
||||
assert ei.value.code == "LLM_ERROR"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_outline_input_validation(monkeypatch):
|
||||
_install_fake_llm(monkeypatch)
|
||||
for scenario, minutes, style, text, code in [
|
||||
("nope", 15, "academic-minimal", LONG_TEXT, "VALIDATION"),
|
||||
("defense", 15, "nope", LONG_TEXT, "VALIDATION"),
|
||||
("defense", 45, "academic-minimal", LONG_TEXT, "VALIDATION"),
|
||||
("defense", 15, "academic-minimal", "short", "VALIDATION"),
|
||||
]:
|
||||
with pytest.raises(OutlineError) as ei:
|
||||
await generate_outline(text, scenario, minutes, style)
|
||||
assert ei.value.code == code, (scenario, minutes, style)
|
||||
|
||||
|
||||
# ---------- P5 增强:section 分隔页 / highlight / 真实图表清单 ----------
|
||||
|
||||
def test_page_plan_section_quota_long_decks_only():
|
||||
# 15 分钟 target=12 ≥11 → 每内容组一张分隔页(3 组)
|
||||
assert page_plan("defense", 15)["section"] == (3, 3)
|
||||
assert page_plan("proposal", 20)["section"] == (3, 3)
|
||||
# 10 分钟 target=9 <11 → 小 deck 无分隔页
|
||||
assert "section" not in page_plan("group_meeting", 10)
|
||||
assert "section" not in page_plan("conference_talk", 10)
|
||||
|
||||
|
||||
def test_coerce_outline_highlight_roundtrip():
|
||||
payload = {"pages": [
|
||||
{"type": "result", "title": "r", "bullets": ["a"],
|
||||
"highlight": {"value": "+0.82 cm", "label": "体长增长"}},
|
||||
{"type": "result", "title": "r2", "bullets": ["b"],
|
||||
"highlight": {"value": ""}},
|
||||
{"type": "result", "title": "r3", "bullets": ["c"]},
|
||||
]}
|
||||
pages = coerce_outline(payload)
|
||||
assert pages[0]["highlight"] == {"value": "+0.82 cm", "label": "体长增长"}
|
||||
assert "highlight" not in pages[1] # value 空 → 不带该字段
|
||||
assert "highlight" not in pages[2] # 缺字段 → 不带该字段
|
||||
# 超长截断 value≤24 / label≤32
|
||||
payload["pages"][0]["highlight"] = {"value": "x" * 40, "label": "y" * 50}
|
||||
hl = coerce_outline(payload)[0]["highlight"]
|
||||
assert len(hl["value"]) == 24 and len(hl["label"]) == 32
|
||||
|
||||
|
||||
def test_trim_drops_section_pages_first():
|
||||
warnings = []
|
||||
types = ["title", "section", "outline", "section", "background",
|
||||
"background", "method", "method", "result", "result",
|
||||
"conclusion", "section"]
|
||||
pages = [{"slide_number": i + 1, "type": t, "title": f"{t}{i}",
|
||||
"bullets": [], "evidence": [], "speaker_notes": "",
|
||||
"suggested_figures": []} for i, t in enumerate(types)]
|
||||
out = trim_to_max(pages, 9, warnings)
|
||||
ts = [p["type"] for p in out]
|
||||
assert len(out) == 9
|
||||
assert ts.count("section") == 0 # section 优先级最高且 floor 0
|
||||
assert ts.count("conclusion") == 1 and ts[0] == "title"
|
||||
assert warnings
|
||||
|
||||
|
||||
def test_validate_evidence_figure_index_drop_and_rewire():
|
||||
index = {"S4-P1": "Figure 2: growth under salt stress.", "S1-P1": "intro text"}
|
||||
figure_index = [{"label": "Fig 2", "para_id": "S4-P1", "kind": "figure",
|
||||
"num": 2, "caption": "Figure 2: growth under salt stress."}]
|
||||
pages = [
|
||||
{"slide_number": 1, "type": "result", "title": "r", "bullets": [],
|
||||
"speaker_notes": "", "evidence": [],
|
||||
"suggested_figures": [
|
||||
{"label": "Fig 2", "para_id": "S4-P1-Fake", "status": ""},
|
||||
{"label": "Fig 9", "para_id": "S1-P1-Fig9", "status": ""},
|
||||
{"label": "Table 3", "para_id": "S1-P1-T3", "status": ""},
|
||||
]},
|
||||
]
|
||||
warnings = validate_evidence(pages, index, figure_index)
|
||||
kept = pages[0]["suggested_figures"]
|
||||
# 清单里只有 Fig 2 → 其余两个直接丢弃(不是标 unverified)
|
||||
assert [f["label"] for f in kept] == ["Fig 2"]
|
||||
assert kept[0]["para_id"] == "S4-P1" # 以扫描出的真实位置为准
|
||||
assert kept[0]["status"] == "verified"
|
||||
assert sum("已丢弃" in w for w in warnings) == 2
|
||||
|
||||
# 空清单 → 全丢(论文没图就不许瞎引)
|
||||
pages2 = [{"slide_number": 1, "type": "result", "title": "r", "bullets": [],
|
||||
"speaker_notes": "", "evidence": [],
|
||||
"suggested_figures": [{"label": "Fig 2", "para_id": "S4-P1-Fig2",
|
||||
"status": ""}]}]
|
||||
validate_evidence(pages2, index, [])
|
||||
assert pages2[0]["suggested_figures"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_outline_drops_bogus_figure(monkeypatch):
|
||||
_install_fake_llm(monkeypatch, bogus_figures=True)
|
||||
pages, warnings, meta = await generate_outline(
|
||||
LONG_TEXT, "defense", 15, "academic-minimal")
|
||||
# map 阶段的虚构图先被清单裁掉(每块各丢 1 个 Fig 9)
|
||||
assert any("map 阶段" in w and "真实图表清单,已丢弃" in w for w in warnings)
|
||||
# reduce 返回里的 Fig 9 也被丢弃;保留的 Fig 2 para_id 被改写为真实位置
|
||||
figs = [f for p in pages for f in (p.get("suggested_figures") or [])]
|
||||
assert figs and all(f["label"] == "Fig 2" for f in figs)
|
||||
assert all(f["para_id"] == "S4-P1" and f["status"] == "verified" for f in figs)
|
||||
assert any("图引用 Fig 9 不在论文真实图表清单" in w for w in warnings)
|
||||
# meta 反映真实清单与实际采用数
|
||||
assert meta["figures"] == 1
|
||||
assert meta["figures_used"] == len(figs) >= 1
|
||||
# 结果页仍落在目标页数区间
|
||||
lo, hi = ppt_outline.PAGE_TARGETS[15]
|
||||
assert lo <= len(pages) <= hi
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_outline_emits_section_pages_and_highlights(monkeypatch):
|
||||
_install_fake_llm(monkeypatch)
|
||||
pages, warnings, meta = await generate_outline(
|
||||
LONG_TEXT, "defense", 15, "academic-minimal")
|
||||
ts = [p["type"] for p in pages]
|
||||
# 15 分钟 defense 出现章节分隔页(fake 每组 3 张 → trim 后仍有)
|
||||
assert ts.count("section") >= 1
|
||||
assert ts[0] == "title" and ts[-1] == "reference"
|
||||
# fake 的 result 页无 highlight 字段 → meta 与页结构不异常
|
||||
assert all(isinstance(p.get("highlight"), dict) or "highlight" not in p
|
||||
for p in pages)
|
||||
|
||||
|
||||
# ---------- P5 二次开发 2.2:table 字段裁决 + prompt 内容契约 ----------
|
||||
|
||||
def test_coerce_table_roundtrip_and_truncation():
|
||||
payload = {
|
||||
"pages": [
|
||||
{"type": "result", "title": "对比", "bullets": ["导读"],
|
||||
"table": {"headers": ["处理", "株高"],
|
||||
"rows": [["对照", "10.2"], ["盐胁迫", 7.4],
|
||||
["x"] * 20, "not-a-row"]}},
|
||||
{"type": "result", "title": "坏表", "bullets": [],
|
||||
"table": {"rows": "oops"}},
|
||||
]
|
||||
}
|
||||
pages = coerce_outline(payload)
|
||||
tbl = pages[0]["table"]
|
||||
assert tbl["headers"] == ["处理", "株高"]
|
||||
assert tbl["rows"][0] == ["对照", "10.2"]
|
||||
assert tbl["rows"][1] == ["盐胁迫", "7.4"] # 非字符串单元格 str 化
|
||||
assert tbl["rows"][2] == ["x"] * 8 # 单行超宽截到 8 列
|
||||
assert len(tbl["rows"]) == 3 # 非 list 行("not-a-row")丢弃
|
||||
assert "table" not in pages[1] # 坏结构直接丢弃
|
||||
|
||||
|
||||
def test_reduce_prompt_layout_contract():
|
||||
from app.services.ppt_outline import _REDUCE_SYSTEM
|
||||
assert "标题即结论" in _REDUCE_SYSTEM
|
||||
assert "版面标记" in _REDUCE_SYSTEM
|
||||
assert '"table"' in _REDUCE_SYSTEM
|
||||
assert "3-5 条" in _REDUCE_SYSTEM
|
||||
assert "> " in _REDUCE_SYSTEM and "→ " in _REDUCE_SYSTEM
|
||||
|
||||
|
||||
# ---------- P5 修复:evidence para_id 反查(LLM 编造段落ID) ----------
|
||||
|
||||
def _index_and_page(quote: str, pid: str):
|
||||
from app.services.ppt_outline import build_index
|
||||
text = "\n".join([
|
||||
"Introduction paragraph without any figure.",
|
||||
f"Salt stress response line describes ion transport: {quote}",
|
||||
"Final paragraph about growth phenotypes under salt.",
|
||||
])
|
||||
_, index = build_index(text)
|
||||
real_pid = next(k for k, v in index.items() if quote in v)
|
||||
page = {"slide_number": 1, "type": "background", "title": "t",
|
||||
"bullets": ["b1"], "evidence": [{"para_id": pid, "quote": quote,
|
||||
"status": "unverified"}],
|
||||
"speaker_notes": "", "suggested_figures": []}
|
||||
return index, page, real_pid
|
||||
|
||||
|
||||
def test_repair_evidence_para_ids_fabricated_pid():
|
||||
from app.services.ppt_outline import repair_evidence_para_ids, validate_evidence
|
||||
quote = "Salt stress response line describes ion transport"
|
||||
index, page, real_pid = _index_and_page(quote, "S9-P42") # 编造的 para_id
|
||||
assert repair_evidence_para_ids([page], index) == 1
|
||||
assert page["evidence"][0]["para_id"] == real_pid # 已填真实 para_id
|
||||
validate_evidence([page], index, figure_index=[])
|
||||
assert page["evidence"][0]["status"] == "verified"
|
||||
|
||||
|
||||
def test_repair_evidence_para_ids_missing_quote_stays_unverified():
|
||||
from app.services.ppt_outline import repair_evidence_para_ids, validate_evidence
|
||||
text_quote = "Salt stress response line describes ion transport"
|
||||
index, page, _ = _index_and_page(text_quote, "S1-P2")
|
||||
# LLM 返回不存在的 quote(真幻觉)→ para_id 不填,保持 unverified
|
||||
page["evidence"][0]["quote"] = "This sentence does not exist anywhere in the paper"
|
||||
assert repair_evidence_para_ids([page], index) == 0
|
||||
assert page["evidence"][0]["para_id"] == "S1-P2" # 未改动
|
||||
validate_evidence([page], index, figure_index=[])
|
||||
assert page["evidence"][0]["status"] == "unverified"
|
||||
|
||||
|
||||
def test_repair_evidence_para_ids_quote_only_no_pid():
|
||||
# quote-only evidence(LLM 未给 para_id)→ 反查填入
|
||||
from app.services.ppt_outline import repair_evidence_para_ids
|
||||
quote = "Salt stress response line describes ion transport"
|
||||
index, page, real_pid = _index_and_page(quote, "")
|
||||
page["evidence"][0]["para_id"] = ""
|
||||
assert repair_evidence_para_ids([page], index) == 1
|
||||
assert page["evidence"][0]["para_id"] == real_pid
|
||||
|
||||
|
||||
def test_reduce_prompt_requires_quote_not_para_id():
|
||||
from app.services.ppt_outline import _REDUCE_SYSTEM, _EXPAND_SYSTEM
|
||||
for sys_prompt in (_REDUCE_SYSTEM, _EXPAND_SYSTEM):
|
||||
assert "quote" in sys_prompt
|
||||
assert "不需要写 para_id" in sys_prompt
|
||||
assert '"para_id": "...", "quote"' not in sys_prompt # evidence schema 已去 para_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_outline_repairs_fabricated_para_ids(monkeypatch):
|
||||
"""端到端:reduce 返回正确 quote + 编造 para_id → 输出里全部 verified。"""
|
||||
from app.services import llm, ppt_outline
|
||||
quote = "salt stress response line 0 describes ion transport and growth"
|
||||
|
||||
async def fake_complete_json(system, user, **kwargs):
|
||||
if "精读助手" in system: # map
|
||||
return {"claims": [{"claim": "c", "para_id": "S1-P1", "quote": quote}],
|
||||
"figures": []}
|
||||
if "策划" in system: # reduce
|
||||
return {"pages": [
|
||||
{"type": "title", "title": "盐胁迫汇报", "bullets": ["张三 等"],
|
||||
"evidence": []},
|
||||
{"type": "background", "title": "背景", "bullets": ["要点一", "要点二"],
|
||||
"evidence": [{"para_id": "S9-P42", "quote": quote}, None]},
|
||||
]}
|
||||
return {"pages": []} # expand 不应触发
|
||||
|
||||
monkeypatch.setattr(llm, "complete_json", fake_complete_json)
|
||||
pages, warnings, meta = await ppt_outline.generate_outline(
|
||||
LONG_TEXT, "defense", 15, "academic-minimal",
|
||||
)
|
||||
ev = next(e for p in pages for e in (p.get("evidence") or []) if e)
|
||||
assert ev["para_id"] != "S9-P42" and ev["status"] == "verified"
|
||||
assert any("反查修正" in w for w in warnings)
|
||||
assert meta["unverified_evidence"] == 0
|
||||
|
||||
|
||||
# ---------- v3 修复 2/3/4:清单收窄 / section 副标题 / 跨段 quote ----------
|
||||
|
||||
def test_narrow_figure_index_to_extracted_only():
|
||||
from app.services.ppt_outline import _narrow_figure_index
|
||||
figs = [{"label": "Fig 1", "para_id": "S1-P2-Fig1"},
|
||||
{"label": "Fig 2", "para_id": "S4-P1-Fig2"},
|
||||
{"label": "Table 1", "para_id": "S5-P3"}]
|
||||
# 部分提取 → 只留磁盘上有的(canon 编号匹配;label 统一成磁盘形态)
|
||||
out = _narrow_figure_index(figs, ["Fig 2", "Table 1"])
|
||||
assert [f["label"] for f in out] == ["Fig 2", "Table 1"]
|
||||
# None / 空列表 → 不收窄(text 模式保持全清单占位框)
|
||||
assert _narrow_figure_index(figs, None) == figs
|
||||
assert _narrow_figure_index(figs, []) == figs
|
||||
# 交集为空(提取 label 与扫描不一致等意外)→ 回退全清单,绝不把图清空
|
||||
assert _narrow_figure_index(figs, ["Fig 99"]) == figs
|
||||
|
||||
|
||||
def test_narrow_figure_index_rewrites_subfigure_and_dedups():
|
||||
"""子图条目(Fig. 6b 无对应文件)→ 归一到磁盘形态 Fig 6,同号去重。"""
|
||||
from app.services.ppt_outline import _narrow_figure_index
|
||||
figs = [{"label": "Fig. 6b", "para_id": "S4-P9-Fig6b"},
|
||||
{"label": "Fig 6", "para_id": "S4-P9-Fig6"},
|
||||
{"label": "Fig 5", "para_id": "S3-P7-Fig5"}]
|
||||
out = _narrow_figure_index(figs, ["Fig 6"])
|
||||
assert [f["label"] for f in out] == ["Fig 6"] # 两条同号合一条、磁盘形态
|
||||
# 磁盘无 Fig 5 → 剔除
|
||||
out2 = _narrow_figure_index(figs, ["Fig 6", "Fig 5"])
|
||||
assert [f["label"] for f in out2] == ["Fig 6", "Fig 5"]
|
||||
|
||||
|
||||
def test_coerce_section_title_strips_trailing_particle_keeps_subtitle():
|
||||
pages = coerce_outline({"pages": [
|
||||
{"type": "section", "title": "研究方法是", "bullets": ["实验设计与取样策略"]},
|
||||
{"type": "section", "title": "研究背景", "bullets": [],
|
||||
"subtitle": "黄瓜遗传改良亟需高精度泛基因组"},
|
||||
{"type": "section", "title": "结果的", "bullets": []},
|
||||
]})
|
||||
# 错字「研究方法是」→ 组名;正常组名不动
|
||||
assert [p["title"] for p in pages] == ["研究方法", "研究背景", "结果"]
|
||||
# 改进 2:副标题进独立 subtitle 字段(旧大纲仍在 bullets[0],读取时兼容)
|
||||
assert pages[0]["subtitle"] == "实验设计与取样策略"
|
||||
assert pages[1]["subtitle"] == "黄瓜遗传改良亟需高精度泛基因组"
|
||||
assert pages[2]["subtitle"] == ""
|
||||
# bullets 原样保留(编辑器仍可改;非 section 页不新增字段)
|
||||
assert pages[0]["bullets"] == ["实验设计与取样策略"]
|
||||
assert "subtitle" not in coerce_outline(
|
||||
{"pages": [{"type": "background", "title": "背景", "bullets": ["要点"]}]}
|
||||
)[0]
|
||||
|
||||
|
||||
def test_coerce_section_subtitle_prefers_edited_bullet():
|
||||
"""编辑器改 bullets[0] 后保存 → 以 bullets 为准,不被旧 subtitle 覆盖。"""
|
||||
page = coerce_outline({"pages": [
|
||||
{"type": "section", "title": "研究背景",
|
||||
"bullets": ["改写后的本章说明"], "subtitle": "旧的副标题"},
|
||||
]})[0]
|
||||
assert page["subtitle"] == "改写后的本章说明"
|
||||
|
||||
|
||||
def test_reduce_prompt_section_subtitle_rule():
|
||||
from app.services.ppt_outline import _REDUCE_SYSTEM
|
||||
assert "副标题" in _REDUCE_SYSTEM # 要求给分隔页副标题
|
||||
assert "subtitle" in _REDUCE_SYSTEM # 走独立字段,不再塞 bullets
|
||||
assert "该章第一张内容页" in _REDUCE_SYSTEM # 副标题内容 = 该章首页 title
|
||||
assert "虚词" in _REDUCE_SYSTEM # 防「研究方法是」类错字
|
||||
|
||||
|
||||
def test_cross_segment_locate_min_length_and_segment():
|
||||
from app.services.ppt_outline import _concat_segments, _locate_span, _norm
|
||||
index = {"A": "alpha beta", "B": "gamma delta"}
|
||||
ni = {k: _norm(v) for k, v in index.items()}
|
||||
concat, offsets = _concat_segments(ni)
|
||||
assert _locate_span("abetagamma", concat, offsets) == "A" # 跨段,起点在 A
|
||||
assert _locate_span("gamma", concat, offsets) is None # <10 归一化字符不启用
|
||||
|
||||
|
||||
def test_repair_and_validate_cross_segment_quote_verified():
|
||||
"""quote 横跨两个段落边界(单段都不含、拼接后含)→ 归属起点段并 verified。"""
|
||||
from app.services.ppt_outline import repair_evidence_para_ids, validate_evidence
|
||||
index = {
|
||||
"S1-P1": "The yield of transgenic lines increased by 23.5 percent",
|
||||
"S1-P2": "compared with the wild type under salt stress conditions.",
|
||||
}
|
||||
quote = "increased by 23.5 percent compared with the wild type"
|
||||
page = {"slide_number": 1, "type": "result", "title": "t", "bullets": ["b"],
|
||||
"evidence": [{"para_id": "", "quote": quote}],
|
||||
"speaker_notes": "", "suggested_figures": []}
|
||||
assert repair_evidence_para_ids([page], index) == 1
|
||||
assert page["evidence"][0]["para_id"] == "S1-P1"
|
||||
warnings = validate_evidence([page], index, figure_index=[])
|
||||
assert page["evidence"][0]["status"] == "verified"
|
||||
assert not any("不符" in w for w in warnings)
|
||||
|
||||
|
||||
def test_validate_cross_segment_wrong_pid_stays_unverified_with_warning():
|
||||
from app.services.ppt_outline import validate_evidence
|
||||
index = {
|
||||
"S1-P1": "The yield of transgenic lines increased by 23.5 percent",
|
||||
"S1-P2": "compared with the wild type under salt stress conditions.",
|
||||
}
|
||||
quote = "increased by 23.5 percent compared with the wild type"
|
||||
# para_id 指到尾巴段(起点在 S1-P1)→ 起点不匹配,仍 unverified + 不符 warning
|
||||
page = {"slide_number": 1, "type": "result", "title": "t", "bullets": ["b"],
|
||||
"evidence": [{"para_id": "S1-P2", "quote": quote}],
|
||||
"speaker_notes": "", "suggested_figures": []}
|
||||
warnings = validate_evidence([page], index, figure_index=[])
|
||||
assert page["evidence"][0]["status"] == "unverified"
|
||||
assert any("不符" in w for w in warnings)
|
||||
|
||||
|
||||
def test_trim_keeps_section_floor_when_floors_given():
|
||||
"""v3 修复3:传 section 保底 → 超页先删超配额内容页,section 不清空。"""
|
||||
warnings = []
|
||||
types = ["title", "section", "outline", "section", "background",
|
||||
"background", "method", "method", "result", "result",
|
||||
"conclusion", "section"]
|
||||
pages = [{"slide_number": i + 1, "type": t, "title": f"{t}{i}",
|
||||
"bullets": [], "evidence": [], "speaker_notes": "",
|
||||
"suggested_figures": []} for i, t in enumerate(types)]
|
||||
out = trim_to_max(pages, 9, warnings, floors={"section": 3})
|
||||
ts = [p["type"] for p in out]
|
||||
assert len(out) == 9
|
||||
assert ts.count("section") == 3 # 配额保底,3 张全留
|
||||
assert ts.count("background") == 1 # 超配额内容页先删(4→1,floor 1)
|
||||
assert warnings
|
||||
@@ -0,0 +1,73 @@
|
||||
"""tests/test_ppt_preview.py —— P5 可感知化:pptx → PNG 页预览(PIL)。
|
||||
|
||||
覆盖:真 3 页 pptx → 3 张 1280×720 PNG、缺文件/坏文件降级 []、
|
||||
重建页数变少不留 slide_*.png 残影、单页渲染失败不中断(原子写不半图)。
|
||||
运行: cd review_service && <venv>/bin/python -m pytest tests/test_ppt_preview.py -q
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from app.services.ppt_build import build_pptx
|
||||
from app.services.ppt_preview import DPI, SLIDE_H_IN, SLIDE_W_IN, render_previews
|
||||
|
||||
PAGES = [
|
||||
{"slide_number": 1, "type": "title", "title": "水稻盐胁迫响应机制",
|
||||
"bullets": ["张三 等"], "evidence": [],
|
||||
"speaker_notes": "", "suggested_figures": []},
|
||||
{"slide_number": 2, "type": "background", "title": "研究背景",
|
||||
"bullets": ["盐胁迫是主要非生物胁迫", "机制不清"],
|
||||
"evidence": [None, None], "speaker_notes": "", "suggested_figures": []},
|
||||
{"slide_number": 3, "type": "conclusion", "title": "结论",
|
||||
"bullets": ["发现关键因子"], "evidence": [None],
|
||||
"speaker_notes": "", "suggested_figures": []},
|
||||
]
|
||||
|
||||
|
||||
def _build(tmp_path: Path, pages=None) -> Path:
|
||||
out = tmp_path / "draft.pptx"
|
||||
build_pptx(pages or PAGES, "academic-minimal", out, context={})
|
||||
return out
|
||||
|
||||
|
||||
def test_render_three_pages(tmp_path):
|
||||
pptx = _build(tmp_path)
|
||||
written = render_previews(pptx, tmp_path / "preview")
|
||||
assert [p.name for p in written] == ["slide_01.png", "slide_02.png", "slide_03.png"]
|
||||
for p in written:
|
||||
assert p.is_file() and p.stat().st_size > 1_000
|
||||
with Image.open(p) as img:
|
||||
assert img.size == (int(SLIDE_W_IN * DPI), int(SLIDE_H_IN * DPI)) # 1280×720
|
||||
|
||||
|
||||
def test_missing_and_corrupt_pptx_degrade(tmp_path):
|
||||
assert render_previews(tmp_path / "nope.pptx", tmp_path / "preview") == []
|
||||
bad = tmp_path / "bad.pptx"
|
||||
bad.write_bytes(b"not a zip at all")
|
||||
assert render_previews(bad, tmp_path / "preview") == []
|
||||
# 空 zip(0 页)→ 不写图不抛
|
||||
import io
|
||||
import zipfile
|
||||
empty = io.BytesIO()
|
||||
with zipfile.ZipFile(empty, "w"):
|
||||
pass
|
||||
z = tmp_path / "empty.pptx"
|
||||
z.write_bytes(empty.getvalue())
|
||||
assert render_previews(z, tmp_path / "preview") == []
|
||||
|
||||
|
||||
def test_rerender_clears_stale_pages(tmp_path):
|
||||
"""重建后页数变少 → 旧的多余 slide_*.png 被清掉(懒渲染 GET 同样受益)。"""
|
||||
pptx = _build(tmp_path)
|
||||
preview = tmp_path / "preview"
|
||||
first = render_previews(pptx, preview)
|
||||
assert len(first) == 3
|
||||
# 模拟"上一次 5 页构建"的残留
|
||||
(preview / "slide_04.png").write_bytes(b"stale")
|
||||
(preview / "slide_05.png").write_bytes(b"stale")
|
||||
second = render_previews(pptx, preview)
|
||||
assert len(second) == 3
|
||||
names = sorted(p.name for p in preview.glob("slide_*.png"))
|
||||
assert names == ["slide_01.png", "slide_02.png", "slide_03.png"]
|
||||
@@ -0,0 +1,290 @@
|
||||
"""tests/test_ppt_router.py —— P5 九端点信封与 job 生命周期(mock 大纲,真 pptx)。
|
||||
|
||||
generate_outline 打桩(router 层单测),ppt_build/ppt_store 走真实实现;
|
||||
覆盖:创建(text/pdf)、信封错误码、PATCH evidence 重验、build→done→下载、
|
||||
DELETE 清行+清目录、下载缺失信封、preview 缩略图(build 渲染/懒渲染/信封)。
|
||||
运行: cd review_service && <venv>/bin/python -m pytest tests/test_ppt_router.py -q
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app.db as db_mod
|
||||
from app import main as app_main
|
||||
from app.config import get_settings
|
||||
from app.services import paper_parse, ppt_store
|
||||
from app.services import ppt_outline
|
||||
|
||||
BASE = "/api/review/ppt"
|
||||
|
||||
RAW_TEXT = "\n".join(
|
||||
f"salt stress response line {i} describes ion transport and growth"
|
||||
for i in range(60)
|
||||
)
|
||||
|
||||
|
||||
def _pages():
|
||||
return [
|
||||
{"slide_number": 1, "type": "title", "title": "盐胁迫汇报",
|
||||
"bullets": ["张三 等"], "evidence": [],
|
||||
"speaker_notes": "", "suggested_figures": []},
|
||||
{"slide_number": 2, "type": "background", "title": "背景",
|
||||
"bullets": ["要点一", "要点二"],
|
||||
"evidence": [
|
||||
{"para_id": "S1-P1", "quote": "salt stress response line 0",
|
||||
"status": "unverified"},
|
||||
None,
|
||||
],
|
||||
"speaker_notes": "备注", "suggested_figures": []},
|
||||
{"slide_number": 3, "type": "conclusion", "title": "结论",
|
||||
"bullets": ["结论一"], "evidence": [None],
|
||||
"speaker_notes": "", "suggested_figures": []},
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(tmp_path, monkeypatch):
|
||||
db_path = tmp_path / "ppt_router_test.db"
|
||||
settings = get_settings()
|
||||
monkeypatch.setattr(settings, "db_path", str(db_path))
|
||||
monkeypatch.setattr(settings, "ppt_jobs_dir", str(tmp_path / "ppt_jobs"))
|
||||
db_mod.set_path(db_path)
|
||||
|
||||
calls = {"outline": 0}
|
||||
|
||||
async def fake_generate_outline(text, scenario, minutes, style_id,
|
||||
*, available_figures=None):
|
||||
# 校验语义与真实 generate_outline 一致(信封码用例依赖)
|
||||
calls["available"] = available_figures # v3 修复2:图 label 透传
|
||||
if scenario not in ppt_outline.SCENARIOS:
|
||||
raise ppt_outline.OutlineError("VALIDATION", f"未知场景 scenario={scenario}")
|
||||
if minutes not in ppt_outline.PAGE_TARGETS:
|
||||
raise ppt_outline.OutlineError("VALIDATION", f"不支持的时长 {minutes} 分钟")
|
||||
if style_id not in ppt_outline.STYLE_IDS:
|
||||
raise ppt_outline.OutlineError("VALIDATION", f"未知风格 style_id={style_id}")
|
||||
if len(text) < 300:
|
||||
raise ppt_outline.OutlineError("VALIDATION", "正文过短(<300 字),无法生成汇报大纲")
|
||||
calls["outline"] += 1
|
||||
return _pages(), ["生成期警告"], {"llm_calls": 3, "target_pages": [11, 14]}
|
||||
|
||||
async def fake_parse_pdf(file_bytes: bytes, filename: str = "paper.pdf") -> str:
|
||||
return RAW_TEXT
|
||||
|
||||
monkeypatch.setattr(ppt_outline, "generate_outline", fake_generate_outline)
|
||||
monkeypatch.setattr(paper_parse, "parse_pdf_bytes", fake_parse_pdf)
|
||||
with TestClient(app_main.app) as c:
|
||||
c.state_outline_calls = calls # type: ignore[attr-defined]
|
||||
yield c
|
||||
|
||||
|
||||
def _create(client, **extra):
|
||||
body = {"text": RAW_TEXT, "scenario": "defense", "minutes": 15,
|
||||
"style_id": "academic-minimal"}
|
||||
body.update(extra)
|
||||
r = client.post(f"{BASE}/jobs", json=body)
|
||||
assert r.status_code == 200, r.text
|
||||
payload = r.json()
|
||||
assert payload["ok"] is True, payload
|
||||
return payload["job"]
|
||||
|
||||
|
||||
def test_styles_endpoint_lists_style_files(client):
|
||||
"""改进 1:GET /styles 回风格实际数量(副标题/风格卡不再写死 8/14)。"""
|
||||
r = client.get(f"{BASE}/styles")
|
||||
assert r.status_code == 200
|
||||
payload = r.json()
|
||||
assert payload["ok"] is True
|
||||
assert payload["meta"]["count"] == len(payload["items"]) == 14
|
||||
ids = [i["id"] for i in payload["items"]]
|
||||
assert ids == list(ppt_outline.STYLE_IDS)
|
||||
assert payload["items"][0]["label"] and payload["items"][0]["description"]
|
||||
|
||||
|
||||
def test_create_text_job(client):
|
||||
job = _create(client)
|
||||
assert job["status"] == "outline_ready"
|
||||
assert job["scenario"] == "defense" and job["minutes"] == 15
|
||||
assert len(job["pages"]) == 3
|
||||
assert job["warnings"] == ["生成期警告"]
|
||||
assert job["source"]["kind"] == "text"
|
||||
assert job["pptx_ready"] is False
|
||||
# GET 全量还原
|
||||
r = client.get(f"{BASE}/jobs/{job['id']}")
|
||||
assert r.json()["job"]["id"] == job["id"]
|
||||
# raw_text 不进 job dict
|
||||
assert "text" not in r.json()["job"]["source"]
|
||||
|
||||
|
||||
def test_create_pdf_job(client):
|
||||
r = client.post(
|
||||
f"{BASE}/jobs/pdf",
|
||||
files={"file": ("paper.pdf", b"%PDF-1.4 fake", "application/pdf")},
|
||||
data={"scenario": "proposal", "minutes": "20", "style_id": "botanic-forest"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
payload = r.json()
|
||||
assert payload["ok"] is True, payload
|
||||
assert payload["job"]["source"]["kind"] == "pdf"
|
||||
assert payload["job"]["source"]["filename"] == "paper.pdf"
|
||||
assert payload["job"]["style_id"] == "botanic-forest"
|
||||
# 2.3:假 PDF 提取失败只降级不 500,meta 有 figures_extracted
|
||||
assert payload["meta"].get("figures_extracted") == 0
|
||||
|
||||
|
||||
def test_pdf_create_passes_extracted_figure_labels(client, monkeypatch):
|
||||
"""v3 修复2:提取到的原图 label 透传给 generate_outline(且不泄漏进响应 meta)。"""
|
||||
import app.routers.ppt as ppt_router
|
||||
|
||||
def fake_extract(pdf_bytes: bytes, job_id: str):
|
||||
return {"figures_extracted": 2, "figure_labels": ["Fig 1", "Fig 3"]}
|
||||
|
||||
monkeypatch.setattr(ppt_router, "_extract_job_figures", fake_extract)
|
||||
r = client.post(
|
||||
f"{BASE}/jobs/pdf",
|
||||
files={"file": ("paper.pdf", b"%PDF-1.4 fake", "application/pdf")},
|
||||
data={"scenario": "proposal", "minutes": "20", "style_id": "botanic-forest"},
|
||||
)
|
||||
assert r.status_code == 200 and r.json()["ok"] is True
|
||||
assert client.state_outline_calls.get("available") == ["Fig 1", "Fig 3"]
|
||||
meta = r.json()["meta"]
|
||||
assert meta.get("figures_extracted") == 2
|
||||
assert "figure_labels" not in meta # 不进响应
|
||||
|
||||
|
||||
def test_create_pdf_rejects_non_pdf(client):
|
||||
r = client.post(
|
||||
f"{BASE}/jobs/pdf",
|
||||
files={"file": ("notes.txt", b"hello", "text/plain")},
|
||||
data={"scenario": "defense", "minutes": "15", "style_id": "academic-minimal"},
|
||||
)
|
||||
assert r.status_code == 200 # 业务错误 = 200 信封
|
||||
payload = r.json()
|
||||
assert payload["ok"] is False and payload["error"]["code"] == "VALIDATION"
|
||||
|
||||
|
||||
def test_create_validation_error_envelope(client):
|
||||
r = client.post(f"{BASE}/jobs", json={"text": RAW_TEXT, "scenario": "nope"})
|
||||
assert r.status_code == 200
|
||||
payload = r.json()
|
||||
assert payload["ok"] is False and payload["error"]["code"] == "VALIDATION"
|
||||
|
||||
|
||||
def test_patch_pages_and_evidence_revalidation(client):
|
||||
job = _create(client)
|
||||
pages = job["pages"]
|
||||
# 编辑:改背景页第 1 条 quote 为编造内容 → 重验为 unverified
|
||||
pages[1]["bullets"][0] = "改成新的要点"
|
||||
pages[1]["evidence"][0] = {"para_id": "S1-P1", "quote": "totally invented words"}
|
||||
r = client.patch(f"{BASE}/jobs/{job['id']}", json={"pages": pages})
|
||||
assert r.status_code == 200
|
||||
payload = r.json()
|
||||
assert payload["ok"] is True, payload
|
||||
ev = payload["job"]["pages"][1]["evidence"][0]
|
||||
assert ev["status"] == "unverified"
|
||||
assert payload["job"]["pages"][1]["bullets"][0] == "改成新的要点"
|
||||
# 编辑为真实 quote → verified
|
||||
pages[1]["evidence"][0] = {"para_id": "S1-P1", "quote": "salt stress response line 0"}
|
||||
r = client.patch(f"{BASE}/jobs/{job['id']}", json={"pages": pages})
|
||||
assert r.json()["job"]["pages"][1]["evidence"][0]["status"] == "verified"
|
||||
# slide_number 重排
|
||||
assert [p["slide_number"] for p in r.json()["job"]["pages"]] == [1, 2, 3]
|
||||
# build 后 pptx_ready → 再编辑则旧 pptx 失效(盘上文件存在也不再下发)
|
||||
assert client.post(f"{BASE}/jobs/{job['id']}/build").json()["job"]["pptx_ready"] is True
|
||||
r = client.patch(f"{BASE}/jobs/{job['id']}", json={"pages": pages})
|
||||
assert r.json()["job"]["pptx_ready"] is False
|
||||
assert (get_settings().ppt_jobs_abs / job["id"] / "draft.pptx").is_file() # 文件仍在盘
|
||||
r = client.get(f"{BASE}/jobs/{job['id']}/pptx")
|
||||
assert r.status_code == 200 and r.json()["ok"] is False
|
||||
|
||||
|
||||
def test_patch_invalid_pages(client):
|
||||
job = _create(client)
|
||||
r = client.patch(f"{BASE}/jobs/{job['id']}", json={"pages": [{"type": "alien"}]})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["ok"] is False and r.json()["error"]["code"] == "VALIDATION"
|
||||
# 空 pages 被 pydantic min_length 拦(422,与 rebuttal 各请求体一致)
|
||||
r = client.patch(f"{BASE}/jobs/{job['id']}", json={"pages": []})
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
def test_build_download_delete_lifecycle(client):
|
||||
job = _create(client)
|
||||
# build 前下载 → 信封 NOT_FOUND
|
||||
r = client.get(f"{BASE}/jobs/{job['id']}/pptx")
|
||||
assert r.status_code == 200 and r.json()["ok"] is False
|
||||
# build → done + pptx_ready
|
||||
r = client.post(f"{BASE}/jobs/{job['id']}/build")
|
||||
assert r.status_code == 200
|
||||
payload = r.json()
|
||||
assert payload["ok"] is True, payload
|
||||
assert payload["job"]["status"] == "done"
|
||||
assert payload["job"]["pptx_ready"] is True
|
||||
assert payload["meta"]["slides"] == 3 and payload["meta"]["bytes"] > 10_000
|
||||
# 3.x:build 即渲染缩略图(页数与 slides 对齐)
|
||||
assert payload["meta"]["previews"] == 3
|
||||
# 下载真 pptx
|
||||
r = client.get(f"{BASE}/jobs/{job['id']}/pptx")
|
||||
assert r.status_code == 200
|
||||
assert r.headers["content-type"].startswith("application/vnd.openxmlformats")
|
||||
assert r.content[:2] == b"PK" # OOXML = zip
|
||||
# DELETE 清行 + 清产物目录(preview/ 一并消失)
|
||||
r = client.delete(f"{BASE}/jobs/{job['id']}")
|
||||
assert r.json()["ok"] is True and r.json()["meta"]["deleted"] == job["id"]
|
||||
assert ppt_store.get_job(job["id"]) is None
|
||||
r = client.get(f"{BASE}/jobs/{job['id']}")
|
||||
assert r.json()["ok"] is False and r.json()["error"]["code"] == "NOT_FOUND"
|
||||
assert not (get_settings().ppt_jobs_abs / job["id"]).exists()
|
||||
|
||||
|
||||
def test_build_without_pages(client):
|
||||
job = _create(client)
|
||||
ppt_store.update_job(job["id"], pages=[])
|
||||
r = client.post(f"{BASE}/jobs/{job['id']}/build")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["ok"] is False and r.json()["error"]["code"] == "VALIDATION"
|
||||
|
||||
|
||||
def test_preview_endpoint(client):
|
||||
"""preview/{page}:build 即有图;越界/未 build 回信封;旧 job 缺图懒渲染。"""
|
||||
job = _create(client)
|
||||
# 未 build → 信封(HTTP 200,对齐 pptx 下载)
|
||||
r = client.get(f"{BASE}/jobs/{job['id']}/preview/1")
|
||||
assert r.status_code == 200 and r.json()["ok"] is False
|
||||
assert r.json()["error"]["code"] == "NOT_FOUND"
|
||||
|
||||
# build → 逐页 PNG
|
||||
assert client.post(f"{BASE}/jobs/{job['id']}/build").json()["ok"] is True
|
||||
r = client.get(f"{BASE}/jobs/{job['id']}/preview/1")
|
||||
assert r.status_code == 200
|
||||
assert r.headers["content-type"] == "image/png"
|
||||
assert r.content[:8] == b"\x89PNG\r\n\x1a\n"
|
||||
r = client.get(f"{BASE}/jobs/{job['id']}/preview/3")
|
||||
assert r.headers["content-type"] == "image/png"
|
||||
|
||||
# 越界 / 非法页码 → 信封
|
||||
r = client.get(f"{BASE}/jobs/{job['id']}/preview/99")
|
||||
assert r.status_code == 200 and r.json()["ok"] is False
|
||||
assert r.json()["error"]["code"] == "NOT_FOUND"
|
||||
r = client.get(f"{BASE}/jobs/{job['id']}/preview/0")
|
||||
assert r.status_code == 200 and r.json()["ok"] is False
|
||||
assert r.json()["error"]["code"] == "VALIDATION"
|
||||
|
||||
# 懒渲染:删掉 preview 目录(模拟端点上线前构建的旧 job)→ 再 GET 自动补齐
|
||||
preview_dir = get_settings().ppt_jobs_abs / job["id"] / "preview"
|
||||
assert preview_dir.is_dir()
|
||||
import shutil
|
||||
shutil.rmtree(preview_dir)
|
||||
r = client.get(f"{BASE}/jobs/{job['id']}/preview/2")
|
||||
assert r.status_code == 200 and r.headers["content-type"] == "image/png"
|
||||
assert (preview_dir / "slide_02.png").is_file()
|
||||
|
||||
# 编辑后 pptx 失效 → preview 同步封(与 pptx 下载一致)
|
||||
pages = client.get(f"{BASE}/jobs/{job['id']}").json()["job"]["pages"]
|
||||
client.patch(f"{BASE}/jobs/{job['id']}", json={"pages": pages})
|
||||
r = client.get(f"{BASE}/jobs/{job['id']}/preview/1")
|
||||
assert r.status_code == 200 and r.json()["ok"] is False
|
||||
|
||||
# 不存在的 job → 信封
|
||||
r = client.get(f"{BASE}/jobs/ppt_ghost/preview/1")
|
||||
assert r.status_code == 200 and r.json()["error"]["code"] == "NOT_FOUND"
|
||||
@@ -0,0 +1,86 @@
|
||||
"""tests/test_ppt_store.py —— P5 SQLite job 存取(隔离 tmp review.db)。
|
||||
|
||||
运行: cd review_service && <venv>/bin/python -m pytest tests/test_ppt_store.py -q
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
import app.db as db_mod
|
||||
from app.config import get_settings
|
||||
from app.services import ppt_store
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def store_env(tmp_path, monkeypatch):
|
||||
db_path = tmp_path / "ppt_store_test.db"
|
||||
settings = get_settings()
|
||||
monkeypatch.setattr(settings, "db_path", str(db_path))
|
||||
db_mod.set_path(db_path)
|
||||
ppt_store.ensure_table()
|
||||
return db_path
|
||||
|
||||
|
||||
def _job(job_id: str) -> dict:
|
||||
return {
|
||||
"id": job_id,
|
||||
"status": "outline_ready",
|
||||
"source": {"kind": "text", "filename": None},
|
||||
"scenario": "defense",
|
||||
"minutes": 15,
|
||||
"style_id": "academic-minimal",
|
||||
"pages": [{"slide_number": 1, "type": "title", "title": "T"}],
|
||||
"warnings": ["w1"],
|
||||
}
|
||||
|
||||
|
||||
def test_insert_get_roundtrip(store_env):
|
||||
ppt_store.insert_job(_job("ppt_abc123def456"), raw_text="hello body")
|
||||
job = ppt_store.get_job("ppt_abc123def456")
|
||||
assert job is not None
|
||||
assert job["status"] == "outline_ready"
|
||||
assert job["scenario"] == "defense"
|
||||
assert job["minutes"] == 15
|
||||
assert job["pages"][0]["type"] == "title"
|
||||
assert job["warnings"] == ["w1"]
|
||||
assert job["source"]["kind"] == "text"
|
||||
assert job["source"]["text_chars"] == len("hello body") # raw_text 不进 dict,只回长度
|
||||
assert "raw_text" not in job["source"]
|
||||
assert job["pptx_ready"] is False
|
||||
# raw_text 独立直读(evidence 重验用)
|
||||
assert ppt_store.get_raw_text("ppt_abc123def456") == "hello body"
|
||||
|
||||
|
||||
def test_update_partial_fields(store_env):
|
||||
ppt_store.insert_job(_job("ppt_u1"), raw_text="t")
|
||||
job = ppt_store.update_job("ppt_u1", status="building")
|
||||
assert job["status"] == "building"
|
||||
assert job["pages"][0]["type"] == "title" # 未提供的列不动
|
||||
job = ppt_store.update_job(
|
||||
"ppt_u1", pages=[{"type": "outline", "title": "O"}], warnings=[]
|
||||
)
|
||||
assert job["pages"][0]["type"] == "outline" and job["warnings"] == []
|
||||
assert job["status"] == "building" # 仍不动
|
||||
|
||||
|
||||
def test_update_rejects_bad_fields(store_env):
|
||||
ppt_store.insert_job(_job("ppt_u2"), raw_text="t")
|
||||
with pytest.raises(ValueError):
|
||||
ppt_store.update_job("ppt_u2", status="exploded") # 非法 status
|
||||
with pytest.raises(TypeError):
|
||||
ppt_store.update_job("ppt_u2", unknown=1)
|
||||
assert ppt_store.get_job("ppt_u2")["status"] == "outline_ready"
|
||||
|
||||
|
||||
def test_insert_rejects_bad_status(store_env):
|
||||
with pytest.raises(ValueError):
|
||||
ppt_store.insert_job({**_job("ppt_bad"), "status": "cooking"})
|
||||
|
||||
|
||||
def test_delete_and_missing(store_env):
|
||||
ppt_store.insert_job(_job("ppt_d1"), raw_text="t")
|
||||
assert ppt_store.delete_job("ppt_d1") is True
|
||||
assert ppt_store.get_job("ppt_d1") is None
|
||||
assert ppt_store.delete_job("ppt_d1") is False
|
||||
assert ppt_store.get_job("ppt_none") is None
|
||||
assert ppt_store.update_job("ppt_none", status="done") is None
|
||||
@@ -0,0 +1,195 @@
|
||||
"""tests/test_zip_safety.py —— P4 zip 安全模块纯逻辑测试(无需 tectonic)。
|
||||
|
||||
覆盖设计 §2 安全清单 #1-#13(#14 tectonic --untrusted 归 test_compile.py,
|
||||
#15 由调用方 to_thread 承担,模块本身只测上限封顶行为)。
|
||||
|
||||
运行: cd review_service && <venv>/bin/python -m pytest -q tests/test_zip_safety.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import stat
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.config import get_settings
|
||||
from app.services.zip_safety import (
|
||||
ExtractResult,
|
||||
ZipSafetyError,
|
||||
classify_entry,
|
||||
safe_extract,
|
||||
sanitize_entry_name,
|
||||
)
|
||||
|
||||
|
||||
def _settings(**over):
|
||||
"""取真实 settings 并覆盖上限(dataclass 风格 monkeypatch)。"""
|
||||
s = get_settings()
|
||||
for k, v in over.items():
|
||||
setattr(s, k, v)
|
||||
return s
|
||||
|
||||
|
||||
def _make_zip(entries: dict[str, bytes], exec_bits: dict[str, int] | None = None) -> bytes:
|
||||
"""entries: name → content;exec_bits: name → unix mode(如 0o100755)。
|
||||
|
||||
必须显式 ZIP_DEFLATED:手动构造的 ZipInfo 默认 ZIP_STORED(ratio=1,测不出压缩比炸弹)。
|
||||
"""
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for name, data in entries.items():
|
||||
info = zipfile.ZipInfo(name)
|
||||
info.compress_type = zipfile.ZIP_DEFLATED
|
||||
if exec_bits and name in exec_bits:
|
||||
info.external_attr = exec_bits[name] << 16
|
||||
zf.writestr(info, data)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _symlink_zip(name: str = "link") -> bytes:
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
info = zipfile.ZipInfo(name)
|
||||
info.external_attr = (stat.S_IFLNK | 0o777) << 16 # 0xA1FF
|
||||
zf.writestr(info, "../../etc/passwd")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
# ================================================================ sanitize(#1/#2/#11)
|
||||
|
||||
class TestSanitize:
|
||||
def test_normal_path_ok(self):
|
||||
assert sanitize_entry_name("main.tex") == "main.tex"
|
||||
assert sanitize_entry_name("figs/a.png") == "figs/a.png"
|
||||
assert sanitize_entry_name("a/b/c/") == "a/b/c" # 目录尾斜杠归一
|
||||
|
||||
def test_path_traversal_rejected(self): # #1
|
||||
with pytest.raises(ZipSafetyError) as e:
|
||||
sanitize_entry_name("../../etc/passwd")
|
||||
assert e.value.code == "ZIP_UNSAFE"
|
||||
|
||||
def test_backslash_traversal_rejected(self): # #2 Windows 变体
|
||||
with pytest.raises(ZipSafetyError) as e:
|
||||
sanitize_entry_name("..\\..\\evil.sh")
|
||||
assert e.value.code == "ZIP_UNSAFE"
|
||||
|
||||
def test_absolute_path_rejected(self):
|
||||
with pytest.raises(ZipSafetyError):
|
||||
sanitize_entry_name("/etc/passwd")
|
||||
with pytest.raises(ZipSafetyError):
|
||||
sanitize_entry_name("C:\\Windows\\evil")
|
||||
|
||||
def test_control_char_rejected(self):
|
||||
with pytest.raises(ZipSafetyError):
|
||||
sanitize_entry_name("ma\x00in.tex")
|
||||
|
||||
def test_depth_limit(self): # #11
|
||||
deep = "/".join(f"d{i}" for i in range(20)) + "/x.tex"
|
||||
with pytest.raises(ZipSafetyError) as e:
|
||||
sanitize_entry_name(deep)
|
||||
assert e.value.code == "ZIP_UNSAFE"
|
||||
|
||||
def test_length_limit(self):
|
||||
with pytest.raises(ZipSafetyError):
|
||||
sanitize_entry_name("a" * 250 + ".tex")
|
||||
|
||||
|
||||
# ================================================================ classify(#4/#6)
|
||||
|
||||
class TestClassify:
|
||||
def test_allow(self):
|
||||
for n in ("main.tex", "journal.cls", "refs.bib", "figs/a.png", "x.sty"):
|
||||
assert classify_entry(n, False) == "allow"
|
||||
assert classify_entry("figs/", True) == "allow"
|
||||
|
||||
def test_deny_dangerous(self): # #4
|
||||
for n in ("run.sh", "evil.exe", "x.so", "x.dll", "a.py", "b.js"):
|
||||
assert classify_entry(n, False) == "deny"
|
||||
|
||||
def test_skip_harmless_non_whitelist(self): # #6
|
||||
for n in ("notes.docx", "old.log", "archive.zip", "img.gif"):
|
||||
assert classify_entry(n, False) == "skip"
|
||||
|
||||
def test_skip_dirs(self): # #6 附属
|
||||
assert classify_entry("node_modules/x.js", False) == "skip"
|
||||
assert classify_entry(".git/config", False) == "skip"
|
||||
assert classify_entry("__MACOSX/._a.tex", False) == "skip"
|
||||
|
||||
|
||||
# ================================================================ safe_extract(#3/#5/#7-#10/#12/#13)
|
||||
|
||||
class TestSafeExtract:
|
||||
def test_happy_path(self, tmp_path: Path):
|
||||
zb = _make_zip({"main.tex": b"hi", "figs/a.png": b"\x89PNG"})
|
||||
r = safe_extract(zb, tmp_path / "p", _settings())
|
||||
assert isinstance(r, ExtractResult)
|
||||
assert r.files == 2 and r.tex == 1 and r.images == 1
|
||||
assert (tmp_path / "p" / "main.tex").read_text() == "hi"
|
||||
assert (tmp_path / "p" / "figs" / "a.png").exists()
|
||||
|
||||
def test_symlink_rejected(self, tmp_path: Path): # #3
|
||||
with pytest.raises(ZipSafetyError) as e:
|
||||
safe_extract(_symlink_zip(), tmp_path / "p", _settings())
|
||||
assert e.value.code == "ZIP_UNSAFE"
|
||||
assert not (tmp_path / "p").exists() # 预扫拒绝 → 未写盘
|
||||
|
||||
def test_deny_ext_rejects_whole_zip(self, tmp_path: Path): # #4
|
||||
zb = _make_zip({"main.tex": b"x", "run.sh": b"#!/bin/sh"})
|
||||
with pytest.raises(ZipSafetyError) as e:
|
||||
safe_extract(zb, tmp_path / "p", _settings())
|
||||
assert e.value.code == "ZIP_UNSAFE" and "run.sh" in e.value.message
|
||||
assert not (tmp_path / "p").exists()
|
||||
|
||||
def test_exec_bit_rejects(self, tmp_path: Path): # #5
|
||||
zb = _make_zip({"main.tex": b"x"}, exec_bits={"main.tex": 0o100755})
|
||||
with pytest.raises(ZipSafetyError) as e:
|
||||
safe_extract(zb, tmp_path / "p", _settings())
|
||||
assert e.value.code == "ZIP_UNSAFE"
|
||||
|
||||
def test_harmless_skipped_not_rejected(self, tmp_path: Path): # #6 决策1
|
||||
zb = _make_zip({"main.tex": b"x", "notes.docx": b"DOC", "old.zip": b"PK"})
|
||||
r = safe_extract(zb, tmp_path / "p", _settings())
|
||||
assert r.files == 1
|
||||
assert r.skipped_count == 2
|
||||
assert {s.name for s in r.skipped} == {"notes.docx", "old.zip"}
|
||||
assert not (tmp_path / "p" / "notes.docx").exists()
|
||||
|
||||
def test_ratio_bomb_rejected(self, tmp_path: Path): # #7
|
||||
zb = _make_zip({"big.txt": b"\x00" * (2 * 1024 * 1024)}) # 全零 → ratio ≫100
|
||||
with pytest.raises(ZipSafetyError) as e:
|
||||
safe_extract(zb, tmp_path / "p", _settings())
|
||||
assert e.value.code == "ZIP_BOMB"
|
||||
|
||||
def test_total_bytes_rejected_runtime(self, tmp_path: Path): # #8 声明上限
|
||||
zb = _make_zip({"a.txt": b"x" * 5000, "b.txt": b"y" * 5000})
|
||||
with pytest.raises(ZipSafetyError) as e:
|
||||
safe_extract(zb, tmp_path / "p", _settings(max_extract_bytes=6000))
|
||||
assert e.value.code == "ZIP_BOMB"
|
||||
|
||||
def test_entry_bytes_rejected(self, tmp_path: Path): # #9
|
||||
zb = _make_zip({"a.txt": b"x" * 10000})
|
||||
with pytest.raises(ZipSafetyError) as e:
|
||||
safe_extract(zb, tmp_path / "p", _settings(max_zip_entry_bytes=1000))
|
||||
assert e.value.code == "ZIP_BOMB"
|
||||
|
||||
def test_entry_count_rejected(self, tmp_path: Path): # #10
|
||||
zb = _make_zip({f"f{i}.txt": b"x" for i in range(30)})
|
||||
with pytest.raises(ZipSafetyError) as e:
|
||||
safe_extract(zb, tmp_path / "p", _settings(max_zip_entries=10))
|
||||
assert e.value.code == "ZIP_BOMB"
|
||||
assert not (tmp_path / "p").exists() # 预扫先于写盘
|
||||
|
||||
def test_corrupt_zip(self, tmp_path: Path):
|
||||
with pytest.raises(ZipSafetyError) as e:
|
||||
safe_extract(b"not a zip at all", tmp_path / "p", _settings())
|
||||
assert e.value.code == "ZIP_INVALID"
|
||||
|
||||
def test_traversal_entry_rejected_end_to_end(self, tmp_path: Path): # #1 端到端
|
||||
zb = _make_zip({"../evil.txt": b"pwned"})
|
||||
with pytest.raises(ZipSafetyError) as e:
|
||||
safe_extract(zb, tmp_path / "p", _settings())
|
||||
assert e.value.code == "ZIP_UNSAFE"
|
||||
assert not (tmp_path / "evil.txt").exists() # 磁盘无残留
|
||||
assert not (tmp_path / "p").exists()
|
||||
@@ -0,0 +1,75 @@
|
||||
"""auth.py —— 鉴权依赖(管理员 + 用户级)+ 鉴权异常。
|
||||
|
||||
- `get_current_user`:受 auth_enabled 开关控制;关=匿名(None),开=校验 token(401 失败)。
|
||||
- `require_user`:强制登录(不受开关控制),用于 /api/auth/me、/logout 等必须登录的端点。
|
||||
- `require_admin`:管理员;复用 require_user + role 校验。
|
||||
- `require_authorization(unit)`:功能门禁;关=放行,开=admin 或 unit 在 authorizations 才放行。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import Depends, Request
|
||||
|
||||
from .config import get_settings
|
||||
from .db import Database, get_db
|
||||
from .logwriter import log_event
|
||||
from .services.auth import resolve_token
|
||||
|
||||
|
||||
class AuthError(Exception):
|
||||
def __init__(self, status: int, code: str, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
self.code = code
|
||||
self.message = message
|
||||
|
||||
|
||||
def _bearer(request: Request) -> str:
|
||||
header = request.headers.get("Authorization", "")
|
||||
return header[7:] if header.startswith("Bearer ") else ""
|
||||
|
||||
|
||||
async def get_current_user(request: Request, db: Database = Depends(get_db)) -> dict | None:
|
||||
"""用户级鉴权;auth_enabled 为假时返回 None(匿名放行)。"""
|
||||
if not get_settings().auth_enabled:
|
||||
return None
|
||||
token = _bearer(request)
|
||||
user = await resolve_token(db, token) if token else None
|
||||
if user is None:
|
||||
log_event("call_denied", method=request.method, path=request.url.path, detail={"reason": "unauthorized"})
|
||||
raise AuthError(401, "UNAUTHORIZED", "未登录或登录已过期")
|
||||
request.state.user = user
|
||||
return user
|
||||
|
||||
|
||||
async def require_user(request: Request, db: Database = Depends(get_db)) -> dict:
|
||||
"""强制登录(不受 auth_enabled 开关控制)。"""
|
||||
token = _bearer(request)
|
||||
user = await resolve_token(db, token) if token else None
|
||||
if user is None:
|
||||
log_event("call_denied", method=request.method, path=request.url.path, detail={"reason": "unauthorized"})
|
||||
raise AuthError(401, "UNAUTHORIZED", "未登录或登录已过期")
|
||||
request.state.user = user
|
||||
return user
|
||||
|
||||
|
||||
async def require_admin(request: Request, db: Database = Depends(get_db)) -> dict:
|
||||
user = await require_user(request, db)
|
||||
if user["role"] != "admin":
|
||||
log_event("call_denied", user_id=user["id"], method=request.method, path=request.url.path, detail={"reason": "not_admin"})
|
||||
raise AuthError(403, "FORBIDDEN", "非管理员账号")
|
||||
return user
|
||||
|
||||
|
||||
def require_authorization(unit: str):
|
||||
"""功能门禁依赖工厂;auth_enabled 关=放行,开=admin 或 unit 在 authorizations 放行。"""
|
||||
|
||||
async def _dep(request: Request, db: Database = Depends(get_db)) -> dict | None:
|
||||
if not get_settings().auth_enabled:
|
||||
return None
|
||||
user = await require_user(request, db)
|
||||
if user["role"] != "admin" and unit not in (user.get("authorizations") or []):
|
||||
log_event("call_denied", user_id=user["id"], method=request.method, path=request.url.path, detail={"reason": "forbidden", "unit": unit})
|
||||
raise AuthError(403, "FORBIDDEN", "无权访问该功能")
|
||||
return user
|
||||
|
||||
return _dep
|
||||
@@ -86,6 +86,18 @@ class Settings(BaseSettings):
|
||||
review_service_url: str = "http://127.0.0.1:8100"
|
||||
review_read_timeout: float = 360.0 # presubmit 上传+LLM 长调用读超时(秒)
|
||||
|
||||
# ---- 管理后台(admin console + 行为日志)----
|
||||
admin_seed_username: str = "admin" # 种子管理员用户名(启动时若无 admin 则创建)
|
||||
admin_seed_password: str = "admin" # 种子管理员初始密码
|
||||
admin_token_ttl_seconds: int = 86400 # 管理员会话凭据有效期(秒)
|
||||
admin_dist_dir: str = "admin/dist" # 管理后台前端构建产物(BFF 托管 /admin)
|
||||
log_dir: str = "data/logs" # 行为日志目录(每日一个 JSONL 文件)
|
||||
log_retention_days: int = 0 # 日志保留天数(0=不清理)
|
||||
|
||||
# ---- 用户端鉴权(普通用户登录;AUTH_ENABLED 为假=匿名放行,兼容浸泡期)----
|
||||
auth_enabled: bool = False
|
||||
user_token_ttl_seconds: int = 86400 # 普通用户会话凭据有效期(秒)
|
||||
|
||||
# ---- 生产形态:BFF 静态托管 ui/dist(ARCHITECTURE.md §3,M6 挂载)----
|
||||
frontend_dist_dir: str = "ui/dist" # 生产形态:静态托管 ui/dist
|
||||
|
||||
|
||||
@@ -125,6 +125,35 @@ _DOCGEN_MIGRATIONS = (
|
||||
)
|
||||
|
||||
|
||||
# 管理后台/鉴权增量表(users + auth_tokens;行为日志走文件,不建表)
|
||||
_ADMIN_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
salt TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
authorizations TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth_tokens (
|
||||
token TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
revoked INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_auth_tokens_user ON auth_tokens(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
@@ -175,6 +204,19 @@ class Database:
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("docgen 表迁移失败(docgen 端点将不可用,其余不受影响)")
|
||||
# 管理后台/鉴权增量表(users + auth_tokens):单独执行;失败只记日志
|
||||
try:
|
||||
await self._conn.executescript(_ADMIN_SCHEMA)
|
||||
except Exception:
|
||||
logger.exception("admin 表迁移失败(管理后台将不可用,其余不受影响)")
|
||||
# sessions 加 owner_id(用户级数据隔离,幂等 ALTER)
|
||||
try:
|
||||
cur = await self._conn.execute("PRAGMA table_info(sessions)")
|
||||
cols = {row[1] for row in await cur.fetchall()}
|
||||
if "owner_id" not in cols:
|
||||
await self._conn.execute("ALTER TABLE sessions ADD COLUMN owner_id TEXT")
|
||||
except Exception:
|
||||
logger.exception("sessions owner_id 迁移失败")
|
||||
await self._conn.commit()
|
||||
|
||||
async def close(self) -> None:
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
"""logwriter.py —— 行为日志异步文件写入器(JSONL,固定目录,每日一个文件)。
|
||||
|
||||
设计:请求/业务只入队(不落盘),后台单任务批量 append,避免日志拖慢接口;
|
||||
文件按日期命名 audit-YYYY-MM-DD.log,跨天自动切文件。写失败仅记 stderr,绝不影响业务。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_writer: "LogWriter | None" = None # 全局单例
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
|
||||
|
||||
|
||||
def _today() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
class LogWriter:
|
||||
def __init__(self, log_dir: str, retention_days: int = 0, hub_id: str = "") -> None:
|
||||
self.log_dir = Path(log_dir)
|
||||
self.retention_days = retention_days
|
||||
self.hub_id = hub_id
|
||||
self._queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=20000)
|
||||
self._task: asyncio.Task | None = None
|
||||
self._date = ""
|
||||
self._fh = None
|
||||
|
||||
def set_hub_id(self, hub_id: str) -> None:
|
||||
self.hub_id = hub_id or ""
|
||||
|
||||
async def start(self) -> None:
|
||||
self.log_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._task = asyncio.create_task(self._run())
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._task is not None:
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._task = None
|
||||
self._close_fh()
|
||||
|
||||
def enqueue(self, entry: dict[str, Any]) -> None:
|
||||
try:
|
||||
self._queue.put_nowait(entry)
|
||||
except asyncio.QueueFull:
|
||||
logger.warning("行为日志队列已满,丢弃一条")
|
||||
|
||||
def _close_fh(self) -> None:
|
||||
if self._fh is not None:
|
||||
try:
|
||||
self._fh.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._fh = None
|
||||
|
||||
def _open_for_date(self, date_str: str) -> None:
|
||||
self._close_fh()
|
||||
path = self.log_dir / f"audit-{date_str}.log"
|
||||
self._fh = open(path, "a", encoding="utf-8")
|
||||
self._date = date_str
|
||||
self._cleanup()
|
||||
|
||||
async def _run(self) -> None:
|
||||
try:
|
||||
while True:
|
||||
batch: list[dict[str, Any]] = []
|
||||
try:
|
||||
batch.append(await asyncio.wait_for(self._queue.get(), timeout=1.0))
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
while len(batch) < 200:
|
||||
try:
|
||||
batch.append(self._queue.get_nowait())
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
await asyncio.to_thread(self._write_sync, batch)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
|
||||
def _write_sync(self, entries: list[dict[str, Any]]) -> None:
|
||||
today = _today()
|
||||
if self._date != today:
|
||||
self._open_for_date(today)
|
||||
for e in entries:
|
||||
self._fh.write(json.dumps(e, ensure_ascii=False) + "\n")
|
||||
self._fh.flush()
|
||||
|
||||
def _cleanup(self) -> None:
|
||||
if not self.retention_days:
|
||||
return
|
||||
try:
|
||||
import time as _time
|
||||
|
||||
cutoff = _time.time() - self.retention_days * 86400
|
||||
for p in self.log_dir.glob("audit-*.log"):
|
||||
try:
|
||||
if p.stat().st_mtime < cutoff:
|
||||
p.unlink()
|
||||
except OSError:
|
||||
continue
|
||||
except Exception:
|
||||
logger.exception("日志清理失败")
|
||||
|
||||
|
||||
def init_writer(log_dir: str, retention_days: int = 0, hub_id: str = "") -> None:
|
||||
global _writer
|
||||
_writer = LogWriter(log_dir, retention_days, hub_id=hub_id)
|
||||
|
||||
|
||||
async def start_writer() -> None:
|
||||
if _writer is not None:
|
||||
await _writer.start()
|
||||
|
||||
|
||||
async def stop_writer() -> None:
|
||||
global _writer
|
||||
if _writer is not None:
|
||||
await _writer.stop()
|
||||
_writer = None
|
||||
|
||||
|
||||
def log_event(
|
||||
event: str,
|
||||
*,
|
||||
user_id: str | None = None,
|
||||
method: str | None = None,
|
||||
path: str | None = None,
|
||||
status: int | None = None,
|
||||
latency_ms: float | None = None,
|
||||
detail: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""通用业务埋点;writer 未初始化时静默 no-op。"""
|
||||
if _writer is None:
|
||||
return
|
||||
entry = {
|
||||
"ts": _now_iso(),
|
||||
"hub_id": _writer.hub_id,
|
||||
"user_id": user_id,
|
||||
"event": event,
|
||||
"method": method,
|
||||
"path": path,
|
||||
"status": status,
|
||||
"latency_ms": latency_ms,
|
||||
"detail": detail or {},
|
||||
}
|
||||
_writer.enqueue(entry)
|
||||
@@ -11,10 +11,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi import Depends, FastAPI, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
@@ -27,6 +28,12 @@ from .routers import bioflow_proxy
|
||||
from .routers import paper
|
||||
from .routers import private_docs
|
||||
from .routers import review_proxy
|
||||
from .routers import admin
|
||||
from .routers import auth
|
||||
from . import logwriter
|
||||
from .auth import AuthError, get_current_user, require_authorization
|
||||
from .services.auth import seed_admin
|
||||
from .services.settings import get_hub_settings
|
||||
from .upstream import close_client
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
@@ -49,22 +56,49 @@ def create_app() -> FastAPI:
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# 行为日志请求中间件:自动采集所有 /api/* 请求(user_id 待鉴权上线后补)
|
||||
@app.middleware("http")
|
||||
async def _log_requests(request: Request, call_next):
|
||||
start = time.monotonic()
|
||||
response = await call_next(request)
|
||||
if request.url.path.startswith("/api/"):
|
||||
user = getattr(request.state, "user", None)
|
||||
logwriter.log_event(
|
||||
"http_request",
|
||||
user_id=user["id"] if user else None,
|
||||
method=request.method,
|
||||
path=request.url.path,
|
||||
status=response.status_code,
|
||||
latency_ms=round((time.monotonic() - start) * 1000, 1),
|
||||
)
|
||||
return response
|
||||
|
||||
# 鉴权异常 → 统一错误信封
|
||||
@app.exception_handler(AuthError)
|
||||
async def _auth_error_handler(_: Request, exc: AuthError) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=exc.status,
|
||||
content={"ok": False, "error": {"code": exc.code, "message": exc.message, "degraded": False}},
|
||||
)
|
||||
|
||||
app.include_router(health.router)
|
||||
app.include_router(sessions.router)
|
||||
app.include_router(qa.router)
|
||||
app.include_router(capabilities.router)
|
||||
app.include_router(kg.router)
|
||||
app.include_router(extraction.router)
|
||||
app.include_router(figures.router)
|
||||
app.include_router(legacy.router)
|
||||
app.include_router(docgen.router) # docgen(P2)
|
||||
app.include_router(private_docs.router) # 私有资料问答(FR-PRIV-004)
|
||||
app.include_router(auth.router) # /api/auth/*(login/status 免鉴权,me/logout 需登录)
|
||||
app.include_router(sessions.router, dependencies=[Depends(get_current_user)])
|
||||
app.include_router(qa.router, dependencies=[Depends(require_authorization("qa"))])
|
||||
app.include_router(capabilities.router, dependencies=[Depends(get_current_user)])
|
||||
app.include_router(kg.router, dependencies=[Depends(require_authorization("kg"))])
|
||||
app.include_router(extraction.router, dependencies=[Depends(require_authorization("extraction"))])
|
||||
app.include_router(figures.router, dependencies=[Depends(require_authorization("figures"))])
|
||||
app.include_router(legacy.router, dependencies=[Depends(require_authorization("legacy"))])
|
||||
app.include_router(docgen.router, dependencies=[Depends(require_authorization("docgen"))]) # docgen(P2)
|
||||
app.include_router(private_docs.router, dependencies=[Depends(require_authorization("private_docs"))]) # 私有资料问答(FR-PRIV-004)
|
||||
if get_settings().bioflow_enabled:
|
||||
app.include_router(bioflow_proxy.router) # 生信反代(BIOFLOW_ENABLED 开关,FR-BIO-028)
|
||||
app.include_router(bioflow_proxy.router, dependencies=[Depends(require_authorization("bioflow"))]) # 生信反代(BIOFLOW_ENABLED 开关,FR-BIO-028)
|
||||
if get_settings().paper_writer_enabled:
|
||||
app.include_router(paper.router) # 论文撰写(PAPER_WRITER_ENABLED 开关)
|
||||
app.include_router(paper.router, dependencies=[Depends(require_authorization("paper"))]) # 论文撰写(PAPER_WRITER_ENABLED 开关)
|
||||
if get_settings().review_enabled:
|
||||
app.include_router(review_proxy.router) # 科研审稿反代(REVIEW_ENABLED 开关)
|
||||
app.include_router(review_proxy.router, dependencies=[Depends(get_current_user)]) # 科研审稿反代(REVIEW_ENABLED 开关)
|
||||
app.include_router(admin.router) # 管理后台(/api/admin/*)
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def _validation_handler(_: Request, exc: RequestValidationError) -> JSONResponse:
|
||||
@@ -79,10 +113,15 @@ def create_app() -> FastAPI:
|
||||
|
||||
@asynccontextmanager
|
||||
async def _lifespan(_app: FastAPI):
|
||||
await init_db()
|
||||
_db = await init_db()
|
||||
await seed_admin(_db)
|
||||
hub = await get_hub_settings(_db)
|
||||
logwriter.init_writer(settings.log_dir, settings.log_retention_days, hub_id=hub["hub_id"])
|
||||
await logwriter.start_writer()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await logwriter.stop_writer()
|
||||
await close_client()
|
||||
try:
|
||||
from .services.neo4j import close_neo4j # A3 提供;缺失则跳过
|
||||
@@ -98,6 +137,10 @@ def create_app() -> FastAPI:
|
||||
# 2026-08-25 PM 修复:dist 路径必须基于项目根解析(BFF 工作目录是 sdk/,
|
||||
# 相对路径会解析成 sdk/ui/dist);sync.sh 排除 dist,需单独同步。
|
||||
project_root = Path(__file__).resolve().parent.parent.parent
|
||||
# 管理后台静态托管(/admin;须在 "/" 之前挂载)
|
||||
admin_dist = project_root / settings.admin_dist_dir if not Path(settings.admin_dist_dir).is_absolute() else Path(settings.admin_dist_dir)
|
||||
if admin_dist.is_dir():
|
||||
app.mount("/admin", StaticFiles(directory=str(admin_dist), html=True), name="admin")
|
||||
dist = project_root / settings.frontend_dist_dir if not Path(settings.frontend_dist_dir).is_absolute() else Path(settings.frontend_dist_dir)
|
||||
if dist.is_dir():
|
||||
app.mount("/", StaticFiles(directory=str(dist), html=True), name="frontend")
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
"""routers/admin.py —— 管理后台端点(登录/用户管理/日志查看)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..auth import require_admin
|
||||
from ..config import get_settings
|
||||
from ..db import Database, get_db
|
||||
from ..logwriter import log_event
|
||||
from .. import logwriter
|
||||
from ..services.auth import (
|
||||
AUTHORIZATION_UNITS,
|
||||
verify_login,
|
||||
issue_token,
|
||||
revoke_token,
|
||||
list_users,
|
||||
get_user_by_username,
|
||||
create_user,
|
||||
update_user,
|
||||
delete_user,
|
||||
)
|
||||
from ..services.logview import query_logs
|
||||
from ..services.settings import get_hub_settings, set_hub_settings
|
||||
from ..upstream import envelope
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
|
||||
|
||||
class LoginBody(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
role: str = "user"
|
||||
authorizations: list[str] = []
|
||||
|
||||
|
||||
class UserPatch(BaseModel):
|
||||
role: str | None = None
|
||||
authorizations: list[str] | None = None
|
||||
password: str | None = None
|
||||
|
||||
|
||||
class HubSettingsBody(BaseModel):
|
||||
hub_id: str | None = None
|
||||
hub_name: str | None = None
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login(body: LoginBody, db: Database = Depends(get_db)):
|
||||
user = await verify_login(db, body.username, body.password)
|
||||
if user is None:
|
||||
log_event("admin_login_fail", detail={"username": body.username})
|
||||
content, status = envelope("UNAUTHORIZED", "用户名或密码错误", status=401)
|
||||
return JSONResponse(status_code=status, content=content)
|
||||
if user["role"] != "admin":
|
||||
log_event("admin_login_fail", detail={"username": body.username, "reason": "not_admin"})
|
||||
content, status = envelope("FORBIDDEN", "非管理员账号", status=403)
|
||||
return JSONResponse(status_code=status, content=content)
|
||||
token = await issue_token(db, user["id"])
|
||||
log_event("admin_login_ok", user_id=user["id"], detail={"username": body.username})
|
||||
return {"ok": True, "token": token, "user": user}
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(request: Request, db: Database = Depends(get_db), _user: dict = Depends(require_admin)):
|
||||
header = request.headers.get("Authorization", "")
|
||||
token = header[7:] if header.startswith("Bearer ") else ""
|
||||
if token:
|
||||
await revoke_token(db, token)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def me(_user: dict = Depends(require_admin)):
|
||||
return {"ok": True, "user": _user}
|
||||
|
||||
|
||||
@router.get("/units")
|
||||
async def units(_user: dict = Depends(require_admin)):
|
||||
return {"ok": True, "units": list(AUTHORIZATION_UNITS)}
|
||||
|
||||
|
||||
@router.get("/settings")
|
||||
async def get_settings_api(db: Database = Depends(get_db), _user: dict = Depends(require_admin)):
|
||||
return {"ok": True, "settings": await get_hub_settings(db)}
|
||||
|
||||
|
||||
@router.put("/settings")
|
||||
async def put_settings_api(body: HubSettingsBody, db: Database = Depends(get_db), _user: dict = Depends(require_admin)):
|
||||
settings = await set_hub_settings(db, body.hub_id, body.hub_name)
|
||||
logwriter.set_hub_id(settings["hub_id"])
|
||||
return {"ok": True, "settings": settings}
|
||||
|
||||
|
||||
@router.get("/users")
|
||||
async def users(db: Database = Depends(get_db), _user: dict = Depends(require_admin)):
|
||||
return {"ok": True, "users": await list_users(db)}
|
||||
|
||||
|
||||
@router.post("/users")
|
||||
async def create_user_api(body: UserCreate, db: Database = Depends(get_db), _user: dict = Depends(require_admin)):
|
||||
username = body.username.strip()
|
||||
if not username:
|
||||
content, _ = envelope("VALIDATION", "用户名不能为空", status=422)
|
||||
return JSONResponse(status_code=422, content=content)
|
||||
if not body.password:
|
||||
content, _ = envelope("VALIDATION", "密码不能为空", status=422)
|
||||
return JSONResponse(status_code=422, content=content)
|
||||
if await get_user_by_username(db, username):
|
||||
return JSONResponse(
|
||||
status_code=409,
|
||||
content={"ok": False, "error": {"code": "CONFLICT", "message": "用户名已存在", "degraded": False}},
|
||||
)
|
||||
user = await create_user(db, username, body.password, body.role, body.authorizations)
|
||||
return {"ok": True, "user": user}
|
||||
|
||||
|
||||
@router.patch("/users/{user_id}")
|
||||
async def patch_user(user_id: str, body: UserPatch, db: Database = Depends(get_db), _user: dict = Depends(require_admin)):
|
||||
user = await update_user(db, user_id, role=body.role, authorizations=body.authorizations, password=body.password)
|
||||
if user is None:
|
||||
content, _ = envelope("NOT_FOUND", status=404)
|
||||
return JSONResponse(status_code=404, content=content)
|
||||
return {"ok": True, "user": user}
|
||||
|
||||
|
||||
@router.delete("/users/{user_id}")
|
||||
async def delete_user_api(user_id: str, db: Database = Depends(get_db), _user: dict = Depends(require_admin)):
|
||||
if user_id == _user["id"]:
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content={"ok": False, "error": {"code": "VALIDATION", "message": "不能删除当前登录账号", "degraded": False}},
|
||||
)
|
||||
await delete_user(db, user_id)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/logs")
|
||||
async def logs(
|
||||
from_date: str | None = Query(None),
|
||||
to_date: str | None = Query(None),
|
||||
event: str | None = Query(None),
|
||||
user_id: str | None = Query(None),
|
||||
q: str | None = Query(None),
|
||||
status: str | None = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=200),
|
||||
_user: dict = Depends(require_admin),
|
||||
):
|
||||
settings = get_settings()
|
||||
result = await query_logs(
|
||||
settings.log_dir,
|
||||
from_date=from_date,
|
||||
to_date=to_date,
|
||||
event=event,
|
||||
user_id=user_id,
|
||||
q=q,
|
||||
status=status,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
return {"ok": True, **result}
|
||||
@@ -0,0 +1,52 @@
|
||||
"""routers/auth.py —— 用户端鉴权端点(登录/登出/me/status)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..auth import require_user
|
||||
from ..config import get_settings
|
||||
from ..db import Database, get_db
|
||||
from ..logwriter import log_event
|
||||
from ..services.auth import verify_login, issue_token, revoke_token
|
||||
from ..upstream import envelope
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
class LoginBody(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def status():
|
||||
"""免鉴权:告知前端是否开启鉴权(决定是否显示登录页)。"""
|
||||
return {"ok": True, "auth_enabled": get_settings().auth_enabled}
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login(body: LoginBody, db: Database = Depends(get_db)):
|
||||
user = await verify_login(db, body.username, body.password)
|
||||
if user is None:
|
||||
log_event("login_fail", detail={"username": body.username})
|
||||
content, status = envelope("UNAUTHORIZED", "用户名或密码错误", status=401)
|
||||
return JSONResponse(status_code=status, content=content)
|
||||
token = await issue_token(db, user["id"], ttl_seconds=get_settings().user_token_ttl_seconds)
|
||||
log_event("login_ok", user_id=user["id"], detail={"username": body.username})
|
||||
return {"ok": True, "token": token, "user": user}
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(request: Request, db: Database = Depends(get_db), _user: dict = Depends(require_user)):
|
||||
header = request.headers.get("Authorization", "")
|
||||
token = header[7:] if header.startswith("Bearer ") else ""
|
||||
if token:
|
||||
await revoke_token(db, token)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def me(_user: dict = Depends(require_user)):
|
||||
return {"ok": True, "user": _user}
|
||||
@@ -138,6 +138,12 @@ async def register_report(request: Request, db: Database = Depends(get_db)) -> A
|
||||
import uuid
|
||||
|
||||
data = payload.get("data", payload) if isinstance(payload, dict) else {}
|
||||
sid = str(data.get("session_id") or "")
|
||||
user = getattr(request.state, "user", None)
|
||||
if user and sid:
|
||||
owner_row = await db.fetchone("SELECT owner_id FROM sessions WHERE id = ?", (sid,))
|
||||
if owner_row is None or owner_row["owner_id"] != user["id"]:
|
||||
return proxied # 归属不符:不落库,仍透传生信响应
|
||||
artifact_id = uuid.uuid4().hex[:16]
|
||||
await db.execute(
|
||||
"""INSERT INTO doc_artifacts (id, session_id, status, title, template_name,
|
||||
|
||||
@@ -36,7 +36,12 @@ async def list_capabilities(
|
||||
session_caps: Optional[dict] = None
|
||||
if session_id:
|
||||
try:
|
||||
row = await db.fetchone("SELECT meta FROM sessions WHERE id = ?", (session_id,))
|
||||
user = getattr(request.state, "user", None)
|
||||
owner_id = user["id"] if user else None
|
||||
if owner_id is None:
|
||||
row = await db.fetchone("SELECT meta FROM sessions WHERE id = ?", (session_id,))
|
||||
else:
|
||||
row = await db.fetchone("SELECT meta FROM sessions WHERE id = ? AND owner_id = ?", (session_id, owner_id))
|
||||
if row is not None:
|
||||
meta = loads_json(row["meta"], default={})
|
||||
if isinstance(meta, dict):
|
||||
|
||||
@@ -49,7 +49,7 @@ from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, Query, Response, UploadFile
|
||||
from fastapi import APIRouter, Depends, File, Form, Query, Request, Response, UploadFile
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
|
||||
from ..config import get_settings
|
||||
@@ -109,6 +109,13 @@ def _envelope(code: str, message: str | None = None, degraded: bool | None = Non
|
||||
return JSONResponse(status_code=http, content=body)
|
||||
|
||||
|
||||
async def _session_owned(db: Database, session_id: str, owner_id: str | None) -> bool:
|
||||
if owner_id is None:
|
||||
return True
|
||||
row = await db.fetchone("SELECT owner_id FROM sessions WHERE id = ?", (session_id,))
|
||||
return row is not None and row["owner_id"] == owner_id
|
||||
|
||||
|
||||
def _validation(message: str) -> JSONResponse:
|
||||
return _envelope("VALIDATION", message=message, status=422)
|
||||
|
||||
@@ -646,6 +653,7 @@ async def _run_docgen_task(
|
||||
@router.post("/generate")
|
||||
async def generate_doc(
|
||||
payload: DocgenGenerateRequest,
|
||||
request: Request,
|
||||
db: Database = Depends(get_db),
|
||||
):
|
||||
"""异步生成(缺陷 #52,2026-09-10):快路径校验 → 插任务行 → 后台生成 → 202。
|
||||
@@ -657,6 +665,9 @@ async def generate_doc(
|
||||
202 {ok, task_id, status:"queued"}(契约见 docs/acceptance-r3.md 附录 B)。
|
||||
chat 消息落库由前端走现有 sessions 机制,本端点不碰。
|
||||
"""
|
||||
user = getattr(request.state, "user", None)
|
||||
if user and payload.session_id and not await _session_owned(db, payload.session_id, user["id"]):
|
||||
return _envelope("NOT_FOUND", message="会话不存在", status=404)
|
||||
bad = _check_description(payload.description)
|
||||
if bad is not None:
|
||||
return bad
|
||||
@@ -767,15 +778,25 @@ async def get_docgen_task(task_id: str, db: Database = Depends(get_db)):
|
||||
|
||||
@router.get("/tasks")
|
||||
async def list_docgen_tasks(
|
||||
request: Request,
|
||||
session_id: str = Query("", description="按会话过滤;空=全部(上限 100)"),
|
||||
db: Database = Depends(get_db),
|
||||
):
|
||||
"""会话任务列表(缺陷 #52 契约,附录 B)。"""
|
||||
user = getattr(request.state, "user", None)
|
||||
if user and session_id and not await _session_owned(db, session_id, user["id"]):
|
||||
return _envelope("NOT_FOUND", message="会话不存在", status=404)
|
||||
if session_id:
|
||||
rows = await db.fetchall(
|
||||
"SELECT id FROM docgen_tasks WHERE session_id = ? ORDER BY created_at DESC LIMIT 100",
|
||||
(session_id,),
|
||||
)
|
||||
elif user:
|
||||
rows = await db.fetchall(
|
||||
"SELECT t.id FROM docgen_tasks t JOIN sessions s ON s.id = t.session_id "
|
||||
"WHERE s.owner_id = ? ORDER BY t.created_at DESC LIMIT 100",
|
||||
(user["id"],),
|
||||
)
|
||||
else:
|
||||
rows = await db.fetchall(
|
||||
"SELECT id FROM docgen_tasks ORDER BY created_at DESC LIMIT 100"
|
||||
|
||||
@@ -9,13 +9,15 @@ import asyncio
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..config import get_settings
|
||||
from ..db import Database, get_db
|
||||
from ..services.paper import agent
|
||||
from ..services.paper.mcp_client import PaperMcpClient
|
||||
from ..upstream import envelope
|
||||
|
||||
router = APIRouter(prefix="/api/paper", tags=["paper"])
|
||||
|
||||
@@ -36,6 +38,13 @@ def _client() -> PaperMcpClient:
|
||||
return PaperMcpClient(s.paper_writer_url, s.paper_writer_read_timeout)
|
||||
|
||||
|
||||
async def _session_owned(db: Database, session_id: str, owner_id: str | None) -> bool:
|
||||
if owner_id is None:
|
||||
return True
|
||||
row = await db.fetchone("SELECT owner_id FROM sessions WHERE id = ?", (session_id,))
|
||||
return row is not None and row["owner_id"] == owner_id
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def paper_health() -> dict[str, Any]:
|
||||
"""探活反代 8097 /health;离线返回 offline 标记(前端据此隐藏 paper 入口)。"""
|
||||
@@ -50,19 +59,28 @@ async def paper_health() -> dict[str, Any]:
|
||||
|
||||
|
||||
@router.post("/turns")
|
||||
async def paper_turn(payload: PaperTurnRequest, db: Database = Depends(get_db)) -> dict[str, Any]:
|
||||
async def paper_turn(payload: PaperTurnRequest, request: Request, db: Database = Depends(get_db)) -> dict[str, Any]:
|
||||
user = getattr(request.state, "user", None)
|
||||
if user and not await _session_owned(db, payload.session_id, user["id"]):
|
||||
return JSONResponse(status_code=404, content=envelope("NOT_FOUND", message="会话不存在", status=404)[0])
|
||||
blocks = await agent.handle_turn(db, payload.session_id, payload.text, payload.paper_id, payload.template_key)
|
||||
return {"ok": True, "blocks": blocks}
|
||||
|
||||
|
||||
@router.post("/papers/{paper_id}/confirm")
|
||||
async def paper_confirm(paper_id: str, payload: PaperConfirmRequest, db: Database = Depends(get_db)) -> dict[str, Any]:
|
||||
async def paper_confirm(paper_id: str, payload: PaperConfirmRequest, request: Request, db: Database = Depends(get_db)) -> dict[str, Any]:
|
||||
user = getattr(request.state, "user", None)
|
||||
if user and not await _session_owned(db, payload.session_id, user["id"]):
|
||||
return JSONResponse(status_code=404, content=envelope("NOT_FOUND", message="会话不存在", status=404)[0])
|
||||
asyncio.create_task(agent.run_writing_loop(db, paper_id, payload.session_id))
|
||||
return {"ok": True, "paper_id": paper_id}
|
||||
|
||||
|
||||
@router.post("/papers/{paper_id}/resume")
|
||||
async def paper_resume(paper_id: str, payload: PaperConfirmRequest, db: Database = Depends(get_db)) -> dict[str, Any]:
|
||||
async def paper_resume(paper_id: str, payload: PaperConfirmRequest, request: Request, db: Database = Depends(get_db)) -> dict[str, Any]:
|
||||
user = getattr(request.state, "user", None)
|
||||
if user and not await _session_owned(db, payload.session_id, user["id"]):
|
||||
return JSONResponse(status_code=404, content=envelope("NOT_FOUND", message="会话不存在", status=404)[0])
|
||||
asyncio.create_task(agent.run_writing_loop(db, paper_id, payload.session_id))
|
||||
return {"ok": True, "paper_id": paper_id}
|
||||
|
||||
|
||||
@@ -100,13 +100,14 @@ async def _proxy(
|
||||
retry_invalid_citation: bool,
|
||||
mode: str,
|
||||
normalizer=None,
|
||||
owner_id: str | None = None,
|
||||
) -> Any:
|
||||
"""非流式上游代理:重试、错误信封、归一化。返回 (JSONResponse)。"""
|
||||
client = sse_relay.get_qa_client()
|
||||
normalizer = normalizer or sse_relay.get_qa_normalizer()
|
||||
url = f"{sse_relay.qa_base_url()}{_upstream_path(endpoint)}"
|
||||
timeout = _read_timeout(read_timeout)
|
||||
json_body, _humanize = await _inject_history(body)
|
||||
json_body, _humanize = await _inject_history(body, owner_id=owner_id)
|
||||
|
||||
for attempt in (1, 2):
|
||||
try:
|
||||
@@ -206,8 +207,10 @@ async def qa_info():
|
||||
async def qa_chat(request: Request):
|
||||
"""非流式问答:上游 /chat + 归一化 + invalid_llm_citation 重试一次。"""
|
||||
body = await request.json()
|
||||
user = getattr(request.state, "user", None)
|
||||
owner_id = user["id"] if user else None
|
||||
return await _proxy(
|
||||
"chat", body, read_timeout=CHAT_READ_TIMEOUT, retry_invalid_citation=True, mode="chat"
|
||||
"chat", body, read_timeout=CHAT_READ_TIMEOUT, retry_invalid_citation=True, mode="chat", owner_id=owner_id
|
||||
)
|
||||
|
||||
|
||||
@@ -224,7 +227,9 @@ async def qa_chat_stream(request: Request):
|
||||
except Exception:
|
||||
return _envelope("VALIDATION", 422)
|
||||
evidence_first = bool(body.pop("evidence_first", False))
|
||||
body, humanize_on = await _inject_history(body)
|
||||
user = getattr(request.state, "user", None)
|
||||
owner_id = user["id"] if user else None
|
||||
body, humanize_on = await _inject_history(body, owner_id=owner_id)
|
||||
return StreamingResponse(
|
||||
sse_relay.relay_stream(body, evidence_first=evidence_first, humanize_enabled=humanize_on),
|
||||
media_type="text/event-stream",
|
||||
@@ -242,8 +247,10 @@ async def qa_search(request: Request):
|
||||
"""
|
||||
body = await request.json()
|
||||
body = {**body, "use_llm": False, "native_chain_enabled": False}
|
||||
user = getattr(request.state, "user", None)
|
||||
owner_id = user["id"] if user else None
|
||||
return await _proxy(
|
||||
"search", body, read_timeout=SEARCH_READ_TIMEOUT, retry_invalid_citation=False, mode="search"
|
||||
"search", body, read_timeout=SEARCH_READ_TIMEOUT, retry_invalid_citation=False, mode="search", owner_id=owner_id
|
||||
)
|
||||
|
||||
|
||||
@@ -251,7 +258,7 @@ async def qa_search(request: Request):
|
||||
# 多轮记忆(S2):剥离 session_id → 组装 history 注入上游
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _inject_history(body: Optional[dict]) -> tuple[dict, bool]:
|
||||
async def _inject_history(body: Optional[dict], owner_id: str | None = None) -> tuple[dict, bool]:
|
||||
"""剥离 BFF 契约字段 session_id,读会话 meta 注入 history/capabilities。
|
||||
|
||||
- 不传 session_id / 会话不存在 → 行为与现役一致(history/capabilities 缺省)
|
||||
@@ -270,9 +277,14 @@ async def _inject_history(body: Optional[dict]) -> tuple[dict, bool]:
|
||||
if body.get("history"):
|
||||
return body, False # 显式传入的历史优先(上游直传场景;capabilities 由请求体自带)
|
||||
try:
|
||||
row = await bff_db.fetchone(
|
||||
"SELECT meta FROM sessions WHERE id = ?", (session_id,)
|
||||
)
|
||||
if owner_id is None:
|
||||
row = await bff_db.fetchone(
|
||||
"SELECT meta FROM sessions WHERE id = ?", (session_id,)
|
||||
)
|
||||
else:
|
||||
row = await bff_db.fetchone(
|
||||
"SELECT meta FROM sessions WHERE id = ? AND owner_id = ?", (session_id, owner_id)
|
||||
)
|
||||
if row is None:
|
||||
return body, False
|
||||
meta = loads_json(row["meta"], default={})
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
- GET /api/sessions/{id}/sources —— 全会话来源聚合:按 paper_id 去重(无 paper_id 按 id),
|
||||
带 count(出现次数)。
|
||||
|
||||
404 → NOT_FOUND 信封;时间 ISO 8601 UTC。
|
||||
用户级数据隔离:auth_enabled 开启时按 sessions.owner_id 过滤/校验(get_current_user 注入当前用户;
|
||||
匿名模式下 user=None 则不过滤,兼容旧形态)。404 → NOT_FOUND 信封;时间 ISO 8601 UTC。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -20,6 +21,7 @@ from typing import Any
|
||||
from fastapi import APIRouter, Depends, Response, status as http_status
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from ..auth import get_current_user
|
||||
from ..db import Database, dumps_json, get_db, loads_json
|
||||
from ..schemas import (
|
||||
MessageCreate,
|
||||
@@ -80,10 +82,14 @@ def _row_to_message(row: Any) -> MessageOut:
|
||||
|
||||
|
||||
async def _get_session_or_none(
|
||||
db: Database, session_id: str, *, with_count: bool = True
|
||||
db: Database, session_id: str, *, with_count: bool = True, owner_id: str | None = None
|
||||
) -> SessionOut | None:
|
||||
sql = _SESSION_SELECT + " WHERE s.id = ?"
|
||||
row = await db.fetchone(sql, (session_id,))
|
||||
params: list[Any] = [session_id]
|
||||
if owner_id is not None:
|
||||
sql += " AND s.owner_id = ?"
|
||||
params.append(owner_id)
|
||||
row = await db.fetchone(sql, tuple(params))
|
||||
return _row_to_session(row) if row else None
|
||||
|
||||
|
||||
@@ -101,31 +107,40 @@ async def _get_message_or_none(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("", response_model=list[SessionOut])
|
||||
async def list_sessions(db: Database = Depends(get_db)) -> list[SessionOut]:
|
||||
rows = await db.fetchall(_SESSION_SELECT + " ORDER BY s.updated_at DESC")
|
||||
async def list_sessions(db: Database = Depends(get_db), user: dict | None = Depends(get_current_user)) -> list[SessionOut]:
|
||||
owner_id = user["id"] if user else None
|
||||
sql = _SESSION_SELECT
|
||||
params: list[Any] = []
|
||||
if owner_id is not None:
|
||||
sql += " WHERE s.owner_id = ?"
|
||||
params.append(owner_id)
|
||||
sql += " ORDER BY s.updated_at DESC"
|
||||
rows = await db.fetchall(sql, tuple(params))
|
||||
return [_row_to_session(r) for r in rows]
|
||||
|
||||
|
||||
@router.post("", response_model=SessionOut, status_code=http_status.HTTP_201_CREATED)
|
||||
async def create_session(
|
||||
payload: SessionCreate, db: Database = Depends(get_db)
|
||||
payload: SessionCreate, db: Database = Depends(get_db), user: dict | None = Depends(get_current_user)
|
||||
) -> SessionOut:
|
||||
now = _db_now()
|
||||
session_id = str(uuid.uuid4())
|
||||
title = payload.normalized_title()
|
||||
owner_id = user["id"] if user else None
|
||||
await db.execute(
|
||||
"INSERT INTO sessions (id, title, status, meta, created_at, updated_at) "
|
||||
"VALUES (?, ?, 'idle', ?, ?, ?)",
|
||||
(session_id, title, dumps_json({"title_manual": False}), now, now),
|
||||
"INSERT INTO sessions (id, title, status, meta, owner_id, created_at, updated_at) "
|
||||
"VALUES (?, ?, 'idle', ?, ?, ?, ?)",
|
||||
(session_id, title, dumps_json({"title_manual": False}), owner_id, now, now),
|
||||
)
|
||||
out = await _get_session_or_none(db, session_id)
|
||||
out = await _get_session_or_none(db, session_id, owner_id=owner_id)
|
||||
assert out is not None # 刚插入必存在
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/{session_id}", response_model=SessionDetail)
|
||||
async def get_session(session_id: str, db: Database = Depends(get_db)):
|
||||
session = await _get_session_or_none(db, session_id)
|
||||
async def get_session(session_id: str, db: Database = Depends(get_db), user: dict | None = Depends(get_current_user)):
|
||||
owner_id = user["id"] if user else None
|
||||
session = await _get_session_or_none(db, session_id, owner_id=owner_id)
|
||||
if session is None:
|
||||
return _not_found(session_id)
|
||||
rows = await db.fetchall(
|
||||
@@ -136,9 +151,10 @@ async def get_session(session_id: str, db: Database = Depends(get_db)):
|
||||
|
||||
@router.patch("/{session_id}", response_model=SessionOut)
|
||||
async def patch_session(
|
||||
session_id: str, payload: SessionPatch, db: Database = Depends(get_db)
|
||||
session_id: str, payload: SessionPatch, db: Database = Depends(get_db), user: dict | None = Depends(get_current_user)
|
||||
):
|
||||
session = await _get_session_or_none(db, session_id)
|
||||
owner_id = user["id"] if user else None
|
||||
session = await _get_session_or_none(db, session_id, owner_id=owner_id)
|
||||
if session is None:
|
||||
return _not_found(session_id)
|
||||
|
||||
@@ -169,14 +185,15 @@ async def patch_session(
|
||||
(dumps_json(meta), _db_now(), session_id),
|
||||
)
|
||||
|
||||
out = await _get_session_or_none(db, session_id)
|
||||
out = await _get_session_or_none(db, session_id, owner_id=owner_id)
|
||||
assert out is not None
|
||||
return out
|
||||
|
||||
|
||||
@router.delete("/{session_id}", status_code=http_status.HTTP_204_NO_CONTENT)
|
||||
async def delete_session(session_id: str, db: Database = Depends(get_db)) -> Response:
|
||||
session = await _get_session_or_none(db, session_id)
|
||||
async def delete_session(session_id: str, db: Database = Depends(get_db), user: dict | None = Depends(get_current_user)) -> Response:
|
||||
owner_id = user["id"] if user else None
|
||||
session = await _get_session_or_none(db, session_id, owner_id=owner_id)
|
||||
if session is None:
|
||||
return _not_found_response(session_id)
|
||||
await db.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
|
||||
@@ -189,9 +206,10 @@ async def delete_session(session_id: str, db: Database = Depends(get_db)) -> Res
|
||||
|
||||
@router.post("/{session_id}/messages", response_model=MessageOut, status_code=201)
|
||||
async def create_message(
|
||||
session_id: str, payload: MessageCreate, db: Database = Depends(get_db)
|
||||
session_id: str, payload: MessageCreate, db: Database = Depends(get_db), user: dict | None = Depends(get_current_user)
|
||||
):
|
||||
session = await _get_session_or_none(db, session_id)
|
||||
owner_id = user["id"] if user else None
|
||||
session = await _get_session_or_none(db, session_id, owner_id=owner_id)
|
||||
if session is None:
|
||||
return _not_found(session_id)
|
||||
|
||||
@@ -206,7 +224,6 @@ async def create_message(
|
||||
(message_id, session_id, payload.kind, text, now, now),
|
||||
)
|
||||
else:
|
||||
# 助手流式骨架:text 置空,blocks 落库初始块
|
||||
blocks = [b.model_dump() for b in payload.blocks]
|
||||
await db.execute(
|
||||
"INSERT INTO messages (id, session_id, role, kind, status, blocks, raw, text, "
|
||||
@@ -215,7 +232,6 @@ async def create_message(
|
||||
dumps_json(blocks), now, now),
|
||||
)
|
||||
|
||||
# 消息活动 → 会话 updated_at 前移、idle → running
|
||||
await db.execute(
|
||||
"UPDATE sessions SET status = CASE WHEN status = 'idle' THEN 'running' ELSE status END, "
|
||||
"updated_at = ? WHERE id = ?",
|
||||
@@ -233,7 +249,13 @@ async def patch_message(
|
||||
message_id: str,
|
||||
payload: MessagePatch,
|
||||
db: Database = Depends(get_db),
|
||||
user: dict | None = Depends(get_current_user),
|
||||
):
|
||||
owner_id = user["id"] if user else None
|
||||
session = await _get_session_or_none(db, session_id, owner_id=owner_id)
|
||||
if session is None:
|
||||
return _not_found(session_id)
|
||||
|
||||
message = await _get_message_or_none(db, session_id, message_id)
|
||||
if message is None:
|
||||
return _not_found(session_id, message_id)
|
||||
@@ -258,7 +280,6 @@ async def patch_message(
|
||||
await db.execute(
|
||||
f"UPDATE messages SET {', '.join(sets)} WHERE id = ?", params
|
||||
)
|
||||
# 同步前移会话 updated_at
|
||||
await db.execute(
|
||||
"UPDATE sessions SET updated_at = ? WHERE id = ?",
|
||||
(_db_now(), session_id),
|
||||
@@ -274,12 +295,10 @@ async def patch_message(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/{session_id}/sources")
|
||||
async def get_session_sources(session_id: str, db: Database = Depends(get_db)):
|
||||
"""按 paper_id 去重(无 paper_id 时按 id),带 count(出现次数)。
|
||||
|
||||
来源来自该会话全部助手消息 blocks 里的 sources 块(§2 块联合)。
|
||||
"""
|
||||
session = await _get_session_or_none(db, session_id)
|
||||
async def get_session_sources(session_id: str, db: Database = Depends(get_db), user: dict | None = Depends(get_current_user)):
|
||||
"""按 paper_id 去重(无 paper_id 时按 id),带 count(出现次数)。"""
|
||||
owner_id = user["id"] if user else None
|
||||
session = await _get_session_or_none(db, session_id, owner_id=owner_id)
|
||||
if session is None:
|
||||
return _not_found(session_id)
|
||||
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
"""services/auth.py —— 管理员鉴权:密码哈希、会话凭据、用户 CRUD、种子管理员。
|
||||
|
||||
密码用 stdlib pbkdf2_hmac + 每用户随机盐(不新增依赖);
|
||||
会话凭据用不透明 token 存 auth_tokens,逐次查库(吊销/过期即时生效)。
|
||||
所有 DB 访问经 `db` 参数注入(与 services/history.py 一致,避免依赖全局单例)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import secrets
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from ..config import get_settings
|
||||
from ..db import Database, dumps_json, loads_json
|
||||
from ..logwriter import log_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 授权清单功能单元(9 项,与管理后台界面一致;后续可调)
|
||||
AUTHORIZATION_UNITS = (
|
||||
"qa", "kg", "extraction", "docgen", "figures",
|
||||
"private_docs", "bioflow", "paper", "legacy",
|
||||
)
|
||||
|
||||
_PBKDF2_ITERATIONS = 200_000
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def hash_password(password: str, salt: str | None = None) -> tuple[str, str]:
|
||||
salt = salt or secrets.token_hex(16)
|
||||
digest = hashlib.pbkdf2_hmac(
|
||||
"sha256", password.encode("utf-8"), salt.encode("utf-8"), _PBKDF2_ITERATIONS
|
||||
)
|
||||
return digest.hex(), salt
|
||||
|
||||
|
||||
def verify_password(password: str, salt: str, expected_hex: str) -> bool:
|
||||
digest, _ = hash_password(password, salt)
|
||||
return secrets.compare_digest(digest, expected_hex)
|
||||
|
||||
|
||||
def _row_to_user(row: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"id": row["id"],
|
||||
"username": row["username"],
|
||||
"role": row["role"],
|
||||
"authorizations": loads_json(row["authorizations"], default=[]),
|
||||
"created_at": row["created_at"],
|
||||
}
|
||||
|
||||
|
||||
# ---- 登录校验 ----
|
||||
|
||||
async def verify_login(db: Database, username: str, password: str) -> dict[str, Any] | None:
|
||||
row = await db.fetchone(
|
||||
"SELECT id, username, password_hash, salt, role, authorizations, created_at "
|
||||
"FROM users WHERE username = ?",
|
||||
(username,),
|
||||
)
|
||||
if row is None:
|
||||
return None
|
||||
if not verify_password(password, row["salt"], row["password_hash"]):
|
||||
return None
|
||||
return _row_to_user(row)
|
||||
|
||||
|
||||
# ---- 会话凭据 ----
|
||||
|
||||
async def issue_token(db: Database, user_id: str, ttl_seconds: int | None = None) -> str:
|
||||
settings = get_settings()
|
||||
ttl = ttl_seconds if ttl_seconds is not None else settings.admin_token_ttl_seconds
|
||||
token = secrets.token_urlsafe(32)
|
||||
now = _now_iso()
|
||||
expires = datetime.now(timezone.utc) + timedelta(seconds=ttl)
|
||||
await db.execute(
|
||||
"INSERT INTO auth_tokens (token, user_id, expires_at, created_at, revoked) "
|
||||
"VALUES (?, ?, ?, ?, 0)",
|
||||
(token, user_id, expires.strftime("%Y-%m-%dT%H:%M:%SZ"), now),
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
async def resolve_token(db: Database, token: str) -> dict[str, Any] | None:
|
||||
row = await db.fetchone(
|
||||
"SELECT t.expires_at, t.revoked, u.id, u.username, u.role, u.authorizations, u.created_at "
|
||||
"FROM auth_tokens t JOIN users u ON u.id = t.user_id WHERE t.token = ?",
|
||||
(token,),
|
||||
)
|
||||
if row is None or row["revoked"]:
|
||||
return None
|
||||
try:
|
||||
expires = datetime.strptime(row["expires_at"], "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
return None
|
||||
if datetime.now(timezone.utc) >= expires:
|
||||
return None
|
||||
return _row_to_user(row)
|
||||
|
||||
|
||||
async def revoke_token(db: Database, token: str) -> None:
|
||||
await db.execute("UPDATE auth_tokens SET revoked = 1 WHERE token = ?", (token,))
|
||||
|
||||
|
||||
# ---- 用户 CRUD ----
|
||||
|
||||
async def list_users(db: Database) -> list[dict[str, Any]]:
|
||||
rows = await db.fetchall(
|
||||
"SELECT id, username, role, authorizations, created_at FROM users ORDER BY created_at ASC"
|
||||
)
|
||||
return [_row_to_user(r) for r in rows]
|
||||
|
||||
|
||||
async def get_user(db: Database, user_id: str) -> dict[str, Any] | None:
|
||||
row = await db.fetchone(
|
||||
"SELECT id, username, role, authorizations, created_at FROM users WHERE id = ?", (user_id,)
|
||||
)
|
||||
return _row_to_user(row) if row else None
|
||||
|
||||
|
||||
async def get_user_by_username(db: Database, username: str) -> dict[str, Any] | None:
|
||||
row = await db.fetchone(
|
||||
"SELECT id, username, role, authorizations, created_at FROM users WHERE username = ?", (username,)
|
||||
)
|
||||
return _row_to_user(row) if row else None
|
||||
|
||||
|
||||
async def create_user(
|
||||
db: Database, username: str, password: str, role: str, authorizations: list[str]
|
||||
) -> dict[str, Any]:
|
||||
user_id = str(uuid.uuid4())
|
||||
ph, salt = hash_password(password)
|
||||
await db.execute(
|
||||
"INSERT INTO users (id, username, password_hash, salt, role, authorizations, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(user_id, username, ph, salt, role, dumps_json(authorizations), _now_iso()),
|
||||
)
|
||||
log_event("user_created", detail={"target": username, "role": role})
|
||||
return {"id": user_id, "username": username, "role": role, "authorizations": authorizations, "created_at": _now_iso()}
|
||||
|
||||
|
||||
async def update_user(
|
||||
db: Database,
|
||||
user_id: str,
|
||||
*,
|
||||
role: str | None = None,
|
||||
authorizations: list[str] | None = None,
|
||||
password: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
fields: list[str] = []
|
||||
params: list[Any] = []
|
||||
if role is not None:
|
||||
fields.append("role = ?")
|
||||
params.append(role)
|
||||
if authorizations is not None:
|
||||
fields.append("authorizations = ?")
|
||||
params.append(dumps_json(authorizations))
|
||||
if password is not None:
|
||||
ph, salt = hash_password(password)
|
||||
fields.append("password_hash = ?")
|
||||
params.append(ph)
|
||||
fields.append("salt = ?")
|
||||
params.append(salt)
|
||||
if fields:
|
||||
params.append(user_id)
|
||||
await db.execute(f"UPDATE users SET {', '.join(fields)} WHERE id = ?", params)
|
||||
log_event("user_updated", detail={"target_id": user_id, "role": role, "password_reset": password is not None})
|
||||
return await get_user(db, user_id)
|
||||
|
||||
|
||||
async def delete_user(db: Database, user_id: str) -> None:
|
||||
await db.execute("DELETE FROM users WHERE id = ?", (user_id,))
|
||||
log_event("user_deleted", detail={"target_id": user_id})
|
||||
|
||||
|
||||
# ---- 种子管理员 ----
|
||||
|
||||
async def seed_admin(db: Database) -> None:
|
||||
"""启动时确保存在至少一个 admin 用户(默认 admin/admin)。"""
|
||||
row = await db.fetchone("SELECT id FROM users WHERE role = 'admin' LIMIT 1")
|
||||
if row is not None:
|
||||
return
|
||||
settings = get_settings()
|
||||
await create_user(db, settings.admin_seed_username, settings.admin_seed_password, "admin", list(AUTHORIZATION_UNITS))
|
||||
logger.info("已创建种子管理员 %s", settings.admin_seed_username)
|
||||
@@ -0,0 +1,100 @@
|
||||
"""services/logview.py —— 读取行为日志文件(JSONL)+ 内存聚合,供管理后台日志查看。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _select_files(log_dir: Path, from_date: str | None, to_date: str | None) -> list[Path]:
|
||||
"""按日期范围选出 audit-YYYY-MM-DD.log(升序)。"""
|
||||
if not log_dir.is_dir():
|
||||
return []
|
||||
out: list[Path] = []
|
||||
for p in sorted(log_dir.glob("audit-*.log")):
|
||||
name = p.name
|
||||
if not (name.startswith("audit-") and name.endswith(".log")):
|
||||
continue
|
||||
stem = name[len("audit-"):-len(".log")]
|
||||
if from_date and stem < from_date:
|
||||
continue
|
||||
if to_date and stem > to_date:
|
||||
continue
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
|
||||
def _read_lines(path: Path) -> list[dict[str, Any]]:
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
except (FileNotFoundError, OSError):
|
||||
return []
|
||||
out: list[dict[str, Any]] = []
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
out.append(json.loads(line))
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def _match(e: dict[str, Any], event, user_id, q, status) -> bool:
|
||||
if event and e.get("event") != event:
|
||||
return False
|
||||
if user_id and e.get("user_id") != user_id:
|
||||
return False
|
||||
if status is not None and str(e.get("status")) != str(status):
|
||||
return False
|
||||
if q:
|
||||
q = str(q)
|
||||
if q not in str(e.get("path") or "") and q not in str(e.get("user_id") or ""):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
async def query_logs(
|
||||
log_dir: str,
|
||||
*,
|
||||
from_date: str | None = None,
|
||||
to_date: str | None = None,
|
||||
event: str | None = None,
|
||||
user_id: str | None = None,
|
||||
q: str | None = None,
|
||||
status: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
) -> dict[str, Any]:
|
||||
files = _select_files(Path(log_dir), from_date, to_date)
|
||||
# 文件 I/O 放线程池,不阻塞事件循环
|
||||
all_lines = await asyncio.to_thread(lambda: [ln for p in files for ln in _read_lines(p)])
|
||||
items = [e for e in all_lines if _match(e, event, user_id, q, status)]
|
||||
items.sort(key=lambda e: str(e.get("ts") or ""), reverse=True)
|
||||
total = len(items)
|
||||
|
||||
by_status: dict[str, int] = {}
|
||||
latencies: list[float] = []
|
||||
denied = 0
|
||||
for e in items:
|
||||
st = e.get("status")
|
||||
if st is not None:
|
||||
key = str(st)
|
||||
by_status[key] = by_status.get(key, 0) + 1
|
||||
if e.get("event") in ("call_denied", "login_fail", "admin_login_fail"):
|
||||
denied += 1
|
||||
lm = e.get("latency_ms")
|
||||
if isinstance(lm, (int, float)):
|
||||
latencies.append(float(lm))
|
||||
latencies.sort()
|
||||
start = (page - 1) * page_size
|
||||
aggregates = {
|
||||
"by_status": by_status,
|
||||
"denied": denied,
|
||||
"avg_latency_ms": round(sum(latencies) / len(latencies), 1) if latencies else None,
|
||||
"p95_latency_ms": latencies[int(len(latencies) * 0.95)] if latencies else None,
|
||||
}
|
||||
return {"items": items[start:start + page_size], "total": total, "aggregates": aggregates}
|
||||
@@ -0,0 +1,40 @@
|
||||
"""services/settings.py —— 中枢级键值设置(hub_id / hub_name)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from ..db import Database
|
||||
|
||||
_HUB_ID_KEY = "hub_id"
|
||||
_HUB_NAME_KEY = "hub_name"
|
||||
DEFAULT_HUB_NAME = "蜂群中枢"
|
||||
|
||||
|
||||
async def get_setting(db: Database, key: str, default: str | None = None) -> str | None:
|
||||
row = await db.fetchone("SELECT value FROM settings WHERE key = ?", (key,))
|
||||
return row["value"] if row else default
|
||||
|
||||
|
||||
async def set_setting(db: Database, key: str, value: str) -> None:
|
||||
row = await db.fetchone("SELECT 1 FROM settings WHERE key = ?", (key,))
|
||||
if row is not None:
|
||||
await db.execute("UPDATE settings SET value = ? WHERE key = ?", (value, key))
|
||||
else:
|
||||
await db.execute("INSERT INTO settings (key, value) VALUES (?, ?)", (key, value))
|
||||
|
||||
|
||||
async def get_hub_settings(db: Database) -> dict:
|
||||
hub_id = await get_setting(db, _HUB_ID_KEY)
|
||||
if not hub_id:
|
||||
hub_id = str(uuid.uuid4())
|
||||
await set_setting(db, _HUB_ID_KEY, hub_id)
|
||||
hub_name = (await get_setting(db, _HUB_NAME_KEY, DEFAULT_HUB_NAME)) or DEFAULT_HUB_NAME
|
||||
return {"hub_id": hub_id, "hub_name": hub_name}
|
||||
|
||||
|
||||
async def set_hub_settings(db: Database, hub_id: str | None, hub_name: str | None) -> dict:
|
||||
if hub_id and hub_id.strip():
|
||||
await set_setting(db, _HUB_ID_KEY, hub_id.strip())
|
||||
if hub_name and hub_name.strip():
|
||||
await set_setting(db, _HUB_NAME_KEY, hub_name.strip())
|
||||
return await get_hub_settings(db)
|
||||
@@ -93,7 +93,7 @@ test.describe('E2E-07 知识图谱', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('E2E-08 提取任务', () => {
|
||||
test.describe('E2E-08 上传文献', () => {
|
||||
test('任务列表 → 详情展开 → 运行日志 → 上传(mock 成功)', async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await cleanupSessionsByTitle(page, 'R1抽取');
|
||||
@@ -109,7 +109,7 @@ test.describe('E2E-08 提取任务', () => {
|
||||
|
||||
await page.goto('/');
|
||||
await expect(page.locator('.app-shell')).toBeVisible();
|
||||
await selectBenchTab(page, '提取任务');
|
||||
await selectBenchTab(page, '上传文献');
|
||||
const sc = benchScope(page);
|
||||
const extPane = sc.locator('.extraction-pane');
|
||||
|
||||
|
||||
@@ -61,13 +61,11 @@ describe('Composer(FR-CHAT-005/007/011)', () => {
|
||||
expect(w.find('.send-btn').attributes('disabled')).toBeDefined();
|
||||
});
|
||||
|
||||
it('模式 pill 三态切换(labels.MODES)', async () => {
|
||||
it('模式 pill 已随 43a7c6c「去模式按钮」移除:Composer 不渲染三态切换', () => {
|
||||
const w = mountComposer({ mode: 'ask' });
|
||||
const pills = w.findAll('.mode-pill');
|
||||
expect(pills).toHaveLength(3);
|
||||
expect(pills[0].classes()).toContain('on');
|
||||
await pills[1].trigger('click');
|
||||
expect(w.emitted('change-mode')[0][0]).toBe('plan');
|
||||
expect(w.findAll('.mode-pill')).toHaveLength(0);
|
||||
expect(w.emitted('change-mode')).toBeUndefined();
|
||||
expect(w.find('textarea').exists()).toBe(true); // 主体输入仍在
|
||||
});
|
||||
|
||||
it('输入法组合键 Enter 不触发发送', async () => {
|
||||
|
||||
@@ -37,7 +37,7 @@ describe('BenchPanel(FR-RETRIEVAL/SOURCE/EXTRACT/KG + P3 文档入口)', ()
|
||||
const wrapper = mount(BenchPanel, { global: { plugins: [pinia] } });
|
||||
const tabs = wrapper.findAll('.bench-tab');
|
||||
expect(tabs).toHaveLength(5);
|
||||
expect(tabs.map((t) => t.text())).toEqual(['检索过程', '文献来源', '提取任务', '知识图谱', '文档']);
|
||||
expect(tabs.map((t) => t.text())).toEqual(['检索过程', '文献来源', '上传文献', '知识图谱', '文档']);
|
||||
expect(wrapper.find('.bench-tab.on').text()).toBe('检索过程');
|
||||
});
|
||||
|
||||
@@ -56,7 +56,7 @@ describe('BenchPanel(FR-RETRIEVAL/SOURCE/EXTRACT/KG + P3 文档入口)', ()
|
||||
const wrapper = mount(BenchPanel, { global: { plugins: [pinia] } });
|
||||
const tabs = wrapper.findAll('.bench-tab');
|
||||
await tabs[2].trigger('click');
|
||||
expect(wrapper.find('.bench-tab.on').text()).toBe('提取任务');
|
||||
expect(wrapper.find('.bench-tab.on').text()).toBe('上传文献');
|
||||
expect(wrapper.findComponent({ name: 'ExtractionPane' }).exists()).toBe(true);
|
||||
await tabs[3].trigger('click');
|
||||
expect(wrapper.findComponent({ name: 'KgPane' }).exists()).toBe(true);
|
||||
|
||||
@@ -81,7 +81,7 @@ describe('BenchPanel 生信 TAB 探活显隐(FR-BIO-025)', () => {
|
||||
const w = mount(BenchPanel, { global: { plugins: [pinia] } });
|
||||
await flushPromises();
|
||||
const labels = w.findAll('.bench-tab').map((t) => t.text());
|
||||
expect(labels).toEqual(['检索过程', '文献来源', '提取任务', '知识图谱', '文档', '生信任务', '生信档案']);
|
||||
expect(labels).toEqual(['检索过程', '文献来源', '上传文献', '知识图谱', '文档', '生信任务', '生信档案']);
|
||||
});
|
||||
|
||||
it('探活失败(health 不可达):生信 TAB 不渲染(TC-BIO-JOB-012/PANE-013)', async () => {
|
||||
@@ -89,7 +89,7 @@ describe('BenchPanel 生信 TAB 探活显隐(FR-BIO-025)', () => {
|
||||
const w = mount(BenchPanel, { global: { plugins: [pinia] } });
|
||||
await flushPromises();
|
||||
const labels = w.findAll('.bench-tab').map((t) => t.text());
|
||||
expect(labels).toEqual(['检索过程', '文献来源', '提取任务', '知识图谱', '文档']);
|
||||
expect(labels).toEqual(['检索过程', '文献来源', '上传文献', '知识图谱', '文档']);
|
||||
expect(labels).not.toContain('生信任务');
|
||||
expect(labels).not.toContain('生信档案');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
// tests/frontend/unit/edu/ppt.panel.test.js —— PptPanel 交互约束(P5)
|
||||
// 1) 生成表单:场景 5 项 / 时长 4 档 / 风格 14 卡;pdf↔text 切换
|
||||
// 2) 编辑器:页卡渲染 + evidence 三色徽标(verified/unverified/无)
|
||||
// + 图占位 chips(真实清单 label + para_id)+ section 分隔页标签
|
||||
// 3) 编辑 → dirty → 保存携带 pages(后端重验回写,highlight 保真)
|
||||
// 4) 脏状态点「生成 PPT」= 先保存后构建(顺序约束);干净状态直接构建
|
||||
// 5) pptx_ready → 下载直链;warnings 展示
|
||||
// 6) 3.x 可感知化:buildMeta.previews → 缩略图条/大图直链;
|
||||
// createMeta.figures_extracted 与证据核实比例 chips 露出
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
|
||||
// 改进 1:/ppt/styles 清单(14 条,顺序 = data/ppt_styles/*.json)供副标题与风格卡同源
|
||||
const { STYLE_ITEMS } = vi.hoisted(() => {
|
||||
const 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',
|
||||
];
|
||||
return {
|
||||
STYLE_ITEMS: ids.map((id, i) => ({
|
||||
id, label: `风格 ${i + 1}`, display_name: `风格 ${i + 1}`,
|
||||
description: `${id} 描述`, bg: '#ffffff', accent: '#2563eb',
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../../../ui/src/api/review.js', () => ({
|
||||
getJournals: vi.fn(),
|
||||
getDirectory: vi.fn(),
|
||||
getConferences: vi.fn(),
|
||||
presubmit: vi.fn(),
|
||||
recommend: vi.fn(),
|
||||
formatCheck: vi.fn(),
|
||||
formatErrorMessage: vi.fn(),
|
||||
reformatTex: vi.fn(),
|
||||
compileTex: vi.fn(),
|
||||
inspectZip: vi.fn(),
|
||||
runZipCompile: vi.fn(),
|
||||
reviewErrorMessage: vi.fn((e) => (e && e.message) || 'err'),
|
||||
rebuttalCreateJob: vi.fn(),
|
||||
rebuttalCreateFromPdf: vi.fn(),
|
||||
rebuttalGetJob: vi.fn(),
|
||||
rebuttalSaveResponse: vi.fn(),
|
||||
rebuttalDraft: vi.fn(),
|
||||
rebuttalStructure: vi.fn(),
|
||||
rebuttalConfirm: vi.fn(),
|
||||
rebuttalLetter: vi.fn(),
|
||||
rebuttalDownloadLetterMd: vi.fn(),
|
||||
rebuttalDeleteJob: vi.fn(),
|
||||
pptCreateJob: vi.fn(),
|
||||
pptCreateFromPdf: vi.fn(),
|
||||
pptSavePages: vi.fn(),
|
||||
pptBuildJob: vi.fn(),
|
||||
pptDeleteJob: vi.fn(),
|
||||
pptStyles: vi.fn().mockResolvedValue({ ok: true, items: STYLE_ITEMS, meta: { count: 14 } }),
|
||||
pptDownloadUrl: vi.fn((id) => `/api/review/ppt/jobs/${id}/pptx`),
|
||||
pptPreviewUrl: vi.fn((id, page) => `/api/review/ppt/jobs/${id}/preview/${page}`),
|
||||
}));
|
||||
|
||||
import { pptBuildJob, pptSavePages, pptStyles } from '../../../../ui/src/api/review.js';
|
||||
import PptPanel from '../../../../ui/src/components/edu/panels/PptPanel.vue';
|
||||
import { useEduStore } from '../../../../ui/src/stores/edu.js';
|
||||
|
||||
function makePptJob(over = {}) {
|
||||
return {
|
||||
id: 'ppt_abc123def456',
|
||||
status: 'outline_ready',
|
||||
source: { kind: 'pdf', filename: 'paper.pdf', text_chars: 5000 },
|
||||
scenario: 'defense',
|
||||
minutes: 15,
|
||||
style_id: 'academic-minimal',
|
||||
pages: [
|
||||
{ slide_number: 1, type: 'title', title: '水稻盐胁迫', bullets: ['张三 等'], evidence: [null], speaker_notes: '', suggested_figures: [] },
|
||||
{
|
||||
slide_number: 2, type: 'result', title: '结果', bullets: ['根长下降', '离子积累', '无来源要点'],
|
||||
evidence: [
|
||||
{ para_id: 'S4-P1', quote: 'salt stress', status: 'verified' },
|
||||
{ para_id: 'S4-P2', quote: 'made up', status: 'unverified' },
|
||||
null,
|
||||
],
|
||||
speaker_notes: '讲这里',
|
||||
suggested_figures: [{ label: 'Fig 2', para_id: 'S4-P1-Fig2', status: 'verified' }],
|
||||
highlight: { value: '+0.82 cm', label: '株高增长' },
|
||||
},
|
||||
],
|
||||
warnings: ['生成 9 页,低于目标下限 11 页(不凑页,请人工补足)'],
|
||||
pptx_ready: true,
|
||||
created_at: '2026-10-14T00:00:00+00:00',
|
||||
updated_at: '2026-10-14T00:00:00+00:00',
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function mountFresh() {
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
const edu = useEduStore();
|
||||
const wrapper = mount(PptPanel, { global: { plugins: [pinia] } });
|
||||
return { wrapper, edu };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); // 只清调用记录,保留 reviewErrorMessage 等实现
|
||||
});
|
||||
|
||||
function mountWithJob(job) {
|
||||
const { wrapper, edu } = mountFresh();
|
||||
edu.ppt.job = job;
|
||||
edu.ppt.status = 'editing';
|
||||
return { wrapper, edu };
|
||||
}
|
||||
|
||||
/** 挂载并 flush 渲染(job 在 mount 后注入 → 须等一 tick 才有编辑器 DOM) */
|
||||
async function mountJobReady(job) {
|
||||
const ctx = mountWithJob(job);
|
||||
await ctx.wrapper.vm.$nextTick();
|
||||
return ctx;
|
||||
}
|
||||
|
||||
describe('PptPanel 生成表单', () => {
|
||||
it('场景 5 项 / 时长 4 档 / 风格 14 卡;pdf↔text 切换', async () => {
|
||||
const { wrapper, edu } = mountFresh();
|
||||
expect(wrapper.find('[data-test="ppt-form"]').exists()).toBe(true);
|
||||
// 改进 1:副标题风格数取自 GET /ppt/styles;清单未返回前先显示「多风格」兜底
|
||||
expect(wrapper.find('.ppt-sub').text()).toContain('多风格·图文混排');
|
||||
await vi.waitFor(() => expect(edu.pptStylesStatus).toBe('ready'));
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.find('.ppt-sub').text()).toContain('14 风格·图文混排');
|
||||
expect(edu.pptStyles).toHaveLength(14);
|
||||
expect(pptStyles).toHaveBeenCalledTimes(1);
|
||||
expect(wrapper.findAll('[data-test="scenario-select"] option')).toHaveLength(5);
|
||||
for (const m of [10, 15, 20, 30]) {
|
||||
expect(wrapper.find(`[data-test="minutes-${m}"]`).exists()).toBe(true);
|
||||
}
|
||||
expect(wrapper.findAll('[class*="ppt-style-card"]')).toHaveLength(14);
|
||||
// 3.x:每张风格卡带真实版式封面图(assets/ppt-style-cards/{id}.png)
|
||||
const covers = wrapper.findAll('[data-test^="cover-"]');
|
||||
expect(covers).toHaveLength(14);
|
||||
expect(covers[0].attributes('src')).toContain('academic-minimal.png');
|
||||
expect(covers[7].attributes('src')).toContain('warm-earth.png');
|
||||
expect(covers[8].attributes('src')).toContain('domestic.png');
|
||||
expect(covers[13].attributes('src')).toContain('editorial.png');
|
||||
|
||||
// 风格切换(trigger 内部 await nextTick;DOM 断言前必须 await)
|
||||
await wrapper.find('[data-test="style-botanic-forest"]').trigger('click');
|
||||
expect(edu.ppt.form.styleId).toBe('botanic-forest');
|
||||
// 新增 5 风格可选
|
||||
await wrapper.find('[data-test="style-academic-blue"]').trigger('click');
|
||||
expect(edu.ppt.form.styleId).toBe('academic-blue');
|
||||
// 时长切换
|
||||
await wrapper.find('[data-test="minutes-30"]').trigger('click');
|
||||
expect(edu.ppt.form.minutes).toBe(30);
|
||||
// pdf → text
|
||||
await wrapper.find('[data-test="mode-text"]').trigger('click');
|
||||
expect(wrapper.find('[data-test="text-input"]').exists()).toBe(true);
|
||||
await wrapper.find('[data-test="mode-pdf"]').trigger('click');
|
||||
expect(wrapper.find('[data-test="file-input"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('缺文件点生成 → 表单错误,不发后端(mock 未被调用)', async () => {
|
||||
const { wrapper } = mountFresh();
|
||||
await wrapper.find('[data-test="generate"]').trigger('click');
|
||||
expect(wrapper.find('[data-test="form-error"]').text()).toContain('PDF');
|
||||
});
|
||||
|
||||
it('风格清单接口失败 → 副标题回落「多风格」,风格卡走本地常量', async () => {
|
||||
pptStyles.mockRejectedValueOnce(new Error('offline'));
|
||||
const { wrapper, edu } = mountFresh();
|
||||
await vi.waitFor(() => expect(edu.pptStylesStatus).toBe('error'));
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.find('.ppt-sub').text()).toContain('多风格·图文混排');
|
||||
expect(edu.pptStyles).toHaveLength(0);
|
||||
expect(wrapper.findAll('[class*="ppt-style-card"]')).toHaveLength(14);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PptPanel 编辑器', () => {
|
||||
it('页卡 + evidence 三色徽标 + 图占位 chips + warnings + 下载直链', async () => {
|
||||
const { wrapper } = await mountJobReady(makePptJob());
|
||||
expect(wrapper.find('[data-test="ppt-editor"]').exists()).toBe(true);
|
||||
expect(wrapper.findAll('[data-test="page-card"]')).toHaveLength(2);
|
||||
// 第 2 页三枚徽标:verified / unverified / 无来源
|
||||
const badges = wrapper.findAll('[data-test="page-card"][class*="tp-result"] [data-test="ev-badge"]');
|
||||
expect(badges).toHaveLength(3);
|
||||
expect(badges[0].classes()).toContain('ev-ok');
|
||||
expect(badges[1].classes()).toContain('ev-bad');
|
||||
expect(badges[2].classes()).toContain('ev-none');
|
||||
expect(badges[0].attributes('title')).toContain('S4-P1');
|
||||
// 图占位(真实清单:label + 论文 para_id)
|
||||
expect(wrapper.find('[data-test="fig-1-0"]').text())
|
||||
.toBe('Fig 2(论文 S4-P1-Fig2)');
|
||||
// warnings
|
||||
expect(wrapper.find('[data-test="warnings"]').text()).toContain('低于目标下限');
|
||||
// 下载直链(pptx_ready)
|
||||
expect(wrapper.find('[data-test="download"]').attributes('href'))
|
||||
.toBe('/api/review/ppt/jobs/ppt_abc123def456/pptx');
|
||||
// 页数
|
||||
expect(wrapper.find('[data-test="page-count"]').text()).toBe('2 页');
|
||||
});
|
||||
|
||||
it('编辑要点 → dirty → 保存携带 pages(顺序:先 save)', async () => {
|
||||
const { wrapper, edu } = await mountJobReady(makePptJob());
|
||||
const saveBtn = wrapper.find('[data-test="save"]');
|
||||
expect(saveBtn.attributes('disabled')).toBeDefined(); // 无修改禁用
|
||||
|
||||
await wrapper.find('[data-test="bullet-1-0"]').setValue('改过的要点');
|
||||
expect(wrapper.find('[data-test="dirty-hint"]').exists()).toBe(true);
|
||||
expect(saveBtn.attributes('disabled')).toBeUndefined();
|
||||
|
||||
const newJob = makePptJob({ status: 'outline_ready', pptx_ready: false });
|
||||
pptSavePages.mockResolvedValue({ ok: true, job: newJob });
|
||||
await saveBtn.trigger('click');
|
||||
await vi.waitFor(() => expect(pptSavePages).toHaveBeenCalledTimes(1));
|
||||
|
||||
const [id, pages] = pptSavePages.mock.calls[0];
|
||||
expect(id).toBe('ppt_abc123def456');
|
||||
expect(pages[1].bullets[0]).toBe('改过的要点');
|
||||
expect(pages[1].evidence[0].para_id).toBe('S4-P1'); // evidence 随行
|
||||
expect(pages[1].highlight).toEqual({ value: '+0.82 cm', label: '株高增长' }); // highlight 保真
|
||||
expect(edu.ppt.job.pptx_ready).toBe(false); // 旧 PPTX 失效
|
||||
await vi.waitFor(() => expect(wrapper.find('[data-test="dirty-hint"]').exists()).toBe(false));
|
||||
});
|
||||
|
||||
it('脏状态「生成 PPT」= 先保存后构建(顺序约束)', async () => {
|
||||
const { wrapper } = await mountJobReady(makePptJob());
|
||||
await wrapper.find('[data-test="bullet-0-0"]').setValue('脏了');
|
||||
pptSavePages.mockResolvedValue({ ok: true, job: makePptJob() });
|
||||
pptBuildJob.mockResolvedValue({
|
||||
ok: true, job: makePptJob({ status: 'done', pptx_ready: true }),
|
||||
meta: { slides: 2, bytes: 12000 },
|
||||
});
|
||||
|
||||
await wrapper.find('[data-test="build"]').trigger('click');
|
||||
await vi.waitFor(() => expect(pptBuildJob).toHaveBeenCalledTimes(1));
|
||||
expect(pptSavePages).toHaveBeenCalledTimes(1);
|
||||
expect(pptSavePages.mock.invocationCallOrder[0]).toBeLessThan(pptBuildJob.mock.invocationCallOrder[0]);
|
||||
// 构建后 done + 下载链接仍在
|
||||
expect(wrapper.find('[data-test="download"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('干净状态「生成 PPT」不打保存', async () => {
|
||||
const { wrapper } = await mountJobReady(makePptJob());
|
||||
pptBuildJob.mockResolvedValue({
|
||||
ok: true, job: makePptJob({ status: 'done' }), meta: { slides: 2, bytes: 12000 },
|
||||
});
|
||||
await wrapper.find('[data-test="build"]').trigger('click');
|
||||
await vi.waitFor(() => expect(pptBuildJob).toHaveBeenCalledTimes(1));
|
||||
expect(pptSavePages).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('P5 增强:section 分隔页与大数字', () => {
|
||||
it('section 页显示「分隔」标签,result highlight 页正常渲染', async () => {
|
||||
const sectionPage = {
|
||||
slide_number: 3, type: 'section', title: '主要结果', bullets: [],
|
||||
evidence: [], speaker_notes: '进入结果部分', suggested_figures: [],
|
||||
};
|
||||
const { wrapper } = await mountJobReady(makePptJob({ pages: [
|
||||
...makePptJob().pages,
|
||||
sectionPage,
|
||||
] }));
|
||||
expect(wrapper.findAll('[data-test="page-card"]')).toHaveLength(3);
|
||||
const types = wrapper.findAll('[data-test="page-type"]').map((n) => n.text());
|
||||
expect(types).toContain('分隔');
|
||||
// highlight 大数字页:result 第 2 页仍走常规渲染,无异常
|
||||
expect(wrapper.find('[data-test="page-count"]').text()).toBe('3 页');
|
||||
});
|
||||
|
||||
it('section 页唯一副标题输入:编辑同写 bullets[0]+subtitle;清空回落默认', async () => {
|
||||
const mkSection = (over = {}) => ({
|
||||
slide_number: 3, type: 'section', title: '主要结果', bullets: [], evidence: [],
|
||||
speaker_notes: '', suggested_figures: [], ...over,
|
||||
});
|
||||
const { wrapper } = await mountJobReady(makePptJob({ pages: [...makePptJob().pages, mkSection()] }));
|
||||
expect(wrapper.findAll('[data-test="page-subtitle"]')).toHaveLength(1); // 只在分隔页出现
|
||||
expect(wrapper.find('[data-test="bullet-0-0"]').exists()).toBe(true); // 正文页仍走要点编辑
|
||||
|
||||
await wrapper.find('[data-test="page-subtitle"]').setValue('结果章节');
|
||||
expect(wrapper.find('[data-test="dirty-hint"]').exists()).toBe(true);
|
||||
pptSavePages.mockResolvedValue({
|
||||
ok: true,
|
||||
job: makePptJob({ pages: [...makePptJob().pages, mkSection({ bullets: ['结果章节'], subtitle: '结果章节' })] }),
|
||||
});
|
||||
await wrapper.find('[data-test="save"]').trigger('click');
|
||||
await vi.waitFor(() => expect(pptSavePages).toHaveBeenCalledTimes(1));
|
||||
const [, pages] = pptSavePages.mock.calls[0];
|
||||
expect(pages[2].bullets).toEqual(['结果章节']); // 后端 coerce bullets[0] 优先
|
||||
expect(pages[2].subtitle).toBe('结果章节'); // 两字段同写,避免互踩
|
||||
|
||||
// 清空 → bullets/subtitle 均空(渲染端回落该章第一页标题)
|
||||
await wrapper.find('[data-test="page-subtitle"]').setValue('');
|
||||
expect(wrapper.find('[data-test="dirty-hint"]').exists()).toBe(true);
|
||||
await wrapper.find('[data-test="save"]').trigger('click');
|
||||
await vi.waitFor(() => expect(pptSavePages).toHaveBeenCalledTimes(2));
|
||||
const pages2 = pptSavePages.mock.calls[1][1];
|
||||
expect(pages2[2].bullets).toEqual([]);
|
||||
expect(pages2[2].subtitle).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('3.x 可感知化:页面预览与 meta 露出', () => {
|
||||
it('build 返回 previews → 缩略图条 + 大图直链;点击切换页', async () => {
|
||||
const { wrapper } = await mountJobReady(makePptJob());
|
||||
// 构建前无预览区
|
||||
expect(wrapper.find('[data-test="preview-wrap"]').exists()).toBe(false);
|
||||
|
||||
pptBuildJob.mockResolvedValue({
|
||||
ok: true,
|
||||
job: makePptJob({ status: 'done', pptx_ready: true }),
|
||||
meta: { slides: 2, bytes: 12000, previews: 2 },
|
||||
});
|
||||
await wrapper.find('[data-test="build"]').trigger('click');
|
||||
await vi.waitFor(() => expect(wrapper.find('[data-test="preview-strip"]').exists()).toBe(true));
|
||||
|
||||
// 缩略图数 = previews;大图直链正确
|
||||
expect(wrapper.findAll('[data-test^="thumb-"]')).toHaveLength(2);
|
||||
expect(wrapper.find('[data-test="preview-main-1"]').attributes('src'))
|
||||
.toBe('/api/review/ppt/jobs/ppt_abc123def456/preview/1');
|
||||
|
||||
// 点缩略图切大图
|
||||
await wrapper.find('[data-test="thumb-2"]').trigger('click');
|
||||
expect(wrapper.find('[data-test="preview-main-2"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-test="preview-main-1"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('旧响应无 previews → 无预览区;pptx 失效(编辑后)→ 已有预览也隐藏', async () => {
|
||||
const { wrapper, edu } = await mountJobReady(makePptJob());
|
||||
pptBuildJob.mockResolvedValue({
|
||||
ok: true, job: makePptJob({ status: 'done', pptx_ready: true }),
|
||||
meta: { slides: 2, bytes: 12000 }, // 无 previews(旧版后端响应)
|
||||
});
|
||||
await wrapper.find('[data-test="build"]').trigger('click');
|
||||
await vi.waitFor(() => expect(pptBuildJob).toHaveBeenCalledTimes(1));
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.find('[data-test="preview-wrap"]').exists()).toBe(false);
|
||||
|
||||
// 有 previews 但 pptx 已失效(保存编辑后 pptx_ready=false + buildMeta 被 store 清空)
|
||||
edu.ppt.buildMeta = { slides: 2, bytes: 12000, previews: 2 };
|
||||
edu.ppt.job = makePptJob({ pptx_ready: false });
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.find('[data-test="preview-wrap"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('提取原图 chip 与证据核实比例 chip 露出', async () => {
|
||||
const { wrapper, edu } = await mountJobReady(makePptJob());
|
||||
// makePptJob:1 verified + 1 unverified → 1/2;无 figures_extracted → 不显示
|
||||
expect(wrapper.find('[data-test="ev-stats"]').text()).toBe('证据 1/2 已核实');
|
||||
expect(wrapper.find('[data-test="figs-chip"]').exists()).toBe(false);
|
||||
|
||||
edu.ppt.createMeta = { target_pages: [11, 14], figures_extracted: 2 };
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.find('[data-test="figs-chip"]').text()).toBe('提取原图 2 张');
|
||||
expect(wrapper.find('[data-test="figs-chip"]').text()).not.toContain('0 张');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
// tests/frontend/unit/edu/ppt.store.test.js —— P5 科研汇报 store actions(edu.ppt)
|
||||
// 1) setSection('ppt') 合法(SECTIONS 收录)
|
||||
// 2) pptGenerate:pdf 入口 FormData 组装 / 缺文件与短文本前置校验(不打后端)
|
||||
// 3) pptSave:job 回写 + buildMeta 清空(旧 PPTX 失效)+ 失败保留编辑态
|
||||
// 4) pptBuild:done + meta 回写 / 失败 → error
|
||||
// 5) pptDelete:清 job 但保留表单(便于立即重做)
|
||||
// 6) pptLoadStyles:清单 items+ready / 失败清空+error / 就绪后不重复请求
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('../../../../ui/src/api/review.js', () => ({
|
||||
getJournals: vi.fn(),
|
||||
getDirectory: vi.fn(),
|
||||
getConferences: vi.fn(),
|
||||
presubmit: vi.fn(),
|
||||
recommend: vi.fn(),
|
||||
formatCheck: vi.fn(),
|
||||
formatErrorMessage: vi.fn(),
|
||||
reformatTex: vi.fn(),
|
||||
compileTex: vi.fn(),
|
||||
inspectZip: vi.fn(),
|
||||
runZipCompile: vi.fn(),
|
||||
reviewErrorMessage: vi.fn((e) => (e && e.message) || 'err'),
|
||||
rebuttalCreateJob: vi.fn(),
|
||||
rebuttalCreateFromPdf: vi.fn(),
|
||||
rebuttalGetJob: vi.fn(),
|
||||
rebuttalSaveResponse: vi.fn(),
|
||||
rebuttalDraft: vi.fn(),
|
||||
rebuttalStructure: vi.fn(),
|
||||
rebuttalConfirm: vi.fn(),
|
||||
rebuttalLetter: vi.fn(),
|
||||
rebuttalDownloadLetterMd: vi.fn(),
|
||||
rebuttalDeleteJob: vi.fn(),
|
||||
pptCreateJob: vi.fn(),
|
||||
pptCreateFromPdf: vi.fn(),
|
||||
pptSavePages: vi.fn(),
|
||||
pptBuildJob: vi.fn(),
|
||||
pptDeleteJob: vi.fn(),
|
||||
pptStyles: vi.fn(),
|
||||
pptDownloadUrl: vi.fn((id) => `/api/review/ppt/jobs/${id}/pptx`),
|
||||
}));
|
||||
|
||||
import {
|
||||
pptBuildJob, pptCreateFromPdf, pptCreateJob, pptDeleteJob, pptSavePages, pptStyles,
|
||||
} from '../../../../ui/src/api/review.js';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { useEduStore } from '../../../../ui/src/stores/edu.js';
|
||||
|
||||
function makePptJob(over = {}) {
|
||||
return {
|
||||
id: 'ppt_abc123def456',
|
||||
status: 'outline_ready',
|
||||
source: { kind: 'pdf', filename: 'paper.pdf', text_chars: 5000 },
|
||||
scenario: 'defense',
|
||||
minutes: 15,
|
||||
style_id: 'academic-minimal',
|
||||
pages: [
|
||||
{ slide_number: 1, type: 'title', title: '封面', bullets: ['张三'], evidence: [null], speaker_notes: '', suggested_figures: [] },
|
||||
{ slide_number: 2, type: 'background', title: '背景', bullets: ['a', 'b'], evidence: [null, null], speaker_notes: 'n', suggested_figures: [] },
|
||||
],
|
||||
warnings: ['生成 9 页,低于目标下限 11 页'],
|
||||
pptx_ready: false,
|
||||
created_at: '2026-10-14T00:00:00+00:00',
|
||||
updated_at: '2026-10-14T00:00:00+00:00',
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
let edu;
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
edu = useEduStore();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('edu.ppt store', () => {
|
||||
it("setSection('ppt') 合法", () => {
|
||||
edu.setSection('ppt');
|
||||
expect(edu.section).toBe('ppt');
|
||||
edu.setSection('nope');
|
||||
expect(edu.section).toBe('ppt'); // 非法 section 不落
|
||||
});
|
||||
|
||||
it('pptGenerate pdf:FormData 组装 + 成功进 editing', async () => {
|
||||
edu.ppt.form.mode = 'pdf';
|
||||
edu.ppt.form.file = new File(['%PDF'], 'paper.pdf', { type: 'application/pdf' });
|
||||
edu.ppt.form.scenario = 'defense';
|
||||
edu.ppt.form.minutes = 20;
|
||||
edu.ppt.form.styleId = 'botanic-forest';
|
||||
const job = makePptJob();
|
||||
pptCreateFromPdf.mockResolvedValue({ ok: true, job, meta: { target_pages: [15, 18], llm_calls: 5 } });
|
||||
|
||||
const got = await edu.pptGenerate();
|
||||
expect(got.id).toBe(job.id);
|
||||
expect(edu.ppt.status).toBe('editing');
|
||||
expect(edu.ppt.createMeta.target_pages).toEqual([15, 18]);
|
||||
const fd = pptCreateFromPdf.mock.calls[0][0];
|
||||
expect(fd).toBeInstanceOf(FormData);
|
||||
expect(fd.get('scenario')).toBe('defense');
|
||||
expect(fd.get('minutes')).toBe('20');
|
||||
expect(fd.get('style_id')).toBe('botanic-forest');
|
||||
expect(pptCreateJob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('pptGenerate 前置校验:缺文件/短文本不打后端', async () => {
|
||||
edu.ppt.form.mode = 'pdf';
|
||||
edu.ppt.form.file = null;
|
||||
expect(await edu.pptGenerate()).toBeNull();
|
||||
expect(edu.ppt.status).toBe('error');
|
||||
expect(pptCreateFromPdf).not.toHaveBeenCalled();
|
||||
|
||||
edu.ppt.form.mode = 'text';
|
||||
edu.ppt.form.text = '短文本';
|
||||
expect(await edu.pptGenerate()).toBeNull();
|
||||
expect(pptCreateJob).not.toHaveBeenCalled();
|
||||
expect(edu.ppt.error).toContain('300');
|
||||
});
|
||||
|
||||
it('pptSave:job 回写 + buildMeta 清空;失败保留编辑态', async () => {
|
||||
edu.ppt.job = makePptJob({ status: 'done', pptx_ready: true });
|
||||
edu.ppt.buildMeta = { slides: 2, bytes: 1000 };
|
||||
edu.ppt.status = 'done';
|
||||
const newJob = makePptJob({ pages: [{ ...makePptJob().pages[0], title: '改过' }] });
|
||||
pptSavePages.mockResolvedValue({ ok: true, job: newJob });
|
||||
|
||||
const saved = await edu.pptSave(newJob.pages);
|
||||
expect(saved.id).toBe(newJob.id);
|
||||
expect(pptSavePages).toHaveBeenCalledWith('ppt_abc123def456', newJob.pages);
|
||||
expect(edu.ppt.job.pages[0].title).toBe('改过');
|
||||
expect(edu.ppt.buildMeta).toBeNull(); // 旧 PPTX 失效
|
||||
expect(edu.ppt.saving).toBe(false);
|
||||
|
||||
// 失败:error 记录但 status 保持(job 不动)
|
||||
pptSavePages.mockRejectedValue(new Error('保存炸了'));
|
||||
expect(await edu.pptSave([])).toBeNull();
|
||||
expect(edu.ppt.job.pages[0].title).toBe('改过');
|
||||
expect(edu.ppt.error).toBe('保存炸了');
|
||||
});
|
||||
|
||||
it('pptBuild:done + meta;失败 → error', async () => {
|
||||
edu.ppt.job = makePptJob();
|
||||
pptBuildJob.mockResolvedValue({
|
||||
ok: true,
|
||||
job: makePptJob({ status: 'done', pptx_ready: true }),
|
||||
meta: { slides: 8, bytes: 24576 },
|
||||
});
|
||||
const job = await edu.pptBuild();
|
||||
expect(job.status).toBe('done');
|
||||
expect(edu.ppt.status).toBe('done');
|
||||
expect(edu.ppt.buildMeta).toEqual({ slides: 8, bytes: 24576 });
|
||||
|
||||
pptBuildJob.mockRejectedValue(new Error('磁盘满'));
|
||||
expect(await edu.pptBuild()).toBeNull();
|
||||
expect(edu.ppt.status).toBe('error');
|
||||
expect(edu.ppt.error).toBe('磁盘满');
|
||||
});
|
||||
|
||||
it('pptDelete:清 job 保留表单;job 不存在不打后端', async () => {
|
||||
edu.ppt.form.mode = 'text';
|
||||
edu.ppt.form.text = 'x'.repeat(400);
|
||||
edu.ppt.job = makePptJob();
|
||||
edu.ppt.status = 'editing';
|
||||
pptDeleteJob.mockResolvedValue({ ok: true, meta: { deleted: 'ppt_abc123def456' } });
|
||||
|
||||
const res = await edu.pptDelete();
|
||||
expect(res.ok).toBe(true);
|
||||
expect(pptDeleteJob).toHaveBeenCalledWith('ppt_abc123def456');
|
||||
expect(edu.ppt.job).toBeNull();
|
||||
expect(edu.ppt.status).toBe('idle');
|
||||
expect(edu.ppt.form.text).toHaveLength(400); // 表单保留
|
||||
|
||||
await edu.pptDelete();
|
||||
expect(pptDeleteJob).toHaveBeenCalledTimes(1); // 无 job 不重复打
|
||||
});
|
||||
|
||||
it('pptLoadStyles:成功落 items+ready、就绪不重复请求;失败清空+error 可重试', async () => {
|
||||
pptStyles.mockResolvedValue({
|
||||
ok: true,
|
||||
items: [{ id: 'academic-minimal', label: '学术极简', description: '白底蓝黑' }],
|
||||
meta: { count: 1 },
|
||||
});
|
||||
const items = await edu.pptLoadStyles();
|
||||
expect(items).toHaveLength(1);
|
||||
expect(edu.pptStyles[0].id).toBe('academic-minimal');
|
||||
expect(edu.pptStylesStatus).toBe('ready');
|
||||
await edu.pptLoadStyles();
|
||||
expect(pptStyles).toHaveBeenCalledTimes(1); // 已就绪 → 不重复打
|
||||
|
||||
// 失败:清空清单 + error(error 态允许下次重试)
|
||||
edu.pptStyles = [];
|
||||
edu.pptStylesStatus = 'idle';
|
||||
pptStyles.mockRejectedValueOnce(new Error('offline'));
|
||||
expect(await edu.pptLoadStyles()).toEqual([]);
|
||||
expect(edu.pptStylesStatus).toBe('error');
|
||||
pptStyles.mockResolvedValue({ ok: true, items: [], meta: { count: 0 } });
|
||||
await edu.pptLoadStyles();
|
||||
expect(pptStyles).toHaveBeenCalledTimes(3); // error → 允许重试
|
||||
expect(edu.pptStylesStatus).toBe('ready');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
// tests/frontend/unit/edu/reformat.compile.test.js —— ReformatPanel 编译 PDF Tab(P3b)
|
||||
// 1) 无 job_id → "请先完成重排版再编译" 引导
|
||||
// 2) 有 job_id → "编译 PDF" 按钮可点
|
||||
// 3) 点击后按钮变 "编译中…"(loading 期间禁用;store 状态驱动,切面板不中断)
|
||||
// 4) 成功状态:iframe 预览 + 下载按钮 + 耗时
|
||||
// 5) 失败状态:错误摘要 + log 尾折叠 <pre> + 查看完整 log
|
||||
// 6) QUEUE_FULL → 固定文案"编译队列已满(最多 5 个任务)"
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import ReformatPanel from '../../../../ui/src/components/edu/panels/ReformatPanel.vue';
|
||||
|
||||
const compileTexMock = vi.fn();
|
||||
vi.mock('../../../../ui/src/api/review.js', async (importOriginal) => {
|
||||
const actual = await importOriginal(); // 保留真实 reviewErrorMessage(QUEUE_FULL 文案走 CODE_HINTS)
|
||||
return {
|
||||
...actual,
|
||||
getJournals: vi.fn().mockResolvedValue({ ok: true, items: [] }),
|
||||
getDirectory: vi.fn(),
|
||||
reformatTex: vi.fn(),
|
||||
compileTex: (...args) => compileTexMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
import { useEduStore } from '../../../../ui/src/stores/edu.js';
|
||||
import { ApiError } from '../../../../ui/src/api/http.js';
|
||||
|
||||
function seedResult(edu, job_id = 'abc123def456') {
|
||||
edu.reformatResult = {
|
||||
ok: true,
|
||||
job_id,
|
||||
modified_tex: '\\documentclass{article}\\begin{document}Hi\\end{document}',
|
||||
changes: [],
|
||||
warnings: [],
|
||||
meta: { filename: 'paper.tex', journal_name: 'Plant Cell', rules_source: 'official' },
|
||||
};
|
||||
edu.reformatStatus = 'ready';
|
||||
}
|
||||
|
||||
async function mountPdfTab(edu) {
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
edu = useEduStore();
|
||||
const wrapper = mount(ReformatPanel, { global: { plugins: [pinia] } });
|
||||
// 切到"编译 PDF" Tab
|
||||
const tabs = wrapper.findAll('.rf-tab');
|
||||
const pdfTab = tabs.find((t) => t.text() === '编译 PDF');
|
||||
await pdfTab.trigger('click');
|
||||
return { wrapper, edu };
|
||||
}
|
||||
|
||||
describe('ReformatPanel 编译 PDF Tab(P3b)', () => {
|
||||
beforeEach(() => {
|
||||
compileTexMock.mockReset();
|
||||
// jsdom 无 URL.createObjectURL:store blob 加载走 catch 回退,不致挂
|
||||
if (!URL.createObjectURL) URL.createObjectURL = vi.fn(() => 'blob:mock');
|
||||
if (!URL.revokeObjectURL) URL.revokeObjectURL = vi.fn();
|
||||
global.fetch = vi.fn().mockRejectedValue(new Error('no network'));
|
||||
});
|
||||
|
||||
it('1) 无 job_id → "请先完成重排版再编译" 引导', async () => {
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
const edu = useEduStore();
|
||||
edu.reformatResult = { ok: true, job_id: null, changes: [], meta: {} };
|
||||
edu.reformatStatus = 'ready';
|
||||
const wrapper = mount(ReformatPanel, { global: { plugins: [pinia] } });
|
||||
await wrapper.findAll('.rf-tab').find((t) => t.text() === '编译 PDF').trigger('click');
|
||||
const guide = wrapper.find('[data-testid="rf-compile-guide"]');
|
||||
expect(guide.exists()).toBe(true);
|
||||
expect(guide.text()).toContain('请先完成重排版再编译');
|
||||
expect(wrapper.find('[data-testid="rf-compile-btn"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('2) 有 job_id → "编译 PDF" 按钮可点', async () => {
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
const edu = useEduStore();
|
||||
seedResult(edu);
|
||||
const wrapper = mount(ReformatPanel, { global: { plugins: [pinia] } });
|
||||
await wrapper.findAll('.rf-tab').find((t) => t.text() === '编译 PDF').trigger('click');
|
||||
const btn = wrapper.find('[data-testid="rf-compile-btn"]');
|
||||
expect(btn.exists()).toBe(true);
|
||||
expect(btn.text()).toBe('编译 PDF');
|
||||
expect(btn.attributes('disabled')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('3) 点击后按钮变"编译中…"并禁用;首次编译黄条出现', async () => {
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
const edu = useEduStore();
|
||||
seedResult(edu);
|
||||
compileTexMock.mockReturnValue(new Promise(() => {})); // 挂起,模拟编译中
|
||||
const wrapper = mount(ReformatPanel, { global: { plugins: [pinia] } });
|
||||
await wrapper.findAll('.rf-tab').find((t) => t.text() === '编译 PDF').trigger('click');
|
||||
await wrapper.find('[data-testid="rf-compile-btn"]').trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
const btn = wrapper.find('[data-testid="rf-compile-btn"]');
|
||||
expect(btn.text()).toContain('编译中');
|
||||
expect(btn.attributes('disabled')).toBeDefined();
|
||||
expect(wrapper.find('[data-testid="rf-compile-loading"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="rf-first-hint"]').exists()).toBe(true);
|
||||
expect(compileTexMock).toHaveBeenCalledTimes(1);
|
||||
// loading 期间再点不重复提交
|
||||
await wrapper.find('[data-testid="rf-compile-btn"]').trigger('click');
|
||||
expect(compileTexMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('4) 成功状态:iframe 预览 + 下载按钮 + 耗时', async () => {
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
const edu = useEduStore();
|
||||
seedResult(edu);
|
||||
edu.compileResult = {
|
||||
ok: true, compile_id: 'abc123def456',
|
||||
pdf_url: '/api/review/compile/abc123def456/pdf',
|
||||
log_url: '/api/review/compile/abc123def456/log',
|
||||
meta: { seconds: 12.34, pdf_bytes: 1048576, engine: 'tectonic' },
|
||||
};
|
||||
edu.compileStatus = 'ready';
|
||||
edu.compilePdfUrl = ''; // blob 失败回退直链
|
||||
const wrapper = mount(ReformatPanel, { global: { plugins: [pinia] } });
|
||||
await wrapper.findAll('.rf-tab').find((t) => t.text() === '编译 PDF').trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
const frame = wrapper.find('[data-testid="rf-pdf-frame"]');
|
||||
expect(frame.exists()).toBe(true);
|
||||
expect(frame.attributes('src')).toContain('/pdf');
|
||||
expect(wrapper.find('[data-testid="rf-download-pdf"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="rf-pdf-size"]').text()).toContain('MB');
|
||||
expect(wrapper.find('[data-testid="rf-compile-secs"]').text()).toContain('12.3 秒');
|
||||
expect(wrapper.find('[data-testid="rf-recompile"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="rf-compile-btn"]').exists()).toBe(false); // 操作行换成下载/重编
|
||||
});
|
||||
|
||||
it('5) 失败状态:错误摘要 + log 尾折叠区 + 完整 log 按钮', async () => {
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
const edu = useEduStore();
|
||||
seedResult(edu);
|
||||
edu.compileStatus = 'error';
|
||||
edu.compileError = 'LaTeX 编译失败:缺少宏包';
|
||||
edu.compileLogTail = 'This is tectonic\n! LaTeX Error: File `foo.sty\' not found.\n68 lines omitted...';
|
||||
const wrapper = mount(ReformatPanel, { global: { plugins: [pinia] } });
|
||||
await wrapper.findAll('.rf-tab').find((t) => t.text() === '编译 PDF').trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
const err = wrapper.find('[data-testid="rf-compile-error"]');
|
||||
expect(err.exists()).toBe(true);
|
||||
expect(err.text()).toContain('缺少宏包');
|
||||
const details = wrapper.find('[data-testid="rf-log-details"]');
|
||||
expect(details.exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="rf-log-body"]').text()).toContain('foo.sty');
|
||||
expect(wrapper.find('[data-testid="rf-download-log"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="rf-recompile"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="rf-pdf-frame"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('6) QUEUE_FULL → 固定文案"编译队列已满(最多 5 个任务)"', async () => {
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
const edu = useEduStore();
|
||||
seedResult(edu);
|
||||
compileTexMock.mockRejectedValue(new ApiError('编译队列已满(7 个任务排队),请稍后重试', {
|
||||
code: 'QUEUE_FULL', status: 429,
|
||||
}));
|
||||
const wrapper = mount(ReformatPanel, { global: { plugins: [pinia] } });
|
||||
await wrapper.findAll('.rf-tab').find((t) => t.text() === '编译 PDF').trigger('click');
|
||||
await wrapper.find('[data-testid="rf-compile-btn"]').trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
await wrapper.vm.$nextTick();
|
||||
const err = wrapper.find('[data-testid="rf-compile-error"]');
|
||||
expect(err.exists()).toBe(true);
|
||||
expect(err.text()).toContain('编译队列已满(最多 5 个任务),请稍后重试');
|
||||
// 报错后按钮立即恢复可点(重新编译)
|
||||
const retry = wrapper.find('[data-testid="rf-recompile"]');
|
||||
expect(retry.exists()).toBe(true);
|
||||
expect(retry.attributes('disabled')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
// tests/frontend/unit/edu/reformat.file.test.js —— ReformatPanel 文件持久化(P3b 修复)
|
||||
// bug:上传 .tex → 重排版 → 点"重新排版"换期刊 → 本地 file 被清空 → 要求重传。
|
||||
// 修复:File 进 store(reformatFile,同 presubmitFile 模式);reset() 不清 file;
|
||||
// 仅"×" removeFile 清空。
|
||||
// 1) store 有 reformatFile → 挂载即回显文件名
|
||||
// 2) 选择文件 → 切换目标期刊 → 文件仍在(store + DOM)
|
||||
// 3) 有结果时点"重新排版"(reset)→ 回上传区且文件保留
|
||||
// 4) "×" 移除 → store + 本地都清空
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import ReformatPanel from '../../../../ui/src/components/edu/panels/ReformatPanel.vue';
|
||||
|
||||
vi.mock('../../../../ui/src/api/review.js', async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
getJournals: vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
items: [{ id: 'gb', name: 'Genome Biology' }, { id: 'mp', name: 'Molecular Plant' }],
|
||||
}),
|
||||
getDirectory: vi.fn(),
|
||||
reformatTex: vi.fn(),
|
||||
compileTex: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { useEduStore } from '../../../../ui/src/stores/edu.js';
|
||||
|
||||
const TEX = new File(['\\documentclass{article}\\begin{document}Hi\\end{document}'], 'paper.tex', {
|
||||
type: 'application/x-tex',
|
||||
});
|
||||
|
||||
/** 先建 pinia → seed store → 再挂载(保证 mount 时读到 store 初值) */
|
||||
function setup(seedFn) {
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
const edu = useEduStore();
|
||||
if (seedFn) seedFn(edu);
|
||||
const wrapper = mount(ReformatPanel, { global: { plugins: [pinia] } });
|
||||
return { wrapper, edu };
|
||||
}
|
||||
|
||||
describe('ReformatPanel 文件持久化(换期刊不丢文件)', () => {
|
||||
beforeEach(() => {
|
||||
global.fetch = vi.fn().mockRejectedValue(new Error('no network'));
|
||||
if (!URL.createObjectURL) URL.createObjectURL = vi.fn(() => 'blob:mock');
|
||||
if (!URL.revokeObjectURL) URL.revokeObjectURL = vi.fn();
|
||||
});
|
||||
|
||||
it('1) store 有 reformatFile → 挂载即回显文件名', async () => {
|
||||
const { wrapper } = setup((edu) => { edu.reformatFile = TEX; });
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.find('.rf-file-name').text()).toContain('paper.tex');
|
||||
});
|
||||
|
||||
it('2) 选择文件后切目标期刊 → 文件仍在(store + DOM)', async () => {
|
||||
const { wrapper, edu } = setup();
|
||||
const input = wrapper.find('input.rf-file-input');
|
||||
Object.defineProperty(input.element, 'files', { value: [TEX], configurable: true });
|
||||
await input.trigger('change');
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(edu.reformatFile).toBe(TEX);
|
||||
expect(wrapper.find('.rf-file-name').text()).toContain('paper.tex');
|
||||
// 切换目标期刊(watch 只写 drafts,不清 file)
|
||||
await wrapper.find('#rf-target').setValue('mp');
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(edu.reformatFile).toBe(TEX);
|
||||
expect(wrapper.find('.rf-file-name').text()).toContain('paper.tex');
|
||||
expect(edu.drafts.reformatTarget).toBe('mp');
|
||||
});
|
||||
|
||||
it('3) 有结果时点"重新排版" → 回上传区且文件保留', async () => {
|
||||
const { wrapper, edu } = setup((e) => {
|
||||
e.reformatFile = TEX;
|
||||
e.reformatResult = {
|
||||
ok: true, job_id: 'abc123def456', modified_tex: 'x', changes: [], warnings: [],
|
||||
meta: { filename: 'paper.tex' },
|
||||
};
|
||||
e.reformatStatus = 'ready';
|
||||
});
|
||||
await wrapper.vm.$nextTick();
|
||||
const again = wrapper.findAll('button').find((b) => b.text() === '重新排版');
|
||||
expect(again).toBeTruthy();
|
||||
await again.trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(edu.reformatResult).toBeNull();
|
||||
expect(edu.reformatFile).toBe(TEX); // store 保留
|
||||
expect(wrapper.find('.rf-file-name').text()).toContain('paper.tex'); // DOM 回显
|
||||
});
|
||||
|
||||
it('4) "×" 移除 → store + 本地都清空', async () => {
|
||||
const { wrapper, edu } = setup((e) => { e.reformatFile = TEX; });
|
||||
await wrapper.vm.$nextTick();
|
||||
await wrapper.find('.rf-remove').trigger('click');
|
||||
expect(edu.reformatFile).toBeNull();
|
||||
expect(wrapper.find('.rf-file-name').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
// tests/frontend/unit/edu/reformat.rules-summary.test.js
|
||||
// 目标期刊规则摘要行(data-testid="rf-rules-summary"):
|
||||
// 1) 有规则字段 + 有 diff → 摘要 + "已修改 N 处"
|
||||
// 2) 规则全 null + diff=0 → "无强制约束" + "样本已满足该刊要求,无需修改"
|
||||
// 背景:GB/BMC PB/Rice 规则相同、Frontiers 几乎无规则,用户切换期刊看不到 diff 差异,
|
||||
// 即使 diff=0 也要展示"该刊要求什么",消除"没生效"错觉。
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import ReformatPanel from '../../../../ui/src/components/edu/panels/ReformatPanel.vue';
|
||||
|
||||
vi.mock('../../../../ui/src/api/review.js', async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
getJournals: vi.fn().mockResolvedValue({ ok: true, items: [] }),
|
||||
getDirectory: vi.fn(),
|
||||
reformatTex: vi.fn(),
|
||||
compileTex: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { useEduStore } from '../../../../ui/src/stores/edu.js';
|
||||
|
||||
function mountWithResult(eduSeed) {
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
const edu = useEduStore();
|
||||
Object.assign(edu, eduSeed);
|
||||
const wrapper = mount(ReformatPanel, { global: { plugins: [pinia] } });
|
||||
return { wrapper, edu };
|
||||
}
|
||||
|
||||
function resultWith(rules, changes) {
|
||||
return {
|
||||
ok: true,
|
||||
job_id: 'abc123def456',
|
||||
modified_tex: 'x',
|
||||
changes,
|
||||
warnings: [],
|
||||
meta: {
|
||||
filename: 'paper.tex',
|
||||
journal_name: 'Molecular Plant',
|
||||
rules_source: 'official',
|
||||
rules_note: 'journals.json 期刊专属规则',
|
||||
rules,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('ReformatPanel 目标期刊规则摘要', () => {
|
||||
beforeEach(() => {
|
||||
global.fetch = vi.fn().mockRejectedValue(new Error('no network'));
|
||||
if (!URL.createObjectURL) URL.createObjectURL = vi.fn(() => 'blob:mock');
|
||||
if (!URL.revokeObjectURL) URL.revokeObjectURL = vi.fn();
|
||||
});
|
||||
|
||||
it('1) 有规则字段 + 有 diff → 摘要列出字段且标注"已修改 N 处"', async () => {
|
||||
// Molecular Plant 真实规则形态:12pt / 1.5 / author_year(font/margin null 不显示)
|
||||
const { wrapper } = mountWithResult({
|
||||
reformatStatus: 'ready',
|
||||
reformatResult: resultWith(
|
||||
{
|
||||
min_font_pt: 12, min_margin_mm: null, min_line_spacing: 1.5,
|
||||
refs_style: 'author_year', max_pages: null, min_figures: null, max_fonts: null,
|
||||
},
|
||||
[
|
||||
{ line: 1, original: 'a', modified: 'b', reason: 'r', rule_key: 'font_pt', action: 'modified' },
|
||||
{ line: 3, original: 'a', modified: 'b', reason: 'r', rule_key: 'line_spacing', action: 'modified' },
|
||||
{ line: 4, original: 'a', modified: 'b', reason: 'r', rule_key: 'bib_style', action: 'modified' },
|
||||
],
|
||||
),
|
||||
});
|
||||
await wrapper.vm.$nextTick();
|
||||
const el = wrapper.find('[data-testid="rf-rules-summary"]');
|
||||
expect(el.exists()).toBe(true);
|
||||
const text = el.text();
|
||||
expect(text).toContain('1.5 倍行距');
|
||||
expect(text).toContain('作者-年份引用');
|
||||
expect(text).toContain('字号 ≥12pt');
|
||||
expect(text).not.toContain('null');
|
||||
expect(text).not.toContain('边距'); // min_margin_mm=null → 不显示
|
||||
expect(text).toContain('已修改 3 处');
|
||||
});
|
||||
|
||||
it('2) 规则全 null + diff=0 → "无强制约束" + "样本已满足该刊要求,无需修改"', async () => {
|
||||
const { wrapper } = mountWithResult({
|
||||
reformatStatus: 'ready',
|
||||
reformatResult: resultWith(
|
||||
{
|
||||
min_font_pt: null, min_margin_mm: null, min_line_spacing: null,
|
||||
refs_style: null, max_pages: null, min_figures: null, max_fonts: null,
|
||||
},
|
||||
[],
|
||||
),
|
||||
});
|
||||
await wrapper.vm.$nextTick();
|
||||
const el = wrapper.find('[data-testid="rf-rules-summary"]');
|
||||
expect(el.exists()).toBe(true);
|
||||
const text = el.text();
|
||||
expect(text).toContain('该刊官网未规定明确格式要求,无强制约束');
|
||||
expect(text).toContain('样本已满足该刊要求,无需修改');
|
||||
expect(text).not.toContain('已修改');
|
||||
});
|
||||
|
||||
it('3) 双倍行距 + Vancouver(GB/BMC/Rice 形态)→ 正确映射中文标签', async () => {
|
||||
const { wrapper } = mountWithResult({
|
||||
reformatStatus: 'ready',
|
||||
reformatResult: resultWith(
|
||||
{
|
||||
min_font_pt: null, min_margin_mm: null, min_line_spacing: 2.0,
|
||||
refs_style: 'vancouver', max_pages: null, min_figures: null, max_fonts: null,
|
||||
},
|
||||
[{ line: 3, original: 'a', modified: 'b', reason: 'r', rule_key: 'line_spacing', action: 'modified' }],
|
||||
),
|
||||
});
|
||||
await wrapper.vm.$nextTick();
|
||||
const text = wrapper.find('[data-testid="rf-rules-summary"]').text();
|
||||
expect(text).toContain('双倍行距');
|
||||
expect(text).toContain('Vancouver 引用');
|
||||
expect(text).toContain('已修改 1 处');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
// tests/frontend/unit/edu/reformat.zip.test.js —— ReformatPanel 项目 zip 两段式(P4)
|
||||
// 1) 默认 .tex 模式;切"项目 .zip"→ zip 上传区 + 打包建议文案 + inspect 按钮
|
||||
// 2) 选 .zip → inspect 成功 → 摘要(main/统计/跳过)+ 编译按钮
|
||||
// 3) inspect 失败(ZIP_UNSAFE 后端原话)→ 错误展示
|
||||
// 4) 点编译 → loading → 成功:iframe 预览 + 耗时
|
||||
// 5) 编译失败:错误摘要 + log 折叠区 + 查看完整 log(失败信封带 log_url)
|
||||
// 6) 模式互斥:切回 .tex 清 zip 状态
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import ReformatPanel from '../../../../ui/src/components/edu/panels/ReformatPanel.vue';
|
||||
|
||||
const inspectZipMock = vi.fn();
|
||||
const runZipCompileMock = vi.fn();
|
||||
vi.mock('../../../../ui/src/api/review.js', async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
getJournals: vi.fn().mockResolvedValue({ ok: true, items: [] }),
|
||||
getDirectory: vi.fn(),
|
||||
reformatTex: vi.fn(),
|
||||
compileTex: vi.fn(),
|
||||
inspectZip: (...args) => inspectZipMock(...args),
|
||||
runZipCompile: (...args) => runZipCompileMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
import { useEduStore } from '../../../../ui/src/stores/edu.js';
|
||||
import { ApiError } from '../../../../ui/src/api/http.js';
|
||||
|
||||
const INSPECT_OK = {
|
||||
ok: true,
|
||||
meta: {
|
||||
job_id: 'abc123def456',
|
||||
main: 'main.tex',
|
||||
stats: { files: 5, tex: 1, images: 1, cls: 1, bib: 1, other: 1, bytes: 4096 },
|
||||
skipped: [{ name: '__MACOSX/._main.tex', reason: 'skip_dir' }],
|
||||
skipped_count: 1,
|
||||
created_at: '2026-10-13T00:00:00+00:00',
|
||||
},
|
||||
};
|
||||
|
||||
function zipFile(name = 'project.zip') {
|
||||
return new File([new Uint8Array([0x50, 0x4b, 0x03, 0x04])], name, {
|
||||
type: 'application/zip',
|
||||
});
|
||||
}
|
||||
|
||||
async function mountZipPanel(edu) {
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
edu = useEduStore();
|
||||
const wrapper = mount(ReformatPanel, { global: { plugins: [pinia] } });
|
||||
await wrapper.find('[data-testid="rf-mode-zip"]').trigger('click');
|
||||
return { wrapper, edu };
|
||||
}
|
||||
|
||||
async function pickZip(wrapper, edu, file = zipFile()) {
|
||||
const input = wrapper.find('input[type="file"]');
|
||||
Object.defineProperty(input.element, 'files', { value: [file], configurable: true });
|
||||
await input.trigger('change');
|
||||
await wrapper.vm.$nextTick();
|
||||
}
|
||||
|
||||
describe('ReformatPanel 项目 zip 两段式(P4)', () => {
|
||||
beforeEach(() => {
|
||||
inspectZipMock.mockReset();
|
||||
runZipCompileMock.mockReset();
|
||||
if (!URL.createObjectURL) URL.createObjectURL = vi.fn(() => 'blob:mock');
|
||||
if (!URL.revokeObjectURL) URL.revokeObjectURL = vi.fn();
|
||||
global.fetch = vi.fn().mockRejectedValue(new Error('no network'));
|
||||
});
|
||||
|
||||
it('1) 默认 .tex 模式;切 zip → zip 上传区 + 打包建议 + inspect 按钮', async () => {
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
const wrapper = mount(ReformatPanel, { global: { plugins: [pinia] } });
|
||||
expect(wrapper.find('[data-testid="rf-mode-tex"]').classes()).toContain('active');
|
||||
expect(wrapper.find('[data-testid="rf-zip-inspect-btn"]').exists()).toBe(false);
|
||||
// tex 提交按钮仍在
|
||||
expect(wrapper.findAll('button').some((b) => b.text() === '开始自动重排版')).toBe(true);
|
||||
|
||||
await wrapper.find('[data-testid="rf-mode-zip"]').trigger('click');
|
||||
const zipBtn = wrapper.find('[data-testid="rf-zip-inspect-btn"]');
|
||||
expect(zipBtn.exists()).toBe(true);
|
||||
expect(zipBtn.attributes('disabled')).toBeDefined(); // 未选文件不可点
|
||||
expect(wrapper.text()).toContain('打包建议:main.tex 放 zip 根目录');
|
||||
// tex 提交按钮消失(互斥)
|
||||
expect(wrapper.findAll('button').some((b) => b.text() === '开始自动重排版')).toBe(false);
|
||||
});
|
||||
|
||||
it('2) 选 .zip → inspect 成功 → 摘要 + 编译按钮', async () => {
|
||||
inspectZipMock.mockResolvedValue(INSPECT_OK);
|
||||
const { wrapper, edu } = await mountZipPanel(null);
|
||||
await pickZip(wrapper, edu);
|
||||
await wrapper.find('[data-testid="rf-zip-inspect-btn"]').trigger('click');
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.find('[data-testid="rf-zip-meta"]').exists()).toBe(true);
|
||||
});
|
||||
expect(inspectZipMock).toHaveBeenCalledTimes(1);
|
||||
const meta = wrapper.find('[data-testid="rf-zip-meta"]');
|
||||
expect(meta.text()).toContain('main.tex');
|
||||
expect(meta.text()).toContain('文件 5 个');
|
||||
expect(meta.text()).toContain('1 个文件已跳过');
|
||||
expect(wrapper.find('[data-testid="rf-zip-skipped"]').text()).toContain('__MACOSX');
|
||||
expect(wrapper.find('[data-testid="rf-zip-compile-btn"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('3) inspect 失败(ZIP_UNSAFE)→ 错误展示', async () => {
|
||||
inspectZipMock.mockRejectedValue(
|
||||
new ApiError('zip 含危险条目:evil.sh', { code: 'ZIP_UNSAFE', status: 200 }),
|
||||
);
|
||||
const { wrapper, edu } = await mountZipPanel(null);
|
||||
await pickZip(wrapper, edu);
|
||||
await wrapper.find('[data-testid="rf-zip-inspect-btn"]').trigger('click');
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.find('[data-testid="rf-zip-error"]').exists()).toBe(true);
|
||||
});
|
||||
expect(wrapper.find('[data-testid="rf-zip-error"]').text()).toContain('evil.sh');
|
||||
expect(wrapper.find('[data-testid="rf-zip-meta"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('4) 编译 → loading → 成功:iframe 预览 + 耗时', async () => {
|
||||
inspectZipMock.mockResolvedValue(INSPECT_OK);
|
||||
let resolveRun;
|
||||
runZipCompileMock.mockReturnValue(new Promise((r) => { resolveRun = r; }));
|
||||
const { wrapper, edu } = await mountZipPanel(null);
|
||||
await pickZip(wrapper, edu);
|
||||
await wrapper.find('[data-testid="rf-zip-inspect-btn"]').trigger('click');
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.find('[data-testid="rf-zip-compile-btn"]').exists()).toBe(true);
|
||||
});
|
||||
await wrapper.find('[data-testid="rf-zip-compile-btn"]').trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.find('[data-testid="rf-compile-loading"]').exists()).toBe(true);
|
||||
|
||||
resolveRun({
|
||||
ok: true,
|
||||
compile_id: 'aabbccddeeff',
|
||||
pdf_url: '/api/review/compile/zip/aabbccddeeff/pdf',
|
||||
log_url: '/api/review/compile/zip/aabbccddeeff/log',
|
||||
meta: { seconds: 3.2, pdf_bytes: 2048, project: true },
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.find('[data-testid="rf-pdf-frame"]').exists()).toBe(true);
|
||||
});
|
||||
expect(wrapper.find('[data-testid="rf-compile-secs"]').text()).toContain('3.2 秒');
|
||||
expect(wrapper.find('[data-testid="rf-download-pdf"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('5) 编译失败:错误摘要 + log 折叠区 + 查看完整 log', async () => {
|
||||
inspectZipMock.mockResolvedValue(INSPECT_OK);
|
||||
const compileErr = new ApiError('LaTeX 源码有语法错误', {
|
||||
code: 'COMPILE_FAILED', status: 200,
|
||||
});
|
||||
compileErr.detail = 'l.12 \\badcmd'; // unwrap 从 error.detail 挂载(log 尾)
|
||||
runZipCompileMock.mockRejectedValue(compileErr);
|
||||
const { wrapper, edu } = await mountZipPanel(null);
|
||||
await pickZip(wrapper, edu);
|
||||
await wrapper.find('[data-testid="rf-zip-inspect-btn"]').trigger('click');
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.find('[data-testid="rf-zip-compile-btn"]').exists()).toBe(true);
|
||||
});
|
||||
await wrapper.find('[data-testid="rf-zip-compile-btn"]').trigger('click');
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.find('[data-testid="rf-compile-error"]').exists()).toBe(true);
|
||||
});
|
||||
expect(wrapper.find('[data-testid="rf-compile-error"]').text()).toContain('语法错误');
|
||||
expect(wrapper.find('[data-testid="rf-log-body"]').text()).toContain('l.12');
|
||||
expect(wrapper.find('[data-testid="rf-download-log"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('6) 模式互斥:切回 .tex 清 zip 状态(inspect 摘要消失)', async () => {
|
||||
inspectZipMock.mockResolvedValue(INSPECT_OK);
|
||||
const { wrapper, edu } = await mountZipPanel(null);
|
||||
await pickZip(wrapper, edu);
|
||||
await wrapper.find('[data-testid="rf-zip-inspect-btn"]').trigger('click');
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.find('[data-testid="rf-zip-meta"]').exists()).toBe(true);
|
||||
});
|
||||
await wrapper.find('[data-testid="rf-mode-tex"]').trigger('click');
|
||||
expect(wrapper.find('[data-testid="rf-zip-meta"]').exists()).toBe(false);
|
||||
expect(edu.zipInspect).toBeNull();
|
||||
expect(edu.zipFile).toBeNull();
|
||||
// tex 上传区回来了
|
||||
expect(wrapper.findAll('button').some((b) => b.text() === '开始自动重排版')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -77,6 +77,7 @@ orjson==3.11.9
|
||||
ormsgpack==1.12.2
|
||||
packaging==26.3
|
||||
pandas==3.0.5
|
||||
pillow==12.3.0
|
||||
pluggy==1.6.0
|
||||
portalocker==3.2.0
|
||||
propcache==0.5.2
|
||||
@@ -95,6 +96,7 @@ python-dateutil==2.9.0.post0
|
||||
python-docx==1.1.2
|
||||
python-dotenv==1.2.3
|
||||
python-multipart==0.0.32
|
||||
python-pptx==1.0.2
|
||||
pytz==2026.3.post1
|
||||
PyYAML==6.0.3
|
||||
qdrant-client==1.18.0
|
||||
@@ -137,6 +139,7 @@ watchfiles==1.2.0
|
||||
websockets==16.0
|
||||
wheel==0.47.0
|
||||
xlrd==2.0.1
|
||||
xlsxwriter==3.2.9
|
||||
xxhash==3.7.0
|
||||
yarl==1.24.2
|
||||
zstandard==0.25.0
|
||||
|
||||
@@ -19,6 +19,9 @@ function unwrap(promise) {
|
||||
degraded: !!payload.error.degraded,
|
||||
});
|
||||
if (payload.error.detail) err.detail = payload.error.detail; // 编译失败的 log 尾等
|
||||
// P4:项目编译失败信封带 compile_id + log_url(补充 3,前端"查看完整 log"用)
|
||||
if (payload.compile_id) err.compileId = payload.compile_id;
|
||||
if (payload.log_url) err.logUrl = payload.log_url;
|
||||
throw err;
|
||||
}
|
||||
return payload;
|
||||
@@ -104,6 +107,23 @@ export function compileTex(formData, opts = {}) {
|
||||
return unwrap(post(`${BASE}/compile`, formData, opts));
|
||||
}
|
||||
|
||||
/** P4 POST /api/review/compile/inspect(multipart .zip ≤50MB,两段式之①)
|
||||
* → {ok, meta:{job_id, main, stats:{files,tex,images,cls,bib,other,bytes},
|
||||
* skipped:[{name,reason}], skipped_count, created_at}}
|
||||
* 错误信封:VALIDATION / ZIP_UNSAFE / ZIP_BOMB / ZIP_INVALID(HTTP 200)。
|
||||
* job_id 1 小时 TTL;run 前不占编译队列。 */
|
||||
export function inspectZip(formData, opts = {}) {
|
||||
return unwrap(post(`${BASE}/compile/inspect`, formData, opts));
|
||||
}
|
||||
|
||||
/** P4 POST /api/review/compile/run(JSON {job_id},两段式之②)
|
||||
* → 与 compileTex 同构信封 {ok, compile_id, pdf_url, log_url, meta};
|
||||
* pdf/log 路径带 /zip/ 前缀。失败(COMPILE_FAILED/TIMEOUT)也带 compile_id+log_url。
|
||||
* QUEUE_FULL → HTTP 429(此时 job 未消费,可直接重试)。 */
|
||||
export function runZipCompile(payload, opts = {}) {
|
||||
return unwrap(post(`${BASE}/compile/run`, payload, opts));
|
||||
}
|
||||
|
||||
/** ⑤ format-check 专用错误文案:解档/校验类 PARSE_FAILED/VALIDATION 用后端原话
|
||||
* (与 presubmit 的"扫描件"提示是不同语境,不能复用 CODE_HINTS),其余走统一口径。 */
|
||||
export function formatErrorMessage(err) {
|
||||
@@ -119,9 +139,11 @@ const CODE_HINTS = {
|
||||
VALIDATION: null,
|
||||
PARSE_FAILED: '论文解析失败:可能是扫描件/图片型 PDF,请换文本型 PDF。',
|
||||
UPSTREAM_DOWN: '审稿模型暂时不可用,请稍后重试。',
|
||||
SEED_UNAVAILABLE: '期刊库未就绪(种子数据缺失),请联系管理员。',
|
||||
SEED_UNAVAILABLE: '期刊库就绪中(种子数据缺失),请稍后再试。',
|
||||
TIMEOUT: '请求超时,请稍后重试。',
|
||||
NETWORK: '网络异常,请检查连接后重试。',
|
||||
// P3b 编译:固定口径(后端 message 带动态 pending 数,此处按规范统一为"最多 5 个")
|
||||
QUEUE_FULL: '编译队列已满(最多 5 个任务),请稍后重试',
|
||||
};
|
||||
|
||||
/** ApiError(或任意错误)→ 用户可读文案。degraded 由调用方读 err.degraded 走降级横幅样式。 */
|
||||
@@ -201,3 +223,53 @@ export async function rebuttalDownloadLetterMd(id) {
|
||||
export function rebuttalDeleteJob(id, opts = {}) {
|
||||
return unwrap(del(`${RB}/jobs/${id}`, opts));
|
||||
}
|
||||
|
||||
/* ==================== P5 科研汇报助手(/api/review/ppt,SQLite job 模式) ==================== */
|
||||
const PPT = `${BASE}/ppt`;
|
||||
|
||||
/** GET /ppt/styles → {ok, items:[{id,label,display_name,description,bg,accent}], meta:{count}}
|
||||
* 风格清单来自 review_service/data/ppt_styles/*.json(数量动态,面板副标题不再写死 8/14) */
|
||||
export function pptStyles(opts = {}) {
|
||||
return unwrap(get(`${PPT}/styles`, {}, opts));
|
||||
}
|
||||
|
||||
/** POST /ppt/jobs {text,scenario,minutes,style_id} → {ok, job, meta}
|
||||
* 文本粘贴主入口(map-reduce 大纲生成为长调用:BFF 360s / http.js 默认 600s) */
|
||||
export function pptCreateJob(body, opts = {}) {
|
||||
return unwrap(post(`${PPT}/jobs`, body, opts));
|
||||
}
|
||||
|
||||
/** POST /ppt/jobs/pdf(multipart:file + scenario/minutes/style_id form 字段)→ 同上 */
|
||||
export function pptCreateFromPdf(formData, opts = {}) {
|
||||
return unwrap(post(`${PPT}/jobs/pdf`, formData, opts));
|
||||
}
|
||||
|
||||
/** GET /ppt/jobs/{id} → {ok, job} 全量还原(raw_text 不回传,source.text_chars 代之) */
|
||||
export function pptGetJob(id, opts = {}) {
|
||||
return unwrap(get(`${PPT}/jobs/${id}`, {}, opts));
|
||||
}
|
||||
|
||||
/** PATCH /ppt/jobs/{id} {pages} → {ok, job}(结构清洗 + evidence 状态按原文重验) */
|
||||
export function pptSavePages(id, pages, opts = {}) {
|
||||
return unwrap(patch(`${PPT}/jobs/${id}`, { pages }, opts));
|
||||
}
|
||||
|
||||
/** POST /ppt/jobs/{id}/build → {ok, job, meta:{slides,bytes,previews}}(python-pptx 本地生成 + 缩略图) */
|
||||
export function pptBuildJob(id, opts = {}) {
|
||||
return unwrap(post(`${PPT}/jobs/${id}/build`, {}, opts));
|
||||
}
|
||||
|
||||
/** DELETE /ppt/jobs/{id} → {ok, meta:{deleted}} 手动清行 + 产物目录(无 TTL) */
|
||||
export function pptDeleteJob(id, opts = {}) {
|
||||
return unwrap(del(`${PPT}/jobs/${id}`, opts));
|
||||
}
|
||||
|
||||
/** pptx 下载直链(<a href>;FileResponse 附件下载,非信封,缺失时回信封 JSON) */
|
||||
export function pptDownloadUrl(id) {
|
||||
return `${PPT}/jobs/${id}/pptx`;
|
||||
}
|
||||
|
||||
/** 第 page 页缩略图直链(<img src>;PNG。缺失时回信封 JSON → img onerror 隐藏) */
|
||||
export function pptPreviewUrl(id, page) {
|
||||
return `${PPT}/jobs/${id}/preview/${page}`;
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 22 KiB |
@@ -19,6 +19,8 @@ const ITEMS = [
|
||||
// SVG 内联(U+1F4AC/U+2709 在用户 Ubuntu 浏览器均无字形 → 方框;SVG 不依赖字体)
|
||||
{ key: 'rebuttal', label: '审稿回复助手', icon: '<svg viewBox="0 0 16 16" width="15" height="15" fill="currentColor"><path d="M2 2h12v9H6l-4 3z"/></svg>' },
|
||||
{ key: 'calendar', label: '会议与投稿日历', icon: '📅' },
|
||||
// P5 科研汇报(幻灯片图标纯 SVG,同上不依赖字体)
|
||||
{ key: 'ppt', label: '科研汇报助手', icon: '<svg viewBox="0 0 16 16" width="15" height="15" fill="currentColor"><path d="M2 2h12v8H2V2zm1 2v4h10V4H3zm2 6h4l1 3H4l1-3z"/></svg>' },
|
||||
];
|
||||
</script>
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import FormatPanel from './panels/FormatPanel.vue';
|
||||
import ReformatPanel from './panels/ReformatPanel.vue';
|
||||
import CalendarPanel from './panels/CalendarPanel.vue';
|
||||
import RebuttalPanel from './panels/RebuttalPanel.vue';
|
||||
import PptPanel from './panels/PptPanel.vue';
|
||||
|
||||
const edu = useEduStore();
|
||||
|
||||
@@ -28,6 +29,7 @@ const panelMap = {
|
||||
reformat: ReformatPanel,
|
||||
calendar: CalendarPanel,
|
||||
rebuttal: RebuttalPanel,
|
||||
ppt: PptPanel,
|
||||
};
|
||||
|
||||
const currentPanel = computed(() => panelMap[edu.section] || JournalsPanel);
|
||||
|
||||
@@ -0,0 +1,578 @@
|
||||
<!-- PptPanel.vue —— 科研汇报助手(P5,/api/review/ppt job 模式)。
|
||||
流程:① 选 PDF/粘贴文本 + 场景/时长/风格 → 生成大纲(同步长调用)
|
||||
→ ② 逐页编辑(bullets/讲稿;evidence 徽标只读展示)→ 保存(重验)
|
||||
→ ③ 生成 PPTX(python-pptx 本地生成,多风格版式 + 逐页缩略图预览)
|
||||
→ 下载;可删除/重新生成。
|
||||
状态机:edu.ppt.status = idle|loading|editing|building|done|error;
|
||||
本地 localPages 副本 + dirty 标记,保存后以后端返回为权威(evidence 重验)。
|
||||
预览:build 返回 meta.previews → 缩略图条 + 大图(preview/{page}.png 直链);
|
||||
旧响应无 previews / 编辑后 pptx 失效 → 区块隐藏。 -->
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useEduStore } from '../../../stores/edu.js';
|
||||
import { pptDownloadUrl, pptPreviewUrl } from '../../../api/review.js';
|
||||
import './ppt-styles.css';
|
||||
|
||||
const edu = useEduStore();
|
||||
const ppt = computed(() => edu.ppt);
|
||||
const job = computed(() => edu.ppt.job);
|
||||
|
||||
const SCENARIOS = [
|
||||
{ key: 'group_meeting', label: '组会汇报' },
|
||||
{ key: 'proposal', label: '开题报告' },
|
||||
{ key: 'conclusion_report', label: '结题汇报' },
|
||||
{ key: 'defense', label: '学位答辩' },
|
||||
{ key: 'conference_talk', label: '学术会议' },
|
||||
];
|
||||
const MINUTES = [
|
||||
{ v: 10, pages: '8-10 页' },
|
||||
{ v: 15, pages: '11-14 页' },
|
||||
{ v: 20, pages: '15-18 页' },
|
||||
{ v: 30, pages: '22-26 页' },
|
||||
];
|
||||
const STYLES = [
|
||||
{ id: 'academic-minimal', label: '学术极简', desc: '白底蓝黑 · 投屏通用' },
|
||||
{ id: 'botanic-forest', label: '植研绿林', desc: '植物绿系 · 所内汇报' },
|
||||
{ id: 'classic-defense', label: '经典答辩', desc: '深蓝衬线 · 正式答辩' },
|
||||
{ id: 'academic-blue', label: '学术蓝', desc: '经典蓝白 · 答辩常用' },
|
||||
{ id: 'nature-green', label: '自然绿', desc: '自然生态绿 · 植物/生态领域' },
|
||||
{ id: 'tech-dark', label: '深色科技', desc: '深色投影 · AI/工程领域' },
|
||||
{ id: 'monochrome', label: '极简黑白', desc: '黑白灰 · 正式严肃' },
|
||||
{ id: 'warm-earth', label: '暖色土系', desc: '暖土色系 · 农业领域' },
|
||||
{ id: 'domestic', label: '国内组会', desc: '深蓝红强调 · 国内组会 / 学术报告' },
|
||||
{ id: 'international', label: '国际会议', desc: '白底红点 · 海外组会 / 国际会议' },
|
||||
{ id: 'systems-talk', label: '系统顶会', desc: '深蓝橙标 · 系统顶会报告' },
|
||||
{ id: 'theory-beamer', label: '理论报告', desc: '灰绿橙点 · Metropolis 理论风' },
|
||||
{ id: 'jp-gothic', label: '日韩发表', desc: '白底蓝黄贴纸 · 日韩技术发表' },
|
||||
{ id: 'editorial', label: '杂志插画', desc: '米底紫衬线 · 插画杂志风' },
|
||||
];
|
||||
const TYPE_LABELS = {
|
||||
title: '封面', outline: '目录', section: '分隔', background: '背景', method: '方法',
|
||||
result: '结果', conclusion: '结论', reference: '参考文献',
|
||||
};
|
||||
|
||||
/** 风格卡封面(assets/ppt-style-cards/*.png:真实 build+PIL 渲染的各风格封面) */
|
||||
function styleCover(id) {
|
||||
return new URL(`../../../assets/ppt-style-cards/${id}.png`, import.meta.url).href;
|
||||
}
|
||||
|
||||
/* —— 改进 1:副标题风格数动态(后端 data/ppt_styles 实际数量,不再写死 8/14) —— */
|
||||
onMounted(() => {
|
||||
edu.pptLoadStyles();
|
||||
});
|
||||
|
||||
/** 副标题括号文案:有清单 →「{n} 风格·图文混排」;接口未取到 →「多风格·图文混排」 */
|
||||
const styleTag = computed(() => {
|
||||
const items = edu.pptStyles;
|
||||
const n = Array.isArray(items) ? items.length : 0;
|
||||
return n > 0 ? `${n} 风格·图文混排` : '多风格·图文混排';
|
||||
});
|
||||
|
||||
/** 风格卡数据:优先后端清单(与副标题数量同源),未取到回落本地 STYLES 常量 */
|
||||
const styleCards = computed(() => {
|
||||
const items = Array.isArray(edu.pptStyles) ? edu.pptStyles : [];
|
||||
if (!items.length) return STYLES;
|
||||
return items.map((s) => ({
|
||||
id: s.id,
|
||||
label: s.label || s.display_name || s.id,
|
||||
desc: s.description || '',
|
||||
}));
|
||||
});
|
||||
|
||||
/* —— 本地编辑副本(后端 job 为权威;生成/保存/构建后重新同步) —— */
|
||||
const localPages = ref([]);
|
||||
const dirty = ref(false);
|
||||
|
||||
watch(
|
||||
job,
|
||||
(j) => {
|
||||
const pages = j ? JSON.parse(JSON.stringify(j.pages || [])) : [];
|
||||
pages.forEach((p) => {
|
||||
p.bullets = p.bullets || [];
|
||||
p.evidence = p.evidence || [];
|
||||
while (p.evidence.length < p.bullets.length) p.evidence.push(null);
|
||||
p.evidence.length = p.bullets.length;
|
||||
p.suggested_figures = p.suggested_figures || [];
|
||||
});
|
||||
localPages.value = pages;
|
||||
dirty.value = false;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function touch() {
|
||||
dirty.value = true;
|
||||
}
|
||||
|
||||
function evOf(page, i) {
|
||||
return (page.evidence && page.evidence[i]) || null;
|
||||
}
|
||||
function badgeClass(page, i) {
|
||||
const ev = evOf(page, i);
|
||||
if (!ev) return 'ev-none';
|
||||
return ev.status === 'verified' ? 'ev-ok' : 'ev-bad';
|
||||
}
|
||||
function badgeTitle(page, i) {
|
||||
const ev = evOf(page, i);
|
||||
if (!ev) return '无来源';
|
||||
const q = String(ev.quote || '').slice(0, 80);
|
||||
return `${ev.status === 'verified' ? '已核实' : '未核实'} ${ev.para_id || ''}:${q}`;
|
||||
}
|
||||
function removeBullet(pi, bi) {
|
||||
const p = localPages.value[pi];
|
||||
p.bullets.splice(bi, 1);
|
||||
if (p.evidence) p.evidence.splice(bi, 1);
|
||||
touch();
|
||||
}
|
||||
function addBullet(pi) {
|
||||
const p = localPages.value[pi];
|
||||
p.bullets.push('');
|
||||
p.evidence = p.evidence || [];
|
||||
p.evidence.push(null);
|
||||
touch();
|
||||
}
|
||||
// 分隔页副标题:后端 coerce 以 bullets[0] 优先、subtitle 兜底,
|
||||
// 编辑器同写两字段保持一致;清空 → 回落该章第一页标题
|
||||
function onSectionSubtitle(pi, e) {
|
||||
const p = localPages.value[pi];
|
||||
const v = e.target.value;
|
||||
p.bullets = v ? [v] : [];
|
||||
p.subtitle = v;
|
||||
touch();
|
||||
}
|
||||
|
||||
/* —— 动作 —— */
|
||||
async function onGenerate() {
|
||||
await edu.pptGenerate();
|
||||
}
|
||||
async function onSave() {
|
||||
const saved = await edu.pptSave(JSON.parse(JSON.stringify(localPages.value)));
|
||||
if (saved) dirty.value = false;
|
||||
return !!saved;
|
||||
}
|
||||
async function onBuild() {
|
||||
if (dirty.value) {
|
||||
const saved = await onSave();
|
||||
if (!saved) return;
|
||||
}
|
||||
await edu.pptBuild();
|
||||
}
|
||||
function onFileChange(e) {
|
||||
edu.ppt.form.file = (e.target.files && e.target.files[0]) || null;
|
||||
}
|
||||
function onReset() {
|
||||
if (dirty.value && !window.confirm('有未保存的修改,放弃并重新生成?')) return;
|
||||
edu.clearPpt();
|
||||
}
|
||||
async function onDelete() {
|
||||
if (!window.confirm('删除该任务(大纲与 PPTX 一并清除)?')) return;
|
||||
await edu.pptDelete();
|
||||
}
|
||||
|
||||
const isBusy = computed(() => ['loading', 'building'].includes(ppt.value.status));
|
||||
const statusLabel = computed(() => ({
|
||||
idle: '待生成', loading: '生成大纲中…', editing: '编辑中',
|
||||
building: '生成 PPT 中…', done: '已生成', error: '出错',
|
||||
}[ppt.value.status] || ppt.value.status));
|
||||
const pageTarget = computed(() => {
|
||||
const m = MINUTES.find((x) => x.v === ppt.value.form.minutes);
|
||||
return m ? m.pages : '';
|
||||
});
|
||||
|
||||
/* —— 可感知化(3.x):meta 露出 + 逐页缩略图预览 —— */
|
||||
/** createMeta.figures_extracted(PDF 原图提取数;text 模式无此键 → 0) */
|
||||
const figsExtracted = computed(() => {
|
||||
const n = ppt.value.createMeta && ppt.value.createMeta.figures_extracted;
|
||||
return typeof n === 'number' ? n : 0;
|
||||
});
|
||||
/** evidence 核实比例(本地副本实时算,保存/重验后自动刷新) */
|
||||
const evStats = computed(() => {
|
||||
let ok = 0;
|
||||
let total = 0;
|
||||
for (const p of localPages.value) {
|
||||
for (const ev of p.evidence || []) {
|
||||
if (!ev) continue;
|
||||
total += 1;
|
||||
if (ev.status === 'verified') ok += 1;
|
||||
}
|
||||
}
|
||||
return { ok, total };
|
||||
});
|
||||
/** 缩略图条:仅最近一次 build 返回了 previews 且 pptx 就绪才渲染 */
|
||||
const previewCount = computed(() => {
|
||||
const m = ppt.value.buildMeta;
|
||||
return job.value && job.value.pptx_ready && m && m.previews > 0 ? m.previews : 0;
|
||||
});
|
||||
const activePreview = ref(1);
|
||||
const failedPreviews = ref(new Set());
|
||||
const previewPages = computed(() => {
|
||||
const out = [];
|
||||
for (let i = 1; i <= previewCount.value; i += 1) {
|
||||
if (!failedPreviews.value.has(i)) out.push(i);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
function onPreviewError(page) {
|
||||
failedPreviews.value = new Set(failedPreviews.value).add(page);
|
||||
if (activePreview.value === page) {
|
||||
activePreview.value = previewPages.value[0] || 1;
|
||||
}
|
||||
}
|
||||
function previewSrc(page) {
|
||||
return pptPreviewUrl(job.value.id, page);
|
||||
}
|
||||
// job/重建变化 → 回到第 1 页、清失败缓存(新 build 会覆盖同名 PNG)
|
||||
watch(
|
||||
() => [job.value && job.value.id, ppt.value.buildMeta],
|
||||
() => {
|
||||
activePreview.value = 1;
|
||||
failedPreviews.value = new Set();
|
||||
},
|
||||
{ deep: false },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="ppt-root">
|
||||
<header class="ppt-head">
|
||||
<div>
|
||||
<h2 class="ppt-title">科研汇报助手</h2>
|
||||
<p class="ppt-sub">论文 PDF → 证据可溯的大纲 → 逐页编辑 → PPTX 初稿 + 逐页页面预览({{ styleTag }})</p>
|
||||
</div>
|
||||
<span v-if="job" class="ppt-status" :class="`st-${ppt.status}`" data-test="status-chip">
|
||||
{{ statusLabel }}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<!-- ============ ① 生成表单(无 job) ============ -->
|
||||
<div v-if="!job" class="ppt-form" data-test="ppt-form">
|
||||
<div class="ppt-field">
|
||||
<label class="ppt-label">输入方式</label>
|
||||
<div class="ppt-seg">
|
||||
<button type="button" :class="{ on: ppt.form.mode === 'pdf' }"
|
||||
data-test="mode-pdf" @click="ppt.form.mode = 'pdf'">上传 PDF</button>
|
||||
<button type="button" :class="{ on: ppt.form.mode === 'text' }"
|
||||
data-test="mode-text" @click="ppt.form.mode = 'text'">粘贴文本</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="ppt.form.mode === 'pdf'" class="ppt-field">
|
||||
<label class="ppt-label">论文 PDF(vstore 解析,≤50MB)</label>
|
||||
<input type="file" accept=".pdf,application/pdf" data-test="file-input" @change="onFileChange" />
|
||||
<span v-if="ppt.form.file" class="ppt-file">{{ ppt.form.file.name }}</span>
|
||||
</div>
|
||||
<div v-else class="ppt-field">
|
||||
<label class="ppt-label">论文正文(≥300 字)</label>
|
||||
<textarea v-model="ppt.form.text" rows="6" data-test="text-input"
|
||||
placeholder="粘贴论文纯文本(标题/摘要/正文/参考文献)" />
|
||||
</div>
|
||||
|
||||
<div class="ppt-field">
|
||||
<label class="ppt-label">汇报场景</label>
|
||||
<select v-model="ppt.form.scenario" data-test="scenario-select">
|
||||
<option v-for="s in SCENARIOS" :key="s.key" :value="s.key">{{ s.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="ppt-field">
|
||||
<label class="ppt-label">汇报时长</label>
|
||||
<div class="ppt-seg">
|
||||
<button v-for="m in MINUTES" :key="m.v" type="button"
|
||||
:class="{ on: ppt.form.minutes === m.v }"
|
||||
:data-test="`minutes-${m.v}`"
|
||||
@click="ppt.form.minutes = m.v">
|
||||
{{ m.v }} 分钟<span class="min-pages">{{ m.pages }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ppt-field">
|
||||
<label class="ppt-label">风格模板(卡面为该风格真实版式封面)</label>
|
||||
<div class="ppt-styles">
|
||||
<button v-for="s in styleCards" :key="s.id" type="button"
|
||||
class="ppt-style-card" :class="[s.id, { on: ppt.form.styleId === s.id }]"
|
||||
:data-test="`style-${s.id}`" @click="ppt.form.styleId = s.id">
|
||||
<img class="ppt-style-cover" :src="styleCover(s.id)" :alt="`${s.label} 版式封面`"
|
||||
:data-test="`cover-${s.id}`" />
|
||||
<span class="ppt-style-meta">
|
||||
<span class="ppt-style-name">{{ s.label }}</span>
|
||||
<span class="ppt-style-desc">{{ s.desc }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="ppt.status === 'error' && ppt.error" class="ppt-error" data-test="form-error">
|
||||
{{ ppt.error }}
|
||||
</p>
|
||||
|
||||
<div class="ppt-actions">
|
||||
<button type="button" class="ppt-primary" data-test="generate"
|
||||
:disabled="isBusy" @click="onGenerate">
|
||||
{{ ppt.status === 'loading' ? '大纲生成中(约 1-3 分钟)…' : '生成大纲' }}
|
||||
</button>
|
||||
<span class="ppt-hint">目标 {{ pageTarget }}(超上限自动裁剪,不足不凑页)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ ② 编辑器(有 job) ============ -->
|
||||
<div v-else class="ppt-editor" data-test="ppt-editor">
|
||||
<div class="ppt-toolbar">
|
||||
<span class="ppt-chip">{{ job.scenario }} · {{ job.minutes }} 分钟</span>
|
||||
<span class="ppt-chip">{{ job.style_id }}</span>
|
||||
<span class="ppt-chip" data-test="page-count">{{ localPages.length }} 页</span>
|
||||
<span v-if="ppt.createMeta && ppt.createMeta.target_pages" class="ppt-chip">
|
||||
目标 {{ ppt.createMeta.target_pages[0] }}-{{ ppt.createMeta.target_pages[1] }} 页
|
||||
</span>
|
||||
<span v-if="figsExtracted" class="ppt-chip" data-test="figs-chip">
|
||||
提取原图 {{ figsExtracted }} 张
|
||||
</span>
|
||||
<span v-if="evStats.total" class="ppt-chip" data-test="ev-stats">
|
||||
证据 {{ evStats.ok }}/{{ evStats.total }} 已核实
|
||||
</span>
|
||||
<span class="ppt-spacer" />
|
||||
<button type="button" data-test="save" :disabled="!dirty || ppt.saving"
|
||||
:title="dirty ? '保存编辑' : '无修改'" @click="onSave">
|
||||
{{ ppt.saving ? '保存中…' : dirty ? '保存' : '已保存' }}
|
||||
</button>
|
||||
<button type="button" class="ppt-primary" data-test="build"
|
||||
:disabled="isBusy" @click="onBuild">
|
||||
{{ ppt.status === 'building' ? '生成中…' : '生成 PPT' }}
|
||||
</button>
|
||||
<a v-if="job.pptx_ready" class="ppt-dl" data-test="download"
|
||||
:href="pptDownloadUrl(job.id)" download>下载 PPTX</a>
|
||||
<button type="button" data-test="regenerate" :disabled="isBusy" @click="onReset">重新生成</button>
|
||||
<button type="button" class="ppt-danger" data-test="delete" :disabled="isBusy" @click="onDelete">删除</button>
|
||||
</div>
|
||||
|
||||
<p v-if="ppt.error && (ppt.status === 'error' || ppt.saving)" class="ppt-error" data-test="editor-error">
|
||||
{{ ppt.error }}
|
||||
</p>
|
||||
<p v-if="dirty" class="ppt-dirty" data-test="dirty-hint">有未保存修改(生成 PPT 前会自动保存)</p>
|
||||
|
||||
<details v-if="job.warnings && job.warnings.length" class="ppt-warnings" data-test="warnings" open>
|
||||
<summary>生成提示({{ job.warnings.length }})</summary>
|
||||
<ul>
|
||||
<li v-for="(w, i) in job.warnings" :key="i">{{ w }}</li>
|
||||
</ul>
|
||||
</details>
|
||||
|
||||
<!-- 3.x 页面预览:build 后逐页缩略图(preview/{page}.png 直链,失败单张隐藏) -->
|
||||
<div v-if="previewCount" class="ppt-previews" data-test="preview-wrap">
|
||||
<div class="ppt-preview-main-wrap">
|
||||
<img class="ppt-preview-main" :src="previewSrc(activePreview)"
|
||||
:data-test="`preview-main-${activePreview}`" :alt="`第 ${activePreview} 页预览`"
|
||||
@error="onPreviewError(activePreview)" />
|
||||
</div>
|
||||
<div class="ppt-preview-thumbs" data-test="preview-strip">
|
||||
<button v-for="p in previewPages" :key="p" type="button"
|
||||
class="ppt-preview-thumb" :class="{ on: p === activePreview }"
|
||||
:data-test="`thumb-${p}`" :title="`第 ${p} 页`"
|
||||
@click="activePreview = p">
|
||||
<img :src="previewSrc(p)" :alt="`第 ${p} 页`" loading="lazy" @error.stop="onPreviewError(p)" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ol class="ppt-pages">
|
||||
<li v-for="(page, pi) in localPages" :key="pi" class="ppt-page" :class="`tp-${page.type}`"
|
||||
data-test="page-card">
|
||||
<div class="ppt-page-head">
|
||||
<span class="ppt-page-no">{{ page.slide_number }}</span>
|
||||
<span class="ppt-type" data-test="page-type">{{ TYPE_LABELS[page.type] || page.type }}</span>
|
||||
<input v-model="page.title" class="ppt-page-title" data-test="page-title"
|
||||
placeholder="页标题" @input="touch" />
|
||||
</div>
|
||||
|
||||
<!-- 分隔页只暴露副标题(渲染端 40pt 主标题 + 20pt 副标题,见 ppt_build._add_section_page) -->
|
||||
<div v-if="page.type === 'section'" class="ppt-sub-row">
|
||||
<input :value="page.bullets[0] || page.subtitle || ''" class="ppt-page-subtitle"
|
||||
data-test="page-subtitle" placeholder="副标题(默认取该章第一页标题)"
|
||||
@input="onSectionSubtitle(pi, $event)" />
|
||||
</div>
|
||||
<template v-else>
|
||||
<ul class="ppt-bullets">
|
||||
<li v-for="(b, bi) in page.bullets" :key="bi" class="ppt-bullet-row">
|
||||
<span class="ppt-ev" :class="badgeClass(page, bi)" data-test="ev-badge"
|
||||
:title="badgeTitle(page, bi)" />
|
||||
<input v-model="page.bullets[bi]" class="ppt-bullet-input" :data-test="`bullet-${pi}-${bi}`"
|
||||
placeholder="要点" @input="touch" />
|
||||
<button type="button" class="ppt-x" :data-test="`rm-${pi}-${bi}`"
|
||||
title="删除要点" @click="removeBullet(pi, bi)">×</button>
|
||||
</li>
|
||||
</ul>
|
||||
<button type="button" class="ppt-add" :data-test="`add-${pi}`" @click="addBullet(pi)">+ 要点</button>
|
||||
</template>
|
||||
|
||||
<div v-if="page.suggested_figures && page.suggested_figures.length" class="ppt-figs">
|
||||
<span v-for="(f, fi) in page.suggested_figures" :key="fi" class="ppt-fig"
|
||||
:class="f.status === 'verified' ? 'ev-ok' : 'ev-bad'"
|
||||
:data-test="`fig-${pi}-${fi}`"
|
||||
:title="`论文真实图引用 ${f.para_id}(${f.status === 'verified' ? '已核实' : '未核实'})`">
|
||||
{{ f.label || '图' }}(论文 {{ f.para_id }})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<textarea v-model="page.speaker_notes" class="ppt-notes" rows="2"
|
||||
:data-test="`notes-${pi}`" placeholder="讲稿备注(不进正文;证据来源自动生成在备注尾部)"
|
||||
@input="touch" />
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<p class="ppt-foot">
|
||||
e 徽标:<span class="ppt-ev ev-ok" /> 已核实 · <span class="ppt-ev ev-bad" /> 未核实
|
||||
(保存后按原文重新校验) · <span class="ppt-ev ev-none" /> 无来源
|
||||
<template v-if="ppt.buildMeta"> · 最近生成 {{ ppt.buildMeta.slides }} 页 / {{ Math.round(ppt.buildMeta.bytes / 1024) }}KB</template>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* tokens 全走 tokens.css(--pa-ink/--pa-surface-N/--pa-hairline/--pa-*);
|
||||
风格选择卡的三色板见 ppt-styles.css(与 data/ppt_styles/*.json 同步)。 */
|
||||
.ppt-root { display: flex; flex-direction: column; gap: 14px; padding: 18px 22px; max-width: 1280px; color: var(--pa-ink); }
|
||||
.ppt-title { font-size: 20px; margin: 0; color: var(--pa-ink); }
|
||||
.ppt-sub { color: var(--pa-ink-muted); font-size: 13px; margin: 4px 0 0; }
|
||||
.ppt-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; }
|
||||
|
||||
.ppt-status {
|
||||
font-size: 12px; padding: 4px 12px; border-radius: var(--pa-r-pill);
|
||||
border: 1px solid var(--pa-hairline); background: var(--pa-surface-2); color: var(--pa-ink-muted);
|
||||
}
|
||||
.ppt-status.st-error { color: var(--pa-error, #c2410c); border-color: currentColor; }
|
||||
.ppt-status.st-done { color: var(--pa-success, #16a34a); border-color: currentColor; }
|
||||
|
||||
.ppt-form, .ppt-editor {
|
||||
background: var(--pa-surface-1); border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-md, 12px); padding: 16px 18px;
|
||||
display: flex; flex-direction: column; gap: 14px;
|
||||
}
|
||||
.ppt-field { display: flex; flex-direction: column; gap: 6px; }
|
||||
.ppt-label { font-size: 13px; font-weight: 600; color: var(--pa-ink); }
|
||||
.ppt-seg { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.ppt-seg button {
|
||||
padding: 6px 14px; border-radius: var(--pa-r-pill, 999px); font-size: 13px;
|
||||
border: 1px solid var(--pa-hairline); background: var(--pa-surface-2); color: var(--pa-ink); cursor: pointer;
|
||||
}
|
||||
.ppt-seg button.on { background: var(--pa-gradient, linear-gradient(135deg, #2563eb, #7c3aed)); color: #fff; border-color: transparent; font-weight: 600; }
|
||||
.min-pages { font-size: 11px; opacity: 0.85; margin-left: 6px; }
|
||||
.ppt-styles { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 8px; }
|
||||
.ppt-file { font-size: 12px; color: var(--pa-ink-muted); }
|
||||
.ppt-hint { font-size: 12px; color: var(--pa-ink-subtle); align-self: center; }
|
||||
.ppt-actions { display: flex; gap: 10px; align-items: center; }
|
||||
|
||||
.ppt-primary {
|
||||
padding: 8px 18px; border-radius: var(--pa-r-pill, 999px); border: none; cursor: pointer;
|
||||
background: var(--pa-gradient, linear-gradient(135deg, #2563eb, #7c3aed)); color: #fff; font-weight: 600; font-size: 14px;
|
||||
}
|
||||
.ppt-primary:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
.ppt-danger { color: var(--pa-error, #c2410c); }
|
||||
.ppt-toolbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
.ppt-toolbar button, .ppt-dl {
|
||||
font-size: 13px; padding: 6px 12px; border-radius: var(--pa-r-pill, 999px);
|
||||
border: 1px solid var(--pa-hairline); background: var(--pa-surface-2); color: var(--pa-ink);
|
||||
cursor: pointer; text-decoration: none;
|
||||
}
|
||||
.ppt-toolbar button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.ppt-spacer { flex: 1; }
|
||||
.ppt-chip {
|
||||
font-size: 12px; padding: 3px 10px; border-radius: var(--pa-r-pill, 999px);
|
||||
background: var(--pa-surface-2); border: 1px solid var(--pa-hairline); color: var(--pa-ink-muted);
|
||||
}
|
||||
|
||||
.ppt-error { color: var(--pa-error, #c2410c); font-size: 13px; margin: 0; }
|
||||
.ppt-dirty { color: #b45309; font-size: 12px; margin: 0; }
|
||||
.ppt-warnings { font-size: 13px; color: #92400e; background: #fef3c722; border: 1px solid #f59e0b66; border-radius: 8px; padding: 6px 12px; }
|
||||
.ppt-warnings summary { cursor: pointer; font-weight: 600; }
|
||||
.ppt-warnings ul { margin: 6px 0 2px; padding-left: 18px; }
|
||||
.ppt-warnings li { margin: 2px 0; }
|
||||
|
||||
/* 页面预览(3.x):大图 + 缩略图条 */
|
||||
.ppt-previews {
|
||||
display: flex; flex-direction: column; gap: 8px;
|
||||
border: 1px solid var(--pa-hairline); border-radius: 10px;
|
||||
background: var(--pa-surface-2); padding: 10px 12px;
|
||||
}
|
||||
.ppt-preview-main-wrap {
|
||||
display: flex; justify-content: center;
|
||||
background: #0000000d; border-radius: 8px; padding: 8px;
|
||||
}
|
||||
.ppt-preview-main {
|
||||
max-width: 100%; width: 720px; max-height: 420px; object-fit: contain;
|
||||
border: 1px solid var(--pa-hairline); border-radius: 4px; background: #fff;
|
||||
}
|
||||
.ppt-preview-thumbs { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.ppt-preview-thumb {
|
||||
padding: 0; border: 2px solid var(--pa-hairline); border-radius: 6px;
|
||||
background: var(--pa-surface-1); cursor: pointer; overflow: hidden;
|
||||
width: 96px; height: 54px; line-height: 0;
|
||||
}
|
||||
.ppt-preview-thumb.on { border-color: var(--pa-accent, #2563eb); box-shadow: 0 0 0 2px #2563eb33; }
|
||||
.ppt-preview-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
|
||||
.ppt-pages { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||
.ppt-page {
|
||||
border: 1px solid var(--pa-hairline); border-left: 4px solid var(--pa-accent, #2563eb);
|
||||
border-radius: 10px; background: var(--pa-surface-2); padding: 10px 12px;
|
||||
display: flex; flex-direction: column; gap: 8px;
|
||||
}
|
||||
.ppt-page.tp-title { border-left-color: #7c3aed; }
|
||||
.ppt-page.tp-outline { border-left-color: #0891b2; }
|
||||
.ppt-page.tp-method { border-left-color: #2563eb; }
|
||||
.ppt-page.tp-result { border-left-color: #16a34a; }
|
||||
.ppt-page.tp-conclusion { border-left-color: #b45309; }
|
||||
.ppt-page.tp-reference { border-left-color: #6b7280; }
|
||||
.ppt-page-head { display: flex; align-items: center; gap: 8px; }
|
||||
.ppt-page-no {
|
||||
width: 22px; height: 22px; border-radius: 50%; font-size: 11px; font-weight: 700;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
background: var(--pa-surface-1); border: 1px solid var(--pa-hairline); color: var(--pa-ink-muted); flex-shrink: 0;
|
||||
}
|
||||
.ppt-type { font-size: 12px; color: var(--pa-ink-muted); min-width: 42px; }
|
||||
.ppt-page-title { flex: 1; font-size: 14px; font-weight: 600; padding: 4px 8px; border-radius: 6px;
|
||||
border: 1px solid transparent; background: transparent; color: var(--pa-ink); }
|
||||
.ppt-page-title:hover, .ppt-page-title:focus { border-color: var(--pa-hairline); background: var(--pa-surface-1); }
|
||||
|
||||
.ppt-sub-row { display: flex; }
|
||||
.ppt-page-subtitle {
|
||||
flex: 1; font-size: 13px; padding: 4px 8px; border-radius: 6px;
|
||||
border: 1px dashed var(--pa-hairline); background: transparent; color: var(--pa-ink-muted);
|
||||
}
|
||||
.ppt-page-subtitle:hover, .ppt-page-subtitle:focus {
|
||||
border-style: solid; border-color: var(--pa-hairline); background: var(--pa-surface-1); color: var(--pa-ink);
|
||||
}
|
||||
|
||||
.ppt-bullets { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
|
||||
.ppt-bullet-row { display: flex; align-items: center; gap: 6px; }
|
||||
.ppt-ev {
|
||||
width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; display: inline-block;
|
||||
border: 1px solid var(--pa-hairline);
|
||||
}
|
||||
.ppt-ev.ev-ok { background: var(--pa-success, #16a34a); border-color: transparent; }
|
||||
.ppt-ev.ev-bad { background: #f59e0b; border-color: transparent; }
|
||||
.ppt-ev.ev-none { background: transparent; border-color: var(--pa-ink-subtle, #9ca3af); }
|
||||
.ppt-bullet-input {
|
||||
flex: 1; font-size: 13px; padding: 4px 8px; border-radius: 6px;
|
||||
border: 1px solid transparent; background: transparent; color: var(--pa-ink);
|
||||
}
|
||||
.ppt-bullet-input:hover, .ppt-bullet-input:focus { border-color: var(--pa-hairline); background: var(--pa-surface-1); }
|
||||
.ppt-x { border: none; background: transparent; color: var(--pa-ink-subtle); cursor: pointer; font-size: 14px; padding: 0 4px; }
|
||||
.ppt-x:hover { color: var(--pa-error, #c2410c); }
|
||||
.ppt-add {
|
||||
align-self: flex-start; font-size: 12px; color: var(--pa-ink-muted); cursor: pointer;
|
||||
border: 1px dashed var(--pa-hairline); background: transparent; border-radius: 6px; padding: 2px 10px;
|
||||
}
|
||||
.ppt-figs { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.ppt-fig {
|
||||
font-size: 11px; padding: 2px 8px; border-radius: var(--pa-r-pill, 999px);
|
||||
background: var(--pa-surface-1); border: 1px solid var(--pa-hairline); color: var(--pa-ink-muted);
|
||||
}
|
||||
.ppt-notes {
|
||||
font-size: 12px; padding: 6px 8px; border-radius: 6px; resize: vertical;
|
||||
border: 1px solid var(--pa-hairline); background: var(--pa-surface-1); color: var(--pa-ink-muted);
|
||||
}
|
||||
.ppt-foot { font-size: 12px; color: var(--pa-ink-subtle); margin: 0; }
|
||||
input, textarea, select { font-family: inherit; color-scheme: light dark; }
|
||||
select { padding: 5px 8px; border-radius: 6px; border: 1px solid var(--pa-hairline); background: var(--pa-surface-2); color: var(--pa-ink); max-width: 320px; }
|
||||
</style>
|
||||
@@ -9,10 +9,14 @@
|
||||
选定后 target = 目录 id,后端经 journal_directory 回链)。
|
||||
结果三 Tab:修改后源码(Blob 下载)/ Diff 报告(按 rule_key 着色、
|
||||
按 action 计数:modified=已修改、warning=建议·橙色·不计入)/
|
||||
编译 PDF(P3 决策暂缓:仅「开发中」占位,不触发编译;后端 tectonic
|
||||
API(P3a)保留备用,前端不暴露编译入口,引导下载 .tex 去 Overleaf/
|
||||
本地编译)。
|
||||
结果跨菜单保留(store reformatResult);样式 rf- 前缀,零新增依赖。 -->
|
||||
编译 PDF(P3b):POST /compile {job_id}(tectonic 单槽队列,约 10-30 秒,
|
||||
首次拉 bundle 1-3 分钟——store.compileFirstHint 黄条提示可切面板后台跑);
|
||||
成功→blob URL 内嵌 iframe 预览 + 下载 + 耗时/重编;失败→错误摘要 +
|
||||
log 尾 200 行折叠 + 完整 log 下载;QUEUE_FULL→固定文案。
|
||||
P4 项目 zip(上传区模式切换,与单文件互斥):POST /compile/inspect(≤50MB,
|
||||
1h TTL,run 前不占编译队列)→ 主文件/统计/跳过文件摘要 → POST /compile/run
|
||||
(原子消费 inspect;信封与 P3b 同构复用 compile* 字段;失败也带 log_url)。
|
||||
结果跨菜单保留(store reformatResult/compile*);样式 rf- 前缀,零新增依赖。 -->
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { getDirectory, reviewErrorMessage } from '../../../api/review.js';
|
||||
@@ -28,7 +32,7 @@ const SOURCE_LABEL = {
|
||||
};
|
||||
|
||||
const fileInput = ref(null);
|
||||
const file = ref(null);
|
||||
const file = ref(edu.reformatFile || null); // File 存 store(换期刊重排不丢;× 移除才清空)
|
||||
const fileError = ref('');
|
||||
const dragging = ref(false);
|
||||
const tab = ref('source'); // 'source' | 'diff' | 'pdf'
|
||||
@@ -117,6 +121,31 @@ const structWarnings = computed(() => result.value?.warnings || []);
|
||||
const modifiedCount = computed(() => changes.value.filter((c) => c.action === 'modified').length);
|
||||
const suggestCount = computed(() => changes.value.filter((c) => c.action === 'warning').length);
|
||||
const sourceLabel = computed(() => SOURCE_LABEL[meta.value?.rules_source] || meta.value?.rules_source || '—');
|
||||
// 目标期刊规则摘要:只显示有值字段(null 不显示);全 null → 明确说明无强制约束。
|
||||
// 目的:diff=0 时(如 Frontiers / GB↔BMC 互切)也能看到"该刊要求什么",消除"没生效"错觉。
|
||||
const _REFS_LABEL = { vancouver: 'Vancouver 引用', apa: 'APA 引用', author_year: '作者-年份引用' };
|
||||
const rulesSummary = computed(() => {
|
||||
const r = meta.value?.rules;
|
||||
if (!r || typeof r !== 'object') return null;
|
||||
const parts = [];
|
||||
const ls = r.min_line_spacing;
|
||||
if (typeof ls === 'number' && ls > 0) {
|
||||
if (ls <= 1.05) parts.push('单倍行距');
|
||||
else if (Math.abs(ls - 1.5) < 0.01) parts.push('1.5 倍行距');
|
||||
else if (ls >= 1.95) parts.push('双倍行距');
|
||||
else parts.push(`行距 ≥${ls} 倍`);
|
||||
}
|
||||
if (r.refs_style && _REFS_LABEL[r.refs_style]) parts.push(_REFS_LABEL[r.refs_style]);
|
||||
if (typeof r.min_font_pt === 'number') parts.push(`字号 ≥${r.min_font_pt}pt`);
|
||||
if (typeof r.min_margin_mm === 'number') parts.push(`边距 ≥${r.min_margin_mm}mm`);
|
||||
if (!parts.length) return '该刊官网未规定明确格式要求,无强制约束';
|
||||
return parts.join(' · ');
|
||||
});
|
||||
// 与 diff 的关系:有修改 → "已修改 N 处";0 修改 → "样本已满足该刊要求,无需修改"
|
||||
const rulesStatusText = computed(() => {
|
||||
if (modifiedCount.value > 0) return `已修改 ${modifiedCount.value} 处`;
|
||||
return '样本已满足该刊要求,无需修改';
|
||||
});
|
||||
// ISO 时间戳 → "10-12 15:25"(内部字段不裸露;helper 规则见 UI 注释)
|
||||
const generatedShort = computed(() => {
|
||||
const s = meta.value?.generated_at;
|
||||
@@ -129,6 +158,137 @@ function copyJobId() {
|
||||
if (id && navigator.clipboard) navigator.clipboard.writeText(String(id)).catch(() => {});
|
||||
}
|
||||
|
||||
/* —— P3b 编译 PDF(状态全在 store → 切菜单/切 Tab 编译继续跑)—— */
|
||||
const compiling = computed(() => edu.compileStatus === 'loading');
|
||||
const compileReady = computed(() => edu.compileStatus === 'ready' && !!edu.compileResult);
|
||||
const compileErrored = computed(() => edu.compileStatus === 'error');
|
||||
const compileMeta = computed(() => edu.compileResult?.meta || null);
|
||||
const pdfSrc = computed(() => edu.compilePdfUrl || edu.compileResult?.pdf_url || '');
|
||||
const pdfSizeText = computed(() => {
|
||||
const b = compileMeta.value?.pdf_bytes;
|
||||
if (b == null) return '';
|
||||
return b >= 1048576 ? `${(b / 1048576).toFixed(1)} MB` : `${Math.ceil(b / 1024)} KB`;
|
||||
});
|
||||
const compileLog = computed(() => edu.compileLogTail || '');
|
||||
const logCollapsed = ref(true); // log 折叠区(默认收起)
|
||||
const firstHintDismissed = ref(false);
|
||||
const showFirstHint = computed(() =>
|
||||
edu.compileFirstHint && compiling.value && !firstHintDismissed.value,
|
||||
);
|
||||
|
||||
function startCompile() {
|
||||
const jid = result.value?.job_id;
|
||||
if (!jid || compiling.value) return;
|
||||
logCollapsed.value = true;
|
||||
edu.submitCompile(jid); // 不 await:按钮/进度由 store 状态驱动,切面板不中断
|
||||
}
|
||||
function downloadPdf() {
|
||||
const href = pdfSrc.value;
|
||||
if (!href) return;
|
||||
const a = document.createElement('a');
|
||||
a.href = href;
|
||||
a.download = `${result.value?.job_id || 'paper'}.pdf`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
}
|
||||
function downloadFullLog() {
|
||||
// 成功:信封带 log_url;失败信封不带——job_id 即 anchor_id,log 同目录可拼路径
|
||||
const jid = result.value?.job_id;
|
||||
const href = edu.compileResult?.log_url || (jid ? `/api/review/compile/${jid}/log` : '');
|
||||
if (!href) return;
|
||||
const a = document.createElement('a');
|
||||
a.href = href;
|
||||
a.download = `${jid || 'compile'}.log`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
}
|
||||
|
||||
/* —— P4 项目 zip(两段式;与单文件模式互斥,切换即清对侧状态)—— */
|
||||
const uploadMode = ref('tex'); // 'tex' | 'zip'
|
||||
const zipInput = ref(null);
|
||||
const zipInspecting = computed(() => edu.zipInspectStatus === 'loading');
|
||||
const zipInspected = computed(() => edu.zipInspectStatus === 'ready' && !!edu.zipInspect);
|
||||
const zipError = computed(() => edu.zipInspectError || '');
|
||||
const MAX_ZIP_SIZE = 50 * 1024 * 1024; // 与后端 REVIEW_MAX_ZIP_BYTES 一致
|
||||
|
||||
function switchMode(mode) {
|
||||
if (uploadMode.value === mode) return;
|
||||
uploadMode.value = mode;
|
||||
fileError.value = '';
|
||||
if (mode === 'zip') {
|
||||
edu.clearReformat(); // 互斥:zip 编译结果与单文件编译共用 compile* 字段
|
||||
} else {
|
||||
edu.clearZip();
|
||||
}
|
||||
}
|
||||
|
||||
function acceptZip(f) {
|
||||
if (!f) return;
|
||||
if (!String(f.name || '').toLowerCase().endsWith('.zip')) {
|
||||
edu.zipInspectStatus = 'error';
|
||||
edu.zipInspectError = '仅支持 .zip 项目包';
|
||||
return;
|
||||
}
|
||||
if (!f.size) {
|
||||
edu.zipInspectStatus = 'error';
|
||||
edu.zipInspectError = '上传文件为空';
|
||||
return;
|
||||
}
|
||||
if (f.size > MAX_ZIP_SIZE) {
|
||||
edu.zipInspectStatus = 'error';
|
||||
edu.zipInspectError = 'zip 超过 50MB 上限';
|
||||
return;
|
||||
}
|
||||
edu.zipFile = f;
|
||||
edu.zipInspectStatus = 'idle'; // 换文件 → 旧 inspect 失效
|
||||
edu.zipInspect = null;
|
||||
edu.zipInspectError = null;
|
||||
edu.clearCompile();
|
||||
}
|
||||
function onZipPick(e) {
|
||||
acceptZip(e.target.files?.[0]);
|
||||
e.target.value = '';
|
||||
}
|
||||
function onZipDrop(e) {
|
||||
dragging.value = false;
|
||||
acceptZip(e.dataTransfer?.files?.[0]);
|
||||
}
|
||||
function removeZip() {
|
||||
edu.clearZip(); // 唯一清空入口:"×" 移除
|
||||
}
|
||||
async function startZipInspect() {
|
||||
if (!edu.zipFile || zipInspecting.value) return;
|
||||
await edu.submitZipInspect(); // 不 await 也可,但保持与其他面板一致写法
|
||||
}
|
||||
function startZipCompile() {
|
||||
logCollapsed.value = true;
|
||||
edu.submitZipCompile(); // 不 await:状态由 store 驱动,切面板不中断
|
||||
}
|
||||
function downloadZipPdf() {
|
||||
const href = pdfSrc.value;
|
||||
if (!href) return;
|
||||
const cid = edu.compileResult?.compile_id || 'project';
|
||||
const a = document.createElement('a');
|
||||
a.href = href;
|
||||
a.download = `${cid}.pdf`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
}
|
||||
function downloadZipLog() {
|
||||
const cid = edu.compileResult?.compile_id;
|
||||
const href = edu.compileResult?.log_url || (cid ? `/api/review/compile/zip/${cid}/log` : '');
|
||||
if (!href) return;
|
||||
const a = document.createElement('a');
|
||||
a.href = href;
|
||||
a.download = `${cid || 'compile'}.log`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (edu.journalsStatus === 'idle') edu.fetchJournals(); // 精选期刊下拉数据源
|
||||
// 恢复的 target 是目录刊但无选中记录(历史草稿)→ 回落通用规则
|
||||
@@ -154,6 +314,7 @@ function acceptFile(f) {
|
||||
return;
|
||||
}
|
||||
file.value = f;
|
||||
edu.reformatFile = f; // 进 store:换期刊/切菜单重排不丢
|
||||
}
|
||||
function onPick(e) {
|
||||
acceptFile(e.target.files?.[0]);
|
||||
@@ -165,6 +326,7 @@ function onDrop(e) {
|
||||
}
|
||||
function removeFile() {
|
||||
file.value = null;
|
||||
edu.reformatFile = null; // 唯一清空入口:"×" 移除
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
@@ -177,7 +339,7 @@ async function submit() {
|
||||
}
|
||||
function reset() {
|
||||
edu.clearReformat();
|
||||
file.value = null;
|
||||
// 不清 file——换期刊重排时保留已上传文件(仅"×"移除才清)
|
||||
fileError.value = '';
|
||||
tab.value = 'source';
|
||||
}
|
||||
@@ -216,6 +378,11 @@ function download() {
|
||||
复制任务 ID
|
||||
</button>
|
||||
</div>
|
||||
<!-- 目标期刊规则摘要:diff=0 时也能看到"该刊要求什么"(次级文字,不抢 diff 主视觉) -->
|
||||
<p v-if="rulesSummary" class="rf-rules" data-testid="rf-rules-summary">
|
||||
📋 该刊规则:{{ rulesSummary }}
|
||||
<span class="rf-rules-status">({{ rulesStatusText }})</span>
|
||||
</p>
|
||||
<p v-if="meta?.rules_note" class="rf-note" role="note">{{ meta.rules_note }}</p>
|
||||
|
||||
<!-- 汇总:已修改 / 建议(橙,不计入修改)/ 结构提示 -->
|
||||
@@ -269,21 +436,95 @@ function download() {
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<!-- 编译 PDF(P3 暂缓):「开发中」占位,不触发编译;后端 P3a API 保留备用 -->
|
||||
<!-- ========== 编译 PDF(P3b) ========== -->
|
||||
<div v-else class="rf-compile">
|
||||
<div class="rf-wip" role="note">
|
||||
<p class="rf-wip-main">
|
||||
PDF 编译功能开发中,暂时请下载修改后的 .tex 文件,在 Overleaf 或本地编译。
|
||||
后续将支持多文件工程和图片上传。
|
||||
</p>
|
||||
<p class="rf-wip-sub">已提供逐行修改对照(Diff 报告)与修改后源码下载。</p>
|
||||
<a
|
||||
class="rf-wip-feedback"
|
||||
href="http://192.168.0.175:3000/admin/PlantAI-rag-new/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>反馈需求 →</a>
|
||||
<!-- 无 job_id(未完成重排版)→ 引导 -->
|
||||
<div v-if="!result?.job_id" class="rf-wip" role="note" data-testid="rf-compile-guide">
|
||||
<p class="rf-wip-main">请先完成重排版再编译。</p>
|
||||
<p class="rf-wip-sub">编译基于重排版后的 .tex 源码,重排版成功后此页会出现「编译 PDF」按钮。</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- 首次编译慢提示(loading 期间显示,可手动关闭) -->
|
||||
<div v-if="showFirstHint" class="rf-first-hint" role="note" data-testid="rf-first-hint">
|
||||
<span>
|
||||
首次编译需要 1-3 分钟(下载 TeX 环境),后续编译秒级。
|
||||
可以切到其他面板,编译会在后台继续。
|
||||
</span>
|
||||
<button class="rf-hint-x" type="button" aria-label="关闭提示"
|
||||
@click="firstHintDismissed = true">×</button>
|
||||
</div>
|
||||
|
||||
<!-- 操作行:编译按钮 / 下载 / 重新编译 -->
|
||||
<div class="rf-compile-bar">
|
||||
<button
|
||||
v-if="!compileReady && !compileErrored"
|
||||
class="rf-compile-btn" type="button"
|
||||
data-testid="rf-compile-btn"
|
||||
:disabled="compiling"
|
||||
@click="startCompile"
|
||||
>
|
||||
{{ compiling ? '编译中…(首次约 1-3 分钟)' : '编译 PDF' }}
|
||||
</button>
|
||||
<template v-if="compileReady">
|
||||
<button class="rf-compile-btn" type="button" data-testid="rf-download-pdf"
|
||||
@click="downloadPdf">下载 PDF</button>
|
||||
<button class="rf-compile-btn rf-compile-ghost" type="button"
|
||||
data-testid="rf-recompile"
|
||||
:disabled="compiling" @click="startCompile">
|
||||
{{ compiling ? '编译中…' : '重新编译' }}
|
||||
</button>
|
||||
</template>
|
||||
<template v-if="compileErrored">
|
||||
<button class="rf-compile-btn" type="button" data-testid="rf-recompile"
|
||||
:disabled="compiling" @click="startCompile">
|
||||
{{ compiling ? '编译中…' : '重新编译' }}
|
||||
</button>
|
||||
<button class="rf-compile-btn rf-compile-ghost" type="button"
|
||||
data-testid="rf-download-log" @click="downloadFullLog">查看完整 log</button>
|
||||
</template>
|
||||
<span v-if="compileReady && pdfSizeText" class="rf-compile-size"
|
||||
data-testid="rf-pdf-size">{{ pdfSizeText }}</span>
|
||||
</div>
|
||||
|
||||
<!-- loading:动画进度(后端无 stage,不确定时长) -->
|
||||
<div v-if="compiling" class="rf-compile-loading" role="status"
|
||||
data-testid="rf-compile-loading">
|
||||
<span class="rf-compile-spin" aria-hidden="true" />
|
||||
<p class="rf-state-msg">
|
||||
编译中…(tectonic 单槽队列,常规 10-30 秒;首次 1-3 分钟)
|
||||
可切换到其他面板,编译在后台继续。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 成功:内嵌 PDF 预览(blob URL) -->
|
||||
<div v-else-if="compileReady" class="rf-compile-ready">
|
||||
<iframe
|
||||
class="rf-compile-frame" :src="pdfSrc" title="PDF 预览"
|
||||
data-testid="rf-pdf-frame"
|
||||
/>
|
||||
<div class="rf-compile-foot">
|
||||
<span class="rf-compile-secs" data-testid="rf-compile-secs">
|
||||
编译耗时 {{ compileMeta?.seconds != null ? `${Number(compileMeta.seconds).toFixed(1)} 秒` : '—' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 失败:错误摘要 + log 尾折叠 + 完整 log 下载 -->
|
||||
<div v-else-if="compileErrored" class="rf-compile-error" role="alert"
|
||||
data-testid="rf-compile-error">
|
||||
<p class="rf-compile-msg">{{ edu.compileError }}</p>
|
||||
<details
|
||||
v-if="compileLog" class="rf-log" :open="!logCollapsed"
|
||||
data-testid="rf-log-details"
|
||||
>
|
||||
<summary class="rf-log-sum" @click="logCollapsed = !logCollapsed">
|
||||
编译日志(尾 200 行)
|
||||
</summary>
|
||||
<pre class="rf-log-body" data-testid="rf-log-body">{{ compileLog }}</pre>
|
||||
</details>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -304,6 +545,145 @@ function download() {
|
||||
|
||||
<!-- ========== ④ 上传区 ========== -->
|
||||
<div v-else class="rf-upload">
|
||||
<!-- P4 模式切换:单文件 .tex(P3 重排版链路)| 项目 .zip(两段式编译,不经重排版) -->
|
||||
<div class="rf-modes" role="tablist" aria-label="上传模式">
|
||||
<button
|
||||
class="rf-mode" :class="{ active: uploadMode === 'tex' }" type="button"
|
||||
role="tab" :aria-selected="uploadMode === 'tex'" data-testid="rf-mode-tex"
|
||||
@click="switchMode('tex')"
|
||||
>单文件 .tex</button>
|
||||
<button
|
||||
class="rf-mode" :class="{ active: uploadMode === 'zip' }" type="button"
|
||||
role="tab" :aria-selected="uploadMode === 'zip'" data-testid="rf-mode-zip"
|
||||
@click="switchMode('zip')"
|
||||
>项目 .zip</button>
|
||||
</div>
|
||||
|
||||
<!-- ========== ④-zip 项目上传(P4 两段式) ========== -->
|
||||
<template v-if="uploadMode === 'zip'">
|
||||
<div
|
||||
class="rf-drop"
|
||||
:class="{ dragging, filled: !!edu.zipFile }"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="zipInput.click()"
|
||||
@keydown.enter="zipInput.click()"
|
||||
@dragover.prevent="dragging = true"
|
||||
@dragleave.prevent="dragging = false"
|
||||
@drop.prevent="onZipDrop"
|
||||
>
|
||||
<input
|
||||
ref="zipInput"
|
||||
class="rf-file-input"
|
||||
type="file"
|
||||
accept=".zip"
|
||||
@click.stop
|
||||
@change="onZipPick"
|
||||
/>
|
||||
<template v-if="edu.zipFile">
|
||||
<span class="rf-file-name">🗜 {{ edu.zipFile.name }} · {{ (edu.zipFile.size / 1024 / 1024).toFixed(1) }} MB</span>
|
||||
<button class="rf-remove" type="button" title="移除文件" @click.stop="removeZip">×</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="rf-drop-main">拖拽 LaTeX 项目(.zip)到此处,或点击选择</span>
|
||||
<span class="rf-hint">打包建议:main.tex 放 zip 根目录,图片用相对路径引用,不要包含 .git / node_modules / 无关大文件。</span>
|
||||
</template>
|
||||
</div>
|
||||
<p v-if="zipError" class="rf-file-error" data-testid="rf-zip-error">{{ zipError }}</p>
|
||||
|
||||
<button
|
||||
class="rf-submit"
|
||||
type="button"
|
||||
:disabled="!edu.zipFile || zipInspecting"
|
||||
data-testid="rf-zip-inspect-btn"
|
||||
@click="startZipInspect"
|
||||
>
|
||||
{{ zipInspecting ? '检查中…' : '上传并检查' }}
|
||||
</button>
|
||||
|
||||
<!-- inspect 通过:项目摘要 + 编译入口 + 编译结果(复用 P3b compile* 状态) -->
|
||||
<template v-if="zipInspected">
|
||||
<div class="rf-zip-meta" data-testid="rf-zip-meta">
|
||||
📦 主文件:<code>{{ edu.zipInspect?.main }}</code>
|
||||
· 文件 {{ edu.zipInspect?.stats?.files }} 个
|
||||
· .tex {{ edu.zipInspect?.stats?.tex }}
|
||||
<template v-if="edu.zipInspect?.stats?.images"> · 图片 {{ edu.zipInspect.stats.images }}</template>
|
||||
<template v-if="edu.zipInspect?.stats?.cls"> · .cls {{ edu.zipInspect.stats.cls }}</template>
|
||||
<template v-if="edu.zipInspect?.stats?.bib"> · .bib {{ edu.zipInspect.stats.bib }}</template>
|
||||
<template v-if="edu.zipInspect?.skipped_count">
|
||||
· <span class="rf-zip-skipped">{{ edu.zipInspect.skipped_count }} 个文件已跳过</span>
|
||||
</template>
|
||||
</div>
|
||||
<p v-if="edu.zipInspect?.skipped_count" class="rf-zip-skipped-list" data-testid="rf-zip-skipped">
|
||||
已跳过(不参与编译):{{ (edu.zipInspect?.skipped || []).map((s) => s.name).join('、') }}
|
||||
</p>
|
||||
|
||||
<div class="rf-compile-bar">
|
||||
<button
|
||||
v-if="!compileReady && !compileErrored"
|
||||
class="rf-compile-btn" type="button"
|
||||
data-testid="rf-zip-compile-btn"
|
||||
:disabled="compiling"
|
||||
@click="startZipCompile"
|
||||
>
|
||||
{{ compiling ? '编译中…' : '编译 PDF' }}
|
||||
</button>
|
||||
<template v-if="compileReady">
|
||||
<button class="rf-compile-btn" type="button" data-testid="rf-download-pdf"
|
||||
@click="downloadZipPdf">下载 PDF</button>
|
||||
<button class="rf-compile-btn rf-compile-ghost" type="button"
|
||||
data-testid="rf-recompile" :disabled="compiling" @click="startZipCompile">
|
||||
{{ compiling ? '编译中…' : '重新编译' }}
|
||||
</button>
|
||||
</template>
|
||||
<template v-if="compileErrored">
|
||||
<button class="rf-compile-btn" type="button" data-testid="rf-recompile"
|
||||
:disabled="compiling" @click="startZipCompile">
|
||||
{{ compiling ? '编译中…' : '重新编译' }}
|
||||
</button>
|
||||
<button class="rf-compile-btn rf-compile-ghost" type="button"
|
||||
data-testid="rf-download-log" @click="downloadZipLog">查看完整 log</button>
|
||||
</template>
|
||||
<span v-if="compileReady && pdfSizeText" class="rf-compile-size"
|
||||
data-testid="rf-pdf-size">{{ pdfSizeText }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="compiling" class="rf-compile-loading" role="status"
|
||||
data-testid="rf-compile-loading">
|
||||
<span class="rf-compile-spin" aria-hidden="true" />
|
||||
<p class="rf-state-msg">编译中…(tectonic 单槽队列,常规 10-30 秒;项目首次编译需下载宏包可能更慢)</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="compileReady" class="rf-compile-ready">
|
||||
<iframe
|
||||
class="rf-compile-frame" :src="pdfSrc" title="PDF 预览"
|
||||
data-testid="rf-pdf-frame"
|
||||
/>
|
||||
<div class="rf-compile-foot">
|
||||
<span class="rf-compile-secs" data-testid="rf-compile-secs">
|
||||
编译耗时 {{ compileMeta?.seconds != null ? `${Number(compileMeta.seconds).toFixed(1)} 秒` : '—' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="compileErrored" class="rf-compile-error" role="alert"
|
||||
data-testid="rf-compile-error">
|
||||
<p class="rf-compile-msg">{{ edu.compileError }}</p>
|
||||
<details
|
||||
v-if="compileLog" class="rf-log" :open="!logCollapsed"
|
||||
data-testid="rf-log-details"
|
||||
>
|
||||
<summary class="rf-log-sum" @click="logCollapsed = !logCollapsed">
|
||||
编译日志(尾 200 行)
|
||||
</summary>
|
||||
<pre class="rf-log-body" data-testid="rf-log-body">{{ compileLog }}</pre>
|
||||
</details>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- ========== ④-tex 单文件上传(P3 链路,零改动) ========== -->
|
||||
<template v-else>
|
||||
<div
|
||||
class="rf-drop"
|
||||
:class="{ dragging, filled: !!file }"
|
||||
@@ -387,6 +767,7 @@ function download() {
|
||||
开始自动重排版
|
||||
</button>
|
||||
<p v-if="dirMode && !actualTarget" class="rf-dirhint">请从搜索结果中选择期刊后提交</p>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -416,6 +797,15 @@ function download() {
|
||||
font-size: 12.5px;
|
||||
color: var(--pa-ink-muted);
|
||||
}
|
||||
/* 规则摘要:次级文字(同 rf-meta 色阶),与汇总条的计数视觉区分 */
|
||||
.rf-rules {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12.5px;
|
||||
color: var(--pa-ink-muted);
|
||||
}
|
||||
.rf-rules-status {
|
||||
color: var(--pa-ink-subtle);
|
||||
}
|
||||
.rf-copy-id {
|
||||
margin-left: 8px;
|
||||
padding: 1px 8px;
|
||||
@@ -625,12 +1015,6 @@ function download() {
|
||||
color: var(--pa-warning);
|
||||
}
|
||||
|
||||
/* 编译 PDF(P3 暂缓:开发中占位) */
|
||||
.rf-compile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.rf-wip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -652,13 +1036,126 @@ function download() {
|
||||
font-size: 12.5px;
|
||||
color: var(--pa-ink-subtle);
|
||||
}
|
||||
.rf-wip-feedback {
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--pa-g-from);
|
||||
text-decoration: none;
|
||||
|
||||
/* 编译 PDF(P3b) */
|
||||
.rf-compile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.rf-first-hint {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid color-mix(in srgb, var(--pa-warning) 45%, transparent);
|
||||
border-radius: var(--pa-r-md);
|
||||
background: color-mix(in srgb, var(--pa-warning) 12%, transparent);
|
||||
color: var(--pa-ink);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.rf-hint-x {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--pa-ink-muted);
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
}
|
||||
.rf-hint-x:hover { color: var(--pa-ink); }
|
||||
.rf-compile-bar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.rf-compile-btn {
|
||||
padding: 7px 18px;
|
||||
border: none;
|
||||
border-radius: var(--pa-r-md);
|
||||
background: var(--pa-gradient);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.rf-compile-btn:disabled { opacity: .6; cursor: not-allowed; }
|
||||
.rf-compile-btn:not(:disabled):hover { filter: brightness(1.08); }
|
||||
.rf-compile-ghost {
|
||||
background: transparent;
|
||||
border: 1px solid var(--pa-hairline-strong);
|
||||
color: var(--pa-ink);
|
||||
}
|
||||
.rf-compile-size {
|
||||
font-size: 12px;
|
||||
color: var(--pa-ink-subtle);
|
||||
}
|
||||
.rf-compile-loading {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 14px 16px;
|
||||
background: var(--pa-surface-2);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-lg);
|
||||
}
|
||||
.rf-compile-spin {
|
||||
flex: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid var(--pa-hairline-strong);
|
||||
border-top-color: var(--pa-g-from);
|
||||
border-radius: 50%;
|
||||
animation: rf-spin .8s linear infinite;
|
||||
}
|
||||
@keyframes rf-spin { to { transform: rotate(360deg); } }
|
||||
.rf-compile-ready { display: flex; flex-direction: column; gap: 8px; }
|
||||
.rf-compile-frame {
|
||||
width: 100%;
|
||||
height: 560px;
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-lg);
|
||||
background: var(--pa-surface-2);
|
||||
}
|
||||
.rf-compile-foot { display: flex; gap: 12px; align-items: center; }
|
||||
.rf-compile-secs { font-size: 12px; color: var(--pa-ink-subtle); }
|
||||
.rf-compile-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background: color-mix(in srgb, var(--pa-error) 8%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--pa-error) 40%, transparent);
|
||||
border-radius: var(--pa-r-lg);
|
||||
}
|
||||
.rf-compile-msg {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--pa-error);
|
||||
}
|
||||
.rf-log { border-top: 1px dashed var(--pa-hairline-strong); padding-top: 8px; }
|
||||
.rf-log-sum {
|
||||
font-size: 12.5px;
|
||||
color: var(--pa-ink-muted);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.rf-log-body {
|
||||
margin: 8px 0 0;
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
padding: 10px 12px;
|
||||
background: var(--pa-code-bg);
|
||||
border-radius: var(--pa-r-md);
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
color: var(--pa-ink-muted);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.rf-wip-feedback:hover { text-decoration: underline; }
|
||||
|
||||
/* 状态区 */
|
||||
.rf-state {
|
||||
@@ -755,6 +1252,45 @@ function download() {
|
||||
font-size: 12.5px;
|
||||
color: var(--pa-error);
|
||||
}
|
||||
/* P4 模式切换(单文件 .tex | 项目 .zip) */
|
||||
.rf-modes {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.rf-mode {
|
||||
padding: 5px 14px;
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-pill);
|
||||
background: transparent;
|
||||
color: var(--pa-ink-muted);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.rf-mode.active {
|
||||
border-color: var(--pa-g-from);
|
||||
background: var(--pa-surface-2);
|
||||
color: var(--pa-ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
/* P4 zip 项目摘要(次级文字,与 rf-meta 同色阶) */
|
||||
.rf-zip-meta {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12.5px;
|
||||
color: var(--pa-ink-muted);
|
||||
}
|
||||
.rf-zip-meta code {
|
||||
padding: 1px 6px;
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-sm);
|
||||
font-size: 12px;
|
||||
}
|
||||
.rf-zip-skipped { color: var(--pa-warning); }
|
||||
.rf-zip-skipped-list {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--pa-ink-subtle);
|
||||
word-break: break-all;
|
||||
}
|
||||
.rf-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/* ppt-styles.css —— P5 科研汇报风格选择卡(前端 CSS 投影)。
|
||||
真源:review_service/data/ppt_styles/*.json(tokens JSON 优先,决定生成的
|
||||
PPT 配色/字体);卡面封面图 = ui/src/assets/ppt-style-cards/*.png
|
||||
(build_pptx + ppt_preview 真实渲染的各风格封面,风格变更时需重新生成)。 */
|
||||
|
||||
.ppt-style-card {
|
||||
display: flex; flex-direction: column; align-items: stretch; gap: 7px;
|
||||
padding: 8px 10px 9px; border-radius: var(--pa-r-md, 10px);
|
||||
border: 1px solid var(--pa-hairline); background: var(--pa-surface-1);
|
||||
cursor: pointer; text-align: left; width: 100%;
|
||||
}
|
||||
.ppt-style-card.on {
|
||||
border-color: var(--pa-accent, #2563eb);
|
||||
box-shadow: 0 0 0 1px var(--pa-accent, #2563eb);
|
||||
}
|
||||
.ppt-style-cover {
|
||||
width: 100%; aspect-ratio: 16 / 9; object-fit: cover; display: block;
|
||||
border-radius: 6px; border: 1px solid var(--pa-hairline);
|
||||
background: var(--st-bg, #fff);
|
||||
}
|
||||
.ppt-style-meta { display: flex; flex-direction: column; gap: 1px; }
|
||||
.ppt-style-name { font-size: 13px; font-weight: 600; color: var(--pa-ink); }
|
||||
.ppt-style-desc { font-size: 12px; color: var(--pa-ink-muted); margin-top: 1px; }
|
||||
|
||||
/* 十四风格 tokens(与 data/ppt_styles/*.json palette 同步;
|
||||
domestic 起 6 个移植自开源 paper-skills styles) */
|
||||
.ppt-style-card.academic-minimal { --st-bg: #ffffff; --st-primary: #111827; --st-accent: #2563eb; }
|
||||
.ppt-style-card.botanic-forest { --st-bg: #f4faf6; --st-primary: #14532d; --st-accent: #16a34a; }
|
||||
.ppt-style-card.classic-defense { --st-bg: #ffffff; --st-primary: #1a365d; --st-accent: #b7791f; }
|
||||
.ppt-style-card.academic-blue { --st-bg: #eff6ff; --st-primary: #1d4ed8; --st-accent: #3b82f6; }
|
||||
.ppt-style-card.nature-green { --st-bg: #f0fdf4; --st-primary: #15803d; --st-accent: #22c55e; }
|
||||
.ppt-style-card.tech-dark { --st-bg: #0b1220; --st-primary: #38bdf8; --st-accent: #22d3ee; }
|
||||
.ppt-style-card.monochrome { --st-bg: #ffffff; --st-primary: #111111; --st-accent: #444444; }
|
||||
.ppt-style-card.warm-earth { --st-bg: #fff7ed; --st-primary: #9a3412; --st-accent: #ea580c; }
|
||||
.ppt-style-card.domestic { --st-bg: #ffffff; --st-primary: #1f4e79; --st-accent: #c00000; }
|
||||
.ppt-style-card.international { --st-bg: #ffffff; --st-primary: #111111; --st-accent: #c0392b; }
|
||||
.ppt-style-card.systems-talk { --st-bg: #ffffff; --st-primary: #1f3a5f; --st-accent: #e4572e; }
|
||||
.ppt-style-card.theory-beamer { --st-bg: #fafafa; --st-primary: #23373b; --st-accent: #eb811b; }
|
||||
.ppt-style-card.jp-gothic { --st-bg: #ffffff; --st-primary: #1e5bd8; --st-accent: #ffe14d; }
|
||||
.ppt-style-card.editorial { --st-bg: #faf7f2; --st-primary: #8e7dbe; --st-accent: #c8553d; }
|
||||
@@ -4,11 +4,11 @@
|
||||
// - 进行中的请求由 store action 持有 → 面板卸载不中断;
|
||||
// - 表单草稿存 drafts → 切菜单不丢。
|
||||
import { defineStore } from 'pinia';
|
||||
import { compileTex, formatCheck, formatErrorMessage, getConferences, getDirectory, getJournals, presubmit, recommend, reformatTex, reviewErrorMessage, rebuttalConfirm, rebuttalCreateFromPdf, rebuttalCreateJob, rebuttalDeleteJob, rebuttalDraft, rebuttalLetter, rebuttalSaveResponse } from '../api/review.js';
|
||||
import { compileTex, formatCheck, formatErrorMessage, getConferences, getDirectory, getJournals, inspectZip, pptBuildJob, pptCreateFromPdf, pptCreateJob, pptDeleteJob, pptSavePages, pptStyles, presubmit, recommend, reformatTex, reviewErrorMessage, rebuttalConfirm, rebuttalCreateFromPdf, rebuttalCreateJob, rebuttalDeleteJob, rebuttalDraft, rebuttalLetter, rebuttalSaveResponse, runZipCompile } from '../api/review.js';
|
||||
|
||||
const STORAGE_KEY = 'plantai.edu.submissions'; // D3 台账持久化(tracker 面板已解除映射,state 保留)
|
||||
// tracker 已砍(2026-10-12,仅解除 UI 映射;TrackerPanel.vue 保留在 panels/ 下可复用)
|
||||
const SECTIONS = ['journals', 'recommend', 'presubmit', 'report', 'format', 'reformat', 'calendar', 'rebuttal'];
|
||||
const SECTIONS = ['journals', 'recommend', 'presubmit', 'report', 'format', 'reformat', 'calendar', 'rebuttal', 'ppt'];
|
||||
|
||||
// ⑥ 台账状态枚举(labels 由 step-7 UI 层给;store 只存字符串)
|
||||
export const SUBMISSION_STATUS = ['draft', 'submitted', 'under_review', 'revision', 'accepted', 'rejected'];
|
||||
@@ -72,6 +72,9 @@ export const useEduStore = defineStore('edu', {
|
||||
// ③ presubmit 已选 PDF(File 对象,仅内存:面板卸载/切菜单不丢,error 态可直接重试;
|
||||
// 不可序列化 → 不进 localStorage;clearReport 一并清空)
|
||||
presubmitFile: null,
|
||||
// ⑤' reformat 已选 .tex(同 presubmitFile 模式:换期刊重排不丢文件;
|
||||
// 面板内"×"移除才清空。File 不可序列化 → 不进 localStorage)
|
||||
reformatFile: null,
|
||||
recommend: null, // ② 推荐响应 {ok, tiers, meta}
|
||||
recommendStatus: 'idle',
|
||||
recommendError: null,
|
||||
@@ -83,11 +86,19 @@ export const useEduStore = defineStore('edu', {
|
||||
reformatResult: null,
|
||||
reformatStatus: 'idle',
|
||||
reformatError: null,
|
||||
/* —— P3a 编译 PDF(tectonic;与 reformatResult 同生命周期,重新排版即失效)—— */
|
||||
compileResult: null, // {ok, compile_id, pdf_url, log_url, meta}
|
||||
/* —— P3a/P3b 编译 PDF(tectonic;与 reformatResult 同生命周期,重新排版即失效)—— */
|
||||
compileResult: null, // {ok, compile_id, pdf_url, log_url, meta:{seconds,pdf_bytes,...}}
|
||||
compileStatus: 'idle', // 'idle' | 'loading' | 'ready' | 'error'
|
||||
compileError: null,
|
||||
compileLogTail: '', // COMPILE_FAILED/TIMEOUT 的 log 尾 200 行(err.detail)
|
||||
compilePdfUrl: null, // PDF blob URL(预览/下载,不暴露 API 路径;失败回退 pdf_url)
|
||||
compileFirstHint: false, // 首次编译黄色提示条(编译开始到 clearCompile 前保留)
|
||||
/* —— P4 项目 zip 两段式(inspect 暂存 1h + run 编译;run 信封与 P3b 同构,
|
||||
复用 compile* 字段展示 PDF/log——zip 编译结果与单文件编译互斥,切换模式即清)—— */
|
||||
zipFile: null, // File(内存态:切菜单不丢,× 移除才清空)
|
||||
zipInspect: null, // inspect meta {job_id, main, stats{}, skipped[], skipped_count}
|
||||
zipInspectStatus: 'idle', // 'idle' | 'loading' | 'ready' | 'error'
|
||||
zipInspectError: null,
|
||||
|
||||
/* —— 表单草稿(切菜单不丢;四字段并存:recommend 用 abstract+prefs、
|
||||
presubmit 用 targetJournal、format 用 formatJournalId(期刊 id 下拉))—— */
|
||||
@@ -121,6 +132,29 @@ export const useEduStore = defineStore('edu', {
|
||||
// Tracker「去写返修」带来的期刊名预填(方案 A:仅跳转,无 jobId 回写)
|
||||
prefillJournal: '',
|
||||
},
|
||||
|
||||
/* —— P5 风格清单(GET /ppt/styles;与 job 生命周期无关,clearPpt/删除都不清)—— */
|
||||
pptStyles: [], // [{id,label,display_name,description,bg,accent}]
|
||||
pptStylesStatus: 'idle', // idle | loading | ready | error(error 允许下次面板挂载重试)
|
||||
|
||||
/* —— P5 科研汇报助手(PPT 大纲 job;纯内存态,切换面板不丢,刷新丢失)—— */
|
||||
ppt: {
|
||||
job: null, // 后端全量 job(pages/warnings/status...;raw_text 不回传)
|
||||
status: 'idle', // idle | loading | editing | building | done | error
|
||||
error: null,
|
||||
saving: false, // PATCH 进行中(编辑器保存按钮 loading)
|
||||
buildMeta: null, // 最近一次 {slides, bytes}(编辑后清空,提示重新生成)
|
||||
createMeta: null, // 生成期 meta(llm_calls/target_pages... 供页头展示)
|
||||
// 生成表单(跨菜单保留;style/scenario/minutes 在创建时固化进 job)
|
||||
form: {
|
||||
mode: 'pdf', // 'pdf' | 'text'
|
||||
scenario: 'group_meeting',
|
||||
minutes: 15,
|
||||
styleId: 'academic-minimal',
|
||||
file: null, // File(pdf 模式;每次生成后保留,便于重试)
|
||||
text: '', // 文本模式草稿
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
getters: {
|
||||
@@ -371,6 +405,126 @@ export const useEduStore = defineStore('edu', {
|
||||
clearRebuttal() {
|
||||
this.rebuttal = this._rebuttalBase();
|
||||
},
|
||||
|
||||
/* —— P5 科研汇报助手 actions(job 为后端权威,本地只镜像)—— */
|
||||
/** 风格清单(面板挂载时取一次;失败留空 → 副标题回落「多风格」文案) */
|
||||
async pptLoadStyles() {
|
||||
if (this.pptStylesStatus === 'loading' || this.pptStylesStatus === 'ready') {
|
||||
return this.pptStyles;
|
||||
}
|
||||
this.pptStylesStatus = 'loading';
|
||||
try {
|
||||
const res = await pptStyles();
|
||||
const items = Array.isArray(res && res.items) ? res.items : [];
|
||||
this.pptStyles = items;
|
||||
this.pptStylesStatus = 'ready';
|
||||
} catch {
|
||||
this.pptStyles = [];
|
||||
this.pptStylesStatus = 'error';
|
||||
}
|
||||
return this.pptStyles;
|
||||
},
|
||||
|
||||
_pptBase() {
|
||||
return {
|
||||
job: null,
|
||||
status: 'idle',
|
||||
error: null,
|
||||
saving: false,
|
||||
buildMeta: null,
|
||||
createMeta: null,
|
||||
form: { ...this.ppt.form },
|
||||
};
|
||||
},
|
||||
/** 生成大纲(pdf 上传 / 文本粘贴,按 form.mode 分派;同步长调用) */
|
||||
async pptGenerate() {
|
||||
const f = this.ppt.form;
|
||||
if (f.mode === 'pdf' && !f.file) {
|
||||
this.ppt.status = 'error';
|
||||
this.ppt.error = '请先选择 PDF 文件';
|
||||
return null;
|
||||
}
|
||||
if (f.mode === 'text' && (!f.text || f.text.trim().length < 300)) {
|
||||
this.ppt.status = 'error';
|
||||
this.ppt.error = '正文过短(<300 字),无法生成汇报大纲';
|
||||
return null;
|
||||
}
|
||||
this.ppt.status = 'loading';
|
||||
this.ppt.error = null;
|
||||
try {
|
||||
let res;
|
||||
if (f.mode === 'pdf') {
|
||||
const fd = new FormData();
|
||||
fd.append('file', f.file);
|
||||
fd.append('scenario', f.scenario);
|
||||
fd.append('minutes', String(f.minutes));
|
||||
fd.append('style_id', f.styleId);
|
||||
res = await pptCreateFromPdf(fd);
|
||||
} else {
|
||||
res = await pptCreateJob({
|
||||
text: f.text, scenario: f.scenario, minutes: f.minutes, style_id: f.styleId,
|
||||
});
|
||||
}
|
||||
this.ppt.job = res.job;
|
||||
this.ppt.createMeta = res.meta || null;
|
||||
this.ppt.buildMeta = null;
|
||||
this.ppt.status = 'editing';
|
||||
return res.job;
|
||||
} catch (err) {
|
||||
this.ppt.status = 'error';
|
||||
this.ppt.error = reviewErrorMessage(err);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
/** 保存编辑后的完整 pages(后端结构清洗 + evidence 重验;失败保留编辑态) */
|
||||
async pptSave(pages) {
|
||||
if (!this.ppt.job) return null;
|
||||
this.ppt.saving = true;
|
||||
try {
|
||||
const res = await pptSavePages(this.ppt.job.id, pages);
|
||||
this.ppt.job = res.job;
|
||||
this.ppt.buildMeta = null; // 旧 pptx 失效(后端亦清 pptx_path)
|
||||
return res.job;
|
||||
} catch (err) {
|
||||
this.ppt.error = reviewErrorMessage(err);
|
||||
return null;
|
||||
} finally {
|
||||
this.ppt.saving = false;
|
||||
}
|
||||
},
|
||||
/** 大纲 → draft.pptx(python-pptx 本地生成) */
|
||||
async pptBuild() {
|
||||
if (!this.ppt.job) return null;
|
||||
this.ppt.status = 'building';
|
||||
this.ppt.error = null;
|
||||
try {
|
||||
const res = await pptBuildJob(this.ppt.job.id);
|
||||
this.ppt.job = res.job;
|
||||
this.ppt.buildMeta = res.meta || null;
|
||||
this.ppt.status = 'done';
|
||||
return res.job;
|
||||
} catch (err) {
|
||||
this.ppt.status = 'error';
|
||||
this.ppt.error = reviewErrorMessage(err);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
/** 删除 job(行 + 产物目录);保留表单便于立即重做 */
|
||||
async pptDelete() {
|
||||
const id = this.ppt.job?.id;
|
||||
this.ppt = this._pptBase();
|
||||
if (!id) return { ok: true };
|
||||
try {
|
||||
await pptDeleteJob(id);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, error: reviewErrorMessage(err) };
|
||||
}
|
||||
},
|
||||
/** 回到生成表单(保留表单值,清 job/产物状态) */
|
||||
clearPpt() {
|
||||
this.ppt = this._pptBase();
|
||||
},
|
||||
async submitRecommend(payload) {
|
||||
this.recommendStatus = 'loading';
|
||||
this.recommendError = null;
|
||||
@@ -426,18 +580,20 @@ export const useEduStore = defineStore('edu', {
|
||||
this.reformatError = null;
|
||||
this.clearCompile();
|
||||
},
|
||||
/* —— P3a 编译 PDF(tectonic 单槽队列;约 10-30 秒)—— */
|
||||
/* —— P3b 编译 PDF(tectonic 单槽队列;约 10-30 秒,首次拉 bundle 1-3 分钟)—— */
|
||||
async submitCompile(jobId) {
|
||||
if (this.compileStatus === 'loading') return null;
|
||||
this.compileStatus = 'loading';
|
||||
this.compileError = null;
|
||||
this.compileLogTail = '';
|
||||
if (!this.compileResult) this.compileFirstHint = true; // 首次编译提示条
|
||||
const fd = new FormData();
|
||||
fd.append('job_id', jobId);
|
||||
try {
|
||||
const payload = await compileTex(fd);
|
||||
this.compileResult = payload;
|
||||
this.compileStatus = 'ready';
|
||||
this._loadCompileBlob(payload.pdf_url); // 异步换 blob URL,失败回退直链
|
||||
return payload;
|
||||
} catch (err) {
|
||||
this.compileStatus = 'error';
|
||||
@@ -446,11 +602,83 @@ export const useEduStore = defineStore('edu', {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
/** PDF → blob URL(预览/下载不暴露后端路径);失败静默回退 pdf_url 直链 */
|
||||
async _loadCompileBlob(url) {
|
||||
if (!url) return;
|
||||
try {
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) return;
|
||||
const blob = await resp.blob();
|
||||
if (this.compilePdfUrl) URL.revokeObjectURL(this.compilePdfUrl);
|
||||
this.compilePdfUrl = URL.createObjectURL(blob);
|
||||
} catch { /* 保持 pdf_url 直链 */ }
|
||||
},
|
||||
clearCompile() {
|
||||
if (this.compilePdfUrl) {
|
||||
try { URL.revokeObjectURL(this.compilePdfUrl); } catch { /* noop */ }
|
||||
}
|
||||
this.compileResult = null;
|
||||
this.compileStatus = 'idle';
|
||||
this.compileError = null;
|
||||
this.compileLogTail = '';
|
||||
this.compilePdfUrl = null;
|
||||
this.compileFirstHint = false;
|
||||
},
|
||||
/* —— P4 项目 zip 两段式 —— */
|
||||
/** 之① inspect:安全解压 + 主文件识别(1h TTL,run 前不占编译队列) */
|
||||
async submitZipInspect() {
|
||||
if (!this.zipFile || this.zipInspectStatus === 'loading') return null;
|
||||
this.zipInspectStatus = 'loading';
|
||||
this.zipInspectError = null;
|
||||
this.zipInspect = null;
|
||||
this.clearCompile(); // 新 zip 使旧 PDF 失效
|
||||
const fd = new FormData();
|
||||
fd.append('file', this.zipFile);
|
||||
try {
|
||||
const payload = await inspectZip(fd);
|
||||
this.zipInspect = payload.meta;
|
||||
this.zipInspectStatus = 'ready';
|
||||
return payload;
|
||||
} catch (err) {
|
||||
this.zipInspectStatus = 'error';
|
||||
this.zipInspectError = reviewErrorMessage(err); // ZIP_UNSAFE/BOMB/INVALID 走后端原话
|
||||
return null;
|
||||
}
|
||||
},
|
||||
/** 之② run:原子消费 inspect → 编译(信封与 P3b 同构 → 复用 compile* 字段) */
|
||||
async submitZipCompile() {
|
||||
const jid = this.zipInspect?.job_id;
|
||||
if (!jid || this.compileStatus === 'loading') return null;
|
||||
this.compileStatus = 'loading';
|
||||
this.compileError = null;
|
||||
this.compileLogTail = '';
|
||||
if (!this.compileResult) this.compileFirstHint = true;
|
||||
try {
|
||||
const payload = await runZipCompile({ job_id: jid });
|
||||
this.compileResult = payload;
|
||||
this.compileStatus = 'ready';
|
||||
this._loadCompileBlob(payload.pdf_url);
|
||||
return payload;
|
||||
} catch (err) {
|
||||
this.compileStatus = 'error';
|
||||
this.compileError = reviewErrorMessage(err);
|
||||
// 失败信封带 compile_id + log_url(补充 3)→ log 尾仍走 err.detail
|
||||
this.compileLogTail = err.detail || '';
|
||||
// 失败也挂结果壳(仅 id/url):下载完整 log 用 /zip/{cid}/log(成功信封同构)
|
||||
if (err.compileId) {
|
||||
this.compileResult = {
|
||||
ok: false, compile_id: err.compileId, log_url: err.logUrl || null,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
},
|
||||
clearZip() {
|
||||
this.zipFile = null;
|
||||
this.zipInspect = null;
|
||||
this.zipInspectStatus = 'idle';
|
||||
this.zipInspectError = null;
|
||||
this.clearCompile();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||