NANI-Nithin commited on
Commit
6172d30
Β·
1 Parent(s): e18b02f

feat: add poster generation from session recap data and improve model caching

Browse files
Files changed (3) hide show
  1. app.py +47 -1
  2. app/services/generator.py +2 -1
  3. 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
- llm = Llama.from_pretrained(
258
- repo_id=NEMOTRON_MODEL_ID,
259
- filename=NEMOTRON_GGUF_FILE,
260
- verbose=False,
261
- n_gpu_layers=-1,
262
- n_ctx=2048,
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 MiniCPM-based generation first, then Nemotron, then falls
303
- back to high-quality templates so the pipeline always returns a result.
 
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 MiniCPM path first (OpenBMB sponsor model)
314
  llm_result = None
315
- try:
316
- from app.services.minicpm import generate_recap as minicpm_recap
317
- llm_result = minicpm_recap(story_packet)
 
 
 
 
318
  if llm_result:
319
- print("[story] MiniCPM recap generated successfully")
320
- except Exception as exc:
321
- print(f"[story] MiniCPM recap unavailable: {exc}")
322
 
323
- # Fall back to Nemotron LLM path
324
  if not llm_result:
325
- template_path = Path("app/prompts/story_recap.txt")
326
- prompt_template = ""
327
- if template_path.exists():
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] Nemotron recap generated successfully")
 
 
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"]