Spaces:
Running on Zero
Running on Zero
Commit Β·
6172d30
1
Parent(s): e18b02f
feat: add poster generation from session recap data and improve model caching
Browse files- app.py +47 -1
- app/services/generator.py +2 -1
- app/services/story.py +40 -26
app.py
CHANGED
|
@@ -405,6 +405,38 @@ def generate_recap(session_id: str):
|
|
| 405 |
return "\n".join(lines), result
|
| 406 |
|
| 407 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 408 |
# ββ Build summary text ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 409 |
def build_summary(game: dict, state: dict, session_id: str = "") -> str:
|
| 410 |
"""Format a human-readable game summary for the UI."""
|
|
@@ -617,7 +649,15 @@ with gr.Blocks(title="CityQuest-AI β Game Generator") as demo:
|
|
| 617 |
recap_btn = gr.Button("π Generate Recap", variant="primary", size="lg")
|
| 618 |
recap_md = gr.Markdown(value="")
|
| 619 |
recap_json = gr.JSON(label="Recap data", visible=False)
|
| 620 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 621 |
# ββ Wire events ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 622 |
|
| 623 |
# Generate tab
|
|
@@ -675,6 +715,12 @@ with gr.Blocks(title="CityQuest-AI β Game Generator") as demo:
|
|
| 675 |
outputs=[recap_md, recap_json],
|
| 676 |
)
|
| 677 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 678 |
gr.Markdown(
|
| 679 |
"""
|
| 680 |
---
|
|
|
|
| 405 |
return "\n".join(lines), result
|
| 406 |
|
| 407 |
|
| 408 |
+
def generate_poster_from_session(session_id: str):
|
| 409 |
+
"""Generate a poster image from an existing session's recap data."""
|
| 410 |
+
if session_id not in SESSION_STORE:
|
| 411 |
+
return None, "[!] Unknown session - generate a recap first"
|
| 412 |
+
|
| 413 |
+
session = SESSION_STORE[session_id]
|
| 414 |
+
|
| 415 |
+
# Check if we have a recap result or can build one
|
| 416 |
+
if "poster_url" in session:
|
| 417 |
+
return session["poster_url"], "Poster ready from previous generation"
|
| 418 |
+
|
| 419 |
+
# Build story packet from stored data
|
| 420 |
+
game = session.get("game")
|
| 421 |
+
if not game:
|
| 422 |
+
return None, "[!] No game data in session"
|
| 423 |
+
|
| 424 |
+
events = load_events(session_id=session_id)
|
| 425 |
+
journals = load_journal_entries(session_id=session_id)
|
| 426 |
+
scores = session.get("scores", compute_scores(events, game))
|
| 427 |
+
photos = session.get("photos", [])
|
| 428 |
+
|
| 429 |
+
packet = build_story_packet(
|
| 430 |
+
game=game, events=events, scores=scores,
|
| 431 |
+
journal_entries=journals, photo_captions=photos,
|
| 432 |
+
)
|
| 433 |
+
result = generate_story(packet, session_id=session_id)
|
| 434 |
+
|
| 435 |
+
poster_url, status = generate_poster_sync(session_id, result["poster_prompt"])
|
| 436 |
+
session["poster_url"] = poster_url
|
| 437 |
+
return poster_url, status
|
| 438 |
+
|
| 439 |
+
|
| 440 |
# ββ Build summary text ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 441 |
def build_summary(game: dict, state: dict, session_id: str = "") -> str:
|
| 442 |
"""Format a human-readable game summary for the UI."""
|
|
|
|
| 649 |
recap_btn = gr.Button("π Generate Recap", variant="primary", size="lg")
|
| 650 |
recap_md = gr.Markdown(value="")
|
| 651 |
recap_json = gr.JSON(label="Recap data", visible=False)
|
| 652 |
+
# Poster section
|
| 653 |
+
gr.Markdown("---\n### Poster")
|
| 654 |
+
poster_session_id = gr.Textbox(
|
| 655 |
+
label="Session ID",
|
| 656 |
+
placeholder="Paste the same Session ID here\u2026",
|
| 657 |
+
)
|
| 658 |
+
poster_btn = gr.Button(" Generate Poster", variant="secondary")
|
| 659 |
+
poster_image = gr.Image(label="Poster", height=400, visible=True)
|
| 660 |
+
poster_status = gr.Markdown(value="")
|
| 661 |
# ββ Wire events ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 662 |
|
| 663 |
# Generate tab
|
|
|
|
| 715 |
outputs=[recap_md, recap_json],
|
| 716 |
)
|
| 717 |
|
| 718 |
+
poster_btn.click(
|
| 719 |
+
fn=generate_poster_from_session,
|
| 720 |
+
inputs=[poster_session_id],
|
| 721 |
+
outputs=[poster_image, poster_status],
|
| 722 |
+
)
|
| 723 |
+
|
| 724 |
gr.Markdown(
|
| 725 |
"""
|
| 726 |
---
|
app/services/generator.py
CHANGED
|
@@ -6,8 +6,9 @@ from typing import Optional
|
|
| 6 |
from pathlib import Path
|
| 7 |
|
| 8 |
|
| 9 |
-
# Model initialization cache
|
| 10 |
_model_cache = {}
|
|
|
|
| 11 |
|
| 12 |
# Model configuration
|
| 13 |
NEMOTRON_MODEL_ID = "nvidia/NVIDIA-Nemotron-3-Nano-4B-GGUF"
|
|
|
|
| 6 |
from pathlib import Path
|
| 7 |
|
| 8 |
|
| 9 |
+
# Model initialization cache (exported for reuse by story.py recap)
|
| 10 |
_model_cache = {}
|
| 11 |
+
NEMOTRON_MODEL_CACHE = _model_cache
|
| 12 |
|
| 13 |
# Model configuration
|
| 14 |
NEMOTRON_MODEL_ID = "nvidia/NVIDIA-Nemotron-3-Nano-4B-GGUF"
|
app/services/story.py
CHANGED
|
@@ -17,7 +17,7 @@ from pathlib import Path
|
|
| 17 |
from typing import Optional
|
| 18 |
|
| 19 |
from app.services.tracing import log_event
|
| 20 |
-
from app.services.generator import NEMOTRON_MODEL_ID, NEMOTRON_GGUF_FILE
|
| 21 |
|
| 22 |
|
| 23 |
# ββ Story packet builder βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -248,19 +248,32 @@ def _template_poster_prompt(packet: dict) -> str:
|
|
| 248 |
def _llm_recap(prompt_template: str, story_packet: dict) -> Optional[dict]:
|
| 249 |
"""Attempt to generate a recap using the Nemotron LLM via llama-cpp-python.
|
| 250 |
|
|
|
|
|
|
|
|
|
|
| 251 |
Returns None if the model is unavailable so callers can fall back to
|
| 252 |
the template engine.
|
| 253 |
"""
|
| 254 |
try:
|
| 255 |
from llama_cpp import Llama
|
| 256 |
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
|
| 265 |
packet_str = json.dumps(story_packet, indent=2, default=str)[:3000]
|
| 266 |
prompt = prompt_template.format(story_packet=packet_str)
|
|
@@ -299,8 +312,9 @@ def _llm_recap(prompt_template: str, story_packet: dict) -> Optional[dict]:
|
|
| 299 |
def generate_story(story_packet: dict, session_id: Optional[str] = None) -> dict:
|
| 300 |
"""Generate final recap story from game data and events.
|
| 301 |
|
| 302 |
-
Attempts
|
| 303 |
-
|
|
|
|
| 304 |
|
| 305 |
Args:
|
| 306 |
story_packet: Structured packet with game info, scores, journals, photos.
|
|
@@ -310,27 +324,27 @@ def generate_story(story_packet: dict, session_id: Optional[str] = None) -> dict
|
|
| 310 |
Story output dict with keys ``short_recap``, ``long_summary``,
|
| 311 |
``poster_prompt``, ``story_packet``.
|
| 312 |
"""
|
| 313 |
-
# Try
|
| 314 |
llm_result = None
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 318 |
if llm_result:
|
| 319 |
-
print("[story]
|
| 320 |
-
except Exception as exc:
|
| 321 |
-
print(f"[story] MiniCPM recap unavailable: {exc}")
|
| 322 |
|
| 323 |
-
# Fall back to
|
| 324 |
if not llm_result:
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
prompt_template = template_path.read_text(encoding="utf-8")
|
| 329 |
-
|
| 330 |
-
if prompt_template:
|
| 331 |
-
llm_result = _llm_recap(prompt_template, story_packet)
|
| 332 |
if llm_result:
|
| 333 |
-
print("[story]
|
|
|
|
|
|
|
| 334 |
|
| 335 |
if llm_result and all(k in llm_result for k in ("short_recap", "long_summary", "poster_prompt")):
|
| 336 |
short_recap = llm_result["short_recap"]
|
|
|
|
| 17 |
from typing import Optional
|
| 18 |
|
| 19 |
from app.services.tracing import log_event
|
| 20 |
+
from app.services.generator import NEMOTRON_MODEL_ID, NEMOTRON_GGUF_FILE, NEMOTRON_MODEL_CACHE
|
| 21 |
|
| 22 |
|
| 23 |
# ββ Story packet builder βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 248 |
def _llm_recap(prompt_template: str, story_packet: dict) -> Optional[dict]:
|
| 249 |
"""Attempt to generate a recap using the Nemotron LLM via llama-cpp-python.
|
| 250 |
|
| 251 |
+
Reuses the cached model from ``generator._model_cache`` if available so we
|
| 252 |
+
don't load a second copy of the 2.84 GB model on the same GPU.
|
| 253 |
+
|
| 254 |
Returns None if the model is unavailable so callers can fall back to
|
| 255 |
the template engine.
|
| 256 |
"""
|
| 257 |
try:
|
| 258 |
from llama_cpp import Llama
|
| 259 |
|
| 260 |
+
# Try to reuse the already-loaded generator model first
|
| 261 |
+
llm = None
|
| 262 |
+
for key, cached in NEMOTRON_MODEL_CACHE.items():
|
| 263 |
+
if key.startswith("llama_cpp_"):
|
| 264 |
+
llm = cached
|
| 265 |
+
print("[story] Reusing cached Nemotron model from generator")
|
| 266 |
+
break
|
| 267 |
+
|
| 268 |
+
if llm is None:
|
| 269 |
+
# Fall back: load a fresh instance
|
| 270 |
+
llm = Llama.from_pretrained(
|
| 271 |
+
repo_id=NEMOTRON_MODEL_ID,
|
| 272 |
+
filename=NEMOTRON_GGUF_FILE,
|
| 273 |
+
verbose=False,
|
| 274 |
+
n_gpu_layers=-1,
|
| 275 |
+
n_ctx=2048,
|
| 276 |
+
)
|
| 277 |
|
| 278 |
packet_str = json.dumps(story_packet, indent=2, default=str)[:3000]
|
| 279 |
prompt = prompt_template.format(story_packet=packet_str)
|
|
|
|
| 312 |
def generate_story(story_packet: dict, session_id: Optional[str] = None) -> dict:
|
| 313 |
"""Generate final recap story from game data and events.
|
| 314 |
|
| 315 |
+
Attempts Nemotron-based generation first (reuses model already loaded
|
| 316 |
+
on GPU from game generation), then MiniCPM, then falls back to
|
| 317 |
+
high-quality templates so the pipeline always returns a result.
|
| 318 |
|
| 319 |
Args:
|
| 320 |
story_packet: Structured packet with game info, scores, journals, photos.
|
|
|
|
| 324 |
Story output dict with keys ``short_recap``, ``long_summary``,
|
| 325 |
``poster_prompt``, ``story_packet``.
|
| 326 |
"""
|
| 327 |
+
# Try Nemotron path first (model already loaded on GPU from game generation)
|
| 328 |
llm_result = None
|
| 329 |
+
template_path = Path("app/prompts/story_recap.txt")
|
| 330 |
+
prompt_template = ""
|
| 331 |
+
if template_path.exists():
|
| 332 |
+
prompt_template = template_path.read_text(encoding="utf-8")
|
| 333 |
+
|
| 334 |
+
if prompt_template:
|
| 335 |
+
llm_result = _llm_recap(prompt_template, story_packet)
|
| 336 |
if llm_result:
|
| 337 |
+
print("[story] Nemotron recap generated successfully")
|
|
|
|
|
|
|
| 338 |
|
| 339 |
+
# Fall back to MiniCPM (OpenBMB sponsor model)
|
| 340 |
if not llm_result:
|
| 341 |
+
try:
|
| 342 |
+
from app.services.minicpm import generate_recap as minicpm_recap
|
| 343 |
+
llm_result = minicpm_recap(story_packet)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 344 |
if llm_result:
|
| 345 |
+
print("[story] MiniCPM recap generated successfully")
|
| 346 |
+
except Exception as exc:
|
| 347 |
+
print(f"[story] MiniCPM recap unavailable: {exc}")
|
| 348 |
|
| 349 |
if llm_result and all(k in llm_result for k in ("short_recap", "long_summary", "poster_prompt")):
|
| 350 |
short_recap = llm_result["short_recap"]
|