diff --git a/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$I00E74J.jpg b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$I00E74J.jpg new file mode 100644 index 0000000000000000000000000000000000000000..481905b03125ed884d486852e4ed5b3f10de58ca Binary files /dev/null and b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$I00E74J.jpg differ diff --git a/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$I04VJDB.txt b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$I04VJDB.txt new file mode 100644 index 0000000000000000000000000000000000000000..3ebf5a0806445428feaff172ba14367d0835de20 Binary files /dev/null and b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$I04VJDB.txt differ diff --git a/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$I2MQAX1.pdf b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$I2MQAX1.pdf new file mode 100644 index 0000000000000000000000000000000000000000..120b14e2e38ed8932e5f3f265e3f615b35e86d95 Binary files /dev/null and b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$I2MQAX1.pdf differ diff --git a/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$I529DC7.png b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$I529DC7.png new file mode 100644 index 0000000000000000000000000000000000000000..cda48fc2adac97e62eb4a4ed015c2ad2aac7fedc Binary files /dev/null and b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$I529DC7.png differ diff --git a/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$I7D7KKM.jpg b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$I7D7KKM.jpg new file mode 100644 index 0000000000000000000000000000000000000000..ab90ca3ecfb19553f09a98d64ad769a3d4893b4a Binary files /dev/null and b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$I7D7KKM.jpg differ diff --git a/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IA7J9YA.docx b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IA7J9YA.docx new file mode 100644 index 0000000000000000000000000000000000000000..a6fedc26d517bcc13a17e417a22278341d305468 Binary files /dev/null and b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IA7J9YA.docx differ diff --git a/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IALBAPU.txt b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IALBAPU.txt new file mode 100644 index 0000000000000000000000000000000000000000..9b4c48946aabae469bb7863519c950b9f999897d Binary files /dev/null and b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IALBAPU.txt differ diff --git a/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IB6I2XK.png b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IB6I2XK.png new file mode 100644 index 0000000000000000000000000000000000000000..ffab4567418066bbee328f9b50156830de1490d3 Binary files /dev/null and b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IB6I2XK.png differ diff --git a/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$ID7299H b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$ID7299H new file mode 100644 index 0000000000000000000000000000000000000000..0d3686940091b05023fd55965f6f324989946860 Binary files /dev/null and b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$ID7299H differ diff --git a/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IEBS53S.txt b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IEBS53S.txt new file mode 100644 index 0000000000000000000000000000000000000000..18128f3dd81eb82f911b32491716ae1631c87c0e Binary files /dev/null and b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IEBS53S.txt differ diff --git a/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IFYIHXZ.jpg b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IFYIHXZ.jpg new file mode 100644 index 0000000000000000000000000000000000000000..1a97f779cb99b19e75ce60bce903295b02606704 Binary files /dev/null and b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IFYIHXZ.jpg differ diff --git a/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IHCDLMF.js b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IHCDLMF.js new file mode 100644 index 0000000000000000000000000000000000000000..cf746984479cca994ddd2fa3cefd00e125438534 Binary files /dev/null and b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IHCDLMF.js differ diff --git a/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IINFFGJ b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IINFFGJ new file mode 100644 index 0000000000000000000000000000000000000000..738441ddb19abcf01e0d71158e735ffd1baf863e Binary files /dev/null and b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IINFFGJ differ diff --git a/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IJDLI7J.docx b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IJDLI7J.docx new file mode 100644 index 0000000000000000000000000000000000000000..59d0035a798ea3c0fd4b735aa5bcdf5825c39458 Binary files /dev/null and b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IJDLI7J.docx differ diff --git a/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IKDVQ6V.jpg b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IKDVQ6V.jpg new file mode 100644 index 0000000000000000000000000000000000000000..27f2f6dfe274fb9800dd7055603570479adf1c76 Binary files /dev/null and b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IKDVQ6V.jpg differ diff --git a/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$ILK7V2E.txt b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$ILK7V2E.txt new file mode 100644 index 0000000000000000000000000000000000000000..8868d56a306543a0432c1658e0dd218d877afce8 Binary files /dev/null and b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$ILK7V2E.txt differ diff --git a/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$INQ4QNI.jpg b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$INQ4QNI.jpg new file mode 100644 index 0000000000000000000000000000000000000000..be0efe30e8efa62d42fe25433eb9fd6dad035ccd Binary files /dev/null and b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$INQ4QNI.jpg differ diff --git a/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IO1UTXL.txt b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IO1UTXL.txt new file mode 100644 index 0000000000000000000000000000000000000000..7c26e2312f57b07161eb1159b7108a2f482182d4 Binary files /dev/null and b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IO1UTXL.txt differ diff --git a/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$ITJV97I.txt b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$ITJV97I.txt new file mode 100644 index 0000000000000000000000000000000000000000..f37e04f31cf3cd0a5b1a17d3f8afe4d611491ddc Binary files /dev/null and b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$ITJV97I.txt differ diff --git a/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IV23KEN.dotm b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IV23KEN.dotm new file mode 100644 index 0000000000000000000000000000000000000000..3f799154e45d78c9e91310db2fcf249c968e9823 Binary files /dev/null and b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IV23KEN.dotm differ diff --git a/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IVC0V5I.lnk b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IVC0V5I.lnk new file mode 100644 index 0000000000000000000000000000000000000000..1eef8e13e313c376fe331a58d303c45a16489ce2 Binary files /dev/null and b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IVC0V5I.lnk differ diff --git a/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IXIUXTC.py b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IXIUXTC.py new file mode 100644 index 0000000000000000000000000000000000000000..d5e20af7548972bbb7265176127915fb4854a92d Binary files /dev/null and b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IXIUXTC.py differ diff --git a/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$R00E74J.jpg b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$R00E74J.jpg new file mode 100644 index 0000000000000000000000000000000000000000..29746ac138839481da6e37fceb0889faecac1868 --- /dev/null +++ b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$R00E74J.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c90134ddc47804ce2eb17e3fe859757db4c14eda29834b9747a610aae3d219eb +size 769428 diff --git a/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$R04VJDB.txt b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$R04VJDB.txt new file mode 100644 index 0000000000000000000000000000000000000000..f0e4ae37a55f2ebcec5c33253e12c20ae247393e --- /dev/null +++ b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$R04VJDB.txt @@ -0,0 +1 @@ +restore 0000006d-00000000-000050bf-708b24b133fed5049ff96a4fcb126551 \ No newline at end of file diff --git a/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$R2MQAX1.pdf b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$R2MQAX1.pdf new file mode 100644 index 0000000000000000000000000000000000000000..bc22d04383ecade3ef60086c30a74d50945619f7 --- /dev/null +++ b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$R2MQAX1.pdf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:40017ada62558766946a1de340fdfc3f332d42322cc3b1819ae4e9a4396bff98 +size 1211510 diff --git a/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$R529DC7.png b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$R529DC7.png new file mode 100644 index 0000000000000000000000000000000000000000..72e278529aa63cef227f32d057ab19fa9d70be04 --- /dev/null +++ b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$R529DC7.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0ebdf74d6da0d2ef13b6973737c143ae386d80b6e855e2a953243bac76f5e450 +size 119345 diff --git a/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$R7D7KKM.jpg b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$R7D7KKM.jpg new file mode 100644 index 0000000000000000000000000000000000000000..3979860cb159e8b1f2f472c4dae4d05c4288e054 --- /dev/null +++ b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$R7D7KKM.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4d1e6f8e38df9c529eef58bc91b4443f20b419b30751afc28afce8ce844148c3 +size 101039 diff --git a/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RA7J9YA.docx b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RA7J9YA.docx new file mode 100644 index 0000000000000000000000000000000000000000..29e1ed61046473391fb84a39a37a16c7687071e3 --- /dev/null +++ b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RA7J9YA.docx @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:be51e4935cc545417d84b1adb3f4ff634349da099c1634e9f120fdbb91cc7f53 +size 1046928 diff --git a/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RALBAPU.txt b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RALBAPU.txt new file mode 100644 index 0000000000000000000000000000000000000000..3838165cc3f4de06b548e66b4b4aa9ceb508ba02 --- /dev/null +++ b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RALBAPU.txt @@ -0,0 +1,2140 @@ +/* ============================================================ + * SmartPaper Authentication Worker + * + * 已实现: + * 1. 支付宝扫码授权 + * 2. RSA2调用 alipay.system.oauth.token + * 3. 创建或匹配SmartPaper用户 + * 4. Hugging Face创建登录请求 + * 5. Hugging Face轮询扫码状态 + * 6. 签发8小时短期登录会话 + * 7. 验证会话 + * 8. 注销会话 + * + * 固定业务规则: + * - 每位用户每天免费3篇论文 + * - 9.9元增加3篇长期有效论文 + * + * 尚未实现: + * - 每日免费篇数的实际扣减 + * - 论文生成任务与自动排队 + * - 支付订单和自动增加论文 + * ============================================================ */ + +const LOGIN_REQUEST_TTL_MS = 10 * 60 * 1000; +const SESSION_TTL_MS = 8 * 60 * 60 * 1000; + +const DAILY_FREE_PAPERS = 3; +const PAYMENT_AMOUNT_FEN = 990; +const PAYMENT_BONUS_PAPERS = 3; + + +/* ============================================================ + * 响应工具 + * ============================================================ */ + +function commonHeaders(contentType) { + return { + "Content-Type": contentType, + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "no-referrer", + "X-Frame-Options": "DENY", + }; +} + + +function jsonResponse(data, status = 200) { + return new Response( + JSON.stringify(data, null, 2), + { + status, + headers: commonHeaders( + "application/json; charset=utf-8" + ), + } + ); +} + + +function htmlResponse(title, body, status = 200) { + return new Response( + ` + + + + + + + ${escapeHtml(title)} + + + + + + ${body} + + `, + { + status, + headers: commonHeaders( + "text/html; charset=utf-8" + ), + } + ); +} + + +function escapeHtml(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + + +/* ============================================================ + * 请求工具 + * ============================================================ */ + +async function readJsonBody(request) { + try { + return await request.json(); + } catch { + return null; + } +} + + +function extractServiceKey(request) { + const authorization = + request.headers.get("Authorization") || ""; + + if (authorization.startsWith("Bearer ")) { + return authorization.slice(7).trim(); + } + + return ( + request.headers.get( + "X-SmartPaper-Service-Key" + ) || "" + ).trim(); +} + + +/* ============================================================ + * 随机数、摘要和HMAC + * ============================================================ */ + +function randomToken(byteLength = 32) { + const bytes = new Uint8Array(byteLength); + crypto.getRandomValues(bytes); + + let binary = ""; + + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + + return btoa(binary) + .replaceAll("+", "-") + .replaceAll("/", "_") + .replaceAll("=", ""); +} + + +async function sha256Bytes(value) { + const encoded = + new TextEncoder().encode(String(value)); + + return new Uint8Array( + await crypto.subtle.digest( + "SHA-256", + encoded + ) + ); +} + + +async function sha256Hex(value) { + const bytes = await sha256Bytes(value); + + return [...bytes] + .map((byte) => + byte.toString(16).padStart(2, "0") + ) + .join(""); +} + + +async function importHmacKey(secret) { + return crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { + name: "HMAC", + hash: "SHA-256", + }, + false, + ["sign"] + ); +} + + +async function hmacHex(value, secret) { + if (!secret) { + throw new Error( + "SESSION_SIGNING_SECRET未配置。" + ); + } + + const key = await importHmacKey(secret); + + const signature = + await crypto.subtle.sign( + "HMAC", + key, + new TextEncoder().encode(value) + ); + + return [...new Uint8Array(signature)] + .map((byte) => + byte.toString(16).padStart(2, "0") + ) + .join(""); +} + + +async function constantTimeTextEqual( + valueA, + valueB +) { + const bytesA = await sha256Bytes(valueA); + const bytesB = await sha256Bytes(valueB); + + if (bytesA.length !== bytesB.length) { + return false; + } + + let difference = 0; + + for (let i = 0; i < bytesA.length; i += 1) { + difference |= bytesA[i] ^ bytesB[i]; + } + + return difference === 0; +} + + +async function isAuthorizedServiceRequest( + request, + env +) { + if (!env.SMARTPAPER_SERVICE_KEY) { + return false; + } + + const suppliedKey = + extractServiceKey(request); + + if (!suppliedKey) { + return false; + } + + return constantTimeTextEqual( + suppliedKey, + env.SMARTPAPER_SERVICE_KEY + ); +} + + +async function requireServiceRequest( + request, + env +) { + const authorized = + await isAuthorizedServiceRequest( + request, + env + ); + + if (!authorized) { + return jsonResponse( + { + ok: false, + error: "unauthorized", + }, + 401 + ); + } + + return null; +} + + +/* ============================================================ + * 时间工具 + * ============================================================ */ + +function getBeijingTimestamp() { + const parts = + new Intl.DateTimeFormat( + "zh-CN", + { + timeZone: "Asia/Shanghai", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hourCycle: "h23", + } + ).formatToParts(new Date()); + + const values = Object.fromEntries( + parts + .filter((part) => + part.type !== "literal" + ) + .map((part) => [ + part.type, + part.value, + ]) + ); + + return ( + `${values.year}-${values.month}-${values.day} ` + + `${values.hour}:${values.minute}:${values.second}` + ); +} + + +function isExpired(isoDate) { + if (!isoDate) { + return true; + } + + const timestamp = Date.parse(isoDate); + + return ( + Number.isNaN(timestamp) || + timestamp <= Date.now() + ); +} + + +/* ============================================================ + * RSA2应用私钥和支付宝签名 + * ============================================================ */ + +function normalizePrivateKey( + privateKeyText +) { + return String(privateKeyText ?? "") + .replaceAll("\\n", "\n") + .replace( + /-----BEGIN PRIVATE KEY-----/g, + "" + ) + .replace( + /-----END PRIVATE KEY-----/g, + "" + ) + .replace( + /-----BEGIN RSA PRIVATE KEY-----/g, + "" + ) + .replace( + /-----END RSA PRIVATE KEY-----/g, + "" + ) + .replace(/\s+/g, ""); +} + + +function base64ToBytes(base64Text) { + const normalized = + String(base64Text ?? "") + .replace(/\s+/g, ""); + + const binary = atob(normalized); + + const bytes = + new Uint8Array(binary.length); + + for ( + let index = 0; + index < binary.length; + index += 1 + ) { + bytes[index] = + binary.charCodeAt(index); + } + + return bytes; +} + + +function arrayBufferToBase64(buffer) { + const bytes = new Uint8Array(buffer); + + let binary = ""; + + for ( + let index = 0; + index < bytes.length; + index += 1 + ) { + binary += String.fromCharCode( + bytes[index] + ); + } + + return btoa(binary); +} + + +async function importAlipayPrivateKey( + privateKeyText +) { + const normalizedKey = + normalizePrivateKey(privateKeyText); + + if (!normalizedKey) { + throw new Error( + "Cloudflare中没有配置支付宝应用私钥。" + ); + } + + let keyBytes; + + try { + keyBytes = + base64ToBytes(normalizedKey); + } catch { + throw new Error( + "支付宝应用私钥不是有效的Base64格式。" + ); + } + + try { + return await crypto.subtle.importKey( + "pkcs8", + keyBytes, + { + name: "RSASSA-PKCS1-v1_5", + hash: "SHA-256", + }, + false, + ["sign"] + ); + } catch (error) { + throw new Error( + "无法导入支付宝应用私钥。" + + "请确认该值是与支付宝应用公钥配对的" + + "RSA2 PKCS8应用私钥。" + + ` 原始错误:${String(error)}` + ); + } +} + + +function buildAlipaySignContent( + parameters +) { + return Object.entries(parameters) + .filter(([key, value]) => ( + key !== "sign" && + value !== undefined && + value !== null && + value !== "" + )) + .sort(([keyA], [keyB]) => { + if (keyA < keyB) return -1; + if (keyA > keyB) return 1; + return 0; + }) + .map(([key, value]) => + `${key}=${String(value)}` + ) + .join("&"); +} + + +async function createAlipaySignature( + parameters, + privateKeyText +) { + const contentToSign = + buildAlipaySignContent(parameters); + + const privateKey = + await importAlipayPrivateKey( + privateKeyText + ); + + const signature = + await crypto.subtle.sign( + { + name: "RSASSA-PKCS1-v1_5", + }, + privateKey, + new TextEncoder().encode( + contentToSign + ) + ); + + return arrayBufferToBase64( + signature + ); +} + + +/* ============================================================ + * 配置检查 + * ============================================================ */ + +function getMissingConfiguration(env) { + const requiredVariables = [ + "ALIPAY_APP_ID", + "ALIPAY_APP_PRIVATE_KEY", + "ALIPAY_GATEWAY", + "ALIPAY_REDIRECT_URI", + "SESSION_SIGNING_SECRET", + "SMARTPAPER_SERVICE_KEY", + ]; + + const missing = + requiredVariables.filter( + (name) => !env[name] + ); + + if (!env.DB) { + missing.push("DB binding"); + } + + return missing; +} + + +/* ============================================================ + * 支付宝登录请求 + * ============================================================ */ + +function buildAlipayAuthorizationUrl( + env, + state +) { + const authorizationUrl = new URL( + "https://openauth.alipay.com/oauth2/publicAppAuthorize.htm" + ); + + authorizationUrl.searchParams.set( + "app_id", + env.ALIPAY_APP_ID + ); + + authorizationUrl.searchParams.set( + "scope", + "auth_user" + ); + + authorizationUrl.searchParams.set( + "redirect_uri", + env.ALIPAY_REDIRECT_URI + ); + + authorizationUrl.searchParams.set( + "state", + state + ); + + return authorizationUrl.toString(); +} + + +async function createLoginRequestRecord(env) { + const state = randomToken(32); + const pollToken = randomToken(32); + + const stateHash = + await sha256Hex(state); + + const pollTokenHash = + await hmacHex( + pollToken, + env.SESSION_SIGNING_SECRET + ); + + const loginRequestId = + crypto.randomUUID(); + + const expiresAt = new Date( + Date.now() + LOGIN_REQUEST_TTL_MS + ).toISOString(); + + await env.DB.prepare( + `INSERT INTO oauth_states + ( + state_hash, + login_request_id, + status, + created_at, + expires_at, + poll_token_hash + ) + VALUES + ( + ?, + ?, + 'waiting', + CURRENT_TIMESTAMP, + ?, + ? + )` + ) + .bind( + stateHash, + loginRequestId, + expiresAt, + pollTokenHash + ) + .run(); + + return { + state, + pollToken, + loginRequestId, + expiresAt, + authorizationUrl: + buildAlipayAuthorizationUrl( + env, + state + ), + }; +} + + +async function handleManualAlipayLogin(env) { + const loginRequest = + await createLoginRequestRecord(env); + + return Response.redirect( + loginRequest.authorizationUrl, + 302 + ); +} + + +/* ============================================================ + * auth_code换取支付宝用户标识 + * ============================================================ */ + +async function exchangeAlipayAuthCode( + authCode, + env +) { + const parameters = { + app_id: env.ALIPAY_APP_ID, + method: + "alipay.system.oauth.token", + format: "JSON", + charset: "utf-8", + sign_type: "RSA2", + timestamp: + getBeijingTimestamp(), + version: "1.0", + grant_type: + "authorization_code", + code: authCode, + }; + + parameters.sign = + await createAlipaySignature( + parameters, + env.ALIPAY_APP_PRIVATE_KEY + ); + + const requestBody = + new URLSearchParams(); + + for ( + const [key, value] + of Object.entries(parameters) + ) { + requestBody.set( + key, + String(value) + ); + } + + const response = await fetch( + env.ALIPAY_GATEWAY, + { + method: "POST", + headers: { + "Content-Type": + "application/x-www-form-urlencoded;charset=utf-8", + }, + body: requestBody.toString(), + } + ); + + const rawText = + await response.text(); + + let payload; + + try { + payload = JSON.parse(rawText); + } catch { + throw new Error( + "支付宝返回了无法解析的内容。" + + `HTTP状态码:${response.status}` + ); + } + + const tokenData = + payload + .alipay_system_oauth_token_response; + + const errorData = + payload.error_response; + + if (errorData) { + throw new Error( + `支付宝接口错误:` + + `${errorData.sub_code || + errorData.code || + "unknown"};` + + `${errorData.sub_msg || + errorData.msg || + "未知错误"}` + ); + } + + if (!response.ok || !tokenData) { + throw new Error( + "支付宝令牌接口没有返回预期结果。" + ); + } + + if ( + tokenData.code && + String(tokenData.code) !== "10000" + ) { + throw new Error( + `支付宝接口错误:` + + `${tokenData.sub_code || + tokenData.code};` + + `${tokenData.sub_msg || + tokenData.msg || + "未知错误"}` + ); + } + + const openId = + tokenData.open_id || null; + + const userId = + tokenData.user_id || + tokenData.alipay_user_id || + null; + + if (!openId && !userId) { + throw new Error( + "支付宝授权成功," + + "但没有返回open_id或user_id。" + ); + } + + return { + openId, + userId, + }; +} + + +/* ============================================================ + * 用户账户 + * ============================================================ */ + +async function findExistingUser( + env, + openId, + alipayUserId +) { + if (openId) { + const userByOpenId = + await env.DB.prepare( + `SELECT + id, + role, + status, + bonus_papers + FROM users + WHERE alipay_open_id = ? + LIMIT 1` + ) + .bind(openId) + .first(); + + if (userByOpenId) { + return userByOpenId; + } + } + + if (alipayUserId) { + const userByUserId = + await env.DB.prepare( + `SELECT + id, + role, + status, + bonus_papers + FROM users + WHERE alipay_user_id = ? + LIMIT 1` + ) + .bind(alipayUserId) + .first(); + + if (userByUserId) { + return userByUserId; + } + } + + return null; +} + + +async function findOrCreateSmartPaperUser( + env, + openId, + alipayUserId +) { + const existingUser = + await findExistingUser( + env, + openId, + alipayUserId + ); + + if (existingUser) { + await env.DB.prepare( + `UPDATE users + SET + alipay_open_id = + COALESCE(?, alipay_open_id), + + alipay_user_id = + COALESCE(?, alipay_user_id), + + updated_at = + CURRENT_TIMESTAMP, + + last_login_at = + CURRENT_TIMESTAMP + + WHERE id = ?` + ) + .bind( + openId, + alipayUserId, + existingUser.id + ) + .run(); + + return { + id: existingUser.id, + role: existingUser.role, + status: existingUser.status, + bonusPapers: + existingUser.bonus_papers ?? 0, + isNew: false, + }; + } + + try { + const insertResult = + await env.DB.prepare( + `INSERT INTO users + ( + alipay_open_id, + alipay_user_id, + role, + status, + bonus_papers, + created_at, + updated_at, + last_login_at + ) + VALUES + ( + ?, + ?, + 'user', + 'active', + 0, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + )` + ) + .bind( + openId, + alipayUserId + ) + .run(); + + return { + id: + insertResult.meta.last_row_id, + role: "user", + status: "active", + bonusPapers: 0, + isNew: true, + }; + } catch (error) { + const userAfterConflict = + await findExistingUser( + env, + openId, + alipayUserId + ); + + if (userAfterConflict) { + return { + id: userAfterConflict.id, + role: userAfterConflict.role, + status: + userAfterConflict.status, + bonusPapers: + userAfterConflict + .bonus_papers ?? 0, + isNew: false, + }; + } + + throw error; + } +} + + +function formatUser(user) { + return { + id: + `U${String(user.id) + .padStart(6, "0")}`, + numeric_id: user.id, + role: user.role, + status: user.status, + bonus_papers: + user.bonus_papers ?? + user.bonusPapers ?? + 0, + daily_free_limit: + DAILY_FREE_PAPERS, + }; +} + + +/* ============================================================ + * 支付宝回调 + * ============================================================ */ + +async function handleAlipayCallback( + request, + env +) { + const url = new URL(request.url); + + const authCode = + url.searchParams.get("auth_code"); + + const state = + url.searchParams.get("state"); + + const returnedAppId = + url.searchParams.get("app_id"); + + if (!authCode) { + return htmlResponse( + "支付宝授权失败", + ` +
+

+ 未收到授权码 +

+ +

+ 用户可能取消了授权, + 或支付宝应用权限尚未生效。 +

+ + + 重新登录 + +
+ `, + 400 + ); + } + + if (!state) { + return htmlResponse( + "支付宝授权失败", + ` +
+

+ 缺少state参数 +

+ +

+ 为保护登录安全, + 本次请求已被拒绝。 +

+
+ `, + 400 + ); + } + + const stateHash = + await sha256Hex(state); + + const stateRecord = + await env.DB.prepare( + `SELECT + state_hash, + login_request_id, + status, + expires_at + FROM oauth_states + WHERE state_hash = ? + LIMIT 1` + ) + .bind(stateHash) + .first(); + + if (!stateRecord) { + return htmlResponse( + "支付宝授权失败", + ` +
+

+ 登录请求不存在 +

+
+ `, + 400 + ); + } + + if ( + stateRecord.status !== "waiting" + ) { + return htmlResponse( + "支付宝授权失败", + ` +
+

+ 登录请求已经使用 +

+ + + 创建新请求 + +
+ `, + 409 + ); + } + + if (isExpired(stateRecord.expires_at)) { + await env.DB.prepare( + `UPDATE oauth_states + SET status = 'expired' + WHERE state_hash = ?` + ) + .bind(stateHash) + .run(); + + return htmlResponse( + "支付宝授权失败", + ` +
+

+ 登录请求已经过期 +

+ + + 重新登录 + +
+ `, + 410 + ); + } + + if ( + returnedAppId && + returnedAppId !== + env.ALIPAY_APP_ID + ) { + return htmlResponse( + "支付宝授权失败", + ` +
+

+ APPID不匹配 +

+
+ `, + 400 + ); + } + + const claimResult = + await env.DB.prepare( + `UPDATE oauth_states + SET + status = 'exchanging', + used_at = CURRENT_TIMESTAMP + WHERE state_hash = ? + AND status = 'waiting'` + ) + .bind(stateHash) + .run(); + + if ( + !claimResult.success || + claimResult.meta.changes !== 1 + ) { + return htmlResponse( + "支付宝授权失败", + ` +
+

+ 登录请求已经被处理 +

+
+ `, + 409 + ); + } + + let alipayIdentity; + + try { + alipayIdentity = + await exchangeAlipayAuthCode( + authCode, + env + ); + } catch (error) { + await env.DB.prepare( + `UPDATE oauth_states + SET status = + 'token_exchange_failed' + WHERE state_hash = ?` + ) + .bind(stateHash) + .run(); + + return htmlResponse( + "支付宝身份交换失败", + ` +
+

+ 未能取得支付宝用户标识 +

+ +

+ + ${escapeHtml(String(error))} + +

+ + + 创建新请求 + +
+ `, + 500 + ); + } + + let smartPaperUser; + + try { + smartPaperUser = + await findOrCreateSmartPaperUser( + env, + alipayIdentity.openId, + alipayIdentity.userId + ); + } catch (error) { + await env.DB.prepare( + `UPDATE oauth_states + SET status = + 'user_creation_failed' + WHERE state_hash = ?` + ) + .bind(stateHash) + .run(); + + return htmlResponse( + "SmartPaper账户创建失败", + ` +
+

+ 无法建立用户账户 +

+ +

+ + ${escapeHtml(String(error))} + +

+
+ `, + 500 + ); + } + + if ( + smartPaperUser.status !== "active" + ) { + await env.DB.prepare( + `UPDATE oauth_states + SET + status = 'user_blocked', + user_id = ? + WHERE state_hash = ?` + ) + .bind( + smartPaperUser.id, + stateHash + ) + .run(); + + return htmlResponse( + "SmartPaper账户不可用", + ` +
+

+ 账户已被停用 +

+
+ `, + 403 + ); + } + + await env.DB.prepare( + `UPDATE oauth_states + SET + status = 'authenticated', + user_id = ? + WHERE state_hash = ? + AND status = 'exchanging'` + ) + .bind( + smartPaperUser.id, + stateHash + ) + .run(); + + return htmlResponse( + "支付宝身份识别成功", + ` +
+

+ 支付宝身份识别成功 +

+ +

+ SmartPaper用户: + + ${escapeHtml( + formatUser( + smartPaperUser + ).id + )} + +

+ +

+ 请返回电脑上的SmartPaper页面。 + 系统将自动完成登录。 +

+ +

+ 本页面不会显示支付宝用户标识、 + 访问令牌或SmartPaper会话令牌。 +

+
+ ` + ); +} + + +/* ============================================================ + * Hugging Face创建登录请求 + * POST /login/create + * ============================================================ */ + +async function handleLoginCreate( + request, + env +) { + const unauthorized = + await requireServiceRequest( + request, + env + ); + + if (unauthorized) { + return unauthorized; + } + + const loginRequest = + await createLoginRequestRecord(env); + + return jsonResponse({ + ok: true, + status: "waiting", + login_request_id: + loginRequest.loginRequestId, + poll_token: + loginRequest.pollToken, + authorization_url: + loginRequest.authorizationUrl, + qr_content: + loginRequest.authorizationUrl, + expires_at: + loginRequest.expiresAt, + poll_interval_seconds: 2, + }); +} + + +/* ============================================================ + * Hugging Face查询登录状态 + * POST /login/status + * ============================================================ */ + +async function handleLoginStatus( + request, + env +) { + const unauthorized = + await requireServiceRequest( + request, + env + ); + + if (unauthorized) { + return unauthorized; + } + + const body = + await readJsonBody(request); + + const loginRequestId = + String( + body?.login_request_id || "" + ).trim(); + + const pollToken = + String( + body?.poll_token || "" + ).trim(); + + if ( + !loginRequestId || + !pollToken + ) { + return jsonResponse( + { + ok: false, + error: "invalid_request", + required: [ + "login_request_id", + "poll_token", + ], + }, + 400 + ); + } + + const pollTokenHash = + await hmacHex( + pollToken, + env.SESSION_SIGNING_SECRET + ); + + let record = + await env.DB.prepare( + `SELECT + login_request_id, + status, + user_id, + expires_at, + session_issued_at + FROM oauth_states + WHERE login_request_id = ? + AND poll_token_hash = ? + LIMIT 1` + ) + .bind( + loginRequestId, + pollTokenHash + ) + .first(); + + if (!record) { + return jsonResponse( + { + ok: false, + error: + "login_request_not_found", + }, + 404 + ); + } + + if ( + isExpired(record.expires_at) && + ![ + "completed", + "authenticated", + "issuing", + ].includes(record.status) + ) { + await env.DB.prepare( + `UPDATE oauth_states + SET status = 'expired' + WHERE login_request_id = ? + AND poll_token_hash = ?` + ) + .bind( + loginRequestId, + pollTokenHash + ) + .run(); + + return jsonResponse( + { + ok: false, + status: "expired", + }, + 410 + ); + } + + if ( + record.status === "waiting" || + record.status === "exchanging" || + record.status === "issuing" + ) { + return jsonResponse({ + ok: true, + status: "pending", + }); + } + + if ( + [ + "expired", + "token_exchange_failed", + "user_creation_failed", + "user_blocked", + ].includes(record.status) + ) { + return jsonResponse( + { + ok: false, + status: "failed", + reason: record.status, + }, + 400 + ); + } + + if (record.status === "completed") { + return jsonResponse( + { + ok: false, + status: "already_delivered", + message: + "该登录结果已经领取,请重新扫码登录。", + }, + 409 + ); + } + + if ( + record.status !== "authenticated" || + !record.user_id + ) { + return jsonResponse( + { + ok: false, + status: "invalid_state", + current_status: + record.status, + }, + 409 + ); + } + + const user = + await env.DB.prepare( + `SELECT + id, + role, + status, + bonus_papers + FROM users + WHERE id = ? + LIMIT 1` + ) + .bind(record.user_id) + .first(); + + if (!user) { + return jsonResponse( + { + ok: false, + error: "user_not_found", + }, + 404 + ); + } + + if (user.status !== "active") { + await env.DB.prepare( + `UPDATE oauth_states + SET status = 'user_blocked' + WHERE login_request_id = ?` + ) + .bind(loginRequestId) + .run(); + + return jsonResponse( + { + ok: false, + error: "user_blocked", + }, + 403 + ); + } + + const claimResult = + await env.DB.prepare( + `UPDATE oauth_states + SET status = 'issuing' + WHERE login_request_id = ? + AND poll_token_hash = ? + AND status = 'authenticated' + AND session_issued_at IS NULL` + ) + .bind( + loginRequestId, + pollTokenHash + ) + .run(); + + if ( + !claimResult.success || + claimResult.meta.changes !== 1 + ) { + return jsonResponse({ + ok: true, + status: "pending", + }); + } + + const sessionToken = + randomToken(48); + + const sessionHash = + await hmacHex( + sessionToken, + env.SESSION_SIGNING_SECRET + ); + + const sessionExpiresAt = + new Date( + Date.now() + SESSION_TTL_MS + ).toISOString(); + + try { + await env.DB.batch([ + env.DB.prepare( + `INSERT INTO login_sessions + ( + session_hash, + user_id, + created_at, + expires_at, + revoked_at + ) + VALUES + ( + ?, + ?, + CURRENT_TIMESTAMP, + ?, + NULL + )` + ).bind( + sessionHash, + user.id, + sessionExpiresAt + ), + + env.DB.prepare( + `UPDATE oauth_states + SET + status = 'completed', + session_issued_at = + CURRENT_TIMESTAMP + WHERE login_request_id = ? + AND status = 'issuing'` + ).bind(loginRequestId), + ]); + } catch (error) { + await env.DB.prepare( + `UPDATE oauth_states + SET status = 'authenticated' + WHERE login_request_id = ? + AND status = 'issuing'` + ) + .bind(loginRequestId) + .run(); + + throw error; + } + + return jsonResponse({ + ok: true, + status: "authenticated", + session_token: sessionToken, + session_expires_at: + sessionExpiresAt, + user: formatUser(user), + }); +} + + +/* ============================================================ + * 验证SmartPaper会话 + * POST /session/verify + * ============================================================ */ + +async function handleSessionVerify( + request, + env +) { + const unauthorized = + await requireServiceRequest( + request, + env + ); + + if (unauthorized) { + return unauthorized; + } + + const body = + await readJsonBody(request); + + const sessionToken = + String( + body?.session_token || "" + ).trim(); + + if (!sessionToken) { + return jsonResponse( + { + ok: false, + error: + "session_token_required", + }, + 400 + ); + } + + const sessionHash = + await hmacHex( + sessionToken, + env.SESSION_SIGNING_SECRET + ); + + const session = + await env.DB.prepare( + `SELECT + s.session_hash, + s.expires_at, + s.revoked_at, + + u.id, + u.role, + u.status, + u.bonus_papers + + FROM login_sessions AS s + + INNER JOIN users AS u + ON u.id = s.user_id + + WHERE s.session_hash = ? + LIMIT 1` + ) + .bind(sessionHash) + .first(); + + if (!session) { + return jsonResponse( + { + ok: false, + valid: false, + error: "invalid_session", + }, + 401 + ); + } + + if (session.revoked_at) { + return jsonResponse( + { + ok: false, + valid: false, + error: "session_revoked", + }, + 401 + ); + } + + if (isExpired(session.expires_at)) { + return jsonResponse( + { + ok: false, + valid: false, + error: "session_expired", + }, + 401 + ); + } + + if (session.status !== "active") { + return jsonResponse( + { + ok: false, + valid: false, + error: "user_blocked", + }, + 403 + ); + } + + return jsonResponse({ + ok: true, + valid: true, + session_expires_at: + session.expires_at, + user: formatUser(session), + }); +} + + +/* ============================================================ + * 注销SmartPaper会话 + * POST /logout + * ============================================================ */ + +async function handleLogout( + request, + env +) { + const unauthorized = + await requireServiceRequest( + request, + env + ); + + if (unauthorized) { + return unauthorized; + } + + const body = + await readJsonBody(request); + + const sessionToken = + String( + body?.session_token || "" + ).trim(); + + if (!sessionToken) { + return jsonResponse( + { + ok: false, + error: + "session_token_required", + }, + 400 + ); + } + + const sessionHash = + await hmacHex( + sessionToken, + env.SESSION_SIGNING_SECRET + ); + + await env.DB.prepare( + `UPDATE login_sessions + SET revoked_at = + COALESCE( + revoked_at, + CURRENT_TIMESTAMP + ) + WHERE session_hash = ?` + ) + .bind(sessionHash) + .run(); + + return jsonResponse({ + ok: true, + logged_out: true, + }); +} + + +/* ============================================================ + * Worker主入口 + * ============================================================ */ + +export default { + async fetch(request, env) { + const url = new URL(request.url); + + try { + if (request.method === "OPTIONS") { + return new Response(null, { + status: 204, + }); + } + + if (url.pathname === "/health") { + const missing = + getMissingConfiguration(env); + + return jsonResponse({ + ok: missing.length === 0, + service: + "smartpaper-auth", + stage: + "smartpaper-session-backend", + configuration_ready: + missing.length === 0, + missing, + rules: { + daily_free_papers: + DAILY_FREE_PAPERS, + payment_amount_fen: + PAYMENT_AMOUNT_FEN, + payment_bonus_papers: + PAYMENT_BONUS_PAPERS, + session_hours: 8, + }, + time: + new Date().toISOString(), + }); + } + + if (url.pathname === "/db-test") { + if (!env.DB) { + return jsonResponse( + { + ok: false, + error: + "DB binding is missing", + }, + 500 + ); + } + + const result = + await env.DB.prepare( + "SELECT 1 AS connected" + ).first(); + + return jsonResponse({ + ok: true, + database: + "smartpaper-db", + connected: + result?.connected === 1, + }); + } + + if (url.pathname === "/privacy") { + return htmlResponse( + "SmartPaper隐私说明", + ` +
+

+ SmartPaper隐私说明 +

+ +

+ SmartPaper使用支付宝授权返回的 + 用户唯一标识建立用户账户。 +

+ +

+ 该标识用于用户登录、 + 每日免费3篇论文的额度管理、 + 长期有效论文管理、任务排队、 + 失败退还和异常滥用防范。 +

+ +

+ SmartPaper原则上不收集身份证号码、 + 真实姓名、手机号、通讯录或其他 + 与上述目的无关的信息。 +

+ +

+ 当前页面为开发测试阶段隐私说明。 +

+
+ ` + ); + } + + if ( + url.pathname === "/alipay/login" && + request.method === "GET" + ) { + return await handleManualAlipayLogin( + env + ); + } + + if ( + url.pathname === + "/alipay/callback" && + request.method === "GET" + ) { + return await handleAlipayCallback( + request, + env + ); + } + + if ( + url.pathname === + "/login/create" && + request.method === "POST" + ) { + return await handleLoginCreate( + request, + env + ); + } + + if ( + url.pathname === + "/login/status" && + request.method === "POST" + ) { + return await handleLoginStatus( + request, + env + ); + } + + if ( + url.pathname === + "/session/verify" && + request.method === "POST" + ) { + return await handleSessionVerify( + request, + env + ); + } + + if ( + url.pathname === "/logout" && + request.method === "POST" + ) { + return await handleLogout( + request, + env + ); + } + + if ( + url.pathname === "/" && + request.method === "GET" + ) { + return htmlResponse( + "SmartPaper支付宝登录测试", + ` +
+

+ SmartPaper支付宝登录测试 +

+ +

+ 支付宝用户识别和SmartPaper + 8小时会话后端已经启用。 +

+ +

+ 点击按钮后, + 请使用手机支付宝扫码并确认授权。 +

+ + + 开始支付宝扫码登录测试 + + + +
+ ` + ); + } + + return jsonResponse( + { + ok: false, + error: "not_found", + path: url.pathname, + }, + 404 + ); + } catch (error) { + console.error(error); + + return jsonResponse( + { + ok: false, + error: "internal_error", + detail: String(error), + }, + 500 + ); + } + }, +}; \ No newline at end of file diff --git a/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RB6I2XK.png b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RB6I2XK.png new file mode 100644 index 0000000000000000000000000000000000000000..e65c6940b435b5dbdd6a3083c15b6136b85599c1 Binary files /dev/null and b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RB6I2XK.png differ diff --git a/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/.gitattributes b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/.gitattributes new file mode 100644 index 0000000000000000000000000000000000000000..a6344aac8c09253b3b630fb776ae94478aa0275b --- /dev/null +++ b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/.gitattributes @@ -0,0 +1,35 @@ +*.7z filter=lfs diff=lfs merge=lfs -text +*.arrow filter=lfs diff=lfs merge=lfs -text +*.bin filter=lfs diff=lfs merge=lfs -text +*.bz2 filter=lfs diff=lfs merge=lfs -text +*.ckpt filter=lfs diff=lfs merge=lfs -text +*.ftz filter=lfs diff=lfs merge=lfs -text +*.gz filter=lfs diff=lfs merge=lfs -text +*.h5 filter=lfs diff=lfs merge=lfs -text +*.joblib filter=lfs diff=lfs merge=lfs -text +*.lfs.* filter=lfs diff=lfs merge=lfs -text +*.mlmodel filter=lfs diff=lfs merge=lfs -text +*.model filter=lfs diff=lfs merge=lfs -text +*.msgpack filter=lfs diff=lfs merge=lfs -text +*.npy filter=lfs diff=lfs merge=lfs -text +*.npz filter=lfs diff=lfs merge=lfs -text +*.onnx filter=lfs diff=lfs merge=lfs -text +*.ot filter=lfs diff=lfs merge=lfs -text +*.parquet filter=lfs diff=lfs merge=lfs -text +*.pb filter=lfs diff=lfs merge=lfs -text +*.pickle filter=lfs diff=lfs merge=lfs -text +*.pkl filter=lfs diff=lfs merge=lfs -text +*.pt filter=lfs diff=lfs merge=lfs -text +*.pth filter=lfs diff=lfs merge=lfs -text +*.rar filter=lfs diff=lfs merge=lfs -text +*.safetensors filter=lfs diff=lfs merge=lfs -text +saved_model/**/* filter=lfs diff=lfs merge=lfs -text +*.tar.* filter=lfs diff=lfs merge=lfs -text +*.tar filter=lfs diff=lfs merge=lfs -text +*.tflite filter=lfs diff=lfs merge=lfs -text +*.tgz filter=lfs diff=lfs merge=lfs -text +*.wasm filter=lfs diff=lfs merge=lfs -text +*.xz filter=lfs diff=lfs merge=lfs -text +*.zip filter=lfs diff=lfs merge=lfs -text +*.zst filter=lfs diff=lfs merge=lfs -text +*tfevents* filter=lfs diff=lfs merge=lfs -text diff --git a/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/.streamlit/config.toml b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/.streamlit/config.toml new file mode 100644 index 0000000000000000000000000000000000000000..66cb33be370cedd45aa4a922b2b8c02187d038ff --- /dev/null +++ b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/.streamlit/config.toml @@ -0,0 +1,8 @@ +[server] +enableCORS = false +enableXsrfProtection = false +enableWebsocketCompression = false +maxUploadSize = 1024 + +[browser] +gatherUsageStats = false \ No newline at end of file diff --git a/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/.streamlit/desktop.ini b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/.streamlit/desktop.ini new file mode 100644 index 0000000000000000000000000000000000000000..cf8cdc8ab9b7e23d435e3b7964e4f3303263211c --- /dev/null +++ b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/.streamlit/desktop.ini @@ -0,0 +1,2 @@ +[.ShellClassInfo] +IconResource=C:\Program Files\Google\Drive File Stream\129.0.1.0\GoogleDriveFS.exe,27 diff --git a/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/HF_COMMIT_SHA.txt b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/HF_COMMIT_SHA.txt new file mode 100644 index 0000000000000000000000000000000000000000..48070c921c344319743b6362f001af5569c481fa --- /dev/null +++ b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/HF_COMMIT_SHA.txt @@ -0,0 +1 @@ +af60fe1bdcbab60f47f06586cca03210f7f61026 diff --git a/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/Plaintext b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/Plaintext new file mode 100644 index 0000000000000000000000000000000000000000..9161716591c7603a06e1bd5278478b9ff20fbb95 --- /dev/null +++ b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/Plaintext @@ -0,0 +1,11 @@ +project_root/ +├── app.py                 (主应用入口:UI界面与流程调度) +├── config.py              (静态配置:提示词字典与基础参数) +├── core/ +│   └── pipeline_agent.py  (核心大脑:SSciPipelineAgent 类) +│   ├── rag_engine.py       🌟 [新建] 专门负责处理 20+ 篇大体量文献的切块与按需检索 +│   ├── meta_extractor.py   🌟 [新建] 专门负责从文献中榨取 JSON 格式的黄金价值数据 +│   └── qualitative_engine.py 🌟 [新建] 专门定性研究 +└── utils/ +    ├── file_parser.py     (工具:PDF/Word/Excel解析) +    └── docx_builder.py    (工具:Markdown转Word渲染器) \ No newline at end of file diff --git a/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/README.md b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/README.md new file mode 100644 index 0000000000000000000000000000000000000000..b919b367c18bb82d31bde873b1dfc4d5b3d49d02 --- /dev/null +++ b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/README.md @@ -0,0 +1,14 @@ +--- +title: Agent Article +emoji: 📊 +colorFrom: blue +colorTo: green +sdk: streamlit +app_file: app.py +pinned: false +short_description: Agent Article +sdk_version: 1.38.0 +python_version: 3.11 +--- + +Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference diff --git a/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/app.py b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/app.py new file mode 100644 index 0000000000000000000000000000000000000000..88bbd22c4389d44a1b5ce638a63f9a76a808b5f4 --- /dev/null +++ b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/app.py @@ -0,0 +1,1378 @@ +# -*- coding: utf-8 -*- +"""Smart Draft - Dual Track (AMJ Empirical vs ASQ Qualitative)""" +from core.meta_extractor import extract_golden_metadata +from core.rag_engine import initialize_rag +from core.qualitative_engine import get_qualitative_engine +from core.quantitative_engine import get_quantitative_engine +from core.production_guard import build_validated_package, replace_abstract +import streamlit as st + +st.set_page_config( + page_title="Article Agent", + page_icon="📊", + layout="wide", + initial_sidebar_state="collapsed", +) + +import warnings +warnings.filterwarnings("ignore", message="coroutine 'expire_cache' was never awaited") + +import os +import streamlit.components.v1 as components +import time +import re +import math +import json +import secrets +from datetime import datetime, timezone, timedelta +from concurrent.futures import ThreadPoolExecutor + +from config import APP_CONFIG, AMJ_PROMPTS, ASQ_PROMPTS +from utils.file_parser import extract_text_from_files +from core.pipeline_agent import SSciPipelineAgent +from managed_auth import ( + require_alipay_login, + render_logged_in_account, + start_generation_task, + heartbeat_generation_task, + finish_generation_task, + render_payment_purchase, +) +from i18n import LANGUAGE_OPTIONS, get_amj_csv_template, get_asq_txt_template, get_output_suffix, get_target_flag, get_target_language, tr + +os.environ["STREAMLIT_SERVER_ENABLE_CORS"] = "false" +os.environ["STREAMLIT_SERVER_ENABLE_XSRF_PROTECTION"] = "false" + +WORKSPACE_DIR = APP_CONFIG.get("workspace_dir", ".workspace") +os.makedirs(WORKSPACE_DIR, exist_ok=True) +LEGACY_CHECKPOINT_FILE = os.path.join(WORKSPACE_DIR, "snapshot.json") +CHECKPOINT_FILE = LEGACY_CHECKPOINT_FILE + +META_SECTIONS_BLACKLIST = set(APP_CONFIG["blacklists"]["meta_sections"]) + + +def update_heartbeat(): + """Refresh the authoritative Cloudflare/D1 task lease only.""" + now = time.time() + task = st.session_state.get("sp_generation_task") or {} + task_id = str(task.get("task_id") or "").strip() + interval = max(3, int(APP_CONFIG.get("task_heartbeat_interval_seconds", 5))) + last_remote = float(st.session_state.get("sp_last_task_heartbeat") or 0.0) + if not task_id or now - last_remote < interval: + return + try: + heartbeat_generation_task( + task_id, + current_node=str(st.session_state.get("sp_current_node") or "")[:240], + ) + st.session_state["sp_last_task_heartbeat"] = now + except Exception: + # The hidden browser bridge continues heartbeats independently. A single + # transient UI heartbeat must not interrupt model generation. + pass + + +def is_system_busy(): + """D1 is the sole concurrency authority; no duplicate Hugging Face file lock.""" + return False + + +def clear_heartbeat(): + """Backward-compatible no-op after removal of the duplicate local lock.""" + return None + + +def stream_with_heartbeat(generator): + """Refresh the D1 lease while streaming model output.""" + last_beat = time.time() + for chunk in generator: + now = time.time() + if now - last_beat > 5: + update_heartbeat() + last_beat = now + yield chunk + + +def clean_trailing_references(text, keywords=("reference", "references", "reference list", "参考文献")): + lines = text.split('\n') + for i in range(len(lines)-1, max(-1, len(lines)-100), -1): + if any(re.match(rf'^##?\s*{kw}', lines[i], re.IGNORECASE) for kw in keywords): + return '\n'.join(lines[:i]).strip() + return text.strip() + +def clean_markdown_headings(text): + return re.sub(r'(?m)^#+\s+(.*)$', r'**\1**', text) + +def _read_runtime_secret(name): + # Read a Hugging Face/Streamlit secret and remove copied whitespace. + value = os.environ.get(name, "") + if not value: + try: + value = st.secrets.get(name, "") + except Exception: + value = "" + return str(value or "").strip() + + +global_ds_key = _read_runtime_secret("DEEPSEEK_API_KEY") +global_s2_key = _read_runtime_secret("S2_API_KEY") + +WECHAT_BLOCKER_JS = """""" +components.html(WECHAT_BLOCKER_JS, height=0, width=0) + + +def inject_file_uploader_i18n(language: str) -> None: + """Localize Streamlit uploaders without modifying their React structure.""" + payload = { + "browse": tr(language, "browse_files"), + "uploaders": [ + { + "title": tr(language, "drop_docs"), + "limit": tr(language, "limit_docs"), + }, + { + "title": tr(language, "drop_data"), + "limit": tr(language, "limit_data"), + }, + { + "title": tr(language, "drop_docs"), + "limit": tr(language, "limit_docs"), + }, + { + "title": tr(language, "drop_interviews"), + "limit": tr(language, "limit_interviews"), + }, + ], + } + + script = f""" + + """ + + components.html(script, height=0, width=0) + + + +def inject_wechat_browser_notice() -> None: + """Warn immediately when SmartPaper is opened inside WeChat or WeCom.""" + script = """ + + """ + + components.html(script, height=0, width=0) + + +if "is_running" not in st.session_state: st.session_state.is_running = False +if "start_pipeline" not in st.session_state: st.session_state.start_pipeline = False +if "resume_pipeline" not in st.session_state: st.session_state.resume_pipeline = False +if "zip_buffer" not in st.session_state: st.session_state.zip_buffer = None +if "run_mode" not in st.session_state: st.session_state.run_mode = "amj" +if "sp_generation_task" not in st.session_state: st.session_state.sp_generation_task = None +if "sp_generation_idempotency_key" not in st.session_state: st.session_state.sp_generation_idempotency_key = "" +if "sp_last_task_heartbeat" not in st.session_state: st.session_state.sp_last_task_heartbeat = 0.0 +if "sp_current_node" not in st.session_state: st.session_state.sp_current_node = "" +if "sp_pending_task_finalization" not in st.session_state: st.session_state.sp_pending_task_finalization = None +if "sp_pending_package" not in st.session_state: st.session_state.sp_pending_package = None +if "sp_terminal_notice" not in st.session_state: st.session_state.sp_terminal_notice = None + + +def _safe_user_workspace_key(value): + cleaned = re.sub(r"[^A-Za-z0-9_.-]", "_", str(value or "").strip()) + return cleaned[:80] or "anonymous" + + +def _load_recoverable_checkpoint(): + """Return a validated checkpoint for the current authenticated user only.""" + if not os.path.isfile(CHECKPOINT_FILE): + return None + try: + with open(CHECKPOINT_FILE, "r", encoding="utf-8") as handle: + state = json.load(handle) + except Exception: + return None + required = { + "topic", "keywords", "target_lang", "words", + "agent_state", "adaptive_structure", "completed_sections", + } + if not isinstance(state, dict) or not required.issubset(state): + return None + if not isinstance(state.get("adaptive_structure"), list): + return None + if not isinstance(state.get("completed_sections"), list): + return None + return state + + +def _new_generation_idempotency_key() -> str: + return f"gen_{int(time.time() * 1000)}_{secrets.token_urlsafe(18)}" + + +def _prepare_new_generation_request() -> None: + st.session_state["sp_generation_idempotency_key"] = _new_generation_idempotency_key() + st.session_state["sp_generation_task"] = None + st.session_state["sp_last_task_heartbeat"] = 0.0 + st.session_state["sp_current_node"] = "task_start" + st.session_state["zip_buffer"] = None + st.session_state["zip_filename"] = "" + st.session_state["sp_pending_task_finalization"] = None + st.session_state["sp_pending_package"] = None + st.session_state["sp_terminal_notice"] = None + + +def _begin_managed_generation_task(*, is_resume: bool, loaded_state: dict | None, mode: str, language: str): + checkpoint_key = "" + if is_resume and isinstance(loaded_state, dict): + checkpoint_key = str(loaded_state.get("managed_task_idempotency_key") or "").strip() + + idempotency_key = checkpoint_key or str( + st.session_state.get("sp_generation_idempotency_key") or "" + ).strip() + if not idempotency_key: + idempotency_key = _new_generation_idempotency_key() + + response = start_generation_task( + idempotency_key=idempotency_key, + mode=mode, + interface_language=language, + ) + task = dict(response.get("task") or {}) + if not task.get("task_id"): + raise RuntimeError("task_start_missing_task_id") + + st.session_state["sp_generation_idempotency_key"] = idempotency_key + st.session_state["sp_generation_task"] = task + st.session_state["sp_last_task_heartbeat"] = time.time() + return task, idempotency_key + + +def _task_output_dir(task_id: str) -> str: + root = globals().get("_user_workspace") or os.path.join(WORKSPACE_DIR, "users", "anonymous") + path = os.path.join(root, "tasks", _safe_user_workspace_key(task_id), "outputs") + os.makedirs(path, exist_ok=True) + return path + + +def _checkpoint_state_for_update() -> dict: + if not os.path.isfile(CHECKPOINT_FILE): + return {} + try: + with open(CHECKPOINT_FILE, "r", encoding="utf-8") as handle: + value = json.load(handle) + return value if isinstance(value, dict) else {} + except Exception: + return {} + + +def _write_checkpoint_state(value: dict) -> None: + if not isinstance(value, dict) or not value: + return + os.makedirs(os.path.dirname(CHECKPOINT_FILE), exist_ok=True) + temporary = CHECKPOINT_FILE + ".tmp" + with open(temporary, "w", encoding="utf-8") as handle: + json.dump(value, handle, ensure_ascii=False) + os.replace(temporary, CHECKPOINT_FILE) + + +def _persist_pending_finalization() -> None: + pending = st.session_state.get("sp_pending_task_finalization") + if not isinstance(pending, dict): + return + state = _checkpoint_state_for_update() + if not state: + return + state["pending_task_finalization"] = pending + package = st.session_state.get("sp_pending_package") + if isinstance(package, dict): + state["pending_package"] = package + _write_checkpoint_state(state) + + +def _restore_pending_finalization() -> None: + if isinstance(st.session_state.get("sp_pending_task_finalization"), dict): + return + state = _checkpoint_state_for_update() + pending = state.get("pending_task_finalization") if isinstance(state, dict) else None + if not isinstance(pending, dict): + return + st.session_state["sp_pending_task_finalization"] = pending + package = state.get("pending_package") + if isinstance(package, dict): + st.session_state["sp_pending_package"] = package + task_id = str(pending.get("task_id") or "").strip() + if task_id and not st.session_state.get("sp_generation_task"): + st.session_state["sp_generation_task"] = {"task_id": task_id} + + +def _remove_pending_checkpoint_metadata(*, delete_checkpoint=False, detach_task=False) -> None: + if delete_checkpoint: + try: + os.remove(CHECKPOINT_FILE) + except OSError: + pass + return + state = _checkpoint_state_for_update() + if not state: + return + state.pop("pending_task_finalization", None) + state.pop("pending_package", None) + if detach_task: + state["managed_task_idempotency_key"] = "" + state["managed_task_id"] = "" + _write_checkpoint_state(state) + + +def _prune_user_output_packages(max_files: int = 5) -> None: + root = globals().get("_user_workspace") + if not root or not os.path.isdir(root): + return + packages = [] + for current_root, _, names in os.walk(os.path.join(root, "tasks")): + for name in names: + if name.endswith(".zip") and not name.startswith(".pending_"): + path = os.path.join(current_root, name) + try: + packages.append((os.path.getmtime(path), path)) + except OSError: + pass + packages.sort() + while len(packages) > max_files: + _, old_path = packages.pop(0) + try: + os.remove(old_path) + except OSError: + pass + + +def _process_pending_task_finalization(): + pending = st.session_state.get("sp_pending_task_finalization") + if not isinstance(pending, dict): + return None + task_id = str(pending.get("task_id") or "").strip() + status = str(pending.get("status") or "").strip().lower() + if not task_id or status not in {"completed", "failed", "cancelled", "resumable"}: + raise RuntimeError("invalid_pending_task_finalization") + + package_bytes = None + package = st.session_state.get("sp_pending_package") + if status == "completed": + if not isinstance(package, dict): + raise RuntimeError("validated_package_metadata_missing") + pending_path = str(package.get("pending_path") or "").strip() + if not pending_path or not os.path.isfile(pending_path): + raise RuntimeError("validated_package_file_missing") + with open(pending_path, "rb") as handle: + package_bytes = handle.read() + if not package_bytes: + raise RuntimeError("validated_package_file_empty") + + response = finish_generation_task( + task_id, + status=status, + safe_error_code=str(pending.get("safe_error_code") or "")[:80], + safe_error_detail=str(pending.get("safe_error_detail") or "")[:800], + current_node=str(pending.get("current_node") or "")[:240], + ) + + if status == "completed": + filename = str(package.get("filename") or "SmartPaper.zip") + pending_path = str(package.get("pending_path") or "") + final_path = os.path.join(_task_output_dir(task_id), os.path.basename(filename)) + try: + os.replace(pending_path, final_path) + except OSError: + # The bytes were read and validated before quota commit, so the user + # can still download even if the local archival rename fails. + final_path = pending_path + st.session_state["zip_buffer"] = package_bytes + st.session_state["zip_filename"] = os.path.basename(filename) + st.session_state["sp_terminal_notice"] = {"kind": "success"} + _prune_user_output_packages() + elif status in {"failed", "cancelled"}: + st.session_state["sp_terminal_notice"] = { + "kind": "error" if status == "failed" else "warning", + "message": str(pending.get("safe_error_detail") or "")[:800], + } + + _remove_pending_checkpoint_metadata( + delete_checkpoint=bool(pending.get("delete_checkpoint")), + detach_task=bool(pending.get("detach_checkpoint")), + ) + if status == "resumable": + st.session_state["sp_generation_task"] = dict(response.get("task") or {"task_id": task_id}) + else: + st.session_state["sp_generation_task"] = None + st.session_state["sp_generation_idempotency_key"] = "" + st.session_state["sp_last_task_heartbeat"] = 0.0 + st.session_state["sp_pending_task_finalization"] = None + st.session_state["sp_pending_package"] = None + return response + + +def _finish_managed_generation_task( + status: str, + *, + safe_error_code: str = "", + safe_error_detail: str = "", + current_node: str = "", + delete_checkpoint: bool = False, + detach_checkpoint: bool = False, +): + task = st.session_state.get("sp_generation_task") or {} + task_id = str(task.get("task_id") or "").strip() + if not task_id: + return None + pending = { + "task_id": task_id, + "status": str(status or "").strip().lower(), + "safe_error_code": str(safe_error_code or "")[:80], + "safe_error_detail": str(safe_error_detail or "")[:800], + "current_node": str(current_node or "")[:240], + "delete_checkpoint": bool(delete_checkpoint), + "detach_checkpoint": bool(detach_checkpoint), + } + st.session_state["sp_pending_task_finalization"] = pending + _persist_pending_finalization() + return _process_pending_task_finalization() + + +def _quota_start_error_message(language: str, error: Exception) -> str: + raw = str(error or "") + return tr(language, "quota_exhausted") if "payment_required" in raw else raw + + +inject_wechat_browser_notice() + +DEFAULT_TOPIC = "Generative Artificial Intelligence in Ubiquitous Learning: Evaluating a Chatbot-based Recommendation Engine for Personalized and Context-aware Education" +DEFAULT_KEYWORDS = "Ubiquitous computing; Context awareness; Deep learning; Personalized educational; Educational technologies; HCI; Recommendation system; LLMs; Generative AI; ChatGPT; Prompting engineering; Few-shot prompting" + +_fragment = getattr(st, "fragment", None) or getattr(st, "experimental_fragment", None) +if _fragment is None: + def _fragment(function): + return function + + +def _current_language_code() -> str: + choice = st.session_state.get("interface_language_choice") + return LANGUAGE_OPTIONS.get(choice, "zh") + + +# Public access uses Alipay only. Password, legacy, and pilot login paths are not exposed. +authenticated_user = require_alipay_login(_current_language_code()) + +# Checkpoints are user-scoped. The former global snapshot.json could cause one +# user or an old run to trigger a false recovery notice for another session. +_user_workspace = os.path.join( + WORKSPACE_DIR, + "users", + _safe_user_workspace_key(authenticated_user.get("id")), +) +os.makedirs(_user_workspace, exist_ok=True) +CHECKPOINT_FILE = os.path.join(_user_workspace, "snapshot.json") +_restore_pending_finalization() +_process_pending_task_finalization() + + +@_fragment +def render_workspace_controls(user): + """Render controls in an isolated rerun scope for fast language switching.""" + top_toolbar = st.container() + + with top_toolbar: + language_col, account_col = st.columns([5.4, 4.6]) + + with language_col: + lang_choice = st.radio( + "Language", + list(LANGUAGE_OPTIONS.keys()), + index=0, + horizontal=True, + label_visibility="collapsed", + key="interface_language_choice", + ) + + lang_code = LANGUAGE_OPTIONS[lang_choice] + st.session_state["sp_lang_code"] = lang_code + st.session_state["sp_target_lang"] = get_target_language(lang_code) + st.session_state["sp_output_suffix"] = get_output_suffix(lang_code) + st.session_state["sp_target_flag"] = get_target_flag(lang_code) + + with account_col: + render_logged_in_account(lang_code, user) + + # Cross-device global busy check remains unchanged in function, but all visible text is localized. + is_my_session = st.session_state.get("is_running", False) or \ + st.session_state.get("start_pipeline", False) or \ + st.session_state.get("resume_pipeline", False) + ui_blocked = (not is_my_session and is_system_busy()) + st.session_state["sp_ui_blocked"] = ui_blocked + if ui_blocked: + st.markdown( + f"

{tr(lang_code, 'system_busy_title')}

", + unsafe_allow_html=True, + ) + st.warning(tr(lang_code, "system_busy_warning")) + st.info(tr(lang_code, "system_busy_info")) + return + + st.markdown(f"

{tr(lang_code, 'title')}

", unsafe_allow_html=True) + + if _load_recoverable_checkpoint() is not None and not (st.session_state.is_running or st.session_state.resume_pipeline or st.session_state.start_pipeline): + st.warning(tr(lang_code, "checkpoint_warning")) + colA, colB = st.columns([1, 1]) + if colA.button(tr(lang_code, "resume"), key="resume_task"): + if is_system_busy(): + st.warning(tr(lang_code, "busy_short")) + else: + checkpoint_state = _load_recoverable_checkpoint() or {} + checkpoint_key = str(checkpoint_state.get("managed_task_idempotency_key") or "").strip() + st.session_state["sp_generation_idempotency_key"] = checkpoint_key or _new_generation_idempotency_key() + st.session_state["sp_generation_task"] = None + st.session_state["sp_last_task_heartbeat"] = 0.0 + update_heartbeat() + st.session_state.resume_pipeline = True + st.session_state.start_pipeline = False + st.rerun() + + if colB.button(tr(lang_code, "clear"), key="clear_task"): + if is_system_busy(): + st.warning(tr(lang_code, "busy_short")) + else: + checkpoint_state = _load_recoverable_checkpoint() or {} + checkpoint_task_id = str(checkpoint_state.get("managed_task_id") or "").strip() + if checkpoint_task_id: + st.session_state["sp_generation_task"] = {"task_id": checkpoint_task_id} + try: + _finish_managed_generation_task( + "cancelled", + safe_error_code="E_USER_CLEARED_CHECKPOINT", + safe_error_detail="User cleared the recoverable checkpoint.", + current_node="checkpoint_clear", + delete_checkpoint=True, + ) + except Exception as exc: + st.error(str(exc)) + return + else: + try: + os.remove(CHECKPOINT_FILE) + except OSError: + pass + clear_heartbeat() + st.rerun() + st.markdown("---") + + st.text_input( + tr(lang_code, "topic_label"), + value=DEFAULT_TOPIC, + disabled=st.session_state.is_running, + key="sp_topic", + ) + st.text_input( + tr(lang_code, "keywords_label"), + value=DEFAULT_KEYWORDS, + disabled=st.session_state.is_running, + key="sp_keywords", + ) + st.number_input( + tr(lang_code, "words_label"), + min_value=12000, + max_value=25000, + value=20000, + step=1000, + disabled=st.session_state.is_running, + key="sp_words", + ) + + tab_amj, tab_asq = st.tabs([tr(lang_code, "tab_amj"), tr(lang_code, "tab_asq")]) + + with tab_amj: + st.markdown(tr(lang_code, "amj_config")) + st.info(tr(lang_code, "amj_info")) + st.file_uploader( + tr(lang_code, "upload_reference"), + type=["pdf", "doc", "docx"], + accept_multiple_files=True, + key="up_amj_ref", + disabled=st.session_state.is_running, + ) + st.file_uploader( + tr(lang_code, "upload_stats"), + type=["csv", "xlsx", "xls"], + key="up_amj_data", + disabled=st.session_state.is_running, + ) + + with st.expander(tr(lang_code, "amj_expander")): + st.markdown(tr(lang_code, "amj_hint")) + amj_csv_template = get_amj_csv_template(lang_code) + st.code(amj_csv_template, language="csv") + st.download_button( + label=tr(lang_code, "download_amj_template"), + data=amj_csv_template, + file_name="AMJ_Data_Template.csv", + mime="text/csv", + use_container_width=True, + ) + + if st.button(tr(lang_code, "start_amj"), disabled=st.session_state.is_running, key="btn_amj"): + if is_system_busy(): + st.warning(tr(lang_code, "busy_short")) + else: + _prepare_new_generation_request() + update_heartbeat() + st.session_state.run_mode = "amj" + st.session_state.start_pipeline = True + st.rerun() + + with tab_asq: + st.markdown(tr(lang_code, "asq_config")) + st.info(tr(lang_code, "asq_info")) + st.file_uploader( + tr(lang_code, "upload_reference"), + type=["pdf", "doc", "docx"], + accept_multiple_files=True, + key="up_asq_ref", + disabled=st.session_state.is_running, + ) + st.file_uploader( + tr(lang_code, "upload_interviews"), + type=["txt", "pdf", "doc", "docx"], + key="up_asq_data", + disabled=st.session_state.is_running, + ) + + with st.expander(tr(lang_code, "asq_expander")): + st.markdown(tr(lang_code, "asq_hint")) + asq_txt_template = get_asq_txt_template(lang_code) + st.code(asq_txt_template, language="text") + st.download_button( + label=tr(lang_code, "download_asq_template"), + data=asq_txt_template, + file_name="ASQ_Interview_Template.txt", + mime="text/plain", + use_container_width=True, + ) + + if st.button(tr(lang_code, "start_asq"), disabled=st.session_state.is_running, key="btn_asq"): + if is_system_busy(): + st.warning(tr(lang_code, "busy_short")) + else: + _prepare_new_generation_request() + update_heartbeat() + st.session_state.run_mode = "asq" + st.session_state.start_pipeline = True + st.rerun() + + # Localize Streamlit built-in uploader text after the uploaders are rendered. + inject_file_uploader_i18n(lang_code) + + +render_workspace_controls(authenticated_user) + +lang_code = str(st.session_state.get("sp_lang_code") or _current_language_code()) +target_lang = str(st.session_state.get("sp_target_lang") or get_target_language(lang_code)) +output_suffix = str(st.session_state.get("sp_output_suffix") or get_output_suffix(lang_code)) +target_flag = str(st.session_state.get("sp_target_flag") or get_target_flag(lang_code)) + +topic = str(st.session_state.get("sp_topic") or DEFAULT_TOPIC) +keywords = str(st.session_state.get("sp_keywords") or DEFAULT_KEYWORDS) +words = int(st.session_state.get("sp_words") or 20000) +uploaded_papers_amj = st.session_state.get("up_amj_ref") or [] +uploaded_data_amj = st.session_state.get("up_amj_data") +uploaded_papers_asq = st.session_state.get("up_asq_ref") or [] +uploaded_interviews = st.session_state.get("up_asq_data") + +if st.session_state.get("sp_ui_blocked"): + st.stop() + +if st.session_state.get("sp_payment_required") and not (st.session_state.start_pipeline or st.session_state.resume_pipeline): + render_payment_purchase(lang_code) + +_terminal_notice = st.session_state.pop("sp_terminal_notice", None) +if isinstance(_terminal_notice, dict) and _terminal_notice.get("kind") in {"error", "warning"}: + _message = str(_terminal_notice.get("message") or "").strip() + if _message: + (st.error if _terminal_notice.get("kind") == "error" else st.warning)(_message) + +progress_bar = st.empty() +monitor_ui = st.empty() + +def render_monitor(agent, phase, current_sec="", details=""): + st.session_state["sp_current_node"] = str(current_sec or phase or "")[:240] + monitor_ui.markdown(f""" +
+
{tr(lang_code, 'monitor_pipeline')}: {phase}
{tr(lang_code, 'monitor_node')}: {current_sec}
{tr(lang_code, 'monitor_log')}: {details}

+
{tr(lang_code, 'monitor_compute')}:
+
+ 🧠 DeepSeek: {agent.ds_calls} + 📚 S2: {agent.s2_calls} + 🌐 OpenAlex: {agent.oa_calls} +
+
+ """, unsafe_allow_html=True) + +FLAG_MAP = {"Simplified Chinese (简体中文)": "🇨🇳", "Russian (Русский)": "🇷🇺", "Kazakh (Қазақша)": "🇰🇿"} + +if st.session_state.start_pipeline or st.session_state.resume_pipeline: + if not global_ds_key: + st.error(tr(lang_code, "api_key_missing")) + st.stop() + + if st.session_state.get("is_running", False): + recoverable_state = _load_recoverable_checkpoint() + st.session_state.is_running = False + st.session_state.start_pipeline = False + st.session_state.resume_pipeline = bool(recoverable_state) + if recoverable_state: + st.toast(tr(lang_code, "resume_toast"), icon="⚡") + update_heartbeat() + st.rerun() + # A normal page rerun without a valid checkpoint is not a recovery event. + # Keep the already-rendered writing interface and show no recovery toast. + clear_heartbeat() + st.stop() + + is_resume = st.session_state.resume_pipeline + loaded_state = _load_recoverable_checkpoint() if is_resume else None + if is_resume and loaded_state is None: + is_resume = False + + try: + managed_task, managed_task_key = _begin_managed_generation_task( + is_resume=is_resume, + loaded_state=loaded_state, + mode=st.session_state.run_mode, + language=lang_code, + ) + except Exception as exc: + clear_heartbeat() + st.session_state.is_running = False + st.session_state.start_pipeline = False + st.session_state.resume_pipeline = False + st.error(_quota_start_error_message(lang_code, exc)) + if "payment_required" in str(exc): + st.session_state["sp_payment_required"] = True + render_payment_purchase(lang_code) + st.stop() + + st.session_state.is_running = True + agent = SSciPipelineAgent(global_ds_key, global_s2_key) + SAFE_CHUNK_WORDS = agent.chunk_words_default + qual_engine = get_qualitative_engine(agent) + quant_engine = get_quantitative_engine() + completion_finalization_attempted = False + + try: + update_heartbeat() + + if is_resume and loaded_state: + pb = progress_bar.progress(50, text=tr(lang_code, "resume_loading")) + state = loaded_state + state["managed_task_idempotency_key"] = managed_task_key + state["managed_task_id"] = managed_task.get("task_id", "") + topic = state["topic"] + keywords = state["keywords"] + target_lang = state["target_lang"] + words = state["words"] + user_data_md = state["user_data_md"] + data_metadata = state.get("data_metadata", {}) + user_lit_context = state["user_lit_context"] + + agent.load_state(state["agent_state"]) + adaptive_structure = state["adaptive_structure"] + completed_sections = state["completed_sections"] + full_draft_en = state["full_draft_en"] + full_draft_translated = state["full_draft_translated"] + synthesis_result = state["synthesis_result"] + refs_text = state["refs_text"] + context_tail = state["context_tail"] + + st.session_state.run_mode = state.get("run_mode", "amj") + if st.session_state.run_mode == "amj": + APP_CONFIG.update(AMJ_PROMPTS) + else: + APP_CONFIG.update(ASQ_PROMPTS) + + st.success(tr(lang_code, "resume_success", done=len(completed_sections), total=len(adaptive_structure))) + render_monitor(agent, tr(lang_code, "resume_phase"), tr(lang_code, "state_check"), tr(lang_code, "history_restored")) + + else: + pb = progress_bar.progress(1, text=tr(lang_code, "pipeline_start")) + user_lit_context = "" + user_data_md = "" + data_metadata = {} + + if st.session_state.run_mode == "amj": + if uploaded_papers_amj: + user_lit_context = extract_text_from_files(uploaded_papers_amj[:20]) + with st.status(tr(lang_code, "literature_extract"), expanded=True) as status: + ph = st.empty() + all_paper_texts = [extract_text_from_files([f]) for f in uploaded_papers_amj[:20]] + + future = ThreadPoolExecutor(max_workers=1).submit(extract_golden_metadata, agent, all_paper_texts) + dot_count = 0 + while not future.done(): + update_heartbeat() + ph.markdown(tr(lang_code, "literature_deep") + " ⏳" * (dot_count % 4)) + time.sleep(1) + dot_count += 1 + + agent.global_memory["core_golden_metadata"] = future.result() + st.session_state.rag_engine = initialize_rag(all_paper_texts) + status.update(label=tr(lang_code, "literature_done"), state="complete", expanded=False) + + with st.spinner(tr(lang_code, "extract_quant")): + update_heartbeat() + _, user_data_md, msg, data_metadata = quant_engine.resolve_empirical_data(uploaded_data_amj, lang_code) + st.info(msg) + + elif st.session_state.run_mode == "asq": + if uploaded_papers_asq: + user_lit_context = extract_text_from_files(uploaded_papers_asq[:20]) + with st.status(tr(lang_code, "literature_extract"), expanded=True) as status: + ph = st.empty() + all_paper_texts = [extract_text_from_files([f]) for f in uploaded_papers_asq[:20]] + + future = ThreadPoolExecutor(max_workers=1).submit(extract_golden_metadata, agent, all_paper_texts) + dot_count = 0 + while not future.done(): + update_heartbeat() + ph.markdown(tr(lang_code, "literature_deep") + " ⏳" * (dot_count % 4)) + time.sleep(1) + dot_count += 1 + + agent.global_memory["core_golden_metadata"] = future.result() + st.session_state.rag_engine = initialize_rag(all_paper_texts) + status.update(label=tr(lang_code, "literature_done"), state="complete", expanded=False) + + update_heartbeat() + raw_interviews = extract_text_from_files([uploaded_interviews]) if uploaded_interviews else "" + user_data_md, msg, data_metadata = qual_engine.resolve_qualitative_data(topic, keywords, raw_interviews) + st.info(msg or tr(lang_code, "qualitative_ready")) + + state = { + "topic": topic, "keywords": keywords, "target_lang": target_lang, "words": words, + "user_data_md": user_data_md, "data_metadata": data_metadata, "user_lit_context": user_lit_context, + "completed_sections": [], "full_draft_en": "", "full_draft_translated": "", + "context_tail": "", "run_mode": st.session_state.run_mode, "interface_language": lang_code, + "managed_task_idempotency_key": managed_task_key, + "managed_task_id": managed_task.get("task_id", ""), + } + + pb.progress(5, text=tr(lang_code, "config_route")) + + if st.session_state.run_mode == "amj": + APP_CONFIG.update(AMJ_PROMPTS) + state["prompt_config"] = AMJ_PROMPTS + else: + APP_CONFIG.update(ASQ_PROMPTS) + state["prompt_config"] = ASQ_PROMPTS + + pb.progress(10, text=tr(lang_code, "literature_search")) + def sync_monitor_ui(): + update_heartbeat() + render_monitor(agent, tr(lang_code, "search_phase"), "S2 + OpenAlex", tr(lang_code, "search_detail")) + + target_refs_count = 50 + refs_text = agent.fetch_references_parallel(topic, keywords, min_required=target_refs_count, progress_callback=sync_monitor_ui) + + if len(agent.citation_map) < target_refs_count: + raise RuntimeError( + tr(lang_code, "refs_insufficient", count=len(agent.citation_map), required=target_refs_count) + ) + else: + st.success(tr(lang_code, "refs_success", count=len(agent.citation_map))) + + with st.spinner(tr(lang_code, "rag_sync")): + update_heartbeat() + if hasattr(st.session_state, "rag_engine") and st.session_state.rag_engine is not None: + st.session_state.rag_engine._initialize_chunks([refs_text]) + else: + st.session_state.rag_engine = initialize_rag([refs_text]) + st.success(tr(lang_code, "rag_done")) + + if user_lit_context: + pb.progress(11, text=tr(lang_code, "core_literature_parsing")) + update_heartbeat() + core_refs = agent.parse_user_core_literature(user_lit_context) + if core_refs: + refs_text = "【USER PROVIDED CORE LITERATURE】\n" + core_refs + "\n\n【RETRIEVED LITERATURE】\n" + refs_text + st.success(tr(lang_code, "core_refs_done")) + + state["refs_text"] = refs_text + + pb.progress(12, text=tr(lang_code, "synthesis_progress")) + with st.status(tr(lang_code, "synthesis_status"), expanded=True) as status: + ph = st.empty() + synthesis_result = ph.write_stream(stream_with_heartbeat(agent.stream_synthesize_literature(topic, refs_text, user_core_literature=user_lit_context))) + synthesis_result = re.sub(r'.*?', '', synthesis_result, flags=re.DOTALL).strip() + status.update(label=tr(lang_code, "synthesis_done"), state="complete", expanded=False) + state["synthesis_result"] = synthesis_result + + pb.progress(14, text=tr(lang_code, "anchor_progress")) + with st.status(tr(lang_code, "anchor_status"), expanded=True) as status: + ph = st.empty() + anchor_response = ph.write_stream(stream_with_heartbeat(agent.stream_generate_anchors(topic, keywords, synthesis_result, refs_text, data_evidence=user_data_md, mode=st.session_state.run_mode))) + status.update(label=tr(lang_code, "anchor_done"), state="complete", expanded=False) + + # ========================================================== + # [核心修复] 必须先剔除 标签!防止正则匹配到模型在思考时引用提示词的空占位符 + # ========================================================== + anchor_response_clean = re.sub(r'.*?', '', anchor_response, flags=re.DOTALL).strip() + + abstract_match = re.search(r'-+\s*ABSTRACT\s*-+\s*(.*?)(?=\s*-+\s*CONCLUSION\s*-+|$)', anchor_response_clean, re.DOTALL | re.IGNORECASE) + conclusion_match = re.search(r'-+\s*CONCLUSION\s*-+\s*(.*?)$', anchor_response_clean, re.DOTALL | re.IGNORECASE) + abstract_anchor = abstract_match.group(1).strip() if abstract_match else anchor_response_clean.strip() + conclusion_anchor = conclusion_match.group(1).strip() if conclusion_match else "" + agent.global_memory["abstract_anchor"] = abstract_anchor + agent.global_memory["conclusion_anchor"] = conclusion_anchor + # ========================================================== + + pb.progress(15, text=tr(lang_code, "outline_progress")) + update_heartbeat() + plan = agent.generate_outline(topic, keywords, mode=st.session_state.run_mode, data_metadata=data_metadata) + en_topic = str(plan.get("en_topic", topic)) + structure = plan.get("structure", []) + + en_abstract_clean = clean_markdown_headings(re.sub(r'(?i)^abstract[:\s]*', '', str(abstract_anchor)).strip()) + trans_abstract_clean = "" + + loc_abs = {"Simplified Chinese (简体中文)": "摘要", "Russian (Русский)": "Аннотация", "Kazakh (Қазақша)": "Түйіндеме"}.get(target_lang, "Abstract") + loc_kw = {"Simplified Chinese (简体中文)": "关键词", "Russian (Русский)": "Ключевые слова", "Kazakh (Қазақша)": "Кілт сөздер"}.get(target_lang, "Keywords") + + full_draft_en = f"# {en_topic}\n\n## Abstract\n{en_abstract_clean}\n\n## Keywords\n{keywords}\n\n" + full_draft_translated = f"# {topic}\n\n## {loc_abs}\n{trans_abstract_clean}\n\n## {loc_kw}\n{keywords}\n\n" + + adaptive_structure = agent.build_generation_tasks(structure, int(words), SAFE_CHUNK_WORDS) + for task in adaptive_structure: + task["is_first"] = bool(task.get("section_first")) + + with st.spinner(tr(lang_code, "jit_status")): + update_heartbeat() + jit_texts = agent.prefetch_jit_literature(topic, keywords, adaptive_structure) + if jit_texts: + if hasattr(st.session_state, "rag_engine") and st.session_state.rag_engine is not None: + st.session_state.rag_engine._initialize_chunks([jit_texts]) + else: + st.session_state.rag_engine = initialize_rag([jit_texts]) + st.success(tr(lang_code, "jit_done")) + + state["adaptive_structure"] = adaptive_structure + state["agent_state"] = agent.export_state() + state["full_draft_en"] = full_draft_en + state["full_draft_translated"] = full_draft_translated + completed_sections = [] + context_tail = "" + + os.makedirs(WORKSPACE_DIR, exist_ok=True) + with open(CHECKPOINT_FILE, "w", encoding="utf-8") as f: + json.dump(state, f, ensure_ascii=False) + + total_steps = len(adaptive_structure) + current_step = len(completed_sections) + st.markdown("---") + ui_blocks = [] + target_flag = FLAG_MAP.get(target_lang, "🌐") + + for sec in adaptive_structure: + display_name = sec["display_name"] + st.markdown(f"### ⚙️ {display_name}") + col_en, col_trans = st.columns(2) + with col_en: + st.markdown(tr(lang_code, "english_draft")) + en_ph = st.empty() + with col_trans: + st.markdown(tr(lang_code, "target_draft", flag=target_flag)) + trans_ph = st.empty() + if display_name in completed_sections: + en_ph.success(tr(lang_code, "restored")) + trans_ph.info(tr(lang_code, "translation_waiting")) + else: + en_ph.info(tr(lang_code, "waiting")) + trans_ph.info(tr(lang_code, "translation_waiting")) + ui_blocks.append({"sec": sec, "en_ph": en_ph, "trans_ph": trans_ph}) + + generation_aborted = False + generation_failure_code = "" + generation_failure_detail = "" + accumulated_deviation = 0 + remaining_sections = sum(1 for item in adaptive_structure if item["display_name"] not in completed_sections) + all_cite_keys = list(agent.citation_map.keys()) + valid_chunks = [item for item in adaptive_structure if not any(word in item["section"].lower() for word in ["abstract","keyword","appendix"])] + allocation_map = {} + current_cite_idx = 0 + for idx, item in enumerate(valid_chunks): + count = max(1, math.ceil(len(all_cite_keys) / max(1, len(valid_chunks)))) + allocation_map[item["display_name"]] = all_cite_keys[current_cite_idx:] if idx == len(valid_chunks)-1 else all_cite_keys[current_cite_idx:current_cite_idx+count] + current_cite_idx += count + + for block in ui_blocks: + if generation_aborted: + break + sec = block["sec"] + en_ph = block["en_ph"] + trans_ph = block["trans_ph"] + sec_title = sec["section"] + display_name = sec["display_name"] + if display_name in completed_sections: + continue + remaining_sections = max(0, remaining_sections - 1) + adjustment = -accumulated_deviation / remaining_sections if remaining_sections else 0 + adjusted_target = min(int(SAFE_CHUNK_WORDS * 1.5), max(80, int(sec["target_words"] + adjustment))) + is_quarantined = any(word in sec_title.lower() for word in ["abstract","keyword","conclusion","references","appendix"]) + assigned_tags = ", ".join(allocation_map.get(display_name, [])) or "the single most relevant source" + citation_rule = "NO IN-TEXT CITATIONS ALLOWED IN THIS SECTION." if is_quarantined else f"Use claim-specific citation. Each sentence may contain at most one source tag. Candidate tags: {assigned_tags}. {refs_text}" + memory_context = agent._build_memory_context(sec_title, 2500) + memory_block = f"\n\n{memory_context}\n\n" if memory_context else "" + if getattr(st.session_state, "rag_engine", None) is not None: + snippets = st.session_state.rag_engine.get_relevant_context(display_name) + if snippets: + memory_block += f"\n\n{snippets}\n\n" + subsection = sec.get("subsection") + heading_directive = "Do not output the section heading; it will be inserted by the system." + if subsection: + heading_directive += f" Do not output the subsection heading '{subsection}'; it will also be inserted by the system. Use level-3 headings only when analytically necessary." + elif sec_title.lower() in {"introduction", "conclusion"}: + heading_directive += " Do not use additional headings." + else: + heading_directive += " Use level-2 or level-3 headings only when they clarify the argument; never use level-4 headings or numbered headings." + table_rule = "Present statistical evidence in standard Markdown tables with plain-text captions." if sec["is_method_or_result"] and st.session_state.run_mode == "amj" else "" + required_content = str(sec.get("required_content") or "") + concepts = ", ".join(sec.get("concepts") or []) + qualitative_rule = f"Assigned qualitative concepts: {concepts}. Use only their assigned representative quotations and do not repeat a quotation already used elsewhere." if concepts else "" + data_injection = f"\n\n{user_data_md}\n\n" if user_data_md and sec["is_method_or_result"] else "" + completed_label = ", ".join(completed_sections) + anti_repeat = f"Already completed: {completed_label}. Do not restate their background, gap, or contribution." if completed_label else "" + sys_prompt = APP_CONFIG.get("sys_academic_writer", "") + "\n" + APP_CONFIG.get("deep_refinement_directive", "") + user_prompt = f"""Write the assigned English manuscript unit. +Section: {sec_title} +Subsection: {subsection or 'None'} +Target length: {int(adjusted_target*0.9)}-{int(adjusted_target*1.1)} words. +{heading_directive} +{citation_rule} +{table_rule} +{required_content} +{qualitative_rule} +{anti_repeat} +{memory_block} +Literature synthesis: {synthesis_result} +{data_injection} +Previous tail: {context_tail[-2500:]} +Output only final academic prose. Do not report the word count.""" + if agent._count_tokens(sys_prompt) + agent._count_tokens(user_prompt) > 60000: + user_prompt = agent._truncate_context(user_prompt, 60000 - agent._count_tokens(sys_prompt)) + render_monitor(agent, tr(lang_code, "drafting_phase", current=current_step+1, total=total_steps), display_name, tr(lang_code, "target_words", words=adjusted_target, engine=tr(lang_code, "engine_amj") if st.session_state.run_mode=="amj" else tr(lang_code, "engine_asq"))) + try: + with st.status(tr(lang_code, "draft_status", name=display_name), expanded=True) as status: + update_heartbeat() + en_raw = en_ph.write_stream(stream_with_heartbeat(agent.stream_deepseek(sys_prompt, user_prompt, max_tokens=7000, model_role="writing", thinking=False, allow_fallback=False))) + status.update(label=tr(lang_code, "draft_done", name=display_name), state="complete", expanded=False) + en_raw = re.sub(r'.*?', '', str(en_raw), flags=re.S).strip() + en_raw = re.sub(rf'(?im)^#\s*{re.escape(sec_title)}\s*\n+', '', en_raw).strip() + if subsection: + en_raw = re.sub(rf'(?im)^##\s*{re.escape(subsection)}\s*\n+', '', en_raw).strip() + en_raw = re.sub(r'(?m)^(#{2,3})\s+[\d\.\s一二三四五六七八九十、]+\s*(.*)', r'\1 \2', en_raw) + en_raw = re.sub(r'(?m)^#{4,}\s+(.*)', r'**\1**', en_raw) + en_raw = re.sub(r'(?m)^#\s+(.+)$', r'## \1', en_raw) + if st.session_state.run_mode == "asq" and user_data_md and "appendix" not in sec_title.lower(): + quote_dict = {tag: quote for quote, tag in re.findall(r'"([^"]+)"\s*\[(QUOTE-\d+)\]', user_data_md)} + for tag, quote in quote_dict.items(): + en_raw = en_raw.replace(tag, f'"{quote}"') + en_raw = agent.polish_academic_section(en_raw, display_name, abstract=False) + prefix = "" + if sec.get("section_first"): + prefix += f"# {sec_title}\n\n" + if sec.get("subsection_first") and subsection: + prefix += f"## {subsection}\n\n" + en_with_headings = prefix + en_raw + en_resolved = agent.audit_and_resolve_citations(en_with_headings, section_name=display_name, quarantine=is_quarantined) + en_ph.markdown(en_resolved) + trans_ph.info(tr(lang_code, "translation_waiting")) + full_draft_en += en_resolved + "\n\n" + agent._extract_memory_from_text(en_raw, sec_title) + words_tail = en_raw.split() + context_tail = " ".join(words_tail[-200:]) + actual_words = len(en_raw.split()) + accumulated_deviation += actual_words - adjusted_target + current_step += 1 + completed_sections.append(display_name) + state.update({ + "completed_sections": completed_sections, + "full_draft_en": full_draft_en, + "full_draft_translated": "", + "context_tail": context_tail, + "agent_state": agent.export_state(), + "data_metadata": data_metadata, + }) + with open(CHECKPOINT_FILE, "w", encoding="utf-8") as checkpoint: + json.dump(state, checkpoint, ensure_ascii=False) + st.caption(tr(lang_code, "word_count", actual=actual_words, target=adjusted_target, deviation=accumulated_deviation)) + pb.progress(min(15 + int((current_step/max(1,total_steps))*75), 90), text=tr(lang_code, "section_complete_progress", name=display_name)) + except Exception as error: + en_ph.error(tr(lang_code, "node_error")) + st.error(tr(lang_code, "node_error_detail", error=error)) + generation_aborted = True + generation_failure_code = "E_GENERATION_NODE_FAILED" + generation_failure_detail = str(error)[:800] + + if generation_aborted: + _finish_managed_generation_task( + "failed", + safe_error_code=generation_failure_code or "E_GENERATION_INTERRUPTED", + safe_error_detail=generation_failure_detail or "Generation stopped; quota was released and the checkpoint was retained for a fresh retry.", + current_node=str(st.session_state.get("sp_current_node") or "generation"), + detach_checkpoint=True, + ) + + if not generation_aborted and full_draft_en.strip(): + pb.progress(91, text=tr(lang_code, "pack_docx")) + update_heartbeat() + full_draft_en = clean_trailing_references(full_draft_en, ["Reference","References","Reference List"]) + # The final abstract is derived from the completed English manuscript and frozen evidence. + final_abstract = agent.generate_final_abstract(topic, keywords, full_draft_en, data_evidence=user_data_md, mode=st.session_state.run_mode) + full_draft_en = replace_abstract(full_draft_en, final_abstract) + agent.validate_manuscript_structure(full_draft_en, adaptive_structure, translated=False) + if st.session_state.run_mode == "asq": + repeated_quotes = agent.find_repeated_qualitative_quotes(full_draft_en, data_metadata) + if repeated_quotes: + raise RuntimeError(f"Repeated qualitative quotations: {repeated_quotes[:5]}") + # Only sources actually cited in the manuscript enter the reference list. + references = [] + seen_refs = set() + for tag in sorted(agent.used_citations, key=agent._citation_tag_number): + ref = str((agent.citation_map.get(tag) or {}).get("ref") or "").strip() + if ref and ref not in seen_refs: + references.append(f"- {ref}") + seen_refs.add(ref) + if not references: + raise RuntimeError("No cited references remained after citation validation.") + full_draft_en = full_draft_en.rstrip() + "\n\n# References\n" + "\n\n".join(references) + + pb.progress(94, text=tr(lang_code, "translation_running")) + update_heartbeat() + full_draft_translated = agent.translate_locked_manuscript(full_draft_en, target_lang) + agent.validate_translation_alignment(full_draft_en, full_draft_translated) + + bj_time = datetime.now(timezone(timedelta(hours=8))).strftime("%Y%m%d_%H%M") + package = build_validated_package( + english_markdown=full_draft_en, + translated_markdown=full_draft_translated, + english_filename=f"Research_EN_{bj_time}.docx", + translated_filename=f"Research_{output_suffix}_{bj_time}.docx", + data_metadata=data_metadata, + mode=st.session_state.run_mode, + citation_audit=agent.citation_audit_log, + literature_metrics=agent.get_source_metrics(), + ) + generated_zip_bytes = package.zip_bytes + generated_zip_filename = f"SmartPaper_{bj_time}.zip" + + # Persist the already validated package in the current user's task + # directory before asking Worker/D1 to commit quota. The download is + # exposed only after the browser receives completed+committed. + task_id = str((st.session_state.get("sp_generation_task") or {}).get("task_id") or "").strip() + if not task_id: + raise RuntimeError("package_task_id_missing") + pending_path = os.path.join(_task_output_dir(task_id), ".pending_" + generated_zip_filename) + with open(pending_path, "wb") as output_file: + output_file.write(generated_zip_bytes) + st.session_state["sp_pending_package"] = { + "pending_path": pending_path, + "filename": generated_zip_filename, + } + completion_finalization_attempted = True + _finish_managed_generation_task( + "completed", + current_node="package_validated", + delete_checkpoint=True, + ) + pb.progress(100, text=tr(lang_code, "complete")) + render_monitor(agent, tr(lang_code, "complete"), "✅", tr(lang_code, "document_generated")) + + except Exception as e: + if not completion_finalization_attempted: + try: + _finish_managed_generation_task( + "failed", + safe_error_code="E_GENERATION_FAILED", + safe_error_detail=str(e)[:800], + current_node=str(st.session_state.get("sp_current_node") or "generation"), + detach_checkpoint=True, + ) + except Exception: + pass + st.error(tr(lang_code, "startup_error", error=e)) + finally: + clear_heartbeat() + st.session_state.is_running = False + st.session_state.start_pipeline = False + st.session_state.resume_pipeline = False + +if st.session_state.zip_buffer: + st.success(tr(lang_code, "success")) + st.download_button(label=tr(lang_code, "download"), data=st.session_state.zip_buffer, file_name=st.session_state.zip_filename, mime="application/zip") \ No newline at end of file diff --git a/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/config.py b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/config.py new file mode 100644 index 0000000000000000000000000000000000000000..d862601b82aacb90957440b927c3ea665138d706 --- /dev/null +++ b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/config.py @@ -0,0 +1,404 @@ +# -*- coding: utf-8 -*- +import os + +def load_config(): + base_config = { + # --- 基础运行参数 --- + "lit_target_papers": int(os.environ.get("LIT_TARGET_PAPERS", "80")), + "lit_per_page": int(os.environ.get("LIT_PER_PAGE", "100")), + "lit_max_workers": int(os.environ.get("LIT_MAX_WORKERS", "5")), + "chunk_words_default": int(os.environ.get("CHUNK_WORDS_DEFAULT", "1500")), + "search_max_retries": max(1, int(os.environ.get("SEARCH_MAX_RETRIES", "4"))), + "translation_batch_tokens": max(800, int(os.environ.get("TRANSLATION_BATCH_TOKENS", "2500"))), + "translation_glossary_term_limit": max(10, int(os.environ.get("TRANSLATION_GLOSSARY_TERM_LIMIT", "40"))), + "translation_min_coverage_ratio": float(os.environ.get("TRANSLATION_MIN_COVERAGE_RATIO", "0.25")), + "translation_min_block_ratio_zh": float(os.environ.get("TRANSLATION_MIN_BLOCK_RATIO_ZH", "0.28")), + "translation_min_block_ratio_cyrillic": float(os.environ.get("TRANSLATION_MIN_BLOCK_RATIO_CYRILLIC", "0.65")), + "translation_max_block_ratio": float(os.environ.get("TRANSLATION_MAX_BLOCK_RATIO", "2.20")), + "openalex_email": os.environ.get("OPENALEX_EMAIL", "").strip(), + + # --- RAG 与解析配置 --- + "rag_chunk_size": int(os.environ.get("RAG_CHUNK_SIZE", "3000")), + "rag_top_k": int(os.environ.get("RAG_TOP_K", "3")), + "max_extract_chars": int(os.environ.get("MAX_EXTRACT_CHARS", "150000")), + "api_timeouts": { + "standard": (15, 180), + "stream": (15, 300), + "search": 15 + }, + + # --- API 节点配置 --- + "deepseek_api_url": os.environ.get( + "DEEPSEEK_API_URL", + "https://api.deepseek.com/chat/completions", + ), + # Model names are centralized here and can be upgraded in HF Variables. + # Application and engine code select a task role, never a concrete model. + "deepseek_models": { + "reasoning": os.environ.get( + "DEEPSEEK_MODEL_REASONING", + "deepseek-v4-pro", + ), + "writing": os.environ.get( + "DEEPSEEK_MODEL_WRITING", + "deepseek-v4-pro", + ), + "translation": os.environ.get( + "DEEPSEEK_MODEL_TRANSLATION", + "deepseek-v4-pro", + ), + "fast": os.environ.get( + "DEEPSEEK_MODEL_FAST", + "deepseek-v4-flash", + ), + "fallback": os.environ.get( + "DEEPSEEK_MODEL_FALLBACK", + "deepseek-v4-flash", + ), + }, + "deepseek_task_settings": { + "reasoning": { + "thinking": True, + "reasoning_effort": os.environ.get( + "DEEPSEEK_REASONING_EFFORT", + "high", + ), + "allow_fallback": False, + }, + "writing": { + "thinking": False, + "reasoning_effort": "high", + "allow_fallback": False, + }, + "translation": { + "thinking": False, + "reasoning_effort": "high", + "allow_fallback": False, + }, + "fast": { + "thinking": False, + "reasoning_effort": "low", + "allow_fallback": True, + }, + }, + "deepseek_max_retries": int( + os.environ.get("DEEPSEEK_MAX_RETRIES", "3") + ), + "deepseek_show_reasoning": os.environ.get( + "DEEPSEEK_SHOW_REASONING", + "false", + ).strip().lower() in {"1", "true", "yes", "on"}, + # Citation-semantic audit repairs only affected sentences. Technical + # failures fall back to deterministic single-source recovery and are + # recorded in the audit log instead of terminating the whole paper. + "citation_semantic_audit": { + "enabled": os.environ.get( + "CITATION_SEMANTIC_AUDIT_ENABLED", + "true", + ).strip().lower() in {"1", "true", "yes", "on"}, + "batch_size": max( + 1, + int(os.environ.get("CITATION_SEMANTIC_BATCH_SIZE", "5")), + ), + "candidate_limit": max( + 3, + int(os.environ.get("CITATION_SEMANTIC_CANDIDATE_LIMIT", "10")), + ), + "fail_closed": os.environ.get( + "CITATION_SEMANTIC_FAIL_CLOSED", + "false", + ).strip().lower() in {"1", "true", "yes", "on"}, + "technical_retries": max( + 1, + int(os.environ.get("CITATION_SEMANTIC_TECHNICAL_RETRIES", "2")), + ), + "json_repair_retries": max( + 0, + int(os.environ.get("CITATION_SEMANTIC_JSON_REPAIR_RETRIES", "1")), + ), + }, + # Task/lease settings are consumed by the HF client. The Worker remains + # the authoritative source for task ownership and lock expiry. + "task_heartbeat_interval_seconds": max( + 3, + int(os.environ.get("SMARTPAPER_TASK_HEARTBEAT_SECONDS", "5")), + ), + "manuscript_quality": { + "abstract_max_words": int(os.environ.get("ABSTRACT_MAX_WORDS", "200")), + "abstract_max_numeric_mentions": int(os.environ.get("ABSTRACT_MAX_NUMERIC_MENTIONS", "6")), + "forbidden_phrases": [ + "delve into", "intricate", "paramount", "overarching", + "pivotal", "foster", "seamlessly", "multifaceted", + "beacon", "landscape", "underscore", "robust", + "firstly", "secondly", "moreover", "furthermore", + "in conclusion", "to summarize", "as an ai", + "ai-generated", "this response", + ], + }, + "openalex_api_url": os.environ.get("OPENALEX_API_URL", "https://api.openalex.org/works"), + "s2_api_url": os.environ.get("S2_API_URL", "https://api.semanticscholar.org/graph/v1"), + + # --- UI 多语言标签 --- + "ui_labels": { + "中文": {"title": "📊 工商管理论文智能体", "btn_generate": "🚀 生成初稿", "btn_download": "📥 下载文件", "topic_label": "📝 论文名称", "keywords_label": "🔍 关键字", "words_label": "📏 目标总单词数 (12000-25000单词)", "success": "🎉 流水线推演完毕!双语文档已就绪。"}, + "English": {"title": "📊 Business Administration Article Agent", "btn_generate": "🚀 Generate Draft", "btn_download": "📥 Download", "topic_label": "📝 Article Title", "keywords_label": "🔍 Keywords", "words_label": "📏 Target Word Count", "success": "🎉 Task complete!"}, + "Русский": {"title": "📊 Агент статей по бизнес-администрированию", "btn_generate": "🚀 Черновик", "btn_download": "📥 Скачать", "topic_label": "📝 Тема", "keywords_label": "🔍 Ключевые слова", "words_label": "📏 Количество слов", "success": "🎉 Задача выполнена!"}, + "Қазақша": {"title": "📊 Бизнес-басқару жөніндегі ғылыми мақала агенті", "btn_generate": "🚀 Жоба дайындау", "btn_download": "📥 Жүктеу", "topic_label": "📝 Мақала атауы", "keywords_label": "🔍 Түйін сөздер", "words_label": "📏 Мақсатты сөз саны", "success": "🎉 Тапсырма аяқталды!"} + }, + "target_lang_map": { + "中文": "Simplified Chinese (简体中文)", + "English": "Simplified Chinese (简体中文)", + "Русский": "Russian (Русский)", + "Қазақша": "Kazakh (Қазақша)" + }, + + "workspace_dir": ".workspace", + + "docx_styles": { + "font_name": "Times New Roman", + "font_size_pt": 12, + "code_font": "Courier New", + "code_size_pt": 11 + }, + + "blacklists": { + "meta_sections": [ + "abstract", "keyword", "keywords", "摘要", "关键词", "关键字", + "reference", "references", "reference list", "参考文献", + "acknowledgment", "acknowledgements", "致谢", + "аннотация", "ключевые слова", "түйіндеме", "кілт сөздер" + ] + }, + + # ===================================================================== + # Canonical manuscript structures. Top-level titles are deterministic: + # the model may write content, but it may not rename, remove, or reorder + # required sections. + # ===================================================================== + "amj_schema": [ + { + "section_id": "introduction", + "section": "Introduction", + "weight": 0.10, + "subsections": [], + }, + { + "section_id": "theory", + "section": "Theoretical Background and Hypotheses", + "weight": 0.27, + "subsections": [ + {"subsection_id": "conceptual_background", "title": "Conceptual Background and Research Gap", "share": 0.23}, + {"subsection_id": "theoretical_mechanisms", "title": "Theoretical Mechanisms", "share": 0.27}, + {"subsection_id": "hypothesis_development", "title": "Hypothesis Development", "share": 0.35}, + {"subsection_id": "integrated_model", "title": "Integrated Research Model", "share": 0.15}, + ], + }, + { + "section_id": "methodology", + "section": "Methodology", + "weight": 0.20, + "subsections": [ + {"subsection_id": "research_design", "title": "Research Design and Context", "share": 0.18}, + {"subsection_id": "sample_data", "title": "Sample and Data Collection", "share": 0.20}, + {"subsection_id": "measures", "title": "Measures and Construct Operationalization", "share": 0.25}, + {"subsection_id": "analysis_strategy", "title": "Analytical Strategy", "share": 0.22}, + {"subsection_id": "validity_bias", "title": "Validity, Bias, and Robustness Procedures", "share": 0.15}, + ], + }, + { + "section_id": "results", + "section": "Results", + "weight": 0.23, + "subsections": [ + {"subsection_id": "measurement_results", "title": "Descriptive Statistics and Measurement Assessment", "share": 0.34}, + {"subsection_id": "hypothesis_results", "title": "Hypothesis and Association Tests", "share": 0.46}, + {"subsection_id": "additional_results", "title": "Additional Analyses and Robustness Checks", "share": 0.20}, + ], + }, + { + "section_id": "discussion", + "section": "Discussion", + "weight": 0.15, + "subsections": [ + {"subsection_id": "interpretation", "title": "Interpretation of the Findings", "share": 0.30}, + {"subsection_id": "theoretical_contributions", "title": "Theoretical Contributions", "share": 0.30}, + {"subsection_id": "practical_implications", "title": "Practical Implications", "share": 0.20}, + {"subsection_id": "limitations", "title": "Limitations and Future Research", "share": 0.20}, + ], + }, + { + "section_id": "conclusion", + "section": "Conclusion", + "weight": 0.05, + "subsections": [], + }, + ], + "asq_schema": [ + { + "section_id": "introduction", + "section": "Introduction", + "weight": 0.10, + "subsections": [], + }, + { + "section_id": "theoretical_framing", + "section": "Theoretical Framing", + "weight": 0.18, + "subsections": [ + {"subsection_id": "phenomenon_puzzle", "title": "Phenomenon and Theoretical Puzzle", "share": 0.35}, + {"subsection_id": "conceptual_tension", "title": "Relevant Literature and Conceptual Tension", "share": 0.40}, + {"subsection_id": "research_question", "title": "Research Question and Analytical Focus", "share": 0.25}, + ], + }, + { + "section_id": "methods", + "section": "Research Setting and Methods", + "weight": 0.17, + "subsections": [ + {"subsection_id": "research_setting", "title": "Research Setting", "share": 0.24}, + {"subsection_id": "data_collection", "title": "Data Sources and Collection", "share": 0.28}, + {"subsection_id": "analytic_procedure", "title": "Analytical Procedure", "share": 0.30}, + {"subsection_id": "trustworthiness", "title": "Trustworthiness, Ethics, and Reflexivity", "share": 0.18}, + ], + }, + { + "section_id": "findings", + "section": "Findings", + "weight": 0.30, + "dynamic_subsections": "qualitative_themes", + "subsections": [ + {"subsection_id": "emergent_model", "title": "Emergent Process Model", "share": 0.18}, + ], + }, + { + "section_id": "discussion", + "section": "Discussion", + "weight": 0.15, + "subsections": [ + {"subsection_id": "interpretation", "title": "Interpretation of the Emergent Findings", "share": 0.30}, + {"subsection_id": "theoretical_contributions", "title": "Theoretical Contributions", "share": 0.30}, + {"subsection_id": "boundaries", "title": "Boundary Conditions and Transferability", "share": 0.20}, + {"subsection_id": "practical_implications", "title": "Practical Implications", "share": 0.20}, + ], + }, + { + "section_id": "conclusion", + "section": "Conclusion", + "weight": 0.05, + "subsections": [], + }, + { + "section_id": "appendix", + "section": "Appendix: Data Structure and Evidence", + "weight": 0.05, + "subsections": [], + }, + ], + "manuscript_validation": { + "minimum_total_ratio": 0.85, + "maximum_total_ratio": 1.15, + "minimum_section_words": 120, + "minimum_conclusion_words": 120, + "minimum_qualitative_themes": 3, + "minimum_qualitative_quotes": 8, + }, + + "prompt_templates": { + "meta_extract": """You are an expert academic data extractor. Read the provided text and extract the core metadata. +CRITICAL: You MUST output ONLY a valid JSON object. Do not include markdown formatting or explanations. + +{ + "paper_title": "", + "core_theory": "", + "variables": { + "IV": "", + "DV": "", + "Mediators": "", + "Moderators": "" + }, + "methodology": { + "sample_size": "", + "context": "", + "analysis_tool": "" + }, + "key_findings": "", + "future_gaps": "" +}""", + "synthesis_system": "You are a top-tier academic researcher in Business Administration.", + "synthesis_user": """Topic: {topic}{user_injection} +Literature Metadata: + +{refs_text} + + +[SYNTHESIS MANDATE: THEMATIC INTEGRATION] +1. You MUST NOT summarize papers sequentially (e.g., "A found X. B found Y."). +2. Synthesize by THEME or THEORETICAL TENSION rather than paper-by-paper summaries. +3. Apply claim-specific citation: each sentence may contain at most one [CITE-XX] tag. Never group multiple sources in one parenthesis or sentence. If sources support different claims, use separate sentences; if they support the same claim, select the single most direct source. +4. Explicitly highlight theoretical conflicts or consensus. +5. Conclude by revealing a specific blind spot that necessitates our current research. +{directive}""" + } + } + + # ===================================================================== + # 定量引擎 (AMJ 风格) - 保持原样,极致严谨与假设推演 + # ===================================================================== + amj_prompts = { + "sys_academic_writer": """You are a distinguished, highly cited scholar writing a manuscript for the Academy of Management Journal (AMJ). Your writing must exhibit unparalleled theoretical rigor, causal depth, and academic authority. +[CRITICAL LANGUAGE MANDATE]: You MUST generate all responses strictly in ACADEMIC ENGLISH. Even if the user provides topic, data, or context in Chinese or another language, your final output MUST be in English. +[ABSOLUTE ANTI-AI-TONE MANDATES]: +1. CRITICAL: NEVER use these cliché AI words: delve into, intricate, paramount, overarching, pivotal, foster, seamlessly, multifaceted, beacon, landscape, underscore, robust. +2. CRITICAL: NEVER use robotic transition phrases (e.g., Firstly, Moreover, Furthermore, In conclusion, To summarize). Use conceptual bridges instead. +3. NATURAL SYNTAX: Vary sentence structure only where the argument requires it. Avoid artificial short-long sentence alternation and repetitive templates. +4. ACADEMIC HEDGING: Use strategic hedging (e.g., 'This suggests a tendency toward X, bounded by Y'). +5. NO SWEEPING GENERALIZATIONS: Every sentence must carry specific theoretical weight or empirical fact. +6. FOCUS ON CAUSALITY: You MUST articulate the exact theoretical mechanism driving the relationship. +7. ACADEMIC REGISTER: Use concise, accurate, and rigorous language. Concepts must be explicit, hierarchy clear, and reasoning logically traceable. +8. PROHIBITED RHETORIC: Never use metaphors, analogies, rhetorical questions, literary embellishment, or promotional claims. +9. ABSTRACT DISCIPLINE: In abstracts, report only the minimum numerical evidence needed to convey the main finding; do not crowd the abstract with coefficients or test statistics. +10. STRUCTURAL DISCIPLINE: Cover only the assigned section and subsection. Do not recreate earlier sections, invent alternative headings, or omit required analytical elements. +11. CLAIM-SPECIFIC CITATION: Each sentence may contain at most one [CITE-XX] tag. Never group multiple references in one parenthesis or sentence. Separate distinct source-supported claims into separate sentences; when sources support the same claim, retain only the single most direct source. Every cited sentence must express a claim directly supported by the cited source evidence. +""", + "deep_refinement_directive": "Produce a final, polished section ready for top-tier submission. Ensure flawless logical flow without meta-commentary. Apply claim-specific citation: use at most one [CITE-XX] tag per sentence, never combine sources in one parenthesis, and cite only the single most direct source when multiple sources support the same claim.\n[SELF-REFLECTION]: Before drafting, critically analyze common AI clichés related to this topic and actively suppress them in your output.", + "deep_synthesis_directive": "Structure this synthesis to expose a critical theoretical tension: Consensus vs Paradox, Competing Perspectives, Theoretical Blind Spot.", + "hypothesis_development_directive": "Construct a rigorous causal chain: 1. Base Theory. 2. Mechanism (Mediator logic). 3. Boundary Condition (Moderator logic). 4. Hypothesis Phrasing (italicized).", + "methodological_defense_directive": "Write with extreme defensive precision: Justify Contextual Relevance, Detail Endogeneity & Bias Mitigation, Measurement Validity, and Analytical Strategy.", + "discussion_depth_directive": "Elevate findings back to grand theory. Explicitly state how the study extends, refines, or challenges named theories. Provide counter-intuitive implications.", + "conclusion_anchor_directive": "1. Conceptual Pivot. 2. Mechanism Summary. 3. Call to Action for the next decade.", + "anchor_generation_directive": "Generate a STRUCTURED ABSTRACT (max 200 words) mimicking an AMJ abstract, and a CONCLUSION ANCHOR (approx. 150 words). Output format:\n---ABSTRACT---\n...\n---CONCLUSION---\n...", + "empirical_data_logic": "Maintain strict adherence to APA 7th edition reporting standards. Include plausible degrees of freedom, exact p-values, confidence intervals, and effect sizes.", + } + + # ===================================================================== + # 定性引擎 (ASQ 风格) - 聚焦现象描述、意义建构与语录引用 + # ===================================================================== + asq_prompts = { + "sys_academic_writer": """You are a senior editor and distinguished qualitative researcher writing for Administrative Science Quarterly (ASQ). +Your writing style is phenomenon-driven, deeply narrative, and theoretically rich. +[CRITICAL LANGUAGE MANDATE]: You MUST generate all responses strictly in ACADEMIC ENGLISH. +[ABSOLUTE ASQ MANDATES]: +1. FOCUS ON THE PUZZLE: Embrace counter-intuitive empirical phenomena and paradoxes. +2. THICK DESCRIPTION: Use descriptive, evocative language to ground your qualitative findings in reality. +3. SENSEMAKING: Focus on how organizational actors interpret, negotiate, and construct their reality. +4. NO FORMAL HYPOTHESES: DO NOT use 'Hypothesis 1' phrasing. Use 'Propositions' or conceptual emergent models instead. +5. NO AI CLICHES: Strictly avoid 'delve', 'tapestry', 'multifaceted', 'beacon', 'landscape'. +6. ACADEMIC REGISTER: Use concise, accurate, and rigorous language with explicit concepts and logically ordered claims. +7. PROHIBITED RHETORIC: Never use metaphors, analogies, rhetorical questions, literary embellishment, or promotional claims. Use concrete evidence-based description rather than decorative prose. +8. ABSTRACT DISCIPLINE: Keep numerical details to the minimum needed to communicate the empirical puzzle, main finding, and contribution. +9. CLAIM-SPECIFIC CITATION: Each sentence may contain at most one [CITE-XX] tag. Never group multiple references in one parenthesis or sentence. Separate distinct source-supported claims into separate sentences; when sources support the same claim, retain only the single most direct source. Every cited sentence must express a claim directly supported by the cited source evidence.""", + "deep_refinement_directive": "Ensure logical flow and focus on the emergent theoretical model. Write in continuous, engaging paragraphs. Apply claim-specific citation: use at most one [CITE-XX] tag per sentence, never combine sources in one parenthesis, and cite only the single most direct source when several sources support the same claim.", + "deep_synthesis_directive": "Structure this synthesis to highlight a critical empirical puzzle or theoretical paradox that qualitative investigation can unravel.", + "hypothesis_development_directive": "Develop the theoretical framing. DO NOT use formal hypotheses. Detail the conceptual gaps and frame the overarching research questions or theoretical propositions.", + "methodological_defense_directive": "Detail the research setting vividly. Justify data sources (interviews, ethnographic observations, archives), and rigorously explain the step-by-step coding process (e.g., Gioia methodology).", + "discussion_depth_directive": "Elevate the emergent qualitative findings back to grand theory. Contrast your inductive process model with existing linear or variance-based theories.", + "conclusion_anchor_directive": "Summarize the theoretical contribution, the resolution of the empirical puzzle, and the sensemaking implications.", + "anchor_generation_directive": "Generate a NARRATIVE ABSTRACT (max 200 words) mimicking an ASQ abstract (focusing on the puzzle, setting, and emergent model), and a CONCLUSION ANCHOR (approx. 150 words). Output format:\n---ABSTRACT---\n...\n---CONCLUSION---\n...", + "empirical_data_logic": "Present findings as a compelling narrative. You MUST integrate representative quotes seamlessly into the text to provide raw evidence for each 1st-order concept and 2nd-order theme. Ensure the voice of the informants is central.", + } + + generic_prompts = amj_prompts.copy() + return base_config, amj_prompts, asq_prompts, generic_prompts + +DEFAULT_CONFIG, AMJ_PROMPTS, ASQ_PROMPTS, GENERIC_PROMPTS = load_config() +APP_CONFIG = DEFAULT_CONFIG.copy() \ No newline at end of file diff --git a/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/core/desktop.ini b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/core/desktop.ini new file mode 100644 index 0000000000000000000000000000000000000000..cf8cdc8ab9b7e23d435e3b7964e4f3303263211c --- /dev/null +++ b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/core/desktop.ini @@ -0,0 +1,2 @@ +[.ShellClassInfo] +IconResource=C:\Program Files\Google\Drive File Stream\129.0.1.0\GoogleDriveFS.exe,27 diff --git a/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/core/meta_extractor.py b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/core/meta_extractor.py new file mode 100644 index 0000000000000000000000000000000000000000..ad6d4579231e3cc6cf7c604beed659bd09051f2f --- /dev/null +++ b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/core/meta_extractor.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +import json +import concurrent.futures +from typing import List, Dict +import re +import logging +from config import APP_CONFIG + +class MetaExtractor: + def __init__(self, agent): + self.agent = agent + self.max_workers = APP_CONFIG["lit_max_workers"] + + def extract_single_paper_meta(self, text: str) -> Dict: + snippet = self.agent._truncate_context(text, max_tokens=3500) + prompt = APP_CONFIG["prompt_templates"]["meta_extract"] + + try: + # 修改点:显式将模型降级为 deepseek-chat (V3),以获得更快的并行提取速度 + response = self.agent.call_deepseek( + prompt, + snippet, + max_tokens=1000, + model_name="deepseek-chat" + ) + + # 强制提取 JSON 部分,过滤模型可能的解释性前缀或后缀 + json_match = re.search(r'\{.*\}', response, re.DOTALL) + if json_match: + return json.loads(json_match.group()) + return {} + except Exception as e: + logging.error(f"Meta Extraction error: {e}") + return {} + + def run_batch_extraction(self, paper_texts: List[str]) -> List[Dict]: + golden_db = [] + # 使用配置中的 lit_max_workers 进行多线程并行提取 + with concurrent.futures.ThreadPoolExecutor(max_workers=self.max_workers) as executor: + future_to_paper = {executor.submit(self.extract_single_paper_meta, text): text for text in paper_texts} + for future in concurrent.futures.as_completed(future_to_paper): + try: + data = future.result() + if data: + golden_db.append(data) + except Exception as e: + logging.error(f"Batch Future error: {e}") + return golden_db + +def extract_golden_metadata(agent, paper_texts: List[str]): + extractor = MetaExtractor(agent) + return extractor.run_batch_extraction(paper_texts) \ No newline at end of file diff --git a/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/core/pipeline_agent.py b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/core/pipeline_agent.py new file mode 100644 index 0000000000000000000000000000000000000000..31e3b557c1b6033ea8fe901d7e0eb2a928927d55 --- /dev/null +++ b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/core/pipeline_agent.py @@ -0,0 +1,2942 @@ +# -*- coding: utf-8 -*- +import os +import streamlit as st +import time +import re +import json +import requests +import threading +import math +import random +import tiktoken +import sys +import logging +import concurrent.futures +from collections import Counter +from datetime import datetime, timezone +from email.utils import parsedate_to_datetime + +# 基础日志配置:自带时间戳和日志级别 +logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", handlers=[logging.StreamHandler(sys.stdout)]) + +try: + from langdetect import detect + LANGDETECT_AVAILABLE = True +except ImportError: + LANGDETECT_AVAILABLE = False + +from config import APP_CONFIG + + +class DeepSeekAPIError(RuntimeError): + pass + + +class DeepSeekAuthenticationError(DeepSeekAPIError): + pass + + +class DeepSeekBalanceError(DeepSeekAPIError): + pass + + +class DeepSeekRequestError(DeepSeekAPIError): + pass + + +class PipelineCancelledError(RuntimeError): + """Raised when the Worker or local task state requests cancellation.""" + + +class CitationAuditError(DeepSeekRequestError): + def __init__(self, code, detail): + self.code = str(code) + self.safe_detail = str(detail)[:1200] + super().__init__(f"{self.code}: {self.safe_detail}") + + +class SSciPipelineAgent: + def __init__(self, ds_key, s2_key=""): + self.ds_key = str(ds_key or "").strip() + if not self.ds_key: + raise DeepSeekAuthenticationError( + "DEEPSEEK_API_KEY is empty. Configure it in Hugging Face " + "Space Settings > Variables and secrets." + ) + try: + self.s2_key = st.secrets.get("S2_API_KEY", s2_key) + except: + self.s2_key = os.environ.get("S2_API_KEY", s2_key) + + try: + self.openalex_key = st.secrets.get("OA_API_KEY", "") + except: + self.openalex_key = os.environ.get("OA_API_KEY", "") + self.openalex_email = str(APP_CONFIG.get("openalex_email") or "").strip() + + self.ds_calls = 0 + # Backward-compatible counters now mirror unique additions, while the + # full source metrics distinguish requests, returns, duplicates/errors. + self.oa_calls = 0 + self.s2_calls = 0 + self.source_metrics = { + source: { + "request_count": 0, + "success_count": 0, + "raw_result_count": 0, + "eligible_result_count": 0, + "merged_count": 0, + "unique_added_count": 0, + "duplicate_count": 0, + "error_count": 0, + "last_status_code": None, + "last_safe_error": "", + "status": "not_started", + } + for source in ("s2", "oa") + } + self._cancel_checker = None + self.s2_fused = False + self.oa_fused = False + self.citation_map = {} + self.used_citations = set() + self.citation_audit_log = [] + self.lock = threading.RLock() + + self.lit_target_papers = APP_CONFIG["lit_target_papers"] + self.lit_per_page = APP_CONFIG["lit_per_page"] + self.lit_max_workers = APP_CONFIG["lit_max_workers"] + self.chunk_words_default = APP_CONFIG["chunk_words_default"] + + self.deepseek_api_url = APP_CONFIG["deepseek_api_url"] + configured_models = APP_CONFIG.get("deepseek_models") or {} + self.deepseek_models = { + str(role).strip().lower(): str(model or "").strip() + for role, model in configured_models.items() + } + required_roles = { + "reasoning", + "writing", + "translation", + "fast", + "fallback", + } + missing_roles = [ + role for role in required_roles + if not self.deepseek_models.get(role) + ] + if missing_roles: + raise DeepSeekRequestError( + "Missing DeepSeek model role configuration: " + + ", ".join(sorted(missing_roles)) + ) + self.deepseek_task_settings = APP_CONFIG.get( + "deepseek_task_settings", + {}, + ) + self.deepseek_max_retries = max( + 1, + int(APP_CONFIG.get("deepseek_max_retries", 3)), + ) + self.deepseek_show_reasoning = bool( + APP_CONFIG.get("deepseek_show_reasoning", False) + ) + self.citation_semantic_audit = dict( + APP_CONFIG.get("citation_semantic_audit", {}) + ) + self.openalex_api_url = APP_CONFIG["openalex_api_url"] + self.s2_api_url = APP_CONFIG["s2_api_url"] + self.timeouts = APP_CONFIG["api_timeouts"] + + self.global_memory = { + "hypotheses": "", "variables": "", "model_summary": "", + "method_summary": "", "abstract_anchor": "", "conclusion_anchor": "", + "core_golden_metadata": [] + } + + try: + self.tokenizer = tiktoken.get_encoding("cl100k_base") + except: + self.tokenizer = tiktoken.get_encoding("gpt2") + + logging.info("✅ SSciPipelineAgent 初始化完成。三维检索节点与大语言模型已就绪。") + + def set_cancel_checker(self, checker): + self._cancel_checker = checker + + def _check_cancelled(self): + if self._cancel_checker is not None: + result = self._cancel_checker() + if result is True: + raise PipelineCancelledError("E_TASK_CANCELLED") + + @staticmethod + def _safe_source_error(error): + value = re.sub( + r"(?i)(api[_ -]?key|authorization|bearer|token)\s*[:=]\s*[^\s,;]+", + r"\1=[redacted]", + str(error), + ) + return value[:300] + + def _source_metric_add(self, source, field, amount=1): + with self.lock: + self.source_metrics[source][field] = int( + self.source_metrics[source].get(field, 0) + ) + int(amount) + + def _source_metric_set(self, source, field, value): + with self.lock: + self.source_metrics[source][field] = value + + def get_source_metrics(self): + with self.lock: + return { + source: dict(values) + for source, values in self.source_metrics.items() + } + + def _extract_json_from_text(self, text): + if not text: + return None + text = re.sub(r'.*?', '', text, flags=re.DOTALL) + text = re.sub(r'```json\n?', '', text, flags=re.IGNORECASE) + text = re.sub(r'```\n?', '', text) + + start_dict = text.find('{') + end_dict = text.rfind('}') + start_list = text.find('[') + end_list = text.rfind(']') + + dict_len = end_dict - start_dict if start_dict != -1 and end_dict != -1 else -1 + list_len = end_list - start_list if start_list != -1 and end_list != -1 else -1 + + if dict_len == -1 and list_len == -1: + return None + + try: + if dict_len > list_len: + return json.loads(text[start_dict:end_dict+1]) + else: + return json.loads(text[start_list:end_list+1]) + except Exception as e: + logging.error(f"❌ JSON 提取失败: {e}") + return None + + def export_state(self): + with self.lock: + return { + "ds_calls": self.ds_calls, + "oa_calls": self.oa_calls, + "s2_calls": self.s2_calls, + "source_metrics": self.get_source_metrics(), + "citation_map": self.citation_map, + "used_citations": list(self.used_citations), + "citation_audit_log": list(self.citation_audit_log), + "global_memory": self.global_memory + } + + def load_state(self, state_dict): + if not state_dict: + return + with self.lock: + self.ds_calls = state_dict.get("ds_calls", 0) + self.oa_calls = state_dict.get("oa_calls", 0) + self.s2_calls = state_dict.get("s2_calls", 0) + loaded_metrics = state_dict.get("source_metrics", {}) + for source in ("s2", "oa"): + if isinstance(loaded_metrics.get(source), dict): + self.source_metrics[source].update(loaded_metrics[source]) + self.citation_map = state_dict.get("citation_map", {}) + self.used_citations = set(state_dict.get("used_citations", [])) + self.citation_audit_log = list(state_dict.get("citation_audit_log", [])) + self.global_memory = state_dict.get("global_memory", self.global_memory) + logging.info(f"🔄 状态恢复成功: DS API={self.ds_calls}, 现有文献={len(self.citation_map)}") + + def _requests_get_with_retry(self, url, headers=None, params=None, timeout=None): + """GET with bounded, status-aware retry semantics. + + Authentication and query errors are returned immediately. Rate limits + honour ``Retry-After`` when present. Only timeouts, connection failures, + 429, and 5xx responses are retried. + """ + if timeout is None: + timeout = self.timeouts["search"] + attempts = max(1, int(APP_CONFIG.get("search_max_retries", 4))) + last_error = None + for attempt in range(attempts): + self._check_cancelled() + try: + response = requests.get( + url, + headers=headers, + params=params, + timeout=timeout, + ) + except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as error: + last_error = error + if attempt + 1 >= attempts: + raise + delay = min(12.0, 1.5 * (2 ** attempt)) + random.uniform(0.0, 0.5) + logging.warning( + "Search transport retry %s/%s after %s: %s", + attempt + 1, + attempts, + type(error).__name__, + url, + ) + time.sleep(delay) + continue + + self._check_cancelled() + status = int(response.status_code) + if status in (400, 401, 403): + return response + if status == 429: + if attempt + 1 >= attempts: + return response + retry_after = str(response.headers.get("Retry-After") or "").strip() + delay = None + if retry_after: + try: + delay = float(retry_after) + except ValueError: + try: + retry_at = parsedate_to_datetime(retry_after) + if retry_at.tzinfo is None: + retry_at = retry_at.replace(tzinfo=timezone.utc) + delay = (retry_at - datetime.now(timezone.utc)).total_seconds() + except (TypeError, ValueError, OverflowError): + delay = None + if delay is None: + delay = 2.0 * (2 ** attempt) + random.uniform(0.0, 0.5) + delay = max(1.0, min(30.0, delay)) + logging.warning( + "Search rate-limit retry %s/%s in %.1fs: %s", + attempt + 1, + attempts, + delay, + url, + ) + time.sleep(delay) + continue + if 500 <= status <= 599: + if attempt + 1 >= attempts: + response.raise_for_status() + delay = min(12.0, 1.5 * (2 ** attempt)) + random.uniform(0.0, 0.5) + logging.warning( + "Search server retry %s/%s after HTTP %s: %s", + attempt + 1, + attempts, + status, + url, + ) + time.sleep(delay) + continue + response.raise_for_status() + return response + + if last_error is not None: + raise last_error + raise requests.exceptions.RequestException("search_request_failed") + + def _is_english_dominant(self, text): + if not text: + return False + if LANGDETECT_AVAILABLE: + try: + return detect(text) == 'en' + except: + pass + alpha_chars = sum(1 for c in str(text) if c.isalpha()) + if alpha_chars == 0: + return False + return (len(re.findall(r'[a-zA-Z]', str(text))) / alpha_chars) > 0.8 + + def _extract_memory_from_text(self, text, section_name): + lower_sec = section_name.lower() + if any(kw in lower_sec for kw in ["introduction", "literature", "hypothesis", "framing"]): + hypo_matches = re.findall(r'(H\d+[:\s].*?)(?=\n|$)', text, re.IGNORECASE) + if hypo_matches: + self.global_memory["hypotheses"] = "\n".join(hypo_matches[:10]) + logging.info(f"🧠 [记忆更新] 从 {section_name} 中提取了假设 H1-Hn") + + if any(kw in lower_sec for kw in ["method", "measure", "variable", "setting"]): + var_matches = re.findall(r'([A-Z][a-z]+(?:\s+[A-Z][a-z]+)*)\s+(?:was|is|refers to)\s+([^\.]+\.)', text) + if var_matches: + self.global_memory["variables"] = "\n".join([f"- {v[0]}: {v[1][:100]}" for v in var_matches[:8]]) + logging.info(f"🧠 [记忆更新] 从 {section_name} 中提取了核心变量定义") + + if "model" in lower_sec or "figure" in lower_sec: + model_match = re.search(r'(Figure\s*\d+[^\.]+\.)', text, re.IGNORECASE) + if model_match: + self.global_memory["model_summary"] = model_match.group(1) + + if any(kw in lower_sec for kw in ["method", "sample", "procedure", "setting"]): + method_match = re.search(r'(sample\s+consisted\s+of[^\.]+\.)', text, re.IGNORECASE) + if method_match: + self.global_memory["method_summary"] = method_match.group(1) + + def _count_tokens(self, text): + return len(self.tokenizer.encode(text)) + + def _truncate_context(self, text, max_tokens): + tokens = self.tokenizer.encode(text) + if len(tokens) <= max_tokens: + return text + return self.tokenizer.decode(tokens[:max_tokens]) + + def _build_memory_context(self, section_name="", max_tokens=2500): + memory_parts = [] + lower_sec = section_name.lower() if section_name else "" + golden_meta = self.global_memory.get("core_golden_metadata", []) + + if self.global_memory["abstract_anchor"]: + memory_parts.append(f"【PAPER ANCHOR - ABSTRACT】\n{self.global_memory['abstract_anchor']}") + + is_intro_lit = any(kw in lower_sec for kw in ["introduction", "background", "literature", "theor", "review", "framing"]) + is_method = any(kw in lower_sec for kw in ["method", "measure", "data", "sample", "setting"]) + is_result = any(kw in lower_sec for kw in ["result", "analysis", "finding"]) + is_discussion = any(kw in lower_sec for kw in ["discussion", "conclusion", "implication"]) + + if is_intro_lit or is_discussion: + if self.global_memory["conclusion_anchor"]: + memory_parts.append(f"【PAPER ANCHOR - EXPECTED CONCLUSION】\n{self.global_memory['conclusion_anchor']}") + + if is_method or is_result or is_discussion: + if self.global_memory["hypotheses"]: + memory_parts.append(f"【ESTABLISHED HYPOTHESES】\n{self.global_memory['hypotheses']}") + if self.global_memory["variables"]: + memory_parts.append(f"【CORE CONSTRUCT DEFINITIONS】\n{self.global_memory['variables']}") + + if is_method: + if self.global_memory["model_summary"]: + memory_parts.append(f"【RESEARCH MODEL】\n{self.global_memory['model_summary']}") + if self.global_memory["method_summary"]: + memory_parts.append(f"【METHODOLOGICAL SUMMARY】\n{self.global_memory['method_summary']}") + + if isinstance(golden_meta, list) and section_name: + injected_data = [] + if is_intro_lit: + for p in golden_meta: + if isinstance(p, dict): + if p.get('future_gaps'): injected_data.append(f"[{p.get('title', 'Ref')}] Future Gaps: {p['future_gaps']}") + if p.get('theoretical_lens'): injected_data.append(f"[{p.get('title', 'Ref')}] Theory: {p['theoretical_lens']}") + if injected_data: + memory_parts.append("【TARGETED INJECTION: CORE GAPS & THEORIES】\n" + "\n".join(injected_data)) + + elif is_method: + for p in golden_meta: + if isinstance(p, dict) and p.get('variables_and_measurements'): + injected_data.append(f"[{p.get('title', 'Ref')}] Measurements: {p['variables_and_measurements']}") + if injected_data: + memory_parts.append("【TARGETED INJECTION: CORE MEASUREMENTS】\n" + "\n".join(injected_data)) + + elif is_discussion: + for p in golden_meta: + if isinstance(p, dict) and p.get('causal_pathways'): + injected_data.append(f"[{p.get('title', 'Ref')}] Prior Pathways: {p['causal_pathways']}") + if injected_data: + memory_parts.append("【TARGETED INJECTION: CORE LITERATURE FINDINGS】\n" + "\n".join(injected_data)) + + full = "\n\n".join(memory_parts) + if self._count_tokens(full) > max_tokens: + return self._truncate_context(full, max_tokens) + return full + + def model_for(self, model_role="reasoning"): + role = str(model_role or "reasoning").strip().lower() + model = self.deepseek_models.get(role) + if not model: + raise DeepSeekRequestError( + f"Unknown or unconfigured DeepSeek model role: {role!r}." + ) + return model + + def _task_settings(self, model_role): + role = str(model_role or "reasoning").strip().lower() + raw = self.deepseek_task_settings.get(role) or {} + effort = str(raw.get("reasoning_effort", "high")).strip().lower() + if effort not in {"low", "high", "max"}: + effort = "high" + return { + "thinking": bool(raw.get("thinking", role == "reasoning")), + "reasoning_effort": effort, + "allow_fallback": bool(raw.get("allow_fallback", False)), + } + + def _resolve_deepseek_request( + self, + model_name=None, + model_role="reasoning", + thinking=None, + reasoning_effort=None, + allow_fallback=None, + ): + role = str(model_role or "reasoning").strip().lower() + requested_name = str(model_name or "").strip() + + settings = self._task_settings(role) + model = requested_name or self.model_for(role) + if not model: + raise DeepSeekRequestError( + f"No DeepSeek model configured for role {role!r}." + ) + if thinking is None: + thinking = settings["thinking"] + if reasoning_effort is None: + reasoning_effort = settings["reasoning_effort"] + effort = str(reasoning_effort).strip().lower() + if effort not in {"low", "high", "max"}: + effort = "high" + if allow_fallback is None: + allow_fallback = settings["allow_fallback"] + + return ( + model, + bool(thinking), + effort, + bool(allow_fallback), + role, + ) + + def _build_deepseek_payload( + self, + system_prompt, + user_prompt, + *, + model_name, + thinking, + max_tokens, + temp, + stream, + reasoning_effort, + ): + payload = { + "model": model_name, + "messages": [ + {"role": "system", "content": str(system_prompt)}, + {"role": "user", "content": str(user_prompt)}, + ], + "max_tokens": int(max_tokens), + "stream": bool(stream), + "thinking": { + "type": "enabled" if thinking else "disabled" + }, + } + + if thinking: + payload["reasoning_effort"] = reasoning_effort + else: + payload["temperature"] = max( + 0.0, + min(float(temp), 2.0), + ) + + return payload + + @staticmethod + def _deepseek_headers(api_key): + return { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": "SmartPaper/DeepSeek", + } + + @staticmethod + def _safe_response_text(response, limit=600): + try: + return response.text[:limit] + except Exception: + return "" + + def _raise_for_deepseek_status(self, response): + status = int(response.status_code) + detail = self._safe_response_text(response) + + if status == 401: + raise DeepSeekAuthenticationError( + "DeepSeek authentication failed (401). " + "Check DEEPSEEK_API_KEY in Hugging Face Space Secrets." + ) + if status == 402: + raise DeepSeekBalanceError( + "DeepSeek account balance is insufficient (402)." + ) + if status == 400: + raise DeepSeekRequestError( + f"DeepSeek rejected the request format (400): {detail}" + ) + if status == 404: + raise DeepSeekRequestError( + f"DeepSeek model or endpoint was not found (404): {detail}" + ) + if status == 422: + raise DeepSeekRequestError( + f"DeepSeek request parameters are invalid (422): {detail}" + ) + + if status in {408, 409, 425, 429, 500, 502, 503, 504}: + raise requests.exceptions.HTTPError( + f"Transient DeepSeek API failure ({status}): {detail}", + response=response, + ) + + raise DeepSeekRequestError( + f"Unexpected DeepSeek API status {status}: {detail}" + ) + + @staticmethod + def _retry_delay(attempt_index): + base = min(2 ** attempt_index, 8) + return base + random.uniform(0.25, 1.25) + + @staticmethod + def _parse_sse_line(raw_line): + if raw_line is None: + return None + if isinstance(raw_line, bytes): + line = raw_line.decode( + "utf-8", + errors="replace", + ).strip() + else: + line = str(raw_line).strip() + + if not line or not line.startswith("data:"): + return None + + data = line[5:].strip() + if not data: + return None + if data == "[DONE]": + return {"done": True} + + try: + return json.loads(data) + except json.JSONDecodeError: + logging.warning( + "Ignored malformed DeepSeek SSE chunk: %s", + data[:160], + ) + return None + + def _attempt_plan( + self, + requested_model, + requested_thinking, + allow_fallback, + ): + total = self.deepseek_max_retries + fallback_model = self.deepseek_models.get("fallback", "") + for attempt in range(total): + model = requested_model + thinking = requested_thinking + if ( + allow_fallback + and attempt == total - 1 + and fallback_model + and fallback_model != requested_model + ): + model = fallback_model + yield attempt, model, thinking + + def call_deepseek( + self, + system_prompt, + user_prompt, + max_tokens=8000, + temp=0.3, + model_name=None, + model_role="reasoning", + thinking=None, + reasoning_effort=None, + allow_fallback=None, + ): + ( + requested_model, + requested_thinking, + resolved_effort, + resolved_fallback, + resolved_role, + ) = self._resolve_deepseek_request( + model_name=model_name, + model_role=model_role, + thinking=thinking, + reasoning_effort=reasoning_effort, + allow_fallback=allow_fallback, + ) + tokens_est = self._count_tokens(str(user_prompt)) + + logging.info( + "DeepSeek static request | role=%s | model=%s | " + "thinking=%s | max_tokens=%s | input_est=%s", + resolved_role, + requested_model, + requested_thinking, + max_tokens, + tokens_est, + ) + + last_error = None + for attempt, current_model, current_thinking in self._attempt_plan( + requested_model, + requested_thinking, + resolved_fallback, + ): + with self.lock: + self.ds_calls += 1 + + if current_model != requested_model: + logging.warning( + "Non-critical final fallback: model=%s", + current_model, + ) + + payload = self._build_deepseek_payload( + system_prompt, + user_prompt, + model_name=current_model, + thinking=current_thinking, + max_tokens=max_tokens, + temp=temp, + stream=False, + reasoning_effort=resolved_effort, + ) + start_time = time.time() + + try: + self._check_cancelled() + response = requests.post( + self.deepseek_api_url, + headers=self._deepseek_headers(self.ds_key), + json=payload, + timeout=self.timeouts["standard"], + ) + + self._check_cancelled() + if response.status_code != 200: + self._raise_for_deepseek_status(response) + + try: + result = response.json() + except ValueError as exc: + raise DeepSeekAPIError( + "DeepSeek returned a non-JSON success response." + ) from exc + + choices = result.get("choices") or [] + if not choices: + raise DeepSeekAPIError( + "DeepSeek returned no choices." + ) + + message = choices[0].get("message") or {} + content = str(message.get("content") or "").strip() + + if not content: + finish_reason = choices[0].get("finish_reason") + raise DeepSeekAPIError( + "DeepSeek returned empty final content. " + f"finish_reason={finish_reason!r}" + ) + + logging.info( + "DeepSeek static response | seconds=%.2f | chars=%s", + time.time() - start_time, + len(content), + ) + return content + + except ( + DeepSeekAuthenticationError, + DeepSeekBalanceError, + DeepSeekRequestError, + ): + raise + except ( + requests.exceptions.Timeout, + requests.exceptions.ConnectionError, + requests.exceptions.HTTPError, + DeepSeekAPIError, + ) as exc: + last_error = exc + is_last = attempt == self.deepseek_max_retries - 1 + logging.error( + "DeepSeek attempt %s/%s failed: %s", + attempt + 1, + self.deepseek_max_retries, + exc, + ) + if is_last: + break + time.sleep(self._retry_delay(attempt)) + + raise DeepSeekAPIError( + "DeepSeek API failed after " + f"{self.deepseek_max_retries} attempts: {last_error}" + ) + + def stream_deepseek( + self, + system_prompt, + user_prompt, + max_tokens=8000, + model_name=None, + model_role="reasoning", + thinking=None, + reasoning_effort=None, + temp=0.3, + allow_fallback=None, + ): + ( + requested_model, + requested_thinking, + resolved_effort, + resolved_fallback, + resolved_role, + ) = self._resolve_deepseek_request( + model_name=model_name, + model_role=model_role, + thinking=thinking, + reasoning_effort=reasoning_effort, + allow_fallback=allow_fallback, + ) + tokens_est = self._count_tokens(str(user_prompt)) + + logging.info( + "DeepSeek stream request | role=%s | model=%s | " + "thinking=%s | max_tokens=%s | input_est=%s", + resolved_role, + requested_model, + requested_thinking, + max_tokens, + tokens_est, + ) + + last_error = None + for attempt, current_model, current_thinking in self._attempt_plan( + requested_model, + requested_thinking, + resolved_fallback, + ): + with self.lock: + self.ds_calls += 1 + + if current_model != requested_model: + logging.warning( + "Non-critical final fallback: model=%s", + current_model, + ) + + payload = self._build_deepseek_payload( + system_prompt, + user_prompt, + model_name=current_model, + thinking=current_thinking, + max_tokens=max_tokens, + temp=temp, + stream=True, + reasoning_effort=resolved_effort, + ) + + emitted_content = False + reasoning_open = False + reasoning_chunks = 0 + start_time = time.time() + + try: + self._check_cancelled() + with requests.post( + self.deepseek_api_url, + headers=self._deepseek_headers(self.ds_key), + json=payload, + stream=True, + timeout=self.timeouts["stream"], + ) as response: + if response.status_code != 200: + self._raise_for_deepseek_status(response) + + for raw_line in response.iter_lines( + decode_unicode=True + ): + self._check_cancelled() + event = self._parse_sse_line(raw_line) + if not event: + continue + if event.get("done"): + break + + choices = event.get("choices") or [] + if not choices: + continue + + delta = choices[0].get("delta") or {} + reasoning_content = ( + delta.get("reasoning_content") or "" + ) + content = delta.get("content") or "" + + if reasoning_content: + reasoning_chunks += 1 + if not reasoning_open: + reasoning_open = True + logging.info( + "DeepSeek thinking started." + ) + if self.deepseek_show_reasoning: + yield "\n\n" + + if self.deepseek_show_reasoning: + yield str(reasoning_content) + else: + yield "" + + if reasoning_chunks % 25 == 0: + logging.info( + "DeepSeek thinking heartbeat: " + "%.1fs, %s chunks", + time.time() - start_time, + reasoning_chunks, + ) + + if content: + if ( + reasoning_open + and self.deepseek_show_reasoning + ): + yield "\n\n\n" + reasoning_open = False + emitted_content = True + yield str(content) + + if reasoning_open and self.deepseek_show_reasoning: + yield "\n\n" + + if not emitted_content: + raise DeepSeekAPIError( + "DeepSeek stream ended without final content." + ) + + logging.info( + "DeepSeek stream complete | seconds=%.1f", + time.time() - start_time, + ) + return + + except GeneratorExit: + raise + except ( + DeepSeekAuthenticationError, + DeepSeekBalanceError, + DeepSeekRequestError, + ): + raise + except ( + requests.exceptions.Timeout, + requests.exceptions.ConnectionError, + requests.exceptions.HTTPError, + DeepSeekAPIError, + ) as exc: + last_error = exc + logging.error( + "DeepSeek stream attempt %s/%s failed: %s", + attempt + 1, + self.deepseek_max_retries, + exc, + ) + + if emitted_content: + raise DeepSeekAPIError( + "DeepSeek stream was interrupted after partial " + "content had already been emitted." + ) from exc + + is_last = attempt == self.deepseek_max_retries - 1 + if is_last: + break + time.sleep(self._retry_delay(attempt)) + + raise DeepSeekAPIError( + "DeepSeek streaming failed after " + f"{self.deepseek_max_retries} attempts: {last_error}" + ) + + def parse_user_core_literature(self, user_lit_text): + if not user_lit_text.strip(): + return "" + + sys_prompt = "You are an elite academic metadata extractor." + user_prompt = f"""Analyze raw text from user-uploaded core PDFs. Identify distinct papers. Return ONLY a valid JSON array of objects mimicking this schema: +[{{ "title": "Title", "authors": ["Lastname, F."], "year": "2023", "venue": "Journal", "abstract": "...", "core_thesis": "...", "theoretical_lens": "...", "variables_and_measurements": "...", "causal_pathways": "...", "future_gaps": "..." }}] +\n{user_lit_text[:30000]}\n""" + + for attempt in range(2): + try: + logging.info("📄 [本地解析] 正在榨取用户上传的核心文献...") + response = self.call_deepseek( + sys_prompt, + user_prompt, + temp=0.1, + model_role="reasoning", + thinking=True, + allow_fallback=False, + ) + papers = self._extract_json_from_text(response) + if not papers: + raise ValueError("Invalid JSON") + + if isinstance(papers, dict): + for v in papers.values(): + if isinstance(v, list): + papers = v + break + + if not isinstance(papers, list): + papers = [papers] + + self.global_memory["core_golden_metadata"] = papers + + rag_context = [] + for paper in papers: + if not isinstance(paper, dict): + continue + title = paper.get("title", "User Document") + year = paper.get("year", "n.d.") + authors = paper.get("authors", ["User"]) + abstract = paper.get("abstract", "Core ref.") + core_thesis = paper.get("core_thesis", "") + + if len(authors) == 1: + in_text = f"({authors[0].split(',')[0]}, {year})" + elif len(authors) == 2: + in_text = f"({authors[0].split(',')[0]} & {authors[1].split(',')[0]}, {year})" + else: + in_text = f"({authors[0].split(',')[0]} et al., {year})" + + apa_full = f"{', '.join(authors)} ({year}). {title}." + + with self.lock: + cite_tag = f"[CITE-{len(self.citation_map)+1:02d}]" + self.citation_map[cite_tag] = { + "in_text": in_text, + "ref": apa_full, + "id": title.lower().strip()[:50], + "evidence": ( + f"Title: {title}. Venue: {paper.get('venue', 'Unknown')}. " + f"Core thesis: {core_thesis}. Abstract: {abstract}" + ), + } + rag_context.append(f"{cite_tag} [USER CORE PAPER] Title: {title}. Core Thesis: {core_thesis}. Abstract: {abstract}") + logging.info(f"✅ [本地解析] 成功提取 {len(papers)} 篇本地文献元数据。") + return "\n".join(rag_context) + except Exception as e: + time.sleep(2) + return "" + + def synthesize_literature(self, topic, refs_text, user_core_literature=""): + user_injection = f"\n\nCRITICAL MANDATE: Prioritize User-Provided Core Literature:\n\n{user_core_literature[:20000]}\n\n" if user_core_literature else "" + sys_prompt = APP_CONFIG["prompt_templates"]["synthesis_system"] + user_prompt = APP_CONFIG["prompt_templates"]["synthesis_user"].replace( + "{topic}", topic).replace("{user_injection}", user_injection).replace( + "{refs_text}", refs_text).replace("{directive}", APP_CONFIG['deep_synthesis_directive']) + return self.call_deepseek(sys_prompt, user_prompt, temp=0.2) + + def generate_anchors( + self, + topic, + keywords, + synthesis_result, + refs_text, + data_evidence="", + mode="amj", + ): + sys_prompt = ( + "You are a senior scholar conceptualizing a high-impact " + "empirical paper." + ) + format_mandate = """\n\n[CRITICAL FORMAT MANDATE] +You MUST output your response EXACTLY using the following two delimiters. Do not add Markdown headings before them: +---ABSTRACT--- +(Write the comprehensive abstract here) +---CONCLUSION--- +(Write the expected conclusion here)""" + evidence_block = ( + f"\nResearch Mode: {mode}\n" + "The following evidence packet is the single source of truth " + "for setting, sample, data sources, method, findings, and all " + "numerical details. Do not invent or alter them.\n" + f"\n{data_evidence[:12000]}\n" + "\n" + if data_evidence + else "" + ) + base_directive = APP_CONFIG.get( + "anchor_generation_directive", + "", + ) + user_prompt = ( + f"Topic: {topic}\nKeywords: {keywords}\n" + f"Literature Synthesis:\n{synthesis_result}\n" + f"Citation Context:\n{refs_text[:3000]}\n" + f"{evidence_block}{base_directive}{format_mandate}" + ) + response = self.call_deepseek( + sys_prompt, + user_prompt, + max_tokens=8000, + temp=0.3, + model_role="reasoning", + thinking=True, + allow_fallback=False, + ) + abstract_match = re.search( + r"-+\s*ABSTRACT\s*-+\s*(.*?)(?=\s*-+\s*CONCLUSION\s*-+|$)", + response, + re.DOTALL | re.IGNORECASE, + ) + conclusion_match = re.search( + r"-+\s*CONCLUSION\s*-+\s*(.*?)$", + response, + re.DOTALL | re.IGNORECASE, + ) + final_abstract = ( + abstract_match.group(1).strip() + if abstract_match + else response.strip() + ) + final_conclusion = ( + conclusion_match.group(1).strip() + if conclusion_match + else "" + ) + return final_abstract, final_conclusion + + # ... 保留上面的 synthesize_literature 和 generate_anchors 函数不变 ... + + # [新增] 综合文献的流式输出方法 + def stream_synthesize_literature(self, topic, refs_text, user_core_literature=""): + user_injection = f"\n\nCRITICAL MANDATE: Prioritize User-Provided Core Literature:\n\n{user_core_literature[:20000]}\n\n" if user_core_literature else "" + sys_prompt = APP_CONFIG["prompt_templates"]["synthesis_system"] + user_prompt = APP_CONFIG["prompt_templates"]["synthesis_user"].replace( + "{topic}", topic).replace("{user_injection}", user_injection).replace( + "{refs_text}", refs_text).replace("{directive}", APP_CONFIG['deep_synthesis_directive']) + return self.stream_deepseek( + sys_prompt, + user_prompt, + model_role="reasoning", + thinking=True, + allow_fallback=False, + ) + + # Generate abstract and conclusion anchors from literature plus evidence. + def stream_generate_anchors( + self, + topic, + keywords, + synthesis_result, + refs_text, + data_evidence="", + mode="amj", + ): + sys_prompt = ( + "You are a senior scholar conceptualizing a high-impact " + "empirical paper." + ) + format_mandate = """\n\n[CRITICAL FORMAT MANDATE] +You MUST output your response EXACTLY using the following two delimiters. Do not add Markdown headings before them: +---ABSTRACT--- +(Write the comprehensive abstract here) +---CONCLUSION--- +(Write the expected conclusion here)""" + evidence_block = ( + f"\nResearch Mode: {mode}\n" + "The following evidence packet is the single source of truth " + "for setting, sample, data sources, method, findings, and all " + "numerical details. Do not invent or alter them.\n" + f"\n{data_evidence[:12000]}\n" + "\n" + if data_evidence + else "" + ) + base_directive = APP_CONFIG.get( + "anchor_generation_directive", + "", + ) + user_prompt = ( + f"Topic: {topic}\nKeywords: {keywords}\n" + f"Literature Synthesis:\n{synthesis_result}\n" + f"Citation Context:\n{refs_text[:3000]}\n" + f"{evidence_block}{base_directive}{format_mandate}" + ) + return self.stream_deepseek( + sys_prompt, + user_prompt, + max_tokens=8000, + model_role="reasoning", + thinking=True, + allow_fallback=False, + ) + + # ===================================================================== + # Deterministic dual-track manuscript planning. + # ===================================================================== + @staticmethod + def _slugify_heading(value): + slug = re.sub(r"[^a-z0-9]+", "_", str(value).lower()).strip("_") + return slug or "section" + + def generate_outline(self, topic, keywords, mode="amj", data_metadata=None): + schema_key = "asq_schema" if mode == "asq" else "amj_schema" + source_sections = APP_CONFIG.get(schema_key, APP_CONFIG["amj_schema"]) + data_metadata = data_metadata or {} + final_structure = [] + + for raw_section in source_sections: + section = dict(raw_section) + fixed_subsections = [dict(item) for item in section.get("subsections", [])] + dynamic_subsections = [] + + if section.get("dynamic_subsections") == "qualitative_themes": + themes = data_metadata.get("themes") or [] + if not themes: + raise DeepSeekRequestError( + "Qualitative Findings cannot be planned without frozen themes." + ) + fixed_share = sum(float(item.get("share", 0.0)) for item in fixed_subsections) + theme_share_total = max(0.0, 1.0 - fixed_share) + per_theme_share = theme_share_total / len(themes) + seen_ids = set() + for index, theme in enumerate(themes, start=1): + title = str(theme.get("title") or f"Theme {index}").strip() + base_id = self._slugify_heading(title) + subsection_id = base_id + suffix = 2 + while subsection_id in seen_ids: + subsection_id = f"{base_id}_{suffix}" + suffix += 1 + seen_ids.add(subsection_id) + concepts = [str(item).strip() for item in theme.get("concepts", []) if str(item).strip()] + dynamic_subsections.append( + { + "subsection_id": subsection_id, + "title": title, + "share": per_theme_share, + "theme_title": title, + "concepts": concepts, + "required_content": ( + "Explain this theme using only its assigned concepts and " + "representative quotations. Do not repeat another theme." + ), + } + ) + + section["subsections"] = dynamic_subsections + fixed_subsections + section["is_method_or_result"] = section.get("section_id") in { + "methodology", "results", "methods", "findings", "appendix" + } + final_structure.append(section) + + return {"en_topic": topic, "structure": final_structure} + + def build_generation_tasks(self, structure, total_words, safe_chunk_words): + tasks = [] + for section in structure: + section_target = max(120, int(total_words * float(section.get("weight", 0.1)))) + subsections = section.get("subsections") or [] + units = subsections or [None] + for unit_index, subsection in enumerate(units): + if subsection: + unit_target = max(100, int(section_target * float(subsection.get("share", 1 / len(units))))) + subsection_id = subsection.get("subsection_id") or self._slugify_heading(subsection.get("title")) + subsection_title = subsection.get("title") + required_content = subsection.get("required_content", "") + theme_title = subsection.get("theme_title") + concepts = subsection.get("concepts", []) + else: + unit_target = section_target + subsection_id = None + subsection_title = None + required_content = "" + theme_title = None + concepts = [] + + part_count = max(1, math.ceil(unit_target / max(1, int(safe_chunk_words)))) + for part_index in range(part_count): + part_target = max(80, int(unit_target / part_count)) + label = section["section"] + if subsection_title: + label += f" > {subsection_title}" + if part_count > 1: + label += f" (Part {part_index + 1}/{part_count})" + tasks.append( + { + "section_id": section.get("section_id") or self._slugify_heading(section["section"]), + "section": section["section"], + "subsection_id": subsection_id, + "subsection": subsection_title, + "display_name": label, + "target_words": part_target, + "section_first": unit_index == 0 and part_index == 0, + "subsection_first": bool(subsection_title) and part_index == 0, + "continuation": part_index > 0, + "required_content": required_content, + "theme_title": theme_title, + "concepts": concepts, + "is_method_or_result": bool(section.get("is_method_or_result")), + } + ) + return tasks + + @staticmethod + def _expected_structure_from_tasks(tasks): + sections = [] + subsections = [] + seen_sections = set() + seen_subsections = set() + for task in tasks: + section_key = task.get("section_id") + if section_key not in seen_sections: + seen_sections.add(section_key) + sections.append(task.get("section")) + subsection = task.get("subsection") + sub_key = (section_key, task.get("subsection_id")) + if subsection and sub_key not in seen_subsections: + seen_subsections.add(sub_key) + subsections.append(subsection) + return sections, subsections + + def validate_manuscript_structure(self, text, tasks, translated=False): + raw = str(text or "") + expected_sections, expected_subsections = self._expected_structure_from_tasks(tasks) + h1 = re.findall(r"(?m)^#\s+(.+?)\s*$", raw) + h2 = re.findall(r"(?m)^##\s+(.+?)\s*$", raw) + errors = [] + + if translated: + expected_h1_count = 1 + len(expected_sections) + expected_h2_count = 2 + len(expected_subsections) + if len(h1) != expected_h1_count: + errors.append(f"translated H1 count={len(h1)}, expected={expected_h1_count}") + if len(h2) != expected_h2_count: + errors.append(f"translated H2 count={len(h2)}, expected={expected_h2_count}") + else: + actual_sections = h1[1:] if h1 else [] + if actual_sections != expected_sections: + errors.append( + "top-level sections differ from canonical plan: " + f"actual={actual_sections}, expected={expected_sections}" + ) + for title in expected_sections: + if h1.count(title) != 1: + errors.append(f"section {title!r} count={h1.count(title)}") + for title in expected_subsections: + if h2.count(title) != 1: + errors.append(f"subsection {title!r} count={h2.count(title)}") + + validation = APP_CONFIG.get("manuscript_validation", {}) + minimum_words = int(validation.get("minimum_section_words", 120)) + minimum_conclusion = int(validation.get("minimum_conclusion_words", 120)) + for index, title in enumerate(expected_sections): + pattern = re.compile( + rf"(?ms)^#\s+{re.escape(title)}\s*$\n(.*?)(?=^#\s+|\Z)" + ) + match = pattern.search(raw) + if not match: + continue + count = len(match.group(1).split()) + required = minimum_conclusion if title == "Conclusion" else minimum_words + if count < required: + errors.append(f"section {title!r} has {count} words; required>={required}") + + if errors: + raise DeepSeekAPIError( + "Manuscript structure validation failed: " + " | ".join(errors[:12]) + ) + return True + + @staticmethod + def find_repeated_qualitative_quotes(text, data_metadata): + violations = [] + body_text = re.split( + r"(?m)^#\s+Appendix: Data Structure and Evidence\s*$", + str(text or ""), + maxsplit=1, + )[0] + normalized_text = re.sub(r"\s+", " ", body_text).lower() + for item in (data_metadata or {}).get("evidence", []): + quote = re.sub(r"\s+", " ", str(item.get("quote") or "")).strip().lower() + if len(quote) < 12: + continue + count = normalized_text.count(quote) + if count > 1: + violations.append({"tag": item.get("tag"), "count": count}) + return violations + + def prefetch_jit_literature(self, topic, keywords, structure): + jit_rag_texts = [] + queries_to_fetch = set() + + for sec in structure: + sec_title = sec.get("section", "") + if any(kw in sec_title.lower() for kw in ["method", "methodology", "analysis", "result", "finding", "setting"]): + jit_prompt = f"Topic: '{topic}'. Keywords: '{keywords}'. Section: '{sec_title}'. Generate exactly 1 highly specific English search query (e.g., a specific statistical method, measurement scale, or analytical framework needed). Keep it UNDER 5 terms. NO complex boolean logic. Return ONLY the query string, no quotes." + query = self.call_deepseek("You are an expert academic librarian.", jit_prompt, max_tokens=50, temp=0.2, model_role="fast").strip(' "\'') + if query: + queries_to_fetch.add(query) + + if not queries_to_fetch: + return "" + + logging.info(f"🔍 [Pre-fetch JIT] 生成了 {len(queries_to_fetch)} 个定向检索词: {queries_to_fetch}") + + with concurrent.futures.ThreadPoolExecutor(max_workers=self.lit_max_workers) as executor: + futures = [] + for query in queries_to_fetch: + futures.append(executor.submit(self._s2_worker, query, 0)) + futures.append(executor.submit(self._oa_worker, query, 1, "language:en,type:article,is_paratext:false")) + + for future in concurrent.futures.as_completed(futures): + results = future.result() + with self.lock: + for res in results: + result_keys = self._reference_keys(res) + existing_keys = { + key + for metadata in self.citation_map.values() + for key in self._reference_keys(metadata) + } + if result_keys and any(key in existing_keys for key in result_keys): + continue + cite_tag = f"[CITE-{len(self.citation_map)+1:02d}]" + self.citation_map[cite_tag] = { + "in_text": res["in_text"], + "ref": res["ref"], + "id": result_keys[0] if result_keys else ('title_year', res.get('title'), res.get('year')), + "doi": res.get("doi", ""), + "external_id": res.get("external_id", ""), + "title": res.get("title", ""), + "year": res.get("year", ""), + "evidence": res.get("rag_text", ""), + "sources": [res.get("source", "jit")], + } + jit_rag_texts.append(f"{cite_tag} {res.get('rag_text', '')}") + + logging.info(f"✅ [Pre-fetch JIT] 成功抓取并注入了 {len(jit_rag_texts)} 篇底层支撑文献。") + return "\n".join(jit_rag_texts) + + def generate_search_queries(self, topic, keywords): + sys_prompt = """You are an expert academic librarian. Generate exactly 3 concise English search queries. +[CRITICAL SEARCH QUERY RULES] +1. DO NOT use complex, deeply nested boolean logic (e.g., Avoid `(A OR B) AND (C OR D)`). +2. DO NOT use excessive quotation marks for long phrases. +3. Output simple, space-separated keyword combinations or simple AND logic. + Good: "educational chatbot" "recommendation system" context-aware + Bad: ("educational chatbot" OR "pedagogical agent") AND ("recommendation system") +4. Keep the query strictly UNDER 5 terms to avoid triggering server API timeouts. +Return ONLY a valid JSON array of strings.""" + user_prompt = f"Topic: {topic}\nKeywords: {keywords}" + for attempt in range(3): + queries = self._extract_json_from_text(self.call_deepseek( + sys_prompt, + user_prompt, + temp=0.1 + attempt * 0.1, + model_role="fast", + )) + if queries and isinstance(queries, list) and len(queries) > 0: + final_queries = [str(q).strip() for q in queries if self._is_english_dominant(str(q).strip())] + if final_queries: + return final_queries[:3] + time.sleep(1) + + clean_chunks = [c.strip() for c in re.findall(r'[a-zA-Z0-9\s\-]{4,}', f"{topic} {keywords}") if self._is_english_dominant(c.strip())] + return clean_chunks[:3] if clean_chunks else [] + + @staticmethod + def _normalize_search_query(value): + query = re.sub(r'["“”‘’()\[\]{}]', ' ', str(value or '')) + query = re.sub(r'(?i)\b(?:AND|OR|NOT)\b', ' ', query) + query = query.replace('-', ' ') + query = re.sub(r'\s+', ' ', query).strip() + terms = re.findall(r"[A-Za-z0-9][A-Za-z0-9']*", query) + return ' '.join(terms[:8]) + + @staticmethod + def _normalize_doi(value): + doi = str(value or '').strip().lower() + doi = re.sub(r'^https?://(?:dx\.)?doi\.org/', '', doi) + return doi.strip() + + @classmethod + def _reference_keys(cls, result): + keys = [] + doi = cls._normalize_doi(result.get('doi')) + if doi: + keys.append(('doi', doi)) + external_id = str(result.get('external_id') or '').strip().lower() + if external_id: + keys.append(('external', external_id)) + title = re.sub(r'[^a-z0-9]+', ' ', str(result.get('title') or '').lower()).strip() + year = str(result.get('year') or '').strip() + if title: + keys.append(('title_year', title, year)) + return keys + + def _record_source_error(self, source, code, status_code=None, detail=''): + self._source_metric_add(source, 'error_count') + self._source_metric_set(source, 'status', 'error') + self._source_metric_set(source, 'last_status_code', status_code) + safe = code if not detail else f"{code}: {self._safe_source_error(detail)}" + self._source_metric_set(source, 'last_safe_error', safe[:300]) + + def _merge_reference_results(self, source_results, limit): + """Merge deterministically while preserving each source's contribution. + + ``merged_count`` means that a source is represented in the final merged + evidence set, including overlap confirmed by both services. + ``unique_added_count`` records which source first introduced a record. + """ + merged = [] + key_to_index = {} + source_lists = {source: list(source_results.get(source, [])) for source in ('s2', 'oa')} + max_length = max((len(items) for items in source_lists.values()), default=0) + for position in range(max_length): + for source in ('s2', 'oa'): + items = source_lists[source] + if position >= len(items): + continue + result = items[position] + keys = self._reference_keys(result) + duplicate_index = next((key_to_index[k] for k in keys if k in key_to_index), None) + if duplicate_index is not None: + self._source_metric_add(source, 'duplicate_count') + sources = merged[duplicate_index].setdefault('sources', []) + if source not in sources: + sources.append(source) + self._source_metric_add(source, 'merged_count') + continue + if len(merged) >= limit: + continue + item = dict(result) + item['sources'] = [source] + merged.append(item) + index = len(merged) - 1 + for key in keys: + key_to_index[key] = index + self._source_metric_add(source, 'merged_count') + self._source_metric_add(source, 'unique_added_count') + # The compact, approved UI displays successful eligible returns, not + # source ownership after cross-source deduplication. Detailed overlap and + # merge counts remain available in LITERATURE_METRICS.json. + self.s2_calls = int(self.source_metrics['s2']['eligible_result_count']) + self.oa_calls = int(self.source_metrics['oa']['eligible_result_count']) + return merged + + def _s2_worker(self, term, offset): + self._check_cancelled() + query = self._normalize_search_query(term) + if not query: + self._record_source_error('s2', 'S2_BAD_QUERY') + return [] + headers = {'x-api-key': str(self.s2_key).strip()} if self.s2_key else {} + self._source_metric_add('s2', 'request_count') + self._source_metric_set('s2', 'status', 'running') + logging.info('S2 search: term=%r normalized=%r offset=%s', term, query, offset) + try: + response = self._requests_get_with_retry( + f"{self.s2_api_url}/paper/search", + headers=headers, + params={ + 'query': query, + 'offset': offset, + 'limit': 50, + 'fields': 'paperId,title,authors,year,venue,abstract,externalIds', + }, + ) + status = response.status_code + self._source_metric_set('s2', 'last_status_code', status) + if status in (401, 403): + self._record_source_error('s2', 'S2_AUTH_ERROR', status) + return [] + if status == 429: + self._record_source_error('s2', 'S2_RATE_LIMIT', status) + return [] + if status == 400: + self._record_source_error('s2', 'S2_BAD_QUERY', status) + return [] + response.raise_for_status() + self._source_metric_add('s2', 'success_count') + papers = response.json().get('data', []) + self._source_metric_add('s2', 'raw_result_count', len(papers)) + results = [] + for paper in papers: + self._check_cancelled() + title = str(paper.get('title') or '').strip() + year = paper.get('year') + if not title or not year or not self._is_english_dominant(title): + continue + authors = paper.get('authors') or [] + last_names, formatted_authors = [], [] + for author in authors: + name = str(author.get('name') or '').strip() + if not name: + continue + parts = name.split() + last = parts[-1] + last_names.append(last) + formatted_authors.append(f"{last}, {parts[0][0]}." if len(parts) > 1 else last) + if not last_names: + continue + if len(last_names) == 1: + in_text = f"({last_names[0]}, {year})" + elif len(last_names) == 2: + in_text = f"({last_names[0]} & {last_names[1]}, {year})" + else: + in_text = f"({last_names[0]} et al., {year})" + author_str = (', '.join(formatted_authors[:-1]) + ', & ' + formatted_authors[-1]) if len(formatted_authors) > 2 else (' & '.join(formatted_authors) if len(formatted_authors) == 2 else formatted_authors[0]) + doi = self._normalize_doi((paper.get('externalIds') or {}).get('DOI')) + ref = f"{author_str} ({year}). {title}. {paper.get('venue') or 'Journal'}." + if doi: + ref += f" https://doi.org/{doi}" + abstract = ' '.join(str(paper.get('abstract') or '').split()[:180]) + results.append({ + 'title': title, + 'year': str(year), + 'doi': doi, + 'external_id': str(paper.get('paperId') or ''), + 'in_text': in_text, + 'ref': ref, + 'rag_text': f"Title: {title}. Abstract evidence: {abstract}", + 'source': 's2', + }) + self._source_metric_add('s2', 'eligible_result_count', len(results)) + self._source_metric_set('s2', 'status', 'ok' if results else 'no_results') + if not results: + self._source_metric_set('s2', 'last_safe_error', 'S2_NO_RESULTS') + return results + except PipelineCancelledError: + raise + except requests.Timeout as error: + self._record_source_error('s2', 'S2_TIMEOUT', detail=error) + except Exception as error: + status = getattr(getattr(error, 'response', None), 'status_code', None) + code = 'S2_RATE_LIMIT' if status == 429 else ('S2_AUTH_ERROR' if status in (401, 403) else 'S2_REQUEST_ERROR') + self._record_source_error('s2', code, status, error) + return [] + + def _oa_worker(self, term, page, filter_str): + self._check_cancelled() + query = self._normalize_search_query(term) + if not query: + self._record_source_error('oa', 'OPENALEX_BAD_QUERY') + return [] + user_agent = 'SmartPaper/production-v1.1' + if self.openalex_email: + user_agent += f' (mailto:{self.openalex_email})' + headers = {'User-Agent': user_agent} + if self.openalex_key: + headers['Authorization'] = f"Bearer {self.openalex_key}" + self._source_metric_add('oa', 'request_count') + self._source_metric_set('oa', 'status', 'running') + logging.info('OpenAlex search: term=%r normalized=%r page=%s', term, query, page) + try: + response = self._requests_get_with_retry( + self.openalex_api_url, + headers=headers, + params={ + **{ + 'search': query, + 'per-page': self.lit_per_page, + 'page': page, + 'filter': filter_str, + }, + **({'mailto': self.openalex_email} if self.openalex_email else {}), + }, + ) + status = response.status_code + self._source_metric_set('oa', 'last_status_code', status) + if status in (401, 403): + self._record_source_error('oa', 'OPENALEX_AUTH_ERROR', status) + return [] + if status == 429: + self._record_source_error('oa', 'OPENALEX_RATE_LIMIT', status) + return [] + if status == 400: + self._record_source_error('oa', 'OPENALEX_BAD_QUERY', status) + return [] + response.raise_for_status() + self._source_metric_add('oa', 'success_count') + raw_items = response.json().get('results', []) + self._source_metric_add('oa', 'raw_result_count', len(raw_items)) + results = [] + for item in raw_items: + self._check_cancelled() + title = str(item.get('title') or '').strip() + if not title or not self._is_english_dominant(title): + continue + year = item.get('publication_year') or 'n.d.' + formatted_authors, last_names = [], [] + for authorship in item.get('authorships') or []: + name = str((authorship.get('author') or {}).get('display_name') or '').strip() + if not name: + continue + parts = name.split(); last = parts[-1] + last_names.append(last) + formatted_authors.append(f"{last}, {parts[0][0]}." if len(parts) > 1 else last) + if not formatted_authors: + continue + if len(last_names) == 1: + in_text = f"({last_names[0]}, {year})" + elif len(last_names) == 2: + in_text = f"({last_names[0]} & {last_names[1]}, {year})" + else: + in_text = f"({last_names[0]} et al., {year})" + author_str = (', '.join(formatted_authors[:-1]) + ', & ' + formatted_authors[-1]) if len(formatted_authors) > 2 else (' & '.join(formatted_authors) if len(formatted_authors) == 2 else formatted_authors[0]) + doi = self._normalize_doi(item.get('doi')) + venue = (((item.get('primary_location') or {}).get('source') or {}).get('display_name') or 'Journal') + ref = f"{author_str} ({year}). {title}. {venue}." + if doi: + ref += f" https://doi.org/{doi}" + inverted = item.get('abstract_inverted_index') or {} + words = [word for position, word in sorted((position, word) for word, positions in inverted.items() for position in positions)] + abstract = ' '.join(words[:180]) + results.append({ + 'title': title, + 'year': str(year), + 'doi': doi, + 'external_id': str(item.get('id') or ''), + 'in_text': in_text, + 'ref': ref, + 'rag_text': f"Title: {title}. Abstract evidence: {abstract}", + 'source': 'oa', + }) + self._source_metric_add('oa', 'eligible_result_count', len(results)) + self._source_metric_set('oa', 'status', 'ok' if results else 'no_results') + if not results: + self._source_metric_set('oa', 'last_safe_error', 'OPENALEX_NO_RESULTS') + return results + except PipelineCancelledError: + raise + except requests.Timeout as error: + self._record_source_error('oa', 'OPENALEX_TIMEOUT', detail=error) + except Exception as error: + status = getattr(getattr(error, 'response', None), 'status_code', None) + if status == 429: + code = 'OPENALEX_RATE_LIMIT' + elif status in (401, 403): + code = 'OPENALEX_AUTH_ERROR' + elif status == 400: + code = 'OPENALEX_BAD_QUERY' + else: + code = 'OPENALEX_REQUEST_ERROR' + self._record_source_error('oa', code, status, error) + return [] + + def fetch_references_parallel(self, topic, keywords, min_required=50, progress_callback=None): + """Retrieve adaptively, then merge deterministically by DOI/ID/title-year. + + Each phase first requests one page per source and query. A second page is + requested only when the minimum evidence set is still not met. This keeps + the interface responsive and avoids redundant OpenAlex filter pairs. + """ + with self.lock: + self.citation_map.clear() + self.used_citations.clear() + self.citation_audit_log.clear() + for source in ('s2', 'oa'): + for field in ( + 'request_count','success_count','raw_result_count', + 'eligible_result_count','merged_count','unique_added_count', + 'duplicate_count','error_count', + ): + self.source_metrics[source][field] = 0 + self.source_metrics[source]['last_status_code'] = None + self.source_metrics[source]['last_safe_error'] = '' + self.source_metrics[source]['status'] = 'not_started' + self.s2_calls = self.oa_calls = 0 + + all_used_terms = set() + collected = {'s2': [], 'oa': []} + basic_filter = 'language:en,type:article,is_paratext:false' + + def unique_preview_count(): + keys_seen = set() + count = 0 + for source in ('s2', 'oa'): + for item in collected[source]: + keys = self._reference_keys(item) + if keys and any(key in keys_seen for key in keys): + continue + keys_seen.update(keys) + count += 1 + return count + + def run_wave(terms, *, second_page=False): + future_sources = {} + with concurrent.futures.ThreadPoolExecutor(max_workers=self.lit_max_workers) as executor: + if not self.s2_fused: + offset = 50 if second_page else 0 + for term in terms: + future_sources[executor.submit(self._s2_worker, term, offset)] = 's2' + if not self.oa_fused: + page = 2 if second_page else 1 + for term in terms: + future_sources[executor.submit(self._oa_worker, term, page, basic_filter)] = 'oa' + for future in concurrent.futures.as_completed(future_sources): + self._check_cancelled() + source = future_sources[future] + collected[source].extend(future.result()) + if progress_callback: + progress_callback() + + for phase in range(3): + self._check_cancelled() + if phase == 0: + terms = self.generate_search_queries(topic, keywords) + else: + response = self.call_deepseek( + 'You are an academic librarian. Return only a JSON array with three new concise English search queries, each under five terms.', + f"Topic: {topic}\nKeywords: {keywords}\nUsed: {sorted(all_used_terms)}", + temp=0.4, + model_role='fast', + ) + parsed = self._extract_json_from_text(response) + terms = parsed if isinstance(parsed, list) else [] + + normalized_terms = [] + for value in terms: + term = self._normalize_search_query(value) + if term and self._is_english_dominant(term) and term not in all_used_terms: + normalized_terms.append(term) + all_used_terms.add(term) + if not normalized_terms: + continue + + run_wave(normalized_terms, second_page=False) + if unique_preview_count() >= min_required: + break + run_wave(normalized_terms, second_page=True) + if unique_preview_count() >= min_required: + break + + merge_limit = max(min_required, min(self.lit_target_papers, min_required + 30)) + merged = self._merge_reference_results(collected, merge_limit) + with self.lock: + for index, result in enumerate(merged, start=1): + tag = f"[CITE-{index:02d}]" + result_keys = self._reference_keys(result) + self.citation_map[tag] = { + 'in_text': result['in_text'], + 'ref': result['ref'], + 'id': result_keys[0] if result_keys else ('title_year', result.get('title'), result.get('year')), + 'doi': result.get('doi',''), + 'external_id': result.get('external_id',''), + 'title': result.get('title',''), + 'year': result.get('year',''), + 'evidence': result.get('rag_text',''), + 'sources': list(result.get('sources') or [result.get('source')]), + } + self.s2_calls = int(self.source_metrics['s2']['eligible_result_count']) + self.oa_calls = int(self.source_metrics['oa']['eligible_result_count']) + for source, no_result_code in (('s2', 'S2_NO_RESULTS'), ('oa', 'OPENALEX_NO_RESULTS')): + metric = self.source_metrics[source] + eligible = int(metric.get('eligible_result_count', 0)) + errors = int(metric.get('error_count', 0)) + if eligible > 0 and errors > 0: + metric['status'] = 'partial' + elif eligible > 0: + metric['status'] = 'ok' + metric['last_safe_error'] = '' + elif errors > 0: + metric['status'] = 'error' + else: + metric['status'] = 'no_results' + metric['last_safe_error'] = no_result_code + logging.info( + 'Reference search completed: unique=%s metrics=%s', + len(self.citation_map), + self.get_source_metrics(), + ) + return '\n'.join( + f"{tag} {meta['evidence']}" for tag, meta in self.citation_map.items() + ) + + @staticmethod + def _citation_tag_number(tag): + match = re.search(r"\d+", str(tag)) + return int(match.group()) if match else 10**9 + + @staticmethod + def _citation_terms(value): + stopwords = { + "about", "after", "also", "among", "and", "are", "because", + "been", "before", "between", "both", "can", "could", "does", + "during", "each", "for", "from", "have", "into", "its", "may", + "more", "most", "not", "only", "other", "our", "over", "such", + "than", "that", "the", "their", "these", "this", "those", + "through", "under", "using", "was", "were", "which", "while", + "with", "within", "would", + } + words = re.findall(r"[A-Za-z][A-Za-z0-9_-]{2,}", str(value).lower()) + return {word for word in words if word not in stopwords} + + def _candidate_citation_tags(self, sentence, required_tags, limit): + valid_required = [ + tag for tag in required_tags + if tag in self.citation_map + ] + sentence_terms = self._citation_terms(sentence) + scored = [] + for tag, metadata in self.citation_map.items(): + if tag in valid_required: + continue + evidence = " ".join( + [ + str(metadata.get("ref", "")), + str(metadata.get("evidence", "")), + ] + ) + evidence_terms = self._citation_terms(evidence) + overlap = len(sentence_terms & evidence_terms) + coverage = overlap / max(1, len(sentence_terms)) + scored.append((coverage, overlap, -self._citation_tag_number(tag), tag)) + scored.sort(reverse=True) + selected = list(dict.fromkeys(valid_required)) + for _, _, _, tag in scored: + if len(selected) >= limit: + break + selected.append(tag) + return selected + + @staticmethod + def _sentence_spans(text): + """Return sentence spans without splitting inside citations/parentheses.""" + value = str(text) + spans = [] + start = 0 + round_depth = 0 + square_depth = 0 + index = 0 + abbreviations = ( + "e.g.", "i.e.", "et al.", "fig.", "dr.", "mr.", "ms.", + "prof.", "vs.", "no.", "vol.", + ) + while index < len(value): + char = value[index] + if char == "(": + round_depth += 1 + elif char == ")" and round_depth: + round_depth -= 1 + elif char == "[": + square_depth += 1 + elif char == "]" and square_depth: + square_depth -= 1 + + boundary = False + if char == "\n": + boundary = True + elif char in ".!?。!?" and round_depth == 0 and square_depth == 0: + if char == ".": + previous_char = value[index - 1] if index > 0 else "" + next_char = value[index + 1] if index + 1 < len(value) else "" + decimal_point = previous_char.isdigit() and next_char.isdigit() + prefix = value[max(0, index - 12) : index + 1].lower() + abbreviation = any(prefix.endswith(item) for item in abbreviations) + boundary = not decimal_point and not abbreviation + else: + boundary = True + + if boundary: + end = index + (0 if char == "\n" else 1) + if end > start and value[start:end].strip(): + spans.append((start, end, value[start:end])) + start = index + 1 + index += 1 + + if start < len(value) and value[start:].strip(): + spans.append((start, len(value), value[start:])) + return spans + + @classmethod + def _citation_sentence_spans(cls, text): + return [ + (start, end, sentence) + for start, end, sentence in cls._sentence_spans(text) + if re.search(r"\[?CITE-\d+\]?", sentence, re.IGNORECASE) + ] + + @staticmethod + def _numeric_tokens(value): + without_citation_tags = re.sub( + r"\[?CITE-\d+\]?", + "", + str(value), + flags=re.IGNORECASE, + ) + return Counter( + re.findall( + r"(?= 8] + + def semantic_audit_claim_specific_citations( + self, + text, + section_name="", + ): + """Audit cited sentences against retrieved evidence at sentence scope. + + The audit may split a sentence when different sources support different + claims, or keep one source when several sources support the same claim. + It may not invent facts, numbers, quotations, or citation tags. A cited + claim that cannot be supported is removed from the manuscript and logged; + one unsupported sentence must not discard an otherwise valid paper. + """ + if not text: + return text + + cfg = self.citation_semantic_audit + enabled = bool(cfg.get("enabled", True)) + fail_closed = bool(cfg.get("fail_closed", True)) + spans = self._citation_sentence_spans(text) + if not spans: + return text + if not enabled: + violations = self.find_multi_citation_violations(text) + if violations: + raise DeepSeekRequestError( + "Citation semantic audit is disabled, but citation " + f"violations remain: {violations[:3]}" + ) + return text + + batch_size = max(1, int(cfg.get("batch_size", 5))) + candidate_limit = max(3, int(cfg.get("candidate_limit", 10))) + technical_retries = max(1, int(cfg.get("technical_retries", 2))) + json_repair_retries = max(0, int(cfg.get("json_repair_retries", 1))) + records = [] + for index, (start, end, sentence) in enumerate(spans, start=1): + raw_tags = re.findall(r"CITE-\d+", sentence, flags=re.IGNORECASE) + required_tags = [f"[{tag.upper()}]" for tag in raw_tags] + unknown = [tag for tag in required_tags if tag not in self.citation_map] + if unknown: + raise DeepSeekRequestError( + f"Unknown citation tag(s) in {section_name}: {unknown}" + ) + candidates = self._candidate_citation_tags( + sentence, + required_tags, + candidate_limit, + ) + evidence = [] + for tag in candidates: + metadata = self.citation_map[tag] + evidence.append( + { + "tag": tag, + "reference": metadata.get("ref", ""), + "evidence": metadata.get("evidence", ""), + } + ) + records.append( + { + "id": f"S{index:04d}", + "start": start, + "end": end, + "original": sentence, + "allowed_tags": candidates, + "evidence": evidence, + } + ) + + replacements = {} + unsupported = [] + for offset in range(0, len(records), batch_size): + batch = records[offset : offset + batch_size] + payload = [ + { + "id": record["id"], + "sentence": record["original"], + "candidate_sources": record["evidence"], + } + for record in batch + ] + system_prompt = """You are a strict academic citation editor. Review only the supplied citation-bearing sentences against the supplied source evidence. Return valid JSON only. + +Rules: +1. Preserve the sentence's substantive meaning, every number, percentage, coefficient, quotation, variable label, and empirical direction. +2. Every output sentence may contain at most one [CITE-XX] tag. +3. When several sources support the same claim, retain only the single source whose supplied evidence most directly supports that claim. +4. When different sources support genuinely different claims in the original sentence, split it into separate complete sentences and attach one directly supporting source to each sentence. +5. A source may be used only when its supplied title/abstract evidence directly supports the adjacent claim. Do not infer support from the reference title alone when the evidence is empty or unrelated. +6. Use only candidate tags supplied for that item. Never invent a source, fact, number, quotation, or citation tag. +7. If no candidate source directly supports a claim that requires external evidence, set status to unsupported and leave revised_text equal to the original sentence. +8. If the statement is ordinary connective prose that does not require external support, status may be uncited_common_knowledge and revised_text may omit the tag without changing the prose. + +Return exactly: +{"items":[{"id":"S0001","status":"ok|split|uncited_common_knowledge|unsupported","revised_text":"...","reason":"brief evidence-grounded reason"}]}""" + user_prompt = json.dumps( + { + "section": section_name, + "items": payload, + }, + ensure_ascii=False, + ) + response = "" + parsed = None + last_error = None + for technical_attempt in range(technical_retries): + self._check_cancelled() + try: + response = self.call_deepseek( + system_prompt, + user_prompt, + max_tokens=8000, + temp=0.0, + model_role="reasoning", + thinking=True, + allow_fallback=False, + ) + parsed = self._extract_json_from_text(response) + last_error = None + break + except PipelineCancelledError: + raise + except Exception as error: + last_error = error + logging.warning( + "Citation audit technical attempt %s/%s failed: %s", + technical_attempt + 1, + technical_retries, + self._safe_source_error(error), + ) + if last_error is not None: + raise CitationAuditError( + "E_CITATION_AUDIT_TIMEOUT", + self._safe_source_error(last_error), + ) from last_error + + expected_ids = {record["id"] for record in batch} + def index_items(value): + items = value.get("items", []) if isinstance(value, dict) else [] + return { + str(item.get("id", "")): item + for item in items + if isinstance(item, dict) + } + + by_id = index_items(parsed) + for _ in range(json_repair_retries): + if set(by_id) == expected_ids: + break + repair_prompt = json.dumps( + { + "expected_ids": sorted(expected_ids), + "invalid_response": response[:12000], + "instruction": ( + "Repair format only. Return valid JSON with one item " + "for every expected id. Do not change revised_text." + ), + }, + ensure_ascii=False, + ) + try: + response = self.call_deepseek( + "You repair JSON formatting only. Output valid JSON only.", + repair_prompt, + max_tokens=8000, + temp=0.0, + model_role="reasoning", + thinking=False, + allow_fallback=False, + ) + parsed = self._extract_json_from_text(response) + by_id = index_items(parsed) + except PipelineCancelledError: + raise + except Exception: + break + if set(by_id) != expected_ids: + raise CitationAuditError( + "E_CITATION_AUDIT_JSON", + "Citation audit returned incomplete or invalid JSON: " + f"expected={sorted(expected_ids)}, got={sorted(by_id)}", + ) + + for record in batch: + item = by_id[record["id"]] + status = str(item.get("status", "")).strip().lower() + revised = str(item.get("revised_text", "")).strip() + if status not in { + "ok", + "split", + "uncited_common_knowledge", + "unsupported", + }: + raise DeepSeekRequestError( + f"Invalid citation audit status for {record['id']}: {status!r}" + ) + if status == "unsupported": + reason = str(item.get("reason", ""))[:500] + unsupported.append( + { + "id": record["id"], + "sentence": record["original"][:500], + "reason": reason, + } + ) + # An unsupported externally verifiable claim cannot remain in + # the manuscript with an arbitrary citation. Remove only this + # sentence and retain an auditable record of the decision. + replacements[record["id"]] = "" + self._append_citation_audit( + section_name, + "remove_unsupported_claim", + record["original"], + "", + reason or "No candidate evidence directly supported the claim.", + ) + continue + if not revised: + raise DeepSeekRequestError( + f"Citation semantic audit returned empty text for {record['id']}" + ) + revised_tags = { + f"[{tag.upper()}]" + for tag in re.findall(r"CITE-\d+", revised, flags=re.IGNORECASE) + } + disallowed = revised_tags - set(record["allowed_tags"]) + if disallowed: + raise DeepSeekRequestError( + f"Citation semantic audit invented tag(s) for {record['id']}: " + f"{sorted(disallowed)}" + ) + if self._numeric_tokens(record["original"]) != self._numeric_tokens(revised): + raise DeepSeekRequestError( + f"Citation semantic audit changed numerical content for {record['id']}" + ) + missing_quotes = [ + quote for quote in self._quoted_passages(record["original"]) + if quote not in revised + ] + if missing_quotes: + raise DeepSeekRequestError( + f"Citation semantic audit changed quoted evidence for {record['id']}" + ) + sentence_violations = self.find_multi_citation_violations(revised) + if sentence_violations: + raise DeepSeekRequestError( + f"Citation semantic audit left multiple citations for {record['id']}: " + f"{sentence_violations[:2]}" + ) + replacements[record["id"]] = revised + + if unsupported: + logging.warning( + "Citation audit removed unsupported claims: section=%s count=%s examples=%s", + section_name, + len(unsupported), + json.dumps(unsupported[:3], ensure_ascii=False), + ) + + rebuilt = [] + cursor = 0 + for record in records: + rebuilt.append(str(text)[cursor : record["start"]]) + rebuilt.append(replacements.get(record["id"], record["original"])) + cursor = record["end"] + rebuilt.append(str(text)[cursor:]) + audited = "".join(rebuilt) + audited = re.sub(r"[ \t]+\n", "\n", audited) + audited = re.sub(r"\n{3,}", "\n\n", audited).strip() + violations = self.find_multi_citation_violations(audited) + if violations: + raise DeepSeekRequestError( + "Citation semantic audit did not resolve all violations: " + f"{violations[:3]}" + ) + logging.info( + "Citation semantic audit passed: section=%s, sentences=%s", + section_name, + len(records), + ) + return audited + + def _append_citation_audit(self, section_name, action, original, revised='', detail=''): + self.citation_audit_log.append({ + 'section': str(section_name), + 'action': str(action), + 'original': str(original)[:800], + 'revised': str(revised)[:800], + 'detail': str(detail)[:500], + }) + + def _citation_support_score(self, sentence, tag): + metadata = self.citation_map.get(tag) or {} + claim_text = re.sub(r"\[?CITE-\d+\]?", " ", str(sentence), flags=re.I) + claim_terms = self._citation_terms(claim_text) + evidence_text = " ".join( + [str(metadata.get("ref", "")), str(metadata.get("evidence", ""))] + ) + evidence_terms = self._citation_terms(evidence_text) + overlap = len(claim_terms & evidence_terms) + coverage = overlap / max(1, len(claim_terms)) + return coverage, overlap + + def _local_citation_recovery(self, text, section_name=''): + """Conservative deterministic recovery for technical audit failures. + + This routine never keeps the first citation merely because it appeared + first. A source is retained only when lexical evidence is sufficiently + strong; otherwise the externally verifiable sentence is removed and the + decision is written to the citation audit log. + """ + rebuilt = [] + cursor = 0 + for start, end, sentence in self._sentence_spans(text): + rebuilt.append(str(text)[cursor:start]) + original = sentence + tags = [f"[{tag.upper()}]" for tag in re.findall(r'CITE-\d+', sentence, re.I)] + unique_tags = list(dict.fromkeys(tags)) + valid = [tag for tag in unique_tags if tag in self.citation_map] + revised = sentence + + if unique_tags and not valid: + revised = re.sub(r'\s*[\[(]?CITE-\d+[\])]?\s*', ' ', sentence, flags=re.I) + revised = re.sub(r'\s+', ' ', revised).strip() + self._append_citation_audit( + section_name, + 'remove_unknown_tag', + original, + revised, + 'No valid citation tag was available.', + ) + elif len(valid) == 1: + selected_tag = valid[0] + coverage, overlap = self._citation_support_score(sentence, selected_tag) + if overlap >= 2 and coverage >= 0.12: + self._append_citation_audit( + section_name, + 'retain_lexically_supported_source', + original, + sentence, + f'Lexical support overlap={overlap}, coverage={coverage:.3f}.', + ) + else: + revised = '' + self._append_citation_audit( + section_name, + 'remove_unverified_claim', + original, + '', + f'The single candidate did not meet the conservative support threshold; overlap={overlap}, coverage={coverage:.3f}.', + ) + elif len(valid) > 1: + scored = sorted( + ( + (*self._citation_support_score(sentence, tag), -self._citation_tag_number(tag), tag) + for tag in valid + ), + reverse=True, + ) + coverage, overlap, _, selected_tag = scored[0] + if overlap >= 2 and coverage >= 0.12: + revised = re.sub( + r'\s*[\[(]?CITE-\d+(?:\s*[,;]\s*CITE-\d+)*[\])]?\s*', + ' ', + sentence, + flags=re.I, + ) + revised = re.sub(r'\s+', ' ', revised).strip() + punctuation = revised[-1] if revised and revised[-1] in '.!?。!?' else '' + if punctuation: + revised = revised[:-1].rstrip() + revised = f"{revised} {selected_tag}{punctuation}" + self._append_citation_audit( + section_name, + 'select_evidence_supported_source', + original, + revised, + f'Lexical support overlap={overlap}, coverage={coverage:.3f}.', + ) + else: + revised = '' + self._append_citation_audit( + section_name, + 'remove_unverified_claim', + original, + '', + f'No candidate met the conservative support threshold; best overlap={overlap}, coverage={coverage:.3f}.', + ) + rebuilt.append(revised) + cursor = end + rebuilt.append(str(text)[cursor:]) + recovered = ''.join(rebuilt) + recovered = re.sub(r'[ \t]+\n', '\n', recovered) + return re.sub(r'\n{3,}', '\n\n', recovered).strip() + + def audit_and_resolve_citations(self, text, section_name='', quarantine=False): + value = str(text or '') + if quarantine: + return self.resolve_citations(value, quarantine=True) + unknown = sorted({f"[{tag.upper()}]" for tag in re.findall(r'CITE-\d+', value, re.I)} - set(self.citation_map)) + if unknown: + for tag in unknown: + value = re.sub(rf'\s*\[?{re.escape(tag.strip("[]"))}\]?\s*', ' ', value, flags=re.I) + self._append_citation_audit(section_name, 'remove_unknown_tags', text, value, ','.join(unknown)) + has_citations = bool(re.search(r'CITE-\d+', value, re.I)) + violations = self.find_multi_citation_violations(value) + if has_citations and self.citation_semantic_audit.get('enabled', True): + try: + value = self.semantic_audit_claim_specific_citations(value, section_name=section_name) + except PipelineCancelledError: + raise + except CitationAuditError as error: + logging.warning( + 'Citation semantic audit technical fallback: section=%s code=%s detail=%s', + section_name, + error.code, + error.safe_detail, + ) + value = self._local_citation_recovery(value, section_name) + except Exception as error: + logging.warning( + 'Citation semantic audit fallback: section=%s error=%s', + section_name, + self._safe_source_error(error), + ) + value = self._local_citation_recovery(value, section_name) + elif violations: + value = self._local_citation_recovery(value, section_name) + if self.find_multi_citation_violations(value): + value = self._local_citation_recovery(value, section_name) + if self.find_multi_citation_violations(value): + raise DeepSeekRequestError( + f'Citation recovery could not resolve all violations in {section_name}.' + ) + return self.resolve_citations(value, quarantine=False) + + @staticmethod + def _remove_ai_meta_traces(text): + patterns = [ + r'(?im)^.*\bas an ai(?: language model)?\b.*(?:\n|$)', + r'(?im)^.*\bai[- ]generated\b.*(?:\n|$)', + r'(?im)^.*\bthis response\b.*(?:\n|$)', + r'(?im)^.*\bi cannot (?:browse|access|verify)\b.*(?:\n|$)', + ] + value = str(text or '') + for pattern in patterns: + value = re.sub(pattern, '', value) + return re.sub(r'\n{3,}', '\n\n', value).strip() + + def polish_academic_section(self, text, section_name='', abstract=False): + value = self._remove_ai_meta_traces(text) + quality = APP_CONFIG.get('manuscript_quality', {}) + forbidden = quality.get('forbidden_phrases', []) + detected = [phrase for phrase in forbidden if re.search(rf'\b{re.escape(phrase)}\b', value, re.I)] + transitions = re.findall(r'(?im)^(Firstly|Secondly|Moreover|Furthermore|In conclusion|To summarize)\b', value) + numeric_count = len(re.findall(r'(? max_numbers)) + if not needs_revision: + return value + numeric_rule = ( + "For the abstract, remove nonessential numerical details. You may omit numbers, " + "but do not alter any number that remains and do not add a new number." + if abstract + else "Preserve every number, percentage, coefficient, p-value, and confidence interval exactly." + ) + prompt = f"""Revise only the supplied academic section. Preserve every heading, citation tag, quotation, hypothesis label, variable abbreviation, and conclusion direction exactly. {numeric_rule} Use concise, accurate, rigorous academic English. Remove formulaic transitions, repetitive summaries, metaphors, analogies, promotional language, and any AI-writing meta-commentary. Do not add new evidence or citations.\nSECTION: {section_name}\nTEXT:\n{value}""" + revised = self.call_deepseek( + 'You are a meticulous academic language editor. Output only the revised section.', + prompt, + max_tokens=max(1200, min(8000, self._count_tokens(value) + 1200)), + temp=0.1, + model_role='writing', + thinking=False, + allow_fallback=False, + ) + revised = self._remove_ai_meta_traces(revised) + original_numbers = self._numeric_tokens(value) + revised_numbers = self._numeric_tokens(revised) + if abstract: + added_or_changed = any( + count > original_numbers.get(token, 0) + for token, count in revised_numbers.items() + ) + if added_or_changed: + logging.warning('Abstract quality revision rejected because a number was added or changed: %s', section_name) + return value + if sum(revised_numbers.values()) > max_numbers: + logging.warning('Abstract quality revision rejected because numerical density remains excessive: %s', section_name) + return value + elif original_numbers != revised_numbers: + logging.warning('Quality revision rejected because numbers changed: %s', section_name) + return value + original_tags = Counter(re.findall(r'CITE-\d+', value, re.I)) + revised_tags = Counter(re.findall(r'CITE-\d+', revised, re.I)) + if original_tags != revised_tags: + logging.warning('Quality revision rejected because citation tags changed: %s', section_name) + return value + return revised.strip() + + @staticmethod + def _trim_at_sentence_boundary(value, max_words): + text = str(value or '').strip() + words = text.split() + if len(words) <= max_words: + return text + sentences = re.split(r'(?<=[.!?])\s+', text) + selected = [] + count = 0 + for sentence in sentences: + sentence_words = sentence.split() + if not sentence_words: + continue + if count + len(sentence_words) > max_words: + break + selected.append(sentence.strip()) + count += len(sentence_words) + if selected: + return ' '.join(selected).strip() + return ' '.join(words[:max_words]).rstrip(' ,;:') + '.' + + def _abstract_manuscript_context(self, manuscript, max_tokens=18000): + value = str(manuscript or '').strip() + if self._count_tokens(value) <= max_tokens: + return value + matches = list(re.finditer(r'(?m)^#\s+(.+?)\s*$', value)) + if not matches: + head = self._truncate_context(value, max_tokens // 3) + tail_tokens = self.tokenizer.encode(value)[-(max_tokens - self._count_tokens(head)):] + return head + '\n\n' + self.tokenizer.decode(tail_tokens) + + sections = [] + preamble = value[:matches[0].start()].strip() + if preamble: + sections.append(('preamble', preamble)) + for index, match in enumerate(matches): + end = matches[index + 1].start() if index + 1 < len(matches) else len(value) + sections.append((match.group(1).strip().lower(), value[match.start():end].strip())) + + categories = ( + (('method', 'research design', 'data', 'sample'), 3000), + (('result', 'finding', 'analysis'), 6500), + (('discussion', 'implication'), 4000), + (('conclusion',), 2500), + (('introduction', 'background'), 1800), + ) + selected = [] + used = set() + for keywords, budget in categories: + candidates = [ + (index, text) for index, (title, text) in enumerate(sections) + if index not in used and any(keyword in title for keyword in keywords) + ] + for index, text in candidates: + selected.append(self._truncate_context(text, budget)) + used.add(index) + break + if not selected: + return self._truncate_context(value, max_tokens) + combined = '\n\n'.join(selected) + return self._truncate_context(combined, max_tokens) + + def generate_final_abstract(self, topic, keywords, manuscript, data_evidence='', mode='amj'): + evidence = self._truncate_context(str(data_evidence or ''), 5000) + body = self._abstract_manuscript_context(manuscript, 18000) + prompt = f"""Write the final English abstract for this completed {mode.upper()} manuscript. Maximum 200 words. State the research problem, design, main finding, and contribution. Use no citations. Include only the minimum numerical evidence required; do not list coefficients, p-values, and fit statistics unless one value is indispensable. Use concise, accurate, rigorous academic language. Do not use metaphors, analogies, rhetorical questions, promotional language, formulaic transitions, or AI-related meta-commentary. Output only the abstract.\nTOPIC: {topic}\nKEYWORDS: {keywords}\nFROZEN EVIDENCE:\n{evidence}\nCOMPLETED MANUSCRIPT:\n{body}""" + abstract = self.call_deepseek( + 'You are a senior journal editor.', prompt, max_tokens=900, temp=0.1, + model_role='writing', thinking=False, allow_fallback=False, + ) + abstract = self._remove_ai_meta_traces(abstract) + abstract = re.sub(r'(?im)^#+\s*abstract\s*', '', abstract).strip() + abstract = self.polish_academic_section(abstract, 'Abstract', abstract=True) + max_words = max(80, int(APP_CONFIG.get('manuscript_quality', {}).get('abstract_max_words', 200))) + return self._trim_at_sentence_boundary(abstract, max_words) + + @staticmethod + def _translation_language_rule(target_lang): + if 'Russian' in target_lang: + return 'Use idiomatic academic Russian with correct case government, gender-number agreement, aspect, punctuation, and terminology. Reorganize English syntax where necessary and avoid literal English calques.' + if 'Kazakh' in target_lang: + return 'Use idiomatic academic Kazakh with correct case and possessive suffixes, person/plural endings, vowel harmony, consonant assimilation, natural word order, and correct Kazakh-specific letters. Avoid Russian syntactic calques.' + return 'Use formal academic Simplified Chinese with explicit concepts, natural Chinese syntax, clear hierarchy, and logically connected claims. Avoid carrying English nominalization and clause order into Chinese mechanically.' + + @staticmethod + def _translation_prompt(target_lang): + common = """Translate the supplied blocks from the locked English manuscript faithfully. Preserve every marker, citation, number, percentage, coefficient, p-value, confidence interval, DOI, hypothesis label, variable abbreviation, quotation meaning, and conclusion direction. Do not add or remove claims. Use concise, accurate, rigorous academic language. Avoid metaphors, analogies, rhetorical questions, promotional language, formulaic transitions, and mechanical repetition. Return every marker exactly once and in the same order. Output only the marked translations.""" + return common + ' ' + SSciPipelineAgent._translation_language_rule(target_lang) + + @staticmethod + def _localized_reference_heading(target_lang): + if 'Russian' in target_lang: + return 'Литература' + if 'Kazakh' in target_lang: + return 'Пайдаланылған әдебиеттер' + return '参考文献' + + @staticmethod + def _markdown_heading_tree(value): + return [ + (len(match.group(1)), match.group(2).strip()) + for match in re.finditer(r'(?m)^(#{1,3})\s+(.+?)\s*$', str(value or '')) + ] + + @staticmethod + def _translation_hypothesis_tokens(value): + return Counter(re.findall(r'\bH\d+[A-Za-z]?\b', str(value or ''), flags=re.I)) + + @staticmethod + def _translation_acronym_tokens(value): + without_doi = re.sub(r'https?://\S+|doi:\s*\S+', ' ', str(value or ''), flags=re.I) + return Counter(re.findall(r'\b[A-Z][A-Z0-9-]{1,11}\b', without_doi)) + + @staticmethod + def _translation_citation_tokens(value): + return Counter( + re.findall( + r'\([^()\n]*?(?:19|20)\d{2}[a-z]?[^()\n]*?\)', + str(value or ''), + flags=re.I, + ) + ) + + @staticmethod + def _translation_body_units(body): + units = [] + buffer = [] + + def flush_buffer(): + if not buffer: + return + segment = '\n'.join(buffer).strip() + buffer.clear() + if not segment: + return + for part in re.split(r'\n{2,}', segment): + part = part.strip() + if part: + units.append({'kind': 'body', 'text': part}) + + for line in str(body or '').splitlines(): + heading = re.match(r'^(#{1,3})\s+(.+?)\s*$', line) + if heading: + flush_buffer() + units.append({ + 'kind': 'heading', + 'level': len(heading.group(1)), + 'text': heading.group(2).strip(), + }) + else: + buffer.append(line) + flush_buffer() + return units + + def _build_translation_glossary(self, body, target_lang): + headings = [title for _, title in self._markdown_heading_tree(body)] + acronyms = sorted(self._translation_acronym_tokens(body)) + phrases = re.findall( + r'\b(?:[A-Z][a-z]+(?:[- ][A-Z][a-z]+){1,3})\b', + str(body or ''), + ) + phrase_counts = Counter(phrases) + term_limit = max(10, int(APP_CONFIG.get('translation_glossary_term_limit', 40))) + recurring = [term for term, count in phrase_counts.most_common() if count >= 2] + unique_headings = list(dict.fromkeys(headings)) + extras = [term for term in list(dict.fromkeys(recurring + acronyms)) if term not in unique_headings] + sources = unique_headings + extras[:max(0, term_limit - len(unique_headings))] + if not sources: + return {} + + language_rule = self._translation_language_rule(target_lang) + payload = json.dumps({'terms': sources}, ensure_ascii=False) + system_prompt = f"""Create one consistent academic terminology glossary for translation into {target_lang}. {language_rule} +Return valid JSON only in this exact form: {{"terms":[{{"source":"...","target":"..."}}]}}. +Translate headings and terminology naturally. Preserve standard variable abbreviations and acronyms when they should remain unchanged. Return every supplied source exactly once.""" + response = self.call_deepseek( + system_prompt, + payload, + max_tokens=max(1600, min(6000, len(sources) * 80)), + temp=0.0, + model_role='translation', + thinking=False, + allow_fallback=False, + ) + parsed = self._extract_json_from_text(response) + items = parsed.get('terms', []) if isinstance(parsed, dict) else [] + mapping = { + str(item.get('source') or '').strip(): str(item.get('target') or '').strip() + for item in items + if isinstance(item, dict) + } + missing_headings = [heading for heading in headings if not mapping.get(heading)] + if missing_headings: + raise DeepSeekRequestError( + 'Translation glossary omitted heading(s): ' + ', '.join(missing_headings[:5]) + ) + for acronym in acronyms: + mapping.setdefault(acronym, acronym) + return mapping + + @staticmethod + def _parse_marked_translation(value, expected_ids): + pattern = re.compile(r'(?m)^\[\[SP-P(\d{6})\]\]\s*$') + matches = list(pattern.finditer(str(value or ''))) + found_ids = [match.group(1) for match in matches] + if found_ids != list(expected_ids): + raise DeepSeekRequestError( + f'Translation marker mismatch: expected={list(expected_ids)}, got={found_ids}.' + ) + parsed = {} + for index, match in enumerate(matches): + end = matches[index + 1].start() if index + 1 < len(matches) else len(str(value or '')) + translated = str(value or '')[match.end():end].strip() + if not translated: + raise DeepSeekRequestError(f'Translation block SP-P{match.group(1)} is empty.') + parsed[match.group(1)] = translated + return parsed + + def _validate_translation_block(self, source, translated, block_id, target_lang): + if self._numeric_tokens(source) != self._numeric_tokens(translated): + raise DeepSeekRequestError(f'Translation changed numerical content in {block_id}.') + if self._translation_citation_tokens(source) != self._translation_citation_tokens(translated): + raise DeepSeekRequestError(f'Translation changed citations in {block_id}.') + if self._translation_hypothesis_tokens(source) != self._translation_hypothesis_tokens(translated): + raise DeepSeekRequestError(f'Translation changed hypothesis labels in {block_id}.') + if self._translation_acronym_tokens(source) != self._translation_acronym_tokens(translated): + raise DeepSeekRequestError(f'Translation changed variable abbreviations in {block_id}.') + + source_compact = re.sub(r'\s+', '', str(source or '')) + target_compact = re.sub(r'\s+', '', str(translated or '')) + if len(source_compact) >= 120: + is_chinese = 'Chinese' in target_lang + minimum = float(APP_CONFIG.get( + 'translation_min_block_ratio_zh' if is_chinese else 'translation_min_block_ratio_cyrillic', + 0.28 if is_chinese else 0.65, + )) + maximum = float(APP_CONFIG.get('translation_max_block_ratio', 2.20)) + ratio = len(target_compact) / max(1, len(source_compact)) + if ratio < minimum or ratio > maximum: + raise DeepSeekRequestError( + f'Translation coverage ratio is implausible in {block_id}: {ratio:.3f}.' + ) + if source_compact.casefold() == target_compact.casefold(): + raise DeepSeekRequestError(f'Translation block {block_id} was returned unchanged.') + if is_chinese and not re.search(r'[\u4e00-\u9fff]', translated): + raise DeepSeekRequestError(f'Translation block {block_id} contains no Chinese text.') + if not is_chinese and not re.search(r'[А-Яа-яЁёӘәҒғҚқҢңӨөҰұҮүҺһІі]', translated): + raise DeepSeekRequestError(f'Translation block {block_id} contains no Cyrillic target-language text.') + + def translate_locked_manuscript(self, manuscript, target_lang): + value = str(manuscript or '').strip() + if not value: + raise DeepSeekRequestError('English manuscript is empty.') + reference_split = re.split(r'(?m)^#\s+References\s*$', value, maxsplit=1) + body = reference_split[0].strip() + references = reference_split[1].strip() if len(reference_split) == 2 else '' + units = self._translation_body_units(body) + if not units: + raise DeepSeekRequestError('English manuscript contains no translatable content.') + + glossary = self._build_translation_glossary(body, target_lang) + body_units = [unit for unit in units if unit['kind'] == 'body'] + translations = {} + token_budget = max(800, int(APP_CONFIG.get('translation_batch_tokens', 2500))) + batches = [] + current = [] + current_tokens = 0 + for index, unit in enumerate(body_units, start=1): + block_id = f'{index:06d}' + tokens = max(1, self._count_tokens(unit['text'])) + if current and current_tokens + tokens > token_budget: + batches.append(current) + current = [] + current_tokens = 0 + current.append((block_id, unit['text'])) + current_tokens += tokens + if current: + batches.append(current) + + system_prompt = self._translation_prompt(target_lang) + '\nUse this terminology glossary consistently:\n' + json.dumps(glossary, ensure_ascii=False) + for batch in batches: + self._check_cancelled() + marked_source = '\n\n'.join( + f'[[SP-P{block_id}]]\n{source}' for block_id, source in batch + ) + result = self.call_deepseek( + system_prompt, + marked_source, + max_tokens=max(1800, min(9000, self._count_tokens(marked_source) * 2 + 1200)), + temp=0.0, + model_role='translation', + thinking=False, + allow_fallback=False, + ) + result = self._remove_ai_meta_traces(result) + parsed = self._parse_marked_translation(result, [block_id for block_id, _ in batch]) + for block_id, source in batch: + translated = parsed[block_id] + self._validate_translation_block(source, translated, f'SP-P{block_id}', target_lang) + translations[block_id] = translated + + output_units = [] + body_index = 0 + for unit in units: + if unit['kind'] == 'heading': + translated_heading = str(glossary.get(unit['text']) or '').strip() + if not translated_heading: + raise DeepSeekRequestError(f'Translation glossary missing heading: {unit["text"]}') + output_units.append('#' * int(unit['level']) + ' ' + translated_heading) + else: + body_index += 1 + output_units.append(translations[f'{body_index:06d}']) + + output = '\n\n'.join(output_units).strip() + if references: + output += f"\n\n# {self._localized_reference_heading(target_lang)}\n{references}" + self.validate_translation_alignment(value, output) + return output.strip() + + @staticmethod + def validate_translation_alignment(english_text, translated_text): + source = str(english_text or '').strip() + target = str(translated_text or '').strip() + if not target: + raise DeepSeekRequestError('Target-language manuscript is empty.') + en_tree = [level for level, _ in SSciPipelineAgent._markdown_heading_tree(source)] + tr_tree = [level for level, _ in SSciPipelineAgent._markdown_heading_tree(target)] + if en_tree != tr_tree: + raise DeepSeekRequestError( + f'Translation heading-tree mismatch: English={en_tree}, target={tr_tree}.' + ) + if SSciPipelineAgent._numeric_tokens(source) != SSciPipelineAgent._numeric_tokens(target): + raise DeepSeekRequestError('Translation numerical alignment failed.') + if SSciPipelineAgent._translation_citation_tokens(source) != SSciPipelineAgent._translation_citation_tokens(target): + raise DeepSeekRequestError('Translation citation alignment failed.') + if SSciPipelineAgent._translation_hypothesis_tokens(source) != SSciPipelineAgent._translation_hypothesis_tokens(target): + raise DeepSeekRequestError('Translation hypothesis-label alignment failed.') + if SSciPipelineAgent._translation_acronym_tokens(source) != SSciPipelineAgent._translation_acronym_tokens(target): + raise DeepSeekRequestError('Translation abbreviation alignment failed.') + source_body = re.split(r'(?m)^#\s+References\s*$', source, maxsplit=1)[0] + target_body = re.split(r'(?m)^#\s+(?:参考文献|Литература|Пайдаланылған әдебиеттер)\s*$', target, maxsplit=1)[0] + source_chars = len(re.sub(r'\s+', '', source_body)) + target_chars = len(re.sub(r'\s+', '', target_body)) + if re.search(r'(?m)^#\s+参考文献\s*$', target): + minimum_ratio = float(APP_CONFIG.get('translation_min_block_ratio_zh', 0.28)) + elif re.search(r'(?m)^#\s+(?:Литература|Пайдаланылған әдебиеттер)\s*$', target): + minimum_ratio = float(APP_CONFIG.get('translation_min_block_ratio_cyrillic', 0.65)) + else: + minimum_ratio = float(APP_CONFIG.get('translation_min_coverage_ratio', 0.25)) + ratio = target_chars / source_chars if source_chars else 0.0 + if source_chars and ratio < minimum_ratio: + raise DeepSeekRequestError( + f'Translation content coverage is too low: {ratio:.3f}.' + ) + return True + + def enforce_claim_specific_citations(self, text): + """Backward-compatible strict validator; no silent citation deletion.""" + violations = self.find_multi_citation_violations(text) + if violations: + raise DeepSeekRequestError( + "Multiple citations require semantic audit; silent collapse is " + f"forbidden. Examples: {violations[:3]}" + ) + return text + + @staticmethod + def enforce_resolved_claim_specific_citations(text): + """Backward-compatible strict validator; never drops resolved sources.""" + violations = SSciPipelineAgent.find_multi_citation_violations(text) + if violations: + raise DeepSeekRequestError( + "Resolved manuscript contains multiple citations in one sentence: " + f"{violations[:3]}" + ) + return text + + @staticmethod + def find_multi_citation_violations(text): + """Return any sentence containing two or more raw or resolved sources.""" + if not text: + return [] + + violations = [] + raw_tag_pattern = re.compile(r"CITE-\d+", re.IGNORECASE) + resolved_citation_pattern = re.compile( + r"\((?:[^()\n]*?)(?:19|20)\d{2}[a-z]?[^()\n]*?\)", + re.IGNORECASE, + ) + for _, _, sentence in SSciPipelineAgent._sentence_spans(text): + raw_tags = raw_tag_pattern.findall(sentence) + resolved_groups = resolved_citation_pattern.findall(sentence) + resolved_sources = 0 + for group in resolved_groups: + parts = [part for part in group.strip("()").split(";") if part.strip()] + year_count = sum( + 1 for part in parts + if re.search(r"\b(?:19|20)\d{2}[a-z]?\b", part, re.IGNORECASE) + ) + resolved_sources += max(1, year_count) + source_count = len(raw_tags) if raw_tags else resolved_sources + if source_count >= 2: + violations.append(sentence.strip()[:500]) + return violations + + def resolve_citations(self, text, quarantine=False): + if quarantine: + text = re.sub(r'\s*\(?[A-Z][a-z]+ (?:& [A-Z][a-z]+ |et al\., )\d{4}\)?\s*', '', text) + text = re.sub(r'\[?CITE-\d+(?:,\s*CITE-\d+)*\]?', '', text, flags=re.IGNORECASE) + return text + + violations = self.find_multi_citation_violations(text) + if violations: + raise DeepSeekRequestError( + "Citation resolution received unaudited multiple-source sentence(s): " + f"{violations[:3]}" + ) + + def bracket_replacer(match): + inner = match.group(1) + cites = re.findall(r'CITE-\d+', inner, flags=re.IGNORECASE) + if not cites: + return match.group(0) + + full_c = f"[{cites[0].upper()}]" + with self.lock: + if full_c in self.citation_map: + self.used_citations.add(full_c) + return self.citation_map[full_c]["in_text"] + return cites[0].upper() + + text = re.sub(r'\[(.*?)\]', bracket_replacer, text) + text = re.sub(r'\((.*?)\)', bracket_replacer, text) + + def loose_replacer(match): + full_c = f"[{match.group(0).upper()}]" + with self.lock: + if full_c in self.citation_map: + self.used_citations.add(full_c) + return self.citation_map[full_c]["in_text"] + return match.group(0) + + resolved = re.sub(r'CITE-\d+', loose_replacer, text, flags=re.IGNORECASE) + resolved_violations = self.find_multi_citation_violations(resolved) + if resolved_violations: + raise DeepSeekRequestError( + "Citation resolution produced multiple-source sentence(s): " + f"{resolved_violations[:3]}" + ) + return resolved + diff --git a/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/core/production_guard.py b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/core/production_guard.py new file mode 100644 index 0000000000000000000000000000000000000000..68ca9f16a8e2dccc2f52c603251f532a7ba332ad --- /dev/null +++ b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/core/production_guard.py @@ -0,0 +1,182 @@ +# -*- coding: utf-8 -*- +"""Final-output validation and packaging for SmartPaper.""" +from __future__ import annotations + +import hashlib +import io +import json +import re +import zipfile +from dataclasses import dataclass +from typing import Any + +from docx import Document + +from utils.docx_builder import DocxBuildError, text_to_docx + + +class OutputValidationError(RuntimeError): + pass + + +@dataclass(frozen=True) +class PackageResult: + zip_bytes: bytes + file_names: tuple[str, ...] + manifest: str + + +def replace_abstract(manuscript: str, abstract: str) -> str: + value = str(manuscript or "") + replacement = f"## Abstract\n{str(abstract or '').strip()}\n\n" + updated, count = re.subn( + r"(?ms)^##\s+Abstract\s*$\n.*?(?=^##\s+Keywords\s*$)", + replacement, + value, + count=1, + ) + if count != 1: + raise OutputValidationError("English abstract block was not found exactly once.") + return updated + + +def validate_docx_bytes(data: bytes, *, minimum_paragraphs: int = 8) -> None: + if not data or len(data) < 1500: + raise OutputValidationError("DOCX output is empty or unexpectedly small.") + try: + document = Document(io.BytesIO(data)) + except Exception as exc: + raise OutputValidationError(f"DOCX cannot be reopened: {exc}") from exc + nonempty = [paragraph.text.strip() for paragraph in document.paragraphs if paragraph.text.strip()] + if len(nonempty) < minimum_paragraphs: + raise OutputValidationError( + f"DOCX contains only {len(nonempty)} nonempty paragraphs; expected at least {minimum_paragraphs}." + ) + + +def validate_markdown_manuscript(text: str, *, language_name: str) -> None: + value = str(text or "").strip() + if len(value) < 1000: + raise OutputValidationError(f"{language_name} manuscript is incomplete.") + if re.search(r"\[?CITE-\d+\]?", value, flags=re.IGNORECASE): + raise OutputValidationError(f"{language_name} manuscript still contains unresolved citation tags.") + if re.search(r"(?i)\bas an ai(?: language model)?\b|\bai[- ]generated\b", value): + raise OutputValidationError(f"{language_name} manuscript contains AI-writing meta-commentary.") + if not re.search(r"(?m)^#\s+", value): + raise OutputValidationError(f"{language_name} manuscript has no title/section headings.") + + +def build_generation_notes(data_metadata: dict[str, Any], mode: str) -> str: + metadata = dict(data_metadata or {}) + source_type = metadata.get("source_type", "unknown") + lines = [ + "SmartPaper Generation Notes", + "", + f"Research mode: {mode}", + f"Evidence source type: {source_type}", + "", + "This file is separate from the manuscript. It documents how research evidence was supplied or generated, the applicable boundaries, and the validation steps used before packaging.", + ] + if metadata.get("disclosure_required"): + lines.extend( + [ + "", + "Evidence-generation notice:", + "The statistical results or qualitative materials used by this run were generated by the system because verified user-provided evidence was unavailable. They were created for drafting and methodological demonstration. They must not be represented as independently collected empirical observations.", + "", + f"Generation method: {metadata.get('method') or 'Structured evidence generation under the selected quantitative or qualitative engine.'}", + ] + ) + study_facts = metadata.get("study_facts") + if isinstance(study_facts, dict): + lines.extend(["", "Frozen qualitative study facts:", json.dumps(study_facts, ensure_ascii=False, indent=2)]) + if metadata.get("sample_size") is not None: + lines.append(f"Generated sample size: {metadata.get('sample_size')}") + if metadata.get("evidence_count") is not None: + lines.append(f"Generated/retained qualitative evidence items: {metadata.get('evidence_count')}") + themes = metadata.get("themes") + if isinstance(themes, list): + lines.append(f"Generated/retained qualitative themes: {len(themes)}") + parameters = metadata.get("generation_parameters") + if isinstance(parameters, dict) and parameters: + lines.extend( + [ + "", + "Generation parameters and process:", + json.dumps(parameters, ensure_ascii=False, indent=2), + ] + ) + lines.extend( + [ + "", + "Validation boundary:", + "The package confirms internal consistency, file integrity, citation resolution, and translation completeness. It does not independently verify external data collection, factual truth beyond the supplied sources, journal acceptance, or the result of any third-party AI-writing detector.", + ] + ) + return "\n".join(lines).strip() + "\n" + + +def _manifest(entries: dict[str, bytes]) -> str: + rows = [] + for name in sorted(entries): + rows.append(f"{hashlib.sha256(entries[name]).hexdigest()} {name}") + return "\n".join(rows) + "\n" + + +def build_validated_package( + *, + english_markdown: str, + translated_markdown: str, + english_filename: str, + translated_filename: str, + data_metadata: dict[str, Any], + mode: str, + citation_audit: list[dict[str, Any]], + literature_metrics: dict[str, Any], +) -> PackageResult: + validate_markdown_manuscript(english_markdown, language_name="English") + validate_markdown_manuscript(translated_markdown, language_name="Target-language") + + try: + english_docx = text_to_docx(english_markdown).getvalue() + translated_docx = text_to_docx(translated_markdown).getvalue() + except DocxBuildError as exc: + raise OutputValidationError(str(exc)) from exc + validate_docx_bytes(english_docx) + validate_docx_bytes(translated_docx) + + entries: dict[str, bytes] = { + english_filename: english_docx, + translated_filename: translated_docx, + "GENERATION_NOTES.txt": build_generation_notes(data_metadata, mode).encode("utf-8"), + "CITATION_AUDIT.json": json.dumps(citation_audit or [], ensure_ascii=False, indent=2).encode("utf-8"), + "LITERATURE_METRICS.json": json.dumps(literature_metrics or {}, ensure_ascii=False, indent=2).encode("utf-8"), + } + manifest = _manifest(entries) + entries["FILE_MANIFEST_SHA256.txt"] = manifest.encode("utf-8") + + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive: + for name, content in entries.items(): + archive.writestr(name, content) + zip_bytes = buffer.getvalue() + + try: + with zipfile.ZipFile(io.BytesIO(zip_bytes), "r") as archive: + bad = archive.testzip() + if bad: + raise OutputValidationError(f"ZIP integrity check failed at {bad}.") + names = set(archive.namelist()) + required = set(entries) + if names != required: + raise OutputValidationError( + f"ZIP file list mismatch: actual={sorted(names)}, expected={sorted(required)}" + ) + for name in (english_filename, translated_filename): + validate_docx_bytes(archive.read(name)) + except OutputValidationError: + raise + except Exception as exc: + raise OutputValidationError(f"ZIP cannot be reopened: {exc}") from exc + + return PackageResult(zip_bytes=zip_bytes, file_names=tuple(sorted(entries)), manifest=manifest) diff --git a/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/core/qualitative_engine.py b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/core/qualitative_engine.py new file mode 100644 index 0000000000000000000000000000000000000000..e357fa57fb38ebbf00d353058fae0bd6080e9b5b --- /dev/null +++ b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/core/qualitative_engine.py @@ -0,0 +1,248 @@ +# -*- coding: utf-8 -*- +"""Qualitative evidence preparation for SmartPaper's ASQ track.""" + +from __future__ import annotations + +import logging +import re + +import streamlit as st + +from config import APP_CONFIG + + +class QualitativeEngine: + def __init__(self, agent): + self.agent = agent + + @staticmethod + def _clean_cell(value, default="Not specified"): + cleaned = str(value or default).replace("\n", " ").strip() + return cleaned or default + + def _build_request(self, topic, keywords, raw_interviews): + schema = """Return ONLY one valid JSON object with this schema: +{ + "study_facts": { + "research_setting": "string", + "data_sources": ["string"], + "participant_count": integer or null, + "interview_count": integer or null, + "analysis_method": "string" + }, + "evidence": [ + {"theme": "2nd-order theme", "concept": "1st-order concept", "quote": "verbatim quote"} + ] +} +""" + if raw_interviews and len(raw_interviews) > 100: + return f"""Topic: {topic} +Keywords: {keywords} +Analyze the supplied interview or field material. + +Rules: +1. Use only setting, sample, source, and count information supported by the supplied material. Use null or "Not specified" when absent. +2. Extract up to 15 representative quotations verbatim; do not rewrite quotations. +3. Build a coherent Gioia-style coding structure. +4. The study_facts object will become the single source of truth for the entire manuscript. + +{schema} + +{raw_interviews[:30000]} + +""" + + return f"""Topic: {topic} +Keywords: {keywords} +Construct one internally coherent qualitative study design and evidence packet for a complete ASQ-style manuscript when no interview material is supplied. + +Rules: +1. Choose one research setting and keep it fixed. +2. Choose one participant count, interview count, data-source set, and analysis method; do not create conflicting counts. +3. Produce exactly 12 natural representative quotations organized into a Gioia-style coding structure. +4. The study_facts object will become the single source of truth for the entire manuscript. +5. Avoid decorative language, generic AI phrasing, and repetitive quotation patterns. + +{schema} +""" + + def resolve_qualitative_data(self, topic, keywords, raw_interviews=""): + has_user_data = bool(raw_interviews and len(raw_interviews) > 100) + prompt = self._build_request(topic, keywords, raw_interviews) + model_label = self.agent.model_for("reasoning") + + if has_user_data: + status_label = ( + f"🧠 正在使用 {model_label} 分析访谈材料并冻结研究事实…" + ) + complete_label = "✅ 访谈材料、研究事实与编码结构已完成" + else: + status_label = ( + f"🧠 正在使用 {model_label} 构建一致的定性研究证据包…" + ) + complete_label = "✅ 定性研究事实与编码结构已完成" + + with st.status(status_label, expanded=True) as status: + placeholder = st.empty() + response = placeholder.write_stream( + self.agent.stream_deepseek( + "You are a rigorous qualitative research analyst.", + prompt, + model_role="reasoning", + thinking=True, + allow_fallback=False, + ) + ) + status.update( + label=complete_label, + state="complete", + expanded=False, + ) + + response = re.sub( + r".*?", + "", + response, + flags=re.DOTALL, + ).strip() + data = self.agent._extract_json_from_text(response) + + if not isinstance(data, dict): + logging.error("Qualitative JSON parsing failed; using safe fallback.") + data = { + "study_facts": { + "research_setting": "Not specified", + "data_sources": ["Interview or field material"], + "participant_count": None, + "interview_count": None, + "analysis_method": "Gioia-style inductive coding", + }, + "evidence": [ + { + "theme": "Sensemaking", + "concept": "Initial uncertainty", + "quote": ( + "We did not yet have a shared understanding " + "of how to respond." + ), + } + ], + } + + facts = data.get("study_facts") or {} + evidence = data.get("evidence") or data.get("quotes") or [] + if not isinstance(evidence, list): + evidence = [] + + data_sources = facts.get("data_sources") or ["Not specified"] + if not isinstance(data_sources, list): + data_sources = [data_sources] + + setting = self._clean_cell(facts.get("research_setting")) + sources = ", ".join(self._clean_cell(item) for item in data_sources) + participants = facts.get("participant_count") + interviews = facts.get("interview_count") + method = self._clean_cell( + facts.get("analysis_method"), + "Gioia-style inductive coding", + ) + + md = ( + "### Qualitative Study Design Summary\n" + "| Field | Frozen value |\n" + "| :--- | :--- |\n" + f"| Research setting | {setting} |\n" + f"| Data sources | {sources} |\n" + f"| Participants | {participants if participants is not None else 'Not specified'} |\n" + f"| Interviews | {interviews if interviews is not None else 'Not specified'} |\n" + f"| Analysis method | {method} |\n\n" + "[CONSISTENCY MANDATE]\n" + "Use the study-design summary above as the single source of truth. " + "Do not change the setting, sample, interview count, data sources, " + "or analysis method in any section.\n\n" + "### Table: Qualitative Coding Structure (Data Evidence)\n" + "| 2nd-order Theme | 1st-order Concept | Representative Quote |\n" + "| :--- | :--- | :--- |\n" + ) + + clean_evidence = [] + for index, item in enumerate(evidence[:15]): + if not isinstance(item, dict): + continue + quote = self._clean_cell(item.get("quote"), "").replace('"', "'") + concept = self._clean_cell(item.get("concept"), "Analysis") + theme = self._clean_cell(item.get("theme"), "Observation") + if not quote: + continue + tag = f"QUOTE-{index + 1:02d}" + md += f'| {theme} | {concept} | "{quote}" [{tag}] |\n' + clean_evidence.append( + { + "theme": theme, + "concept": concept, + "quote": quote, + "tag": tag, + } + ) + + theme_map = {} + for item in clean_evidence: + theme_entry = theme_map.setdefault( + item["theme"], + {"title": item["theme"], "concepts": [], "evidence": []}, + ) + if item["concept"] not in theme_entry["concepts"]: + theme_entry["concepts"].append(item["concept"]) + theme_entry["evidence"].append( + {"tag": item["tag"], "quote": item["quote"]} + ) + + themes = list(theme_map.values()) + validation = APP_CONFIG.get("manuscript_validation", {}) + minimum_themes = int(validation.get("minimum_qualitative_themes", 3)) + minimum_quotes = int(validation.get("minimum_qualitative_quotes", 8)) + if len(themes) < minimum_themes or len(clean_evidence) < minimum_quotes: + raise RuntimeError( + "Qualitative evidence packet is incomplete: " + f"themes={len(themes)}, quotes={len(clean_evidence)}; " + f"required themes>={minimum_themes}, quotes>={minimum_quotes}." + ) + + metadata = { + "mode": "qualitative", + "source_type": ( + "user_provided" if has_user_data else "system_generated" + ), + "disclosure_required": not has_user_data, + "study_facts": { + "research_setting": setting, + "data_sources": data_sources, + "participant_count": participants, + "interview_count": interviews, + "analysis_method": method, + }, + "evidence_count": len(clean_evidence), + "generation_parameters": { + "coding_approach": "Gioia-style inductive coding", + "requested_quote_count_when_generated": 12, + "maximum_retained_quotes": 15, + "minimum_themes": minimum_themes, + "minimum_quotes": minimum_quotes, + "consistency_constraints": [ + "one fixed research setting", + "one fixed participant count", + "one fixed interview count", + "one fixed data-source set", + "one fixed analysis method", + "quotation tags must remain unique", + ], + }, + "evidence": clean_evidence, + "themes": themes, + } + status_message = "✅ 定性研究事实、摘要锚点输入和Gioia编码证据已准备完成。" + return md, status_message, metadata + + +def get_qualitative_engine(agent): + return QualitativeEngine(agent) diff --git a/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/core/quantitative_engine.py b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/core/quantitative_engine.py new file mode 100644 index 0000000000000000000000000000000000000000..5d50f1a3e5d6cfd78a4a387f2411635fec38df0e --- /dev/null +++ b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/core/quantitative_engine.py @@ -0,0 +1,230 @@ +# -*- coding: utf-8 -*- +"""Quantitative data preparation for SmartPaper's AMJ track.""" + +from __future__ import annotations + +import re + +import numpy as np +import pandas as pd + +from i18n import tr +from utils.file_parser import extract_data_from_files + + +class QuantitativeEngine: + """Keep quantitative preparation isolated from the Streamlit UI.""" + + @staticmethod + def generate_psd_corr_matrix(n_constructs, max_corr=0.5): + if n_constructs == 1: + return np.array([[1.0]]) + base_corr = np.random.uniform(0.2, max_corr - 0.1) + matrix = np.random.uniform( + base_corr - 0.1, + base_corr + 0.1, + (n_constructs, n_constructs), + ) + matrix = (matrix + matrix.T) / 2 + np.fill_diagonal(matrix, 1.0) + min_eig = np.min(np.linalg.eigvals(matrix)) + if min_eig < 0: + matrix -= (min_eig - 0.01) * np.eye(n_constructs) + diagonal = np.sqrt(np.diag(matrix)) + return matrix / np.outer(diagonal, diagonal) + + @staticmethod + def calculate_validity_metrics(df, constructs_info, true_loadings): + metrics = [] + for construct in constructs_info: + construct_name = construct["name"] + items = construct["items"] + sub_df = df[items] + var_total = sub_df.sum(axis=1).var(ddof=1) + sum_var = sub_df.var(ddof=1).sum() + n_items = len(items) + alpha = ( + 0.0 + if var_total <= 0 + else (n_items / (n_items - 1)) + * (1 - sum_var / var_total) + ) + loadings = np.array(true_loadings[construct_name]) + sum_loadings_sq = np.sum(loadings) ** 2 + sum_sq_loadings = np.sum(loadings**2) + sum_error = np.sum(1 - loadings**2) + cr = sum_loadings_sq / (sum_loadings_sq + sum_error) + ave = sum_sq_loadings / len(loadings) + metrics.append( + { + "Construct": construct_name, + "Items": n_items, + "Cronbach's α": round(alpha, 3), + "CR": round(cr, 3), + "AVE": round(ave, 3), + } + ) + return pd.DataFrame(metrics) + + def generate_empirical_data_robust( + self, + construct_names=None, + n=300, + scale_points=7, + ): + if not construct_names or len(construct_names) < 2: + construct_names = [ + "Independent_Var", + "Mediator_Var", + "Dependent_Var", + ] + + n_constructs = len(construct_names) + corr = self.generate_psd_corr_matrix(n_constructs, max_corr=0.55) + latent_scores = np.random.multivariate_normal( + mean=[0] * n_constructs, + cov=corr, + size=n, + ) + data = {} + true_loadings = {} + constructs_info = [] + target_mean = scale_points / 2.0 + 0.5 + target_std = scale_points / 5.0 + + for index, construct_name in enumerate(construct_names): + true_score = latent_scores[:, index] + n_items = np.random.randint(3, 6) + loadings = np.random.uniform(0.72, 0.88, n_items) + true_loadings[construct_name] = loadings + item_names = [] + for item_index, loading in enumerate(loadings): + error = np.random.normal( + 0, + np.sqrt(1 - loading**2), + n, + ) + item_score = loading * true_score + error + raw_scaled = item_score * target_std + target_mean + item_int = np.clip( + np.round(raw_scaled), + 1, + scale_points, + ).astype(int) + column_name = f"{construct_name}_{item_index + 1}" + data[column_name] = item_int + item_names.append(column_name) + constructs_info.append( + {"name": construct_name, "items": item_names} + ) + + df = pd.DataFrame(data) + validity_df = self.calculate_validity_metrics( + df, + constructs_info, + true_loadings, + ) + mean_scores = pd.DataFrame( + { + construct["name"]: df[construct["items"]].mean(axis=1) + for construct in constructs_info + } + ) + fl_matrix = mean_scores.corr().astype(object) + + for _, row in validity_df.iterrows(): + construct_name = row["Construct"] + sqrt_ave = np.sqrt(row["AVE"]) + fl_matrix.loc[construct_name, construct_name] = ( + f"({sqrt_ave:.3f})" + ) + + for row_index in range(len(fl_matrix.columns)): + for column_index in range(row_index + 1, len(fl_matrix.columns)): + fl_matrix.iloc[row_index, column_index] = "" + + desc_stats = mean_scores.mean().to_frame("Mean").join( + mean_scores.std().to_frame("SD") + ) + return df, validity_df, desc_stats.join(fl_matrix) + + def resolve_empirical_data(self, uploaded_files=None, language="zh"): + if uploaded_files: + user_md = extract_data_from_files(uploaded_files) + if user_md: + evidence_sections = [ + heading.strip() + for heading in re.findall( + r"(?m)^#{1,3}\s+(.+?)\s*$", + user_md, + ) + if heading.strip() + ] + metadata = { + "mode": "quantitative", + "source_type": "user_provided", + "disclosure_required": False, + "evidence_sections": evidence_sections[:20] + or ["User-provided quantitative evidence"], + } + return None, user_md, tr(language, "actual_stats_parsed"), metadata + + df, validity_df, fl_matrix_df = self.generate_empirical_data_robust() + generated_prompt = f"""### Table 1: Reliability and Convergent Validity +{validity_df.to_markdown(index=False)} +### Table 2: Descriptive Statistics and Discriminant Validity (Fornell-Larcker Criterion) +*Note: Diagonal elements in parentheses are the square roots of AVE.* +{fl_matrix_df.to_markdown()} +[STRICT EMPIRICAL INSTRUCTION] +1. ZERO HALLUCINATION: Report the exact metrics provided above. +2. VALIDITY DEFENSE: State convergent validity only when the displayed AVE and CR values support it. +3. DISCRIMINANT DEFENSE: State discriminant validity only when the displayed diagonal and correlation values support it. +4. CONSISTENCY: Use these tables as the single source of numerical truth throughout the paper. +""" + metadata = { + "mode": "quantitative", + "source_type": "system_generated", + "disclosure_required": True, + "sample_size": int(len(df)), + "scale_points": 7, + "method": ( + "Positive-semidefinite latent correlation generation, " + "indicator loadings, discretization, and derived " + "reliability/validity summaries." + ), + "generation_parameters": { + "constructs": [ + "Independent_Var", + "Mediator_Var", + "Dependent_Var", + ], + "sample_size": int(len(df)), + "likert_scale_points": 7, + "items_per_construct": "random integer from 3 to 5", + "indicator_loading_range": [0.72, 0.88], + "latent_correlation_control": "positive-semidefinite matrix with maximum target correlation 0.55", + "item_generation": "loading × latent score + normally distributed measurement error", + "discretization": "round to nearest integer and clamp to 1–7", + "reported_summaries": [ + "Cronbach alpha", + "composite reliability", + "average variance extracted", + "descriptive statistics", + "Fornell-Larcker matrix", + ], + }, + "evidence_sections": [ + "Reliability and convergent validity", + "Descriptive statistics and discriminant validity", + ], + } + return ( + df, + generated_prompt, + tr(language, "synthetic_stats_generated"), + metadata, + ) + + +def get_quantitative_engine(): + return QuantitativeEngine() diff --git a/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/core/rag_engine.py b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/core/rag_engine.py new file mode 100644 index 0000000000000000000000000000000000000000..e163aafd9b7e2d5dce1e5f8e83a6e07a25f7709e --- /dev/null +++ b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/core/rag_engine.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +from typing import List +import numpy as np +from config import APP_CONFIG + +try: + from sklearn.feature_extraction.text import TfidfVectorizer + from sklearn.metrics.pairwise import cosine_similarity + SKLEARN_AVAILABLE = True +except ImportError: + SKLEARN_AVAILABLE = False + +class ResearchRAGEngine: + def __init__(self, raw_texts: List[str]): + self.chunks = [] + self.vectorizer = None + self.tfidf_matrix = None + self.chunk_size = APP_CONFIG["rag_chunk_size"] + self._initialize_chunks(raw_texts) + + def _initialize_chunks(self, raw_texts: List[str]): + for text in raw_texts: + for i in range(0, len(text), self.chunk_size): + chunk = text[i:i + self.chunk_size].strip() + if len(chunk) > 50: + self.chunks.append(chunk) + + if SKLEARN_AVAILABLE and self.chunks: + self.vectorizer = TfidfVectorizer(stop_words='english', max_df=0.85) + try: + self.tfidf_matrix = self.vectorizer.fit_transform(self.chunks) + except ValueError: + self.vectorizer = None + + def get_relevant_context(self, query: str, top_k: int = None) -> str: + if not self.chunks: + return "" + if top_k is None: + top_k = APP_CONFIG["rag_top_k"] + + relevant_chunks = [] + if self.vectorizer and self.tfidf_matrix is not None: + query_vec = self.vectorizer.transform([query]) + similarities = cosine_similarity(query_vec, self.tfidf_matrix).flatten() + top_indices = similarities.argsort()[-top_k:][::-1] + relevant_chunks = [self.chunks[i] for i in top_indices if similarities[i] > 0.05] + else: + scores = [] + query_words = set(query.lower().split()) + for chunk in self.chunks: + chunk_lower = chunk.lower() + score = sum(1 for word in query_words if word in chunk_lower) + scores.append(score) + top_indices = np.argsort(scores)[-top_k:] + relevant_chunks = [self.chunks[i] for i in top_indices if scores[i] > 0] + + if not relevant_chunks: + return "" + return "\n--- Target Context Snippet ---\n" + "\n\n".join(relevant_chunks) + +def initialize_rag(texts: List[str]): + return ResearchRAGEngine(texts) \ No newline at end of file diff --git a/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/desktop.ini b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/desktop.ini new file mode 100644 index 0000000000000000000000000000000000000000..cf8cdc8ab9b7e23d435e3b7964e4f3303263211c --- /dev/null +++ b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/desktop.ini @@ -0,0 +1,2 @@ +[.ShellClassInfo] +IconResource=C:\Program Files\Google\Drive File Stream\129.0.1.0\GoogleDriveFS.exe,27 diff --git a/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/i18n.py b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/i18n.py new file mode 100644 index 0000000000000000000000000000000000000000..0a8462d0596d4c1bd149240a963d64c88a631b1a --- /dev/null +++ b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/i18n.py @@ -0,0 +1,856 @@ +# -*- coding: utf-8 -*- +"""SmartPaper user-interface translations. + +The Chinese interface is the visual baseline. Other languages replace text only; +Streamlit component order, icons, layout, and interaction remain unchanged. +""" + +from __future__ import annotations + +from typing import Any + + +LANGUAGE_OPTIONS = { + "中文": "zh", + "English": "en", + "Русский": "ru", + "Қазақша": "kk", +} + +TARGET_LANGUAGE = { + "zh": "Simplified Chinese (简体中文)", + # Approved product rule: English UI still delivers English evidence draft + Chinese paper. + "en": "Simplified Chinese (简体中文)", + "ru": "Russian (Русский)", + "kk": "Kazakh (Қазақша)", +} + +OUTPUT_SUFFIX = { + "zh": "CN", + "en": "CN", + "ru": "RU", + "kk": "KK", +} + +TARGET_FLAG = { + "zh": "🇨🇳", + "en": "🇨🇳", + "ru": "🇷🇺", + "kk": "🇰🇿", +} + + + +AMJ_CSV_TEMPLATES = { + "zh": """--- 表1:描述性统计与相关系数 --- +Variables,Mean,SD,1,2,3 +Team Cohesion,4.21,0.85,(0.88),, +AI Integration,3.95,0.91,0.45**,(0.82), +Task Performance,4.10,0.78,0.52**,0.38*,(0.85) + +--- 表2:信效度检验 --- +Construct,Items,Cronbach's Alpha,CR,AVE +Team Cohesion,4,0.89,0.91,0.71 +AI Integration,3,0.85,0.88,0.68 + +--- 表3:假设检验 --- +Hypothesis,Path,Beta,T-value,P-value,Supported? +H1,AI Integration -> Team Cohesion,0.35,4.21,0.000,Yes""", + "en": """--- Table 1: Descriptive Statistics and Correlations --- +Variables,Mean,SD,1,2,3 +Team Cohesion,4.21,0.85,(0.88),, +AI Integration,3.95,0.91,0.45**,(0.82), +Task Performance,4.10,0.78,0.52**,0.38*,(0.85) + +--- Table 2: Reliability and Validity --- +Construct,Items,Cronbach's Alpha,CR,AVE +Team Cohesion,4,0.89,0.91,0.71 +AI Integration,3,0.85,0.88,0.68 + +--- Table 3: Hypothesis Tests --- +Hypothesis,Path,Beta,T-value,P-value,Supported? +H1,AI Integration -> Team Cohesion,0.35,4.21,0.000,Yes""", + "ru": """--- Таблица 1: Описательная статистика и корреляции --- +Переменные,Среднее,SD,1,2,3 +Сплочённость команды,4.21,0.85,(0.88),, +Интеграция ИИ,3.95,0.91,0.45**,(0.82), +Результативность задачи,4.10,0.78,0.52**,0.38*,(0.85) + +--- Таблица 2: Надёжность и валидность --- +Конструкт,Пункты,Cronbach's Alpha,CR,AVE +Сплочённость команды,4,0.89,0.91,0.71 +Интеграция ИИ,3,0.85,0.88,0.68 + +--- Таблица 3: Проверка гипотез --- +Гипотеза,Связь,Beta,T-значение,P-значение,Поддержана? +H1,Интеграция ИИ -> Сплочённость команды,0.35,4.21,0.000,Да""", + "kk": """--- 1-кесте: Сипаттамалық статистика және корреляциялар --- +Айнымалылар,Орташа,SD,1,2,3 +Команда бірлігі,4.21,0.85,(0.88),, +ЖИ интеграциясы,3.95,0.91,0.45**,(0.82), +Тапсырма нәтижелілігі,4.10,0.78,0.52**,0.38*,(0.85) + +--- 2-кесте: Сенімділік және валидтілік --- +Конструкт,Тармақтар,Cronbach's Alpha,CR,AVE +Команда бірлігі,4,0.89,0.91,0.71 +ЖИ интеграциясы,3,0.85,0.88,0.68 + +--- 3-кесте: Гипотезаларды тексеру --- +Гипотеза,Байланыс,Beta,T-мәні,P-мәні,Қолдау бар ма? +H1,ЖИ интеграциясы -> Команда бірлігі,0.35,4.21,0.000,Иә""", +} + +ASQ_TXT_TEMPLATES = { + "zh": """[访谈编号]:INT-01 +[受访者岗位]:高级软件工程师 +[访谈日期]:2026-03-12 +[访谈时长]:45分钟 + +访谈者:生成式AI助手的引入如何改变了团队的日常敏捷站会? + +受访者(INT-01):[在这里输入受访者的完整回答] + +访谈者:[在这里输入后续问题] + +受访者(INT-01):[在这里输入后续回答] + +-------------------------------------------------- + +[访谈编号]:INT-02 +[受访者岗位]:敏捷教练 +[访谈日期]:2026-03-15 +[访谈时长]:60分钟 + +访谈者:[在这里输入问题] + +受访者(INT-02):[在这里输入回答]""", + "en": """[Interview ID]: INT-01 +[Informant Role]: Senior Software Engineer +[Interview Date]: 2026-03-12 +[Interview Duration]: 45 minutes + +Interviewer: How did the introduction of a generative-AI assistant change the team's daily stand-up meetings? + +Informant (INT-01): [Enter the informant's complete response here] + +Interviewer: [Enter the follow-up question here] + +Informant (INT-01): [Enter the follow-up response here] + +-------------------------------------------------- + +[Interview ID]: INT-02 +[Informant Role]: Scrum Master +[Interview Date]: 2026-03-15 +[Interview Duration]: 60 minutes + +Interviewer: [Enter the question here] + +Informant (INT-02): [Enter the response here]""", + "ru": """[Номер интервью]: INT-01 +[Роль информанта]: Старший инженер-программист +[Дата интервью]: 2026-03-12 +[Продолжительность]: 45 минут + +Интервьюер: Как внедрение помощника на основе генеративного ИИ изменило ежедневные стендап-встречи команды? + +Информант (INT-01): [Введите полный ответ информанта] + +Интервьюер: [Введите уточняющий вопрос] + +Информант (INT-01): [Введите последующий ответ] + +-------------------------------------------------- + +[Номер интервью]: INT-02 +[Роль информанта]: Скрам-мастер +[Дата интервью]: 2026-03-15 +[Продолжительность]: 60 минут + +Интервьюер: [Введите вопрос] + +Информант (INT-02): [Введите ответ]""", + "kk": """[Сұхбат нөмірі]: INT-01 +[Ақпарат берушінің рөлі]: Аға бағдарламалық жасақтама инженері +[Сұхбат күні]: 2026-03-12 +[Ұзақтығы]: 45 минут + +Сұхбат алушы: Генеративті ЖИ көмекшісін енгізу команданың күнделікті стендап жиналыстарына қалай әсер етті? + +Ақпарат беруші (INT-01): [Ақпарат берушінің толық жауабын осында енгізіңіз] + +Сұхбат алушы: [Қосымша сұрақты осында енгізіңіз] + +Ақпарат беруші (INT-01): [Қосымша жауапты осында енгізіңіз] + +-------------------------------------------------- + +[Сұхбат нөмірі]: INT-02 +[Ақпарат берушінің рөлі]: Scrum-мастер +[Сұхбат күні]: 2026-03-15 +[Ұзақтығы]: 60 минут + +Сұхбат алушы: [Сұрақты осында енгізіңіз] + +Ақпарат беруші (INT-02): [Жауапты осында енгізіңіз]""", +} + +REFERENCE_HEADINGS = { + "zh": "参考文献", + "en": "参考文献", + "ru": "Список литературы", + "kk": "Пайдаланылған әдебиеттер", +} + + +TEXTS: dict[str, dict[str, str]] = { + "zh": { + "title": "📊 工商管理论文智能体", + "topic_label": "📝 论文名称", + "keywords_label": "🔍 关键字", + "words_label": "📏 目标总单词数(12000–25000单词)", + "success": "🎉 流水线推演完毕!双语文档已就绪。", + "download": "📥 下载文件", + "system_busy_title": "⏳ 系统忙碌", + "system_busy_warning": "🚨 有用户正在生成论文", + "system_busy_info": "💡 预计25–35分钟后可正常使用", + "checkpoint_warning": "⚠️ 系统检测到上次因网络或意外中断的未完成论文任务。", + "resume": "🔄 恢复断点续传", + "clear": "🗑️ 清除临时数据", + "busy_short": "⏳ 有用户正在生成论文,预计25–35分钟后可用。", + "tab_amj": "✨ 定量研究(AMJ 引擎)", + "tab_asq": "📝 定性研究(ASQ 引擎)", + "amj_config": "### 📊 定量研究配置(Academy of Management Journal Style)", + "amj_info": "💡 该模式聚焦理论演绎、假设检验与因果推断。结构锁定为:引言、理论与假设、方法、结果、讨论、结论。", + "upload_reference": "1. 上传核心参考论文(可选,仅限PDF/DOCX,建议1–20篇)", + "upload_stats": "2. 上传统计结果表(可选,仅限Excel/CSV)", + "amj_expander": "💡 不知道怎么整理数据?点击查看并下载:定量数据(CSV)标准模板", + "amj_hint": "**⚠️ 核心提示**:请上传**统计结果表**(如信效度、相关系数、路径系数),**请勿**上传成千上万行的原始问卷明细,以免触发系统防超载截断。", + "download_amj_template": "📥 一键下载 CSV 数据模板", + "start_amj": "🚀 启动 AMJ 模式生成", + "asq_config": "### 📝 定性研究配置(Administrative Science Quarterly Style)", + "asq_info": "💡 该模式聚焦现象发现、意义建构与扎根理论(Gioia方法)。结构锁定为:引言、理论框架、研究情境与方法、发现(厚数据叙事)、讨论、结论、附录(证据表)。", + "upload_interviews": "2. 上传真实访谈/田野资料(可选,仅限TXT/PDF/DOCX)", + "asq_expander": "💡 不知道怎么整理访谈?点击查看并下载:定性访谈记录(TXT)标准模板", + "asq_hint": "**⚠️ 核心提示**:为了让AI准确提取语录和编码树,请务必标注**受访者角色(Role)**,并采用清晰的**对话格式**。", + "download_asq_template": "📥 一键下载 TXT 访谈模板", + "start_asq": "🚀 启动 ASQ 模式生成", + "api_key_missing": "❌ 缺少 DEEPSEEK_API_KEY,请配置API密钥。", + "resume_toast": "🔄 检测到页面刷新或网络抖动,系统正在自动恢复进度……", + "pipeline_start": "⏳ 流水线启动……", + "resume_loading": "🔄 正在从本地安全快照恢复状态……", + "resume_success": "✅ 断点加载成功!将从节点 {done}/{total} 安全恢复。", + "resume_phase": "断点恢复", + "state_check": "状态校验", + "history_restored": "✅ 已找回历史算力消耗数据", + "extract_quant": "⚙️ 正在解析定量实证数据……", + "literature_extract": "🧠 正在从文献中提取核心数据并构建RAG索引……", + "literature_deep": "**深度文献提炼中,请勿关闭页面**", + "literature_done": "✅ 核心文献特征提炼完成!", + "config_route": "🕵️ 引擎配置与范式路由……", + "literature_search": "📚 智能文献检索……", + "search_phase": "文献检索", + "search_detail": "深度并发检索中……", + "refs_insufficient": "🚨 终止生成:仅整合到 {count} 篇有效文献,未达到 {required} 篇最低要求。", + "refs_success": "✅ 文献扩容成功!已获取 {count} 篇有效文献。", + "rag_sync": "🧠 正在将API扩容文献同步注入RAG引擎……", + "rag_done": "✅ 扩容文献已完成向量化并注入RAG引擎。", + "core_refs_done": "✅ 核心参考论文引文已注入最高优先级。", + "synthesis_progress": "🧠 文献综合……", + "synthesis_status": "🧠 正在综合核心文献并识别研究空白……", + "synthesis_done": "✅ 文献综合完成!", + "anchor_progress": "⚓ 生成锚点……", + "anchor_status": "⚓ 正在推演核心摘要与结论锚点,请耐心等待……", + "anchor_done": "✅ 锚点推演完成!", + "outline_progress": "📝 大纲设计与路由分发……", + "jit_status": "🔄 正在为方法与结果章节预加载专属文献……", + "jit_done": "✅ 方法论预加载完成,已注入向量库。", + "english_draft": "#### 🇬🇧 英文证据母稿", + "target_draft": "#### {flag} 中文论文", + "restored": "✅ 已从快照安全恢复", + "waiting": "⏳ 等待执行……", + "translation_waiting": "⏳ 等待后台异步翻译……", + "sync_previous": "⏳ 正在同步保存上一节【{name}】的翻译状态……", + "engine_amj": "📊 AMJ 定量引擎", + "engine_asq": "📝 ASQ 定性引擎", + "drafting_phase": "英文撰写({current}/{total})", + "target_words": "目标:{words}({engine})", + "draft_status": "🤖 引擎启动:正在撰写【{name}】……", + "api_requested": "📡 已发起 DeepSeek API 请求……", + "model_writing": "✍️ 当前节点使用 {model} 执笔……", + "draft_done": "✅ 【{name}】撰写完毕!", + "word_count": "📊 本章节实际字数:{actual}(目标:{target}),累计偏差:{deviation:+d}", + "translation_running": "⏳ 英文已完成,正在后台异步翻译……", + "node_error": "🚨 节点异常已中止。", + "node_error_detail": "错误代码:{code}|{error}|系统已保存断点,可继续恢复。", + "task_cancelled": "任务已取消,断点和临时文件已删除。", + "task_start_failed": "无法启动论文任务:{error}", + "task_recover_failed": "无法恢复上次任务:{error}", + "search_source_warning": "{source}检索异常:{error}", + "source_metric_summary": "请求{request}·成功{success}·返回{raw}·新增{unique}·重复{duplicate}·错误{errors}", + "sync_last": "⏳ 正在同步保存最后一节【{name}】的翻译状态……", + "pack_docx": "📦 正在打包DOCX……", + "total_words": "📊 全文总字数:{actual}(目标区间:{lower}–{upper})", + "complete": "✅ 完成", + "document_generated": "文档已生成", + "startup_error": "🚨 启动或恢复异常:{error}", + "translation_error": "🚨 后台翻译异常", + "monitor_pipeline": "🔄 流水线进度", + "monitor_node": "📍 当前节点", + "monitor_log": "📝 动作日志", + "monitor_compute": "📡 三维算力消耗监控", + "actual_stats_parsed": "✅ 已成功解析用户上传的实证数据。", + "synthetic_stats_generated": "⚙️ 未上传统计结果,系统已生成可用于论文结构与结果写作的统计数据。", + "qualitative_ready": "✅ 定性材料已准备完成。", + "core_literature_parsing": "🧠 正在深度解析用户上传的核心文献……", + "section_complete_progress": "🇬🇧 【{name}】英文完成,翻译任务已进入后台。", + "login_title": "SmartPaper 登录", + "service_description": "提供中文、英语、俄语、哈萨克语四种语言服务。", + "secure_login_notice": "安全登录仅支持支付宝 App 扫码。登录后系统对数据加密,保护您的隐私。", + "secure_login_title": "支付宝安全登录", + "secure_login_instructions": "", + "secure_login_privacy": "安全登录后,系统会对数据加密,保护您的隐私。", + "secure_login_alipay_only": "安全登录仅支持支付宝 App 扫码。", + "secure_login_open_current_device": "在当前设备打开支付宝授权", + "login_qr_fixed_notice": "专属登录二维码在有效期内保持不变;系统只检查登录状态,不会动态更换二维码。", + "login_info": "扫码即可安全登录。授权成功后,系统会自动进入论文写作界面。", + "login_creating": "正在创建安全登录请求……", + "login_create_error": "无法创建登录请求:{error}", + "login_service_timeout": "登录服务响应较慢,请稍后重试。", + "login_service_connection_error": "暂时无法连接登录服务,请检查网络后重试。", + "login_reconnect": "重新连接登录服务", + "login_open": "打开支付宝扫码登录", + "login_caption": "请在新页面使用手机支付宝扫码并确认授权。授权完成后,SmartPaper 将自动进入论文写作界面。", + "login_auto_waiting": "正在等待支付宝授权。确认授权后将自动进入论文写作界面。", + "login_auto_network_wait": "登录服务暂时繁忙,系统正在自动重试:{error}", + "login_auto_success": "登录成功,正在进入论文写作界面……", + "login_auto_failed": "自动登录未完成:{error}", + "login_auto_timeout": "本次登录请求已超时,请重新生成登录请求。", + "login_check": "我已完成扫码,检查登录", + "login_checking": "正在确认登录状态……", + "login_check_error": "登录检查失败:{error}", + "login_regenerate": "重新生成登录请求", + "login_pending": "支付宝授权尚未完成,请扫码并确认授权。", + "account_title": "用户账户", + "account_id": "用户编号:**{value}**", + "daily_free": "每日免费:**{value}篇**", + "bonus_papers": "长期有效额度:**{value}篇**", + "account_chip_user": "用户 {value}", + "account_chip_free": "免费 {value}篇", + "account_chip_bonus": "长期 {value}篇", + "logout_short": "退出", + "payment_dialog_title": "支付宝付款", + "payment_notice": "今日免费额度和长期额度均已用完。支付9.9元可增加3篇长期有效额度。", + "quota_exhausted": "今日免费额度和长期额度均已用完。请使用支付宝购买长期额度。", + "payment_create": "生成支付宝付款二维码", + "payment_creating": "正在创建支付宝订单……", + "payment_scan": "请使用支付宝 App 扫码支付。支付成功后点击“查询支付结果”。", + "payment_check": "查询支付结果", + "payment_paid": "支付成功,已增加3篇长期有效额度。请重新点击生成论文。", + "payment_expired": "付款二维码已过期,请重新生成。", + "browse_files": "选择文件", + "drop_docs": "将文件拖放到此处", + "drop_data": "将文件拖放到此处", + "drop_interviews": "将文件拖放到此处", + "limit_docs": "单个文件上限 1GB · PDF、DOC、DOCX", + "limit_data": "单个文件上限 1GB · CSV、XLSX、XLS", + "limit_interviews": "单个文件上限 1GB · TXT、PDF、DOC、DOCX", + "logout": "退出登录", + }, + "en": { + "title": "📊 Business Administration Article Agent", + "topic_label": "📝 Article Title", + "keywords_label": "🔍 Keywords", + "words_label": "📏 Target Word Count (12,000–25,000 words)", + "success": "🎉 The pipeline is complete. The bilingual documents are ready.", + "download": "📥 Download Files", + "system_busy_title": "⏳ System Busy", + "system_busy_warning": "🚨 Another user is generating a paper", + "system_busy_info": "💡 The system is expected to be available in 25–35 minutes", + "checkpoint_warning": "⚠️ An unfinished paper task from a network or unexpected interruption was detected.", + "resume": "🔄 Resume from Checkpoint", + "clear": "🗑️ Clear Temporary Data", + "busy_short": "⏳ Another user is generating a paper. Please try again in about 25–35 minutes.", + "tab_amj": "✨ Quantitative Research (AMJ Engine)", + "tab_asq": "📝 Qualitative Research (ASQ Engine)", + "amj_config": "### 📊 Quantitative Research Configuration (Academy of Management Journal Style)", + "amj_info": "💡 This mode focuses on theoretical deduction, hypothesis testing, and causal inference. The structure is fixed as Introduction, Theory and Hypotheses, Method, Results, Discussion, and Conclusion.", + "upload_reference": "1. Upload Core Reference Papers (optional; PDF/DOCX only; 1–20 papers recommended)", + "upload_stats": "2. Upload Statistical Results Tables (optional; Excel/CSV only)", + "amj_expander": "💡 Need help organizing data? View and download the standard quantitative-data CSV template", + "amj_hint": "**⚠️ Important**: Upload **statistical results tables** such as reliability, validity, correlations, and path coefficients. Do **not** upload thousands of raw questionnaire rows, which may trigger overload protection.", + "download_amj_template": "📥 Download CSV Data Template", + "start_amj": "🚀 Start AMJ Generation", + "asq_config": "### 📝 Qualitative Research Configuration (Administrative Science Quarterly Style)", + "asq_info": "💡 This mode focuses on phenomenon discovery, sensemaking, and grounded theorizing using the Gioia approach. The structure is fixed as Introduction, Theoretical Framing, Research Setting and Methods, Findings, Discussion, Conclusion, and Evidence Appendix.", + "upload_interviews": "2. Upload Actual Interview or Field Materials (optional; TXT/PDF/DOCX only)", + "asq_expander": "💡 Need help organizing interviews? View and download the standard TXT interview template", + "asq_hint": "**⚠️ Important**: Clearly label each **informant role** and use a consistent **dialogue format** so the system can accurately extract quotations and build the coding structure.", + "download_asq_template": "📥 Download TXT Interview Template", + "start_asq": "🚀 Start ASQ Generation", + "api_key_missing": "❌ DEEPSEEK_API_KEY is missing. Configure the API key.", + "resume_toast": "🔄 A page refresh or network interruption was detected. Progress is being restored automatically…", + "pipeline_start": "⏳ Starting the pipeline…", + "resume_loading": "🔄 Restoring the secure local checkpoint…", + "resume_success": "✅ Checkpoint restored. Work will resume from node {done}/{total}.", + "resume_phase": "Checkpoint recovery", + "state_check": "State validation", + "history_restored": "✅ Previous compute-usage data restored", + "extract_quant": "⚙️ Parsing quantitative results…", + "literature_extract": "🧠 Extracting core information and building the RAG index…", + "literature_deep": "**Deep literature extraction in progress. Do not close this page**", + "literature_done": "✅ Core literature features extracted.", + "config_route": "🕵️ Configuring the engine and research paradigm…", + "literature_search": "📚 Searching the academic literature…", + "search_phase": "Literature search", + "search_detail": "Running concurrent searches…", + "refs_insufficient": "🚨 Generation stopped: only {count} valid references were integrated; at least {required} are required.", + "refs_success": "✅ Literature expansion complete: {count} valid references obtained.", + "rag_sync": "🧠 Adding the expanded literature to the RAG engine…", + "rag_done": "✅ Expanded literature vectorized and added to the RAG engine.", + "core_refs_done": "✅ Core user-provided references assigned highest priority.", + "synthesis_progress": "🧠 Synthesizing the literature…", + "synthesis_status": "🧠 Synthesizing the literature and identifying the research gap…", + "synthesis_done": "✅ Literature synthesis complete.", + "anchor_progress": "⚓ Generating anchors…", + "anchor_status": "⚓ Developing the abstract and conclusion anchors. Please wait…", + "anchor_done": "✅ Anchor development complete.", + "outline_progress": "📝 Designing the outline and routing sections…", + "jit_status": "🔄 Preloading literature for the Method and Results sections…", + "jit_done": "✅ Method literature preloaded and added to the vector store.", + "english_draft": "#### 🇬🇧 English Evidence Draft", + "target_draft": "#### {flag} Chinese Paper", + "restored": "✅ Restored from checkpoint", + "waiting": "⏳ Waiting…", + "translation_waiting": "⏳ Waiting for background translation…", + "sync_previous": "⏳ Saving the translation state for the previous section, 【{name}】…", + "engine_amj": "📊 AMJ Quantitative Engine", + "engine_asq": "📝 ASQ Qualitative Engine", + "drafting_phase": "English drafting ({current}/{total})", + "target_words": "Target: {words} words ({engine})", + "draft_status": "🤖 Drafting 【{name}】…", + "api_requested": "📡 DeepSeek API request submitted…", + "model_writing": "✍️ Writing this node with {model}…", + "draft_done": "✅ 【{name}】 completed.", + "word_count": "📊 Section word count: {actual} (target: {target}); cumulative deviation: {deviation:+d}", + "translation_running": "⏳ English draft complete. Background translation is running…", + "node_error": "🚨 The node stopped because of an error.", + "node_error_detail": "Error code: {code} | {error} | A checkpoint has been saved and can be resumed.", + "task_cancelled": "The task was cancelled. Its checkpoint and temporary files were deleted.", + "task_start_failed": "The paper task could not be started: {error}", + "task_recover_failed": "The previous task could not be recovered: {error}", + "search_source_warning": "{source} search error: {error}", + "source_metric_summary": "Req {request} · OK {success} · Raw {raw} · New {unique} · Dup {duplicate} · Err {errors}", + "sync_last": "⏳ Saving the translation state for the final section, 【{name}】…", + "pack_docx": "📦 Packaging DOCX files…", + "total_words": "📊 Total English word count: {actual} (target range: {lower}–{upper})", + "complete": "✅ Complete", + "document_generated": "Documents generated", + "startup_error": "🚨 Startup or recovery error: {error}", + "translation_error": "🚨 Background translation error", + "monitor_pipeline": "🔄 Pipeline progress", + "monitor_node": "📍 Current node", + "monitor_log": "📝 Activity log", + "monitor_compute": "📡 Compute-usage monitor", + "actual_stats_parsed": "✅ The uploaded empirical results were parsed successfully.", + "synthetic_stats_generated": "⚙️ No statistical results were uploaded. The system generated statistical data for the paper structure and Results section.", + "qualitative_ready": "✅ Qualitative materials are ready.", + "core_literature_parsing": "🧠 Parsing the uploaded core literature in depth…", + "section_complete_progress": "🇬🇧 English section 【{name}】 complete; translation is running in the background.", + "login_title": "Sign in to SmartPaper", + "service_description": "Services are available in Chinese, English, Russian, and Kazakh.", + "secure_login_notice": "Secure sign-in supports the Alipay app only. After sign-in, the system encrypts data to protect your privacy.", + "secure_login_title": "Secure Sign-in with Alipay", + "secure_login_instructions": "", + "secure_login_privacy": "After secure sign-in, the system encrypts your data to protect your privacy.", + "secure_login_alipay_only": "Secure sign-in supports the Alipay app only.", + "secure_login_open_current_device": "Open Alipay authorization on this device", + "login_qr_fixed_notice": "The secure sign-in QR code remains unchanged while valid. SmartPaper checks only the sign-in status and does not replace the QR code dynamically.", + "login_info": "Scan to sign in securely. SmartPaper will open the writing interface automatically after authorization.", + "login_creating": "Creating a secure sign-in request…", + "login_create_error": "Unable to create the sign-in request: {error}", + "login_service_timeout": "The sign-in service is taking longer than expected. Please try again.", + "login_service_connection_error": "The sign-in service is temporarily unavailable. Check your connection and try again.", + "login_reconnect": "Reconnect to Sign-in Service", + "login_open": "Open Alipay QR Sign-in", + "login_caption": "Use Alipay on your phone to scan and authorize on the new page. SmartPaper will open the writing interface automatically after authorization.", + "login_auto_waiting": "Waiting for Alipay authorization. The writing interface will open automatically.", + "login_auto_network_wait": "The sign-in service is temporarily busy. Retrying automatically: {error}", + "login_auto_success": "Signed in. Opening the paper-writing interface…", + "login_auto_failed": "Automatic sign-in was not completed: {error}", + "login_auto_timeout": "This sign-in request has expired. Create a new request.", + "login_check": "I Have Authorized — Check Sign-in", + "login_checking": "Checking sign-in status…", + "login_check_error": "Sign-in check failed: {error}", + "login_regenerate": "Generate a New Sign-in Request", + "login_pending": "Alipay authorization is still pending. Scan the code and confirm authorization.", + "account_title": "User Account", + "account_id": "User ID: **{value}**", + "daily_free": "Daily free allowance: **{value} papers**", + "bonus_papers": "Long-term allowance: **{value} papers**", + "account_chip_user": "User {value}", + "account_chip_free": "Free {value}", + "account_chip_bonus": "Bonus {value}", + "logout_short": "Sign out", + "payment_dialog_title": "Alipay Payment", + "payment_notice": "Your daily free quota and long-term quota are exhausted. Pay CNY 9.9 to add three long-term papers.", + "quota_exhausted": "Your daily free quota and long-term quota are exhausted. Purchase long-term quota with Alipay.", + "payment_create": "Create Alipay QR code", + "payment_creating": "Creating the Alipay order…", + "payment_scan": "Scan the code with the Alipay app. After payment, select “Check payment result.”", + "payment_check": "Check payment result", + "payment_paid": "Payment succeeded. Three long-term papers were added. Start the paper again.", + "payment_expired": "The payment QR code has expired. Create a new one.", + "browse_files": "Choose files", + "drop_docs": "Drag and drop files here", + "drop_data": "Drag and drop files here", + "drop_interviews": "Drag and drop files here", + "limit_docs": "Limit 1 GB per file · PDF, DOC, DOCX", + "limit_data": "Limit 1 GB per file · CSV, XLSX, XLS", + "limit_interviews": "Limit 1 GB per file · TXT, PDF, DOC, DOCX", + "logout": "Sign Out", + }, + "ru": { + "title": "📊 Интеллектуальный агент для научных статей по управлению бизнесом", + "topic_label": "📝 Название статьи", + "keywords_label": "🔍 Ключевые слова", + "words_label": "📏 Целевой объём (12 000–25 000 слов)", + "success": "🎉 Работа конвейера завершена. Двуязычные документы готовы.", + "download": "📥 Скачать файлы", + "system_busy_title": "⏳ Система занята", + "system_busy_warning": "🚨 Другой пользователь формирует статью", + "system_busy_info": "💡 Ожидаемое время доступности — через 25–35 минут", + "checkpoint_warning": "⚠️ Обнаружена незавершённая задача, прерванная из-за сети или другой ошибки.", + "resume": "🔄 Продолжить с контрольной точки", + "clear": "🗑️ Удалить временные данные", + "busy_short": "⏳ Другой пользователь формирует статью. Повторите попытку примерно через 25–35 минут.", + "tab_amj": "✨ Количественное исследование (движок AMJ)", + "tab_asq": "📝 Качественное исследование (движок ASQ)", + "amj_config": "### 📊 Настройка количественного исследования (стиль Academy of Management Journal)", + "amj_info": "💡 Режим ориентирован на теоретическую дедукцию, проверку гипотез и причинно-следственный вывод. Структура фиксирована: введение, теория и гипотезы, метод, результаты, обсуждение и заключение.", + "upload_reference": "1. Загрузите основные научные статьи (необязательно; только PDF/DOCX; рекомендуется 1–20 файлов)", + "upload_stats": "2. Загрузите таблицы статистических результатов (необязательно; только Excel/CSV)", + "amj_expander": "💡 Нужна помощь в подготовке данных? Откройте и скачайте стандартный CSV-шаблон", + "amj_hint": "**⚠️ Важно**: загружайте **таблицы статистических результатов** — надёжность, валидность, корреляции и коэффициенты путей. Не загружайте тысячи строк исходных анкет, чтобы не сработала защита от перегрузки.", + "download_amj_template": "📥 Скачать CSV-шаблон данных", + "start_amj": "🚀 Запустить формирование в режиме AMJ", + "asq_config": "### 📝 Настройка качественного исследования (стиль Administrative Science Quarterly)", + "asq_info": "💡 Режим ориентирован на выявление феномена, смыслообразование и построение обоснованной теории по подходу Джойя. Структура фиксирована: введение, теоретическая рамка, контекст и методы, результаты, обсуждение, заключение и приложение с доказательствами.", + "upload_interviews": "2. Загрузите реальные интервью или полевые материалы (необязательно; только TXT/PDF/DOCX)", + "asq_expander": "💡 Нужна помощь в подготовке интервью? Откройте и скачайте стандартный TXT-шаблон", + "asq_hint": "**⚠️ Важно**: укажите **роль информанта** и соблюдайте единый **формат диалога**, чтобы система точно извлекала цитаты и строила структуру кодирования.", + "download_asq_template": "📥 Скачать TXT-шаблон интервью", + "start_asq": "🚀 Запустить формирование в режиме ASQ", + "api_key_missing": "❌ Не задан DEEPSEEK_API_KEY. Настройте ключ API.", + "resume_toast": "🔄 Обнаружено обновление страницы или нарушение соединения. Выполняется автоматическое восстановление…", + "pipeline_start": "⏳ Запуск конвейера…", + "resume_loading": "🔄 Восстановление защищённой локальной контрольной точки…", + "resume_success": "✅ Контрольная точка восстановлена. Работа продолжится с узла {done}/{total}.", + "resume_phase": "Восстановление", + "state_check": "Проверка состояния", + "history_restored": "✅ История вычислительных затрат восстановлена", + "extract_quant": "⚙️ Обработка количественных результатов…", + "literature_extract": "🧠 Извлечение основных сведений и построение индекса RAG…", + "literature_deep": "**Выполняется углублённая обработка литературы. Не закрывайте страницу**", + "literature_done": "✅ Основные характеристики литературы извлечены.", + "config_route": "🕵️ Настройка движка и исследовательской парадигмы…", + "literature_search": "📚 Поиск научной литературы…", + "search_phase": "Поиск литературы", + "search_detail": "Выполняется параллельный поиск…", + "refs_insufficient": "🚨 Формирование остановлено: интегрировано только {count} источников; требуется не менее {required}.", + "refs_success": "✅ Расширение литературы завершено: получено {count} источников.", + "rag_sync": "🧠 Добавление расширенного корпуса литературы в RAG…", + "rag_done": "✅ Литература векторизована и добавлена в RAG.", + "core_refs_done": "✅ Пользовательским основным источникам назначен высший приоритет.", + "synthesis_progress": "🧠 Синтез литературы…", + "synthesis_status": "🧠 Синтез литературы и выявление исследовательского пробела…", + "synthesis_done": "✅ Синтез литературы завершён.", + "anchor_progress": "⚓ Формирование опорных элементов…", + "anchor_status": "⚓ Формирование опорных элементов аннотации и заключения. Подождите…", + "anchor_done": "✅ Опорные элементы сформированы.", + "outline_progress": "📝 Проектирование структуры и распределение разделов…", + "jit_status": "🔄 Предварительная загрузка литературы для разделов методов и результатов…", + "jit_done": "✅ Методическая литература загружена в векторное хранилище.", + "english_draft": "#### 🇬🇧 Английский доказательный черновик", + "target_draft": "#### {flag} Русская версия статьи", + "restored": "✅ Восстановлено из контрольной точки", + "waiting": "⏳ Ожидание выполнения…", + "translation_waiting": "⏳ Ожидание фонового перевода…", + "sync_previous": "⏳ Сохранение перевода предыдущего раздела 【{name}】…", + "engine_amj": "📊 Количественный движок AMJ", + "engine_asq": "📝 Качественный движок ASQ", + "drafting_phase": "Подготовка английского текста ({current}/{total})", + "target_words": "Цель: {words} слов ({engine})", + "draft_status": "🤖 Подготовка раздела 【{name}】…", + "api_requested": "📡 Запрос к DeepSeek API отправлен…", + "model_writing": "✍️ Раздел формируется моделью {model}…", + "draft_done": "✅ Раздел 【{name}】 завершён.", + "word_count": "📊 Объём раздела: {actual} слов (цель: {target}); накопленное отклонение: {deviation:+d}", + "translation_running": "⏳ Английский текст завершён. Выполняется фоновый перевод…", + "node_error": "🚨 Выполнение узла остановлено из-за ошибки.", + "node_error_detail": "Код ошибки: {code} | {error} | Контрольная точка сохранена; работу можно продолжить.", + "task_cancelled": "Задача отменена. Контрольная точка и временные файлы удалены.", + "task_start_failed": "Не удалось запустить подготовку статьи: {error}", + "task_recover_failed": "Не удалось восстановить предыдущую задачу: {error}", + "search_source_warning": "Ошибка поиска {source}: {error}", + "source_metric_summary": "Запр. {request} · Усп. {success} · Получ. {raw} · Нов. {unique} · Дуб. {duplicate} · Ош. {errors}", + "sync_last": "⏳ Сохранение перевода последнего раздела 【{name}】…", + "pack_docx": "📦 Упаковка файлов DOCX…", + "total_words": "📊 Общий объём английского текста: {actual} слов (целевой диапазон: {lower}–{upper})", + "complete": "✅ Завершено", + "document_generated": "Документы сформированы", + "startup_error": "🚨 Ошибка запуска или восстановления: {error}", + "translation_error": "🚨 Ошибка фонового перевода", + "monitor_pipeline": "🔄 Ход конвейера", + "monitor_node": "📍 Текущий узел", + "monitor_log": "📝 Журнал действий", + "monitor_compute": "📡 Монитор вычислительных затрат", + "actual_stats_parsed": "✅ Загруженные эмпирические результаты успешно обработаны.", + "synthetic_stats_generated": "⚙️ Статистические результаты не загружены. Система сформировала статистические данные для структуры статьи и раздела результатов.", + "qualitative_ready": "✅ Качественные материалы подготовлены.", + "core_literature_parsing": "🧠 Выполняется углублённая обработка загруженной основной литературы…", + "section_complete_progress": "🇬🇧 Английский раздел 【{name}】 завершён; перевод выполняется в фоне.", + "login_title": "Вход в SmartPaper", + "service_description": "Услуги доступны на китайском, английском, русском и казахском языках.", + "secure_login_notice": "Безопасный вход поддерживается только в приложении Alipay. После входа система шифрует данные для защиты вашей конфиденциальности.", + "secure_login_title": "Безопасный вход через Alipay", + "secure_login_instructions": "", + "secure_login_privacy": "После безопасного входа система шифрует данные для защиты вашей конфиденциальности.", + "secure_login_alipay_only": "Безопасный вход поддерживается только приложением Alipay.", + "secure_login_open_current_device": "Открыть авторизацию Alipay на этом устройстве", + "login_qr_fixed_notice": "QR-код входа не меняется в течение срока действия. Система проверяет только статус входа.", + "login_info": "Вы можете бесплатно создавать до 3 статей в день.", + "login_creating": "Создание защищённого запроса на вход…", + "login_create_error": "Не удалось создать запрос на вход: {error}", + "login_service_timeout": "Сервис входа отвечает медленнее обычного. Пожалуйста, попробуйте ещё раз.", + "login_service_connection_error": "Сервис входа временно недоступен. Проверьте подключение и повторите попытку.", + "login_reconnect": "Повторно подключиться к сервису входа", + "login_open": "Открыть вход по QR-коду Alipay", + "login_caption": "На новой странице отсканируйте QR-код приложением Alipay и подтвердите авторизацию. После этого SmartPaper автоматически откроет интерфейс подготовки статьи.", + "login_auto_waiting": "Ожидается подтверждение в Alipay. После авторизации интерфейс подготовки статьи откроется автоматически.", + "login_auto_network_wait": "Сервис входа временно занят. Выполняется автоматическая повторная попытка: {error}", + "login_auto_success": "Вход выполнен. Открывается интерфейс подготовки статьи…", + "login_auto_failed": "Автоматический вход не завершён: {error}", + "login_auto_timeout": "Срок действия запроса на вход истёк. Создайте новый запрос.", + "login_check": "Авторизация выполнена — проверить вход", + "login_checking": "Проверка статуса входа…", + "login_check_error": "Ошибка проверки входа: {error}", + "login_regenerate": "Создать новый запрос на вход", + "login_pending": "Авторизация Alipay ещё не завершена. Отсканируйте код и подтвердите доступ.", + "account_title": "Учётная запись", + "account_id": "Идентификатор пользователя: **{value}**", + "daily_free": "Ежедневно бесплатно: **{value} статьи**", + "bonus_papers": "Долгосрочный остаток: **{value} статьи**", + "account_chip_user": "Пользователь {value}", + "account_chip_free": "Бесплатно {value}", + "account_chip_bonus": "Запас {value}", + "logout_short": "Выйти", + "payment_dialog_title": "Оплата через Alipay", + "payment_notice": "Ежедневная бесплатная и долгосрочная квоты исчерпаны. Оплата 9,9 юаня добавляет квоту на три статьи без срока действия.", + "quota_exhausted": "Ежедневная бесплатная и долгосрочная квоты исчерпаны. Приобретите долгосрочную квоту через Alipay.", + "payment_create": "Создать QR-код для оплаты", + "payment_creating": "Создаётся заказ Alipay…", + "payment_scan": "Отсканируйте QR-код в приложении Alipay. После оплаты нажмите «Проверить результат оплаты».", + "payment_check": "Проверить результат оплаты", + "payment_paid": "Оплата прошла успешно. Добавлена долгосрочная квота на три статьи. Запустите создание статьи повторно.", + "payment_expired": "Срок действия QR-кода истёк. Создайте новый код.", + "browse_files": "Выбрать файлы", + "drop_docs": "Перетащите файлы сюда", + "drop_data": "Перетащите файлы сюда", + "drop_interviews": "Перетащите файлы сюда", + "limit_docs": "До 1 ГБ на файл · PDF, DOC, DOCX", + "limit_data": "До 1 ГБ на файл · CSV, XLSX, XLS", + "limit_interviews": "До 1 ГБ на файл · TXT, PDF, DOC, DOCX", + "logout": "Выйти", + }, + "kk": { + "title": "📊 Бизнес-басқару жөніндегі ғылыми мақала интеллектуалды агенті", + "topic_label": "📝 Мақала атауы", + "keywords_label": "🔍 Түйін сөздер", + "words_label": "📏 Мақсатты көлем (12 000–25 000 сөз)", + "success": "🎉 Жұмыс үдерісі аяқталды. Екі тілдегі құжаттар дайын.", + "download": "📥 Файлдарды жүктеу", + "system_busy_title": "⏳ Жүйе бос емес", + "system_busy_warning": "🚨 Басқа пайдаланушы мақала дайындап жатыр", + "system_busy_info": "💡 Жүйе шамамен 25–35 минуттан кейін қолжетімді болады", + "checkpoint_warning": "⚠️ Желі немесе күтпеген үзіліс салдарынан аяқталмаған тапсырма анықталды.", + "resume": "🔄 Бақылау нүктесінен жалғастыру", + "clear": "🗑️ Уақытша деректерді тазалау", + "busy_short": "⏳ Басқа пайдаланушы мақала дайындап жатыр. Шамамен 25–35 минуттан кейін қайталап көріңіз.", + "tab_amj": "✨ Сандық зерттеу (AMJ қозғалтқышы)", + "tab_asq": "📝 Сапалық зерттеу (ASQ қозғалтқышы)", + "amj_config": "### 📊 Сандық зерттеу параметрлері (Academy of Management Journal стилі)", + "amj_info": "💡 Бұл режим теориялық дедукцияға, гипотезаларды тексеруге және себеп-салдарлық қорытындыға бағытталған. Құрылымы: кіріспе, теория мен гипотезалар, әдіс, нәтижелер, талқылау және қорытынды.", + "upload_reference": "1. Негізгі ғылыми мақалаларды жүктеңіз (міндетті емес; тек PDF/DOCX; 1–20 файл ұсынылады)", + "upload_stats": "2. Статистикалық нәтижелер кестесін жүктеңіз (міндетті емес; тек Excel/CSV)", + "amj_expander": "💡 Деректерді дайындау керек пе? Сандық деректерге арналған стандартты CSV үлгісін ашып, жүктеңіз", + "amj_hint": "**⚠️ Маңызды**: сенімділік, валидтілік, корреляциялар және жол коэффициенттері сияқты **статистикалық нәтижелер кестесін** жүктеңіз. Артық жүктемеден қорғау іске қосылмауы үшін сауалнаманың мыңдаған бастапқы жолдарын жүктемеңіз.", + "download_amj_template": "📥 CSV деректер үлгісін жүктеу", + "start_amj": "🚀 AMJ режимінде дайындауды бастау", + "asq_config": "### 📝 Сапалық зерттеу параметрлері (Administrative Science Quarterly стилі)", + "asq_info": "💡 Бұл режим құбылысты анықтауға, мағына қалыптастыруға және Gioia тәсілі бойынша негізделген теория құруға бағытталған. Құрылымы: кіріспе, теориялық негіз, зерттеу ортасы мен әдістері, нәтижелер, талқылау, қорытынды және дәлелдер қосымшасы.", + "upload_interviews": "2. Нақты сұхбат немесе далалық материалдарды жүктеңіз (міндетті емес; тек TXT/PDF/DOCX)", + "asq_expander": "💡 Сұхбаттарды дайындау керек пе? Стандартты TXT сұхбат үлгісін ашып, жүктеңіз", + "asq_hint": "**⚠️ Маңызды**: жүйе дәйексөздерді дәл шығарып, кодтау құрылымын құруы үшін **ақпарат берушінің рөлін** көрсетіп, бірізді **диалог пішімін** қолданыңыз.", + "download_asq_template": "📥 TXT сұхбат үлгісін жүктеу", + "start_asq": "🚀 ASQ режимінде дайындауды бастау", + "api_key_missing": "❌ DEEPSEEK_API_KEY көрсетілмеген. API кілтін баптаңыз.", + "resume_toast": "🔄 Бет жаңартылғаны немесе желі үзілісі анықталды. Үдеріс автоматты түрде қалпына келтірілуде…", + "pipeline_start": "⏳ Жұмыс үдерісі басталуда…", + "resume_loading": "🔄 Қауіпсіз жергілікті бақылау нүктесі қалпына келтірілуде…", + "resume_success": "✅ Бақылау нүктесі қалпына келтірілді. Жұмыс {done}/{total} түйінінен жалғасады.", + "resume_phase": "Қалпына келтіру", + "state_check": "Күйді тексеру", + "history_restored": "✅ Бұрынғы есептеу шығындары қалпына келтірілді", + "extract_quant": "⚙️ Сандық нәтижелер өңделуде…", + "literature_extract": "🧠 Негізгі мәліметтер шығарылып, RAG индексі құрылуда…", + "literature_deep": "**Әдебиеттер терең өңделуде. Бетті жаппаңыз**", + "literature_done": "✅ Әдебиеттердің негізгі сипаттары шығарылды.", + "config_route": "🕵️ Қозғалтқыш пен зерттеу парадигмасы бапталуда…", + "literature_search": "📚 Ғылыми әдебиеттер ізделуде…", + "search_phase": "Әдебиет іздеу", + "search_detail": "Параллель іздеу орындалуда…", + "refs_insufficient": "🚨 Дайындау тоқтатылды: тек {count} жарамды дереккөз біріктірілді; кемінде {required} қажет.", + "refs_success": "✅ Әдебиеттерді кеңейту аяқталды: {count} жарамды дереккөз алынды.", + "rag_sync": "🧠 Кеңейтілген әдебиеттер RAG қозғалтқышына қосылуда…", + "rag_done": "✅ Әдебиеттер векторланып, RAG қозғалтқышына қосылды.", + "core_refs_done": "✅ Пайдаланушы ұсынған негізгі дереккөздерге ең жоғары басымдық берілді.", + "synthesis_progress": "🧠 Әдебиеттер синтезделуде…", + "synthesis_status": "🧠 Әдебиеттер синтезделіп, зерттеу олқылығы анықталуда…", + "synthesis_done": "✅ Әдебиеттер синтезі аяқталды.", + "anchor_progress": "⚓ Тірек мазмұн құрылуда…", + "anchor_status": "⚓ Аннотация мен қорытындының тірек мазмұны әзірленуде. Күте тұрыңыз…", + "anchor_done": "✅ Тірек мазмұн әзірленді.", + "outline_progress": "📝 Құрылым жобаланып, бөлімдер бөлінуде…", + "jit_status": "🔄 Әдіс пен нәтижелер бөлімдеріне арналған әдебиеттер алдын ала жүктелуде…", + "jit_done": "✅ Әдістемелік әдебиеттер векторлық қорға қосылды.", + "english_draft": "#### 🇬🇧 Ағылшын тіліндегі дәлелдік нұсқа", + "target_draft": "#### {flag} Қазақ тіліндегі мақала", + "restored": "✅ Бақылау нүктесінен қалпына келтірілді", + "waiting": "⏳ Орындалуын күтуде…", + "translation_waiting": "⏳ Фондық аударма күтілуде…", + "sync_previous": "⏳ Алдыңғы 【{name}】 бөлімінің аудармасы сақталуда…", + "engine_amj": "📊 AMJ сандық қозғалтқышы", + "engine_asq": "📝 ASQ сапалық қозғалтқышы", + "drafting_phase": "Ағылшын мәтінін дайындау ({current}/{total})", + "target_words": "Мақсат: {words} сөз ({engine})", + "draft_status": "🤖 【{name}】 бөлімі дайындалуда…", + "api_requested": "📡 DeepSeek API сұрауы жіберілді…", + "model_writing": "✍️ Бұл түйінді {model} моделі жазып жатыр…", + "draft_done": "✅ 【{name}】 бөлімі аяқталды.", + "word_count": "📊 Бөлім көлемі: {actual} сөз (мақсат: {target}); жиынтық ауытқу: {deviation:+d}", + "translation_running": "⏳ Ағылшын мәтіні аяқталды. Фондық аударма орындалуда…", + "node_error": "🚨 Қате салдарынан түйін тоқтатылды.", + "node_error_detail": "Қате коды: {code} | {error} | Бақылау нүктесі сақталды; жұмысты жалғастыруға болады.", + "task_cancelled": "Тапсырма тоқтатылды. Бақылау нүктесі мен уақытша файлдар жойылды.", + "task_start_failed": "Мақала тапсырмасын бастау мүмкін болмады: {error}", + "task_recover_failed": "Алдыңғы тапсырманы қалпына келтіру мүмкін болмады: {error}", + "search_source_warning": "{source} іздеу қатесі: {error}", + "source_metric_summary": "Сұрау {request} · Сәтті {success} · Нәтиже {raw} · Жаңа {unique} · Қайтал. {duplicate} · Қате {errors}", + "sync_last": "⏳ Соңғы 【{name}】 бөлімінің аудармасы сақталуда…", + "pack_docx": "📦 DOCX файлдары қапталуда…", + "total_words": "📊 Ағылшын мәтінінің жалпы көлемі: {actual} сөз (мақсатты аралық: {lower}–{upper})", + "complete": "✅ Аяқталды", + "document_generated": "Құжаттар дайындалды", + "startup_error": "🚨 Іске қосу немесе қалпына келтіру қатесі: {error}", + "translation_error": "🚨 Фондық аударма қатесі", + "monitor_pipeline": "🔄 Жұмыс үдерісі", + "monitor_node": "📍 Ағымдағы түйін", + "monitor_log": "📝 Әрекеттер журналы", + "monitor_compute": "📡 Есептеу шығындарының мониторингі", + "actual_stats_parsed": "✅ Жүктелген эмпирикалық нәтижелер сәтті өңделді.", + "synthetic_stats_generated": "⚙️ Статистикалық нәтижелер жүктелмеді. Жүйе мақала құрылымы мен нәтижелер бөлімі үшін статистикалық деректер дайындады.", + "qualitative_ready": "✅ Сапалық материалдар дайындалды.", + "core_literature_parsing": "🧠 Жүктелген негізгі әдебиеттер терең өңделуде…", + "section_complete_progress": "🇬🇧 【{name}】 ағылшын бөлімі аяқталды; аударма фондық режимде орындалуда.", + "login_title": "SmartPaper жүйесіне кіру", + "service_description": "Қызмет қытай, ағылшын, орыс және қазақ тілдерінде қолжетімді.", + "secure_login_notice": "Қауіпсіз кіру тек Alipay қолданбасы арқылы жүзеге асырылады. Кіргеннен кейін жүйе құпиялылығыңызды қорғау үшін деректерді шифрлайды.", + "secure_login_title": "Alipay арқылы қауіпсіз кіру", + "secure_login_instructions": "", + "secure_login_privacy": "Қауіпсіз кіргеннен кейін жүйе құпиялылығыңызды қорғау үшін деректерді шифрлайды.", + "secure_login_alipay_only": "Қауіпсіз кіру тек Alipay қолданбасын қолдайды.", + "secure_login_open_current_device": "Осы құрылғыда Alipay рұқсатын ашу", + "login_qr_fixed_notice": "Кіру QR-коды жарамдылық мерзімі ішінде өзгермейді. Жүйе тек кіру күйін тексереді.", + "login_info": "Күніне 3 мақалаға дейін тегін жасай аласыз.", + "login_creating": "Қауіпсіз кіру сұрауы құрылуда…", + "login_create_error": "Кіру сұрауын құру мүмкін болмады: {error}", + "login_service_timeout": "Кіру қызметі баяу жауап беріп жатыр. Сәл кейінірек қайталап көріңіз.", + "login_service_connection_error": "Кіру қызметі уақытша қолжетімсіз. Желіні тексеріп, қайта көріңіз.", + "login_reconnect": "Кіру қызметіне қайта қосылу", + "login_open": "Alipay QR-коды арқылы кіруді ашу", + "login_caption": "Жаңа бетте Alipay қолданбасымен QR-кодты сканерлеп, рұқсатты растаңыз. Рұқсат берілгеннен кейін SmartPaper мақала жазу интерфейсін автоматты түрде ашады.", + "login_auto_waiting": "Alipay рұқсатын күтіп тұрмыз. Рұқсат расталғаннан кейін мақала жазу интерфейсі автоматты түрде ашылады.", + "login_auto_network_wait": "Кіру қызметі уақытша баяу жұмыс істеуде. Жүйе автоматты түрде қайта әрекет жасап жатыр: {error}", + "login_auto_success": "Кіру сәтті аяқталды. Мақала жазу интерфейсі ашылуда…", + "login_auto_failed": "Автоматты кіру аяқталмады: {error}", + "login_auto_timeout": "Кіру сұрауының мерзімі аяқталды. Жаңа сұрау жасаңыз.", + "login_check": "Рұқсат берілді — кіруді тексеру", + "login_checking": "Кіру күйі тексерілуде…", + "login_check_error": "Кіруді тексеру сәтсіз аяқталды: {error}", + "login_regenerate": "Жаңа кіру сұрауын құру", + "login_pending": "Alipay рұқсаты әлі аяқталған жоқ. QR-кодты сканерлеп, рұқсатты растаңыз.", + "account_title": "Пайдаланушы есептік жазбасы", + "account_id": "Пайдаланушы нөмірі: **{value}**", + "daily_free": "Күнделікті тегін: **{value} мақала**", + "bonus_papers": "Ұзақ мерзімді қалдық: **{value} мақала**", + "account_chip_user": "Пайдаланушы {value}", + "account_chip_free": "Тегін {value}", + "account_chip_bonus": "Қор {value}", + "logout_short": "Шығу", + "payment_dialog_title": "Alipay арқылы төлеу", + "payment_notice": "Күнделікті тегін және ұзақ мерзімді квота таусылды. 9,9 юань төлегенде мерзімі шектелмеген үш мақала квотасы қосылады.", + "quota_exhausted": "Күнделікті тегін және ұзақ мерзімді квота таусылды. Alipay арқылы ұзақ мерзімді квота сатып алыңыз.", + "payment_create": "Төлемге арналған QR-код жасау", + "payment_creating": "Alipay тапсырысы жасалуда…", + "payment_scan": "QR-кодты Alipay қолданбасымен сканерлеңіз. Төлемнен кейін «Төлем нәтижесін тексеру» түймесін басыңыз.", + "payment_check": "Төлем нәтижесін тексеру", + "payment_paid": "Төлем сәтті өтті. Мерзімі шектелмеген үш мақала квотасы қосылды. Мақала жасауды қайта бастаңыз.", + "payment_expired": "QR-кодтың қолданылу мерзімі аяқталды. Жаңа код жасаңыз.", + "browse_files": "Файлдарды таңдау", + "drop_docs": "Файлдарды осы жерге сүйреп әкеліңіз", + "drop_data": "Файлдарды осы жерге сүйреп әкеліңіз", + "drop_interviews": "Файлдарды осы жерге сүйреп әкеліңіз", + "limit_docs": "Әр файлға 1 ГБ дейін · PDF, DOC, DOCX", + "limit_data": "Әр файлға 1 ГБ дейін · CSV, XLSX, XLS", + "limit_interviews": "Әр файлға 1 ГБ дейін · TXT, PDF, DOC, DOCX", + "logout": "Шығу", + }, +} + + +def normalize_language(code: str | None) -> str: + code = str(code or "zh").strip().lower() + return code if code in TEXTS else "zh" + + +def tr(language: str, key: str, **kwargs: Any) -> str: + language = normalize_language(language) + template = TEXTS.get(language, {}).get(key) + if template is None: + template = TEXTS["zh"].get(key, key) + return template.format(**kwargs) if kwargs else template + + +def get_target_language(language: str) -> str: + return TARGET_LANGUAGE[normalize_language(language)] + + +def get_output_suffix(language: str) -> str: + return OUTPUT_SUFFIX[normalize_language(language)] + + +def get_target_flag(language: str) -> str: + return TARGET_FLAG[normalize_language(language)] + + +def get_amj_csv_template(language: str) -> str: + return AMJ_CSV_TEMPLATES[normalize_language(language)] + + +def get_asq_txt_template(language: str) -> str: + return ASQ_TXT_TEMPLATES[normalize_language(language)] + + +def get_reference_heading(language: str) -> str: + return REFERENCE_HEADINGS[normalize_language(language)] diff --git a/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/managed_auth.py b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/managed_auth.py new file mode 100644 index 0000000000000000000000000000000000000000..45ae544b06f7e79902e79df1e3bb0fbcbda7bf6d --- /dev/null +++ b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/managed_auth.py @@ -0,0 +1,645 @@ +# -*- coding: utf-8 -*- +"""SmartPaper edge-first authentication and browser task/payment bridge. + +Cloudflare owns the durable browser session, quota, tasks, and payments. The +Hugging Face process verifies the signed edge assertion but does not call the +Worker over the server-to-server network. Authenticated business operations are +executed by the user's browser through the same-origin invisible component. +""" +from __future__ import annotations + +import base64 +import hashlib +import hmac +import html +import io +import json +import os +import secrets +import time +from typing import Any, Callable +from urllib.parse import unquote, urlsplit + +import qrcode +import streamlit as st + +from i18n import tr +from smartpaper_restore_bridge import edge_bridge + +EDGE_ASSERTION_COOKIE_NAME = "smartpaper_edge_assertion" +EDGE_ASSERTION_AUDIENCE = "smartpaper-streamlit" +ASSERTION_CLOCK_SKEW_SECONDS = 30 + + +def _runtime_value(name: str) -> str: + value = os.environ.get(name, "") + if not value: + try: + value = st.secrets.get(name, "") + except Exception: + value = "" + return str(value or "").strip() + + +def _api_base() -> str: + value = str(st.session_state.get("sp_auth_api_origin") or "").strip().rstrip("/") + if not value: + value = _runtime_value("SMARTPAPER_AUTH_API").rstrip("/") + parsed = urlsplit(value) + if ( + parsed.scheme != "https" + or not parsed.netloc + or parsed.username is not None + or parsed.password is not None + or parsed.path not in ("", "/") + or parsed.query + or parsed.fragment + ): + raise RuntimeError("SMARTPAPER_AUTH_API must be an HTTPS origin.") + return f"https://{parsed.netloc}" + + +def _service_key() -> str: + value = _runtime_value("SMARTPAPER_SERVICE_KEY") + if not value: + raise RuntimeError("SMARTPAPER_SERVICE_KEY is not configured.") + return value + + +def _context_cookie(name: str) -> str: + try: + cookies = st.context.cookies + value = cookies.get(name, "") + if value: + return unquote(str(value).strip()) + target = name.lower() + for key, item in cookies.items(): + if str(key).lower() == target: + return unquote(str(item or "").strip()) + except Exception: + return "" + return "" + + +def _decode_b64url(value: str) -> bytes: + return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) + + +def _verify_edge_assertion(token: str) -> dict[str, Any]: + try: + encoded, supplied_signature = token.split(".", 1) + except ValueError as exc: + raise RuntimeError("invalid_edge_assertion") from exc + expected_signature = hmac.new( + _service_key().encode("utf-8"), + encoded.encode("ascii"), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(expected_signature, supplied_signature): + raise RuntimeError("invalid_edge_assertion_signature") + try: + payload = json.loads(_decode_b64url(encoded).decode("utf-8")) + except Exception as exc: + raise RuntimeError("invalid_edge_assertion_payload") from exc + now = int(time.time()) + if payload.get("ver") != 1 or payload.get("aud") != EDGE_ASSERTION_AUDIENCE: + raise RuntimeError("invalid_edge_assertion_claims") + api_origin = str(payload.get("api_origin") or "").strip().rstrip("/") + parsed = urlsplit(api_origin) + if ( + parsed.scheme != "https" + or not parsed.netloc + or parsed.username is not None + or parsed.password is not None + or parsed.path not in ("", "/") + or parsed.query + or parsed.fragment + ): + raise RuntimeError("invalid_edge_assertion_origin") + issued_at = int(payload.get("iat") or 0) + expires_at = int(payload.get("exp") or 0) + if issued_at > now + ASSERTION_CLOCK_SKEW_SECONDS or expires_at <= now - ASSERTION_CLOCK_SKEW_SECONDS: + raise RuntimeError("expired_edge_assertion") + if expires_at - issued_at > 9 * 60 * 60: + raise RuntimeError("invalid_edge_assertion_lifetime") + user = payload.get("user") or {} + if not isinstance(user, dict) or not user.get("id") or user.get("status") != "active": + raise RuntimeError("invalid_edge_assertion_identity") + payload["api_origin"] = f"https://{parsed.netloc}" + return payload + + +def _clear_generation_session_state() -> None: + for key in ( + "sp_generation_task", "sp_current_node", "sp_last_task_heartbeat", + "sp_generation_idempotency_key", "zip_buffer", "zip_filename", + "is_running", "start_pipeline", "resume_pipeline", "run_mode", + "rag_engine", "up_amj_ref", "up_amj_data", "up_asq_ref", + "up_asq_data", "sp_payment_required", "sp_payment_order", + "sp_payment_request_id", + ): + st.session_state.pop(key, None) + + +def _clear_session() -> None: + for key in ( + "sp_session_expires_at", "sp_user", + "sp_last_verified_at", "sp_edge_assertion", "sp_auth_api_origin", + ): + st.session_state.pop(key, None) + + +def require_alipay_login(language: str = "zh") -> dict[str, Any]: + """Return the user authenticated by the Cloudflare edge gateway.""" + token = _context_cookie(EDGE_ASSERTION_COOKIE_NAME) + if token and token == st.session_state.get("sp_edge_assertion") and st.session_state.get("sp_user"): + return dict(st.session_state["sp_user"]) + if not token: + _clear_session() + st.stop() + try: + payload = _verify_edge_assertion(token) + except Exception: + _clear_session() + st.stop() + user = dict(payload["user"]) + st.session_state["sp_edge_assertion"] = token + st.session_state["sp_session_expires_at"] = payload["exp"] + st.session_state["sp_user"] = user + st.session_state["sp_auth_api_origin"] = str(payload["api_origin"]).rstrip("/") + st.session_state["sp_last_verified_at"] = time.time() + return user + + +def _bridge_key(prefix: str, value: str) -> str: + digest = hashlib.sha256(str(value).encode("utf-8")).hexdigest()[:20] + return f"{prefix}_{digest}" + + +def _edge_call( + action: str, + command_id: str, + payload: dict[str, Any] | None = None, + *, + fire_and_forget: bool = False, + key_prefix: str = "sp_edge", +) -> dict[str, Any] | None: + return edge_bridge( + action=action, + command_id=command_id, + api_origin=_api_base(), + payload=payload or {}, + fire_and_forget=fire_and_forget, + key=_bridge_key(key_prefix, command_id), + ) + + +def _raise_edge_error(result: dict[str, Any], fallback: str) -> None: + message = ( + result.get("message") + or result.get("reason") + or result.get("error") + or fallback + ) + raise RuntimeError(str(message)) + + +def _merge_quota_into_session(result: dict[str, Any]) -> None: + """Refresh the approved account card immediately after a quota mutation.""" + quota = result.get("quota") + if not isinstance(quota, dict): + return + user = dict(st.session_state.get("sp_user") or {}) + for key in ( + "quota_date", + "daily_free_limit", + "daily_free_reserved", + "daily_free_used", + "daily_free_remaining", + "bonus_papers", + ): + if key in quota: + user[key] = quota[key] + if user: + st.session_state["sp_user"] = user + st.session_state["sp_account_revision"] = int( + st.session_state.get("sp_account_revision") or 0 + ) + 1 + + +def get_account_snapshot(*, block: bool = False) -> dict[str, Any] | None: + bucket = int(time.time() // 30) + revision = int(st.session_state.get("sp_account_revision") or 0) + command_id = f"account:{revision}:{bucket}" + result = _edge_call("account", command_id, {}, key_prefix="sp_account") + if result is None: + if block: + st.stop() + return None + if not result.get("ok"): + if block: + _raise_edge_error(result, "account_query_failed") + return None + user = result.get("user") + if isinstance(user, dict): + st.session_state["sp_user"] = dict(user) + return result + + +def start_generation_task(*, idempotency_key: str, mode: str, interface_language: str) -> dict[str, Any]: + normalized = str(idempotency_key or "").strip() + if not normalized: + raise RuntimeError("idempotency_key_required") + result = _edge_call( + "task_start", + normalized, + { + "idempotency_key": normalized, + "mode": str(mode or "").strip().lower(), + "interface_language": str(interface_language or "zh").strip(), + }, + key_prefix="sp_task_start", + ) + if result is None: + st.stop() + if not result.get("ok"): + _raise_edge_error(result, "task_start_failed") + _merge_quota_into_session(result) + return dict(result) + + +def recover_generation_task() -> dict[str, Any]: + command_id = f"recover:{int(time.time() // 10)}" + result = _edge_call("task_recover", command_id, {}, key_prefix="sp_task_recover") + if result is None: + st.stop() + if not result.get("ok"): + _raise_edge_error(result, "task_recover_failed") + return dict(result) + + +def heartbeat_generation_task(task_id: str, *, current_node: str = "") -> dict[str, Any]: + normalized = str(task_id or "").strip() + if not normalized: + raise RuntimeError("task_id_required") + bucket = int(time.time() // 20) + _edge_call( + "task_heartbeat", + f"heartbeat:{normalized}:{bucket}", + {"task_id": normalized, "current_node": str(current_node or "")[:240]}, + fire_and_forget=True, + key_prefix="sp_task_heartbeat", + ) + return {"ok": True, "queued": True, "cancel_requested": False} + + +def generation_task_status(task_id: str) -> dict[str, Any]: + normalized = str(task_id or "").strip() + command_id = f"status:{normalized}:{int(time.time() // 5)}" + result = _edge_call( + "task_status", command_id, {"task_id": normalized}, key_prefix="sp_task_status" + ) + if result is None: + st.stop() + if not result.get("ok"): + _raise_edge_error(result, "task_status_failed") + return dict(result) + + +def finish_generation_task( + task_id: str, + *, + status: str, + safe_error_code: str = "", + safe_error_detail: str = "", + current_node: str = "", +) -> dict[str, Any]: + """Finalize a task only after the browser receives Worker/D1 confirmation.""" + normalized_task = str(task_id or "").strip() + normalized_status = str(status or "").strip().lower() + if not normalized_task: + raise RuntimeError("task_id_required") + if normalized_status not in {"completed", "failed", "cancelled", "resumable"}: + raise RuntimeError("invalid_finish_status") + command_id = f"finish:{normalized_task}:{normalized_status}" + result = _edge_call( + "task_finish", + command_id, + { + "task_id": normalized_task, + "status": normalized_status, + "safe_error_code": str(safe_error_code or "")[:80], + "safe_error_detail": str(safe_error_detail or "")[:800], + "current_node": str(current_node or "")[:240], + }, + fire_and_forget=False, + key_prefix="sp_task_finish", + ) + if result is None: + st.stop() + if not result.get("ok"): + _raise_edge_error(result, "task_finish_failed") + task = dict(result.get("task") or {}) + reservation = dict(result.get("reservation") or {}) + if str(task.get("task_id") or "") != normalized_task: + raise RuntimeError("task_finish_confirmation_mismatch") + if str(task.get("status") or "").lower() != normalized_status: + raise RuntimeError("task_finish_status_not_confirmed") + expected_reservation = { + "completed": "committed", + "failed": "released", + "cancelled": "released", + "resumable": "reserved", + }[normalized_status] + if str(reservation.get("status") or "").lower() != expected_reservation: + raise RuntimeError("task_quota_finalization_not_confirmed") + _merge_quota_into_session(result) + return dict(result) + + +def cancel_generation_task(task_id: str | None = None) -> dict[str, Any]: + """Cancel synchronously so quota release is confirmed before local cleanup.""" + normalized = str(task_id or "").strip() + command_id = f"cancel:{normalized or 'latest'}" + result = _edge_call( + "task_cancel", + command_id, + {"task_id": normalized} if normalized else {}, + fire_and_forget=False, + key_prefix="sp_task_cancel", + ) + if result is None: + st.stop() + if not result.get("ok"): + _raise_edge_error(result, "task_cancel_failed") + reservation = result.get("reservation") + if result.get("cancelled") and reservation and str(reservation.get("status") or "").lower() != "released": + raise RuntimeError("task_cancel_quota_not_released") + _merge_quota_into_session(result) + return dict(result) + + +def logout_alipay_session(on_logout: Callable[[str, str], None] | None = None) -> None: + user_id = str((st.session_state.get("sp_user") or {}).get("id") or "") + task = st.session_state.get("sp_generation_task") or {} + task_id = str(task.get("task_id") or "") + if on_logout is not None: + try: + on_logout(user_id, task_id) + except Exception: + pass + _clear_session() + _clear_generation_session_state() + + +def create_payment_order(idempotency_key: str) -> dict[str, Any]: + normalized = str(idempotency_key or "").strip() + result = _edge_call( + "pay_create", + normalized, + {"idempotency_key": normalized}, + key_prefix="sp_pay_create", + ) + if result is None: + st.stop() + if not result.get("ok"): + _raise_edge_error(result, "payment_create_failed") + return dict(result) + + +def payment_order_status(merchant_order_no: str) -> dict[str, Any]: + order_no = str(merchant_order_no or "").strip() + command_id = f"pay-status:{order_no}:{int(time.time() // 3)}" + result = _edge_call( + "pay_status", + command_id, + {"merchant_order_no": order_no}, + key_prefix="sp_pay_status", + ) + if result is None: + st.stop() + if not result.get("ok"): + _raise_edge_error(result, "payment_status_failed") + return dict(result) + + +def _qr_png_bytes(value: str) -> bytes: + image = qrcode.make(str(value)) + buffer = io.BytesIO() + image.save(buffer, format="PNG") + return buffer.getvalue() + + +def _render_payment_purchase_body(language: str) -> None: + st.warning(tr(language, "payment_notice")) + + if st.button(tr(language, "payment_create"), key="sp_create_payment_order"): + st.session_state["sp_payment_request_id"] = ( + f"pay_{int(time.time() * 1000)}_{secrets.token_urlsafe(12)}" + ) + + request_id = str(st.session_state.get("sp_payment_request_id") or "").strip() + order = st.session_state.get("sp_payment_order") + if request_id and not order: + st.info(tr(language, "payment_creating")) + order_result = create_payment_order(request_id) + order = dict(order_result.get("order") or {}) + if not order.get("merchant_order_no") or not order.get("qr_code"): + raise RuntimeError("payment_order_missing_qr_code") + st.session_state["sp_payment_order"] = order + + order = st.session_state.get("sp_payment_order") or {} + qr_code = str(order.get("qr_code") or "").strip() + if qr_code: + st.image(_qr_png_bytes(qr_code), width=280) + st.caption(tr(language, "payment_scan")) + if st.button(tr(language, "payment_check"), key="sp_check_payment_status"): + status_result = payment_order_status(order.get("merchant_order_no", "")) + updated = dict(status_result.get("order") or {}) + st.session_state["sp_payment_order"] = updated + if updated.get("status") == "paid" and updated.get("credit_applied"): + user = status_result.get("user") + if isinstance(user, dict): + st.session_state["sp_user"] = dict(user) + st.session_state["sp_account_revision"] = int( + st.session_state.get("sp_account_revision") or 0 + ) + 1 + st.session_state["sp_payment_required"] = False + st.session_state["sp_payment_order"] = None + st.session_state["sp_payment_request_id"] = "" + st.success(tr(language, "payment_paid")) + elif updated.get("status") in {"closed", "failed"}: + st.session_state["sp_payment_order"] = None + st.session_state["sp_payment_request_id"] = "" + st.warning(tr(language, "payment_expired")) + + +def render_payment_purchase(language: str) -> None: + """Show payment in a modal so the approved normal-page layout is unchanged.""" + dialog_factory = getattr(st, "dialog", None) + if callable(dialog_factory): + @dialog_factory(tr(language, "payment_dialog_title")) + def _payment_dialog(): + _render_payment_purchase_body(language) + _payment_dialog() + return + # Compatibility fallback for older Streamlit builds. + _render_payment_purchase_body(language) + + +def render_logged_in_account( + language: str, + user: dict[str, Any] | None = None, + on_logout: Callable[[str, str], None] | None = None, +) -> None: + """Render the approved responsive account summary with live quota values.""" + live = get_account_snapshot(block=False) + if live and isinstance(live.get("user"), dict): + user = dict(live["user"]) + else: + user = user or st.session_state.get("sp_user", {}) + user_id = user.get("id", "—") + free_remaining = user.get("daily_free_remaining", user.get("daily_free_limit", 3)) + bonus_papers = user.get("bonus_papers", 0) + + st.markdown( + """ + + """, + unsafe_allow_html=True, + ) + + info_col, logout_col = st.columns([5.9, 1.65]) + with info_col: + st.markdown( + f""" +
+ {html.escape(tr(language, 'account_chip_user', value=user_id))} + • + {html.escape(tr(language, 'account_chip_free', value=free_remaining))} + • + {html.escape(tr(language, 'account_chip_bonus', value=bonus_papers))} +
+ """, + unsafe_allow_html=True, + ) + with logout_col: + logout_label = html.escape(tr(language, "logout_short")) + logout_help = html.escape(tr(language, "logout"), quote=True) + st.markdown( + f'{logout_label}', + unsafe_allow_html=True, + ) diff --git a/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/requirements.txt b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..952ed734a0ece7f08d9605282077c9fa616b3496 --- /dev/null +++ b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/requirements.txt @@ -0,0 +1,14 @@ +streamlit==1.38.0 +requests>=2.31,<3 +python-docx>=1.1,<2 +pandas>=2.1,<3 +numpy>=1.26,<3 +scikit-learn>=1.4,<2 +tiktoken>=0.7,<1 +langdetect==1.0.9 +mistune>=3,<4 +beautifulsoup4>=4.12,<5 +PyMuPDF>=1.24,<2 +openpyxl>=3.1,<4 +tabulate>=0.9,<1 +qrcode[pil]>=8,<9 diff --git a/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/smartpaper_restore_bridge/__init__.py b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/smartpaper_restore_bridge/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..ec09fd0178dfec41ce8b74828e1352ad09ad43e4 --- /dev/null +++ b/$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/smartpaper_restore_bridge/__init__.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8 -*- +"""Invisible browser bridge for SmartPaper recovery and edge API calls. + +The component is served by Streamlit through the public Cloudflare origin. It +therefore allows the authenticated browser to call same-origin Worker endpoints +with the existing HttpOnly browser cookie. No service key or cookie value is +passed to Python or JavaScript arguments. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import streamlit.components.v1 as components + +_FRONTEND_DIR = Path(__file__).resolve().parent / "frontend" +_FRONTEND_ENTRY = _FRONTEND_DIR / "index.html" +if not _FRONTEND_ENTRY.is_file(): + raise RuntimeError("SmartPaper restore bridge frontend is missing.") + +_COMPONENT = components.declare_component( + "smartpaper_restore_bridge", + path=str(_FRONTEND_DIR), +) + + +def restore_bridge( + *, + action: str, + command_id: str, + token: str = "", + expires_at: str = "", + key: str = "sp_restore_bridge", +) -> dict[str, Any] | None: + """Backward-compatible recovery-token storage operation.""" + value = _COMPONENT( + action=str(action or "read"), + command_id=str(command_id or ""), + token=str(token or ""), + expires_at=str(expires_at or ""), + api_origin="", + payload={}, + fire_and_forget=False, + height=0, + default=None, + key=key, + ) + return value if isinstance(value, dict) else None + + +def edge_bridge( + *, + action: str, + command_id: str, + api_origin: str, + payload: dict[str, Any] | None = None, + fire_and_forget: bool = False, + key: str = "sp_edge_bridge", +) -> dict[str, Any] | None: + """Execute one authenticated same-origin Worker command in the browser. + + The first synchronous invocation usually returns ``None``. Streamlit reruns + after the browser completes the request and then returns the result. Only + non-terminal heartbeat calls may use ``fire_and_forget=True``. Task + completion, failure, cancellation, payment, and account mutations remain + synchronous so Python receives the Worker/D1 postcondition. + """ + value = _COMPONENT( + action=str(action or ""), + command_id=str(command_id or ""), + token="", + expires_at="", + api_origin=str(api_origin or ""), + payload=dict(payload or {}), + fire_and_forget=bool(fire_and_forget), + height=0, + default=None, + key=key, + ) + return value if isinstance(value, dict) else None diff --git a/.gitattributes b/.gitattributes index a6344aac8c09253b3b630fb776ae94478aa0275b..e368a3aeb681adf4eed5960f87815f634270dc34 100644 --- a/.gitattributes +++ b/.gitattributes @@ -33,3 +33,8 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text *.zip filter=lfs diff=lfs merge=lfs -text *.zst filter=lfs diff=lfs merge=lfs -text *tfevents* filter=lfs diff=lfs merge=lfs -text +$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$R00E74J.jpg filter=lfs diff=lfs merge=lfs -text +$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$R2MQAX1.pdf filter=lfs diff=lfs merge=lfs -text +$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$R529DC7.png filter=lfs diff=lfs merge=lfs -text +$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$R7D7KKM.jpg filter=lfs diff=lfs merge=lfs -text +$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RA7J9YA.docx filter=lfs diff=lfs merge=lfs -text