Source: github.com/f/prompts.chat · Contributor: f
---
name: event-seating-chart-planner
description: Builds a seating chart for weddings, dinners and team events from your tables and guest list - keeps couples and families together, honours sit-with and keep-apart requests and head-table seats, puts mobility needs at accessible tables, seats nobody among strangers and fills tables by group. Flags misspelled names, duplicates, conflicting requests and missing seats; prints place cards and meal counts. Includes an offline stdlib Python planner.
---
# Event Seating Chart Planner
You help hosts turn a guest list into a seating chart without the spreadsheet agony. Seating is the job everyone leaves until the last week: the divorced uncle and aunt must not share a table, grandma needs a step-free spot by the door, the plus-one knows nobody, two RSVPs are still missing, the caterer wants vegan and child meals per table, and every change breaks something else. You collect the tables and the guest list, check the list for problems first, build a chart that meets the hard requests, explain the trade-offs and produce place cards and a caterer sheet.
You work only from the information the host gives you. You do not judge family politics: when two requests conflict, say so and ask the host which one wins. Keep the guest list private - do not repeat notes about relationships beyond what the chart needs.
## Files in this skill
- `scripts/seating_plan.py` - reads an event JSON (tables with seats, accessible, kids, fixed head-table guests; rules) and a guest CSV (name, party, group, rsvp, child, needs, diet, sit_with, keep_apart), merges parties and sit-with requests, places them table by table and improves the plan by moving and swapping groups; reports SC01-SC10 and prints the chart, a caterer summary and place cards; `--csv` place-card export, `--json`, `--include-pending` (Python 3.8+ standard library only)
- `references/seating-rules-and-etiquette.md` - how to collect the list, what counts as a hard or soft request, head table and family conventions, accessibility, children, dietary needs and room layout tips
- `references/checks-and-codes.md` - every finding code, its severity and what to do, plus the input format and how the planner scores a chart
- `templates/seating-brief.md` - the summary to send the host (and the venue / caterer)
- `examples/event.json` - a wedding dinner with a head table, an accessible table, four tables of 8 and a kids table
- `examples/guests.csv` - 32 rows with families, friends, work colleagues, a declined and a pending RSVP, a duplicate row, a misspelled sit-with name, a keep-apart pair, a wheelchair user and dietary needs
- `examples/seating-chart.md` - the real planner output on the example
## Workflow
### 1. Collect the tables and the list
Ask for the venue's table plan (number of tables, seats per table, which are step-free or near the exit, a kids table, the head table and who sits there). Ask for the guest list with RSVP, party (people who arrive together: couple, family), group (how they know the hosts: bride family, work, school friends), children, mobility or other needs, meal choice, and any "must sit with" or "keep apart" requests. Put them in `examples/event.json` and `examples/guests.csv` form.
### 2. Run the planner
```
python3 scripts/seating_plan.py examples/event.json examples/guests.csv
python3 scripts/seating_plan.py event.json guests.csv --include-pending
python3 scripts/seating_plan.py event.json guests.csv --csv > place-cards.csv
python3 scripts/seating_plan.py event.json guests.csv --json
python3 scripts/seating_plan.py --self-test
```
Exit 0 = all hard requests met, 1 = a hard request failed or the list has a high finding, 2 = bad input. Without a shell, seat fixed guests first, then the accessibility needs, then the largest groups, and check every keep-apart pair at the end.
### 3. Read the result
- High: SC01 not enough seats (or a group that fits nowhere), SC02 conflicting or broken keep-apart requests, SC03 names in requests or fixed seats that are not on the attending list (spelling suggestions shown).
- Medium: SC04 a guest at a table with nobody they know, SC05 a mobility need at a non-accessible table, SC08 duplicate rows, SC09 parties or requests too big for one table, SC10 a child without an adult from their party.
- Low/info: half-empty or empty tables (SC06), pending RSVPs seated provisionally (SC07).
### 4. Adjust with the host
Fix the list problems first (spelling, duplicates, RSVPs) and re-run. Then walk the host through each table: who is there and why, the lonely guests and the trade-offs. Make manual moves the host asks for and re-run to confirm the keep-apart pairs still hold. Fill `templates/seating-brief.md`.
### 5. Deliver
Return the chart per table, the open questions, the place-card list A-Z, the meal counts per table and in total, and the special needs list for the venue (wheelchair space, high chairs, allergies).
## Example result
29 guests attend (1 declined, 1 pending left out) for 44 seats. The couple, maid of honour, best man and Zeynep (who asked to sit with Selin) fill the head table. Grandma Fatma (wheelchair) sits at accessible Table 1 with the Kaya family; Linda and Richard Moore are kept apart (Tables 2 and 3); the work colleagues share Table 4 with the bride's friends. Sam's request to sit with "Olivr Grant" is a typo of Oliver Grant, Merve Sahin is listed twice, and neighbour Kemal knows nobody at his table. The kids table is empty because children stay with their parents.
## Rules
- Hard requests (keep apart, fixed seats, accessibility) beat soft ones (groups, balance); say clearly which request was dropped and why.
- Never split a couple or a parent and a young child unless the host asks.
- Do not guess at conflicts: ask the host, and keep sensitive notes out of anything printed.
- Re-run after every manual change; the chart is only valid for the list it was made from.
- Pending RSVPs are left out unless the host says to seat them; chase them before printing place cards.
FILE:references/seating-rules-and-etiquette.md
# Seating rules and etiquette
## Collecting the list
- One row per person, including children and plus-ones (use "Guest of <name>" until you have a name).
- Party = who arrives together and must sit together (couple, parent with young children). Group = how they know the hosts; it decides who shares a table.
- Ask the hosts directly for keep-apart pairs (exes, family feuds) and keep this column out of anything printed or shared with the venue.
- RSVP status: seat only confirmed guests; set a deadline for pending ones about 3 weeks before the event, when most venues need final numbers.
## Hard vs soft requests
- Hard: keep apart, fixed seats (head table, speakers, VIPs), accessibility, a young child next to a parent, capacity.
- Soft: sit with friends, tables by group, balanced tables, mixing groups at networking events.
When a soft request breaks a hard one, the hard one wins; tell the host.
## Common conventions
- Weddings: the head table (couple, wedding party, sometimes parents) faces the room; parents and grandparents at the tables closest to it; families of both sides near the front; friends and colleagues further back. Sweetheart table = couple only.
- Family dinners and birthdays: the guest of honour in the middle of the long side, with the people they see least often nearby.
- Corporate dinners: a host from the company at every table; mix departments if networking is the goal (`mix_groups: true`), seat speakers near the stage and the exit.
- Round tables of 8-10 work best for conversation; 12+ means people only talk to their neighbours.
- Avoid a table where one guest is the only one from their group - pair singles with someone outgoing or a similar-age group.
## Accessibility and needs
- Wheelchair users: a table on a step-free route near the door and the toilets, with a chair removed at the place (mark it on the room plan).
- Hearing impairment: away from speakers and the band, facing the room.
- Older guests: away from loudspeakers and the dance floor, near the exit and toilets.
- High chairs and booster seats: tell the venue per table.
- Allergies: list name, table and allergen for the caterer; a printed card at the place for severe allergies.
## Children
- Toddlers stay with their parents. A kids table works for ages about 5-12 when it is next to the parents' tables and has an adult or sitter nearby.
- Count child meals separately (and high chairs) in the caterer summary.
## Room layout tips
- Number tables in a way guests can find (signs on every table, an alphabetical escort-card list at the entrance).
- Keep the path from the door to the accessible table clear.
- Leave 1-2 empty seats across the room for late changes; venues usually allow 1-2 extra chairs per round table, but check the fire-safety capacity.
## Final week checklist
- Final RSVPs and meal choices -> re-run the planner -> host approval.
- Send the venue: table plan, guest count per table, high chairs, wheelchair spaces.
- Send the caterer: meals per table and in total, allergies with names and tables.
- Print place cards and the escort-card list from `--csv`.
FILE:references/checks-and-codes.md
# Checks and finding codes
| Code | Severity | Check | What to do |
|---|---|---|---|
| SC01 | high | more attending guests than seats, or a party/group that fits at no table with enough free seats | add a table or seats, confirm RSVPs, split the group over neighbouring tables |
| SC02 | high | a sit-with request joins two guests who must be kept apart (request dropped); keep-apart inside one party; a keep-apart pair seated together | ask the host which request wins |
| SC03 | high | a name in sit_with / keep_apart or a fixed seat is not on the attending list (did-you-mean suggestion) | fix the spelling or add the guest |
| SC04 | medium | guest at a table with fewer than `min_known` people from their party, group or sit-with list | swap, or seat them next to a sociable guest |
| SC05 | medium | wheelchair / step-free / walker / mobility / crutches need at a table not marked accessible | swap with someone at an accessible table |
| SC06 | low: table under half full; info: empty table | balance | merge tables or remove one |
| SC07 | info | RSVP pending, seated because of `--include-pending` | chase the RSVP |
| SC08 | medium | the same name twice (seated once), row without a name | check if two different people; add an initial |
| SC09 | medium | a party larger than the largest table (split), a sit-with group that would exceed the largest table (request dropped), a party split over tables | neighbouring tables; free seats at one table |
| SC10 | medium | child at a non-kids table without an adult from their party | move next to their parents |
## Verdict
- **N hard request(s) could not be met** - any high finding.
- **all hard requests met, N soft issue(s)** - medium findings only.
- **all requests met**.
## How the planner works
1. Attending guests only (rsvp yes; pending with `--include-pending`; no/declined skipped).
2. Parties are merged (when `keep_parties_together`), then sit-with requests, unless that joins a keep-apart pair or exceeds the largest table.
3. Groups are placed in order: fixed head-table guests (only they and their sit-with partners use a table with `fixed`), mobility needs, then by group size.
4. Each group goes to the table where the chart's penalty rises least; then the planner moves single groups, whole groups and swaps pairs while the penalty falls.
5. Penalty: keep-apart pair at one table 100, mobility need at a non-accessible table 40, a guest who knows fewer than `min_known` people 12, each extra group at a table 2 (unless `mix_groups`), each extra table a group is spread over 3, an adult at the kids table 3.
The plan is deterministic: the same input gives the same chart.
## Event JSON
`event, date, rules{keep_parties_together (true), mix_groups (false), min_known (1)}, tables[{name, seats, accessible, kids, fixed[names], notes}]`.
## Guest CSV
`name, party, group, rsvp (yes/no/pending), child (yes), needs (wheelchair; high chair; nut allergy), diet (vegetarian, vegan, gluten-free, child menu ...), sit_with (names separated by ;), keep_apart (names separated by ;)`. Only `name` is required; party defaults to the name, group to "-", rsvp to yes.
FILE:templates/seating-brief.md
# Seating brief: <event> (<date>)
**Guests:** <29 attending (1 declined, 1 pending)> **Seats:** <44 at 6 tables> **Status:** <draft 2 - waiting for 1 RSVP>
## Open questions for the host
- [ ] <Sam asked to sit with "Olivr Grant" - Oliver Grant? (seated together after the fix)>
- [ ] <Merve Sahin is on the list twice - one person or two?>
- [ ] <Kemal knows nobody at Table 3 - who should he sit next to?>
- [ ] <Chris Evans: RSVP pending - seat at Table 3 if he comes>
## Tables
| Table | Who | Notes |
|---|---|---|
| <Head table> | <Elif, Daniel, Selin, Tom, Zeynep> | <facing the room> |
| <Table 1 (accessible)> | <Kaya family, Grandma Fatma> | <wheelchair place on the aisle, high chair for Ela> |
## For the venue
- Tables and seat counts: <...>
- Wheelchair places: <Table 1 x1> High chairs: <Table 1 x1>
- Remove: <kids table (empty)>
## For the caterer
| Table | Standard | Vegetarian | Vegan | Gluten-free | Child | Other |
|---|---|---|---|---|---|---|
| <Head table> | <3> | <2> | <0> | <0> | <0> | <-> |
| **Total** | <18> | <3> | <2> | <1> | <3> | <pescatarian 1, soft food 1> |
Allergies: <Max Hill (Table 2) - nut allergy>
## Place cards
<attach place-cards.csv from `seating_plan.py --csv`>
FILE:examples/event.json
{
"event": "Elif and Daniel's wedding dinner",
"date": "2027-06-12",
"rules": {"keep_parties_together": true, "mix_groups": false, "min_known": 1},
"tables": [
{"name": "Head table", "seats": 6, "fixed": ["Elif Kaya", "Daniel Moore"], "notes": "facing the room"},
{"name": "Table 1", "seats": 8, "accessible": true, "notes": "next to the terrace door, step-free"},
{"name": "Table 2", "seats": 8},
{"name": "Table 3", "seats": 8},
{"name": "Table 4", "seats": 8},
{"name": "Kids table", "seats": 6, "kids": true, "notes": "near Table 2, crayons and placemats"}
]
}
FILE:examples/guests.csv
name,party,group,rsvp,child,needs,diet,sit_with,keep_apart
Elif Kaya,Couple,couple,yes,,,vegetarian,,
Daniel Moore,Couple,couple,yes,,,standard,,
Selin Kaya,Maid of honour,bride friends,yes,,,standard,Elif Kaya,
Tom Moore,Best man,groom family,yes,,,standard,Daniel Moore,
Ayse Kaya,Kaya parents,bride family,yes,,,standard,,
Mehmet Kaya,Kaya parents,bride family,yes,,,standard,,
Fatma Yilmaz,Grandma Fatma,bride family,yes,,wheelchair,soft food,Ayse Kaya,
Can Kaya,Can family,bride family,yes,,,standard,,
Deniz Kaya,Can family,bride family,yes,,,vegan,,
Ela Kaya,Can family,bride family,yes,yes,high chair,child menu,,
Linda Moore,Linda,groom family,yes,,,gluten-free,,Richard Moore
Richard Moore,Richard and Jo,groom family,yes,,,standard,,Linda Moore
Jo Parker,Richard and Jo,groom family,yes,,,standard,,
Sarah Moore,Sarah family,groom family,yes,,,standard,,
James Hill,Sarah family,groom family,yes,,,standard,,
Lily Hill,Sarah family,groom family,yes,yes,,child menu,,
Max Hill,Sarah family,groom family,yes,yes,nut allergy,child menu,,
Zeynep Arslan,Zeynep,bride friends,yes,,,vegetarian,Selin Kaya,
Burak Demir,Burak and Ece,bride friends,yes,,,standard,,
Ece Demir,Burak and Ece,bride friends,yes,,,standard,,
Merve Sahin,Merve,bride friends,yes,,,vegan,,
Oliver Grant,Oliver and Priya,groom friends,yes,,,standard,,
Priya Grant,Oliver and Priya,groom friends,yes,,,vegetarian,,
Sam Turner,Sam,groom friends,yes,,,standard,Olivr Grant,
Chris Evans,Chris,groom friends,pending,,,standard,,
Nina Patel,Nina,work,yes,,,standard,,
Leo Fischer,Leo and Mia,work,yes,,,standard,,
Mia Fischer,Leo and Mia,work,yes,,,pescatarian,,
Hannah Lee,Hannah,work,yes,,,standard,,
Peter Novak,Peter,work,no,,,standard,,
Kemal Ozturk,Kemal,neighbours,yes,,,standard,,
Merve Sahin,Merve,bride friends,yes,,,vegan,,
FILE:examples/seating-chart.md
# Seating chart: Elif and Daniel's wedding dinner (2027-06-12)
29 attending guest(s) (1 declined; 1 pending left out), 44 seat(s) at 6 table(s). Source: examples/guests.csv.
**Verdict: 1 hard request(s) could not be met - see findings**
Findings: 1 high, 2 medium, 0 low, 1 info.
## Tables
| Table | Seats | Guests | Groups | Meals |
|---|---|---|---|---|
| Head table [fixed] | 5/6 | Tom Moore, Elif Kaya, Daniel Moore, Selin Kaya, Zeynep Arslan | bride friends 2, couple 2, groom family 1 | standard 3, vegetarian 2 |
| Table 1 [accessible] | 6/8 | Can Kaya, Deniz Kaya, Ela Kaya (child), Fatma Yilmaz *, Ayse Kaya, Mehmet Kaya | bride family 6 | child menu (child) 1, soft food 1, standard 3, vegan 1 |
| Table 2 | 5/8 | Linda Moore, Sarah Moore, James Hill, Lily Hill (child), Max Hill (child) | groom family 5 | child menu (child) 2, gluten-free 1, standard 2 |
| Table 3 | 6/8 | Kemal Ozturk, Oliver Grant, Priya Grant, Richard Moore, Jo Parker, Sam Turner | groom family 2, groom friends 3, neighbours 1 | standard 5, vegetarian 1 |
| Table 4 | 7/8 | Burak Demir, Ece Demir, Hannah Lee, Leo Fischer, Mia Fischer, Merve Sahin, Nina Patel | bride friends 3, work 4 | pescatarian 1, standard 5, vegan 1 |
| Kids table [kids] | 0/6 | - | - | - |
`*` = mobility need (seat on the aisle side, nearest the door).
## Findings
| Sev | Code | Who | Finding | Fix |
|---|---|---|---|---|
| high | SC03 | Sam Turner | sit_with 'Olivr Grant' is not on the (attending) guest list - did you mean Oliver Grant? | fix the spelling |
| medium | SC04 | Kemal Ozturk | at Table 3 without anyone from their party or group (neighbours) | seat them next to the most sociable guest there, or swap |
| medium | SC08 | Merve Sahin | listed twice (lines 22 and 33) - seated once | check whether these are two different people; add a surname or initial |
| info | SC06 | Kids table | empty table | remove it from the room plan |
## Caterer summary
| Meal | Count |
|---|---|
| child menu (child) | 3 |
| gluten-free | 1 |
| pescatarian | 1 |
| soft food | 1 |
| standard | 18 |
| vegan | 2 |
| vegetarian | 3 |
Special needs: Ela Kaya (Table 1) - high chair; Fatma Yilmaz (Table 1) - wheelchair; Max Hill (Table 2) - nut allergy.
## Place cards (A-Z)
| Guest | Table |
|---|---|
| Ayse Kaya | Table 1 |
| Burak Demir | Table 4 |
| Can Kaya | Table 1 |
| Daniel Moore | Head table |
| Deniz Kaya | Table 1 |
| Ece Demir | Table 4 |
| Ela Kaya | Table 1 |
| Elif Kaya | Head table |
| Fatma Yilmaz | Table 1 |
| Hannah Lee | Table 4 |
| James Hill | Table 2 |
| Jo Parker | Table 3 |
| Kemal Ozturk | Table 3 |
| Leo Fischer | Table 4 |
| Lily Hill | Table 2 |
| Linda Moore | Table 2 |
| Max Hill | Table 2 |
| Mehmet Kaya | Table 1 |
| Merve Sahin | Table 4 |
| Mia Fischer | Table 4 |
| Nina Patel | Table 4 |
| Oliver Grant | Table 3 |
| Priya Grant | Table 3 |
| Richard Moore | Table 3 |
| Sam Turner | Table 3 |
| Sarah Moore | Table 2 |
| Selin Kaya | Head table |
| Tom Moore | Head table |
| Zeynep Arslan | Head table |
The chart is a starting point: move people by hand where you know better, then re-run to check the requests still hold.
FILE:scripts/seating_plan.py
#!/usr/bin/env python3
"""Event seating chart planner (stdlib only, Python 3.8+).
Reads an event file (tables with seat counts, accessible / kids / fixed head-table seats, rules)
and a guest list CSV (name, party, group, RSVP, child, needs, diet, sit_with, keep_apart) and
builds a seating chart: parties stay together, "sit with" requests share a table, "keep apart"
pairs never share one, guests with mobility needs go to accessible tables, nobody is seated
without someone they know, and tables are filled by group where possible. Checks the list first
(unknown or misspelled names, duplicates, impossible or conflicting requests, not enough seats)
and prints the chart, place cards and a per-table meal count for the caterer.
Usage:
seating_plan.py EVENT.json GUESTS.csv [--include-pending] [--csv] [--json]
seating_plan.py --self-test
Exit: 0 = all hard requests met, 1 = a hard request could not be met (or a high finding), 2 = bad input.
"""
import argparse, csv, difflib, io, json, sys
SEV = {"high": 0, "medium": 1, "low": 2, "info": 3}
EVENT_KEYS = {"event", "date", "tables", "rules"}
TABLE_KEYS = {"name", "seats", "accessible", "kids", "fixed", "notes"}
RULE_KEYS = {"keep_parties_together", "mix_groups", "min_known"}
MOBILITY = ("wheelchair", "step-free", "walker", "mobility", "crutches")
W_APART, W_ACCESS, W_LONELY, W_GROUP, W_SPREAD = 100, 40, 12, 2, 3
class InputError(Exception):
pass
def norm(s):
return " ".join(str(s or "").split()).lower()
def split_list(s):
return [x.strip() for x in str(s or "").replace(",", ";").split(";") if x.strip()]
# ---------------------------------------------------------------- load
def load_event(cfg):
if not isinstance(cfg, dict) or not isinstance(cfg.get("tables"), list) or not cfg["tables"]:
raise InputError("event file must be a JSON object with a non-empty \"tables\" list")
bad = set(cfg) - EVENT_KEYS
if bad:
raise InputError("unknown key(s) in event file: %s" % ", ".join(sorted(bad)))
rules = cfg.get("rules") or {}
badr = set(rules) - RULE_KEYS
if badr:
raise InputError("unknown rule(s): %s" % ", ".join(sorted(badr)))
tables, names = [], set()
for i, t in enumerate(cfg["tables"], 1):
badt = set(t) - TABLE_KEYS
if badt:
raise InputError("table #%d: unknown key(s) %s" % (i, ", ".join(sorted(badt))))
name = str(t.get("name") or "Table %d" % i)
if name in names:
raise InputError("table name %r used twice" % name)
names.add(name)
seats = t.get("seats")
if not isinstance(seats, int) or seats < 1:
raise InputError("table %s: seats must be a positive whole number, got %r" % (name, seats))
tables.append({"name": name, "seats": seats, "accessible": bool(t.get("accessible")), "kids": bool(t.get("kids")),
"fixed": [str(x) for x in t.get("fixed") or []], "notes": t.get("notes", "")})
return {"event": cfg.get("event", "Event"), "date": cfg.get("date", ""), "tables": tables,
"together": rules.get("keep_parties_together", True), "mix": bool(rules.get("mix_groups", False)),
"min_known": int(rules.get("min_known", 1))}
def load_guests(text, include_pending=False):
rows = list(csv.DictReader(io.StringIO(text)))
if not rows:
raise InputError("guest list is empty")
cols = {c.strip().lower() for c in rows[0].keys() if c}
if "name" not in cols:
raise InputError("guest list needs a 'name' column")
guests, skipped, F, seen = [], {"no": 0, "pending": 0}, [], {}
for ln, r in enumerate(rows, 2):
r = {(k or "").strip().lower(): (v or "").strip() for k, v in r.items()}
name = " ".join(r.get("name", "").split())
if not name:
F.append(("medium", "SC08", "line %d" % ln, "row without a name skipped", "fill in the name or delete the row"))
continue
rsvp = norm(r.get("rsvp") or "yes")
if rsvp in ("no", "declined", "n"):
skipped["no"] += 1
continue
if rsvp in ("pending", "maybe", "?", "invited", ""):
if not include_pending:
skipped["pending"] += 1
continue
if norm(name) in seen:
F.append(("medium", "SC08", name, "listed twice (lines %d and %d) - seated once" % (seen[norm(name)], ln), "check whether these are two different people; add a surname or initial"))
continue
seen[norm(name)] = ln
needs = [norm(x) for x in split_list(r.get("needs"))]
guests.append({"name": name, "party": r.get("party") or name, "group": r.get("group") or "-",
"child": norm(r.get("child")) in ("yes", "y", "true", "1", "child"),
"needs": needs, "mobility": any(any(m in n for m in MOBILITY) for n in needs),
"diet": norm(r.get("diet")) or "standard", "sit_with": split_list(r.get("sit_with")),
"keep_apart": split_list(r.get("keep_apart")), "line": ln, "pending": rsvp != "yes" and rsvp not in ("y", "accepted")})
return guests, skipped, F
# ---------------------------------------------------------------- plan
class DSU:
def __init__(self, n):
self.p = list(range(n))
def find(self, a):
while self.p[a] != a:
self.p[a] = self.p[self.p[a]]
a = self.p[a]
return a
def union(self, a, b):
self.p[self.find(a)] = self.find(b)
def plan(ev, guests, F=None):
F = list(F or [])
add = lambda sev, code, who, msg, fix: F.append((sev, code, who, msg, fix))
idx = {norm(g["name"]): i for i, g in enumerate(guests)}
allnames = [g["name"] for g in guests]
def resolve(who, ref, kind):
j = idx.get(norm(ref))
if j is None:
close = difflib.get_close_matches(ref, allnames, 1, 0.7)
add("high", "SC03", who, "%s %r is not on the (attending) guest list%s" % (kind, ref, " - did you mean %s?" % close[0] if close else ""),
"fix the spelling" if close else "add the guest or remove the request")
return j
n = len(guests)
seats = sum(t["seats"] for t in ev["tables"])
maxt = max(t["seats"] for t in ev["tables"])
if n > seats:
add("high", "SC01", "-", "%d guests but only %d seats (%d short)" % (n, seats, n - seats), "add a table or seats, or confirm the RSVPs")
dsu = DSU(n)
party_of = {}
for i, g in enumerate(guests):
party_of.setdefault(norm(g["party"]), []).append(i)
if ev["together"]:
for p, mem in party_of.items():
if len(mem) > maxt:
add("medium", "SC09", guests[mem[0]]["party"], "party of %d is larger than the largest table (%d) - it is split" % (len(mem), maxt), "seat the party on neighbouring tables")
for a, b in zip(mem, mem[1:]):
if (mem.index(b)) % maxt:
dsu.union(a, b)
else:
for a, b in zip(mem, mem[1:]):
dsu.union(a, b)
apart = set()
for i, g in enumerate(guests):
for ref in g["keep_apart"]:
j = resolve(g["name"], ref, "keep_apart")
if j is not None and j != i:
apart.add(tuple(sorted((i, j))))
# sit_with links, refusing those that would exceed the largest table or join a keep-apart pair
for i, g in enumerate(guests):
for ref in g["sit_with"]:
j = resolve(g["name"], ref, "sit_with")
if j is None or j == i:
continue
a, b = dsu.find(i), dsu.find(j)
if a == b:
continue
size = sum(1 for k in range(n) if dsu.find(k) in (a, b))
clash = [p for p in apart if {dsu.find(p[0]), dsu.find(p[1])} == {a, b}]
if clash:
x, y = clash[0]
add("high", "SC02", g["name"], "asks to sit with %s, but %s and %s must be kept apart - the request is dropped" % (guests[j]["name"], guests[x]["name"], guests[y]["name"]),
"ask the host which request wins")
continue
if size > maxt:
add("medium", "SC09", g["name"], "sitting with %s would make a group of %d (largest table %d) - request dropped" % (guests[j]["name"], size, maxt), "seat them on neighbouring tables")
continue
dsu.union(i, j)
for x, y in sorted(apart):
if dsu.find(x) == dsu.find(y):
add("high", "SC02", guests[x]["name"], "must be kept apart from %s but they are in the same party - cannot be met" % guests[y]["name"], "change the party or the request")
links = set()
for i, g in enumerate(guests):
for ref in g["sit_with"]:
j = idx.get(norm(ref))
if j is not None:
links.add((i, j)); links.add((j, i))
def knows(a, b):
ga, gb = guests[a], guests[b]
return (norm(ga["party"]) == norm(gb["party"]) or (ga["group"] == gb["group"] and ga["group"] != "-") or (a, b) in links)
clusters = {}
for i in range(n):
clusters.setdefault(dsu.find(i), []).append(i)
cl = list(clusters.values())
T = ev["tables"]
fixed_at = {}
for ti, t in enumerate(T):
for nm in t["fixed"]:
j = idx.get(norm(nm))
if j is None:
close = difflib.get_close_matches(nm, allnames, 1, 0.7)
add("high", "SC03", t["name"], "fixed seat for %r who is not on the attending list%s" % (nm, " - did you mean %s?" % close[0] if close else ""), "fix the name in the event file")
else:
fixed_at[j] = ti
assign = {}
load = [0] * len(T)
reserved = {ti for ti, t in enumerate(T) if t["fixed"]}
def pen_table(ti, members):
p = 0
mem = set(members)
for x, y in apart:
if x in mem and y in mem:
p += W_APART
groups = {}
for m in members:
g = guests[m]
if g["mobility"] and not T[ti]["accessible"]:
p += W_ACCESS
groups[g["group"]] = groups.get(g["group"], 0) + 1
if not ev["mix"]:
p += W_GROUP * max(0, len(groups) - 1)
for m in members:
g = guests[m]
known = sum(1 for o in members if o != m and knows(m, o))
if known < ev["min_known"]:
p += W_LONELY
if T[ti]["kids"]:
p += sum(3 for m in members if not guests[m]["child"])
return p
def members_of(ti):
return [m for c, t in assign.items() if t == ti for m in cl[c]]
def total():
p = sum(pen_table(ti, members_of(ti)) for ti in range(len(T)))
spread = {}
for c, ti in assign.items():
for m in cl[c]:
if guests[m]["group"] != "-":
spread.setdefault(guests[m]["group"], set()).add(ti)
return p + W_SPREAD * sum(len(v) - 1 for v in spread.values())
gsize = {}
for g in guests:
gsize[g["group"]] = gsize.get(g["group"], 0) + 1
order = sorted(range(len(cl)), key=lambda c: (-any(i in fixed_at for i in cl[c]), -any(guests[i]["mobility"] for i in cl[c]),
-gsize[guests[cl[c][0]]["group"]], guests[cl[c][0]]["group"], -len(cl[c]), cl[c][0]))
unseated = []
for c in order:
size = len(cl[c])
fx = [fixed_at[i] for i in cl[c] if i in fixed_at]
cands = [fx[0]] if fx else range(len(T))
best = None
for ti in cands:
if load[ti] + size > T[ti]["seats"] or (T[ti]["fixed"] and not fx and ti in reserved):
continue
assign[c] = ti
delta = total()
del assign[c]
key = (delta, -(load[ti] > 0), ti)
if best is None or key < best[0]:
best = (key, ti)
if best is None:
unseated.append(c)
continue
assign[c] = best[1]
load[best[1]] += size
# local search: move or swap clusters while it lowers the penalty (deterministic)
fixed_cl = {c for c in assign if any(i in fixed_at for i in cl[c])}
improved, rounds = True, 0
while improved and rounds < 50:
improved, rounds = False, rounds + 1
cur = total()
for c in sorted(assign):
if c in fixed_cl:
continue
for ti in range(len(T)):
if ti == assign[c] or load[ti] + len(cl[c]) > T[ti]["seats"] or ti in reserved:
continue
old = assign[c]
assign[c] = ti
new = total()
if new < cur:
load[old] -= len(cl[c]); load[ti] += len(cl[c]); cur = new; improved = True
else:
assign[c] = old
# move every cluster of one group (from all tables) to one table
for gname in sorted({guests[cl[c][0]]["group"] for c in assign if c not in fixed_cl}):
block = [c for c in assign if c not in fixed_cl and guests[cl[c][0]]["group"] == gname]
for b in range(len(T)):
if b in reserved:
continue
old = {c: assign[c] for c in block}
moving = sum(len(cl[c]) for c in block if old[c] != b)
if not moving or load[b] + moving > T[b]["seats"]:
continue
for c in block:
assign[c] = b
new = total()
if new < cur:
for c in block:
if old[c] != b:
load[old[c]] -= len(cl[c]); load[b] += len(cl[c])
cur = new; improved = True
break
for c in block:
assign[c] = old[c]
# move a whole group block (all clusters of one group at one table) to another table
for a in range(len(T)):
groups_here = sorted({guests[cl[c][0]]["group"] for c, x in assign.items() if x == a and c not in fixed_cl})
for gname in groups_here:
block = [c for c, x in assign.items() if x == a and c not in fixed_cl and guests[cl[c][0]]["group"] == gname]
bsize = sum(len(cl[c]) for c in block)
for b in range(len(T)):
if b == a or b in reserved or load[b] + bsize > T[b]["seats"]:
continue
for c in block:
assign[c] = b
new = total()
if new < cur:
load[a] -= bsize; load[b] += bsize; cur = new; improved = True
break
for c in block:
assign[c] = a
for c in sorted(assign):
for d in sorted(assign):
if d <= c or c in fixed_cl or d in fixed_cl or assign[c] == assign[d] or assign[c] in reserved or assign[d] in reserved:
continue
a, b = assign[c], assign[d]
if load[a] - len(cl[c]) + len(cl[d]) > T[a]["seats"] or load[b] - len(cl[d]) + len(cl[c]) > T[b]["seats"]:
continue
assign[c], assign[d] = b, a
new = total()
if new < cur:
load[a] += len(cl[d]) - len(cl[c]); load[b] += len(cl[c]) - len(cl[d]); cur = new; improved = True
else:
assign[c], assign[d] = a, b
seat = {}
for c, ti in assign.items():
for i in cl[c]:
seat[i] = ti
for c in unseated:
add("high", "SC01", ", ".join(guests[i]["name"] for i in cl[c]), "no table with %d free seats together - not seated" % len(cl[c]), "add seats or split the group across neighbouring tables")
# findings on the result
for x, y in sorted(apart):
if x in seat and y in seat and seat[x] == seat[y]:
add("high", "SC02", guests[x]["name"], "seated at %s together with %s although they must be kept apart" % (T[seat[x]]["name"], guests[y]["name"]), "move one of them by hand")
for i, g in enumerate(guests):
if i not in seat:
continue
ti = seat[i]
if g["mobility"] and not T[ti]["accessible"]:
add("medium", "SC05", g["name"], "needs %s but %s is not marked accessible" % ("/".join(g["needs"]), T[ti]["name"]), "swap with a guest at an accessible table")
mem = [m for m, t in seat.items() if t == ti and m != i]
known = [m for m in mem if knows(i, m)]
if len(known) < ev["min_known"]:
add("medium", "SC04", g["name"], "at %s without anyone from their party or group (%s)" % (T[ti]["name"], g["group"]), "seat them next to the most sociable guest there, or swap")
if g["child"] and not T[ti]["kids"] and not any(norm(guests[m]["party"]) == norm(g["party"]) and not guests[m]["child"] for m in mem):
add("medium", "SC10", g["name"], "child seated without an adult from their party", "move next to their parents or to the kids table")
if g["pending"]:
add("info", "SC07", g["name"], "RSVP still pending - seated provisionally", "chase the RSVP before printing place cards")
for ti, t in enumerate(T):
used = sum(1 for i in seat if seat[i] == ti)
if 0 < used < t["seats"] / 2:
add("low", "SC06", t["name"], "only %d of %d seats used" % (used, t["seats"]), "merge with another table or take it out")
if used == 0:
add("info", "SC06", t["name"], "empty table", "remove it from the room plan")
for p, mem in party_of.items():
ts = {seat[i] for i in mem if i in seat}
if len(ts) > 1 and len(mem) <= maxt:
add("medium", "SC09", guests[mem[0]]["party"], "party split over %s" % ", ".join(T[x]["name"] for x in sorted(ts)), "free seats at one table and move them together")
F.sort(key=lambda x: (SEV[x[0]], x[1], x[2]))
return {"seat": seat, "tables": T, "guests": guests, "findings": F, "penalty": total(), "seats": seats}
# ---------------------------------------------------------------- output
def report(ev, r, skipped, src):
G, T, seat = r["guests"], r["tables"], r["seat"]
c = {s: 0 for s in SEV}
for f in r["findings"]:
c[f[0]] += 1
hard = [f for f in r["findings"] if f[0] == "high"]
out = ["# Seating chart: %s%s" % (ev["event"], (" (%s)" % ev["date"]) if ev["date"] else ""), "",
"%d attending guest(s) (%d declined; %s), %d seat(s) at %d table(s). Source: %s." % (
len(G), skipped["no"], ("%d pending seated provisionally" % sum(1 for g in G if g["pending"])) if any(g["pending"] for g in G)
else "%d pending left out" % skipped["pending"], r["seats"], len(T), src), ""]
v = ("%d hard request(s) could not be met - see findings" % len(hard)) if hard else ("all hard requests met, %d soft issue(s) to look at" % c["medium"] if c["medium"] else "all requests met")
out += ["**Verdict: %s**" % v, "", "Findings: %d high, %d medium, %d low, %d info." % (c["high"], c["medium"], c["low"], c["info"]), ""]
out += ["## Tables", "", "| Table | Seats | Guests | Groups | Meals |", "|---|---|---|---|---|"]
for ti, t in enumerate(T):
mem = sorted((i for i in seat if seat[i] == ti), key=lambda i: (norm(G[i]["party"]), G[i]["child"], G[i]["line"]))
tags = [x for x in ("accessible" if t["accessible"] else "", "kids" if t["kids"] else "", "fixed" if t["fixed"] else "") if x]
names = ", ".join(G[i]["name"] + (" (child)" if G[i]["child"] else "") + (" *" if G[i]["mobility"] else "") for i in mem) or "-"
groups = {}
meals = {}
for i in mem:
groups[G[i]["group"]] = groups.get(G[i]["group"], 0) + 1
k = G[i]["diet"] + (" (child)" if G[i]["child"] else "")
meals[k] = meals.get(k, 0) + 1
out.append("| %s%s | %d/%d | %s | %s | %s |" % (t["name"], " [%s]" % ", ".join(tags) if tags else "", len(mem), t["seats"], names,
", ".join("%s %d" % kv for kv in sorted(groups.items())) or "-", ", ".join("%s %d" % kv for kv in sorted(meals.items())) or "-"))
out += ["", "`*` = mobility need (seat on the aisle side, nearest the door).", ""]
out += ["## Findings", "", "| Sev | Code | Who | Finding | Fix |", "|---|---|---|---|---|"]
for s, code, who, msg, fix in r["findings"]:
out.append("| %s | %s | %s | %s | %s |" % (s, code, who, msg.replace("|", "\\|"), fix.replace("|", "\\|")))
meals = {}
for i in seat:
k = G[i]["diet"] + (" (child)" if G[i]["child"] else "")
meals[k] = meals.get(k, 0) + 1
out += ["", "## Caterer summary", "", "| Meal | Count |", "|---|---|"]
for k, v_ in sorted(meals.items()):
out.append("| %s | %d |" % (k, v_))
notes = [(G[i]["name"], T[seat[i]]["name"], ", ".join(x for x in G[i]["needs"])) for i in sorted(seat, key=lambda i: G[i]["name"]) if G[i]["needs"]]
if notes:
out += ["", "Special needs: " + "; ".join("%s (%s) - %s" % n for n in notes) + "."]
out += ["", "## Place cards (A-Z)", "", "| Guest | Table |", "|---|---|"]
for i in sorted(seat, key=lambda i: norm(G[i]["name"])):
out.append("| %s | %s |" % (G[i]["name"], T[seat[i]]["name"]))
out += ["", "The chart is a starting point: move people by hand where you know better, then re-run to check the requests still hold."]
return "\n".join(out) + "\n"
def run(event_path, guests_path, include_pending=False):
try:
ev = load_event(json.load(open(event_path, encoding="utf-8")))
text = open(guests_path, encoding="utf-8-sig").read()
except (OSError, ValueError) as e:
raise InputError("cannot read input: %s" % e)
guests, skipped, F = load_guests(text, include_pending)
return ev, plan(ev, guests, F), skipped
# ---------------------------------------------------------------- self-test
def self_test():
tests = []
def t(name, cond):
tests.append((name, bool(cond)))
H = "name,party,group,rsvp,child,needs,diet,sit_with,keep_apart\n"
def P(tables, rows, pending=False, **rules):
ev = load_event({"tables": tables, "rules": rules})
g, sk, F = load_guests(H + rows, pending)
return plan(ev, g, F), g
def codes(r):
return [(f[0], f[1]) for f in r["findings"]]
def tab(r, g, name):
i = [k for k, x in enumerate(g) if x["name"] == name][0]
return r["seat"].get(i)
T2 = [{"name": "A", "seats": 4}, {"name": "B", "seats": 4}]
rows = "Ann,P1,work,yes\nBob,P1,work,yes\nCid,P2,family,yes\nDee,P2,family,yes\nEve,P3,work,yes\nFay,P4,family,yes\n"
r, g = P(T2, rows)
t("parties stay together", tab(r, g, "Ann") == tab(r, g, "Bob") and tab(r, g, "Cid") == tab(r, g, "Dee"))
t("groups fill tables", tab(r, g, "Eve") == tab(r, g, "Ann") and tab(r, g, "Fay") == tab(r, g, "Cid"))
t("clean plan has no high/medium", not [f for f in r["findings"] if f[0] in ("high", "medium")])
r, g = P(T2, "Ann,P1,work,yes,,,,,Bob\nBob,P2,work,yes\nCid,P3,work,yes\nDee,P4,work,yes\n")
t("keep_apart respected", tab(r, g, "Ann") != tab(r, g, "Bob") and ("high", "SC02") not in codes(r))
r, g = P(T2, "Ann,P1,work,yes,,,,Dee\nBob,P2,family,yes\nCid,P3,family,yes\nDee,P4,friends,yes\n")
t("sit_with respected", tab(r, g, "Ann") == tab(r, g, "Dee"))
r, g = P(T2, "Ann,P1,work,yes,,,,Bob,Bob\nBob,P2,work,yes\n")
t("sit_with vs keep_apart conflict -> SC02 high", ("high", "SC02") in codes(r))
r, g = P(T2, "Ann,P1,work,yes,,,,,Bob\nBob,P1,work,yes\n")
t("keep apart inside one party -> SC02", ("high", "SC02") in codes(r))
r, g = P(T2, "Ann,P1,work,yes,,,,Bobb\nBob,P2,work,yes\n")
t("misspelled name -> SC03 with suggestion", any(f[1] == "SC03" and "did you mean Bob" in f[3] for f in r["findings"]))
r, g = P([{"name": "A", "seats": 2}], "Ann,P1,w,yes\nBob,P2,w,yes\nCid,P3,w,yes\n")
t("not enough seats -> SC01 high", ("high", "SC01") in codes(r))
r, g = P(T2, "Ann,P1,w,yes\nAnn,P1,w,yes\nBob,P1,w,yes\n")
t("duplicate -> SC08 and seated once", ("medium", "SC08") in codes(r) and len(g) == 2)
r, g = P(T2, "Ann,P1,w,no\nBob,P2,w,pending\nCid,P3,w,yes\nDee,P3,w,yes\n")
t("declined and pending excluded", len(g) == 2)
r, g = P(T2, "Bob,P2,w,pending\nCid,P3,w,yes\n", pending=True)
t("--include-pending seats pending -> SC07", len(g) == 2 and ("info", "SC07") in codes(r))
r, g = P([{"name": "A", "seats": 4}, {"name": "B", "seats": 4, "accessible": True}], "Ann,P1,w,yes\nBob,P2,x,yes,,wheelchair\n")
t("wheelchair -> accessible table", tab(r, g, "Bob") == 1)
r, g = P([{"name": "A", "seats": 4}, {"name": "B", "seats": 4}], "Ann,P1,w,yes\nBob,P2,x,yes,,wheelchair\n")
t("no accessible table -> SC05", ("medium", "SC05") in codes(r))
r, g = P(T2, "Ann,P1,work,yes\nBob,P1,work,yes\nZed,P9,gym,yes\n")
t("lonely guest -> SC04", ("medium", "SC04") in codes(r))
r, g = P([{"name": "Head", "seats": 4, "fixed": ["Bride", "Groom"]}, {"name": "B", "seats": 4}], "Bride,C,couple,yes\nGroom,C,couple,yes\nBest,Best,groom family,yes,,,,Groom\n")
t("sit_with partner counts as someone they know", ("medium", "SC04") not in codes(r) and tab(r, g, "Best") == 0)
r, g = P([{"name": "A", "seats": 4}, {"name": "B", "seats": 4}, {"name": "C", "seats": 4}],
"A1,a1,fam,yes\nA2,a1,fam,yes\nA3,a1,fam,yes\nW1,w1,work,yes\nB1,b1,fam,yes\nB2,b1,fam,yes\nB3,b1,fam,yes\nW2,w2,work,yes\n")
t("group split by greedy is pulled together", tab(r, g, "W1") == tab(r, g, "W2"))
r, g = P([{"name": "A", "seats": 3}, {"name": "B", "seats": 3}], "A1,Big,w,yes\nA2,Big,w,yes\nA3,Big,w,yes\nA4,Big,w,yes\n")
t("party bigger than a table is split -> SC09", ("medium", "SC09") in codes(r) and len(r["seat"]) == 4)
r, g = P([{"name": "Head", "seats": 2, "fixed": ["Cid"]}, {"name": "B", "seats": 4}], "Ann,P1,w,yes\nBob,P1,w,yes\nCid,P3,w,yes\n")
t("fixed seat honoured", tab(r, g, "Cid") == 0)
r, g = P([{"name": "Head", "seats": 2, "fixed": ["Cyd"]}, {"name": "B", "seats": 4}], "Cid,P3,w,yes\n")
t("fixed seat typo -> SC03", ("high", "SC03") in codes(r))
r, g = P([{"name": "A", "seats": 10}, {"name": "B", "seats": 10}], "Ann,P1,w,yes\nBob,P1,w,yes\n")
t("half-empty and empty tables -> SC06", ("low", "SC06") in codes(r) and ("info", "SC06") in codes(r))
r, g = P([{"name": "A", "seats": 4}, {"name": "Kids", "seats": 4, "kids": True}], "Mum,F,fam,yes\nTim,F,fam,yes,yes\nLia,G,fam,yes,yes\nDad,G,fam,yes\n")
t("children stay with their party by default", tab(r, g, "Tim") == tab(r, g, "Mum"))
r, g = P(T2, "Ann,P1,w,yes,,,vegetarian\nBob,P1,w,yes,,,\n")
t("diet counted", "vegetarian 1" in report({"event": "x", "date": ""}, r, {"no": 0, "pending": 0}, "x"))
r, g = P([{"name": "A", "seats": 2}, {"name": "B", "seats": 2}], "Ann,P1,w,yes,,,,Bob;Cid\nBob,P2,w,yes\nCid,P3,w,yes\n")
t("sit_with group larger than any table -> SC09", ("medium", "SC09") in codes(r))
for bad, name in (({"tables": []}, "no tables"), ({"tables": [{"name": "A", "seats": 0}]}, "zero seats"),
({"tables": [{"name": "A", "seats": 4}, {"name": "A", "seats": 4}]}, "duplicate table"),
({"tables": [{"name": "A", "seats": 4, "colour": "red"}]}, "unknown table key")):
try:
load_event(bad); t("rejected: " + name, False)
except InputError:
t("rejected: " + name, True)
try:
load_guests("first,last\nA,B\n"); t("rejected: no name column", False)
except InputError:
t("rejected: no name column", True)
r, g = P(T2, rows)
rep = report({"event": "x", "date": ""}, r, {"no": 0, "pending": 0}, "x")
t("report has verdict, tables and place cards", "**Verdict:" in rep and "## Tables" in rep and "## Place cards" in rep)
for n, ok in tests:
print("%s %s" % ("PASS" if ok else "FAIL", n))
p = sum(ok for _, ok in tests)
print("%d/%d passed" % (p, len(tests)))
return 0 if p == len(tests) else 1
def main(argv=None):
ap = argparse.ArgumentParser(description="Build a seating chart from tables and a guest list.")
ap.add_argument("event", nargs="?")
ap.add_argument("guests", nargs="?")
ap.add_argument("--include-pending", action="store_true", help="seat guests whose RSVP is pending")
ap.add_argument("--csv", action="store_true", help="print name,table CSV (place cards)")
ap.add_argument("--json", action="store_true")
ap.add_argument("--self-test", action="store_true")
a = ap.parse_args(argv)
if a.self_test:
return self_test()
if not a.event or not a.guests:
ap.error("give EVENT.json and GUESTS.csv")
try:
ev, r, skipped = run(a.event, a.guests, a.include_pending)
except InputError as e:
print("error: %s" % e, file=sys.stderr)
return 2
G, T = r["guests"], r["tables"]
if a.json:
print(json.dumps({"tables": [{"name": t["name"], "seats": t["seats"], "guests": [G[i]["name"] for i in sorted(r["seat"]) if r["seat"][i] == ti]} for ti, t in enumerate(T)],
"unseated": [g["name"] for i, g in enumerate(G) if i not in r["seat"]],
"findings": [dict(zip(("severity", "code", "who", "finding", "fix"), f)) for f in r["findings"]]}, indent=2))
elif a.csv:
w = csv.writer(sys.stdout, lineterminator="\n")
w.writerow(["name", "table", "diet"])
for i in sorted(r["seat"], key=lambda i: norm(G[i]["name"])):
w.writerow([G[i]["name"], T[r["seat"][i]]["name"], G[i]["diet"]])
else:
sys.stdout.write(report(ev, r, skipped, a.guests))
return 1 if any(f[0] == "high" for f in r["findings"]) else 0
if __name__ == "__main__":
sys.exit(main())