File size: 10,097 Bytes
d72231c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
"""NEMOCITY event-type specs — validation/clamping for the 4 city event types.

Semantics (godseed lineage):
* out-of-range NUMBERS are CLAMPED, never rejected;
* unknown kinds are repaired via SYNONYMS (anything unknown -> house);
* malformed shapes (bad cells, missing geometry) are rejected with a terse
  observation string — but the only module that COMPOSES these events is the
  engine itself (placement/traffic), so rejections mean engine bugs, not model
  noise. The LLM never outputs coordinates.

Pure stdlib. world.py validates through `validate_call`; queue_worker uses
`resolve_kind` / `sanitize_name` / `default_name` on model-supplied fields.
"""

from __future__ import annotations

import re
import zlib
from typing import Any, Optional

from . import constants as C
from .moderation import Moderator

_moderator = Moderator()  # wordlist layers only; no judge

TOOL_NAMES: tuple[str, ...] = ("place_building", "lay_road", "apply_fix", "note")

FIX_ACTIONS = ("new_road", "upgrade_avenue")
NOTE_KINDS = ("milestone", "infill", "fix")

_FALLBACK_KIND = "house"


# ----------------------------------------------------------------- kind resolver

def resolve_kind(value: Any) -> str:
    """Map model-supplied kind text to a BUILDINGS key. Never fails: synonyms
    repair, substring rescues, anything else becomes a humble house."""
    if not isinstance(value, str):
        return _FALLBACK_KIND
    v = re.sub(r"[\s\-]+", "_", value.strip().lower()).strip("_")
    if v in C.BUILDINGS:
        return v
    if v in C.SYNONYMS:
        return C.SYNONYMS[v]
    if v.endswith("s") and v[:-1] in C.BUILDINGS:
        return v[:-1]
    for token, kind in C.SYNONYMS.items():
        if token in v:
            return kind
    for kind in C.BUILDINGS:
        if kind in v:
            return kind
    return _FALLBACK_KIND


# ------------------------------------------------------------------ name helpers

_STOPWORDS = frozenset((
    "a", "an", "the", "and", "or", "of", "for", "with", "near", "by", "at",
    "in", "on", "to", "please", "build", "make", "add", "put", "want", "like",
    "new", "some", "my", "our", "me", "us", "city", "town",
))


def sanitize_name(value: Any, default: str = "") -> str:
    """Printable, wordlist-clean, <=24 chars; falls back to `default`."""
    if not isinstance(value, str):
        return default
    name = "".join(ch for ch in value if ch.isprintable())
    name = re.sub(r"\s+", " ", name).strip()[: C.NAME_MAX_LEN].strip()
    if not name or not _moderator.check_content(name).allowed:
        return default
    return name


def default_name(kind: str, petition_text: str, wish_id: str) -> str:
    """Deterministic pleasant default derived from petition words."""
    words = [
        w for w in re.findall(r"[A-Za-z]+", str(petition_text or ""))
        if len(w) > 2 and w.lower() not in _STOPWORDS
        and w.lower() not in C.BUILDINGS and w.lower() not in C.SYNONYMS
    ]
    label = kind.replace("_", " ").title()
    if words:
        pick = words[zlib.crc32(f"{wish_id}:{kind}".encode()) % len(words)].title()
        candidate = f"{pick} {label}"[: C.NAME_MAX_LEN].strip()
        if _moderator.check_content(candidate).allowed:
            return candidate
    return f"The {label}"[: C.NAME_MAX_LEN]


def street_name_for(key: str) -> str:
    """Curated street name from a deterministic key (e.g. 'w_000005:1:road')."""
    return C.STREET_NAMES[zlib.crc32(str(key).encode()) % len(C.STREET_NAMES)]


# ------------------------------------------------------------------- primitives

def _err(msg: str) -> tuple[None, str]:
    return None, f"rejected: {msg}"


def _int_in(value: Any, lo: int, hi: int) -> Optional[int]:
    if isinstance(value, bool) or not isinstance(value, (int, float)):
        try:
            value = float(str(value).strip())
        except (ValueError, TypeError):
            return None
    return int(round(min(max(float(value), lo), hi)))


def _coerce_cell(cell: Any) -> Optional[list[int]]:
    if not isinstance(cell, (list, tuple)) or len(cell) < 2:
        return None
    cx = _int_in(cell[0], C.COORD_MIN, C.COORD_MAX)
    cz = _int_in(cell[1], C.COORD_MIN, C.COORD_MAX)
    if cx is None or cz is None:
        return None
    return [cx, cz]


def _coerce_cells(raw: Any, max_cells: int = 256) -> Optional[list[list[int]]]:
    if not isinstance(raw, (list, tuple)) or not raw:
        return None
    out: list[list[int]] = []
    seen: set[tuple[int, int]] = set()
    for c in list(raw)[:max_cells]:
        cell = _coerce_cell(c)
        if cell is None:
            return None
        if (cell[0], cell[1]) in seen:
            continue
        seen.add((cell[0], cell[1]))
        out.append(cell)
    return out


def _text(value: Any, max_len: int) -> str:
    s = "".join(ch for ch in str(value or "") if ch.isprintable())
    return re.sub(r"\s+", " ", s).strip()[:max_len]


# ------------------------------------------------------------------- validators

def _validate_place_building(args: dict):
    kind = resolve_kind(args.get("kind"))
    spec = C.BUILDINGS[kind]
    cx = _int_in(args.get("cx"), C.COORD_MIN, C.COORD_MAX - spec["w"] + 1)
    cz = _int_in(args.get("cz"), C.COORD_MIN, C.COORD_MAX - spec["d"] + 1)
    if cx is None or cz is None or args.get("cx") is None or args.get("cz") is None:
        return _err("place_building needs cx, cz")
    lo, hi = spec["floors"]
    floors = _int_in(args.get("floors"), lo, hi)
    if floors is None:
        floors = lo
    hue = _int_in(args.get("hue"), 0, 360)
    if hue is None:
        hue = 35
    variant = _int_in(args.get("variant"), 0, 7)
    if variant is None:
        variant = 0
    name = sanitize_name(args.get("name"), default=f"The {kind.replace('_', ' ').title()}")
    return {
        "kind": kind, "name": name, "cx": cx, "cz": cz,
        "w": spec["w"], "d": spec["d"], "floors": floors,
        "hue": hue, "variant": variant,
    }, None


def _validate_lay_road(args: dict):
    cells = _coerce_cells(args.get("cells"))
    if cells is None:
        return _err("lay_road needs cells [[cx,cz],...]")
    klass = str(args.get("klass") or "street").strip().lower()
    if klass not in C.ROAD_CLASSES:
        klass = "street"
    name = sanitize_name(args.get("name"), default="")
    out = {"cells": cells, "klass": klass}
    if name:
        out["name"] = name
    return out, None


def _coerce_metrics(raw: Any) -> dict:
    out: dict[str, Any] = {}
    if isinstance(raw, dict):
        for k, v in list(raw.items())[:6]:
            key = _text(k, 24)
            if not key:
                continue
            if isinstance(v, bool):
                continue
            if isinstance(v, (int, float)):
                num = round(float(v), 2)
                out[key] = int(num) if num.is_integer() else num
            elif isinstance(v, str):
                out[key] = _text(v, 48)
    return out


def _validate_apply_fix(args: dict):
    action = str(args.get("action") or "").strip().lower()
    if action not in FIX_ACTIONS:
        return _err(f"unknown fix action '{_text(args.get('action'), 24)}'")
    cells = _coerce_cells(args.get("cells"))
    if cells is None:
        return _err("apply_fix needs cells [[cx,cz],...]")
    klass = str(args.get("klass") or "avenue").strip().lower()
    if klass not in C.ROAD_CLASSES:
        klass = "avenue"
    name = sanitize_name(args.get("name"), default=street_name_for(repr(cells[0])))
    diagnosis = _text(args.get("diagnosis"), 200)
    return {
        "action": action, "cells": cells, "klass": klass, "name": name,
        "diagnosis": diagnosis,
        "metrics_before": _coerce_metrics(args.get("metrics_before")),
        "metrics_predicted": _coerce_metrics(args.get("metrics_predicted")),
    }, None


def _validate_note(args: dict):
    text = _text(args.get("text"), 140)
    if not text:
        return _err("note needs text")
    if not _moderator.check_content(text).allowed:
        return _err("those words may not be posted on the city ledger")
    kind = str(args.get("kind") or "milestone").strip().lower()
    if kind not in NOTE_KINDS:
        kind = "milestone"
    return {"text": text, "kind": kind}, None


_VALIDATORS = {
    "place_building": _validate_place_building,
    "lay_road": _validate_lay_road,
    "apply_fix": _validate_apply_fix,
    "note": _validate_note,
}


def validate_call(tool: Any, args: Any) -> tuple[Optional[dict], Optional[str]]:
    """Validate one event. Returns (canonical_args, None) or (None, rejection)."""
    if not isinstance(tool, str) or tool not in _VALIDATORS:
        return _err(f"unknown tool '{_text(tool, 32)}'")
    if args is None:
        args = {}
    if not isinstance(args, dict):
        return _err("args must be an object")
    return _VALIDATORS[tool](args)


def as_dict() -> dict:
    """Plain-JSON view of the event surface (prompt builders may render this)."""
    return {
        "place_building": {
            "kind": {"type": "enum", "values": list(C.BUILDINGS)},
            "name": {"type": "string", "max_len": C.NAME_MAX_LEN},
            "cx": {"type": "int", "min": C.COORD_MIN, "max": C.COORD_MAX},
            "cz": {"type": "int", "min": C.COORD_MIN, "max": C.COORD_MAX},
            "floors": {"type": "int", "note": "clamped to the kind's range"},
            "hue": {"type": "int", "min": 0, "max": 360},
        },
        "lay_road": {
            "cells": {"type": "cells", "note": "[[cx,cz],...] engine-routed only"},
            "klass": {"type": "enum", "values": list(C.ROAD_CLASSES)},
            "name": {"type": "string", "max_len": C.NAME_MAX_LEN},
        },
        "apply_fix": {
            "action": {"type": "enum", "values": list(FIX_ACTIONS)},
            "cells": {"type": "cells"},
            "klass": {"type": "enum", "values": list(C.ROAD_CLASSES)},
            "diagnosis": {"type": "string", "max_len": 200},
        },
        "note": {
            "text": {"type": "string", "max_len": 140},
            "kind": {"type": "enum", "values": list(NOTE_KINDS)},
        },
    }