rairo commited on
Commit
eb773e5
·
verified ·
1 Parent(s): 6d840b9

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +336 -0
main.py CHANGED
@@ -421,6 +421,342 @@ def deep_dive():
421
  except Exception as e:
422
  return jsonify({'error': str(e)}), 500
423
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
424
  # -----------------------------------------------------------------------------
425
  # 5. THE CHIRON MENTOR & SYSTEM UTILS
426
  # -----------------------------------------------------------------------------
 
421
  except Exception as e:
422
  return jsonify({'error': str(e)}), 500
423
 
424
+
425
+ # -----------------------------------------------------------------------------
426
+ # ODYSSEUS TRIAL ENGINE: REASONING-BASED GAME GENERATOR (NO FALLBACK)
427
+ # - Generates a Trial once per (epiphanyId, layerKey) and persists it forever
428
+ # - Cache hit returns existing Trial (no re-charge)
429
+ # - If Gemini output can't "compile" into schema: return 422 + store diagnostics
430
+ # - Credits are deducted ONLY after schema validation passes
431
+ # - Uses an RTDB lock (transaction) to avoid double-click / concurrent generation
432
+ # -----------------------------------------------------------------------------
433
+
434
+ TRIAL_RENDERERS = {"thermal", "electrical", "mechanical", "chemical", "biological"}
435
+ TRIAL_GRAMMARS = {"accumulate", "balance", "transform"}
436
+
437
+ def _strip_json_fences(s: str) -> str:
438
+ s = (s or "").strip()
439
+ if "```" in s:
440
+ m = re.search(r"```(?:json)?\s*(.*?)\s*```", s, re.DOTALL)
441
+ if m:
442
+ return m.group(1).strip()
443
+ return s
444
+
445
+ def _is_num(x):
446
+ return isinstance(x, (int, float)) and not isinstance(x, bool)
447
+
448
+ def validate_trial_schema(t: dict) -> (bool, str):
449
+ """
450
+ Minimal v1 compiler checks (deterministic frontend assumptions):
451
+ - renderer must be in fixed set (so UI knows which canvas renderer to use)
452
+ - grammar must be one of three
453
+ - 1 variable + 1 slider control bound to that variable
454
+ - win zone + dwell
455
+ """
456
+ if not isinstance(t, dict):
457
+ return False, "trial is not an object"
458
+
459
+ engine = t.get("engine")
460
+ if not isinstance(engine, dict):
461
+ return False, "engine missing/invalid"
462
+
463
+ renderer = t.get("renderer")
464
+ grammar = t.get("grammar")
465
+ labels = t.get("labels")
466
+ logic = t.get("logic")
467
+
468
+ if renderer not in TRIAL_RENDERERS:
469
+ return False, f"renderer must be one of {sorted(TRIAL_RENDERERS)} (got {renderer})"
470
+ if grammar not in TRIAL_GRAMMARS:
471
+ return False, f"grammar must be one of {sorted(TRIAL_GRAMMARS)} (got {grammar})"
472
+
473
+ if not isinstance(labels, dict):
474
+ return False, "labels missing/invalid"
475
+ if not isinstance(labels.get("title"), str) or len(labels["title"].strip()) < 2:
476
+ return False, "labels.title missing/invalid"
477
+ if not isinstance(labels.get("goal"), str) or len(labels["goal"].strip()) < 3:
478
+ return False, "labels.goal missing/invalid"
479
+ if not isinstance(labels.get("controls"), list) or len(labels["controls"]) < 1:
480
+ return False, "labels.controls missing/invalid"
481
+
482
+ if not isinstance(logic, dict):
483
+ return False, "logic missing/invalid"
484
+
485
+ variables = logic.get("variables")
486
+ controls = logic.get("controls")
487
+ win = logic.get("win")
488
+ fail = logic.get("fail")
489
+
490
+ if not isinstance(variables, list) or len(variables) != 1:
491
+ return False, "logic.variables must be a list of exactly 1 variable for v1"
492
+ if not isinstance(controls, list) or len(controls) != 1:
493
+ return False, "logic.controls must be a list of exactly 1 control for v1"
494
+ if not isinstance(win, dict):
495
+ return False, "logic.win missing/invalid"
496
+ if not isinstance(fail, dict):
497
+ return False, "logic.fail missing/invalid"
498
+
499
+ v = variables[0]
500
+ if not isinstance(v, dict):
501
+ return False, "variable[0] invalid"
502
+ for k in ("id", "label", "min", "max", "start"):
503
+ if k not in v:
504
+ return False, f"variable[0].{k} missing"
505
+ if not isinstance(v["id"], str) or not v["id"].strip():
506
+ return False, "variable[0].id invalid"
507
+ if not isinstance(v["label"], str) or len(v["label"].strip()) < 2:
508
+ return False, "variable[0].label invalid"
509
+ if not _is_num(v["min"]) or not _is_num(v["max"]) or not _is_num(v["start"]):
510
+ return False, "variable[0] numeric fields invalid"
511
+ if v["min"] >= v["max"]:
512
+ return False, "variable[0] min>=max"
513
+ if not (v["min"] <= v["start"] <= v["max"]):
514
+ return False, "variable[0] start out of range"
515
+
516
+ c = controls[0]
517
+ if not isinstance(c, dict):
518
+ return False, "control[0] invalid"
519
+ for k in ("id", "label", "type", "min", "max", "step", "binds_to", "gain"):
520
+ if k not in c:
521
+ return False, f"control[0].{k} missing"
522
+ if c.get("type") != "slider":
523
+ return False, "control[0].type must be 'slider' for v1"
524
+ if not isinstance(c["label"], str) or len(c["label"].strip()) < 2:
525
+ return False, "control[0].label invalid"
526
+ if not _is_num(c["min"]) or not _is_num(c["max"]) or not _is_num(c["step"]) or not _is_num(c["gain"]):
527
+ return False, "control[0] numeric fields invalid"
528
+ if c["min"] >= c["max"]:
529
+ return False, "control[0] min>=max"
530
+ if c.get("binds_to") != v["id"]:
531
+ return False, "control[0].binds_to must match variable id"
532
+
533
+ if win.get("type") != "zone":
534
+ return False, "win.type must be 'zone' for v1"
535
+ if win.get("var") != v["id"]:
536
+ return False, "win.var must match variable id"
537
+ if not _is_num(win.get("min")) or not _is_num(win.get("max")):
538
+ return False, "win.min/win.max invalid"
539
+ if win["min"] >= win["max"]:
540
+ return False, "win min>=max"
541
+ dwell = win.get("dwell_ms", 0)
542
+ if not _is_num(dwell) or dwell < 0:
543
+ return False, "win.dwell_ms invalid"
544
+
545
+ if not isinstance(fail.get("reason"), str) or len(fail["reason"].strip()) < 3:
546
+ return False, "fail.reason missing/invalid"
547
+
548
+ return True, "ok"
549
+
550
+
551
+ @app.route('/api/trial/generate', methods=['POST'])
552
+ def generate_trial():
553
+ logger.info(">>> ODYSSEUS TRIAL GENERATION INITIATED")
554
+
555
+ # 1) AUTH
556
+ uid = verify_token(request.headers.get('Authorization'))
557
+ if not uid:
558
+ return jsonify({"error": "Unauthorized"}), 401
559
+
560
+ payload = request.get_json(silent=True) or {}
561
+ epiphany_id = payload.get("epiphanyId")
562
+ layer_key = payload.get("layerKey") # genesis | scientific_core | engineering_edge | cross_pollination
563
+ subject = payload.get("subject")
564
+
565
+ if not all([epiphany_id, layer_key, subject]):
566
+ return jsonify({"error": "Missing context (epiphanyId, layerKey, subject)."}), 400
567
+
568
+ # 2) OWNERSHIP CHECK
569
+ ep_ref = db_ref.child(f"epiphanies/{epiphany_id}")
570
+ ep = ep_ref.get() or {}
571
+ if not ep or ep.get("uid") != uid:
572
+ return jsonify({"error": "Forbidden: epiphany not found or not owned by user."}), 403
573
+
574
+ # 3) CACHE FOREVER
575
+ trial_path = f"epiphanies/{epiphany_id}/trials/{layer_key}"
576
+ trial_ref = db_ref.child(trial_path)
577
+ existing = trial_ref.get()
578
+ if existing:
579
+ return jsonify(existing), 200
580
+
581
+ # 4) LOCK (atomic via transaction)
582
+ lock_id = str(uuid.uuid4())
583
+ lock_path = f"epiphanies/{epiphany_id}/trialLocks/{layer_key}"
584
+ lock_ref = db.reference(lock_path)
585
+
586
+ lock_payload = {"uid": uid, "lockId": lock_id, "at": datetime.utcnow().isoformat()}
587
+
588
+ def lock_txn(cur):
589
+ # If a lock already exists, keep it (do not overwrite)
590
+ if cur:
591
+ return cur
592
+ return lock_payload
593
+
594
+ lock_result = lock_ref.transaction(lock_txn)
595
+ # If someone else already holds the lock, return 409
596
+ if lock_result and lock_result.get("lockId") != lock_id:
597
+ return jsonify({
598
+ "status": "locked",
599
+ "error": "Trial is being generated. Please retry shortly.",
600
+ "cooldown_ms": 1200
601
+ }), 409
602
+
603
+ def cleanup_lock_best_effort():
604
+ try:
605
+ cur = lock_ref.get()
606
+ if cur and cur.get("lockId") == lock_id:
607
+ lock_ref.delete()
608
+ except Exception:
609
+ pass
610
+
611
+ # 5) GEMINI GENERATION (2 attempts), NO FALLBACK
612
+ model_name = ATHENA_FLASH
613
+ attempts = 0
614
+ raw_outputs = []
615
+ last_validation_error = None
616
+ last_exception = None
617
+
618
+ trial_prompt = f"""
619
+ You are designing a short, playable scientific Trial for: {subject}
620
+ Layer: {layer_key}
621
+
622
+ Output JSON ONLY (no markdown).
623
+
624
+ Rules:
625
+ - Choose a FREE domain_label (any string) describing the concept (e.g., "orbital mechanics", "distillation").
626
+ - Choose renderer from EXACTLY: thermal, mechanical, electrical, chemical, biological
627
+ - Choose grammar from EXACTLY: accumulate, balance, transform
628
+ - Must be solvable in <= 30 seconds.
629
+ - v1 STRICT: 1 variable and 1 slider control.
630
+
631
+ JSON SCHEMA:
632
+ {{
633
+ "engine": {{"name":"odysseus","version":"v1"}},
634
+ "renderer":"thermal|mechanical|electrical|chemical|biological",
635
+ "domain_label":"free text",
636
+ "grammar":"accumulate|balance|transform",
637
+ "labels": {{
638
+ "title":"Short Title",
639
+ "goal":"One-line goal",
640
+ "controls":["Single control label"]
641
+ }},
642
+ "logic": {{
643
+ "variables":[{{"id":"x","label":"Label (include units)","min":number,"max":number,"start":number}}],
644
+ "controls":[{{"id":"c1","label":"Same as labels.controls[0]","type":"slider","min":number,"max":number,"step":number,"binds_to":"x","gain":number}}],
645
+ "win":{{"type":"zone","var":"x","min":number,"max":number,"dwell_ms":number}},
646
+ "fail":{{"type":"out_of_range","reason":"Scientific reason for instability"}},
647
+ "difficulty": number
648
+ }},
649
+ "narrative": {{
650
+ "on_win":"One sentence insight.",
651
+ "on_fail":"One sentence failure explanation."
652
+ }}
653
+ }}
654
+ """
655
+
656
+ try:
657
+ trial_json = None
658
+
659
+ for _ in range(2):
660
+ attempts += 1
661
+ try:
662
+ res = client.models.generate_content(
663
+ model=model_name,
664
+ contents=trial_prompt,
665
+ config=types.GenerateContentConfig(response_mime_type="application/json")
666
+ )
667
+ raw = _strip_json_fences(res.text)
668
+ raw_outputs.append(raw[:2000]) # keep a preview for logging
669
+
670
+ candidate = json.loads(raw)
671
+ candidate["createdAt"] = datetime.utcnow().isoformat()
672
+
673
+ ok, why = validate_trial_schema(candidate)
674
+ if ok:
675
+ trial_json = candidate
676
+ break
677
+ last_validation_error = why
678
+
679
+ except Exception as e:
680
+ last_exception = str(e)
681
+
682
+ # 6) IF INVALID: LOG FAILURE + RETURN 422 (no charge)
683
+ if not trial_json:
684
+ failure_doc = {
685
+ "status": "failed",
686
+ "uid": uid,
687
+ "epiphanyId": epiphany_id,
688
+ "layerKey": layer_key,
689
+ "subject": subject,
690
+ "model": model_name,
691
+ "attempts": attempts,
692
+ "validation_error": last_validation_error,
693
+ "exception": last_exception,
694
+ "raw_output_preview": raw_outputs[-1] if raw_outputs else None,
695
+ "at": datetime.utcnow().isoformat()
696
+ }
697
+ db_ref.child(f"trial_failures/{uid}/{epiphany_id}/{layer_key}").push(failure_doc)
698
+
699
+ cleanup_lock_best_effort()
700
+ return jsonify({
701
+ "status": "failed",
702
+ "epiphanyId": epiphany_id,
703
+ "layerKey": layer_key,
704
+ "subject": subject,
705
+ "reason": "schema_invalid" if last_validation_error else "model_error",
706
+ "details": {
707
+ "validation_error": last_validation_error,
708
+ "exception": last_exception,
709
+ "attempts": attempts,
710
+ "raw_output_preview": raw_outputs[-1] if raw_outputs else None
711
+ },
712
+ "next": {"action": "regenerate", "cooldown_ms": 1200}
713
+ }), 422
714
+
715
+ # 7) CHARGE (atomic) ONLY AFTER VALID TRIAL EXISTS
716
+ credits_ref = db.reference(f"users/{uid}/credits")
717
+ did_deduct = {"ok": False}
718
+
719
+ def credits_txn(cur):
720
+ cur = cur or 0
721
+ if cur < 1:
722
+ return cur
723
+ did_deduct["ok"] = True
724
+ return cur - 1
725
+
726
+ credits_ref.transaction(credits_txn)
727
+
728
+ if not did_deduct["ok"]:
729
+ cleanup_lock_best_effort()
730
+ return jsonify({"error": "Insufficient Sparks. (Trial requires 1 spark)"}), 402
731
+
732
+ # 8) PERSIST TRIAL FOREVER
733
+ trial_ref.set(trial_json)
734
+
735
+ cleanup_lock_best_effort()
736
+ logger.info(f"Trial manifested & persisted: {trial_path}")
737
+ return jsonify(trial_json), 201
738
+
739
+ except Exception as e:
740
+ # Hard failure path (still no fallback)
741
+ failure_doc = {
742
+ "status": "failed",
743
+ "uid": uid,
744
+ "epiphanyId": epiphany_id,
745
+ "layerKey": layer_key,
746
+ "subject": subject,
747
+ "model": model_name,
748
+ "attempts": attempts,
749
+ "validation_error": last_validation_error,
750
+ "exception": str(e),
751
+ "raw_output_preview": raw_outputs[-1] if raw_outputs else None,
752
+ "at": datetime.utcnow().isoformat()
753
+ }
754
+ db_ref.child(f"trial_failures/{uid}/{epiphany_id}/{layer_key}").push(failure_doc)
755
+
756
+ cleanup_lock_best_effort()
757
+ logger.error(f"Trial Generation Failure: {e}")
758
+ return jsonify({"error": "Trial generation failed."}), 500
759
+
760
  # -----------------------------------------------------------------------------
761
  # 5. THE CHIRON MENTOR & SYSTEM UTILS
762
  # -----------------------------------------------------------------------------