diff --git a/.gitignore b/.gitignore index 2785d9b0..a61b1b2e 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ logs/ configs/change-logs/change-logs.json # Wizard import staging klasoru (gecici; oturum bitince silinir) configs/seeds/.imports/ + +# Deployment secret'lari (WEBUI_SECRET_KEY vb.) — sunucuda uretilir, depoya girmez +configs/deployment/.env diff --git a/README.md b/README.md index 8d203b9a..443300fe 100644 --- a/README.md +++ b/README.md @@ -183,7 +183,7 @@ sozsoft-platform/ │ │ # host/{crud,custom,wizard,sql,postgres} │ │ # tenants/{tenantId}/{crud,custom,wizard,sql,postgres} │ │ # {sql|postgres}/{object,execute} -│ └── ai/ # AI entegrasyonu (n8n akış tanımları) +│ └── ai/ # AI entegrasyonu (n8n akış tanımları, Ollama model kurulum notları) ├── .github/instructions/ # ai.instructions.md (bağlayıcı platform kuralları) │ # lowcode.instructions.md (artefakt üretim referansı: şemalar, örnekler) │ # dotnet.instructions.md (api/ için bağlayıcı .NET/ABP kod standardı) @@ -1148,9 +1148,16 @@ kuyruğu arka plan işçisiyle çalıştırır ve sonuçları günlükler. ### 13.5. AI entegrasyonu -`AiBot` kayıtları tenant bazlıdır; n8n webhook üzerinden LangChain ajanı + bellek penceresi ve -Gemini sohbet modeli bağlanır (`configs/ai/Chat.json`). UI tarafında `/admin/ai` ve header'daki -asistan bileşeni bu uç noktayı kullanır. +`AiBot` kayıtları tenant bazlıdır; n8n webhook üzerinden bir niyet sınıflandırma adımı ve +ardından sohbet ya da SQL dalı çalışır (`configs/ai/Chat.json`). Model, DevOps makinesindeki +Ollama'da yerel olarak koşar (`http://ollama:11434/api/chat`, varsayılan `qwen2.5:3b`) — dışarıya +model çağrısı yapılmaz. UI tarafında `/admin/ai` ve header'daki asistan bileşeni bu uç noktayı +kullanır. + +Ollama'ya yüklü modelleri yönetmek için `configs/ai/Kurulum.txt`, sohbet arayüzü (Open WebUI) ve +alan adı yönlendirmesi için §20 kullanılır. Akıştaki model adı değiştirilirse `configs/ai/Chat.json` +içindeki üç varsayılanın (`intentModel`, `chatModel`, `sqlModel`) birlikte güncellenmesi gerekir; +biri kalırsa o dal "model not found" ile düşer. ### 13.6. MCP sunucusu @@ -1254,6 +1261,30 @@ claude mcp add sozsoft-local --transport http https://localhost:44344/mcp \n -- Ters vekilde `/mcp` ayrı bir blokta karşılanır (`configs/deployment/configs/nginx.conf`): Streamable HTTP yanıtları SSE akışıdır, `location /` altındaki tamponlama akışı bekletir. +**Şema tuzağı.** İstek vekilden HTTP olarak gelir. `issuer` ve `registration_endpoint` +yapılandırmadan sabitlenir (`AuthServer:Authority`), ama `authorization_endpoint` ve +`token_endpoint` adreslerini OpenIddict istekten türetir. Uygulama `X-Forwarded-Proto` +başlığını işlemezse bu iki adres keşif belgesine `http://` olarak yazılır ve **OAuth 2.1 +istemcileri (MCP connector'ları) TLS'siz uç kabul etmediği için bağlantı kurulamaz** — +istemci tarafında görülen hata genellikle "please register the OAuth client" olur. + +Çözüm iki parçalıdır ve ikisi birlikte olmak zorundadır: + +- API konteynerinde `ASPNETCORE_FORWARDEDHEADERS_ENABLED=true` (`docker-compose-app.yml`) +- nginx'te **her** `location` bloğunun `X-Forwarded-Proto $scheme` göndermesi. `location /` + bunu `proxy_params` üzerinden yapar; `/mcp`, `/videoroomhub` ve `/messengerhub` kendi + blokları olduğu için başlığı ayrıca set eder. Bir blok atlanırsa istemcinin gönderdiği + başlık olduğu gibi geçer ve şema taklit edilebilir hale gelir. + +Doğrulama: + +```bash +curl -s https://api.sozsoft.com/.well-known/oauth-authorization-server \ + | tr ',' '\n' | grep -E 'issuer|authorization_endpoint|token_endpoint|registration_endpoint' +``` + +Dördü de `https://` olmalıdır. + **Yetkiler.** `App.DeveloperKit.McpServers` (ekran), `.Invoke` (araç çağırma) ve `.Write` (veri yazma). Son ikisi `MultiTenancySide = Both`, yani kiracı kullanıcılarına da verilebilir. @@ -1419,19 +1450,67 @@ grant_type=password ## 20. Dağıtım (Deployment) `configs/deployment` altında üç ortam için compose dosyaları ve numaralandırılmış scriptler bulunur. +Benzer adlı `configs/docker` ile karıştırılmamalı: `docker/` yalnızca **yerel geliştirmede** veri +katmanını (SQL/PostgreSQL/Redis) ayağa kaldırır, `deployment/` ise **sunucudaki** kurulumdur. | Dosya | Amaç | | --- | --- | | `docker-compose-data.yml` | Redis, SQL Server / PostgreSQL, yedek volume'ları | | `docker-compose-app.yml` / `.dev.yml` / `.production.yml` | API, UI, migrator, nginx | -| `docker-compose-devops.yml` | Forgejo + runner (CI/CD) | +| `docker-compose-devops.yml` | Forgejo (CI/CD), Rocket.Chat, n8n, Ollama, Open WebUI, coturn | | `Dockerfile`, `api/*.Dockerfile`, `ui/Sozsoft.Platform.Ui.Dockerfile` | İmaj tanımları | -| `configs/nginx.conf`, `redis.conf`, `turnserver.conf`, `elasticsearch.yml` | Servis yapılandırmaları | + +Servis yapılandırma dosyaları tek klasörde, `configs/deployment/configs/` altındadır — compose +dosyaları buradan mount eder, ikinci bir kopya tutulmaz: + +| Dosya | Kullanan servis | +| --- | --- | +| `nginx.conf` | Ters vekil: tüm `*.sozsoft.com` vhost'ları, SSL sonlandırma, `/mcp` bloğu | +| `redis.conf` / `redis.7.conf` | Redis (dağıtık cache, sürüme göre) | +| `turnserver.conf` | coturn — WebRTC TURN/STUN (bkz. §Messenger) | +| `elasticsearch.yml` | Elasticsearch (opsiyonel log/arama katmanı) | + +### Alt alan adı → port eşlemesi + +`nginx.conf` içindeki vhost'lar host üzerinde yayınlanan portlara bakar; yeni bir servis +eklerken portun boş olduğu buradan doğrulanır. + +| Alan adı | Port | Servis | +| --- | --- | --- | +| `devops.sozsoft.com` | 3000 | Forgejo | +| `chat.sozsoft.com` | 3001 | Rocket.Chat | +| `sozsoft.com` + tenant alt alan adları | 3002 | UI (React) | +| `ollama.sozsoft.com` | 3003 | Open WebUI — sohbet arayüzü | +| `ollama.sozsoft.com/ollama-api/` | 11434 | Ollama ham API'si (basic auth) | +| `ai.sozsoft.com` | 5678 | n8n | +| `api.sozsoft.com` | 8080 | API (`/mcp`, `/videoroomhub`, `/messengerhub` dahil) | +| `cdn.sozsoft.com` | 8081 | CDN | + +Open WebUI kendi backend'ini `/api/...` altında sunduğu için Ollama'nın ham API'si `/ollama-api/` +önekine alınmıştır; iki uç aynı önekte yaşayamaz. + +Basic auth yalnızca `/ollama-api/` bloğundadır — Ollama'nın kendi kimlik doğrulaması yoktur. +Sohbet arayüzünün önüne **konmaz**: Open WebUI socket.io üzerinden WebSocket açar, tarayıcı WS +upgrade isteğine basic auth kimliğini eklemez ve bağlantı 401 döngüsüne girer. Arayüzün güvenlik +sınırı uygulamanın kendi girişidir; bu yüzden kurulumdan hemen sonra **yeni kayıt alımının +kapatılması bir tercih değil, kurulumun parçasıdır** (`Settings → Admin → General → Enable New +Sign Ups`). Daha sıkı bir sınır gerekiyorsa vhost'a `allow`/`deny` ile IP kısıtı eklenir; basic +auth bu arayüz için bir seçenek değildir. + +Compose tarafında `DEFAULT_USER_ROLE=pending` ikinci bir emniyet katmanıdır: ilk kaydolan admin +olur, sonrakiler onay bekleyen role düşer. Bu değer ve `ENABLE_SIGNUP` Open WebUI'nin +*PersistentConfig* değişkenlerindendir — yalnızca **ilk açılışta** env'den okunur, sonrasında +veritabanındaki değer geçerlidir; yani env'i sonradan değiştirmek yerine arayüzdeki ayar kullanılır. + +GPU olmayan 8 GB'lık DevOps makinesinde Ollama `OLLAMA_MAX_LOADED_MODELS=1` ile sınırlıdır ve +Open WebUI'nin gömme/erişim hattı kapalıdır (`BYPASS_EMBEDDING_AND_RETRIEVAL`): varsayılan RAG +hattı ayrıca bir gömme modeli indirir, bu makinede bellek bütçesi yoktur. Yüklenen dosyanın metni +doğrudan bağlama girer. Script sırası (`configs/deployment/scripts`): ``` -1-devops.sh → DevOps makinesi (Forgejo, runner) +1-devops.sh → DevOps makinesi (Forgejo, Rocket.Chat, n8n, Ollama, Open WebUI) 2-data-postgres.sh / 3-data-sql.sh → Veri katmanı 4-production-build.sh → İmaj build diff --git a/configs/ai/Chat.json b/configs/ai/Chat.json index 8af95a12..6a06bd21 100644 --- a/configs/ai/Chat.json +++ b/configs/ai/Chat.json @@ -24,7 +24,7 @@ }, { "parameters": { - "jsCode": "const body = $('Webhook').first().json.body || {};\nconst question = String(body.question || body.message || '').trim();\nreturn [{ json: {\n question,\n ollamaBody: {\n model: body.intentModel || 'gemma3:4b',\n stream: false,\n options: { temperature: 0, top_p: 0.1, num_ctx: 1024, num_predict: 8 },\n messages: [\n { role: 'system', content: 'Sen bir niyet sınıflandırma motorusun. Sadece CHAT veya SQL döndür. Kullanıcı veritabanından veri istiyorsa SQL döndür. Listele, getir, göster, say, kaç, adet, toplam, özet, rapor, sorgula, ara, filtrele, grupla, gruplandır, bazında, kırılım, pivot, tanımlama, kayıt, tablo, kolon, müşteri, sipariş, stok, ayar gibi veri/tablo amaçları varsa SQL döndür. Selamlaşma, açıklama, yardım, teşekkür, genel sohbet ise CHAT döndür. Açıklama yazma.' },\n { role: 'user', content: question }\n ]\n }\n}}];\n" + "jsCode": "const body = $('Webhook').first().json.body || {};\nconst question = String(body.question || body.message || '').trim();\nreturn [{ json: {\n question,\n ollamaBody: {\n model: body.intentModel || 'qwen2.5:3b',\n stream: false,\n options: { temperature: 0, top_p: 0.1, num_ctx: 1024, num_predict: 8 },\n messages: [\n { role: 'system', content: 'Sen bir niyet sınıflandırma motorusun. Sadece CHAT veya SQL döndür. Kullanıcı veritabanından veri istiyorsa SQL döndür. Listele, getir, göster, say, kaç, adet, toplam, özet, rapor, sorgula, ara, filtrele, grupla, gruplandır, bazında, kırılım, pivot, tanımlama, kayıt, tablo, kolon, müşteri, sipariş, stok, ayar gibi veri/tablo amaçları varsa SQL döndür. Selamlaşma, açıklama, yardım, teşekkür, genel sohbet ise CHAT döndür. Açıklama yazma.' },\n { role: 'user', content: question }\n ]\n }\n}}];\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2, @@ -101,7 +101,7 @@ }, { "parameters": { - "jsCode": "\nconst body = $('Webhook').first().json.body || {};\nconst question = String(body.question || body.message || '').trim();\nreturn [{ json: { type:'chat', question, ollamaBody: {\n model: body.chatModel || 'gemma3:4b',\n stream: false,\n options: { temperature: 0.2, top_p: 0.8, num_ctx: 2048, num_predict: 384 },\n messages: [\n { role: 'system', content: 'Sen Türkçe konuşan yardımcı bir asistansın. Kullanıcı SQL/veri istemiyorsa kısa ve net cevap ver. Veritabanı sorgusu üretme.' },\n { role: 'user', content: question }\n ]\n}}}];\n" + "jsCode": "\nconst body = $('Webhook').first().json.body || {};\nconst question = String(body.question || body.message || '').trim();\nreturn [{ json: { type:'chat', question, ollamaBody: {\n model: body.chatModel || 'qwen2.5:3b',\n stream: false,\n options: { temperature: 0.2, top_p: 0.8, num_ctx: 2048, num_predict: 384 },\n messages: [\n { role: 'system', content: 'Sen Türkçe konuşan yardımcı bir asistansın. Kullanıcı SQL/veri istemiyorsa kısa ve net cevap ver. Veritabanı sorgusu üretme.' },\n { role: 'user', content: question }\n ]\n}}}];\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2, @@ -178,7 +178,7 @@ }, { "parameters": { - "jsCode": "\nconst row = $input.first().json || {};\nconst body = $('Webhook').first().json.body || {};\n\nconst question = String(body.question || body.message || '').trim();\nconst tenantName = String(body.tenantName || 'Sozsoft').trim();\nconst dictText = String(row.ListFormDataDictionaryJson || '').trim();\n\nif (!dictText) {\n return [{\n json: {\n valid: false,\n type: 'dictionary_error',\n error: 'Sas_H_DbTableColumn tablosunda aktif JSON veri sözlüğü bulunamadı. Önce JSON üretip dbo.Sas_H_DbTableColumn tablosuna yazın.',\n question\n }\n }];\n}\n\nlet dict;\ntry {\n dict = JSON.parse(dictText);\n} catch (e) {\n return [{\n json: {\n valid: false,\n type: 'dictionary_error',\n error: 'JsonData parse edilemedi: ' + e.message,\n question\n }\n }];\n}\n\nconst listForms = Array.isArray(dict.listForms) ? dict.listForms : [];\n\nfunction escapeRegExp(s) {\n return String(s || '').replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n}\n\nfunction normalizeText(value) {\n return String(value || '')\n .replace(/\\\\u0022/g, '')\n .replace(/\\u0022/g, '')\n .replace(/"/gi, '')\n .replace(/[\\\"'`[\\]\\\\]/g, ' ')\n .toLocaleLowerCase('tr-TR')\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/ı/g, 'i')\n .replace(/ğ/g, 'g')\n .replace(/ü/g, 'u')\n .replace(/ş/g, 's')\n .replace(/ö/g, 'o')\n .replace(/ç/g, 'c')\n .replace(/[^a-z0-9]+/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction decodeSqlEscapes(value) {\n return String(value || '')\n .replace(/\\\\u0022/g, '')\n .replace(/\\u0022/g, '')\n .replace(/u0022/g, '')\n .replace(/\\\\u0027/g, \"'\")\n .replace(/\\u0027/g, \"'\")\n .replace(/u0027/g, \"'\")\n .replace(/"/gi, '')\n .replace(/'/gi, \"'\");\n}\n\nfunction cleanIdentifier(value) {\n let s = decodeSqlEscapes(value)\n .replace(/LookupQuery\\s*:/gi, '')\n .replace(/[\"'`\\[\\]\\\\]/g, '')\n .replace(/\\s+AS\\s+(KEY|NAME)$/i, '')\n .replace(/^dbo\\./i, '')\n .trim();\n\n const dbRe = new RegExp('^' + escapeRegExp(tenantName) + '\\\\.', 'i');\n s = s.replace(dbRe, '').replace(/^dbo\\./i, '').trim();\n\n const parts = s.split('.').map(x => x.trim()).filter(Boolean);\n s = parts.length ? parts[parts.length - 1] : s;\n\n // Only allow real identifier tokens. SQL expressions such as CONCAT(Name,' (',DisplayName,')') are not column names.\n if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(s)) return '';\n return s;\n}\n\nfunction simpleIdentifierFromExpression(value) {\n let s = decodeSqlEscapes(value).trim();\n s = s.replace(/^\\s*SELECT\\s+/i, '').replace(/\\s+AS\\s+(KEY|NAME)\\s*$/i, '').trim();\n\n // remove outer brackets/quotes/backticks for a simple identifier only\n if (/^\\[[A-Za-z_][A-Za-z0-9_]*\\]$/.test(s)) s = s.slice(1, -1);\n if (/^\"[A-Za-z_][A-Za-z0-9_]*\"$/.test(s)) s = s.slice(1, -1);\n if (/^`[A-Za-z_][A-Za-z0-9_]*`$/.test(s)) s = s.slice(1, -1);\n\n // dbo.Column or Table.Column => use last part only if all parts are simple identifiers\n const parts = s.split('.').map(x => x.replace(/[\\[\\]\"`]/g, '').trim()).filter(Boolean);\n if (parts.length && parts.every(x => /^[A-Za-z_][A-Za-z0-9_]*$/.test(x))) return parts[parts.length - 1];\n\n return '';\n}\n\nfunction bracketIdentifier(value, fallback = 'Id') {\n const id = cleanIdentifier(value) || cleanIdentifier(fallback) || 'Id';\n return '[' + id.replace(/]/g, ']]') + ']';\n}\n\nfunction cleanAliasLabel(value, fallback) {\n let label = decodeSqlEscapes(value)\n .replace(/\\r|\\n|\\t/g, ' ')\n .replace(/[\\[\\]]/g, '')\n .replace(/[\\u0000-\\u001F]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n\n // Alias kullanıcıya görünen başlıktır; Türkçe karakter ve boşluk kalabilir.\n // Ancak SQL ifadesi veya boş değer alias olamaz.\n if (!label || /^(CONCAT|CASE|SELECT|NULLIF|ISNULL|COALESCE)\\s*\\(/i.test(label)) {\n label = decodeSqlEscapes(fallback).replace(/[\\[\\]]/g, '').trim();\n }\n if (!label) label = 'Column';\n return label.slice(0, 128);\n}\n\nfunction bracketAlias(value, fallback = 'Column') {\n const label = cleanAliasLabel(value, fallback);\n return '[' + label.replace(/]/g, ']]') + ']';\n}\n\nfunction splitTokens(text) {\n const n = normalizeText(text);\n if (!n) return [];\n return n.split(/\\s+/).filter(Boolean);\n}\n\nfunction uniqueTokens(text) {\n return [...new Set(splitTokens(text))];\n}\n\nfunction parseLookupSql(text) {\n const s = decodeSqlEscapes(text)\n .replace(/[\"`\\[\\]\\\\]/g, '')\n .replace(/\\s+/g, ' ')\n .trim();\n\n // Generic SQL lookup pattern:\n // SELECT AS Key, AS Name FROM ...\n const m = s.match(/\\bselect\\s+(.+?)\\s+as\\s+key\\s*,\\s*(.+?)\\s+as\\s+name\\s+from\\s+([a-zA-Z0-9_.]+)/i);\n if (!m) return null;\n\n const valueColumn = simpleIdentifierFromExpression(m[1]);\n let displayColumn = simpleIdentifierFromExpression(m[2]);\n\n // If display expression is a SQL expression like CONCAT(...), do not treat it as a column.\n // Use the lookup key column as a safe display fallback instead of generating [Lx].[CONCAT(...)] or [L0].\n if (!displayColumn) displayColumn = valueColumn;\n\n return {\n tableName: cleanIdentifier(m[3]),\n valueColumn,\n displayColumn\n };\n}\n\nfunction compactField(field) {\n const parsedLookup = parseLookupSql(field.lookupJsonText || field.lookupJson || '');\n const sqlColumnName = cleanIdentifier(field.sqlColumnName || field.field);\n const displayName = String(field.displayName || field.captionValue || field.captionKey || sqlColumnName || '');\n\n const lookupTableName = cleanIdentifier(\n parsedLookup?.tableName ||\n field.lookupTableName ||\n field.parsedLookupTableName ||\n ''\n );\n\n const lookupValueColumn = cleanIdentifier(\n parsedLookup?.valueColumn ||\n field.parsedLookupValueColumn ||\n field.lookupValueColumn ||\n ''\n );\n\n let lookupDisplayColumn = cleanIdentifier(\n parsedLookup?.displayColumn ||\n field.parsedLookupDisplayColumn ||\n field.lookupDisplayColumn ||\n ''\n );\n\n if (!lookupDisplayColumn && lookupValueColumn) lookupDisplayColumn = lookupValueColumn;\n\n // A field is a usable lookup only when table/value/display are clean real identifiers.\n // This prevents SQL like [L0].[u0027)u0027)] and [L1].[CONCAT(...)] from being generated.\n const hasLookup = Boolean(lookupTableName && lookupValueColumn && lookupDisplayColumn);\n let lookupJoinMode = hasLookup ? (field.lookupJoinMode || '') : '';\n\n if (hasLookup && !lookupJoinMode) {\n const baseNorm = normalizeText(sqlColumnName);\n const valueNorm = normalizeText(lookupValueColumn);\n const idLike = baseNorm.endsWith('id') || valueNorm === 'id';\n lookupJoinMode = idLike ? 'convert-nvarchar-safe' : 'direct-string';\n }\n\n return {\n sqlColumnName,\n displayName,\n captionKey: String(field.captionKey || ''),\n sqlDataType: String(field.sqlDataType || ''),\n listOrderNo: Number(field.listOrderNo ?? 999999),\n isBooleanColumn: Boolean(field.isBooleanColumn),\n isVisible: field.isVisible !== false,\n isDefaultSelectColumn: Boolean(field.isDefaultSelectColumn),\n isActiveStatusColumn: Boolean(field.isActiveStatusColumn),\n isDeletedStatusColumn: Boolean(field.isDeletedStatusColumn),\n isSensitive: Boolean(field.isSensitive) || /password|token|secret|apikey|privatekey|connectionstring/i.test(sqlColumnName),\n hasLookup,\n lookup: hasLookup ? {\n tableName: lookupTableName,\n valueColumn: lookupValueColumn,\n displayColumn: lookupDisplayColumn || lookupValueColumn,\n joinMode: lookupJoinMode,\n fallbackToSourceColumn: Boolean(field.lookupFallbackToSourceColumn)\n } : null,\n searchText: normalizeText([\n field.sqlColumnName,\n field.field,\n field.displayName,\n field.captionValue,\n field.captionKey,\n field.lookupSearchText\n ].join(' '))\n };\n}\n\nfunction compactForm(form) {\n const fields = Array.isArray(form.fields) ? form.fields.map(compactField).filter(f => f.sqlColumnName && !f.isSensitive) : [];\n return {\n formCode: String(form.formCode || form.listFormCode || ''),\n displayName: String(form.displayName || form.nameValue || form.title || ''),\n tableName: cleanIdentifier(form.sqlTableName || form.tableName || form.sqlSourceName || ''),\n primaryKeyColumn: cleanIdentifier(form.primaryKeyColumn || form.keyFieldName || 'Id'),\n sourceKind: form.sourceKind || 'table',\n searchText: normalizeText([\n form.formCode,\n form.listFormCode,\n form.displayName,\n form.nameValue,\n form.title,\n form.description,\n form.tableName,\n form.sqlTableName,\n form.searchText\n ].join(' ')),\n fields\n };\n}\n\nconst compactAllForms = listForms.map(compactForm).filter(f => f.tableName && f.fields.length > 0);\n\n// Generic question semantics; these are filter/action words, not entity names.\n// They are intentionally not mapped to any specific table.\nconst actionOrFilterWords = new Set([\n 'liste', 'listesi', 'listele', 'goster', 'getir', 'ver', 'bul', 'ara',\n 'aktif', 'pasif', 'active', 'inactive', 'etkin', 'silinmis', 'silinen',\n 'toplam', 'say', 'adet', 'kac', 'kaç', 'rapor', 'ozet', 'özet', 'ozetle', 'özetle', 'pivot', 'bazinda', 'bazında', 'gruplandir', 'gruplandır', 'group', 'grouping', 'kirilim', 'kırılım'\n]);\n\nconst questionTokens = uniqueTokens(question);\nconst discriminatingTokens = questionTokens.filter(t => !actionOrFilterWords.has(t));\n\nfunction tokenHitScore(token, text) {\n if (!token || !text) return 0;\n if (text.split(' ').includes(token)) return 12;\n if (token.length >= 3 && text.includes(token)) return 6;\n return 0;\n}\n\nfunction scoreForm(form) {\n const formText = normalizeText([form.formCode, form.displayName, form.tableName, form.searchText].join(' '));\n const fieldText = normalizeText(form.fields.map(f => [f.sqlColumnName, f.displayName, f.captionKey].join(' ')).join(' '));\n\n let score = 0;\n\n for (const token of discriminatingTokens) {\n score += tokenHitScore(token, formText) * 4;\n score += tokenHitScore(token, fieldText);\n }\n\n // If no discriminating token exists, allow generic tokens to select by visible labels.\n if (discriminatingTokens.length === 0) {\n for (const token of questionTokens) {\n score += tokenHitScore(token, formText);\n score += tokenHitScore(token, fieldText) / 2;\n }\n }\n\n // Exact normalized phrase in form/table/display name gets a generic boost.\n const qNorm = normalizeText(question);\n if (qNorm && formText.includes(qNorm)) score += 30;\n\n // Penalize forms that only match action/filter words.\n if (score === 0 && questionTokens.some(t => actionOrFilterWords.has(t)) && form.fields.some(f => f.isActiveStatusColumn)) {\n score += 1;\n }\n\n return score;\n}\n\nconst maxCandidateForms = Math.max(1, Math.min(Number(body.maxCandidateForms || 5), 12));\nconst scored = compactAllForms\n .map(form => ({ form, score: scoreForm(form) }))\n .sort((a, b) => b.score - a.score || a.form.formCode.localeCompare(b.form.formCode));\n\nlet compactForms = scored.filter(x => x.score > 0).slice(0, maxCandidateForms).map(x => x.form);\nif (compactForms.length === 0) compactForms = compactAllForms.slice(0, maxCandidateForms);\n\nconst selectedForm = compactForms[0] || null;\n\nconst allowedTables = new Set();\nfor (const form of compactForms) {\n if (form.tableName) allowedTables.add(form.tableName);\n for (const f of form.fields || []) {\n if (f.lookup?.tableName) allowedTables.add(f.lookup.tableName);\n }\n}\n\nfunction aliasFor(index) {\n return '[L' + index + ']';\n}\n\nfunction sqlExpressionForField(baseAlias, lookupAlias, field) {\n const col = bracketIdentifier(field.sqlColumnName);\n const alias = bracketAlias(field.displayName, field.sqlColumnName);\n\n if (lookupAlias && lookupAlias !== '[L0]' && field.hasLookup && field.lookup?.tableName && field.lookup?.displayColumn) {\n const displayColumn = bracketIdentifier(field.lookup.displayColumn);\n if (field.lookup.fallbackToSourceColumn) {\n return `COALESCE(CONVERT(NVARCHAR(4000), ${lookupAlias}.${displayColumn}), CONVERT(NVARCHAR(4000), ${baseAlias}.${col})) AS ${alias}`;\n }\n return `${lookupAlias}.${displayColumn} AS ${alias}`;\n }\n\n if (field.isBooleanColumn) {\n return `CASE WHEN ${baseAlias}.${col} = 1 THEN 'Yes' WHEN ${baseAlias}.${col} = 0 THEN 'No' ELSE NULL END AS ${alias}`;\n }\n\n return `${baseAlias}.${col} AS ${alias}`;\n}\n\n\nfunction detectResultMode(questionText) {\n const q = normalizeText(questionText);\n const tokens = new Set(q.split(' ').filter(Boolean));\n\n const wantsPivot = tokens.has('pivot') || tokens.has('kirılım') || tokens.has('kirilim') || tokens.has('bazinda') || tokens.has('bazli') || tokens.has('gruplandir') || tokens.has('gruplama') || tokens.has('group') || tokens.has('grouping');\n const wantsSummary = tokens.has('ozet') || tokens.has('ozetle') || tokens.has('summarize') || tokens.has('summary') || tokens.has('toplam') || tokens.has('adet') || tokens.has('say') || tokens.has('sayisi') || tokens.has('kac') || tokens.has('kaç') || wantsPivot;\n\n if (wantsPivot) return 'pivot';\n if (wantsSummary) return 'summary';\n return 'list';\n}\n\nfunction isNumericSqlType(typeName) {\n return /^(bigint|int|smallint|tinyint|decimal|numeric|money|smallmoney|float|real)$/i.test(String(typeName || ''));\n}\n\nfunction scoreGroupField(field, qNorm) {\n const fieldNorm = normalizeText([field.sqlColumnName, field.displayName, field.captionKey].join(' '));\n if (!fieldNorm) return 0;\n\n let score = 0;\n if (qNorm.includes(fieldNorm)) score += 100;\n\n const parts = fieldNorm.split(' ').filter(Boolean);\n for (const p of parts) {\n if (p.length >= 2 && qNorm.split(' ').includes(p)) score += 10;\n }\n\n // \"Ana Grup Anahtarı bazında\" gibi isteklerde özellikle metin/kod alanlarını tercih et.\n if (/grup|group|kategori|category|tip|type|tur|tür|anahtar|key|kod|code|name|ad/i.test(fieldNorm)) score += 8;\n\n // ID alanlarını kullanıcı özellikle istemiyorsa geriye at.\n if (/id$/i.test(field.sqlColumnName)) score -= 15;\n\n return score;\n}\n\nfunction pickGroupFields(fields, questionText, maxCount = 2) {\n const qNorm = normalizeText(questionText);\n\n const candidates = fields\n .filter(f => f.sqlColumnName && !f.isSensitive && f.isVisible !== false)\n .filter(f => !isNumericSqlType(f.sqlDataType) || /anahtar|key|kod|code|grup|group|tip|type|tur|tür/i.test(normalizeText([f.sqlColumnName, f.displayName, f.captionKey].join(' '))))\n .map(f => ({ field: f, score: scoreGroupField(f, qNorm) }))\n .sort((a, b) => b.score - a.score || a.field.listOrderNo - b.field.listOrderNo);\n\n const explicit = candidates.filter(x => x.score >= 20).slice(0, maxCount).map(x => x.field);\n if (explicit.length) return explicit;\n\n // Varsayılan: görünen ilk metin/kod alanı\n const fallback = fields.find(f =>\n f.sqlColumnName &&\n !f.isSensitive &&\n f.isVisible !== false &&\n !/^id$/i.test(f.sqlColumnName) &&\n !isNumericSqlType(f.sqlDataType)\n );\n\n return fallback ? [fallback] : fields.filter(f => f.sqlColumnName && !f.isSensitive).slice(0, 1);\n}\n\nfunction buildDirectSql(form, questionText) {\n if (!form || !form.tableName) return '';\n\n const baseAlias = '[T]';\n const qNorm = normalizeText(questionText);\n const resultMode = detectResultMode(questionText);\n\n const fields = (form.fields || [])\n .filter(f => f.sqlColumnName && !f.isSensitive && f.isVisible !== false)\n .sort((a, b) => a.listOrderNo - b.listOrderNo);\n\n const where = [];\n const activeField = fields.find(f => f.isActiveStatusColumn) || fields.find(f => normalizeText(f.sqlColumnName) === 'isactive');\n const deletedField = fields.find(f => f.isDeletedStatusColumn) || fields.find(f => normalizeText(f.sqlColumnName) === 'isdeleted');\n\n const asksActive = qNorm.split(' ').includes('aktif') || qNorm.split(' ').includes('active') || qNorm.split(' ').includes('etkin');\n const asksPassive = qNorm.split(' ').includes('pasif') || qNorm.split(' ').includes('inactive');\n\n if (activeField && asksActive) where.push(`${baseAlias}.${bracketIdentifier(activeField.sqlColumnName)} = 1`);\n if (activeField && asksPassive) where.push(`${baseAlias}.${bracketIdentifier(activeField.sqlColumnName)} = 0`);\n if (deletedField) where.push(`${baseAlias}.${bracketIdentifier(deletedField.sqlColumnName)} = 0`);\n\n // ÖZET / PIVOT / GROUPING: SELECT * asla kullanılmaz. GROUP BY alan + COUNT(1) döner.\n if (resultMode === 'summary' || resultMode === 'pivot') {\n const groupFields = pickGroupFields(fields, questionText, 2);\n\n if (groupFields.length > 0) {\n const groupExpressions = groupFields.map(f => `${baseAlias}.${bracketIdentifier(f.sqlColumnName)}`);\n const selectParts = groupFields.map(f => `${baseAlias}.${bracketIdentifier(f.sqlColumnName)} AS ${bracketAlias(f.displayName, f.sqlColumnName)}`);\n\n selectParts.push(`COUNT(1) AS [Adet]`);\n\n // Kullanıcı açıkça toplam isterse, ilk uygun sayısal kolonları da topla.\n const wantsTotal = qNorm.split(' ').includes('toplam') || qNorm.split(' ').includes('sum');\n if (wantsTotal) {\n const numericFields = fields\n .filter(f => isNumericSqlType(f.sqlDataType))\n .filter(f => !/^id$/i.test(f.sqlColumnName))\n .slice(0, 3);\n\n for (const nf of numericFields) {\n selectParts.push(`SUM(TRY_CONVERT(DECIMAL(38, 6), ${baseAlias}.${bracketIdentifier(nf.sqlColumnName)})) AS ${bracketAlias((nf.displayName || nf.sqlColumnName) + ' Toplam', nf.sqlColumnName + ' Toplam')}`);\n }\n }\n\n return `SELECT ${selectParts.join(', ')} FROM [${tenantName}].[dbo].${bracketIdentifier(form.tableName)} AS ${baseAlias}` +\n (where.length ? ' WHERE ' + where.join(' AND ') : '') +\n ` GROUP BY ${groupExpressions.join(', ')} ORDER BY [Adet] DESC`;\n }\n\n return `SELECT COUNT(1) AS [Adet] FROM [${tenantName}].[dbo].${bracketIdentifier(form.tableName)} AS ${baseAlias}` +\n (where.length ? ' WHERE ' + where.join(' AND ') : '');\n }\n\n let selectedFields = fields.filter(f => f.isDefaultSelectColumn);\n if (selectedFields.length === 0) selectedFields = fields.slice(0, 12);\n\n selectedFields = selectedFields\n .filter(f => !/^id$/i.test(f.sqlColumnName))\n .slice(0, Math.max(1, Math.min(Number(body.maxSelectColumns || 10), 20)));\n\n const joins = [];\n const lookupAliasByColumn = new Map();\n let joinIndex = 1;\n\n for (const field of selectedFields) {\n if (!field.hasLookup || !field.lookup?.tableName || !field.lookup?.valueColumn || !field.lookup?.displayColumn) continue;\n\n const lookupAlias = aliasFor(joinIndex++);\n lookupAliasByColumn.set(field.sqlColumnName, lookupAlias);\n\n const baseColumn = bracketIdentifier(field.sqlColumnName);\n const valueColumn = bracketIdentifier(field.lookup.valueColumn);\n const lookupTable = bracketIdentifier(field.lookup.tableName);\n\n const joinCondition = field.lookup.joinMode === 'direct-string'\n ? `${baseAlias}.${baseColumn} = ${lookupAlias}.${valueColumn}`\n : `CONVERT(NVARCHAR(100), ${baseAlias}.${baseColumn}) = CONVERT(NVARCHAR(100), ${lookupAlias}.${valueColumn})`;\n\n joins.push(`LEFT JOIN [${tenantName}].[dbo].${lookupTable} AS ${lookupAlias} ON ${joinCondition}`);\n }\n\n const selectParts = selectedFields.map(field =>\n sqlExpressionForField(baseAlias, lookupAliasByColumn.get(field.sqlColumnName) || '[L0]', field)\n );\n\n if (selectParts.length === 0) {\n selectParts.push(`${baseAlias}.${bracketIdentifier(form.primaryKeyColumn || 'Id')} AS [Id]`);\n }\n\n return `SELECT ${selectParts.join(', ')} FROM [${tenantName}].[dbo].${bracketIdentifier(form.tableName)} AS ${baseAlias}` +\n (joins.length ? ' ' + joins.join(' ') : '') +\n (where.length ? ' WHERE ' + where.join(' AND ') : '');\n}\n\n\nconst directSql = buildDirectSql(selectedForm, question);\n\nconst systemPrompt = `Sen Microsoft SQL Server T-SQL üretim motorusun.\nSadece SELECT sorgusu döndür. Açıklama, özet, yorum, JSON açıklaması, markdown veya kod bloğu yazma.\nİlk kelime kesinlikle SELECT olmalı.\nSadece verilen JSON veri sözlüğündeki form, tablo, kolon ve lookup bilgilerini kullan.\nTablo referansları [${tenantName}].[dbo].[TableName] formatında olmalı.\nSELECT * kullanma.\nINSERT, UPDATE, DELETE, MERGE, DROP, ALTER, TRUNCATE, CREATE, EXEC, EXECUTE üretme.\nLookup alanlarda kullanıcı ID istemedikçe lookup.displayColumn değerini LEFT JOIN ile göster.\nlookup.valueColumn değerini kolon adı olarak aynen kullan. \"LookupQuery:\" gibi metinler kolon adı değildir.\nlookup.joinMode direct-string ise ON [T].[baseColumn] = [Lx].[lookup.valueColumn] kullan.\nlookup.joinMode convert-nvarchar-safe ise ON CONVERT(NVARCHAR(100), [T].[baseColumn]) = CONVERT(NVARCHAR(100), [Lx].[lookup.valueColumn]) kullan.\nBoolean alanları SELECT çıktısında CASE ile Yes/No olarak göster.\nAktif/pasif filtreleri yalnızca veri sözlüğünde uygun aktiflik kolonu varsa uygulanır.\nHassas alanları döndürme.\nKullanıcı listele/getir/göster/select derse normal satır listesi üret.\nKullanıcı özetle/özet/toplam/adet/kaç/sayısı derse aggregate üret: SELECT grupKolonu, COUNT(1) AS [Adet] ... GROUP BY grupKolonu.\nKullanıcı bazında/kırılım/gruplandır/grouping/pivot derse ilgili alanı GROUP BY yap ve COUNT(1) AS [Adet] döndür.\nGROUP BY varsa SELECT * kullanmak kesinlikle yasaktır.\nGROUP BY varsa SELECT içindeki her düz kolon GROUP BY içinde de olmalı veya aggregate fonksiyonda olmalı.\nSQL Server PIVOT için değerleri bilmiyorsan PIVOT operatörü kullanma; GROUP BY ile pivotlanabilir sonuç üret.`;\n\nconst userContent = `Kullanıcı isteği: ${question}\n\nADAY_VERI_SOZLUGU_JSON:\n${JSON.stringify({ listForms: compactForms }, null, 2)}\n\nDETERMINISTIK_SQL:\n${directSql}\n\nDeterministik SQL kullanıcı isteğini karşılıyorsa aynen döndür.`;\n\nreturn [{\n json: {\n valid: true,\n question,\n tenantName,\n dictionaryMeta: {\n cacheKey: row.CacheKey,\n refreshedAt: row.RefreshedAt,\n formCount: row.FormCount,\n fieldCount: row.FieldCount,\n jsonLength: row.JsonLength\n },\n candidateFormCodes: compactForms.map(f => f.formCode),\n formScores: scored.slice(0, maxCandidateForms).map(x => ({ formCode: x.form.formCode, tableName: x.form.tableName, score: x.score })),\n selectedFormCode: selectedForm?.formCode || null,\n selectedTableName: selectedForm?.tableName || null,\n directSql,\n compactDictionary: { listForms: compactForms },\n allowedTables: [...allowedTables],\n ollamaBody: {\n model: body.sqlModel || 'gemma3:4b',\n stream: false,\n options: {\n temperature: 0,\n top_p: 0.05,\n num_ctx: Math.max(1024, Math.min(Number(body.numCtx || 2048), 4096)),\n num_predict: Math.max(256, Math.min(Number(body.numPredict || 600), 900)),\n stop: [';\\n\\n', 'Açıklama:', 'Explanation:', 'Not:', '```']\n },\n messages: [\n { role: 'system', content: systemPrompt },\n { role: 'user', content: userContent }\n ]\n }\n }\n}];\n" + "jsCode": "\nconst row = $input.first().json || {};\nconst body = $('Webhook').first().json.body || {};\n\nconst question = String(body.question || body.message || '').trim();\nconst tenantName = String(body.tenantName || 'Sozsoft').trim();\nconst dictText = String(row.ListFormDataDictionaryJson || '').trim();\n\nif (!dictText) {\n return [{\n json: {\n valid: false,\n type: 'dictionary_error',\n error: 'Sas_H_DbTableColumn tablosunda aktif JSON veri sözlüğü bulunamadı. Önce JSON üretip dbo.Sas_H_DbTableColumn tablosuna yazın.',\n question\n }\n }];\n}\n\nlet dict;\ntry {\n dict = JSON.parse(dictText);\n} catch (e) {\n return [{\n json: {\n valid: false,\n type: 'dictionary_error',\n error: 'JsonData parse edilemedi: ' + e.message,\n question\n }\n }];\n}\n\nconst listForms = Array.isArray(dict.listForms) ? dict.listForms : [];\n\nfunction escapeRegExp(s) {\n return String(s || '').replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n}\n\nfunction normalizeText(value) {\n return String(value || '')\n .replace(/\\\\u0022/g, '')\n .replace(/\\u0022/g, '')\n .replace(/"/gi, '')\n .replace(/[\\\"'`[\\]\\\\]/g, ' ')\n .toLocaleLowerCase('tr-TR')\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/ı/g, 'i')\n .replace(/ğ/g, 'g')\n .replace(/ü/g, 'u')\n .replace(/ş/g, 's')\n .replace(/ö/g, 'o')\n .replace(/ç/g, 'c')\n .replace(/[^a-z0-9]+/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction decodeSqlEscapes(value) {\n return String(value || '')\n .replace(/\\\\u0022/g, '')\n .replace(/\\u0022/g, '')\n .replace(/u0022/g, '')\n .replace(/\\\\u0027/g, \"'\")\n .replace(/\\u0027/g, \"'\")\n .replace(/u0027/g, \"'\")\n .replace(/"/gi, '')\n .replace(/'/gi, \"'\");\n}\n\nfunction cleanIdentifier(value) {\n let s = decodeSqlEscapes(value)\n .replace(/LookupQuery\\s*:/gi, '')\n .replace(/[\"'`\\[\\]\\\\]/g, '')\n .replace(/\\s+AS\\s+(KEY|NAME)$/i, '')\n .replace(/^dbo\\./i, '')\n .trim();\n\n const dbRe = new RegExp('^' + escapeRegExp(tenantName) + '\\\\.', 'i');\n s = s.replace(dbRe, '').replace(/^dbo\\./i, '').trim();\n\n const parts = s.split('.').map(x => x.trim()).filter(Boolean);\n s = parts.length ? parts[parts.length - 1] : s;\n\n // Only allow real identifier tokens. SQL expressions such as CONCAT(Name,' (',DisplayName,')') are not column names.\n if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(s)) return '';\n return s;\n}\n\nfunction simpleIdentifierFromExpression(value) {\n let s = decodeSqlEscapes(value).trim();\n s = s.replace(/^\\s*SELECT\\s+/i, '').replace(/\\s+AS\\s+(KEY|NAME)\\s*$/i, '').trim();\n\n // remove outer brackets/quotes/backticks for a simple identifier only\n if (/^\\[[A-Za-z_][A-Za-z0-9_]*\\]$/.test(s)) s = s.slice(1, -1);\n if (/^\"[A-Za-z_][A-Za-z0-9_]*\"$/.test(s)) s = s.slice(1, -1);\n if (/^`[A-Za-z_][A-Za-z0-9_]*`$/.test(s)) s = s.slice(1, -1);\n\n // dbo.Column or Table.Column => use last part only if all parts are simple identifiers\n const parts = s.split('.').map(x => x.replace(/[\\[\\]\"`]/g, '').trim()).filter(Boolean);\n if (parts.length && parts.every(x => /^[A-Za-z_][A-Za-z0-9_]*$/.test(x))) return parts[parts.length - 1];\n\n return '';\n}\n\nfunction bracketIdentifier(value, fallback = 'Id') {\n const id = cleanIdentifier(value) || cleanIdentifier(fallback) || 'Id';\n return '[' + id.replace(/]/g, ']]') + ']';\n}\n\nfunction cleanAliasLabel(value, fallback) {\n let label = decodeSqlEscapes(value)\n .replace(/\\r|\\n|\\t/g, ' ')\n .replace(/[\\[\\]]/g, '')\n .replace(/[\\u0000-\\u001F]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n\n // Alias kullanıcıya görünen başlıktır; Türkçe karakter ve boşluk kalabilir.\n // Ancak SQL ifadesi veya boş değer alias olamaz.\n if (!label || /^(CONCAT|CASE|SELECT|NULLIF|ISNULL|COALESCE)\\s*\\(/i.test(label)) {\n label = decodeSqlEscapes(fallback).replace(/[\\[\\]]/g, '').trim();\n }\n if (!label) label = 'Column';\n return label.slice(0, 128);\n}\n\nfunction bracketAlias(value, fallback = 'Column') {\n const label = cleanAliasLabel(value, fallback);\n return '[' + label.replace(/]/g, ']]') + ']';\n}\n\nfunction splitTokens(text) {\n const n = normalizeText(text);\n if (!n) return [];\n return n.split(/\\s+/).filter(Boolean);\n}\n\nfunction uniqueTokens(text) {\n return [...new Set(splitTokens(text))];\n}\n\nfunction parseLookupSql(text) {\n const s = decodeSqlEscapes(text)\n .replace(/[\"`\\[\\]\\\\]/g, '')\n .replace(/\\s+/g, ' ')\n .trim();\n\n // Generic SQL lookup pattern:\n // SELECT AS Key, AS Name FROM ...\n const m = s.match(/\\bselect\\s+(.+?)\\s+as\\s+key\\s*,\\s*(.+?)\\s+as\\s+name\\s+from\\s+([a-zA-Z0-9_.]+)/i);\n if (!m) return null;\n\n const valueColumn = simpleIdentifierFromExpression(m[1]);\n let displayColumn = simpleIdentifierFromExpression(m[2]);\n\n // If display expression is a SQL expression like CONCAT(...), do not treat it as a column.\n // Use the lookup key column as a safe display fallback instead of generating [Lx].[CONCAT(...)] or [L0].\n if (!displayColumn) displayColumn = valueColumn;\n\n return {\n tableName: cleanIdentifier(m[3]),\n valueColumn,\n displayColumn\n };\n}\n\nfunction compactField(field) {\n const parsedLookup = parseLookupSql(field.lookupJsonText || field.lookupJson || '');\n const sqlColumnName = cleanIdentifier(field.sqlColumnName || field.field);\n const displayName = String(field.displayName || field.captionValue || field.captionKey || sqlColumnName || '');\n\n const lookupTableName = cleanIdentifier(\n parsedLookup?.tableName ||\n field.lookupTableName ||\n field.parsedLookupTableName ||\n ''\n );\n\n const lookupValueColumn = cleanIdentifier(\n parsedLookup?.valueColumn ||\n field.parsedLookupValueColumn ||\n field.lookupValueColumn ||\n ''\n );\n\n let lookupDisplayColumn = cleanIdentifier(\n parsedLookup?.displayColumn ||\n field.parsedLookupDisplayColumn ||\n field.lookupDisplayColumn ||\n ''\n );\n\n if (!lookupDisplayColumn && lookupValueColumn) lookupDisplayColumn = lookupValueColumn;\n\n // A field is a usable lookup only when table/value/display are clean real identifiers.\n // This prevents SQL like [L0].[u0027)u0027)] and [L1].[CONCAT(...)] from being generated.\n const hasLookup = Boolean(lookupTableName && lookupValueColumn && lookupDisplayColumn);\n let lookupJoinMode = hasLookup ? (field.lookupJoinMode || '') : '';\n\n if (hasLookup && !lookupJoinMode) {\n const baseNorm = normalizeText(sqlColumnName);\n const valueNorm = normalizeText(lookupValueColumn);\n const idLike = baseNorm.endsWith('id') || valueNorm === 'id';\n lookupJoinMode = idLike ? 'convert-nvarchar-safe' : 'direct-string';\n }\n\n return {\n sqlColumnName,\n displayName,\n captionKey: String(field.captionKey || ''),\n sqlDataType: String(field.sqlDataType || ''),\n listOrderNo: Number(field.listOrderNo ?? 999999),\n isBooleanColumn: Boolean(field.isBooleanColumn),\n isVisible: field.isVisible !== false,\n isDefaultSelectColumn: Boolean(field.isDefaultSelectColumn),\n isActiveStatusColumn: Boolean(field.isActiveStatusColumn),\n isDeletedStatusColumn: Boolean(field.isDeletedStatusColumn),\n isSensitive: Boolean(field.isSensitive) || /password|token|secret|apikey|privatekey|connectionstring/i.test(sqlColumnName),\n hasLookup,\n lookup: hasLookup ? {\n tableName: lookupTableName,\n valueColumn: lookupValueColumn,\n displayColumn: lookupDisplayColumn || lookupValueColumn,\n joinMode: lookupJoinMode,\n fallbackToSourceColumn: Boolean(field.lookupFallbackToSourceColumn)\n } : null,\n searchText: normalizeText([\n field.sqlColumnName,\n field.field,\n field.displayName,\n field.captionValue,\n field.captionKey,\n field.lookupSearchText\n ].join(' '))\n };\n}\n\nfunction compactForm(form) {\n const fields = Array.isArray(form.fields) ? form.fields.map(compactField).filter(f => f.sqlColumnName && !f.isSensitive) : [];\n return {\n formCode: String(form.formCode || form.listFormCode || ''),\n displayName: String(form.displayName || form.nameValue || form.title || ''),\n tableName: cleanIdentifier(form.sqlTableName || form.tableName || form.sqlSourceName || ''),\n primaryKeyColumn: cleanIdentifier(form.primaryKeyColumn || form.keyFieldName || 'Id'),\n sourceKind: form.sourceKind || 'table',\n searchText: normalizeText([\n form.formCode,\n form.listFormCode,\n form.displayName,\n form.nameValue,\n form.title,\n form.description,\n form.tableName,\n form.sqlTableName,\n form.searchText\n ].join(' ')),\n fields\n };\n}\n\nconst compactAllForms = listForms.map(compactForm).filter(f => f.tableName && f.fields.length > 0);\n\n// Generic question semantics; these are filter/action words, not entity names.\n// They are intentionally not mapped to any specific table.\nconst actionOrFilterWords = new Set([\n 'liste', 'listesi', 'listele', 'goster', 'getir', 'ver', 'bul', 'ara',\n 'aktif', 'pasif', 'active', 'inactive', 'etkin', 'silinmis', 'silinen',\n 'toplam', 'say', 'adet', 'kac', 'kaç', 'rapor', 'ozet', 'özet', 'ozetle', 'özetle', 'pivot', 'bazinda', 'bazında', 'gruplandir', 'gruplandır', 'group', 'grouping', 'kirilim', 'kırılım'\n]);\n\nconst questionTokens = uniqueTokens(question);\nconst discriminatingTokens = questionTokens.filter(t => !actionOrFilterWords.has(t));\n\nfunction tokenHitScore(token, text) {\n if (!token || !text) return 0;\n if (text.split(' ').includes(token)) return 12;\n if (token.length >= 3 && text.includes(token)) return 6;\n return 0;\n}\n\nfunction scoreForm(form) {\n const formText = normalizeText([form.formCode, form.displayName, form.tableName, form.searchText].join(' '));\n const fieldText = normalizeText(form.fields.map(f => [f.sqlColumnName, f.displayName, f.captionKey].join(' ')).join(' '));\n\n let score = 0;\n\n for (const token of discriminatingTokens) {\n score += tokenHitScore(token, formText) * 4;\n score += tokenHitScore(token, fieldText);\n }\n\n // If no discriminating token exists, allow generic tokens to select by visible labels.\n if (discriminatingTokens.length === 0) {\n for (const token of questionTokens) {\n score += tokenHitScore(token, formText);\n score += tokenHitScore(token, fieldText) / 2;\n }\n }\n\n // Exact normalized phrase in form/table/display name gets a generic boost.\n const qNorm = normalizeText(question);\n if (qNorm && formText.includes(qNorm)) score += 30;\n\n // Penalize forms that only match action/filter words.\n if (score === 0 && questionTokens.some(t => actionOrFilterWords.has(t)) && form.fields.some(f => f.isActiveStatusColumn)) {\n score += 1;\n }\n\n return score;\n}\n\nconst maxCandidateForms = Math.max(1, Math.min(Number(body.maxCandidateForms || 5), 12));\nconst scored = compactAllForms\n .map(form => ({ form, score: scoreForm(form) }))\n .sort((a, b) => b.score - a.score || a.form.formCode.localeCompare(b.form.formCode));\n\nlet compactForms = scored.filter(x => x.score > 0).slice(0, maxCandidateForms).map(x => x.form);\nif (compactForms.length === 0) compactForms = compactAllForms.slice(0, maxCandidateForms);\n\nconst selectedForm = compactForms[0] || null;\n\nconst allowedTables = new Set();\nfor (const form of compactForms) {\n if (form.tableName) allowedTables.add(form.tableName);\n for (const f of form.fields || []) {\n if (f.lookup?.tableName) allowedTables.add(f.lookup.tableName);\n }\n}\n\nfunction aliasFor(index) {\n return '[L' + index + ']';\n}\n\nfunction sqlExpressionForField(baseAlias, lookupAlias, field) {\n const col = bracketIdentifier(field.sqlColumnName);\n const alias = bracketAlias(field.displayName, field.sqlColumnName);\n\n if (lookupAlias && lookupAlias !== '[L0]' && field.hasLookup && field.lookup?.tableName && field.lookup?.displayColumn) {\n const displayColumn = bracketIdentifier(field.lookup.displayColumn);\n if (field.lookup.fallbackToSourceColumn) {\n return `COALESCE(CONVERT(NVARCHAR(4000), ${lookupAlias}.${displayColumn}), CONVERT(NVARCHAR(4000), ${baseAlias}.${col})) AS ${alias}`;\n }\n return `${lookupAlias}.${displayColumn} AS ${alias}`;\n }\n\n if (field.isBooleanColumn) {\n return `CASE WHEN ${baseAlias}.${col} = 1 THEN 'Yes' WHEN ${baseAlias}.${col} = 0 THEN 'No' ELSE NULL END AS ${alias}`;\n }\n\n return `${baseAlias}.${col} AS ${alias}`;\n}\n\n\nfunction detectResultMode(questionText) {\n const q = normalizeText(questionText);\n const tokens = new Set(q.split(' ').filter(Boolean));\n\n const wantsPivot = tokens.has('pivot') || tokens.has('kirılım') || tokens.has('kirilim') || tokens.has('bazinda') || tokens.has('bazli') || tokens.has('gruplandir') || tokens.has('gruplama') || tokens.has('group') || tokens.has('grouping');\n const wantsSummary = tokens.has('ozet') || tokens.has('ozetle') || tokens.has('summarize') || tokens.has('summary') || tokens.has('toplam') || tokens.has('adet') || tokens.has('say') || tokens.has('sayisi') || tokens.has('kac') || tokens.has('kaç') || wantsPivot;\n\n if (wantsPivot) return 'pivot';\n if (wantsSummary) return 'summary';\n return 'list';\n}\n\nfunction isNumericSqlType(typeName) {\n return /^(bigint|int|smallint|tinyint|decimal|numeric|money|smallmoney|float|real)$/i.test(String(typeName || ''));\n}\n\nfunction scoreGroupField(field, qNorm) {\n const fieldNorm = normalizeText([field.sqlColumnName, field.displayName, field.captionKey].join(' '));\n if (!fieldNorm) return 0;\n\n let score = 0;\n if (qNorm.includes(fieldNorm)) score += 100;\n\n const parts = fieldNorm.split(' ').filter(Boolean);\n for (const p of parts) {\n if (p.length >= 2 && qNorm.split(' ').includes(p)) score += 10;\n }\n\n // \"Ana Grup Anahtarı bazında\" gibi isteklerde özellikle metin/kod alanlarını tercih et.\n if (/grup|group|kategori|category|tip|type|tur|tür|anahtar|key|kod|code|name|ad/i.test(fieldNorm)) score += 8;\n\n // ID alanlarını kullanıcı özellikle istemiyorsa geriye at.\n if (/id$/i.test(field.sqlColumnName)) score -= 15;\n\n return score;\n}\n\nfunction pickGroupFields(fields, questionText, maxCount = 2) {\n const qNorm = normalizeText(questionText);\n\n const candidates = fields\n .filter(f => f.sqlColumnName && !f.isSensitive && f.isVisible !== false)\n .filter(f => !isNumericSqlType(f.sqlDataType) || /anahtar|key|kod|code|grup|group|tip|type|tur|tür/i.test(normalizeText([f.sqlColumnName, f.displayName, f.captionKey].join(' '))))\n .map(f => ({ field: f, score: scoreGroupField(f, qNorm) }))\n .sort((a, b) => b.score - a.score || a.field.listOrderNo - b.field.listOrderNo);\n\n const explicit = candidates.filter(x => x.score >= 20).slice(0, maxCount).map(x => x.field);\n if (explicit.length) return explicit;\n\n // Varsayılan: görünen ilk metin/kod alanı\n const fallback = fields.find(f =>\n f.sqlColumnName &&\n !f.isSensitive &&\n f.isVisible !== false &&\n !/^id$/i.test(f.sqlColumnName) &&\n !isNumericSqlType(f.sqlDataType)\n );\n\n return fallback ? [fallback] : fields.filter(f => f.sqlColumnName && !f.isSensitive).slice(0, 1);\n}\n\nfunction buildDirectSql(form, questionText) {\n if (!form || !form.tableName) return '';\n\n const baseAlias = '[T]';\n const qNorm = normalizeText(questionText);\n const resultMode = detectResultMode(questionText);\n\n const fields = (form.fields || [])\n .filter(f => f.sqlColumnName && !f.isSensitive && f.isVisible !== false)\n .sort((a, b) => a.listOrderNo - b.listOrderNo);\n\n const where = [];\n const activeField = fields.find(f => f.isActiveStatusColumn) || fields.find(f => normalizeText(f.sqlColumnName) === 'isactive');\n const deletedField = fields.find(f => f.isDeletedStatusColumn) || fields.find(f => normalizeText(f.sqlColumnName) === 'isdeleted');\n\n const asksActive = qNorm.split(' ').includes('aktif') || qNorm.split(' ').includes('active') || qNorm.split(' ').includes('etkin');\n const asksPassive = qNorm.split(' ').includes('pasif') || qNorm.split(' ').includes('inactive');\n\n if (activeField && asksActive) where.push(`${baseAlias}.${bracketIdentifier(activeField.sqlColumnName)} = 1`);\n if (activeField && asksPassive) where.push(`${baseAlias}.${bracketIdentifier(activeField.sqlColumnName)} = 0`);\n if (deletedField) where.push(`${baseAlias}.${bracketIdentifier(deletedField.sqlColumnName)} = 0`);\n\n // ÖZET / PIVOT / GROUPING: SELECT * asla kullanılmaz. GROUP BY alan + COUNT(1) döner.\n if (resultMode === 'summary' || resultMode === 'pivot') {\n const groupFields = pickGroupFields(fields, questionText, 2);\n\n if (groupFields.length > 0) {\n const groupExpressions = groupFields.map(f => `${baseAlias}.${bracketIdentifier(f.sqlColumnName)}`);\n const selectParts = groupFields.map(f => `${baseAlias}.${bracketIdentifier(f.sqlColumnName)} AS ${bracketAlias(f.displayName, f.sqlColumnName)}`);\n\n selectParts.push(`COUNT(1) AS [Adet]`);\n\n // Kullanıcı açıkça toplam isterse, ilk uygun sayısal kolonları da topla.\n const wantsTotal = qNorm.split(' ').includes('toplam') || qNorm.split(' ').includes('sum');\n if (wantsTotal) {\n const numericFields = fields\n .filter(f => isNumericSqlType(f.sqlDataType))\n .filter(f => !/^id$/i.test(f.sqlColumnName))\n .slice(0, 3);\n\n for (const nf of numericFields) {\n selectParts.push(`SUM(TRY_CONVERT(DECIMAL(38, 6), ${baseAlias}.${bracketIdentifier(nf.sqlColumnName)})) AS ${bracketAlias((nf.displayName || nf.sqlColumnName) + ' Toplam', nf.sqlColumnName + ' Toplam')}`);\n }\n }\n\n return `SELECT ${selectParts.join(', ')} FROM [${tenantName}].[dbo].${bracketIdentifier(form.tableName)} AS ${baseAlias}` +\n (where.length ? ' WHERE ' + where.join(' AND ') : '') +\n ` GROUP BY ${groupExpressions.join(', ')} ORDER BY [Adet] DESC`;\n }\n\n return `SELECT COUNT(1) AS [Adet] FROM [${tenantName}].[dbo].${bracketIdentifier(form.tableName)} AS ${baseAlias}` +\n (where.length ? ' WHERE ' + where.join(' AND ') : '');\n }\n\n let selectedFields = fields.filter(f => f.isDefaultSelectColumn);\n if (selectedFields.length === 0) selectedFields = fields.slice(0, 12);\n\n selectedFields = selectedFields\n .filter(f => !/^id$/i.test(f.sqlColumnName))\n .slice(0, Math.max(1, Math.min(Number(body.maxSelectColumns || 10), 20)));\n\n const joins = [];\n const lookupAliasByColumn = new Map();\n let joinIndex = 1;\n\n for (const field of selectedFields) {\n if (!field.hasLookup || !field.lookup?.tableName || !field.lookup?.valueColumn || !field.lookup?.displayColumn) continue;\n\n const lookupAlias = aliasFor(joinIndex++);\n lookupAliasByColumn.set(field.sqlColumnName, lookupAlias);\n\n const baseColumn = bracketIdentifier(field.sqlColumnName);\n const valueColumn = bracketIdentifier(field.lookup.valueColumn);\n const lookupTable = bracketIdentifier(field.lookup.tableName);\n\n const joinCondition = field.lookup.joinMode === 'direct-string'\n ? `${baseAlias}.${baseColumn} = ${lookupAlias}.${valueColumn}`\n : `CONVERT(NVARCHAR(100), ${baseAlias}.${baseColumn}) = CONVERT(NVARCHAR(100), ${lookupAlias}.${valueColumn})`;\n\n joins.push(`LEFT JOIN [${tenantName}].[dbo].${lookupTable} AS ${lookupAlias} ON ${joinCondition}`);\n }\n\n const selectParts = selectedFields.map(field =>\n sqlExpressionForField(baseAlias, lookupAliasByColumn.get(field.sqlColumnName) || '[L0]', field)\n );\n\n if (selectParts.length === 0) {\n selectParts.push(`${baseAlias}.${bracketIdentifier(form.primaryKeyColumn || 'Id')} AS [Id]`);\n }\n\n return `SELECT ${selectParts.join(', ')} FROM [${tenantName}].[dbo].${bracketIdentifier(form.tableName)} AS ${baseAlias}` +\n (joins.length ? ' ' + joins.join(' ') : '') +\n (where.length ? ' WHERE ' + where.join(' AND ') : '');\n}\n\n\nconst directSql = buildDirectSql(selectedForm, question);\n\nconst systemPrompt = `Sen Microsoft SQL Server T-SQL üretim motorusun.\nSadece SELECT sorgusu döndür. Açıklama, özet, yorum, JSON açıklaması, markdown veya kod bloğu yazma.\nİlk kelime kesinlikle SELECT olmalı.\nSadece verilen JSON veri sözlüğündeki form, tablo, kolon ve lookup bilgilerini kullan.\nTablo referansları [${tenantName}].[dbo].[TableName] formatında olmalı.\nSELECT * kullanma.\nINSERT, UPDATE, DELETE, MERGE, DROP, ALTER, TRUNCATE, CREATE, EXEC, EXECUTE üretme.\nLookup alanlarda kullanıcı ID istemedikçe lookup.displayColumn değerini LEFT JOIN ile göster.\nlookup.valueColumn değerini kolon adı olarak aynen kullan. \"LookupQuery:\" gibi metinler kolon adı değildir.\nlookup.joinMode direct-string ise ON [T].[baseColumn] = [Lx].[lookup.valueColumn] kullan.\nlookup.joinMode convert-nvarchar-safe ise ON CONVERT(NVARCHAR(100), [T].[baseColumn]) = CONVERT(NVARCHAR(100), [Lx].[lookup.valueColumn]) kullan.\nBoolean alanları SELECT çıktısında CASE ile Yes/No olarak göster.\nAktif/pasif filtreleri yalnızca veri sözlüğünde uygun aktiflik kolonu varsa uygulanır.\nHassas alanları döndürme.\nKullanıcı listele/getir/göster/select derse normal satır listesi üret.\nKullanıcı özetle/özet/toplam/adet/kaç/sayısı derse aggregate üret: SELECT grupKolonu, COUNT(1) AS [Adet] ... GROUP BY grupKolonu.\nKullanıcı bazında/kırılım/gruplandır/grouping/pivot derse ilgili alanı GROUP BY yap ve COUNT(1) AS [Adet] döndür.\nGROUP BY varsa SELECT * kullanmak kesinlikle yasaktır.\nGROUP BY varsa SELECT içindeki her düz kolon GROUP BY içinde de olmalı veya aggregate fonksiyonda olmalı.\nSQL Server PIVOT için değerleri bilmiyorsan PIVOT operatörü kullanma; GROUP BY ile pivotlanabilir sonuç üret.`;\n\nconst userContent = `Kullanıcı isteği: ${question}\n\nADAY_VERI_SOZLUGU_JSON:\n${JSON.stringify({ listForms: compactForms }, null, 2)}\n\nDETERMINISTIK_SQL:\n${directSql}\n\nDeterministik SQL kullanıcı isteğini karşılıyorsa aynen döndür.`;\n\nreturn [{\n json: {\n valid: true,\n question,\n tenantName,\n dictionaryMeta: {\n cacheKey: row.CacheKey,\n refreshedAt: row.RefreshedAt,\n formCount: row.FormCount,\n fieldCount: row.FieldCount,\n jsonLength: row.JsonLength\n },\n candidateFormCodes: compactForms.map(f => f.formCode),\n formScores: scored.slice(0, maxCandidateForms).map(x => ({ formCode: x.form.formCode, tableName: x.form.tableName, score: x.score })),\n selectedFormCode: selectedForm?.formCode || null,\n selectedTableName: selectedForm?.tableName || null,\n directSql,\n compactDictionary: { listForms: compactForms },\n allowedTables: [...allowedTables],\n ollamaBody: {\n model: body.sqlModel || 'qwen2.5:3b',\n stream: false,\n options: {\n temperature: 0,\n top_p: 0.05,\n num_ctx: Math.max(1024, Math.min(Number(body.numCtx || 2048), 4096)),\n num_predict: Math.max(256, Math.min(Number(body.numPredict || 600), 900)),\n stop: [';\\n\\n', 'Açıklama:', 'Explanation:', 'Not:', '```']\n },\n messages: [\n { role: 'system', content: systemPrompt },\n { role: 'user', content: userContent }\n ]\n }\n }\n}];\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2, diff --git a/configs/ai/Kurulum.txt b/configs/ai/Kurulum.txt index f0d99831..7ffcb9cf 100644 --- a/configs/ai/Kurulum.txt +++ b/configs/ai/Kurulum.txt @@ -1,3 +1,6 @@ docker exec -it ollama ollama list docker exec -it ollama ollama pull qwen2.5:3b -docker exec -it ollama ollama pull gemma3:4b \ No newline at end of file +docker exec -it ollama ollama pull gemma3:4b + +docker exec -it ollama ollama rm gemma3:4b +docker exec -it ollama ollama stop gemma3:4b diff --git a/configs/deployment/Readme_Devops.md b/configs/deployment/Readme_Devops.md index a22fd15e..abdc72cc 100644 --- a/configs/deployment/Readme_Devops.md +++ b/configs/deployment/Readme_Devops.md @@ -90,6 +90,18 @@ - sudo systemctl restart nginx - sudo nginx -t +## Basic auth kullanıcıları (/etc/nginx/.htpasswd) + + nginx.conf'ta auth_basic kullanan bloklar bu dosyaya bakar; su an tek kullanan + ollama.sozsoft.com/ollama-api/ (Ollama ham API'si). Dosya her istekte okunur, + reload gerekmez. + + - sudo apt install apache2-utils -y + - sudo cut -d: -f1 /etc/nginx/.htpasswd # tanımlı kullanıcılar + - sudo htpasswd /etc/nginx/.htpasswd # ekle / parola değiştir + + Dikkat: -c parametresi mevcut dosyayı siler, yalnızca ilk kullanıcıda kullanılır. + ## Forgejo kurulumu ayarları yapılacak - home klasöründeki `cd ~/forgejo/data/gitea/conf` klasörün altındaki app.ini dosyasına aşağıdaki satırlar eklenecek. @@ -133,9 +145,18 @@ mkdir configs - Dosya yapısı - -> sozsoft-platform->configs->deployment->configs->nginx-devops.conf + -> sozsoft-platform->configs->deployment->configs->nginx.conf + -> sozsoft-platform->configs->deployment->configs->turnserver.conf -> sozsoft-platform->configs->deployment->docker-compose-devops.yml -> sozsoft-platform->configs->deployment->Dockerfile + -> sozsoft-platform->configs->deployment->.env + + Servis konfigürasyonlarının tamamı `configs/deployment/configs/` altındadır + (nginx.conf, turnserver.conf, redis.conf, redis.7.conf, elasticsearch.yml); + compose dosyaları buradan mount eder, ayrı bir kopya tutulmaz. + + - .env içeriği (compose bu değişkeni okur, depoya girmez) + echo "WEBUI_SECRET_KEY=$(openssl rand -hex 32)" >> .env cd ~/sozsoft-platform/configs/deployment chmod 775 * @@ -153,5 +174,30 @@ - 192.168.0.2 sozsoft.com - 192.168.0.2 api.sozsoft.com - 192.168.0.2 cdn.sozsoft.com + - 192.168.0.2 ollama.sozsoft.com + +## Ollama + Open WebUI + + `ollama.sozsoft.com` kökü Open WebUI'ye (3003), `/ollama-api/` öneki Ollama'nın ham + API'sine (11434) gider. Open WebUI kendi backend'ini `/api` altında sunduğu için ham + API bu öneğe taşınmıştır: https://ollama.sozsoft.com/ollama-api/tags + + - Model: docker exec -it ollama ollama pull qwen2.5:3b + Makinede GPU yok ve 8 GB RAM var; compose `OLLAMA_MAX_LOADED_MODELS=1` ile ikinci bir + modelin belleğe alınmasını engeller. Yeni model çekmeden önce `free -h` bakılır. + + - Basic auth yalnızca /ollama-api/ bloğundadır (Ollama'nın kendi kimlik doğrulaması yok). + Sohbet arayüzünün önüne KONMAZ: Open WebUI socket.io ile WebSocket açar, tarayıcı WS + upgrade isteğine basic auth kimliğini eklemediği için bağlantı 401 döngüsüne girer ve + parola ekranı üst üste sorulur. + + - Arayüzün güvenlik sınırı uygulamanın kendi girişidir. Open WebUI ilk açılışta kaydolan + kullanıcıyı admin yapar; bu yüzden ilk hesap oluşturulur oluşturulmaz + Settings -> Admin -> General -> Enable New Sign Ups KAPATILIR. Bu adım atlanırsa ekran + internete açık kalır. Daha sıkı bir sınır gerekiyorsa vhost'a allow/deny ile IP kısıtı + eklenir; basic auth bu arayüz için seçenek değildir. + + - Gömme/erişim hattı kapalıdır (BYPASS_EMBEDDING_AND_RETRIEVAL); yüklenen dosyanın metni + doğrudan bağlama girer. Modelin num_ctx değeri 8192 yapılır. `ipconfig /flushdns` \ No newline at end of file diff --git a/configs/deployment/configs/nginx.conf b/configs/deployment/configs/nginx.conf index 6ebbd3de..bf920d03 100644 --- a/configs/deployment/configs/nginx.conf +++ b/configs/deployment/configs/nginx.conf @@ -90,6 +90,9 @@ server { proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_set_header __tenant $http___tenant; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; proxy_read_timeout 3600; proxy_send_timeout 3600; } @@ -101,6 +104,9 @@ server { proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_set_header __tenant $http___tenant; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; proxy_read_timeout 3600; proxy_send_timeout 3600; } @@ -113,6 +119,9 @@ server { proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header __tenant $http___tenant; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Connection ""; proxy_buffering off; proxy_cache off; @@ -155,13 +164,17 @@ server { ssl_trusted_certificate /etc/ssl/sozsoft.com/chain1.pem; ssl_certificate_key /etc/letsencrypt/live/ollama.sozsoft.com/privkey.pem; - auth_basic "Restricted"; - auth_basic_user_file /etc/nginx/.htpasswd; - client_max_body_size 512M; - location / { - proxy_pass http://127.0.0.1:11434; + # Ollama ham API'sinin kendi kimlik dogrulamasi yoktur, bu yuzden basic auth arkasindadir. + # Open WebUI kendi backend'ini de /api altinda sunar; iki uc ayni onekte yasayamaz, + # o yuzden ham API /ollama-api/ altina alindi: + # https://ollama.sozsoft.com/ollama-api/tags -> http://127.0.0.1:11434/api/tags + location /ollama-api/ { + auth_basic "Restricted"; + auth_basic_user_file /etc/nginx/.htpasswd; + + proxy_pass http://127.0.0.1:11434/api/; proxy_http_version 1.1; proxy_set_header Host 127.0.0.1:11434; @@ -176,6 +189,29 @@ server { proxy_connect_timeout 3600; proxy_send_timeout 3600; } + + # Sohbet arayuzu (Open WebUI). Buraya basic auth konmaz: uygulama socket.io uzerinden + # WebSocket acar, tarayici WS upgrade istegine basic auth kimligini eklemez ve baglanti + # 401 dongusune girer. Guvenlik siniri uygulamanin kendi girisidir; kurulumdan hemen + # sonra Settings -> Admin -> General -> Enable New Sign Ups kapatilir. + location / { + proxy_pass http://127.0.0.1:3003; + + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Yanit CPU uzerinde token token uretilir; tamponlama akisi durdurur. + proxy_buffering off; + + proxy_read_timeout 3600; + proxy_connect_timeout 3600; + proxy_send_timeout 3600; + } } # dashboard.sozsoft.com diff --git a/configs/deployment/docker-compose-app.yml b/configs/deployment/docker-compose-app.yml index 8873b1f0..4f6e575f 100644 --- a/configs/deployment/docker-compose-app.yml +++ b/configs/deployment/docker-compose-app.yml @@ -37,6 +37,12 @@ services: environment: - ASPNETCORE_ENVIRONMENT=Dev - DOTNET_ENVIRONMENT=Dev + # Istek ters vekilden HTTP olarak gelir. Bu anahtar olmadan Request.Scheme "http" + # kalir ve OpenIddict kesif belgesindeki authorization_endpoint/token_endpoint + # adreslerini http:// olarak uretir; OAuth 2.1 istemcileri (MCP connector'lari) + # TLS'siz uc kabul etmedigi icin baglanti kurulamaz. nginx bu basliklari her + # location blogunda set eder, istemciden gelen deger her zaman ezilir. + - ASPNETCORE_FORWARDEDHEADERS_ENABLED=true ports: - 8080:8080 volumes: diff --git a/configs/deployment/docker-compose-devops.yml b/configs/deployment/docker-compose-devops.yml index 3613dedc..844be0d4 100644 --- a/configs/deployment/docker-compose-devops.yml +++ b/configs/deployment/docker-compose-devops.yml @@ -19,6 +19,7 @@ volumes: driver: local n8n_data: ollama_data: + open_webui_data: # netdataconfig: # netdatalib: # netdatacache: @@ -113,9 +114,45 @@ services: - ollama_data:/root/.ollama environment: - OLLAMA_HOST=0.0.0.0 + # 8 GB RAM, GPU yok: ikinci bir model bellege alinirsa kutu takasa duser. + - OLLAMA_MAX_LOADED_MODELS=1 + - OLLAMA_NUM_PARALLEL=1 + - OLLAMA_KEEP_ALIVE=5m networks: - ollama - n8n + + # ollama.sozsoft.com sohbet arayuzu. Ters vekilde basic auth ARKASINDA DEGILDIR + # (WebSocket'i kirar, bkz. nginx.conf); guvenlik siniri uygulamanin kendi girisidir. + # Gomme/erisim hatti kapali: varsayilan RAG sentence-transformers indirir, bu kutuda + # ne disk ne bellek butcesi var. + open-webui: + image: ghcr.io/open-webui/open-webui:main + container_name: open-webui + restart: always + ports: + - 127.0.0.1:3003:8080 + environment: + - OLLAMA_BASE_URL=http://ollama:11434 + - WEBUI_URL=https://ollama.sozsoft.com + - WEBUI_SECRET_KEY=${WEBUI_SECRET_KEY} + # Ilk kaydolan kullanici admin olur; sonrakiler onaya duser ve onaylanana kadar hicbir + # sey yapamaz. Ilk hesap olusturuldugunda Settings -> Admin -> General uzerinden yeni + # kayit alimi da kapatilir. Bu iki deger PersistentConfig'tir: yalnizca ILK acilista + # env'den okunur, sonrasinda veritabanindaki deger gecerlidir. + - DEFAULT_USER_ROLE=pending + - BYPASS_EMBEDDING_AND_RETRIEVAL=true + - ENABLE_IMAGE_GENERATION=false + - WHISPER_MODEL_AUTO_UPDATE=false + - HF_HUB_OFFLINE=1 + - GENERIC_TIMEZONE=Europe/Istanbul + volumes: + - open_webui_data:/app/backend/data + mem_limit: 2g + depends_on: + - ollama + networks: + - ollama coturn: image: coturn/coturn:latest # ✅ Güncel resmi image diff --git a/configs/deployment/scripts/1-devops.sh b/configs/deployment/scripts/1-devops.sh index f986f51c..2c0b3d03 100644 --- a/configs/deployment/scripts/1-devops.sh +++ b/configs/deployment/scripts/1-devops.sh @@ -3,5 +3,4 @@ cd ~/sozsoft-platform/configs/deployment docker compose -f docker-compose-devops.yml down docker compose -f docker-compose-devops.yml up -d -docker exec -it ollama ollama pull qwen2.5:3b -docker exec -it ollama ollama pull qwen2.5-coder:1.5b \ No newline at end of file +docker exec -it ollama ollama pull qwen2.5:3b \ No newline at end of file