Spaces:
Running
Running
Create app.py
Browse files
app.py
ADDED
|
@@ -0,0 +1,1661 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import gradio as gr
|
| 2 |
+
import os
|
| 3 |
+
import json
|
| 4 |
+
import requests
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
import time
|
| 7 |
+
from typing import List, Dict, Any, Generator, Tuple, Optional, Set
|
| 8 |
+
import logging
|
| 9 |
+
import re
|
| 10 |
+
import tempfile
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
import sqlite3
|
| 13 |
+
import hashlib
|
| 14 |
+
import threading
|
| 15 |
+
from contextlib import contextmanager
|
| 16 |
+
from dataclasses import dataclass, field, asdict
|
| 17 |
+
from collections import defaultdict
|
| 18 |
+
import random
|
| 19 |
+
from huggingface_hub import HfApi, upload_file, hf_hub_download
|
| 20 |
+
|
| 21 |
+
# --- Logging setup ---
|
| 22 |
+
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
| 23 |
+
logger = logging.getLogger(__name__)
|
| 24 |
+
|
| 25 |
+
# --- Document export imports ---
|
| 26 |
+
try:
|
| 27 |
+
from docx import Document
|
| 28 |
+
from docx.shared import Inches, Pt, RGBColor, Mm
|
| 29 |
+
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
| 30 |
+
from docx.enum.style import WD_STYLE_TYPE
|
| 31 |
+
from docx.oxml.ns import qn
|
| 32 |
+
from docx.oxml import OxmlElement
|
| 33 |
+
DOCX_AVAILABLE = True
|
| 34 |
+
except ImportError:
|
| 35 |
+
DOCX_AVAILABLE = False
|
| 36 |
+
logger.warning("python-docx not installed. DOCX export will be disabled.")
|
| 37 |
+
|
| 38 |
+
import io # Add io import for DOCX export
|
| 39 |
+
|
| 40 |
+
# --- Environment variables and constants ---
|
| 41 |
+
FRIENDLI_TOKEN = os.getenv("FRIENDLI_TOKEN", "")
|
| 42 |
+
BRAVE_SEARCH_API_KEY = os.getenv("BRAVE_SEARCH_API_KEY", "")
|
| 43 |
+
API_URL = "https://api.friendli.ai/dedicated/v1/chat/completions"
|
| 44 |
+
MODEL_ID = "dep86pjolcjjnv8"
|
| 45 |
+
DB_PATH = "webnovel_sessions_v1.db"
|
| 46 |
+
|
| 47 |
+
# Target settings for web novel - UPDATED FOR LONGER EPISODES
|
| 48 |
+
TARGET_EPISODES = 40 # 40화 완결
|
| 49 |
+
WORDS_PER_EPISODE = 400 # 각 화당 400-600 단어 (기존 200-300에서 증가)
|
| 50 |
+
TARGET_WORDS = TARGET_EPISODES * WORDS_PER_EPISODE # 총 16000 단어
|
| 51 |
+
|
| 52 |
+
# Web novel genres
|
| 53 |
+
WEBNOVEL_GENRES = {
|
| 54 |
+
"로맨스": "Romance",
|
| 55 |
+
"로판": "Romance Fantasy",
|
| 56 |
+
"판타지": "Fantasy",
|
| 57 |
+
"현판": "Modern Fantasy",
|
| 58 |
+
"무협": "Martial Arts",
|
| 59 |
+
"미스터리": "Mystery",
|
| 60 |
+
"라이트노벨": "Light Novel"
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
# --- Environment validation ---
|
| 64 |
+
if not FRIENDLI_TOKEN:
|
| 65 |
+
logger.error("FRIENDLI_TOKEN not set. Application will not work properly.")
|
| 66 |
+
FRIENDLI_TOKEN = "dummy_token_for_testing"
|
| 67 |
+
|
| 68 |
+
if not BRAVE_SEARCH_API_KEY:
|
| 69 |
+
logger.warning("BRAVE_SEARCH_API_KEY not set. Web search features will be disabled.")
|
| 70 |
+
|
| 71 |
+
# --- Global variables ---
|
| 72 |
+
db_lock = threading.Lock()
|
| 73 |
+
|
| 74 |
+
# --- Data classes ---
|
| 75 |
+
@dataclass
|
| 76 |
+
class WebNovelBible:
|
| 77 |
+
"""Web novel story bible for maintaining consistency"""
|
| 78 |
+
genre: str = ""
|
| 79 |
+
title: str = ""
|
| 80 |
+
characters: Dict[str, Dict[str, Any]] = field(default_factory=dict)
|
| 81 |
+
settings: Dict[str, str] = field(default_factory=dict)
|
| 82 |
+
plot_points: List[Dict[str, Any]] = field(default_factory=list)
|
| 83 |
+
episode_hooks: Dict[int, str] = field(default_factory=dict)
|
| 84 |
+
genre_elements: Dict[str, Any] = field(default_factory=dict)
|
| 85 |
+
power_system: Dict[str, Any] = field(default_factory=dict)
|
| 86 |
+
relationships: List[Dict[str, str]] = field(default_factory=list)
|
| 87 |
+
|
| 88 |
+
@dataclass
|
| 89 |
+
class EpisodeCritique:
|
| 90 |
+
"""Critique for each episode"""
|
| 91 |
+
episode_number: int
|
| 92 |
+
hook_effectiveness: float = 0.0
|
| 93 |
+
pacing_score: float = 0.0
|
| 94 |
+
genre_adherence: float = 0.0
|
| 95 |
+
character_consistency: List[str] = field(default_factory=list)
|
| 96 |
+
reader_engagement: float = 0.0
|
| 97 |
+
required_fixes: List[str] = field(default_factory=list)
|
| 98 |
+
|
| 99 |
+
# --- Genre-specific prompts and elements ---
|
| 100 |
+
GENRE_ELEMENTS = {
|
| 101 |
+
"로맨스": {
|
| 102 |
+
"key_elements": ["감정선", "오해와 화해", "달콤한 순간", "질투", "고백"],
|
| 103 |
+
"popular_tropes": ["계약연애", "재벌과 평민", "첫사랑 재회", "짝사랑", "삼각관계"],
|
| 104 |
+
"must_have": ["심쿵 포인트", "달달한 대사", "감정 묘사", "스킨십", "해피엔딩"],
|
| 105 |
+
"episode_structure": "감정의 롤러코스터, 매 화 끝 설렘 포인트"
|
| 106 |
+
},
|
| 107 |
+
"로판": {
|
| 108 |
+
"key_elements": ["회귀/빙의", "원작 지식", "운명 변경", "마법/검술", "신분 상승"],
|
| 109 |
+
"popular_tropes": ["악녀가 되었다", "폐녀 각성", "계약결혼", "집착남주", "역하렘"],
|
| 110 |
+
"must_have": ["차원이동 설정", "먼치킨 요소", "로맨스", "복수", "성장"],
|
| 111 |
+
"episode_structure": "원작 전개 비틀기, 매 화 새로운 변수"
|
| 112 |
+
},
|
| 113 |
+
"판타지": {
|
| 114 |
+
"key_elements": ["마법체계", "레벨업", "던전", "길드", "모험"],
|
| 115 |
+
"popular_tropes": ["회귀", "시스템", "먼치킨", "히든피스", "각성"],
|
| 116 |
+
"must_have": ["성장 곡선", "전투씬", "세계관", "동료", "최종보스"],
|
| 117 |
+
"episode_structure": "점진적 강해짐, 새로운 도전과 극복"
|
| 118 |
+
},
|
| 119 |
+
"현판": {
|
| 120 |
+
"key_elements": ["숨겨진 능력", "일상과 비일상", "도시 판타지", "능력자 사회", "각성"],
|
| 121 |
+
"popular_tropes": ["헌터", "게이트", "길드", "랭킹", "아이템"],
|
| 122 |
+
"must_have": ["현실감", "능력 각성", "사회 시스템", "액션", "성장"],
|
| 123 |
+
"episode_structure": "일상 속 비일상 발견, 점진적 세계관 확장"
|
| 124 |
+
},
|
| 125 |
+
"무��": {
|
| 126 |
+
"key_elements": ["무공", "문파", "강호", "복수", "의협"],
|
| 127 |
+
"popular_tropes": ["천재", "폐급에서 최강", "기연", "환생", "마교"],
|
| 128 |
+
"must_have": ["무공 수련", "대결", "문파 설정", "경지", "최종 결전"],
|
| 129 |
+
"episode_structure": "수련과 대결의 반복, 점진적 경지 상승"
|
| 130 |
+
},
|
| 131 |
+
"미스터리": {
|
| 132 |
+
"key_elements": ["단서", "추리", "반전", "서스펜스", "진실"],
|
| 133 |
+
"popular_tropes": ["탐정", "연쇄 사건", "과거의 비밀", "복수극", "심리전"],
|
| 134 |
+
"must_have": ["복선", "붉은 청어", "논리적 추리", "충격 반전", "해결"],
|
| 135 |
+
"episode_structure": "단서의 점진적 공개, 긴장감 상승"
|
| 136 |
+
},
|
| 137 |
+
"라이트노벨": {
|
| 138 |
+
"key_elements": ["학원", "일상", "코미디", "모에", "배틀"],
|
| 139 |
+
"popular_tropes": ["이세계", "하렘", "츤데레", "치트", "길드"],
|
| 140 |
+
"must_have": ["가벼운 문체", "유머", "캐릭터성", "일러스트적 묘사", "왁자지껄"],
|
| 141 |
+
"episode_structure": "에피소드 중심, 개그와 진지의 균형"
|
| 142 |
+
}
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
# Episode hooks by genre
|
| 146 |
+
EPISODE_HOOKS = {
|
| 147 |
+
"로맨스": [
|
| 148 |
+
"그의 입술이 내 귀에 닿을 듯 가까워졌다.",
|
| 149 |
+
"'사실... 너를 처음 본 순간부터...'",
|
| 150 |
+
"그때, 예상치 못한 사람이 문을 열고 들어왔다.",
|
| 151 |
+
"메시지를 확인한 순간, 심장이 멈출 것 같았다."
|
| 152 |
+
],
|
| 153 |
+
"로판": [
|
| 154 |
+
"그 순간, 원작에는 없던 인물이 나타났다.",
|
| 155 |
+
"'폐하, 계약을 파기하겠습니다.'",
|
| 156 |
+
"검은 오라가 그를 감싸며 눈빛이 변했다.",
|
| 157 |
+
"회귀 전에는 몰랐던 진실이 드러났다."
|
| 158 |
+
],
|
| 159 |
+
"판타지": [
|
| 160 |
+
"[새로운 스킬을 획득했습니다!]",
|
| 161 |
+
"던전 최심부에서 발견한 것은...",
|
| 162 |
+
"'이건... SSS급 아이템이다!'",
|
| 163 |
+
"시스템 창에 뜬 경고 메시지를 보고 경악했다."
|
| 164 |
+
],
|
| 165 |
+
"현판": [
|
| 166 |
+
"평범한 학생인 줄 알았던 그의 눈이 붉게 빛났다.",
|
| 167 |
+
"갑자기 하늘에 거대한 균열이 생겼다.",
|
| 168 |
+
"'당신도... 능력자였군요.'",
|
| 169 |
+
"핸드폰에 뜬 긴급 재난 문자를 보고 얼어붙었다."
|
| 170 |
+
],
|
| 171 |
+
"무협": [
|
| 172 |
+
"그의 검에서 흘러나온 검기를 보고 모두가 경악했다.",
|
| 173 |
+
"'이것이... 전설의 천마신공?!'",
|
| 174 |
+
"피를 토하며 쓰러진 사부가 마지막으로 남긴 말은...",
|
| 175 |
+
"그때, 하늘에서 한 줄기 빛이 내려왔다."
|
| 176 |
+
],
|
| 177 |
+
"미스터리": [
|
| 178 |
+
"그리고 시체 옆에서 발견된 것은...",
|
| 179 |
+
"'범인은 이 안에 있습니다.'",
|
| 180 |
+
"일기장의 마지막 페이지를 넘기자...",
|
| 181 |
+
"CCTV에 찍힌 그 순간, 모든 것이 뒤바뀌었다."
|
| 182 |
+
],
|
| 183 |
+
"라이트노벨": [
|
| 184 |
+
"'선배! 사실 저... 마왕이에요!'",
|
| 185 |
+
"전학생의 정체는 다름 아닌...",
|
| 186 |
+
"그녀의 가방에서 떨어진 것을 보고 경악했다.",
|
| 187 |
+
"'어라? 이거... 게임 아이템이 현실에?'"
|
| 188 |
+
]
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
# --- Core logic classes ---
|
| 192 |
+
class WebNovelTracker:
|
| 193 |
+
"""Web novel narrative tracker"""
|
| 194 |
+
def __init__(self):
|
| 195 |
+
self.story_bible = WebNovelBible()
|
| 196 |
+
self.episode_critiques: Dict[int, EpisodeCritique] = {}
|
| 197 |
+
self.episodes: Dict[int, str] = {}
|
| 198 |
+
self.total_word_count = 0
|
| 199 |
+
self.reader_engagement_curve: List[float] = []
|
| 200 |
+
|
| 201 |
+
def set_genre(self, genre: str):
|
| 202 |
+
"""Set the novel genre"""
|
| 203 |
+
self.story_bible.genre = genre
|
| 204 |
+
self.story_bible.genre_elements = GENRE_ELEMENTS.get(genre, {})
|
| 205 |
+
|
| 206 |
+
def add_episode(self, episode_num: int, content: str, hook: str):
|
| 207 |
+
"""Add episode content"""
|
| 208 |
+
self.episodes[episode_num] = content
|
| 209 |
+
self.story_bible.episode_hooks[episode_num] = hook
|
| 210 |
+
self.total_word_count = sum(len(ep.split()) for ep in self.episodes.values())
|
| 211 |
+
|
| 212 |
+
def add_episode_critique(self, episode_num: int, critique: EpisodeCritique):
|
| 213 |
+
"""Add episode critique"""
|
| 214 |
+
self.episode_critiques[episode_num] = critique
|
| 215 |
+
self.reader_engagement_curve.append(critique.reader_engagement)
|
| 216 |
+
|
| 217 |
+
class WebNovelDatabase:
|
| 218 |
+
"""Database management for web novel system"""
|
| 219 |
+
@staticmethod
|
| 220 |
+
def init_db():
|
| 221 |
+
with sqlite3.connect(DB_PATH) as conn:
|
| 222 |
+
conn.execute("PRAGMA journal_mode=WAL")
|
| 223 |
+
cursor = conn.cursor()
|
| 224 |
+
|
| 225 |
+
# Sessions table with genre
|
| 226 |
+
cursor.execute('''
|
| 227 |
+
CREATE TABLE IF NOT EXISTS sessions (
|
| 228 |
+
session_id TEXT PRIMARY KEY,
|
| 229 |
+
user_query TEXT NOT NULL,
|
| 230 |
+
genre TEXT NOT NULL,
|
| 231 |
+
language TEXT NOT NULL,
|
| 232 |
+
title TEXT,
|
| 233 |
+
created_at TEXT DEFAULT (datetime('now')),
|
| 234 |
+
updated_at TEXT DEFAULT (datetime('now')),
|
| 235 |
+
status TEXT DEFAULT 'active',
|
| 236 |
+
current_episode INTEGER DEFAULT 0,
|
| 237 |
+
total_episodes INTEGER DEFAULT 40,
|
| 238 |
+
final_novel TEXT,
|
| 239 |
+
reader_report TEXT,
|
| 240 |
+
total_words INTEGER DEFAULT 0,
|
| 241 |
+
story_bible TEXT,
|
| 242 |
+
engagement_score REAL DEFAULT 0.0
|
| 243 |
+
)
|
| 244 |
+
''')
|
| 245 |
+
|
| 246 |
+
# Episodes table
|
| 247 |
+
cursor.execute('''
|
| 248 |
+
CREATE TABLE IF NOT EXISTS episodes (
|
| 249 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 250 |
+
session_id TEXT NOT NULL,
|
| 251 |
+
episode_number INTEGER NOT NULL,
|
| 252 |
+
content TEXT,
|
| 253 |
+
hook TEXT,
|
| 254 |
+
word_count INTEGER DEFAULT 0,
|
| 255 |
+
reader_engagement REAL DEFAULT 0.0,
|
| 256 |
+
status TEXT DEFAULT 'pending',
|
| 257 |
+
created_at TEXT DEFAULT (datetime('now')),
|
| 258 |
+
FOREIGN KEY (session_id) REFERENCES sessions(session_id),
|
| 259 |
+
UNIQUE(session_id, episode_number)
|
| 260 |
+
)
|
| 261 |
+
''')
|
| 262 |
+
|
| 263 |
+
# Episode critiques table
|
| 264 |
+
cursor.execute('''
|
| 265 |
+
CREATE TABLE IF NOT EXISTS episode_critiques (
|
| 266 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 267 |
+
session_id TEXT NOT NULL,
|
| 268 |
+
episode_number INTEGER NOT NULL,
|
| 269 |
+
critique_data TEXT,
|
| 270 |
+
created_at TEXT DEFAULT (datetime('now')),
|
| 271 |
+
FOREIGN KEY (session_id) REFERENCES sessions(session_id)
|
| 272 |
+
)
|
| 273 |
+
''')
|
| 274 |
+
|
| 275 |
+
# Random themes library with genre
|
| 276 |
+
cursor.execute('''
|
| 277 |
+
CREATE TABLE IF NOT EXISTS webnovel_themes (
|
| 278 |
+
theme_id TEXT PRIMARY KEY,
|
| 279 |
+
genre TEXT NOT NULL,
|
| 280 |
+
theme_text TEXT NOT NULL,
|
| 281 |
+
language TEXT NOT NULL,
|
| 282 |
+
title TEXT,
|
| 283 |
+
protagonist TEXT,
|
| 284 |
+
setting TEXT,
|
| 285 |
+
hook TEXT,
|
| 286 |
+
generated_at TEXT DEFAULT (datetime('now')),
|
| 287 |
+
use_count INTEGER DEFAULT 0,
|
| 288 |
+
rating REAL DEFAULT 0.0,
|
| 289 |
+
tags TEXT
|
| 290 |
+
)
|
| 291 |
+
''')
|
| 292 |
+
|
| 293 |
+
conn.commit()
|
| 294 |
+
|
| 295 |
+
@staticmethod
|
| 296 |
+
@contextmanager
|
| 297 |
+
def get_db():
|
| 298 |
+
with db_lock:
|
| 299 |
+
conn = sqlite3.connect(DB_PATH, timeout=30.0)
|
| 300 |
+
conn.row_factory = sqlite3.Row
|
| 301 |
+
try:
|
| 302 |
+
yield conn
|
| 303 |
+
finally:
|
| 304 |
+
conn.close()
|
| 305 |
+
|
| 306 |
+
@staticmethod
|
| 307 |
+
def create_session(user_query: str, genre: str, language: str) -> str:
|
| 308 |
+
session_id = hashlib.md5(f"{user_query}{genre}{datetime.now()}".encode()).hexdigest()
|
| 309 |
+
with WebNovelDatabase.get_db() as conn:
|
| 310 |
+
conn.cursor().execute(
|
| 311 |
+
'''INSERT INTO sessions (session_id, user_query, genre, language)
|
| 312 |
+
VALUES (?, ?, ?, ?)''',
|
| 313 |
+
(session_id, user_query, genre, language)
|
| 314 |
+
)
|
| 315 |
+
conn.commit()
|
| 316 |
+
return session_id
|
| 317 |
+
|
| 318 |
+
@staticmethod
|
| 319 |
+
def save_episode(session_id: str, episode_num: int, content: str,
|
| 320 |
+
hook: str, engagement: float = 0.0):
|
| 321 |
+
word_count = len(content.split()) if content else 0
|
| 322 |
+
with WebNovelDatabase.get_db() as conn:
|
| 323 |
+
cursor = conn.cursor()
|
| 324 |
+
cursor.execute('''
|
| 325 |
+
INSERT INTO episodes (session_id, episode_number, content, hook,
|
| 326 |
+
word_count, reader_engagement, status)
|
| 327 |
+
VALUES (?, ?, ?, ?, ?, ?, 'complete')
|
| 328 |
+
ON CONFLICT(session_id, episode_number)
|
| 329 |
+
DO UPDATE SET content=?, hook=?, word_count=?,
|
| 330 |
+
reader_engagement=?, status='complete'
|
| 331 |
+
''', (session_id, episode_num, content, hook, word_count, engagement,
|
| 332 |
+
content, hook, word_count, engagement))
|
| 333 |
+
|
| 334 |
+
# Update session progress
|
| 335 |
+
cursor.execute('''
|
| 336 |
+
UPDATE sessions
|
| 337 |
+
SET current_episode = ?,
|
| 338 |
+
total_words = (
|
| 339 |
+
SELECT SUM(word_count) FROM episodes WHERE session_id = ?
|
| 340 |
+
),
|
| 341 |
+
updated_at = datetime('now')
|
| 342 |
+
WHERE session_id = ?
|
| 343 |
+
''', (episode_num, session_id, session_id))
|
| 344 |
+
|
| 345 |
+
conn.commit()
|
| 346 |
+
|
| 347 |
+
@staticmethod
|
| 348 |
+
def get_episodes(session_id: str) -> List[Dict]:
|
| 349 |
+
with WebNovelDatabase.get_db() as conn:
|
| 350 |
+
rows = conn.cursor().execute(
|
| 351 |
+
'''SELECT * FROM episodes WHERE session_id = ?
|
| 352 |
+
ORDER BY episode_number''',
|
| 353 |
+
(session_id,)
|
| 354 |
+
).fetchall()
|
| 355 |
+
return [dict(row) for row in rows]
|
| 356 |
+
|
| 357 |
+
@staticmethod
|
| 358 |
+
def save_webnovel_theme(genre: str, theme_text: str, language: str,
|
| 359 |
+
metadata: Dict[str, Any]) -> str:
|
| 360 |
+
theme_id = hashlib.md5(f"{genre}{theme_text}{datetime.now()}".encode()).hexdigest()[:12]
|
| 361 |
+
|
| 362 |
+
with WebNovelDatabase.get_db() as conn:
|
| 363 |
+
conn.cursor().execute('''
|
| 364 |
+
INSERT INTO webnovel_themes
|
| 365 |
+
(theme_id, genre, theme_text, language, title, protagonist,
|
| 366 |
+
setting, hook, tags)
|
| 367 |
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
| 368 |
+
''', (theme_id, genre, theme_text, language,
|
| 369 |
+
metadata.get('title', ''),
|
| 370 |
+
metadata.get('protagonist', ''),
|
| 371 |
+
metadata.get('setting', ''),
|
| 372 |
+
metadata.get('hook', ''),
|
| 373 |
+
json.dumps(metadata.get('tags', []))))
|
| 374 |
+
conn.commit()
|
| 375 |
+
|
| 376 |
+
return theme_id
|
| 377 |
+
|
| 378 |
+
# --- LLM Integration ---
|
| 379 |
+
class WebNovelSystem:
|
| 380 |
+
"""Web novel generation system"""
|
| 381 |
+
def __init__(self):
|
| 382 |
+
self.token = FRIENDLI_TOKEN
|
| 383 |
+
self.api_url = API_URL
|
| 384 |
+
self.model_id = MODEL_ID
|
| 385 |
+
self.tracker = WebNovelTracker()
|
| 386 |
+
self.current_session_id = None
|
| 387 |
+
WebNovelDatabase.init_db()
|
| 388 |
+
|
| 389 |
+
def create_headers(self):
|
| 390 |
+
return {"Authorization": f"Bearer {self.token}", "Content-Type": "application/json"}
|
| 391 |
+
|
| 392 |
+
# --- Prompt generation functions ---
|
| 393 |
+
def create_planning_prompt(self, query: str, genre: str, language: str) -> str:
|
| 394 |
+
"""Create initial planning prompt for web novel"""
|
| 395 |
+
genre_info = GENRE_ELEMENTS.get(genre, {})
|
| 396 |
+
|
| 397 |
+
lang_prompts = {
|
| 398 |
+
"Korean": f"""한국 웹소설 시장을 겨냥한 {genre} 장르 웹소설을 기획하세요.
|
| 399 |
+
|
| 400 |
+
**주제:** {query}
|
| 401 |
+
**장르:** {genre}
|
| 402 |
+
**목표:** 40화 완결, 총 16,000단어
|
| 403 |
+
|
| 404 |
+
**장르 필수 요소:**
|
| 405 |
+
- 핵심 요소: {', '.join(genre_info.get('key_elements', []))}
|
| 406 |
+
- 인기 트로프: {', '.join(genre_info.get('popular_tropes', []))}
|
| 407 |
+
- 필수 포함: {', '.join(genre_info.get('must_have', []))}
|
| 408 |
+
|
| 409 |
+
**전체 구성:**
|
| 410 |
+
1. **1-5화**: 흥미로운 도입부, 주인공과 세계관 소개, 핵심 갈등 제시
|
| 411 |
+
2. **6-15화**: 갈등 상승, 주요 인물 관계 형성, 첫 번째 위기
|
| 412 |
+
3. **16-25화**: 중간 클라이맥스, 반전, 새로운 진실 발견
|
| 413 |
+
4. **26-35화**: 최종 갈등으로 치닫기, 모든 갈등의 수렴
|
| 414 |
+
5. **36-40화**: 대결전, 클라이맥스와 결말, 감동적 마무리
|
| 415 |
+
|
| 416 |
+
**각 화 구성 원칙:**
|
| 417 |
+
- 400-600단어 분량 (충실한 내용)
|
| 418 |
+
- 3-4개의 주요 장면 포함
|
| 419 |
+
- 매 화 끝 강력한 후크
|
| 420 |
+
- 빠른 전개와 몰입감
|
| 421 |
+
- 독자가 다음 화를 기다리게 만들기
|
| 422 |
+
|
| 423 |
+
구체적인 40화 플롯라인을 제시하세요. 각 화마다 핵심 사건과 전개를 명시하세요.""",
|
| 424 |
+
|
| 425 |
+
"English": f"""Plan a Korean-style web novel for {genre} genre.
|
| 426 |
+
|
| 427 |
+
**Theme:** {query}
|
| 428 |
+
**Genre:** {genre}
|
| 429 |
+
**Goal:** 40 episodes, total 16,000 words
|
| 430 |
+
|
| 431 |
+
**Genre Requirements:**
|
| 432 |
+
- Key elements: {', '.join(genre_info.get('key_elements', []))}
|
| 433 |
+
- Popular tropes: {', '.join(genre_info.get('popular_tropes', []))}
|
| 434 |
+
- Must include: {', '.join(genre_info.get('must_have', []))}
|
| 435 |
+
|
| 436 |
+
**Overall Structure:**
|
| 437 |
+
1. **Episodes 1-5**: Engaging introduction, protagonist and world, core conflict
|
| 438 |
+
2. **Episodes 6-15**: Rising conflict, main relationships, first crisis
|
| 439 |
+
3. **Episodes 16-25**: Mid climax, plot twist, new revelations
|
| 440 |
+
4. **Episodes 26-35**: Building to final conflict, convergence of all conflicts
|
| 441 |
+
5. **Episodes 36-40**: Final battle, climax and resolution, emotional closure
|
| 442 |
+
|
| 443 |
+
**Episode Principles:**
|
| 444 |
+
- 400-600 words each (substantial content)
|
| 445 |
+
- 3-4 major scenes per episode
|
| 446 |
+
- Strong hook at episode end
|
| 447 |
+
- Fast pacing and immersion
|
| 448 |
+
- Make readers crave next episode
|
| 449 |
+
|
| 450 |
+
Provide detailed 40-episode plotline with key events for each episode."""
|
| 451 |
+
}
|
| 452 |
+
|
| 453 |
+
return lang_prompts.get(language, lang_prompts["Korean"])
|
| 454 |
+
|
| 455 |
+
def create_episode_prompt(self, episode_num: int, plot_outline: str,
|
| 456 |
+
previous_content: str, genre: str, language: str) -> str:
|
| 457 |
+
"""Create prompt for individual episode - UPDATED FOR LONGER CONTENT"""
|
| 458 |
+
genre_info = GENRE_ELEMENTS.get(genre, {})
|
| 459 |
+
hooks = EPISODE_HOOKS.get(genre, ["다음 순간, 충격적인 일이..."])
|
| 460 |
+
|
| 461 |
+
lang_prompts = {
|
| 462 |
+
"Korean": f"""웹소설 {episode_num}화를 작성하세요.
|
| 463 |
+
|
| 464 |
+
**장르:** {genre}
|
| 465 |
+
**분량:** 400-600단어 (엄격히 준수 - 충실한 내용으로)
|
| 466 |
+
|
| 467 |
+
**전체 플롯에서 {episode_num}화 내용:**
|
| 468 |
+
{self._extract_episode_plan(plot_outline, episode_num)}
|
| 469 |
+
|
| 470 |
+
**이전 내용 요약:**
|
| 471 |
+
{previous_content[-1500:] if previous_content else "첫 화입니다"}
|
| 472 |
+
|
| 473 |
+
**작성 형식:**
|
| 474 |
+
반드시 다음 형식으로 시작하세요:
|
| 475 |
+
{episode_num}화. [이번 화의 핵심을 담은 매력적인 소제목]
|
| 476 |
+
|
| 477 |
+
(한 줄 띄우고 본문 시작)
|
| 478 |
+
|
| 479 |
+
**작성 지침:**
|
| 480 |
+
1. **구성**: 3-4개의 주요 장면으로 구성
|
| 481 |
+
- 도입부: 이전 화 연결 및 현재 상황
|
| 482 |
+
- 전개부: 2-3개의 핵심 사건/대화
|
| 483 |
+
- 클라이맥스: 긴장감 최고조
|
| 484 |
+
- 후크: 다음 화 예고
|
| 485 |
+
|
| 486 |
+
2. **필수 요소:**
|
| 487 |
+
- 생생한 대화와 ���동 묘사
|
| 488 |
+
- 캐릭터 감정과 내면 갈등
|
| 489 |
+
- 장면 전환과 템포 조절
|
| 490 |
+
- 독자 몰입을 위한 감각적 묘사
|
| 491 |
+
|
| 492 |
+
3. **장르별 특색:**
|
| 493 |
+
- {genre_info.get('episode_structure', '빠른 전개')}
|
| 494 |
+
- 핵심 요소 1개 이상 포함
|
| 495 |
+
|
| 496 |
+
4. **분량 배분:**
|
| 497 |
+
- 도입 (50-80단어)
|
| 498 |
+
- 주요 전개 (250-350단어)
|
| 499 |
+
- 클라이맥스와 후크 (100-150단어)
|
| 500 |
+
|
| 501 |
+
**참고 후크 예시:**
|
| 502 |
+
{random.choice(hooks)}
|
| 503 |
+
|
| 504 |
+
소제목은 이번 화의 핵심 사건이나 전환점을 암시하는 매력적인 문구로 작성하세요.
|
| 505 |
+
{episode_num}화를 풍성하고 몰입감 있게 작성하세요. 반드시 400-600단어로 작성하세요.""",
|
| 506 |
+
|
| 507 |
+
"English": f"""Write episode {episode_num} of the web novel.
|
| 508 |
+
|
| 509 |
+
**Genre:** {genre}
|
| 510 |
+
**Length:** 400-600 words (strict - with substantial content)
|
| 511 |
+
|
| 512 |
+
**Episode {episode_num} from plot:**
|
| 513 |
+
{self._extract_episode_plan(plot_outline, episode_num)}
|
| 514 |
+
|
| 515 |
+
**Previous content:**
|
| 516 |
+
{previous_content[-1500:] if previous_content else "First episode"}
|
| 517 |
+
|
| 518 |
+
**Format:**
|
| 519 |
+
Must start with:
|
| 520 |
+
Episode {episode_num}. [Attractive subtitle that captures the essence of this episode]
|
| 521 |
+
|
| 522 |
+
(blank line then start main text)
|
| 523 |
+
|
| 524 |
+
**Guidelines:**
|
| 525 |
+
1. **Structure**: 3-4 major scenes
|
| 526 |
+
- Opening: Connect from previous, current situation
|
| 527 |
+
- Development: 2-3 key events/dialogues
|
| 528 |
+
- Climax: Peak tension
|
| 529 |
+
- Hook: Next episode teaser
|
| 530 |
+
|
| 531 |
+
2. **Essential elements:**
|
| 532 |
+
- Vivid dialogue and action
|
| 533 |
+
- Character emotions and conflicts
|
| 534 |
+
- Scene transitions and pacing
|
| 535 |
+
- Sensory details for immersion
|
| 536 |
+
|
| 537 |
+
3. **Genre specifics:**
|
| 538 |
+
- {genre_info.get('episode_structure', 'Fast pacing')}
|
| 539 |
+
- Include at least 1 core element
|
| 540 |
+
|
| 541 |
+
4. **Word distribution:**
|
| 542 |
+
- Opening (50-80 words)
|
| 543 |
+
- Main development (250-350 words)
|
| 544 |
+
- Climax and hook (100-150 words)
|
| 545 |
+
|
| 546 |
+
**Hook example:**
|
| 547 |
+
{random.choice(hooks)}
|
| 548 |
+
|
| 549 |
+
Create an attractive subtitle that hints at key events or turning points.
|
| 550 |
+
Write rich, immersive episode {episode_num}. Must be 400-600 words."""
|
| 551 |
+
}
|
| 552 |
+
|
| 553 |
+
return lang_prompts.get(language, lang_prompts["Korean"])
|
| 554 |
+
|
| 555 |
+
def create_episode_critique_prompt(self, episode_num: int, content: str,
|
| 556 |
+
genre: str, language: str) -> str:
|
| 557 |
+
"""Create critique prompt for episode"""
|
| 558 |
+
lang_prompts = {
|
| 559 |
+
"Korean": f"""{genre} 웹소설 {episode_num}화를 평가하세요.
|
| 560 |
+
|
| 561 |
+
**작성된 내용:**
|
| 562 |
+
{content}
|
| 563 |
+
|
| 564 |
+
**평가 기준:**
|
| 565 |
+
1. **후크 효과성 (25점)**: 다음 화를 읽고 싶게 만드는가?
|
| 566 |
+
2. **페이싱 (25점)**: 전개 속도가 적절한가?
|
| 567 |
+
3. **장르 적합성 (25점)**: {genre} 장르 관습을 잘 따르는가?
|
| 568 |
+
4. **독자 몰입도 (25점)**: 감정적으로 빠져들게 하는가?
|
| 569 |
+
|
| 570 |
+
**점수: /100점**
|
| 571 |
+
|
| 572 |
+
구체적인 개선점을 제시하세요.""",
|
| 573 |
+
|
| 574 |
+
"English": f"""Evaluate {genre} web novel episode {episode_num}.
|
| 575 |
+
|
| 576 |
+
**Written content:**
|
| 577 |
+
{content}
|
| 578 |
+
|
| 579 |
+
**Evaluation criteria:**
|
| 580 |
+
1. **Hook effectiveness (25pts)**: Makes readers want next episode?
|
| 581 |
+
2. **Pacing (25pts)**: Appropriate development speed?
|
| 582 |
+
3. **Genre fit (25pts)**: Follows {genre} conventions?
|
| 583 |
+
4. **Reader engagement (25pts)**: Emotionally immersive?
|
| 584 |
+
|
| 585 |
+
**Score: /100 points**
|
| 586 |
+
|
| 587 |
+
Provide specific improvements."""
|
| 588 |
+
}
|
| 589 |
+
|
| 590 |
+
return lang_prompts.get(language, lang_prompts["Korean"])
|
| 591 |
+
|
| 592 |
+
def _extract_episode_plan(self, plot_outline: str, episode_num: int) -> str:
|
| 593 |
+
"""Extract specific episode plan from outline"""
|
| 594 |
+
lines = plot_outline.split('\n')
|
| 595 |
+
episode_section = []
|
| 596 |
+
capturing = False
|
| 597 |
+
|
| 598 |
+
patterns = [
|
| 599 |
+
f"{episode_num}화:", f"Episode {episode_num}:",
|
| 600 |
+
f"제{episode_num}화:", f"EP{episode_num}:"
|
| 601 |
+
]
|
| 602 |
+
|
| 603 |
+
for line in lines:
|
| 604 |
+
if any(pattern in line for pattern in patterns):
|
| 605 |
+
capturing = True
|
| 606 |
+
elif capturing and any(f"{episode_num+1}" in line for pattern in patterns):
|
| 607 |
+
break
|
| 608 |
+
elif capturing:
|
| 609 |
+
episode_section.append(line)
|
| 610 |
+
|
| 611 |
+
return '\n'.join(episode_section) if episode_section else "플롯을 참고하여 작성"
|
| 612 |
+
|
| 613 |
+
# --- LLM call functions ---
|
| 614 |
+
def call_llm_sync(self, messages: List[Dict[str, str]], role: str, language: str) -> str:
|
| 615 |
+
full_content = ""
|
| 616 |
+
for chunk in self.call_llm_streaming(messages, role, language):
|
| 617 |
+
full_content += chunk
|
| 618 |
+
if full_content.startswith("❌"):
|
| 619 |
+
raise Exception(f"LLM Call Failed: {full_content}")
|
| 620 |
+
return full_content
|
| 621 |
+
|
| 622 |
+
def call_llm_streaming(self, messages: List[Dict[str, str]], role: str,
|
| 623 |
+
language: str) -> Generator[str, None, None]:
|
| 624 |
+
try:
|
| 625 |
+
system_prompts = self.get_system_prompts(language)
|
| 626 |
+
full_messages = [{"role": "system", "content": system_prompts.get(role, "")}, *messages]
|
| 627 |
+
|
| 628 |
+
# Increased max_tokens for longer episodes
|
| 629 |
+
max_tokens = 5000 if role == "writer" else 10000
|
| 630 |
+
|
| 631 |
+
payload = {
|
| 632 |
+
"model": self.model_id,
|
| 633 |
+
"messages": full_messages,
|
| 634 |
+
"max_tokens": max_tokens,
|
| 635 |
+
"temperature": 0.85,
|
| 636 |
+
"top_p": 0.95,
|
| 637 |
+
"presence_penalty": 0.3,
|
| 638 |
+
"frequency_penalty": 0.3,
|
| 639 |
+
"stream": True
|
| 640 |
+
}
|
| 641 |
+
|
| 642 |
+
response = requests.post(
|
| 643 |
+
self.api_url,
|
| 644 |
+
headers=self.create_headers(),
|
| 645 |
+
json=payload,
|
| 646 |
+
stream=True,
|
| 647 |
+
timeout=180
|
| 648 |
+
)
|
| 649 |
+
|
| 650 |
+
if response.status_code != 200:
|
| 651 |
+
yield f"❌ API Error (Status Code: {response.status_code})"
|
| 652 |
+
return
|
| 653 |
+
|
| 654 |
+
buffer = ""
|
| 655 |
+
for line in response.iter_lines():
|
| 656 |
+
if not line:
|
| 657 |
+
continue
|
| 658 |
+
|
| 659 |
+
try:
|
| 660 |
+
line_str = line.decode('utf-8').strip()
|
| 661 |
+
if not line_str.startswith("data: "):
|
| 662 |
+
continue
|
| 663 |
+
|
| 664 |
+
data_str = line_str[6:]
|
| 665 |
+
if data_str == "[DONE]":
|
| 666 |
+
break
|
| 667 |
+
|
| 668 |
+
data = json.loads(data_str)
|
| 669 |
+
choices = data.get("choices", [])
|
| 670 |
+
if choices and choices[0].get("delta", {}).get("content"):
|
| 671 |
+
content = choices[0]["delta"]["content"]
|
| 672 |
+
buffer += content
|
| 673 |
+
|
| 674 |
+
if len(buffer) >= 50 or '\n' in buffer:
|
| 675 |
+
yield buffer
|
| 676 |
+
buffer = ""
|
| 677 |
+
time.sleep(0.01)
|
| 678 |
+
|
| 679 |
+
except Exception as e:
|
| 680 |
+
logger.error(f"Chunk processing error: {str(e)}")
|
| 681 |
+
continue
|
| 682 |
+
|
| 683 |
+
if buffer:
|
| 684 |
+
yield buffer
|
| 685 |
+
|
| 686 |
+
except Exception as e:
|
| 687 |
+
logger.error(f"Streaming error: {type(e).__name__}: {str(e)}")
|
| 688 |
+
yield f"❌ Error occurred: {str(e)}"
|
| 689 |
+
|
| 690 |
+
def get_system_prompts(self, language: str) -> Dict[str, str]:
|
| 691 |
+
"""System prompts for web novel roles - UPDATED FOR LONGER EPISODES"""
|
| 692 |
+
base_prompts = {
|
| 693 |
+
"Korean": {
|
| 694 |
+
"planner": """당신은 한국 웹소설 시장을 완벽히 이해하는 기획자입니다.
|
| 695 |
+
독자를 중독시키는 플롯과 전개를 설계합니다.
|
| 696 |
+
장르별 관습과 독자 기대를 정확히 파악합니다.
|
| 697 |
+
40화 완결 구조로 완벽한 기승전결을 만듭니다.
|
| 698 |
+
각 화마다 충실한 내용과 전개를 계획합니다.""",
|
| 699 |
+
|
| 700 |
+
"writer": """당신은 독자를 사로잡는 웹소설 작가입니다.
|
| 701 |
+
풍부하고 몰입감 있는 문체를 구사합니다.
|
| 702 |
+
각 화를 400-600단어로 충실하게 작성합니다.
|
| 703 |
+
여러 장면과 전환을 통해 이야기를 전개합니다.
|
| 704 |
+
대화, 행동, 내면 묘사를 균형있게 배치합니다.
|
| 705 |
+
매 화 끝에 강력한 후크로 다음 화를 기다리게 만듭니다.""",
|
| 706 |
+
|
| 707 |
+
"critic": """당신은 웹소설 독자의 마음을 읽는 평론가입니다.
|
| 708 |
+
재미와 몰입감을 최우선으로 평가합니다.
|
| 709 |
+
장르적 쾌감과 독자 만족도를 분석합니다.
|
| 710 |
+
구체적이고 실용적인 개선안을 제시합니다."""
|
| 711 |
+
},
|
| 712 |
+
"English": {
|
| 713 |
+
"planner": """You perfectly understand the Korean web novel market.
|
| 714 |
+
Design addictive plots and developments.
|
| 715 |
+
Accurately grasp genre conventions and reader expectations.
|
| 716 |
+
Create perfect story structure in 40 episodes.
|
| 717 |
+
Plan substantial content and development for each episode.""",
|
| 718 |
+
|
| 719 |
+
"writer": """You are a web novelist who captivates readers.
|
| 720 |
+
Use rich and immersive writing style.
|
| 721 |
+
Write each episode with 400-600 words faithfully.
|
| 722 |
+
Develop story through multiple scenes and transitions.
|
| 723 |
+
Balance dialogue, action, and inner descriptions.
|
| 724 |
+
End each episode with powerful hook for next.""",
|
| 725 |
+
|
| 726 |
+
"critic": """You read web novel readers' minds.
|
| 727 |
+
Prioritize fun and immersion in evaluation.
|
| 728 |
+
Analyze genre satisfaction and reader enjoyment.
|
| 729 |
+
Provide specific, practical improvements."""
|
| 730 |
+
}
|
| 731 |
+
}
|
| 732 |
+
|
| 733 |
+
return base_prompts.get(language, base_prompts["Korean"])
|
| 734 |
+
|
| 735 |
+
# --- Main process ---
|
| 736 |
+
def process_webnovel_stream(self, query: str, genre: str, language: str,
|
| 737 |
+
session_id: Optional[str] = None) -> Generator[Tuple[str, str, str, str], None, None]:
|
| 738 |
+
"""Web novel generation process"""
|
| 739 |
+
try:
|
| 740 |
+
resume_from_episode = 0
|
| 741 |
+
plot_outline = ""
|
| 742 |
+
|
| 743 |
+
if session_id:
|
| 744 |
+
self.current_session_id = session_id
|
| 745 |
+
# Resume logic here
|
| 746 |
+
else:
|
| 747 |
+
self.current_session_id = WebNovelDatabase.create_session(query, genre, language)
|
| 748 |
+
self.tracker.set_genre(genre)
|
| 749 |
+
logger.info(f"Created new session: {self.current_session_id}")
|
| 750 |
+
|
| 751 |
+
# Generate plot outline first
|
| 752 |
+
if resume_from_episode == 0:
|
| 753 |
+
yield "🎬 웹소설 플롯 구성 중...", "", f"장르: {genre}", self.current_session_id
|
| 754 |
+
|
| 755 |
+
plot_prompt = self.create_planning_prompt(query, genre, language)
|
| 756 |
+
plot_outline = self.call_llm_sync(
|
| 757 |
+
[{"role": "user", "content": plot_prompt}],
|
| 758 |
+
"planner", language
|
| 759 |
+
)
|
| 760 |
+
|
| 761 |
+
yield "✅ 플롯 구성 완료!", "", f"40화 구성 완료", self.current_session_id
|
| 762 |
+
|
| 763 |
+
# Generate episodes
|
| 764 |
+
accumulated_content = ""
|
| 765 |
+
for episode_num in range(resume_from_episode + 1, TARGET_EPISODES + 1):
|
| 766 |
+
# Write episode
|
| 767 |
+
yield f"✍️ {episode_num}화 집필 중...", accumulated_content, f"진행률: {episode_num}/{TARGET_EPISODES}화", self.current_session_id
|
| 768 |
+
|
| 769 |
+
episode_prompt = self.create_episode_prompt(
|
| 770 |
+
episode_num, plot_outline, accumulated_content, genre, language
|
| 771 |
+
)
|
| 772 |
+
|
| 773 |
+
episode_content = self.call_llm_sync(
|
| 774 |
+
[{"role": "user", "content": episode_prompt}],
|
| 775 |
+
"writer", language
|
| 776 |
+
)
|
| 777 |
+
|
| 778 |
+
# Extract episode title and content
|
| 779 |
+
lines = episode_content.strip().split('\n')
|
| 780 |
+
episode_title = ""
|
| 781 |
+
actual_content = episode_content
|
| 782 |
+
|
| 783 |
+
# Check if first line contains episode number and title
|
| 784 |
+
if lines and (f"{episode_num}화." in lines[0] or f"Episode {episode_num}." in lines[0]):
|
| 785 |
+
episode_title = lines[0]
|
| 786 |
+
# Join the rest as content (excluding the title line and empty line after it)
|
| 787 |
+
actual_content = '\n'.join(lines[2:] if len(lines) > 2 and lines[1].strip() == "" else lines[1:])
|
| 788 |
+
else:
|
| 789 |
+
# If no title format found, generate a default title
|
| 790 |
+
episode_title = f"{episode_num}화. 제{episode_num}화"
|
| 791 |
+
|
| 792 |
+
# Extract hook (last sentence)
|
| 793 |
+
sentences = actual_content.split('.')
|
| 794 |
+
hook = sentences[-2] + '.' if len(sentences) > 1 else sentences[-1]
|
| 795 |
+
|
| 796 |
+
# Save episode with title
|
| 797 |
+
WebNovelDatabase.save_episode(
|
| 798 |
+
self.current_session_id, episode_num,
|
| 799 |
+
actual_content, hook
|
| 800 |
+
)
|
| 801 |
+
|
| 802 |
+
# Add to accumulated content with title
|
| 803 |
+
accumulated_content += f"\n\n### {episode_title}\n{actual_content}"
|
| 804 |
+
|
| 805 |
+
# Quick critique every 5 episodes
|
| 806 |
+
if episode_num % 5 == 0:
|
| 807 |
+
critique_prompt = self.create_episode_critique_prompt(
|
| 808 |
+
episode_num, episode_content, genre, language
|
| 809 |
+
)
|
| 810 |
+
critique = self.call_llm_sync(
|
| 811 |
+
[{"role": "user", "content": critique_prompt}],
|
| 812 |
+
"critic", language
|
| 813 |
+
)
|
| 814 |
+
|
| 815 |
+
yield f"✅ {episode_num}화 완료!", accumulated_content, f"진행률: {episode_num}/{TARGET_EPISODES}화", self.current_session_id
|
| 816 |
+
|
| 817 |
+
# Complete
|
| 818 |
+
total_words = len(accumulated_content.split())
|
| 819 |
+
yield f"🎉 웹소설 완성!", accumulated_content, f"총 {total_words:,}단어, {TARGET_EPISODES}화 완결", self.current_session_id
|
| 820 |
+
|
| 821 |
+
except Exception as e:
|
| 822 |
+
logger.error(f"Web novel generation error: {e}", exc_info=True)
|
| 823 |
+
yield f"❌ 오류 발생: {e}", accumulated_content if 'accumulated_content' in locals() else "", "오류", self.current_session_id
|
| 824 |
+
|
| 825 |
+
# --- Export functions ---
|
| 826 |
+
def export_to_txt(episodes: List[Dict], genre: str, title: str = "") -> str:
|
| 827 |
+
"""Export web novel to TXT format"""
|
| 828 |
+
content = f"{'=' * 50}\n"
|
| 829 |
+
content += f"{title if title else genre + ' 웹소설'}\n"
|
| 830 |
+
content += f"{'=' * 50}\n\n"
|
| 831 |
+
content += f"총 {len(episodes)}화 완결\n"
|
| 832 |
+
content += f"총 단어 수: {sum(ep.get('word_count', 0) for ep in episodes):,}\n"
|
| 833 |
+
content += f"{'=' * 50}\n\n"
|
| 834 |
+
|
| 835 |
+
for ep in episodes:
|
| 836 |
+
ep_num = ep.get('episode_number', 0)
|
| 837 |
+
ep_content = ep.get('content', '')
|
| 838 |
+
|
| 839 |
+
# Extract title if exists in content
|
| 840 |
+
lines = ep_content.strip().split('\n')
|
| 841 |
+
if lines and (f"{ep_num}화." in lines[0] or f"Episode {ep_num}." in lines[0]):
|
| 842 |
+
content += f"\n{lines[0]}\n"
|
| 843 |
+
content += f"{'-' * 40}\n\n"
|
| 844 |
+
actual_content = '\n'.join(lines[2:] if len(lines) > 2 and lines[1].strip() == "" else lines[1:])
|
| 845 |
+
content += actual_content
|
| 846 |
+
else:
|
| 847 |
+
content += f"\n{ep_num}화\n"
|
| 848 |
+
content += f"{'-' * 40}\n\n"
|
| 849 |
+
content += ep_content
|
| 850 |
+
|
| 851 |
+
content += f"\n\n{'=' * 50}\n"
|
| 852 |
+
|
| 853 |
+
return content
|
| 854 |
+
|
| 855 |
+
def export_to_docx(episodes: List[Dict], genre: str, title: str = "") -> bytes:
|
| 856 |
+
"""Export web novel to DOCX format"""
|
| 857 |
+
if not DOCX_AVAILABLE:
|
| 858 |
+
raise Exception("python-docx is not installed")
|
| 859 |
+
|
| 860 |
+
doc = Document()
|
| 861 |
+
|
| 862 |
+
# Title
|
| 863 |
+
doc.add_heading(title if title else f"{genre} 웹소설", 0)
|
| 864 |
+
|
| 865 |
+
# Stats
|
| 866 |
+
doc.add_paragraph(f"총 {len(episodes)}화 완결")
|
| 867 |
+
doc.add_paragraph(f"총 단어 수: {sum(ep.get('word_count', 0) for ep in episodes):,}")
|
| 868 |
+
doc.add_page_break()
|
| 869 |
+
|
| 870 |
+
# Episodes
|
| 871 |
+
for ep in episodes:
|
| 872 |
+
ep_num = ep.get('episode_number', 0)
|
| 873 |
+
ep_content = ep.get('content', '')
|
| 874 |
+
|
| 875 |
+
# Extract title if exists
|
| 876 |
+
lines = ep_content.strip().split('\n')
|
| 877 |
+
if lines and (f"{ep_num}화." in lines[0] or f"Episode {ep_num}." in lines[0]):
|
| 878 |
+
doc.add_heading(lines[0], 1)
|
| 879 |
+
actual_content = '\n'.join(lines[2:] if len(lines) > 2 and lines[1].strip() == "" else lines[1:])
|
| 880 |
+
else:
|
| 881 |
+
doc.add_heading(f"{ep_num}화", 1)
|
| 882 |
+
actual_content = ep_content
|
| 883 |
+
|
| 884 |
+
# Add content paragraphs
|
| 885 |
+
for paragraph in actual_content.split('\n'):
|
| 886 |
+
if paragraph.strip():
|
| 887 |
+
doc.add_paragraph(paragraph.strip())
|
| 888 |
+
|
| 889 |
+
if ep_num < len(episodes):
|
| 890 |
+
doc.add_page_break()
|
| 891 |
+
|
| 892 |
+
# Save to bytes
|
| 893 |
+
import io
|
| 894 |
+
bytes_io = io.BytesIO()
|
| 895 |
+
doc.save(bytes_io)
|
| 896 |
+
bytes_io.seek(0)
|
| 897 |
+
return bytes_io.getvalue()
|
| 898 |
+
def generate_random_webnovel_theme(genre: str, language: str) -> str:
|
| 899 |
+
"""Generate random web novel theme using novel_themes.json and LLM"""
|
| 900 |
+
try:
|
| 901 |
+
# Load novel_themes.json with error handling
|
| 902 |
+
json_path = Path("novel_themes.json")
|
| 903 |
+
if not json_path.exists():
|
| 904 |
+
logger.warning("novel_themes.json not found, using fallback")
|
| 905 |
+
return generate_fallback_theme(genre, language)
|
| 906 |
+
|
| 907 |
+
try:
|
| 908 |
+
with open(json_path, 'r', encoding='utf-8') as f:
|
| 909 |
+
content = f.read()
|
| 910 |
+
# Try to fix common JSON errors
|
| 911 |
+
content = content.replace("'", '"') # Replace single quotes with double quotes
|
| 912 |
+
content = re.sub(r',\s*}', '}', content) # Remove trailing commas before }
|
| 913 |
+
content = re.sub(r',\s*]', ']', content) # Remove trailing commas before ]
|
| 914 |
+
themes_data = json.loads(content)
|
| 915 |
+
except json.JSONDecodeError as e:
|
| 916 |
+
logger.error(f"JSON parsing error: {e}")
|
| 917 |
+
logger.error(f"Error at position: {e.pos if hasattr(e, 'pos') else 'unknown'}")
|
| 918 |
+
# If JSON parsing fails, use LLM with genre-specific prompt
|
| 919 |
+
return generate_theme_with_llm_only(genre, language)
|
| 920 |
+
|
| 921 |
+
# Map genres to theme data
|
| 922 |
+
genre_mapping = {
|
| 923 |
+
"로맨스": ["romance_fantasy_villainess", "villainess_wants_to_be_lazy", "chaebol_family_intrigue"],
|
| 924 |
+
"로판": ["romance_fantasy_villainess", "BL_novel_transmigration", "regression_childcare"],
|
| 925 |
+
"판타지": ["system_constellation_hunter", "tower_ascension_challenger", "necromancer_solo_leveling"],
|
| 926 |
+
"현판": ["system_constellation_hunter", "chaebol_family_intrigue", "post_apocalypse_survival"],
|
| 927 |
+
"무협": ["regression_revenge_pro", "necromancer_solo_leveling"],
|
| 928 |
+
"미스터리": ["post_apocalypse_survival", "tower_ascension_challenger"],
|
| 929 |
+
"라이트노벨": ["BL_novel_transmigration", "villainess_wants_to_be_lazy"]
|
| 930 |
+
}
|
| 931 |
+
|
| 932 |
+
# Get relevant core genres for selected genre
|
| 933 |
+
relevant_genres = genre_mapping.get(genre, ["regression_revenge_pro"])
|
| 934 |
+
selected_genre_key = random.choice(relevant_genres)
|
| 935 |
+
|
| 936 |
+
# Get genre data
|
| 937 |
+
core_genre = themes_data["core_genres"].get(selected_genre_key, {})
|
| 938 |
+
compatible_elements = core_genre.get("compatible_elements", {})
|
| 939 |
+
|
| 940 |
+
# Select random elements
|
| 941 |
+
character_keys = compatible_elements.get("characters", [])
|
| 942 |
+
selected_character_key = random.choice(character_keys) if character_keys else "betrayed_protagonist"
|
| 943 |
+
|
| 944 |
+
# Get character variations
|
| 945 |
+
character_data = themes_data["characters"].get(selected_character_key, {})
|
| 946 |
+
character_variations = character_data.get("variations", [])
|
| 947 |
+
character_desc = random.choice(character_variations) if character_variations else ""
|
| 948 |
+
character_traits = character_data.get("traits", [])
|
| 949 |
+
|
| 950 |
+
# Get settings
|
| 951 |
+
settings = compatible_elements.get("settings", [])
|
| 952 |
+
selected_setting = random.choice(settings) if settings else ""
|
| 953 |
+
|
| 954 |
+
# Get all available settings for more variety
|
| 955 |
+
all_settings = themes_data.get("settings", {})
|
| 956 |
+
setting_details = []
|
| 957 |
+
for setting_list in all_settings.values():
|
| 958 |
+
setting_details.extend(setting_list)
|
| 959 |
+
specific_setting = random.choice(setting_details) if setting_details else selected_setting
|
| 960 |
+
|
| 961 |
+
# Get mechanics
|
| 962 |
+
mechanics_keys = list(themes_data.get("core_mechanics", {}).keys())
|
| 963 |
+
selected_mechanic = random.choice(mechanics_keys) if mechanics_keys else ""
|
| 964 |
+
mechanic_data = themes_data["core_mechanics"].get(selected_mechanic, {})
|
| 965 |
+
plot_points = mechanic_data.get("plot_points", [])
|
| 966 |
+
reader_questions = mechanic_data.get("reader_questions", [])
|
| 967 |
+
|
| 968 |
+
# Get hooks
|
| 969 |
+
hook_types = list(themes_data.get("episode_hooks", {}).keys())
|
| 970 |
+
selected_hook_type = random.choice(hook_types) if hook_types else "introduction"
|
| 971 |
+
hooks = themes_data["episode_hooks"].get(selected_hook_type, [])
|
| 972 |
+
selected_hook = random.choice(hooks) if hooks else ""
|
| 973 |
+
|
| 974 |
+
# Get items/artifacts
|
| 975 |
+
item_categories = list(themes_data.get("key_items_and_artifacts", {}).keys())
|
| 976 |
+
if item_categories and genre in ["판타지", "현판", "무협"]:
|
| 977 |
+
selected_category = random.choice(item_categories)
|
| 978 |
+
items = themes_data["key_items_and_artifacts"].get(selected_category, [])
|
| 979 |
+
selected_item = random.choice(items) if items else ""
|
| 980 |
+
else:
|
| 981 |
+
selected_item = ""
|
| 982 |
+
|
| 983 |
+
# Get plot twists
|
| 984 |
+
twist_categories = list(themes_data.get("plot_twists_and_cliches", {}).keys())
|
| 985 |
+
if twist_categories:
|
| 986 |
+
selected_twist_cat = random.choice(twist_categories)
|
| 987 |
+
twists = themes_data["plot_twists_and_cliches"].get(selected_twist_cat, [])
|
| 988 |
+
selected_twist = random.choice(twists) if twists else ""
|
| 989 |
+
else:
|
| 990 |
+
selected_twist = ""
|
| 991 |
+
|
| 992 |
+
# Check for fusion genres
|
| 993 |
+
fusion_genres = themes_data.get("fusion_genres", {})
|
| 994 |
+
fusion_options = list(fusion_genres.values())
|
| 995 |
+
selected_fusion = random.choice(fusion_options) if fusion_options and random.random() > 0.7 else ""
|
| 996 |
+
|
| 997 |
+
# Now use LLM to create a coherent theme from these elements
|
| 998 |
+
system = WebNovelSystem()
|
| 999 |
+
|
| 1000 |
+
# Create prompt for LLM
|
| 1001 |
+
if language == "Korean":
|
| 1002 |
+
prompt = f"""다음 요소들을 활용하여 {genre} 장르의 매력적인 웹소설을 기획하세요:
|
| 1003 |
+
|
| 1004 |
+
【선택된 요소들】
|
| 1005 |
+
- 핵심 장르: {selected_genre_key}
|
| 1006 |
+
- 캐릭터: {character_desc}
|
| 1007 |
+
- 캐릭터 특성: {', '.join(character_traits[:3])}
|
| 1008 |
+
- 배경: {specific_setting}
|
| 1009 |
+
- 핵심 메커니즘: {selected_mechanic}
|
| 1010 |
+
{"- 아이템: " + selected_item if selected_item else ""}
|
| 1011 |
+
{"- 반전 요소: " + selected_twist if selected_twist else ""}
|
| 1012 |
+
{"- 퓨전 설정: " + selected_fusion if selected_fusion else ""}
|
| 1013 |
+
|
| 1014 |
+
【참고 훅】
|
| 1015 |
+
{selected_hook}
|
| 1016 |
+
|
| 1017 |
+
【독자를 사로잡을 질문들】
|
| 1018 |
+
{chr(10).join(reader_questions[:2]) if reader_questions else ""}
|
| 1019 |
+
|
| 1020 |
+
다음 형식으로 정확히 작성하세요:
|
| 1021 |
+
|
| 1022 |
+
📖 **제목:**
|
| 1023 |
+
[매력적이고 기억에 남는 제목]
|
| 1024 |
+
|
| 1025 |
+
🌍 **설정:**
|
| 1026 |
+
[세계관과 배경 설정을 3-4줄로 설명]
|
| 1027 |
+
|
| 1028 |
+
👥 **주요 캐릭터:**
|
| 1029 |
+
• 주인공: [이름] - [간단한 설명]
|
| 1030 |
+
• 주요인물1: [이름] - [간단한 설명]
|
| 1031 |
+
• 주요인물2: [이름] - [간단한 설명]
|
| 1032 |
+
|
| 1033 |
+
📝 **작품소개:**
|
| 1034 |
+
[독자의 흥미를 끄는 3-4줄의 작품 소개. 주인공의 상황, 목표, 핵심 갈등을 포함]"""
|
| 1035 |
+
|
| 1036 |
+
else: # English
|
| 1037 |
+
prompt = f"""Create an engaging web novel for {genre} genre using these elements:
|
| 1038 |
+
|
| 1039 |
+
【Selected Elements】
|
| 1040 |
+
- Core genre: {selected_genre_key}
|
| 1041 |
+
- Character: {character_desc}
|
| 1042 |
+
- Character traits: {', '.join(character_traits[:3])}
|
| 1043 |
+
- Setting: {specific_setting}
|
| 1044 |
+
- Core mechanism: {selected_mechanic}
|
| 1045 |
+
{"- Item: " + selected_item if selected_item else ""}
|
| 1046 |
+
{"- Twist: " + selected_twist if selected_twist else ""}
|
| 1047 |
+
{"- Fusion: " + selected_fusion if selected_fusion else ""}
|
| 1048 |
+
|
| 1049 |
+
【Reference Hook】
|
| 1050 |
+
{selected_hook}
|
| 1051 |
+
|
| 1052 |
+
Format exactly as follows:
|
| 1053 |
+
|
| 1054 |
+
📖 **Title:**
|
| 1055 |
+
[Attractive and memorable title]
|
| 1056 |
+
|
| 1057 |
+
🌍 **Setting:**
|
| 1058 |
+
[World and background setting in 3-4 lines]
|
| 1059 |
+
|
| 1060 |
+
👥 **Main Characters:**
|
| 1061 |
+
• Protagonist: [Name] - [Brief description]
|
| 1062 |
+
• Key Character 1: [Name] - [Brief description]
|
| 1063 |
+
• Key Character 2: [Name] - [Brief description]
|
| 1064 |
+
|
| 1065 |
+
📝 **Synopsis:**
|
| 1066 |
+
[3-4 lines that hook readers. Include protagonist's situation, goal, and core conflict]"""
|
| 1067 |
+
|
| 1068 |
+
# Call LLM to generate theme
|
| 1069 |
+
messages = [{"role": "user", "content": prompt}]
|
| 1070 |
+
generated_theme = system.call_llm_sync(messages, "writer", language)
|
| 1071 |
+
|
| 1072 |
+
return generated_theme
|
| 1073 |
+
|
| 1074 |
+
except Exception as e:
|
| 1075 |
+
logger.error(f"Error generating theme from JSON: {e}")
|
| 1076 |
+
return generate_fallback_theme(genre, language)
|
| 1077 |
+
|
| 1078 |
+
def generate_fallback_theme(genre: str, language: str) -> str:
|
| 1079 |
+
"""Fallback theme generator when JSON is not available"""
|
| 1080 |
+
templates = {
|
| 1081 |
+
"로맨스": {
|
| 1082 |
+
"themes": [
|
| 1083 |
+
"""📖 **제목:** 계약결혼 365일, 기억을 잃은 재벌 남편
|
| 1084 |
+
|
| 1085 |
+
🌍 **설정:**
|
| 1086 |
+
현대 서울, 대기업 본사와 강남의 펜트하우스가 주 무대. 3개월 계약결혼 만료 직전, 남편이 교통사고로 기억을 잃고 아내를 첫사랑으로 착각하는 상황.
|
| 1087 |
+
|
| 1088 |
+
👥 **주요 캐릭터:**
|
| 1089 |
+
• 주인공: 서연우(28) - 평범한 회사원, 부모님 병원비를 위해 계약결혼
|
| 1090 |
+
• 남주: 강준혁(32) - 냉혈 재벌 3세, 기억상실 후 순정남으로 변신
|
| 1091 |
+
• 조연: 한소영(30) - 준혁의 전 약혼녀, 복수를 계획 중
|
| 1092 |
+
|
| 1093 |
+
📝 **작품소개:**
|
| 1094 |
+
"당신이 내 첫사랑이야." 이혼 서류에 도장을 찍으려던 순간, 교통사고를 당한 냉혈 재벌 남편이 나를 운명의 상대로 착각한다. 3개월간 연기했던 가짜 부부에서 진짜 사랑이 시작되는데...""",
|
| 1095 |
+
|
| 1096 |
+
"""📖 **제목:** 검사님, 이혼 소송은 제가 맡을게요
|
| 1097 |
+
|
| 1098 |
+
🌍 **설정:**
|
| 1099 |
+
서울중앙지법과 검찰청이 주 무대. 냉혈 검사와 이혼 전문 변호사가 법정에서 대립하며 티격태격하는 법정 로맨스.
|
| 1100 |
+
|
| 1101 |
+
👥 **주요 캐릭터:**
|
| 1102 |
+
• 주인공: 오지원(30) - 승률 100% 이혼 전문 변호사
|
| 1103 |
+
• 남주: 민시준(33) - 원칙주의 엘리트 검사
|
| 1104 |
+
• 조연: 박세진(35) - 지원의 전 남편이자 시준의 선배 검사
|
| 1105 |
+
|
| 1106 |
+
📝 **작품소개:**
|
| 1107 |
+
"변호사님, 법정에서만 만나기로 했잖아요." 하필 전 남편의 불륜 소송을 맡은 날, 상대 검사가 나타났다. 법정에선 적, 밖에선 연인. 우리의 관계는 대체 뭘까?"""
|
| 1108 |
+
]
|
| 1109 |
+
},
|
| 1110 |
+
"로판": {
|
| 1111 |
+
"themes": [
|
| 1112 |
+
"""📖 **제목:** 악녀는 이번 생에서 도망친다
|
| 1113 |
+
|
| 1114 |
+
🌍 **설정:**
|
| 1115 |
+
마법이 존재하는 제국, 1년 후 처형당할 운명의 악녀 공작 영애로 빙의. 북부 변방의 전쟁광 공작과의 계약결혼이 유일한 생존루트.
|
| 1116 |
+
|
| 1117 |
+
👥 **주요 캐릭터:**
|
| 1118 |
+
• 주인공: 아델라이드(20) - 빙의한 악녀, 원작 지식 보유
|
| 1119 |
+
• 남주: 카시우스(25) - 북부의 전쟁광 공작, 숨겨진 순정남
|
| 1120 |
+
• 악역: 황태자 레온(23) - 여주에게 집착하는 얀데레
|
| 1121 |
+
|
| 1122 |
+
📝 **작품소개:**
|
| 1123 |
+
소설 속 악녀로 빙의했는데 이미 처형 선고를 받은 상태? 살려면 원작에 없던 북부 공작과 계약결혼해야 한다. "1년만 함께해주세요. 그 후엔 자유를 드리겠습니다." 하지만 계약 기간이 끝나도 그가 날 놓아주지 않는다.""",
|
| 1124 |
+
|
| 1125 |
+
"""📖 **제목:** 회귀한 황녀는 버려진 왕자를 택한다
|
| 1126 |
+
|
| 1127 |
+
🌍 **설정:**
|
| 1128 |
+
제국력 892년으로 회귀한 황녀. 전생에서 자신을 배신한 황태자 대신, 버려진 서자 왕자와 손을 잡고 제국을 뒤집으려 한다.
|
| 1129 |
+
|
| 1130 |
+
👥 **주요 캐릭터:**
|
| 1131 |
+
• 주인공: 로젤린(22) - 회귀한 황녀, 미래를 아는 전략가
|
| 1132 |
+
• 남주: 다미안(24) - 버려진 서자 왕자, 숨겨진 흑막
|
| 1133 |
+
• 악역: 황태자 세바스찬(26) - 전생의 배신자
|
| 1134 |
+
|
| 1135 |
+
📝 **작품소개:**
|
| 1136 |
+
독살당해 회귀한 황녀, 이번엔 다르게 살겠다. 모두가 무시하는 서자 왕자의 손을 잡았다. "저와 함께 제국을 뒤집으시겠습니까?" 하지만 그는 내가 아는 것보다 훨씬 위험한 남자였다."""
|
| 1137 |
+
]
|
| 1138 |
+
},
|
| 1139 |
+
"판타지": {
|
| 1140 |
+
"themes": [
|
| 1141 |
+
"""📖 **제목:** F급 헌터, SSS급 네크로맨서가 되다
|
| 1142 |
+
|
| 1143 |
+
🌍 **설정:**
|
| 1144 |
+
게이트와 던전이 출현한 지 10년 후의 한국. F급 헌터가 우연히 얻은 스킬로 죽은 보스 몬스터를 부활시켜 부리는 유일무이 네크로맨서가 된다.
|
| 1145 |
+
|
| 1146 |
+
👥 **주요 캐릭터:**
|
| 1147 |
+
• 주인공: 김도현(24) - F급에서 SSS급 네크로맨서로 각성
|
| 1148 |
+
• 조력자: 리치 왕(???) - 첫 번째 언데드, 전설의 대마법사
|
| 1149 |
+
• 라이벌: 최강훈(26) - S급 길드 마스터, 주인공을 경계
|
| 1150 |
+
|
| 1151 |
+
📝 **작품소개:**
|
| 1152 |
+
"F급 주제에 무슨 헛소리야?" 모두가 비웃었다. 하지만 첫 번째 보스를 쓰러뜨린 순간, 시스템 메시지가 떴다. [SSS급 히든 클래스: 네크로맨서 각성] 이제 죽은 보스들이 내 부하가 된다.""",
|
| 1153 |
+
|
| 1154 |
+
"""📖 **제목:** 탑을 역주행하는 회귀자
|
| 1155 |
+
|
| 1156 |
+
🌍 **설정:**
|
| 1157 |
+
100층 탑 정상에서 죽은 후 튜토리얼로 회귀. 하지만 이번엔 100층부터 거꾸로 내려가며 모든 층을 정복하는 역주행 시스템이 열렸다.
|
| 1158 |
+
|
| 1159 |
+
👥 **주요 캐릭터:**
|
| 1160 |
+
• 주인공: 이성진(28) - 유일한 역주행 회귀자
|
| 1161 |
+
• 조력자: 관리자(???) - 탑의 시스템 AI, 주인공에게 호의적
|
| 1162 |
+
• 라이벌: 성하윤(25) - 이번 회차 최강 신인
|
| 1163 |
+
|
| 1164 |
+
📝 **작품소개:**
|
| 1165 |
+
100층에서 죽었다. 눈을 떠보니 튜토리얼이었다. [역주행 시스템이 개방되었습니다] "뭐? 100층부터 시작한다고?" 최강자의 기억을 가진 채 정상에서부터 내려가는 전무후무한 공략이 시작된다."""
|
| 1166 |
+
]
|
| 1167 |
+
},
|
| 1168 |
+
"현판": {
|
| 1169 |
+
"themes": [
|
| 1170 |
+
"""📖 **제목:** 무능력자의 SSS급 아이템 제작
|
| 1171 |
+
|
| 1172 |
+
🌍 **설정:**
|
| 1173 |
+
게이트 출현 10년, 전 국민의 70%가 각성한 한국. 무능력자로 살던 주인공에게 갑자기 아이템 제작 시스템이 열린다.
|
| 1174 |
+
|
| 1175 |
+
👥 **주요 캐릭터:**
|
| 1176 |
+
• 주인공: 박준서(25) - 무능력자에서 유일무이 아이템 제작사로
|
| 1177 |
+
• 의뢰인: 강하늘(27) - S급 헌터, 첫 번째 고객
|
| 1178 |
+
• 라이벌: 대기업 '아르테미스' - 아이템 독점 기업
|
| 1179 |
+
|
| 1180 |
+
📝 **작품소개:**
|
| 1181 |
+
"각성 등급: 없음" 10년째 무능력자로 살았다. 그런데 오늘, 이상한 시스템 창이 떴다. [SSS급 생산직: 아이템 크래프터] 이제 내가 만든 아이템이 세계를 바꾼다.""",
|
| 1182 |
+
|
| 1183 |
+
"""📖 **제목:** 헌터 사관학교의 숨겨진 최강자
|
| 1184 |
+
|
| 1185 |
+
🌍 **설정:**
|
| 1186 |
+
한국 최고의 헌터 사관학교. 입학시험 꼴찌로 들어온 주인공이 사실은 능력을 숨기고 있는 특급 요원.
|
| 1187 |
+
|
| 1188 |
+
👥 **주요 캐릭터:**
|
| 1189 |
+
• 주인공: 윤시우(20) - 꼴찌로 위장한 특급 헌터
|
| 1190 |
+
• 히로인: 차유진(20) - 학년 수석, 재벌가 영애
|
| 1191 |
+
• 교관: 한태성(35) - 전설의 헌터, 주인공의 정체를 의심
|
| 1192 |
+
|
| 1193 |
+
📝 **작품소개:**
|
| 1194 |
+
"측정 불가? 그럼 F급이네." 일부러 힘을 숨기고 꼴찌로 입학했다. 하지만 S급 게이트가 학교에 열리면서 정체를 숨길 수 없게 됐다. "너... 대체 누구야?"라는 물음에 어떻게 답해야 할까."""
|
| 1195 |
+
]
|
| 1196 |
+
},
|
| 1197 |
+
"무협": {
|
| 1198 |
+
"themes": [
|
| 1199 |
+
"""📖 **제목:** 천하제일문 폐급제자의 마교 비급
|
| 1200 |
+
|
| 1201 |
+
🌍 **설정:**
|
| 1202 |
+
정파 무림의 중원. 천하제일문의 폐급 막내제자가 우연히 마교 교주의 비급을 습득하고 정마를 아우르는 절대무공을 익힌다.
|
| 1203 |
+
|
| 1204 |
+
👥 **주요 캐릭터:**
|
| 1205 |
+
• 주인공: 진천(18) - 폐급에서 절대고수로
|
| 1206 |
+
• 스승: 혈마노조(???) - 비급에 깃든 마교 전설
|
| 1207 |
+
• 라이벌: 남궁세가 소가주 - 정파 제일 천재
|
| 1208 |
+
|
| 1209 |
+
📝 **작품소개:**
|
| 1210 |
+
"하찮은 것이 감히!" 모두가 무시하던 막내제자. 하지만 떨어진 절벽에서 발견한 것은 전설로만 전해지던 천마신공. "이제부터가 진짜 시작이다." 정파와 마교를 뒤흔들 폐급의 반란이 시작된다.""",
|
| 1211 |
+
|
| 1212 |
+
"""📖 **제목:** 화산파 장문인으로 회귀하다
|
| 1213 |
+
|
| 1214 |
+
🌍 **설정:**
|
| 1215 |
+
100년 전 화산파가 최고 문파이던 시절로 회귀. 미래를 아는 장문인이 되어 문파를 지키고 무림을 재편한다.
|
| 1216 |
+
|
| 1217 |
+
👥 **주요 캐릭터:**
|
| 1218 |
+
• 주인공: 청운진인(45→25) - 회귀한 화산파 장문인
|
| 1219 |
+
• 제자: 백무진(15) - 미래의 화산파 배신자
|
| 1220 |
+
• 맹우: 마교 성녀 - 전생의 적, 이생의 동료
|
| 1221 |
+
|
| 1222 |
+
📝 **작품소개:**
|
| 1223 |
+
멸문 직전에 회귀했다. 이번엔 다르다. "앞으로 화산파는 정파의 규율을 벗어난다." 미래를 아는 장문인의 파격적인 결정. 마교와 손잡고 무림의 판도를 뒤집는다."""
|
| 1224 |
+
]
|
| 1225 |
+
},
|
| 1226 |
+
"미스터리": {
|
| 1227 |
+
"themes": [
|
| 1228 |
+
"""📖 **제목:** 폐교에 갇힌 7명, 그리고 나
|
| 1229 |
+
|
| 1230 |
+
🌍 **설정:**
|
| 1231 |
+
폐쇄된 산골 학교, 동창회를 위해 모인 8명이 갇힌다. 하나씩 사라지는 동창들. 범인은 이 안에 있다.
|
| 1232 |
+
|
| 1233 |
+
👥 **주요 캐릭터:**
|
| 1234 |
+
• 주인공: 서민준(28) - 프로파일러 출신 교사
|
| 1235 |
+
• 용의자1: 김태희(28) - 실종된 친구의 전 연인
|
| 1236 |
+
• 용의자2: 박진우(28) - 10년 전 사건의 목격자
|
| 1237 |
+
|
| 1238 |
+
📝 **작품소개:**
|
| 1239 |
+
"10년 전 그날처럼..." 폐교에서 열린 동창회, 하지만 출구는 봉쇄됐다. 한 명씩 사라지는 친구들. 10년 전 묻어둔 비밀이 되살아난다. 살인자는 우리 중 한 명이다.""",
|
| 1240 |
+
|
| 1241 |
+
"""📖 **제목:** 타임루프 속 연쇄살인마를 찾아라
|
| 1242 |
+
|
| 1243 |
+
🌍 **설정:**
|
| 1244 |
+
같은 하루가 반복되는 타임루프. 매번 다른 방법으로 살인이 일어나지만 범인은 동일인. 루프를 깨려면 범인을 찾아야 한다.
|
| 1245 |
+
|
| 1246 |
+
👥 **주요 캐릭터:**
|
| 1247 |
+
• 주인공: 강해인(30) - 타임루프에 갇힌 형사
|
| 1248 |
+
• 희생자: 이수연(25) - 매번 죽는 카페 알바생
|
| 1249 |
+
• 용의자들: 카페 단골 5명 - 각자의 비밀을 숨기고 있음
|
| 1250 |
+
|
| 1251 |
+
📝 **작품소개:**
|
| 1252 |
+
"또 오늘이야..." 49번째 같은 아침. 오후 3시 33분, 카페에서 살인이 일어난다. 범인을 잡아야 내일이 온다. 하지만 범인은 매번 완벽한 알리바이를 만든다. 과연 50번째 오늘은 다를까?"""
|
| 1253 |
+
]
|
| 1254 |
+
},
|
| 1255 |
+
"라이트노벨": {
|
| 1256 |
+
"themes": [
|
| 1257 |
+
"""📖 **제목:** 내 여자친구가 사실은 마왕이었다
|
| 1258 |
+
|
| 1259 |
+
🌍 **설정:**
|
| 1260 |
+
평범한 고등학교, 하지만 학생과 교사 중 일부는 이세계에서 온 존재들. 주인공만 모르는 학교의 비밀.
|
| 1261 |
+
|
| 1262 |
+
👥 **주요 캐릭터:**
|
| 1263 |
+
• 주인공: 김태양(17) - 평범한 고등학생(?)
|
| 1264 |
+
• 히로인: 루시퍼(17) - 마왕이자 여자친구
|
| 1265 |
+
• 라이벌: 미카엘(17) - 천사이자 학생회장
|
| 1266 |
+
|
| 1267 |
+
📝 **작품소개:**
|
| 1268 |
+
"선배, 사실 저... 마왕이에요!" 1년째 사귄 여자친구의 충격 고백. 근데 학생회장은 천사고, 담임은 드래곤이라고? 평범한 줄 알았던 우리 학교의 정체가 밝혀진다. "그래서... 우리 헤어져야 해?"라고 묻자 그녀가 울기 시작했다.""",
|
| 1269 |
+
|
| 1270 |
+
"""📖 **제목:** 게임 아이템이 현실에 떨어진다
|
| 1271 |
+
|
| 1272 |
+
🌍 **설정:**
|
| 1273 |
+
모바일 게임과 현실이 연동되기 시작한 세계. 게임에서 얻은 아이템이 현실에 나타나면서 벌어지는 학원 코미디.
|
| 1274 |
+
|
| 1275 |
+
👥 **주요 캐릭터:**
|
| 1276 |
+
• 주인공: 박도윤(18) - 게임 폐인 고등학생
|
| 1277 |
+
• 히로인: 최서연(18) - 전교 1등, 의외로 게임 고수
|
| 1278 |
+
• 친구: 장민혁(18) - 현질 전사, 개그 담당
|
| 1279 |
+
|
| 1280 |
+
📝 **작품소개:**
|
| 1281 |
+
"어? 이거 내 SSR 무기잖아?" 핸드폰 게임에서 뽑은 아이템이 책상 위에 나타났다. 문제는 학교에 몬스터도 나타나기 시작했다는 것. "야, 수능보다 레이드가 더 중요해진 것 같은데?"라며 웃는 친구들과 함께하는 좌충우돌 학원 판타지."""
|
| 1282 |
+
]
|
| 1283 |
+
}
|
| 1284 |
+
}
|
| 1285 |
+
|
| 1286 |
+
genre_themes = templates.get(genre, templates["로맨스"])
|
| 1287 |
+
selected = random.choice(genre_themes["themes"])
|
| 1288 |
+
|
| 1289 |
+
return selected
|
| 1290 |
+
|
| 1291 |
+
def generate_theme_with_llm_only(genre: str, language: str) -> str:
|
| 1292 |
+
"""Generate theme using only LLM when JSON is not available or has errors"""
|
| 1293 |
+
system = WebNovelSystem()
|
| 1294 |
+
|
| 1295 |
+
# Genre-specific prompts based on popular web novel trends
|
| 1296 |
+
genre_prompts = {
|
| 1297 |
+
"로맨스": {
|
| 1298 |
+
"elements": ["계약결혼", "재벌", "이혼", "첫사랑", "운명적 만남", "오해와 화해"],
|
| 1299 |
+
"hooks": ["기억상실", "정체 숨기기", "가짜 연인", "원나잇 후 재회"]
|
| 1300 |
+
},
|
| 1301 |
+
"로판": {
|
| 1302 |
+
"elements": ["빙의", "회귀", "악녀", "황녀", "공작", "원작 파괴"],
|
| 1303 |
+
"hooks": ["처형 직전", "파혼 선언", "독살 시도", "폐위 위기"]
|
| 1304 |
+
},
|
| 1305 |
+
"판타지": {
|
| 1306 |
+
"elements": ["시스템", "각성", "던전", "회귀", "탑 등반", "SSS급"],
|
| 1307 |
+
"hooks": ["F급에서 시작", "숨겨진 클래스", "유일무이 스킬", "죽음 후 각성"]
|
| 1308 |
+
},
|
| 1309 |
+
"현판": {
|
| 1310 |
+
"elements": ["헌터", "게이트", "각성자", "길드", "아이템", "랭킹"],
|
| 1311 |
+
"hooks": ["늦은 각성", "재능 재평가", "S급 게이트", "시스템 오류"]
|
| 1312 |
+
},
|
| 1313 |
+
"무협": {
|
| 1314 |
+
"elements": ["회귀", "천재", "마교", "비급", "복수", "환생"],
|
| 1315 |
+
"hooks": ["폐급에서 최강", "배신 후 각성", "숨겨진 혈통", "기연 획득"]
|
| 1316 |
+
},
|
| 1317 |
+
"미스터리": {
|
| 1318 |
+
"elements": ["탐정", "연쇄살인", "타임루프", "초능력", "과거의 비밀"],
|
| 1319 |
+
"hooks": ["밀실 살인", "예고 살인", "기억 조작", "시간 역행"]
|
| 1320 |
+
},
|
| 1321 |
+
"라이트노벨": {
|
| 1322 |
+
"elements": ["학원", "이세계", "히로인", "게임", "일상", "판타지"],
|
| 1323 |
+
"hooks": ["전학생 정체", "게임 현실화", "평행세계", "숨겨진 능력"]
|
| 1324 |
+
}
|
| 1325 |
+
}
|
| 1326 |
+
|
| 1327 |
+
genre_info = genre_prompts.get(genre, genre_prompts["로맨스"])
|
| 1328 |
+
|
| 1329 |
+
if language == "Korean":
|
| 1330 |
+
prompt = f"""한국 웹소설 {genre} 장르의 중독성 있는 작품을 기획하세요.
|
| 1331 |
+
|
| 1332 |
+
다음 인기 요소들을 참고하세요:
|
| 1333 |
+
- 핵심 요소: {', '.join(genre_info['elements'])}
|
| 1334 |
+
- 인기 훅: {', '.join(genre_info['hooks'])}
|
| 1335 |
+
|
| 1336 |
+
다음 형식으로 정확히 작성하세요:
|
| 1337 |
+
|
| 1338 |
+
📖 **제목:**
|
| 1339 |
+
[매력적이고 기억하기 쉬운 제목]
|
| 1340 |
+
|
| 1341 |
+
🌍 **설정:**
|
| 1342 |
+
[세계관과 배경을 3-4줄로 설명. 시대, 장소, 핵심 설정 포함]
|
| 1343 |
+
|
| 1344 |
+
👥 **주요 캐릭터:**
|
| 1345 |
+
• 주인공: [이름(나이)] - [직업/신분, 핵심 특징]
|
| 1346 |
+
• 주요인물1: [이름(나이)] - [관계/역할, 특징]
|
| 1347 |
+
• 주요인물2: [이름(나이)] - [관계/역할, 특징]
|
| 1348 |
+
|
| 1349 |
+
📝 **작품소개:**
|
| 1350 |
+
[3-4줄로 작품의 핵심 갈등과 매력을 소개. 첫 문장은 강한 훅으로 시작하고, 주인공의 목표와 장애물을 명확히 제시]"""
|
| 1351 |
+
else:
|
| 1352 |
+
prompt = f"""Generate an addictive Korean web novel for {genre} genre.
|
| 1353 |
+
|
| 1354 |
+
Reference these popular elements:
|
| 1355 |
+
- Core elements: {', '.join(genre_info['elements'])}
|
| 1356 |
+
- Popular hooks: {', '.join(genre_info['hooks'])}
|
| 1357 |
+
|
| 1358 |
+
Format exactly as follows:
|
| 1359 |
+
|
| 1360 |
+
📖 **Title:**
|
| 1361 |
+
[Attractive and memorable title]
|
| 1362 |
+
|
| 1363 |
+
🌍 **Setting:**
|
| 1364 |
+
[World and background in 3-4 lines. Include era, location, core settings]
|
| 1365 |
+
|
| 1366 |
+
👥 **Main Characters:**
|
| 1367 |
+
• Protagonist: [Name(Age)] - [Job/Status, key traits]
|
| 1368 |
+
• Key Character 1: [Name(Age)] - [Relationship/Role, traits]
|
| 1369 |
+
• Key Character 2: [Name(Age)] - [Relationship/Role, traits]
|
| 1370 |
+
|
| 1371 |
+
📝 **Synopsis:**
|
| 1372 |
+
[3-4 lines introducing core conflict and appeal. Start with strong hook, clearly present protagonist's goal and obstacles]"""
|
| 1373 |
+
|
| 1374 |
+
messages = [{"role": "user", "content": prompt}]
|
| 1375 |
+
generated_theme = system.call_llm_sync(messages, "writer", language)
|
| 1376 |
+
|
| 1377 |
+
return generated_theme
|
| 1378 |
+
|
| 1379 |
+
# --- UI functions ---
|
| 1380 |
+
def format_episodes_display(episodes: List[Dict], current_episode: int = 0) -> str:
|
| 1381 |
+
"""Format episodes for display"""
|
| 1382 |
+
markdown = "## 📚 웹소설 연재 현황\n\n"
|
| 1383 |
+
|
| 1384 |
+
if not episodes:
|
| 1385 |
+
return markdown + "*아직 작성된 에피소드가 없습니다.*"
|
| 1386 |
+
|
| 1387 |
+
# Stats
|
| 1388 |
+
total_episodes = len(episodes)
|
| 1389 |
+
total_words = sum(ep.get('word_count', 0) for ep in episodes)
|
| 1390 |
+
avg_engagement = sum(ep.get('reader_engagement', 0) for ep in episodes) / len(episodes) if episodes else 0
|
| 1391 |
+
|
| 1392 |
+
markdown += f"**진행 상황:** {total_episodes} / {TARGET_EPISODES}화\n"
|
| 1393 |
+
markdown += f"**총 단어 수:** {total_words:,} / {TARGET_WORDS:,}\n"
|
| 1394 |
+
markdown += f"**평균 몰입도:** ⭐ {avg_engagement:.1f} / 10\n\n"
|
| 1395 |
+
markdown += "---\n\n"
|
| 1396 |
+
|
| 1397 |
+
# Episode list
|
| 1398 |
+
for ep in episodes[-5:]: # Show last 5 episodes
|
| 1399 |
+
ep_num = ep.get('episode_number', 0)
|
| 1400 |
+
word_count = ep.get('word_count', 0)
|
| 1401 |
+
|
| 1402 |
+
markdown += f"### 📖 {ep_num}화\n"
|
| 1403 |
+
markdown += f"*{word_count}단어*\n\n"
|
| 1404 |
+
|
| 1405 |
+
content = ep.get('content', '')
|
| 1406 |
+
if content:
|
| 1407 |
+
preview = content[:200] + "..." if len(content) > 200 else content
|
| 1408 |
+
markdown += f"{preview}\n\n"
|
| 1409 |
+
|
| 1410 |
+
hook = ep.get('hook', '')
|
| 1411 |
+
if hook:
|
| 1412 |
+
markdown += f"**🪝 후크:** *{hook}*\n\n"
|
| 1413 |
+
|
| 1414 |
+
markdown += "---\n\n"
|
| 1415 |
+
|
| 1416 |
+
return markdown
|
| 1417 |
+
|
| 1418 |
+
def format_webnovel_display(episodes: List[Dict], genre: str) -> str:
|
| 1419 |
+
"""Format complete web novel for display"""
|
| 1420 |
+
if not episodes:
|
| 1421 |
+
return "아직 완성된 웹소설이 없습니다."
|
| 1422 |
+
|
| 1423 |
+
formatted = f"# 🎭 {genre} 웹소설\n\n"
|
| 1424 |
+
|
| 1425 |
+
# Novel stats
|
| 1426 |
+
total_words = sum(ep.get('word_count', 0) for ep in episodes)
|
| 1427 |
+
formatted += f"**총 {len(episodes)}화 완결 | {total_words:,}단어**\n\n"
|
| 1428 |
+
formatted += "---\n\n"
|
| 1429 |
+
|
| 1430 |
+
# Episodes
|
| 1431 |
+
for ep in episodes:
|
| 1432 |
+
ep_num = ep.get('episode_number', 0)
|
| 1433 |
+
content = ep.get('content', '')
|
| 1434 |
+
|
| 1435 |
+
# Check if content already has episode title
|
| 1436 |
+
lines = content.strip().split('\n')
|
| 1437 |
+
if lines and (f"{ep_num}화." in lines[0] or f"Episode {ep_num}." in lines[0]):
|
| 1438 |
+
# Use the existing title
|
| 1439 |
+
formatted += f"## {lines[0]}\n\n"
|
| 1440 |
+
# Use the rest as content
|
| 1441 |
+
actual_content = '\n'.join(lines[2:] if len(lines) > 2 and lines[1].strip() == "" else lines[1:])
|
| 1442 |
+
formatted += f"{actual_content}\n\n"
|
| 1443 |
+
else:
|
| 1444 |
+
# No title found, use default
|
| 1445 |
+
formatted += f"## 제{ep_num}화\n\n"
|
| 1446 |
+
formatted += f"{content}\n\n"
|
| 1447 |
+
|
| 1448 |
+
if ep_num < len(episodes): # Not last episode
|
| 1449 |
+
formatted += "➡️ *다음 화에 계속...*\n\n"
|
| 1450 |
+
|
| 1451 |
+
formatted += "---\n\n"
|
| 1452 |
+
|
| 1453 |
+
return formatted
|
| 1454 |
+
|
| 1455 |
+
# --- Gradio interface ---
|
| 1456 |
+
def create_interface():
|
| 1457 |
+
with gr.Blocks(theme=gr.themes.Soft(), title="K-WebNovel Generator") as interface:
|
| 1458 |
+
gr.HTML("""
|
| 1459 |
+
<div style="text-align: center; margin-bottom: 2rem;">
|
| 1460 |
+
<h1 style="font-size: 3rem; margin-bottom: 1rem;">📚 K-WebNovel Generator</h1>
|
| 1461 |
+
<p style="font-size: 1.2rem;">한국형 웹소설 자동 생성 시스템</p>
|
| 1462 |
+
<p>장르별 맞춤형 40화 완결 웹소설을 생성합니다</p>
|
| 1463 |
+
</div>
|
| 1464 |
+
""")
|
| 1465 |
+
|
| 1466 |
+
# State
|
| 1467 |
+
current_session_id = gr.State(None)
|
| 1468 |
+
|
| 1469 |
+
with gr.Tab("✍️ 웹소설 쓰기"):
|
| 1470 |
+
with gr.Group():
|
| 1471 |
+
gr.Markdown("### 🎯 웹소설 설정")
|
| 1472 |
+
|
| 1473 |
+
with gr.Row():
|
| 1474 |
+
with gr.Column(scale=2):
|
| 1475 |
+
genre_select = gr.Radio(
|
| 1476 |
+
choices=list(WEBNOVEL_GENRES.keys()),
|
| 1477 |
+
value="로맨스",
|
| 1478 |
+
label="장르 선택",
|
| 1479 |
+
info="원하는 장르를 선택하세요"
|
| 1480 |
+
)
|
| 1481 |
+
|
| 1482 |
+
query_input = gr.Textbox(
|
| 1483 |
+
label="스토리 테마",
|
| 1484 |
+
placeholder="웹소설의 기본 설정이나 주제를 입력하세요...",
|
| 1485 |
+
lines=3
|
| 1486 |
+
)
|
| 1487 |
+
|
| 1488 |
+
with gr.Row():
|
| 1489 |
+
random_btn = gr.Button("🎲 랜덤 테마", variant="secondary")
|
| 1490 |
+
submit_btn = gr.Button("📝 연재 시작", variant="primary", size="lg")
|
| 1491 |
+
|
| 1492 |
+
with gr.Column(scale=1):
|
| 1493 |
+
language_select = gr.Radio(
|
| 1494 |
+
choices=["Korean", "English"],
|
| 1495 |
+
value="Korean",
|
| 1496 |
+
label="언어"
|
| 1497 |
+
)
|
| 1498 |
+
|
| 1499 |
+
gr.Markdown("""
|
| 1500 |
+
**장르별 특징:**
|
| 1501 |
+
- 로맨스: 달달한 사랑 이야기
|
| 1502 |
+
- 로판: 회귀/빙의 판타지
|
| 1503 |
+
- 판타지: 성장과 모험
|
| 1504 |
+
- 현판: 현대 배경 능력자
|
| 1505 |
+
- 무협: 무공과 강호
|
| 1506 |
+
- 미스터리: 추리와 반전
|
| 1507 |
+
- 라노벨: 가벼운 일상물
|
| 1508 |
+
""")
|
| 1509 |
+
|
| 1510 |
+
status_text = gr.Textbox(
|
| 1511 |
+
label="진행 상황",
|
| 1512 |
+
interactive=False,
|
| 1513 |
+
value="장르를 선택하고 테마를 입력하세요"
|
| 1514 |
+
)
|
| 1515 |
+
|
| 1516 |
+
# Output
|
| 1517 |
+
with gr.Row():
|
| 1518 |
+
with gr.Column():
|
| 1519 |
+
episodes_display = gr.Markdown("*연재 진행 상황이 여기에 표시됩니다*")
|
| 1520 |
+
|
| 1521 |
+
with gr.Column():
|
| 1522 |
+
novel_display = gr.Markdown("*완성된 웹소설이 여기에 표시됩니다*")
|
| 1523 |
+
|
| 1524 |
+
with gr.Row():
|
| 1525 |
+
download_format = gr.Radio(
|
| 1526 |
+
choices=["TXT", "DOCX"],
|
| 1527 |
+
value="TXT",
|
| 1528 |
+
label="다운로드 형식"
|
| 1529 |
+
)
|
| 1530 |
+
download_btn = gr.Button("📥 다운로드", variant="secondary")
|
| 1531 |
+
|
| 1532 |
+
download_file = gr.File(visible=False)
|
| 1533 |
+
|
| 1534 |
+
with gr.Tab("📚 테마 라이브러리"):
|
| 1535 |
+
gr.Markdown("### 인기 웹소설 테마")
|
| 1536 |
+
|
| 1537 |
+
library_genre = gr.Radio(
|
| 1538 |
+
choices=["전체"] + list(WEBNOVEL_GENRES.keys()),
|
| 1539 |
+
value="전체",
|
| 1540 |
+
label="장르 필터"
|
| 1541 |
+
)
|
| 1542 |
+
|
| 1543 |
+
theme_library = gr.HTML("<p>테마 라이브러리 로딩 중...</p>")
|
| 1544 |
+
|
| 1545 |
+
refresh_library_btn = gr.Button("🔄 새로고침")
|
| 1546 |
+
|
| 1547 |
+
# Event handlers
|
| 1548 |
+
def process_query(query, genre, language, session_id):
|
| 1549 |
+
system = WebNovelSystem()
|
| 1550 |
+
episodes = ""
|
| 1551 |
+
novel = ""
|
| 1552 |
+
|
| 1553 |
+
for ep_display, novel_display, status, new_session_id in system.process_webnovel_stream(query, genre, language, session_id):
|
| 1554 |
+
episodes = ep_display
|
| 1555 |
+
novel = novel_display
|
| 1556 |
+
yield episodes, novel, status, new_session_id
|
| 1557 |
+
|
| 1558 |
+
def handle_random_theme(genre, language):
|
| 1559 |
+
return generate_random_webnovel_theme(genre, language)
|
| 1560 |
+
|
| 1561 |
+
def handle_download(download_format, session_id, genre):
|
| 1562 |
+
"""Handle download request"""
|
| 1563 |
+
if not session_id:
|
| 1564 |
+
return None
|
| 1565 |
+
|
| 1566 |
+
try:
|
| 1567 |
+
episodes = WebNovelDatabase.get_episodes(session_id)
|
| 1568 |
+
if not episodes:
|
| 1569 |
+
return None
|
| 1570 |
+
|
| 1571 |
+
# Get title from first episode or generate default
|
| 1572 |
+
title = f"{genre} 웹소설"
|
| 1573 |
+
|
| 1574 |
+
if download_format == "TXT":
|
| 1575 |
+
content = export_to_txt(episodes, genre, title)
|
| 1576 |
+
|
| 1577 |
+
# Save to temporary file
|
| 1578 |
+
with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8',
|
| 1579 |
+
suffix='.txt', delete=False) as f:
|
| 1580 |
+
f.write(content)
|
| 1581 |
+
return f.name
|
| 1582 |
+
|
| 1583 |
+
elif download_format == "DOCX":
|
| 1584 |
+
if not DOCX_AVAILABLE:
|
| 1585 |
+
gr.Warning("DOCX export requires python-docx library")
|
| 1586 |
+
return None
|
| 1587 |
+
|
| 1588 |
+
content = export_to_docx(episodes, genre, title)
|
| 1589 |
+
|
| 1590 |
+
# Save to temporary file
|
| 1591 |
+
with tempfile.NamedTemporaryFile(mode='wb', suffix='.docx',
|
| 1592 |
+
delete=False) as f:
|
| 1593 |
+
f.write(content)
|
| 1594 |
+
return f.name
|
| 1595 |
+
|
| 1596 |
+
except Exception as e:
|
| 1597 |
+
logger.error(f"Download error: {e}")
|
| 1598 |
+
gr.Warning(f"다운로드 중 오류 발생: {str(e)}")
|
| 1599 |
+
return None
|
| 1600 |
+
|
| 1601 |
+
# Connect events
|
| 1602 |
+
submit_btn.click(
|
| 1603 |
+
fn=process_query,
|
| 1604 |
+
inputs=[query_input, genre_select, language_select, current_session_id],
|
| 1605 |
+
outputs=[episodes_display, novel_display, status_text, current_session_id]
|
| 1606 |
+
)
|
| 1607 |
+
|
| 1608 |
+
random_btn.click(
|
| 1609 |
+
fn=handle_random_theme,
|
| 1610 |
+
inputs=[genre_select, language_select],
|
| 1611 |
+
outputs=[query_input]
|
| 1612 |
+
)
|
| 1613 |
+
|
| 1614 |
+
download_btn.click(
|
| 1615 |
+
fn=handle_download,
|
| 1616 |
+
inputs=[download_format, current_session_id, genre_select],
|
| 1617 |
+
outputs=[download_file]
|
| 1618 |
+
).then(
|
| 1619 |
+
fn=lambda x: gr.update(visible=True) if x else gr.update(visible=False),
|
| 1620 |
+
inputs=[download_file],
|
| 1621 |
+
outputs=[download_file]
|
| 1622 |
+
)
|
| 1623 |
+
|
| 1624 |
+
# Examples
|
| 1625 |
+
gr.Examples(
|
| 1626 |
+
examples=[
|
| 1627 |
+
["계약결혼한 재벌 3세와 평범한 회사원의 로맨스", "로맨스"],
|
| 1628 |
+
["회귀한 천재 마법사의 복수극", "로판"],
|
| 1629 |
+
["F급 헌터에서 SSS급 각성자가 되는 이야기", "현판"],
|
| 1630 |
+
["폐급에서 천하제일이 되는 무공 천재", "무협"],
|
| 1631 |
+
["평범한 고등학생이 이세계 용사가 되는 이야기", "라이트노벨"]
|
| 1632 |
+
],
|
| 1633 |
+
inputs=[query_input, genre_select]
|
| 1634 |
+
)
|
| 1635 |
+
|
| 1636 |
+
return interface
|
| 1637 |
+
|
| 1638 |
+
# Main
|
| 1639 |
+
if __name__ == "__main__":
|
| 1640 |
+
logger.info("K-WebNovel Generator Starting...")
|
| 1641 |
+
logger.info("=" * 60)
|
| 1642 |
+
|
| 1643 |
+
# Environment check
|
| 1644 |
+
logger.info(f"API Endpoint: {API_URL}")
|
| 1645 |
+
logger.info(f"Target: {TARGET_EPISODES} episodes, {TARGET_WORDS:,} words")
|
| 1646 |
+
logger.info("Genres: " + ", ".join(WEBNOVEL_GENRES.keys()))
|
| 1647 |
+
|
| 1648 |
+
logger.info("=" * 60)
|
| 1649 |
+
|
| 1650 |
+
# Initialize database
|
| 1651 |
+
logger.info("Initializing database...")
|
| 1652 |
+
WebNovelDatabase.init_db()
|
| 1653 |
+
logger.info("Database ready.")
|
| 1654 |
+
|
| 1655 |
+
# Launch interface
|
| 1656 |
+
interface = create_interface()
|
| 1657 |
+
interface.launch(
|
| 1658 |
+
server_name="0.0.0.0",
|
| 1659 |
+
server_port=7860,
|
| 1660 |
+
share=False
|
| 1661 |
+
)
|