From 7c4ef18df0abcbf29e0e201dd0888583ff88f752 Mon Sep 17 00:00:00 2001 From: yilsem Date: Wed, 14 Oct 2026 07:48:33 +0300 Subject: [PATCH] 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 --- apps/desktop/assets/codex-bridge/README.md | 31 ++ apps/desktop/assets/codex-bridge/bridge.py | 2 +- .../codex-bridge/jev-settings-defaults.json | 43 +- .../assets/codex-bridge/jev_denetim.py | 107 ++++- .../assets/codex-bridge/jev_settings.py | 161 ++++++- .../assets/codex-bridge/karar_politikasi.py | 92 ++++ apps/desktop/assets/codex-bridge/karar_sor.py | 58 +++ apps/desktop/assets/codex-bridge/kopru.py | 170 ++++++- .../assets/codex-bridge/test_jev_denetim.py | 54 +-- .../codex-bridge/test_karar_asamalari.py | 418 ++++++++++++++++++ apps/desktop/electron/codex-link-config.ts | 2 +- .../electron/staged-transition-types.ts | 25 ++ .../electron/staged-transition.test.ts | 59 ++- apps/desktop/electron/staged-transition.ts | 224 ++++++++-- apps/desktop/package.json | 2 +- .../staged-transition-settings.test.tsx | 43 +- .../settings/staged-transition-settings.tsx | 298 ++++++++++++- package-lock.json | 2 +- 18 files changed, 1672 insertions(+), 119 deletions(-) create mode 100644 apps/desktop/assets/codex-bridge/karar_politikasi.py create mode 100644 apps/desktop/assets/codex-bridge/karar_sor.py create mode 100644 apps/desktop/assets/codex-bridge/test_karar_asamalari.py diff --git a/apps/desktop/assets/codex-bridge/README.md b/apps/desktop/assets/codex-bridge/README.md index 1d8b669..f27a3e1 100644 --- a/apps/desktop/assets/codex-bridge/README.md +++ b/apps/desktop/assets/codex-bridge/README.md @@ -27,6 +27,37 @@ Run: `python bridge.py --home --hermes-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/` 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 diff --git a/apps/desktop/assets/codex-bridge/bridge.py b/apps/desktop/assets/codex-bridge/bridge.py index dd30357..600d793 100644 --- a/apps/desktop/assets/codex-bridge/bridge.py +++ b/apps/desktop/assets/codex-bridge/bridge.py @@ -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] diff --git a/apps/desktop/assets/codex-bridge/jev-settings-defaults.json b/apps/desktop/assets/codex-bridge/jev-settings-defaults.json index 71a7f47..17848a7 100644 --- a/apps/desktop/assets/codex-bridge/jev-settings-defaults.json +++ b/apps/desktop/assets/codex-bridge/jev-settings-defaults.json @@ -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 + } } diff --git a/apps/desktop/assets/codex-bridge/jev_denetim.py b/apps/desktop/assets/codex-bridge/jev_denetim.py index 008c929..3531f98 100644 --- a/apps/desktop/assets/codex-bridge/jev_denetim.py +++ b/apps/desktop/assets/codex-bridge/jev_denetim.py @@ -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 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".*?(.*?)", 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'] diff --git a/apps/desktop/assets/codex-bridge/jev_settings.py b/apps/desktop/assets/codex-bridge/jev_settings.py index 10c88e1..2efb9a9 100644 --- a/apps/desktop/assets/codex-bridge/jev_settings.py +++ b/apps/desktop/assets/codex-bridge/jev_settings.py @@ -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'] diff --git a/apps/desktop/assets/codex-bridge/karar_politikasi.py b/apps/desktop/assets/codex-bridge/karar_politikasi.py new file mode 100644 index 0000000..e5dd149 --- /dev/null +++ b/apps/desktop/assets/codex-bridge/karar_politikasi.py @@ -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) diff --git a/apps/desktop/assets/codex-bridge/karar_sor.py b/apps/desktop/assets/codex-bridge/karar_sor.py new file mode 100644 index 0000000..479a5b5 --- /dev/null +++ b/apps/desktop/assets/codex-bridge/karar_sor.py @@ -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()) diff --git a/apps/desktop/assets/codex-bridge/kopru.py b/apps/desktop/assets/codex-bridge/kopru.py index f0f26cb..e80fe56 100644 --- a/apps/desktop/assets/codex-bridge/kopru.py +++ b/apps/desktop/assets/codex-bridge/kopru.py @@ -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) diff --git a/apps/desktop/assets/codex-bridge/test_jev_denetim.py b/apps/desktop/assets/codex-bridge/test_jev_denetim.py index 7e320bc..e85611a 100644 --- a/apps/desktop/assets/codex-bridge/test_jev_denetim.py +++ b/apps/desktop/assets/codex-bridge/test_jev_denetim.py @@ -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()) diff --git a/apps/desktop/assets/codex-bridge/test_karar_asamalari.py b/apps/desktop/assets/codex-bridge/test_karar_asamalari.py new file mode 100644 index 0000000..6fe3bd1 --- /dev/null +++ b/apps/desktop/assets/codex-bridge/test_karar_asamalari.py @@ -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) diff --git a/apps/desktop/electron/codex-link-config.ts b/apps/desktop/electron/codex-link-config.ts index dd05e7d..e5d0b2f 100644 --- a/apps/desktop/electron/codex-link-config.ts +++ b/apps/desktop/electron/codex-link-config.ts @@ -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' diff --git a/apps/desktop/electron/staged-transition-types.ts b/apps/desktop/electron/staged-transition-types.ts index 390b37f..95ef7aa 100644 --- a/apps/desktop/electron/staged-transition-types.ts +++ b/apps/desktop/electron/staged-transition-types.ts @@ -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 } diff --git a/apps/desktop/electron/staged-transition.test.ts b/apps/desktop/electron/staged-transition.test.ts index 5c680ca..00f68fd 100644 --- a/apps/desktop/electron/staged-transition.test.ts +++ b/apps/desktop/electron/staged-transition.test.ts @@ -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 + + 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() + }) }) diff --git a/apps/desktop/electron/staged-transition.ts b/apps/desktop/electron/staged-transition.ts index 69e9127..75f05b8 100644 --- a/apps/desktop/electron/staged-transition.ts +++ b/apps/desktop/electron/staged-transition.ts @@ -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 + +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)) { diff --git a/apps/desktop/package.json b/apps/desktop/package.json index f4f34e2..ff8fc67 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -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": { diff --git a/apps/desktop/src/app/settings/staged-transition-settings.test.tsx b/apps/desktop/src/app/settings/staged-transition-settings.test.tsx index bb7b710..c6aebc1 100644 --- a/apps/desktop/src/app/settings/staged-transition-settings.test.tsx +++ b/apps/desktop/src/app/settings/staged-transition-settings.test.tsx @@ -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')) diff --git a/apps/desktop/src/app/settings/staged-transition-settings.tsx b/apps/desktop/src/app/settings/staged-transition-settings.tsx index a846081..7538a71 100644 --- a/apps/desktop/src/app/settings/staged-transition-settings.tsx +++ b/apps/desktop/src/app/settings/staged-transition-settings.tsx @@ -21,6 +21,7 @@ export function StagedTransitionSettingsView() { const [state, setState] = useState(null) const [draft, setDraft] = useState(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 = (name: K, value: Partial) => + change(name, { ...draft[name], ...value } as StagedTransitionSettings[K]) + + const probabilityInput = (label: string, value: number, onChange: (value: number) => void) => ( + onChange(Number(e.target.value))} + step={0.01} + type="number" + value={value} + /> + ) + + const secondsInput = (label: string, value: number, max: number, onChange: (value: number) => void) => ( + onChange(Number(e.target.value))} type="number" value={value} /> + ) + + const question = (label: string, value: string, onChange: (value: string) => void, rows = 3) => ( +