File size: 5,347 Bytes
714a774
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
"""
test_concept.py — verify STAGE 2 (monster design -> strict JSON) in isolation.

Sends the same system + JSON prompt the server uses, then checks the reply parses
into a monster spec with the required keys. Run `inspect_api.py` first to confirm
the /chat arg order below.

The server is defensive: it clamps out-of-range numbers and falls back if `type`
is off-list, so the HARD requirement is only that the reply *parses* and has the
keys. Out-of-range values are printed as soft warnings, not failures.

Consumes a little ZeroGPU quota (one call).

Usage:
    HF_TOKEN=hf_xxx python test_concept.py
    HF_TOKEN=hf_xxx python test_concept.py --descriptor "wooden chair"
"""

import os
import re
import sys
import json
import argparse

from gradio_client import Client

SPACE = os.getenv("CONCEPT_SPACE", "huggingface-projects/gemma-4-12b-it")
TOKEN = os.getenv("HF_TOKEN")

TYPES = ["beast", "bug", "aquatic", "flora", "mineral",
         "space", "machina", "structure", "culture", "cuisine"]

SYSTEM = (
    "You are a creature designer for a monster-collection game called Piclets. Given a "
    "real-world object, you invent ONE original collectible creature inspired by it. You "
    "always reply with exactly one JSON object and nothing else — no prose, no markdown, "
    "no code fences."
)


def build_prompt(descriptor: str) -> str:
    return (
        f'Design a Piclet inspired by this object: "{descriptor}".\n\n'
        "Return a JSON object with EXACTLY these keys and nothing else:\n"
        '- "name": 1-2 words, max 20 chars, must not contain the object name.\n'
        f'- "type": exactly one of {TYPES}.\n'
        '- "appearance": 1-3 sentences for an image generator; no object name, no art style.\n'
        '- "description": 1-2 sentences of flavour.\n'
        '- "weight_kg": a number.\n'
        '- "height_m": a number.\n'
        '- "rarity": an integer 1-100.\n\n'
        "Reply with only the JSON object."
    )


def extract_json(text: str) -> dict:
    """Mirrors app.py _extract_json: strip framing/fences, parse first {...}."""
    text = text.replace("**💬 Response:**", "")
    text = re.sub(r"^\s*assistant(final)?\s*", "", text, flags=re.IGNORECASE)
    text = re.sub(r"```(?:json)?", "", text)
    s, e = text.find("{"), text.rfind("}")
    if s != -1 and e > s:
        text = text[s:e + 1]
    return json.loads(text)


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--descriptor", default="ceramic coffee mug")
    args = ap.parse_args()

    print(f"[concept] space = {SPACE}")
    print(f"[concept] descriptor = {args.descriptor!r}")
    if not TOKEN:
        print("[concept] WARNING: no HF_TOKEN — tiny anonymous quota; may fail.")

    client = Client(SPACE, hf_token=TOKEN)

    # huggingface-projects/gemma-4-12b-it /chat positional args (verify with
    # inspect_api.py):
    #   (text, files, history, thinking, max_new_tokens, image_token_budget,
    #    system_prompt, temperature, top_p, top_k, repetition_penalty) -> response
    # thinking=False so the reply is clean JSON, not interleaved reasoning.
    result = client.predict(
        build_prompt(args.descriptor),  # text
        None,          # files
        None,          # history
        False,         # thinking
        2000,          # max_new_tokens
        280,           # image_token_budget
        SYSTEM,        # system_prompt
        0.7,           # temperature
        api_name="/chat",
    )

    print("\n[concept] RAW RESULT:")
    print(repr(result)[:2000])

    # gemma returns {"reasoning": "", "content": "<reply>"}; normalize to the reply
    # string before parsing. Mirror app.py generate_concept's extraction.
    if isinstance(result, dict):
        raw = result.get("content") or result.get("text") or result.get("response") or ""
    elif isinstance(result, (list, tuple)) and result:
        raw = result[0]
    else:
        raw = result
    raw = raw if isinstance(raw, str) else str(raw)
    try:
        data = extract_json(raw)
    except Exception as exc:
        print(f"\n[concept] FAIL — reply did not parse as JSON: {exc}")
        print("        Check RAW RESULT and adjust the prompt or _extract_json framing rules.")
        sys.exit(1)

    print("\n[concept] parsed JSON:")
    print(json.dumps(data, indent=2, ensure_ascii=False))

    required = ["name", "type", "appearance", "description", "weight_kg", "height_m", "rarity"]
    missing = [k for k in required if k not in data]

    # Soft checks — the server clamps/falls-back on these, so they only warn.
    soft = {
        "type in categories": str(data.get("type", "")).lower() in TYPES,
        "weight is a number": isinstance(data.get("weight_kg"), (int, float)),
        "height is a number": isinstance(data.get("height_m"), (int, float)),
        "rarity within 1-100": isinstance(data.get("rarity"), (int, float)) and 1 <= float(data.get("rarity", 0)) <= 100,
    }
    for name, good in soft.items():
        print(f"  [{'ok' if good else '~~'}] {name}")
    if missing:
        print(f"  missing required keys: {missing}")

    ok = not missing  # hard requirement: parses + has all keys
    print("[concept] PASS" if ok else "[concept] FAIL — required keys missing; fix the prompt/model.")
    sys.exit(0 if ok else 1)


if __name__ == "__main__":
    main()