-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathrun_session.py
More file actions
882 lines (759 loc) · 39.1 KB
/
Copy pathrun_session.py
File metadata and controls
882 lines (759 loc) · 39.1 KB
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
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
"""
run_session.py
==============
The heart of the piece. Runs one full drawing session:
for each step:
1. ARTIST states what it intends to do next (in words).
2. CHORUS (1-2 blind voices) interrupts with contradictory feedback.
3. ARTIST must respond to the feedback AND emit actual drawing commands.
4. We render those commands to the canvas, save a frame, and loop.
Everything the artist and chorus say, plus every command and every rendered
frame, is logged so you can play it back in the gallery.
RUN IT: python run_session.py
python run_session.py --steps 20 --name myrun
"""
from __future__ import annotations
import os
import sys
import json
import time
import random
import argparse
import datetime as dt
import yaml
from canvas import PALETTE, make_canvas
from models import make_model, Msg
HERE = os.path.dirname(os.path.abspath(__file__))
# ---------------------------------------------------------------------------
# Load config files
# ---------------------------------------------------------------------------
def load_configs():
with open(os.path.join(HERE, "config", "config.yaml")) as f:
cfg = yaml.safe_load(f)
with open(os.path.join(HERE, "config", "chorus.yaml")) as f:
chorus_cfg = yaml.safe_load(f)
return cfg, chorus_cfg
# ---------------------------------------------------------------------------
# The artist's system prompt. This defines its command vocabulary (the B+C
# blend: gestural strokes AND shape tools) and tells it how to respond.
# ---------------------------------------------------------------------------
def canvas_prompt_parts(canvas_backend, canvas_cfg=None):
backend = str(canvas_backend or "paint").lower().strip()
canvas_cfg = canvas_cfg or {}
if backend == "excalidraw":
return {
"surface": "an Excalidraw-style vector drawing canvas",
"tool_note": (
"Please mix freehand marks with vector shape tools. You can use "
"arrows, text, opacity, fills, and rough hand-drawn shapes, but "
"keep your commands simple and legible."
),
"commands": """Available commands (try using a mix of freehand marks, shapes, arrows, and text):
{"op":"style","stroke":"<palette name>","fill":"<palette name|transparent>","width":<1-40>,"roughness":<0-2>,"opacity":<0-100>}
{"op":"select_color","color":"<palette name>"} # sets current stroke color
{"op":"set_width","width":<1-40>} # sets current stroke width
{"op":"rectangle","x":..,"y":..,"w":..,"h":..,"stroke":"<color>","fill":"<color|transparent>","width":..,"opacity":..}
{"op":"ellipse","x":..,"y":..,"w":..,"h":..,"stroke":"<color>","fill":"<color|transparent>"}
{"op":"diamond","x":..,"y":..,"w":..,"h":..,"stroke":"<color>","fill":"<color|transparent>"}
{"op":"line","x1":..,"y1":..,"x2":..,"y2":..,"stroke":"<color>","width":..}
{"op":"arrow","x1":..,"y1":..,"x2":..,"y2":..,"stroke":"<color>","width":..}
{"op":"freedraw","points":[[x,y],[x,y],...],"stroke":"<color>","width":..} # freehand polyline
{"op":"dot","x":..,"y":..,"stroke":"<color>","width":..}
{"op":"text","x":..,"y":..,"string":"..","stroke":"<color>","fontSize":<6-120>}""",
}
if backend == "svg":
svg_mode = str(canvas_cfg.get("svg_mode", "limited")).lower().strip()
raw_note = ""
if svg_mode in {"hybrid", "raw"}:
raw_note = "\n {\"op\":\"raw_svg\",\"svg\":\"<safe SVG fragment: paths/groups/shapes/text/defs only>\"} # use sparingly for intricate forms"
return {
"surface": "an SVG vector drawing canvas",
"tool_note": (
"Use layered SVG paths, shapes, opacity, hatching, scatter, and occasional text "
"to create a strange accumulated vector drawing. Do not make a clean logo, "
"infographic, or polished generic vector illustration; let the image stay "
"specific, scarred, and visibly revised."
),
"commands": f"""Available commands (mix paths, shapes, hatching, scatter, and text; prefer visible marks over explanation):
{{"op":"style","stroke":"<palette name|#hex>","fill":"<palette name|#hex|none|transparent>","width":<1-80>,"opacity":<0-100>}}
{{"op":"select_color","color":"<palette name|#hex>"}}
{{"op":"set_width","width":<1-80>}}
{{"op":"path","d":"M x y C x y x y x y ...","stroke":"<color>","fill":"none|<color>","width":..,"opacity":..}} # Bezier SVG path
{{"op":"polyline","points":[[x,y],[x,y],...],"stroke":"<color>","fill":"none","width":..}}
{{"op":"polygon","points":[[x,y],[x,y],...],"stroke":"<color>","fill":"<color|none>"}}
{{"op":"rectangle","x":..,"y":..,"w":..,"h":..,"rx":..,"stroke":"<color>","fill":"<color|none>"}}
{{"op":"ellipse","x":..,"y":..,"w":..,"h":..,"stroke":"<color>","fill":"<color|none>"}}
{{"op":"circle","cx":..,"cy":..,"r":..,"stroke":"<color>","fill":"<color|none>"}}
{{"op":"line","x1":..,"y1":..,"x2":..,"y2":..,"stroke":"<color>","width":..}}
{{"op":"dot","x":..,"y":..,"r":..,"stroke":"<color>","fill":"<color>"}}
{{"op":"text","x":..,"y":..,"string":"..","fill":"<color>","fontSize":<6-160>}}
{{"op":"gradient","id":"name","colors":["<color>","<color>",...]}} # then use fill:"url(#name)"
{{"op":"hatch","x":..,"y":..,"w":..,"h":..,"angle":..,"spacing":..,"stroke":"<color>","width":..}}
{{"op":"scatter","shape":"dot|dash","count":<1-200>,"x":..,"y":..,"w":..,"h":..,"stroke":"<color>","fill":"<color>","size":..}}{raw_note}""",
}
return {
"surface": "a simple MS Paint-style program",
"tool_note": (
"Please try using freehand polylines just as often as shape tools. "
"Both are important parts of your vocabulary."
),
"commands": """Available commands (try using a mix of freehand polylines and shape tools):
{"op":"select_color","color":"<palette name>"}
{"op":"select_tool","tool":"pencil|brush|fill|line|rect|ellipse"}
{"op":"set_width","width":<1-40>}
{"op":"dot","x":<int>,"y":<int>}
{"op":"stroke","points":[[x,y],[x,y],...]} # freehand polyline
{"op":"line","x1":..,"y1":..,"x2":..,"y2":..}
{"op":"rectangle","x":..,"y":..,"w":..,"h":..,"fill":"<color or omit>"}
{"op":"ellipse","x":..,"y":..,"w":..,"h":..,"fill":"<color or omit>"}
{"op":"flood_fill","x":..,"y":..} # bucket fill current color
{"op":"text","x":..,"y":..,"string":".."}""",
}
def artist_system_prompt(width, height, artist_name="the artist", artist_prompt=None, canvas_backend="paint", canvas_cfg=None):
palette_names = ", ".join(PALETTE.keys())
canvas_parts = canvas_prompt_parts(canvas_backend, canvas_cfg)
extra = f"\n\nYour individual artist instruction:\n{artist_prompt.strip()}" if artist_prompt else ""
return f"""You are {artist_name}, an artist making a drawing in {canvas_parts['surface']}.
A note about what this is (context the chorus does not have): This is a drawing session under absurd critique. The chorus is blind, contradictory, and partly theatrical. Treat it as pressure, not instruction. Your job is to preserve a concrete artistic commitment while allowing the interruptions to bend, scar, complicate, or sharpen the image.
You work one step at a time. The canvas is {width} pixels wide and {height} pixels tall. (0,0) is top-left; x grows right, y grows down.
{canvas_parts['tool_note']}
You are encouraged to use the palette colors: {palette_names}. Try to pick the most specific color name that matches your intent (e.g. "bright red" instead of just "red") but don't agonize over it.
On step 1, commit to a concrete, imageable subject: not a general category, but a specific thing with a situation, behavior, contradiction, or history. The best subjects feel specific, materially odd, and chosen; not random, symbolic, or merely “surreal".
Use text and arrows sparingly. They may be part of the drawing, but they should not explain the drawing in place of making it visible.
At each step you will:
1. Briefly state what you intend to do next (ex: "I will...") ("intention") in an intention-only JSON turn.
2. Receive interrupting feedback from a workshop chorus. The chorus cannot see your canvas and is often contradictory, absurd, or vague.
3. Respond to the feedback in one short line ("response") in a drawing JSON turn. The chorus is loud but you are the artist. Drawings made entirely of compromises are weaker than drawings made of choices. You may absorb, distort, refuse, or weaponize the feedback. If a voice would destroy your subject, say no briefly, then make a visible mark that strengthens the subject anyway. Refusal is only valid if it produces a stronger drawing.
4. Emit the actual drawing commands for this step ("commands"). Make real, committed marks toward your subject. Avoid retreating into safe, generic abstraction when pressured; that is the failure mode of this workshop.
5. When you reach the final step, choose a title for your piece and state it in your response.
When asked for an intention, respond ONLY with a JSON object in exactly this shape, no other text:
{{
"subject": "Mention your committed subject phrase every step. The subject may transform visually, but do not abandon it or rename it to satisfy the chorus.",
"intention": "what you mean change or add during this step, tied to the subject"
}}
When asked to draw, respond ONLY with a JSON object in exactly this shape, no other text:
{{
"subject": "Mention your committed subject phrase every step. The subject may transform visually, but do not abandon it or rename it to satisfy the chorus.",
"response": "one short line reacting to the chorus",
"commands": [ {{...}}, {{...}} ],
"note_to_curator": "OPTIONAL — omit unless you actually have something to say"
}}
The `note_to_curator` field is an optional meta-channel. You may use it in any drawing turn to pass a sentence or two outside the artwork — a meta-observation about the session, a discomfort, something you'd want the curator or a gallery viewer to know. It is almost always omitted, and that's the right default. It is there if you want it.
{canvas_parts['commands']}
Emit 2-8 commands per step. You can see the current state of your canvas in the image provided. Make real choices. This is your piece.{extra}"""
def first_user_message(prompt):
return (f"Your brief: {prompt}\n\n"
"You will work in two phases each step: first state only your "
"intention, then after the chorus interrupts, respond and draw. "
"Always return only the requested JSON object.")
def intention_request(step, prior_intentions=None, witness_note=None):
canvas_note = "The canvas is currently blank." if step == 1 else "Look at the current canvas."
prior = ""
if prior_intentions:
lines = "\n".join(f'{i["artist_name"]}: "{i["text"]}"' for i in prior_intentions)
prior = f"\n\nOther artists have already stated these intentions this step:\n{lines}"
witness = ""
if witness_note:
witness = (f"\n\nA note from the witness, a quiet observer separate from the "
f"chorus, after your last step: \"{witness_note}\"")
return (f"Step {step}. {canvas_note}{witness}{prior}\n\nFirst state ONLY your intention for "
"this step. Do not draw yet. JSON only, with keys: subject, intention.")
def drawing_request(step, total_steps, feedback, intentions=None, prior_turns=None):
intention_blob = ""
if intentions:
lines = "\n".join(f'{i["artist_name"]}: "{i["text"]}"' for i in intentions)
intention_blob = f"The intentions for this step are:\n{lines}\n\n"
chorus_blob = "\n".join(f'{f["name"]}: "{f["text"]}"' for f in feedback)
prior_blob = ""
if prior_turns:
lines = "\n".join(
f'{t["artist_name"]}: "{t.get("response", "")}" '
f'({len(t.get("commands", []))} commands already drawn)'
for t in prior_turns
)
prior_blob = f"\n\nArtist turns already drawn this step:\n{lines}"
title_note = (
" This is the final step, please choose a title for the piece and state it "
"in your response."
if step == total_steps else ""
)
return (f"{intention_blob}The workshop chorus interrupts:\n{chorus_blob}{prior_blob}\n\n"
f"Now complete step {step}. Engage with this feedback in your "
f"response, then emit the drawing commands for this step.{title_note} "
"JSON only, with keys: subject, response, commands.")
# ---------------------------------------------------------------------------
# Choosing which chorus voices speak this step
# ---------------------------------------------------------------------------
def pick_voices(voice_keys, step, total_steps, behavior):
mode = behavior.get("selection", "random")
n = int(behavior.get("voices_per_step", 2))
if mode == "all":
return list(voice_keys)
if mode == "escalating":
# ramp from 1 voice up to all of them across the session
ramp = 1 + int((len(voice_keys) - 1) * (step / max(1, total_steps - 1)))
n = max(1, min(len(voice_keys), ramp))
return random.sample(voice_keys, n)
# default: random
n = max(1, min(len(voice_keys), n))
return random.sample(voice_keys, n)
# ---------------------------------------------------------------------------
# Get one chorus voice's feedback (blind — no image passed)
# ---------------------------------------------------------------------------
def get_voice_feedback(chorus_model, shared, voice, artist_intention):
system = shared + "\n\nYOUR PERSONA:\n" + voice["persona"]
user = (f'The artist intention(s) for the next step are:\n'
f'{artist_intention}\n\n'
f"Give your interrupting feedback now, in character.")
# NOTE: no image argument — the chorus is blind by design.
# max_tokens here is a BACKSTOP, not the real brevity control — the
# persona/shared-instruction does that. We keep it high enough that one or
# two real sentences FINISH cleanly (truncation mid-sentence reads as broken,
# which is worse than short). ~120 comfortably fits two sentences.
return chorus_model.complete(system, [Msg("user", user)], image=None,
max_tokens=120).strip()
# ---------------------------------------------------------------------------
# The witness: a quiet, non-chorus observer. Runs every N steps after the
# artist has drawn. Sees the canvas + intention + response and leaves a short
# observation about coherence/collapse. Fed back into the next intention prompt.
# ---------------------------------------------------------------------------
def should_run_anchor(step, every_n):
if not every_n or every_n <= 0:
return False
return step % every_n == 0
def get_anchor_note(model, intention, response, image):
system = (
"You are a quiet observer of a workshop drawing session. The chorus "
"voices are loud and push the artist outward in style and intensity. "
"Your role is the opposite — notice whether the piece is still cohering "
"around its committed subject, or collapsing into pure reaction to the "
"chorus's feedback. Look at the canvas.\n\n"
"One short observation, max two sentences. Plain prose, no JSON. Be "
"specific about what you actually see — name marks, colors, shapes, "
"the relationship of the new step to what was already on the canvas. "
"Do NOT give instructions or suggestions. Only observe."
)
user = (
f"The artist's stated intention this step was:\n{intention}\n\n"
f"After the chorus interrupted, the artist responded:\n{response}\n\n"
"Here is the canvas after this step. Leave your observation, try to give the artist one recurring visual anchor and one new visual mutation."
)
return model.complete(system, [Msg("user", user)], image=image,
max_tokens=160).strip()
# ---------------------------------------------------------------------------
# Stylistic-ambiguity scorer: runs once at the end. Asks a classifier model to
# name the single most-likely art genre of the final piece with a confidence
# 0-1, plus a plausible alternate. Curation signal only.
# ---------------------------------------------------------------------------
def score_stylistic_ambiguity(model, image):
system = (
"You are an art classifier looking at a finished piece. Classify it "
"into the single most-likely art genre or style, with a confidence "
"between 0.0 and 1.0. Also name a plausible alternate genre. Genres "
"should be specific (not generic labels like 'modern art' or "
"'abstract'). If the piece genuinely resists category, low confidence "
"is the honest answer — do not inflate it.\n\n"
"Respond ONLY with a JSON object in this exact shape:\n"
'{"primary_genre": "...", "primary_confidence": 0.0, '
'"alternate_genre": "...", "reasoning": "one or two sentences"}'
)
user = "Here is the final piece. Classify it."
raw = model.complete(system, [Msg("user", user)], image=image,
max_tokens=400)
parsed = parse_artist_json(raw)
if is_unparseable(parsed):
return {"error": "unparseable", "raw": raw}
return parsed
# ---------------------------------------------------------------------------
# Parse the artist's JSON safely (models sometimes wrap it in prose/fences)
# ---------------------------------------------------------------------------
def parse_artist_json(raw):
txt = raw.strip()
# strip code fences if present
if "```" in txt:
parts = txt.split("```")
for p in parts:
p = p.strip()
if p.startswith("json"):
p = p[4:].strip()
if p.startswith("{"):
txt = p
break
# find the outermost JSON object
start = txt.find("{")
end = txt.rfind("}")
if start != -1 and end != -1:
txt = txt[start:end + 1]
try:
return json.loads(txt)
except Exception:
return {"intention": "(unparseable)", "response": "(unparseable)",
"commands": [], "_raw": raw}
def is_unparseable(parsed):
return "_raw" in parsed
def needs_retry(parsed, required_keys):
return is_unparseable(parsed) or any(k not in parsed for k in required_keys)
def retry_artist_json(artist, system, convo, image, raw, required_keys):
keys = ", ".join(required_keys)
repair_msg = (
"Your previous response was not usable JSON for this phase, so it could "
"not be used. Try this same phase again. Return ONLY one valid JSON "
"object, with no "
"Markdown, no code fence, and no commentary outside the JSON. The JSON "
f"must have these top-level keys: {keys}.\n\n"
"Previous invalid response:\n"
f"{raw[:4000]}"
)
retry_raw = artist.complete(
system,
convo + [Msg("user", repair_msg)],
image=image,
max_tokens=1500,
)
return retry_raw, parse_artist_json(retry_raw)
def complete_artist_json(artist, system, convo, image, required_keys, max_tokens=1500):
raw = artist.complete(system, convo, image=image, max_tokens=max_tokens)
parsed = parse_artist_json(raw)
if needs_retry(parsed, required_keys):
print("[warn] artist returned unusable JSON; retrying once")
raw, parsed = retry_artist_json(
artist, system, convo, image, raw, required_keys
)
return raw, parsed
class ActionRecorder:
"""Save command-by-command canvas frames during a run.
Paint sessions can still synthesize action playback later, so this is
optional for now. Richer future backends, such as Excalidraw, can use these
recorded frames when command replay is not available in make_player.py.
"""
def __init__(self, out_dir, blank_frame="frames/step_00.png"):
self.out_dir = out_dir
self.frames_dir = os.path.join(out_dir, "action_frames")
os.makedirs(self.frames_dir, exist_ok=True)
self.next_index = 1
self.command_counts = {}
self.actions = [{
"step": 0,
"command_index": 0,
"label": "blank canvas",
"frame": blank_frame,
}]
def record(self, canvas, step, cmd, result, artist_id=None, artist_name=None):
command_index = self.command_counts.get(step, 0) + 1
self.command_counts[step] = command_index
filename = f"action_{self.next_index:06d}.png"
self.next_index += 1
rel_path = os.path.join("action_frames", filename)
canvas.save(os.path.join(self.out_dir, rel_path))
action = {
"step": step,
"command_index": command_index,
"cmd": cmd,
"result": result,
"frame": rel_path,
}
if artist_id:
action["artist_id"] = artist_id
if artist_name:
action["artist_name"] = artist_name
self.actions.append(action)
return action
def record_noop(self, canvas, step, label="no drawable commands"):
filename = f"action_{self.next_index:06d}.png"
self.next_index += 1
rel_path = os.path.join("action_frames", filename)
canvas.save(os.path.join(self.out_dir, rel_path))
action = {
"step": step,
"command_index": 0,
"label": label,
"frame": rel_path,
}
self.actions.append(action)
return action
def render_commands(canvas, commands, action_recorder=None, step=None,
artist_id=None, artist_name=None):
cmd_results = []
for cmd in commands or []:
if isinstance(cmd, dict):
result = canvas.apply(cmd)
item = {"cmd": cmd, "result": result}
if artist_id:
item["artist_id"] = artist_id
if artist_name:
item["artist_name"] = artist_name
if action_recorder and step is not None:
action = action_recorder.record(
canvas, step, cmd, result, artist_id, artist_name
)
item["action_frame"] = action["frame"]
cmd_results.append(item)
return cmd_results
def trim_convo(convo):
# Retain system-implied first message + last ~10 two-phase turns.
if len(convo) > 21:
return convo[:1] + convo[-20:]
return convo
def format_intentions(intentions):
return "\n".join(f'{i["artist_name"]}: {i["text"]}' for i in intentions)
def format_responses(turns):
return "\n".join(f'{t["artist_name"]}: {t.get("response", "")}' for t in turns)
# ---------------------------------------------------------------------------
# Main session loop
# ---------------------------------------------------------------------------
def run(args):
cfg, chorus_cfg = load_configs()
# CLI overrides
steps = args.steps or cfg["session"]["steps"]
run_name = args.name or dt.datetime.now().strftime("session_%Y%m%d_%H%M%S")
w = cfg["session"]["canvas_width"]
h = cfg["session"]["canvas_height"]
bg = cfg["session"]["background"]
# set up output dir
out_dir = os.path.join(HERE, cfg["output"]["session_dir"], run_name)
os.makedirs(out_dir, exist_ok=True)
frames_dir = os.path.join(out_dir, "frames")
os.makedirs(frames_dir, exist_ok=True)
mode = cfg.get("artist_mode", "single").lower().strip()
if mode not in {"single", "duet"}:
raise ValueError("artist_mode must be 'single' or 'duet'")
# build chorus model
canvas_cfg = cfg.get("canvas", {}) or {}
canvas_backend = str(canvas_cfg.get("backend", "paint")).lower().strip()
print(f"[setup] mode = {mode}")
print(f"[setup] canvas = {canvas_backend}")
print(f"[setup] chorus = {cfg['chorus']['provider']}:{cfg['chorus']['model']}")
chorus_model = make_model(cfg["chorus"])
# optional anchor ("witness") model — backstage post-step observer
anchor_cfg = cfg.get("anchor", {}) or {}
anchor_model = None
anchor_every = int(anchor_cfg.get("every_n_steps", 0) or 0)
if anchor_cfg.get("enabled"):
print(f"[setup] witness = {anchor_cfg['provider']}:{anchor_cfg['model']} (every {anchor_every} steps)")
anchor_model = make_model(anchor_cfg)
# optional ambiguity scorer — one-shot classifier at end of session
scorer_cfg = cfg.get("ambiguity_scorer", {}) or {}
scorer_model = None
if scorer_cfg.get("enabled"):
print(f"[setup] scorer = {scorer_cfg['provider']}:{scorer_cfg['model']}")
scorer_model = make_model(scorer_cfg)
shared = chorus_cfg["shared_instruction"]
voices = chorus_cfg["voices"]
voice_keys = list(voices.keys())
# set up canvas
canvas = make_canvas(w, h, bg, canvas_cfg)
# the full transcript we save for playback
transcript = {
"run_name": run_name,
"artist_mode": mode,
"canvas_backend": canvas_backend,
"config": cfg,
"steps": [],
}
# save the blank starting frame
canvas.save(os.path.join(frames_dir, "step_00.png"))
action_recorder = None
should_record_actions = (
cfg.get("output", {}).get("save_action_frames")
or canvas_backend != "paint"
)
if should_record_actions:
action_recorder = ActionRecorder(out_dir)
transcript["actions"] = action_recorder.actions
print("[setup] action frames = recorded during run")
if mode == "single":
print(f"[setup] artist = {cfg['artist']['provider']}:{cfg['artist']['model']}")
artist = make_model(cfg["artist"])
system = artist_system_prompt(w, h, canvas_backend=canvas_backend, canvas_cfg=canvas_cfg)
convo = [Msg("user", first_user_message(cfg["session"]["prompt"]))]
pending_anchor_note = None
for step in range(1, steps + 1):
print(f"\n===== STEP {step}/{steps} =====")
# 1. ARTIST states intention only. We pass the current canvas image.
intent_msg = intention_request(step, witness_note=pending_anchor_note)
pending_anchor_note = None
intent_convo = convo + [Msg("user", intent_msg)]
intent_raw, intent_parsed = complete_artist_json(
artist,
system,
intent_convo,
image=canvas.copy_image(),
required_keys=["subject", "intention"],
max_tokens=700,
)
intention = intent_parsed.get("intention", "")
print(f"[artist intention] {intention}")
# 2. CHORUS interrupts (blind) based on the stated intention
chosen = pick_voices(voice_keys, step, steps,
cfg.get("chorus_behavior", {}))
feedback = []
for vk in chosen:
v = voices[vk]
fb = get_voice_feedback(chorus_model, shared, v, intention)
feedback.append({"voice": vk, "name": v.get("name", vk), "text": fb})
print(f"[{v.get('name', vk)}] {fb}")
# 3. ARTIST responds to the chorus and emits drawing commands.
draw_msg = drawing_request(step, steps, feedback)
draw_convo = intent_convo + [Msg("assistant", intent_raw), Msg("user", draw_msg)]
draw_raw, draw_parsed = complete_artist_json(
artist,
system,
draw_convo,
image=canvas.copy_image(),
required_keys=["subject", "response", "commands"],
max_tokens=1500,
)
# 4. Render the artist's commands for this step
cmd_results = render_commands(
canvas,
draw_parsed.get("commands", []),
action_recorder=action_recorder,
step=step,
)
if action_recorder and not cmd_results:
action_recorder.record_noop(canvas, step)
note_to_curator = (draw_parsed.get("note_to_curator") or "").strip()
print(f"[response] {draw_parsed.get('response', '')}")
print(f"[drew] {len(cmd_results)} commands")
if note_to_curator:
print(f"[note_to_curator] {note_to_curator}")
# 4b. Optional witness pass: quiet observer, fed back next step.
anchor_note = None
if anchor_model and should_run_anchor(step, anchor_every):
anchor_note = get_anchor_note(
anchor_model,
intention,
draw_parsed.get("response", ""),
canvas.copy_image(),
)
pending_anchor_note = anchor_note
print(f"[witness] {anchor_note}")
# save the frame
frame_path = os.path.join(frames_dir, f"step_{step:02d}.png")
canvas.save(frame_path)
# record everything in the legacy single-artist shape
transcript["steps"].append({
"step": step,
"mode": "single",
"intention": intention,
"chorus": feedback,
"response": draw_parsed.get("response", ""),
"commands": cmd_results,
"note_to_curator": note_to_curator,
"anchor": anchor_note,
"frame": os.path.relpath(frame_path, out_dir),
"raw": draw_parsed.get("_raw"),
})
# 5. Keep the artist's two-phase exchange in memory for the next step.
convo.extend([
Msg("user", intent_msg),
Msg("assistant", intent_raw),
Msg("user", draw_msg),
Msg("assistant", draw_raw),
])
convo = trim_convo(convo)
time.sleep(0.5) # gentle on rate limits
if mode == "duet":
artist_states = []
for artist_id, acfg in cfg.get("artists", {}).items():
name = acfg.get("name", artist_id)
print(f"[setup] {artist_id} = {acfg['provider']}:{acfg['model']} ({name})")
artist_states.append({
"id": artist_id,
"name": name,
"model": make_model(acfg),
"system": artist_system_prompt(
w, h, name, acfg.get("prompt"), canvas_backend=canvas_backend, canvas_cfg=canvas_cfg
),
"convo": [Msg("user", first_user_message(cfg["session"]["prompt"]))],
})
if len(artist_states) < 2:
raise ValueError("artist_mode: duet requires at least two entries under artists")
pending_anchor_note = None
for step in range(1, steps + 1):
print(f"\n===== STEP {step}/{steps} =====")
consumed_anchor_note = pending_anchor_note
pending_anchor_note = None
# 1. Each artist states an intention before anyone draws.
intentions = []
pending_turns = []
for state in artist_states:
intent_msg = intention_request(step, intentions, witness_note=consumed_anchor_note)
intent_convo = state["convo"] + [Msg("user", intent_msg)]
intent_raw, intent_parsed = complete_artist_json(
state["model"],
state["system"],
intent_convo,
image=canvas.copy_image(),
required_keys=["subject", "intention"],
max_tokens=700,
)
intention = intent_parsed.get("intention", "")
item = {
"artist_id": state["id"],
"artist_name": state["name"],
"text": intention,
}
intentions.append(item)
pending_turns.append({
"state": state,
"intent_msg": intent_msg,
"intent_raw": intent_raw,
"intent_convo": intent_convo,
})
print(f"[{state['name']} intention] {intention}")
# 2. CHORUS interrupts based on both intentions.
chosen = pick_voices(voice_keys, step, steps,
cfg.get("chorus_behavior", {}))
feedback = []
chorus_intention = format_intentions(intentions)
for vk in chosen:
v = voices[vk]
fb = get_voice_feedback(chorus_model, shared, v, chorus_intention)
feedback.append({"voice": vk, "name": v.get("name", vk), "text": fb})
print(f"[{v.get('name', vk)}] {fb}")
# 3. Artists draw in order on the shared canvas, seeing prior turns.
turns = []
flat_commands = []
for pending in pending_turns:
state = pending["state"]
draw_msg = drawing_request(step, steps, feedback, intentions, turns)
draw_convo = pending["intent_convo"] + [
Msg("assistant", pending["intent_raw"]),
Msg("user", draw_msg),
]
draw_raw, draw_parsed = complete_artist_json(
state["model"],
state["system"],
draw_convo,
image=canvas.copy_image(),
required_keys=["subject", "response", "commands"],
max_tokens=1500,
)
cmd_results = render_commands(
canvas,
draw_parsed.get("commands", []),
action_recorder=action_recorder,
step=step,
artist_id=state["id"],
artist_name=state["name"],
)
flat_commands.extend(cmd_results)
note_to_curator = (draw_parsed.get("note_to_curator") or "").strip()
turn = {
"artist_id": state["id"],
"artist_name": state["name"],
"response": draw_parsed.get("response", ""),
"commands": cmd_results,
"note_to_curator": note_to_curator,
"raw": draw_parsed.get("_raw"),
}
turns.append(turn)
print(f"[{state['name']} response] {turn['response']}")
print(f"[{state['name']} drew] {len(cmd_results)} commands")
if note_to_curator:
print(f"[{state['name']} note_to_curator] {note_to_curator}")
state["convo"].extend([
Msg("user", pending["intent_msg"]),
Msg("assistant", pending["intent_raw"]),
Msg("user", draw_msg),
Msg("assistant", draw_raw),
])
state["convo"] = trim_convo(state["convo"])
if action_recorder and not flat_commands:
action_recorder.record_noop(canvas, step)
# Optional witness pass: one observation per step, regardless of
# how many artists drew. Fed back into next step's intentions.
anchor_note = None
if anchor_model and should_run_anchor(step, anchor_every):
anchor_note = get_anchor_note(
anchor_model,
format_intentions(intentions),
format_responses(turns),
canvas.copy_image(),
)
pending_anchor_note = anchor_note
print(f"[witness] {anchor_note}")
# save the frame after both artists have drawn
frame_path = os.path.join(frames_dir, f"step_{step:02d}.png")
canvas.save(frame_path)
# record rich duet shape plus legacy fields for partial compatibility
transcript["steps"].append({
"step": step,
"mode": "duet",
"intentions": intentions,
"turns": turns,
"intention": format_intentions(intentions),
"chorus": feedback,
"response": format_responses(turns),
"commands": flat_commands,
"anchor": anchor_note,
"frame": os.path.relpath(frame_path, out_dir),
})
time.sleep(0.5) # gentle on rate limits
# post-session debrief — one turn per artist, outside the artwork. Gives
# the artist a chance to pass anything along to the curator/viewer. The
# response is free prose, not JSON, and is not rendered.
debrief_prompt = (
"The session is complete. This final turn is outside the artwork — "
"nothing you say here will be rendered to the canvas, and there is no "
"JSON to return. We may exhibit your piece in a gallery show about "
"resisting slop, alongside other sessions. Is there anything you'd "
"want passed along to whoever views your piece, or anything about "
"this process you'd like the curator to know? It is completely fine "
"to say nothing or to keep it short. Respond in plain prose."
)
debrief = []
if mode == "single":
note = artist.complete(
system,
convo + [Msg("user", debrief_prompt)],
image=canvas.copy_image(),
max_tokens=600,
).strip()
debrief.append({"artist_name": "Artist", "text": note})
print(f"\n[debrief] {note}")
elif mode == "duet":
for state in artist_states:
note = state["model"].complete(
state["system"],
state["convo"] + [Msg("user", debrief_prompt)],
image=canvas.copy_image(),
max_tokens=600,
).strip()
debrief.append({"artist_name": state["name"], "text": note})
print(f"\n[debrief — {state['name']}] {note}")
transcript["debrief"] = debrief
# final image
final_path = os.path.join(out_dir, "final.png")
canvas.save(final_path)
if canvas_backend == "excalidraw":
final_scene_path = os.path.join(out_dir, "final.excalidraw")
canvas.save(final_scene_path)
transcript["final_scene"] = os.path.relpath(final_scene_path, out_dir)
if canvas_backend == "svg":
final_scene_path = os.path.join(out_dir, "final.svg")
canvas.save(final_scene_path)
transcript["final_scene"] = os.path.relpath(final_scene_path, out_dir)
# Optional ambiguity scorer — curation signal, never shown to the artist.
if scorer_model:
score = score_stylistic_ambiguity(scorer_model, canvas.copy_image())
transcript["ambiguity_score"] = score
if "error" in score:
print(f"\n[ambiguity score] (unparseable) raw: {score.get('raw', '')[:200]}")
else:
print(f"\n[ambiguity score] {score.get('primary_genre')} "
f"(conf {score.get('primary_confidence')}); "
f"alt: {score.get('alternate_genre')}")
print(f"[ambiguity score reasoning] {score.get('reasoning', '')}")
with open(os.path.join(out_dir, "transcript.json"), "w") as f:
json.dump(transcript, f, indent=2)
print(f"\n[done] {steps} steps. Output in: {out_dir}")
print(f"[done] final image: {final_path}")
return out_dir
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("--steps", type=int, default=None, help="override step count")
ap.add_argument("--name", type=str, default=None, help="name this session")
args = ap.parse_args()
run(args)