Lot 23 : sonde de gabarit en Rust (avec LOKI_CHAT=rust)

Port de chat_tplprobe.go : la façade sonde le gabarit du moteur après ses
complétions (/health, /props, /apply-template), avec les verdicts
preserves_history, prefix_stable et renders_reasoning, le cache par
build/modèle/gabarit/forme et la relance bornée.

- l'écho du raisonnement suit le verdict de la sonde (echo_policy_for) ;
- NUDGE_IN_TOOL ne garde plus la conversation au Go : le rappel de budget
  va dans le dernier résultat d'outil quand le préfixe n'est pas stable ;
- le Go lit le verdict de la façade (/internal/facade/v1/chat/tpl) quand
  elle tient la conversation.

Parité : parity_tplprobe_test.go tire du Go un journal d'échanges rejoué
octet pour octet par tests/tplprobe.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
This commit is contained in:
Claude committed 2026-10-15 21:34:57 +00:00
1 parent 78766a28d9
commit 0f3a3d52c1
17 files changed
+5006 -22

No files matched your search

+3
View File
@@ -238,6 +238,9 @@ func tplCapsCurrent() (tplProbeResult, bool) {
if externalActive() {
return tplProbeResult{}, false
}
if facadeChat() { // la façade sonde après SES complétions (facade_chat.go)
return facadeTplCaps()
}
tplProbeMu.Lock()
defer tplProbeMu.Unlock()
if tplProbeLast == nil {
+27 -12
View File
@@ -49,15 +49,14 @@ func facadeChat() bool {
// facadeChatBlocker : le réglage qui garde la conversation ici, "" s'il n'y
// en a pas. Ces clés opt-in demandent une machinerie restée au Go (bloc projet
// figé, images gardées, slots du moteur, préchauffage, motifs de relance
// personnalisés).
// personnalisés). NUDGE_IN_TOOL n'en est plus (lot 23) : la façade a sa sonde
// de gabarit.
func facadeChatBlocker(cfg map[string]string) string {
switch {
case projSnapEnabled(cfg):
return "PROJ_SNAPSHOT"
case keepTurnImagesOn(cfg):
return "KEEP_TURN_IMAGES"
case cfgOn(cfg, "NUDGE_IN_TOOL"):
return "NUDGE_IN_TOOL"
case sideSlotOn(cfg):
return "SIDE_SLOT"
case slotPersistOn(cfg):
@@ -70,15 +69,6 @@ func facadeChatBlocker(cfg map[string]string) string {
return ""
}
// cfgOn : une clé booléenne posée (mêmes valeurs que les autres clés opt-in).
func cfgOn(cfg map[string]string, k string) bool {
switch strings.ToLower(strings.TrimSpace(cfg[k])) {
case "on", "1", "true", "yes", "oui":
return true
}
return false
}
// Décision prise UNE fois par process : passer la conversation d'un détenteur
// à l'autre en cours de route laisserait chacun avec sa copie — un réglage
// changé vaut au redémarrage suivant. La façade la lit au démarrage
@@ -244,6 +234,31 @@ func facadePost(path string, body any) error {
return nil
}
// facadeTplCaps : le dernier verdict de la sonde de gabarit de la façade,
// quand elle tient la conversation (c'est elle qui sonde après ses
// complétions).
func facadeTplCaps() (tplProbeResult, bool) {
req, err := http.NewRequest(http.MethodPost, "http://"+facadeAddr()+"/internal/facade/v1/chat/tpl", strings.NewReader("{}"))
if err != nil {
return tplProbeResult{}, false
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set(internalTokenHeader, internalToken())
resp, err := facadeClient.Do(req)
if err != nil {
return tplProbeResult{}, false
}
defer resp.Body.Close()
var out struct {
OK bool `json:"ok"`
Result tplProbeResult `json:"result"`
}
if resp.StatusCode != http.StatusOK || json.NewDecoder(resp.Body).Decode(&out) != nil || !out.OK {
return tplProbeResult{}, false
}
return out.Result, true
}
// facadeError : refus de la façade, avec son statut (409 : un tour tourne).
type facadeError struct {
status int
+39 -1
View File
@@ -298,7 +298,7 @@ func TestFacadeReglageQuiGardeLaConversation(t *testing.T) {
t.Fatalf("remis à la façade malgré le réglage : %q %v", rec.Body.String(), f.recu())
}
for k, want := range map[string]string{
"PROJ_SNAPSHOT": "PROJ_SNAPSHOT", "NUDGE_IN_TOOL": "NUDGE_IN_TOOL", "SIDE_SLOT": "SIDE_SLOT",
"PROJ_SNAPSHOT": "PROJ_SNAPSHOT", "NUDGE_IN_TOOL": "", "SIDE_SLOT": "SIDE_SLOT",
"SLOT_PERSIST": "SLOT_PERSIST", "PREWARM": "PREWARM",
} {
if got := facadeChatBlocker(map[string]string{k: "on"}); got != want {
@@ -1064,3 +1064,41 @@ func TestFacadeBackends(t *testing.T) {
}
lcAppend("perdue") // tracée, sans panique
}
// Lot 23 : quand la façade tient la conversation, c'est elle qui sonde le
// gabarit ; le résumé des performances relit son verdict.
func TestFacadeSondeDeGabarit(t *testing.T) {
testHome(t)
freshTplProbe(t)
oublierDecisionFacade(t)
verdict := `{"ok":true,"result":{"preserves_history":"no","prefix_stable":"no","renders_reasoning":"yes","model":"m1.gguf","tools":2,"at":"2026-10-15T20:00:00.5Z"}}`
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get(internalTokenHeader) != jetonTest || r.URL.Path != "/internal/facade/v1/chat/tpl" {
w.WriteHeader(404)
return
}
io.WriteString(w, verdict)
}))
defer srv.Close()
t.Setenv(internalTokenEnv, jetonTest)
t.Setenv(facadeAddrEnv, strings.TrimPrefix(srv.URL, "http://"))
t.Setenv(facadeChatEnv, "rust")
r, ok := tplCapsCurrent()
if !ok || r.PrefixStable != tplNo || r.Model != "m1.gguf" || r.Tools != 2 || r.At.IsZero() {
t.Fatalf("verdict de la façade : %+v %v", r, ok)
}
rec := httptest.NewRecorder()
handlePerfSummary(rec, httptest.NewRequest(http.MethodGet, "/api/perf/summary", nil))
if !strings.Contains(rec.Body.String(), `"prefix_stable":"no"`) {
t.Fatalf("résumé : %s", rec.Body.String())
}
// Aucun verdict encore, ou façade injoignable : rien.
verdict = `{"ok":false}`
if _, ok := tplCapsCurrent(); ok {
t.Fatal("verdict inventé")
}
t.Setenv(facadeAddrEnv, "127.0.0.1:1")
if _, ok := tplCapsCurrent(); ok {
t.Fatal("verdict sans façade")
}
}
+358
View File
@@ -0,0 +1,358 @@
package loki
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"testing"
)
// parity_tplprobe_test.go — journal de référence de la sonde de gabarit
// (lot 23 de la migration Rust) : pour chaque comportement de moteur (gabarit
// Qwen3, gabarit qui garde tout, raisonnement jamais rendu, amorce ignorée,
// 404, exception du gabarit, clé exigée, 503 relancés ou persistants, /props
// illisible, moteur pas prêt, réponse sans prompt, rendu incohérent, preset
// externe), chaque échange avec le moteur — méthode, chemin, clé, corps envoyé,
// réponse — puis le verdict, sa ligne de journal, ce qu'en disent
// tplCapsCurrent et tplCapsFor, et le cache (même moteur : pas de nouveau
// rendu ; gabarit changé : nouvelle sonde). Puis le relevé du rendu
// (tplRenderPrint), sa comparaison (tplRenderDiff) et l'empreinte des formes.
// Rangé dans rust/loki-rs/tests/parity_tplprobe/tplprobe.json ;
// tests/tplprobe.rs rejoue chaque échange contre le port Rust.
//
// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteSondeGabarit
const parityTplFile = "../../rust/loki-rs/tests/parity_tplprobe/tplprobe.json"
// Un échange avec le moteur, tel que le moteur l'a vu.
type parityTplExchange struct {
Method string `json:"method"`
Path string `json:"path"`
Auth string `json:"auth,omitempty"`
Body string `json:"body,omitempty"`
Status int `json:"status"`
Reply string `json:"reply"`
}
// parityTplEngine : le faux moteur des tests de la sonde (fakeTpl), avec des
// réponses imposées par chemin et le journal des échanges.
type parityTplEngine struct {
fake *fakeTpl
mu sync.Mutex
override map[string][]parityTplExchange // chemin → réponses imposées, consommées dans l'ordre
exchanges []parityTplExchange
}
func (e *parityTplEngine) ServeHTTP(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
e.mu.Lock()
var forced *parityTplExchange
if l := e.override[r.URL.Path]; len(l) > 0 {
forced = &l[0]
e.override[r.URL.Path] = l[1:]
}
e.mu.Unlock()
rec := httptest.NewRecorder()
if forced != nil {
rec.WriteHeader(forced.Status)
_, _ = rec.WriteString(forced.Reply)
} else {
r2 := r.Clone(r.Context())
r2.Body = io.NopCloser(strings.NewReader(string(body)))
e.fake.handler(rec, r2)
}
ex := parityTplExchange{Method: r.Method, Path: r.URL.Path, Auth: r.Header.Get("Authorization"),
Body: string(body), Status: rec.Code, Reply: rec.Body.String()}
e.mu.Lock()
e.exchanges = append(e.exchanges, ex)
e.mu.Unlock()
w.WriteHeader(rec.Code)
_, _ = w.Write(rec.Body.Bytes())
}
// parityTplResult : le verdict sans l'instant, plus ce qui n'est pas exporté.
func parityTplResult(r tplProbeResult) map[string]any {
b, _ := json.Marshal(r)
var m map[string]any
_ = json.Unmarshal(b, &m)
delete(m, "at")
m["cacheable"] = r.cacheable
return m
}
func TestPariteSondeGabarit(t *testing.T) {
type step struct {
Shape string `json:"shape"` // forme rejouée (shapes)
Mode string `json:"mode,omitempty"`
Template string `json:"template,omitempty"`
Build string `json:"build,omitempty"`
Override map[string][]parityTplExchange `json:"override,omitempty"`
Exchanges []parityTplExchange `json:"exchanges"`
Result any `json:"result"`
Line string `json:"line"`
Current any `json:"current"`
For any `json:"for"`
}
type scenario struct {
Name string `json:"name"`
Key string `json:"key,omitempty"` // clé exigée par le moteur
APIKey string `json:"api_key,omitempty"` // clé connue de Loki (state/api_key)
External bool `json:"external,omitempty"` // preset externe actif
Config map[string]string `json:"config,omitempty"`
Steps []step `json:"steps"`
}
shapes := map[string]struct {
Tools string `json:"tools"`
Kwargs map[string]any `json:"kwargs"`
Effort string `json:"effort"`
}{
"nue": {},
"outils": {Tools: `[{"type":"function","function":{"name":"bash","description":"d <b> & é","parameters":{"type":"object"}}}]`, Kwargs: map[string]any{"enable_thinking": false}},
"kwargs": {Kwargs: map[string]any{"enable_thinking": true, "reasoning_effort": "high"}},
"effort": {Effort: "low"},
"deux": {Tools: `[{"type":"function","function":{"name":"lire","description":"","parameters":{"type":"object","properties":{"p":{"type":"string"}}}}},{"type":"function","function":{"name":"écrire","description":"x","parameters":null}}]`},
"kw-vides": {Kwargs: map[string]any{}},
}
shapeOf := func(name string) tplShape {
s := shapes[name]
var tools []Tool
if s.Tools != "" {
if err := json.Unmarshal([]byte(s.Tools), &tools); err != nil {
t.Fatal(err)
}
}
return newTplShape(tools, s.Kwargs, s.Effort)
}
jsonErr := `{"error":{"code":500,"message":"Jinja Exception: ` + strings.Repeat("é", 200) + `"}}`
scenarios := []scenario{
{Name: "Qwen3, outils et kwargs", Steps: []step{
{Shape: "outils", Mode: "qwen3"},
{Shape: "outils", Mode: "qwen3"}, // en cache : /health et /props seulement
{Shape: "nue", Mode: "qwen3"}, // autre forme : nouvelle sonde
{Shape: "outils", Mode: "qwen3", Template: "qwen3 v2"},
{Shape: "outils", Mode: "qwen3", Template: "qwen3 v2", Build: "b8000-def"},
}},
{Name: "garde tout", Steps: []step{{Shape: "nue", Mode: "keep"}, {Shape: "deux", Mode: "keep"}}},
{Name: "raisonnement jamais rendu", Steps: []step{{Shape: "kwargs", Mode: "drop"}}},
{Name: "amorce ignorée", Steps: []step{{Shape: "effort", Mode: "noagp"}}},
{Name: "route absente", Steps: []step{
{Shape: "nue", Mode: "keep", Override: map[string][]parityTplExchange{"/apply-template": {
{Status: 404, Reply: "Not Found"}, {Status: 404, Reply: ""}, {Status: 404, Reply: ""}, {Status: 404, Reply: ""},
}}},
{Shape: "nue", Mode: "keep"}, // 404 définitif : en cache
}},
{Name: "exception du gabarit", Steps: []step{{Shape: "nue", Mode: "keep",
Override: map[string][]parityTplExchange{"/apply-template": {{Status: 500, Reply: jsonErr}}}}}},
{Name: "clé exigée, inconnue", Key: "sk-loki-k", Steps: []step{{Shape: "nue", Mode: "keep"}, {Shape: "nue", Mode: "keep"}}},
{Name: "clé exigée, connue", Key: "sk-loki-k", APIKey: "sk-loki-k", Steps: []step{{Shape: "nue", Mode: "keep"}}},
{Name: "503 puis rendu", Steps: []step{{Shape: "nue", Mode: "qwen3", Override: map[string][]parityTplExchange{"/apply-template": {
{Status: 503, Reply: `{"error":{"code":503,"message":"Loading model"}}`}, {Status: 503, Reply: `{"error":{"code":503,"message":"Loading model"}}`},
}}}}},
{Name: "503 persistant", Steps: []step{{Shape: "nue", Mode: "qwen3", Override: map[string][]parityTplExchange{"/apply-template": {
{Status: 503, Reply: "x"}, {Status: 503, Reply: "x"}, {Status: 503, Reply: "x"},
{Status: 503, Reply: "y"}, {Status: 503, Reply: "y"}, {Status: 503, Reply: "y"},
}}}, {Shape: "nue", Mode: "qwen3"}}}, // non rangé : resondé
{Name: "messages aux bornes", Steps: []step{{Shape: "nue", Mode: "keep", Override: map[string][]parityTplExchange{"/apply-template": {
{Status: 500, Reply: " " + strings.Repeat("à", 160) + " "}, {Status: 500, Reply: strings.Repeat("ù", 161)},
}}}}},
{Name: "props de travers", Steps: []step{{Shape: "nue", Mode: "keep", Override: map[string][]parityTplExchange{"/props": {{Status: 200, Reply: `{"build_info":5}`}}}}}},
{Name: "historique sans la question B", Steps: []step{{Shape: "nue", Mode: "keep", Override: map[string][]parityTplExchange{"/apply-template": {
{Status: 200, Reply: `{"prompt":"` + tplMarkC1 + `"}`},
{Status: 200, Reply: `{"prompt":"` + tplMarkT1 + `"}`},
{Status: 200, Reply: `{"prompt":"` + tplMarkT1 + ` amorce"}`},
{Status: 200, Reply: `{"prompt":"` + tplMarkT1 + ` ` + tplMarkB + `"}`},
}}}}},
{Name: "préfixe sans la question B", Steps: []step{{Shape: "nue", Mode: "keep", Override: map[string][]parityTplExchange{"/apply-template": {
{Status: 200, Reply: `{"prompt":"` + tplMarkC1 + ` ` + tplMarkB + `"}`},
{Status: 200, Reply: `{"prompt":"` + tplMarkT1 + `"}`},
{Status: 200, Reply: `{"prompt":"` + tplMarkT1 + ` amorce"}`},
{Status: 200, Reply: `{"prompt":"` + tplMarkT1 + ` suite"}`},
}}}}},
{Name: "historique en erreur, outil rendu", Steps: []step{{Shape: "nue", Mode: "keep", Override: map[string][]parityTplExchange{"/apply-template": {
{Status: 500, Reply: "boum"},
{Status: 200, Reply: `{"prompt":"` + tplMarkT1 + `"}`},
{Status: 200, Reply: `{"prompt":"` + tplMarkT1 + ` amorce"}`},
{Status: 200, Reply: `{"prompt":"` + tplMarkT1 + ` ` + tplMarkB + `"}`},
}}}}},
{Name: "trop de requêtes", Steps: []step{{Shape: "nue", Mode: "keep", Override: map[string][]parityTplExchange{"/apply-template": {
{Status: 429, Reply: " lent "},
}}}}},
{Name: "props illisible", Steps: []step{
{Shape: "nue", Mode: "keep", Override: map[string][]parityTplExchange{"/props": {{Status: 200, Reply: "<html>"}}}},
{Shape: "nue", Mode: "keep", Override: map[string][]parityTplExchange{"/props": {{Status: 500, Reply: "panne"}}}},
}},
{Name: "moteur pas prêt", Steps: []step{{Shape: "outils", Mode: "keep", Override: map[string][]parityTplExchange{"/health": {{Status: 503, Reply: `{"status":"loading"}`}}}}}},
{Name: "réponse sans prompt", Steps: []step{{Shape: "nue", Mode: "keep", Override: map[string][]parityTplExchange{"/apply-template": {
{Status: 200, Reply: `{}`}, {Status: 200, Reply: `{"prompt":null}`}, {Status: 200, Reply: `pas du json`},
}}}}},
{Name: "rendu incohérent", Steps: []step{{Shape: "nue", Mode: "keep", Override: map[string][]parityTplExchange{"/apply-template": {
{Status: 200, Reply: `{"prompt":"x"}`}, {Status: 200, Reply: `{"prompt":"y"}`}, {Status: 200, Reply: `{"prompt":"z"}`}, {Status: 200, Reply: `{"prompt":"yz"}`},
}}}}},
{Name: "préfixe incohérent", Steps: []step{{Shape: "nue", Mode: "keep", Override: map[string][]parityTplExchange{"/apply-template": {
{Status: 200, Reply: `{"prompt":"x"}`}, {Status: 200, Reply: `{"prompt":"y"}`}, {Status: 200, Reply: `{"prompt":"z"}`}, {Status: 200, Reply: `{"prompt":"w"}`},
}}}}},
{Name: "preset externe", External: true, Steps: []step{{Shape: "nue", Mode: "keep"}}},
}
for i := range scenarios {
sc := &scenarios[i]
t.Run(sc.Name, func(t *testing.T) {
testHome(t)
freshTplProbe(t)
eng := &parityTplEngine{fake: &fakeTpl{key: sc.Key}, override: map[string][]parityTplExchange{}}
srv := httptest.NewServer(eng)
defer srv.Close()
u, _ := url.Parse(srv.URL)
if sc.External {
if err := WriteConfig(parseEnv(externalPresetContent("http://127.0.0.1:1", "gpt-4o-mini", "", "", false))); err != nil {
t.Fatal(err)
}
sc.Config = ReadConfig()
}
if err := SetConfigKey("PORT", u.Port()); err != nil {
t.Fatal(err)
}
if sc.APIKey != "" {
if err := writeAPIKey(sc.APIKey); err != nil {
t.Fatal(err)
}
}
for j := range sc.Steps {
st := &sc.Steps[j]
eng.mu.Lock()
eng.fake.mode, eng.fake.tpl, eng.fake.build = st.Mode, st.Template, st.Build
for p, l := range st.Override {
eng.override[p] = append([]parityTplExchange(nil), l...)
}
eng.exchanges = nil
eng.mu.Unlock()
r := tplProbeEnsure(context.Background(), shapeOf(st.Shape))
eng.mu.Lock()
st.Exchanges = append([]parityTplExchange(nil), eng.exchanges...)
eng.override = map[string][]parityTplExchange{}
eng.mu.Unlock()
st.Result = parityTplResult(r)
st.Line = tplProbeLine(r)
if c, ok := tplCapsCurrent(); ok {
st.Current = parityTplResult(c)
}
if c, ok := tplCapsFor(shapeOf(st.Shape)); ok {
st.For = parityTplResult(c)
}
}
if sc.External {
delete(sc.Config, "PORT")
}
})
}
// --- relevé du rendu et comparaison ---
type printCase struct {
Shape string `json:"shape"`
Mode string `json:"mode"`
Override map[string][]parityTplExchange `json:"override,omitempty"`
Exchanges []parityTplExchange `json:"exchanges"`
Out string `json:"out"`
Err string `json:"err"`
}
prints := []printCase{
{Shape: "nue", Mode: "qwen3"},
{Shape: "outils", Mode: "keep"},
{Shape: "kwargs", Mode: "drop"},
{Shape: "nue", Mode: "keep", Override: map[string][]parityTplExchange{"/apply-template": {{Status: 200, Reply: `{"prompt":"a"}`}, {Status: 500, Reply: "boum"}}}},
}
func() {
testHome(t)
freshTplProbe(t)
eng := &parityTplEngine{fake: &fakeTpl{}, override: map[string][]parityTplExchange{}}
srv := httptest.NewServer(eng)
defer srv.Close()
u, _ := url.Parse(srv.URL)
if err := SetConfigKey("PORT", u.Port()); err != nil {
t.Fatal(err)
}
for i := range prints {
pc := &prints[i]
eng.mu.Lock()
eng.fake.mode = pc.Mode
for p, l := range pc.Override {
eng.override[p] = append([]parityTplExchange(nil), l...)
}
eng.exchanges = nil
eng.mu.Unlock()
out, err := tplRenderPrint(context.Background(), tplLocalProber(), shapeOf(pc.Shape))
pc.Out = out
if err != nil {
pc.Err = err.Error()
}
eng.mu.Lock()
pc.Exchanges = append([]parityTplExchange(nil), eng.exchanges...)
eng.override = map[string][]parityTplExchange{}
eng.mu.Unlock()
}
}()
type diffCase struct {
Before string `json:"before"`
After string `json:"after"`
Same bool `json:"same"`
Detail string `json:"detail"`
}
long := strings.Repeat("abcdéfgh\n", 30)
var diffs []diffCase
for _, c := range [][2]string{
{prints[0].Out, prints[0].Out},
{prints[0].Out, prints[1].Out},
{prints[1].Out, prints[2].Out},
{"", "x"},
{"x", ""},
{"abc", "abd"},
{long + "X" + long, long + "Y" + long},
{tplRenderSep + "section ---\n" + "ééé" + long, tplRenderSep + "section ---\n" + "éèé" + long},
{"sans\x00section", "sans\x00sectioN"},
} {
same, d := tplRenderDiff(c[0], c[1])
diffs = append(diffs, diffCase{Before: c[0], After: c[1], Same: same, Detail: d})
}
// --- empreinte des formes ---
hashes := map[string]string{}
for name := range shapes {
hashes[name] = shapeOf(name).hash()
}
got, err := parityJSON(map[string]any{
"shapes": shapes,
"scenarios": scenarios,
"prints": prints,
"diffs": diffs,
"hashes": hashes,
})
if err != nil {
t.Fatal(err)
}
if os.Getenv("LOKI_PARITY_UPDATE") == "1" {
if err := os.MkdirAll(filepath.Dir(parityTplFile), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(parityTplFile, got, 0o644); err != nil {
t.Fatal(err)
}
return
}
want, err := os.ReadFile(parityTplFile)
if err != nil {
t.Fatalf("journal de référence absent (%v) : LOKI_PARITY_UPDATE=1 pour le créer", err)
}
if string(got) != string(want) {
t.Errorf("la sonde de gabarit ne produit plus le journal rangé dans %s.\n"+
"Si c'est voulu : LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteSondeGabarit,\n"+
"puis cargo test --test tplprobe dit si le port Rust suit.\n--- obtenu ---\n%s", parityTplFile, got)
}
}