| Server IP : 3.147.158.171 / Your IP : 216.73.216.88 Web Server : Apache/2.4.67 (Amazon Linux) OpenSSL/3.5.5 System : Linux ip-172-31-2-178.us-east-2.compute.internal 6.1.172-216.329.amzn2023.x86_64 #1 SMP PREEMPT_DYNAMIC Wed May 20 06:31:34 UTC 2026 x86_64 User : ec2-user ( 1000) PHP Version : 8.4.21 Disable Function : NONE MySQL : OFF | cURL : ON | WGET : ON | Perl : ON | Python : OFF | Sudo : ON | Pkexec : OFF Directory : /tsai/www/html/api.turmansolutions.ai/ |
Upload File : |
"""
Widget Refinement Helper
A small CLI used by the `refine-widget` skill to iteratively improve a widget's
`config.instructions` system prompt. It exercises the *existing* runtime endpoints
(it does not change any backend behavior):
list List a site's widgets (owned + system-wide) with id / storageId.
show Print one widget's full config JSON.
play Run ONE assistant turn for a widget through the real /chat
store+stream pipeline and print the reply (used to test-play a game).
patch-instructions Replace a widget's config.instructions and PATCH it. Handles both
site-owned widgets (via the /cms/widget/ endpoint) and system-wide
`fapi`-owned widgets (direct Drupal JSON:API PATCH, mirroring
create_widget.py's --system-wide path).
All commands resolve the site's cb_host from its slug and use it as the Origin header,
exactly like create_widget.py. Run on the server inside the api venv.
Examples:
python refine_widget.py list --site jpt
python refine_widget.py show --site jpt --id anagram-scramble
python refine_widget.py play --site jpt --id anagram-scramble --conversation /tmp/turns.json
python refine_widget.py patch-instructions --site jpt --id anagram-scramble \
--instructions-file /tmp/new-instructions.txt
The --conversation file for `play` is a JSON array of turns, oldest first, e.g.:
[{"role": "user", "body": "let's play"},
{"role": "assistant", "body": "Sure! Here are your letters: ..."},
{"role": "user", "body": "is it RIVER?"}]
The LAST turn must be role "user" (the current move); earlier turns become the history.
"""
import argparse
import asyncio
import json
import os
import sys
from pathlib import Path
from dotenv import load_dotenv
load_dotenv(dotenv_path=Path(__file__).parent / '.env')
import requests
API_BASE = "http://127.0.0.1:8000/api/v1"
# Stream markers (mirror app.appTypes.Constants)
CB_NEW_LINE = "__chatbot_br__"
CB_END_STREAM = "__chatbot_end__"
def resolve_cb_host(site_slug: str) -> str:
from app.services.site_config_service import SiteConfigService
service = SiteConfigService()
site = service.get_site_by_slug(site_slug)
if site:
return site.cb_host
all_slugs = sorted(s.slug for s in service.get_all_sites())
print(f"Error: site '{site_slug}' not found. Available: {', '.join(all_slugs)}")
sys.exit(1)
def fetch_widgets(cb_host: str) -> list:
"""Return the list of Widget dicts visible to the site (owned + system-wide)."""
resp = requests.get(
f"{API_BASE}/cms/widgets/",
headers={"Origin": f"https://{cb_host}"},
timeout=30,
)
resp.raise_for_status()
return resp.json().get("data", [])
def find_widget(cb_host: str, widget_id: str) -> dict:
widgets = fetch_widgets(cb_host)
for w in widgets:
if w.get("id") == widget_id or w.get("config", {}).get("card", {}).get("id") == widget_id:
return w
ids = ", ".join(w.get("id", "?") for w in widgets)
print(f"Error: widget '{widget_id}' not found. Available ids: {ids}")
sys.exit(1)
def cmd_list(args):
cb_host = resolve_cb_host(args.site)
for w in fetch_widgets(cb_host):
scope = "system-wide" if w.get("systemWide") else "site"
print(f"{w.get('name')} | id={w.get('id')} | storageId={w.get('storageId')} | {scope}")
def cmd_show(args):
cb_host = resolve_cb_host(args.site)
w = find_widget(cb_host, args.id)
print(json.dumps(w.get("config", {}), indent=2))
def cmd_play(args):
"""Run a single assistant turn through the real chat store+stream endpoints."""
cb_host = resolve_cb_host(args.site)
w = find_widget(cb_host, args.id)
config = w.get("config", {})
instructions = config.get("instructions", "You are a helpful assistant.")
model = config.get("model", {}) or {}
# The /chat pipeline serves OpenAI models only; a widget set to a Claude model
# won't stream. --model overrides the name so the instructions can be test-played
# against a working model (instruction behavior is what we're tuning).
if getattr(args, "model", None):
model = {**model, "name": args.model}
turns = json.loads(Path(args.conversation).read_text())
if not turns or turns[-1].get("role") != "user":
print("Error: the last turn in --conversation must have role 'user'.")
sys.exit(1)
history = turns[:-1]
current_body = turns[-1]["body"]
conversation = [
{"userId": 1 if t.get("role") == "assistant" else 0, "body": t.get("body", "")}
for t in history
]
payload = {
"userId": 0,
"body": current_body,
"instructions": instructions,
"conversation": conversation,
"model": model,
}
store = requests.post(f"{API_BASE}/chat/store/", json=payload, timeout=30)
store.raise_for_status()
key = store.json()["message"]
reply_parts = []
try:
with requests.get(f"{API_BASE}/chat/stream/{key}", stream=True, timeout=120) as stream:
stream.raise_for_status()
for raw in stream.iter_lines(decode_unicode=True):
if not raw or not raw.startswith("data: "):
continue
token = raw[len("data: "):]
if token == CB_END_STREAM:
break
reply_parts.append(token.replace(CB_NEW_LINE, "\n"))
except requests.exceptions.ChunkedEncodingError:
# Server closed the stream early — usually a model error (e.g. a Claude model
# name on the OpenAI-only /chat pipeline; pass --model gpt-5.5).
if not reply_parts:
print("ERROR: stream ended with no content (model error? try --model gpt-5.5)",
file=sys.stderr)
sys.exit(1)
print("".join(reply_parts))
def _patch_systemwide(storage_id: str, config: dict, shared: bool, title: str):
"""Direct Drupal JSON:API PATCH for an fapi-owned (system-wide) widget node."""
from app.services.drupal import DrupalService
drupal_host = os.getenv("DRUPAL_HOST")
drupal_api = os.getenv("DRUPAL_API")
async def _patch():
service = DrupalService()
session = requests.Session()
try:
login_data = await service.login(session)
logout_token = login_data.get("logout_token")
csrf_token = await service.get_csrf_token(session)
headers = {
"Content-Type": "application/vnd.api+json",
"X-CSRF-Token": csrf_token,
"Authorization": f"Bearer {csrf_token}",
}
node_data = {
"data": {
"type": "node--widget",
"id": storage_id,
"attributes": {
"title": title,
"field_shared2": shared,
"body": {"value": json.dumps(config), "format": "plain_text"},
},
}
}
url = f"{drupal_host}{drupal_api}/node/widget/{storage_id}"
resp = session.patch(url, headers=headers, json=node_data)
await service.logout(session, logout_token)
return resp
finally:
session.close()
return asyncio.run(_patch())
def _patch_widget(cb_host: str, w: dict, config: dict):
"""PATCH a widget's config, auto-routing system-wide vs site-owned. Returns (ok, msg)."""
title = config.get("card", {}).get("title", w.get("name", "widget"))
shared = w.get("shared", True)
if w.get("systemWide"):
# fapi-owned: the /cms/widget/ endpoint enforces site ownership and would 403,
# so patch the Drupal node directly.
resp = _patch_systemwide(w["storageId"], config, shared, title)
if resp.status_code == 200:
return True, "ok"
return False, f"Error {resp.status_code}: {resp.text}"
# Site-owned widget: go through the ownership-checked /cms/widget/ endpoint.
body = {
"widget": {
"storageId": w["storageId"],
"config": config,
"shared": shared,
"name": w.get("name", title),
"id": w.get("id", ""),
"category": w.get("category", ""),
"enabled": w.get("enabled", True),
"hidden": w.get("hidden", False),
}
}
resp = requests.patch(
f"{API_BASE}/cms/widget/",
json=body,
headers={"Origin": f"https://{cb_host}", "Content-Type": "application/json"},
timeout=30,
)
if resp.status_code in (200, 201):
return True, "ok"
return False, f"Error {resp.status_code}: {resp.text}"
def cmd_patch_instructions(args):
cb_host = resolve_cb_host(args.site)
w = find_widget(cb_host, args.id)
config = w.get("config", {})
config["instructions"] = Path(args.instructions_file).read_text()
ok, msg = _patch_widget(cb_host, w, config)
if ok:
print(f"Widget '{args.id}' instructions updated.")
else:
print(msg)
sys.exit(1)
def cmd_migrate_langchain(args):
"""Flip eligible chat widgets onto the multi-provider LangChain streaming path.
Both useLangChain and streamedResponses are required: streamedResponses gates
sendStreamedMessage (messenger-base.ts) and useLangChain selects the /chat-lc path.
Widgets using quizMode or useStructuredOutput are intentionally OpenAI-only and skipped.
"""
cb_host = resolve_cb_host(args.site)
seen = set()
flipped = skipped = failed = 0
for w in fetch_widgets(cb_host):
sid = w.get("storageId")
if not sid or sid in seen:
continue
seen.add(sid)
config = w.get("config", {})
feats = config.setdefault("features", {})
name = w.get("name") or w.get("id") or sid
if feats.get("quizMode") or feats.get("useStructuredOutput"):
print(f"skip {name} (quiz/structured - OpenAI-only)")
skipped += 1
continue
if feats.get("useLangChain") and feats.get("streamedResponses"):
print(f"ok {name} (already LangChain)")
skipped += 1
continue
if args.dry_run:
print(f"WOULD flip {name}")
flipped += 1
continue
feats["useLangChain"] = True
feats["streamedResponses"] = True
ok, msg = _patch_widget(cb_host, w, config)
if ok:
print(f"flip {name} -> useLangChain + streamedResponses")
flipped += 1
else:
print(f"FAIL {name}: {msg}")
failed += 1
verb = "would flip" if args.dry_run else "flipped"
print(f"\n{verb} {flipped}; skipped {skipped}; failed {failed}.")
if failed:
sys.exit(1)
def main():
parser = argparse.ArgumentParser(description="Refine a widget's instructions")
parser.add_argument("--env", default=os.getenv("TSAI_ENV", "development"),
choices=["development", "production", "scike_ai"],
help="Environment (default: TSAI_ENV)")
sub = parser.add_subparsers(dest="command", required=True)
p_list = sub.add_parser("list", help="List a site's widgets")
p_list.add_argument("--site", required=True)
p_list.set_defaults(func=cmd_list)
p_show = sub.add_parser("show", help="Print a widget's full config")
p_show.add_argument("--site", required=True)
p_show.add_argument("--id", required=True, help="Widget card.id")
p_show.set_defaults(func=cmd_show)
p_play = sub.add_parser("play", help="Run one assistant turn for a widget")
p_play.add_argument("--site", required=True)
p_play.add_argument("--id", required=True, help="Widget card.id")
p_play.add_argument("--conversation", required=True, help="Path to JSON turns file")
p_play.add_argument("--model", default=None,
help="Override the model name (the /chat pipeline serves OpenAI "
"models only; e.g. gpt-5.5)")
p_play.set_defaults(func=cmd_play)
p_patch = sub.add_parser("patch-instructions", help="Replace a widget's instructions")
p_patch.add_argument("--site", required=True)
p_patch.add_argument("--id", required=True, help="Widget card.id")
p_patch.add_argument("--instructions-file", required=True,
help="Path to a text file with the new instructions")
p_patch.set_defaults(func=cmd_patch_instructions)
p_mig = sub.add_parser("migrate-langchain",
help="Flip eligible chat widgets to useLangChain + streamedResponses")
p_mig.add_argument("--site", required=True,
help="A site slug to resolve the Origin (system-wide widgets are "
"covered from any one site)")
p_mig.add_argument("--dry-run", action="store_true", help="Print the plan without patching")
p_mig.set_defaults(func=cmd_migrate_langchain)
args = parser.parse_args()
os.environ["TSAI_ENV"] = args.env
args.func(args)
if __name__ == "__main__":
main()