syntaxmatrix 2.5.8.2__py3-none-any.whl → 2.6.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- syntaxmatrix/agentic/agents.py +1149 -54
- syntaxmatrix/agentic/agents_orchestrer.py +326 -0
- syntaxmatrix/agentic/code_tools_registry.py +27 -32
- syntaxmatrix/commentary.py +16 -16
- syntaxmatrix/core.py +145 -75
- syntaxmatrix/db.py +416 -4
- syntaxmatrix/{display.py → display_html.py} +2 -6
- syntaxmatrix/gpt_models_latest.py +1 -1
- syntaxmatrix/media/__init__.py +0 -0
- syntaxmatrix/media/media_pixabay.py +277 -0
- syntaxmatrix/models.py +1 -1
- syntaxmatrix/page_builder_defaults.py +183 -0
- syntaxmatrix/page_builder_generation.py +1122 -0
- syntaxmatrix/page_layout_contract.py +644 -0
- syntaxmatrix/page_patch_publish.py +1471 -0
- syntaxmatrix/preface.py +128 -8
- syntaxmatrix/profiles.py +26 -13
- syntaxmatrix/routes.py +1475 -429
- syntaxmatrix/selftest_page_templates.py +360 -0
- syntaxmatrix/settings/client_items.py +28 -0
- syntaxmatrix/settings/model_map.py +1022 -208
- syntaxmatrix/settings/prompts.py +328 -130
- syntaxmatrix/static/assets/hero-default.svg +22 -0
- syntaxmatrix/static/icons/bot-icon.png +0 -0
- syntaxmatrix/static/icons/favicon.png +0 -0
- syntaxmatrix/static/icons/logo.png +0 -0
- syntaxmatrix/static/icons/logo3.png +0 -0
- syntaxmatrix/templates/admin_branding.html +104 -0
- syntaxmatrix/templates/admin_secretes.html +108 -0
- syntaxmatrix/templates/dashboard.html +116 -72
- syntaxmatrix/templates/edit_page.html +2535 -0
- syntaxmatrix/utils.py +2365 -2411
- {syntaxmatrix-2.5.8.2.dist-info → syntaxmatrix-2.6.1.dist-info}/METADATA +6 -2
- {syntaxmatrix-2.5.8.2.dist-info → syntaxmatrix-2.6.1.dist-info}/RECORD +37 -24
- syntaxmatrix/generate_page.py +0 -644
- syntaxmatrix/static/icons/hero_bg.jpg +0 -0
- {syntaxmatrix-2.5.8.2.dist-info → syntaxmatrix-2.6.1.dist-info}/WHEEL +0 -0
- {syntaxmatrix-2.5.8.2.dist-info → syntaxmatrix-2.6.1.dist-info}/licenses/LICENSE.txt +0 -0
- {syntaxmatrix-2.5.8.2.dist-info → syntaxmatrix-2.6.1.dist-info}/top_level.txt +0 -0
syntaxmatrix/agentic/agents.py
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
import os, re, json, textwrap, requests
|
|
4
4
|
import pandas as pd
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
import uuid
|
|
6
|
+
import io
|
|
7
|
+
from typing import Optional, List, Any, Dict
|
|
7
8
|
|
|
8
9
|
from syntaxmatrix import utils
|
|
9
10
|
from syntaxmatrix.settings.model_map import GPT_MODELS_LATEST
|
|
@@ -13,6 +14,14 @@ from google.genai import types
|
|
|
13
14
|
import tiktoken
|
|
14
15
|
from google.genai.errors import APIError
|
|
15
16
|
|
|
17
|
+
from io import BytesIO
|
|
18
|
+
from PIL import Image
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
import hashlib
|
|
22
|
+
from PIL import Image
|
|
23
|
+
from syntaxmatrix.page_layout_contract import normalise_layout, validate_layout
|
|
24
|
+
|
|
16
25
|
|
|
17
26
|
def token_calculator(total_input_content, llm_profile):
|
|
18
27
|
|
|
@@ -42,7 +51,7 @@ def token_calculator(total_input_content, llm_profile):
|
|
|
42
51
|
input_prompt_tokens = len(enc.encode(total_input_content))
|
|
43
52
|
return input_prompt_tokens
|
|
44
53
|
|
|
45
|
-
def mlearning_agent(user_prompt, system_prompt,
|
|
54
|
+
def mlearning_agent(user_prompt, system_prompt, coder_profile):
|
|
46
55
|
"""
|
|
47
56
|
Returns:
|
|
48
57
|
(text, usage_dict)
|
|
@@ -58,11 +67,12 @@ def mlearning_agent(user_prompt, system_prompt, coding_profile):
|
|
|
58
67
|
}
|
|
59
68
|
"""
|
|
60
69
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
70
|
+
_coder_profile = _prof.get_profile('coder')
|
|
71
|
+
_coder_profile['client'] = _prof.get_client(_coder_profile)
|
|
72
|
+
_client = _coder_profile['client']
|
|
73
|
+
_provider = _coder_profile["provider"].lower()
|
|
74
|
+
_model = _coder_profile["model"]
|
|
75
|
+
|
|
66
76
|
usage = {
|
|
67
77
|
"provider": _provider,
|
|
68
78
|
"model": _model,
|
|
@@ -185,7 +195,7 @@ def mlearning_agent(user_prompt, system_prompt, coding_profile):
|
|
|
185
195
|
|
|
186
196
|
# Anthropic
|
|
187
197
|
def anthropic_generate_code():
|
|
188
|
-
|
|
198
|
+
|
|
189
199
|
try:
|
|
190
200
|
resp = _client.messages.create(
|
|
191
201
|
model=_model,
|
|
@@ -292,15 +302,16 @@ def mlearning_agent(user_prompt, system_prompt, coding_profile):
|
|
|
292
302
|
return code, usage
|
|
293
303
|
|
|
294
304
|
|
|
295
|
-
def
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
305
|
+
def context_compatibility(question: str, dataset_context: str | None = None) -> str:
|
|
306
|
+
|
|
307
|
+
_profile = _prof.get_profile('classifier') or _prof.get_profile('admin')
|
|
308
|
+
_profile['client'] = _prof.get_client(_profile)
|
|
309
|
+
_client = _profile['client']
|
|
310
|
+
_provider = _profile.get("provider").lower()
|
|
311
|
+
_model = _profile.get("model")
|
|
312
|
+
|
|
313
|
+
def compatibility_response(user_prompt, system_prompt, temp=0.0, max_tokens=128):
|
|
314
|
+
|
|
304
315
|
# Google GenAI
|
|
305
316
|
if _provider == "google":
|
|
306
317
|
resp = _client.models.generate_content(
|
|
@@ -384,37 +395,32 @@ def refine_question_agent(raw_question: str, dataset_context: str | None = None)
|
|
|
384
395
|
return "Configure LLM Profiles or contact your administrator."
|
|
385
396
|
|
|
386
397
|
system_prompt = ("""
|
|
387
|
-
- You are a Machine Learning (ML) and Data Science (DS) expert.
|
|
388
|
-
- Your goal is to
|
|
389
|
-
-
|
|
390
|
-
-
|
|
391
|
-
- DO NOT include any prelude or preamble. Just the
|
|
392
|
-
- If and only if the dataset summary columns are not relevant to your desired columns that you deduced by analysing the question, and you suspect that the wrong dataset was used in the dataset summary, stop and just say: 'incompatible'.
|
|
398
|
+
- You are a Machine Learning (ML) and Data Science (DS) expert who detects incompatibilities between user questions and dataset summaries.
|
|
399
|
+
- Your goal is to analyze the question and the provided dataset summary to determine if they are compatible.
|
|
400
|
+
- If and only if the dataset summary columns are not relevant to your desired columns that you have deduced, by analysing the question, and you suspect that the wrong dataset was used in the dataset summary, you MUST STOP just say: 'incompatible'.
|
|
401
|
+
- If they are compatible, just 'compatible'.
|
|
402
|
+
- DO NOT include any prelude or preamble. Just the response: 'incompatible' or 'compatible'.
|
|
393
403
|
""")
|
|
394
404
|
|
|
395
|
-
user_prompt = f"User question:\n{
|
|
405
|
+
user_prompt = f"User question:\n{question}\n\n"
|
|
396
406
|
if dataset_context:
|
|
397
407
|
user_prompt += f"Dataset summary:\n{dataset_context}\n"
|
|
398
408
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
return "ERROR"
|
|
402
|
-
|
|
403
|
-
_refiner_profile['client'] = _prof.get_client(_refiner_profile)
|
|
404
|
-
|
|
405
|
-
refined_question = response_agent(user_prompt, system_prompt, _refiner_profile, temp=0.0, max_tokens=128)
|
|
406
|
-
return refined_question
|
|
409
|
+
compatibility = compatibility_response(user_prompt, system_prompt, temp=0.0, max_tokens=120)
|
|
410
|
+
return compatibility
|
|
407
411
|
|
|
408
412
|
|
|
409
413
|
def classify_ml_job_agent(refined_question, dataset_profile):
|
|
410
414
|
import ast
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
415
|
+
|
|
416
|
+
_profile = _prof.get_profile('classifier') or _prof.get_profile('admin')
|
|
417
|
+
_profile['client'] = _prof.get_client(_profile)
|
|
418
|
+
_client = _profile['client']
|
|
419
|
+
_provider = _profile["provider"].lower()
|
|
420
|
+
_model = _profile["model"]
|
|
417
421
|
|
|
422
|
+
def ml_response(user_prompt, system_prompt):
|
|
423
|
+
|
|
418
424
|
prompt = user_prompt + "\n\n" + system_prompt
|
|
419
425
|
|
|
420
426
|
# Google GenAI
|
|
@@ -520,10 +526,17 @@ def classify_ml_job_agent(refined_question, dataset_profile):
|
|
|
520
526
|
return "Configure LLM Profiles or contact your administrator."
|
|
521
527
|
|
|
522
528
|
system_prompt = ("""
|
|
523
|
-
You are
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
529
|
+
"You are an expert ML task extractor. Your job is to analyze the user's task description and extract every implied or explicit machine learning (ML) task that would be necessary to accomplish the user's goals. Use the provided list of ML tasks as your reference for classification.
|
|
530
|
+
Core Instruction:
|
|
531
|
+
Extract every implied or explicit ML task. Format the response solely as a simple, flat list of tasks. Use concise, imperative verbs. Do not include explanations, examples, preludes, conclusions, or any non-task text.
|
|
532
|
+
|
|
533
|
+
Extraction Rules:
|
|
534
|
+
Ignore all context-setting, descriptions, goals, and commentary.
|
|
535
|
+
Convert every actionable step, data operation, and visualization generation into a discrete task.
|
|
536
|
+
Generalize any dataset-specific terms (e.g., column names) to their functional purpose.
|
|
537
|
+
Treat "CoT" or reasoning steps as a source for data preparation tasks.
|
|
538
|
+
Do not include steps like "No Scaling required" or "No Modeling required" as tasks.
|
|
539
|
+
Output only the list. No titles, headers, numbering or bullet points.
|
|
527
540
|
""")
|
|
528
541
|
|
|
529
542
|
# --- 1. Define the Master List of ML Tasks (Generalized) ---
|
|
@@ -568,24 +581,1106 @@ def classify_ml_job_agent(refined_question, dataset_profile):
|
|
|
568
581
|
if dataset_profile:
|
|
569
582
|
user_prompt += f"\nDataset profile:\n{dataset_profile}\n"
|
|
570
583
|
|
|
571
|
-
llm_profile = _prof.get_profile("classification") or _prof.get_profile("admin")
|
|
572
|
-
if not llm_profile:
|
|
573
|
-
return (
|
|
574
|
-
"<div class='smx-alert smx-alert-warn'>"
|
|
575
|
-
"No LLM profile is configured for Classification. Please, do that in the Admin panel or contact your Administrator."
|
|
576
|
-
"</div>"
|
|
577
|
-
)
|
|
578
|
-
|
|
579
584
|
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
tasks = ml_response(user_prompt, system_prompt, llm_profile)
|
|
585
|
+
tasks = ml_response(user_prompt, system_prompt)
|
|
583
586
|
try:
|
|
584
587
|
return ast.literal_eval(tasks)
|
|
585
588
|
except Exception:
|
|
586
589
|
return tasks
|
|
587
590
|
|
|
588
591
|
|
|
592
|
+
# ─────────────────────────────────────────────────────────
|
|
593
|
+
# Agentic Page Generation (plan JSON → validate → Pixabay → compile HTML)
|
|
594
|
+
# ─────────────────────────────────────────────────────────
|
|
595
|
+
def agentic_generate_page(*,
|
|
596
|
+
page_slug: str,
|
|
597
|
+
website_description: str,
|
|
598
|
+
client_dir: str,
|
|
599
|
+
pixabay_api_key: str = "",
|
|
600
|
+
llm_profile: dict | None = None,
|
|
601
|
+
max_retries: int = 2,
|
|
602
|
+
max_images: int = 9,
|
|
603
|
+
) -> dict:
|
|
604
|
+
"""
|
|
605
|
+
Returns:
|
|
606
|
+
{
|
|
607
|
+
"slug": "<slug>",
|
|
608
|
+
"plan": <dict>,
|
|
609
|
+
"html": "<compiled html>",
|
|
610
|
+
"notes": [..]
|
|
611
|
+
}
|
|
612
|
+
"""
|
|
613
|
+
|
|
614
|
+
_ICON_SVGS = {
|
|
615
|
+
"spark": '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">'
|
|
616
|
+
'<path d="M12 2l1.2 6.2L20 12l-6.8 3.8L12 22l-1.2-6.2L4 12l6.8-3.8L12 2z"/></svg>',
|
|
617
|
+
"shield": '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">'
|
|
618
|
+
'<path d="M12 2l7 4v6c0 5-3.5 9-7 10-3.5-1-7-5-7-10V6l7-4z"/></svg>',
|
|
619
|
+
"stack": '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">'
|
|
620
|
+
'<path d="M12 2l9 5-9 5-9-5 9-5z"/><path d="M3 12l9 5 9-5"/><path d="M3 17l9 5 9-5"/></svg>',
|
|
621
|
+
"chart": '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">'
|
|
622
|
+
'<path d="M3 3v18h18"/><path d="M7 14v4"/><path d="M12 10v8"/><path d="M17 6v12"/></svg>',
|
|
623
|
+
"rocket": '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">'
|
|
624
|
+
'<path d="M5 13l4 6 6-4c6-4 5-12 5-12S13 2 9 8l-4 5z"/><path d="M9 8l7 7"/>'
|
|
625
|
+
'<path d="M5 13l-2 2"/><path d="M11 19l-2 2"/></svg>',
|
|
626
|
+
"plug": '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">'
|
|
627
|
+
'<path d="M9 2v6"/><path d="M15 2v6"/><path d="M7 8h10"/>'
|
|
628
|
+
'<path d="M12 8v7a4 4 0 0 1-4 4H7"/><path d="M12 8v7a4 4 0 0 0 4 4h1"/></svg>',
|
|
629
|
+
"arrow": '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">'
|
|
630
|
+
'<path d="M5 12h12"/><path d="M13 6l6 6-6 6"/></svg>',
|
|
631
|
+
"users": '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">'
|
|
632
|
+
'<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>'
|
|
633
|
+
'<circle cx="9" cy="7" r="4"/>'
|
|
634
|
+
'<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>'
|
|
635
|
+
'<path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>',
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
_PLACEHOLDER_PATTERNS = [
|
|
639
|
+
r"\blorem\b",
|
|
640
|
+
r"\bplaceholder\b",
|
|
641
|
+
r"coming soon",
|
|
642
|
+
r"add (?:a|your|an)\b",
|
|
643
|
+
r"replace this",
|
|
644
|
+
r"insert (?:your|a)\b",
|
|
645
|
+
r"dummy text",
|
|
646
|
+
r"example (?:text|copy)\b",
|
|
647
|
+
]
|
|
648
|
+
|
|
649
|
+
_PX_BANNED_TAGS = {
|
|
650
|
+
"shoe", "shoes", "sneaker", "sneakers", "footwear", "fashion",
|
|
651
|
+
"lingerie", "bikini", "underwear", "swimwear",
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _strip(s: str) -> str:
|
|
656
|
+
return (s or "").strip()
|
|
657
|
+
|
|
658
|
+
def _slugify(s: str) -> str:
|
|
659
|
+
s = _strip(s).lower()
|
|
660
|
+
s = re.sub(r"[^a-z0-9\s\-]", "", s)
|
|
661
|
+
s = re.sub(r"\s+", "-", s).strip("-")
|
|
662
|
+
s = re.sub(r"-{2,}", "-", s)
|
|
663
|
+
return s or "page"
|
|
664
|
+
|
|
665
|
+
def _title_from_slug(slug: str) -> str:
|
|
666
|
+
t = _strip(slug).replace("-", " ").replace("_", " ")
|
|
667
|
+
t = re.sub(r"\s+", " ", t)
|
|
668
|
+
return (t[:1].upper() + t[1:]) if t else "New page"
|
|
669
|
+
|
|
670
|
+
def _contains_placeholders(text: str) -> bool:
|
|
671
|
+
t = (text or "").lower()
|
|
672
|
+
for pat in _PLACEHOLDER_PATTERNS:
|
|
673
|
+
if re.search(pat, t):
|
|
674
|
+
return True
|
|
675
|
+
return False
|
|
676
|
+
|
|
677
|
+
def _extract_domain_keywords(website_description: str, max_terms: int = 6) -> list[str]:
|
|
678
|
+
"""
|
|
679
|
+
Very lightweight keyword extraction to keep Pixabay queries on-topic.
|
|
680
|
+
No ML needed: just pick frequent meaningful tokens.
|
|
681
|
+
"""
|
|
682
|
+
wd = (website_description or "").lower()
|
|
683
|
+
wd = re.sub(r"[^a-z0-9\s\-]", " ", wd)
|
|
684
|
+
toks = [t for t in re.split(r"\s+", wd) if 3 <= len(t) <= 18]
|
|
685
|
+
stop = {
|
|
686
|
+
"this", "that", "with", "from", "into", "your", "their", "have", "will",
|
|
687
|
+
"also", "more", "than", "them", "such", "only", "when", "where", "which",
|
|
688
|
+
"what", "about", "page", "website", "company", "product", "service",
|
|
689
|
+
"syntaxmatrix", "framework", "system", "platform",
|
|
690
|
+
}
|
|
691
|
+
freq = {}
|
|
692
|
+
for t in toks:
|
|
693
|
+
if t in stop:
|
|
694
|
+
continue
|
|
695
|
+
freq[t] = freq.get(t, 0) + 1
|
|
696
|
+
ranked = sorted(freq.items(), key=lambda x: x[1], reverse=True)
|
|
697
|
+
out = [k for k, _ in ranked[:max_terms]]
|
|
698
|
+
# Always anchor to software/AI semantics if present
|
|
699
|
+
anchors = []
|
|
700
|
+
for a in ["ai", "assistant", "dashboard", "retrieval", "vector", "ml", "analytics", "deployment"]:
|
|
701
|
+
if a in wd and a not in out:
|
|
702
|
+
anchors.append(a)
|
|
703
|
+
return (anchors + out)[:max_terms]
|
|
704
|
+
|
|
705
|
+
def _get_json_call(system_prompt: str, user_prompt: str) -> dict:
|
|
706
|
+
|
|
707
|
+
llm_profile = _prof.get_profile('coder')
|
|
708
|
+
llm_profile['client'] = _prof.get_client(llm_profile)
|
|
709
|
+
client = llm_profile["client"]
|
|
710
|
+
model = llm_profile["model"]
|
|
711
|
+
provider = llm_profile["provider"].lower()
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def openai_sdk_response():
|
|
715
|
+
resp = client.chat.completions.create(
|
|
716
|
+
model=model,
|
|
717
|
+
messages=[
|
|
718
|
+
{"role": "system", "content": system_prompt},
|
|
719
|
+
{"role": "user", "content": user_prompt},
|
|
720
|
+
],
|
|
721
|
+
response_format={"type": "json_object"},
|
|
722
|
+
)
|
|
723
|
+
|
|
724
|
+
# Access the text via choices[0].message.content
|
|
725
|
+
txt = resp.choices[0].message.content.strip()
|
|
726
|
+
|
|
727
|
+
try:
|
|
728
|
+
return json.loads(txt)
|
|
729
|
+
except Exception:
|
|
730
|
+
# try to salvage first JSON object (consistent with your other providers)
|
|
731
|
+
m = re.search(r"\{.*\}", txt, re.S)
|
|
732
|
+
if not m:
|
|
733
|
+
raise RuntimeError(f"Model did not return JSON. Output was:\n{txt[:800]}")
|
|
734
|
+
return json.loads(m.group(0))
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
if provider == "google":
|
|
738
|
+
cfg = types.GenerateContentConfig(
|
|
739
|
+
system_instruction=system_prompt,
|
|
740
|
+
response_mime_type="application/json",
|
|
741
|
+
)
|
|
742
|
+
resp = client.models.generate_content(
|
|
743
|
+
model=model,
|
|
744
|
+
contents=user_prompt,
|
|
745
|
+
config=cfg,
|
|
746
|
+
)
|
|
747
|
+
txt = (resp.text or "").strip()
|
|
748
|
+
try:
|
|
749
|
+
return json.loads(txt)
|
|
750
|
+
except Exception:
|
|
751
|
+
# try to salvage first JSON object
|
|
752
|
+
m = re.search(r"\{.*\}", txt, re.S)
|
|
753
|
+
if not m:
|
|
754
|
+
raise RuntimeError(f"Model did not return JSON. Output was:\n{txt[:800]}")
|
|
755
|
+
return json.loads(m.group(0))
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
if provider == "openai":
|
|
759
|
+
if int(model.split("gpt-")[1][0])>=5:
|
|
760
|
+
response = client.responses.create(
|
|
761
|
+
model=model,
|
|
762
|
+
instructions=system_prompt,
|
|
763
|
+
input=[
|
|
764
|
+
{"role": "user", "content": user_prompt}
|
|
765
|
+
],
|
|
766
|
+
reasoning={"effort": "medium"},
|
|
767
|
+
text=[
|
|
768
|
+
{"verbosity": "low"},
|
|
769
|
+
{"format": {"type": "json_object"}}
|
|
770
|
+
],
|
|
771
|
+
)
|
|
772
|
+
|
|
773
|
+
txt = (response.output_text or "")
|
|
774
|
+
try:
|
|
775
|
+
return json.loads(txt)
|
|
776
|
+
except Exception:
|
|
777
|
+
# try to salvage first JSON object
|
|
778
|
+
m = re.search(r"\{.*\}", txt, re.S)
|
|
779
|
+
if not m:
|
|
780
|
+
raise RuntimeError(f"Model did not return JSON. Output was:\n{txt[:800]}")
|
|
781
|
+
return json.loads(m.group(0))
|
|
782
|
+
|
|
783
|
+
else:
|
|
784
|
+
return openai_sdk_response()
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
if provider == "anthropic":
|
|
788
|
+
# Anthropic requires a max_tokens parameter
|
|
789
|
+
resp = client.messages.create(
|
|
790
|
+
model=model,
|
|
791
|
+
system=system_prompt,
|
|
792
|
+
messages=[
|
|
793
|
+
{"role": "user", "content": user_prompt}
|
|
794
|
+
],
|
|
795
|
+
max_tokens=4096,
|
|
796
|
+
)
|
|
797
|
+
|
|
798
|
+
# Anthropic returns a list of content blocks
|
|
799
|
+
txt = resp.content[0].text.strip()
|
|
800
|
+
|
|
801
|
+
try:
|
|
802
|
+
return json.loads(txt)
|
|
803
|
+
except Exception:
|
|
804
|
+
# try to salvage first JSON object (same logic as your Google snippet)
|
|
805
|
+
m = re.search(r"\{.*\}", txt, re.S)
|
|
806
|
+
if not m:
|
|
807
|
+
raise RuntimeError(f"Model did not return JSON. Output was:\n{txt[:800]}")
|
|
808
|
+
return json.loads(m.group(0))
|
|
809
|
+
|
|
810
|
+
return openai_sdk_response()
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def _page_plan_system_prompt(spec: dict) -> str:
|
|
814
|
+
allowed_sections = spec.get("allowed_section_types") or ["hero", "features", "gallery", "testimonials", "faq", "cta", "richtext"]
|
|
815
|
+
req = spec.get("required_sections") or []
|
|
816
|
+
|
|
817
|
+
req_lines = ""
|
|
818
|
+
if req:
|
|
819
|
+
req_lines = "REQUIRED SECTIONS (must appear in this exact order):\n" + "\n".join(
|
|
820
|
+
[f"- {r['id']} (type: {r['type']})" for r in req]
|
|
821
|
+
)
|
|
822
|
+
|
|
823
|
+
return f"""
|
|
824
|
+
You are a senior UX designer + product copywriter for modern software websites.
|
|
825
|
+
|
|
826
|
+
TASK:
|
|
827
|
+
Create a complete page plan (content + structure) for a page builder.
|
|
828
|
+
|
|
829
|
+
RULES (strict):
|
|
830
|
+
- No placeholders, no “add your…”, no “replace this…”, no “lorem ipsum”, no “coming soon”.
|
|
831
|
+
- All copy must be final, meaningful, and grounded in the provided WEBSITE_DESCRIPTION.
|
|
832
|
+
- Produce a page that looks like a finished, publish-ready website page.
|
|
833
|
+
- Choose section types and item types from the allowed lists.
|
|
834
|
+
- Choose icon names only from the allowed icon list.
|
|
835
|
+
- Provide image search queries for items that need images. Keep queries on-topic (software/AI/tech).
|
|
836
|
+
|
|
837
|
+
{req_lines}
|
|
838
|
+
|
|
839
|
+
OUTPUT:
|
|
840
|
+
Return ONLY valid JSON.
|
|
841
|
+
|
|
842
|
+
ALLOWED SECTION TYPES:
|
|
843
|
+
{chr(10).join([f"- {t}" for t in allowed_sections])}
|
|
844
|
+
|
|
845
|
+
ALLOWED ITEM TYPES:
|
|
846
|
+
- card
|
|
847
|
+
- quote
|
|
848
|
+
- faq
|
|
849
|
+
|
|
850
|
+
ALLOWED ICONS:
|
|
851
|
+
- spark, shield, stack, chart, rocket, plug, arrow, users
|
|
852
|
+
|
|
853
|
+
JSON SCHEMA:
|
|
854
|
+
{{
|
|
855
|
+
"page": "<slug>",
|
|
856
|
+
"category": "<string>",
|
|
857
|
+
"template": {{ "id": "<string>", "version": "<string>" }},
|
|
858
|
+
"meta": {{
|
|
859
|
+
"pageTitle": "<string>",
|
|
860
|
+
"summary": "<string>"
|
|
861
|
+
}},
|
|
862
|
+
"sections": [
|
|
863
|
+
{{
|
|
864
|
+
"id": "<string>",
|
|
865
|
+
"type": "<sectionType>",
|
|
866
|
+
"title": "<string>",
|
|
867
|
+
"text": "<string>",
|
|
868
|
+
"cols": 1-5,
|
|
869
|
+
"items": [
|
|
870
|
+
{{
|
|
871
|
+
"id": "<string>",
|
|
872
|
+
"type": "<itemType>",
|
|
873
|
+
"title": "<string>",
|
|
874
|
+
"text": "<string>",
|
|
875
|
+
"icon": "<iconName or empty>",
|
|
876
|
+
"imgQuery": "<search query or empty>",
|
|
877
|
+
"needsImage": true|false
|
|
878
|
+
}}
|
|
879
|
+
]
|
|
880
|
+
}}
|
|
881
|
+
]
|
|
882
|
+
}}
|
|
883
|
+
|
|
884
|
+
GUIDANCE:
|
|
885
|
+
- Keep sections between {spec.get("min_sections", 4)} and {spec.get("max_sections", 7)}.
|
|
886
|
+
- Keep total images between {spec.get("min_images", 6)} and {spec.get("max_images", 9)}.
|
|
887
|
+
""".strip()
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
def _make_page_plan(*, page_slug: str, website_description: str, template_spec: dict) -> dict:
|
|
891
|
+
slug = _slugify(page_slug)
|
|
892
|
+
wd = _strip(website_description)
|
|
893
|
+
if not wd:
|
|
894
|
+
raise ValueError("website_description is empty. Pass smx.website_description if the form field is blank.")
|
|
895
|
+
|
|
896
|
+
domain_terms = _extract_domain_keywords(wd)
|
|
897
|
+
user_prompt = json.dumps({
|
|
898
|
+
"PAGE_SLUG": slug,
|
|
899
|
+
"PAGE_TITLE": _title_from_slug(slug),
|
|
900
|
+
"WEBSITE_DESCRIPTION": wd,
|
|
901
|
+
"DOMAIN_KEYWORDS": domain_terms,
|
|
902
|
+
"HARD_REQUIREMENTS": {
|
|
903
|
+
"no_placeholders": True,
|
|
904
|
+
"uk_english": True,
|
|
905
|
+
"min_sections": 4,
|
|
906
|
+
"max_sections": 7,
|
|
907
|
+
"min_images": 6,
|
|
908
|
+
"max_images": 9
|
|
909
|
+
}
|
|
910
|
+
}, indent=2)
|
|
911
|
+
|
|
912
|
+
plan = _get_json_call(
|
|
913
|
+
system_prompt=_page_plan_system_prompt(template_spec),
|
|
914
|
+
user_prompt=user_prompt
|
|
915
|
+
)
|
|
916
|
+
|
|
917
|
+
plan["page"] = slug
|
|
918
|
+
plan["category"] = template_spec["category"]
|
|
919
|
+
plan["template"] = template_spec["template"]
|
|
920
|
+
|
|
921
|
+
# Normalise a few fields
|
|
922
|
+
plan["page"] = slug
|
|
923
|
+
if "sections" not in plan or not isinstance(plan["sections"], list):
|
|
924
|
+
raise RuntimeError("Invalid plan: missing sections[]")
|
|
925
|
+
|
|
926
|
+
return plan
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
def _validate_plan_or_raise(plan: dict) -> None:
|
|
930
|
+
if not isinstance(plan, dict):
|
|
931
|
+
raise ValueError("Plan is not a dict.")
|
|
932
|
+
|
|
933
|
+
if not plan.get("page"):
|
|
934
|
+
raise ValueError("Plan missing 'page'.")
|
|
935
|
+
|
|
936
|
+
secs = plan.get("sections")
|
|
937
|
+
if not isinstance(secs, list) or len(secs) < 3:
|
|
938
|
+
raise ValueError("Plan must have at least 3 sections.")
|
|
939
|
+
|
|
940
|
+
total_imgs = 0
|
|
941
|
+
for s in secs:
|
|
942
|
+
if not isinstance(s, dict):
|
|
943
|
+
raise ValueError("Section is not an object.")
|
|
944
|
+
if _contains_placeholders(s.get("title", "")) or _contains_placeholders(s.get("text", "")):
|
|
945
|
+
raise ValueError("Plan contains placeholder text in section title/text.")
|
|
946
|
+
|
|
947
|
+
items = s.get("items") or []
|
|
948
|
+
if not isinstance(items, list):
|
|
949
|
+
raise ValueError("Section items must be a list.")
|
|
950
|
+
for it in items:
|
|
951
|
+
if not isinstance(it, dict):
|
|
952
|
+
raise ValueError("Item is not an object.")
|
|
953
|
+
if _contains_placeholders(it.get("title", "")) or _contains_placeholders(it.get("text", "")):
|
|
954
|
+
raise ValueError("Plan contains placeholder text in item title/text.")
|
|
955
|
+
if it.get("needsImage"):
|
|
956
|
+
total_imgs += 1
|
|
957
|
+
|
|
958
|
+
if total_imgs < 4:
|
|
959
|
+
raise ValueError("Plan is too light on imagery; needs at least 4 items marked needsImage=true.")
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
def _repair_plan(*, plan: dict, error_msg: str, website_description: str) -> dict:
|
|
963
|
+
# Ask Gemini to repair the plan, not recreate randomly.
|
|
964
|
+
system_prompt = _page_plan_system_prompt() + "\n\nYou are repairing an existing plan. Keep it consistent and improve only what is needed."
|
|
965
|
+
user_prompt = json.dumps({
|
|
966
|
+
"ERROR": error_msg,
|
|
967
|
+
"WEBSITE_DESCRIPTION": website_description,
|
|
968
|
+
"PLAN": plan
|
|
969
|
+
}, indent=2)
|
|
970
|
+
|
|
971
|
+
fixed = _get_json_call(
|
|
972
|
+
system_prompt=system_prompt,
|
|
973
|
+
user_prompt=user_prompt
|
|
974
|
+
)
|
|
975
|
+
fixed["page"] = plan.get("page") or fixed.get("page")
|
|
976
|
+
return fixed
|
|
977
|
+
|
|
978
|
+
def _ensure_hero_image(plan: dict, default_url: str = "/static/assets/hero-default.svg") -> None:
|
|
979
|
+
"""Guarantee hero.imageUrl exists so contract validation never fails."""
|
|
980
|
+
sections = plan.get("sections") if isinstance(plan.get("sections"), list) else []
|
|
981
|
+
hero = next((s for s in sections if isinstance(s, dict) and (s.get("type") or "").lower() == "hero"), None)
|
|
982
|
+
if not hero:
|
|
983
|
+
return
|
|
984
|
+
|
|
985
|
+
def _first_image_in_items(items):
|
|
986
|
+
if not isinstance(items, list):
|
|
987
|
+
return ""
|
|
988
|
+
for it in items:
|
|
989
|
+
if not isinstance(it, dict):
|
|
990
|
+
continue
|
|
991
|
+
u = (it.get("imageUrl") or "").strip()
|
|
992
|
+
if u:
|
|
993
|
+
return u
|
|
994
|
+
return ""
|
|
995
|
+
|
|
996
|
+
# 1) Use hero.imageUrl if present
|
|
997
|
+
img = (hero.get("imageUrl") or "").strip()
|
|
998
|
+
|
|
999
|
+
# 2) Else use hero.items[*].imageUrl
|
|
1000
|
+
if not img:
|
|
1001
|
+
img = _first_image_in_items(hero.get("items"))
|
|
1002
|
+
|
|
1003
|
+
# 3) Else use first image anywhere else in the plan
|
|
1004
|
+
if not img:
|
|
1005
|
+
for s in sections:
|
|
1006
|
+
if not isinstance(s, dict):
|
|
1007
|
+
continue
|
|
1008
|
+
img = _first_image_in_items(s.get("items"))
|
|
1009
|
+
if img:
|
|
1010
|
+
break
|
|
1011
|
+
|
|
1012
|
+
# 4) Final fallback
|
|
1013
|
+
if not img:
|
|
1014
|
+
img = default_url
|
|
1015
|
+
|
|
1016
|
+
hero["imageUrl"] = img
|
|
1017
|
+
|
|
1018
|
+
# Back-compat: ensure hero.items[0].imageUrl exists too (your normaliser also does this)
|
|
1019
|
+
items = hero.get("items") if isinstance(hero.get("items"), list) else []
|
|
1020
|
+
if items:
|
|
1021
|
+
if not (items[0].get("imageUrl") or "").strip():
|
|
1022
|
+
items[0]["imageUrl"] = img
|
|
1023
|
+
else:
|
|
1024
|
+
hero["items"] = [{"id": "hero_media", "type": "card", "title": "Hero image", "text": "", "imageUrl": img}]
|
|
1025
|
+
|
|
1026
|
+
TEMPLATE_SPECS = {
|
|
1027
|
+
"generic_v1": {
|
|
1028
|
+
"category": "landing",
|
|
1029
|
+
"template": {"id": "generic_v1", "version": "1.0.0"},
|
|
1030
|
+
"allowed_section_types": ["hero", "features", "gallery", "testimonials", "faq", "cta", "richtext"],
|
|
1031
|
+
"required_sections": [],
|
|
1032
|
+
"min_sections": 4,
|
|
1033
|
+
"max_sections": 7,
|
|
1034
|
+
"min_images": 6,
|
|
1035
|
+
"max_images": 9,
|
|
1036
|
+
},
|
|
1037
|
+
"services_grid_v1": {
|
|
1038
|
+
"category": "services",
|
|
1039
|
+
"template": {"id": "services_grid_v1", "version": "1.0.0"},
|
|
1040
|
+
"allowed_section_types": ["hero", "services", "process", "proof", "faq", "cta", "richtext"],
|
|
1041
|
+
"required_sections": [
|
|
1042
|
+
{"id": "sec_hero", "type": "hero"},
|
|
1043
|
+
{"id": "sec_services", "type": "services"},
|
|
1044
|
+
{"id": "sec_process", "type": "process"},
|
|
1045
|
+
{"id": "sec_proof", "type": "proof"},
|
|
1046
|
+
{"id": "sec_faq", "type": "faq"},
|
|
1047
|
+
{"id": "sec_cta", "type": "cta"},
|
|
1048
|
+
],
|
|
1049
|
+
"min_sections": 6,
|
|
1050
|
+
"max_sections": 6,
|
|
1051
|
+
"min_images": 6,
|
|
1052
|
+
"max_images": 9,
|
|
1053
|
+
},
|
|
1054
|
+
"services_detail_v1": {
|
|
1055
|
+
"category": "services",
|
|
1056
|
+
"template": {"id": "services_detail_v1", "version": "1.0.0"},
|
|
1057
|
+
"allowed_section_types": ["hero", "offers", "comparison", "process", "case_studies", "faq", "cta", "richtext"],
|
|
1058
|
+
"required_sections": [
|
|
1059
|
+
{"id": "sec_hero", "type": "hero"},
|
|
1060
|
+
{"id": "sec_offers", "type": "offers"},
|
|
1061
|
+
{"id": "sec_comparison", "type": "comparison"},
|
|
1062
|
+
{"id": "sec_process", "type": "process"},
|
|
1063
|
+
{"id": "sec_case_studies", "type": "case_studies"},
|
|
1064
|
+
{"id": "sec_faq", "type": "faq"},
|
|
1065
|
+
{"id": "sec_cta", "type": "cta"},
|
|
1066
|
+
],
|
|
1067
|
+
"min_sections": 7,
|
|
1068
|
+
"max_sections": 7,
|
|
1069
|
+
"min_images": 6,
|
|
1070
|
+
"max_images": 9,
|
|
1071
|
+
},
|
|
1072
|
+
"about_glass_hero_v1": {
|
|
1073
|
+
"category": "about",
|
|
1074
|
+
"template": {"id": "about_glass_hero_v1", "version": "1.0.0"},
|
|
1075
|
+
"allowed_section_types": ["hero", "story", "values", "logos", "team", "testimonials", "faq", "cta", "richtext"],
|
|
1076
|
+
"required_sections": [
|
|
1077
|
+
{"id": "sec_hero", "type": "hero"},
|
|
1078
|
+
{"id": "sec_story", "type": "story"},
|
|
1079
|
+
{"id": "sec_values", "type": "values"},
|
|
1080
|
+
{"id": "sec_cta", "type": "cta"},
|
|
1081
|
+
],
|
|
1082
|
+
"min_sections": 4,
|
|
1083
|
+
"max_sections": 7,
|
|
1084
|
+
"min_images": 6,
|
|
1085
|
+
"max_images": 9,
|
|
1086
|
+
},
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
def _select_template_spec(slug: str) -> dict:
|
|
1090
|
+
s = _slugify(slug)
|
|
1091
|
+
if "service" in s:
|
|
1092
|
+
if any(k in s for k in ("pricing", "plan", "plans", "package", "packages", "tier", "tiers")):
|
|
1093
|
+
return TEMPLATE_SPECS["services_detail_v1"]
|
|
1094
|
+
return TEMPLATE_SPECS["services_grid_v1"]
|
|
1095
|
+
if "about" in s:
|
|
1096
|
+
return TEMPLATE_SPECS["about_glass_hero_v1"]
|
|
1097
|
+
return TEMPLATE_SPECS["generic_v1"]
|
|
1098
|
+
|
|
1099
|
+
|
|
1100
|
+
PIXABAY_API_URL = "https://pixabay.com/api/"
|
|
1101
|
+
|
|
1102
|
+
def _pixabay_search(api_key: str, query: str, *, category: str = "AI", per_page: int = 20, timeout: int = 15) -> list[dict]:
|
|
1103
|
+
q = _strip(query)
|
|
1104
|
+
if not api_key or not q:
|
|
1105
|
+
return []
|
|
1106
|
+
params = {
|
|
1107
|
+
"key": api_key,
|
|
1108
|
+
"q": q,
|
|
1109
|
+
"image_type": "photo",
|
|
1110
|
+
"orientation": "horizontal",
|
|
1111
|
+
"safesearch": "true",
|
|
1112
|
+
"editors_choice": "false",
|
|
1113
|
+
"order": "popular",
|
|
1114
|
+
"category": category or "AI" or "Artificial Intelligence" or "computer",
|
|
1115
|
+
"per_page": max(3, min(200, int(per_page or 20))),
|
|
1116
|
+
"page": 1,
|
|
1117
|
+
}
|
|
1118
|
+
r = requests.get(PIXABAY_API_URL, params=params, timeout=timeout)
|
|
1119
|
+
r.raise_for_status()
|
|
1120
|
+
data = r.json() or {}
|
|
1121
|
+
return data.get("hits") or []
|
|
1122
|
+
|
|
1123
|
+
|
|
1124
|
+
def _is_pixabay_url(url: str) -> bool:
|
|
1125
|
+
u = _strip(url).lower()
|
|
1126
|
+
return u.startswith("https://") and ("pixabay.com" in u)
|
|
1127
|
+
|
|
1128
|
+
|
|
1129
|
+
def _fetch_bytes(url: str, timeout: int = 20) -> bytes:
|
|
1130
|
+
if not _is_pixabay_url(url):
|
|
1131
|
+
raise ValueError("Only Pixabay URLs are allowed")
|
|
1132
|
+
r = requests.get(url, stream=True, timeout=timeout)
|
|
1133
|
+
r.raise_for_status()
|
|
1134
|
+
return r.content
|
|
1135
|
+
|
|
1136
|
+
|
|
1137
|
+
def _save_image(img_bytes: bytes, out_path_no_ext: str, *, max_width: int = 1920) -> str:
|
|
1138
|
+
img = Image.open(io.BytesIO(img_bytes))
|
|
1139
|
+
img.load()
|
|
1140
|
+
|
|
1141
|
+
if img.width > int(max_width or 1920):
|
|
1142
|
+
ratio = (int(max_width) / float(img.width))
|
|
1143
|
+
new_h = max(1, int(round(img.height * ratio)))
|
|
1144
|
+
img = img.resize((int(max_width), new_h), Image.LANCZOS)
|
|
1145
|
+
|
|
1146
|
+
has_alpha = ("A" in img.getbands())
|
|
1147
|
+
ext = ".png" if has_alpha else ".jpg"
|
|
1148
|
+
out_path = out_path_no_ext + ext
|
|
1149
|
+
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
|
1150
|
+
|
|
1151
|
+
if ext == ".jpg":
|
|
1152
|
+
rgb = img.convert("RGB") if img.mode != "RGB" else img
|
|
1153
|
+
rgb.save(out_path, "JPEG", quality=85, optimize=True, progressive=True)
|
|
1154
|
+
else:
|
|
1155
|
+
img.save(out_path, "PNG", optimize=True)
|
|
1156
|
+
|
|
1157
|
+
return out_path
|
|
1158
|
+
|
|
1159
|
+
|
|
1160
|
+
def _pick_pixabay_hit(hits: list[dict], *, min_width: int) -> dict | None:
|
|
1161
|
+
for h in hits:
|
|
1162
|
+
tags = (h.get("tags") or "").lower()
|
|
1163
|
+
if any(b in tags for b in _PX_BANNED_TAGS):
|
|
1164
|
+
continue
|
|
1165
|
+
w = int(h.get("imageWidth") or 0)
|
|
1166
|
+
if w >= int(min_width or 0):
|
|
1167
|
+
return h
|
|
1168
|
+
# fallback: first non-banned
|
|
1169
|
+
for h in hits:
|
|
1170
|
+
tags = (h.get("tags") or "").lower()
|
|
1171
|
+
if any(b in tags for b in _PX_BANNED_TAGS):
|
|
1172
|
+
continue
|
|
1173
|
+
return h
|
|
1174
|
+
return None
|
|
1175
|
+
|
|
1176
|
+
|
|
1177
|
+
def fill_plan_images_from_pixabay(plan: dict, *, api_key: str, client_dir: str, max_width: int = 1920, max_downloads: int = 9) -> dict:
|
|
1178
|
+
if not api_key:
|
|
1179
|
+
return plan
|
|
1180
|
+
|
|
1181
|
+
media_dir = os.path.join(client_dir, "uploads", "media")
|
|
1182
|
+
imported_dir = os.path.join(media_dir, "images", "imported")
|
|
1183
|
+
os.makedirs(imported_dir, exist_ok=True)
|
|
1184
|
+
|
|
1185
|
+
used_ids = set()
|
|
1186
|
+
downloads = 0
|
|
1187
|
+
|
|
1188
|
+
domain_terms = []
|
|
1189
|
+
try:
|
|
1190
|
+
meta = plan.get("meta") or {}
|
|
1191
|
+
domain_terms = _extract_domain_keywords(meta.get("summary") or "", max_terms=5)
|
|
1192
|
+
except Exception:
|
|
1193
|
+
domain_terms = []
|
|
1194
|
+
|
|
1195
|
+
for s in (plan.get("sections") or []):
|
|
1196
|
+
items = s.get("items") or []
|
|
1197
|
+
for it in items:
|
|
1198
|
+
if downloads >= max_downloads:
|
|
1199
|
+
return plan
|
|
1200
|
+
if not it.get("needsImage"):
|
|
1201
|
+
continue
|
|
1202
|
+
if _strip(it.get("imageUrl")):
|
|
1203
|
+
continue
|
|
1204
|
+
|
|
1205
|
+
q = _strip(it.get("imgQuery"))
|
|
1206
|
+
if not q:
|
|
1207
|
+
# if model forgot: make something safe and on-topic
|
|
1208
|
+
q = f"{_strip(it.get('title'))} software ai dashboard"
|
|
1209
|
+
|
|
1210
|
+
# keep the query on-domain
|
|
1211
|
+
if domain_terms:
|
|
1212
|
+
q = f"{q} " + " ".join(domain_terms[:3])
|
|
1213
|
+
|
|
1214
|
+
min_w = 1920 if (s.get("type") == "hero") else 1100
|
|
1215
|
+
|
|
1216
|
+
hits = _pixabay_search(api_key, q, category="computer")
|
|
1217
|
+
if not hits:
|
|
1218
|
+
continue
|
|
1219
|
+
|
|
1220
|
+
chosen = _pick_pixabay_hit(hits, min_width=min_w)
|
|
1221
|
+
if not chosen:
|
|
1222
|
+
continue
|
|
1223
|
+
|
|
1224
|
+
pid = int(chosen.get("id") or 0)
|
|
1225
|
+
if not pid or pid in used_ids:
|
|
1226
|
+
continue
|
|
1227
|
+
used_ids.add(pid)
|
|
1228
|
+
|
|
1229
|
+
web_u = _strip(chosen.get("webformatURL") or "")
|
|
1230
|
+
large_u = _strip(chosen.get("largeImageURL") or "")
|
|
1231
|
+
|
|
1232
|
+
base = os.path.join(imported_dir, f"pixabay-{pid}")
|
|
1233
|
+
existing = None
|
|
1234
|
+
for ext in (".jpg", ".png"):
|
|
1235
|
+
if os.path.exists(base + ext):
|
|
1236
|
+
existing = base + ext
|
|
1237
|
+
break
|
|
1238
|
+
|
|
1239
|
+
if existing:
|
|
1240
|
+
rel = os.path.relpath(existing, media_dir).replace("\\", "/")
|
|
1241
|
+
it["imageUrl"] = f"/uploads/media/{rel}"
|
|
1242
|
+
continue
|
|
1243
|
+
|
|
1244
|
+
try:
|
|
1245
|
+
b1 = _fetch_bytes(web_u)
|
|
1246
|
+
img1 = Image.open(io.BytesIO(b1)); img1.load()
|
|
1247
|
+
|
|
1248
|
+
chosen_bytes = b1
|
|
1249
|
+
if img1.width < min_w and large_u:
|
|
1250
|
+
try:
|
|
1251
|
+
b2 = _fetch_bytes(large_u)
|
|
1252
|
+
img2 = Image.open(io.BytesIO(b2)); img2.load()
|
|
1253
|
+
if img2.width > img1.width:
|
|
1254
|
+
chosen_bytes = b2
|
|
1255
|
+
except Exception:
|
|
1256
|
+
pass
|
|
1257
|
+
|
|
1258
|
+
saved = _save_image(chosen_bytes, base, max_width=max_width)
|
|
1259
|
+
rel = os.path.relpath(saved, media_dir).replace("\\", "/")
|
|
1260
|
+
it["imageUrl"] = f"/uploads/media/{rel}"
|
|
1261
|
+
downloads += 1
|
|
1262
|
+
except Exception:
|
|
1263
|
+
continue
|
|
1264
|
+
|
|
1265
|
+
return plan
|
|
1266
|
+
|
|
1267
|
+
|
|
1268
|
+
# ─────────────────────────────────────────────────────────
|
|
1269
|
+
# Compile plan JSON → modern HTML (responsive + animations)
|
|
1270
|
+
# ─────────────────────────────────────────────────────────
|
|
1271
|
+
def compile_plan_to_html(plan: dict) -> str:
|
|
1272
|
+
page_slug = _slugify(plan.get("page") or "page")
|
|
1273
|
+
page_id = f"smx-page-{page_slug}"
|
|
1274
|
+
|
|
1275
|
+
sections = list(plan.get("sections") or [])
|
|
1276
|
+
meta = plan.get("meta") or {}
|
|
1277
|
+
|
|
1278
|
+
# Useful anchor targets for CTAs
|
|
1279
|
+
sec_id_by_type = {}
|
|
1280
|
+
for s in sections:
|
|
1281
|
+
st = (s.get("type") or "").lower()
|
|
1282
|
+
sid = _strip(s.get("id"))
|
|
1283
|
+
if st and sid and st not in sec_id_by_type:
|
|
1284
|
+
sec_id_by_type[st] = sid
|
|
1285
|
+
|
|
1286
|
+
def esc(s: str) -> str:
|
|
1287
|
+
s = s or ""
|
|
1288
|
+
s = s.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
1289
|
+
s = s.replace('"', """).replace("'", "'")
|
|
1290
|
+
return s
|
|
1291
|
+
|
|
1292
|
+
def _btn(label: str, href: str, *, primary: bool = False) -> str:
|
|
1293
|
+
label = _strip(label)
|
|
1294
|
+
href = _strip(href)
|
|
1295
|
+
if not label or not href:
|
|
1296
|
+
return ""
|
|
1297
|
+
cls = "btn btn-primary" if primary else "btn"
|
|
1298
|
+
return f'<a class="{cls}" href="{esc(href)}">{esc(label)}</a>'
|
|
1299
|
+
|
|
1300
|
+
def icon(name: str) -> str:
|
|
1301
|
+
svg = _ICON_SVGS.get((name or "").strip().lower())
|
|
1302
|
+
if not svg:
|
|
1303
|
+
return ""
|
|
1304
|
+
return f'<span class="smx-ic">{svg}</span>'
|
|
1305
|
+
|
|
1306
|
+
css = f"""
|
|
1307
|
+
<style>
|
|
1308
|
+
#{page_id} {{
|
|
1309
|
+
--r: 18px;
|
|
1310
|
+
--bd: rgba(148,163,184,.25);
|
|
1311
|
+
--fg: #0f172a;
|
|
1312
|
+
--mut: #475569;
|
|
1313
|
+
--card: rgba(255,255,255,.78);
|
|
1314
|
+
--bg: #f8fafc;
|
|
1315
|
+
font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
|
|
1316
|
+
background: var(--bg);
|
|
1317
|
+
color: var(--fg);
|
|
1318
|
+
overflow-x: clip;
|
|
1319
|
+
}}
|
|
1320
|
+
@media (prefers-color-scheme: dark){{
|
|
1321
|
+
#{page_id} {{
|
|
1322
|
+
--fg: #e2e8f0;
|
|
1323
|
+
--mut: #a7b3c6;
|
|
1324
|
+
--card: rgba(2,6,23,.45);
|
|
1325
|
+
--bg: radial-gradient(circle at 20% 10%, rgba(30,64,175,.25), rgba(2,6,23,.95) 55%);
|
|
1326
|
+
--bd: rgba(148,163,184,.18);
|
|
1327
|
+
}}
|
|
1328
|
+
}}
|
|
1329
|
+
#{page_id} .wrap{{ max-width:1120px; margin:0 auto; padding:0 18px; }}
|
|
1330
|
+
#{page_id} .sec{{ padding:56px 0; }}
|
|
1331
|
+
#{page_id} .kicker{{ color:var(--mut); font-size:.92rem; margin:0 0 8px; }}
|
|
1332
|
+
#{page_id} h1{{ font-size:clamp(2rem,3.4vw,3.1rem); line-height:1.08; margin:0 0 12px; }}
|
|
1333
|
+
#{page_id} h2{{ font-size:clamp(1.35rem,2.2vw,1.95rem); margin:0 0 10px; }}
|
|
1334
|
+
#{page_id} p{{ margin:0; color:var(--mut); line-height:1.65; }}
|
|
1335
|
+
#{page_id} .hero{{ padding:0; }}
|
|
1336
|
+
#{page_id} .card{{ border:1px solid var(--bd); border-radius:var(--r); background:var(--card); padding:14px; }}
|
|
1337
|
+
#{page_id} .btnrow{{ display:flex; gap:10px; flex-wrap:wrap; margin-top:18px; }}
|
|
1338
|
+
#{page_id} .btn{{ display:inline-flex; gap:8px; align-items:center; border-radius:999px; padding:10px 14px;
|
|
1339
|
+
border:1px solid var(--bd); text-decoration:none; background: rgba(99,102,241,.12); color:inherit; }}
|
|
1340
|
+
#{page_id} .btn-primary{{ background: rgba(99,102,241,.22); border-color: rgba(99,102,241,.35); }}
|
|
1341
|
+
#{page_id} .btn:hover{{ transform: translateY(-1px); }}
|
|
1342
|
+
#{page_id} .grid{{ display:grid; gap:12px; }}
|
|
1343
|
+
#{page_id} img{{ width:100%; height:auto; border-radius: calc(var(--r) - 6px); display:block; }}
|
|
1344
|
+
#{page_id} .smx-ic{{ width:20px; height:20px; display:inline-block; opacity:.9; }}
|
|
1345
|
+
#{page_id} .smx-ic svg{{ width:20px; height:20px; }}
|
|
1346
|
+
|
|
1347
|
+
/* HERO BANNER */
|
|
1348
|
+
#{page_id} .hero-banner{{
|
|
1349
|
+
position:relative;
|
|
1350
|
+
width:100%;
|
|
1351
|
+
min-height:clamp(380px, 60vh, 680px);
|
|
1352
|
+
display:flex;
|
|
1353
|
+
align-items:flex-end;
|
|
1354
|
+
overflow:hidden;
|
|
1355
|
+
}}
|
|
1356
|
+
#{page_id} .hero-bg{{
|
|
1357
|
+
position:absolute; inset:0;
|
|
1358
|
+
background-position:center;
|
|
1359
|
+
background-size:cover;
|
|
1360
|
+
background-repeat:no-repeat;
|
|
1361
|
+
transform:scale(1.02);
|
|
1362
|
+
filter:saturate(1.02);
|
|
1363
|
+
}}
|
|
1364
|
+
#{page_id} .hero-overlay{{
|
|
1365
|
+
position:absolute; inset:0;
|
|
1366
|
+
background:linear-gradient(90deg,
|
|
1367
|
+
rgba(2,6,23,.62) 0%,
|
|
1368
|
+
rgba(2,6,23,.40) 42%,
|
|
1369
|
+
rgba(2,6,23,.14) 72%,
|
|
1370
|
+
rgba(2,6,23,.02) 100%
|
|
1371
|
+
);
|
|
1372
|
+
}}
|
|
1373
|
+
@media (max-width: 860px){{
|
|
1374
|
+
#{page_id} .hero-overlay{{
|
|
1375
|
+
background:linear-gradient(180deg,
|
|
1376
|
+
rgba(2,6,23,.16) 0%,
|
|
1377
|
+
rgba(2,6,23,.55) 70%,
|
|
1378
|
+
rgba(2,6,23,.70) 100%
|
|
1379
|
+
);
|
|
1380
|
+
}}
|
|
1381
|
+
}}
|
|
1382
|
+
#{page_id} .hero-content{{ position:relative; width:100%; padding:72px 18px 48px; }}
|
|
1383
|
+
#{page_id} .hero-panel{{
|
|
1384
|
+
max-width:700px;
|
|
1385
|
+
border:1px solid rgba(148,163,184,.30);
|
|
1386
|
+
background:rgba(2,6,23,.24);
|
|
1387
|
+
border-radius:var(--r);
|
|
1388
|
+
padding:18px;
|
|
1389
|
+
backdrop-filter: blur(4px);
|
|
1390
|
+
-webkit-backdrop-filter: blur(4px);
|
|
1391
|
+
box-shadow: 0 18px 40px rgba(2,6,23,.18);
|
|
1392
|
+
color:#e2e8f0;
|
|
1393
|
+
}}
|
|
1394
|
+
#{page_id} .hero-panel p{{ color:rgba(226,232,240,.84); }}
|
|
1395
|
+
#{page_id} .hero-panel h1{{ text-shadow:0 10px 30px rgba(2,6,23,.45); }}
|
|
1396
|
+
#{page_id} .hero-panel .kicker{{
|
|
1397
|
+
margin:0 0 8px;
|
|
1398
|
+
font-size:.9rem;
|
|
1399
|
+
color:#a5b4fc;
|
|
1400
|
+
text-transform:uppercase;
|
|
1401
|
+
letter-spacing:.18em;
|
|
1402
|
+
opacity:.95;
|
|
1403
|
+
}}
|
|
1404
|
+
#{page_id} .hero-panel .btn{{
|
|
1405
|
+
background:rgba(15,23,42,.55);
|
|
1406
|
+
border-color:rgba(148,163,184,.45);
|
|
1407
|
+
color:#e2e8f0;
|
|
1408
|
+
}}
|
|
1409
|
+
#{page_id} .hero-panel .btn-primary{{
|
|
1410
|
+
background:rgba(79,70,229,.92);
|
|
1411
|
+
border-color:rgba(129,140,248,.70);
|
|
1412
|
+
}}
|
|
1413
|
+
#{page_id} .lead{{ margin-top:10px; font-size:1.05rem; line-height:1.65; }}
|
|
1414
|
+
|
|
1415
|
+
/* FAQ */
|
|
1416
|
+
#{page_id} .faq details{{ border:1px solid var(--bd); border-radius:14px; background:var(--card); padding:12px 14px; }}
|
|
1417
|
+
#{page_id} .faq summary{{ cursor:pointer; font-weight:600; }}
|
|
1418
|
+
#{page_id} .faq details + details{{ margin-top:10px; }}
|
|
1419
|
+
|
|
1420
|
+
#{page_id} .quote{{ font-size:1.02rem; line-height:1.6; color:inherit; }}
|
|
1421
|
+
#{page_id} .mut{{ color:var(--mut); }}
|
|
1422
|
+
|
|
1423
|
+
#{page_id} .reveal{{ opacity:0; transform:translateY(14px); transition:opacity .55s ease, transform .55s ease; }}
|
|
1424
|
+
#{page_id} .reveal.in{{ opacity:1; transform:none; }}
|
|
1425
|
+
@media (prefers-reduced-motion: reduce){{ #{page_id} .reveal{{ transition:none; transform:none; opacity:1; }} }}
|
|
1426
|
+
</style>
|
|
1427
|
+
""".strip()
|
|
1428
|
+
|
|
1429
|
+
js = f"""
|
|
1430
|
+
<script>
|
|
1431
|
+
(function(){{
|
|
1432
|
+
const root = document.getElementById("{page_id}");
|
|
1433
|
+
if(!root) return;
|
|
1434
|
+
const els = root.querySelectorAll(".reveal");
|
|
1435
|
+
const io = new IntersectionObserver((entries)=>{{
|
|
1436
|
+
entries.forEach(e=>{{ if(e.isIntersecting) e.target.classList.add("in"); }});
|
|
1437
|
+
}}, {{ threshold: 0.12 }});
|
|
1438
|
+
els.forEach(el=>io.observe(el));
|
|
1439
|
+
}})();
|
|
1440
|
+
</script>
|
|
1441
|
+
""".strip()
|
|
1442
|
+
|
|
1443
|
+
parts = [f'<div id="{page_id}">', css]
|
|
1444
|
+
|
|
1445
|
+
for s in sections:
|
|
1446
|
+
st = (s.get("type") or "section").lower()
|
|
1447
|
+
title = esc(s.get("title") or "")
|
|
1448
|
+
text = esc(s.get("text") or "")
|
|
1449
|
+
cols = int(s.get("cols") or 3)
|
|
1450
|
+
cols = max(1, min(5, cols))
|
|
1451
|
+
items = s.get("items") or []
|
|
1452
|
+
sec_dom_id = _strip(s.get("id"))
|
|
1453
|
+
sec_id_attr = f' id="{esc(sec_dom_id)}"' if sec_dom_id else ""
|
|
1454
|
+
|
|
1455
|
+
# HERO BANNER (no /admin links)
|
|
1456
|
+
if st == "hero":
|
|
1457
|
+
hero_img = ""
|
|
1458
|
+
for it in items:
|
|
1459
|
+
u = _strip(it.get("imageUrl"))
|
|
1460
|
+
if u:
|
|
1461
|
+
hero_img = u
|
|
1462
|
+
break
|
|
1463
|
+
|
|
1464
|
+
primary = meta.get("primaryCta") or {}
|
|
1465
|
+
secondary = meta.get("secondaryCta") or {}
|
|
1466
|
+
cta_anchor = "#" + (sec_id_by_type.get("cta") or "sec_cta")
|
|
1467
|
+
feats_anchor = "#" + (sec_id_by_type.get("features") or "sec_features")
|
|
1468
|
+
|
|
1469
|
+
primary_label = primary.get("label") or "Request a demo"
|
|
1470
|
+
primary_href = primary.get("href") or cta_anchor
|
|
1471
|
+
secondary_label = secondary.get("label") or "See capabilities"
|
|
1472
|
+
secondary_href = secondary.get("href") or feats_anchor
|
|
1473
|
+
|
|
1474
|
+
bg_style = f"style=\"background-image:url('{esc(hero_img)}')\"" if hero_img else ""
|
|
1475
|
+
|
|
1476
|
+
parts.append(f"""
|
|
1477
|
+
<section class="hero hero-banner"{sec_id_attr}>
|
|
1478
|
+
<div class="hero-bg" {bg_style}></div>
|
|
1479
|
+
<div class="hero-overlay"></div>
|
|
1480
|
+
<div class="wrap hero-content">
|
|
1481
|
+
<div class="hero-panel reveal">
|
|
1482
|
+
<p class="kicker">{esc(meta.get("pageTitle") or title)}</p>
|
|
1483
|
+
<h1>{title}</h1>
|
|
1484
|
+
<p class="lead">{text}</p>
|
|
1485
|
+
<div class="btnrow">
|
|
1486
|
+
{_btn(primary_label, primary_href, primary=True)}
|
|
1487
|
+
{_btn(secondary_label, secondary_href)}
|
|
1488
|
+
</div>
|
|
1489
|
+
</div>
|
|
1490
|
+
</div>
|
|
1491
|
+
</section>
|
|
1492
|
+
""".strip())
|
|
1493
|
+
continue
|
|
1494
|
+
|
|
1495
|
+
# FAQ as accordion
|
|
1496
|
+
if st == "faq":
|
|
1497
|
+
qa = []
|
|
1498
|
+
for it in items:
|
|
1499
|
+
q = esc(it.get("title") or "")
|
|
1500
|
+
a = esc(it.get("text") or "")
|
|
1501
|
+
if not q and not a:
|
|
1502
|
+
continue
|
|
1503
|
+
qa.append(
|
|
1504
|
+
f"<details class=\"reveal\"><summary>{q}</summary>"
|
|
1505
|
+
f"<div class=\"mut\" style=\"margin-top:8px;\">{a}</div></details>"
|
|
1506
|
+
)
|
|
1507
|
+
|
|
1508
|
+
parts.append(f"""
|
|
1509
|
+
<section class="sec faq"{sec_id_attr}>
|
|
1510
|
+
<div class="wrap">
|
|
1511
|
+
<h2 class="reveal">{title}</h2>
|
|
1512
|
+
{"<p class='reveal' style='margin-bottom:14px;'>" + text + "</p>" if text else ""}
|
|
1513
|
+
{"".join(qa)}
|
|
1514
|
+
</div>
|
|
1515
|
+
</section>
|
|
1516
|
+
""".strip())
|
|
1517
|
+
continue
|
|
1518
|
+
|
|
1519
|
+
# Testimonials styled differently
|
|
1520
|
+
if st == "testimonials":
|
|
1521
|
+
cards = []
|
|
1522
|
+
for it in items:
|
|
1523
|
+
quote = esc(it.get("text") or "")
|
|
1524
|
+
who = esc(it.get("title") or "")
|
|
1525
|
+
if not quote:
|
|
1526
|
+
continue
|
|
1527
|
+
cards.append(
|
|
1528
|
+
f"<div class='card reveal'><div class='quote'>“{quote}”</div>"
|
|
1529
|
+
f"<div class='mut' style='margin-top:10px;font-weight:600;'>{who}</div></div>"
|
|
1530
|
+
)
|
|
1531
|
+
|
|
1532
|
+
grid_html = (
|
|
1533
|
+
f'<div class="grid" style="grid-template-columns:repeat({max(1, min(cols, 3))}, minmax(0,1fr));">'
|
|
1534
|
+
+ "\n".join(cards) + "</div>"
|
|
1535
|
+
) if cards else ""
|
|
1536
|
+
|
|
1537
|
+
parts.append(f"""
|
|
1538
|
+
<section class="sec"{sec_id_attr}>
|
|
1539
|
+
<div class="wrap">
|
|
1540
|
+
<h2 class="reveal">{title}</h2>
|
|
1541
|
+
{"<p class='reveal' style='margin-bottom:14px;'>" + text + "</p>" if text else ""}
|
|
1542
|
+
{grid_html}
|
|
1543
|
+
</div>
|
|
1544
|
+
</section>
|
|
1545
|
+
""".strip())
|
|
1546
|
+
continue
|
|
1547
|
+
|
|
1548
|
+
# Stats + Logos break rhythm so pages look different
|
|
1549
|
+
if st in ("stats", "logos"):
|
|
1550
|
+
cards = []
|
|
1551
|
+
for it in items:
|
|
1552
|
+
it_title = esc(it.get("title") or "")
|
|
1553
|
+
it_text = esc(it.get("text") or "")
|
|
1554
|
+
img = _strip(it.get("imageUrl"))
|
|
1555
|
+
if st == "logos" and img:
|
|
1556
|
+
cards.append(
|
|
1557
|
+
f"<div class='card reveal' style='padding:12px;display:flex;align-items:center;justify-content:center;'>"
|
|
1558
|
+
f"<img loading='lazy' decoding='async' src='{esc(img)}' alt='{it_title}' style='max-height:46px;width:auto;border-radius:0;'>"
|
|
1559
|
+
f"</div>"
|
|
1560
|
+
)
|
|
1561
|
+
else:
|
|
1562
|
+
cards.append(
|
|
1563
|
+
f"<div class='card reveal'><div style='font-size:1.35rem;font-weight:800;line-height:1.1;'>{it_title}</div>"
|
|
1564
|
+
f"<div class='mut' style='margin-top:8px;'>{it_text}</div></div>"
|
|
1565
|
+
)
|
|
1566
|
+
|
|
1567
|
+
use_cols = max(2, min(cols, 5))
|
|
1568
|
+
grid_html = (
|
|
1569
|
+
f'<div class="grid" style="grid-template-columns:repeat({use_cols}, minmax(0,1fr));">'
|
|
1570
|
+
+ "\n".join(cards) + "</div>"
|
|
1571
|
+
) if cards else ""
|
|
1572
|
+
|
|
1573
|
+
parts.append(f"""
|
|
1574
|
+
<section class="sec"{sec_id_attr}>
|
|
1575
|
+
<div class="wrap">
|
|
1576
|
+
{"<h2 class='reveal'>" + title + "</h2>" if title else ""}
|
|
1577
|
+
{"<p class='reveal' style='margin-bottom:14px;'>" + text + "</p>" if text else ""}
|
|
1578
|
+
{grid_html}
|
|
1579
|
+
</div>
|
|
1580
|
+
</section>
|
|
1581
|
+
""".strip())
|
|
1582
|
+
continue
|
|
1583
|
+
|
|
1584
|
+
# Default cards grid (features, gallery, process, integrations, team, timeline, richtext, cta etc.)
|
|
1585
|
+
cards = []
|
|
1586
|
+
for it in items:
|
|
1587
|
+
it_title = esc(it.get("title") or "")
|
|
1588
|
+
it_text = esc(it.get("text") or "")
|
|
1589
|
+
it_icon = icon(it.get("icon") or "")
|
|
1590
|
+
img = _strip(it.get("imageUrl"))
|
|
1591
|
+
img_html = f'<img loading="lazy" decoding="async" src="{esc(img)}" alt="{it_title}">' if img else ""
|
|
1592
|
+
cards.append(f"""
|
|
1593
|
+
<div class="card reveal">
|
|
1594
|
+
{img_html}
|
|
1595
|
+
<div style="display:flex; gap:10px; align-items:center; margin-top:{'10px' if img_html else '0'};">
|
|
1596
|
+
{it_icon}
|
|
1597
|
+
<h3 style="margin:0; font-size:1.05rem;">{it_title}</h3>
|
|
1598
|
+
</div>
|
|
1599
|
+
<p style="margin-top:8px;">{it_text}</p>
|
|
1600
|
+
</div>
|
|
1601
|
+
""".strip())
|
|
1602
|
+
|
|
1603
|
+
grid_html = (
|
|
1604
|
+
f'<div class="grid" style="grid-template-columns:repeat({cols}, minmax(0,1fr));">'
|
|
1605
|
+
+ "\n".join(cards) + "</div>"
|
|
1606
|
+
) if cards else ""
|
|
1607
|
+
|
|
1608
|
+
parts.append(f"""
|
|
1609
|
+
<section class="sec"{sec_id_attr}>
|
|
1610
|
+
<div class="wrap">
|
|
1611
|
+
<h2 class="reveal">{title}</h2>
|
|
1612
|
+
{"<p class='reveal' style='margin-bottom:14px;'>" + text + "</p>" if text else ""}
|
|
1613
|
+
{grid_html}
|
|
1614
|
+
</div>
|
|
1615
|
+
</section>
|
|
1616
|
+
""".strip())
|
|
1617
|
+
|
|
1618
|
+
parts.append(js)
|
|
1619
|
+
parts.append("</div>")
|
|
1620
|
+
return "\n\n".join(parts)
|
|
1621
|
+
|
|
1622
|
+
notes = []
|
|
1623
|
+
|
|
1624
|
+
tpl_spec = _select_template_spec(page_slug)
|
|
1625
|
+
plan = _make_page_plan(page_slug=page_slug, website_description=website_description, template_spec=tpl_spec)
|
|
1626
|
+
|
|
1627
|
+
|
|
1628
|
+
for attempt in range(max_retries + 1):
|
|
1629
|
+
try:
|
|
1630
|
+
_validate_plan_or_raise(plan)
|
|
1631
|
+
break
|
|
1632
|
+
except Exception as e:
|
|
1633
|
+
notes.append(f"plan_validation_failed: {e}")
|
|
1634
|
+
if attempt >= max_retries:
|
|
1635
|
+
raise
|
|
1636
|
+
plan = _repair_plan(plan=plan, error_msg=str(e), website_description=website_description)
|
|
1637
|
+
|
|
1638
|
+
# Fill images locally (Pixabay) to avoid broken links
|
|
1639
|
+
if pixabay_api_key:
|
|
1640
|
+
try:
|
|
1641
|
+
plan = fill_plan_images_from_pixabay(
|
|
1642
|
+
plan,
|
|
1643
|
+
api_key=pixabay_api_key,
|
|
1644
|
+
client_dir=client_dir,
|
|
1645
|
+
max_width=1920,
|
|
1646
|
+
max_downloads=max_images
|
|
1647
|
+
)
|
|
1648
|
+
except Exception as e:
|
|
1649
|
+
notes.append(f"pixabay_fill_failed: {e}")
|
|
1650
|
+
|
|
1651
|
+
# Normalise and validate against the layout contract (after images exist)
|
|
1652
|
+
plan = normalise_layout(
|
|
1653
|
+
plan,
|
|
1654
|
+
default_category=(plan.get("category") or "landing"),
|
|
1655
|
+
default_template_id=((plan.get("template") or {}).get("id") or "generic_v1"),
|
|
1656
|
+
default_template_version=((plan.get("template") or {}).get("version") or "1.0.0"),
|
|
1657
|
+
mode="prod",
|
|
1658
|
+
)
|
|
1659
|
+
|
|
1660
|
+
_ensure_hero_image(plan)
|
|
1661
|
+
|
|
1662
|
+
issues = validate_layout(plan)
|
|
1663
|
+
errors = [i for i in issues if i.level == "error"]
|
|
1664
|
+
if errors:
|
|
1665
|
+
msg = "layout_contract_validation_failed:\n" + "\n".join([f"{e.path}: {e.message}" for e in errors])
|
|
1666
|
+
notes.append(msg)
|
|
1667
|
+
raise RuntimeError(msg)
|
|
1668
|
+
|
|
1669
|
+
# Final sanity check: no placeholders left
|
|
1670
|
+
blob = json.dumps(plan, ensure_ascii=False)
|
|
1671
|
+
if _contains_placeholders(blob):
|
|
1672
|
+
raise RuntimeError("Refusing to publish: plan still contains placeholder-style text.")
|
|
1673
|
+
|
|
1674
|
+
html = compile_plan_to_html(plan)
|
|
1675
|
+
return {
|
|
1676
|
+
"slug": _slugify(plan.get("page") or page_slug),
|
|
1677
|
+
"plan": plan,
|
|
1678
|
+
"html": html,
|
|
1679
|
+
"notes": notes,
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
|
|
1683
|
+
|
|
589
1684
|
def text_formatter_agent(text):
|
|
590
1685
|
"""
|
|
591
1686
|
Parses an ML job description using the Gemini API with Structured JSON Output.
|