Delphi
Ejemplo con System.Net.HttpClient (THTTPClient) y FireDAC, sin componentes de terceros. Funciona en Delphi 10.3 Rio o más reciente, en aplicaciones VCL, FMX o servicios.
En una aplicación de escritorio instalada en la máquina del usuario, cualquiera puede extraer la API key del ejecutable. Haz la llamada a Datafala desde un servidor tuyo (un servicio DataSnap, Horse, RAD Server u otra API) y deja que la aplicación hable solo con ese servidor. Consulta Autenticación.
Cliente
unit Datafala.Client;
interface
uses
System.SysUtils, System.Classes, System.JSON, System.Hash,
System.Net.HttpClient, System.Net.URLClient;
type
EDatafala = class(Exception)
public
Status: Integer;
Code: string; // código estable de la API: sql_rejected, no_answer, rate_limited...
RetryAfter: Integer; // segundos, cuando la API envía Retry-After
RequestId: string;
end;
TDatafalaAnswer = record
Sql: string;
Params: TJSONObject; // es del llamador: libéralo con Params.Free
Dialect: string;
Explanation: string;
RequestId: string;
end;
TDatafalaClient = class
private
FBaseUrl, FApiKey, FEndUserSecret: string;
function Post(const Path: string; Body: TJSONObject): IHTTPResponse;
procedure RaiseError(const Response: IHTTPResponse);
public
constructor Create(const BaseUrl, ApiKey, EndUserSecret: string);
function EndUserId(const UserId: string): string;
function Ask(const Question, UserId: string; MaxRows: Integer = 0): TDatafalaAnswer;
procedure Feedback(const RequestId, Rating: string; const Comment: string = '');
end;
implementation
constructor TDatafalaClient.Create(const BaseUrl, ApiKey, EndUserSecret: string);
begin
inherited Create;
FBaseUrl := BaseUrl; // https://app.datafala.com.br
FApiKey := ApiKey; // léela de una variable de entorno o de un almacén, nunca del código
FEndUserSecret := EndUserSecret; // secreto fijo del servidor; no lo cambies después
end;
function TDatafalaClient.EndUserId(const UserId: string): string;
begin
// HMAC-SHA256 del ID interno: 64 caracteres hexadecimales, sin dato personal.
Result := THashSHA2.GetHMAC(UserId, FEndUserSecret, THashSHA2.TSHA2Version.SHA256);
end;
function TDatafalaClient.Post(const Path: string; Body: TJSONObject): IHTTPResponse;
var
Http: THTTPClient;
Content: TStringStream;
begin
Http := THTTPClient.Create;
Content := TStringStream.Create(Body.ToJSON, TEncoding.UTF8);
try
Http.ConnectionTimeout := 5000;
Http.ResponseTimeout := 60000; // el LLM puede tardar varios segundos
Http.ContentType := 'application/json';
Http.CustomHeaders['Authorization'] := 'Bearer ' + FApiKey;
Result := Http.Post(FBaseUrl + Path, Content);
finally
Content.Free;
Http.Free;
end;
end;
procedure TDatafalaClient.RaiseError(const Response: IHTTPResponse);
var
Body: TJSONValue;
Error: EDatafala;
begin
Body := TJSONObject.ParseJSONValue(Response.ContentAsString(TEncoding.UTF8));
try
if Body is TJSONObject then
Error := EDatafala.Create(TJSONObject(Body).GetValue<string>('message', ''))
else
Error := EDatafala.Create('HTTP ' + Response.StatusCode.ToString);
Error.Status := Response.StatusCode;
if Body is TJSONObject then
Error.Code := TJSONObject(Body).GetValue<string>('code', 'unknown')
else
Error.Code := 'unknown';
Error.RetryAfter := StrToIntDef(Response.HeaderValue['Retry-After'], 0);
Error.RequestId := Response.HeaderValue['X-Request-ID'];
finally
Body.Free;
end;
raise Error;
end;
function TDatafalaClient.Ask(const Question, UserId: string; MaxRows: Integer): TDatafalaAnswer;
var
Body, Json: TJSONObject;
Response: IHTTPResponse;
begin
Body := TJSONObject.Create;
try
Body.AddPair('question', Question);
Body.AddPair('end_user', EndUserId(UserId));
if MaxRows > 0 then
Body.AddPair('max_rows', TJSONNumber.Create(MaxRows));
Response := Post('/v1/ask', Body);
finally
Body.Free;
end;
if Response.StatusCode <> 200 then
RaiseError(Response);
Json := TJSONObject.ParseJSONValue(Response.ContentAsString(TEncoding.UTF8)) as TJSONObject;
try
Result.Sql := Json.GetValue<string>('sql');
Result.Dialect := Json.GetValue<string>('dialect');
// explanation puede venir null
if not Json.TryGetValue<string>('explanation', Result.Explanation) then
Result.Explanation := '';
Result.RequestId := Json.GetValue<string>('request_id');
Result.Params := Json.GetValue<TJSONObject>('params').Clone as TJSONObject;
finally
Json.Free;
end;
end;
procedure TDatafalaClient.Feedback(const RequestId, Rating, Comment: string);
var
Body: TJSONObject;
Response: IHTTPResponse;
begin
Body := TJSONObject.Create;
try
Body.AddPair('request_id', RequestId);
Body.AddPair('rating', Rating); // 'good' o 'bad'
if Comment <> '' then
Body.AddPair('comment', Copy(Comment, 1, 500));
Response := Post('/v1/feedback', Body);
finally
Body.Free;
end;
if Response.StatusCode <> 204 then
RaiseError(Response);
end;
end.
Convertir los placeholders para FireDAC
Datafala devuelve %(nombre)s y escapa todo % de literal como %%. FireDAC usa :nombre. Convierte en una sola pasada, de izquierda a derecha, para que un %% dentro de un literal nunca se confunda con un placeholder:
unit Datafala.Sql;
interface
// %(nombre)s -> :nombre ; %% -> %
function ToFireDAC(const Sql: string): string;
implementation
uses
System.SysUtils;
function IsNameChar(C: Char; First: Boolean): Boolean;
begin
Result := CharInSet(C, ['A'..'Z', 'a'..'z', '_']) or (not First and CharInSet(C, ['0'..'9']));
end;
function ToFireDAC(const Sql: string): string;
var
I, J: Integer;
Builder: TStringBuilder;
begin
Builder := TStringBuilder.Create(Length(Sql));
try
I := 1;
while I <= Length(Sql) do
begin
if (Sql[I] = '%') and (I < Length(Sql)) and (Sql[I + 1] = '%') then
begin
Builder.Append('%');
Inc(I, 2);
end
else if (Sql[I] = '%') and (I < Length(Sql)) and (Sql[I + 1] = '(') then
begin
J := I + 2;
while (J <= Length(Sql)) and IsNameChar(Sql[J], J = I + 2) do
Inc(J);
if (J < Length(Sql)) and (J > I + 2) and (Sql[J] = ')') and (Sql[J + 1] = 's') then
begin
Builder.Append(':').Append(Copy(Sql, I + 2, J - I - 2));
I := J + 2;
end
else
raise Exception.Create('Placeholder inválido en el SQL de Datafala');
end
else
begin
Builder.Append(Sql[I]);
Inc(I);
end;
end;
Result := Builder.ToString;
finally
Builder.Free;
end;
end;
end.
Ejecutar con FireDAC
Usa una TFDConnection con un usuario de solo lectura y una transacción de solo lectura. Desactiva las macros y las secuencias de escape de FireDAC: el SQL de Datafala es SQL puro, y un &, ! o { dentro de un literal no puede ser interpretado por FireDAC.
uses
System.JSON, Data.DB, FireDAC.Comp.Client, FireDAC.Stan.Option,
Datafala.Client, Datafala.Sql;
procedure BindParams(Query: TFDQuery; Params: TJSONObject);
var
Pair: TJSONPair;
Param: TFDParam;
begin
for Pair in Params do
begin
Param := Query.ParamByName(Pair.JsonString.Value);
if Pair.JsonValue is TJSONNull then
begin
Param.DataType := ftString;
Param.Clear;
end
else if Pair.JsonValue is TJSONBool then
Param.AsBoolean := TJSONBool(Pair.JsonValue).AsBoolean
else if Pair.JsonValue is TJSONNumber then
begin
if Pair.JsonValue.Value.IndexOfAny(['.', 'e', 'E']) >= 0 then
Param.AsFloat := TJSONNumber(Pair.JsonValue).AsDouble
else
Param.AsLargeInt := TJSONNumber(Pair.JsonValue).AsInt64;
end
else
Param.AsString := Pair.JsonValue.Value; // las fechas llegan como texto ISO (2026-10-22)
end;
end;
function Preguntar(Client: TDatafalaClient; Conexion: TFDConnection;
const Pregunta, IdDelUsuario: string): TFDQuery;
var
Answer: TDatafalaAnswer;
begin
Answer := Client.Ask(Pregunta, IdDelUsuario);
try
Result := TFDQuery.Create(nil);
try
Result.Connection := Conexion;
Result.ResourceOptions.MacroCreate := False;
Result.ResourceOptions.MacroExpand := False;
Result.ResourceOptions.EscapeExpand := False;
Result.ResourceOptions.CmdExecMode := amCancelable;
Result.ResourceOptions.CmdExecTimeout := 5000; // ms
Result.SQL.Text := ToFireDAC(Answer.Sql);
BindParams(Result, Answer.Params);
Result.Open;
except
Result.Free;
raise;
end;
finally
Answer.Params.Free;
end;
end;
Configuración de la conexión de solo lectura:
// Firebird
FDConexionLectura.Params.DriverID := 'FB';
FDConexionLectura.Params.UserName := 'DATAFALA_RO'; // GRANT SELECT solo en las tablas registradas
FDConexionLectura.TxOptions.ReadOnly := True;
// PostgreSQL: DriverID 'PG'; MySQL: DriverID 'MySQL'; Firebird: DriverID 'FB'. El usuario debe ser de solo lectura.
Tratar errores
try
Query := Preguntar(Client, FDConexionLectura, EditPregunta.Text, IntToStr(UsuarioLogueado.Id));
except
on E: EDatafala do
if (E.Code = 'no_answer') or (E.Code = 'sql_rejected') then
ShowMessage(E.Message) // mensaje ya traducido: pide al usuario que reformule
else if E.Code = 'rate_limited' then
ShowMessage(Format('Demasiadas preguntas. Inténtalo de nuevo en %d s.', [E.RetryAfter]))
else
raise;
end;
Recordatorios:
- No repitas automáticamente un
Askque superó el tiempo: cada intento gasta tokens de tu LLM. - Guarda
Answer.RequestIdpara enviar 👍/👎 conClient.Feedback(RequestId, 'good'). - Consulta la lista completa de códigos en Errores.