"""The saved review: its format, its checks, and conversion to situations.json. Kept free of clustering and page code so apply_review.py and the tests load instantly. A review run directory holds: candidates.json written by review.py: the groups the program found (read-only) REVIEW.html written by review.py: the page the reviewer works in review-.json saved from that page, usually into Downloads situations.json written by apply_review.py once the review passes every check The page (review_page_logic.cjs) applies the same rules as it goes, so a reviewer sees a problem where it happens. This module is still the authority: apply_review.py checks the saved file again before anything downstream reads it. """ import collections SCHEMA_VERSION = 2 MIN_MEMBERS, MIN_STORIES, MAX_DOMINANT = 5, 4, 0.40 MAX_FIELD_CHARS = 80 DECISIONS = { 'accepted': ('keep', 'drop', 'same'), # passed the gate: a decision is required 'borderline': ('skip', 'keep', 'same'), # nearly passed: optional, left out by default } RULES = {'minMembers': MIN_MEMBERS, 'minStories': MIN_STORIES, 'maxDominant': MAX_DOMINANT, 'maxFieldChars': MAX_FIELD_CHARS} def member_id(cid, n): """C04.07 — the 7th scene of group C04.""" return f'{cid}.{n:02d}' def blank_entry(cid, kind): return {'id': cid, 'decision': None if kind == 'accepted' else 'skip', 'same_as': None, 'actor': '', 'wants': '', 'counterpart': '', 'excluded': []} def display_name(candidates, cid): """What the page calls a group: "Group 3", or "Extra group 2" for a near miss.""" kind = candidates['candidates'][cid]['kind'] same_kind = [c for c, v in candidates['candidates'].items() if v['kind'] == kind] n = same_kind.index(cid) + 1 return f'Group {n}' if kind == 'accepted' else f'Extra group {n}' def shape(members): """(scene count, story count, share held by the largest story).""" counts = collections.Counter(m['story'] for m in members) return len(members), len(counts), (max(counts.values()) / len(members) if members else 0.0) def sentence(entry): return f'A {entry["actor"].strip()} wants {entry["wants"].strip()} from {entry["counterpart"].strip()}.' def _text(value): return value.strip() if isinstance(value, str) else '' def validate(candidates, form): """Return (errors, warnings, situations). situations is None whenever there is any error. Every problem is collected, so the reviewer can fix them all in one pass. Messages name groups the way the page does, and say what to do there. """ errors, warnings = [], [] cands = candidates['candidates'] run = candidates['run'] name = lambda cid: display_name(candidates, cid) if form.get('schema_version') != SCHEMA_VERSION: errors.append(f'This review file was made by a different version of the review tool ' f'(format {form.get("schema_version")!r}; this checker reads {SCHEMA_VERSION}). ' f'Regenerate the page with review.py and review in that.') return errors, warnings, None if form.get('run') != run['id']: errors.append(f'This review file is for run {form.get("run")!r}, but this directory is run ' f'{run["id"]!r}. Use the file saved from this run\'s REVIEW.html.') if run.get('fingerprint') and form.get('fingerprint') and form['fingerprint'] != run['fingerprint']: errors.append('This review file was made for a different set of groups that used the same run ' 'name. Review again in the current REVIEW.html.') entries = form.get('entries') if not isinstance(entries, list): errors.append('The review file has no list of groups in it. Save it again from REVIEW.html.') return errors, warnings, None by_id = {} for e in entries: cid = e.get('id') if isinstance(e, dict) else None if cid not in cands: errors.append(f'The review file mentions a group {cid!r} that is not in this run.') elif cid not in by_id: by_id[cid] = e for cid in cands: if cid not in by_id: errors.append(f'{name(cid)} is missing from the review file. Save it again from REVIEW.html.') for cid, e in by_id.items(): kind = cands[cid]['kind'] decision = e.get('decision') if decision is None: errors.append(f'{name(cid)}: not decided yet. Open it in REVIEW.html and choose keep, drop, ' f'or same as another group.') continue if decision not in DECISIONS[kind]: errors.append(f'{name(cid)}: the decision {decision!r} is not one this group can have.') continue members = cands[cid]['members'] valid = {m['member'] for m in members} excluded = e.get('excluded', []) if not isinstance(excluded, list) or any(x not in valid for x in excluded): errors.append(f'{name(cid)}: the unticked scenes do not match this group. Save the review ' f'again from REVIEW.html.') if decision == 'keep': missing = [label for key, label in (('actor', 'who wants something'), ('wants', 'what they want'), ('counterpart', 'who they want it from')) if not _text(e.get(key))] if missing: errors.append(f'{name(cid)}: kept, but its name is not finished. Fill in ' f'{", ".join(missing)} in "A ___ wants ___ from ___".') for key in ('actor', 'wants', 'counterpart'): if len(_text(e.get(key))) > MAX_FIELD_CHARS: errors.append(f'{name(cid)}: "{key}" is longer than {MAX_FIELD_CHARS} characters.') if isinstance(excluded, list) and len(excluded) > len(members) / 2: warnings.append(f'{name(cid)}: more than half its scenes are unticked. Check the name ' f'describes what the group is mostly about.') if decision == 'same': target = e.get('same_as') if target == cid or target not in by_id: errors.append(f'{name(cid)}: marked "same as" a group that is not in this run.') elif by_id[target].get('decision') != 'keep': errors.append(f'{name(cid)}: marked the same as {name(target)}, but {name(target)} is not ' f'kept. Keep {name(target)}, or choose differently for {name(cid)}.') if errors: return errors, warnings, None groups = collections.OrderedDict((cid, [cid]) for cid, e in by_id.items() if e['decision'] == 'keep') for cid, e in by_id.items(): if e['decision'] == 'same': groups[e['same_as']].append(cid) situations = [] for cid, sources in groups.items(): members, seen = [], set() for src in sources: excluded = set(by_id[src].get('excluded', [])) for m in cands[src]['members']: if m['member'] in excluded or m['chunk'] in seen: continue seen.add(m['chunk']) members.append(m) size, stories, dominant = shape(members) if size < MIN_MEMBERS or stories < MIN_STORIES or dominant > MAX_DOMINANT: merged = f' (with {", ".join(name(s) for s in sources[1:])} merged in)' if len(sources) > 1 else '' errors.append(f'{name(cid)}{merged}: {size} ticked scenes from {stories} stories, the largest ' f'story holding {dominant:.0%}. A situation needs at least {MIN_MEMBERS} scenes ' f'from at least {MIN_STORIES} stories, with no story above {MAX_DOMINANT:.0%}. ' f'Tick more scenes, mark another group the same as it, or drop it.') continue e = by_id[cid] situations.append({ 'id': f'S{len(situations) + 1:02d}', 'from': sources, 'situation': sentence(e), 'actor': _text(e['actor']), 'wants': _text(e['wants']), 'counterpart': _text(e['counterpart']), 'size': size, 'stories': stories, 'members': [{k: m[k] for k in ('member', 'chunk', 'story', 'title', 'summary')} for m in members], }) if errors: return errors, warnings, None decided = collections.Counter(e['decision'] for e in by_id.values()) result = { 'schema_version': SCHEMA_VERSION, 'run': run, 'counts': {'groups': len(cands), 'situations': len(situations), 'kept': decided['keep'], 'same': decided['same'], 'dropped': decided['drop'], 'left_out': decided['skip']}, 'situations': situations, 'dropped': [cid for cid, e in by_id.items() if e['decision'] == 'drop'], 'left_out': [cid for cid, e in by_id.items() if e['decision'] == 'skip'], } return errors, warnings, result