_NUMBER = re.compile(r"\d+(?:\.\d+)?")
# Figures written as words slip past the digit check, so they are not allowed at all.
# "one" is left out: it is everyday English ("one cause", "one at a time"), not a figure.
_NUMBER_WORD = re.compile(
r"\b(two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|fourteen|fifteen|"
r"sixteen|seventeen|eighteen|nineteen|twenty|thirty|forty|fifty|sixty|seventy|eighty|ninety|"
r"hundred|thousand|million|billion|half|halve|halves|quarter|third|double|doubles|twice|triple|"
r"thrice|dozen)\b", re.IGNORECASE)
def allowed_numbers(sub: dict, blocks: list[dict]) -> set[str]:
"""Every number the AI may use: the answers, plus the selected blocks' values and text."""
allowed = set()
sources = [str(v) for v in sub["values"].values()]
for b in blocks:
sources += [fmt_num(b["value"]), b["title"], b["text"]]
for text in sources:
allowed.update(fmt_num(n) for n in _NUMBER.findall(text))
return allowed
def ai_text_fields(reply: dict):
yield "summary", reply.get("summary", "")
yield "links_between_issues", reply.get("links_between_issues", "")
for i, p in enumerate(reply.get("priorities", []), start=1):
yield f"priorities[{i}].headline", p.get("headline", "")
yield f"priorities[{i}].why", p.get("why", "")
def validate_ai(reply: dict, sub: dict, blocks: list[dict]) -> list[str]:
problems = []
for key, typ in (("summary", str), ("priorities", list),
("links_between_issues", str), ("chosen_block_ids", list)):
if not isinstance(reply.get(key), typ):
problems.append(f"'{key}' is missing or not a {typ.__name__}")
if problems:
return problems
selected = {b["id"] for b in blocks}
for bid in reply["chosen_block_ids"]:
if bid not in selected:
problems.append(f"chosen_block_ids: '{bid}' was not selected by the rules")
if len(set(reply["chosen_block_ids"])) != len(reply["chosen_block_ids"]):
problems.append("chosen_block_ids: contains duplicates")
if not 1 <= len(reply["priorities"]) <= 3:
problems.append(f"priorities: expected 1-3, got {len(reply['priorities'])}")
for i, p in enumerate(reply["priorities"], start=1):
if p.get("block_id") not in reply["chosen_block_ids"]:
problems.append(f"priorities[{i}]: block '{p.get('block_id')}' is not in chosen_block_ids")
allowed = allowed_numbers(sub, blocks)
for field, text in ai_text_fields(reply):
if not str(text).strip():
problems.append(f"{field}: empty")
for num in _NUMBER.findall(str(text)):
if fmt_num(num) not in allowed:
problems.append(f"{field}: number {num} is not in the answers or the content sheet")
for word in _NUMBER_WORD.findall(str(text)):
problems.append(f"{field}: '{word}' is a figure written as a word; use the digits "
"from the answers or the content sheet, or leave it out")
return problems