{ "name": "Chat_SQL_DbTableColumns_TableAgent_v29_AliasFix_FullyDynamic", "nodes": [ { "parameters": { "multipleMethods": true, "httpMethod": [ "POST" ], "path": "chat", "responseMode": "lastNode", "responseData": "allEntries", "options": {} }, "type": "n8n-nodes-base.webhook", "typeVersion": 2, "position": [ -32912, -3184 ], "id": "18507856-9247-491e-8d4a-9159419d252e", "name": "Webhook", "webhookId": "b0b9c7f7-9eab-4227-8673-85e604bab2d2" }, { "parameters": { "jsCode": "\nconst 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_predict: 12 },\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, listele/getir/say/toplam/raporla/sorgula/ara/filtrele gibi amaç 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": [ -32704, -3184 ], "id": "6067108d-b5dd-48df-9500-70416053db9f", "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": [ -32480, -3184 ], "id": "3baceaba-e945-47cd-b8c9-6561125f260d", "name": "Intent Ollama" }, { "parameters": { "jsCode": "\nconst 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();\nconst intent = content.includes('SQL') ? 'SQL' : 'CHAT';\nreturn [{ json: { question, tenantName, intent, intentRaw: res } }];\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ -32256, -3184 ], "id": "d4e9ab81-138e-4acc-a31d-e18be333d9ec", "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": [ -32032, -3184 ], "id": "0ae28c72-73f3-4851-b149-d4a2913418a9", "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 || 'qwen2.5:3b',\n stream: false,\n options: { temperature: 0.2, top_p: 0.8, num_predict: 512 },\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": [ -31824, -2944 ], "id": "6528b16c-51e1-4d19-9806-6d2f790ae6c2", "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": [ -31600, -2944 ], "id": "c5334fb9-eded-4903-bc2f-6454fd800e2a", "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": [ -31376, -2944 ], "id": "468e3ad7-1c0e-45db-bfd0-63b391247393", "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": [ -31824, -3360 ], "id": "d96b157a-de5e-4cc3-ba7d-ef2030ca7755", "name": "Build Dictionary Query" }, { "parameters": { "operation": "executeQuery", "query": "={{ $json.dictionaryQuery }}" }, "type": "n8n-nodes-base.microsoftSql", "typeVersion": 1.1, "position": [ -31584, -3360 ], "id": "70d35887-291c-41e5-acac-e5c5c60e0196", "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 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', 'rapor'\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 || 8), 25));\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\nfunction buildDirectSql(form, questionText) {\n if (!form || !form.tableName) return '';\n\n const baseAlias = '[T]';\n const qNorm = normalizeText(questionText);\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 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 || 14), 30)));\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 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 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\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.`;\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-coder:1.5b',\n stream: false,\n options: {\n temperature: 0,\n top_p: 0.05,\n num_predict: Math.max(80, Math.min(Number(body.numPredict || 220), 500)),\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": [ -31344, -3360 ], "id": "7b44191c-172d-464b-ba11-bdd5728d25fc", "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": [ -31104, -3360 ], "id": "4ca40c6f-3605-4e7c-8beb-2d25784094d8", "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\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 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": [ -30864, -3360 ], "id": "8d752e56-0e49-4bc9-8af4-4dade50bc690", "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": [ -30640, -3360 ], "id": "8a653bdf-3fc8-4cba-af8d-d3c827dfd739", "name": "SQL Valid?" }, { "parameters": { "operation": "executeQuery", "query": "={{ $json.sql }}" }, "type": "n8n-nodes-base.microsoftSql", "typeVersion": 1.1, "position": [ -30416, -3456 ], "id": "13a61ae4-27e2-4b0b-ab81-636281d81812", "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 || {};\nreturn [{ json: {\n type: 'sql_result',\n question: v.question,\n sql: v.sql,\n rowCount: rows.length,\n columns: rows.length > 0 ? Object.keys(rows[0]) : [],\n rows,\n debug: { usedTables: v.usedTables, candidateFormCodes: v.candidateFormCodes, dictionaryMeta: v.dictionaryMeta }\n}}];\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ -30176, -3456 ], "id": "c6325122-ac66-4ecc-845b-61d8b8b9a0c2", "name": "Format 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": [ -30416, -3216 ], "id": "6969c1e6-1c67-4305-a4c3-937e66ce3dea", "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 SQL Result", "type": "main", "index": 0 } ] ] } }, "active": true, "settings": { "executionOrder": "v1", "availableInMCP": false, "timeSavedMode": "fixed", "callerPolicy": "workflowsFromSameOwner" }, "versionId": "22ff5d0b-ab86-47fb-8251-c38258384419", "meta": { "templateCredsSetupCompleted": true, "instanceId": "1d288821beaaeeada5e8dce6f282c802098a0c83ef6ddb35a174b09a9d43850e" }, "id": "MgXO29wLFA4urmNk", "tags": [] }