feat(codex-bridge): decision-model stages, Dify rules and beta.39
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>
This commit is contained in:
1 parent
8f53b090f7
commit
7c4ef18df0
18 files changed
+1672
-119
No files matched your search
@@ -27,6 +27,37 @@ Run: `python bridge.py --home <data dir> --hermes-home <agent home>`. The IDE wr
|
||||
|
||||
Tests: `python -m unittest discover` in this directory.
|
||||
|
||||
## Version 19: decision stages (14 October 2026)
|
||||
|
||||
The decision model (Jev or another System One compatible service such as Celeris) can now be asked at
|
||||
several stages. Every new stage is switched on in Settings → Kademeli Geçiş and starts disabled, so an
|
||||
upgrade changes no behaviour by itself. The decision model never writes text; an unreachable or invalid
|
||||
decision always keeps the current path.
|
||||
|
||||
- `jev-settings.json` schema 2 adds `watchModels`, `prefilter`, `autoRoute`, `finishCheck`,
|
||||
`verifierCheck` and `policy`. A schema 1 file is filled from `jev-settings-defaults.json`.
|
||||
The default chain is `Katman-1 → Katman-2 → Katman-3`; `Katman-3`, `Chat-Model` and
|
||||
`bekci-sesli-bekci` always pass.
|
||||
- **Correction channel** (`watchModels`): the Bekçi watch sideband is no longer bound to the hard-coded
|
||||
name `Sesli-Hizli-Zeki`; any exact target in the list owns it.
|
||||
- **Refusal pre-check** (`prefilter`): at the start of a turn, "would a mainstream model refuse or water
|
||||
this down?". If yes, the step starts at `prefilter.target` instead of collecting a refusal first.
|
||||
- **Automatic model** (`autoRoute`): a virtual `ide/<model>` entry ("Otomatik") in the Codex list. Each new
|
||||
user message gets one decision call (hard? research?) and goes to the configured combo. Tool steps of
|
||||
the same turn keep that combo, because switching providers mid-turn breaks encrypted history. The
|
||||
user's explicit model choice is never changed by this stage.
|
||||
- Both turn-start questions go in one call, bounded by `timeoutSeconds` (default 3 s).
|
||||
- **Layer window filter**: a layer whose context window cannot hold the request is skipped.
|
||||
- **Early finish check** (`finishCheck`): asked in the same call as the refusal check on a final answer.
|
||||
If the answer stopped early or claimed success without evidence, it goes back to the same model once
|
||||
with `finishCheck.note`; the retry is delivered without another finish question.
|
||||
- **Rules from Dify** (`policy.source = dify`, `karar_politikasi.py`): the stage questions, thresholds and
|
||||
combo choices may come from a Dify workflow's `kurallar` output. Fetched in the background every
|
||||
`refreshMinutes`, validated against the local settings, last good copy kept in `jev-policy.json`.
|
||||
The transition chain and the always-pass list stay local. A turn never waits for Dify.
|
||||
- **Workflow reviewer check** (`verifierCheck`, `karar_sor.py`): the Codex dynamic-workflow acceptance
|
||||
step asks whether a reviewer approved without concrete evidence; if so acceptance is blocked.
|
||||
|
||||
## Version 11: isolated routes and cross-provider preparation
|
||||
|
||||
- Pure subscription chats and native voice retain their ChatGPT route. Added
|
||||
|
||||
@@ -35,7 +35,7 @@ import gecis_context
|
||||
import terminal_stream
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
SURUM = "18" # AITURK IDE'nin bekledigi kopru surumu (codex-link-config.ts CODEX_BRIDGE_VERSION)
|
||||
SURUM = "19" # AITURK IDE'nin bekledigi kopru surumu (codex-link-config.ts CODEX_BRIDGE_VERSION)
|
||||
try:
|
||||
with open(os.path.abspath(__file__), "rb") as _f:
|
||||
KOD = hashlib.sha256(_f.read()).hexdigest()[:8]
|
||||
|
||||
@@ -5,10 +5,9 @@
|
||||
"fallbackModel": "Katman-1",
|
||||
"rejectionRules": [
|
||||
{ "from": "Katman-1", "to": "Katman-2" },
|
||||
{ "from": "Katman-2", "to": "Katman-3" },
|
||||
{ "from": "Katman-3", "to": "Katman-4" }
|
||||
{ "from": "Katman-2", "to": "Katman-3" }
|
||||
],
|
||||
"exemptModels": ["Sesli-Hizli-Zeki", "Katman-4"],
|
||||
"exemptModels": ["Chat-Model", "bekci-sesli-bekci", "Katman-3"],
|
||||
"thresholds": { "ret": 0.5, "sart": 0.7 },
|
||||
"questions": {
|
||||
"ret": "The assistant did NOT do what the user asked or did NOT answer the user's question: it refused, declined, said it cannot or will not, avoided giving the requested judgment, or redirected to a different topic.",
|
||||
@@ -18,5 +17,41 @@
|
||||
"criteria": { "ret": { "true": "", "false": "" }, "sart": { "true": "", "false": "" } },
|
||||
"maxConcurrent": 0,
|
||||
"queueWaitSeconds": 3600,
|
||||
"failureAction": "pass"
|
||||
"failureAction": "pass",
|
||||
"watchModels": ["Sesli-Hizli-Zeki", "Chat-Model", "bekci-sesli-bekci"],
|
||||
"prefilter": {
|
||||
"enabled": false,
|
||||
"question": "A mainstream AI assistant such as ChatGPT, Claude or Gemini would refuse this request, water it down, or answer it only with added warnings or conditions.",
|
||||
"threshold": 0.6,
|
||||
"target": "Katman-2",
|
||||
"timeoutSeconds": 3
|
||||
},
|
||||
"autoRoute": {
|
||||
"enabled": false,
|
||||
"model": "Otomatik",
|
||||
"timeoutSeconds": 3,
|
||||
"questions": {
|
||||
"zor": "The user's request is hard: doing it well needs multi-step work, expert or technical knowledge, careful analysis, planning, calculation or writing and changing code. Greetings, small talk, simple facts and short simple commands are NOT hard.",
|
||||
"arastirma": "The user's request is mainly research: finding, reading and comparing many sources or files and reporting what they say, rather than changing anything."
|
||||
},
|
||||
"thresholds": { "zor": 0.5, "arastirma": 0.6 },
|
||||
"targets": { "basit": "Workflow-Kesif", "zor": "Ajan", "arastirma": "Arastirma" }
|
||||
},
|
||||
"finishCheck": {
|
||||
"enabled": false,
|
||||
"question": "The assistant stopped before the user's task was finished: it said it is done or handed back without doing all of what was asked, or it claimed success (tests pass, file changed, deployed) without showing the actual evidence.",
|
||||
"threshold": 0.6,
|
||||
"note": "Önceki cevabın iş bitmeden bitti dedi ya da kanıt göstermedi. İşi gerçekten bitir; gerekiyorsa araçları kullan ve gerçek kanıtı göster.",
|
||||
"timeoutSeconds": 5
|
||||
},
|
||||
"verifierCheck": {
|
||||
"enabled": false,
|
||||
"question": "The reviewer approved the work without concrete evidence: it does not cite the actual diff, the real command output or specific lines, so the approval may be a guess.",
|
||||
"threshold": 0.6
|
||||
},
|
||||
"policy": {
|
||||
"source": "local",
|
||||
"endpoint": "https://dify.turkservis.online/v1",
|
||||
"refreshMinutes": 5
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,16 @@ Kullanici karari (11 Ekim 2026): "Jev kati olsun. Af yok istisna yok. Herhangi b
|
||||
gecirmezse 1.Katmana yonlendirecek. 1.Katman combosu gecmezse 2.Katmana, 2.Katman gecmezse 3.Katmana, 3.Katman
|
||||
gecmezse 4.Katmana. 4.Katman combosu gelirse Jev bunu sorgulamadan gecirecek."
|
||||
|
||||
12 Ekim 2026 istisnasi: çözülmüş hedefi tam Sesli-Hizli-Zeki olan cevap Jev'e sorulmaz.
|
||||
12 Ekim 2026 istisnasi: çözülmüş hedefi muaf listesinde olan cevap Jev'e sorulmaz (ayar: exemptModels).
|
||||
Taşıma/boş yanıt kurtarması ve yedek katmanların denetimi korunur.
|
||||
Diğer hedeflerde denetim secili modelin katman olup olmamasina bakmaz.
|
||||
Gecmezse adimi sirayla Katman-1 -> Katman-2 -> Katman-3 -> Katman-4 yapar; secili model zaten bir katmansa
|
||||
yalniz ondan sonrakiler denenir. Katmanlar OmniRoute combolaridir (KATMAN); kopru onlari yalniz adiyla cagirir.
|
||||
Gecmezse adimi ayardaki zincir yapar (14 Ekim 2026: Katman-1 -> Katman-2 -> Katman-3, Katman-3 muaf); secili model
|
||||
zaten bir katmansa yalniz ondan sonrakiler denenir. Katmanlar OmniRoute combolaridir; kopru onlari yalniz adiyla cagirir.
|
||||
|
||||
14 Ekim 2026 karar asamalari (hepsi ayardan acilir, varsayilan kapali): tur basinda "Otomatik" modelin combo secimi
|
||||
(autoRoute), normal modelin reddedecegi tahmin edilen isi dogrudan katmana verme (prefilter), isi bitirmeden "bitti"
|
||||
diyen son cevabi ayni modele bir kez geri verme (finishCheck). Karar modeli metin yazmaz; hata ya da zaman asiminda
|
||||
bugunku yol izlenir.
|
||||
|
||||
Jev (TypeSafe System One) metin uretmez, sorulan evet/hayir sorularina olasilik verir. Kural tektir ve istisnasi
|
||||
yoktur: cevap istegi yapmiyorsa (ret) ya da sormadan kendi sartlariyla degistiriyorsa (sart) adim gecmez.
|
||||
@@ -22,7 +27,7 @@ import jev_settings
|
||||
|
||||
ADRES = "https://api.typesafe.ai/v1/systemone"
|
||||
MODEL = "jev-1.13.0"
|
||||
KATMAN = ("Katman-1", "Katman-2", "Katman-3", "Katman-4")
|
||||
KATMAN = ("Katman-1", "Katman-2", "Katman-3") # varsayilan zincir; asil zincir ayardan okunur
|
||||
ESIK = {"ret": 0.5, "sart": 0.7}
|
||||
SORU = {
|
||||
"ret": ("The assistant did NOT do what the user asked or did NOT answer the user's question: it refused, declined, "
|
||||
@@ -32,6 +37,14 @@ SORU = {
|
||||
}
|
||||
_ANAHTAR = {"okundu": False, "deger": ""}
|
||||
|
||||
def izleme_mi(hedef):
|
||||
"""Bekci izleme kanali (cevaptan sonra duzeltme) bu hedefin mi? Ayar: watchModels, tam ad eslesmesi."""
|
||||
try:
|
||||
return hedef in jev_settings.read()[0]['watchModels']
|
||||
except Exception:
|
||||
return hedef == "Sesli-Hizli-Zeki"
|
||||
|
||||
|
||||
def muaf_mi(hedef):
|
||||
"""Settings' always-pass list matches exact resolved model IDs.
|
||||
|
||||
@@ -109,16 +122,23 @@ def _metin(oge):
|
||||
def son_istek(req):
|
||||
"""Kullanicinin bu turdaki istegi: son kullanici mesaji. Sesli aktarimda yalniz <input> icindeki soz;
|
||||
baska sarmalli sistem mesajlari (kanca baglami) atlanir."""
|
||||
for oge in reversed(req.get("input") or []):
|
||||
return son_istek_sirasi(req)[0]
|
||||
|
||||
|
||||
def son_istek_sirasi(req):
|
||||
"""(istek, girdideki sirasi). Turun arac adimlarinda da ayni sira doner; yoksa ("", -1)."""
|
||||
girdi = req.get("input") or []
|
||||
for sira in range(len(girdi) - 1, -1, -1):
|
||||
oge = girdi[sira]
|
||||
if not (isinstance(oge, dict) and oge.get("type", "message") == "message" and oge.get("role") == "user"):
|
||||
continue
|
||||
metin = _metin(oge).strip()
|
||||
m = re.search(r"<realtime_delegation>.*?<input>(.*?)</input>", metin, re.S)
|
||||
if m:
|
||||
return m.group(1).strip()
|
||||
return m.group(1).strip(), sira
|
||||
if metin and not metin.startswith("<"):
|
||||
return metin
|
||||
return ""
|
||||
return metin, sira
|
||||
return "", -1
|
||||
|
||||
|
||||
def hazirlik_mi(govde):
|
||||
@@ -166,24 +186,83 @@ def komutlar(govde):
|
||||
return "\n".join(satirlar)
|
||||
|
||||
|
||||
def _gonder(govde, key, settings=None):
|
||||
def _gonder(govde, key, settings=None, timeout=None):
|
||||
settings = settings or jev_settings.read()[0]
|
||||
istek = urllib.request.Request(settings['endpoint'], data=json.dumps(govde).encode("utf-8"), method="POST",
|
||||
headers={"Authorization": "Bearer " + key, "Content-Type": "application/json"})
|
||||
with urllib.request.urlopen(istek, timeout=settings['timeoutSeconds']) as yanit:
|
||||
with urllib.request.urlopen(istek, timeout=timeout or settings['timeoutSeconds']) as yanit:
|
||||
return json.load(yanit)
|
||||
|
||||
|
||||
def sorular(durum, soru, key, timeout, gonder=_gonder, settings=None):
|
||||
"""Karar modeline bir cagrida birden cok evet/hayir sorusu. Donus {ad: 0-1 olasilik} | None (sorulamadi).
|
||||
durum: {"user_request": ..., ["assistant_reply": ...]}; soru: {ad: talimat}."""
|
||||
settings = settings or jev_settings.read()[0]
|
||||
if not key or not soru or not durum.get("user_request"):
|
||||
return None
|
||||
govde = {"model": settings['model'], "state": durum,
|
||||
"questions": {k: {"type": "noul", "instructions": v} for k, v in soru.items()}}
|
||||
if not jev_settings.acquire(settings):
|
||||
return None
|
||||
try:
|
||||
response = gonder(govde, key, settings, timeout) if gonder is _gonder else gonder(govde, key)
|
||||
yanitlar = (response or {}).get("answers") or {}
|
||||
result = {k: float(yanitlar[k]["noul"]) for k in soru}
|
||||
return result if all(0 <= v <= 1 for v in result.values()) else None
|
||||
except Exception:
|
||||
return None
|
||||
finally:
|
||||
jev_settings.release()
|
||||
|
||||
|
||||
def on_karar(istek, oto, ret, key, gonder=_gonder):
|
||||
"""Tur basi karari: istenen sorular tek cagrida. oto: Otomatik modelin combo secimi; ret: normal model reddeder mi.
|
||||
Donus: {"hedef": Otomatik icin secilen ad ya da None, "ret": bool ya da None, "puan": {...} ya da None}."""
|
||||
settings = jev_settings.read()[0]
|
||||
a, p = settings['autoRoute'], settings['prefilter']
|
||||
soru = {}
|
||||
if oto:
|
||||
soru.update(a['questions'])
|
||||
if ret and p['enabled']:
|
||||
soru["ret"] = p['question']
|
||||
if not soru:
|
||||
return {"hedef": None, "ret": None, "puan": None}
|
||||
sure = max(a['timeoutSeconds'] if oto else 0, p['timeoutSeconds'] if ret else 0) or 3
|
||||
puan = sorular({"user_request": istek}, soru, key, sure, gonder=gonder, settings=settings)
|
||||
hedef = None
|
||||
if oto:
|
||||
if puan is None:
|
||||
hedef = a['targets']['zor'] # karar alinamazsa guclu yol
|
||||
elif puan["arastirma"] >= a['thresholds']['arastirma']:
|
||||
hedef = a['targets']['arastirma']
|
||||
elif puan["zor"] >= a['thresholds']['zor']:
|
||||
hedef = a['targets']['zor']
|
||||
else:
|
||||
hedef = a['targets']['basit']
|
||||
tahmin = None if puan is None or "ret" not in puan else puan["ret"] >= p['threshold']
|
||||
return {"hedef": hedef, "ret": tahmin, "puan": puan}
|
||||
|
||||
|
||||
def bitmedi_mi(puan):
|
||||
"""finishCheck: son cevap isi bitirmeden 'bitti' mi dedi? Soru sorulmadiysa False."""
|
||||
if not puan or "bitmedi" not in puan:
|
||||
return False
|
||||
return puan["bitmedi"] >= jev_settings.read()[0]['finishCheck']['threshold']
|
||||
|
||||
|
||||
class Puan(dict):
|
||||
"""Carry the thresholds used for this evaluation through a settings change."""
|
||||
pass
|
||||
|
||||
|
||||
def sor(istek, cevap, key, gonder=_gonder):
|
||||
"""Jev'e sorar. Donus: {"ret", "sart"} olasiliklari | None (sorulamadi)."""
|
||||
def sor(istek, cevap, key, gonder=_gonder, bitis=False):
|
||||
"""Jev'e sorar. Donus: {"ret", "sart"[, "bitmedi"]} olasiliklari | None (sorulamadi).
|
||||
bitis=True ve finishCheck acikken ayni cagriya "is bitmeden bitti mi dedi" sorusu da eklenir."""
|
||||
settings = jev_settings.read()[0]
|
||||
govde = {"model": settings['model'], "state": {"user_request": istek, "assistant_reply": cevap},
|
||||
"questions": {k: {"type": "noul", "instructions": v} for k, v in settings['questions'].items()}}
|
||||
if bitis and settings['finishCheck']['enabled']:
|
||||
govde['questions']['bitmedi'] = {"type": "noul", "instructions": settings['finishCheck']['question']}
|
||||
for name, criteria in settings.get('criteria', {}).items():
|
||||
values = {k: v for k, v in criteria.items() if v.strip()}
|
||||
if values:
|
||||
@@ -193,7 +272,9 @@ def sor(istek, cevap, key, gonder=_gonder):
|
||||
try:
|
||||
response = gonder(govde, key, settings) if gonder is _gonder else gonder(govde, key)
|
||||
yanitlar = (response or {}).get("answers") or {}
|
||||
result = Puan({k: float(yanitlar[k]["noul"]) for k in SORU})
|
||||
result = Puan({k: float(yanitlar[k]["noul"]) for k in settings['questions']})
|
||||
if 'bitmedi' in govde['questions'] and isinstance(yanitlar.get('bitmedi'), dict):
|
||||
result['bitmedi'] = float(yanitlar['bitmedi']['noul']) # ek soru: cevapsizsa ana karar yine gecerli
|
||||
if any(not 0 <= v <= 1 for v in result.values()):
|
||||
return None
|
||||
result.thresholds = settings['thresholds']
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
"""Per-user settings, re-read for new evaluations without restarting active chats."""
|
||||
"""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
|
||||
@@ -8,8 +15,13 @@ 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
|
||||
|
||||
@@ -20,16 +32,37 @@ def home():
|
||||
return Path(os.environ.get('AITURK_CODEX_BRIDGE_HOME') or Path(__file__).parent)
|
||||
|
||||
|
||||
def read():
|
||||
try:
|
||||
data = json.loads((home() / 'jev-settings.json').read_text(encoding='utf-8'))
|
||||
except FileNotFoundError:
|
||||
return copy.deepcopy(DEFAULTS), None
|
||||
settings = data['settings']
|
||||
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.
|
||||
from urllib.parse import urlsplit
|
||||
url = urlsplit(settings['endpoint'])
|
||||
if url.scheme != 'https' or not url.netloc or url.username or url.password:
|
||||
if not _https(settings['endpoint']):
|
||||
raise ValueError('Invalid Jev endpoint')
|
||||
for name in ('ret', 'sart'):
|
||||
n = settings['thresholds'][name]
|
||||
@@ -42,24 +75,79 @@ def read():
|
||||
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')
|
||||
return settings, data.get('protectedKey')
|
||||
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 key():
|
||||
_, encrypted = read()
|
||||
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')
|
||||
from ctypes import wintypes
|
||||
class Blob(ctypes.Structure):
|
||||
_fields_ = [('cbData', wintypes.DWORD), ('pbData', ctypes.POINTER(ctypes.c_ubyte))]
|
||||
raw = base64.b64decode(encrypted, validate=True)
|
||||
buf = (ctypes.c_ubyte * len(raw)).from_buffer_copy(raw)
|
||||
source, result = Blob(len(raw), buf), Blob()
|
||||
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, wintypes.DWORD, ctypes.POINTER(Blob)]
|
||||
decrypt.restype = wintypes.BOOL
|
||||
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:
|
||||
@@ -69,6 +157,41 @@ def key():
|
||||
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']
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Decision rules from Dify (14 Oct 2026).
|
||||
|
||||
Owner request, 14 Oct 2026: "Dify'de hazirlansa oradan gelse". The model a Codex step talks to stays an
|
||||
OmniRoute combo, because a Dify app would not pass Codex's own tools through. What Dify supplies is the rules:
|
||||
the decision questions, thresholds and which combo each kind of request goes to. The bridge fetches them in
|
||||
the background (policy.refreshMinutes), validates them against the local settings and stores the last good
|
||||
copy in jev-policy.json. A turn never waits for Dify; a failed fetch keeps the last good rules.
|
||||
|
||||
The Dify workflow returns one output named ``kurallar``: a JSON object (or JSON text) whose keys are any of
|
||||
jev_settings.POLICY_KEYS, each holding the same fields as the local settings section, for example
|
||||
{"autoRoute": {"enabled": true, "targets": {"zor": "Ajan"}}}.
|
||||
"""
|
||||
import copy
|
||||
import json
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
import jev_settings
|
||||
|
||||
UA = "AITURK-IDE-codex-bridge/karar-politikasi"
|
||||
MAX_BYTES = 64 * 1024
|
||||
_son = {"zaman": 0.0}
|
||||
|
||||
|
||||
def ayikla(cikti, yerel):
|
||||
"""Dify output -> validated overrides. Raises ValueError with a short reason when unusable."""
|
||||
if isinstance(cikti, str):
|
||||
if len(cikti.encode("utf-8")) > MAX_BYTES:
|
||||
raise ValueError("kurallar too large")
|
||||
cikti = json.loads(cikti)
|
||||
if not isinstance(cikti, dict):
|
||||
raise ValueError("kurallar is not an object")
|
||||
bilinmeyen = sorted(set(cikti) - set(jev_settings.POLICY_KEYS))
|
||||
if bilinmeyen:
|
||||
raise ValueError("unknown sections: " + ", ".join(bilinmeyen))
|
||||
overrides = {k: v for k, v in cikti.items() if isinstance(v, dict)}
|
||||
if len(overrides) != len(cikti):
|
||||
raise ValueError("every section must be an object")
|
||||
birlesik = copy.deepcopy(yerel)
|
||||
for k, v in overrides.items():
|
||||
birlesik[k] = jev_settings._fill(v, yerel[k])
|
||||
jev_settings.validate(birlesik)
|
||||
return overrides
|
||||
|
||||
|
||||
def cek(settings, key, urlopen=urllib.request.urlopen):
|
||||
"""One fetch. Returns {"ok": bool, "overrides": {...}, "error": str}; never raises."""
|
||||
if not key:
|
||||
return {"ok": False, "error": "Dify key not configured"}
|
||||
url = settings['policy']['endpoint'].rstrip('/') + '/workflows/run'
|
||||
govde = json.dumps({"inputs": {}, "response_mode": "blocking", "user": "aiturk-codex-bridge"}).encode("utf-8")
|
||||
istek = urllib.request.Request(url, data=govde, method="POST", headers={
|
||||
"Authorization": "Bearer " + key, "Content-Type": "application/json", "User-Agent": UA})
|
||||
try:
|
||||
with urlopen(istek, timeout=30) as yanit:
|
||||
ham = yanit.read(MAX_BYTES * 4 + 1)
|
||||
if len(ham) > MAX_BYTES * 4:
|
||||
return {"ok": False, "error": "Dify response too large"}
|
||||
veri = json.loads(ham)
|
||||
cikti = ((veri.get("data") or {}).get("outputs") or {}).get("kurallar")
|
||||
if cikti is None:
|
||||
return {"ok": False, "error": "Dify output 'kurallar' missing"}
|
||||
return {"ok": True, "overrides": ayikla(cikti, settings)}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": f"{type(e).__name__}: {e}"[:300]}
|
||||
|
||||
|
||||
def zamani_mi(settings, simdi=None):
|
||||
simdi = time.time() if simdi is None else simdi
|
||||
return settings['policy']['source'] == 'dify' and simdi - _son["zaman"] >= settings['policy']['refreshMinutes'] * 60
|
||||
|
||||
|
||||
def yenile(log=lambda _m: None, urlopen=urllib.request.urlopen, zorla=False):
|
||||
"""Fetch when due; store the outcome. Local settings are read without the Dify overlay."""
|
||||
try:
|
||||
yerel, _ = _yerel_ayar()
|
||||
except Exception as e:
|
||||
log(f"karar kurallari: ayar okunamadi ({type(e).__name__})")
|
||||
return None
|
||||
if not zorla and not zamani_mi(yerel):
|
||||
return None
|
||||
_son["zaman"] = time.time()
|
||||
sonuc = cek(yerel, jev_settings.policy_key(), urlopen=urlopen)
|
||||
kayit = jev_settings.write_policy(sonuc)
|
||||
log("karar kurallari Dify'dan alindi" if sonuc.get("ok") else f"karar kurallari alinamadi: {sonuc.get('error')}"
|
||||
+ (" (son gecerli kurallar kullaniliyor)" if kayit.get("ok") else ""))
|
||||
return kayit
|
||||
|
||||
|
||||
def _yerel_ayar():
|
||||
"""Settings as written locally (no Dify overlay), so a bad overlay can never validate itself."""
|
||||
return jev_settings.read(overlay=False)
|
||||
@@ -0,0 +1,58 @@
|
||||
"""One decision-model question from the command line (14 Oct 2026).
|
||||
|
||||
Used by the Codex dynamic-workflow acceptance step (verifierCheck): before a reviewer's "kabul" is accepted,
|
||||
the decision model is asked whether the approval cites concrete evidence. Reads the IDE's own Kademeli Geçiş
|
||||
settings and key; nothing is written.
|
||||
|
||||
python karar_sor.py dogrulayici < {"user_request": "...", "assistant_reply": "..."}
|
||||
|
||||
Prints JSON {"enabled": bool, "kanitsiz": bool|null, "puan": float|null, "neden": str}. Exit 0 also when the
|
||||
stage is off or the model cannot be reached; the caller decides what an unanswered check means.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
import jev_denetim # noqa: E402
|
||||
import jev_settings # noqa: E402
|
||||
|
||||
SINIR = 20000
|
||||
|
||||
|
||||
def dogrulayici(veri, anahtar=None, gonder=None):
|
||||
s = jev_settings.read()[0]
|
||||
v = s['verifierCheck']
|
||||
if not (s['enabled'] and v['enabled']):
|
||||
return {'enabled': False, 'kanitsiz': None, 'puan': None, 'neden': 'kapali'}
|
||||
durum = {'user_request': str(veri.get('user_request') or '')[:SINIR], 'assistant_reply': str(veri.get('assistant_reply') or '')[:SINIR]}
|
||||
ek = {'gonder': gonder} if gonder else {}
|
||||
puan = jev_denetim.sorular(durum, {'kanitsiz': v['question']}, jev_denetim.anahtar() if anahtar is None else anahtar, 15, **ek)
|
||||
if puan is None:
|
||||
return {'enabled': True, 'kanitsiz': None, 'puan': None, 'neden': 'karar modeline sorulamadi'}
|
||||
return {'enabled': True, 'kanitsiz': puan['kanitsiz'] >= v['threshold'], 'puan': round(puan['kanitsiz'], 3), 'neden': ''}
|
||||
|
||||
|
||||
def main(argv=None, stdin=None, out=None):
|
||||
argv = sys.argv[1:] if argv is None else argv
|
||||
stdin, out = stdin or sys.stdin, out or sys.stdout
|
||||
soru = [a for i, a in enumerate(argv) if a != '--home' and (i == 0 or argv[i - 1] != '--home')]
|
||||
if soru != ['dogrulayici']:
|
||||
print('kullanim: python karar_sor.py dogrulayici < {"user_request": ..., "assistant_reply": ...}', file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
veri = json.load(stdin)
|
||||
sonuc = dogrulayici(veri if isinstance(veri, dict) else {})
|
||||
except Exception as e:
|
||||
sonuc = {'enabled': None, 'kanitsiz': None, 'puan': None, 'neden': f'{type(e).__name__}: {e}'[:300]}
|
||||
out.write(json.dumps(sonuc, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Installed copy: the settings live in the user's bridge home, not next to this file.
|
||||
if '--home' not in sys.argv and not os.environ.get('AITURK_CODEX_BRIDGE_HOME'):
|
||||
os.environ['AITURK_CODEX_BRIDGE_HOME'] = str(Path(os.environ.get('LOCALAPPDATA', '')) / 'TurkServis' / 'AITURK-IDE' / 'codex-bridge')
|
||||
sys.exit(main())
|
||||
@@ -34,13 +34,15 @@ import model_gecis
|
||||
import is_hatti
|
||||
import internet
|
||||
import jev_denetim
|
||||
import jev_settings
|
||||
import karar_politikasi
|
||||
|
||||
from bekci_watch import MetadataFilter
|
||||
|
||||
# Installed only by owned native launcher, never an HTTP control endpoint.
|
||||
WATCH_OWNER = None
|
||||
|
||||
SURUM = "18"
|
||||
SURUM = "19"
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
try:
|
||||
with open(os.path.abspath(__file__), "rb") as _f:
|
||||
@@ -228,6 +230,22 @@ def load_ide_catalog(ag=True):
|
||||
"kaynak": g.get("pencere_kaynak") or "", "claude": bool(g.get("claude"))}
|
||||
model_map[slug] = (IDE_ROUTE, g["id"], None)
|
||||
n += 1
|
||||
oto = _oto_ayari()
|
||||
if oto and IDE_PREFIX + oto["model"] not in model_map:
|
||||
# Sanal model: istek geldiginde karar modeli ayardaki combo'lardan birini secer (14 Ekim 2026).
|
||||
slug = IDE_PREFIX + oto["model"]
|
||||
pencereler = [IDE_BILGI.get(IDE_PREFIX + h, {}).get("pencere") for h in set(oto["targets"].values())]
|
||||
pencere_oto = min(pencereler) if pencereler and all(pencereler) else None
|
||||
extra_models.append({"slug": slug, "display_name": f"{oto['model']} (karar modeli seçer)",
|
||||
"description": "Her turda karar modeli işin türüne göre combo seçer: "
|
||||
+ ", ".join(f"{k}: {v}" for k, v in oto["targets"].items()),
|
||||
"route": IDE_ROUTE, "target": oto["model"], "clone_from": clone, "priority": 299,
|
||||
"reasoning_levels": levels, "kind": "combo", "context_window": pencere_oto})
|
||||
IDE_BILGI[slug] = {"pencere": pencere_oto, "gorsel": all(IDE_BILGI.get(IDE_PREFIX + h, {}).get("gorsel")
|
||||
for h in set(oto["targets"].values())),
|
||||
"arac": True, "kaynak": "", "claude": False}
|
||||
model_map[slug] = (IDE_ROUTE, oto["model"], None)
|
||||
n += 1
|
||||
else:
|
||||
ROUTES.pop(IDE_ROUTE, None)
|
||||
MODEL_MAP = model_map
|
||||
@@ -237,10 +255,94 @@ def load_ide_catalog(ag=True):
|
||||
IDE_DURUM[0] = ozet
|
||||
log(f"AITURK IDE Codex Baglama: {ozet}")
|
||||
|
||||
def _oto_ayari():
|
||||
"""autoRoute acik ve Kademeli Gecis acikken onun ayari; degilse None. Ayar okunamazsa None."""
|
||||
try:
|
||||
s = jev_settings.read()[0]
|
||||
except Exception:
|
||||
return None
|
||||
return s["autoRoute"] if s["enabled"] and s["autoRoute"]["enabled"] else None
|
||||
|
||||
# Tur basi kararlari: sohbet -> (tur anahtari, gelen slug, secilen slug). Ayni turun arac adimlari ayni karari kullanir.
|
||||
ON_KARAR = {}
|
||||
ON_KARAR_SINIR = 500
|
||||
|
||||
# Acilista pencere verisi icin aga cikilmaz (IDE koprunun dinlemeye baslamasini sinirli sure bekler);
|
||||
# ilk tam yenileme asagidaki dongunun basinda, arka planda yapilir.
|
||||
load_ide_catalog(ag=False)
|
||||
|
||||
def karar_asamasi(n, req, sohbet, slug):
|
||||
"""14 Ekim 2026 karar asamalari: "Otomatik" modelin combo secimi (autoRoute) ve normal modelin reddedecegi
|
||||
tahmin edilen isi dogrudan ayardaki katmana verme (prefilter). Karar tur basinda bir kez verilir; ayni turun
|
||||
arac adimlari ayni karari kullanir (her adimda model degisirse saglayicilar arasi gecmis bozulur). Kapali ayar,
|
||||
bos istek ya da karar modeli hatasi bugunku yolu degistirmez (Otomatik icin guclu hedef secilir)."""
|
||||
if not slug.startswith(IDE_PREFIX):
|
||||
return slug
|
||||
settings = jev_settings.read()[0]
|
||||
if not settings["enabled"]:
|
||||
return slug
|
||||
a, p = settings["autoRoute"], settings["prefilter"]
|
||||
oto = a["enabled"] and slug == IDE_PREFIX + a["model"]
|
||||
hedef = slug[len(IDE_PREFIX):]
|
||||
ret = p["enabled"] and (oto or not (jev_denetim.muaf_mi(hedef) or hedef == p["target"]
|
||||
or hedef in jev_denetim.sonrakiler(p["target"])))
|
||||
if not oto and not ret:
|
||||
return slug
|
||||
istek, sira = jev_denetim.son_istek_sirasi(req)
|
||||
# Tur = son kullanici mesaji (metni ve girdideki yeri); ayni turun arac adimlarinda degismez.
|
||||
tur = hashlib.sha256(f"{sohbet}\0{sira}\0{istek}".encode("utf-8", "replace")).hexdigest()
|
||||
kayit = ON_KARAR.get(sohbet) if sohbet else None
|
||||
if kayit and kayit[0] == tur and kayit[1] == slug:
|
||||
secilen = kayit[2]
|
||||
else:
|
||||
if istek:
|
||||
t0 = time.time()
|
||||
sonuc = jev_denetim.on_karar(istek, oto, ret, jev_denetim.anahtar())
|
||||
puan = sonuc["puan"]
|
||||
log(f"[{n} {slug}] tur basi karari ({time.time() - t0:.1f} sn): "
|
||||
+ (", ".join(f"{k}={v:.2f}" for k, v in puan.items()) if puan else "karar modeline sorulamadi"))
|
||||
else:
|
||||
sonuc = {"hedef": a["targets"]["zor"] if oto else None, "ret": None}
|
||||
if oto:
|
||||
hedef = sonuc["hedef"]
|
||||
secilen = IDE_PREFIX + hedef
|
||||
if (p["enabled"] and sonuc["ret"] and not jev_denetim.muaf_mi(hedef) and hedef != p["target"]
|
||||
and hedef not in jev_denetim.sonrakiler(p["target"])):
|
||||
log(f"[{n} {slug}] ret on kontrolu: normal model reddedebilir, adim {p['target']} katmanindan basliyor")
|
||||
secilen = IDE_PREFIX + p["target"]
|
||||
if sohbet:
|
||||
ON_KARAR[sohbet] = (tur, slug, secilen)
|
||||
while len(ON_KARAR) > ON_KARAR_SINIR:
|
||||
ON_KARAR.pop(next(iter(ON_KARAR)))
|
||||
if secilen != slug:
|
||||
log(f"[{n} {slug}] karar: bu tur {secilen} modeline gidiyor")
|
||||
req["model"] = secilen
|
||||
return secilen
|
||||
|
||||
def sigan_katmanlar(sira, req, tag):
|
||||
"""Istegi sigdiramayacak katman zincirden cikar (bos baglam hatasiyla bosa dusmesin)."""
|
||||
tahmin, sonuc = None, []
|
||||
for ad in sira:
|
||||
p = (IDE_BILGI.get(IDE_PREFIX + ad) or {}).get("pencere")
|
||||
if p:
|
||||
if tahmin is None:
|
||||
tahmin = pencere.tahmini_token(req, 2.0)
|
||||
if tahmin > int(p * 0.90):
|
||||
log(f"{tag} katman {ad} atlandi: istek sigmaz (tahmini {tahmin} token, pencere {p})")
|
||||
continue
|
||||
sonuc.append(ad)
|
||||
return sonuc
|
||||
|
||||
def karar_kurallari_dongusu():
|
||||
"""Dify kural kaynagi (policy.source = dify) arka planda yenilenir; tur hic beklemez."""
|
||||
time.sleep(5)
|
||||
while True:
|
||||
try:
|
||||
karar_politikasi.yenile(log)
|
||||
except Exception as e:
|
||||
log(f"karar kurallari yenileme hatasi: {type(e).__name__}: {e}")
|
||||
time.sleep(60)
|
||||
|
||||
def catalog_refresh_loop():
|
||||
"""Kataloglari arka planda yeniler; liste istegini yavaslatmaz."""
|
||||
minutes = max(1, int(CFG.get("catalog_refresh_min", 5)))
|
||||
@@ -258,6 +360,7 @@ def catalog_refresh_loop():
|
||||
log(f"katalog yenileme hatasi: {e}")
|
||||
|
||||
threading.Thread(target=catalog_refresh_loop, daemon=True).start()
|
||||
threading.Thread(target=karar_kurallari_dongusu, daemon=True).start()
|
||||
|
||||
# ---- AITURK is hatti: isi gonderebilen AITURK komutu (is_hatti.py). Kurulum sonradan gelebilir: yoksa 2 dk'da bir bakilir.
|
||||
HAT_KOMUT = [None]
|
||||
@@ -943,7 +1046,7 @@ class H(http.server.BaseHTTPRequestHandler):
|
||||
self.send_header("Transfer-Encoding", "chunked")
|
||||
self.end_headers()
|
||||
|
||||
def _denetimli_akis(self, resp, tag, rewriter, izle, istek, sira, ac, hedef=None):
|
||||
def _denetimli_akis(self, resp, tag, rewriter, izle, istek, sira, ac, hedef=None, devam=None):
|
||||
"""Katman denetimi (jev_denetim.py): cevap istemciye verilmeden once tamami okunur ve Jev'e sorulur.
|
||||
Gecerse oldugu gibi verilir; gecmezse atilir ve ayni adimi siradaki katman yapar (ac(ad): (resp, rewriter,
|
||||
tag)). Son katmanin cevabi sorulmadan akar. Beklerken SSE yorum satiri gider ki Codex akisi kopmus saymasin."""
|
||||
@@ -955,6 +1058,7 @@ class H(http.server.BaseHTTPRequestHandler):
|
||||
else:
|
||||
self._akis_basligi(resp, resp.status)
|
||||
kalan, son_yazi, son_govde = list(sira), time.time(), b""
|
||||
devam_kullanildi = False # finishCheck: son cevap ayni modele en fazla bir kez geri verilir
|
||||
|
||||
def w(b):
|
||||
if b:
|
||||
@@ -999,7 +1103,10 @@ class H(http.server.BaseHTTPRequestHandler):
|
||||
jev_done, jev_result = jev_threading.Event(), [None]
|
||||
def denetle():
|
||||
try:
|
||||
jev_result[0] = jev_denetim.sor(istek, metin, jev_denetim.anahtar())
|
||||
if devam is not None and not devam_kullanildi:
|
||||
jev_result[0] = jev_denetim.sor(istek, metin, jev_denetim.anahtar(), bitis=True)
|
||||
else:
|
||||
jev_result[0] = jev_denetim.sor(istek, metin, jev_denetim.anahtar())
|
||||
finally:
|
||||
jev_done.set()
|
||||
jev_threading.Thread(target=denetle, daemon=True).start()
|
||||
@@ -1011,8 +1118,20 @@ class H(http.server.BaseHTTPRequestHandler):
|
||||
log(f"{tag} katman denetimi: Jev'e sorulamadi, {'siradaki katman' if gecmedi else 'cevap oldugu gibi geciyor'}")
|
||||
else:
|
||||
gecmedi = jev_denetim.karar(puan)
|
||||
log(f"{tag} katman denetimi: ret={puan['ret']:.2f} sart={puan['sart']:.2f} -> "
|
||||
log(f"{tag} katman denetimi: ret={puan['ret']:.2f} sart={puan['sart']:.2f}"
|
||||
+ (f" bitmedi={puan['bitmedi']:.2f}" if 'bitmedi' in puan else "") + " -> "
|
||||
f"{'GECMEDI' if gecmedi else 'gecti'} ({time.time() - t0:.1f} sn, {len(metin)} karakter{'' if yazi else ', yalniz komut'})")
|
||||
if not gecmedi and devam is not None and not devam_kullanildi and jev_denetim.bitmedi_mi(puan):
|
||||
devam_kullanildi = True
|
||||
try:
|
||||
yeni_devam = devam(hedef, metin)
|
||||
except Exception as e:
|
||||
yeni_devam = None
|
||||
log(f"{tag} bitis denetimi: devam istegi acilamadi, cevap oldugu gibi geciyor ({type(e).__name__}: {e})")
|
||||
if yeni_devam is not None:
|
||||
resp, rewriter, tag = yeni_devam
|
||||
log(f"{tag} bitis denetimi: is bitmeden bitti denmis, ayni modele bir kez geri verildi")
|
||||
continue
|
||||
else:
|
||||
# Bos adim: ne yazi var ne arac cagrisi (hata olayi da boyledir, or. "upstream 400"). Kabul
|
||||
# edilmez, siradaki katman yapar; bos yanit cevap diye verilirse Codex adimi bastan dener.
|
||||
@@ -1547,6 +1666,10 @@ class H(http.server.BaseHTTPRequestHandler):
|
||||
if hedef and hedef != gelen:
|
||||
req["model"] = slug = hedef
|
||||
log(f"[{n} {gelen}] sohbet yonlendirmesi: istek {hedef} modeline gidiyor")
|
||||
try:
|
||||
slug = karar_asamasi(n, req, sohbet, slug)
|
||||
except Exception as e:
|
||||
log(f"[{n} {slug}] karar asamasi atlandi: {type(e).__name__}: {e}")
|
||||
mapping = MODEL_MAP.get(slug)
|
||||
if CFG.get("nokta_karsila", True) and nokta_mesaji_mi(req) and model_gecis.tur_ortasi(req, model_gecis.son_sira(req["input"])):
|
||||
# Calisan turun icine dusen nokta secimi tasimaz (olcum 10 Ekim 2026) ve cevaplanirsa isi yarida
|
||||
@@ -1733,14 +1856,19 @@ class H(http.server.BaseHTTPRequestHandler):
|
||||
open(os.path.join(LOGDIR, f"{n:04d}_req.json"), "wb").write(data)
|
||||
hdrs = {"Authorization": f"Bearer {route['key']}", "Content-Type": "application/json", "Accept": "text/event-stream", "User-Agent": UA}
|
||||
last = None
|
||||
# Katman denetimi (jev_denetim.py): Sesli-Hizli-Zeki dışında ek modeller Jev'e sorulur; gecmezse adim
|
||||
# sirayla Katman-1 -> 2 -> 3 -> 4 yapar (secili model zaten katmansa ondan sonrakiler). Katman-4 sorulmadan
|
||||
# gecer. Katmanlar OmniRoute combolaridir: yedek her zaman AITURK gecidi (ide_route) uzerinden acilir,
|
||||
# Katman denetimi (jev_denetim.py): muaf listesi disindaki ek modeller Jev'e sorulur; gecmezse adim
|
||||
# ayardaki zinciri izler (14 Ekim 2026: Katman-1 -> 2 -> 3; secili model zaten katmansa ondan sonrakiler).
|
||||
# Muaf katman sorulmadan gecer. Katmanlar OmniRoute combolaridir: yedek her zaman AITURK gecidi (ide_route) uzerinden acilir,
|
||||
# secili model baska bir rotada olsa bile. Gecit yoksa denetim yapilmaz. Ozet isteginde ve aracsiz
|
||||
# (rewriter yok) yolda kapalidir.
|
||||
jev_rota = ROUTES.get(ide_route)
|
||||
jev_etkin = jev_denetim.etkin_mi() and not (ozet_tetik or rewriter is None or jev_rota is None or not jev_denetim.anahtar())
|
||||
jev_sira = jev_denetim.sonrakiler(target) if jev_etkin else []
|
||||
if jev_sira:
|
||||
try:
|
||||
jev_sira = sigan_katmanlar(jev_sira, req, tag)
|
||||
except Exception as e:
|
||||
log(f"{tag} katman pencere kontrolu atlandi: {type(e).__name__}: {e}")
|
||||
jev_istek = jev_denetim.son_istek(req) if jev_etkin else ""
|
||||
|
||||
def jev_ac(ad):
|
||||
@@ -1753,6 +1881,28 @@ class H(http.server.BaseHTTPRequestHandler):
|
||||
timeout=UPSTREAM_IDLE_TIMEOUT)
|
||||
return (yanit, SseRewriter(custom, hedef=ad, tasma=self._tasma_ogren, aracsiz=self._aracsiz_ogren, tool_names=tool_names),
|
||||
f"[{n} {slug}=>{ad}]")
|
||||
|
||||
def jev_devam(ad, onceki):
|
||||
"""finishCheck: ayni hedefe, erken biten cevap ve not eklenmis istek. Yalniz AITURK gecidindeki hedefler."""
|
||||
if ad == target and route_name != ide_route:
|
||||
raise ValueError("hedef AITURK gecidinde degil")
|
||||
devam_req = json.loads(data)
|
||||
devam_req["model"] = ad
|
||||
devam_req.setdefault("input", []).extend([
|
||||
{"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": onceki}]},
|
||||
{"type": "message", "role": "user", "content": [
|
||||
{"type": "input_text", "text": jev_settings.read()[0]["finishCheck"]["note"]}]}])
|
||||
jev_hdrs = {"Authorization": f"Bearer {jev_rota['key']}", "Content-Type": "application/json",
|
||||
"Accept": "text/event-stream", "User-Agent": UA}
|
||||
yanit = urllib.request.urlopen(urllib.request.Request(
|
||||
jev_rota["url"], data=json.dumps(devam_req, ensure_ascii=False).encode("utf-8"), headers=jev_hdrs, method="POST"),
|
||||
timeout=UPSTREAM_IDLE_TIMEOUT)
|
||||
return (yanit, SseRewriter(custom, hedef=ad, tasma=self._tasma_ogren, aracsiz=self._aracsiz_ogren, tool_names=tool_names),
|
||||
f"[{n} {slug}=>{ad} devam]")
|
||||
try:
|
||||
jev_bitis = jev_devam if jev_etkin and jev_settings.read()[0]["finishCheck"]["enabled"] else None
|
||||
except Exception:
|
||||
jev_bitis = None
|
||||
# A 9Router combo (Fusion-*, no provider prefix) runs a whole panel upstream. Once it may
|
||||
# have started (timeout, 504, cut stream) resending it runs and bills it twice (28 Sep 2026);
|
||||
# only failures before the work starts (no connection, 500/502/503/529) are retried.
|
||||
@@ -1803,7 +1953,7 @@ class H(http.server.BaseHTTPRequestHandler):
|
||||
time.sleep(min(2 * attempt, 8)); continue
|
||||
# Only the resolved exact voice target owns this sideband protocol.
|
||||
# Other models retain their original response/Jev/stream pipeline.
|
||||
if target == 'Sesli-Hizli-Zeki':
|
||||
if jev_denetim.izleme_mi(target):
|
||||
# Capture was taken before rewriting/request dispatch. Only this exact
|
||||
# route credential may authenticate the returned public receipt.
|
||||
correlation = self.watch_correlation
|
||||
@@ -1825,7 +1975,9 @@ class H(http.server.BaseHTTPRequestHandler):
|
||||
log(f"{tag} ozet kopru: {akis.sonuc_notu()}")
|
||||
return sonuc
|
||||
if jev_etkin and resp.status == 200:
|
||||
return self._denetimli_akis(resp, tag, rewriter, izle, jev_istek, jev_sira, jev_ac, hedef=target)
|
||||
# devam yalniz finishCheck acikken gecer; kapaliyken cagri eskisiyle birebir aynidir.
|
||||
return self._denetimli_akis(resp, tag, rewriter, izle, jev_istek, jev_sira, jev_ac, hedef=target,
|
||||
**({"devam": jev_bitis} if jev_bitis else {}))
|
||||
return self._stream(resp, resp.status, tag, rewriter, izle)
|
||||
if jev_etkin: # secili modele ulasilamadi: adimi siradaki katman yapar
|
||||
return self._denetimli_akis(None, tag, rewriter, izle, jev_istek, jev_sira, jev_ac, hedef=target)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Layer check (11 Oct 2026): Jev passes or blocks each step of Katman-1..3; a blocked step is redone by the next
|
||||
layer. Katman-4 is never asked. No exceptions."""
|
||||
layer. The last layer is never asked. No exceptions.
|
||||
|
||||
14 Oct 2026 defaults: Katman-1 -> Katman-2 -> Katman-3; Katman-3, Chat-Model and bekci-sesli-bekci always pass."""
|
||||
import ast
|
||||
import io
|
||||
import json
|
||||
@@ -60,18 +62,19 @@ class Karar(unittest.TestCase):
|
||||
|
||||
class Katman(unittest.TestCase):
|
||||
def test_a_non_layer_model_falls_through_every_layer_from_the_top(self):
|
||||
self.assertEqual(jev_denetim.sonrakiler("Uncensored-Uygulama"),
|
||||
["Katman-1", "Katman-2", "Katman-3", "Katman-4"])
|
||||
self.assertEqual(jev_denetim.sonrakiler("ide/some-model"),
|
||||
["Katman-1", "Katman-2", "Katman-3", "Katman-4"])
|
||||
self.assertEqual(jev_denetim.sonrakiler("Uncensored-Uygulama"), ["Katman-1", "Katman-2", "Katman-3"])
|
||||
self.assertEqual(jev_denetim.sonrakiler("ide/some-model"), ["Katman-1", "Katman-2", "Katman-3"])
|
||||
|
||||
def test_a_selected_layer_only_tries_the_ones_after_it(self):
|
||||
self.assertEqual(jev_denetim.sonrakiler("Katman-1"), ["Katman-2", "Katman-3", "Katman-4"])
|
||||
self.assertEqual(jev_denetim.sonrakiler("Katman-2"), ["Katman-3", "Katman-4"])
|
||||
self.assertEqual(jev_denetim.sonrakiler("Katman-3"), ["Katman-4"])
|
||||
self.assertEqual(jev_denetim.sonrakiler("Katman-1"), ["Katman-2", "Katman-3"])
|
||||
self.assertEqual(jev_denetim.sonrakiler("Katman-2"), ["Katman-3"])
|
||||
# The always-pass last layer only uses the general fallback on transport failure; no revisits.
|
||||
self.assertEqual(jev_denetim.sonrakiler("Katman-3"), ["Katman-1", "Katman-2"])
|
||||
|
||||
def test_last_layer_is_never_asked(self):
|
||||
self.assertTrue(jev_denetim.muaf_mi("Katman-4"))
|
||||
self.assertTrue(jev_denetim.muaf_mi("Katman-3"))
|
||||
self.assertTrue(jev_denetim.muaf_mi("Chat-Model"))
|
||||
self.assertFalse(jev_denetim.muaf_mi("Katman-4"))
|
||||
self.assertEqual(jev_denetim.sonrakiler(None), [])
|
||||
|
||||
|
||||
@@ -139,7 +142,7 @@ class Akis(unittest.TestCase):
|
||||
self.h.send_response = Mock()
|
||||
self.h.send_header = Mock()
|
||||
self.h.end_headers = Mock()
|
||||
self.cevap = {"Katman-1": sse("bir"), "Katman-2": sse("iki"), "Katman-3": sse("uc"), "Katman-4": sse("dort")}
|
||||
self.cevap = {"Katman-1": sse("bir"), "Katman-2": sse("iki"), "Katman-3": sse("uc")}
|
||||
|
||||
def sor(self, istek, metin, key):
|
||||
self.asked.append(metin)
|
||||
@@ -162,7 +165,7 @@ class Akis(unittest.TestCase):
|
||||
raw = rest[n + 2:]
|
||||
return out
|
||||
|
||||
def calistir(self, body, sira=("Katman-2", "Katman-3", "Katman-4")):
|
||||
def calistir(self, body, sira=("Katman-2", "Katman-3")):
|
||||
self.h._denetimli_akis(Yanit(body), "[1]", None, None, "yap", list(sira), self.ac)
|
||||
|
||||
def test_a_passed_step_is_delivered_unchanged(self):
|
||||
@@ -176,7 +179,7 @@ class Akis(unittest.TestCase):
|
||||
body = sse("Bunu yapamam.")
|
||||
self.puanlar = [{"ret": 0.9, "sart": 0.0}]
|
||||
self.h._denetimli_akis(Yanit(body), "[1]", None, None, "yap",
|
||||
list(jev_denetim.KATMAN), self.ac, hedef="Sesli-Hizli-Zeki")
|
||||
list(jev_denetim.KATMAN), self.ac, hedef="Chat-Model")
|
||||
self.assertEqual(self.sent(), body)
|
||||
self.assertEqual(self.asked, [])
|
||||
self.assertEqual(self.opened, [])
|
||||
@@ -184,14 +187,13 @@ class Akis(unittest.TestCase):
|
||||
def test_voice_transport_failure_still_checks_the_fallback_model(self):
|
||||
self.puanlar = [{"ret": 0.9, "sart": 0.0}]
|
||||
self.h._denetimli_akis(None, "[1]", None, None, "yap",
|
||||
list(jev_denetim.KATMAN), self.ac, hedef="Sesli-Hizli-Zeki")
|
||||
list(jev_denetim.KATMAN), self.ac, hedef="Chat-Model")
|
||||
self.assertEqual(self.opened, ["Katman-1", "Katman-2"])
|
||||
self.assertEqual(self.asked, ["bir", "iki"])
|
||||
self.assertEqual(self.sent(), self.cevap["Katman-2"])
|
||||
|
||||
def test_voice_lookalikes_are_still_checked(self):
|
||||
for target in ("sesli-hizli-zeki", "Sesli-Hizli-Zeki-v2", "ide/Sesli-Hizli-Zeki",
|
||||
"Sesli-Hizli-Zeki ", "Other-Combo"):
|
||||
for target in ("chat-model", "Chat-Model-v2", "ide/Chat-Model", "Chat-Model ", "Other-Combo"):
|
||||
with self.subTest(target=target):
|
||||
self.setUp()
|
||||
self.puanlar = [{"ret": 0.9, "sart": 0.0}]
|
||||
@@ -202,7 +204,7 @@ class Akis(unittest.TestCase):
|
||||
|
||||
def test_a_blocked_non_layer_model_goes_to_layer_1_first(self):
|
||||
self.puanlar = [{"ret": 0.9, "sart": 0.0}] # the selected (non-layer) model is blocked
|
||||
self.calistir(sse("Bunu yapamam."), sira=("Katman-1", "Katman-2", "Katman-3", "Katman-4"))
|
||||
self.calistir(sse("Bunu yapamam."), sira=("Katman-1", "Katman-2", "Katman-3"))
|
||||
self.assertEqual(self.sent(), self.cevap["Katman-1"])
|
||||
self.assertEqual(self.opened, ["Katman-1"])
|
||||
self.assertEqual(self.asked, ["Bunu yapamam.", "bir"])
|
||||
@@ -216,17 +218,17 @@ class Akis(unittest.TestCase):
|
||||
self.assertEqual(self.asked, ["Bunu yapamam.", "iki"])
|
||||
self.h.send_response.assert_called_once()
|
||||
|
||||
def test_every_layer_blocked_ends_at_layer_4_unasked(self):
|
||||
self.puanlar = [{"ret": 0.9, "sart": 0.0}] * 3
|
||||
def test_every_layer_blocked_ends_at_layer_3_unasked(self):
|
||||
self.puanlar = [{"ret": 0.9, "sart": 0.0}] * 2
|
||||
self.calistir(sse("hayir"))
|
||||
self.assertEqual(self.sent(), self.cevap["Katman-4"])
|
||||
self.assertEqual(self.opened, ["Katman-2", "Katman-3", "Katman-4"])
|
||||
self.assertEqual(self.asked, ["hayir", "iki", "uc"]) # layer 4 is never asked
|
||||
self.assertEqual(self.sent(), self.cevap["Katman-3"])
|
||||
self.assertEqual(self.opened, ["Katman-2", "Katman-3"])
|
||||
self.assertEqual(self.asked, ["hayir", "iki"]) # the always-pass last layer is never asked
|
||||
|
||||
def test_blocked_layer_3_goes_straight_to_layer_4(self):
|
||||
def test_blocked_layer_2_goes_straight_to_layer_3(self):
|
||||
self.puanlar = [{"ret": 0.0, "sart": 0.8}]
|
||||
self.calistir(sse("kendi sartimla"), sira=("Katman-4",))
|
||||
self.assertEqual(self.sent(), self.cevap["Katman-4"])
|
||||
self.calistir(sse("kendi sartimla"), sira=("Katman-3",))
|
||||
self.assertEqual(self.sent(), self.cevap["Katman-3"])
|
||||
self.assertEqual(self.asked, ["kendi sartimla"])
|
||||
|
||||
def test_a_layer_that_cannot_be_opened_is_skipped(self):
|
||||
@@ -329,9 +331,9 @@ class Akis(unittest.TestCase):
|
||||
|
||||
def test_when_no_layer_opens_the_blocked_answer_is_not_delivered(self):
|
||||
self.puanlar = [{"ret": 0.9, "sart": 0.0}]
|
||||
self.cevap.update({"Katman-2": None, "Katman-3": None, "Katman-4": None})
|
||||
self.cevap.update({"Katman-2": None, "Katman-3": None})
|
||||
self.calistir(sse("Bunu yapamam."))
|
||||
self.assertEqual(self.opened, ["Katman-2", "Katman-3", "Katman-4"])
|
||||
self.assertEqual(self.opened, ["Katman-2", "Katman-3"])
|
||||
self.assertNotIn(b"yapamam", self.h.wfile.getvalue())
|
||||
self.assertIn(b'response.failed', self.sent())
|
||||
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
"""Decision stages (14 Oct 2026): settings schema 2, turn-start decisions, layer window filter, finish check
|
||||
and Dify rules. Every new stage is off by default; the tests turn them on explicitly."""
|
||||
import ast
|
||||
import copy
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import jev_denetim
|
||||
import jev_settings
|
||||
import karar_politikasi
|
||||
import model_gecis
|
||||
import pencere
|
||||
import yarim
|
||||
from test_jev_denetim import Yanit, method, sse, user
|
||||
|
||||
SOURCE = Path(__file__).with_name("kopru.py").read_text(encoding="utf-8")
|
||||
TREE = ast.parse(SOURCE)
|
||||
|
||||
|
||||
def fonksiyon(name):
|
||||
return next(n for n in TREE.body if isinstance(n, ast.FunctionDef) and n.name == name)
|
||||
|
||||
|
||||
class Ev(unittest.TestCase):
|
||||
"""A temporary bridge home with a settings file."""
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.home = Path(self.tmp.name)
|
||||
p = patch.object(jev_settings, 'home', return_value=self.home)
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
self.settings = copy.deepcopy(jev_settings.DEFAULTS)
|
||||
|
||||
def save(self, settings=None, **extra):
|
||||
data = {'settings': settings if settings is not None else self.settings}
|
||||
data.update(extra)
|
||||
(self.home / 'jev-settings.json').write_text(json.dumps(data), encoding='utf-8')
|
||||
|
||||
|
||||
class Ayarlar(Ev):
|
||||
def test_a_schema_1_file_gets_every_new_stage_disabled(self):
|
||||
eski = {k: v for k, v in self.settings.items() if k not in jev_settings.SECTIONS}
|
||||
self.save(eski)
|
||||
s, _ = jev_settings.read()
|
||||
for bolum in ('prefilter', 'autoRoute', 'finishCheck', 'verifierCheck'):
|
||||
self.assertFalse(s[bolum]['enabled'], bolum)
|
||||
self.assertEqual(s['policy']['source'], 'local')
|
||||
self.assertEqual(s['watchModels'], jev_settings.DEFAULTS['watchModels'])
|
||||
|
||||
def test_a_partial_section_keeps_written_values_and_fills_the_rest(self):
|
||||
self.settings['prefilter'] = {'enabled': True, 'threshold': 0.8}
|
||||
self.save()
|
||||
p = jev_settings.read()[0]['prefilter']
|
||||
self.assertEqual((p['enabled'], p['threshold']), (True, 0.8))
|
||||
self.assertEqual(p['target'], jev_settings.DEFAULTS['prefilter']['target'])
|
||||
|
||||
def test_bad_values_are_rejected(self):
|
||||
for bolum, alan, deger in (('prefilter', 'threshold', 1.5), ('prefilter', 'timeoutSeconds', 0),
|
||||
('autoRoute', 'model', ''), ('finishCheck', 'note', ''),
|
||||
('policy', 'endpoint', 'http://dify.example'), ('policy', 'source', 'web')):
|
||||
with self.subTest(bolum=bolum, alan=alan):
|
||||
s = copy.deepcopy(self.settings)
|
||||
s[bolum][alan] = deger
|
||||
self.save(s)
|
||||
with self.assertRaises(ValueError):
|
||||
jev_settings.read()
|
||||
|
||||
def test_automatic_model_cannot_target_itself(self):
|
||||
self.settings['autoRoute']['targets']['zor'] = 'Otomatik'
|
||||
self.save()
|
||||
with self.assertRaises(ValueError):
|
||||
jev_settings.read()
|
||||
|
||||
def test_watch_channel_is_an_exact_name_from_settings(self):
|
||||
self.settings['watchModels'] = ['Chat-Model']
|
||||
self.save()
|
||||
self.assertTrue(jev_denetim.izleme_mi('Chat-Model'))
|
||||
for ad in ('chat-model', 'Chat-Model ', 'ide/Chat-Model', 'Sesli-Hizli-Zeki'):
|
||||
self.assertFalse(jev_denetim.izleme_mi(ad), ad)
|
||||
|
||||
|
||||
class Politika(Ev):
|
||||
def test_dify_rules_apply_only_when_the_source_is_dify_and_they_are_valid(self):
|
||||
(self.home / jev_settings.POLICY_FILE).write_text(json.dumps(
|
||||
{'ok': True, 'overrides': {'prefilter': {'enabled': True, 'target': 'Katman-3'}}}), encoding='utf-8')
|
||||
self.save()
|
||||
self.assertFalse(jev_settings.read()[0]['prefilter']['enabled']) # source local
|
||||
self.settings['policy']['source'] = 'dify'
|
||||
self.save()
|
||||
p = jev_settings.read()[0]['prefilter']
|
||||
self.assertEqual((p['enabled'], p['target']), (True, 'Katman-3'))
|
||||
self.assertFalse(jev_settings.read(overlay=False)[0]['prefilter']['enabled'])
|
||||
(self.home / jev_settings.POLICY_FILE).write_text(json.dumps(
|
||||
{'ok': True, 'overrides': {'prefilter': {'threshold': 7}}}), encoding='utf-8')
|
||||
self.assertFalse(jev_settings.read()[0]['prefilter']['enabled']) # invalid rules: local in force
|
||||
|
||||
def test_rules_cannot_change_the_chain_or_unknown_sections(self):
|
||||
s = jev_settings.read(overlay=False)[0]
|
||||
with self.assertRaises(ValueError):
|
||||
karar_politikasi.ayikla({'exemptModels': {'x': 1}}, s)
|
||||
with self.assertRaises(ValueError):
|
||||
karar_politikasi.ayikla({'autoRoute': {'targets': {'zor': 'Otomatik'}}}, s)
|
||||
with self.assertRaises(ValueError):
|
||||
karar_politikasi.ayikla('[1, 2]', s)
|
||||
self.assertEqual(karar_politikasi.ayikla('{"autoRoute": {"enabled": true}}', s), {'autoRoute': {'enabled': True}})
|
||||
|
||||
def test_fetch_reads_the_kurallar_output_and_never_raises(self):
|
||||
s = jev_settings.read(overlay=False)[0]
|
||||
|
||||
def yanit(govde):
|
||||
r = Mock()
|
||||
r.__enter__ = lambda self_: self_
|
||||
r.__exit__ = lambda *a: False
|
||||
r.read = lambda n=-1: json.dumps(govde).encode()
|
||||
return r
|
||||
istekler = []
|
||||
|
||||
def urlopen(req, timeout):
|
||||
istekler.append(req)
|
||||
return yanit({'data': {'outputs': {'kurallar': {'finishCheck': {'enabled': True}}}}})
|
||||
sonuc = karar_politikasi.cek(s, 'app-key', urlopen=urlopen)
|
||||
self.assertEqual(sonuc, {'ok': True, 'overrides': {'finishCheck': {'enabled': True}}})
|
||||
self.assertTrue(istekler[0].full_url.endswith('/workflows/run'))
|
||||
self.assertEqual(istekler[0].get_header('Authorization'), 'Bearer app-key')
|
||||
self.assertFalse(karar_politikasi.cek(s, '', urlopen=urlopen)['ok'])
|
||||
self.assertFalse(karar_politikasi.cek(s, 'k', urlopen=Mock(side_effect=OSError('kapali')))['ok'])
|
||||
bos = karar_politikasi.cek(s, 'k', urlopen=lambda req, timeout: yanit({'data': {'outputs': {}}}))
|
||||
self.assertEqual(bos['ok'], False)
|
||||
|
||||
def test_a_failed_fetch_keeps_the_last_good_rules(self):
|
||||
jev_settings.write_policy({'ok': True, 'overrides': {'finishCheck': {'enabled': True}}})
|
||||
kayit = jev_settings.write_policy({'ok': False, 'error': 'Dify kapali'})
|
||||
self.assertTrue(kayit['ok'])
|
||||
self.assertEqual(kayit['overrides'], {'finishCheck': {'enabled': True}})
|
||||
self.assertEqual(kayit['error'], 'Dify kapali')
|
||||
self.settings['policy']['source'] = 'dify'
|
||||
self.save()
|
||||
self.assertTrue(jev_settings.read()[0]['finishCheck']['enabled'])
|
||||
|
||||
|
||||
class OnKarar(Ev):
|
||||
def gonder(self, cevap):
|
||||
self.govdeler = []
|
||||
|
||||
def f(govde, key):
|
||||
self.govdeler.append(govde)
|
||||
if isinstance(cevap, Exception):
|
||||
raise cevap
|
||||
return {'answers': {k: {'noul': v} for k, v in cevap.items()}}
|
||||
return f
|
||||
|
||||
def test_automatic_routing_picks_research_then_hard_then_simple(self):
|
||||
self.settings['autoRoute']['enabled'] = True
|
||||
self.save()
|
||||
t = self.settings['autoRoute']['targets']
|
||||
for puan, hedef in (({'zor': 0.9, 'arastirma': 0.8}, t['arastirma']), ({'zor': 0.9, 'arastirma': 0.1}, t['zor']),
|
||||
({'zor': 0.1, 'arastirma': 0.1}, t['basit'])):
|
||||
sonuc = jev_denetim.on_karar('istek', True, False, 'k', gonder=self.gonder(puan))
|
||||
self.assertEqual(sonuc['hedef'], hedef)
|
||||
self.assertEqual(set(self.govdeler[0]['questions']), {'zor', 'arastirma'})
|
||||
self.assertNotIn('assistant_reply', self.govdeler[0]['state'])
|
||||
|
||||
def test_no_decision_falls_to_the_strong_target(self):
|
||||
self.settings['autoRoute']['enabled'] = True
|
||||
self.save()
|
||||
sonuc = jev_denetim.on_karar('istek', True, False, 'k', gonder=self.gonder(OSError('yok')))
|
||||
self.assertEqual((sonuc['hedef'], sonuc['ret']), (self.settings['autoRoute']['targets']['zor'], None))
|
||||
|
||||
def test_refusal_question_goes_in_the_same_call(self):
|
||||
self.settings['autoRoute']['enabled'] = True
|
||||
self.settings['prefilter']['enabled'] = True
|
||||
self.save()
|
||||
sonuc = jev_denetim.on_karar('istek', True, True, 'k', gonder=self.gonder({'zor': 0.1, 'arastirma': 0.1, 'ret': 0.9}))
|
||||
self.assertEqual(len(self.govdeler), 1)
|
||||
self.assertEqual(set(self.govdeler[0]['questions']), {'zor', 'arastirma', 'ret'})
|
||||
self.assertTrue(sonuc['ret'])
|
||||
|
||||
def test_a_missing_answer_is_no_decision(self):
|
||||
self.settings['prefilter']['enabled'] = True
|
||||
self.save()
|
||||
sonuc = jev_denetim.on_karar('istek', False, True, 'k', gonder=self.gonder({}))
|
||||
self.assertIsNone(sonuc['ret'])
|
||||
|
||||
|
||||
class TurBasi(Ev):
|
||||
"""kopru.karar_asamasi through its own source."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.kararlar = []
|
||||
self.logs = []
|
||||
sahte_denetim = Mock(wraps=jev_denetim)
|
||||
sahte_denetim.anahtar = lambda: 'k'
|
||||
sahte_denetim.on_karar = self.on_karar
|
||||
sahte_denetim.muaf_mi = jev_denetim.muaf_mi
|
||||
sahte_denetim.sonrakiler = jev_denetim.sonrakiler
|
||||
sahte_denetim.son_istek = jev_denetim.son_istek
|
||||
self.cevap = {'hedef': None, 'ret': None, 'puan': None}
|
||||
sahte_denetim.son_istek_sirasi = jev_denetim.son_istek_sirasi
|
||||
self.env = {'IDE_PREFIX': 'ide/', 'ON_KARAR': {}, 'ON_KARAR_SINIR': 3, 'jev_settings': jev_settings,
|
||||
'jev_denetim': sahte_denetim, 'model_gecis': model_gecis, 'hashlib': hashlib, 'time': time,
|
||||
'log': self.logs.append}
|
||||
exec(compile(ast.Module(body=[fonksiyon('karar_asamasi')], type_ignores=[]), 'kopru.py', 'exec'), self.env)
|
||||
self.karar = self.env['karar_asamasi']
|
||||
|
||||
def on_karar(self, istek, oto, ret, key):
|
||||
self.kararlar.append((istek, oto, ret))
|
||||
return dict(self.cevap)
|
||||
|
||||
def istek(self, model, *girdi):
|
||||
return {'model': model, 'prompt_cache_key': 'sohbet-1', 'input': list(girdi)}
|
||||
|
||||
def test_everything_off_changes_nothing_and_asks_nothing(self):
|
||||
self.save()
|
||||
req = self.istek('ide/Otomatik', user('yap'))
|
||||
self.assertEqual(self.karar(1, req, 'sohbet-1', 'ide/Otomatik'), 'ide/Otomatik')
|
||||
self.assertEqual(self.kararlar, [])
|
||||
|
||||
def test_automatic_choice_is_kept_for_the_tool_steps_of_the_same_turn(self):
|
||||
self.settings['autoRoute']['enabled'] = True
|
||||
self.save()
|
||||
self.cevap = {'hedef': 'Arastirma', 'ret': None, 'puan': {'zor': 0.2, 'arastirma': 0.9}}
|
||||
req = self.istek('ide/Otomatik', user('kaynaklari karsilastir'))
|
||||
self.assertEqual(self.karar(1, req, 'sohbet-1', 'ide/Otomatik'), 'ide/Arastirma')
|
||||
self.assertEqual(req['model'], 'ide/Arastirma')
|
||||
arac = {'type': 'function_call_output', 'call_id': 'c1', 'output': 'ok'}
|
||||
req2 = self.istek('ide/Otomatik', user('kaynaklari karsilastir'), {'type': 'function_call', 'call_id': 'c1'}, arac)
|
||||
self.assertEqual(self.karar(2, req2, 'sohbet-1', 'ide/Otomatik'), 'ide/Arastirma')
|
||||
self.assertEqual(len(self.kararlar), 1) # same turn: no second question
|
||||
req3 = self.istek('ide/Otomatik', user('kaynaklari karsilastir'), arac, user('simdi kodu duzelt'))
|
||||
self.cevap = {'hedef': 'Ajan', 'ret': None, 'puan': {'zor': 0.9, 'arastirma': 0.1}}
|
||||
self.assertEqual(self.karar(3, req3, 'sohbet-1', 'ide/Otomatik'), 'ide/Ajan')
|
||||
self.assertEqual(len(self.kararlar), 2) # new user message: new decision
|
||||
|
||||
def test_predicted_refusal_starts_at_the_configured_layer(self):
|
||||
self.settings['prefilter']['enabled'] = True
|
||||
self.save()
|
||||
self.cevap = {'hedef': None, 'ret': True, 'puan': {'ret': 0.9}}
|
||||
req = self.istek('ide/codex-gpt-6-astra', user('sert bir roast yaz'))
|
||||
self.assertEqual(self.karar(1, req, 'sohbet-1', 'ide/codex-gpt-6-astra'), 'ide/Katman-2')
|
||||
self.assertEqual(self.kararlar, [('sert bir roast yaz', False, True)])
|
||||
|
||||
def test_exempt_and_later_layers_are_not_asked(self):
|
||||
self.settings['prefilter']['enabled'] = True
|
||||
self.save()
|
||||
for model in ('ide/Chat-Model', 'ide/Katman-2', 'ide/Katman-3', 'codex/gpt-6-astra'):
|
||||
req = self.istek(model, user('yap'))
|
||||
self.assertEqual(self.karar(1, req, 'sohbet-1', model), model)
|
||||
self.assertEqual(self.kararlar, [])
|
||||
|
||||
def test_no_refusal_or_no_decision_keeps_the_model(self):
|
||||
self.settings['prefilter']['enabled'] = True
|
||||
self.save()
|
||||
for cevap in ({'hedef': None, 'ret': False, 'puan': {'ret': 0.1}}, {'hedef': None, 'ret': None, 'puan': None}):
|
||||
self.env['ON_KARAR'].clear()
|
||||
self.cevap = cevap
|
||||
req = self.istek('ide/Ajan', user('yap'))
|
||||
self.assertEqual(self.karar(1, req, 'sohbet-1', 'ide/Ajan'), 'ide/Ajan')
|
||||
self.assertEqual(req['model'], 'ide/Ajan')
|
||||
|
||||
def test_master_switch_off_disables_every_stage(self):
|
||||
self.settings['enabled'] = False
|
||||
self.settings['prefilter']['enabled'] = True
|
||||
self.settings['autoRoute']['enabled'] = True
|
||||
self.save()
|
||||
req = self.istek('ide/Otomatik', user('yap'))
|
||||
self.assertEqual(self.karar(1, req, 'sohbet-1', 'ide/Otomatik'), 'ide/Otomatik')
|
||||
self.assertEqual(self.kararlar, [])
|
||||
|
||||
def test_turn_memory_is_bounded(self):
|
||||
self.settings['prefilter']['enabled'] = True
|
||||
self.save()
|
||||
self.cevap = {'hedef': None, 'ret': False, 'puan': {'ret': 0.1}}
|
||||
for i in range(6):
|
||||
self.karar(i, {'model': 'ide/Ajan', 'prompt_cache_key': f's{i}', 'input': [user('yap')]}, f's{i}', 'ide/Ajan')
|
||||
self.assertLessEqual(len(self.env['ON_KARAR']), 3)
|
||||
|
||||
|
||||
class KatmanPenceresi(unittest.TestCase):
|
||||
def test_a_layer_too_small_for_the_request_is_skipped(self):
|
||||
logs = []
|
||||
env = {'IDE_PREFIX': 'ide/', 'IDE_BILGI': {'ide/Katman-2': {'pencere': 1000}, 'ide/Katman-3': {'pencere': 10 ** 6}},
|
||||
'pencere': pencere, 'log': logs.append}
|
||||
exec(compile(ast.Module(body=[fonksiyon('sigan_katmanlar')], type_ignores=[]), 'kopru.py', 'exec'), env)
|
||||
req = {'input': [user('x' * 20000)]}
|
||||
self.assertEqual(env['sigan_katmanlar'](['Katman-2', 'Katman-3', 'Bilinmeyen'], req, '[1]'), ['Katman-3', 'Bilinmeyen'])
|
||||
self.assertIn('Katman-2', logs[0])
|
||||
|
||||
|
||||
class BitisDenetimi(Ev):
|
||||
"""_denetimli_akis with finishCheck: an answer that stops early goes back to the same model once."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.settings['finishCheck']['enabled'] = True
|
||||
self.save()
|
||||
self.asked, self.logs, self.devamlar = [], [], []
|
||||
self.puanlar = []
|
||||
sahte = Mock()
|
||||
sahte.anahtar = lambda: 'k'
|
||||
sahte.sor = self.sor
|
||||
sahte.karar = jev_denetim.karar
|
||||
sahte.bitmedi_mi = jev_denetim.bitmedi_mi
|
||||
sahte.hazirlik_mi = jev_denetim.hazirlik_mi
|
||||
sahte.muaf_mi = jev_denetim.muaf_mi
|
||||
sahte.hata_gecis = jev_denetim.hata_gecis
|
||||
env = {'jev_denetim': sahte, 'yarim': yarim, 'time': time, 'http': __import__('http.client'),
|
||||
'log': self.logs.append, 'RESP_HOP': set(), 'json': json}
|
||||
adlar = ('_akis_basligi', '_denetimli_akis', '_stream')
|
||||
exec(compile(ast.Module(body=[method(a) for a in adlar], type_ignores=[]), 'kopru.py', 'exec'), env)
|
||||
self.h = type('H', (), {a: env[a] for a in adlar})()
|
||||
self.h.wfile = io.BytesIO()
|
||||
self.h.send_response = self.h.send_header = self.h.end_headers = Mock()
|
||||
|
||||
def sor(self, istek, metin, key, bitis=False):
|
||||
self.asked.append((metin, bitis))
|
||||
return self.puanlar.pop(0) if self.puanlar else {'ret': 0.0, 'sart': 0.0}
|
||||
|
||||
def devam(self, ad, onceki):
|
||||
self.devamlar.append((ad, onceki))
|
||||
return Yanit(sse('gercekten bitti, cikti: OK')), None, f'[{ad} devam]'
|
||||
|
||||
def calistir(self, body):
|
||||
self.h._denetimli_akis(Yanit(body), '[1]', None, None, 'yap', ['Katman-1'], Mock(), hedef='Ajan', devam=self.devam)
|
||||
return self.gonderilen()
|
||||
|
||||
def gonderilen(self):
|
||||
raw, out = self.h.wfile.getvalue(), b''
|
||||
while raw:
|
||||
size, _, rest = raw.partition(b'\r\n')
|
||||
n = int(size, 16)
|
||||
if n == 0:
|
||||
break
|
||||
out += rest[:n]
|
||||
raw = rest[n + 2:]
|
||||
return out
|
||||
|
||||
def test_an_early_stop_goes_back_once_and_the_retry_is_delivered(self):
|
||||
self.puanlar = [{'ret': 0.0, 'sart': 0.0, 'bitmedi': 0.9}, {'ret': 0.0, 'sart': 0.0, 'bitmedi': 0.9}]
|
||||
out = self.calistir(sse('Bitti.'))
|
||||
self.assertEqual(self.devamlar, [('Ajan', 'Bitti.')])
|
||||
self.assertIn(b'gercekten bitti', out)
|
||||
self.assertNotIn(b'"Bitti."', out)
|
||||
self.assertEqual([b for _, b in self.asked], [True, False]) # the retry is not asked again
|
||||
|
||||
def test_a_finished_answer_is_delivered_unchanged(self):
|
||||
self.puanlar = [{'ret': 0.0, 'sart': 0.0, 'bitmedi': 0.1}]
|
||||
out = self.calistir(sse('Tamam, test ciktisi: 3 passed.'))
|
||||
self.assertEqual(self.devamlar, [])
|
||||
self.assertIn(b'3 passed', out)
|
||||
|
||||
def test_a_refusal_still_goes_down_the_chain_not_back(self):
|
||||
self.puanlar = [{'ret': 0.9, 'sart': 0.0, 'bitmedi': 0.9}]
|
||||
ac = Mock(return_value=(Yanit(sse('katman')), None, '[K1]'))
|
||||
self.h._denetimli_akis(Yanit(sse('Yapamam.')), '[1]', None, None, 'yap', ['Katman-1'], ac, hedef='Ajan', devam=self.devam)
|
||||
self.assertEqual(self.devamlar, [])
|
||||
ac.assert_called_once_with('Katman-1')
|
||||
|
||||
def test_a_failed_retry_delivers_the_first_answer(self):
|
||||
self.puanlar = [{'ret': 0.0, 'sart': 0.0, 'bitmedi': 0.9}]
|
||||
|
||||
def bozuk(ad, onceki):
|
||||
raise OSError('acilamadi')
|
||||
self.h._denetimli_akis(Yanit(sse('Bitti.')), '[1]', None, None, 'yap', [], Mock(), hedef='Ajan', devam=bozuk)
|
||||
self.assertIn(b'Bitti.', self.gonderilen())
|
||||
|
||||
def test_without_the_stage_the_finish_question_is_not_sent(self):
|
||||
govdeler = []
|
||||
|
||||
def gonder(govde, key):
|
||||
govdeler.append(govde)
|
||||
return {'answers': {'ret': {'noul': 0.1}, 'sart': {'noul': 0.1}}}
|
||||
puan = jev_denetim.sor('i', 'c', 'k', gonder=gonder)
|
||||
self.assertNotIn('bitmedi', govdeler[0]['questions'])
|
||||
self.assertNotIn('bitmedi', puan)
|
||||
puan = jev_denetim.sor('i', 'c', 'k', gonder=gonder, bitis=True)
|
||||
self.assertIn('bitmedi', govdeler[1]['questions'])
|
||||
self.assertEqual(set(puan), {'ret', 'sart'}) # unanswered extra question: main verdict stands
|
||||
self.assertFalse(jev_denetim.bitmedi_mi(puan))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
|
||||
class DogrulayiciElemesi(Ev):
|
||||
def test_off_by_default_and_asked_only_when_enabled(self):
|
||||
import io as _io
|
||||
import karar_sor
|
||||
self.save()
|
||||
out = _io.StringIO()
|
||||
self.assertEqual(karar_sor.main(['dogrulayici'], _io.StringIO('{"user_request": "x", "assistant_reply": "kabul"}'), out), 0)
|
||||
self.assertEqual(json.loads(out.getvalue())['enabled'], False)
|
||||
self.settings['verifierCheck']['enabled'] = True
|
||||
self.save()
|
||||
govdeler = []
|
||||
|
||||
def gonder(govde, key):
|
||||
govdeler.append(govde)
|
||||
return {'answers': {'kanitsiz': {'noul': 0.92}}}
|
||||
sonuc = karar_sor.dogrulayici({'user_request': 'olcut', 'assistant_reply': '{"karar":"kabul","bulgular":[]}'}, 'k', gonder)
|
||||
self.assertEqual((sonuc['enabled'], sonuc['kanitsiz'], sonuc['puan']), (True, True, 0.92))
|
||||
self.assertEqual(set(govdeler[0]['questions']), {'kanitsiz'})
|
||||
bozuk = karar_sor.dogrulayici({'user_request': 'o', 'assistant_reply': 'r'}, 'k', Mock(side_effect=OSError('yok')))
|
||||
self.assertIsNone(bozuk['kanitsiz'])
|
||||
|
||||
def test_wrong_usage_exits_2(self):
|
||||
import io as _io
|
||||
import karar_sor
|
||||
self.assertEqual(karar_sor.main(['baska'], _io.StringIO('{}'), _io.StringIO()), 2)
|
||||
@@ -27,7 +27,7 @@ export const CODEX_LINK_REFRESH_URL = `${CODEX_LINK_ORIGIN}/aiturk/codex-baglama
|
||||
export const CODEX_LINK_MARKER = '# AITURK IDE Codex Baglama'
|
||||
// Must equal SURUM in assets/codex-bridge/bridge.py; a running bridge that reports
|
||||
// another version is restarted so an updated IDE never keeps an old bridge alive.
|
||||
export const CODEX_BRIDGE_VERSION = '18'
|
||||
export const CODEX_BRIDGE_VERSION = '19'
|
||||
export const CODEX_LINK_LINE = `openai_base_url = "${CODEX_LINK_ORIGIN}/backend-api/codex" ${CODEX_LINK_MARKER}`
|
||||
|
||||
export const CODEX_LINK_RUN_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run'
|
||||
|
||||
@@ -12,11 +12,35 @@ export interface StagedTransitionSettings {
|
||||
maxConcurrent: number
|
||||
queueWaitSeconds: number
|
||||
failureAction: 'pass' | 'next'
|
||||
// Schema 2 (14 Oct 2026). A schema 1 file gets these from the defaults; every stage starts disabled.
|
||||
watchModels: string[]
|
||||
prefilter: { enabled: boolean; question: string; threshold: number; target: string; timeoutSeconds: number }
|
||||
autoRoute: {
|
||||
enabled: boolean
|
||||
model: string
|
||||
timeoutSeconds: number
|
||||
questions: { zor: string; arastirma: string }
|
||||
thresholds: { zor: number; arastirma: number }
|
||||
targets: { basit: string; zor: string; arastirma: string }
|
||||
}
|
||||
finishCheck: { enabled: boolean; question: string; threshold: number; note: string; timeoutSeconds: number }
|
||||
verifierCheck: { enabled: boolean; question: string; threshold: number }
|
||||
policy: { source: 'local' | 'dify'; endpoint: string; refreshMinutes: number }
|
||||
}
|
||||
|
||||
export interface StagedTransitionPolicyStatus {
|
||||
fetchedAt?: string
|
||||
lastGoodAt?: string
|
||||
ok: boolean
|
||||
error?: string
|
||||
sections: string[]
|
||||
}
|
||||
|
||||
export interface StagedTransitionState {
|
||||
settings: StagedTransitionSettings
|
||||
keyConfigured: boolean
|
||||
policyKeyConfigured?: boolean
|
||||
policyStatus?: StagedTransitionPolicyStatus | null
|
||||
models?: { id: string; name: string }[]
|
||||
modelsAvailable?: boolean
|
||||
}
|
||||
@@ -24,4 +48,5 @@ export interface StagedTransitionState {
|
||||
export interface StagedTransitionPatch {
|
||||
settings: StagedTransitionSettings
|
||||
apiKey?: string
|
||||
policyApiKey?: string
|
||||
}
|
||||
@@ -4,7 +4,12 @@ import path from 'node:path'
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { createStagedTransitionStore, stagedTransitionDefaults, validateStagedTransition } from './staged-transition'
|
||||
import {
|
||||
createStagedTransitionStore,
|
||||
readPolicyStatus,
|
||||
stagedTransitionDefaults,
|
||||
validateStagedTransition
|
||||
} from './staged-transition'
|
||||
|
||||
const dirs: string[] = []
|
||||
afterEach(() => {
|
||||
@@ -50,4 +55,56 @@ describe('staged transition settings', () => {
|
||||
expect(() => validateStagedTransition({ ...before.settings, thresholds: { ret: NaN, sart: 0.7 } })).toThrow()
|
||||
expect(() => validateStagedTransition({ ...before.settings, endpoint: 'http://example.com' })).toThrow()
|
||||
})
|
||||
it('loads a schema 1 file with every new stage disabled and keeps unknown-free output', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'jev-settings-test-'))
|
||||
dirs.push(dir)
|
||||
const old = structuredClone(stagedTransitionDefaults) as unknown as Record<string, unknown>
|
||||
|
||||
for (const key of ['watchModels', 'prefilter', 'autoRoute', 'finishCheck', 'verifierCheck', 'policy']) {
|
||||
delete old[key]
|
||||
}
|
||||
fs.writeFileSync(path.join(dir, 'jev-settings.json'), JSON.stringify({ settings: old, protectedKey: 'k' }))
|
||||
const store = createStagedTransitionStore(dir, path.join(dir, 'missing.env'), x => x)
|
||||
const state = store.get()
|
||||
expect(state.settings.prefilter.enabled).toBe(false)
|
||||
expect(state.settings.autoRoute.enabled).toBe(false)
|
||||
expect(state.settings.finishCheck.enabled).toBe(false)
|
||||
expect(state.settings.verifierCheck.enabled).toBe(false)
|
||||
expect(state.settings.policy.source).toBe('local')
|
||||
expect(state.keyConfigured).toBe(true)
|
||||
const partial = { ...structuredClone(stagedTransitionDefaults), prefilter: { enabled: true, threshold: 0.8 } }
|
||||
const filled = validateStagedTransition(partial)
|
||||
expect(filled.prefilter.enabled).toBe(true)
|
||||
expect(filled.prefilter.threshold).toBe(0.8)
|
||||
expect(filled.prefilter.target).toBe(stagedTransitionDefaults.prefilter.target)
|
||||
})
|
||||
it('keeps the Dify key protected and reports the last rules fetch', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'jev-settings-test-'))
|
||||
dirs.push(dir)
|
||||
const store = createStagedTransitionStore(dir, path.join(dir, 'missing.env'), () => 'protected-value')
|
||||
const settings = { ...structuredClone(stagedTransitionDefaults), policy: { ...stagedTransitionDefaults.policy, source: 'dify' as const } }
|
||||
const saved = store.save({ settings, policyApiKey: 'app-dummy-secret' })
|
||||
expect(saved.policyKeyConfigured).toBe(true)
|
||||
expect(fs.readFileSync(path.join(dir, 'jev-settings.json'), 'utf8')).not.toContain('app-dummy-secret')
|
||||
expect(store.save({ settings }).policyKeyConfigured).toBe(true)
|
||||
expect(store.save({ settings, policyApiKey: '' }).policyKeyConfigured).toBe(false)
|
||||
expect(readPolicyStatus(dir)).toBeNull()
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'jev-policy.json'),
|
||||
JSON.stringify({ fetchedAt: 'now', ok: true, error: 'Dify down', overrides: { autoRoute: { enabled: true } } })
|
||||
)
|
||||
expect(store.get().policyStatus).toEqual({ fetchedAt: 'now', lastGoodAt: undefined, ok: true, error: 'Dify down', sections: ['autoRoute'] })
|
||||
})
|
||||
it('rejects invalid decision stage values', () => {
|
||||
const base = structuredClone(stagedTransitionDefaults)
|
||||
expect(() => validateStagedTransition({ ...base, prefilter: { ...base.prefilter, threshold: 2 } })).toThrow()
|
||||
expect(() => validateStagedTransition({ ...base, prefilter: { ...base.prefilter, timeoutSeconds: 0 } })).toThrow()
|
||||
expect(() =>
|
||||
validateStagedTransition({ ...base, autoRoute: { ...base.autoRoute, targets: { ...base.autoRoute.targets, zor: 'Otomatik' } } })
|
||||
).toThrow()
|
||||
expect(() => validateStagedTransition({ ...base, finishCheck: { ...base.finishCheck, note: '' } })).toThrow()
|
||||
expect(() => validateStagedTransition({ ...base, policy: { ...base.policy, endpoint: 'http://dify.example' } })).toThrow()
|
||||
expect(() => validateStagedTransition({ ...base, policy: { ...base.policy, source: 'web' } })).toThrow()
|
||||
expect(() => validateStagedTransition({ ...base, watchModels: ['a', 'a'] })).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -3,16 +3,70 @@ import path from 'node:path'
|
||||
|
||||
import defaults from '../assets/codex-bridge/jev-settings-defaults.json'
|
||||
|
||||
import type { StagedTransitionPatch, StagedTransitionSettings, StagedTransitionState } from './staged-transition-types'
|
||||
import type {
|
||||
StagedTransitionPatch,
|
||||
StagedTransitionPolicyStatus,
|
||||
StagedTransitionSettings,
|
||||
StagedTransitionState
|
||||
} from './staged-transition-types'
|
||||
|
||||
export const stagedTransitionDefaults = defaults as StagedTransitionSettings
|
||||
|
||||
export function validateStagedTransition(value: unknown): StagedTransitionSettings {
|
||||
if (!value || typeof value !== 'object') {
|
||||
// Sections added in schema 2 (14 Oct 2026). Mirrors codex-bridge/jev_settings.py: a missing section or
|
||||
// field comes from the defaults, so a schema 1 file stays valid and every new stage starts disabled.
|
||||
const SECTIONS = ['watchModels', 'prefilter', 'autoRoute', 'finishCheck', 'verifierCheck', 'policy'] as const
|
||||
const POLICY_FILE = 'jev-policy.json'
|
||||
|
||||
type Plain = Record<string, unknown>
|
||||
|
||||
const isPlain = (value: unknown): value is Plain => Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
||||
|
||||
function fill(value: unknown, fallback: unknown): unknown {
|
||||
if (!isPlain(fallback) || !isPlain(value)) {
|
||||
return value
|
||||
}
|
||||
const out: Plain = { ...value }
|
||||
|
||||
for (const [key, def] of Object.entries(fallback)) {
|
||||
out[key] = key in value ? fill(value[key], def) : structuredClone(def)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
export function withSchemaDefaults(value: unknown): unknown {
|
||||
if (!isPlain(value)) {
|
||||
return value
|
||||
}
|
||||
const out: Plain = { ...value }
|
||||
const defs = stagedTransitionDefaults as unknown as Plain
|
||||
|
||||
for (const section of SECTIONS) {
|
||||
out[section] = section in value ? fill(value[section], defs[section]) : structuredClone(defs[section])
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
const text = (s: unknown, max = 10000): s is string => typeof s === 'string' && s.trim().length > 0 && s.length <= max
|
||||
const probability = (n: unknown): n is number => typeof n === 'number' && Number.isFinite(n) && n >= 0 && n <= 1
|
||||
const integer = (n: unknown, min: number, max: number): n is number =>
|
||||
typeof n === 'number' && Number.isInteger(n) && n >= min && n <= max
|
||||
|
||||
function https(value: unknown): value is string {
|
||||
if (!text(value, 2048)) {
|
||||
return false
|
||||
}
|
||||
const url = new URL(value)
|
||||
|
||||
return url.protocol === 'https:' && !url.username && !url.password && !url.hash
|
||||
}
|
||||
|
||||
export function validateStagedTransition(input: unknown): StagedTransitionSettings {
|
||||
if (!input || typeof input !== 'object') {
|
||||
throw new Error('Invalid settings')
|
||||
}
|
||||
const v = value as StagedTransitionSettings
|
||||
const text = (s: unknown, max = 10000): s is string => typeof s === 'string' && s.trim().length > 0 && s.length <= max
|
||||
const v = withSchemaDefaults(input) as StagedTransitionSettings
|
||||
|
||||
const list = (s: unknown, allowEmpty: boolean) =>
|
||||
Array.isArray(s) &&
|
||||
@@ -21,15 +75,13 @@ export function validateStagedTransition(value: unknown): StagedTransitionSettin
|
||||
s.every(x => text(x, 256)) &&
|
||||
new Set(s.map(x => x.trim())).size === s.length
|
||||
|
||||
const seconds = (n: unknown) => typeof n === 'number' && Number.isInteger(n) && n >= 1 && n <= 3600
|
||||
const seconds = (n: unknown) => integer(n, 1, 3600)
|
||||
|
||||
if (typeof v.enabled !== 'boolean' || !text(v.endpoint, 2048) || !text(v.model, 256)) {
|
||||
throw new Error('Invalid connection settings')
|
||||
}
|
||||
|
||||
const url = new URL(v.endpoint)
|
||||
|
||||
if (url.protocol !== 'https:' || url.username || url.password || url.hash) {
|
||||
if (!https(v.endpoint)) {
|
||||
throw new Error('HTTPS endpoint required')
|
||||
}
|
||||
|
||||
@@ -60,13 +112,7 @@ export function validateStagedTransition(value: unknown): StagedTransitionSettin
|
||||
}
|
||||
|
||||
for (const id of ['ret', 'sart'] as const) {
|
||||
if (
|
||||
!text(v.questions?.[id]) ||
|
||||
typeof v.thresholds?.[id] !== 'number' ||
|
||||
!Number.isFinite(v.thresholds[id]) ||
|
||||
v.thresholds[id] < 0 ||
|
||||
v.thresholds[id] > 1
|
||||
) {
|
||||
if (!text(v.questions?.[id]) || !probability(v.thresholds?.[id])) {
|
||||
throw new Error('Invalid evaluation rule')
|
||||
}
|
||||
|
||||
@@ -80,28 +126,124 @@ export function validateStagedTransition(value: unknown): StagedTransitionSettin
|
||||
if (
|
||||
!seconds(v.timeoutSeconds) ||
|
||||
!seconds(v.queueWaitSeconds) ||
|
||||
!Number.isInteger(v.maxConcurrent) ||
|
||||
v.maxConcurrent < 0 ||
|
||||
v.maxConcurrent > 512 ||
|
||||
!integer(v.maxConcurrent, 0, 512) ||
|
||||
!['pass', 'next'].includes(v.failureAction)
|
||||
) {
|
||||
throw new Error('Invalid request limits')
|
||||
}
|
||||
|
||||
if (!list(v.watchModels, true)) {
|
||||
throw new Error('Invalid watch models')
|
||||
}
|
||||
|
||||
const p = v.prefilter
|
||||
|
||||
if (
|
||||
typeof p?.enabled !== 'boolean' ||
|
||||
!text(p.question) ||
|
||||
!probability(p.threshold) ||
|
||||
!text(p.target, 256) ||
|
||||
!integer(p.timeoutSeconds, 1, 60)
|
||||
) {
|
||||
throw new Error('Invalid prefilter')
|
||||
}
|
||||
|
||||
const a = v.autoRoute
|
||||
|
||||
if (
|
||||
typeof a?.enabled !== 'boolean' ||
|
||||
!text(a.model, 256) ||
|
||||
!integer(a.timeoutSeconds, 1, 60) ||
|
||||
(['zor', 'arastirma'] as const).some(k => !text(a.questions?.[k]) || !probability(a.thresholds?.[k])) ||
|
||||
(['basit', 'zor', 'arastirma'] as const).some(k => !text(a.targets?.[k], 256))
|
||||
) {
|
||||
throw new Error('Invalid automatic routing')
|
||||
}
|
||||
|
||||
if (Object.values(a.targets).some(t => t.trim() === a.model.trim())) {
|
||||
throw new Error('Automatic routing cannot target itself')
|
||||
}
|
||||
|
||||
const f = v.finishCheck
|
||||
|
||||
if (
|
||||
typeof f?.enabled !== 'boolean' ||
|
||||
!text(f.question) ||
|
||||
!probability(f.threshold) ||
|
||||
!text(f.note, 4000) ||
|
||||
!integer(f.timeoutSeconds, 1, 60)
|
||||
) {
|
||||
throw new Error('Invalid finish check')
|
||||
}
|
||||
|
||||
const c = v.verifierCheck
|
||||
|
||||
if (typeof c?.enabled !== 'boolean' || !text(c.question) || !probability(c.threshold)) {
|
||||
throw new Error('Invalid verifier check')
|
||||
}
|
||||
|
||||
const pol = v.policy
|
||||
|
||||
if (!['local', 'dify'].includes(pol?.source) || !https(pol.endpoint) || !integer(pol.refreshMinutes, 1, 1440)) {
|
||||
throw new Error('Invalid policy source')
|
||||
}
|
||||
|
||||
const trimmed = (s: string) => s.trim()
|
||||
|
||||
return {
|
||||
enabled: v.enabled,
|
||||
endpoint: v.endpoint.trim(),
|
||||
model: v.model.trim(),
|
||||
fallbackModel: v.fallbackModel.trim(),
|
||||
rejectionRules: v.rejectionRules.map(r => ({ from: r.from.trim(), to: r.to.trim() })),
|
||||
exemptModels: v.exemptModels.map(x => x.trim()),
|
||||
exemptModels: v.exemptModels.map(trimmed),
|
||||
questions: { ...v.questions },
|
||||
thresholds: { ...v.thresholds },
|
||||
criteria: structuredClone(v.criteria),
|
||||
timeoutSeconds: v.timeoutSeconds,
|
||||
maxConcurrent: v.maxConcurrent,
|
||||
queueWaitSeconds: v.queueWaitSeconds,
|
||||
failureAction: v.failureAction
|
||||
failureAction: v.failureAction,
|
||||
watchModels: v.watchModels.map(trimmed),
|
||||
prefilter: { ...p, target: p.target.trim() },
|
||||
autoRoute: {
|
||||
enabled: a.enabled,
|
||||
model: a.model.trim(),
|
||||
timeoutSeconds: a.timeoutSeconds,
|
||||
questions: { zor: a.questions.zor, arastirma: a.questions.arastirma },
|
||||
thresholds: { zor: a.thresholds.zor, arastirma: a.thresholds.arastirma },
|
||||
targets: { basit: a.targets.basit.trim(), zor: a.targets.zor.trim(), arastirma: a.targets.arastirma.trim() }
|
||||
},
|
||||
finishCheck: {
|
||||
enabled: f.enabled,
|
||||
question: f.question,
|
||||
threshold: f.threshold,
|
||||
note: f.note,
|
||||
timeoutSeconds: f.timeoutSeconds
|
||||
},
|
||||
verifierCheck: { enabled: c.enabled, question: c.question, threshold: c.threshold },
|
||||
policy: { source: pol.source, endpoint: pol.endpoint.trim(), refreshMinutes: pol.refreshMinutes }
|
||||
}
|
||||
}
|
||||
|
||||
// The bridge writes jev-policy.json after each Dify fetch; only a summary reaches the renderer.
|
||||
export function readPolicyStatus(directory: string): StagedTransitionPolicyStatus | null {
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(path.join(directory, POLICY_FILE), 'utf8'))
|
||||
|
||||
if (!isPlain(data)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
fetchedAt: typeof data.fetchedAt === 'string' ? data.fetchedAt : undefined,
|
||||
lastGoodAt: typeof data.lastGoodAt === 'string' ? data.lastGoodAt : undefined,
|
||||
ok: data.ok === true,
|
||||
error: typeof data.error === 'string' && data.error ? data.error.slice(0, 500) : undefined,
|
||||
sections: isPlain(data.overrides) ? Object.keys(data.overrides) : []
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,13 +254,17 @@ export function createStagedTransitionStore(
|
||||
) {
|
||||
const file = path.join(directory, 'jev-settings.json')
|
||||
|
||||
const read = (): { settings: StagedTransitionSettings; protectedKey?: string } => {
|
||||
const read = (): { settings: StagedTransitionSettings; protectedKey?: string; protectedPolicyKey?: string } => {
|
||||
if (!fs.existsSync(file)) {
|
||||
return { settings: structuredClone(stagedTransitionDefaults) }
|
||||
}
|
||||
const data = JSON.parse(fs.readFileSync(file, 'utf8'))
|
||||
|
||||
return { settings: validateStagedTransition(data.settings), protectedKey: data.protectedKey }
|
||||
return {
|
||||
settings: validateStagedTransition(data.settings),
|
||||
protectedKey: data.protectedKey,
|
||||
protectedPolicyKey: data.protectedPolicyKey
|
||||
}
|
||||
}
|
||||
|
||||
const state = (): StagedTransitionState => {
|
||||
@@ -135,32 +281,40 @@ export function createStagedTransitionStore(
|
||||
)
|
||||
}
|
||||
|
||||
return { settings: current.settings, keyConfigured: Boolean(current.protectedKey) || legacy }
|
||||
return {
|
||||
settings: current.settings,
|
||||
keyConfigured: Boolean(current.protectedKey) || legacy,
|
||||
policyKeyConfigured: Boolean(current.protectedPolicyKey),
|
||||
policyStatus: readPolicyStatus(directory)
|
||||
}
|
||||
}
|
||||
|
||||
const secret = (value: unknown, name: string) => {
|
||||
if (value !== undefined && (typeof value !== 'string' || value.length > 8192)) {
|
||||
throw new Error(`Invalid ${name}`)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
get: state,
|
||||
save(patch: StagedTransitionPatch): StagedTransitionState {
|
||||
const settings = validateStagedTransition(patch?.settings)
|
||||
|
||||
if (patch.apiKey !== undefined && (typeof patch.apiKey !== 'string' || patch.apiKey.length > 8192)) {
|
||||
throw new Error('Invalid API key')
|
||||
}
|
||||
secret(patch.apiKey, 'API key')
|
||||
secret(patch.policyApiKey, 'policy key')
|
||||
|
||||
const current = read()
|
||||
|
||||
const protectedKey =
|
||||
patch.apiKey === undefined
|
||||
? current.protectedKey
|
||||
: patch.apiKey.trim()
|
||||
? protect(patch.apiKey.trim())
|
||||
: undefined
|
||||
const update = (value: string | undefined, previous: string | undefined) =>
|
||||
value === undefined ? previous : value.trim() ? protect(value.trim()) : undefined
|
||||
|
||||
const protectedKey = update(patch.apiKey, current.protectedKey)
|
||||
const protectedPolicyKey = update(patch.policyApiKey, current.protectedPolicyKey)
|
||||
|
||||
fs.mkdirSync(directory, { recursive: true })
|
||||
const tmp = `${file}.${process.pid}.tmp`
|
||||
|
||||
try {
|
||||
fs.writeFileSync(tmp, JSON.stringify({ settings, protectedKey }, null, 2), { mode: 0o600 })
|
||||
fs.writeFileSync(tmp, JSON.stringify({ settings, protectedKey, protectedPolicyKey }, null, 2), { mode: 0o600 })
|
||||
fs.renameSync(tmp, file)
|
||||
} finally {
|
||||
if (fs.existsSync(tmp)) {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "aiturk-ide",
|
||||
"productName": "AITURK IDE",
|
||||
"private": true,
|
||||
"version": "1.0.0-beta.38",
|
||||
"version": "1.0.0-beta.39",
|
||||
"description": "TurkServis resmi yapay zekâ geliştirme ortamı. Hermes Agent tabanlıdır.",
|
||||
"author": "AITURK / TurkServis",
|
||||
"repository": {
|
||||
|
||||
@@ -44,13 +44,13 @@ describe('staged transition settings UI', () => {
|
||||
show()
|
||||
await screen.findByText('Kademeli Geçiş')
|
||||
fireEvent.click(screen.getByText('+ Geçiş kuralı ekle'))
|
||||
expect(screen.getByLabelText('İtiraz edilen model 4')).toBeTruthy()
|
||||
expect(screen.getByLabelText('Kullanılacak model 4')).toBeTruthy()
|
||||
fireEvent.click(screen.getByLabelText('Geçiş kuralı 4 sil'))
|
||||
expect(screen.queryByLabelText('İtiraz edilen model 4')).toBeNull()
|
||||
expect(screen.getByLabelText('İtiraz edilen model 3')).toBeTruthy()
|
||||
expect(screen.getByLabelText('Kullanılacak model 3')).toBeTruthy()
|
||||
fireEvent.click(screen.getByLabelText('Geçiş kuralı 3 sil'))
|
||||
expect(screen.queryByLabelText('İtiraz edilen model 3')).toBeNull()
|
||||
fireEvent.click(screen.getByText('+ Her zaman geç kuralı ekle'))
|
||||
expect(screen.getByLabelText('Her zaman geçen model 3')).toBeTruthy()
|
||||
fireEvent.click(screen.getByLabelText('Her zaman geç kuralı 3 sil'))
|
||||
expect(screen.getByLabelText('Her zaman geçen model 4')).toBeTruthy()
|
||||
fireEvent.click(screen.getByLabelText('Her zaman geç kuralı 4 sil'))
|
||||
fireEvent.change(screen.getByLabelText('İsteği yerine getirmeme denetim metni'), {
|
||||
target: { value: 'custom prompt' }
|
||||
})
|
||||
@@ -58,6 +58,37 @@ describe('staged transition settings UI', () => {
|
||||
await screen.findByText('Kaydedildi. Yeni değerlendirmelerde uygulanır.')
|
||||
expect(set.mock.calls[0][0].settings.questions.ret).toBe('custom prompt')
|
||||
expect(set.mock.calls[0][0]).not.toHaveProperty('apiKey')
|
||||
expect(set.mock.calls[0][0]).not.toHaveProperty('policyApiKey')
|
||||
})
|
||||
it('edits the decision stages, the correction channel and the Dify rules source', async () => {
|
||||
get.mockResolvedValue({
|
||||
...state(),
|
||||
policyKeyConfigured: false,
|
||||
policyStatus: { ok: true, fetchedAt: 'dün', sections: ['autoRoute'], error: 'Dify kapalı' }
|
||||
})
|
||||
show()
|
||||
await screen.findByText('Karar aşamaları')
|
||||
fireEvent.click(screen.getByLabelText('Ret ön kontrolü'))
|
||||
fireEvent.change(screen.getByLabelText('Ret ön kontrolü eşiği'), { target: { value: '0.75' } })
|
||||
fireEvent.click(screen.getByLabelText('Otomatik model'))
|
||||
fireEvent.change(screen.getByLabelText('Zor iş sorusu'), { target: { value: 'zor mu?' } })
|
||||
fireEvent.click(screen.getByLabelText('Erken bitiş denetimi'))
|
||||
fireEvent.change(screen.getByLabelText('Modele verilecek not'), { target: { value: 'işi bitir' } })
|
||||
fireEvent.click(screen.getByLabelText('Workflow doğrulayıcı elemesi'))
|
||||
fireEvent.click(screen.getByText('+ Düzeltme kanalı modeli ekle'))
|
||||
expect(screen.getByLabelText('Düzeltme kanalı modeli 4')).toBeTruthy()
|
||||
fireEvent.click(screen.getByLabelText('Düzeltme kanalı modeli 4 sil'))
|
||||
fireEvent.change(screen.getByLabelText('Dify uygulama anahtarı'), { target: { value: 'app-key' } })
|
||||
fireEvent.click(screen.getByText('Kaydet'))
|
||||
await screen.findByText('Kaydedildi. Yeni değerlendirmelerde uygulanır.')
|
||||
const saved = set.mock.calls[0][0]
|
||||
expect(saved.settings.prefilter).toMatchObject({ enabled: true, threshold: 0.75 })
|
||||
expect(saved.settings.autoRoute).toMatchObject({ enabled: true, questions: { zor: 'zor mu?' } })
|
||||
expect(saved.settings.finishCheck).toMatchObject({ enabled: true, note: 'işi bitir' })
|
||||
expect(saved.settings.verifierCheck.enabled).toBe(true)
|
||||
expect(saved.settings.watchModels).toEqual(defaults.watchModels)
|
||||
expect(saved.policyApiKey).toBe('app-key')
|
||||
expect((screen.getByLabelText('Dify uygulama anahtarı') as HTMLInputElement).value).toBe('')
|
||||
})
|
||||
it('retains user edits if persistence fails', async () => {
|
||||
set.mockRejectedValue(new Error('disk error'))
|
||||
|
||||
@@ -21,6 +21,7 @@ export function StagedTransitionSettingsView() {
|
||||
const [state, setState] = useState<StagedTransitionState | null>(null)
|
||||
const [draft, setDraft] = useState<StagedTransitionSettings | null>(null)
|
||||
const [key, setKey] = useState('')
|
||||
const [policyKey, setPolicyKey] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
const [error, setError] = useState(false)
|
||||
@@ -80,7 +81,10 @@ export function StagedTransitionSettingsView() {
|
||||
const storedModels = [
|
||||
draft.fallbackModel,
|
||||
...draft.exemptModels,
|
||||
...draft.rejectionRules.flatMap(rule => [rule.from, rule.to])
|
||||
...draft.rejectionRules.flatMap(rule => [rule.from, rule.to]),
|
||||
...draft.watchModels,
|
||||
draft.prefilter.target,
|
||||
...Object.values(draft.autoRoute.targets)
|
||||
]
|
||||
|
||||
for (const id of storedModels) {
|
||||
@@ -127,14 +131,62 @@ export function StagedTransitionSettingsView() {
|
||||
/>
|
||||
)
|
||||
|
||||
// Decision stages (14 Oct 2026). Each stage has its own switch; all start disabled.
|
||||
type StageName = 'prefilter' | 'autoRoute' | 'finishCheck' | 'verifierCheck'
|
||||
|
||||
const stage = <K extends StageName>(name: K, value: Partial<StagedTransitionSettings[K]>) =>
|
||||
change(name, { ...draft[name], ...value } as StagedTransitionSettings[K])
|
||||
|
||||
const probabilityInput = (label: string, value: number, onChange: (value: number) => void) => (
|
||||
<Input
|
||||
aria-label={label}
|
||||
max={1}
|
||||
min={0}
|
||||
onChange={e => onChange(Number(e.target.value))}
|
||||
step={0.01}
|
||||
type="number"
|
||||
value={value}
|
||||
/>
|
||||
)
|
||||
|
||||
const secondsInput = (label: string, value: number, max: number, onChange: (value: number) => void) => (
|
||||
<Input aria-label={label} max={max} min={1} onChange={e => onChange(Number(e.target.value))} type="number" value={value} />
|
||||
)
|
||||
|
||||
const question = (label: string, value: string, onChange: (value: string) => void, rows = 3) => (
|
||||
<label className="block py-2">
|
||||
<span>{label}</span>
|
||||
<Textarea aria-label={label} onChange={e => onChange(e.target.value)} rows={rows} value={value} />
|
||||
</label>
|
||||
)
|
||||
|
||||
const stageSwitch = (name: StageName, label: string, description: string) => (
|
||||
<ListRow
|
||||
action={
|
||||
<Switch aria-label={label} checked={draft[name].enabled} onCheckedChange={value => stage(name, { enabled: value })} />
|
||||
}
|
||||
description={description}
|
||||
title={label}
|
||||
/>
|
||||
)
|
||||
|
||||
const policy = state?.policyStatus
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true)
|
||||
setMessage('')
|
||||
setError(false)
|
||||
|
||||
try {
|
||||
adopt(await api.setStagedTransition({ settings: draft, ...(key.trim() ? { apiKey: key.trim() } : {}) }))
|
||||
adopt(
|
||||
await api.setStagedTransition({
|
||||
settings: draft,
|
||||
...(key.trim() ? { apiKey: key.trim() } : {}),
|
||||
...(policyKey.trim() ? { policyApiKey: policyKey.trim() } : {})
|
||||
})
|
||||
)
|
||||
setKey('')
|
||||
setPolicyKey('')
|
||||
setMessage(text('Kaydedildi. Yeni değerlendirmelerde uygulanır.', 'Saved. Applies to new evaluations.'))
|
||||
} catch (failure) {
|
||||
setError(true)
|
||||
@@ -404,6 +456,248 @@ export function StagedTransitionSettingsView() {
|
||||
{text('+ Her zaman geç kuralı ekle', '+ Add always-pass rule')}
|
||||
</Button>
|
||||
</SettingsSection>
|
||||
<SettingsSection icon={Link} title={text('Karar aşamaları', 'Decision stages')}>
|
||||
<p>
|
||||
{text(
|
||||
'Karar modeli metin yazmaz; her aşamada evet/hayır olasılığı verir. Aşamalar yukarıdaki ana anahtar açıkken çalışır. Karar alınamazsa bugünkü yol izlenir.',
|
||||
'The decision model writes no text; each stage gets a yes/no probability. Stages run while the main switch above is on. Without a decision the current path is kept.'
|
||||
)}
|
||||
</p>
|
||||
{stageSwitch(
|
||||
'prefilter',
|
||||
text('Ret ön kontrolü', 'Refusal pre-check'),
|
||||
text(
|
||||
'Tur başında sorulur: normal bir model bu isteği reddeder ya da yumuşatır mı? Evetse adım beklemeden seçilen katmandan başlar.',
|
||||
'Asked at the start of a turn: would a mainstream model refuse or water down this request? If so the step starts at the chosen layer.'
|
||||
)
|
||||
)}
|
||||
<ListRow
|
||||
action={modelSelect(text('Ret ön kontrolü hedefi', 'Refusal pre-check target'), draft.prefilter.target, value =>
|
||||
stage('prefilter', { target: value })
|
||||
)}
|
||||
title={text('Reddedilecekse başlanacak model', 'Model to start with when a refusal is expected')}
|
||||
/>
|
||||
<ListRow
|
||||
action={probabilityInput(text('Ret ön kontrolü eşiği', 'Refusal pre-check threshold'), draft.prefilter.threshold, value =>
|
||||
stage('prefilter', { threshold: value })
|
||||
)}
|
||||
title={text('Eşik (0–1)', 'Threshold (0–1)')}
|
||||
/>
|
||||
{question(text('Ret ön kontrolü sorusu', 'Refusal pre-check question'), draft.prefilter.question, value =>
|
||||
stage('prefilter', { question: value })
|
||||
)}
|
||||
{stageSwitch(
|
||||
'autoRoute',
|
||||
text('Otomatik model', 'Automatic model'),
|
||||
text(
|
||||
'Codex listesinde bir model olarak görünür. Seçildiğinde her yeni istekte karar modeli işin türüne göre combo seçer; aynı turun araç adımları aynı combo ile sürer. Açınca listede birkaç dakika içinde görünür.',
|
||||
'Appears as a model in the Codex list. When selected, the decision model picks a combo for each new request; tool steps of the same turn keep it. It appears in the list within a few minutes after enabling.'
|
||||
)
|
||||
)}
|
||||
<ListRow
|
||||
action={
|
||||
<Input
|
||||
aria-label={text('Otomatik modelin adı', 'Automatic model name')}
|
||||
onChange={e => stage('autoRoute', { model: e.target.value })}
|
||||
value={draft.autoRoute.model}
|
||||
/>
|
||||
}
|
||||
title={text('Listede görünecek ad', 'Name in the model list')}
|
||||
/>
|
||||
{(['basit', 'zor', 'arastirma'] as const).map(kind => {
|
||||
const label =
|
||||
kind === 'basit'
|
||||
? text('Basit iş combo', 'Simple request combo')
|
||||
: kind === 'zor'
|
||||
? text('Zor iş combo', 'Hard request combo')
|
||||
: text('Araştırma combo', 'Research combo')
|
||||
|
||||
return (
|
||||
<ListRow
|
||||
action={modelSelect(label, draft.autoRoute.targets[kind], value =>
|
||||
stage('autoRoute', { targets: { ...draft.autoRoute.targets, [kind]: value } })
|
||||
)}
|
||||
key={kind}
|
||||
title={label}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{(['zor', 'arastirma'] as const).map(kind => {
|
||||
const label = kind === 'zor' ? text('Zor iş sorusu', 'Hard request question') : text('Araştırma sorusu', 'Research question')
|
||||
|
||||
return (
|
||||
<div key={kind}>
|
||||
<ListRow
|
||||
action={probabilityInput(`${label} ${text('eşiği', 'threshold')}`, draft.autoRoute.thresholds[kind], value =>
|
||||
stage('autoRoute', { thresholds: { ...draft.autoRoute.thresholds, [kind]: value } })
|
||||
)}
|
||||
title={`${label} ${text('eşiği', 'threshold')}`}
|
||||
/>
|
||||
{question(label, draft.autoRoute.questions[kind], value =>
|
||||
stage('autoRoute', { questions: { ...draft.autoRoute.questions, [kind]: value } })
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{stageSwitch(
|
||||
'finishCheck',
|
||||
text('Erken bitiş denetimi', 'Early finish check'),
|
||||
text(
|
||||
'Son cevapta sorulur: iş bitmeden “bitti” dendi mi ya da kanıt gösterilmedi mi? Evetse cevap aşağıdaki notla aynı modele bir kez geri verilir.',
|
||||
'Asked on the final answer: did it say done before finishing or claim success without evidence? If so it goes back to the same model once with the note below.'
|
||||
)
|
||||
)}
|
||||
<ListRow
|
||||
action={probabilityInput(text('Erken bitiş eşiği', 'Early finish threshold'), draft.finishCheck.threshold, value =>
|
||||
stage('finishCheck', { threshold: value })
|
||||
)}
|
||||
title={text('Eşik (0–1)', 'Threshold (0–1)')}
|
||||
/>
|
||||
{question(text('Erken bitiş sorusu', 'Early finish question'), draft.finishCheck.question, value =>
|
||||
stage('finishCheck', { question: value })
|
||||
)}
|
||||
{question(
|
||||
text('Modele verilecek not', 'Note given to the model'),
|
||||
draft.finishCheck.note,
|
||||
value => stage('finishCheck', { note: value }),
|
||||
2
|
||||
)}
|
||||
{stageSwitch(
|
||||
'verifierCheck',
|
||||
text('Workflow doğrulayıcı elemesi', 'Workflow reviewer check'),
|
||||
text(
|
||||
'Dinamik workflow kabulünde sorulur: doğrulayıcı somut kanıt göstermeden mi onayladı? Evetse kabul engellenir, doğrulama yeniden yapılır.',
|
||||
'Asked when a dynamic workflow is accepted: did the reviewer approve without concrete evidence? If so acceptance is blocked and verification is redone.'
|
||||
)
|
||||
)}
|
||||
<ListRow
|
||||
action={probabilityInput(text('Doğrulayıcı elemesi eşiği', 'Reviewer check threshold'), draft.verifierCheck.threshold, value =>
|
||||
stage('verifierCheck', { threshold: value })
|
||||
)}
|
||||
title={text('Eşik (0–1)', 'Threshold (0–1)')}
|
||||
/>
|
||||
{question(text('Doğrulayıcı elemesi sorusu', 'Reviewer check question'), draft.verifierCheck.question, value =>
|
||||
stage('verifierCheck', { question: value })
|
||||
)}
|
||||
<ListRow
|
||||
action={secondsInput(text('Tur başı karar bekleme (saniye)', 'Turn-start decision wait (seconds)'), draft.prefilter.timeoutSeconds, 60, value => {
|
||||
setDraft({
|
||||
...draft,
|
||||
prefilter: { ...draft.prefilter, timeoutSeconds: value },
|
||||
autoRoute: { ...draft.autoRoute, timeoutSeconds: value }
|
||||
})
|
||||
})}
|
||||
description={text(
|
||||
'Bu sürede karar gelmezse istek bugünkü yoldan devam eder.',
|
||||
'Without a decision within this time the request continues on the current path.'
|
||||
)}
|
||||
title={text('Tur başı karar bekleme (saniye)', 'Turn-start decision wait (seconds)')}
|
||||
/>
|
||||
</SettingsSection>
|
||||
<SettingsSection icon={Link} title={text('Cevaptan sonra düzeltme kanalı', 'Correction channel after answers')}>
|
||||
<p>
|
||||
{text(
|
||||
'Bekçi bu modellerin cevabından sonra düzeltme ya da ek bilgi gönderebilir. Ad tam eşleşmelidir.',
|
||||
'Bekçi may send corrections or extra information after these models answer. Names must match exactly.'
|
||||
)}
|
||||
</p>
|
||||
{draft.watchModels.map((model, index) => (
|
||||
<div className="flex flex-wrap items-center gap-2 py-2" key={index}>
|
||||
{modelSelect(text(`Düzeltme kanalı modeli ${index + 1}`, `Correction channel model ${index + 1}`), model, value =>
|
||||
change(
|
||||
'watchModels',
|
||||
draft.watchModels.map((m, i) => (i === index ? value : m))
|
||||
)
|
||||
)}
|
||||
<Button
|
||||
aria-label={text(`Düzeltme kanalı modeli ${index + 1} sil`, `Remove correction channel model ${index + 1}`)}
|
||||
onClick={() =>
|
||||
change(
|
||||
'watchModels',
|
||||
draft.watchModels.filter((_, i) => i !== index)
|
||||
)
|
||||
}
|
||||
variant="ghost"
|
||||
>
|
||||
{text('Sil', 'Remove')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button onClick={() => change('watchModels', [...draft.watchModels, ''])} variant="outline">
|
||||
{text('+ Düzeltme kanalı modeli ekle', '+ Add correction channel model')}
|
||||
</Button>
|
||||
</SettingsSection>
|
||||
<SettingsSection icon={Link} title={text('Kural kaynağı', 'Rules source')}>
|
||||
<p>
|
||||
{text(
|
||||
'Dify seçilirse karar aşamalarının soruları, eşikleri ve combo seçimleri Dify’daki iş akışından birkaç dakikada bir alınır. Codex’in konuştuğu model yine OmniRoute combo’sudur. Dify’a ulaşılamazsa son geçerli kurallar, o da yoksa bu sayfadaki ayarlar kullanılır.',
|
||||
'With Dify, the stage questions, thresholds and combo choices are fetched from the Dify workflow every few minutes. Codex still talks to OmniRoute combos. If Dify is unreachable the last good rules, otherwise the settings on this page, are used.'
|
||||
)}
|
||||
</p>
|
||||
<ListRow
|
||||
action={
|
||||
<Select
|
||||
onValueChange={value => change('policy', { ...draft.policy, source: value as 'local' | 'dify' })}
|
||||
value={draft.policy.source}
|
||||
>
|
||||
<SelectTrigger aria-label={text('Kural kaynağı', 'Rules source')}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="local">{text('Bu sayfa', 'This page')}</SelectItem>
|
||||
<SelectItem value="dify">Dify</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
}
|
||||
title={text('Kural kaynağı', 'Rules source')}
|
||||
/>
|
||||
<ListRow
|
||||
action={
|
||||
<Input
|
||||
aria-label={text('Dify API adresi', 'Dify API endpoint')}
|
||||
onChange={e => change('policy', { ...draft.policy, endpoint: e.target.value })}
|
||||
type="url"
|
||||
value={draft.policy.endpoint}
|
||||
/>
|
||||
}
|
||||
title={text('Dify API adresi', 'Dify API endpoint')}
|
||||
/>
|
||||
<ListRow
|
||||
action={
|
||||
<Input
|
||||
aria-label={text('Dify uygulama anahtarı', 'Dify app key')}
|
||||
autoComplete="new-password"
|
||||
onChange={e => setPolicyKey(e.target.value)}
|
||||
type="password"
|
||||
value={policyKey}
|
||||
/>
|
||||
}
|
||||
description={
|
||||
state?.policyKeyConfigured
|
||||
? text('Anahtar tanımlı. Değiştirmek için yeni anahtarı girin.', 'Key configured. Enter a new key to replace it.')
|
||||
: text('Henüz anahtar tanımlı değil.', 'No key configured.')
|
||||
}
|
||||
title={text('Dify uygulama anahtarı', 'Dify app key')}
|
||||
/>
|
||||
<ListRow
|
||||
action={secondsInput(text('Yenileme aralığı (dakika)', 'Refresh interval (minutes)'), draft.policy.refreshMinutes, 1440, value =>
|
||||
change('policy', { ...draft.policy, refreshMinutes: value })
|
||||
)}
|
||||
title={text('Yenileme aralığı (dakika)', 'Refresh interval (minutes)')}
|
||||
/>
|
||||
{draft.policy.source === 'dify' && (
|
||||
<p role="status">
|
||||
{!policy
|
||||
? text('Dify kuralları henüz alınmadı.', 'Dify rules not fetched yet.')
|
||||
: policy.ok
|
||||
? text(
|
||||
`Son geçerli kurallar: ${policy.lastGoodAt ?? policy.fetchedAt ?? '-'} (${policy.sections.join(', ') || 'boş'})${policy.error ? ` · son deneme hatası: ${policy.error}` : ''}`,
|
||||
`Last good rules: ${policy.lastGoodAt ?? policy.fetchedAt ?? '-'} (${policy.sections.join(', ') || 'empty'})${policy.error ? ` · last attempt failed: ${policy.error}` : ''}`
|
||||
)
|
||||
: text(`Kurallar alınamadı: ${policy.error ?? '-'}`, `Rules could not be fetched: ${policy.error ?? '-'}`)}
|
||||
</p>
|
||||
)}
|
||||
</SettingsSection>
|
||||
<SettingsSection icon={Link} title={text('Paralellik ve bekleme', 'Concurrency and waiting')}>
|
||||
{number(
|
||||
'maxConcurrent',
|
||||
|
||||
Generated
+1
-1
@@ -65,7 +65,7 @@
|
||||
},
|
||||
"apps/desktop": {
|
||||
"name": "aiturk-ide",
|
||||
"version": "1.0.0-beta.38",
|
||||
"version": "1.0.0-beta.39",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@assistant-ui/core": "0.2.23",
|
||||
|
||||
Reference in new issue
Block a user