A5-R3-R4C dormant ModelPolicy v2 execution seam

#112
This view is limited to 50 files because it contains too many changes. See the raw diff here.
Files changed (50) hide show
  1. $RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$I00E74J.jpg +0 -0
  2. $RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$I04VJDB.txt +0 -0
  3. $RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$I2MQAX1.pdf +0 -0
  4. $RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$I529DC7.png +0 -0
  5. $RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$I7D7KKM.jpg +0 -0
  6. $RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IA7J9YA.docx +0 -0
  7. $RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IALBAPU.txt +0 -0
  8. $RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IB6I2XK.png +0 -0
  9. $RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$ID7299H +0 -0
  10. $RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IEBS53S.txt +0 -0
  11. $RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IFYIHXZ.jpg +0 -0
  12. $RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IHCDLMF.js +0 -0
  13. $RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IINFFGJ +0 -0
  14. $RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IJDLI7J.docx +0 -0
  15. $RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IKDVQ6V.jpg +0 -0
  16. $RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$ILK7V2E.txt +0 -0
  17. $RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$INQ4QNI.jpg +0 -0
  18. $RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IO1UTXL.txt +0 -0
  19. $RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$ITJV97I.txt +0 -0
  20. $RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IV23KEN.dotm +0 -0
  21. $RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IVC0V5I.lnk +0 -0
  22. $RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IXIUXTC.py +0 -0
  23. $RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$R00E74J.jpg +3 -0
  24. $RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$R04VJDB.txt +1 -0
  25. $RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$R2MQAX1.pdf +3 -0
  26. $RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$R529DC7.png +3 -0
  27. $RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$R7D7KKM.jpg +3 -0
  28. $RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RA7J9YA.docx +3 -0
  29. $RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RALBAPU.txt +2140 -0
  30. $RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RB6I2XK.png +0 -0
  31. $RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/.gitattributes +35 -0
  32. $RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/.streamlit/config.toml +8 -0
  33. $RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/.streamlit/desktop.ini +2 -0
  34. $RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/HF_COMMIT_SHA.txt +1 -0
  35. $RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/Plaintext +11 -0
  36. $RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/README.md +14 -0
  37. $RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/app.py +1378 -0
  38. $RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/config.py +404 -0
  39. $RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/core/desktop.ini +2 -0
  40. $RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/core/meta_extractor.py +52 -0
  41. $RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/core/pipeline_agent.py +0 -0
  42. $RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/core/production_guard.py +182 -0
  43. $RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/core/qualitative_engine.py +248 -0
  44. $RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/core/quantitative_engine.py +230 -0
  45. $RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/core/rag_engine.py +62 -0
  46. $RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/desktop.ini +2 -0
  47. $RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/i18n.py +856 -0
  48. $RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/managed_auth.py +645 -0
  49. $RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/requirements.txt +14 -0
  50. $RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/smartpaper_restore_bridge/__init__.py +81 -0
$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$I00E74J.jpg ADDED
$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$I04VJDB.txt ADDED
Binary file (98 Bytes). View file
 
$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$I2MQAX1.pdf ADDED
Binary file (322 Bytes). View file
 
$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$I529DC7.png ADDED
$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$I7D7KKM.jpg ADDED
$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IA7J9YA.docx ADDED
Binary file (214 Bytes). View file
 
$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IALBAPU.txt ADDED
Binary file (96 Bytes). View file
 
$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IB6I2XK.png ADDED
$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$ID7299H ADDED
Binary file (98 Bytes). View file
 
$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IEBS53S.txt ADDED
Binary file (88 Bytes). View file
 
$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IFYIHXZ.jpg ADDED
$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IHCDLMF.js ADDED
Binary file (94 Bytes). View file
 
$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IINFFGJ ADDED
Binary file (144 Bytes). View file
 
$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IJDLI7J.docx ADDED
Binary file (98 Bytes). View file
 
$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IKDVQ6V.jpg ADDED
$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$ILK7V2E.txt ADDED
Binary file (96 Bytes). View file
 
$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$INQ4QNI.jpg ADDED
$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IO1UTXL.txt ADDED
Binary file (158 Bytes). View file
 
$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$ITJV97I.txt ADDED
Binary file (98 Bytes). View file
 
$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IV23KEN.dotm ADDED
Binary file (154 Bytes). View file
 
$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IVC0V5I.lnk ADDED
Binary file (108 Bytes). View file
 
$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$IXIUXTC.py ADDED
Binary file (88 Bytes). View file
 
$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$R00E74J.jpg ADDED

Git LFS Details

  • SHA256: c90134ddc47804ce2eb17e3fe859757db4c14eda29834b9747a610aae3d219eb
  • Pointer size: 131 Bytes
  • Size of remote file: 769 kB
$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$R04VJDB.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ restore 0000006d-00000000-000050bf-708b24b133fed5049ff96a4fcb126551
$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$R2MQAX1.pdf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:40017ada62558766946a1de340fdfc3f332d42322cc3b1819ae4e9a4396bff98
3
+ size 1211510
$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$R529DC7.png ADDED

Git LFS Details

  • SHA256: 0ebdf74d6da0d2ef13b6973737c143ae386d80b6e855e2a953243bac76f5e450
  • Pointer size: 131 Bytes
  • Size of remote file: 119 kB
$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$R7D7KKM.jpg ADDED

Git LFS Details

  • SHA256: 4d1e6f8e38df9c529eef58bc91b4443f20b419b30751afc28afce8ce844148c3
  • Pointer size: 131 Bytes
  • Size of remote file: 101 kB
$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RA7J9YA.docx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:be51e4935cc545417d84b1adb3f4ff634349da099c1634e9f120fdbb91cc7f53
3
+ size 1046928
$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RALBAPU.txt ADDED
@@ -0,0 +1,2140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================================
2
+ * SmartPaper Authentication Worker
3
+ *
4
+ * 已实现:
5
+ * 1. 支付宝扫码授权
6
+ * 2. RSA2调用 alipay.system.oauth.token
7
+ * 3. 创建或匹配SmartPaper用户
8
+ * 4. Hugging Face创建登录请求
9
+ * 5. Hugging Face轮询扫码状态
10
+ * 6. 签发8小时短期登录会话
11
+ * 7. 验证会话
12
+ * 8. 注销会话
13
+ *
14
+ * 固定业务规则:
15
+ * - 每位用户每天免费3篇论文
16
+ * - 9.9元增加3篇长期有效论文
17
+ *
18
+ * 尚未实现:
19
+ * - 每日免费篇数的实际扣减
20
+ * - 论文生成任务与自动排队
21
+ * - 支付订单和自动增加论文
22
+ * ============================================================ */
23
+
24
+ const LOGIN_REQUEST_TTL_MS = 10 * 60 * 1000;
25
+ const SESSION_TTL_MS = 8 * 60 * 60 * 1000;
26
+
27
+ const DAILY_FREE_PAPERS = 3;
28
+ const PAYMENT_AMOUNT_FEN = 990;
29
+ const PAYMENT_BONUS_PAPERS = 3;
30
+
31
+
32
+ /* ============================================================
33
+ * 响应工具
34
+ * ============================================================ */
35
+
36
+ function commonHeaders(contentType) {
37
+ return {
38
+ "Content-Type": contentType,
39
+ "Cache-Control": "no-store",
40
+ "X-Content-Type-Options": "nosniff",
41
+ "Referrer-Policy": "no-referrer",
42
+ "X-Frame-Options": "DENY",
43
+ };
44
+ }
45
+
46
+
47
+ function jsonResponse(data, status = 200) {
48
+ return new Response(
49
+ JSON.stringify(data, null, 2),
50
+ {
51
+ status,
52
+ headers: commonHeaders(
53
+ "application/json; charset=utf-8"
54
+ ),
55
+ }
56
+ );
57
+ }
58
+
59
+
60
+ function htmlResponse(title, body, status = 200) {
61
+ return new Response(
62
+ `<!doctype html>
63
+ <html lang="zh-CN">
64
+ <head>
65
+ <meta charset="utf-8">
66
+
67
+ <meta
68
+ name="viewport"
69
+ content="width=device-width, initial-scale=1"
70
+ >
71
+
72
+ <title>${escapeHtml(title)}</title>
73
+
74
+ <style>
75
+ :root {
76
+ color-scheme: light dark;
77
+ }
78
+
79
+ body {
80
+ max-width: 760px;
81
+ margin: 50px auto;
82
+ padding: 0 20px;
83
+ font-family:
84
+ Arial,
85
+ "Microsoft YaHei",
86
+ "PingFang SC",
87
+ sans-serif;
88
+ line-height: 1.8;
89
+ }
90
+
91
+ h1 {
92
+ margin-top: 0;
93
+ font-size: 28px;
94
+ }
95
+
96
+ .card {
97
+ padding: 28px;
98
+ border: 1px solid #8885;
99
+ border-radius: 14px;
100
+ background: #8881;
101
+ }
102
+
103
+ .success {
104
+ color: #21a33b;
105
+ }
106
+
107
+ .error {
108
+ color: #d83b3b;
109
+ }
110
+
111
+ .button {
112
+ display: inline-block;
113
+ margin-top: 16px;
114
+ padding: 11px 20px;
115
+ border-radius: 8px;
116
+ background: #1677ff;
117
+ color: #ffffff;
118
+ text-decoration: none;
119
+ font-weight: 600;
120
+ }
121
+
122
+ .button:hover {
123
+ background: #0d63d7;
124
+ }
125
+
126
+ code {
127
+ word-break: break-all;
128
+ padding: 2px 5px;
129
+ border-radius: 4px;
130
+ background: #8882;
131
+ }
132
+
133
+ .muted {
134
+ opacity: 0.72;
135
+ font-size: 14px;
136
+ }
137
+
138
+ .links {
139
+ margin-top: 26px;
140
+ }
141
+ </style>
142
+ </head>
143
+
144
+ <body>
145
+ ${body}
146
+ </body>
147
+ </html>`,
148
+ {
149
+ status,
150
+ headers: commonHeaders(
151
+ "text/html; charset=utf-8"
152
+ ),
153
+ }
154
+ );
155
+ }
156
+
157
+
158
+ function escapeHtml(value) {
159
+ return String(value ?? "")
160
+ .replaceAll("&", "&amp;")
161
+ .replaceAll("<", "&lt;")
162
+ .replaceAll(">", "&gt;")
163
+ .replaceAll('"', "&quot;")
164
+ .replaceAll("'", "&#039;");
165
+ }
166
+
167
+
168
+ /* ============================================================
169
+ * 请求工具
170
+ * ============================================================ */
171
+
172
+ async function readJsonBody(request) {
173
+ try {
174
+ return await request.json();
175
+ } catch {
176
+ return null;
177
+ }
178
+ }
179
+
180
+
181
+ function extractServiceKey(request) {
182
+ const authorization =
183
+ request.headers.get("Authorization") || "";
184
+
185
+ if (authorization.startsWith("Bearer ")) {
186
+ return authorization.slice(7).trim();
187
+ }
188
+
189
+ return (
190
+ request.headers.get(
191
+ "X-SmartPaper-Service-Key"
192
+ ) || ""
193
+ ).trim();
194
+ }
195
+
196
+
197
+ /* ============================================================
198
+ * 随机数、摘要和HMAC
199
+ * ============================================================ */
200
+
201
+ function randomToken(byteLength = 32) {
202
+ const bytes = new Uint8Array(byteLength);
203
+ crypto.getRandomValues(bytes);
204
+
205
+ let binary = "";
206
+
207
+ for (const byte of bytes) {
208
+ binary += String.fromCharCode(byte);
209
+ }
210
+
211
+ return btoa(binary)
212
+ .replaceAll("+", "-")
213
+ .replaceAll("/", "_")
214
+ .replaceAll("=", "");
215
+ }
216
+
217
+
218
+ async function sha256Bytes(value) {
219
+ const encoded =
220
+ new TextEncoder().encode(String(value));
221
+
222
+ return new Uint8Array(
223
+ await crypto.subtle.digest(
224
+ "SHA-256",
225
+ encoded
226
+ )
227
+ );
228
+ }
229
+
230
+
231
+ async function sha256Hex(value) {
232
+ const bytes = await sha256Bytes(value);
233
+
234
+ return [...bytes]
235
+ .map((byte) =>
236
+ byte.toString(16).padStart(2, "0")
237
+ )
238
+ .join("");
239
+ }
240
+
241
+
242
+ async function importHmacKey(secret) {
243
+ return crypto.subtle.importKey(
244
+ "raw",
245
+ new TextEncoder().encode(secret),
246
+ {
247
+ name: "HMAC",
248
+ hash: "SHA-256",
249
+ },
250
+ false,
251
+ ["sign"]
252
+ );
253
+ }
254
+
255
+
256
+ async function hmacHex(value, secret) {
257
+ if (!secret) {
258
+ throw new Error(
259
+ "SESSION_SIGNING_SECRET未配置。"
260
+ );
261
+ }
262
+
263
+ const key = await importHmacKey(secret);
264
+
265
+ const signature =
266
+ await crypto.subtle.sign(
267
+ "HMAC",
268
+ key,
269
+ new TextEncoder().encode(value)
270
+ );
271
+
272
+ return [...new Uint8Array(signature)]
273
+ .map((byte) =>
274
+ byte.toString(16).padStart(2, "0")
275
+ )
276
+ .join("");
277
+ }
278
+
279
+
280
+ async function constantTimeTextEqual(
281
+ valueA,
282
+ valueB
283
+ ) {
284
+ const bytesA = await sha256Bytes(valueA);
285
+ const bytesB = await sha256Bytes(valueB);
286
+
287
+ if (bytesA.length !== bytesB.length) {
288
+ return false;
289
+ }
290
+
291
+ let difference = 0;
292
+
293
+ for (let i = 0; i < bytesA.length; i += 1) {
294
+ difference |= bytesA[i] ^ bytesB[i];
295
+ }
296
+
297
+ return difference === 0;
298
+ }
299
+
300
+
301
+ async function isAuthorizedServiceRequest(
302
+ request,
303
+ env
304
+ ) {
305
+ if (!env.SMARTPAPER_SERVICE_KEY) {
306
+ return false;
307
+ }
308
+
309
+ const suppliedKey =
310
+ extractServiceKey(request);
311
+
312
+ if (!suppliedKey) {
313
+ return false;
314
+ }
315
+
316
+ return constantTimeTextEqual(
317
+ suppliedKey,
318
+ env.SMARTPAPER_SERVICE_KEY
319
+ );
320
+ }
321
+
322
+
323
+ async function requireServiceRequest(
324
+ request,
325
+ env
326
+ ) {
327
+ const authorized =
328
+ await isAuthorizedServiceRequest(
329
+ request,
330
+ env
331
+ );
332
+
333
+ if (!authorized) {
334
+ return jsonResponse(
335
+ {
336
+ ok: false,
337
+ error: "unauthorized",
338
+ },
339
+ 401
340
+ );
341
+ }
342
+
343
+ return null;
344
+ }
345
+
346
+
347
+ /* ============================================================
348
+ * 时间工具
349
+ * ============================================================ */
350
+
351
+ function getBeijingTimestamp() {
352
+ const parts =
353
+ new Intl.DateTimeFormat(
354
+ "zh-CN",
355
+ {
356
+ timeZone: "Asia/Shanghai",
357
+ year: "numeric",
358
+ month: "2-digit",
359
+ day: "2-digit",
360
+ hour: "2-digit",
361
+ minute: "2-digit",
362
+ second: "2-digit",
363
+ hourCycle: "h23",
364
+ }
365
+ ).formatToParts(new Date());
366
+
367
+ const values = Object.fromEntries(
368
+ parts
369
+ .filter((part) =>
370
+ part.type !== "literal"
371
+ )
372
+ .map((part) => [
373
+ part.type,
374
+ part.value,
375
+ ])
376
+ );
377
+
378
+ return (
379
+ `${values.year}-${values.month}-${values.day} ` +
380
+ `${values.hour}:${values.minute}:${values.second}`
381
+ );
382
+ }
383
+
384
+
385
+ function isExpired(isoDate) {
386
+ if (!isoDate) {
387
+ return true;
388
+ }
389
+
390
+ const timestamp = Date.parse(isoDate);
391
+
392
+ return (
393
+ Number.isNaN(timestamp) ||
394
+ timestamp <= Date.now()
395
+ );
396
+ }
397
+
398
+
399
+ /* ============================================================
400
+ * RSA2应用私钥和支付宝签名
401
+ * ============================================================ */
402
+
403
+ function normalizePrivateKey(
404
+ privateKeyText
405
+ ) {
406
+ return String(privateKeyText ?? "")
407
+ .replaceAll("\\n", "\n")
408
+ .replace(
409
+ /-----BEGIN PRIVATE KEY-----/g,
410
+ ""
411
+ )
412
+ .replace(
413
+ /-----END PRIVATE KEY-----/g,
414
+ ""
415
+ )
416
+ .replace(
417
+ /-----BEGIN RSA PRIVATE KEY-----/g,
418
+ ""
419
+ )
420
+ .replace(
421
+ /-----END RSA PRIVATE KEY-----/g,
422
+ ""
423
+ )
424
+ .replace(/\s+/g, "");
425
+ }
426
+
427
+
428
+ function base64ToBytes(base64Text) {
429
+ const normalized =
430
+ String(base64Text ?? "")
431
+ .replace(/\s+/g, "");
432
+
433
+ const binary = atob(normalized);
434
+
435
+ const bytes =
436
+ new Uint8Array(binary.length);
437
+
438
+ for (
439
+ let index = 0;
440
+ index < binary.length;
441
+ index += 1
442
+ ) {
443
+ bytes[index] =
444
+ binary.charCodeAt(index);
445
+ }
446
+
447
+ return bytes;
448
+ }
449
+
450
+
451
+ function arrayBufferToBase64(buffer) {
452
+ const bytes = new Uint8Array(buffer);
453
+
454
+ let binary = "";
455
+
456
+ for (
457
+ let index = 0;
458
+ index < bytes.length;
459
+ index += 1
460
+ ) {
461
+ binary += String.fromCharCode(
462
+ bytes[index]
463
+ );
464
+ }
465
+
466
+ return btoa(binary);
467
+ }
468
+
469
+
470
+ async function importAlipayPrivateKey(
471
+ privateKeyText
472
+ ) {
473
+ const normalizedKey =
474
+ normalizePrivateKey(privateKeyText);
475
+
476
+ if (!normalizedKey) {
477
+ throw new Error(
478
+ "Cloudflare中没有配置支付宝应用私钥。"
479
+ );
480
+ }
481
+
482
+ let keyBytes;
483
+
484
+ try {
485
+ keyBytes =
486
+ base64ToBytes(normalizedKey);
487
+ } catch {
488
+ throw new Error(
489
+ "支付宝应��私钥不是有效的Base64格式。"
490
+ );
491
+ }
492
+
493
+ try {
494
+ return await crypto.subtle.importKey(
495
+ "pkcs8",
496
+ keyBytes,
497
+ {
498
+ name: "RSASSA-PKCS1-v1_5",
499
+ hash: "SHA-256",
500
+ },
501
+ false,
502
+ ["sign"]
503
+ );
504
+ } catch (error) {
505
+ throw new Error(
506
+ "无法导入支付宝应用私钥。" +
507
+ "请确认该值是与支付宝应用公钥配对的" +
508
+ "RSA2 PKCS8应用私钥。" +
509
+ ` 原始错误:${String(error)}`
510
+ );
511
+ }
512
+ }
513
+
514
+
515
+ function buildAlipaySignContent(
516
+ parameters
517
+ ) {
518
+ return Object.entries(parameters)
519
+ .filter(([key, value]) => (
520
+ key !== "sign" &&
521
+ value !== undefined &&
522
+ value !== null &&
523
+ value !== ""
524
+ ))
525
+ .sort(([keyA], [keyB]) => {
526
+ if (keyA < keyB) return -1;
527
+ if (keyA > keyB) return 1;
528
+ return 0;
529
+ })
530
+ .map(([key, value]) =>
531
+ `${key}=${String(value)}`
532
+ )
533
+ .join("&");
534
+ }
535
+
536
+
537
+ async function createAlipaySignature(
538
+ parameters,
539
+ privateKeyText
540
+ ) {
541
+ const contentToSign =
542
+ buildAlipaySignContent(parameters);
543
+
544
+ const privateKey =
545
+ await importAlipayPrivateKey(
546
+ privateKeyText
547
+ );
548
+
549
+ const signature =
550
+ await crypto.subtle.sign(
551
+ {
552
+ name: "RSASSA-PKCS1-v1_5",
553
+ },
554
+ privateKey,
555
+ new TextEncoder().encode(
556
+ contentToSign
557
+ )
558
+ );
559
+
560
+ return arrayBufferToBase64(
561
+ signature
562
+ );
563
+ }
564
+
565
+
566
+ /* ============================================================
567
+ * 配置检查
568
+ * ============================================================ */
569
+
570
+ function getMissingConfiguration(env) {
571
+ const requiredVariables = [
572
+ "ALIPAY_APP_ID",
573
+ "ALIPAY_APP_PRIVATE_KEY",
574
+ "ALIPAY_GATEWAY",
575
+ "ALIPAY_REDIRECT_URI",
576
+ "SESSION_SIGNING_SECRET",
577
+ "SMARTPAPER_SERVICE_KEY",
578
+ ];
579
+
580
+ const missing =
581
+ requiredVariables.filter(
582
+ (name) => !env[name]
583
+ );
584
+
585
+ if (!env.DB) {
586
+ missing.push("DB binding");
587
+ }
588
+
589
+ return missing;
590
+ }
591
+
592
+
593
+ /* ============================================================
594
+ * 支付宝登录请求
595
+ * ============================================================ */
596
+
597
+ function buildAlipayAuthorizationUrl(
598
+ env,
599
+ state
600
+ ) {
601
+ const authorizationUrl = new URL(
602
+ "https://openauth.alipay.com/oauth2/publicAppAuthorize.htm"
603
+ );
604
+
605
+ authorizationUrl.searchParams.set(
606
+ "app_id",
607
+ env.ALIPAY_APP_ID
608
+ );
609
+
610
+ authorizationUrl.searchParams.set(
611
+ "scope",
612
+ "auth_user"
613
+ );
614
+
615
+ authorizationUrl.searchParams.set(
616
+ "redirect_uri",
617
+ env.ALIPAY_REDIRECT_URI
618
+ );
619
+
620
+ authorizationUrl.searchParams.set(
621
+ "state",
622
+ state
623
+ );
624
+
625
+ return authorizationUrl.toString();
626
+ }
627
+
628
+
629
+ async function createLoginRequestRecord(env) {
630
+ const state = randomToken(32);
631
+ const pollToken = randomToken(32);
632
+
633
+ const stateHash =
634
+ await sha256Hex(state);
635
+
636
+ const pollTokenHash =
637
+ await hmacHex(
638
+ pollToken,
639
+ env.SESSION_SIGNING_SECRET
640
+ );
641
+
642
+ const loginRequestId =
643
+ crypto.randomUUID();
644
+
645
+ const expiresAt = new Date(
646
+ Date.now() + LOGIN_REQUEST_TTL_MS
647
+ ).toISOString();
648
+
649
+ await env.DB.prepare(
650
+ `INSERT INTO oauth_states
651
+ (
652
+ state_hash,
653
+ login_request_id,
654
+ status,
655
+ created_at,
656
+ expires_at,
657
+ poll_token_hash
658
+ )
659
+ VALUES
660
+ (
661
+ ?,
662
+ ?,
663
+ 'waiting',
664
+ CURRENT_TIMESTAMP,
665
+ ?,
666
+ ?
667
+ )`
668
+ )
669
+ .bind(
670
+ stateHash,
671
+ loginRequestId,
672
+ expiresAt,
673
+ pollTokenHash
674
+ )
675
+ .run();
676
+
677
+ return {
678
+ state,
679
+ pollToken,
680
+ loginRequestId,
681
+ expiresAt,
682
+ authorizationUrl:
683
+ buildAlipayAuthorizationUrl(
684
+ env,
685
+ state
686
+ ),
687
+ };
688
+ }
689
+
690
+
691
+ async function handleManualAlipayLogin(env) {
692
+ const loginRequest =
693
+ await createLoginRequestRecord(env);
694
+
695
+ return Response.redirect(
696
+ loginRequest.authorizationUrl,
697
+ 302
698
+ );
699
+ }
700
+
701
+
702
+ /* ============================================================
703
+ * auth_code换取支付宝用户标识
704
+ * ============================================================ */
705
+
706
+ async function exchangeAlipayAuthCode(
707
+ authCode,
708
+ env
709
+ ) {
710
+ const parameters = {
711
+ app_id: env.ALIPAY_APP_ID,
712
+ method:
713
+ "alipay.system.oauth.token",
714
+ format: "JSON",
715
+ charset: "utf-8",
716
+ sign_type: "RSA2",
717
+ timestamp:
718
+ getBeijingTimestamp(),
719
+ version: "1.0",
720
+ grant_type:
721
+ "authorization_code",
722
+ code: authCode,
723
+ };
724
+
725
+ parameters.sign =
726
+ await createAlipaySignature(
727
+ parameters,
728
+ env.ALIPAY_APP_PRIVATE_KEY
729
+ );
730
+
731
+ const requestBody =
732
+ new URLSearchParams();
733
+
734
+ for (
735
+ const [key, value]
736
+ of Object.entries(parameters)
737
+ ) {
738
+ requestBody.set(
739
+ key,
740
+ String(value)
741
+ );
742
+ }
743
+
744
+ const response = await fetch(
745
+ env.ALIPAY_GATEWAY,
746
+ {
747
+ method: "POST",
748
+ headers: {
749
+ "Content-Type":
750
+ "application/x-www-form-urlencoded;charset=utf-8",
751
+ },
752
+ body: requestBody.toString(),
753
+ }
754
+ );
755
+
756
+ const rawText =
757
+ await response.text();
758
+
759
+ let payload;
760
+
761
+ try {
762
+ payload = JSON.parse(rawText);
763
+ } catch {
764
+ throw new Error(
765
+ "支付宝返回了无法解析的内容。" +
766
+ `HTTP状态码:${response.status}`
767
+ );
768
+ }
769
+
770
+ const tokenData =
771
+ payload
772
+ .alipay_system_oauth_token_response;
773
+
774
+ const errorData =
775
+ payload.error_response;
776
+
777
+ if (errorData) {
778
+ throw new Error(
779
+ `支付宝接口错误:` +
780
+ `${errorData.sub_code ||
781
+ errorData.code ||
782
+ "unknown"};` +
783
+ `${errorData.sub_msg ||
784
+ errorData.msg ||
785
+ "未知错误"}`
786
+ );
787
+ }
788
+
789
+ if (!response.ok || !tokenData) {
790
+ throw new Error(
791
+ "支付宝令牌接口没有返回预期结果。"
792
+ );
793
+ }
794
+
795
+ if (
796
+ tokenData.code &&
797
+ String(tokenData.code) !== "10000"
798
+ ) {
799
+ throw new Error(
800
+ `支付宝接口错误:` +
801
+ `${tokenData.sub_code ||
802
+ tokenData.code};` +
803
+ `${tokenData.sub_msg ||
804
+ tokenData.msg ||
805
+ "未知错误"}`
806
+ );
807
+ }
808
+
809
+ const openId =
810
+ tokenData.open_id || null;
811
+
812
+ const userId =
813
+ tokenData.user_id ||
814
+ tokenData.alipay_user_id ||
815
+ null;
816
+
817
+ if (!openId && !userId) {
818
+ throw new Error(
819
+ "支付宝授权成功," +
820
+ "但没有返回open_id或user_id。"
821
+ );
822
+ }
823
+
824
+ return {
825
+ openId,
826
+ userId,
827
+ };
828
+ }
829
+
830
+
831
+ /* ============================================================
832
+ * 用户账户
833
+ * ============================================================ */
834
+
835
+ async function findExistingUser(
836
+ env,
837
+ openId,
838
+ alipayUserId
839
+ ) {
840
+ if (openId) {
841
+ const userByOpenId =
842
+ await env.DB.prepare(
843
+ `SELECT
844
+ id,
845
+ role,
846
+ status,
847
+ bonus_papers
848
+ FROM users
849
+ WHERE alipay_open_id = ?
850
+ LIMIT 1`
851
+ )
852
+ .bind(openId)
853
+ .first();
854
+
855
+ if (userByOpenId) {
856
+ return userByOpenId;
857
+ }
858
+ }
859
+
860
+ if (alipayUserId) {
861
+ const userByUserId =
862
+ await env.DB.prepare(
863
+ `SELECT
864
+ id,
865
+ role,
866
+ status,
867
+ bonus_papers
868
+ FROM users
869
+ WHERE alipay_user_id = ?
870
+ LIMIT 1`
871
+ )
872
+ .bind(alipayUserId)
873
+ .first();
874
+
875
+ if (userByUserId) {
876
+ return userByUserId;
877
+ }
878
+ }
879
+
880
+ return null;
881
+ }
882
+
883
+
884
+ async function findOrCreateSmartPaperUser(
885
+ env,
886
+ openId,
887
+ alipayUserId
888
+ ) {
889
+ const existingUser =
890
+ await findExistingUser(
891
+ env,
892
+ openId,
893
+ alipayUserId
894
+ );
895
+
896
+ if (existingUser) {
897
+ await env.DB.prepare(
898
+ `UPDATE users
899
+ SET
900
+ alipay_open_id =
901
+ COALESCE(?, alipay_open_id),
902
+
903
+ alipay_user_id =
904
+ COALESCE(?, alipay_user_id),
905
+
906
+ updated_at =
907
+ CURRENT_TIMESTAMP,
908
+
909
+ last_login_at =
910
+ CURRENT_TIMESTAMP
911
+
912
+ WHERE id = ?`
913
+ )
914
+ .bind(
915
+ openId,
916
+ alipayUserId,
917
+ existingUser.id
918
+ )
919
+ .run();
920
+
921
+ return {
922
+ id: existingUser.id,
923
+ role: existingUser.role,
924
+ status: existingUser.status,
925
+ bonusPapers:
926
+ existingUser.bonus_papers ?? 0,
927
+ isNew: false,
928
+ };
929
+ }
930
+
931
+ try {
932
+ const insertResult =
933
+ await env.DB.prepare(
934
+ `INSERT INTO users
935
+ (
936
+ alipay_open_id,
937
+ alipay_user_id,
938
+ role,
939
+ status,
940
+ bonus_papers,
941
+ created_at,
942
+ updated_at,
943
+ last_login_at
944
+ )
945
+ VALUES
946
+ (
947
+ ?,
948
+ ?,
949
+ 'user',
950
+ 'active',
951
+ 0,
952
+ CURRENT_TIMESTAMP,
953
+ CURRENT_TIMESTAMP,
954
+ CURRENT_TIMESTAMP
955
+ )`
956
+ )
957
+ .bind(
958
+ openId,
959
+ alipayUserId
960
+ )
961
+ .run();
962
+
963
+ return {
964
+ id:
965
+ insertResult.meta.last_row_id,
966
+ role: "user",
967
+ status: "active",
968
+ bonusPapers: 0,
969
+ isNew: true,
970
+ };
971
+ } catch (error) {
972
+ const userAfterConflict =
973
+ await findExistingUser(
974
+ env,
975
+ openId,
976
+ alipayUserId
977
+ );
978
+
979
+ if (userAfterConflict) {
980
+ return {
981
+ id: userAfterConflict.id,
982
+ role: userAfterConflict.role,
983
+ status:
984
+ userAfterConflict.status,
985
+ bonusPapers:
986
+ userAfterConflict
987
+ .bonus_papers ?? 0,
988
+ isNew: false,
989
+ };
990
+ }
991
+
992
+ throw error;
993
+ }
994
+ }
995
+
996
+
997
+ function formatUser(user) {
998
+ return {
999
+ id:
1000
+ `U${String(user.id)
1001
+ .padStart(6, "0")}`,
1002
+ numeric_id: user.id,
1003
+ role: user.role,
1004
+ status: user.status,
1005
+ bonus_papers:
1006
+ user.bonus_papers ??
1007
+ user.bonusPapers ??
1008
+ 0,
1009
+ daily_free_limit:
1010
+ DAILY_FREE_PAPERS,
1011
+ };
1012
+ }
1013
+
1014
+
1015
+ /* ============================================================
1016
+ * 支付宝回调
1017
+ * ============================================================ */
1018
+
1019
+ async function handleAlipayCallback(
1020
+ request,
1021
+ env
1022
+ ) {
1023
+ const url = new URL(request.url);
1024
+
1025
+ const authCode =
1026
+ url.searchParams.get("auth_code");
1027
+
1028
+ const state =
1029
+ url.searchParams.get("state");
1030
+
1031
+ const returnedAppId =
1032
+ url.searchParams.get("app_id");
1033
+
1034
+ if (!authCode) {
1035
+ return htmlResponse(
1036
+ "支付宝授权失败",
1037
+ `
1038
+ <div class="card">
1039
+ <h1 class="error">
1040
+ 未收到授权码
1041
+ </h1>
1042
+
1043
+ <p>
1044
+ 用户可能取消了授权,
1045
+ 或支付宝应用权限尚未生效。
1046
+ </p>
1047
+
1048
+ <a
1049
+ class="button"
1050
+ href="/alipay/login"
1051
+ >
1052
+ 重新登录
1053
+ </a>
1054
+ </div>
1055
+ `,
1056
+ 400
1057
+ );
1058
+ }
1059
+
1060
+ if (!state) {
1061
+ return htmlResponse(
1062
+ "支付宝授权失败",
1063
+ `
1064
+ <div class="card">
1065
+ <h1 class="error">
1066
+ 缺少state参数
1067
+ </h1>
1068
+
1069
+ <p>
1070
+ 为保护登录安全,
1071
+ 本次请求已被拒绝。
1072
+ </p>
1073
+ </div>
1074
+ `,
1075
+ 400
1076
+ );
1077
+ }
1078
+
1079
+ const stateHash =
1080
+ await sha256Hex(state);
1081
+
1082
+ const stateRecord =
1083
+ await env.DB.prepare(
1084
+ `SELECT
1085
+ state_hash,
1086
+ login_request_id,
1087
+ status,
1088
+ expires_at
1089
+ FROM oauth_states
1090
+ WHERE state_hash = ?
1091
+ LIMIT 1`
1092
+ )
1093
+ .bind(stateHash)
1094
+ .first();
1095
+
1096
+ if (!stateRecord) {
1097
+ return htmlResponse(
1098
+ "支付宝授权失败",
1099
+ `
1100
+ <div class="card">
1101
+ <h1 class="error">
1102
+ 登录请求不存在
1103
+ </h1>
1104
+ </div>
1105
+ `,
1106
+ 400
1107
+ );
1108
+ }
1109
+
1110
+ if (
1111
+ stateRecord.status !== "waiting"
1112
+ ) {
1113
+ return htmlResponse(
1114
+ "支付宝授权失败",
1115
+ `
1116
+ <div class="card">
1117
+ <h1 class="error">
1118
+ 登录请求已经使用
1119
+ </h1>
1120
+
1121
+ <a
1122
+ class="button"
1123
+ href="/alipay/login"
1124
+ >
1125
+ 创建新请求
1126
+ </a>
1127
+ </div>
1128
+ `,
1129
+ 409
1130
+ );
1131
+ }
1132
+
1133
+ if (isExpired(stateRecord.expires_at)) {
1134
+ await env.DB.prepare(
1135
+ `UPDATE oauth_states
1136
+ SET status = 'expired'
1137
+ WHERE state_hash = ?`
1138
+ )
1139
+ .bind(stateHash)
1140
+ .run();
1141
+
1142
+ return htmlResponse(
1143
+ "支付宝授权失败",
1144
+ `
1145
+ <div class="card">
1146
+ <h1 class="error">
1147
+ 登录请求已经过期
1148
+ </h1>
1149
+
1150
+ <a
1151
+ class="button"
1152
+ href="/alipay/login"
1153
+ >
1154
+ 重新登录
1155
+ </a>
1156
+ </div>
1157
+ `,
1158
+ 410
1159
+ );
1160
+ }
1161
+
1162
+ if (
1163
+ returnedAppId &&
1164
+ returnedAppId !==
1165
+ env.ALIPAY_APP_ID
1166
+ ) {
1167
+ return htmlResponse(
1168
+ "支付宝授权失败",
1169
+ `
1170
+ <div class="card">
1171
+ <h1 class="error">
1172
+ APPID不匹配
1173
+ </h1>
1174
+ </div>
1175
+ `,
1176
+ 400
1177
+ );
1178
+ }
1179
+
1180
+ const claimResult =
1181
+ await env.DB.prepare(
1182
+ `UPDATE oauth_states
1183
+ SET
1184
+ status = 'exchanging',
1185
+ used_at = CURRENT_TIMESTAMP
1186
+ WHERE state_hash = ?
1187
+ AND status = 'waiting'`
1188
+ )
1189
+ .bind(stateHash)
1190
+ .run();
1191
+
1192
+ if (
1193
+ !claimResult.success ||
1194
+ claimResult.meta.changes !== 1
1195
+ ) {
1196
+ return htmlResponse(
1197
+ "支付宝授权失败",
1198
+ `
1199
+ <div class="card">
1200
+ <h1 class="error">
1201
+ 登录请求已经被处理
1202
+ </h1>
1203
+ </div>
1204
+ `,
1205
+ 409
1206
+ );
1207
+ }
1208
+
1209
+ let alipayIdentity;
1210
+
1211
+ try {
1212
+ alipayIdentity =
1213
+ await exchangeAlipayAuthCode(
1214
+ authCode,
1215
+ env
1216
+ );
1217
+ } catch (error) {
1218
+ await env.DB.prepare(
1219
+ `UPDATE oauth_states
1220
+ SET status =
1221
+ 'token_exchange_failed'
1222
+ WHERE state_hash = ?`
1223
+ )
1224
+ .bind(stateHash)
1225
+ .run();
1226
+
1227
+ return htmlResponse(
1228
+ "支付宝身份交换失败",
1229
+ `
1230
+ <div class="card">
1231
+ <h1 class="error">
1232
+ 未能取得支付宝用户标识
1233
+ </h1>
1234
+
1235
+ <p>
1236
+ <code>
1237
+ ${escapeHtml(String(error))}
1238
+ </code>
1239
+ </p>
1240
+
1241
+ <a
1242
+ class="button"
1243
+ href="/alipay/login"
1244
+ >
1245
+ 创建新请求
1246
+ </a>
1247
+ </div>
1248
+ `,
1249
+ 500
1250
+ );
1251
+ }
1252
+
1253
+ let smartPaperUser;
1254
+
1255
+ try {
1256
+ smartPaperUser =
1257
+ await findOrCreateSmartPaperUser(
1258
+ env,
1259
+ alipayIdentity.openId,
1260
+ alipayIdentity.userId
1261
+ );
1262
+ } catch (error) {
1263
+ await env.DB.prepare(
1264
+ `UPDATE oauth_states
1265
+ SET status =
1266
+ 'user_creation_failed'
1267
+ WHERE state_hash = ?`
1268
+ )
1269
+ .bind(stateHash)
1270
+ .run();
1271
+
1272
+ return htmlResponse(
1273
+ "SmartPaper账户创建失败",
1274
+ `
1275
+ <div class="card">
1276
+ <h1 class="error">
1277
+ 无法建立用户账户
1278
+ </h1>
1279
+
1280
+ <p>
1281
+ <code>
1282
+ ${escapeHtml(String(error))}
1283
+ </code>
1284
+ </p>
1285
+ </div>
1286
+ `,
1287
+ 500
1288
+ );
1289
+ }
1290
+
1291
+ if (
1292
+ smartPaperUser.status !== "active"
1293
+ ) {
1294
+ await env.DB.prepare(
1295
+ `UPDATE oauth_states
1296
+ SET
1297
+ status = 'user_blocked',
1298
+ user_id = ?
1299
+ WHERE state_hash = ?`
1300
+ )
1301
+ .bind(
1302
+ smartPaperUser.id,
1303
+ stateHash
1304
+ )
1305
+ .run();
1306
+
1307
+ return htmlResponse(
1308
+ "SmartPaper账户不可用",
1309
+ `
1310
+ <div class="card">
1311
+ <h1 class="error">
1312
+ 账户已被停用
1313
+ </h1>
1314
+ </div>
1315
+ `,
1316
+ 403
1317
+ );
1318
+ }
1319
+
1320
+ await env.DB.prepare(
1321
+ `UPDATE oauth_states
1322
+ SET
1323
+ status = 'authenticated',
1324
+ user_id = ?
1325
+ WHERE state_hash = ?
1326
+ AND status = 'exchanging'`
1327
+ )
1328
+ .bind(
1329
+ smartPaperUser.id,
1330
+ stateHash
1331
+ )
1332
+ .run();
1333
+
1334
+ return htmlResponse(
1335
+ "支付宝身份识别成功",
1336
+ `
1337
+ <div class="card">
1338
+ <h1 class="success">
1339
+ 支付宝身份识别成功
1340
+ </h1>
1341
+
1342
+ <p>
1343
+ SmartPaper用户:
1344
+ <strong>
1345
+ ${escapeHtml(
1346
+ formatUser(
1347
+ smartPaperUser
1348
+ ).id
1349
+ )}
1350
+ </strong>
1351
+ </p>
1352
+
1353
+ <p>
1354
+ 请返回电脑上的SmartPaper页面。
1355
+ 系统将自动完成登录。
1356
+ </p>
1357
+
1358
+ <p class="muted">
1359
+ 本页面不会显示支付宝用户标识、
1360
+ 访问令牌或SmartPaper会话令牌。
1361
+ </p>
1362
+ </div>
1363
+ `
1364
+ );
1365
+ }
1366
+
1367
+
1368
+ /* ============================================================
1369
+ * Hugging Face创建登录请求
1370
+ * POST /login/create
1371
+ * ============================================================ */
1372
+
1373
+ async function handleLoginCreate(
1374
+ request,
1375
+ env
1376
+ ) {
1377
+ const unauthorized =
1378
+ await requireServiceRequest(
1379
+ request,
1380
+ env
1381
+ );
1382
+
1383
+ if (unauthorized) {
1384
+ return unauthorized;
1385
+ }
1386
+
1387
+ const loginRequest =
1388
+ await createLoginRequestRecord(env);
1389
+
1390
+ return jsonResponse({
1391
+ ok: true,
1392
+ status: "waiting",
1393
+ login_request_id:
1394
+ loginRequest.loginRequestId,
1395
+ poll_token:
1396
+ loginRequest.pollToken,
1397
+ authorization_url:
1398
+ loginRequest.authorizationUrl,
1399
+ qr_content:
1400
+ loginRequest.authorizationUrl,
1401
+ expires_at:
1402
+ loginRequest.expiresAt,
1403
+ poll_interval_seconds: 2,
1404
+ });
1405
+ }
1406
+
1407
+
1408
+ /* ============================================================
1409
+ * Hugging Face查询登录状态
1410
+ * POST /login/status
1411
+ * ============================================================ */
1412
+
1413
+ async function handleLoginStatus(
1414
+ request,
1415
+ env
1416
+ ) {
1417
+ const unauthorized =
1418
+ await requireServiceRequest(
1419
+ request,
1420
+ env
1421
+ );
1422
+
1423
+ if (unauthorized) {
1424
+ return unauthorized;
1425
+ }
1426
+
1427
+ const body =
1428
+ await readJsonBody(request);
1429
+
1430
+ const loginRequestId =
1431
+ String(
1432
+ body?.login_request_id || ""
1433
+ ).trim();
1434
+
1435
+ const pollToken =
1436
+ String(
1437
+ body?.poll_token || ""
1438
+ ).trim();
1439
+
1440
+ if (
1441
+ !loginRequestId ||
1442
+ !pollToken
1443
+ ) {
1444
+ return jsonResponse(
1445
+ {
1446
+ ok: false,
1447
+ error: "invalid_request",
1448
+ required: [
1449
+ "login_request_id",
1450
+ "poll_token",
1451
+ ],
1452
+ },
1453
+ 400
1454
+ );
1455
+ }
1456
+
1457
+ const pollTokenHash =
1458
+ await hmacHex(
1459
+ pollToken,
1460
+ env.SESSION_SIGNING_SECRET
1461
+ );
1462
+
1463
+ let record =
1464
+ await env.DB.prepare(
1465
+ `SELECT
1466
+ login_request_id,
1467
+ status,
1468
+ user_id,
1469
+ expires_at,
1470
+ session_issued_at
1471
+ FROM oauth_states
1472
+ WHERE login_request_id = ?
1473
+ AND poll_token_hash = ?
1474
+ LIMIT 1`
1475
+ )
1476
+ .bind(
1477
+ loginRequestId,
1478
+ pollTokenHash
1479
+ )
1480
+ .first();
1481
+
1482
+ if (!record) {
1483
+ return jsonResponse(
1484
+ {
1485
+ ok: false,
1486
+ error:
1487
+ "login_request_not_found",
1488
+ },
1489
+ 404
1490
+ );
1491
+ }
1492
+
1493
+ if (
1494
+ isExpired(record.expires_at) &&
1495
+ ![
1496
+ "completed",
1497
+ "authenticated",
1498
+ "issuing",
1499
+ ].includes(record.status)
1500
+ ) {
1501
+ await env.DB.prepare(
1502
+ `UPDATE oauth_states
1503
+ SET status = 'expired'
1504
+ WHERE login_request_id = ?
1505
+ AND poll_token_hash = ?`
1506
+ )
1507
+ .bind(
1508
+ loginRequestId,
1509
+ pollTokenHash
1510
+ )
1511
+ .run();
1512
+
1513
+ return jsonResponse(
1514
+ {
1515
+ ok: false,
1516
+ status: "expired",
1517
+ },
1518
+ 410
1519
+ );
1520
+ }
1521
+
1522
+ if (
1523
+ record.status === "waiting" ||
1524
+ record.status === "exchanging" ||
1525
+ record.status === "issuing"
1526
+ ) {
1527
+ return jsonResponse({
1528
+ ok: true,
1529
+ status: "pending",
1530
+ });
1531
+ }
1532
+
1533
+ if (
1534
+ [
1535
+ "expired",
1536
+ "token_exchange_failed",
1537
+ "user_creation_failed",
1538
+ "user_blocked",
1539
+ ].includes(record.status)
1540
+ ) {
1541
+ return jsonResponse(
1542
+ {
1543
+ ok: false,
1544
+ status: "failed",
1545
+ reason: record.status,
1546
+ },
1547
+ 400
1548
+ );
1549
+ }
1550
+
1551
+ if (record.status === "completed") {
1552
+ return jsonResponse(
1553
+ {
1554
+ ok: false,
1555
+ status: "already_delivered",
1556
+ message:
1557
+ "该登录结果已经领取,请重新扫码登录。",
1558
+ },
1559
+ 409
1560
+ );
1561
+ }
1562
+
1563
+ if (
1564
+ record.status !== "authenticated" ||
1565
+ !record.user_id
1566
+ ) {
1567
+ return jsonResponse(
1568
+ {
1569
+ ok: false,
1570
+ status: "invalid_state",
1571
+ current_status:
1572
+ record.status,
1573
+ },
1574
+ 409
1575
+ );
1576
+ }
1577
+
1578
+ const user =
1579
+ await env.DB.prepare(
1580
+ `SELECT
1581
+ id,
1582
+ role,
1583
+ status,
1584
+ bonus_papers
1585
+ FROM users
1586
+ WHERE id = ?
1587
+ LIMIT 1`
1588
+ )
1589
+ .bind(record.user_id)
1590
+ .first();
1591
+
1592
+ if (!user) {
1593
+ return jsonResponse(
1594
+ {
1595
+ ok: false,
1596
+ error: "user_not_found",
1597
+ },
1598
+ 404
1599
+ );
1600
+ }
1601
+
1602
+ if (user.status !== "active") {
1603
+ await env.DB.prepare(
1604
+ `UPDATE oauth_states
1605
+ SET status = 'user_blocked'
1606
+ WHERE login_request_id = ?`
1607
+ )
1608
+ .bind(loginRequestId)
1609
+ .run();
1610
+
1611
+ return jsonResponse(
1612
+ {
1613
+ ok: false,
1614
+ error: "user_blocked",
1615
+ },
1616
+ 403
1617
+ );
1618
+ }
1619
+
1620
+ const claimResult =
1621
+ await env.DB.prepare(
1622
+ `UPDATE oauth_states
1623
+ SET status = 'issuing'
1624
+ WHERE login_request_id = ?
1625
+ AND poll_token_hash = ?
1626
+ AND status = 'authenticated'
1627
+ AND session_issued_at IS NULL`
1628
+ )
1629
+ .bind(
1630
+ loginRequestId,
1631
+ pollTokenHash
1632
+ )
1633
+ .run();
1634
+
1635
+ if (
1636
+ !claimResult.success ||
1637
+ claimResult.meta.changes !== 1
1638
+ ) {
1639
+ return jsonResponse({
1640
+ ok: true,
1641
+ status: "pending",
1642
+ });
1643
+ }
1644
+
1645
+ const sessionToken =
1646
+ randomToken(48);
1647
+
1648
+ const sessionHash =
1649
+ await hmacHex(
1650
+ sessionToken,
1651
+ env.SESSION_SIGNING_SECRET
1652
+ );
1653
+
1654
+ const sessionExpiresAt =
1655
+ new Date(
1656
+ Date.now() + SESSION_TTL_MS
1657
+ ).toISOString();
1658
+
1659
+ try {
1660
+ await env.DB.batch([
1661
+ env.DB.prepare(
1662
+ `INSERT INTO login_sessions
1663
+ (
1664
+ session_hash,
1665
+ user_id,
1666
+ created_at,
1667
+ expires_at,
1668
+ revoked_at
1669
+ )
1670
+ VALUES
1671
+ (
1672
+ ?,
1673
+ ?,
1674
+ CURRENT_TIMESTAMP,
1675
+ ?,
1676
+ NULL
1677
+ )`
1678
+ ).bind(
1679
+ sessionHash,
1680
+ user.id,
1681
+ sessionExpiresAt
1682
+ ),
1683
+
1684
+ env.DB.prepare(
1685
+ `UPDATE oauth_states
1686
+ SET
1687
+ status = 'completed',
1688
+ session_issued_at =
1689
+ CURRENT_TIMESTAMP
1690
+ WHERE login_request_id = ?
1691
+ AND status = 'issuing'`
1692
+ ).bind(loginRequestId),
1693
+ ]);
1694
+ } catch (error) {
1695
+ await env.DB.prepare(
1696
+ `UPDATE oauth_states
1697
+ SET status = 'authenticated'
1698
+ WHERE login_request_id = ?
1699
+ AND status = 'issuing'`
1700
+ )
1701
+ .bind(loginRequestId)
1702
+ .run();
1703
+
1704
+ throw error;
1705
+ }
1706
+
1707
+ return jsonResponse({
1708
+ ok: true,
1709
+ status: "authenticated",
1710
+ session_token: sessionToken,
1711
+ session_expires_at:
1712
+ sessionExpiresAt,
1713
+ user: formatUser(user),
1714
+ });
1715
+ }
1716
+
1717
+
1718
+ /* ============================================================
1719
+ * 验证SmartPaper会话
1720
+ * POST /session/verify
1721
+ * ============================================================ */
1722
+
1723
+ async function handleSessionVerify(
1724
+ request,
1725
+ env
1726
+ ) {
1727
+ const unauthorized =
1728
+ await requireServiceRequest(
1729
+ request,
1730
+ env
1731
+ );
1732
+
1733
+ if (unauthorized) {
1734
+ return unauthorized;
1735
+ }
1736
+
1737
+ const body =
1738
+ await readJsonBody(request);
1739
+
1740
+ const sessionToken =
1741
+ String(
1742
+ body?.session_token || ""
1743
+ ).trim();
1744
+
1745
+ if (!sessionToken) {
1746
+ return jsonResponse(
1747
+ {
1748
+ ok: false,
1749
+ error:
1750
+ "session_token_required",
1751
+ },
1752
+ 400
1753
+ );
1754
+ }
1755
+
1756
+ const sessionHash =
1757
+ await hmacHex(
1758
+ sessionToken,
1759
+ env.SESSION_SIGNING_SECRET
1760
+ );
1761
+
1762
+ const session =
1763
+ await env.DB.prepare(
1764
+ `SELECT
1765
+ s.session_hash,
1766
+ s.expires_at,
1767
+ s.revoked_at,
1768
+
1769
+ u.id,
1770
+ u.role,
1771
+ u.status,
1772
+ u.bonus_papers
1773
+
1774
+ FROM login_sessions AS s
1775
+
1776
+ INNER JOIN users AS u
1777
+ ON u.id = s.user_id
1778
+
1779
+ WHERE s.session_hash = ?
1780
+ LIMIT 1`
1781
+ )
1782
+ .bind(sessionHash)
1783
+ .first();
1784
+
1785
+ if (!session) {
1786
+ return jsonResponse(
1787
+ {
1788
+ ok: false,
1789
+ valid: false,
1790
+ error: "invalid_session",
1791
+ },
1792
+ 401
1793
+ );
1794
+ }
1795
+
1796
+ if (session.revoked_at) {
1797
+ return jsonResponse(
1798
+ {
1799
+ ok: false,
1800
+ valid: false,
1801
+ error: "session_revoked",
1802
+ },
1803
+ 401
1804
+ );
1805
+ }
1806
+
1807
+ if (isExpired(session.expires_at)) {
1808
+ return jsonResponse(
1809
+ {
1810
+ ok: false,
1811
+ valid: false,
1812
+ error: "session_expired",
1813
+ },
1814
+ 401
1815
+ );
1816
+ }
1817
+
1818
+ if (session.status !== "active") {
1819
+ return jsonResponse(
1820
+ {
1821
+ ok: false,
1822
+ valid: false,
1823
+ error: "user_blocked",
1824
+ },
1825
+ 403
1826
+ );
1827
+ }
1828
+
1829
+ return jsonResponse({
1830
+ ok: true,
1831
+ valid: true,
1832
+ session_expires_at:
1833
+ session.expires_at,
1834
+ user: formatUser(session),
1835
+ });
1836
+ }
1837
+
1838
+
1839
+ /* ============================================================
1840
+ * 注销SmartPaper会话
1841
+ * POST /logout
1842
+ * ============================================================ */
1843
+
1844
+ async function handleLogout(
1845
+ request,
1846
+ env
1847
+ ) {
1848
+ const unauthorized =
1849
+ await requireServiceRequest(
1850
+ request,
1851
+ env
1852
+ );
1853
+
1854
+ if (unauthorized) {
1855
+ return unauthorized;
1856
+ }
1857
+
1858
+ const body =
1859
+ await readJsonBody(request);
1860
+
1861
+ const sessionToken =
1862
+ String(
1863
+ body?.session_token || ""
1864
+ ).trim();
1865
+
1866
+ if (!sessionToken) {
1867
+ return jsonResponse(
1868
+ {
1869
+ ok: false,
1870
+ error:
1871
+ "session_token_required",
1872
+ },
1873
+ 400
1874
+ );
1875
+ }
1876
+
1877
+ const sessionHash =
1878
+ await hmacHex(
1879
+ sessionToken,
1880
+ env.SESSION_SIGNING_SECRET
1881
+ );
1882
+
1883
+ await env.DB.prepare(
1884
+ `UPDATE login_sessions
1885
+ SET revoked_at =
1886
+ COALESCE(
1887
+ revoked_at,
1888
+ CURRENT_TIMESTAMP
1889
+ )
1890
+ WHERE session_hash = ?`
1891
+ )
1892
+ .bind(sessionHash)
1893
+ .run();
1894
+
1895
+ return jsonResponse({
1896
+ ok: true,
1897
+ logged_out: true,
1898
+ });
1899
+ }
1900
+
1901
+
1902
+ /* ============================================================
1903
+ * Worker主入口
1904
+ * ============================================================ */
1905
+
1906
+ export default {
1907
+ async fetch(request, env) {
1908
+ const url = new URL(request.url);
1909
+
1910
+ try {
1911
+ if (request.method === "OPTIONS") {
1912
+ return new Response(null, {
1913
+ status: 204,
1914
+ });
1915
+ }
1916
+
1917
+ if (url.pathname === "/health") {
1918
+ const missing =
1919
+ getMissingConfiguration(env);
1920
+
1921
+ return jsonResponse({
1922
+ ok: missing.length === 0,
1923
+ service:
1924
+ "smartpaper-auth",
1925
+ stage:
1926
+ "smartpaper-session-backend",
1927
+ configuration_ready:
1928
+ missing.length === 0,
1929
+ missing,
1930
+ rules: {
1931
+ daily_free_papers:
1932
+ DAILY_FREE_PAPERS,
1933
+ payment_amount_fen:
1934
+ PAYMENT_AMOUNT_FEN,
1935
+ payment_bonus_papers:
1936
+ PAYMENT_BONUS_PAPERS,
1937
+ session_hours: 8,
1938
+ },
1939
+ time:
1940
+ new Date().toISOString(),
1941
+ });
1942
+ }
1943
+
1944
+ if (url.pathname === "/db-test") {
1945
+ if (!env.DB) {
1946
+ return jsonResponse(
1947
+ {
1948
+ ok: false,
1949
+ error:
1950
+ "DB binding is missing",
1951
+ },
1952
+ 500
1953
+ );
1954
+ }
1955
+
1956
+ const result =
1957
+ await env.DB.prepare(
1958
+ "SELECT 1 AS connected"
1959
+ ).first();
1960
+
1961
+ return jsonResponse({
1962
+ ok: true,
1963
+ database:
1964
+ "smartpaper-db",
1965
+ connected:
1966
+ result?.connected === 1,
1967
+ });
1968
+ }
1969
+
1970
+ if (url.pathname === "/privacy") {
1971
+ return htmlResponse(
1972
+ "SmartPaper隐私说明",
1973
+ `
1974
+ <div class="card">
1975
+ <h1>
1976
+ SmartPaper隐私说明
1977
+ </h1>
1978
+
1979
+ <p>
1980
+ SmartPaper使用支付宝授权返回的
1981
+ 用户唯一标识建立用户账户。
1982
+ </p>
1983
+
1984
+ <p>
1985
+ 该标识用于用户登录、
1986
+ 每日免费3篇论文的额度管理、
1987
+ 长期有效论文管理、任务排队、
1988
+ 失败退还和异常滥用防范。
1989
+ </p>
1990
+
1991
+ <p>
1992
+ SmartPaper原则上不收集身份证号码、
1993
+ 真实姓名、手机号、通讯录或其他
1994
+ 与上述目的无关的信息。
1995
+ </p>
1996
+
1997
+ <p>
1998
+ 当前页面为开发测试阶段隐私说明。
1999
+ </p>
2000
+ </div>
2001
+ `
2002
+ );
2003
+ }
2004
+
2005
+ if (
2006
+ url.pathname === "/alipay/login" &&
2007
+ request.method === "GET"
2008
+ ) {
2009
+ return await handleManualAlipayLogin(
2010
+ env
2011
+ );
2012
+ }
2013
+
2014
+ if (
2015
+ url.pathname ===
2016
+ "/alipay/callback" &&
2017
+ request.method === "GET"
2018
+ ) {
2019
+ return await handleAlipayCallback(
2020
+ request,
2021
+ env
2022
+ );
2023
+ }
2024
+
2025
+ if (
2026
+ url.pathname ===
2027
+ "/login/create" &&
2028
+ request.method === "POST"
2029
+ ) {
2030
+ return await handleLoginCreate(
2031
+ request,
2032
+ env
2033
+ );
2034
+ }
2035
+
2036
+ if (
2037
+ url.pathname ===
2038
+ "/login/status" &&
2039
+ request.method === "POST"
2040
+ ) {
2041
+ return await handleLoginStatus(
2042
+ request,
2043
+ env
2044
+ );
2045
+ }
2046
+
2047
+ if (
2048
+ url.pathname ===
2049
+ "/session/verify" &&
2050
+ request.method === "POST"
2051
+ ) {
2052
+ return await handleSessionVerify(
2053
+ request,
2054
+ env
2055
+ );
2056
+ }
2057
+
2058
+ if (
2059
+ url.pathname === "/logout" &&
2060
+ request.method === "POST"
2061
+ ) {
2062
+ return await handleLogout(
2063
+ request,
2064
+ env
2065
+ );
2066
+ }
2067
+
2068
+ if (
2069
+ url.pathname === "/" &&
2070
+ request.method === "GET"
2071
+ ) {
2072
+ return htmlResponse(
2073
+ "SmartPaper支付宝登录测试",
2074
+ `
2075
+ <div class="card">
2076
+ <h1>
2077
+ SmartPaper支付宝登录测试
2078
+ </h1>
2079
+
2080
+ <p>
2081
+ 支付宝用户识别和SmartPaper
2082
+ 8小时会话后端已经启用。
2083
+ </p>
2084
+
2085
+ <p>
2086
+ 点击按钮后,
2087
+ 请使用手机支付宝扫码并确认授权。
2088
+ </p>
2089
+
2090
+ <a
2091
+ class="button"
2092
+ href="/alipay/login"
2093
+ >
2094
+ 开始支付宝扫码登录测试
2095
+ </a>
2096
+
2097
+ <p class="links">
2098
+ <a href="/health">
2099
+ 检查Worker
2100
+ </a>
2101
+
2102
+ &nbsp;|&nbsp;
2103
+
2104
+ <a href="/db-test">
2105
+ 检查数据库
2106
+ </a>
2107
+
2108
+ &nbsp;|&nbsp;
2109
+
2110
+ <a href="/privacy">
2111
+ 查看隐私说明
2112
+ </a>
2113
+ </p>
2114
+ </div>
2115
+ `
2116
+ );
2117
+ }
2118
+
2119
+ return jsonResponse(
2120
+ {
2121
+ ok: false,
2122
+ error: "not_found",
2123
+ path: url.pathname,
2124
+ },
2125
+ 404
2126
+ );
2127
+ } catch (error) {
2128
+ console.error(error);
2129
+
2130
+ return jsonResponse(
2131
+ {
2132
+ ok: false,
2133
+ error: "internal_error",
2134
+ detail: String(error),
2135
+ },
2136
+ 500
2137
+ );
2138
+ }
2139
+ },
2140
+ };
$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RB6I2XK.png ADDED
$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/.gitattributes ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/.streamlit/config.toml ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ [server]
2
+ enableCORS = false
3
+ enableXsrfProtection = false
4
+ enableWebsocketCompression = false
5
+ maxUploadSize = 1024
6
+
7
+ [browser]
8
+ gatherUsageStats = false
$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/.streamlit/desktop.ini ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ [.ShellClassInfo]
2
+ IconResource=C:\Program Files\Google\Drive File Stream\129.0.1.0\GoogleDriveFS.exe,27
$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/HF_COMMIT_SHA.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ af60fe1bdcbab60f47f06586cca03210f7f61026
$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/Plaintext ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ project_root/
2
+ ├── app.py                 (主应用入口:UI界面与流程调度)
3
+ ├── config.py              (静态配置:提示词字典与基础参数)
4
+ ├── core/
5
+ │   └── pipeline_agent.py  (核心大脑:SSciPipelineAgent 类)
6
+ │   ├── rag_engine.py       🌟 [新建] 专门负责处理 20+ 篇大体量文献的切块与按需检索
7
+ │   ├── meta_extractor.py   🌟 [新建] 专门负责从文献中榨取 JSON 格式的黄金价值数据
8
+ │   └── qualitative_engine.py 🌟 [新建] 专门定性研究
9
+ └── utils/
10
+     ├── file_parser.py     (工具:PDF/Word/Excel解析)
11
+     └── docx_builder.py    (工具:Markdown转Word渲染器)
$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/README.md ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Agent Article
3
+ emoji: 📊
4
+ colorFrom: blue
5
+ colorTo: green
6
+ sdk: streamlit
7
+ app_file: app.py
8
+ pinned: false
9
+ short_description: Agent Article
10
+ sdk_version: 1.38.0
11
+ python_version: 3.11
12
+ ---
13
+
14
+ Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/app.py ADDED
@@ -0,0 +1,1378 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """Smart Draft - Dual Track (AMJ Empirical vs ASQ Qualitative)"""
3
+ from core.meta_extractor import extract_golden_metadata
4
+ from core.rag_engine import initialize_rag
5
+ from core.qualitative_engine import get_qualitative_engine
6
+ from core.quantitative_engine import get_quantitative_engine
7
+ from core.production_guard import build_validated_package, replace_abstract
8
+ import streamlit as st
9
+
10
+ st.set_page_config(
11
+ page_title="Article Agent",
12
+ page_icon="📊",
13
+ layout="wide",
14
+ initial_sidebar_state="collapsed",
15
+ )
16
+
17
+ import warnings
18
+ warnings.filterwarnings("ignore", message="coroutine 'expire_cache' was never awaited")
19
+
20
+ import os
21
+ import streamlit.components.v1 as components
22
+ import time
23
+ import re
24
+ import math
25
+ import json
26
+ import secrets
27
+ from datetime import datetime, timezone, timedelta
28
+ from concurrent.futures import ThreadPoolExecutor
29
+
30
+ from config import APP_CONFIG, AMJ_PROMPTS, ASQ_PROMPTS
31
+ from utils.file_parser import extract_text_from_files
32
+ from core.pipeline_agent import SSciPipelineAgent
33
+ from managed_auth import (
34
+ require_alipay_login,
35
+ render_logged_in_account,
36
+ start_generation_task,
37
+ heartbeat_generation_task,
38
+ finish_generation_task,
39
+ render_payment_purchase,
40
+ )
41
+ from i18n import LANGUAGE_OPTIONS, get_amj_csv_template, get_asq_txt_template, get_output_suffix, get_target_flag, get_target_language, tr
42
+
43
+ os.environ["STREAMLIT_SERVER_ENABLE_CORS"] = "false"
44
+ os.environ["STREAMLIT_SERVER_ENABLE_XSRF_PROTECTION"] = "false"
45
+
46
+ WORKSPACE_DIR = APP_CONFIG.get("workspace_dir", ".workspace")
47
+ os.makedirs(WORKSPACE_DIR, exist_ok=True)
48
+ LEGACY_CHECKPOINT_FILE = os.path.join(WORKSPACE_DIR, "snapshot.json")
49
+ CHECKPOINT_FILE = LEGACY_CHECKPOINT_FILE
50
+
51
+ META_SECTIONS_BLACKLIST = set(APP_CONFIG["blacklists"]["meta_sections"])
52
+
53
+
54
+ def update_heartbeat():
55
+ """Refresh the authoritative Cloudflare/D1 task lease only."""
56
+ now = time.time()
57
+ task = st.session_state.get("sp_generation_task") or {}
58
+ task_id = str(task.get("task_id") or "").strip()
59
+ interval = max(3, int(APP_CONFIG.get("task_heartbeat_interval_seconds", 5)))
60
+ last_remote = float(st.session_state.get("sp_last_task_heartbeat") or 0.0)
61
+ if not task_id or now - last_remote < interval:
62
+ return
63
+ try:
64
+ heartbeat_generation_task(
65
+ task_id,
66
+ current_node=str(st.session_state.get("sp_current_node") or "")[:240],
67
+ )
68
+ st.session_state["sp_last_task_heartbeat"] = now
69
+ except Exception:
70
+ # The hidden browser bridge continues heartbeats independently. A single
71
+ # transient UI heartbeat must not interrupt model generation.
72
+ pass
73
+
74
+
75
+ def is_system_busy():
76
+ """D1 is the sole concurrency authority; no duplicate Hugging Face file lock."""
77
+ return False
78
+
79
+
80
+ def clear_heartbeat():
81
+ """Backward-compatible no-op after removal of the duplicate local lock."""
82
+ return None
83
+
84
+
85
+ def stream_with_heartbeat(generator):
86
+ """Refresh the D1 lease while streaming model output."""
87
+ last_beat = time.time()
88
+ for chunk in generator:
89
+ now = time.time()
90
+ if now - last_beat > 5:
91
+ update_heartbeat()
92
+ last_beat = now
93
+ yield chunk
94
+
95
+
96
+ def clean_trailing_references(text, keywords=("reference", "references", "reference list", "参考文献")):
97
+ lines = text.split('\n')
98
+ for i in range(len(lines)-1, max(-1, len(lines)-100), -1):
99
+ if any(re.match(rf'^##?\s*{kw}', lines[i], re.IGNORECASE) for kw in keywords):
100
+ return '\n'.join(lines[:i]).strip()
101
+ return text.strip()
102
+
103
+ def clean_markdown_headings(text):
104
+ return re.sub(r'(?m)^#+\s+(.*)$', r'**\1**', text)
105
+
106
+ def _read_runtime_secret(name):
107
+ # Read a Hugging Face/Streamlit secret and remove copied whitespace.
108
+ value = os.environ.get(name, "")
109
+ if not value:
110
+ try:
111
+ value = st.secrets.get(name, "")
112
+ except Exception:
113
+ value = ""
114
+ return str(value or "").strip()
115
+
116
+
117
+ global_ds_key = _read_runtime_secret("DEEPSEEK_API_KEY")
118
+ global_s2_key = _read_runtime_secret("S2_API_KEY")
119
+
120
+ WECHAT_BLOCKER_JS = """<script>try{var ua=navigator.userAgent.toLowerCase();if(ua.indexOf('micromessenger')!==-1){var parentDoc=window.parent.document;if(!parentDoc.getElementById('wx-blocker')){var overlay=parentDoc.createElement('div');overlay.id='wx-blocker';overlay.style.cssText='position:fixed;top:0;left:0;width:100vw;height:100vh;background:#1a365d;z-index:9999999;color:white;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;font-family:sans-serif;padding:30px;box-sizing:border-box;';overlay.innerHTML='<h2 style="font-size:26px;margin-bottom:15px;font-weight:bold;">环境受限 / Restricted</h2><p style="font-size:18px;line-height:1.6;color:#e2e8f0;max-width:100%;">为了保障最终双语 ZIP 压缩包正常下载,<b>本系统禁止在微信内直接运行</b>。<br><br>👉 <b>请点击右上角 [···] 菜单,选择“在浏览器打开” (Open in Browser)</b></p>';parentDoc.body.appendChild(overlay);}}}catch(e){}</script>"""
121
+ components.html(WECHAT_BLOCKER_JS, height=0, width=0)
122
+
123
+
124
+ def inject_file_uploader_i18n(language: str) -> None:
125
+ """Localize Streamlit uploaders without modifying their React structure."""
126
+ payload = {
127
+ "browse": tr(language, "browse_files"),
128
+ "uploaders": [
129
+ {
130
+ "title": tr(language, "drop_docs"),
131
+ "limit": tr(language, "limit_docs"),
132
+ },
133
+ {
134
+ "title": tr(language, "drop_data"),
135
+ "limit": tr(language, "limit_data"),
136
+ },
137
+ {
138
+ "title": tr(language, "drop_docs"),
139
+ "limit": tr(language, "limit_docs"),
140
+ },
141
+ {
142
+ "title": tr(language, "drop_interviews"),
143
+ "limit": tr(language, "limit_interviews"),
144
+ },
145
+ ],
146
+ }
147
+
148
+ script = f"""
149
+ <script>
150
+ (() => {{
151
+ const payload = {json.dumps(payload, ensure_ascii=False)};
152
+ const parentDocument = window.parent.document;
153
+
154
+ function leafTextElements(root) {{
155
+ return Array.from(
156
+ root.querySelectorAll("p, span, small, div")
157
+ ).filter((element) => (
158
+ element.children.length === 0 &&
159
+ element.textContent.trim().length > 0
160
+ ));
161
+ }}
162
+
163
+ function replaceTextSafely(element, value) {{
164
+ if (element && element.textContent.trim() !== value) {{
165
+ element.textContent = value;
166
+ }}
167
+ }}
168
+
169
+ function applyUploaderTranslations() {{
170
+ const zones = parentDocument.querySelectorAll(
171
+ '[data-testid="stFileUploaderDropzone"]'
172
+ );
173
+
174
+ zones.forEach((zone, index) => {{
175
+ const config = payload.uploaders[index];
176
+ if (!config) return;
177
+
178
+ const instructions = zone.querySelector(
179
+ '[data-testid="stFileUploaderDropzoneInstructions"]'
180
+ );
181
+
182
+ if (instructions) {{
183
+ const leaves = leafTextElements(instructions);
184
+ replaceTextSafely(leaves[0], config.title);
185
+ replaceTextSafely(leaves[1], config.limit);
186
+ }}
187
+
188
+ const button = zone.querySelector("button");
189
+ if (button) {{
190
+ const buttonLeaves = leafTextElements(button);
191
+ const textTarget = buttonLeaves[buttonLeaves.length - 1];
192
+
193
+ replaceTextSafely(textTarget, payload.browse);
194
+ button.setAttribute("aria-label", payload.browse);
195
+ button.setAttribute("title", payload.browse);
196
+ }}
197
+ }});
198
+ }}
199
+
200
+ [0, 100, 300, 700, 1400, 2500].forEach((delay) => {{
201
+ window.setTimeout(applyUploaderTranslations, delay);
202
+ }});
203
+ }})();
204
+ </script>
205
+ """
206
+
207
+ components.html(script, height=0, width=0)
208
+
209
+
210
+
211
+ def inject_wechat_browser_notice() -> None:
212
+ """Warn immediately when SmartPaper is opened inside WeChat or WeCom."""
213
+ script = """
214
+ <script>
215
+ (() => {
216
+ const parentWindow = window.parent;
217
+ const parentDocument = parentWindow.document;
218
+ const userAgent = String(
219
+ parentWindow.navigator.userAgent || navigator.userAgent || ""
220
+ );
221
+
222
+ const isWechat =
223
+ /MicroMessenger|WeChat|wxwork/i.test(userAgent);
224
+
225
+ const noticeId = "smartpaper-wechat-browser-notice";
226
+ const existingNotice = parentDocument.getElementById(noticeId);
227
+
228
+ if (!isWechat) {
229
+ if (existingNotice) existingNotice.remove();
230
+ return;
231
+ }
232
+
233
+ if (existingNotice) return;
234
+
235
+ const overlay = parentDocument.createElement("div");
236
+ overlay.id = noticeId;
237
+ overlay.style.cssText = [
238
+ "position:fixed",
239
+ "inset:0",
240
+ "z-index:2147483647",
241
+ "display:flex",
242
+ "align-items:center",
243
+ "justify-content:center",
244
+ "padding:24px",
245
+ "background:rgba(8,12,20,.96)",
246
+ "box-sizing:border-box"
247
+ ].join(";");
248
+
249
+ const card = parentDocument.createElement("div");
250
+ card.style.cssText = [
251
+ "width:min(620px,100%)",
252
+ "padding:30px",
253
+ "border:1px solid rgba(148,163,184,.35)",
254
+ "border-radius:18px",
255
+ "background:#111827",
256
+ "color:#f8fafc",
257
+ "font-family:Arial,'Microsoft YaHei','PingFang SC',sans-serif",
258
+ "line-height:1.7",
259
+ "box-shadow:0 20px 50px rgba(0,0,0,.38)"
260
+ ].join(";");
261
+
262
+ const title = parentDocument.createElement("h2");
263
+ title.textContent = "请在浏览器中打开 / Open in Browser";
264
+ title.style.cssText =
265
+ "margin:0 0 14px;font-size:24px;color:#ffffff;";
266
+
267
+ const chinese = parentDocument.createElement("p");
268
+ chinese.textContent =
269
+ "微信内置页面可能无法正常完成登录和文件操作。请点击右上角“…”并选择“在浏览器打开”。";
270
+ chinese.style.cssText = "margin:0 0 12px;font-size:16px;";
271
+
272
+ const english = parentDocument.createElement("p");
273
+ english.textContent =
274
+ "The WeChat in-app page may not complete sign-in or file operations correctly. Tap “…” in the top-right corner and choose “Open in Browser.”";
275
+ english.style.cssText =
276
+ "margin:0 0 20px;font-size:15px;color:#cbd5e1;";
277
+
278
+ const copyButton = parentDocument.createElement("button");
279
+ copyButton.type = "button";
280
+ copyButton.textContent = "复制网址 / Copy link";
281
+ copyButton.style.cssText = [
282
+ "width:100%",
283
+ "padding:12px 16px",
284
+ "border:0",
285
+ "border-radius:10px",
286
+ "background:#1677ff",
287
+ "color:#ffffff",
288
+ "font-size:16px",
289
+ "font-weight:700",
290
+ "cursor:pointer"
291
+ ].join(";");
292
+
293
+ copyButton.addEventListener("click", async () => {
294
+ const url = parentWindow.location.href;
295
+
296
+ try {
297
+ await parentWindow.navigator.clipboard.writeText(url);
298
+ copyButton.textContent = "已复制 / Copied";
299
+ } catch (_) {
300
+ const textarea = parentDocument.createElement("textarea");
301
+ textarea.value = url;
302
+ textarea.style.position = "fixed";
303
+ textarea.style.opacity = "0";
304
+ parentDocument.body.appendChild(textarea);
305
+ textarea.focus();
306
+ textarea.select();
307
+ parentDocument.execCommand("copy");
308
+ textarea.remove();
309
+ copyButton.textContent = "已复制 / Copied";
310
+ }
311
+
312
+ parentWindow.setTimeout(() => {
313
+ copyButton.textContent = "复制网址 / Copy link";
314
+ }, 1800);
315
+ });
316
+
317
+ card.appendChild(title);
318
+ card.appendChild(chinese);
319
+ card.appendChild(english);
320
+ card.appendChild(copyButton);
321
+ overlay.appendChild(card);
322
+ parentDocument.body.appendChild(overlay);
323
+ })();
324
+ </script>
325
+ """
326
+
327
+ components.html(script, height=0, width=0)
328
+
329
+
330
+ if "is_running" not in st.session_state: st.session_state.is_running = False
331
+ if "start_pipeline" not in st.session_state: st.session_state.start_pipeline = False
332
+ if "resume_pipeline" not in st.session_state: st.session_state.resume_pipeline = False
333
+ if "zip_buffer" not in st.session_state: st.session_state.zip_buffer = None
334
+ if "run_mode" not in st.session_state: st.session_state.run_mode = "amj"
335
+ if "sp_generation_task" not in st.session_state: st.session_state.sp_generation_task = None
336
+ if "sp_generation_idempotency_key" not in st.session_state: st.session_state.sp_generation_idempotency_key = ""
337
+ if "sp_last_task_heartbeat" not in st.session_state: st.session_state.sp_last_task_heartbeat = 0.0
338
+ if "sp_current_node" not in st.session_state: st.session_state.sp_current_node = ""
339
+ if "sp_pending_task_finalization" not in st.session_state: st.session_state.sp_pending_task_finalization = None
340
+ if "sp_pending_package" not in st.session_state: st.session_state.sp_pending_package = None
341
+ if "sp_terminal_notice" not in st.session_state: st.session_state.sp_terminal_notice = None
342
+
343
+
344
+ def _safe_user_workspace_key(value):
345
+ cleaned = re.sub(r"[^A-Za-z0-9_.-]", "_", str(value or "").strip())
346
+ return cleaned[:80] or "anonymous"
347
+
348
+
349
+ def _load_recoverable_checkpoint():
350
+ """Return a validated checkpoint for the current authenticated user only."""
351
+ if not os.path.isfile(CHECKPOINT_FILE):
352
+ return None
353
+ try:
354
+ with open(CHECKPOINT_FILE, "r", encoding="utf-8") as handle:
355
+ state = json.load(handle)
356
+ except Exception:
357
+ return None
358
+ required = {
359
+ "topic", "keywords", "target_lang", "words",
360
+ "agent_state", "adaptive_structure", "completed_sections",
361
+ }
362
+ if not isinstance(state, dict) or not required.issubset(state):
363
+ return None
364
+ if not isinstance(state.get("adaptive_structure"), list):
365
+ return None
366
+ if not isinstance(state.get("completed_sections"), list):
367
+ return None
368
+ return state
369
+
370
+
371
+ def _new_generation_idempotency_key() -> str:
372
+ return f"gen_{int(time.time() * 1000)}_{secrets.token_urlsafe(18)}"
373
+
374
+
375
+ def _prepare_new_generation_request() -> None:
376
+ st.session_state["sp_generation_idempotency_key"] = _new_generation_idempotency_key()
377
+ st.session_state["sp_generation_task"] = None
378
+ st.session_state["sp_last_task_heartbeat"] = 0.0
379
+ st.session_state["sp_current_node"] = "task_start"
380
+ st.session_state["zip_buffer"] = None
381
+ st.session_state["zip_filename"] = ""
382
+ st.session_state["sp_pending_task_finalization"] = None
383
+ st.session_state["sp_pending_package"] = None
384
+ st.session_state["sp_terminal_notice"] = None
385
+
386
+
387
+ def _begin_managed_generation_task(*, is_resume: bool, loaded_state: dict | None, mode: str, language: str):
388
+ checkpoint_key = ""
389
+ if is_resume and isinstance(loaded_state, dict):
390
+ checkpoint_key = str(loaded_state.get("managed_task_idempotency_key") or "").strip()
391
+
392
+ idempotency_key = checkpoint_key or str(
393
+ st.session_state.get("sp_generation_idempotency_key") or ""
394
+ ).strip()
395
+ if not idempotency_key:
396
+ idempotency_key = _new_generation_idempotency_key()
397
+
398
+ response = start_generation_task(
399
+ idempotency_key=idempotency_key,
400
+ mode=mode,
401
+ interface_language=language,
402
+ )
403
+ task = dict(response.get("task") or {})
404
+ if not task.get("task_id"):
405
+ raise RuntimeError("task_start_missing_task_id")
406
+
407
+ st.session_state["sp_generation_idempotency_key"] = idempotency_key
408
+ st.session_state["sp_generation_task"] = task
409
+ st.session_state["sp_last_task_heartbeat"] = time.time()
410
+ return task, idempotency_key
411
+
412
+
413
+ def _task_output_dir(task_id: str) -> str:
414
+ root = globals().get("_user_workspace") or os.path.join(WORKSPACE_DIR, "users", "anonymous")
415
+ path = os.path.join(root, "tasks", _safe_user_workspace_key(task_id), "outputs")
416
+ os.makedirs(path, exist_ok=True)
417
+ return path
418
+
419
+
420
+ def _checkpoint_state_for_update() -> dict:
421
+ if not os.path.isfile(CHECKPOINT_FILE):
422
+ return {}
423
+ try:
424
+ with open(CHECKPOINT_FILE, "r", encoding="utf-8") as handle:
425
+ value = json.load(handle)
426
+ return value if isinstance(value, dict) else {}
427
+ except Exception:
428
+ return {}
429
+
430
+
431
+ def _write_checkpoint_state(value: dict) -> None:
432
+ if not isinstance(value, dict) or not value:
433
+ return
434
+ os.makedirs(os.path.dirname(CHECKPOINT_FILE), exist_ok=True)
435
+ temporary = CHECKPOINT_FILE + ".tmp"
436
+ with open(temporary, "w", encoding="utf-8") as handle:
437
+ json.dump(value, handle, ensure_ascii=False)
438
+ os.replace(temporary, CHECKPOINT_FILE)
439
+
440
+
441
+ def _persist_pending_finalization() -> None:
442
+ pending = st.session_state.get("sp_pending_task_finalization")
443
+ if not isinstance(pending, dict):
444
+ return
445
+ state = _checkpoint_state_for_update()
446
+ if not state:
447
+ return
448
+ state["pending_task_finalization"] = pending
449
+ package = st.session_state.get("sp_pending_package")
450
+ if isinstance(package, dict):
451
+ state["pending_package"] = package
452
+ _write_checkpoint_state(state)
453
+
454
+
455
+ def _restore_pending_finalization() -> None:
456
+ if isinstance(st.session_state.get("sp_pending_task_finalization"), dict):
457
+ return
458
+ state = _checkpoint_state_for_update()
459
+ pending = state.get("pending_task_finalization") if isinstance(state, dict) else None
460
+ if not isinstance(pending, dict):
461
+ return
462
+ st.session_state["sp_pending_task_finalization"] = pending
463
+ package = state.get("pending_package")
464
+ if isinstance(package, dict):
465
+ st.session_state["sp_pending_package"] = package
466
+ task_id = str(pending.get("task_id") or "").strip()
467
+ if task_id and not st.session_state.get("sp_generation_task"):
468
+ st.session_state["sp_generation_task"] = {"task_id": task_id}
469
+
470
+
471
+ def _remove_pending_checkpoint_metadata(*, delete_checkpoint=False, detach_task=False) -> None:
472
+ if delete_checkpoint:
473
+ try:
474
+ os.remove(CHECKPOINT_FILE)
475
+ except OSError:
476
+ pass
477
+ return
478
+ state = _checkpoint_state_for_update()
479
+ if not state:
480
+ return
481
+ state.pop("pending_task_finalization", None)
482
+ state.pop("pending_package", None)
483
+ if detach_task:
484
+ state["managed_task_idempotency_key"] = ""
485
+ state["managed_task_id"] = ""
486
+ _write_checkpoint_state(state)
487
+
488
+
489
+ def _prune_user_output_packages(max_files: int = 5) -> None:
490
+ root = globals().get("_user_workspace")
491
+ if not root or not os.path.isdir(root):
492
+ return
493
+ packages = []
494
+ for current_root, _, names in os.walk(os.path.join(root, "tasks")):
495
+ for name in names:
496
+ if name.endswith(".zip") and not name.startswith(".pending_"):
497
+ path = os.path.join(current_root, name)
498
+ try:
499
+ packages.append((os.path.getmtime(path), path))
500
+ except OSError:
501
+ pass
502
+ packages.sort()
503
+ while len(packages) > max_files:
504
+ _, old_path = packages.pop(0)
505
+ try:
506
+ os.remove(old_path)
507
+ except OSError:
508
+ pass
509
+
510
+
511
+ def _process_pending_task_finalization():
512
+ pending = st.session_state.get("sp_pending_task_finalization")
513
+ if not isinstance(pending, dict):
514
+ return None
515
+ task_id = str(pending.get("task_id") or "").strip()
516
+ status = str(pending.get("status") or "").strip().lower()
517
+ if not task_id or status not in {"completed", "failed", "cancelled", "resumable"}:
518
+ raise RuntimeError("invalid_pending_task_finalization")
519
+
520
+ package_bytes = None
521
+ package = st.session_state.get("sp_pending_package")
522
+ if status == "completed":
523
+ if not isinstance(package, dict):
524
+ raise RuntimeError("validated_package_metadata_missing")
525
+ pending_path = str(package.get("pending_path") or "").strip()
526
+ if not pending_path or not os.path.isfile(pending_path):
527
+ raise RuntimeError("validated_package_file_missing")
528
+ with open(pending_path, "rb") as handle:
529
+ package_bytes = handle.read()
530
+ if not package_bytes:
531
+ raise RuntimeError("validated_package_file_empty")
532
+
533
+ response = finish_generation_task(
534
+ task_id,
535
+ status=status,
536
+ safe_error_code=str(pending.get("safe_error_code") or "")[:80],
537
+ safe_error_detail=str(pending.get("safe_error_detail") or "")[:800],
538
+ current_node=str(pending.get("current_node") or "")[:240],
539
+ )
540
+
541
+ if status == "completed":
542
+ filename = str(package.get("filename") or "SmartPaper.zip")
543
+ pending_path = str(package.get("pending_path") or "")
544
+ final_path = os.path.join(_task_output_dir(task_id), os.path.basename(filename))
545
+ try:
546
+ os.replace(pending_path, final_path)
547
+ except OSError:
548
+ # The bytes were read and validated before quota commit, so the user
549
+ # can still download even if the local archival rename fails.
550
+ final_path = pending_path
551
+ st.session_state["zip_buffer"] = package_bytes
552
+ st.session_state["zip_filename"] = os.path.basename(filename)
553
+ st.session_state["sp_terminal_notice"] = {"kind": "success"}
554
+ _prune_user_output_packages()
555
+ elif status in {"failed", "cancelled"}:
556
+ st.session_state["sp_terminal_notice"] = {
557
+ "kind": "error" if status == "failed" else "warning",
558
+ "message": str(pending.get("safe_error_detail") or "")[:800],
559
+ }
560
+
561
+ _remove_pending_checkpoint_metadata(
562
+ delete_checkpoint=bool(pending.get("delete_checkpoint")),
563
+ detach_task=bool(pending.get("detach_checkpoint")),
564
+ )
565
+ if status == "resumable":
566
+ st.session_state["sp_generation_task"] = dict(response.get("task") or {"task_id": task_id})
567
+ else:
568
+ st.session_state["sp_generation_task"] = None
569
+ st.session_state["sp_generation_idempotency_key"] = ""
570
+ st.session_state["sp_last_task_heartbeat"] = 0.0
571
+ st.session_state["sp_pending_task_finalization"] = None
572
+ st.session_state["sp_pending_package"] = None
573
+ return response
574
+
575
+
576
+ def _finish_managed_generation_task(
577
+ status: str,
578
+ *,
579
+ safe_error_code: str = "",
580
+ safe_error_detail: str = "",
581
+ current_node: str = "",
582
+ delete_checkpoint: bool = False,
583
+ detach_checkpoint: bool = False,
584
+ ):
585
+ task = st.session_state.get("sp_generation_task") or {}
586
+ task_id = str(task.get("task_id") or "").strip()
587
+ if not task_id:
588
+ return None
589
+ pending = {
590
+ "task_id": task_id,
591
+ "status": str(status or "").strip().lower(),
592
+ "safe_error_code": str(safe_error_code or "")[:80],
593
+ "safe_error_detail": str(safe_error_detail or "")[:800],
594
+ "current_node": str(current_node or "")[:240],
595
+ "delete_checkpoint": bool(delete_checkpoint),
596
+ "detach_checkpoint": bool(detach_checkpoint),
597
+ }
598
+ st.session_state["sp_pending_task_finalization"] = pending
599
+ _persist_pending_finalization()
600
+ return _process_pending_task_finalization()
601
+
602
+
603
+ def _quota_start_error_message(language: str, error: Exception) -> str:
604
+ raw = str(error or "")
605
+ return tr(language, "quota_exhausted") if "payment_required" in raw else raw
606
+
607
+
608
+ inject_wechat_browser_notice()
609
+
610
+ DEFAULT_TOPIC = "Generative Artificial Intelligence in Ubiquitous Learning: Evaluating a Chatbot-based Recommendation Engine for Personalized and Context-aware Education"
611
+ DEFAULT_KEYWORDS = "Ubiquitous computing; Context awareness; Deep learning; Personalized educational; Educational technologies; HCI; Recommendation system; LLMs; Generative AI; ChatGPT; Prompting engineering; Few-shot prompting"
612
+
613
+ _fragment = getattr(st, "fragment", None) or getattr(st, "experimental_fragment", None)
614
+ if _fragment is None:
615
+ def _fragment(function):
616
+ return function
617
+
618
+
619
+ def _current_language_code() -> str:
620
+ choice = st.session_state.get("interface_language_choice")
621
+ return LANGUAGE_OPTIONS.get(choice, "zh")
622
+
623
+
624
+ # Public access uses Alipay only. Password, legacy, and pilot login paths are not exposed.
625
+ authenticated_user = require_alipay_login(_current_language_code())
626
+
627
+ # Checkpoints are user-scoped. The former global snapshot.json could cause one
628
+ # user or an old run to trigger a false recovery notice for another session.
629
+ _user_workspace = os.path.join(
630
+ WORKSPACE_DIR,
631
+ "users",
632
+ _safe_user_workspace_key(authenticated_user.get("id")),
633
+ )
634
+ os.makedirs(_user_workspace, exist_ok=True)
635
+ CHECKPOINT_FILE = os.path.join(_user_workspace, "snapshot.json")
636
+ _restore_pending_finalization()
637
+ _process_pending_task_finalization()
638
+
639
+
640
+ @_fragment
641
+ def render_workspace_controls(user):
642
+ """Render controls in an isolated rerun scope for fast language switching."""
643
+ top_toolbar = st.container()
644
+
645
+ with top_toolbar:
646
+ language_col, account_col = st.columns([5.4, 4.6])
647
+
648
+ with language_col:
649
+ lang_choice = st.radio(
650
+ "Language",
651
+ list(LANGUAGE_OPTIONS.keys()),
652
+ index=0,
653
+ horizontal=True,
654
+ label_visibility="collapsed",
655
+ key="interface_language_choice",
656
+ )
657
+
658
+ lang_code = LANGUAGE_OPTIONS[lang_choice]
659
+ st.session_state["sp_lang_code"] = lang_code
660
+ st.session_state["sp_target_lang"] = get_target_language(lang_code)
661
+ st.session_state["sp_output_suffix"] = get_output_suffix(lang_code)
662
+ st.session_state["sp_target_flag"] = get_target_flag(lang_code)
663
+
664
+ with account_col:
665
+ render_logged_in_account(lang_code, user)
666
+
667
+ # Cross-device global busy check remains unchanged in function, but all visible text is localized.
668
+ is_my_session = st.session_state.get("is_running", False) or \
669
+ st.session_state.get("start_pipeline", False) or \
670
+ st.session_state.get("resume_pipeline", False)
671
+ ui_blocked = (not is_my_session and is_system_busy())
672
+ st.session_state["sp_ui_blocked"] = ui_blocked
673
+ if ui_blocked:
674
+ st.markdown(
675
+ f"<h3 style='text-align:center;color:#e6a23c;'>{tr(lang_code, 'system_busy_title')}</h3>",
676
+ unsafe_allow_html=True,
677
+ )
678
+ st.warning(tr(lang_code, "system_busy_warning"))
679
+ st.info(tr(lang_code, "system_busy_info"))
680
+ return
681
+
682
+ st.markdown(f"<div class='main-title'><h2>{tr(lang_code, 'title')}</h2></div>", unsafe_allow_html=True)
683
+
684
+ 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):
685
+ st.warning(tr(lang_code, "checkpoint_warning"))
686
+ colA, colB = st.columns([1, 1])
687
+ if colA.button(tr(lang_code, "resume"), key="resume_task"):
688
+ if is_system_busy():
689
+ st.warning(tr(lang_code, "busy_short"))
690
+ else:
691
+ checkpoint_state = _load_recoverable_checkpoint() or {}
692
+ checkpoint_key = str(checkpoint_state.get("managed_task_idempotency_key") or "").strip()
693
+ st.session_state["sp_generation_idempotency_key"] = checkpoint_key or _new_generation_idempotency_key()
694
+ st.session_state["sp_generation_task"] = None
695
+ st.session_state["sp_last_task_heartbeat"] = 0.0
696
+ update_heartbeat()
697
+ st.session_state.resume_pipeline = True
698
+ st.session_state.start_pipeline = False
699
+ st.rerun()
700
+
701
+ if colB.button(tr(lang_code, "clear"), key="clear_task"):
702
+ if is_system_busy():
703
+ st.warning(tr(lang_code, "busy_short"))
704
+ else:
705
+ checkpoint_state = _load_recoverable_checkpoint() or {}
706
+ checkpoint_task_id = str(checkpoint_state.get("managed_task_id") or "").strip()
707
+ if checkpoint_task_id:
708
+ st.session_state["sp_generation_task"] = {"task_id": checkpoint_task_id}
709
+ try:
710
+ _finish_managed_generation_task(
711
+ "cancelled",
712
+ safe_error_code="E_USER_CLEARED_CHECKPOINT",
713
+ safe_error_detail="User cleared the recoverable checkpoint.",
714
+ current_node="checkpoint_clear",
715
+ delete_checkpoint=True,
716
+ )
717
+ except Exception as exc:
718
+ st.error(str(exc))
719
+ return
720
+ else:
721
+ try:
722
+ os.remove(CHECKPOINT_FILE)
723
+ except OSError:
724
+ pass
725
+ clear_heartbeat()
726
+ st.rerun()
727
+ st.markdown("---")
728
+
729
+ st.text_input(
730
+ tr(lang_code, "topic_label"),
731
+ value=DEFAULT_TOPIC,
732
+ disabled=st.session_state.is_running,
733
+ key="sp_topic",
734
+ )
735
+ st.text_input(
736
+ tr(lang_code, "keywords_label"),
737
+ value=DEFAULT_KEYWORDS,
738
+ disabled=st.session_state.is_running,
739
+ key="sp_keywords",
740
+ )
741
+ st.number_input(
742
+ tr(lang_code, "words_label"),
743
+ min_value=12000,
744
+ max_value=25000,
745
+ value=20000,
746
+ step=1000,
747
+ disabled=st.session_state.is_running,
748
+ key="sp_words",
749
+ )
750
+
751
+ tab_amj, tab_asq = st.tabs([tr(lang_code, "tab_amj"), tr(lang_code, "tab_asq")])
752
+
753
+ with tab_amj:
754
+ st.markdown(tr(lang_code, "amj_config"))
755
+ st.info(tr(lang_code, "amj_info"))
756
+ st.file_uploader(
757
+ tr(lang_code, "upload_reference"),
758
+ type=["pdf", "doc", "docx"],
759
+ accept_multiple_files=True,
760
+ key="up_amj_ref",
761
+ disabled=st.session_state.is_running,
762
+ )
763
+ st.file_uploader(
764
+ tr(lang_code, "upload_stats"),
765
+ type=["csv", "xlsx", "xls"],
766
+ key="up_amj_data",
767
+ disabled=st.session_state.is_running,
768
+ )
769
+
770
+ with st.expander(tr(lang_code, "amj_expander")):
771
+ st.markdown(tr(lang_code, "amj_hint"))
772
+ amj_csv_template = get_amj_csv_template(lang_code)
773
+ st.code(amj_csv_template, language="csv")
774
+ st.download_button(
775
+ label=tr(lang_code, "download_amj_template"),
776
+ data=amj_csv_template,
777
+ file_name="AMJ_Data_Template.csv",
778
+ mime="text/csv",
779
+ use_container_width=True,
780
+ )
781
+
782
+ if st.button(tr(lang_code, "start_amj"), disabled=st.session_state.is_running, key="btn_amj"):
783
+ if is_system_busy():
784
+ st.warning(tr(lang_code, "busy_short"))
785
+ else:
786
+ _prepare_new_generation_request()
787
+ update_heartbeat()
788
+ st.session_state.run_mode = "amj"
789
+ st.session_state.start_pipeline = True
790
+ st.rerun()
791
+
792
+ with tab_asq:
793
+ st.markdown(tr(lang_code, "asq_config"))
794
+ st.info(tr(lang_code, "asq_info"))
795
+ st.file_uploader(
796
+ tr(lang_code, "upload_reference"),
797
+ type=["pdf", "doc", "docx"],
798
+ accept_multiple_files=True,
799
+ key="up_asq_ref",
800
+ disabled=st.session_state.is_running,
801
+ )
802
+ st.file_uploader(
803
+ tr(lang_code, "upload_interviews"),
804
+ type=["txt", "pdf", "doc", "docx"],
805
+ key="up_asq_data",
806
+ disabled=st.session_state.is_running,
807
+ )
808
+
809
+ with st.expander(tr(lang_code, "asq_expander")):
810
+ st.markdown(tr(lang_code, "asq_hint"))
811
+ asq_txt_template = get_asq_txt_template(lang_code)
812
+ st.code(asq_txt_template, language="text")
813
+ st.download_button(
814
+ label=tr(lang_code, "download_asq_template"),
815
+ data=asq_txt_template,
816
+ file_name="ASQ_Interview_Template.txt",
817
+ mime="text/plain",
818
+ use_container_width=True,
819
+ )
820
+
821
+ if st.button(tr(lang_code, "start_asq"), disabled=st.session_state.is_running, key="btn_asq"):
822
+ if is_system_busy():
823
+ st.warning(tr(lang_code, "busy_short"))
824
+ else:
825
+ _prepare_new_generation_request()
826
+ update_heartbeat()
827
+ st.session_state.run_mode = "asq"
828
+ st.session_state.start_pipeline = True
829
+ st.rerun()
830
+
831
+ # Localize Streamlit built-in uploader text after the uploaders are rendered.
832
+ inject_file_uploader_i18n(lang_code)
833
+
834
+
835
+ render_workspace_controls(authenticated_user)
836
+
837
+ lang_code = str(st.session_state.get("sp_lang_code") or _current_language_code())
838
+ target_lang = str(st.session_state.get("sp_target_lang") or get_target_language(lang_code))
839
+ output_suffix = str(st.session_state.get("sp_output_suffix") or get_output_suffix(lang_code))
840
+ target_flag = str(st.session_state.get("sp_target_flag") or get_target_flag(lang_code))
841
+
842
+ topic = str(st.session_state.get("sp_topic") or DEFAULT_TOPIC)
843
+ keywords = str(st.session_state.get("sp_keywords") or DEFAULT_KEYWORDS)
844
+ words = int(st.session_state.get("sp_words") or 20000)
845
+ uploaded_papers_amj = st.session_state.get("up_amj_ref") or []
846
+ uploaded_data_amj = st.session_state.get("up_amj_data")
847
+ uploaded_papers_asq = st.session_state.get("up_asq_ref") or []
848
+ uploaded_interviews = st.session_state.get("up_asq_data")
849
+
850
+ if st.session_state.get("sp_ui_blocked"):
851
+ st.stop()
852
+
853
+ if st.session_state.get("sp_payment_required") and not (st.session_state.start_pipeline or st.session_state.resume_pipeline):
854
+ render_payment_purchase(lang_code)
855
+
856
+ _terminal_notice = st.session_state.pop("sp_terminal_notice", None)
857
+ if isinstance(_terminal_notice, dict) and _terminal_notice.get("kind") in {"error", "warning"}:
858
+ _message = str(_terminal_notice.get("message") or "").strip()
859
+ if _message:
860
+ (st.error if _terminal_notice.get("kind") == "error" else st.warning)(_message)
861
+
862
+ progress_bar = st.empty()
863
+ monitor_ui = st.empty()
864
+
865
+ def render_monitor(agent, phase, current_sec="", details=""):
866
+ st.session_state["sp_current_node"] = str(current_sec or phase or "")[:240]
867
+ monitor_ui.markdown(f"""
868
+ <div class='log-box'>
869
+ <div><b>{tr(lang_code, 'monitor_pipeline')}:</b> {phase}</div><div><b>{tr(lang_code, 'monitor_node')}:</b> {current_sec}</div><div><b>{tr(lang_code, 'monitor_log')}:</b> {details}</div><hr>
870
+ <div><b>{tr(lang_code, 'monitor_compute')}:</b></div>
871
+ <div style='display: flex; gap: 8px;'>
872
+ <span style='background: #fef3c7; padding: 4px 8px; border-radius: 4px; color: black;'>🧠 DeepSeek: <b>{agent.ds_calls}</b></span>
873
+ <span style='background: #dbeafe; padding: 4px 8px; border-radius: 4px; color: black;'>📚 S2: <b>{agent.s2_calls}</b></span>
874
+ <span style='background: #d1fae5; padding: 4px 8px; border-radius: 4px; color: black;'>🌐 OpenAlex: <b>{agent.oa_calls}</b></span>
875
+ </div>
876
+ </div>
877
+ """, unsafe_allow_html=True)
878
+
879
+ FLAG_MAP = {"Simplified Chinese (简体中文)": "🇨🇳", "Russian (Русский)": "🇷🇺", "Kazakh (Қазақша)": "🇰🇿"}
880
+
881
+ if st.session_state.start_pipeline or st.session_state.resume_pipeline:
882
+ if not global_ds_key:
883
+ st.error(tr(lang_code, "api_key_missing"))
884
+ st.stop()
885
+
886
+ if st.session_state.get("is_running", False):
887
+ recoverable_state = _load_recoverable_checkpoint()
888
+ st.session_state.is_running = False
889
+ st.session_state.start_pipeline = False
890
+ st.session_state.resume_pipeline = bool(recoverable_state)
891
+ if recoverable_state:
892
+ st.toast(tr(lang_code, "resume_toast"), icon="⚡")
893
+ update_heartbeat()
894
+ st.rerun()
895
+ # A normal page rerun without a valid checkpoint is not a recovery event.
896
+ # Keep the already-rendered writing interface and show no recovery toast.
897
+ clear_heartbeat()
898
+ st.stop()
899
+
900
+ is_resume = st.session_state.resume_pipeline
901
+ loaded_state = _load_recoverable_checkpoint() if is_resume else None
902
+ if is_resume and loaded_state is None:
903
+ is_resume = False
904
+
905
+ try:
906
+ managed_task, managed_task_key = _begin_managed_generation_task(
907
+ is_resume=is_resume,
908
+ loaded_state=loaded_state,
909
+ mode=st.session_state.run_mode,
910
+ language=lang_code,
911
+ )
912
+ except Exception as exc:
913
+ clear_heartbeat()
914
+ st.session_state.is_running = False
915
+ st.session_state.start_pipeline = False
916
+ st.session_state.resume_pipeline = False
917
+ st.error(_quota_start_error_message(lang_code, exc))
918
+ if "payment_required" in str(exc):
919
+ st.session_state["sp_payment_required"] = True
920
+ render_payment_purchase(lang_code)
921
+ st.stop()
922
+
923
+ st.session_state.is_running = True
924
+ agent = SSciPipelineAgent(global_ds_key, global_s2_key)
925
+ SAFE_CHUNK_WORDS = agent.chunk_words_default
926
+ qual_engine = get_qualitative_engine(agent)
927
+ quant_engine = get_quantitative_engine()
928
+ completion_finalization_attempted = False
929
+
930
+ try:
931
+ update_heartbeat()
932
+
933
+ if is_resume and loaded_state:
934
+ pb = progress_bar.progress(50, text=tr(lang_code, "resume_loading"))
935
+ state = loaded_state
936
+ state["managed_task_idempotency_key"] = managed_task_key
937
+ state["managed_task_id"] = managed_task.get("task_id", "")
938
+ topic = state["topic"]
939
+ keywords = state["keywords"]
940
+ target_lang = state["target_lang"]
941
+ words = state["words"]
942
+ user_data_md = state["user_data_md"]
943
+ data_metadata = state.get("data_metadata", {})
944
+ user_lit_context = state["user_lit_context"]
945
+
946
+ agent.load_state(state["agent_state"])
947
+ adaptive_structure = state["adaptive_structure"]
948
+ completed_sections = state["completed_sections"]
949
+ full_draft_en = state["full_draft_en"]
950
+ full_draft_translated = state["full_draft_translated"]
951
+ synthesis_result = state["synthesis_result"]
952
+ refs_text = state["refs_text"]
953
+ context_tail = state["context_tail"]
954
+
955
+ st.session_state.run_mode = state.get("run_mode", "amj")
956
+ if st.session_state.run_mode == "amj":
957
+ APP_CONFIG.update(AMJ_PROMPTS)
958
+ else:
959
+ APP_CONFIG.update(ASQ_PROMPTS)
960
+
961
+ st.success(tr(lang_code, "resume_success", done=len(completed_sections), total=len(adaptive_structure)))
962
+ render_monitor(agent, tr(lang_code, "resume_phase"), tr(lang_code, "state_check"), tr(lang_code, "history_restored"))
963
+
964
+ else:
965
+ pb = progress_bar.progress(1, text=tr(lang_code, "pipeline_start"))
966
+ user_lit_context = ""
967
+ user_data_md = ""
968
+ data_metadata = {}
969
+
970
+ if st.session_state.run_mode == "amj":
971
+ if uploaded_papers_amj:
972
+ user_lit_context = extract_text_from_files(uploaded_papers_amj[:20])
973
+ with st.status(tr(lang_code, "literature_extract"), expanded=True) as status:
974
+ ph = st.empty()
975
+ all_paper_texts = [extract_text_from_files([f]) for f in uploaded_papers_amj[:20]]
976
+
977
+ future = ThreadPoolExecutor(max_workers=1).submit(extract_golden_metadata, agent, all_paper_texts)
978
+ dot_count = 0
979
+ while not future.done():
980
+ update_heartbeat()
981
+ ph.markdown(tr(lang_code, "literature_deep") + " ⏳" * (dot_count % 4))
982
+ time.sleep(1)
983
+ dot_count += 1
984
+
985
+ agent.global_memory["core_golden_metadata"] = future.result()
986
+ st.session_state.rag_engine = initialize_rag(all_paper_texts)
987
+ status.update(label=tr(lang_code, "literature_done"), state="complete", expanded=False)
988
+
989
+ with st.spinner(tr(lang_code, "extract_quant")):
990
+ update_heartbeat()
991
+ _, user_data_md, msg, data_metadata = quant_engine.resolve_empirical_data(uploaded_data_amj, lang_code)
992
+ st.info(msg)
993
+
994
+ elif st.session_state.run_mode == "asq":
995
+ if uploaded_papers_asq:
996
+ user_lit_context = extract_text_from_files(uploaded_papers_asq[:20])
997
+ with st.status(tr(lang_code, "literature_extract"), expanded=True) as status:
998
+ ph = st.empty()
999
+ all_paper_texts = [extract_text_from_files([f]) for f in uploaded_papers_asq[:20]]
1000
+
1001
+ future = ThreadPoolExecutor(max_workers=1).submit(extract_golden_metadata, agent, all_paper_texts)
1002
+ dot_count = 0
1003
+ while not future.done():
1004
+ update_heartbeat()
1005
+ ph.markdown(tr(lang_code, "literature_deep") + " ⏳" * (dot_count % 4))
1006
+ time.sleep(1)
1007
+ dot_count += 1
1008
+
1009
+ agent.global_memory["core_golden_metadata"] = future.result()
1010
+ st.session_state.rag_engine = initialize_rag(all_paper_texts)
1011
+ status.update(label=tr(lang_code, "literature_done"), state="complete", expanded=False)
1012
+
1013
+ update_heartbeat()
1014
+ raw_interviews = extract_text_from_files([uploaded_interviews]) if uploaded_interviews else ""
1015
+ user_data_md, msg, data_metadata = qual_engine.resolve_qualitative_data(topic, keywords, raw_interviews)
1016
+ st.info(msg or tr(lang_code, "qualitative_ready"))
1017
+
1018
+ state = {
1019
+ "topic": topic, "keywords": keywords, "target_lang": target_lang, "words": words,
1020
+ "user_data_md": user_data_md, "data_metadata": data_metadata, "user_lit_context": user_lit_context,
1021
+ "completed_sections": [], "full_draft_en": "", "full_draft_translated": "",
1022
+ "context_tail": "", "run_mode": st.session_state.run_mode, "interface_language": lang_code,
1023
+ "managed_task_idempotency_key": managed_task_key,
1024
+ "managed_task_id": managed_task.get("task_id", ""),
1025
+ }
1026
+
1027
+ pb.progress(5, text=tr(lang_code, "config_route"))
1028
+
1029
+ if st.session_state.run_mode == "amj":
1030
+ APP_CONFIG.update(AMJ_PROMPTS)
1031
+ state["prompt_config"] = AMJ_PROMPTS
1032
+ else:
1033
+ APP_CONFIG.update(ASQ_PROMPTS)
1034
+ state["prompt_config"] = ASQ_PROMPTS
1035
+
1036
+ pb.progress(10, text=tr(lang_code, "literature_search"))
1037
+ def sync_monitor_ui():
1038
+ update_heartbeat()
1039
+ render_monitor(agent, tr(lang_code, "search_phase"), "S2 + OpenAlex", tr(lang_code, "search_detail"))
1040
+
1041
+ target_refs_count = 50
1042
+ refs_text = agent.fetch_references_parallel(topic, keywords, min_required=target_refs_count, progress_callback=sync_monitor_ui)
1043
+
1044
+ if len(agent.citation_map) < target_refs_count:
1045
+ raise RuntimeError(
1046
+ tr(lang_code, "refs_insufficient", count=len(agent.citation_map), required=target_refs_count)
1047
+ )
1048
+ else:
1049
+ st.success(tr(lang_code, "refs_success", count=len(agent.citation_map)))
1050
+
1051
+ with st.spinner(tr(lang_code, "rag_sync")):
1052
+ update_heartbeat()
1053
+ if hasattr(st.session_state, "rag_engine") and st.session_state.rag_engine is not None:
1054
+ st.session_state.rag_engine._initialize_chunks([refs_text])
1055
+ else:
1056
+ st.session_state.rag_engine = initialize_rag([refs_text])
1057
+ st.success(tr(lang_code, "rag_done"))
1058
+
1059
+ if user_lit_context:
1060
+ pb.progress(11, text=tr(lang_code, "core_literature_parsing"))
1061
+ update_heartbeat()
1062
+ core_refs = agent.parse_user_core_literature(user_lit_context)
1063
+ if core_refs:
1064
+ refs_text = "【USER PROVIDED CORE LITERATURE】\n" + core_refs + "\n\n【RETRIEVED LITERATURE】\n" + refs_text
1065
+ st.success(tr(lang_code, "core_refs_done"))
1066
+
1067
+ state["refs_text"] = refs_text
1068
+
1069
+ pb.progress(12, text=tr(lang_code, "synthesis_progress"))
1070
+ with st.status(tr(lang_code, "synthesis_status"), expanded=True) as status:
1071
+ ph = st.empty()
1072
+ synthesis_result = ph.write_stream(stream_with_heartbeat(agent.stream_synthesize_literature(topic, refs_text, user_core_literature=user_lit_context)))
1073
+ synthesis_result = re.sub(r'<think>.*?</think>', '', synthesis_result, flags=re.DOTALL).strip()
1074
+ status.update(label=tr(lang_code, "synthesis_done"), state="complete", expanded=False)
1075
+ state["synthesis_result"] = synthesis_result
1076
+
1077
+ pb.progress(14, text=tr(lang_code, "anchor_progress"))
1078
+ with st.status(tr(lang_code, "anchor_status"), expanded=True) as status:
1079
+ ph = st.empty()
1080
+ 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)))
1081
+ status.update(label=tr(lang_code, "anchor_done"), state="complete", expanded=False)
1082
+
1083
+ # ==========================================================
1084
+ # [核心修复] 必须先剔除 <think> 标签!防止正则匹配到模型在思考时引用提示词的空占位符
1085
+ # ==========================================================
1086
+ anchor_response_clean = re.sub(r'<think>.*?</think>', '', anchor_response, flags=re.DOTALL).strip()
1087
+
1088
+ abstract_match = re.search(r'-+\s*ABSTRACT\s*-+\s*(.*?)(?=\s*-+\s*CONCLUSION\s*-+|$)', anchor_response_clean, re.DOTALL | re.IGNORECASE)
1089
+ conclusion_match = re.search(r'-+\s*CONCLUSION\s*-+\s*(.*?)$', anchor_response_clean, re.DOTALL | re.IGNORECASE)
1090
+ abstract_anchor = abstract_match.group(1).strip() if abstract_match else anchor_response_clean.strip()
1091
+ conclusion_anchor = conclusion_match.group(1).strip() if conclusion_match else ""
1092
+ agent.global_memory["abstract_anchor"] = abstract_anchor
1093
+ agent.global_memory["conclusion_anchor"] = conclusion_anchor
1094
+ # ==========================================================
1095
+
1096
+ pb.progress(15, text=tr(lang_code, "outline_progress"))
1097
+ update_heartbeat()
1098
+ plan = agent.generate_outline(topic, keywords, mode=st.session_state.run_mode, data_metadata=data_metadata)
1099
+ en_topic = str(plan.get("en_topic", topic))
1100
+ structure = plan.get("structure", [])
1101
+
1102
+ en_abstract_clean = clean_markdown_headings(re.sub(r'(?i)^abstract[:\s]*', '', str(abstract_anchor)).strip())
1103
+ trans_abstract_clean = ""
1104
+
1105
+ loc_abs = {"Simplified Chinese (简体中文)": "摘要", "Russian (Русский)": "Аннотация", "Kazakh (Қазақша)": "Түйіндеме"}.get(target_lang, "Abstract")
1106
+ loc_kw = {"Simplified Chinese (简体中文)": "关键词", "Russian (Русский)": "Ключевые слова", "Kazakh (Қазақша)": "Кілт сөздер"}.get(target_lang, "Keywords")
1107
+
1108
+ full_draft_en = f"# {en_topic}\n\n## Abstract\n{en_abstract_clean}\n\n## Keywords\n{keywords}\n\n"
1109
+ full_draft_translated = f"# {topic}\n\n## {loc_abs}\n{trans_abstract_clean}\n\n## {loc_kw}\n{keywords}\n\n"
1110
+
1111
+ adaptive_structure = agent.build_generation_tasks(structure, int(words), SAFE_CHUNK_WORDS)
1112
+ for task in adaptive_structure:
1113
+ task["is_first"] = bool(task.get("section_first"))
1114
+
1115
+ with st.spinner(tr(lang_code, "jit_status")):
1116
+ update_heartbeat()
1117
+ jit_texts = agent.prefetch_jit_literature(topic, keywords, adaptive_structure)
1118
+ if jit_texts:
1119
+ if hasattr(st.session_state, "rag_engine") and st.session_state.rag_engine is not None:
1120
+ st.session_state.rag_engine._initialize_chunks([jit_texts])
1121
+ else:
1122
+ st.session_state.rag_engine = initialize_rag([jit_texts])
1123
+ st.success(tr(lang_code, "jit_done"))
1124
+
1125
+ state["adaptive_structure"] = adaptive_structure
1126
+ state["agent_state"] = agent.export_state()
1127
+ state["full_draft_en"] = full_draft_en
1128
+ state["full_draft_translated"] = full_draft_translated
1129
+ completed_sections = []
1130
+ context_tail = ""
1131
+
1132
+ os.makedirs(WORKSPACE_DIR, exist_ok=True)
1133
+ with open(CHECKPOINT_FILE, "w", encoding="utf-8") as f:
1134
+ json.dump(state, f, ensure_ascii=False)
1135
+
1136
+ total_steps = len(adaptive_structure)
1137
+ current_step = len(completed_sections)
1138
+ st.markdown("---")
1139
+ ui_blocks = []
1140
+ target_flag = FLAG_MAP.get(target_lang, "🌐")
1141
+
1142
+ for sec in adaptive_structure:
1143
+ display_name = sec["display_name"]
1144
+ st.markdown(f"### ⚙️ {display_name}")
1145
+ col_en, col_trans = st.columns(2)
1146
+ with col_en:
1147
+ st.markdown(tr(lang_code, "english_draft"))
1148
+ en_ph = st.empty()
1149
+ with col_trans:
1150
+ st.markdown(tr(lang_code, "target_draft", flag=target_flag))
1151
+ trans_ph = st.empty()
1152
+ if display_name in completed_sections:
1153
+ en_ph.success(tr(lang_code, "restored"))
1154
+ trans_ph.info(tr(lang_code, "translation_waiting"))
1155
+ else:
1156
+ en_ph.info(tr(lang_code, "waiting"))
1157
+ trans_ph.info(tr(lang_code, "translation_waiting"))
1158
+ ui_blocks.append({"sec": sec, "en_ph": en_ph, "trans_ph": trans_ph})
1159
+
1160
+ generation_aborted = False
1161
+ generation_failure_code = ""
1162
+ generation_failure_detail = ""
1163
+ accumulated_deviation = 0
1164
+ remaining_sections = sum(1 for item in adaptive_structure if item["display_name"] not in completed_sections)
1165
+ all_cite_keys = list(agent.citation_map.keys())
1166
+ valid_chunks = [item for item in adaptive_structure if not any(word in item["section"].lower() for word in ["abstract","keyword","appendix"])]
1167
+ allocation_map = {}
1168
+ current_cite_idx = 0
1169
+ for idx, item in enumerate(valid_chunks):
1170
+ count = max(1, math.ceil(len(all_cite_keys) / max(1, len(valid_chunks))))
1171
+ 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]
1172
+ current_cite_idx += count
1173
+
1174
+ for block in ui_blocks:
1175
+ if generation_aborted:
1176
+ break
1177
+ sec = block["sec"]
1178
+ en_ph = block["en_ph"]
1179
+ trans_ph = block["trans_ph"]
1180
+ sec_title = sec["section"]
1181
+ display_name = sec["display_name"]
1182
+ if display_name in completed_sections:
1183
+ continue
1184
+ remaining_sections = max(0, remaining_sections - 1)
1185
+ adjustment = -accumulated_deviation / remaining_sections if remaining_sections else 0
1186
+ adjusted_target = min(int(SAFE_CHUNK_WORDS * 1.5), max(80, int(sec["target_words"] + adjustment)))
1187
+ is_quarantined = any(word in sec_title.lower() for word in ["abstract","keyword","conclusion","references","appendix"])
1188
+ assigned_tags = ", ".join(allocation_map.get(display_name, [])) or "the single most relevant source"
1189
+ 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}. <AVAILABLE_CITATIONS>{refs_text}</AVAILABLE_CITATIONS>"
1190
+ memory_context = agent._build_memory_context(sec_title, 2500)
1191
+ memory_block = f"\n<GLOBAL_MEMORY>\n{memory_context}\n</GLOBAL_MEMORY>\n" if memory_context else ""
1192
+ if getattr(st.session_state, "rag_engine", None) is not None:
1193
+ snippets = st.session_state.rag_engine.get_relevant_context(display_name)
1194
+ if snippets:
1195
+ memory_block += f"\n<TARGETED_RAG>\n{snippets}\n</TARGETED_RAG>\n"
1196
+ subsection = sec.get("subsection")
1197
+ heading_directive = "Do not output the section heading; it will be inserted by the system."
1198
+ if subsection:
1199
+ 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."
1200
+ elif sec_title.lower() in {"introduction", "conclusion"}:
1201
+ heading_directive += " Do not use additional headings."
1202
+ else:
1203
+ heading_directive += " Use level-2 or level-3 headings only when they clarify the argument; never use level-4 headings or numbered headings."
1204
+ 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 ""
1205
+ required_content = str(sec.get("required_content") or "")
1206
+ concepts = ", ".join(sec.get("concepts") or [])
1207
+ 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 ""
1208
+ data_injection = f"\n<RESEARCH_EVIDENCE>\n{user_data_md}\n</RESEARCH_EVIDENCE>\n" if user_data_md and sec["is_method_or_result"] else ""
1209
+ completed_label = ", ".join(completed_sections)
1210
+ anti_repeat = f"Already completed: {completed_label}. Do not restate their background, gap, or contribution." if completed_label else ""
1211
+ sys_prompt = APP_CONFIG.get("sys_academic_writer", "") + "\n" + APP_CONFIG.get("deep_refinement_directive", "")
1212
+ user_prompt = f"""Write the assigned English manuscript unit.
1213
+ Section: {sec_title}
1214
+ Subsection: {subsection or 'None'}
1215
+ Target length: {int(adjusted_target*0.9)}-{int(adjusted_target*1.1)} words.
1216
+ {heading_directive}
1217
+ {citation_rule}
1218
+ {table_rule}
1219
+ {required_content}
1220
+ {qualitative_rule}
1221
+ {anti_repeat}
1222
+ {memory_block}
1223
+ Literature synthesis: {synthesis_result}
1224
+ {data_injection}
1225
+ Previous tail: {context_tail[-2500:]}
1226
+ Output only final academic prose. Do not report the word count."""
1227
+ if agent._count_tokens(sys_prompt) + agent._count_tokens(user_prompt) > 60000:
1228
+ user_prompt = agent._truncate_context(user_prompt, 60000 - agent._count_tokens(sys_prompt))
1229
+ 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")))
1230
+ try:
1231
+ with st.status(tr(lang_code, "draft_status", name=display_name), expanded=True) as status:
1232
+ update_heartbeat()
1233
+ 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)))
1234
+ status.update(label=tr(lang_code, "draft_done", name=display_name), state="complete", expanded=False)
1235
+ en_raw = re.sub(r'<think>.*?</think>', '', str(en_raw), flags=re.S).strip()
1236
+ en_raw = re.sub(rf'(?im)^#\s*{re.escape(sec_title)}\s*\n+', '', en_raw).strip()
1237
+ if subsection:
1238
+ en_raw = re.sub(rf'(?im)^##\s*{re.escape(subsection)}\s*\n+', '', en_raw).strip()
1239
+ en_raw = re.sub(r'(?m)^(#{2,3})\s+[\d\.\s一二三四五六七八九十、]+\s*(.*)', r'\1 \2', en_raw)
1240
+ en_raw = re.sub(r'(?m)^#{4,}\s+(.*)', r'**\1**', en_raw)
1241
+ en_raw = re.sub(r'(?m)^#\s+(.+)$', r'## \1', en_raw)
1242
+ if st.session_state.run_mode == "asq" and user_data_md and "appendix" not in sec_title.lower():
1243
+ quote_dict = {tag: quote for quote, tag in re.findall(r'"([^"]+)"\s*\[(QUOTE-\d+)\]', user_data_md)}
1244
+ for tag, quote in quote_dict.items():
1245
+ en_raw = en_raw.replace(tag, f'"{quote}"')
1246
+ en_raw = agent.polish_academic_section(en_raw, display_name, abstract=False)
1247
+ prefix = ""
1248
+ if sec.get("section_first"):
1249
+ prefix += f"# {sec_title}\n\n"
1250
+ if sec.get("subsection_first") and subsection:
1251
+ prefix += f"## {subsection}\n\n"
1252
+ en_with_headings = prefix + en_raw
1253
+ en_resolved = agent.audit_and_resolve_citations(en_with_headings, section_name=display_name, quarantine=is_quarantined)
1254
+ en_ph.markdown(en_resolved)
1255
+ trans_ph.info(tr(lang_code, "translation_waiting"))
1256
+ full_draft_en += en_resolved + "\n\n"
1257
+ agent._extract_memory_from_text(en_raw, sec_title)
1258
+ words_tail = en_raw.split()
1259
+ context_tail = " ".join(words_tail[-200:])
1260
+ actual_words = len(en_raw.split())
1261
+ accumulated_deviation += actual_words - adjusted_target
1262
+ current_step += 1
1263
+ completed_sections.append(display_name)
1264
+ state.update({
1265
+ "completed_sections": completed_sections,
1266
+ "full_draft_en": full_draft_en,
1267
+ "full_draft_translated": "",
1268
+ "context_tail": context_tail,
1269
+ "agent_state": agent.export_state(),
1270
+ "data_metadata": data_metadata,
1271
+ })
1272
+ with open(CHECKPOINT_FILE, "w", encoding="utf-8") as checkpoint:
1273
+ json.dump(state, checkpoint, ensure_ascii=False)
1274
+ st.caption(tr(lang_code, "word_count", actual=actual_words, target=adjusted_target, deviation=accumulated_deviation))
1275
+ pb.progress(min(15 + int((current_step/max(1,total_steps))*75), 90), text=tr(lang_code, "section_complete_progress", name=display_name))
1276
+ except Exception as error:
1277
+ en_ph.error(tr(lang_code, "node_error"))
1278
+ st.error(tr(lang_code, "node_error_detail", error=error))
1279
+ generation_aborted = True
1280
+ generation_failure_code = "E_GENERATION_NODE_FAILED"
1281
+ generation_failure_detail = str(error)[:800]
1282
+
1283
+ if generation_aborted:
1284
+ _finish_managed_generation_task(
1285
+ "failed",
1286
+ safe_error_code=generation_failure_code or "E_GENERATION_INTERRUPTED",
1287
+ safe_error_detail=generation_failure_detail or "Generation stopped; quota was released and the checkpoint was retained for a fresh retry.",
1288
+ current_node=str(st.session_state.get("sp_current_node") or "generation"),
1289
+ detach_checkpoint=True,
1290
+ )
1291
+
1292
+ if not generation_aborted and full_draft_en.strip():
1293
+ pb.progress(91, text=tr(lang_code, "pack_docx"))
1294
+ update_heartbeat()
1295
+ full_draft_en = clean_trailing_references(full_draft_en, ["Reference","References","Reference List"])
1296
+ # The final abstract is derived from the completed English manuscript and frozen evidence.
1297
+ final_abstract = agent.generate_final_abstract(topic, keywords, full_draft_en, data_evidence=user_data_md, mode=st.session_state.run_mode)
1298
+ full_draft_en = replace_abstract(full_draft_en, final_abstract)
1299
+ agent.validate_manuscript_structure(full_draft_en, adaptive_structure, translated=False)
1300
+ if st.session_state.run_mode == "asq":
1301
+ repeated_quotes = agent.find_repeated_qualitative_quotes(full_draft_en, data_metadata)
1302
+ if repeated_quotes:
1303
+ raise RuntimeError(f"Repeated qualitative quotations: {repeated_quotes[:5]}")
1304
+ # Only sources actually cited in the manuscript enter the reference list.
1305
+ references = []
1306
+ seen_refs = set()
1307
+ for tag in sorted(agent.used_citations, key=agent._citation_tag_number):
1308
+ ref = str((agent.citation_map.get(tag) or {}).get("ref") or "").strip()
1309
+ if ref and ref not in seen_refs:
1310
+ references.append(f"- {ref}")
1311
+ seen_refs.add(ref)
1312
+ if not references:
1313
+ raise RuntimeError("No cited references remained after citation validation.")
1314
+ full_draft_en = full_draft_en.rstrip() + "\n\n# References\n" + "\n\n".join(references)
1315
+
1316
+ pb.progress(94, text=tr(lang_code, "translation_running"))
1317
+ update_heartbeat()
1318
+ full_draft_translated = agent.translate_locked_manuscript(full_draft_en, target_lang)
1319
+ agent.validate_translation_alignment(full_draft_en, full_draft_translated)
1320
+
1321
+ bj_time = datetime.now(timezone(timedelta(hours=8))).strftime("%Y%m%d_%H%M")
1322
+ package = build_validated_package(
1323
+ english_markdown=full_draft_en,
1324
+ translated_markdown=full_draft_translated,
1325
+ english_filename=f"Research_EN_{bj_time}.docx",
1326
+ translated_filename=f"Research_{output_suffix}_{bj_time}.docx",
1327
+ data_metadata=data_metadata,
1328
+ mode=st.session_state.run_mode,
1329
+ citation_audit=agent.citation_audit_log,
1330
+ literature_metrics=agent.get_source_metrics(),
1331
+ )
1332
+ generated_zip_bytes = package.zip_bytes
1333
+ generated_zip_filename = f"SmartPaper_{bj_time}.zip"
1334
+
1335
+ # Persist the already validated package in the current user's task
1336
+ # directory before asking Worker/D1 to commit quota. The download is
1337
+ # exposed only after the browser receives completed+committed.
1338
+ task_id = str((st.session_state.get("sp_generation_task") or {}).get("task_id") or "").strip()
1339
+ if not task_id:
1340
+ raise RuntimeError("package_task_id_missing")
1341
+ pending_path = os.path.join(_task_output_dir(task_id), ".pending_" + generated_zip_filename)
1342
+ with open(pending_path, "wb") as output_file:
1343
+ output_file.write(generated_zip_bytes)
1344
+ st.session_state["sp_pending_package"] = {
1345
+ "pending_path": pending_path,
1346
+ "filename": generated_zip_filename,
1347
+ }
1348
+ completion_finalization_attempted = True
1349
+ _finish_managed_generation_task(
1350
+ "completed",
1351
+ current_node="package_validated",
1352
+ delete_checkpoint=True,
1353
+ )
1354
+ pb.progress(100, text=tr(lang_code, "complete"))
1355
+ render_monitor(agent, tr(lang_code, "complete"), "✅", tr(lang_code, "document_generated"))
1356
+
1357
+ except Exception as e:
1358
+ if not completion_finalization_attempted:
1359
+ try:
1360
+ _finish_managed_generation_task(
1361
+ "failed",
1362
+ safe_error_code="E_GENERATION_FAILED",
1363
+ safe_error_detail=str(e)[:800],
1364
+ current_node=str(st.session_state.get("sp_current_node") or "generation"),
1365
+ detach_checkpoint=True,
1366
+ )
1367
+ except Exception:
1368
+ pass
1369
+ st.error(tr(lang_code, "startup_error", error=e))
1370
+ finally:
1371
+ clear_heartbeat()
1372
+ st.session_state.is_running = False
1373
+ st.session_state.start_pipeline = False
1374
+ st.session_state.resume_pipeline = False
1375
+
1376
+ if st.session_state.zip_buffer:
1377
+ st.success(tr(lang_code, "success"))
1378
+ st.download_button(label=tr(lang_code, "download"), data=st.session_state.zip_buffer, file_name=st.session_state.zip_filename, mime="application/zip")
$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/config.py ADDED
@@ -0,0 +1,404 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ import os
3
+
4
+ def load_config():
5
+ base_config = {
6
+ # --- 基础运行参数 ---
7
+ "lit_target_papers": int(os.environ.get("LIT_TARGET_PAPERS", "80")),
8
+ "lit_per_page": int(os.environ.get("LIT_PER_PAGE", "100")),
9
+ "lit_max_workers": int(os.environ.get("LIT_MAX_WORKERS", "5")),
10
+ "chunk_words_default": int(os.environ.get("CHUNK_WORDS_DEFAULT", "1500")),
11
+ "search_max_retries": max(1, int(os.environ.get("SEARCH_MAX_RETRIES", "4"))),
12
+ "translation_batch_tokens": max(800, int(os.environ.get("TRANSLATION_BATCH_TOKENS", "2500"))),
13
+ "translation_glossary_term_limit": max(10, int(os.environ.get("TRANSLATION_GLOSSARY_TERM_LIMIT", "40"))),
14
+ "translation_min_coverage_ratio": float(os.environ.get("TRANSLATION_MIN_COVERAGE_RATIO", "0.25")),
15
+ "translation_min_block_ratio_zh": float(os.environ.get("TRANSLATION_MIN_BLOCK_RATIO_ZH", "0.28")),
16
+ "translation_min_block_ratio_cyrillic": float(os.environ.get("TRANSLATION_MIN_BLOCK_RATIO_CYRILLIC", "0.65")),
17
+ "translation_max_block_ratio": float(os.environ.get("TRANSLATION_MAX_BLOCK_RATIO", "2.20")),
18
+ "openalex_email": os.environ.get("OPENALEX_EMAIL", "").strip(),
19
+
20
+ # --- RAG 与解析配置 ---
21
+ "rag_chunk_size": int(os.environ.get("RAG_CHUNK_SIZE", "3000")),
22
+ "rag_top_k": int(os.environ.get("RAG_TOP_K", "3")),
23
+ "max_extract_chars": int(os.environ.get("MAX_EXTRACT_CHARS", "150000")),
24
+ "api_timeouts": {
25
+ "standard": (15, 180),
26
+ "stream": (15, 300),
27
+ "search": 15
28
+ },
29
+
30
+ # --- API 节点配置 ---
31
+ "deepseek_api_url": os.environ.get(
32
+ "DEEPSEEK_API_URL",
33
+ "https://api.deepseek.com/chat/completions",
34
+ ),
35
+ # Model names are centralized here and can be upgraded in HF Variables.
36
+ # Application and engine code select a task role, never a concrete model.
37
+ "deepseek_models": {
38
+ "reasoning": os.environ.get(
39
+ "DEEPSEEK_MODEL_REASONING",
40
+ "deepseek-v4-pro",
41
+ ),
42
+ "writing": os.environ.get(
43
+ "DEEPSEEK_MODEL_WRITING",
44
+ "deepseek-v4-pro",
45
+ ),
46
+ "translation": os.environ.get(
47
+ "DEEPSEEK_MODEL_TRANSLATION",
48
+ "deepseek-v4-pro",
49
+ ),
50
+ "fast": os.environ.get(
51
+ "DEEPSEEK_MODEL_FAST",
52
+ "deepseek-v4-flash",
53
+ ),
54
+ "fallback": os.environ.get(
55
+ "DEEPSEEK_MODEL_FALLBACK",
56
+ "deepseek-v4-flash",
57
+ ),
58
+ },
59
+ "deepseek_task_settings": {
60
+ "reasoning": {
61
+ "thinking": True,
62
+ "reasoning_effort": os.environ.get(
63
+ "DEEPSEEK_REASONING_EFFORT",
64
+ "high",
65
+ ),
66
+ "allow_fallback": False,
67
+ },
68
+ "writing": {
69
+ "thinking": False,
70
+ "reasoning_effort": "high",
71
+ "allow_fallback": False,
72
+ },
73
+ "translation": {
74
+ "thinking": False,
75
+ "reasoning_effort": "high",
76
+ "allow_fallback": False,
77
+ },
78
+ "fast": {
79
+ "thinking": False,
80
+ "reasoning_effort": "low",
81
+ "allow_fallback": True,
82
+ },
83
+ },
84
+ "deepseek_max_retries": int(
85
+ os.environ.get("DEEPSEEK_MAX_RETRIES", "3")
86
+ ),
87
+ "deepseek_show_reasoning": os.environ.get(
88
+ "DEEPSEEK_SHOW_REASONING",
89
+ "false",
90
+ ).strip().lower() in {"1", "true", "yes", "on"},
91
+ # Citation-semantic audit repairs only affected sentences. Technical
92
+ # failures fall back to deterministic single-source recovery and are
93
+ # recorded in the audit log instead of terminating the whole paper.
94
+ "citation_semantic_audit": {
95
+ "enabled": os.environ.get(
96
+ "CITATION_SEMANTIC_AUDIT_ENABLED",
97
+ "true",
98
+ ).strip().lower() in {"1", "true", "yes", "on"},
99
+ "batch_size": max(
100
+ 1,
101
+ int(os.environ.get("CITATION_SEMANTIC_BATCH_SIZE", "5")),
102
+ ),
103
+ "candidate_limit": max(
104
+ 3,
105
+ int(os.environ.get("CITATION_SEMANTIC_CANDIDATE_LIMIT", "10")),
106
+ ),
107
+ "fail_closed": os.environ.get(
108
+ "CITATION_SEMANTIC_FAIL_CLOSED",
109
+ "false",
110
+ ).strip().lower() in {"1", "true", "yes", "on"},
111
+ "technical_retries": max(
112
+ 1,
113
+ int(os.environ.get("CITATION_SEMANTIC_TECHNICAL_RETRIES", "2")),
114
+ ),
115
+ "json_repair_retries": max(
116
+ 0,
117
+ int(os.environ.get("CITATION_SEMANTIC_JSON_REPAIR_RETRIES", "1")),
118
+ ),
119
+ },
120
+ # Task/lease settings are consumed by the HF client. The Worker remains
121
+ # the authoritative source for task ownership and lock expiry.
122
+ "task_heartbeat_interval_seconds": max(
123
+ 3,
124
+ int(os.environ.get("SMARTPAPER_TASK_HEARTBEAT_SECONDS", "5")),
125
+ ),
126
+ "manuscript_quality": {
127
+ "abstract_max_words": int(os.environ.get("ABSTRACT_MAX_WORDS", "200")),
128
+ "abstract_max_numeric_mentions": int(os.environ.get("ABSTRACT_MAX_NUMERIC_MENTIONS", "6")),
129
+ "forbidden_phrases": [
130
+ "delve into", "intricate", "paramount", "overarching",
131
+ "pivotal", "foster", "seamlessly", "multifaceted",
132
+ "beacon", "landscape", "underscore", "robust",
133
+ "firstly", "secondly", "moreover", "furthermore",
134
+ "in conclusion", "to summarize", "as an ai",
135
+ "ai-generated", "this response",
136
+ ],
137
+ },
138
+ "openalex_api_url": os.environ.get("OPENALEX_API_URL", "https://api.openalex.org/works"),
139
+ "s2_api_url": os.environ.get("S2_API_URL", "https://api.semanticscholar.org/graph/v1"),
140
+
141
+ # --- UI 多语言标签 ---
142
+ "ui_labels": {
143
+ "中文": {"title": "📊 工商管理论文智能体", "btn_generate": "🚀 生成初稿", "btn_download": "📥 下载文件", "topic_label": "📝 论文名称", "keywords_label": "🔍 关键字", "words_label": "📏 目标总单词数 (12000-25000单词)", "success": "🎉 流水线推演完毕!双语文档已就绪。"},
144
+ "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!"},
145
+ "Русский": {"title": "📊 Агент статей по бизнес-администрированию", "btn_generate": "🚀 Черновик", "btn_download": "📥 Скачать", "topic_label": "📝 Тема", "keywords_label": "🔍 Ключевые слова", "words_label": "📏 Количество слов", "success": "🎉 Задача выполнена!"},
146
+ "Қазақша": {"title": "📊 Бизнес-басқару жөніндегі ғылыми мақала агенті", "btn_generate": "🚀 Жоба дайындау", "btn_download": "📥 Жүктеу", "topic_label": "📝 Мақала атауы", "keywords_label": "🔍 Түйін сөздер", "words_label": "📏 Мақсатты сөз саны", "success": "🎉 Тапсырма аяқталды!"}
147
+ },
148
+ "target_lang_map": {
149
+ "中文": "Simplified Chinese (简体中文)",
150
+ "English": "Simplified Chinese (简体中文)",
151
+ "Русский": "Russian (Русский)",
152
+ "Қазақша": "Kazakh (Қазақша)"
153
+ },
154
+
155
+ "workspace_dir": ".workspace",
156
+
157
+ "docx_styles": {
158
+ "font_name": "Times New Roman",
159
+ "font_size_pt": 12,
160
+ "code_font": "Courier New",
161
+ "code_size_pt": 11
162
+ },
163
+
164
+ "blacklists": {
165
+ "meta_sections": [
166
+ "abstract", "keyword", "keywords", "摘要", "关键词", "关键字",
167
+ "reference", "references", "reference list", "参考文献",
168
+ "acknowledgment", "acknowledgements", "致谢",
169
+ "аннотация", "ключевые слова", "түйіндеме", "кілт сөздер"
170
+ ]
171
+ },
172
+
173
+ # =====================================================================
174
+ # Canonical manuscript structures. Top-level titles are deterministic:
175
+ # the model may write content, but it may not rename, remove, or reorder
176
+ # required sections.
177
+ # =====================================================================
178
+ "amj_schema": [
179
+ {
180
+ "section_id": "introduction",
181
+ "section": "Introduction",
182
+ "weight": 0.10,
183
+ "subsections": [],
184
+ },
185
+ {
186
+ "section_id": "theory",
187
+ "section": "Theoretical Background and Hypotheses",
188
+ "weight": 0.27,
189
+ "subsections": [
190
+ {"subsection_id": "conceptual_background", "title": "Conceptual Background and Research Gap", "share": 0.23},
191
+ {"subsection_id": "theoretical_mechanisms", "title": "Theoretical Mechanisms", "share": 0.27},
192
+ {"subsection_id": "hypothesis_development", "title": "Hypothesis Development", "share": 0.35},
193
+ {"subsection_id": "integrated_model", "title": "Integrated Research Model", "share": 0.15},
194
+ ],
195
+ },
196
+ {
197
+ "section_id": "methodology",
198
+ "section": "Methodology",
199
+ "weight": 0.20,
200
+ "subsections": [
201
+ {"subsection_id": "research_design", "title": "Research Design and Context", "share": 0.18},
202
+ {"subsection_id": "sample_data", "title": "Sample and Data Collection", "share": 0.20},
203
+ {"subsection_id": "measures", "title": "Measures and Construct Operationalization", "share": 0.25},
204
+ {"subsection_id": "analysis_strategy", "title": "Analytical Strategy", "share": 0.22},
205
+ {"subsection_id": "validity_bias", "title": "Validity, Bias, and Robustness Procedures", "share": 0.15},
206
+ ],
207
+ },
208
+ {
209
+ "section_id": "results",
210
+ "section": "Results",
211
+ "weight": 0.23,
212
+ "subsections": [
213
+ {"subsection_id": "measurement_results", "title": "Descriptive Statistics and Measurement Assessment", "share": 0.34},
214
+ {"subsection_id": "hypothesis_results", "title": "Hypothesis and Association Tests", "share": 0.46},
215
+ {"subsection_id": "additional_results", "title": "Additional Analyses and Robustness Checks", "share": 0.20},
216
+ ],
217
+ },
218
+ {
219
+ "section_id": "discussion",
220
+ "section": "Discussion",
221
+ "weight": 0.15,
222
+ "subsections": [
223
+ {"subsection_id": "interpretation", "title": "Interpretation of the Findings", "share": 0.30},
224
+ {"subsection_id": "theoretical_contributions", "title": "Theoretical Contributions", "share": 0.30},
225
+ {"subsection_id": "practical_implications", "title": "Practical Implications", "share": 0.20},
226
+ {"subsection_id": "limitations", "title": "Limitations and Future Research", "share": 0.20},
227
+ ],
228
+ },
229
+ {
230
+ "section_id": "conclusion",
231
+ "section": "Conclusion",
232
+ "weight": 0.05,
233
+ "subsections": [],
234
+ },
235
+ ],
236
+ "asq_schema": [
237
+ {
238
+ "section_id": "introduction",
239
+ "section": "Introduction",
240
+ "weight": 0.10,
241
+ "subsections": [],
242
+ },
243
+ {
244
+ "section_id": "theoretical_framing",
245
+ "section": "Theoretical Framing",
246
+ "weight": 0.18,
247
+ "subsections": [
248
+ {"subsection_id": "phenomenon_puzzle", "title": "Phenomenon and Theoretical Puzzle", "share": 0.35},
249
+ {"subsection_id": "conceptual_tension", "title": "Relevant Literature and Conceptual Tension", "share": 0.40},
250
+ {"subsection_id": "research_question", "title": "Research Question and Analytical Focus", "share": 0.25},
251
+ ],
252
+ },
253
+ {
254
+ "section_id": "methods",
255
+ "section": "Research Setting and Methods",
256
+ "weight": 0.17,
257
+ "subsections": [
258
+ {"subsection_id": "research_setting", "title": "Research Setting", "share": 0.24},
259
+ {"subsection_id": "data_collection", "title": "Data Sources and Collection", "share": 0.28},
260
+ {"subsection_id": "analytic_procedure", "title": "Analytical Procedure", "share": 0.30},
261
+ {"subsection_id": "trustworthiness", "title": "Trustworthiness, Ethics, and Reflexivity", "share": 0.18},
262
+ ],
263
+ },
264
+ {
265
+ "section_id": "findings",
266
+ "section": "Findings",
267
+ "weight": 0.30,
268
+ "dynamic_subsections": "qualitative_themes",
269
+ "subsections": [
270
+ {"subsection_id": "emergent_model", "title": "Emergent Process Model", "share": 0.18},
271
+ ],
272
+ },
273
+ {
274
+ "section_id": "discussion",
275
+ "section": "Discussion",
276
+ "weight": 0.15,
277
+ "subsections": [
278
+ {"subsection_id": "interpretation", "title": "Interpretation of the Emergent Findings", "share": 0.30},
279
+ {"subsection_id": "theoretical_contributions", "title": "Theoretical Contributions", "share": 0.30},
280
+ {"subsection_id": "boundaries", "title": "Boundary Conditions and Transferability", "share": 0.20},
281
+ {"subsection_id": "practical_implications", "title": "Practical Implications", "share": 0.20},
282
+ ],
283
+ },
284
+ {
285
+ "section_id": "conclusion",
286
+ "section": "Conclusion",
287
+ "weight": 0.05,
288
+ "subsections": [],
289
+ },
290
+ {
291
+ "section_id": "appendix",
292
+ "section": "Appendix: Data Structure and Evidence",
293
+ "weight": 0.05,
294
+ "subsections": [],
295
+ },
296
+ ],
297
+ "manuscript_validation": {
298
+ "minimum_total_ratio": 0.85,
299
+ "maximum_total_ratio": 1.15,
300
+ "minimum_section_words": 120,
301
+ "minimum_conclusion_words": 120,
302
+ "minimum_qualitative_themes": 3,
303
+ "minimum_qualitative_quotes": 8,
304
+ },
305
+
306
+ "prompt_templates": {
307
+ "meta_extract": """You are an expert academic data extractor. Read the provided text and extract the core metadata.
308
+ CRITICAL: You MUST output ONLY a valid JSON object. Do not include markdown formatting or explanations.
309
+
310
+ {
311
+ "paper_title": "<String: Exact title>",
312
+ "core_theory": "<String: Max 5 words, e.g., 'Social Exchange Theory'>",
313
+ "variables": {
314
+ "IV": "<String: Independent Variables>",
315
+ "DV": "<String: Dependent Variables>",
316
+ "Mediators": "<String or 'N/A'>",
317
+ "Moderators": "<String or 'N/A'>"
318
+ },
319
+ "methodology": {
320
+ "sample_size": "<Integer or 'N/A'>",
321
+ "context": "<String: Max 10 words, e.g., 'US Tech Industry'>",
322
+ "analysis_tool": "<String: e.g., 'SEM, PLS, OLS'>"
323
+ },
324
+ "key_findings": "<String: Strict ONE-SENTENCE summary emphasizing causality>",
325
+ "future_gaps": "<String: Explicit limitations or future directions>"
326
+ }""",
327
+ "synthesis_system": "You are a top-tier academic researcher in Business Administration.",
328
+ "synthesis_user": """Topic: {topic}{user_injection}
329
+ Literature Metadata:
330
+ <LIT_DATA>
331
+ {refs_text}
332
+ </LIT_DATA>
333
+
334
+ [SYNTHESIS MANDATE: THEMATIC INTEGRATION]
335
+ 1. You MUST NOT summarize papers sequentially (e.g., "A found X. B found Y.").
336
+ 2. Synthesize by THEME or THEORETICAL TENSION rather than paper-by-paper summaries.
337
+ 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.
338
+ 4. Explicitly highlight theoretical conflicts or consensus.
339
+ 5. Conclude by revealing a specific blind spot that necessitates our current research.
340
+ {directive}"""
341
+ }
342
+ }
343
+
344
+ # =====================================================================
345
+ # 定量引擎 (AMJ 风格) - 保持原样,极致严谨与假设推演
346
+ # =====================================================================
347
+ amj_prompts = {
348
+ "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.
349
+ [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.
350
+ [ABSOLUTE ANTI-AI-TONE MANDATES]:
351
+ 1. CRITICAL: NEVER use these cliché AI words: delve into, intricate, paramount, overarching, pivotal, foster, seamlessly, multifaceted, beacon, landscape, underscore, robust.
352
+ 2. CRITICAL: NEVER use robotic transition phrases (e.g., Firstly, Moreover, Furthermore, In conclusion, To summarize). Use conceptual bridges instead.
353
+ 3. NATURAL SYNTAX: Vary sentence structure only where the argument requires it. Avoid artificial short-long sentence alternation and repetitive templates.
354
+ 4. ACADEMIC HEDGING: Use strategic hedging (e.g., 'This suggests a tendency toward X, bounded by Y').
355
+ 5. NO SWEEPING GENERALIZATIONS: Every sentence must carry specific theoretical weight or empirical fact.
356
+ 6. FOCUS ON CAUSALITY: You MUST articulate the exact theoretical mechanism driving the relationship.
357
+ 7. ACADEMIC REGISTER: Use concise, accurate, and rigorous language. Concepts must be explicit, hierarchy clear, and reasoning logically traceable.
358
+ 8. PROHIBITED RHETORIC: Never use metaphors, analogies, rhetorical questions, literary embellishment, or promotional claims.
359
+ 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.
360
+ 10. STRUCTURAL DISCIPLINE: Cover only the assigned section and subsection. Do not recreate earlier sections, invent alternative headings, or omit required analytical elements.
361
+ 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.
362
+ """,
363
+ "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.",
364
+ "deep_synthesis_directive": "Structure this synthesis to expose a critical theoretical tension: Consensus vs Paradox, Competing Perspectives, Theoretical Blind Spot.",
365
+ "hypothesis_development_directive": "Construct a rigorous causal chain: 1. Base Theory. 2. Mechanism (Mediator logic). 3. Boundary Condition (Moderator logic). 4. Hypothesis Phrasing (italicized).",
366
+ "methodological_defense_directive": "Write with extreme defensive precision: Justify Contextual Relevance, Detail Endogeneity & Bias Mitigation, Measurement Validity, and Analytical Strategy.",
367
+ "discussion_depth_directive": "Elevate findings back to grand theory. Explicitly state how the study extends, refines, or challenges named theories. Provide counter-intuitive implications.",
368
+ "conclusion_anchor_directive": "1. Conceptual Pivot. 2. Mechanism Summary. 3. Call to Action for the next decade.",
369
+ "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...",
370
+ "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.",
371
+ }
372
+
373
+ # =====================================================================
374
+ # 定性引擎 (ASQ 风格) - 聚焦现象描述、意义建构与语录引用
375
+ # =====================================================================
376
+ asq_prompts = {
377
+ "sys_academic_writer": """You are a senior editor and distinguished qualitative researcher writing for Administrative Science Quarterly (ASQ).
378
+ Your writing style is phenomenon-driven, deeply narrative, and theoretically rich.
379
+ [CRITICAL LANGUAGE MANDATE]: You MUST generate all responses strictly in ACADEMIC ENGLISH.
380
+ [ABSOLUTE ASQ MANDATES]:
381
+ 1. FOCUS ON THE PUZZLE: Embrace counter-intuitive empirical phenomena and paradoxes.
382
+ 2. THICK DESCRIPTION: Use descriptive, evocative language to ground your qualitative findings in reality.
383
+ 3. SENSEMAKING: Focus on how organizational actors interpret, negotiate, and construct their reality.
384
+ 4. NO FORMAL HYPOTHESES: DO NOT use 'Hypothesis 1' phrasing. Use 'Propositions' or conceptual emergent models instead.
385
+ 5. NO AI CLICHES: Strictly avoid 'delve', 'tapestry', 'multifaceted', 'beacon', 'landscape'.
386
+ 6. ACADEMIC REGISTER: Use concise, accurate, and rigorous language with explicit concepts and logically ordered claims.
387
+ 7. PROHIBITED RHETORIC: Never use metaphors, analogies, rhetorical questions, literary embellishment, or promotional claims. Use concrete evidence-based description rather than decorative prose.
388
+ 8. ABSTRACT DISCIPLINE: Keep numerical details to the minimum needed to communicate the empirical puzzle, main finding, and contribution.
389
+ 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.""",
390
+ "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.",
391
+ "deep_synthesis_directive": "Structure this synthesis to highlight a critical empirical puzzle or theoretical paradox that qualitative investigation can unravel.",
392
+ "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.",
393
+ "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).",
394
+ "discussion_depth_directive": "Elevate the emergent qualitative findings back to grand theory. Contrast your inductive process model with existing linear or variance-based theories.",
395
+ "conclusion_anchor_directive": "Summarize the theoretical contribution, the resolution of the empirical puzzle, and the sensemaking implications.",
396
+ "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...",
397
+ "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.",
398
+ }
399
+
400
+ generic_prompts = amj_prompts.copy()
401
+ return base_config, amj_prompts, asq_prompts, generic_prompts
402
+
403
+ DEFAULT_CONFIG, AMJ_PROMPTS, ASQ_PROMPTS, GENERIC_PROMPTS = load_config()
404
+ APP_CONFIG = DEFAULT_CONFIG.copy()
$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/core/desktop.ini ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ [.ShellClassInfo]
2
+ IconResource=C:\Program Files\Google\Drive File Stream\129.0.1.0\GoogleDriveFS.exe,27
$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/core/meta_extractor.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ import json
3
+ import concurrent.futures
4
+ from typing import List, Dict
5
+ import re
6
+ import logging
7
+ from config import APP_CONFIG
8
+
9
+ class MetaExtractor:
10
+ def __init__(self, agent):
11
+ self.agent = agent
12
+ self.max_workers = APP_CONFIG["lit_max_workers"]
13
+
14
+ def extract_single_paper_meta(self, text: str) -> Dict:
15
+ snippet = self.agent._truncate_context(text, max_tokens=3500)
16
+ prompt = APP_CONFIG["prompt_templates"]["meta_extract"]
17
+
18
+ try:
19
+ # 修改点:显式将模型降级为 deepseek-chat (V3),以获得更快的并行提取速度
20
+ response = self.agent.call_deepseek(
21
+ prompt,
22
+ snippet,
23
+ max_tokens=1000,
24
+ model_name="deepseek-chat"
25
+ )
26
+
27
+ # 强制提取 JSON 部分,过滤模型可能的解释性前缀或后缀
28
+ json_match = re.search(r'\{.*\}', response, re.DOTALL)
29
+ if json_match:
30
+ return json.loads(json_match.group())
31
+ return {}
32
+ except Exception as e:
33
+ logging.error(f"Meta Extraction error: {e}")
34
+ return {}
35
+
36
+ def run_batch_extraction(self, paper_texts: List[str]) -> List[Dict]:
37
+ golden_db = []
38
+ # 使用配置中的 lit_max_workers 进行多线程并行提取
39
+ with concurrent.futures.ThreadPoolExecutor(max_workers=self.max_workers) as executor:
40
+ future_to_paper = {executor.submit(self.extract_single_paper_meta, text): text for text in paper_texts}
41
+ for future in concurrent.futures.as_completed(future_to_paper):
42
+ try:
43
+ data = future.result()
44
+ if data:
45
+ golden_db.append(data)
46
+ except Exception as e:
47
+ logging.error(f"Batch Future error: {e}")
48
+ return golden_db
49
+
50
+ def extract_golden_metadata(agent, paper_texts: List[str]):
51
+ extractor = MetaExtractor(agent)
52
+ return extractor.run_batch_extraction(paper_texts)
$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/core/pipeline_agent.py ADDED
The diff for this file is too large to render. See raw diff
 
$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/core/production_guard.py ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """Final-output validation and packaging for SmartPaper."""
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import io
7
+ import json
8
+ import re
9
+ import zipfile
10
+ from dataclasses import dataclass
11
+ from typing import Any
12
+
13
+ from docx import Document
14
+
15
+ from utils.docx_builder import DocxBuildError, text_to_docx
16
+
17
+
18
+ class OutputValidationError(RuntimeError):
19
+ pass
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class PackageResult:
24
+ zip_bytes: bytes
25
+ file_names: tuple[str, ...]
26
+ manifest: str
27
+
28
+
29
+ def replace_abstract(manuscript: str, abstract: str) -> str:
30
+ value = str(manuscript or "")
31
+ replacement = f"## Abstract\n{str(abstract or '').strip()}\n\n"
32
+ updated, count = re.subn(
33
+ r"(?ms)^##\s+Abstract\s*$\n.*?(?=^##\s+Keywords\s*$)",
34
+ replacement,
35
+ value,
36
+ count=1,
37
+ )
38
+ if count != 1:
39
+ raise OutputValidationError("English abstract block was not found exactly once.")
40
+ return updated
41
+
42
+
43
+ def validate_docx_bytes(data: bytes, *, minimum_paragraphs: int = 8) -> None:
44
+ if not data or len(data) < 1500:
45
+ raise OutputValidationError("DOCX output is empty or unexpectedly small.")
46
+ try:
47
+ document = Document(io.BytesIO(data))
48
+ except Exception as exc:
49
+ raise OutputValidationError(f"DOCX cannot be reopened: {exc}") from exc
50
+ nonempty = [paragraph.text.strip() for paragraph in document.paragraphs if paragraph.text.strip()]
51
+ if len(nonempty) < minimum_paragraphs:
52
+ raise OutputValidationError(
53
+ f"DOCX contains only {len(nonempty)} nonempty paragraphs; expected at least {minimum_paragraphs}."
54
+ )
55
+
56
+
57
+ def validate_markdown_manuscript(text: str, *, language_name: str) -> None:
58
+ value = str(text or "").strip()
59
+ if len(value) < 1000:
60
+ raise OutputValidationError(f"{language_name} manuscript is incomplete.")
61
+ if re.search(r"\[?CITE-\d+\]?", value, flags=re.IGNORECASE):
62
+ raise OutputValidationError(f"{language_name} manuscript still contains unresolved citation tags.")
63
+ if re.search(r"(?i)\bas an ai(?: language model)?\b|\bai[- ]generated\b", value):
64
+ raise OutputValidationError(f"{language_name} manuscript contains AI-writing meta-commentary.")
65
+ if not re.search(r"(?m)^#\s+", value):
66
+ raise OutputValidationError(f"{language_name} manuscript has no title/section headings.")
67
+
68
+
69
+ def build_generation_notes(data_metadata: dict[str, Any], mode: str) -> str:
70
+ metadata = dict(data_metadata or {})
71
+ source_type = metadata.get("source_type", "unknown")
72
+ lines = [
73
+ "SmartPaper Generation Notes",
74
+ "",
75
+ f"Research mode: {mode}",
76
+ f"Evidence source type: {source_type}",
77
+ "",
78
+ "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.",
79
+ ]
80
+ if metadata.get("disclosure_required"):
81
+ lines.extend(
82
+ [
83
+ "",
84
+ "Evidence-generation notice:",
85
+ "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.",
86
+ "",
87
+ f"Generation method: {metadata.get('method') or 'Structured evidence generation under the selected quantitative or qualitative engine.'}",
88
+ ]
89
+ )
90
+ study_facts = metadata.get("study_facts")
91
+ if isinstance(study_facts, dict):
92
+ lines.extend(["", "Frozen qualitative study facts:", json.dumps(study_facts, ensure_ascii=False, indent=2)])
93
+ if metadata.get("sample_size") is not None:
94
+ lines.append(f"Generated sample size: {metadata.get('sample_size')}")
95
+ if metadata.get("evidence_count") is not None:
96
+ lines.append(f"Generated/retained qualitative evidence items: {metadata.get('evidence_count')}")
97
+ themes = metadata.get("themes")
98
+ if isinstance(themes, list):
99
+ lines.append(f"Generated/retained qualitative themes: {len(themes)}")
100
+ parameters = metadata.get("generation_parameters")
101
+ if isinstance(parameters, dict) and parameters:
102
+ lines.extend(
103
+ [
104
+ "",
105
+ "Generation parameters and process:",
106
+ json.dumps(parameters, ensure_ascii=False, indent=2),
107
+ ]
108
+ )
109
+ lines.extend(
110
+ [
111
+ "",
112
+ "Validation boundary:",
113
+ "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.",
114
+ ]
115
+ )
116
+ return "\n".join(lines).strip() + "\n"
117
+
118
+
119
+ def _manifest(entries: dict[str, bytes]) -> str:
120
+ rows = []
121
+ for name in sorted(entries):
122
+ rows.append(f"{hashlib.sha256(entries[name]).hexdigest()} {name}")
123
+ return "\n".join(rows) + "\n"
124
+
125
+
126
+ def build_validated_package(
127
+ *,
128
+ english_markdown: str,
129
+ translated_markdown: str,
130
+ english_filename: str,
131
+ translated_filename: str,
132
+ data_metadata: dict[str, Any],
133
+ mode: str,
134
+ citation_audit: list[dict[str, Any]],
135
+ literature_metrics: dict[str, Any],
136
+ ) -> PackageResult:
137
+ validate_markdown_manuscript(english_markdown, language_name="English")
138
+ validate_markdown_manuscript(translated_markdown, language_name="Target-language")
139
+
140
+ try:
141
+ english_docx = text_to_docx(english_markdown).getvalue()
142
+ translated_docx = text_to_docx(translated_markdown).getvalue()
143
+ except DocxBuildError as exc:
144
+ raise OutputValidationError(str(exc)) from exc
145
+ validate_docx_bytes(english_docx)
146
+ validate_docx_bytes(translated_docx)
147
+
148
+ entries: dict[str, bytes] = {
149
+ english_filename: english_docx,
150
+ translated_filename: translated_docx,
151
+ "GENERATION_NOTES.txt": build_generation_notes(data_metadata, mode).encode("utf-8"),
152
+ "CITATION_AUDIT.json": json.dumps(citation_audit or [], ensure_ascii=False, indent=2).encode("utf-8"),
153
+ "LITERATURE_METRICS.json": json.dumps(literature_metrics or {}, ensure_ascii=False, indent=2).encode("utf-8"),
154
+ }
155
+ manifest = _manifest(entries)
156
+ entries["FILE_MANIFEST_SHA256.txt"] = manifest.encode("utf-8")
157
+
158
+ buffer = io.BytesIO()
159
+ with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
160
+ for name, content in entries.items():
161
+ archive.writestr(name, content)
162
+ zip_bytes = buffer.getvalue()
163
+
164
+ try:
165
+ with zipfile.ZipFile(io.BytesIO(zip_bytes), "r") as archive:
166
+ bad = archive.testzip()
167
+ if bad:
168
+ raise OutputValidationError(f"ZIP integrity check failed at {bad}.")
169
+ names = set(archive.namelist())
170
+ required = set(entries)
171
+ if names != required:
172
+ raise OutputValidationError(
173
+ f"ZIP file list mismatch: actual={sorted(names)}, expected={sorted(required)}"
174
+ )
175
+ for name in (english_filename, translated_filename):
176
+ validate_docx_bytes(archive.read(name))
177
+ except OutputValidationError:
178
+ raise
179
+ except Exception as exc:
180
+ raise OutputValidationError(f"ZIP cannot be reopened: {exc}") from exc
181
+
182
+ return PackageResult(zip_bytes=zip_bytes, file_names=tuple(sorted(entries)), manifest=manifest)
$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/core/qualitative_engine.py ADDED
@@ -0,0 +1,248 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """Qualitative evidence preparation for SmartPaper's ASQ track."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import logging
7
+ import re
8
+
9
+ import streamlit as st
10
+
11
+ from config import APP_CONFIG
12
+
13
+
14
+ class QualitativeEngine:
15
+ def __init__(self, agent):
16
+ self.agent = agent
17
+
18
+ @staticmethod
19
+ def _clean_cell(value, default="Not specified"):
20
+ cleaned = str(value or default).replace("\n", " ").strip()
21
+ return cleaned or default
22
+
23
+ def _build_request(self, topic, keywords, raw_interviews):
24
+ schema = """Return ONLY one valid JSON object with this schema:
25
+ {
26
+ "study_facts": {
27
+ "research_setting": "string",
28
+ "data_sources": ["string"],
29
+ "participant_count": integer or null,
30
+ "interview_count": integer or null,
31
+ "analysis_method": "string"
32
+ },
33
+ "evidence": [
34
+ {"theme": "2nd-order theme", "concept": "1st-order concept", "quote": "verbatim quote"}
35
+ ]
36
+ }
37
+ """
38
+ if raw_interviews and len(raw_interviews) > 100:
39
+ return f"""Topic: {topic}
40
+ Keywords: {keywords}
41
+ Analyze the supplied interview or field material.
42
+
43
+ Rules:
44
+ 1. Use only setting, sample, source, and count information supported by the supplied material. Use null or "Not specified" when absent.
45
+ 2. Extract up to 15 representative quotations verbatim; do not rewrite quotations.
46
+ 3. Build a coherent Gioia-style coding structure.
47
+ 4. The study_facts object will become the single source of truth for the entire manuscript.
48
+
49
+ {schema}
50
+ <QUALITATIVE_MATERIAL>
51
+ {raw_interviews[:30000]}
52
+ </QUALITATIVE_MATERIAL>
53
+ """
54
+
55
+ return f"""Topic: {topic}
56
+ Keywords: {keywords}
57
+ Construct one internally coherent qualitative study design and evidence packet for a complete ASQ-style manuscript when no interview material is supplied.
58
+
59
+ Rules:
60
+ 1. Choose one research setting and keep it fixed.
61
+ 2. Choose one participant count, interview count, data-source set, and analysis method; do not create conflicting counts.
62
+ 3. Produce exactly 12 natural representative quotations organized into a Gioia-style coding structure.
63
+ 4. The study_facts object will become the single source of truth for the entire manuscript.
64
+ 5. Avoid decorative language, generic AI phrasing, and repetitive quotation patterns.
65
+
66
+ {schema}
67
+ """
68
+
69
+ def resolve_qualitative_data(self, topic, keywords, raw_interviews=""):
70
+ has_user_data = bool(raw_interviews and len(raw_interviews) > 100)
71
+ prompt = self._build_request(topic, keywords, raw_interviews)
72
+ model_label = self.agent.model_for("reasoning")
73
+
74
+ if has_user_data:
75
+ status_label = (
76
+ f"🧠 正在使用 {model_label} 分析访谈材料并冻结研究事实…"
77
+ )
78
+ complete_label = "✅ 访谈材料、研究事实与编码结构已完成"
79
+ else:
80
+ status_label = (
81
+ f"🧠 正在使用 {model_label} 构建一致的定性研究证据包…"
82
+ )
83
+ complete_label = "✅ 定性研究事实与编码结构已完成"
84
+
85
+ with st.status(status_label, expanded=True) as status:
86
+ placeholder = st.empty()
87
+ response = placeholder.write_stream(
88
+ self.agent.stream_deepseek(
89
+ "You are a rigorous qualitative research analyst.",
90
+ prompt,
91
+ model_role="reasoning",
92
+ thinking=True,
93
+ allow_fallback=False,
94
+ )
95
+ )
96
+ status.update(
97
+ label=complete_label,
98
+ state="complete",
99
+ expanded=False,
100
+ )
101
+
102
+ response = re.sub(
103
+ r"<think>.*?</think>",
104
+ "",
105
+ response,
106
+ flags=re.DOTALL,
107
+ ).strip()
108
+ data = self.agent._extract_json_from_text(response)
109
+
110
+ if not isinstance(data, dict):
111
+ logging.error("Qualitative JSON parsing failed; using safe fallback.")
112
+ data = {
113
+ "study_facts": {
114
+ "research_setting": "Not specified",
115
+ "data_sources": ["Interview or field material"],
116
+ "participant_count": None,
117
+ "interview_count": None,
118
+ "analysis_method": "Gioia-style inductive coding",
119
+ },
120
+ "evidence": [
121
+ {
122
+ "theme": "Sensemaking",
123
+ "concept": "Initial uncertainty",
124
+ "quote": (
125
+ "We did not yet have a shared understanding "
126
+ "of how to respond."
127
+ ),
128
+ }
129
+ ],
130
+ }
131
+
132
+ facts = data.get("study_facts") or {}
133
+ evidence = data.get("evidence") or data.get("quotes") or []
134
+ if not isinstance(evidence, list):
135
+ evidence = []
136
+
137
+ data_sources = facts.get("data_sources") or ["Not specified"]
138
+ if not isinstance(data_sources, list):
139
+ data_sources = [data_sources]
140
+
141
+ setting = self._clean_cell(facts.get("research_setting"))
142
+ sources = ", ".join(self._clean_cell(item) for item in data_sources)
143
+ participants = facts.get("participant_count")
144
+ interviews = facts.get("interview_count")
145
+ method = self._clean_cell(
146
+ facts.get("analysis_method"),
147
+ "Gioia-style inductive coding",
148
+ )
149
+
150
+ md = (
151
+ "### Qualitative Study Design Summary\n"
152
+ "| Field | Frozen value |\n"
153
+ "| :--- | :--- |\n"
154
+ f"| Research setting | {setting} |\n"
155
+ f"| Data sources | {sources} |\n"
156
+ f"| Participants | {participants if participants is not None else 'Not specified'} |\n"
157
+ f"| Interviews | {interviews if interviews is not None else 'Not specified'} |\n"
158
+ f"| Analysis method | {method} |\n\n"
159
+ "[CONSISTENCY MANDATE]\n"
160
+ "Use the study-design summary above as the single source of truth. "
161
+ "Do not change the setting, sample, interview count, data sources, "
162
+ "or analysis method in any section.\n\n"
163
+ "### Table: Qualitative Coding Structure (Data Evidence)\n"
164
+ "| 2nd-order Theme | 1st-order Concept | Representative Quote |\n"
165
+ "| :--- | :--- | :--- |\n"
166
+ )
167
+
168
+ clean_evidence = []
169
+ for index, item in enumerate(evidence[:15]):
170
+ if not isinstance(item, dict):
171
+ continue
172
+ quote = self._clean_cell(item.get("quote"), "").replace('"', "'")
173
+ concept = self._clean_cell(item.get("concept"), "Analysis")
174
+ theme = self._clean_cell(item.get("theme"), "Observation")
175
+ if not quote:
176
+ continue
177
+ tag = f"QUOTE-{index + 1:02d}"
178
+ md += f'| {theme} | {concept} | "{quote}" [{tag}] |\n'
179
+ clean_evidence.append(
180
+ {
181
+ "theme": theme,
182
+ "concept": concept,
183
+ "quote": quote,
184
+ "tag": tag,
185
+ }
186
+ )
187
+
188
+ theme_map = {}
189
+ for item in clean_evidence:
190
+ theme_entry = theme_map.setdefault(
191
+ item["theme"],
192
+ {"title": item["theme"], "concepts": [], "evidence": []},
193
+ )
194
+ if item["concept"] not in theme_entry["concepts"]:
195
+ theme_entry["concepts"].append(item["concept"])
196
+ theme_entry["evidence"].append(
197
+ {"tag": item["tag"], "quote": item["quote"]}
198
+ )
199
+
200
+ themes = list(theme_map.values())
201
+ validation = APP_CONFIG.get("manuscript_validation", {})
202
+ minimum_themes = int(validation.get("minimum_qualitative_themes", 3))
203
+ minimum_quotes = int(validation.get("minimum_qualitative_quotes", 8))
204
+ if len(themes) < minimum_themes or len(clean_evidence) < minimum_quotes:
205
+ raise RuntimeError(
206
+ "Qualitative evidence packet is incomplete: "
207
+ f"themes={len(themes)}, quotes={len(clean_evidence)}; "
208
+ f"required themes>={minimum_themes}, quotes>={minimum_quotes}."
209
+ )
210
+
211
+ metadata = {
212
+ "mode": "qualitative",
213
+ "source_type": (
214
+ "user_provided" if has_user_data else "system_generated"
215
+ ),
216
+ "disclosure_required": not has_user_data,
217
+ "study_facts": {
218
+ "research_setting": setting,
219
+ "data_sources": data_sources,
220
+ "participant_count": participants,
221
+ "interview_count": interviews,
222
+ "analysis_method": method,
223
+ },
224
+ "evidence_count": len(clean_evidence),
225
+ "generation_parameters": {
226
+ "coding_approach": "Gioia-style inductive coding",
227
+ "requested_quote_count_when_generated": 12,
228
+ "maximum_retained_quotes": 15,
229
+ "minimum_themes": minimum_themes,
230
+ "minimum_quotes": minimum_quotes,
231
+ "consistency_constraints": [
232
+ "one fixed research setting",
233
+ "one fixed participant count",
234
+ "one fixed interview count",
235
+ "one fixed data-source set",
236
+ "one fixed analysis method",
237
+ "quotation tags must remain unique",
238
+ ],
239
+ },
240
+ "evidence": clean_evidence,
241
+ "themes": themes,
242
+ }
243
+ status_message = "✅ 定性研究事实、摘要锚点输入和Gioia编码证据已准备完成。"
244
+ return md, status_message, metadata
245
+
246
+
247
+ def get_qualitative_engine(agent):
248
+ return QualitativeEngine(agent)
$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/core/quantitative_engine.py ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """Quantitative data preparation for SmartPaper's AMJ track."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import re
7
+
8
+ import numpy as np
9
+ import pandas as pd
10
+
11
+ from i18n import tr
12
+ from utils.file_parser import extract_data_from_files
13
+
14
+
15
+ class QuantitativeEngine:
16
+ """Keep quantitative preparation isolated from the Streamlit UI."""
17
+
18
+ @staticmethod
19
+ def generate_psd_corr_matrix(n_constructs, max_corr=0.5):
20
+ if n_constructs == 1:
21
+ return np.array([[1.0]])
22
+ base_corr = np.random.uniform(0.2, max_corr - 0.1)
23
+ matrix = np.random.uniform(
24
+ base_corr - 0.1,
25
+ base_corr + 0.1,
26
+ (n_constructs, n_constructs),
27
+ )
28
+ matrix = (matrix + matrix.T) / 2
29
+ np.fill_diagonal(matrix, 1.0)
30
+ min_eig = np.min(np.linalg.eigvals(matrix))
31
+ if min_eig < 0:
32
+ matrix -= (min_eig - 0.01) * np.eye(n_constructs)
33
+ diagonal = np.sqrt(np.diag(matrix))
34
+ return matrix / np.outer(diagonal, diagonal)
35
+
36
+ @staticmethod
37
+ def calculate_validity_metrics(df, constructs_info, true_loadings):
38
+ metrics = []
39
+ for construct in constructs_info:
40
+ construct_name = construct["name"]
41
+ items = construct["items"]
42
+ sub_df = df[items]
43
+ var_total = sub_df.sum(axis=1).var(ddof=1)
44
+ sum_var = sub_df.var(ddof=1).sum()
45
+ n_items = len(items)
46
+ alpha = (
47
+ 0.0
48
+ if var_total <= 0
49
+ else (n_items / (n_items - 1))
50
+ * (1 - sum_var / var_total)
51
+ )
52
+ loadings = np.array(true_loadings[construct_name])
53
+ sum_loadings_sq = np.sum(loadings) ** 2
54
+ sum_sq_loadings = np.sum(loadings**2)
55
+ sum_error = np.sum(1 - loadings**2)
56
+ cr = sum_loadings_sq / (sum_loadings_sq + sum_error)
57
+ ave = sum_sq_loadings / len(loadings)
58
+ metrics.append(
59
+ {
60
+ "Construct": construct_name,
61
+ "Items": n_items,
62
+ "Cronbach's α": round(alpha, 3),
63
+ "CR": round(cr, 3),
64
+ "AVE": round(ave, 3),
65
+ }
66
+ )
67
+ return pd.DataFrame(metrics)
68
+
69
+ def generate_empirical_data_robust(
70
+ self,
71
+ construct_names=None,
72
+ n=300,
73
+ scale_points=7,
74
+ ):
75
+ if not construct_names or len(construct_names) < 2:
76
+ construct_names = [
77
+ "Independent_Var",
78
+ "Mediator_Var",
79
+ "Dependent_Var",
80
+ ]
81
+
82
+ n_constructs = len(construct_names)
83
+ corr = self.generate_psd_corr_matrix(n_constructs, max_corr=0.55)
84
+ latent_scores = np.random.multivariate_normal(
85
+ mean=[0] * n_constructs,
86
+ cov=corr,
87
+ size=n,
88
+ )
89
+ data = {}
90
+ true_loadings = {}
91
+ constructs_info = []
92
+ target_mean = scale_points / 2.0 + 0.5
93
+ target_std = scale_points / 5.0
94
+
95
+ for index, construct_name in enumerate(construct_names):
96
+ true_score = latent_scores[:, index]
97
+ n_items = np.random.randint(3, 6)
98
+ loadings = np.random.uniform(0.72, 0.88, n_items)
99
+ true_loadings[construct_name] = loadings
100
+ item_names = []
101
+ for item_index, loading in enumerate(loadings):
102
+ error = np.random.normal(
103
+ 0,
104
+ np.sqrt(1 - loading**2),
105
+ n,
106
+ )
107
+ item_score = loading * true_score + error
108
+ raw_scaled = item_score * target_std + target_mean
109
+ item_int = np.clip(
110
+ np.round(raw_scaled),
111
+ 1,
112
+ scale_points,
113
+ ).astype(int)
114
+ column_name = f"{construct_name}_{item_index + 1}"
115
+ data[column_name] = item_int
116
+ item_names.append(column_name)
117
+ constructs_info.append(
118
+ {"name": construct_name, "items": item_names}
119
+ )
120
+
121
+ df = pd.DataFrame(data)
122
+ validity_df = self.calculate_validity_metrics(
123
+ df,
124
+ constructs_info,
125
+ true_loadings,
126
+ )
127
+ mean_scores = pd.DataFrame(
128
+ {
129
+ construct["name"]: df[construct["items"]].mean(axis=1)
130
+ for construct in constructs_info
131
+ }
132
+ )
133
+ fl_matrix = mean_scores.corr().astype(object)
134
+
135
+ for _, row in validity_df.iterrows():
136
+ construct_name = row["Construct"]
137
+ sqrt_ave = np.sqrt(row["AVE"])
138
+ fl_matrix.loc[construct_name, construct_name] = (
139
+ f"({sqrt_ave:.3f})"
140
+ )
141
+
142
+ for row_index in range(len(fl_matrix.columns)):
143
+ for column_index in range(row_index + 1, len(fl_matrix.columns)):
144
+ fl_matrix.iloc[row_index, column_index] = ""
145
+
146
+ desc_stats = mean_scores.mean().to_frame("Mean").join(
147
+ mean_scores.std().to_frame("SD")
148
+ )
149
+ return df, validity_df, desc_stats.join(fl_matrix)
150
+
151
+ def resolve_empirical_data(self, uploaded_files=None, language="zh"):
152
+ if uploaded_files:
153
+ user_md = extract_data_from_files(uploaded_files)
154
+ if user_md:
155
+ evidence_sections = [
156
+ heading.strip()
157
+ for heading in re.findall(
158
+ r"(?m)^#{1,3}\s+(.+?)\s*$",
159
+ user_md,
160
+ )
161
+ if heading.strip()
162
+ ]
163
+ metadata = {
164
+ "mode": "quantitative",
165
+ "source_type": "user_provided",
166
+ "disclosure_required": False,
167
+ "evidence_sections": evidence_sections[:20]
168
+ or ["User-provided quantitative evidence"],
169
+ }
170
+ return None, user_md, tr(language, "actual_stats_parsed"), metadata
171
+
172
+ df, validity_df, fl_matrix_df = self.generate_empirical_data_robust()
173
+ generated_prompt = f"""### Table 1: Reliability and Convergent Validity
174
+ {validity_df.to_markdown(index=False)}
175
+ ### Table 2: Descriptive Statistics and Discriminant Validity (Fornell-Larcker Criterion)
176
+ *Note: Diagonal elements in parentheses are the square roots of AVE.*
177
+ {fl_matrix_df.to_markdown()}
178
+ [STRICT EMPIRICAL INSTRUCTION]
179
+ 1. ZERO HALLUCINATION: Report the exact metrics provided above.
180
+ 2. VALIDITY DEFENSE: State convergent validity only when the displayed AVE and CR values support it.
181
+ 3. DISCRIMINANT DEFENSE: State discriminant validity only when the displayed diagonal and correlation values support it.
182
+ 4. CONSISTENCY: Use these tables as the single source of numerical truth throughout the paper.
183
+ """
184
+ metadata = {
185
+ "mode": "quantitative",
186
+ "source_type": "system_generated",
187
+ "disclosure_required": True,
188
+ "sample_size": int(len(df)),
189
+ "scale_points": 7,
190
+ "method": (
191
+ "Positive-semidefinite latent correlation generation, "
192
+ "indicator loadings, discretization, and derived "
193
+ "reliability/validity summaries."
194
+ ),
195
+ "generation_parameters": {
196
+ "constructs": [
197
+ "Independent_Var",
198
+ "Mediator_Var",
199
+ "Dependent_Var",
200
+ ],
201
+ "sample_size": int(len(df)),
202
+ "likert_scale_points": 7,
203
+ "items_per_construct": "random integer from 3 to 5",
204
+ "indicator_loading_range": [0.72, 0.88],
205
+ "latent_correlation_control": "positive-semidefinite matrix with maximum target correlation 0.55",
206
+ "item_generation": "loading × latent score + normally distributed measurement error",
207
+ "discretization": "round to nearest integer and clamp to 1–7",
208
+ "reported_summaries": [
209
+ "Cronbach alpha",
210
+ "composite reliability",
211
+ "average variance extracted",
212
+ "descriptive statistics",
213
+ "Fornell-Larcker matrix",
214
+ ],
215
+ },
216
+ "evidence_sections": [
217
+ "Reliability and convergent validity",
218
+ "Descriptive statistics and discriminant validity",
219
+ ],
220
+ }
221
+ return (
222
+ df,
223
+ generated_prompt,
224
+ tr(language, "synthetic_stats_generated"),
225
+ metadata,
226
+ )
227
+
228
+
229
+ def get_quantitative_engine():
230
+ return QuantitativeEngine()
$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/core/rag_engine.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ from typing import List
3
+ import numpy as np
4
+ from config import APP_CONFIG
5
+
6
+ try:
7
+ from sklearn.feature_extraction.text import TfidfVectorizer
8
+ from sklearn.metrics.pairwise import cosine_similarity
9
+ SKLEARN_AVAILABLE = True
10
+ except ImportError:
11
+ SKLEARN_AVAILABLE = False
12
+
13
+ class ResearchRAGEngine:
14
+ def __init__(self, raw_texts: List[str]):
15
+ self.chunks = []
16
+ self.vectorizer = None
17
+ self.tfidf_matrix = None
18
+ self.chunk_size = APP_CONFIG["rag_chunk_size"]
19
+ self._initialize_chunks(raw_texts)
20
+
21
+ def _initialize_chunks(self, raw_texts: List[str]):
22
+ for text in raw_texts:
23
+ for i in range(0, len(text), self.chunk_size):
24
+ chunk = text[i:i + self.chunk_size].strip()
25
+ if len(chunk) > 50:
26
+ self.chunks.append(chunk)
27
+
28
+ if SKLEARN_AVAILABLE and self.chunks:
29
+ self.vectorizer = TfidfVectorizer(stop_words='english', max_df=0.85)
30
+ try:
31
+ self.tfidf_matrix = self.vectorizer.fit_transform(self.chunks)
32
+ except ValueError:
33
+ self.vectorizer = None
34
+
35
+ def get_relevant_context(self, query: str, top_k: int = None) -> str:
36
+ if not self.chunks:
37
+ return ""
38
+ if top_k is None:
39
+ top_k = APP_CONFIG["rag_top_k"]
40
+
41
+ relevant_chunks = []
42
+ if self.vectorizer and self.tfidf_matrix is not None:
43
+ query_vec = self.vectorizer.transform([query])
44
+ similarities = cosine_similarity(query_vec, self.tfidf_matrix).flatten()
45
+ top_indices = similarities.argsort()[-top_k:][::-1]
46
+ relevant_chunks = [self.chunks[i] for i in top_indices if similarities[i] > 0.05]
47
+ else:
48
+ scores = []
49
+ query_words = set(query.lower().split())
50
+ for chunk in self.chunks:
51
+ chunk_lower = chunk.lower()
52
+ score = sum(1 for word in query_words if word in chunk_lower)
53
+ scores.append(score)
54
+ top_indices = np.argsort(scores)[-top_k:]
55
+ relevant_chunks = [self.chunks[i] for i in top_indices if scores[i] > 0]
56
+
57
+ if not relevant_chunks:
58
+ return ""
59
+ return "\n--- Target Context Snippet ---\n" + "\n\n".join(relevant_chunks)
60
+
61
+ def initialize_rag(texts: List[str]):
62
+ return ResearchRAGEngine(texts)
$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/desktop.ini ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ [.ShellClassInfo]
2
+ IconResource=C:\Program Files\Google\Drive File Stream\129.0.1.0\GoogleDriveFS.exe,27
$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/i18n.py ADDED
@@ -0,0 +1,856 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """SmartPaper user-interface translations.
3
+
4
+ The Chinese interface is the visual baseline. Other languages replace text only;
5
+ Streamlit component order, icons, layout, and interaction remain unchanged.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+
13
+ LANGUAGE_OPTIONS = {
14
+ "中文": "zh",
15
+ "English": "en",
16
+ "Русский": "ru",
17
+ "Қазақша": "kk",
18
+ }
19
+
20
+ TARGET_LANGUAGE = {
21
+ "zh": "Simplified Chinese (简体中文)",
22
+ # Approved product rule: English UI still delivers English evidence draft + Chinese paper.
23
+ "en": "Simplified Chinese (简体中文)",
24
+ "ru": "Russian (Русский)",
25
+ "kk": "Kazakh (Қазақша)",
26
+ }
27
+
28
+ OUTPUT_SUFFIX = {
29
+ "zh": "CN",
30
+ "en": "CN",
31
+ "ru": "RU",
32
+ "kk": "KK",
33
+ }
34
+
35
+ TARGET_FLAG = {
36
+ "zh": "🇨🇳",
37
+ "en": "🇨🇳",
38
+ "ru": "🇷🇺",
39
+ "kk": "🇰🇿",
40
+ }
41
+
42
+
43
+
44
+ AMJ_CSV_TEMPLATES = {
45
+ "zh": """--- 表1:描述性统计与相关系数 ---
46
+ Variables,Mean,SD,1,2,3
47
+ Team Cohesion,4.21,0.85,(0.88),,
48
+ AI Integration,3.95,0.91,0.45**,(0.82),
49
+ Task Performance,4.10,0.78,0.52**,0.38*,(0.85)
50
+
51
+ --- 表2:信效度检验 ---
52
+ Construct,Items,Cronbach's Alpha,CR,AVE
53
+ Team Cohesion,4,0.89,0.91,0.71
54
+ AI Integration,3,0.85,0.88,0.68
55
+
56
+ --- 表3:假设检验 ---
57
+ Hypothesis,Path,Beta,T-value,P-value,Supported?
58
+ H1,AI Integration -> Team Cohesion,0.35,4.21,0.000,Yes""",
59
+ "en": """--- Table 1: Descriptive Statistics and Correlations ---
60
+ Variables,Mean,SD,1,2,3
61
+ Team Cohesion,4.21,0.85,(0.88),,
62
+ AI Integration,3.95,0.91,0.45**,(0.82),
63
+ Task Performance,4.10,0.78,0.52**,0.38*,(0.85)
64
+
65
+ --- Table 2: Reliability and Validity ---
66
+ Construct,Items,Cronbach's Alpha,CR,AVE
67
+ Team Cohesion,4,0.89,0.91,0.71
68
+ AI Integration,3,0.85,0.88,0.68
69
+
70
+ --- Table 3: Hypothesis Tests ---
71
+ Hypothesis,Path,Beta,T-value,P-value,Supported?
72
+ H1,AI Integration -> Team Cohesion,0.35,4.21,0.000,Yes""",
73
+ "ru": """--- Таблица 1: Описательная статистика и корреляции ---
74
+ Переменные,Среднее,SD,1,2,3
75
+ Сплочённость команды,4.21,0.85,(0.88),,
76
+ Интеграция ИИ,3.95,0.91,0.45**,(0.82),
77
+ Результативность задачи,4.10,0.78,0.52**,0.38*,(0.85)
78
+
79
+ --- Таблица 2: Надёжность и валидность ---
80
+ Конструкт,Пункты,Cronbach's Alpha,CR,AVE
81
+ Сплочённость команды,4,0.89,0.91,0.71
82
+ Интеграция ИИ,3,0.85,0.88,0.68
83
+
84
+ --- Таблица 3: Проверка гипотез ---
85
+ Гипотеза,Связь,Beta,T-значение,P-значение,Поддержана?
86
+ H1,Интеграция ИИ -> Сплочённость команды,0.35,4.21,0.000,Да""",
87
+ "kk": """--- 1-кесте: Сипаттамалық статистика және корреляциялар ---
88
+ Айнымалылар,Орташа,SD,1,2,3
89
+ Команда бірлігі,4.21,0.85,(0.88),,
90
+ ЖИ интеграциясы,3.95,0.91,0.45**,(0.82),
91
+ Тапсырма нәтижелілігі,4.10,0.78,0.52**,0.38*,(0.85)
92
+
93
+ --- 2-кесте: Сенімділік және валидтілік ---
94
+ Конструкт,Тармақтар,Cronbach's Alpha,CR,AVE
95
+ Команда бірлігі,4,0.89,0.91,0.71
96
+ ЖИ интеграциясы,3,0.85,0.88,0.68
97
+
98
+ --- 3-кесте: Гипотезаларды тексеру ---
99
+ Гипотеза,Байланыс,Beta,T-мәні,P-мәні,Қолдау бар ма?
100
+ H1,ЖИ интеграциясы -> Команда бірлігі,0.35,4.21,0.000,Иә""",
101
+ }
102
+
103
+ ASQ_TXT_TEMPLATES = {
104
+ "zh": """[访谈编号]:INT-01
105
+ [受访者岗位]:高级软件工程师
106
+ [访谈日期]:2026-03-12
107
+ [访谈时长]:45分钟
108
+
109
+ 访谈者:生成式AI助手的引入如何改变了团队的日常敏捷站会?
110
+
111
+ 受访者(INT-01):[在这里输入受访者的完整回答]
112
+
113
+ 访谈者:[在这里输入后续问题]
114
+
115
+ 受访者(INT-01):[在这里输入后续回答]
116
+
117
+ --------------------------------------------------
118
+
119
+ [访谈编号]:INT-02
120
+ [受访者岗位]:敏捷教练
121
+ [访谈日期]:2026-03-15
122
+ [访谈时长]:60分钟
123
+
124
+ 访谈者:[在这里输入问题]
125
+
126
+ 受访者(INT-02):[在这里输入回答]""",
127
+ "en": """[Interview ID]: INT-01
128
+ [Informant Role]: Senior Software Engineer
129
+ [Interview Date]: 2026-03-12
130
+ [Interview Duration]: 45 minutes
131
+
132
+ Interviewer: How did the introduction of a generative-AI assistant change the team's daily stand-up meetings?
133
+
134
+ Informant (INT-01): [Enter the informant's complete response here]
135
+
136
+ Interviewer: [Enter the follow-up question here]
137
+
138
+ Informant (INT-01): [Enter the follow-up response here]
139
+
140
+ --------------------------------------------------
141
+
142
+ [Interview ID]: INT-02
143
+ [Informant Role]: Scrum Master
144
+ [Interview Date]: 2026-03-15
145
+ [Interview Duration]: 60 minutes
146
+
147
+ Interviewer: [Enter the question here]
148
+
149
+ Informant (INT-02): [Enter the response here]""",
150
+ "ru": """[Номер интервью]: INT-01
151
+ [Роль информанта]: Старший инженер-программист
152
+ [Дата интервью]: 2026-03-12
153
+ [Продолжительность]: 45 минут
154
+
155
+ Интервьюер: Как внедрение помощника на основе генеративного ИИ изменило ежедневные стендап-встречи команды?
156
+
157
+ Информант (INT-01): [Введите полный ответ информанта]
158
+
159
+ Интервьюер: [Введите уточняющий вопрос]
160
+
161
+ Информант (INT-01): [Введите последующий ответ]
162
+
163
+ --------------------------------------------------
164
+
165
+ [Номер интервью]: INT-02
166
+ [Роль информанта]: Скрам-мастер
167
+ [Дата интервью]: 2026-03-15
168
+ [Продолжительность]: 60 минут
169
+
170
+ Интервьюер: [Введите вопрос]
171
+
172
+ Информант (INT-02): [Введите ответ]""",
173
+ "kk": """[Сұхбат нөмірі]: INT-01
174
+ [Ақпарат берушінің рөлі]: Аға бағдарламалық жасақтама инженері
175
+ [Сұхбат күні]: 2026-03-12
176
+ [Ұзақтығы]: 45 минут
177
+
178
+ Сұхбат алушы: Генеративті ЖИ көмекшісін енгізу команданың күнделікті стендап жиналыстарына қалай әсер етті?
179
+
180
+ Ақпарат беруші (INT-01): [Ақпарат берушінің толық жауабын осында енгізіңіз]
181
+
182
+ Сұхбат алушы: [Қосымша сұрақты осында енгізіңіз]
183
+
184
+ Ақпарат беруші (INT-01): [Қосымша жауапты осында енгізіңіз]
185
+
186
+ --------------------------------------------------
187
+
188
+ [Сұхбат нөмірі]: INT-02
189
+ [Ақпарат берушінің рөлі]: Scrum-мастер
190
+ [Сұхбат күні]: 2026-03-15
191
+ [Ұзақтығы]: 60 минут
192
+
193
+ Сұхбат алушы: [Сұрақты осында енгізіңіз]
194
+
195
+ Ақпарат беруші (INT-02): [Жауапты осында енгізіңіз]""",
196
+ }
197
+
198
+ REFERENCE_HEADINGS = {
199
+ "zh": "参考文献",
200
+ "en": "参考文献",
201
+ "ru": "Список литературы",
202
+ "kk": "Пайдаланылған әдебиеттер",
203
+ }
204
+
205
+
206
+ TEXTS: dict[str, dict[str, str]] = {
207
+ "zh": {
208
+ "title": "📊 工商管理论文智能体",
209
+ "topic_label": "📝 论文名称",
210
+ "keywords_label": "🔍 关键字",
211
+ "words_label": "📏 目标总单词数(12000–25000单词)",
212
+ "success": "🎉 流水线推演完毕!双语文档已就绪。",
213
+ "download": "📥 下载文件",
214
+ "system_busy_title": "⏳ 系统忙碌",
215
+ "system_busy_warning": "🚨 有用户正在生成论文",
216
+ "system_busy_info": "💡 预计25–35分钟后可正常使用",
217
+ "checkpoint_warning": "⚠️ 系统检测到上次因网络或意外中断的未完成论文任务。",
218
+ "resume": "🔄 恢复断点续传",
219
+ "clear": "🗑️ 清除临时数据",
220
+ "busy_short": "⏳ 有用户正在生成论文,预计25–35分钟后可用。",
221
+ "tab_amj": "✨ 定量研究(AMJ 引擎)",
222
+ "tab_asq": "📝 定性研究(ASQ 引擎)",
223
+ "amj_config": "### 📊 定量研究配置(Academy of Management Journal Style)",
224
+ "amj_info": "💡 该模式聚焦理论演绎、假设检验与因果推断。结构锁定为:引言、理论与假设、方法、结果、讨论、结论。",
225
+ "upload_reference": "1. 上传核心参考论文(可选,仅限PDF/DOCX,建议1–20篇)",
226
+ "upload_stats": "2. 上传统计结果表(可选,仅限Excel/CSV)",
227
+ "amj_expander": "💡 不知道怎么整理数据?点击查看并下载:定量数据(CSV)标准模板",
228
+ "amj_hint": "**⚠️ 核心提示**:请上传**统计结果表**(如信效度、相关系数、路径系数),**请勿**上传成千上万行的原始问卷明细,以免触发系统防超载截断。",
229
+ "download_amj_template": "📥 一键下载 CSV 数据模板",
230
+ "start_amj": "🚀 启动 AMJ 模式生成",
231
+ "asq_config": "### 📝 定性研究配置(Administrative Science Quarterly Style)",
232
+ "asq_info": "💡 该模式聚焦现象发现、意义建构与扎根理论(Gioia方法)。结构锁定为:引言、理论框架、研究情境与方法、发现(厚数据叙事)、讨论、结论、附录(证据表)。",
233
+ "upload_interviews": "2. 上传真实访谈/田野资料(可选,仅限TXT/PDF/DOCX)",
234
+ "asq_expander": "💡 不知道怎么整理访谈?点击查看并下载:定性访谈记录(TXT)标准模板",
235
+ "asq_hint": "**⚠️ 核心提示**:为了让AI准确提取语录和编码树,请务必标注**受访者角色(Role)**,并采用清晰的**对话格式**。",
236
+ "download_asq_template": "📥 一键下载 TXT 访谈模板",
237
+ "start_asq": "🚀 ��动 ASQ 模式生成",
238
+ "api_key_missing": "❌ 缺少 DEEPSEEK_API_KEY,请配置API密钥。",
239
+ "resume_toast": "🔄 检测到页面刷新或网络抖动,系统正在自动恢复进度……",
240
+ "pipeline_start": "⏳ 流水线启动……",
241
+ "resume_loading": "🔄 正在从本地安全快照恢复状态……",
242
+ "resume_success": "✅ 断点加载成功!将从节点 {done}/{total} 安全恢复。",
243
+ "resume_phase": "断点恢复",
244
+ "state_check": "状态校验",
245
+ "history_restored": "✅ 已找回历史算力消耗数据",
246
+ "extract_quant": "⚙️ 正在解析定量实证数据……",
247
+ "literature_extract": "🧠 正在从文献中提取核心数据并构建RAG索引……",
248
+ "literature_deep": "**深度文献提炼中,请勿关闭页面**",
249
+ "literature_done": "✅ 核心文献特征提炼完成!",
250
+ "config_route": "🕵️ 引擎配置与范式路由……",
251
+ "literature_search": "📚 智能文献检索……",
252
+ "search_phase": "文献检索",
253
+ "search_detail": "深度并发检索中……",
254
+ "refs_insufficient": "🚨 终止生成:仅整合到 {count} 篇有效文献,未达到 {required} 篇最低要求。",
255
+ "refs_success": "✅ 文献扩容成功!已获取 {count} 篇有效文献。",
256
+ "rag_sync": "🧠 正在将API扩容文献同步注入RAG引擎……",
257
+ "rag_done": "✅ 扩容文献已完成向量化并注入RAG引擎。",
258
+ "core_refs_done": "✅ 核心参考论文引文已注入最高优先级。",
259
+ "synthesis_progress": "🧠 文献综合……",
260
+ "synthesis_status": "🧠 正在综合核心文献并识别研究空白……",
261
+ "synthesis_done": "✅ 文献综合完成!",
262
+ "anchor_progress": "⚓ 生成锚点……",
263
+ "anchor_status": "⚓ 正在推演核心摘要与结论锚点,请耐心等待……",
264
+ "anchor_done": "✅ 锚点推演完成!",
265
+ "outline_progress": "📝 大纲设计与路由分发……",
266
+ "jit_status": "🔄 正在为方法与结果章节预加载专属文献……",
267
+ "jit_done": "✅ 方法论预加载完成,已注入向量库。",
268
+ "english_draft": "#### 🇬🇧 英文证据母稿",
269
+ "target_draft": "#### {flag} 中文论文",
270
+ "restored": "✅ 已从快照安全恢复",
271
+ "waiting": "⏳ 等待执行……",
272
+ "translation_waiting": "⏳ 等待后台异步翻译……",
273
+ "sync_previous": "⏳ 正在同步保存上一节【{name}】的翻译状态……",
274
+ "engine_amj": "📊 AMJ 定量引擎",
275
+ "engine_asq": "📝 ASQ 定性引擎",
276
+ "drafting_phase": "英文撰写({current}/{total})",
277
+ "target_words": "目标:{words}({engine})",
278
+ "draft_status": "🤖 引擎启动:正在撰写【{name}】……",
279
+ "api_requested": "📡 已发起 DeepSeek API 请求……",
280
+ "model_writing": "✍️ 当前节点使用 {model} 执笔……",
281
+ "draft_done": "✅ 【{name}】撰写完毕!",
282
+ "word_count": "📊 本章节实际字数:{actual}(目标:{target}),累计偏差:{deviation:+d}",
283
+ "translation_running": "⏳ 英文已完成,正在后台异步翻译……",
284
+ "node_error": "🚨 节点异常已中止。",
285
+ "node_error_detail": "错误代码:{code}|{error}|系统已保存断点,可继续恢复。",
286
+ "task_cancelled": "任务已取消,断点和临时文件已删除。",
287
+ "task_start_failed": "无法启动论文任务:{error}",
288
+ "task_recover_failed": "无法恢复上次任务:{error}",
289
+ "search_source_warning": "{source}检索异常:{error}",
290
+ "source_metric_summary": "请求{request}·成功{success}·返回{raw}·新增{unique}·重复{duplicate}·错误{errors}",
291
+ "sync_last": "⏳ 正在同步保存最后一节【{name}】的翻译状态……",
292
+ "pack_docx": "📦 正在打包DOCX……",
293
+ "total_words": "📊 全文总字数:{actual}(目标区间:{lower}–{upper})",
294
+ "complete": "✅ 完成",
295
+ "document_generated": "文档已生成",
296
+ "startup_error": "🚨 启动或恢复异常:{error}",
297
+ "translation_error": "🚨 后台翻译异常",
298
+ "monitor_pipeline": "🔄 流水线进度",
299
+ "monitor_node": "📍 当前节点",
300
+ "monitor_log": "📝 动作日志",
301
+ "monitor_compute": "📡 三维算力消耗监控",
302
+ "actual_stats_parsed": "✅ 已成功解析用户上传的实证数据。",
303
+ "synthetic_stats_generated": "⚙️ 未上传统计结果,系统已生成可用于论文结构与结果写作的统计数据。",
304
+ "qualitative_ready": "✅ 定性材料已准备完成。",
305
+ "core_literature_parsing": "🧠 正在深度解析用户上传的核心文献……",
306
+ "section_complete_progress": "🇬🇧 【{name}】英文完成,翻译任务已进入后台。",
307
+ "login_title": "SmartPaper 登录",
308
+ "service_description": "提供中文、英语、俄语、哈萨克语四种语言服务。",
309
+ "secure_login_notice": "安全登录仅支持支付宝 App 扫码。登录后系统对数据加密,保护您的隐私。",
310
+ "secure_login_title": "支付宝安全登录",
311
+ "secure_login_instructions": "",
312
+ "secure_login_privacy": "安全登录后,系统会对数据加密,保护您的隐私。",
313
+ "secure_login_alipay_only": "安全登录仅支持支付宝 App 扫码。",
314
+ "secure_login_open_current_device": "在当前设备打开支付宝授权",
315
+ "login_qr_fixed_notice": "专属登录二维码在有效期内保持不变;系统只检查登录状态,不会动态更换二维码。",
316
+ "login_info": "扫码即可安全登录。授权成功后,系统会自动进入论文写作界面。",
317
+ "login_creating": "正在创建安全登录请求……",
318
+ "login_create_error": "无法创建登录请求:{error}",
319
+ "login_service_timeout": "登录服务响应较慢,请稍后重试。",
320
+ "login_service_connection_error": "暂时无法连接登录服务,请检查网络后重试。",
321
+ "login_reconnect": "重新连接登录服务",
322
+ "login_open": "打开支付宝扫码登录",
323
+ "login_caption": "请在新页面使用手机支付宝扫码并确认授权。授权完成后,SmartPaper 将自动进入论文写作界面。",
324
+ "login_auto_waiting": "正在等待支付宝授权。确认授权后将自动进入论文写作界面。",
325
+ "login_auto_network_wait": "登录服务暂时繁忙,系统正在自动重试:{error}",
326
+ "login_auto_success": "登录成功,正在进入论文写作界面……",
327
+ "login_auto_failed": "自动登录未完成:{error}",
328
+ "login_auto_timeout": "本次登录请求已超时,请重新生成登录请求。",
329
+ "login_check": "我已完成扫码,检查登录",
330
+ "login_checking": "正在确认登录状态……",
331
+ "login_check_error": "登录检查失败:{error}",
332
+ "login_regenerate": "重新生成登录请求",
333
+ "login_pending": "支付宝授权尚未完成,请扫码并确认授权。",
334
+ "account_title": "用户账户",
335
+ "account_id": "用户编号:**{value}**",
336
+ "daily_free": "每日免费:**{value}篇**",
337
+ "bonus_papers": "长期有效额度:**{value}篇**",
338
+ "account_chip_user": "用户 {value}",
339
+ "account_chip_free": "免费 {value}篇",
340
+ "account_chip_bonus": "长期 {value}篇",
341
+ "logout_short": "退出",
342
+ "payment_dialog_title": "支付宝付款",
343
+ "payment_notice": "今日免费额度和长期额度均已用完。支付9.9元可增加3篇长期有效额度。",
344
+ "quota_exhausted": "今日免费额度和长期额度均已用完。请使用支付宝购买长期额度。",
345
+ "payment_create": "生成支付宝付款二维码",
346
+ "payment_creating": "正在创建支付宝订单……",
347
+ "payment_scan": "请使用支付宝 App 扫码支付。支付成功后点击“查询支付结果”。",
348
+ "payment_check": "查询支付结果",
349
+ "payment_paid": "支付成功,已增加3篇长期有效额度。请重新点击生成论文。",
350
+ "payment_expired": "付款二维码已过期,请重新生成。",
351
+ "browse_files": "选择文件",
352
+ "drop_docs": "将文件拖放到此处",
353
+ "drop_data": "将文件拖放到此处",
354
+ "drop_interviews": "将文件拖放到此处",
355
+ "limit_docs": "单个文件上限 1GB · PDF、DOC、DOCX",
356
+ "limit_data": "单个文件上限 1GB · CSV、XLSX、XLS",
357
+ "limit_interviews": "单个文件上限 1GB · TXT、PDF、DOC、DOCX",
358
+ "logout": "退出登录",
359
+ },
360
+ "en": {
361
+ "title": "📊 Business Administration Article Agent",
362
+ "topic_label": "📝 Article Title",
363
+ "keywords_label": "🔍 Keywords",
364
+ "words_label": "📏 Target Word Count (12,000–25,000 words)",
365
+ "success": "🎉 The pipeline is complete. The bilingual documents are ready.",
366
+ "download": "📥 Download Files",
367
+ "system_busy_title": "⏳ System Busy",
368
+ "system_busy_warning": "🚨 Another user is generating a paper",
369
+ "system_busy_info": "💡 The system is expected to be available in 25–35 minutes",
370
+ "checkpoint_warning": "⚠️ An unfinished paper task from a network or unexpected interruption was detected.",
371
+ "resume": "🔄 Resume from Checkpoint",
372
+ "clear": "🗑️ Clear Temporary Data",
373
+ "busy_short": "⏳ Another user is generating a paper. Please try again in about 25–35 minutes.",
374
+ "tab_amj": "✨ Quantitative Research (AMJ Engine)",
375
+ "tab_asq": "📝 Qualitative Research (ASQ Engine)",
376
+ "amj_config": "### 📊 Quantitative Research Configuration (Academy of Management Journal Style)",
377
+ "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.",
378
+ "upload_reference": "1. Upload Core Reference Papers (optional; PDF/DOCX only; 1–20 papers recommended)",
379
+ "upload_stats": "2. Upload Statistical Results Tables (optional; Excel/CSV only)",
380
+ "amj_expander": "💡 Need help organizing data? View and download the standard quantitative-data CSV template",
381
+ "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.",
382
+ "download_amj_template": "📥 Download CSV Data Template",
383
+ "start_amj": "🚀 Start AMJ Generation",
384
+ "asq_config": "### 📝 Qualitative Research Configuration (Administrative Science Quarterly Style)",
385
+ "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.",
386
+ "upload_interviews": "2. Upload Actual Interview or Field Materials (optional; TXT/PDF/DOCX only)",
387
+ "asq_expander": "💡 Need help organizing interviews? View and download the standard TXT interview template",
388
+ "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.",
389
+ "download_asq_template": "📥 Download TXT Interview Template",
390
+ "start_asq": "🚀 Start ASQ Generation",
391
+ "api_key_missing": "❌ DEEPSEEK_API_KEY is missing. Configure the API key.",
392
+ "resume_toast": "🔄 A page refresh or network interruption was detected. Progress is being restored automatically…",
393
+ "pipeline_start": "⏳ Starting the pipeline…",
394
+ "resume_loading": "🔄 Restoring the secure local checkpoint…",
395
+ "resume_success": "✅ Checkpoint restored. Work will resume from node {done}/{total}.",
396
+ "resume_phase": "Checkpoint recovery",
397
+ "state_check": "State validation",
398
+ "history_restored": "✅ Previous compute-usage data restored",
399
+ "extract_quant": "⚙️ Parsing quantitative results…",
400
+ "literature_extract": "🧠 Extracting core information and building the RAG index…",
401
+ "literature_deep": "**Deep literature extraction in progress. Do not close this page**",
402
+ "literature_done": "✅ Core literature features extracted.",
403
+ "config_route": "🕵️ Configuring the engine and research paradigm…",
404
+ "literature_search": "📚 Searching the academic literature…",
405
+ "search_phase": "Literature search",
406
+ "search_detail": "Running concurrent searches…",
407
+ "refs_insufficient": "🚨 Generation stopped: only {count} valid references were integrated; at least {required} are required.",
408
+ "refs_success": "✅ Literature expansion complete: {count} valid references obtained.",
409
+ "rag_sync": "🧠 Adding the expanded literature to the RAG engine…",
410
+ "rag_done": "✅ Expanded literature vectorized and added to the RAG engine.",
411
+ "core_refs_done": "✅ Core user-provided references assigned highest priority.",
412
+ "synthesis_progress": "🧠 Synthesizing the literature…",
413
+ "synthesis_status": "🧠 Synthesizing the literature and identifying the research gap…",
414
+ "synthesis_done": "✅ Literature synthesis complete.",
415
+ "anchor_progress": "⚓ Generating anchors…",
416
+ "anchor_status": "⚓ Developing the abstract and conclusion anchors. Please wait…",
417
+ "anchor_done": "✅ Anchor development complete.",
418
+ "outline_progress": "📝 Designing the outline and routing sections…",
419
+ "jit_status": "🔄 Preloading literature for the Method and Results sections…",
420
+ "jit_done": "✅ Method literature preloaded and added to the vector store.",
421
+ "english_draft": "#### 🇬🇧 English Evidence Draft",
422
+ "target_draft": "#### {flag} Chinese Paper",
423
+ "restored": "✅ Restored from checkpoint",
424
+ "waiting": "⏳ Waiting…",
425
+ "translation_waiting": "⏳ Waiting for background translation…",
426
+ "sync_previous": "⏳ Saving the translation state for the previous section, 【{name}】…",
427
+ "engine_amj": "📊 AMJ Quantitative Engine",
428
+ "engine_asq": "📝 ASQ Qualitative Engine",
429
+ "drafting_phase": "English drafting ({current}/{total})",
430
+ "target_words": "Target: {words} words ({engine})",
431
+ "draft_status": "🤖 Drafting 【{name}】…",
432
+ "api_requested": "📡 DeepSeek API request submitted…",
433
+ "model_writing": "✍️ Writing this node with {model}…",
434
+ "draft_done": "✅ 【{name}】 completed.",
435
+ "word_count": "📊 Section word count: {actual} (target: {target}); cumulative deviation: {deviation:+d}",
436
+ "translation_running": "⏳ English draft complete. Background translation is running…",
437
+ "node_error": "🚨 The node stopped because of an error.",
438
+ "node_error_detail": "Error code: {code} | {error} | A checkpoint has been saved and can be resumed.",
439
+ "task_cancelled": "The task was cancelled. Its checkpoint and temporary files were deleted.",
440
+ "task_start_failed": "The paper task could not be started: {error}",
441
+ "task_recover_failed": "The previous task could not be recovered: {error}",
442
+ "search_source_warning": "{source} search error: {error}",
443
+ "source_metric_summary": "Req {request} · OK {success} · Raw {raw} · New {unique} · Dup {duplicate} · Err {errors}",
444
+ "sync_last": "⏳ Saving the translation state for the final section, 【{name}】…",
445
+ "pack_docx": "📦 Packaging DOCX files…",
446
+ "total_words": "📊 Total English word count: {actual} (target range: {lower}–{upper})",
447
+ "complete": "✅ Complete",
448
+ "document_generated": "Documents generated",
449
+ "startup_error": "🚨 Startup or recovery error: {error}",
450
+ "translation_error": "🚨 Background translation error",
451
+ "monitor_pipeline": "🔄 Pipeline progress",
452
+ "monitor_node": "📍 Current node",
453
+ "monitor_log": "📝 Activity log",
454
+ "monitor_compute": "📡 Compute-usage monitor",
455
+ "actual_stats_parsed": "✅ The uploaded empirical results were parsed successfully.",
456
+ "synthetic_stats_generated": "⚙️ No statistical results were uploaded. The system generated statistical data for the paper structure and Results section.",
457
+ "qualitative_ready": "✅ Qualitative materials are ready.",
458
+ "core_literature_parsing": "🧠 Parsing the uploaded core literature in depth…",
459
+ "section_complete_progress": "🇬🇧 English section 【{name}】 complete; translation is running in the background.",
460
+ "login_title": "Sign in to SmartPaper",
461
+ "service_description": "Services are available in Chinese, English, Russian, and Kazakh.",
462
+ "secure_login_notice": "Secure sign-in supports the Alipay app only. After sign-in, the system encrypts data to protect your privacy.",
463
+ "secure_login_title": "Secure Sign-in with Alipay",
464
+ "secure_login_instructions": "",
465
+ "secure_login_privacy": "After secure sign-in, the system encrypts your data to protect your privacy.",
466
+ "secure_login_alipay_only": "Secure sign-in supports the Alipay app only.",
467
+ "secure_login_open_current_device": "Open Alipay authorization on this device",
468
+ "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.",
469
+ "login_info": "Scan to sign in securely. SmartPaper will open the writing interface automatically after authorization.",
470
+ "login_creating": "Creating a secure sign-in request…",
471
+ "login_create_error": "Unable to create the sign-in request: {error}",
472
+ "login_service_timeout": "The sign-in service is taking longer than expected. Please try again.",
473
+ "login_service_connection_error": "The sign-in service is temporarily unavailable. Check your connection and try again.",
474
+ "login_reconnect": "Reconnect to Sign-in Service",
475
+ "login_open": "Open Alipay QR Sign-in",
476
+ "login_caption": "Use Alipay on your phone to scan and authorize on the new page. SmartPaper will open the writing interface automatically after authorization.",
477
+ "login_auto_waiting": "Waiting for Alipay authorization. The writing interface will open automatically.",
478
+ "login_auto_network_wait": "The sign-in service is temporarily busy. Retrying automatically: {error}",
479
+ "login_auto_success": "Signed in. Opening the paper-writing interface…",
480
+ "login_auto_failed": "Automatic sign-in was not completed: {error}",
481
+ "login_auto_timeout": "This sign-in request has expired. Create a new request.",
482
+ "login_check": "I Have Authorized — Check Sign-in",
483
+ "login_checking": "Checking sign-in status…",
484
+ "login_check_error": "Sign-in check failed: {error}",
485
+ "login_regenerate": "Generate a New Sign-in Request",
486
+ "login_pending": "Alipay authorization is still pending. Scan the code and confirm authorization.",
487
+ "account_title": "User Account",
488
+ "account_id": "User ID: **{value}**",
489
+ "daily_free": "Daily free allowance: **{value} papers**",
490
+ "bonus_papers": "Long-term allowance: **{value} papers**",
491
+ "account_chip_user": "User {value}",
492
+ "account_chip_free": "Free {value}",
493
+ "account_chip_bonus": "Bonus {value}",
494
+ "logout_short": "Sign out",
495
+ "payment_dialog_title": "Alipay Payment",
496
+ "payment_notice": "Your daily free quota and long-term quota are exhausted. Pay CNY 9.9 to add three long-term papers.",
497
+ "quota_exhausted": "Your daily free quota and long-term quota are exhausted. Purchase long-term quota with Alipay.",
498
+ "payment_create": "Create Alipay QR code",
499
+ "payment_creating": "Creating the Alipay order…",
500
+ "payment_scan": "Scan the code with the Alipay app. After payment, select “Check payment result.”",
501
+ "payment_check": "Check payment result",
502
+ "payment_paid": "Payment succeeded. Three long-term papers were added. Start the paper again.",
503
+ "payment_expired": "The payment QR code has expired. Create a new one.",
504
+ "browse_files": "Choose files",
505
+ "drop_docs": "Drag and drop files here",
506
+ "drop_data": "Drag and drop files here",
507
+ "drop_interviews": "Drag and drop files here",
508
+ "limit_docs": "Limit 1 GB per file · PDF, DOC, DOCX",
509
+ "limit_data": "Limit 1 GB per file · CSV, XLSX, XLS",
510
+ "limit_interviews": "Limit 1 GB per file · TXT, PDF, DOC, DOCX",
511
+ "logout": "Sign Out",
512
+ },
513
+ "ru": {
514
+ "title": "📊 Интеллектуальный агент для научных статей по управлению бизнесом",
515
+ "topic_label": "📝 Название статьи",
516
+ "keywords_label": "🔍 Ключевые слова",
517
+ "words_label": "📏 Целевой объём (12 000–25 000 слов)",
518
+ "success": "🎉 Работа конвейера завершена. Двуязычные документы готовы.",
519
+ "download": "📥 Скачать файлы",
520
+ "system_busy_title": "⏳ Система занята",
521
+ "system_busy_warning": "🚨 Другой пользователь формирует статью",
522
+ "system_busy_info": "💡 Ожидаемое время доступности — через 25–35 минут",
523
+ "checkpoint_warning": "⚠️ Обнаружена незавершённая задача, прерванная из-за сети или другой ошибки.",
524
+ "resume": "🔄 Продолжить с контрольной точки",
525
+ "clear": "🗑️ Удалить временные данные",
526
+ "busy_short": "⏳ Другой пользователь формирует статью. Повторите попытку примерно через 25–35 минут.",
527
+ "tab_amj": "✨ Количественное исследование (движок AMJ)",
528
+ "tab_asq": "📝 Качественное исследование (движок ASQ)",
529
+ "amj_config": "### 📊 Настройка количественного исследования (стиль Academy of Management Journal)",
530
+ "amj_info": "💡 Режим ориентирован на теоретическую дедукцию, проверку гипотез и причинно-следственный вывод. Структура фиксирована: введение, теория и гипотезы, метод, результаты, обсуждение и заключение.",
531
+ "upload_reference": "1. Загрузите основные научные статьи (необязательно; только PDF/DOCX; рекомендуется 1–20 файлов)",
532
+ "upload_stats": "2. Загрузите таблицы статистических результатов (необязательно; только Excel/CSV)",
533
+ "amj_expander": "💡 Нужна помощь в подготовке данных? Откройте и скачайте стандартный CSV-шаблон",
534
+ "amj_hint": "**⚠️ Важно**: загружайте **таблицы статистических результатов** — надёжность, валидность, корреляции и коэффициенты путей. Не загружайте тысячи строк исходных анкет, чтобы не сработала защита от перегрузки.",
535
+ "download_amj_template": "📥 Скачать CSV-шаблон данных",
536
+ "start_amj": "🚀 Запустить формирование в режиме AMJ",
537
+ "asq_config": "### 📝 Настройка качественного исследования (стиль Administrative Science Quarterly)",
538
+ "asq_info": "💡 Режим ориентирован на выявление феномена, смыслообразование и построение обоснованной теории по подходу Джойя. Структура фиксирована: введение, теоретическая рамка, контекст и методы, результаты, обсуждение, заключение и приложение с доказательствами.",
539
+ "upload_interviews": "2. Загрузите реальные интервью или полевые материалы (необязател��но; только TXT/PDF/DOCX)",
540
+ "asq_expander": "💡 Нужна помощь в подготовке интервью? Откройте и скачайте стандартный TXT-шаблон",
541
+ "asq_hint": "**⚠️ Важно**: укажите **роль информанта** и соблюдайте единый **формат диалога**, чтобы система точно извлекала цитаты и строила структуру кодирования.",
542
+ "download_asq_template": "📥 Скачать TXT-шаблон интервью",
543
+ "start_asq": "🚀 Запустить формирование в режиме ASQ",
544
+ "api_key_missing": "❌ Не задан DEEPSEEK_API_KEY. Настройте ключ API.",
545
+ "resume_toast": "🔄 Обнаружено обновление страницы или нарушение соединения. Выполняется автоматическое восстановление…",
546
+ "pipeline_start": "⏳ Запуск конвейера…",
547
+ "resume_loading": "🔄 Восстановление защищённой локальной контрольной точки…",
548
+ "resume_success": "✅ Контрольная точка восстановлена. Работа продолжится с узла {done}/{total}.",
549
+ "resume_phase": "Восстановление",
550
+ "state_check": "Проверка состояния",
551
+ "history_restored": "✅ История вычислительных затрат восстановлена",
552
+ "extract_quant": "⚙️ Обработка количественных результатов…",
553
+ "literature_extract": "🧠 Извлечение основных сведений и построение индекса RAG…",
554
+ "literature_deep": "**Выполняется углублённая обработка литературы. Не закрывайте страницу**",
555
+ "literature_done": "✅ Основные характеристики литературы извлечены.",
556
+ "config_route": "🕵️ Настройка движка и исследовательской парадигмы…",
557
+ "literature_search": "📚 Поиск научной литературы…",
558
+ "search_phase": "Поиск литературы",
559
+ "search_detail": "Выполняется параллельный поиск…",
560
+ "refs_insufficient": "🚨 Формирование остановлено: интегрировано только {count} источников; требуется не менее {required}.",
561
+ "refs_success": "✅ Расширение литературы завершено: получено {count} источников.",
562
+ "rag_sync": "🧠 Добавление расширенного корпуса литературы в RAG…",
563
+ "rag_done": "✅ Литература векторизована и добавлена в RAG.",
564
+ "core_refs_done": "✅ Пользовательским основным источникам назначен высший приоритет.",
565
+ "synthesis_progress": "🧠 Синтез литературы…",
566
+ "synthesis_status": "🧠 Синтез литературы и выявление исследовательского пробела…",
567
+ "synthesis_done": "✅ Синтез литературы завершён.",
568
+ "anchor_progress": "⚓ Формирование опорных элементов…",
569
+ "anchor_status": "⚓ Формирование опорных элементов аннотации и заключения. Подождите…",
570
+ "anchor_done": "✅ Опорные элементы сформированы.",
571
+ "outline_progress": "📝 Проектирование структуры и распределение разделов…",
572
+ "jit_status": "🔄 Предварительная загрузка литературы для разделов методов и результатов…",
573
+ "jit_done": "✅ Методическая литература загружена в векторное хранилище.",
574
+ "english_draft": "#### 🇬🇧 Английский доказательный черновик",
575
+ "target_draft": "#### {flag} Русская версия статьи",
576
+ "restored": "✅ Восстановлено из контрольной точки",
577
+ "waiting": "⏳ Ожидание выполнения…",
578
+ "translation_waiting": "⏳ Ожидание фонового перевода…",
579
+ "sync_previous": "⏳ Сохранение перевода предыдущего раздела 【{name}】…",
580
+ "engine_amj": "📊 Количественный движок AMJ",
581
+ "engine_asq": "📝 Качественный движок ASQ",
582
+ "drafting_phase": "Подготовка английского текста ({current}/{total})",
583
+ "target_words": "Цель: {words} слов ({engine})",
584
+ "draft_status": "🤖 Подготовка раздела 【{name}】…",
585
+ "api_requested": "📡 Запрос к DeepSeek API отправлен…",
586
+ "model_writing": "✍️ Раздел формируется моделью {model}…",
587
+ "draft_done": "✅ Раздел 【{name}】 завершён.",
588
+ "word_count": "📊 Объём раздела: {actual} слов (цель: {target}); накопленное отклонение: {deviation:+d}",
589
+ "translation_running": "⏳ Английский текст завершён. Выполняется фоновый перевод…",
590
+ "node_error": "🚨 Выполнение узла остановлено из-за ошибки.",
591
+ "node_error_detail": "Код ошибки: {code} | {error} | Контрольная точка сохранена; работу можно продолжить.",
592
+ "task_cancelled": "Задача отменена. Контрольная точка и временные файлы удалены.",
593
+ "task_start_failed": "Не удалось запустить подготовку статьи: {error}",
594
+ "task_recover_failed": "Не удалось восстановить предыдущую задачу: {error}",
595
+ "search_source_warning": "Ошибка поиска {source}: {error}",
596
+ "source_metric_summary": "Запр. {request} · Усп. {success} · Получ. {raw} · Нов. {unique} · Дуб. {duplicate} · Ош. {errors}",
597
+ "sync_last": "⏳ Сохранение перевода последнего раздела 【{name}】…",
598
+ "pack_docx": "📦 Упаковка файлов DOCX…",
599
+ "total_words": "📊 Общий объём английского текста: {actual} слов (целевой диапазон: {lower}–{upper})",
600
+ "complete": "✅ Завершено",
601
+ "document_generated": "Документы сформированы",
602
+ "startup_error": "🚨 Ошибка запуска или восстановления: {error}",
603
+ "translation_error": "🚨 Ошибка фонового перевода",
604
+ "monitor_pipeline": "🔄 Ход конвейера",
605
+ "monitor_node": "📍 Текущий узел",
606
+ "monitor_log": "📝 Журнал действий",
607
+ "monitor_compute": "📡 Монитор вычислительных затрат",
608
+ "actual_stats_parsed": "✅ Загруженные эмпирические результаты успешно обработаны.",
609
+ "synthetic_stats_generated": "⚙️ Статистические результаты не загружены. Система сформировала статистические данные для структуры статьи и раздела результатов.",
610
+ "qualitative_ready": "✅ Качественные материалы подготовлены.",
611
+ "core_literature_parsing": "🧠 Выполняется углублённая обработка загруженной основной литературы…",
612
+ "section_complete_progress": "🇬🇧 Английский раздел 【{name}】 завершён; перевод выполняется в фоне.",
613
+ "login_title": "Вход в SmartPaper",
614
+ "service_description": "Услуги доступны на китайском, английском, русском и казахском языках.",
615
+ "secure_login_notice": "Безопасный вход поддерживается только в приложении Alipay. После входа система шифрует данные для защиты вашей конфиденциальности.",
616
+ "secure_login_title": "Безопасный вход через Alipay",
617
+ "secure_login_instructions": "",
618
+ "secure_login_privacy": "После безопасного входа система шифрует данные для защиты вашей конфиденциальности.",
619
+ "secure_login_alipay_only": "Безопасный вход поддерживается только приложением Alipay.",
620
+ "secure_login_open_current_device": "Открыть авторизацию Alipay на этом устройстве",
621
+ "login_qr_fixed_notice": "QR-код входа не меняется в течение срока действия. Система проверяет только статус входа.",
622
+ "login_info": "Вы можете бесплатно создавать до 3 статей в день.",
623
+ "login_creating": "Создание защищённого запроса на вход…",
624
+ "login_create_error": "Не удалось создать запрос на вход: {error}",
625
+ "login_service_timeout": "Сервис входа отвечает медленнее обычного. Пожалуйста, попробуйте ещё раз.",
626
+ "login_service_connection_error": "Сервис входа временно недоступен. Проверьте подключение и повторите попытку.",
627
+ "login_reconnect": "Повторно подключиться к сервису входа",
628
+ "login_open": "Открыть вход по QR-коду Alipay",
629
+ "login_caption": "На новой странице отсканируйте QR-код приложением Alipay и подтвердите авторизацию. После этого SmartPaper автоматически откроет интерфейс подготовки статьи.",
630
+ "login_auto_waiting": "Ожидается подтверждение в Alipay. После авторизации интерфейс подготовки статьи откроется автоматически.",
631
+ "login_auto_network_wait": "Сервис входа временно занят. Выполняется автоматическая повторная попытка: {error}",
632
+ "login_auto_success": "Вход выполнен. Открывается интерфейс подготовки статьи…",
633
+ "login_auto_failed": "Автоматический вход не завершён: {error}",
634
+ "login_auto_timeout": "Срок действия запроса на вход истёк. Создайте новый запрос.",
635
+ "login_check": "Авторизация выполнена — проверить вход",
636
+ "login_checking": "Проверка статуса входа…",
637
+ "login_check_error": "Ошибка проверки входа: {error}",
638
+ "login_regenerate": "Создать новый запрос на вход",
639
+ "login_pending": "Авторизация Alipay ещё не завершена. Отсканируйте код и подтвердите доступ.",
640
+ "account_title": "Учётная запись",
641
+ "account_id": "Идентификатор пользователя: **{value}**",
642
+ "daily_free": "Ежедневно бесплатно: **{value} статьи**",
643
+ "bonus_papers": "Долгосрочный остаток: **{value} статьи**",
644
+ "account_chip_user": "Пользователь {value}",
645
+ "account_chip_free": "Бесплатно {value}",
646
+ "account_chip_bonus": "Запас {value}",
647
+ "logout_short": "Выйти",
648
+ "payment_dialog_title": "Оплата через Alipay",
649
+ "payment_notice": "Ежедневная бесплатная и долгосрочная квоты исчерпаны. Оплата 9,9 юаня добавляет квоту на три статьи без срока действия.",
650
+ "quota_exhausted": "Ежедневная бесплатная и долгосрочная квоты исчерпаны. Приобретите долгосрочную квоту через Alipay.",
651
+ "payment_create": "Создать QR-код для оплаты",
652
+ "payment_creating": "Создаётся заказ Alipay…",
653
+ "payment_scan": "Отсканируйте QR-код в приложении Alipay. После оплаты нажмите «Проверить результат оплаты».",
654
+ "payment_check": "Проверить результат оплаты",
655
+ "payment_paid": "Оплата прошла успешно. Добавлена долгосрочная квота на три статьи. Запустите создание статьи повторно.",
656
+ "payment_expired": "Срок действия QR-кода истёк. Создайте новый код.",
657
+ "browse_files": "Выбрать файлы",
658
+ "drop_docs": "Перетащите файлы сюда",
659
+ "drop_data": "Перетащите файлы сюда",
660
+ "drop_interviews": "Перетащите файлы сюда",
661
+ "limit_docs": "До 1 ГБ на файл · PDF, DOC, DOCX",
662
+ "limit_data": "До 1 ГБ на файл · CSV, XLSX, XLS",
663
+ "limit_interviews": "До 1 ГБ на файл · TXT, PDF, DOC, DOCX",
664
+ "logout": "Выйти",
665
+ },
666
+ "kk": {
667
+ "title": "📊 Бизнес-басқару жөніндегі ғылыми мақала интеллектуалды агенті",
668
+ "topic_label": "📝 Мақала атауы",
669
+ "keywords_label": "🔍 Түйін сөздер",
670
+ "words_label": "📏 Мақсатты көлем (12 000–25 000 сөз)",
671
+ "success": "🎉 Жұмыс үдерісі аяқталды. Екі тілдегі құжаттар дайын.",
672
+ "download": "📥 Файлдарды жүктеу",
673
+ "system_busy_title": "⏳ Жүйе бос емес",
674
+ "system_busy_warning": "🚨 Басқа пайд��ланушы мақала дайындап жатыр",
675
+ "system_busy_info": "💡 Жүйе шамамен 25–35 минуттан кейін қолжетімді болады",
676
+ "checkpoint_warning": "⚠️ Желі немесе күтпеген үзіліс салдарынан аяқталмаған тапсырма анықталды.",
677
+ "resume": "🔄 Бақылау нүктесінен жалғастыру",
678
+ "clear": "🗑️ Уақытша деректерді тазалау",
679
+ "busy_short": "⏳ Басқа пайдаланушы мақала дайындап жатыр. Шамамен 25–35 минуттан кейін қайталап көріңіз.",
680
+ "tab_amj": "✨ Сандық зерттеу (AMJ қозғалтқышы)",
681
+ "tab_asq": "📝 Сапалық зерттеу (ASQ қозғалтқышы)",
682
+ "amj_config": "### 📊 Сандық зерттеу параметрлері (Academy of Management Journal стилі)",
683
+ "amj_info": "💡 Бұл режим теориялық дедукцияға, гипотезаларды тексеруге және себеп-салдарлық қорытындыға бағытталған. Құрылымы: кіріспе, теория мен гипотезалар, әдіс, нәтижелер, талқылау және қорытынды.",
684
+ "upload_reference": "1. Негізгі ғылыми мақалаларды жүктеңіз (міндетті емес; тек PDF/DOCX; 1–20 файл ұсынылады)",
685
+ "upload_stats": "2. Статистикалық нәтижелер кестесін жүктеңіз (міндетті емес; тек Excel/CSV)",
686
+ "amj_expander": "💡 Деректерді дайындау керек пе? Сандық деректерге арналған стандартты CSV үлгісін ашып, жүктеңіз",
687
+ "amj_hint": "**⚠️ Маңызды**: сенімділік, валидтілік, корреляциялар және жол коэффициенттері сияқты **статистикалық нәтижелер кестесін** жүктеңіз. Артық жүктемеден қорғау іске қосылмауы үшін сауалнаманың мыңдаған бастапқы жолдарын жүктемеңіз.",
688
+ "download_amj_template": "📥 CSV деректер үлгісін жүктеу",
689
+ "start_amj": "🚀 AMJ режимінде дайындауды бастау",
690
+ "asq_config": "### 📝 Сапалық зерттеу параметрлері (Administrative Science Quarterly стилі)",
691
+ "asq_info": "💡 Бұл режим құбылысты анықтауға, мағына қалыптастыруға және Gioia тәсілі бойынша негізделген теория құруға бағытталған. Құрылымы: кіріспе, теориялық негіз, зерттеу ортасы мен әдістері, нәтижелер, талқылау, қорытынды және дәлелдер қосымшасы.",
692
+ "upload_interviews": "2. Нақты сұхбат немесе далалық материалдарды жүктеңіз (міндетті емес; тек TXT/PDF/DOCX)",
693
+ "asq_expander": "💡 Сұхбаттарды дайындау керек пе? Стандартты TXT сұхбат үлгісін ашып, жүктеңіз",
694
+ "asq_hint": "**⚠️ Маңызды**: жүйе дәйексөздерді дәл шығарып, кодтау құрылымын құруы үшін **ақпарат берушінің рөлін** көрсетіп, бірізді **диалог пішімін** қолданыңыз.",
695
+ "download_asq_template": "📥 TXT сұхбат үлгісін жүктеу",
696
+ "start_asq": "🚀 ASQ режимінде дайындауды бастау",
697
+ "api_key_missing": "❌ DEEPSEEK_API_KEY көрсетілмеген. API кілтін баптаңыз.",
698
+ "resume_toast": "🔄 Бет жаңартылғаны немесе желі үзілісі анықталды. Үдеріс автоматты түрде қалпына келтірілуде…",
699
+ "pipeline_start": "⏳ Жұмыс үдерісі басталуда…",
700
+ "resume_loading": "🔄 Қауіпсіз жергілікті бақылау нүктесі қалпына келтірілуде…",
701
+ "resume_success": "✅ Бақылау нүктесі қалпына келтірілді. Жұмыс {done}/{total} түйінінен жалғасады.",
702
+ "resume_phase": "Қалпына келтіру",
703
+ "state_check": "Күйді тексеру",
704
+ "history_restored": "✅ Бұрынғы есептеу шығындары қалпына келтірілді",
705
+ "extract_quant": "⚙️ Сандық нәтижелер өңделуде…",
706
+ "literature_extract": "🧠 Негізгі мәліметтер шығарылып, RAG индексі құрылуда…",
707
+ "literature_deep": "**Әдебиеттер терең өңделуде. Бетті жаппаңыз**",
708
+ "literature_done": "✅ Әдебиеттердің негізгі сипаттары шығарылды.",
709
+ "config_route": "🕵️ Қозғалтқыш пен зерттеу парадигмасы бапталуда…",
710
+ "literature_search": "📚 Ғылыми әдебиеттер ізделуде…",
711
+ "search_phase": "Әдебиет іздеу",
712
+ "search_detail": "Параллель іздеу орындалуда…",
713
+ "refs_insufficient": "🚨 Дайындау тоқтатылды: тек {count} жарамды дереккөз біріктірілді; кемінде {required} қажет.",
714
+ "refs_success": "✅ Әдебиеттерді кеңейту аяқталды: {count} жарамды дереккөз алынды.",
715
+ "rag_sync": "🧠 Кеңейтілген әдебиеттер RAG қозғалтқышына қосылуда…",
716
+ "rag_done": "✅ Әдебиеттер векторланып, RAG қозғалтқышына қосылды.",
717
+ "core_refs_done": "✅ Пайдаланушы ұсынған негізгі дереккөздерге ең жоғары басымдық берілді.",
718
+ "synthesis_progress": "🧠 Әдебиеттер синтезделуде…",
719
+ "synthesis_status": "🧠 Әдебиеттер синтезделіп, зерттеу олқылығы анықталуда…",
720
+ "synthesis_done": "✅ Әдебиеттер синтезі аяқталды.",
721
+ "anchor_progress": "⚓ Тірек мазмұн құрылуда…",
722
+ "anchor_status": "⚓ Аннотация мен қорытындының тірек мазмұны әзірленуде. Күте тұрыңыз…",
723
+ "anchor_done": "✅ Тірек мазмұн әзірленді.",
724
+ "outline_progress": "📝 Құрылым жобаланып, бөлімдер бөлінуде…",
725
+ "jit_status": "🔄 Әдіс пен нәтижелер бөлімдеріне арналған әдебиеттер алдын ала жүктелуде…",
726
+ "jit_done": "✅ Әдістемелік әдебиеттер векторлық қорға қосылды.",
727
+ "english_draft": "#### 🇬🇧 Ағылшын тіліндегі дәлелдік нұсқа",
728
+ "target_draft": "#### {flag} Қазақ тіліндегі мақала",
729
+ "restored": "✅ Бақылау нүктесінен қалпына келтірілді",
730
+ "waiting": "⏳ Орындалуын күтуде…",
731
+ "translation_waiting": "⏳ Фондық аударма күтілуде…",
732
+ "sync_previous": "⏳ Алдыңғы 【{name}】 бөлімінің аудармасы сақталуда…",
733
+ "engine_amj": "📊 AMJ сандық қозғалтқышы",
734
+ "engine_asq": "📝 ASQ сапалық қозғалтқышы",
735
+ "drafting_phase": "Ағылшын мәтінін дайындау ({current}/{total})",
736
+ "target_words": "Мақсат: {words} сөз ({engine})",
737
+ "draft_status": "🤖 【{name}】 бөлімі дайындалуда…",
738
+ "api_requested": "📡 DeepSeek API сұрауы жіберілді…",
739
+ "model_writing": "✍️ Бұл түйінді {model} моделі жазып жатыр…",
740
+ "draft_done": "✅ 【{name}】 бөлімі аяқталды.",
741
+ "word_count": "📊 Бөлім көлемі: {actual} сөз (мақсат: {target}); жиынтық ауытқу: {deviation:+d}",
742
+ "translation_running": "⏳ Ағылшын мәтіні аяқталды. Фондық аударма орындалуда…",
743
+ "node_error": "🚨 Қате салдарынан түйін тоқтатылды.",
744
+ "node_error_detail": "Қате коды: {code} | {error} | Бақылау нүктесі сақталды; жұмысты жалғастыруға болады.",
745
+ "task_cancelled": "Тапсырма тоқтатылды. Бақылау нүктесі мен уақытша файлдар жойылды.",
746
+ "task_start_failed": "Мақала тапсырмасын бастау мүмкін болмады: {error}",
747
+ "task_recover_failed": "Алдыңғы тапсырманы қалпына келтіру мүмкін болмады: {error}",
748
+ "search_source_warning": "{source} іздеу қатесі: {error}",
749
+ "source_metric_summary": "Сұрау {request} · Сәтті {success} · Нәтиже {raw} · Жаңа {unique} · Қайтал. {duplicate} · Қате {errors}",
750
+ "sync_last": "⏳ Соңғы 【{name}】 бөлімінің аудармасы сақталуда…",
751
+ "pack_docx": "📦 DOCX файлдары қапталуда…",
752
+ "total_words": "📊 Ағылшын мәтінінің жалпы көлемі: {actual} сөз (мақсатты аралық: {lower}–{upper})",
753
+ "complete": "✅ Аяқталды",
754
+ "document_generated": "Құжаттар дайындалды",
755
+ "startup_error": "🚨 Іске қосу немесе қалпына келтіру қатесі: {error}",
756
+ "translation_error": "🚨 Фондық аударма қатесі",
757
+ "monitor_pipeline": "🔄 Жұмыс үдерісі",
758
+ "monitor_node": "📍 Ағымдағы түйін",
759
+ "monitor_log": "📝 Әрекеттер журналы",
760
+ "monitor_compute": "📡 Есептеу шығындарының мониторингі",
761
+ "actual_stats_parsed": "✅ Жүктелген эмпирикалық нәтижелер сәтті өңделді.",
762
+ "synthetic_stats_generated": "⚙️ Статистикалық нәтижелер жүктелмеді. Жүйе мақала құрылымы мен нәтижелер бөлімі үшін статистикалық деректер дайындады.",
763
+ "qualitative_ready": "✅ Сапалық материалдар дайындалды.",
764
+ "core_literature_parsing": "🧠 Жүктелген негізгі әдебиеттер терең өңделуде…",
765
+ "section_complete_progress": "🇬🇧 【{name}】 ағылшын бөлімі аяқталды; аударма фондық режимде орындалуда.",
766
+ "login_title": "SmartPaper жүйесіне кіру",
767
+ "service_description": "Қызмет қытай, ағылшын, орыс және қазақ тілдерінде қолжетімді.",
768
+ "secure_login_notice": "Қауіпсіз кіру тек Alipay қолданбасы арқылы жүзеге асырылады. Кіргеннен кейін жүйе құпиялылығыңызды қорғау үшін деректерді шифрлайды.",
769
+ "secure_login_title": "Alipay арқылы қауіпсіз кіру",
770
+ "secure_login_instructions": "",
771
+ "secure_login_privacy": "Қауіпсіз кіргеннен кейін жүйе құпиялылығыңызды қорғау үшін деректерді шифрлайды.",
772
+ "secure_login_alipay_only": "Қауіпсіз кіру тек Alipay қолданбасын қолдайды.",
773
+ "secure_login_open_current_device": "Осы құрылғыда Alipay рұқсатын ашу",
774
+ "login_qr_fixed_notice": "Кіру QR-коды жарамдылық мерзімі ішінде өзгермейді. Жүйе тек кіру күйін тексереді.",
775
+ "login_info": "Күніне 3 мақалаға дейін тегін жасай аласыз.",
776
+ "login_creating": "Қауіпсіз кіру сұрауы құрылуда…",
777
+ "login_create_error": "Кіру сұрауын құру мүмкін болмады: {error}",
778
+ "login_service_timeout": "Кіру қызметі баяу жауап беріп жатыр. Сәл кейінірек қайталап көріңіз.",
779
+ "login_service_connection_error": "Кіру қызметі уақытша қолжетімсіз. Желіні тексеріп, қайта көріңіз.",
780
+ "login_reconnect": "Кіру қызметіне қайта қосылу",
781
+ "login_open": "Alipay QR-коды арқылы кіруді ашу",
782
+ "login_caption": "Жаңа бетте Alipay қолданбасымен QR-кодты сканерлеп, рұқсатты растаңыз. Рұқсат берілгеннен кейін SmartPaper мақала жазу интерфейсін автоматты түрде ашады.",
783
+ "login_auto_waiting": "Alipay рұқсатын күтіп тұрмыз. Рұқсат расталғаннан кейін мақала жазу интерфейсі автоматты түрде ашылады.",
784
+ "login_auto_network_wait": "Кіру қызметі уақытша баяу жұмыс істеуде. Жүйе автоматты түрде қайта әрекет жасап жатыр: {error}",
785
+ "login_auto_success": "Кіру сәтті аяқталды. Мақала жазу интерфейсі ашылуда…",
786
+ "login_auto_failed": "Автоматты кіру аяқталмады: {error}",
787
+ "login_auto_timeout": "Кіру сұрауының мерзімі аяқталды. Жаңа сұрау жасаңыз.",
788
+ "login_check": "Рұқсат берілді — кіруді тексеру",
789
+ "login_checking": "Кіру күйі тексерілуде…",
790
+ "login_check_error": "Кіруді тексеру сәтсіз аяқталды: {error}",
791
+ "login_regenerate": "Жаңа кіру сұрауын құру",
792
+ "login_pending": "Alipay рұқсаты әлі аяқталған жоқ. QR-кодты сканерлеп, рұқсатты растаңыз.",
793
+ "account_title": "Пайдаланушы есептік жазбасы",
794
+ "account_id": "Пайдалануш�� нөмірі: **{value}**",
795
+ "daily_free": "Күнделікті тегін: **{value} мақала**",
796
+ "bonus_papers": "Ұзақ мерзімді қалдық: **{value} мақала**",
797
+ "account_chip_user": "Пайдаланушы {value}",
798
+ "account_chip_free": "Тегін {value}",
799
+ "account_chip_bonus": "Қор {value}",
800
+ "logout_short": "Шығу",
801
+ "payment_dialog_title": "Alipay арқылы төлеу",
802
+ "payment_notice": "Күнделікті тегін және ұзақ мерзімді квота таусылды. 9,9 юань төлегенде мерзімі шектелмеген үш мақала квотасы қосылады.",
803
+ "quota_exhausted": "Күнделікті тегін және ұзақ мерзімді квота таусылды. Alipay арқылы ұзақ мерзімді квота сатып алыңыз.",
804
+ "payment_create": "Төлемге арналған QR-код жасау",
805
+ "payment_creating": "Alipay тапсырысы жасалуда…",
806
+ "payment_scan": "QR-кодты Alipay қолданбасымен сканерлеңіз. Төлемнен кейін «Төлем нәтижесін тексеру» түймесін басыңыз.",
807
+ "payment_check": "Төлем нәтижесін тексеру",
808
+ "payment_paid": "Төлем сәтті өтті. Мерзімі шектелмеген үш мақала квотасы қосылды. Мақала жасауды қайта бастаңыз.",
809
+ "payment_expired": "QR-кодтың қолданылу мерзімі аяқталды. Жаңа код жасаңыз.",
810
+ "browse_files": "Файлдарды таңдау",
811
+ "drop_docs": "Файлдарды осы жерге сүйреп әкеліңіз",
812
+ "drop_data": "Файлдарды осы жерге сүйреп әкеліңіз",
813
+ "drop_interviews": "Файлдарды осы жерге сүйреп әкеліңіз",
814
+ "limit_docs": "Әр файлға 1 ГБ дейін · PDF, DOC, DOCX",
815
+ "limit_data": "Әр файлға 1 ГБ дейін · CSV, XLSX, XLS",
816
+ "limit_interviews": "Әр файлға 1 ГБ дейін · TXT, PDF, DOC, DOCX",
817
+ "logout": "Шығу",
818
+ },
819
+ }
820
+
821
+
822
+ def normalize_language(code: str | None) -> str:
823
+ code = str(code or "zh").strip().lower()
824
+ return code if code in TEXTS else "zh"
825
+
826
+
827
+ def tr(language: str, key: str, **kwargs: Any) -> str:
828
+ language = normalize_language(language)
829
+ template = TEXTS.get(language, {}).get(key)
830
+ if template is None:
831
+ template = TEXTS["zh"].get(key, key)
832
+ return template.format(**kwargs) if kwargs else template
833
+
834
+
835
+ def get_target_language(language: str) -> str:
836
+ return TARGET_LANGUAGE[normalize_language(language)]
837
+
838
+
839
+ def get_output_suffix(language: str) -> str:
840
+ return OUTPUT_SUFFIX[normalize_language(language)]
841
+
842
+
843
+ def get_target_flag(language: str) -> str:
844
+ return TARGET_FLAG[normalize_language(language)]
845
+
846
+
847
+ def get_amj_csv_template(language: str) -> str:
848
+ return AMJ_CSV_TEMPLATES[normalize_language(language)]
849
+
850
+
851
+ def get_asq_txt_template(language: str) -> str:
852
+ return ASQ_TXT_TEMPLATES[normalize_language(language)]
853
+
854
+
855
+ def get_reference_heading(language: str) -> str:
856
+ return REFERENCE_HEADINGS[normalize_language(language)]
$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/managed_auth.py ADDED
@@ -0,0 +1,645 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """SmartPaper edge-first authentication and browser task/payment bridge.
3
+
4
+ Cloudflare owns the durable browser session, quota, tasks, and payments. The
5
+ Hugging Face process verifies the signed edge assertion but does not call the
6
+ Worker over the server-to-server network. Authenticated business operations are
7
+ executed by the user's browser through the same-origin invisible component.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import hashlib
13
+ import hmac
14
+ import html
15
+ import io
16
+ import json
17
+ import os
18
+ import secrets
19
+ import time
20
+ from typing import Any, Callable
21
+ from urllib.parse import unquote, urlsplit
22
+
23
+ import qrcode
24
+ import streamlit as st
25
+
26
+ from i18n import tr
27
+ from smartpaper_restore_bridge import edge_bridge
28
+
29
+ EDGE_ASSERTION_COOKIE_NAME = "smartpaper_edge_assertion"
30
+ EDGE_ASSERTION_AUDIENCE = "smartpaper-streamlit"
31
+ ASSERTION_CLOCK_SKEW_SECONDS = 30
32
+
33
+
34
+ def _runtime_value(name: str) -> str:
35
+ value = os.environ.get(name, "")
36
+ if not value:
37
+ try:
38
+ value = st.secrets.get(name, "")
39
+ except Exception:
40
+ value = ""
41
+ return str(value or "").strip()
42
+
43
+
44
+ def _api_base() -> str:
45
+ value = str(st.session_state.get("sp_auth_api_origin") or "").strip().rstrip("/")
46
+ if not value:
47
+ value = _runtime_value("SMARTPAPER_AUTH_API").rstrip("/")
48
+ parsed = urlsplit(value)
49
+ if (
50
+ parsed.scheme != "https"
51
+ or not parsed.netloc
52
+ or parsed.username is not None
53
+ or parsed.password is not None
54
+ or parsed.path not in ("", "/")
55
+ or parsed.query
56
+ or parsed.fragment
57
+ ):
58
+ raise RuntimeError("SMARTPAPER_AUTH_API must be an HTTPS origin.")
59
+ return f"https://{parsed.netloc}"
60
+
61
+
62
+ def _service_key() -> str:
63
+ value = _runtime_value("SMARTPAPER_SERVICE_KEY")
64
+ if not value:
65
+ raise RuntimeError("SMARTPAPER_SERVICE_KEY is not configured.")
66
+ return value
67
+
68
+
69
+ def _context_cookie(name: str) -> str:
70
+ try:
71
+ cookies = st.context.cookies
72
+ value = cookies.get(name, "")
73
+ if value:
74
+ return unquote(str(value).strip())
75
+ target = name.lower()
76
+ for key, item in cookies.items():
77
+ if str(key).lower() == target:
78
+ return unquote(str(item or "").strip())
79
+ except Exception:
80
+ return ""
81
+ return ""
82
+
83
+
84
+ def _decode_b64url(value: str) -> bytes:
85
+ return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
86
+
87
+
88
+ def _verify_edge_assertion(token: str) -> dict[str, Any]:
89
+ try:
90
+ encoded, supplied_signature = token.split(".", 1)
91
+ except ValueError as exc:
92
+ raise RuntimeError("invalid_edge_assertion") from exc
93
+ expected_signature = hmac.new(
94
+ _service_key().encode("utf-8"),
95
+ encoded.encode("ascii"),
96
+ hashlib.sha256,
97
+ ).hexdigest()
98
+ if not hmac.compare_digest(expected_signature, supplied_signature):
99
+ raise RuntimeError("invalid_edge_assertion_signature")
100
+ try:
101
+ payload = json.loads(_decode_b64url(encoded).decode("utf-8"))
102
+ except Exception as exc:
103
+ raise RuntimeError("invalid_edge_assertion_payload") from exc
104
+ now = int(time.time())
105
+ if payload.get("ver") != 1 or payload.get("aud") != EDGE_ASSERTION_AUDIENCE:
106
+ raise RuntimeError("invalid_edge_assertion_claims")
107
+ api_origin = str(payload.get("api_origin") or "").strip().rstrip("/")
108
+ parsed = urlsplit(api_origin)
109
+ if (
110
+ parsed.scheme != "https"
111
+ or not parsed.netloc
112
+ or parsed.username is not None
113
+ or parsed.password is not None
114
+ or parsed.path not in ("", "/")
115
+ or parsed.query
116
+ or parsed.fragment
117
+ ):
118
+ raise RuntimeError("invalid_edge_assertion_origin")
119
+ issued_at = int(payload.get("iat") or 0)
120
+ expires_at = int(payload.get("exp") or 0)
121
+ if issued_at > now + ASSERTION_CLOCK_SKEW_SECONDS or expires_at <= now - ASSERTION_CLOCK_SKEW_SECONDS:
122
+ raise RuntimeError("expired_edge_assertion")
123
+ if expires_at - issued_at > 9 * 60 * 60:
124
+ raise RuntimeError("invalid_edge_assertion_lifetime")
125
+ user = payload.get("user") or {}
126
+ if not isinstance(user, dict) or not user.get("id") or user.get("status") != "active":
127
+ raise RuntimeError("invalid_edge_assertion_identity")
128
+ payload["api_origin"] = f"https://{parsed.netloc}"
129
+ return payload
130
+
131
+
132
+ def _clear_generation_session_state() -> None:
133
+ for key in (
134
+ "sp_generation_task", "sp_current_node", "sp_last_task_heartbeat",
135
+ "sp_generation_idempotency_key", "zip_buffer", "zip_filename",
136
+ "is_running", "start_pipeline", "resume_pipeline", "run_mode",
137
+ "rag_engine", "up_amj_ref", "up_amj_data", "up_asq_ref",
138
+ "up_asq_data", "sp_payment_required", "sp_payment_order",
139
+ "sp_payment_request_id",
140
+ ):
141
+ st.session_state.pop(key, None)
142
+
143
+
144
+ def _clear_session() -> None:
145
+ for key in (
146
+ "sp_session_expires_at", "sp_user",
147
+ "sp_last_verified_at", "sp_edge_assertion", "sp_auth_api_origin",
148
+ ):
149
+ st.session_state.pop(key, None)
150
+
151
+
152
+ def require_alipay_login(language: str = "zh") -> dict[str, Any]:
153
+ """Return the user authenticated by the Cloudflare edge gateway."""
154
+ token = _context_cookie(EDGE_ASSERTION_COOKIE_NAME)
155
+ if token and token == st.session_state.get("sp_edge_assertion") and st.session_state.get("sp_user"):
156
+ return dict(st.session_state["sp_user"])
157
+ if not token:
158
+ _clear_session()
159
+ st.stop()
160
+ try:
161
+ payload = _verify_edge_assertion(token)
162
+ except Exception:
163
+ _clear_session()
164
+ st.stop()
165
+ user = dict(payload["user"])
166
+ st.session_state["sp_edge_assertion"] = token
167
+ st.session_state["sp_session_expires_at"] = payload["exp"]
168
+ st.session_state["sp_user"] = user
169
+ st.session_state["sp_auth_api_origin"] = str(payload["api_origin"]).rstrip("/")
170
+ st.session_state["sp_last_verified_at"] = time.time()
171
+ return user
172
+
173
+
174
+ def _bridge_key(prefix: str, value: str) -> str:
175
+ digest = hashlib.sha256(str(value).encode("utf-8")).hexdigest()[:20]
176
+ return f"{prefix}_{digest}"
177
+
178
+
179
+ def _edge_call(
180
+ action: str,
181
+ command_id: str,
182
+ payload: dict[str, Any] | None = None,
183
+ *,
184
+ fire_and_forget: bool = False,
185
+ key_prefix: str = "sp_edge",
186
+ ) -> dict[str, Any] | None:
187
+ return edge_bridge(
188
+ action=action,
189
+ command_id=command_id,
190
+ api_origin=_api_base(),
191
+ payload=payload or {},
192
+ fire_and_forget=fire_and_forget,
193
+ key=_bridge_key(key_prefix, command_id),
194
+ )
195
+
196
+
197
+ def _raise_edge_error(result: dict[str, Any], fallback: str) -> None:
198
+ message = (
199
+ result.get("message")
200
+ or result.get("reason")
201
+ or result.get("error")
202
+ or fallback
203
+ )
204
+ raise RuntimeError(str(message))
205
+
206
+
207
+ def _merge_quota_into_session(result: dict[str, Any]) -> None:
208
+ """Refresh the approved account card immediately after a quota mutation."""
209
+ quota = result.get("quota")
210
+ if not isinstance(quota, dict):
211
+ return
212
+ user = dict(st.session_state.get("sp_user") or {})
213
+ for key in (
214
+ "quota_date",
215
+ "daily_free_limit",
216
+ "daily_free_reserved",
217
+ "daily_free_used",
218
+ "daily_free_remaining",
219
+ "bonus_papers",
220
+ ):
221
+ if key in quota:
222
+ user[key] = quota[key]
223
+ if user:
224
+ st.session_state["sp_user"] = user
225
+ st.session_state["sp_account_revision"] = int(
226
+ st.session_state.get("sp_account_revision") or 0
227
+ ) + 1
228
+
229
+
230
+ def get_account_snapshot(*, block: bool = False) -> dict[str, Any] | None:
231
+ bucket = int(time.time() // 30)
232
+ revision = int(st.session_state.get("sp_account_revision") or 0)
233
+ command_id = f"account:{revision}:{bucket}"
234
+ result = _edge_call("account", command_id, {}, key_prefix="sp_account")
235
+ if result is None:
236
+ if block:
237
+ st.stop()
238
+ return None
239
+ if not result.get("ok"):
240
+ if block:
241
+ _raise_edge_error(result, "account_query_failed")
242
+ return None
243
+ user = result.get("user")
244
+ if isinstance(user, dict):
245
+ st.session_state["sp_user"] = dict(user)
246
+ return result
247
+
248
+
249
+ def start_generation_task(*, idempotency_key: str, mode: str, interface_language: str) -> dict[str, Any]:
250
+ normalized = str(idempotency_key or "").strip()
251
+ if not normalized:
252
+ raise RuntimeError("idempotency_key_required")
253
+ result = _edge_call(
254
+ "task_start",
255
+ normalized,
256
+ {
257
+ "idempotency_key": normalized,
258
+ "mode": str(mode or "").strip().lower(),
259
+ "interface_language": str(interface_language or "zh").strip(),
260
+ },
261
+ key_prefix="sp_task_start",
262
+ )
263
+ if result is None:
264
+ st.stop()
265
+ if not result.get("ok"):
266
+ _raise_edge_error(result, "task_start_failed")
267
+ _merge_quota_into_session(result)
268
+ return dict(result)
269
+
270
+
271
+ def recover_generation_task() -> dict[str, Any]:
272
+ command_id = f"recover:{int(time.time() // 10)}"
273
+ result = _edge_call("task_recover", command_id, {}, key_prefix="sp_task_recover")
274
+ if result is None:
275
+ st.stop()
276
+ if not result.get("ok"):
277
+ _raise_edge_error(result, "task_recover_failed")
278
+ return dict(result)
279
+
280
+
281
+ def heartbeat_generation_task(task_id: str, *, current_node: str = "") -> dict[str, Any]:
282
+ normalized = str(task_id or "").strip()
283
+ if not normalized:
284
+ raise RuntimeError("task_id_required")
285
+ bucket = int(time.time() // 20)
286
+ _edge_call(
287
+ "task_heartbeat",
288
+ f"heartbeat:{normalized}:{bucket}",
289
+ {"task_id": normalized, "current_node": str(current_node or "")[:240]},
290
+ fire_and_forget=True,
291
+ key_prefix="sp_task_heartbeat",
292
+ )
293
+ return {"ok": True, "queued": True, "cancel_requested": False}
294
+
295
+
296
+ def generation_task_status(task_id: str) -> dict[str, Any]:
297
+ normalized = str(task_id or "").strip()
298
+ command_id = f"status:{normalized}:{int(time.time() // 5)}"
299
+ result = _edge_call(
300
+ "task_status", command_id, {"task_id": normalized}, key_prefix="sp_task_status"
301
+ )
302
+ if result is None:
303
+ st.stop()
304
+ if not result.get("ok"):
305
+ _raise_edge_error(result, "task_status_failed")
306
+ return dict(result)
307
+
308
+
309
+ def finish_generation_task(
310
+ task_id: str,
311
+ *,
312
+ status: str,
313
+ safe_error_code: str = "",
314
+ safe_error_detail: str = "",
315
+ current_node: str = "",
316
+ ) -> dict[str, Any]:
317
+ """Finalize a task only after the browser receives Worker/D1 confirmation."""
318
+ normalized_task = str(task_id or "").strip()
319
+ normalized_status = str(status or "").strip().lower()
320
+ if not normalized_task:
321
+ raise RuntimeError("task_id_required")
322
+ if normalized_status not in {"completed", "failed", "cancelled", "resumable"}:
323
+ raise RuntimeError("invalid_finish_status")
324
+ command_id = f"finish:{normalized_task}:{normalized_status}"
325
+ result = _edge_call(
326
+ "task_finish",
327
+ command_id,
328
+ {
329
+ "task_id": normalized_task,
330
+ "status": normalized_status,
331
+ "safe_error_code": str(safe_error_code or "")[:80],
332
+ "safe_error_detail": str(safe_error_detail or "")[:800],
333
+ "current_node": str(current_node or "")[:240],
334
+ },
335
+ fire_and_forget=False,
336
+ key_prefix="sp_task_finish",
337
+ )
338
+ if result is None:
339
+ st.stop()
340
+ if not result.get("ok"):
341
+ _raise_edge_error(result, "task_finish_failed")
342
+ task = dict(result.get("task") or {})
343
+ reservation = dict(result.get("reservation") or {})
344
+ if str(task.get("task_id") or "") != normalized_task:
345
+ raise RuntimeError("task_finish_confirmation_mismatch")
346
+ if str(task.get("status") or "").lower() != normalized_status:
347
+ raise RuntimeError("task_finish_status_not_confirmed")
348
+ expected_reservation = {
349
+ "completed": "committed",
350
+ "failed": "released",
351
+ "cancelled": "released",
352
+ "resumable": "reserved",
353
+ }[normalized_status]
354
+ if str(reservation.get("status") or "").lower() != expected_reservation:
355
+ raise RuntimeError("task_quota_finalization_not_confirmed")
356
+ _merge_quota_into_session(result)
357
+ return dict(result)
358
+
359
+
360
+ def cancel_generation_task(task_id: str | None = None) -> dict[str, Any]:
361
+ """Cancel synchronously so quota release is confirmed before local cleanup."""
362
+ normalized = str(task_id or "").strip()
363
+ command_id = f"cancel:{normalized or 'latest'}"
364
+ result = _edge_call(
365
+ "task_cancel",
366
+ command_id,
367
+ {"task_id": normalized} if normalized else {},
368
+ fire_and_forget=False,
369
+ key_prefix="sp_task_cancel",
370
+ )
371
+ if result is None:
372
+ st.stop()
373
+ if not result.get("ok"):
374
+ _raise_edge_error(result, "task_cancel_failed")
375
+ reservation = result.get("reservation")
376
+ if result.get("cancelled") and reservation and str(reservation.get("status") or "").lower() != "released":
377
+ raise RuntimeError("task_cancel_quota_not_released")
378
+ _merge_quota_into_session(result)
379
+ return dict(result)
380
+
381
+
382
+ def logout_alipay_session(on_logout: Callable[[str, str], None] | None = None) -> None:
383
+ user_id = str((st.session_state.get("sp_user") or {}).get("id") or "")
384
+ task = st.session_state.get("sp_generation_task") or {}
385
+ task_id = str(task.get("task_id") or "")
386
+ if on_logout is not None:
387
+ try:
388
+ on_logout(user_id, task_id)
389
+ except Exception:
390
+ pass
391
+ _clear_session()
392
+ _clear_generation_session_state()
393
+
394
+
395
+ def create_payment_order(idempotency_key: str) -> dict[str, Any]:
396
+ normalized = str(idempotency_key or "").strip()
397
+ result = _edge_call(
398
+ "pay_create",
399
+ normalized,
400
+ {"idempotency_key": normalized},
401
+ key_prefix="sp_pay_create",
402
+ )
403
+ if result is None:
404
+ st.stop()
405
+ if not result.get("ok"):
406
+ _raise_edge_error(result, "payment_create_failed")
407
+ return dict(result)
408
+
409
+
410
+ def payment_order_status(merchant_order_no: str) -> dict[str, Any]:
411
+ order_no = str(merchant_order_no or "").strip()
412
+ command_id = f"pay-status:{order_no}:{int(time.time() // 3)}"
413
+ result = _edge_call(
414
+ "pay_status",
415
+ command_id,
416
+ {"merchant_order_no": order_no},
417
+ key_prefix="sp_pay_status",
418
+ )
419
+ if result is None:
420
+ st.stop()
421
+ if not result.get("ok"):
422
+ _raise_edge_error(result, "payment_status_failed")
423
+ return dict(result)
424
+
425
+
426
+ def _qr_png_bytes(value: str) -> bytes:
427
+ image = qrcode.make(str(value))
428
+ buffer = io.BytesIO()
429
+ image.save(buffer, format="PNG")
430
+ return buffer.getvalue()
431
+
432
+
433
+ def _render_payment_purchase_body(language: str) -> None:
434
+ st.warning(tr(language, "payment_notice"))
435
+
436
+ if st.button(tr(language, "payment_create"), key="sp_create_payment_order"):
437
+ st.session_state["sp_payment_request_id"] = (
438
+ f"pay_{int(time.time() * 1000)}_{secrets.token_urlsafe(12)}"
439
+ )
440
+
441
+ request_id = str(st.session_state.get("sp_payment_request_id") or "").strip()
442
+ order = st.session_state.get("sp_payment_order")
443
+ if request_id and not order:
444
+ st.info(tr(language, "payment_creating"))
445
+ order_result = create_payment_order(request_id)
446
+ order = dict(order_result.get("order") or {})
447
+ if not order.get("merchant_order_no") or not order.get("qr_code"):
448
+ raise RuntimeError("payment_order_missing_qr_code")
449
+ st.session_state["sp_payment_order"] = order
450
+
451
+ order = st.session_state.get("sp_payment_order") or {}
452
+ qr_code = str(order.get("qr_code") or "").strip()
453
+ if qr_code:
454
+ st.image(_qr_png_bytes(qr_code), width=280)
455
+ st.caption(tr(language, "payment_scan"))
456
+ if st.button(tr(language, "payment_check"), key="sp_check_payment_status"):
457
+ status_result = payment_order_status(order.get("merchant_order_no", ""))
458
+ updated = dict(status_result.get("order") or {})
459
+ st.session_state["sp_payment_order"] = updated
460
+ if updated.get("status") == "paid" and updated.get("credit_applied"):
461
+ user = status_result.get("user")
462
+ if isinstance(user, dict):
463
+ st.session_state["sp_user"] = dict(user)
464
+ st.session_state["sp_account_revision"] = int(
465
+ st.session_state.get("sp_account_revision") or 0
466
+ ) + 1
467
+ st.session_state["sp_payment_required"] = False
468
+ st.session_state["sp_payment_order"] = None
469
+ st.session_state["sp_payment_request_id"] = ""
470
+ st.success(tr(language, "payment_paid"))
471
+ elif updated.get("status") in {"closed", "failed"}:
472
+ st.session_state["sp_payment_order"] = None
473
+ st.session_state["sp_payment_request_id"] = ""
474
+ st.warning(tr(language, "payment_expired"))
475
+
476
+
477
+ def render_payment_purchase(language: str) -> None:
478
+ """Show payment in a modal so the approved normal-page layout is unchanged."""
479
+ dialog_factory = getattr(st, "dialog", None)
480
+ if callable(dialog_factory):
481
+ @dialog_factory(tr(language, "payment_dialog_title"))
482
+ def _payment_dialog():
483
+ _render_payment_purchase_body(language)
484
+ _payment_dialog()
485
+ return
486
+ # Compatibility fallback for older Streamlit builds.
487
+ _render_payment_purchase_body(language)
488
+
489
+
490
+ def render_logged_in_account(
491
+ language: str,
492
+ user: dict[str, Any] | None = None,
493
+ on_logout: Callable[[str, str], None] | None = None,
494
+ ) -> None:
495
+ """Render the approved responsive account summary with live quota values."""
496
+ live = get_account_snapshot(block=False)
497
+ if live and isinstance(live.get("user"), dict):
498
+ user = dict(live["user"])
499
+ else:
500
+ user = user or st.session_state.get("sp_user", {})
501
+ user_id = user.get("id", "—")
502
+ free_remaining = user.get("daily_free_remaining", user.get("daily_free_limit", 3))
503
+ bonus_papers = user.get("bonus_papers", 0)
504
+
505
+ st.markdown(
506
+ """
507
+ <style>
508
+ section[data-testid="stSidebar"] {
509
+ display: none !important;
510
+ }
511
+
512
+ [data-testid="collapsedControl"] {
513
+ display: none !important;
514
+ }
515
+
516
+ .sp-account-card {
517
+ display: flex;
518
+ align-items: center;
519
+ justify-content: center;
520
+ gap: clamp(0.3rem, 0.55vw, 0.58rem);
521
+ width: 100%;
522
+ min-width: 0;
523
+ min-height: 2.85rem;
524
+ padding: 0.48rem clamp(0.55rem, 0.85vw, 0.9rem);
525
+ border: 1px solid rgba(148, 163, 184, 0.30);
526
+ border-radius: 13px;
527
+ background: rgba(30, 41, 59, 0.58);
528
+ color: var(--text-color, #f3f4f6);
529
+ font-size: clamp(0.76rem, 0.84vw, 0.92rem);
530
+ font-weight: 500;
531
+ line-height: 1.25;
532
+ white-space: nowrap;
533
+ overflow: visible;
534
+ box-sizing: border-box;
535
+ }
536
+
537
+ .sp-account-card span {
538
+ display: inline-block;
539
+ white-space: nowrap;
540
+ }
541
+
542
+ .sp-account-separator {
543
+ flex: 0 0 auto;
544
+ opacity: 0.48;
545
+ }
546
+
547
+ div[data-testid="stMarkdownContainer"]:has(.sp-logout-link) {
548
+ width: 100%;
549
+ }
550
+
551
+ div[data-testid="stMarkdownContainer"]:has(.sp-logout-link) p {
552
+ width: 100%;
553
+ margin: 0;
554
+ }
555
+
556
+ .sp-logout-link {
557
+ display: flex;
558
+ align-items: center;
559
+ justify-content: center;
560
+ width: 100%;
561
+ min-width: 6.8rem;
562
+ min-height: 2.85rem;
563
+ padding: 0.45rem 0.72rem;
564
+ border: 1px solid rgba(148, 163, 184, 0.30);
565
+ border-radius: 0.5rem;
566
+ background: transparent;
567
+ color: var(--text-color, #f3f4f6) !important;
568
+ text-decoration: none !important;
569
+ white-space: nowrap !important;
570
+ overflow-wrap: normal !important;
571
+ word-break: keep-all !important;
572
+ line-height: 1.15;
573
+ font-size: clamp(0.78rem, 0.82vw, 0.92rem);
574
+ font-weight: 400;
575
+ box-sizing: border-box;
576
+ cursor: pointer;
577
+ transition: border-color 120ms ease, background-color 120ms ease;
578
+ }
579
+
580
+ .sp-logout-link:hover {
581
+ border-color: rgba(255, 75, 75, 0.78);
582
+ background: rgba(255, 255, 255, 0.035);
583
+ color: var(--text-color, #f3f4f6) !important;
584
+ }
585
+
586
+ .sp-logout-link:focus-visible {
587
+ outline: 2px solid rgba(255, 75, 75, 0.85);
588
+ outline-offset: 2px;
589
+ }
590
+
591
+ @media (max-width: 1150px) {
592
+ .sp-account-card {
593
+ font-size: 0.75rem;
594
+ gap: 0.3rem;
595
+ padding-left: 0.48rem;
596
+ padding-right: 0.48rem;
597
+ }
598
+
599
+ .sp-logout-link {
600
+ min-width: 5.8rem;
601
+ font-size: 0.78rem;
602
+ padding-left: 0.46rem;
603
+ padding-right: 0.46rem;
604
+ }
605
+ }
606
+
607
+ @media (max-width: 760px) {
608
+ .sp-account-card {
609
+ justify-content: flex-start;
610
+ overflow-x: auto;
611
+ scrollbar-width: none;
612
+ }
613
+
614
+ .sp-account-card::-webkit-scrollbar {
615
+ display: none;
616
+ }
617
+ }
618
+ </style>
619
+ """,
620
+ unsafe_allow_html=True,
621
+ )
622
+
623
+ info_col, logout_col = st.columns([5.9, 1.65])
624
+ with info_col:
625
+ st.markdown(
626
+ f"""
627
+ <div class="sp-account-card">
628
+ <span>{html.escape(tr(language, 'account_chip_user', value=user_id))}</span>
629
+ <span class="sp-account-separator">•</span>
630
+ <span>{html.escape(tr(language, 'account_chip_free', value=free_remaining))}</span>
631
+ <span class="sp-account-separator">•</span>
632
+ <span>{html.escape(tr(language, 'account_chip_bonus', value=bonus_papers))}</span>
633
+ </div>
634
+ """,
635
+ unsafe_allow_html=True,
636
+ )
637
+ with logout_col:
638
+ logout_label = html.escape(tr(language, "logout_short"))
639
+ logout_help = html.escape(tr(language, "logout"), quote=True)
640
+ st.markdown(
641
+ f'<a class="sp-logout-link" href="/edge/logout" target="_self" '
642
+ f'rel="nofollow" role="button" aria-label="{logout_help}" '
643
+ f'title="{logout_help}">{logout_label}</a>',
644
+ unsafe_allow_html=True,
645
+ )
$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/requirements.txt ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ streamlit==1.38.0
2
+ requests>=2.31,<3
3
+ python-docx>=1.1,<2
4
+ pandas>=2.1,<3
5
+ numpy>=1.26,<3
6
+ scikit-learn>=1.4,<2
7
+ tiktoken>=0.7,<1
8
+ langdetect==1.0.9
9
+ mistune>=3,<4
10
+ beautifulsoup4>=4.12,<5
11
+ PyMuPDF>=1.24,<2
12
+ openpyxl>=3.1,<4
13
+ tabulate>=0.9,<1
14
+ qrcode[pil]>=8,<9
$RECYCLE.BIN/S-1-5-21-2308098342-1360407909-2433281377-1001/$RD7299H/smartpaper_restore_bridge/__init__.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """Invisible browser bridge for SmartPaper recovery and edge API calls.
3
+
4
+ The component is served by Streamlit through the public Cloudflare origin. It
5
+ therefore allows the authenticated browser to call same-origin Worker endpoints
6
+ with the existing HttpOnly browser cookie. No service key or cookie value is
7
+ passed to Python or JavaScript arguments.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ import streamlit.components.v1 as components
16
+
17
+ _FRONTEND_DIR = Path(__file__).resolve().parent / "frontend"
18
+ _FRONTEND_ENTRY = _FRONTEND_DIR / "index.html"
19
+ if not _FRONTEND_ENTRY.is_file():
20
+ raise RuntimeError("SmartPaper restore bridge frontend is missing.")
21
+
22
+ _COMPONENT = components.declare_component(
23
+ "smartpaper_restore_bridge",
24
+ path=str(_FRONTEND_DIR),
25
+ )
26
+
27
+
28
+ def restore_bridge(
29
+ *,
30
+ action: str,
31
+ command_id: str,
32
+ token: str = "",
33
+ expires_at: str = "",
34
+ key: str = "sp_restore_bridge",
35
+ ) -> dict[str, Any] | None:
36
+ """Backward-compatible recovery-token storage operation."""
37
+ value = _COMPONENT(
38
+ action=str(action or "read"),
39
+ command_id=str(command_id or ""),
40
+ token=str(token or ""),
41
+ expires_at=str(expires_at or ""),
42
+ api_origin="",
43
+ payload={},
44
+ fire_and_forget=False,
45
+ height=0,
46
+ default=None,
47
+ key=key,
48
+ )
49
+ return value if isinstance(value, dict) else None
50
+
51
+
52
+ def edge_bridge(
53
+ *,
54
+ action: str,
55
+ command_id: str,
56
+ api_origin: str,
57
+ payload: dict[str, Any] | None = None,
58
+ fire_and_forget: bool = False,
59
+ key: str = "sp_edge_bridge",
60
+ ) -> dict[str, Any] | None:
61
+ """Execute one authenticated same-origin Worker command in the browser.
62
+
63
+ The first synchronous invocation usually returns ``None``. Streamlit reruns
64
+ after the browser completes the request and then returns the result. Only
65
+ non-terminal heartbeat calls may use ``fire_and_forget=True``. Task
66
+ completion, failure, cancellation, payment, and account mutations remain
67
+ synchronous so Python receives the Worker/D1 postcondition.
68
+ """
69
+ value = _COMPONENT(
70
+ action=str(action or ""),
71
+ command_id=str(command_id or ""),
72
+ token="",
73
+ expires_at="",
74
+ api_origin=str(api_origin or ""),
75
+ payload=dict(payload or {}),
76
+ fire_and_forget=bool(fire_and_forget),
77
+ height=0,
78
+ default=None,
79
+ key=key,
80
+ )
81
+ return value if isinstance(value, dict) else None