diff options
| -rw-r--r-- | docs/CHANGES.rst | 18 | ||||
| -rw-r--r-- | include/administration.hpp | 4 | ||||
| -rw-r--r-- | include/administration_reader.hpp | 3 | ||||
| -rw-r--r-- | include/ai_service.hpp | 10 | ||||
| -rw-r--r-- | run.bat | 4 | ||||
| -rw-r--r-- | src/administration.cpp | 4 | ||||
| -rw-r--r-- | src/administration_reader.cpp | 18 | ||||
| -rw-r--r-- | src/ai_providers/openAI.cpp | 252 | ||||
| -rw-r--r-- | src/ai_service.cpp | 135 | ||||
| -rw-r--r-- | src/ui/ui_expenses.cpp | 2 |
10 files changed, 346 insertions, 104 deletions
diff --git a/docs/CHANGES.rst b/docs/CHANGES.rst index 157b56e..1a46011 100644 --- a/docs/CHANGES.rst +++ b/docs/CHANGES.rst @@ -1,7 +1,25 @@ .. _changes: TODO: +for invoice importing using AI: + 4. find billing item tax rate based on paid tax vs total + 6. set document original and copy path (save file to zip first) + 7. create a new invoice ID + + 8. call ai_document_to_invoice in a thread & create a new UI page to show loading screen. + The user should not be able to add/update existing invoices but they can navigate to other screens + and come back to the loading screen. +- retrieve available balance from AI api & show in settings/services. +- let user choose the model to use in settings/services/ai +- real error logging for OpenAI and importing in general +- when importing an invoice: do not accept invoices for unsupported countries (yet) + +- log_set_depth function so data can be grouped +- move string function out of openAI.cpp into strops.cpp +- log elapsed time for ai requests - refactor _add functions to use _import functions +- _import functions should not check for validity and should never fail because of invalid data +- invalid invoices should be marked in the UI - write tests that check error handling for corrupt files. (e.g. references to tax rates, project and cost center that failed to load) - it is possible a referenced tax rate is loaded after an invoice is loaded. This means all invoices need to be recalculated after file load. (try to write a test for this). - invoice sequential number should be modifyable & checked for uniqueness (for external invoices being imported) diff --git a/include/administration.hpp b/include/administration.hpp index ec64833..3d6a88c 100644 --- a/include/administration.hpp +++ b/include/administration.hpp @@ -27,8 +27,8 @@ #define MAX_LEN_FILENAME 255 #define MAX_LEN_PATH 4096 #define MAX_LEN_CURRENCY 8 -#define MAX_LEN_SHORT_DESC 32 -#define MAX_LEN_LONG_DESC 64 +#define MAX_LEN_SHORT_DESC 64 +#define MAX_LEN_LONG_DESC 256 #define MAX_LEN_EMAIL 64 #define MAX_LEN_PHONE 16 #define MAX_LEN_BANK 35 diff --git a/include/administration_reader.hpp b/include/administration_reader.hpp index 7765680..d650855 100644 --- a/include/administration_reader.hpp +++ b/include/administration_reader.hpp @@ -25,4 +25,5 @@ bool administration_reader_import_tax_rate(char* buffer, size_t buffer_size); bool administration_reader_import_cost_center(char* buffer, size_t buffer_size); bool administration_reader_import_project(char* buffer, size_t buffer_size); bool administration_reader_import_contact(char* buffer, size_t buffer_size); -bool administration_reader_import_invoice(char* buffer, size_t buffer_size);
\ No newline at end of file +bool administration_reader_import_invoice(char* buffer, size_t buffer_size); +bool administration_reader_read_invoice_from_xml(invoice* result, char* buffer, size_t buffer_size);
\ No newline at end of file diff --git a/include/ai_service.hpp b/include/ai_service.hpp index b481b58..cf0b67a 100644 --- a/include/ai_service.hpp +++ b/include/ai_service.hpp @@ -16,6 +16,8 @@ #pragma once +#include "administration.hpp" + typedef struct { time_t started_at; @@ -23,4 +25,12 @@ typedef struct char* result; } ai_request; +typedef struct +{ + bool (*upload_file)(char* file_path, char* file_id, size_t file_id_len); + bool (*query_with_file)(char* query, size_t query_length, char* file_id, char** response); +} ai_provider_impl; + +extern ai_provider_impl _chatgpt_api_provider; + ai_request* ai_document_to_invoice(char* file_path);
\ No newline at end of file @@ -28,13 +28,13 @@ set LIB_SOURCES=libs\imgui-1.92.1\backends\imgui_impl_dx11.cpp^ libs\xml.c\src\*.c^ libs\timer_lib\*.c^ libs\tinyfiledialogs\tinyfiledialogs.c -@set SOURCES= src\*.cpp src\ui\*.cpp src\locales\*.cpp +@set SOURCES= src\*.cpp src\ui\*.cpp src\locales\*.cpp src\ai_providers\*.cpp @set LIBS=opengl32.lib Advapi32.lib Shell32.lib Ole32.lib User32.lib Pathcch.lib D3D11.lib Comdlg32.lib Kernel32.lib /LIBPATH:"libs/openssl-3.6.0-beta1/x64/lib" libssl.lib libcrypto.lib @set FLAGS=/nologo /Ob0 /MD /Oy- /Zi /FS /W4 /EHsc /utf-8 @set INCLUDE_DIRS=/I"libs/imgui-1.92.1" /I"libs/imgui-1.92.1/backends" /I"/" /I"libs/openssl-3.6.0-beta1/x64/include" /I"libs/cpp-httplib" /I"libs/timer_lib" /I"libs/greatest" /I"libs/simclist-1.5" /I"libs/tinyfiledialogs" /I"libs/zip/src" /I"libs/xml.c/src" /I"libs/" /Iinclude @set DEFINITIONS=/D_BUILD_DATE_=\"%date%\" /D_COMMIT_=\"%COMMIT_ID%\" /D_PLATFORM_=\"win64\" -if "%1"=="-t" @set SOURCES= tests\main.cpp src\administration.cpp src\administration_writer.cpp src\administration_reader.cpp src\strops.cpp src\log.cpp src\locales.cpp src\locales\*.cpp src\ui\helpers.cpp +if "%1"=="-t" @set SOURCES= tests\main.cpp src\administration.cpp src\administration_writer.cpp src\administration_reader.cpp src\strops.cpp src\log.cpp src\locales.cpp src\locales\*.cpp src\ai_providers\*.cpp src\ui\helpers.cpp if "%1"=="-t" @set OUT_EXE=accounting_tests cl %FLAGS% %INCLUDE_DIRS% %DEFINITIONS% %SOURCES% %LIB_SOURCES% /Fe%OUT_DIR%/%OUT_EXE%.exe /Fd%OUT_DIR%/vc140.pdb /Fo%OUT_DIR%/ /link %LIBS% diff --git a/src/administration.cpp b/src/administration.cpp index e1405b8..231953b 100644 --- a/src/administration.cpp +++ b/src/administration.cpp @@ -1558,8 +1558,8 @@ a_err administration_invoice_update(invoice* inv) a_err administration_invoice_import(invoice* inv) { - a_err result = administration_invoice_is_valid(inv); - if (result != A_ERR_SUCCESS) return result; + //a_err result = administration_invoice_is_valid(inv); + //if (result != A_ERR_SUCCESS) return result; inv->is_triangulation = !(memcmp(&inv->addressee.address, &inv->customer.address, sizeof(address)) == 0); diff --git a/src/administration_reader.cpp b/src/administration_reader.cpp index b8b4958..f316f40 100644 --- a/src/administration_reader.cpp +++ b/src/administration_reader.cpp @@ -123,14 +123,13 @@ bool administration_reader_open_existing(char* file_path) return true; } -bool administration_reader_import_invoice(char* buffer, size_t buffer_size) +bool administration_reader_read_invoice_from_xml(invoice* result, char* buffer, size_t buffer_size) { - STOPWATCH_START; - xml_document* document = xml_parse_document((uint8_t *)buffer, buffer_size); if (!document) return false; struct xml_node* root = xml_document_root(document); + if (!root) return false; invoice data = administration_invoice_create_empty(); xml_get_str(root, data.id, MAX_LEN_ID, "cbc:ID"); @@ -238,8 +237,19 @@ bool administration_reader_import_invoice(char* buffer, size_t buffer_size) free(child_name); } - + + *result = data; + return true; +} + +bool administration_reader_import_invoice(char* buffer, size_t buffer_size) +{ + STOPWATCH_START; + + invoice data; + if (!administration_reader_read_invoice_from_xml(&data, buffer, buffer_size)) return false; a_err result = administration_invoice_import(&data); + if (result == A_ERR_SUCCESS) { log_info("Loaded invoice '%s' in %.3fms.", data.sequential_number, STOPWATCH_TIME); } diff --git a/src/ai_providers/openAI.cpp b/src/ai_providers/openAI.cpp new file mode 100644 index 0000000..5dd2c50 --- /dev/null +++ b/src/ai_providers/openAI.cpp @@ -0,0 +1,252 @@ +/* +* Copyright (c) 2025 Aldrik Ramaekers <aldrik.ramaekers@gmail.com> +* +* Permission to use, copy, modify, and/or distribute this software for any +* purpose with or without fee is hereby granted, provided that the above +* copyright notice and this permission notice appear in all copies. +* +* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +* ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +* ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ + +#include <fstream> +#include <iostream> +#include <string> + +#define CPPHTTPLIB_OPENSSL_SUPPORT +#include "httplib.h" +#include "log.hpp" +#include "ai_service.hpp" + +static char *extract_json_value(const char *json, const char *key, char *out, size_t out_size, int skip = 0) { + char pattern[128]; + snprintf(pattern, sizeof(pattern), "\"%s\"", key); + const char *pos = strstr(json, pattern); + while(skip > 0) { + pos = strstr(pos+1, pattern); + skip--; + } + if (!pos) return NULL; + pos = strchr(pos, ':'); + if (!pos) return NULL; + pos++; + + // Skip whitespace and quotes + while (*pos == ' ' || *pos == '\"') pos++; + + size_t i = 0; + while (*pos && !(*pos == '\"' && *(pos-1) != '\\') && i < out_size - 1) { + out[i++] = *pos++; + } + out[i] = '\0'; + return out; +} + +static const char* get_filename(const char* path) { + const char* filename = strrchr(path, '/'); // for Unix-style paths + if (filename) return filename + 1; // skip the '/' + filename = strrchr(path, '\\'); // for Windows-style paths + if (filename) return filename + 1; + return path; // no slashes found, path itself is filename +} + +static char *escape_quotes(const char *input, size_t buffer_size) { + if (!input) return NULL; + + char *result = (char*)malloc(buffer_size + 100); // Ballpark + if (!result) return NULL; + + const char *src = input; + char *dst = result; + + while (*src) { + if (*src == '"') { + *dst++ = '\\'; + *dst++ = '"'; + } + else if (*src == '\n') { + // empty + } + else { + *dst++ = *src; + } + src++; + } + *dst = '\0'; + + return result; +} + +static char *unescape_quotes(char *input) { + if (!input) return NULL; + + char *src = input; + char *dst = input; + + while (*src) { + if (*src == '\\' && *(src+1) == '"') { + src++; + } + else if (*src == '\\' && *(src+1) == 'n') { + src++;src++; + } + *dst++ = *src++; + } + *dst = '\0'; + + return input; +} + +static bool _openAI_query_with_file(char* query, size_t query_length, char* file_id, char** response) +{ + #define TESTING_IMPORT + + #ifndef TESTING_IMPORT + const char *api_key = administration_get_ai_service().api_key_public; + + httplib::SSLClient cli("api.openai.com", 443); + //cli.enable_server_certificate_verification(false); + + char* query_escaped = escape_quotes(query, query_length); + free(query); + + size_t body_size = query_length + 200; + char* body = (char*)malloc(body_size); + snprintf(body, body_size, + "{\"model\":\"gpt-5-nano\", \"input\": [ { \"role\": \"user\", \"content\": [ { \"type\": \"input_file\", \"file_id\": \"%s\" }, " + "{ \"type\": \"input_text\", \"text\": \"%s\" } ] } ] }", file_id, query_escaped); + + httplib::Headers headers; + headers.insert(std::make_pair("Authorization", std::string("Bearer ") + api_key)); + + httplib::Result res = cli.Post("/v1/responses", headers, body, "application/json"); + free(body); + + if (!res || res->status != 200) { + log_error("ERROR Failed to query API."); + return 0; + } + + char* response_body = (char*)res->body.c_str(); + #else + char* response_body = "{\n \"id\": \"resp_68d9482030fc8196930b43b6b28feeb104e98afee829eee0\",\n \"object\": \"response\",\n \"created_at\": 1759070240,\n \"status\": \"completed\",\n \"background\": false,\n \"billing\": {\n \"payer\": \"developer\"\n },\n \"error\": null,\n \"incomplete_details\": null,\n \"instructions\": null,\n \"max_output_tokens\": null,\n \"max_tool_calls\": null,\n \"model\": \"gpt-5-2025-08-07\",\n \"output\": [\n {\n \"id\": \"rs_68d94821d1f0819694533a6ed7ed6b2904e98afee829eee0\",\n \"type\": \"reasoning\",\n \"summary\": []\n },\n {\n \"id\": \"msg_68d948a09e0c819696782e09c6b7626104e98afee829eee0\",\n \"type\": \"message\",\n \"status\": \"completed\",\n \"content\": [\n {\n \"type\": \"output_text\",\n \"annotations\": [],\n \"logprobs\": [],\n \"text\": \"<Invoice xmlns=\\\"urn:oasis:names:specification:ubl:schema:xsd:Invoice-2\\\" xmlns:cac=\\\"urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2\\\" xmlns:cbc=\\\"urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2\\\">\\n <cbc:CustomizationID>urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0</cbc:CustomizationID>\\n <cbc:ProfileID>urn:fdc:peppol.eu:2017:poacc:billing:01:1.0</cbc:ProfileID>\\n <cbc:ID>586928</cbc:ID>\\n <cbc:IssueDate>2025-03-17</cbc:IssueDate>\\n <cbc:DueDate>2025-03-24</cbc:DueDate>\\n <cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>\\n <cbc:DocumentCurrencyCode>EUR</cbc:DocumentCurrencyCode>\\n <cac:DespatchDocumentReference>\\n <cbc:ID>699607</cbc:ID>\\n </cac:DespatchDocumentReference>\\n <cac:AdditionalDocumentReference>\\n <cbc:ID>AR385893</cbc:ID>\\n <cbc:DocumentDescription>Jouw bestelling : 420675-WWW.SCHROEVEN-EXPRESS.NL-14.03.25-18:31</cbc:DocumentDescription>\\n </cac:AdditionalDocumentReference>\\n <cac:OrderReference>\\n <cbc:ID>420675-WWW.SCHROEVEN-EXPRESS.NL-14.03.25-18:31</cbc:ID>\\n </cac:OrderReference>\\n <cac:ProjectReference>\\n <cbc:ID></cbc:ID>\\n </cac:ProjectReference>\\n <cbc:AccountingCost></cbc:AccountingCost>\\n <cac:AccountingSupplierParty>\\n <cac:Party>\\n <cbc:EndpointID schemeID=\\\"\\\"></cbc:EndpointID>\\n <cac:PartyIdentification>\\n <cbc:ID schemeID=\\\"ZZZ\\\">R.C le mans B 302 494 224</cbc:ID>\\n </cac:PartyIdentification>\\n <cac:PartyName>\\n <cbc:Name>Visserie-service</cbc:Name>\\n </cac:PartyName>\\n <cac:PostalAddress>\\n <cbc:StreetName>Z.A Nord</cbc:StreetName>\\n <cbc:AdditionalStreetName></cbc:AdditionalStreetName>\\n <cbc:CityName>Parce sur Sarthe</cbc:CityName>\\n <cbc:PostalZone>72300</cbc:PostalZone>\\n <cbc:CountrySubentity></cbc:CountrySubentity>\\n <cac:Country>\\n <cbc:IdentificationCode>FR</cbc:IdentificationCode>\\n </cac:Country>\\n </cac:PostalAddress>\\n <cac:PartyTaxScheme>\\n <cbc:CompanyID>FR57 302 494 224</cbc:CompanyID>\\n <cac:TaxScheme>\\n <cbc:ID>VAT</cbc:ID>\\n </cac:TaxScheme>\\n </cac:PartyTaxScheme>\\n <cac:PartyLegalEntity>\\n <cbc:RegistrationName>Visserie Service SAS</cbc:RegistrationName>\\n </cac:PartyLegalEntity>\\n <cac:Contact>\\n <cbc:Name>AMELIE L</cbc:Name>\\n <cbc:Telephone>02.43.62.09.08</cbc:Telephone>\\n <cbc:ElectronicMail>klantenservice@schroeven-express.nl</cbc:ElectronicMail>\\n </cac:Contact>\\n </cac:Party>\\n </cac:AccountingSupplierParty>\\n <cac:AccountingCustomerParty>\\n <cac:Party>\\n <cbc:EndpointID schemeID=\\\"\\\"></cbc:EndpointID>\\n <cac:PartyIdentification>\\n <cbc:ID schemeID=\\\"ZZZ\\\">cl585187</cbc:ID>\\n </cac:PartyIdentification>\\n <cac:PartyName>\\n <cbc:Name>ALDRIK RAMAEKERS</cbc:Name>\\n </cac:PartyName>\\n <cac:PostalAddress>\\n <cbc:StreetName>KEERDERSTRAAT 81</cbc:StreetName>\\n <cbc:AdditionalStreetName></cbc:AdditionalStreetName>\\n <cbc:CityName>MAASTRICHT</cbc:CityName>\\n <cbc:PostalZone>6226X</cbc:PostalZone>\\n <cbc:CountrySubentity></cbc:CountrySubentity>\\n <cac:Country>\\n <cbc:IdentificationCode>NL</cbc:IdentificationCode>\\n </cac:Country>\\n </cac:PostalAddress>\\n <cac:PartyTaxScheme>\\n <cbc:CompanyID></cbc:CompanyID>\\n <cac:TaxScheme>\\n <cbc:ID>VAT</cbc:ID>\\n </cac:TaxScheme>\\n </cac:PartyTaxScheme>\\n <cac:PartyLegalEntity>\\n <cbc:RegistrationName>ALDRIK RAMAEKERS</cbc:RegistrationName>\\n </cac:PartyLegalEntity>\\n <cac:Contact>\\n <cbc:Name>A RAMAEKERS</cbc:Name>\\n <cbc:Telephone>31618260377</cbc:Telephone>\\n <cbc:ElectronicMail>aldrikboy@gmail.com</cbc:ElectronicMail>\\n </cac:Contact>\\n </cac:Party>\\n </cac:AccountingCustomerParty>\\n <cac:Delivery>\\n <cbc:ActualDeliveryDate>2025-03-17</cbc:ActualDeliveryDate>\\n <cac:DeliveryLocation>\\n <cac:Address>\\n <cbc:StreetName>KEERDERSTRAAT 81</cbc:StreetName>\\n <cbc:AdditionalStreetName></cbc:AdditionalStreetName>\\n <cbc:CityName>MAASTRICHT</cbc:CityName>\\n <cbc:PostalZone>6226X</cbc:PostalZone>\\n <cbc:CountrySubentity></cbc:CountrySubentity>\\n <cac:Country>\\n <cbc:IdentificationCode>NL</cbc:IdentificationCode>\\n </cac:Country>\\n </cac:Address>\\n </cac:DeliveryLocation>\\n <cac:DeliveryParty>\\n <cac:PartyName>\\n <cbc:Name>ALDRIK RAMAEKERS</cbc:Name>\\n </cac:PartyName>\\n </cac:DeliveryParty>\\n </cac:Delivery>\\n <cac:PaymentMeans>\\n <cbc:PaymentMeansCode></cbc:PaymentMeansCode>\\n <cbc:PaymentID>586928</cbc:PaymentID>\\n <cac:PayeeFinancialAccount>\\n <cbc:ID>FR76 1790 6001 1272 5017 0700 137</cbc:ID>\\n <cbc:Name>Visserie Service SAS</cbc:Name>\\n <cac:FinancialInstitutionBranch>\\n <cac:FinancialInstitution>\\n <cbc:ID>AGRIFRPP879</cbc:ID>\\n </cac:FinancialInstitution>\\n </cac:FinancialInstitutionBranch>\\n </cac:PayeeFinancialAccount>\\n <cac:PayerFinancialAccount>\\n <cbc:ID></cbc:ID>\\n </cac:PayerFinancialAccount>\\n </cac:PaymentMeans>\\n <cac:TaxTotal>\\n <cbc:TaxAmount currencyID=\\\"EUR\\\">2.59</cbc:TaxAmount>\\n <cac:TaxSubtotal>\\n <cbc:TaxableAmount currencyID=\\\"EUR\\\">12.36</cbc:TaxableAmount>\\n <cbc:TaxAmount currencyID=\\\"EUR\\\">2.59</cbc:TaxAmount>\\n <cac:TaxCategory>\\n <cbc:ID></cbc:ID>\\n <cbc:Percent>21</cbc:Percent>\\n <cac:TaxScheme>\\n <cbc:ID>VAT</cbc:ID>\\n </cac:TaxScheme>\\n </cac:TaxCategory>\\n </cac:TaxSubtotal>\\n </cac:TaxTotal>\\n <cac:LegalMonetaryTotal>\\n <cbc:LineExtensionAmount currencyID=\\\"EUR\\\">6.95</cbc:LineExtensionAmount>\\n <cbc:TaxExclusiveAmount currencyID=\\\"EUR\\\">12.36</cbc:TaxExclusiveAmount>\\n <cbc:TaxInclusiveAmount currencyID=\\\"EUR\\\">14.95</cbc:TaxInclusiveAmount>\\n <cbc:PayableAmount currencyID=\\\"EUR\\\">14.95</cbc:PayableAmount>\\n </cac:LegalMonetaryTotal>\\n <cac:InvoiceLine>\\n <cbc:ID>1</cbc:ID>\\n <cbc:InvoicedQuantity unitCode=\\\"\\\">500</cbc:InvoicedQuantity>\\n <cbc:LineExtensionAmount currencyID=\\\"EUR\\\">6.95</cbc:LineExtensionAmount>\\n <cac:AllowanceCharge>\\n <cbc:ChargeIndicator>false</cbc:ChargeIndicator>\\n <cbc:AllowanceChargeReason>Discount</cbc:AllowanceChargeReason>\\n <cbc:MultiplierFactorNumeric></cbc:MultiplierFactorNumeric>\\n <cbc:Amount currencyID=\\\"EUR\\\"></cbc:Amount>\\n <cbc:BaseAmount currencyID=\\\"EUR\\\"></cbc:BaseAmount>\\n </cac:AllowanceCharge>\\n <cac:Item>\\n <cbc:Name>Metalen schroeven RVS A2 gefreesde kop Pozi N\\u00b01 M2X4 DIN 965 ISO 7046, VS0109, VS0110</cbc:Name>\\n <cac:AdditionalItemProperty>\\n <cbc:Name>Internal Tax Rate ID</cbc:Name>\\n <cbc:Value></cbc:Value>\\n </cac:AdditionalItemProperty>\\n <cac:ClassifiedTaxCategory>\\n <cbc:ID></cbc:ID>\\n <cbc:Percent>21</cbc:Percent>\\n <cac:TaxScheme>\\n <cbc:ID>VAT</cbc:ID>\\n </cac:TaxScheme>\\n </cac:ClassifiedTaxCategory>\\n </cac:Item>\\n <cac:Price>\\n <cbc:PriceAmount currencyID=\\\"EUR\\\">1.39</cbc:PriceAmount>\\n </cac:Price>\\n </cac:InvoiceLine>\\n</Invoice>\"\n }\n ],\n \"role\": \"assistant\"\n }\n ],\n \"parallel_tool_calls\": true,\n"; + #endif + + *response = (char*)malloc(100000); + memset(*response, 0, 100000); + strncpy(*response, response_body, 100000); + + extract_json_value(*response, "text", *response, 100000); + *response = unescape_quotes(*response); + + return 1; +} + +static bool _openAI_upload_file(char* file_path, char* file_id, size_t file_id_len) +{ + const char *api_key = administration_get_ai_service().api_key_public; + const char *filename = get_filename(file_path); + + FILE* orig_file = fopen(file_path, "rb"); + if (orig_file == NULL) { + log_error("ERROR: file to upload could not be opened."); + return 0; + } + + fseek(orig_file, 0L, SEEK_END); + long sz = ftell(orig_file); + fseek(orig_file, 0, SEEK_SET); + + httplib::SSLClient cli("api.openai.com", 443); + //cli.enable_server_certificate_verification(false); + + char body[512]; + snprintf(body, sizeof(body), "{\"filename\":\"%s\",\"purpose\":\"user_data\", \"bytes\": %d, \"mime_type\": \"application/pdf\", \"expires_after\": { \"anchor\": \"created_at\", \"seconds\": 3600 } }", filename, sz); + + httplib::Headers headers; + headers.insert(std::make_pair("Authorization", std::string("Bearer ") + api_key)); + + httplib::Result res = cli.Post("/v1/uploads", headers, body, "application/json"); + if (!res || res->status != 200) { + log_error("ERROR Failed to create upload."); + fclose(orig_file); + return 0; + } + + char upload_id[128]; + extract_json_value(res->body.c_str(), "id", upload_id, sizeof(upload_id)); + size_t part_size = 64000000; // 64mb + log_info("Created upload %s with part size %zu.", upload_id, part_size); + + char *buffer = (char*)malloc(part_size); + + char completion_body[1048]; + snprintf(completion_body, sizeof(completion_body), "{\"part_ids\": ["); + + int part_number = 0; + while (1) { + size_t read_bytes = fread(buffer, 1, part_size, orig_file); + if (read_bytes == 0) break; + + httplib::Headers part_headers; + part_headers.insert(std::make_pair("Authorization", std::string("Bearer ") + api_key)); + + std::string chunk(buffer, read_bytes); + + httplib::UploadFormDataItems items = { + {"data", chunk, filename, "application/octet-stream"} + }; + + char path[256]; + snprintf(path, sizeof(path), "/v1/uploads/%s/parts?part_number=%d", upload_id, part_number); + + httplib::Result part_res = cli.Post(path, part_headers, items); + + if (!part_res || part_res->status != 200) { + log_error("Failed to upload part %d.", part_number); + free(buffer); + fclose(orig_file); + return 0; + } + else { + char part_id[128]; + extract_json_value(part_res->body.c_str(), "id", part_id, sizeof(part_id)); + if (part_number == 0) snprintf(completion_body+strlen(completion_body), sizeof(completion_body)-strlen(completion_body), "\"%s\"", part_id); + if (part_number != 0) snprintf(completion_body+strlen(completion_body), sizeof(completion_body)-strlen(completion_body), ", \"%s\"", part_id); + } + + log_info("Uploaded part %d\n", part_number); + part_number++; + } + + snprintf(completion_body+strlen(completion_body), sizeof(completion_body)-strlen(completion_body), "]}"); + + free(buffer); + fclose(orig_file); + + // ---------- Step 3: Complete upload ---------- + + httplib::Result complete_res = cli.Post((std::string("/v1/uploads/") + upload_id + "/complete").c_str(), + headers, completion_body, "application/json"); + + + if (!complete_res || complete_res->status != 200) { + log_error("ERROR Failed to complete upload."); + return 0; + } + + char* completion_body_response = (char*)complete_res->body.c_str(); + extract_json_value(completion_body_response, "id", file_id, file_id_len, 1); + + return 1; +} + +ai_provider_impl _chatgpt_api_provider = { + _openAI_upload_file, + _openAI_query_with_file, +};
\ No newline at end of file diff --git a/src/ai_service.cpp b/src/ai_service.cpp index 2553f61..e271fcb 100644 --- a/src/ai_service.cpp +++ b/src/ai_service.cpp @@ -22,121 +22,72 @@ #include "httplib.h" #include "log.hpp" #include "ai_service.hpp" +#include "strops.hpp" +#include "administration_reader.hpp" +ai_provider_impl _ai_get_impl() +{ + ai_provider provider = administration_get_ai_service().provider; -// ---- Utility: simple JSON value extractor (very naive) ---- -char *extract_json_value(const char *json, const char *key, char *out, size_t out_size) { - char pattern[128]; - snprintf(pattern, sizeof(pattern), "\"%s\"", key); - const char *pos = strstr(json, pattern); - if (!pos) return NULL; - pos = strchr(pos, ':'); - if (!pos) return NULL; - pos++; - - // Skip whitespace and quotes - while (*pos == ' ' || *pos == '\"') pos++; - - size_t i = 0; - while (*pos && *pos != '\"' && *pos != ',' && *pos != '}' && i < out_size - 1) { - out[i++] = *pos++; + switch(provider) + { + case AI_PROVIDER_OPENAI: return _chatgpt_api_provider; + default: assert(0); break; } - out[i] = '\0'; - return out; -} -// ---- Read file chunk ---- -size_t read_chunk(FILE *fp, char *buffer, size_t chunk_size) { - return fread(buffer, 1, chunk_size, fp); + return ai_provider_impl {0}; } -const char* get_filename(const char* path) { - const char* filename = strrchr(path, '/'); // for Unix-style paths - if (filename) return filename + 1; // skip the '/' - filename = strrchr(path, '\\'); // for Windows-style paths - if (filename) return filename + 1; - return path; // no slashes found, path itself is filename -} +extern const char* peppol_invoice_template; +extern const char* peppol_invoice_line_template; ai_request* ai_document_to_invoice(char* file_path) { - const char *api_key = administration_get_ai_service().api_key_public; - const char *filename = get_filename(file_path); - - FILE* orig_file = fopen(file_path, "rb"); - if (orig_file == NULL) { - log_error("ERROR: file to upload could not be opened."); - return 0; - } + ai_provider_impl impl = _ai_get_impl(); - fseek(orig_file, 0L, SEEK_END); - long sz = ftell(orig_file); - fseek(orig_file, 0, SEEK_SET); - - httplib::SSLClient cli("api.openai.com", 443); - cli.enable_server_certificate_verification(false); - - char body[512]; - snprintf(body, sizeof(body), "{\"filename\":\"%s\",\"purpose\":\"user_data\", \"bytes\": %d, \"mime_type\": \"application/pdf\", \"expires_after\": { \"anchor\": \"created_at\", \"seconds\": 3600 } }", filename, sz); - - httplib::Headers headers; - headers.insert(std::make_pair("Authorization", std::string("Bearer ") + api_key)); - - httplib::Result res = cli.Post("/v1/uploads", headers, body, "application/json"); - if (!res || res->status != 200) { - log_error("ERROR Failed to create upload."); - fclose(orig_file); + char file_id[100]; + if (!impl.upload_file(file_path, file_id, 100)) { return 0; } - char upload_id[128]; - extract_json_value(res->body.c_str(), "id", upload_id, sizeof(upload_id)); - size_t part_size = 64000000; // 64mb - log_info("Created upload %s with part size %zu.", upload_id, part_size); + size_t query_buffer_len = 50000; + char* template_buffer = (char*)malloc(query_buffer_len); + memset(template_buffer, 0, query_buffer_len); - char *buffer = (char*)malloc(part_size); + strncpy(template_buffer, peppol_invoice_template, query_buffer_len); + strops_replace(template_buffer, 50000, "{{INVOICE_LINE_LIST}}", peppol_invoice_line_template); - int part_number = 0; - while (1) { - size_t read_bytes = read_chunk(orig_file, buffer, part_size); - if (read_bytes == 0) break; + char* ai_query = + "\n\nI have provided a file containing an invoice. Fill in the above Peppol 3.0 template with the information from the invoice." + "Do not add any fields to the template. If you can't find data for a given field, leave it empty. Do not make up any information." + "Only return the filled out template in valid XML format. Nothing else.\n"; - httplib::Headers part_headers; - part_headers.insert(std::make_pair("Authorization", std::string("Bearer ") + api_key)); - part_headers.insert(std::make_pair("Content-Type", "multipart/form-data")); + size_t query_len = strlen(template_buffer); + strncpy(template_buffer + query_len, ai_query, query_buffer_len - query_len); - std::string chunk(buffer, read_bytes); + char* response; + if (!impl.query_with_file(template_buffer, query_buffer_len, file_id, &response)) { + return 0; + } - httplib::UploadFormDataItems items = { - {"data", chunk, filename, "application/pdf"} - }; + invoice inv; + if (!administration_reader_read_invoice_from_xml(&inv, response, strlen(response))) { + return false; + } - char path[256]; - snprintf(path, sizeof(path), "/v1/uploads/%s/parts?part_number=%d", upload_id, part_number); + invoice tmp = administration_invoice_create_empty(); - httplib::Result part_res = cli.Post(path, part_headers, items); + inv.status = invoice_status::INVOICE_RECEIVED; + strops_copy(inv.id, tmp.id, MAX_LEN_ID); // TODO next_id is not being incremented + strops_copy(inv.customer.id, MY_COMPANY_ID, MAX_LEN_ID); // TODO param for incomming/exporting necessary - if (!part_res || part_res->status != 200) { - log_error("Failed to upload part %d.", part_number); - free(buffer); - fclose(orig_file); - return 0; - } + strops_copy(inv.document.original_path, file_path, MAX_LEN_PATH); + strops_copy(inv.document.copy_path, "", MAX_LEN_PATH); - log_info("Uploaded part %d\n", part_number); - part_number++; - } + a_err result = administration_invoice_import(&inv); - free(buffer); - fclose(orig_file); - - // ---------- Step 3: Complete upload ---------- - httplib::Result complete_res = cli.Post((std::string("/v1/uploads/") + upload_id + "/complete").c_str(), - headers, "", "application/json"); - if (!complete_res || complete_res->status != 200) { - log_error("ERROR Failed to complete upload."); - return 0; - } + free(template_buffer); + free(response); return 0; }
\ No newline at end of file diff --git a/src/ui/ui_expenses.cpp b/src/ui/ui_expenses.cpp index 790394a..153e6b7 100644 --- a/src/ui/ui_expenses.cpp +++ b/src/ui/ui_expenses.cpp @@ -176,7 +176,7 @@ static void ui_draw_expenses_list() char import_file_path[MAX_LEN_PATH] = {0}; ImGui::SameLine(); if (ImGui::FormInvoiceFileSelector("+ Import", import_file_path)) { // @localize - current_view_state = view_state::CREATE; + //current_view_state = view_state::CREATE; active_invoice = administration_invoice_create_empty(); // @leak active_invoice.customer = administration_company_info_get(); active_invoice.is_outgoing = 0; |
