"""The human review form: its schema, member ids, checks, and conversion to situations.json. Kept free of clustering code so apply_review.py and the tests load instantly. A review run has three files in one directory: candidates.json written by review.py: what the program found (read-only) review.json written blank by review.py, filled in by the reviewer situations.json written by apply_review.py once review.json passes every check """ import collections SCHEMA_VERSION = 1 MIN_MEMBERS, MIN_STORIES, MAX_DOMINANT = 5, 4, 0.40 MAX_SITUATION_CHARS = 200 DECISIONS = { 'accepted': ('accept', 'reject', 'merge'), # passed the gate: the reviewer must decide 'borderline': ('skip', 'accept', 'merge'), # narrowly failed: optional, "skip" by default } def member_id(cid, n): """C04.07 — the 7th scene of candidate C04.""" return f'{cid}.{n:02d}' def blank_entry(cid, kind): return { 'id': cid, 'kind': kind, 'decision': None if kind == 'accepted' else 'skip', 'situation': '', 'actor': '', 'counterpart': '', 'stakes': '', 'exclude': [], 'merge_into': None, 'notes': '', } def blank_form(candidates): return { 'schema_version': SCHEMA_VERSION, 'run': candidates['run']['id'], 'instructions': 'Read REVIEW.md in this directory. Fill in each entry below, then run: ' f'python3 apply_review.py review/{candidates["run"]["id"]}', 'entries': [blank_entry(cid, c['kind']) for cid, c in candidates['candidates'].items()], } 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 _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. """ errors, warnings = [], [] cands = candidates['candidates'] if form.get('schema_version') != SCHEMA_VERSION: errors.append(f'"schema_version" is {form.get("schema_version")!r}; this checker reads {SCHEMA_VERSION}.') if form.get('run') != candidates['run']['id']: errors.append(f'This form is for run {form.get("run")!r}, but candidates.json is run ' f'{candidates["run"]["id"]!r}. Use the review.json from the same directory.') entries = form.get('entries') if not isinstance(entries, list): errors.append('"entries" must be a list of candidate entries.') 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'An entry has id {cid!r}, which is not a candidate in this run.') elif cid in by_id: errors.append(f'{cid}: appears more than once; keep one entry.') else: by_id[cid] = e for cid in cands: if cid not in by_id: errors.append(f'{cid}: its entry is missing from the form. Restore it (the blank entry is ' f'{{"id": "{cid}", ...}}) and set "decision".') for cid, e in by_id.items(): kind = cands[cid]['kind'] allowed = DECISIONS[kind] decision = e.get('decision') if decision is None: errors.append(f'{cid}: no decision yet. Set "decision" to one of: {", ".join(allowed)}.') continue if decision not in allowed: errors.append(f'{cid}: "decision" is {decision!r}; for a {kind} candidate use one of: ' f'{", ".join(allowed)}.') continue members = cands[cid]['members'] valid = {m['member'] for m in members} exclude = e.get('exclude', []) if not isinstance(exclude, list): errors.append(f'{cid}: "exclude" must be a list of member ids, like ["{cid}.03"].') exclude = [] unknown = [x for x in exclude if x not in valid] if unknown: errors.append(f'{cid}: "exclude" lists {", ".join(map(str, unknown))}, which are not scenes of ' f'{cid}; its scenes are {member_id(cid, 1)} to {member_id(cid, len(members))}.') if decision == 'accept': for field in ('situation', 'actor', 'counterpart'): if not _text(e.get(field)): errors.append(f'{cid}: accepted, so "{field}" must be filled in.') situation = _text(e.get('situation')) if len(situation) > MAX_SITUATION_CHARS: errors.append(f'{cid}: "situation" is {len(situation)} characters; keep it to one sentence ' f'of at most {MAX_SITUATION_CHARS}.') if situation and 'want' not in situation.lower(): warnings.append(f'{cid}: "situation" does not say who wants what ("… wants … from …"). ' f'Accepted as written.') if not _text(e.get('stakes')): warnings.append(f'{cid}: "stakes" is empty.') if decision == 'merge': target = e.get('merge_into') if target == cid: errors.append(f'{cid}: cannot merge into itself.') elif target not in by_id: errors.append(f'{cid}: "merge_into" is {target!r}, which is not a candidate id in this form.') elif by_id[target].get('decision') != 'accept': errors.append(f'{cid}: merges into {target}, but {target} has decision ' f'{by_id[target].get("decision")!r}. Merge only into an accepted candidate, ' f'or accept {cid} instead.') elif e.get('merge_into') not in (None, ''): warnings.append(f'{cid}: "merge_into" is set but the decision is {decision!r}; it is ignored.') if decision in ('reject', 'skip') and exclude: warnings.append(f'{cid}: "exclude" is ignored because the decision is {decision!r}.') if decision in ('accept', 'merge') and len(exclude) > len(members) / 2: warnings.append(f'{cid}: more than half of its scenes are excluded. Check that the situation ' f'named is what this candidate is mostly about.') if errors: return errors, warnings, None groups = collections.OrderedDict((cid, [cid]) for cid, e in by_id.items() if e['decision'] == 'accept') for cid, e in by_id.items(): if e['decision'] == 'merge': groups[e['merge_into']].append(cid) situations = [] for cid, sources in groups.items(): members, seen = [], set() for src in sources: excluded = set(by_id[src].get('exclude', [])) 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(sources[1:])} merged in)' if len(sources) > 1 else '' errors.append(f'{cid}{merged}: after exclusions {size} scenes remain from {stories} stories, the ' f'largest story holding {dominant:.0%}. A situation needs at least {MIN_MEMBERS} ' f'scenes from at least {MIN_STORIES} stories, with no story above ' f'{MAX_DOMINANT:.0%}. Exclude fewer scenes, merge another candidate into it, ' f'or reject it.') continue e = by_id[cid] situations.append({ 'id': f'S{len(situations) + 1:02d}', 'from': sources, 'situation': _text(e['situation']), 'actor': _text(e['actor']), 'counterpart': _text(e['counterpart']), 'stakes': _text(e.get('stakes')), 'notes': [_text(by_id[s].get('notes')) for s in sources if _text(by_id[s].get('notes'))], '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': candidates['run'], 'counts': {'candidates': len(cands), 'situations': len(situations), 'accepted': decided['accept'], 'merged': decided['merge'], 'rejected': decided['reject'], 'skipped': decided['skip']}, 'situations': situations, 'rejected': [cid for cid, e in by_id.items() if e['decision'] == 'reject'], 'skipped': [cid for cid, e in by_id.items() if e['decision'] == 'skip'], } return errors, warnings, result