LazyHuman10 commited on
Commit
7cd5e9d
·
1 Parent(s): 0a3f822

Add NPCverse model engine

Browse files
Files changed (1) hide show
  1. model_engine.py +352 -0
model_engine.py ADDED
@@ -0,0 +1,352 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """AI model engine for NPCverse.
2
+
3
+ NPCverse transforms uploaded photos into living RPG characters using
4
+ MiniCPM-V on Hugging Face ZeroGPU.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import re
11
+ from typing import Any
12
+
13
+ import spaces
14
+ import torch
15
+ from transformers import AutoModel, AutoTokenizer
16
+ from PIL import Image
17
+
18
+ MODEL_ID = "openbmb/MiniCPM-V-2_6"
19
+ model = AutoModel.from_pretrained(
20
+ MODEL_ID,
21
+ trust_remote_code=True,
22
+ attn_implementation="sdpa",
23
+ torch_dtype=torch.bfloat16
24
+ ).eval()
25
+ tokenizer = AutoTokenizer.from_pretrained(MODEL_ID, trust_remote_code=True)
26
+
27
+ FRIENDSHIP_THRESHOLDS = [10, 20, 35, 55]
28
+ SECRET_THRESHOLDS = [8, 18, 35]
29
+
30
+ DEFAULT_NPC: dict[str, Any] = {
31
+ "name": "Nyx Vale",
32
+ "title": "Wanderer of the Digital Realm",
33
+ "class": "Reality Glitch Rogue",
34
+ "level": 7,
35
+ "rarity": "Rare",
36
+ "alignment": "Chaotic Good",
37
+ "lore": (
38
+ "A strange traveler assembled from scattered memories, half rumor and "
39
+ "half starlight, who appears wherever forgotten stories need a champion."
40
+ ),
41
+ "stats": {
42
+ "strength": 42,
43
+ "intelligence": 76,
44
+ "charisma": 68,
45
+ "luck": 81,
46
+ "stealth": 73,
47
+ "chaos": 64,
48
+ },
49
+ "passive_ability": {
50
+ "name": "Signal Echo",
51
+ "description": "Reads emotional static in the air to sense hidden motives.",
52
+ },
53
+ "ultimate": {
54
+ "name": "Myth Rewrite",
55
+ "description": "Briefly bends the scene into a heroic legend where one impossible action can succeed.",
56
+ },
57
+ "weakness": "Becomes uncertain when memories conflict with the present moment.",
58
+ "faction": "The Patchwork Covenant",
59
+ "world": "The Neon Wilds",
60
+ "opening_line": "You found me between one heartbeat and the next. That usually means trouble.",
61
+ "quests": [
62
+ {
63
+ "title": "Trace the Lost Signal",
64
+ "description": "Follow a broken transmission through the alleys of a city that dreams.",
65
+ "reward": "Echo Compass",
66
+ "rarity": "Uncommon",
67
+ },
68
+ {
69
+ "title": "Steal Back the Moon Key",
70
+ "description": "Recover a silver key from a guild of masked probability thieves.",
71
+ "reward": "Moonlit Lockpick",
72
+ "rarity": "Rare",
73
+ },
74
+ {
75
+ "title": "Defend the Last Save Point",
76
+ "description": "Hold the line while ancient code repairs a collapsing sanctuary.",
77
+ "reward": "Legendary Bond Fragment",
78
+ "rarity": "Epic",
79
+ },
80
+ ],
81
+ "secrets": [
82
+ "Nyx remembers fragments of every player who has ever abandoned a quest.",
83
+ "Their shadow sometimes moves a few seconds before they do.",
84
+ "The Patchwork Covenant may have created Nyx as a living apology.",
85
+ ],
86
+ "emoji": "✨",
87
+ }
88
+
89
+ REQUIRED_NPC_KEYS = {
90
+ "name",
91
+ "title",
92
+ "class",
93
+ "level",
94
+ "rarity",
95
+ "alignment",
96
+ "lore",
97
+ "stats",
98
+ "passive_ability",
99
+ "ultimate",
100
+ "weakness",
101
+ "faction",
102
+ "world",
103
+ "opening_line",
104
+ "quests",
105
+ "secrets",
106
+ "emoji",
107
+ }
108
+
109
+ REQUIRED_STAT_KEYS = {
110
+ "strength",
111
+ "intelligence",
112
+ "charisma",
113
+ "luck",
114
+ "stealth",
115
+ "chaos",
116
+ }
117
+
118
+
119
+ def parse_json_safe(text: str) -> dict:
120
+ """Parse JSON after removing common markdown fences and wrapper text."""
121
+ cleaned = re.sub(r"^\s*```(?:json)?\s*", "", text.strip(), flags=re.IGNORECASE)
122
+ cleaned = re.sub(r"\s*```\s*$", "", cleaned).strip()
123
+
124
+ try:
125
+ parsed = json.loads(cleaned)
126
+ except json.JSONDecodeError:
127
+ match = re.search(r"\{.*\}", cleaned, flags=re.DOTALL)
128
+ if match is None:
129
+ raise
130
+ parsed = json.loads(match.group(0))
131
+
132
+ if not isinstance(parsed, dict):
133
+ raise ValueError("Expected a JSON object.")
134
+ return parsed
135
+
136
+
137
+ def _validate_npc_payload(payload: dict) -> dict:
138
+ """Validate the NPC payload shape required by the UI."""
139
+ missing = REQUIRED_NPC_KEYS - payload.keys()
140
+ if missing:
141
+ raise ValueError(f"NPC payload missing keys: {sorted(missing)}")
142
+
143
+ stats = payload.get("stats")
144
+ if not isinstance(stats, dict):
145
+ raise ValueError("NPC stats must be a dictionary.")
146
+
147
+ missing_stats = REQUIRED_STAT_KEYS - stats.keys()
148
+ if missing_stats:
149
+ raise ValueError(f"NPC stats missing keys: {sorted(missing_stats)}")
150
+
151
+ payload["level"] = int(payload["level"])
152
+ for key in REQUIRED_STAT_KEYS:
153
+ stats[key] = max(1, min(100, int(stats[key])))
154
+
155
+ return payload
156
+
157
+
158
+ def get_friendship_label(msg_count: int) -> str:
159
+ """Return the friendship label for the current message count."""
160
+ if msg_count >= FRIENDSHIP_THRESHOLDS[3]:
161
+ return "Legendary Bond"
162
+ if msg_count >= FRIENDSHIP_THRESHOLDS[2]:
163
+ return "Trusted Ally"
164
+ if msg_count >= FRIENDSHIP_THRESHOLDS[1]:
165
+ return "Friend"
166
+ if msg_count >= FRIENDSHIP_THRESHOLDS[0]:
167
+ return "Acquaintance"
168
+ return "Stranger"
169
+
170
+
171
+ def check_new_secrets(msg_count: int, already_unlocked: list) -> list[int]:
172
+ """Return newly unlocked secret indices for the current message count."""
173
+ unlocked = {int(index) for index in already_unlocked if str(index).isdigit()}
174
+ return [
175
+ index
176
+ for index, threshold in enumerate(SECRET_THRESHOLDS)
177
+ if msg_count >= threshold and index not in unlocked
178
+ ]
179
+
180
+
181
+ @spaces.GPU
182
+ def analyze_image(image_path: str) -> str:
183
+ """Describe an uploaded image as factual character source material."""
184
+ prompt_text = (
185
+ "Describe this person's appearance in detail. Include: approximate age and gender, "
186
+ "clothing style and colors, facial expression and mood, hair style and color, "
187
+ "accessories (glasses, jewelry, etc.), body language and pose, background environment. "
188
+ "Be specific and factual. Under 120 words."
189
+ )
190
+
191
+ try:
192
+ with Image.open(image_path) as image_obj:
193
+ image_obj = image_obj.convert("RGB")
194
+ msgs = [{'role': 'user', 'content': [image_obj, prompt_text]}]
195
+ result = model.chat(image=None, msgs=msgs, tokenizer=tokenizer)
196
+ return str(result).strip()
197
+ except Exception:
198
+ return "A mysterious figure in the digital realm."
199
+
200
+
201
+ def _npc_generation_prompt(description: str) -> str:
202
+ """Build the primary JSON-only NPC generation prompt."""
203
+ return f"""
204
+ SYSTEM: You are the NPCverse character engine. Transform the visual description
205
+ into a vivid RPG character while preserving factual visual inspiration.
206
+
207
+ Return ONLY valid JSON. Do not include backticks, markdown, comments, or preamble.
208
+
209
+ Required JSON keys:
210
+ name, title, class, level, rarity, alignment, lore, stats, passive_ability,
211
+ ultimate, weakness, faction, world, opening_line, quests, secrets, emoji.
212
+
213
+ Rules:
214
+ - level must be an integer.
215
+ - stats must be a dict with integer values from 1 to 100 for exactly:
216
+ strength, intelligence, charisma, luck, stealth, chaos.
217
+ - passive_ability must be a dict with keys: name, description.
218
+ - ultimate must be a dict with keys: name, description.
219
+ - quests must be a list of exactly 3 dicts, each with keys:
220
+ title, description, reward, rarity.
221
+ - secrets must be a list of exactly 3 strings.
222
+ - emoji must be a single emoji character.
223
+
224
+ Visual description:
225
+ {description}
226
+ """.strip()
227
+
228
+
229
+ def _npc_retry_prompt(description: str) -> str:
230
+ """Build a shorter strict prompt for retrying malformed JSON."""
231
+ return f"""
232
+ Return ONLY one valid JSON object for an RPG NPC based on this description:
233
+ {description}
234
+
235
+ Use exactly these top-level keys:
236
+ name, title, class, level, rarity, alignment, lore, stats, passive_ability,
237
+ ultimate, weakness, faction, world, opening_line, quests, secrets, emoji.
238
+
239
+ stats keys: strength, intelligence, charisma, luck, stealth, chaos.
240
+ passive_ability keys: name, description.
241
+ ultimate keys: name, description.
242
+ quests: exactly 3 objects with title, description, reward, rarity.
243
+ secrets: exactly 3 strings.
244
+ No markdown. No extra text.
245
+ """.strip()
246
+
247
+
248
+ @spaces.GPU
249
+ def generate_npc(description: str) -> dict:
250
+ """Generate a complete RPG NPC JSON object from a visual description."""
251
+ try:
252
+ msgs = [{'role': 'user', 'content': _npc_generation_prompt(description)}]
253
+ result = model.chat(image=None, msgs=msgs, tokenizer=tokenizer)
254
+ return _validate_npc_payload(parse_json_safe(str(result)))
255
+ except Exception:
256
+ try:
257
+ retry_msgs = [{'role': 'user', 'content': _npc_retry_prompt(description)}]
258
+ retry_result = model.chat(image=None, msgs=retry_msgs, tokenizer=tokenizer)
259
+ return _validate_npc_payload(parse_json_safe(str(retry_result)))
260
+ except Exception:
261
+ return DEFAULT_NPC
262
+
263
+
264
+ def _stats_summary(npc: dict) -> str:
265
+ """Format NPC stats for the roleplay prompt."""
266
+ stats = npc.get("stats", {})
267
+ return ", ".join(
268
+ f"{key}: {stats.get(key, DEFAULT_NPC['stats'][key])}"
269
+ for key in ["strength", "intelligence", "charisma", "luck", "stealth", "chaos"]
270
+ )
271
+
272
+
273
+ def _format_unlocked_secrets(npc: dict, unlocked_secrets: list) -> str:
274
+ """Format unlocked secret indices and text for the roleplay prompt."""
275
+ secrets = npc.get("secrets", [])
276
+ lines = []
277
+ for index in unlocked_secrets:
278
+ try:
279
+ secret_index = int(index)
280
+ secret_text = secrets[secret_index]
281
+ except (TypeError, ValueError, IndexError):
282
+ continue
283
+ lines.append(f"{secret_index}: {secret_text}")
284
+ return "\n".join(lines) if lines else "None"
285
+
286
+
287
+ def _normalize_history(history: list) -> list[dict[str, str]]:
288
+ """Convert common Gradio chat history formats into MiniCPM messages."""
289
+ normalized: list[dict[str, str]] = []
290
+
291
+ for exchange in history[-10:]:
292
+ if isinstance(exchange, dict):
293
+ role = exchange.get("role")
294
+ content = exchange.get("content")
295
+ if role in {"user", "assistant"} and content:
296
+ normalized.append({"role": role, "content": str(content)})
297
+ continue
298
+
299
+ if isinstance(exchange, (list, tuple)) and len(exchange) >= 2:
300
+ user_turn, assistant_turn = exchange[0], exchange[1]
301
+ if user_turn:
302
+ normalized.append({"role": "user", "content": str(user_turn)})
303
+ if assistant_turn:
304
+ normalized.append({"role": "assistant", "content": str(assistant_turn)})
305
+
306
+ return normalized
307
+
308
+
309
+ @spaces.GPU
310
+ def chat_respond(
311
+ npc: dict,
312
+ history: list,
313
+ user_message: str,
314
+ msg_count: int,
315
+ unlocked_secrets: list,
316
+ ) -> str:
317
+ """Generate an in-character NPC chat response."""
318
+ npc_name = str(npc.get("name", DEFAULT_NPC["name"]))
319
+
320
+ try:
321
+ friendship_label = get_friendship_label(msg_count)
322
+ passive = npc.get("passive_ability", DEFAULT_NPC["passive_ability"])
323
+
324
+ system_prompt = f"""
325
+ You are {npc_name}, an NPC in NPCverse. ALWAYS stay in character.
326
+
327
+ Name: {npc_name}
328
+ Class: {npc.get("class", DEFAULT_NPC["class"])}
329
+ World: {npc.get("world", DEFAULT_NPC["world"])}
330
+ Alignment: {npc.get("alignment", DEFAULT_NPC["alignment"])}
331
+ Stats summary: {_stats_summary(npc)}
332
+ Passive ability: {passive.get("name", DEFAULT_NPC["passive_ability"]["name"])} - {passive.get("description", DEFAULT_NPC["passive_ability"]["description"])}
333
+ Weakness: {npc.get("weakness", DEFAULT_NPC["weakness"])}
334
+ Current friendship level: {friendship_label}
335
+ Unlocked secrets by index:
336
+ {_format_unlocked_secrets(npc, unlocked_secrets)}
337
+
338
+ Respond naturally as this character. Keep replies concise, flavorful, and interactive.
339
+ Never say you are an AI model or break character.
340
+ """.strip()
341
+
342
+ msgs = [
343
+ {"role": "user", "content": system_prompt},
344
+ {"role": "assistant", "content": "Understood. I will remain fully in character."},
345
+ ]
346
+ msgs.extend(_normalize_history(history))
347
+ msgs.append({"role": "user", "content": user_message})
348
+
349
+ result = model.chat(image=None, msgs=msgs, tokenizer=tokenizer)
350
+ return str(result).strip()
351
+ except Exception:
352
+ return f"*{npc_name} seems momentarily absent...*"