489 lines
No EOL
53 KiB
JSON
489 lines
No EOL
53 KiB
JSON
{
|
||
"name": "Chat",
|
||
"nodes": [
|
||
{
|
||
"parameters": {
|
||
"multipleMethods": true,
|
||
"httpMethod": [
|
||
"POST"
|
||
],
|
||
"path": "chat",
|
||
"responseMode": "lastNode",
|
||
"responseData": "allEntries",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.webhook",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-39376,
|
||
-3072
|
||
],
|
||
"id": "46b4fe58-58c7-4344-a6fb-c099caab6651",
|
||
"name": "Webhook",
|
||
"webhookId": "b0b9c7f7-9eab-4227-8673-85e604bab2d2"
|
||
},
|
||
{
|
||
"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"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-39168,
|
||
-3072
|
||
],
|
||
"id": "f5ab8edd-fd83-4e5c-94cc-6c58ff80bad6",
|
||
"name": "Build Intent Request"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "POST",
|
||
"url": "http://ollama:11434/api/chat",
|
||
"sendBody": true,
|
||
"specifyBody": "json",
|
||
"jsonBody": "={{ $json.ollamaBody }}",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.2,
|
||
"position": [
|
||
-38944,
|
||
-3072
|
||
],
|
||
"id": "d9a8bd55-350e-40dd-b145-2f70aaf76f79",
|
||
"name": "Intent Ollama"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const res = $input.first().json || {};\nconst body = $('Webhook').first().json.body || {};\nconst content = String(res.message?.content || res.response || res.output || '').toUpperCase();\nconst question = String(body.question || body.message || '').trim();\nconst tenantName = String(body.tenantName || 'Sozsoft').trim();\n\nfunction normalizeText(value) {\n return String(value || '')\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\nconst q = normalizeText(question);\nconst tokens = new Set(q.split(' ').filter(Boolean));\n\n// LLM yanlışlıkla CHAT dese bile bu kelimeler veri sorgusu niyetidir.\nconst sqlActionWords = [\n 'listele','liste','listesi','getir','goster','ara','bul','sorgula','filtrele','raporla','rapor',\n 'say','sayisi','kac','adet','toplam','ozet','ozetle','sum','count',\n 'grupla','gruplandir','gruplama','bazinda','kirilim','pivot','group','grouping',\n 'select','sql'\n];\n\nconst dataObjectWords = [\n 'tanim','tanimlama','tanimlamalari','ayar','ayarlari','kayit','kayitlari','tablo','kolon',\n 'musteri','siparis','stok','urun','firma','depo','fatura','personel','kumas','iplik'\n];\n\nconst hasSqlAction = sqlActionWords.some(w => tokens.has(w) || q.includes(w));\nconst hasDataObject = dataObjectWords.some(w => tokens.has(w) || q.includes(w));\nconst forceSql = body.forceSql === true || body.intent === 'SQL' || body.type === 'sql';\n\nlet intent;\nif (forceSql || hasSqlAction || (hasDataObject && /\\b(ana|grup|anahtar|key|bazinda|toplam|ozet|adet|sayisi)\\b/.test(q))) {\n intent = 'SQL';\n} else {\n intent = content.includes('SQL') ? 'SQL' : 'CHAT';\n}\n\nreturn [{ json: { question, tenantName, intent, intentRaw: res, intentByRule: intent === 'SQL' && !content.includes('SQL') } }];\n"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-38720,
|
||
-3072
|
||
],
|
||
"id": "27993516-1b97-422e-b026-162afb4fb99b",
|
||
"name": "Normalize Intent"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 1
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "89f40b61-7147-4055-8ffb-5e24ad9f7879",
|
||
"leftValue": "={{ $json.intent }}",
|
||
"rightValue": "SQL",
|
||
"operator": {
|
||
"type": "string",
|
||
"operation": "equals"
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-38496,
|
||
-3072
|
||
],
|
||
"id": "2b7ee598-6977-4f0a-921c-11411576b1a9",
|
||
"name": "Is SQL?"
|
||
},
|
||
{
|
||
"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"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-38288,
|
||
-2832
|
||
],
|
||
"id": "f5fa30e8-2934-4bf7-a2ae-96dc98acb64a",
|
||
"name": "Build Chat Request"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "POST",
|
||
"url": "http://ollama:11434/api/chat",
|
||
"sendBody": true,
|
||
"specifyBody": "json",
|
||
"jsonBody": "={{ $json.ollamaBody }}",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.2,
|
||
"position": [
|
||
-38064,
|
||
-2832
|
||
],
|
||
"id": "e68d00f6-fe8d-4f6a-8170-fce9cdd14f0a",
|
||
"name": "Chat Ollama"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const res=$input.first().json||{}; return [{json:{type:'chat', question:$('Webhook').first().json.body?.question || '', answer:res.message?.content || res.response || res.output || ''}}];"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-37840,
|
||
-2832
|
||
],
|
||
"id": "7cde5a7e-e0f2-497c-a38a-62de4084241d",
|
||
"name": "Chat Response"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const body = $('Webhook').first().json.body || {};\n\nconst question = String(body.question || body.message || '').trim();\nconst tenantName = String(body.tenantName || 'Sozsoft').trim();\nconst definitionCultureName = body.definitionCultureName || 'en';\nconst uiCultureName = body.uiCultureName || 'tr';\nconst listFormCode = body.listFormCode || null;\nconst onlyBaseDefinitions = body.onlyBaseDefinitions === false ? 0 : 1;\nconst resourceName = body.resourceName || null;\nconst maxForms = Number(body.maxForms || 500);\n\nfunction q(v) {\n if (v === null || v === undefined || v === '') return 'NULL';\n return \"N'\" + String(v).replace(/'/g, \"''\") + \"'\";\n}\n\nconst safeMaxForms = Number.isFinite(maxForms) && maxForms > 0 ? Math.min(maxForms, 2000) : 500;\n\n// v23: Sadece dbo.Sas_H_DbTableColumn tablosundan okur.\nconst dictionaryQuery = `\nSELECT TOP (1)\n JsonData AS ListFormDataDictionaryJson,\n CacheKey,\n RefreshedAt,\n FormCount,\n FieldCount,\n JsonLength\nFROM dbo.Sas_H_DbTableColumn WITH (NOLOCK)\nWHERE DictionaryType = N'ListFormJson'\n AND ISNULL(IsActive, 0) = 1\n AND DefinitionCultureName = ${q(definitionCultureName)}\n AND UiCultureName = ${q(uiCultureName)}\n AND ISNULL(ListFormCode, N'') = ISNULL(${q(listFormCode)}, N'')\n AND ISNULL(OnlyBaseDefinitions, 1) = ${onlyBaseDefinitions}\n AND ISNULL(ResourceName, N'') = ISNULL(${q(resourceName)}, N'')\n AND ISNULL(MaxForms, 500) = ${safeMaxForms}\nORDER BY RefreshedAt DESC;\n`;\n\nreturn [{\n json: {\n question,\n tenantName,\n definitionCultureName,\n uiCultureName,\n listFormCode,\n onlyBaseDefinitions,\n resourceName,\n maxForms: safeMaxForms,\n dictionaryQuery\n }\n}];"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-38288,
|
||
-3248
|
||
],
|
||
"id": "326e9983-14e0-490e-bcb8-4852bbca1d49",
|
||
"name": "Build Dictionary Query"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"operation": "executeQuery",
|
||
"query": "={{ $json.dictionaryQuery }}"
|
||
},
|
||
"type": "n8n-nodes-base.microsoftSql",
|
||
"typeVersion": 1.1,
|
||
"position": [
|
||
-38048,
|
||
-3248
|
||
],
|
||
"id": "986bc23a-44ac-48e6-bd68-96c70b48e7dc",
|
||
"name": "Get Dictionary JSON",
|
||
"credentials": {
|
||
"microsoftSql": {
|
||
"id": "FV4335uaFWUGPlIf",
|
||
"name": "Microsoft SQL account"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"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 <valueExpr> AS Key, <displayExpr> AS Name FROM <tableName> ...\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"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-37808,
|
||
-3248
|
||
],
|
||
"id": "61007e7c-6f51-4572-ad61-13d9f0bffa4c",
|
||
"name": "Build SQL Request"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "POST",
|
||
"url": "http://ollama:11434/api/chat",
|
||
"sendBody": true,
|
||
"specifyBody": "json",
|
||
"jsonBody": "={{ $json.ollamaBody }}",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.2,
|
||
"position": [
|
||
-37568,
|
||
-3248
|
||
],
|
||
"id": "2b6630c2-f520-472a-8733-06e362264849",
|
||
"name": "SQL Ollama"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const res = $input.first().json || {};\nconst build = $('Build SQL Request').first().json || {};\nconst question = build.question || $('Webhook').first().json.body?.question || '';\nconst tenantName = String(build.tenantName || $('Webhook').first().json.body?.tenantName || 'Sozsoft').trim();\n\nfunction escapeRegExp(s) {\n return String(s || '').replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n}\n\nfunction cleanName(v) {\n let s = String(v || '')\n .replace(/\\\\u0022/g, '')\n .replace(/\\u0022/g, '')\n .replace(/"/gi, '')\n .replace(/LookupQuery\\s*:/gi, '')\n .replace(/[\"`\\[\\]\\\\]/g, '')\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 return parts.length ? parts[parts.length - 1] : s;\n}\n\nconst allowedTables = new Set((build.allowedTables || []).map(cleanName).filter(Boolean));\n\nfunction getOllamaText(obj) {\n return String(\n obj.message?.content ||\n obj.response ||\n obj.output ||\n obj.text ||\n obj.content ||\n ''\n ).trim();\n}\n\nconst raw = getOllamaText(res);\n\nfunction cleanSql(text) {\n let t = String(text || '')\n .replace(/```sql/gi, '')\n .replace(/```/g, '')\n .replace(/^SQL\\s*:/i, '')\n .replace(/\\\\u0022/g, '')\n .replace(/\\u0022/g, '')\n .replace(/"/gi, '')\n .replace(/\"/g, '')\n .trim();\n\n const idx = t.search(/\\bSELECT\\b/i);\n if (idx < 0) return '';\n\n t = t.substring(idx).trim();\n const semi = t.indexOf(';');\n if (semi >= 0) t = t.substring(0, semi).trim();\n return t.replace(/\\s+/g, ' ').trim();\n}\n\nfunction canonicalizeSql(sql) {\n let q = String(sql || '')\n .replace(/\\\\u0022/g, '')\n .replace(/\\u0022/g, '')\n .replace(/"/gi, '')\n .replace(/\\[LookupQuery:\\s*([^\\]]+)\\]/gi, '[$1]');\n\n const db = escapeRegExp(tenantName);\n\n // [Sozsoft].[Sozsoft].[dbo].[X] -> [Sozsoft].[dbo].[X]\n q = q.replace(new RegExp('\\\\[' + db + '\\\\]\\\\.\\\\[' + db + '\\\\]\\\\.\\\\[dbo\\\\]\\\\.', 'gi'), '[' + tenantName + '].[dbo].');\n q = q.replace(new RegExp(db + '\\\\.' + db + '\\\\.dbo\\\\.', 'gi'), '[' + tenantName + '].[dbo].');\n\n // [dbo].[X] -> [Sozsoft].[dbo].[X], eğer önünde zaten [Sozsoft]. yoksa\n q = q.replace(/\\[dbo\\]\\.\\[([^\\]]+)\\]/gi, (m, t, off, str) => {\n const before = str.slice(Math.max(0, off - tenantName.length - 4), off);\n const alreadyDb = new RegExp('\\\\[' + db + '\\\\]\\\\.$', 'i').test(before);\n if (alreadyDb) return m;\n return '[' + tenantName + '].[dbo].[' + cleanName(t) + ']';\n });\n\n // DİKKAT: Köşeli parantez içindeki her şeyi temizleme.\n // Aliaslar [Adı], [Üst Adı], [Çalışma Saati] gibi Türkçe/boşluklu olabilir.\n // Global bracket temizliği aliasları [] haline getirir.\n return q.replace(/\\s+/g, ' ').trim();\n}\n\nfunction qualifyTables(sql) {\n let q = canonicalizeSql(sql);\n const tables = Array.from(allowedTables).sort((a, b) => b.length - a.length);\n\n for (const table of tables) {\n const t = escapeRegExp(table);\n\n // FROM dbo.Table / JOIN dbo.Table\n q = q.replace(new RegExp('\\\\b(FROM|JOIN)\\\\s+dbo\\\\.\\\\[?' + t + '\\\\]?', 'gi'), '$1 [' + tenantName + '].[dbo].[' + table + ']');\n\n // FROM [dbo].[Table] / JOIN [dbo].[Table]\n q = q.replace(new RegExp('\\\\b(FROM|JOIN)\\\\s+\\\\[dbo\\\\]\\\\.\\\\[' + t + '\\\\]', 'gi'), '$1 [' + tenantName + '].[dbo].[' + table + ']');\n\n // FROM Table / JOIN Table veya FROM [Table] / JOIN [Table]\n q = q.replace(new RegExp('\\\\b(FROM|JOIN)\\\\s+\\\\[?' + t + '\\\\]?(?=\\\\s|$)', 'gi'), '$1 [' + tenantName + '].[dbo].[' + table + ']');\n }\n\n return canonicalizeSql(q);\n}\n\nfunction extractTables(sql) {\n const tables = [];\n const re = /\\b(?:FROM|JOIN)\\s+((?:\\[[^\\]]+\\]\\.){0,3}\\[[^\\]]+\\]|(?:[A-Za-z0-9_]+\\.){0,3}[A-Za-z0-9_]+)/gi;\n let m;\n while ((m = re.exec(sql)) !== null) {\n const parts = String(m[1]).split('.');\n tables.push(cleanName(parts[parts.length - 1]));\n }\n return tables;\n}\n\n\nfunction splitSqlListPreserve(text) {\n const s = String(text || '');\n const parts = [];\n let cur = '';\n let depth = 0;\n let bracket = 0;\n let quote = null;\n\n for (let i = 0; i < s.length; i++) {\n const ch = s[i];\n const next = s[i + 1];\n\n if (quote) {\n cur += ch;\n if (ch === quote) {\n if (next === quote) {\n cur += next;\n i++;\n } else {\n quote = null;\n }\n }\n continue;\n }\n\n if (ch === \"'\" || ch === '\"') {\n quote = ch;\n cur += ch;\n continue;\n }\n\n if (ch === '[') bracket++;\n if (ch === ']') bracket = Math.max(0, bracket - 1);\n if (ch === '(') depth++;\n if (ch === ')') depth = Math.max(0, depth - 1);\n\n if (ch === ',' && depth === 0 && bracket === 0) {\n parts.push(cur.trim());\n cur = '';\n continue;\n }\n\n cur += ch;\n }\n\n if (cur.trim()) parts.push(cur.trim());\n return parts;\n}\n\nfunction aliasFromGroupExpression(expr) {\n let s = String(expr || '').trim();\n s = s.replace(/\\s+ASC$|\\s+DESC$/i, '').trim();\n const parts = s.split('.');\n s = parts[parts.length - 1] || s;\n s = cleanName(s) || 'Grup';\n return '[' + s.replace(/]/g, ']]') + ']';\n}\n\nfunction repairGroupByStar(sql) {\n let q = String(sql || '').trim();\n\n if (!/\\bGROUP\\s+BY\\b/i.test(q)) return q;\n if (!/^SELECT\\s+\\*/i.test(q)) return q;\n\n const m = q.match(/^SELECT\\s+\\*\\s+FROM\\s+(.+?)\\s+GROUP\\s+BY\\s+(.+?)(?:\\s+ORDER\\s+BY\\s+.+)?$/i);\n if (!m) return q;\n\n const fromPart = m[1].trim();\n const groupPart = m[2].trim();\n const groupExprs = splitSqlListPreserve(groupPart);\n\n if (!groupExprs.length) return q;\n\n const selectParts = groupExprs.map(expr => `${expr} AS ${aliasFromGroupExpression(expr)}`);\n selectParts.push('COUNT(1) AS [Adet]');\n\n return `SELECT ${selectParts.join(', ')} FROM ${fromPart} GROUP BY ${groupExprs.join(', ')} ORDER BY [Adet] DESC`;\n}\n\nfunction validateGroupByRules(sql) {\n if (!/\\bGROUP\\s+BY\\b/i.test(sql)) return { ok: true };\n\n if (/^SELECT\\s+\\*/i.test(sql)) {\n return { ok: false, error: 'GROUP BY ile SELECT * kullanılamaz.' };\n }\n\n return { ok: true };\n}\n\n\nfunction validateCandidate(sqlText, sourceName) {\n let sql = cleanSql(sqlText);\n if (!sql) return { ok: false, error: 'SELECT bulunamadı' };\n if (!/^SELECT\\b/i.test(sql)) return { ok: false, error: 'Üretilen çıktı SELECT ile başlamıyor.' };\n\n const forbidden = /\\b(INSERT|UPDATE|DELETE|MERGE|DROP|ALTER|TRUNCATE|CREATE|EXEC|EXECUTE|GRANT|REVOKE|BACKUP|RESTORE)\\b/i;\n if (forbidden.test(sql)) {\n return { ok: false, error: 'Güvenlik nedeniyle sadece SELECT sorgularına izin veriliyor.' };\n }\n\n sql = qualifyTables(sql);\n sql = cleanSql(sql);\n\n sql = repairGroupByStar(sql);\n sql = qualifyTables(sql);\n sql = cleanSql(sql);\n\n const groupCheck = validateGroupByRules(sql);\n if (!groupCheck.ok) {\n return { ok: false, error: groupCheck.error, cleanedText: sql };\n }\n\n if (/LookupQuery\\s*:/i.test(sql)) {\n return { ok: false, error: 'SQL içinde hatalı LookupQuery kolon formatı var.', cleanedText: sql };\n }\n\n const doubleDbRe = new RegExp('\\\\[' + escapeRegExp(tenantName) + '\\\\]\\\\.\\\\[' + escapeRegExp(tenantName) + '\\\\]\\\\.\\\\[dbo\\\\]', 'i');\n if (doubleDbRe.test(sql)) {\n return { ok: false, error: 'SQL içinde çift database formatı var.', cleanedText: sql };\n }\n\n const usedTables = extractTables(sql);\n const unknown = usedTables.filter(t => !allowedTables.has(cleanName(t)));\n if (unknown.length) {\n return { ok: false, error: 'Veri sözlüğünde olmayan tablo kullanıldı.', unknownTables: unknown, usedTables, cleanedText: sql };\n }\n\n return { ok: true, sql, usedTables, sourceName };\n}\n\n// Önemli: Model bazen doğru SQL'i yorumluyor/eleştiriyor. Bu yüzden önce deterministik directSql kullanılır.\nlet candidate = build.directSql\n ? validateCandidate(build.directSql, 'directSql')\n : { ok: false, error: 'directSql yok' };\n\n// directSql yoksa veya geçersizse Ollama çıktısı denenir.\nif (!candidate.ok) {\n candidate = validateCandidate(raw, 'ollama');\n}\n\nif (!candidate.ok) {\n return [{\n json: {\n valid: false,\n type: 'sql_error',\n question,\n error: candidate.error || 'SQL doğrulanamadı.',\n unknownTables: candidate.unknownTables || null,\n allowedTables: Array.from(allowedTables),\n generatedText: raw,\n cleanedText: candidate.cleanedText || null,\n directSql: build.directSql || null,\n selectedFormCode: build.selectedFormCode,\n candidateFormCodes: build.candidateFormCodes\n }\n }];\n}\n\nreturn [{\n json: {\n valid: true,\n type: 'sql',\n question,\n tenantName,\n sql: candidate.sql,\n sqlSource: candidate.sourceName,\n generatedText: raw,\n directSql: build.directSql,\n usedTables: candidate.usedTables,\n dictionaryMeta: build.dictionaryMeta,\n candidateFormCodes: build.candidateFormCodes,\n selectedFormCode: build.selectedFormCode\n }\n}];\n"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-37328,
|
||
-3248
|
||
],
|
||
"id": "2a9ba43b-f687-4c27-a00b-841d912cc9a1",
|
||
"name": "Validate SQL"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 1
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "c6195621-ef9a-4298-9317-ae22e6c86b92",
|
||
"leftValue": "={{ $json.valid }}",
|
||
"rightValue": true,
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-37104,
|
||
-3248
|
||
],
|
||
"id": "4445ba0d-c494-4389-be21-1b2468fade63",
|
||
"name": "SQL Valid?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"operation": "executeQuery",
|
||
"query": "={{ $json.sql }}"
|
||
},
|
||
"type": "n8n-nodes-base.microsoftSql",
|
||
"typeVersion": 1.1,
|
||
"position": [
|
||
-36880,
|
||
-3344
|
||
],
|
||
"id": "ec109600-5013-4b38-aeee-be8f2b654864",
|
||
"name": "Execute Generated SQL",
|
||
"credentials": {
|
||
"microsoftSql": {
|
||
"id": "FV4335uaFWUGPlIf",
|
||
"name": "Microsoft SQL account"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "\nconst rows = $input.all().map(item => item.json);\nconst v = $('Validate SQL').first().json || {};\nconst question = String(v.question || '').toLocaleLowerCase('tr-TR');\n\nfunction normalizeText(value) {\n return String(value || '')\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 isNumberValue(value) {\n if (value === null || value === undefined || value === '') return false;\n if (typeof value === 'number') return Number.isFinite(value);\n return !isNaN(Number(String(value).replace(',', '.')));\n}\n\nfunction toNumber(value) {\n if (typeof value === 'number') return value;\n return Number(String(value).replace(',', '.'));\n}\n\nconst q = normalizeText(question);\nconst columns = rows.length > 0 ? Object.keys(rows[0]) : [];\n\nconst wantsPivot =\n q.includes('pivot') ||\n q.includes('bazinda') ||\n q.includes('kirilim') ||\n q.includes('gruplandir') ||\n q.includes('gruplama') ||\n q.includes('grouping') ||\n q.includes('group');\n\nconst wantsSummary =\n wantsPivot ||\n q.includes('ozet') ||\n q.includes('summarize') ||\n q.includes('summary') ||\n q.includes('toplam') ||\n q.includes('ortalama') ||\n q.includes('adet') ||\n q.includes('kac') ||\n q.includes('sayisi');\n\nif (!rows.length) {\n return [{\n json: {\n type: 'sql_result',\n mode: wantsPivot ? 'pivot' : wantsSummary ? 'summary' : 'list',\n question: v.question,\n sql: v.sql,\n rowCount: 0,\n columns,\n rows: [],\n debug: { sqlSource: v.sqlSource, usedTables: v.usedTables, candidateFormCodes: v.candidateFormCodes, dictionaryMeta: v.dictionaryMeta }\n }\n }];\n}\n\nconst numericColumns = columns.filter(col => rows.some(r => isNumberValue(r[col])));\nconst dimensionColumns = columns.filter(col => !numericColumns.includes(col));\n\nif (wantsSummary && !wantsPivot) {\n const summary = {\n totalRows: rows.length\n };\n\n for (const col of numericColumns) {\n const values = rows.map(r => toNumber(r[col])).filter(n => Number.isFinite(n));\n summary[col] = {\n toplam: values.reduce((a, b) => a + b, 0),\n ortalama: values.length ? values.reduce((a, b) => a + b, 0) / values.length : 0,\n min: values.length ? Math.min(...values) : null,\n max: values.length ? Math.max(...values) : null\n };\n }\n\n return [{\n json: {\n type: 'sql_result',\n mode: 'summary',\n question: v.question,\n sql: v.sql,\n rowCount: rows.length,\n columns,\n summary,\n rows,\n debug: { sqlSource: v.sqlSource, usedTables: v.usedTables, candidateFormCodes: v.candidateFormCodes, dictionaryMeta: v.dictionaryMeta }\n }\n }];\n}\n\nif (wantsPivot) {\n // SQL zaten GROUP BY ile döndüyse doğrudan pivotlanabilir tablo gibi aktar.\n const hasAggregateColumn = columns.some(c => ['adet', 'count', 'total', 'toplam'].includes(normalizeText(c)));\n if (hasAggregateColumn) {\n return [{\n json: {\n type: 'sql_result',\n mode: 'pivot',\n question: v.question,\n sql: v.sql,\n rowCount: rows.length,\n columns,\n rows,\n debug: { sqlSource: v.sqlSource, usedTables: v.usedTables, candidateFormCodes: v.candidateFormCodes, dictionaryMeta: v.dictionaryMeta }\n }\n }];\n }\n\n const groupColumn = dimensionColumns[0] || columns[0];\n const valueColumn = numericColumns[0] || null;\n const map = {};\n\n for (const row of rows) {\n const key = row[groupColumn] ?? 'Boş';\n if (!map[key]) {\n map[key] = { [groupColumn]: key, Adet: 0 };\n if (valueColumn) map[key][`${valueColumn}_Toplam`] = 0;\n }\n\n map[key].Adet++;\n\n if (valueColumn && isNumberValue(row[valueColumn])) {\n map[key][`${valueColumn}_Toplam`] += toNumber(row[valueColumn]);\n }\n }\n\n const pivotRows = Object.values(map).sort((a, b) => (b.Adet || 0) - (a.Adet || 0));\n\n return [{\n json: {\n type: 'sql_result',\n mode: 'pivot',\n question: v.question,\n sql: v.sql,\n groupColumn,\n valueColumn,\n rowCount: pivotRows.length,\n columns: Object.keys(pivotRows[0] || {}),\n rows: pivotRows,\n debug: { sqlSource: v.sqlSource, usedTables: v.usedTables, candidateFormCodes: v.candidateFormCodes, dictionaryMeta: v.dictionaryMeta }\n }\n }];\n}\n\nreturn [{\n json: {\n type: 'sql_result',\n mode: 'list',\n question: v.question,\n sql: v.sql,\n rowCount: rows.length,\n columns,\n rows,\n debug: { sqlSource: v.sqlSource, usedTables: v.usedTables, candidateFormCodes: v.candidateFormCodes, dictionaryMeta: v.dictionaryMeta }\n }\n}];\n"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-36640,
|
||
-3344
|
||
],
|
||
"id": "4797a41c-acb6-4a8b-b66c-e3cebc230dae",
|
||
"name": "Format Smart SQL Result"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "\nconst e = $input.first().json || {};\nreturn [{ json: { type: e.type || 'sql_error', question: e.question || $('Webhook').first().json.body?.question || '', error: e.error || 'SQL üretilemedi.', generatedText: e.generatedText || null, cleanedText: e.cleanedText || null, unknownTables: e.unknownTables || null, allowedTables: e.allowedTables || null } }];\n"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-36880,
|
||
-3104
|
||
],
|
||
"id": "ce66daea-a390-43ae-bd7d-0a00d80c86be",
|
||
"name": "SQL Error Response"
|
||
}
|
||
],
|
||
"pinData": {},
|
||
"connections": {
|
||
"Webhook": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Build Intent Request",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Build Intent Request": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Intent Ollama",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Intent Ollama": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Normalize Intent",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Normalize Intent": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Is SQL?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Is SQL?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Build Dictionary Query",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Build Chat Request",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Build Chat Request": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Chat Ollama",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Chat Ollama": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Chat Response",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Build Dictionary Query": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Get Dictionary JSON",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Get Dictionary JSON": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Build SQL Request",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Build SQL Request": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "SQL Ollama",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"SQL Ollama": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Validate SQL",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Validate SQL": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "SQL Valid?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"SQL Valid?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Execute Generated SQL",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "SQL Error Response",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Execute Generated SQL": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Format Smart SQL Result",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
}
|
||
},
|
||
"active": true,
|
||
"settings": {
|
||
"executionOrder": "v1",
|
||
"availableInMCP": false,
|
||
"timeSavedMode": "fixed",
|
||
"callerPolicy": "workflowsFromSameOwner"
|
||
},
|
||
"versionId": "39108360-6275-4878-8546-69b1baa0c871",
|
||
"meta": {
|
||
"templateCredsSetupCompleted": true,
|
||
"instanceId": "1d288821beaaeeada5e8dce6f282c802098a0c83ef6ddb35a174b09a9d43850e"
|
||
},
|
||
"id": "MgXO29wLFA4urmNk",
|
||
"tags": []
|
||
} |