Owner request, 14 October 2026: use the decision model (Jev / Celeris) at several stages, let Dify supply the rules, code it without new bugs. Every new stage is switched on in Settings > Kademeli Gecis and starts disabled, so the upgrade changes no behaviour by itself. - Settings schema 2 (Python and TypeScript validate the same rules); a schema 1 file is filled from the defaults. Default chain is now Katman-1 -> Katman-2 -> Katman-3 with Katman-3, Chat-Model and bekci-sesli-bekci always passing (owner decision of the same day). - watchModels: the Bekci correction channel follows a settings list instead of the hard-coded Sesli-Hizli-Zeki. - prefilter: a predicted refusal starts the step at the chosen layer. - autoRoute: virtual "Otomatik" model; one decision per user turn picks the combo, tool steps of the same turn keep it. - finishCheck: an answer that stops early goes back to the same model once. - A layer whose window cannot hold the request is skipped. - policy: stage rules from a Dify workflow (karar_politikasi.py), fetched in the background, validated, last good copy kept; turns never wait. - karar_sor.py: verifierCheck for the dynamic-workflow acceptance step. - Bridge version 19, IDE 1.0.0-beta.39. Tests: 299 bridge tests, 36 electron tests; live run on a side bridge (port 8797) with Celeris and the AITURK gateway for every stage. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
213 lines
9.2 KiB
Python
213 lines
9.2 KiB
Python
"""Per-user settings, re-read for new evaluations without restarting active chats.
|
|
|
|
Schema 2 (14 Oct 2026) adds the decision stages: watchModels, prefilter, autoRoute, finishCheck,
|
|
verifierCheck and policy. A schema 1 file stays valid: every missing section is filled from the
|
|
defaults, and every new stage starts disabled, so an upgrade changes no behaviour by itself.
|
|
With policy.source "dify" the stage settings may be overridden by the last good rules fetched
|
|
from Dify (jev-policy.json); invalid or missing rules leave the local settings in force.
|
|
"""
|
|
import base64
|
|
import copy
|
|
import ctypes
|
|
import json
|
|
import os
|
|
import sys
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
from urllib.parse import urlsplit
|
|
|
|
DEFAULTS = json.loads(Path(__file__).with_name('jev-settings-defaults.json').read_text(encoding='utf-8'))
|
|
POLICY_FILE = 'jev-policy.json'
|
|
# Sections Dify rules may change. The transition chain and the always-pass list stay local.
|
|
POLICY_KEYS = ('prefilter', 'autoRoute', 'finishCheck', 'verifierCheck', 'thresholds', 'questions')
|
|
SECTIONS = ('watchModels', 'prefilter', 'autoRoute', 'finishCheck', 'verifierCheck', 'policy')
|
|
_condition = threading.Condition()
|
|
_active = 0
|
|
|
|
|
|
def home():
|
|
if '--home' in sys.argv:
|
|
return Path(sys.argv[sys.argv.index('--home') + 1])
|
|
return Path(os.environ.get('AITURK_CODEX_BRIDGE_HOME') or Path(__file__).parent)
|
|
|
|
|
|
def _fill(value, default):
|
|
"""Missing keys of a schema 1 file come from the defaults; present values are kept as written."""
|
|
if isinstance(default, dict) and isinstance(value, dict):
|
|
return {k: _fill(value[k], d) if k in value else copy.deepcopy(d) for k, d in default.items()} | \
|
|
{k: v for k, v in value.items() if k not in default}
|
|
return value
|
|
|
|
|
|
def _text(value, limit=10000):
|
|
return isinstance(value, str) and bool(value.strip()) and len(value) <= limit
|
|
|
|
|
|
def _prob(value):
|
|
return isinstance(value, (int, float)) and not isinstance(value, bool) and 0 <= value <= 1
|
|
|
|
|
|
def _int(value, low, high):
|
|
return isinstance(value, int) and not isinstance(value, bool) and low <= value <= high
|
|
|
|
|
|
def _https(value):
|
|
if not _text(value, 2048):
|
|
return False
|
|
url = urlsplit(value)
|
|
return url.scheme == 'https' and bool(url.netloc) and not url.username and not url.password
|
|
|
|
|
|
def validate(settings):
|
|
"""Raise ValueError when a value could route to the wrong place or wait without bound."""
|
|
# A corrupted file must not silently change routing or contact another host.
|
|
if not _https(settings['endpoint']):
|
|
raise ValueError('Invalid Jev endpoint')
|
|
for name in ('ret', 'sart'):
|
|
n = settings['thresholds'][name]
|
|
if not isinstance(n, (int, float)) or not 0 <= n <= 1 or not settings['questions'][name]:
|
|
raise ValueError('Invalid Jev rule')
|
|
for name in ('timeoutSeconds', 'queueWaitSeconds'):
|
|
if not isinstance(settings[name], int) or not 1 <= settings[name] <= 3600:
|
|
raise ValueError('Invalid Jev timeout')
|
|
if not isinstance(settings['maxConcurrent'], int) or not 0 <= settings['maxConcurrent'] <= 512:
|
|
raise ValueError('Invalid Jev concurrency')
|
|
if not settings['fallbackModel'] or not isinstance(settings['rejectionRules'], list) or settings['failureAction'] not in ('pass', 'next'):
|
|
raise ValueError('Invalid Jev routing')
|
|
watch = settings['watchModels']
|
|
if not isinstance(watch, list) or len(watch) > 64 or not all(_text(x, 256) for x in watch) or len(set(watch)) != len(watch):
|
|
raise ValueError('Invalid watch models')
|
|
p = settings['prefilter']
|
|
if not (isinstance(p.get('enabled'), bool) and _text(p.get('question')) and _prob(p.get('threshold'))
|
|
and _text(p.get('target'), 256) and _int(p.get('timeoutSeconds'), 1, 60)):
|
|
raise ValueError('Invalid prefilter')
|
|
a = settings['autoRoute']
|
|
if not (isinstance(a.get('enabled'), bool) and _text(a.get('model'), 256) and _int(a.get('timeoutSeconds'), 1, 60)
|
|
and all(_text((a.get('questions') or {}).get(k)) and _prob((a.get('thresholds') or {}).get(k)) for k in ('zor', 'arastirma'))
|
|
and all(_text((a.get('targets') or {}).get(k), 256) for k in ('basit', 'zor', 'arastirma'))):
|
|
raise ValueError('Invalid automatic routing')
|
|
if a['model'] in a['targets'].values():
|
|
raise ValueError('Automatic routing cannot target itself')
|
|
f = settings['finishCheck']
|
|
if not (isinstance(f.get('enabled'), bool) and _text(f.get('question')) and _prob(f.get('threshold'))
|
|
and _text(f.get('note'), 4000) and _int(f.get('timeoutSeconds'), 1, 60)):
|
|
raise ValueError('Invalid finish check')
|
|
v = settings['verifierCheck']
|
|
if not (isinstance(v.get('enabled'), bool) and _text(v.get('question')) and _prob(v.get('threshold'))):
|
|
raise ValueError('Invalid verifier check')
|
|
pol = settings['policy']
|
|
if not (pol.get('source') in ('local', 'dify') and _https(pol.get('endpoint')) and _int(pol.get('refreshMinutes'), 1, 1440)):
|
|
raise ValueError('Invalid policy source')
|
|
return settings
|
|
|
|
|
|
def _policy(settings):
|
|
"""Last good Dify rules on top of the local settings; anything invalid keeps the local settings."""
|
|
if settings['policy']['source'] != 'dify':
|
|
return settings
|
|
try:
|
|
cache = json.loads((home() / POLICY_FILE).read_text(encoding='utf-8'))
|
|
overrides = cache.get('overrides')
|
|
if not cache.get('ok') or not isinstance(overrides, dict):
|
|
return settings
|
|
merged = copy.deepcopy(settings)
|
|
for key in POLICY_KEYS:
|
|
if isinstance(overrides.get(key), dict):
|
|
merged[key] = _fill(overrides[key], settings[key])
|
|
return validate(merged)
|
|
except (OSError, ValueError, KeyError, TypeError, AttributeError):
|
|
return settings
|
|
|
|
|
|
def read(overlay=True):
|
|
"""(settings, protectedKey). overlay=False: the local settings without the Dify rules."""
|
|
try:
|
|
data = json.loads((home() / 'jev-settings.json').read_text(encoding='utf-8'))
|
|
except FileNotFoundError:
|
|
return copy.deepcopy(DEFAULTS), None
|
|
settings = data['settings']
|
|
for section in SECTIONS:
|
|
settings[section] = _fill(settings[section], DEFAULTS[section]) if section in settings else copy.deepcopy(DEFAULTS[section])
|
|
validate(settings)
|
|
return (_policy(settings) if overlay else settings), data.get('protectedKey')
|
|
|
|
|
|
class _Blob(ctypes.Structure):
|
|
_fields_ = [('cbData', ctypes.c_uint32), ('pbData', ctypes.POINTER(ctypes.c_ubyte))]
|
|
|
|
|
|
def _unprotect(encrypted):
|
|
if not encrypted:
|
|
return ''
|
|
if os.name != 'nt':
|
|
raise RuntimeError('Windows credential protection required')
|
|
raw = base64.b64decode(encrypted, validate=True)
|
|
buf = (ctypes.c_ubyte * len(raw)).from_buffer_copy(raw)
|
|
source, result = _Blob(len(raw), buf), _Blob()
|
|
decrypt = ctypes.windll.crypt32.CryptUnprotectData
|
|
decrypt.argtypes = [ctypes.POINTER(_Blob), ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_uint32, ctypes.POINTER(_Blob)]
|
|
decrypt.restype = ctypes.c_int
|
|
if not decrypt(ctypes.byref(source), None, None, None, None, 0, ctypes.byref(result)):
|
|
raise ctypes.WinError()
|
|
try:
|
|
return ctypes.string_at(result.pbData, result.cbData).decode('utf-8')
|
|
finally:
|
|
ctypes.windll.kernel32.LocalFree.argtypes = [ctypes.c_void_p]
|
|
ctypes.windll.kernel32.LocalFree(result.pbData)
|
|
|
|
|
|
def key():
|
|
_, encrypted = read()
|
|
return _unprotect(encrypted)
|
|
|
|
|
|
def policy_key():
|
|
"""Dify app key for the rules source (protectedPolicyKey, Windows DPAPI). Empty when not set."""
|
|
try:
|
|
data = json.loads((home() / 'jev-settings.json').read_text(encoding='utf-8'))
|
|
except (OSError, ValueError):
|
|
return ''
|
|
return _unprotect(data.get('protectedPolicyKey'))
|
|
|
|
|
|
def write_policy(result):
|
|
"""Atomically store the outcome of a Dify fetch. A failed fetch keeps the last good overrides."""
|
|
path = home() / POLICY_FILE
|
|
previous = {}
|
|
try:
|
|
previous = json.loads(path.read_text(encoding='utf-8'))
|
|
except (OSError, ValueError):
|
|
pass
|
|
record = {'fetchedAt': time.strftime('%Y-%m-%dT%H:%M:%S%z'), 'ok': bool(result.get('ok')), 'error': str(result.get('error') or '')[:500]}
|
|
record['overrides'] = result.get('overrides') if result.get('ok') else previous.get('overrides')
|
|
if not result.get('ok') and previous.get('ok'):
|
|
record['ok'] = True # the last good rules stay in force; the error is reported beside them
|
|
record['lastGoodAt'] = previous.get('lastGoodAt') or previous.get('fetchedAt')
|
|
elif result.get('ok'):
|
|
record['lastGoodAt'] = record['fetchedAt']
|
|
tmp = path.with_name(path.name + f'.{os.getpid()}.tmp')
|
|
tmp.write_text(json.dumps(record, ensure_ascii=False, indent=2), encoding='utf-8')
|
|
os.replace(tmp, path)
|
|
return record
|
|
|
|
|
|
def acquire(settings):
|
|
global _active
|
|
deadline = time.monotonic() + settings['queueWaitSeconds']
|
|
with _condition:
|
|
while settings['maxConcurrent'] and _active >= settings['maxConcurrent']:
|
|
remaining = deadline - time.monotonic()
|
|
if remaining <= 0:
|
|
return False
|
|
_condition.wait(remaining)
|
|
_active += 1
|
|
return True
|
|
|
|
|
|
def release():
|
|
global _active
|
|
with _condition:
|
|
_active -= 1
|
|
_condition.notify_all()
|