From b1bd57390568e91ff8e54ffc7c51e4b26cb64de4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sedat=20=C3=96zt=C3=BCrk?= Date: Wed, 24 Jun 2026 23:46:32 +0300 Subject: [PATCH] n8n Workflow --- .../Sas_H_ListFormFieldsDictionaryJson.sql | 391 +++++++++--------- .../EntityFrameworkCore/PlatformDbContext.cs | 2 +- ....cs => 20260624175615_Initial.Designer.cs} | 4 +- ...3_Initial.cs => 20260624175615_Initial.cs} | 2 +- .../PlatformDbContextModelSnapshot.cs | 2 +- configs/ai/Chat.json | 122 +++--- configs/ai/Kurulum.txt | 3 + ui/src/views/ai/Assistant.tsx | 6 +- 8 files changed, 277 insertions(+), 255 deletions(-) rename api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/{20260624135753_Initial.Designer.cs => 20260624175615_Initial.Designer.cs} (99%) rename api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/{20260624135753_Initial.cs => 20260624175615_Initial.cs} (99%) create mode 100644 configs/ai/Kurulum.txt diff --git a/api/src/Sozsoft.Platform.DbMigrator/Seeds/SqlData/HostData/Sas_H_ListFormFieldsDictionaryJson.sql b/api/src/Sozsoft.Platform.DbMigrator/Seeds/SqlData/HostData/Sas_H_ListFormFieldsDictionaryJson.sql index 99784d7..2fb5514 100644 --- a/api/src/Sozsoft.Platform.DbMigrator/Seeds/SqlData/HostData/Sas_H_ListFormFieldsDictionaryJson.sql +++ b/api/src/Sozsoft.Platform.DbMigrator/Seeds/SqlData/HostData/Sas_H_ListFormFieldsDictionaryJson.sql @@ -1,13 +1,12 @@ USE [Sozsoft] GO --- v12: Fully dynamic lookup dictionary. No hard-coded field-specific lookup rules. CREATE OR ALTER PROCEDURE [dbo].[Sas_H_ListFormFieldsDictionaryJson] - @DefinitionCultureName NVARCHAR(10) = N'en', -- ListForm/ListFormField kayıt kültürü. Genelde en. - @UiCultureName NVARCHAR(10) = N'tr', -- Kullanıcıya görünen dil. Genelde tr. - @ListFormCode NVARCHAR(64) = NULL, -- Tek form istenirse. - @OnlyBaseDefinitions BIT = 1, -- 1 = UserId/RoleId özel kayıtları hariç. - @ResourceName NVARCHAR(64) = NULL -- Dil kaynağı filtrelenmek istenirse. + @DefinitionCultureName NVARCHAR(10) = N'en', + @UiCultureName NVARCHAR(10) = N'tr', + @ListFormCode NVARCHAR(64) = NULL, + @OnlyBaseDefinitions BIT = 1, + @ResourceName NVARCHAR(64) = NULL AS BEGIN SET NOCOUNT ON; @@ -26,16 +25,7 @@ BEGIN DescriptionValue = LT_DESC.[Value], L.TableName, SelectCommandText = NULLIF(LTRIM(RTRIM(CAST(L.SelectCommand AS NVARCHAR(MAX)))), N''), - L.SelectCommandType, L.KeyFieldName, - L.KeyFieldDbSourceType, - L.ListFormType, - L.PageSize, - L.SortMode, - L.IsTenant, - L.IsBranch, - L.IsOrganizationUnit, - L.IsSubForm, L.UserId, L.RoleId FROM dbo.Sas_H_ListForm L @@ -75,42 +65,24 @@ BEGIN OR (L.UserId IS NULL AND L.RoleId IS NULL) ) ) - SELECT + SELECT CAST( ( SELECT - L.ListFormCode AS [formCode], L.ListFormCode AS [listFormCode], - L.CultureName AS [definitionCulture], - @UiCultureName AS [uiCulture], ISNULL(CAST(L.NameValue AS NVARCHAR(MAX)), ISNULL(L.Name, N'')) AS [displayName], - ISNULL(CAST(L.NameValue AS NVARCHAR(MAX)), ISNULL(L.Name, N'')) AS [nameValue], ISNULL(CAST(L.TitleValue AS NVARCHAR(MAX)), ISNULL(L.Title, N'')) AS [title], - ISNULL(CAST(L.TitleValue AS NVARCHAR(MAX)), ISNULL(L.Title, N'')) AS [titleValue], ISNULL(CAST(L.DescriptionValue AS NVARCHAR(MAX)), ISNULL(L.Description, N'')) AS [description], - ISNULL(CAST(L.DescriptionValue AS NVARCHAR(MAX)), ISNULL(L.Description, N'')) AS [descriptionValue], - ISNULL(L.TableName, N'') AS [sqlTableName], ISNULL(L.TableName, N'') AS [tableName], - ISNULL(L.SelectCommandText, N'') AS [selectCommand], ISNULL(COALESCE(NULLIF(L.TableName, N''), L.SelectCommandText), N'') AS [sqlSourceName], CASE WHEN COALESCE(NULLIF(L.TableName, N''), L.SelectCommandText) IS NULL THEN N'unknown' WHEN UPPER(LTRIM(COALESCE(NULLIF(L.TableName, N''), L.SelectCommandText))) LIKE N'SELECT%' THEN N'selectSql' ELSE N'table' END AS [sourceKind], - L.SelectCommandType AS [selectCommandType], - ISNULL(L.KeyFieldName, N'') AS [primaryKeyColumn], ISNULL(L.KeyFieldName, N'') AS [keyFieldName], - L.KeyFieldDbSourceType AS [keyFieldDbSourceType], - L.ListFormType AS [listFormType], - L.PageSize AS [pageSize], - L.SortMode AS [sortMode], - CONVERT(bit, L.IsTenant) AS [isTenant], - CONVERT(bit, L.IsBranch) AS [isBranch], - CONVERT(bit, L.IsOrganizationUnit) AS [isOrganizationUnit], - CONVERT(bit, L.IsSubForm) AS [isSubForm], CONCAT_WS(N' ', L.ListFormCode, @@ -125,97 +97,105 @@ BEGIN ( ( SELECT - F.FieldName AS [sqlColumnName], F.FieldName AS [field], ISNULL(CAST(LT_CAP.[Value] AS NVARCHAR(MAX)), ISNULL(F.CaptionName, N'')) AS [displayName], - ISNULL(CAST(LT_CAP.[Value] AS NVARCHAR(MAX)), ISNULL(F.CaptionName, N'')) AS [captionValue], - ISNULL(F.CaptionName, N'') AS [captionKey], - F.SourceDbType AS [sourceDbType], + SC.SqlDataType AS [sqlDataType], - SC.SqlMaxLength AS [sqlMaxLength], + F.ListOrderNo AS [listOrderNo], + CONVERT(bit, ISNULL(F.Visible, 1)) AS [isVisible], + CONVERT(bit, CASE WHEN SC.SqlDataType = N'bit' THEN 1 WHEN F.FieldName LIKE N'Is%' THEN 1 WHEN F.FieldName LIKE N'Has%' THEN 1 - WHEN F.FieldName IN (N'Monday', N'Tuesday', N'Wednesday', N'Thursday', N'Friday', N'Saturday', N'Sunday') THEN 1 - WHEN F.FieldName IN (N'EmailConfirmed', N'TwoFactorEnabled', N'IsVerified', N'IsDefault', N'IsPublic', N'IsEnabled', N'IsPublished', N'IsPinned', N'IsAnonymous', N'IsFixed', N'IsCustomized', N'IsEncrypted', N'IsInherited', N'ZipRequired', N'StateRequired') THEN 1 + WHEN F.FieldName IN + ( + N'Monday', N'Tuesday', N'Wednesday', N'Thursday', + N'Friday', N'Saturday', N'Sunday' + ) THEN 1 + WHEN F.FieldName IN + ( + N'EmailConfirmed', N'TwoFactorEnabled', N'IsVerified', + N'IsDefault', N'IsPublic', N'IsEnabled', N'IsPublished', + N'IsPinned', N'IsAnonymous', N'IsFixed', N'IsCustomized', + N'IsEncrypted', N'IsInherited', N'ZipRequired', N'StateRequired' + ) THEN 1 ELSE 0 END) AS [isBooleanColumn], - F.ListOrderNo AS [listOrderNo], - F.Width AS [width], - F.Alignment AS [alignment], - F.Format AS [format], - CONVERT(bit, ISNULL(F.Visible, 1)) AS [isVisible], - CONVERT(bit, ISNULL(F.IsActive, 1)) AS [isActive], - CONVERT(bit, ISNULL(F.AllowSearch, 0)) AS [allowSearch], - CONVERT(bit, ISNULL(F.AllowEditing, 1)) AS [allowEditing], - CONVERT(bit, ISNULL(F.AllowAdding, 1)) AS [allowAdding], - CONVERT(bit, CASE WHEN LJ.LookupJsonText IS NOT NULL THEN 1 ELSE 0 END) AS [hasLookup], - CONVERT(bit, CASE WHEN LJ.LookupJsonText IS NOT NULL THEN 1 ELSE 0 END) AS [isLookupColumn], - LJ.LookupJsonText AS [lookupJsonText], - JSON_QUERY(CASE WHEN ISJSON(LJ.LookupJsonText) = 1 THEN LJ.LookupJsonText ELSE NULL END) AS [lookupJson], - LJ.JoinTableJsonText AS [joinTableJsonText], - JSON_QUERY(CASE WHEN ISJSON(LJ.JoinTableJsonText) = 1 THEN LJ.JoinTableJsonText ELSE NULL END) AS [joinTableJson], - LKE.ParsedLookupTableName AS [parsedLookupTableName], - LK.ParsedLookupValueColumn AS [parsedLookupValueColumn], - LK.ParsedLookupDisplayColumn AS [parsedLookupDisplayColumn], + LJ.LookupJsonTextDecoded AS [lookupJsonText], - CASE WHEN ISJSON(LJ.LookupJsonText) = 1 THEN COALESCE( - JSON_VALUE(LJ.LookupJsonText, '$.listFormCode'), - JSON_VALUE(LJ.LookupJsonText, '$.formCode'), - JSON_VALUE(LJ.LookupJsonText, '$.lookupListFormCode'), - JSON_VALUE(LJ.LookupJsonText, '$.dataSource.listFormCode'), - JSON_VALUE(LJ.LookupJsonText, '$.dataSource.formCode') - ) END AS [lookupListFormCode], COALESCE( - CASE WHEN ISJSON(LJ.LookupJsonText) = 1 THEN COALESCE( - JSON_VALUE(LJ.LookupJsonText, '$.tableName'), - JSON_VALUE(LJ.LookupJsonText, '$.sqlTableName'), - JSON_VALUE(LJ.LookupJsonText, '$.lookupTableName'), - JSON_VALUE(LJ.LookupJsonText, '$.dataSource.tableName'), - JSON_VALUE(LJ.LookupJsonText, '$.dataSource.sqlTableName') + CASE WHEN ISJSON(LJ.LookupJsonTextDecoded) = 1 THEN COALESCE( + JSON_VALUE(LJ.LookupJsonTextDecoded, '$.tableName'), + JSON_VALUE(LJ.LookupJsonTextDecoded, '$.sqlTableName'), + JSON_VALUE(LJ.LookupJsonTextDecoded, '$.lookupTableName'), + JSON_VALUE(LJ.LookupJsonTextDecoded, '$.dataSource.tableName'), + JSON_VALUE(LJ.LookupJsonTextDecoded, '$.dataSource.sqlTableName') ) END, - LKE.ParsedLookupTableName + LKP.ParsedLookupTableName ) AS [lookupTableName], - COALESCE( - CASE WHEN ISJSON(LJ.LookupJsonText) = 1 THEN COALESCE( - JSON_VALUE(LJ.LookupJsonText, '$.valueExpr'), - JSON_VALUE(LJ.LookupJsonText, '$.valueField'), - JSON_VALUE(LJ.LookupJsonText, '$.valueColumn'), - JSON_VALUE(LJ.LookupJsonText, '$.keyField'), - JSON_VALUE(LJ.LookupJsonText, '$.keyFieldName') - ) END, - LK.ParsedLookupValueColumn - ) AS [lookupValueColumn], - COALESCE( - CASE WHEN ISJSON(LJ.LookupJsonText) = 1 THEN COALESCE( - JSON_VALUE(LJ.LookupJsonText, '$.displayExpr'), - JSON_VALUE(LJ.LookupJsonText, '$.displayField'), - JSON_VALUE(LJ.LookupJsonText, '$.displayColumn'), - JSON_VALUE(LJ.LookupJsonText, '$.textField'), - JSON_VALUE(LJ.LookupJsonText, '$.textColumn'), - JSON_VALUE(LJ.LookupJsonText, '$.nameField'), - JSON_VALUE(LJ.LookupJsonText, '$.nameColumn') - ) END, - LK.ParsedLookupDisplayColumn - ) AS [lookupDisplayColumn], - CASE - WHEN LJ.LookupJsonText IS NOT NULL AND F.FieldName NOT LIKE N'%Id' AND COALESCE(LK.ParsedLookupValueColumn, N'') IN (N'Name', N'Code', N'Key', N'Value') THEN N'direct-string' - WHEN LJ.LookupJsonText IS NOT NULL OR F.FieldName LIKE N'%Id' THEN N'convert-nvarchar-safe' - END AS [lookupJoinMode], - CASE - WHEN LJ.LookupJsonText IS NOT NULL AND F.FieldName NOT LIKE N'%Id' AND COALESCE(LK.ParsedLookupValueColumn, N'') IN (N'Name', N'Code', N'Key', N'Value') THEN N'ON base.lookupColumn = lookup.valueColumn' - WHEN LJ.LookupJsonText IS NOT NULL OR F.FieldName LIKE N'%Id' THEN N'ON CONVERT(NVARCHAR(100), base.lookupColumn) = CONVERT(NVARCHAR(100), lookup.valueColumn)' - END AS [lookupJoinRule], - CONVERT(bit, CASE - WHEN LJ.LookupJsonText IS NOT NULL - AND F.FieldName NOT LIKE N'%Id' - THEN 1 ELSE 0 END) AS [lookupFallbackToSourceColumn], - CASE WHEN LJ.LookupJsonText IS NOT NULL THEN CONCAT_WS(N' ', F.FieldName, F.CaptionName, CAST(LT_CAP.[Value] AS NVARCHAR(MAX)), LJ.LookupJsonText, LJ.JoinTableJsonText) END AS [lookupSearchText], - CONVERT(bit, CASE WHEN F.FieldName IN (N'IsActive', N'IsEnabled', N'IsPublished') THEN 1 ELSE 0 END) AS [isActiveStatusColumn], - CONVERT(bit, CASE WHEN F.FieldName IN (N'IsDeleted', N'Deleted', N'IsRemoved') THEN 1 ELSE 0 END) AS [isDeletedStatusColumn], + COALESCE( + CASE WHEN ISJSON(LJ.LookupJsonTextDecoded) = 1 THEN COALESCE( + JSON_VALUE(LJ.LookupJsonTextDecoded, '$.valueExpr'), + JSON_VALUE(LJ.LookupJsonTextDecoded, '$.valueField'), + JSON_VALUE(LJ.LookupJsonTextDecoded, '$.valueColumn'), + JSON_VALUE(LJ.LookupJsonTextDecoded, '$.keyField'), + JSON_VALUE(LJ.LookupJsonTextDecoded, '$.keyFieldName') + ) END, + LKP.ParsedLookupValueColumn + ) AS [lookupValueColumn], + + COALESCE( + CASE WHEN ISJSON(LJ.LookupJsonTextDecoded) = 1 THEN COALESCE( + JSON_VALUE(LJ.LookupJsonTextDecoded, '$.displayExpr'), + JSON_VALUE(LJ.LookupJsonTextDecoded, '$.displayField'), + JSON_VALUE(LJ.LookupJsonTextDecoded, '$.displayColumn'), + JSON_VALUE(LJ.LookupJsonTextDecoded, '$.textField'), + JSON_VALUE(LJ.LookupJsonTextDecoded, '$.textColumn'), + JSON_VALUE(LJ.LookupJsonTextDecoded, '$.nameField'), + JSON_VALUE(LJ.LookupJsonTextDecoded, '$.nameColumn') + ) END, + LKP.ParsedLookupDisplayColumn + ) AS [lookupDisplayColumn], + + CASE + WHEN LJ.LookupJsonTextDecoded IS NOT NULL AND F.FieldName NOT LIKE N'%Id' + AND COALESCE(LKP.ParsedLookupValueColumn, N'') IN (N'Name', N'Code', N'Key', N'Value', N'key', N'value') + THEN N'direct-string' + WHEN LJ.LookupJsonTextDecoded IS NOT NULL AND F.FieldName NOT LIKE N'%Id' + THEN N'direct-string' + WHEN LJ.LookupJsonTextDecoded IS NOT NULL OR F.FieldName LIKE N'%Id' + THEN N'convert-nvarchar-safe' + END AS [lookupJoinMode], + + CONVERT(bit, CASE + WHEN LJ.LookupJsonTextDecoded IS NOT NULL + AND F.FieldName NOT LIKE N'%Id' + THEN 1 ELSE 0 + END) AS [lookupFallbackToSourceColumn], + + CASE + WHEN LJ.LookupJsonTextDecoded IS NOT NULL + THEN CONCAT_WS(N' ', + F.FieldName, + F.CaptionName, + CAST(LT_CAP.[Value] AS NVARCHAR(MAX)), + LJ.LookupJsonTextDecoded + ) + END AS [lookupSearchText], + + CONVERT(bit, CASE + WHEN F.FieldName IN (N'IsActive', N'IsEnabled', N'IsPublished') + THEN 1 ELSE 0 + END) AS [isActiveStatusColumn], + + CONVERT(bit, CASE + WHEN F.FieldName IN (N'IsDeleted', N'Deleted', N'IsRemoved') + THEN 1 ELSE 0 + END) AS [isDeletedStatusColumn], + CONVERT(bit, CASE WHEN F.FieldName LIKE N'%Password%' OR F.FieldName LIKE N'%ConnectionString%' @@ -223,7 +203,9 @@ BEGIN OR F.FieldName LIKE N'%Secret%' OR F.FieldName LIKE N'%ApiKey%' OR F.FieldName LIKE N'%PrivateKey%' - THEN 1 ELSE 0 END) AS [isSensitive], + THEN 1 ELSE 0 + END) AS [isSensitive], + CONVERT(bit, CASE WHEN ISNULL(F.Visible, 1) = 1 AND ISNULL(F.IsActive, 1) = 1 @@ -234,21 +216,14 @@ BEGIN AND F.FieldName NOT LIKE N'%ApiKey%' AND F.FieldName NOT LIKE N'%PrivateKey%' AND F.FieldName NOT IN (N'Avatar', N'ExtraProperties') - THEN 1 ELSE 0 END) AS [isDefaultSelectColumn] + THEN 1 ELSE 0 + END) AS [isDefaultSelectColumn] + FROM dbo.Sas_H_ListFormField F OUTER APPLY - ( - SELECT - LookupJsonText = NULLIF(LTRIM(RTRIM(CAST(F.LookupJson AS NVARCHAR(MAX)))), N''), - JoinTableJsonText = NULLIF(LTRIM(RTRIM(CAST(F.JoinTableJson AS NVARCHAR(MAX)))), N'') - ) LJ - OUTER APPLY ( SELECT TOP (1) - SqlDataType = TYPE_NAME(C.user_type_id), - SqlMaxLength = C.max_length, - SqlPrecision = C.precision, - SqlScale = C.scale + SqlDataType = TYPE_NAME(C.user_type_id) FROM sys.schemas S INNER JOIN sys.tables TBL ON TBL.schema_id = S.schema_id INNER JOIN sys.columns C ON C.object_id = TBL.object_id @@ -259,77 +234,120 @@ BEGIN OUTER APPLY ( SELECT - CleanLookupSql = NULLIF( - LTRIM(RTRIM( - REPLACE( - REPLACE( - REPLACE( - REPLACE( - REPLACE( - REPLACE( - REPLACE( - REPLACE( - REPLACE(CAST(F.LookupJson AS NVARCHAR(MAX)), N'\\u0022', N''), - N'\u0022', N''), - N'"', N''), - N'"', N''), - N'[', N''), - N']', N''), - N'`', N''), - N'\', N''), - CHAR(13) + CHAR(10), N' ') - )), - N'') - ) LKC + LookupJsonTextRaw = NULLIF(LTRIM(RTRIM(CAST(F.LookupJson AS NVARCHAR(MAX)))), N'') + ) LJR OUTER APPLY ( SELECT - UpperLookupSql = UPPER(LKC.CleanLookupSql), - FromPos = CHARINDEX(N' FROM ', UPPER(LKC.CleanLookupSql)), - OrderPos = CHARINDEX(N' ORDER BY ', UPPER(LKC.CleanLookupSql)), - WherePos = CHARINDEX(N' WHERE ', UPPER(LKC.CleanLookupSql)), - GroupPos = CHARINDEX(N' GROUP BY ', UPPER(LKC.CleanLookupSql)) - ) LKP + LookupJsonTextDecoded = NULLIF(LTRIM(RTRIM( + REPLACE( + REPLACE( + REPLACE( + REPLACE( + REPLACE( + LJR.LookupJsonTextRaw, + N'\\u0022', N'"'), + N'\u0022', N'"'), + N'\\u0027', N''''), + N'\u0027', N''''), + N'"', N'"') + )), N'') + ) LJ + OUTER APPLY + ( + SELECT + LookupQueryText = CASE + WHEN ISJSON(LJ.LookupJsonTextDecoded) = 1 + THEN JSON_VALUE(LJ.LookupJsonTextDecoded, '$.LookupQuery') + END + ) LQ + OUTER APPLY + ( + SELECT + CleanLookupQuery = NULLIF(LTRIM(RTRIM( + REPLACE( + REPLACE( + REPLACE( + REPLACE( + REPLACE( + REPLACE( + REPLACE( + REPLACE( + LQ.LookupQueryText, + N'"', N''), + N'[', N''), + N']', N''), + N'`', N''), + NCHAR(13), N' '), + NCHAR(10), N' '), + NCHAR(9), N' '), + N' ', N' ') + )), N'') + ) LQC + OUTER APPLY + ( + SELECT + FromPos = CHARINDEX(N' FROM ', UPPER(ISNULL(LQC.CleanLookupQuery, N''))), + OrderPos = CHARINDEX(N' ORDER BY ', UPPER(ISNULL(LQC.CleanLookupQuery, N''))), + WherePos = CHARINDEX(N' WHERE ', UPPER(ISNULL(LQC.CleanLookupQuery, N''))), + GroupPos = CHARINDEX(N' GROUP BY ', UPPER(ISNULL(LQC.CleanLookupQuery, N''))), + SelectPart = CASE + WHEN CHARINDEX(N' FROM ', UPPER(ISNULL(LQC.CleanLookupQuery, N''))) > 0 + THEN LTRIM(RTRIM(SUBSTRING( + LQC.CleanLookupQuery, + 7, + CHARINDEX(N' FROM ', UPPER(LQC.CleanLookupQuery)) - 7 + ))) + END + ) LQP OUTER APPLY ( SELECT StopPos = CASE - WHEN LKP.FromPos <= 0 THEN 0 - WHEN LKP.WherePos > LKP.FromPos THEN LKP.WherePos - WHEN LKP.GroupPos > LKP.FromPos THEN LKP.GroupPos - WHEN LKP.OrderPos > LKP.FromPos THEN LKP.OrderPos - ELSE LEN(LKC.CleanLookupSql) + 1 + WHEN LQP.FromPos <= 0 THEN 0 + WHEN LQP.WherePos > LQP.FromPos THEN LQP.WherePos + WHEN LQP.GroupPos > LQP.FromPos THEN LQP.GroupPos + WHEN LQP.OrderPos > LQP.FromPos THEN LQP.OrderPos + ELSE LEN(LQC.CleanLookupQuery) + 1 + END + ) LQS + OUTER APPLY + ( + SELECT + ParsedLookupTableName = CASE + WHEN LQP.FromPos > 0 AND LQS.StopPos > LQP.FromPos + THEN NULLIF(REPLACE(REPLACE(LEFT( + LTRIM(RTRIM(SUBSTRING( + LQC.CleanLookupQuery, + LQP.FromPos + 6, + LQS.StopPos - (LQP.FromPos + 6) + ))), + CHARINDEX(N' ', LTRIM(RTRIM(SUBSTRING( + LQC.CleanLookupQuery, + LQP.FromPos + 6, + LQS.StopPos - (LQP.FromPos + 6) + ))) + N' ') - 1 + ), N'dbo.', N''), N'DBO.', N''), N'') END, - SelectPart = CASE WHEN LKP.FromPos > 0 THEN LTRIM(RTRIM(SUBSTRING(LKC.CleanLookupSql, 7, LKP.FromPos - 7))) END - ) LKS - OUTER APPLY - ( - SELECT - TablePart = CASE WHEN LKP.FromPos > 0 AND LKS.StopPos > LKP.FromPos - THEN LTRIM(RTRIM(SUBSTRING(LKC.CleanLookupSql, LKP.FromPos + 6, LKS.StopPos - (LKP.FromPos + 6)))) - END, - KeyAsPos = CHARINDEX(N' AS KEY', UPPER(ISNULL(LKS.SelectPart, N''))), - NameAsPos = CHARINDEX(N' AS NAME', UPPER(ISNULL(LKS.SelectPart, N''))) - ) LKT - OUTER APPLY - ( - SELECT - ParsedLookupTableName = NULLIF(REPLACE(REPLACE(LEFT(LKT.TablePart, CHARINDEX(N' ', LKT.TablePart + N' ') - 1), N'dbo.', N''), N'DBO.', N''), N''), - KeyExprChunk = CASE WHEN LKT.KeyAsPos > 0 THEN LTRIM(RTRIM(SUBSTRING(LKS.SelectPart, 1, LKT.KeyAsPos - 1))) END, - NameExprChunk = CASE WHEN LKT.NameAsPos > 0 THEN LTRIM(RTRIM(SUBSTRING(LKS.SelectPart, 1, LKT.NameAsPos - 1))) END - ) LKE - OUTER APPLY - ( - SELECT - ParsedLookupValueColumn = NULLIF(LTRIM(RTRIM(REPLACE(REPLACE( - CASE WHEN LKE.KeyExprChunk IS NOT NULL THEN - RIGHT(LKE.KeyExprChunk, CHARINDEX(N',', REVERSE(LKE.KeyExprChunk) + N',') - 1) - END, N'SELECT', N''), N'select', N''))), N''), - ParsedLookupDisplayColumn = NULLIF(LTRIM(RTRIM(REPLACE(REPLACE( - CASE WHEN LKE.NameExprChunk IS NOT NULL THEN - RIGHT(LKE.NameExprChunk, CHARINDEX(N',', REVERSE(LKE.NameExprChunk) + N',') - 1) - END, N'SELECT', N''), N'select', N''))), N'') - ) LK + ParsedLookupValueColumn = COALESCE( + NULLIF(JSON_VALUE(LJ.LookupJsonTextDecoded, '$.ValueExpr'), N''), + NULLIF(JSON_VALUE(LJ.LookupJsonTextDecoded, '$.valueExpr'), N''), + NULLIF(JSON_VALUE(LJ.LookupJsonTextDecoded, '$.valueField'), N''), + NULLIF(JSON_VALUE(LJ.LookupJsonTextDecoded, '$.valueColumn'), N''), + NULLIF(JSON_VALUE(LJ.LookupJsonTextDecoded, '$.keyField'), N''), + NULLIF(JSON_VALUE(LJ.LookupJsonTextDecoded, '$.keyFieldName'), N'') + ), + ParsedLookupDisplayColumn = COALESCE( + NULLIF(JSON_VALUE(LJ.LookupJsonTextDecoded, '$.DisplayExpr'), N''), + NULLIF(JSON_VALUE(LJ.LookupJsonTextDecoded, '$.displayExpr'), N''), + NULLIF(JSON_VALUE(LJ.LookupJsonTextDecoded, '$.displayField'), N''), + NULLIF(JSON_VALUE(LJ.LookupJsonTextDecoded, '$.displayColumn'), N''), + NULLIF(JSON_VALUE(LJ.LookupJsonTextDecoded, '$.textField'), N''), + NULLIF(JSON_VALUE(LJ.LookupJsonTextDecoded, '$.textColumn'), N''), + NULLIF(JSON_VALUE(LJ.LookupJsonTextDecoded, '$.nameField'), N''), + NULLIF(JSON_VALUE(LJ.LookupJsonTextDecoded, '$.nameColumn'), N'') + ) + ) LKP OUTER APPLY ( SELECT TOP (1) LT.[Value] @@ -357,6 +375,7 @@ BEGIN FROM ListFormSource L ORDER BY L.ListFormCode FOR JSON PATH, ROOT('listForms') - ) AS ListFormDataDictionaryJson; + ) + AS NVARCHAR(MAX)) AS ListFormDataDictionaryJson; END GO \ No newline at end of file diff --git a/api/src/Sozsoft.Platform.EntityFrameworkCore/EntityFrameworkCore/PlatformDbContext.cs b/api/src/Sozsoft.Platform.EntityFrameworkCore/EntityFrameworkCore/PlatformDbContext.cs index 924b960..f7fff24 100644 --- a/api/src/Sozsoft.Platform.EntityFrameworkCore/EntityFrameworkCore/PlatformDbContext.cs +++ b/api/src/Sozsoft.Platform.EntityFrameworkCore/EntityFrameworkCore/PlatformDbContext.cs @@ -1542,7 +1542,7 @@ public class PlatformDbContext : b.Property(x => x.ResourceName).HasMaxLength(64); b.Property(x => x.MaxForms).HasDefaultValue(500); b.Property(x => x.CacheKey).HasMaxLength(512).IsRequired(); - b.Property(x => x.JsonData).IsRequired(); + b.Property(x => x.JsonData).IsRequired().HasColumnType("text"); b.Property(x => x.FormCount).HasDefaultValue(0); b.Property(x => x.FieldCount).HasDefaultValue(0); b.Property(x => x.JsonLength).HasDefaultValue(0); diff --git a/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260624135753_Initial.Designer.cs b/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260624175615_Initial.Designer.cs similarity index 99% rename from api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260624135753_Initial.Designer.cs rename to api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260624175615_Initial.Designer.cs index 55ea5d6..ed73e32 100644 --- a/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260624135753_Initial.Designer.cs +++ b/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260624175615_Initial.Designer.cs @@ -13,7 +13,7 @@ using Volo.Abp.EntityFrameworkCore; namespace Sozsoft.Platform.Migrations { [DbContext(typeof(PlatformDbContext))] - [Migration("20260624135753_Initial")] + [Migration("20260624175615_Initial")] partial class Initial { /// @@ -2005,7 +2005,7 @@ namespace Sozsoft.Platform.Migrations b.Property("JsonData") .IsRequired() - .HasColumnType("nvarchar(max)"); + .HasColumnType("text"); b.Property("JsonLength") .ValueGeneratedOnAdd() diff --git a/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260624135753_Initial.cs b/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260624175615_Initial.cs similarity index 99% rename from api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260624135753_Initial.cs rename to api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260624175615_Initial.cs index 688f2aa..25c26d3 100644 --- a/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260624135753_Initial.cs +++ b/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260624175615_Initial.cs @@ -1209,7 +1209,7 @@ namespace Sozsoft.Platform.Migrations ResourceName = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true), MaxForms = table.Column(type: "int", nullable: false, defaultValue: 500), CacheKey = table.Column(type: "nvarchar(512)", maxLength: 512, nullable: false), - JsonData = table.Column(type: "nvarchar(max)", nullable: false), + JsonData = table.Column(type: "text", nullable: false), FormCount = table.Column(type: "int", nullable: false, defaultValue: 0), FieldCount = table.Column(type: "int", nullable: false, defaultValue: 0), JsonLength = table.Column(type: "int", nullable: false, defaultValue: 0), diff --git a/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/PlatformDbContextModelSnapshot.cs b/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/PlatformDbContextModelSnapshot.cs index 7874d47..089d070 100644 --- a/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/PlatformDbContextModelSnapshot.cs +++ b/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/PlatformDbContextModelSnapshot.cs @@ -2002,7 +2002,7 @@ namespace Sozsoft.Platform.Migrations b.Property("JsonData") .IsRequired() - .HasColumnType("nvarchar(max)"); + .HasColumnType("text"); b.Property("JsonLength") .ValueGeneratedOnAdd() diff --git a/configs/ai/Chat.json b/configs/ai/Chat.json index d12d32d..8af95a1 100644 --- a/configs/ai/Chat.json +++ b/configs/ai/Chat.json @@ -1,5 +1,5 @@ { - "name": "Chat_SQL_DbTableColumns_TableAgent_v29_AliasFix_FullyDynamic", + "name": "Chat", "nodes": [ { "parameters": { @@ -15,24 +15,24 @@ "type": "n8n-nodes-base.webhook", "typeVersion": 2, "position": [ - -32912, - -3184 + -39376, + -3072 ], - "id": "18507856-9247-491e-8d4a-9159419d252e", + "id": "46b4fe58-58c7-4344-a6fb-c099caab6651", "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" + "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": [ - -32704, - -3184 + -39168, + -3072 ], - "id": "6067108d-b5dd-48df-9500-70416053db9f", + "id": "f5ab8edd-fd83-4e5c-94cc-6c58ff80bad6", "name": "Build Intent Request" }, { @@ -47,23 +47,23 @@ "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ - -32480, - -3184 + -38944, + -3072 ], - "id": "3baceaba-e945-47cd-b8c9-6561125f260d", + "id": "d9a8bd55-350e-40dd-b145-2f70aaf76f79", "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" + "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": [ - -32256, - -3184 + -38720, + -3072 ], - "id": "d4e9ab81-138e-4acc-a31d-e18be333d9ec", + "id": "27993516-1b97-422e-b026-162afb4fb99b", "name": "Normalize Intent" }, { @@ -93,23 +93,23 @@ "type": "n8n-nodes-base.if", "typeVersion": 2, "position": [ - -32032, - -3184 + -38496, + -3072 ], - "id": "0ae28c72-73f3-4851-b149-d4a2913418a9", + "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 || '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" + "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": [ - -31824, - -2944 + -38288, + -2832 ], - "id": "6528b16c-51e1-4d19-9806-6d2f790ae6c2", + "id": "f5fa30e8-2934-4bf7-a2ae-96dc98acb64a", "name": "Build Chat Request" }, { @@ -124,10 +124,10 @@ "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ - -31600, - -2944 + -38064, + -2832 ], - "id": "c5334fb9-eded-4903-bc2f-6454fd800e2a", + "id": "e68d00f6-fe8d-4f6a-8170-fce9cdd14f0a", "name": "Chat Ollama" }, { @@ -137,10 +137,10 @@ "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - -31376, - -2944 + -37840, + -2832 ], - "id": "468e3ad7-1c0e-45db-bfd0-63b391247393", + "id": "7cde5a7e-e0f2-497c-a38a-62de4084241d", "name": "Chat Response" }, { @@ -150,10 +150,10 @@ "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - -31824, - -3360 + -38288, + -3248 ], - "id": "d96b157a-de5e-4cc3-ba7d-ef2030ca7755", + "id": "326e9983-14e0-490e-bcb8-4852bbca1d49", "name": "Build Dictionary Query" }, { @@ -164,10 +164,10 @@ "type": "n8n-nodes-base.microsoftSql", "typeVersion": 1.1, "position": [ - -31584, - -3360 + -38048, + -3248 ], - "id": "70d35887-291c-41e5-acac-e5c5c60e0196", + "id": "986bc23a-44ac-48e6-bd68-96c70b48e7dc", "name": "Get Dictionary JSON", "credentials": { "microsoftSql": { @@ -178,15 +178,15 @@ }, { "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" + "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" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - -31344, - -3360 + -37808, + -3248 ], - "id": "7b44191c-172d-464b-ba11-bdd5728d25fc", + "id": "61007e7c-6f51-4572-ad61-13d9f0bffa4c", "name": "Build SQL Request" }, { @@ -201,23 +201,23 @@ "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ - -31104, - -3360 + -37568, + -3248 ], - "id": "4ca40c6f-3605-4e7c-8beb-2d25784094d8", + "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\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" + "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": [ - -30864, - -3360 + -37328, + -3248 ], - "id": "8d752e56-0e49-4bc9-8af4-4dade50bc690", + "id": "2a9ba43b-f687-4c27-a00b-841d912cc9a1", "name": "Validate SQL" }, { @@ -248,10 +248,10 @@ "type": "n8n-nodes-base.if", "typeVersion": 2, "position": [ - -30640, - -3360 + -37104, + -3248 ], - "id": "8a653bdf-3fc8-4cba-af8d-d3c827dfd739", + "id": "4445ba0d-c494-4389-be21-1b2468fade63", "name": "SQL Valid?" }, { @@ -262,10 +262,10 @@ "type": "n8n-nodes-base.microsoftSql", "typeVersion": 1.1, "position": [ - -30416, - -3456 + -36880, + -3344 ], - "id": "13a61ae4-27e2-4b0b-ab81-636281d81812", + "id": "ec109600-5013-4b38-aeee-be8f2b654864", "name": "Execute Generated SQL", "credentials": { "microsoftSql": { @@ -276,16 +276,16 @@ }, { "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" + "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": [ - -30176, - -3456 + -36640, + -3344 ], - "id": "c6325122-ac66-4ecc-845b-61d8b8b9a0c2", - "name": "Format SQL Result" + "id": "4797a41c-acb6-4a8b-b66c-e3cebc230dae", + "name": "Format Smart SQL Result" }, { "parameters": { @@ -294,10 +294,10 @@ "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - -30416, - -3216 + -36880, + -3104 ], - "id": "6969c1e6-1c67-4305-a4c3-937e66ce3dea", + "id": "ce66daea-a390-43ae-bd7d-0a00d80c86be", "name": "SQL Error Response" } ], @@ -464,7 +464,7 @@ "main": [ [ { - "node": "Format SQL Result", + "node": "Format Smart SQL Result", "type": "main", "index": 0 } @@ -479,7 +479,7 @@ "timeSavedMode": "fixed", "callerPolicy": "workflowsFromSameOwner" }, - "versionId": "22ff5d0b-ab86-47fb-8251-c38258384419", + "versionId": "39108360-6275-4878-8546-69b1baa0c871", "meta": { "templateCredsSetupCompleted": true, "instanceId": "1d288821beaaeeada5e8dce6f282c802098a0c83ef6ddb35a174b09a9d43850e" diff --git a/configs/ai/Kurulum.txt b/configs/ai/Kurulum.txt new file mode 100644 index 0000000..f0d9983 --- /dev/null +++ b/configs/ai/Kurulum.txt @@ -0,0 +1,3 @@ +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 diff --git a/ui/src/views/ai/Assistant.tsx b/ui/src/views/ai/Assistant.tsx index 32f48b3..a60150a 100644 --- a/ui/src/views/ai/Assistant.tsx +++ b/ui/src/views/ai/Assistant.tsx @@ -428,7 +428,7 @@ const Assistant = () => { + -
+                    
                       {cleanedSql}
                     
@@ -439,7 +439,7 @@ const Assistant = () => { Sonuç bulunamadı. ) : ( -
+
@@ -730,7 +730,7 @@ const Assistant = () => { )}
{renderMessageContent(msg)}