diff --git a/.gitignore b/.gitignore index 7684431..c9650be 100644 --- a/.gitignore +++ b/.gitignore @@ -1,499 +1,505 @@ -## Ignore Visual Studio temporary files, build results, and -## files generated by popular Visual Studio add-ons. -## -## Get latest from `dotnet new gitignore` - -# dotenv files -.env - -# User-specific files -*.rsuser -*.suo -*.user -*.userosscache -*.sln.docstates - -# User-specific files (MonoDevelop/Xamarin Studio) -*.userprefs - -# Mono auto generated files -mono_crash.* - -# Build results -[Dd]ebug/ -[Dd]ebugPublic/ -[Rr]elease/ -[Rr]eleases/ -x64/ -x86/ -[Ww][Ii][Nn]32/ -[Aa][Rr][Mm]/ -[Aa][Rr][Mm]64/ -bld/ -[Bb]in/ -[Oo]bj/ -[Ll]og/ -[Ll]ogs/ - -# Visual Studio 2015/2017 cache/options directory -.vs/ -# Uncomment if you have tasks that create the project's static files in wwwroot -#wwwroot/ - -# Visual Studio 2017 auto generated files -Generated\ Files/ - -# MSTest test Results -[Tt]est[Rr]esult*/ -[Bb]uild[Ll]og.* - -# NUnit -*.VisualState.xml -TestResult.xml -nunit-*.xml - -# Build Results of an ATL Project -[Dd]ebugPS/ -[Rr]eleasePS/ -dlldata.c - -# Benchmark Results -BenchmarkDotNet.Artifacts/ - -# .NET -project.lock.json -project.fragment.lock.json -artifacts/ - -# Tye -.tye/ - -# ASP.NET Scaffolding -ScaffoldingReadMe.txt - -# StyleCop -StyleCopReport.xml - -# Files built by Visual Studio -*_i.c -*_p.c -*_h.h -*.ilk -*.meta -*.obj -*.iobj -*.pch -*.pdb -*.ipdb -*.pgc -*.pgd -*.rsp -*.sbr -*.tlb -*.tli -*.tlh -*.tmp -*.tmp_proj -*_wpftmp.csproj -*.log -*.tlog -*.vspscc -*.vssscc -.builds -*.pidb -*.svclog -*.scc - -# Chutzpah Test files -_Chutzpah* - -# Visual C++ cache files -ipch/ -*.aps -*.ncb -*.opendb -*.opensdf -*.sdf -*.cachefile -*.VC.db -*.VC.VC.opendb - -# Visual Studio profiler -*.psess -*.vsp -*.vspx -*.sap - -# Visual Studio Trace Files -*.e2e - -# TFS 2012 Local Workspace -$tf/ - -# Guidance Automation Toolkit -*.gpState - -# ReSharper is a .NET coding add-in -_ReSharper*/ -*.[Rr]e[Ss]harper -*.DotSettings.user - -# TeamCity is a build add-in -_TeamCity* - -# DotCover is a Code Coverage Tool -*.dotCover - -# AxoCover is a Code Coverage Tool -.axoCover/* -!.axoCover/settings.json - -# Coverlet is a free, cross platform Code Coverage Tool -coverage*.json -coverage*.xml -coverage*.info - -# Visual Studio code coverage results -*.coverage -*.coveragexml - -# NCrunch -_NCrunch_* -.*crunch*.local.xml -nCrunchTemp_* - -# MightyMoose -*.mm.* -AutoTest.Net/ - -# Web workbench (sass) -.sass-cache/ - -# Installshield output folder -[Ee]xpress/ - -# DocProject is a documentation generator add-in -DocProject/buildhelp/ -DocProject/Help/*.HxT -DocProject/Help/*.HxC -DocProject/Help/*.hhc -DocProject/Help/*.hhk -DocProject/Help/*.hhp -DocProject/Help/Html2 -DocProject/Help/html - -# Click-Once directory -publish/ - -# Publish Web Output -*.[Pp]ublish.xml -*.azurePubxml -# Note: Comment the next line if you want to checkin your web deploy settings, -# but database connection strings (with potential passwords) will be unencrypted -*.pubxml -*.publishproj - -# Microsoft Azure Web App publish settings. Comment the next line if you want to -# checkin your Azure Web App publish settings, but sensitive information contained -# in these scripts will be unencrypted -PublishScripts/ - -# NuGet Packages -*.nupkg -# NuGet Symbol Packages -*.snupkg -# The packages folder can be ignored because of Package Restore -**/[Pp]ackages/* -# except build/, which is used as an MSBuild target. -!**/[Pp]ackages/build/ -# Uncomment if necessary however generally it will be regenerated when needed -#!**/[Pp]ackages/repositories.config -# NuGet v3's project.json files produces more ignorable files -*.nuget.props -*.nuget.targets - -# Microsoft Azure Build Output -csx/ -*.build.csdef - -# Microsoft Azure Emulator -ecf/ -rcf/ - -# Windows Store app package directories and files -AppPackages/ -BundleArtifacts/ -Package.StoreAssociation.xml -_pkginfo.txt -*.appx -*.appxbundle -*.appxupload - -# Visual Studio cache files -# files ending in .cache can be ignored -*.[Cc]ache -# but keep track of directories ending in .cache -!?*.[Cc]ache/ - -# Others -ClientBin/ -~$* -*~ -*.dbmdl -*.dbproj.schemaview -*.jfm -*.pfx -*.publishsettings -orleans.codegen.cs - -# Including strong name files can present a security risk -# (https://github.com/github/gitignore/pull/2483#issue-259490424) -#*.snk - -# Since there are multiple workflows, uncomment next line to ignore bower_components -# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) -#bower_components/ - -# RIA/Silverlight projects -Generated_Code/ - -# Backup & report files from converting an old project file -# to a newer Visual Studio version. Backup files are not needed, -# because we have git ;-) -_UpgradeReport_Files/ -Backup*/ -UpgradeLog*.XML -UpgradeLog*.htm -ServiceFabricBackup/ -*.rptproj.bak - -# SQL Server files -*.mdf -*.ldf -*.ndf - -# Business Intelligence projects -*.rdl.data -*.bim.layout -*.bim_*.settings -*.rptproj.rsuser -*- [Bb]ackup.rdl -*- [Bb]ackup ([0-9]).rdl -*- [Bb]ackup ([0-9][0-9]).rdl - -# Microsoft Fakes -FakesAssemblies/ - -# GhostDoc plugin setting file -*.GhostDoc.xml - -# Node.js Tools for Visual Studio -.ntvs_analysis.dat -node_modules/ - -# Visual Studio 6 build log -*.plg - -# Visual Studio 6 workspace options file -*.opt - -# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) -*.vbw - -# Visual Studio 6 auto-generated project file (contains which files were open etc.) -*.vbp - -# Visual Studio 6 workspace and project file (working project files containing files to include in project) -*.dsw -*.dsp - -# Visual Studio 6 technical files -*.ncb -*.aps - -# Visual Studio LightSwitch build output -**/*.HTMLClient/GeneratedArtifacts -**/*.DesktopClient/GeneratedArtifacts -**/*.DesktopClient/ModelManifest.xml -**/*.Server/GeneratedArtifacts -**/*.Server/ModelManifest.xml -_Pvt_Extensions - -# Paket dependency manager -.paket/paket.exe -paket-files/ - -# FAKE - F# Make -.fake/ - -# CodeRush personal settings -.cr/personal - -# Python Tools for Visual Studio (PTVS) -__pycache__/ -*.pyc - -# Cake - Uncomment if you are using it -# tools/** -# !tools/packages.config - -# Tabs Studio -*.tss - -# Telerik's JustMock configuration file -*.jmconfig - -# BizTalk build output -*.btp.cs -*.btm.cs -*.odx.cs -*.xsd.cs - -# OpenCover UI analysis results -OpenCover/ - -# Azure Stream Analytics local run output -ASALocalRun/ - -# MSBuild Binary and Structured Log -*.binlog - -# NVidia Nsight GPU debugger configuration file -*.nvuser - -# MFractors (Xamarin productivity tool) working folder -.mfractor/ - -# Local History for Visual Studio -.localhistory/ - -# Visual Studio History (VSHistory) files -.vshistory/ - -# BeatPulse healthcheck temp database -healthchecksdb - -# Backup folder for Package Reference Convert tool in Visual Studio 2017 -MigrationBackup/ - -# Ionide (cross platform F# VS Code tools) working folder -.ionide/ - -# Fody - auto-generated XML schema -FodyWeavers.xsd - -# VS Code files for those working on multiple tools -.vscode/* -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json -*.code-workspace - -# Local History for Visual Studio Code -.history/ - -# Windows Installer files from build outputs -*.cab -*.msi -*.msix -*.msm -*.msp - -# JetBrains Rider -*.sln.iml -.idea - -## -## Visual studio for Mac -## - - -# globs -Makefile.in -*.userprefs -*.usertasks -config.make -config.status -aclocal.m4 -install-sh -autom4te.cache/ -*.tar.gz -tarballs/ -test-results/ - -# Mac bundle stuff -*.dmg -*.app - -# content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore -# General -.DS_Store -.AppleDouble -.LSOverride - -# Icon must end with two \r -Icon - - -# Thumbnails -._* - -# Files that might appear in the root of a volume -.DocumentRevisions-V100 -.fseventsd -.Spotlight-V100 -.TemporaryItems -.Trashes -.VolumeIcon.icns -.com.apple.timemachine.donotpresent - -# Directories potentially created on remote AFP share -.AppleDB -.AppleDesktop -Network Trash Folder -Temporary Items -.apdisk - -# content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore -# Windows thumbnail cache files -Thumbs.db -ehthumbs.db -ehthumbs_vista.db - -# Dump file -*.stackdump - -# Folder config file -[Dd]esktop.ini - -# Recycle Bin used on file shares -$RECYCLE.BIN/ - -# Windows Installer files -*.cab -*.msi -*.msix -*.msm -*.msp - -# Windows shortcuts -*.lnk - -# Vim temporary swap files -*.swp - -*/bin/ - - -NoteBookmark.Api/obj/ -NoteBookmark.Api/appsettings.Development.json - -NoteBookmark.BlazorApp/appsettings.Development.json -.azure - -NoteBookmark.AppHost/appsettings.Development.json - -src/NoteBookmark.AppHost/appsettings.[Dd]evelopment.json - -src/NoteBookmark.AppHost/appsettings.json +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from `dotnet new gitignore` + +# dotenv files +.env + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET +project.lock.json +project.fragment.lock.json +artifacts/ + +# Tye +.tye/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml +.idea + +## +## Visual studio for Mac +## + + +# globs +Makefile.in +*.userprefs +*.usertasks +config.make +config.status +aclocal.m4 +install-sh +autom4te.cache/ +*.tar.gz +tarballs/ +test-results/ + +# Mac bundle stuff +*.dmg +*.app + +# content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# Vim temporary swap files +*.swp + +*/bin/ + + +NoteBookmark.Api/obj/ +NoteBookmark.Api/appsettings.Development.json + +NoteBookmark.BlazorApp/appsettings.Development.json +.azure + +NoteBookmark.AppHost/appsettings.Development.json + +src/NoteBookmark.AppHost/appsettings.[Dd]evelopment.json + +src/NoteBookmark.AppHost/appsettings.json + +# Todos folder +todos/ + +# AI Team folder +.ai-team/ \ No newline at end of file diff --git a/Directory.Packages.props b/Directory.Packages.props index 5c232e3..ebf1998 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,48 +1,51 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/NoteBookmark.sln b/NoteBookmark.sln index c39b4a6..f97100e 100644 --- a/NoteBookmark.sln +++ b/NoteBookmark.sln @@ -17,6 +17,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NoteBookmark.Api.Tests", "s EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NoteBookmark.AIServices", "src\NoteBookmark.AIServices\NoteBookmark.AIServices.csproj", "{D29D80A5-82EC-4350-B738-96BAF88EB9DD}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NoteBookmark.AIServices.Tests", "src\NoteBookmark.AIServices.Tests\NoteBookmark.AIServices.Tests.csproj", "{13B6E1BC-4B32-4082-A080-FE443F598967}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -111,10 +115,25 @@ Global {D29D80A5-82EC-4350-B738-96BAF88EB9DD}.Release|x64.Build.0 = Release|Any CPU {D29D80A5-82EC-4350-B738-96BAF88EB9DD}.Release|x86.ActiveCfg = Release|Any CPU {D29D80A5-82EC-4350-B738-96BAF88EB9DD}.Release|x86.Build.0 = Release|Any CPU + {13B6E1BC-4B32-4082-A080-FE443F598967}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {13B6E1BC-4B32-4082-A080-FE443F598967}.Debug|Any CPU.Build.0 = Debug|Any CPU + {13B6E1BC-4B32-4082-A080-FE443F598967}.Debug|x64.ActiveCfg = Debug|Any CPU + {13B6E1BC-4B32-4082-A080-FE443F598967}.Debug|x64.Build.0 = Debug|Any CPU + {13B6E1BC-4B32-4082-A080-FE443F598967}.Debug|x86.ActiveCfg = Debug|Any CPU + {13B6E1BC-4B32-4082-A080-FE443F598967}.Debug|x86.Build.0 = Debug|Any CPU + {13B6E1BC-4B32-4082-A080-FE443F598967}.Release|Any CPU.ActiveCfg = Release|Any CPU + {13B6E1BC-4B32-4082-A080-FE443F598967}.Release|Any CPU.Build.0 = Release|Any CPU + {13B6E1BC-4B32-4082-A080-FE443F598967}.Release|x64.ActiveCfg = Release|Any CPU + {13B6E1BC-4B32-4082-A080-FE443F598967}.Release|x64.Build.0 = Release|Any CPU + {13B6E1BC-4B32-4082-A080-FE443F598967}.Release|x86.ActiveCfg = Release|Any CPU + {13B6E1BC-4B32-4082-A080-FE443F598967}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {13B6E1BC-4B32-4082-A080-FE443F598967} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {D59FFF09-97C3-47EF-B64D-B014BFA22C80} EndGlobalSection diff --git a/src/NoteBookmark.AIServices.Tests/NoteBookmark.AIServices.Tests.csproj b/src/NoteBookmark.AIServices.Tests/NoteBookmark.AIServices.Tests.csproj new file mode 100644 index 0000000..113669c --- /dev/null +++ b/src/NoteBookmark.AIServices.Tests/NoteBookmark.AIServices.Tests.csproj @@ -0,0 +1,36 @@ + + + + false + true + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/NoteBookmark.AIServices.Tests/ResearchServiceTests.cs b/src/NoteBookmark.AIServices.Tests/ResearchServiceTests.cs new file mode 100644 index 0000000..c5378d3 --- /dev/null +++ b/src/NoteBookmark.AIServices.Tests/ResearchServiceTests.cs @@ -0,0 +1,260 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using NoteBookmark.Domain; + +namespace NoteBookmark.AIServices.Tests; + +public class ResearchServiceTests +{ + private readonly Mock> _mockLogger; + + public ResearchServiceTests() + { + _mockLogger = new Mock>(); + } + + [Fact] + public async Task SearchSuggestionsAsync_WithMissingApiKey_ThrowsInvalidOperationException() + { + // Arrange + var settingsProvider = CreateSettingsProvider(apiKey: null); + var service = new ResearchService(_mockLogger.Object, settingsProvider); + var searchCriterias = new SearchCriterias("Test prompt") { SearchTopic = "AI" }; + + // Act + var result = await service.SearchSuggestionsAsync(searchCriterias); + + // Assert + result.Should().NotBeNull(); + result.Suggestions.Should().BeNullOrEmpty(); + VerifyErrorLogged("An error occurred while fetching research suggestions"); + } + + [Fact] + public async Task SearchSuggestionsAsync_WithMissingApiKey_LogsError() + { + // Arrange + var settingsProvider = CreateSettingsProvider(apiKey: null); + var service = new ResearchService(_mockLogger.Object, settingsProvider); + var searchCriterias = new SearchCriterias("Test prompt"); + + // Act + await service.SearchSuggestionsAsync(searchCriterias); + + // Assert + VerifyErrorLogged("An error occurred while fetching research suggestions"); + } + + [Fact] + public async Task SearchSuggestionsAsync_WithApiKeyFromAppSettings_UsesCorrectValue() + { + // Arrange + var settingsProvider = CreateSettingsProvider(apiKey: "test-api-key-from-settings"); + var service = new ResearchService(_mockLogger.Object, settingsProvider); + var searchCriterias = new SearchCriterias("Test prompt") { SearchTopic = "Testing" }; + + // Act - Will fail to connect but won't throw missing config exception + var result = await service.SearchSuggestionsAsync(searchCriterias); + + // Assert + result.Should().NotBeNull(); + } + + [Fact] + public async Task SearchSuggestionsAsync_WithApiKeyFromRekaEnvVar_UsesCorrectValue() + { + // Arrange + var settingsProvider = CreateSettingsProvider(apiKey: "test-reka-key"); + var service = new ResearchService(_mockLogger.Object, settingsProvider); + var searchCriterias = new SearchCriterias("Test prompt") { SearchTopic = "Testing" }; + + // Act + var result = await service.SearchSuggestionsAsync(searchCriterias); + + // Assert + result.Should().NotBeNull(); + } + + [Fact] + public async Task SearchSuggestionsAsync_WithCustomBaseUrl_UsesProvidedUrl() + { + // Arrange + const string customUrl = "https://custom.api.example.com/v1"; + var settingsProvider = CreateSettingsProvider(apiKey: "test-key", baseUrl: customUrl); + var service = new ResearchService(_mockLogger.Object, settingsProvider); + var searchCriterias = new SearchCriterias("Test prompt"); + + // Act + var result = await service.SearchSuggestionsAsync(searchCriterias); + + // Assert + result.Should().NotBeNull(); + } + + [Fact] + public async Task SearchSuggestionsAsync_WithDefaultBaseUrl_UsesRekaApi() + { + // Arrange + var settingsProvider = CreateSettingsProvider(apiKey: "test-key", baseUrl: "https://api.reka.ai/v1"); + var service = new ResearchService(_mockLogger.Object, settingsProvider); + var searchCriterias = new SearchCriterias("Test prompt"); + + // Act + var result = await service.SearchSuggestionsAsync(searchCriterias); + + // Assert - Should use default "https://api.reka.ai/v1" + result.Should().NotBeNull(); + } + + [Fact] + public async Task SearchSuggestionsAsync_WithCustomModelName_UsesProvidedModel() + { + // Arrange + const string customModel = "custom-model-v2"; + var settingsProvider = CreateSettingsProvider(apiKey: "test-key", modelName: customModel); + var service = new ResearchService(_mockLogger.Object, settingsProvider); + var searchCriterias = new SearchCriterias("Test prompt"); + + // Act + var result = await service.SearchSuggestionsAsync(searchCriterias); + + // Assert + result.Should().NotBeNull(); + } + + [Fact] + public async Task SearchSuggestionsAsync_WithDefaultModelName_UsesRekaFlashResearch() + { + // Arrange + var settingsProvider = CreateSettingsProvider(apiKey: "test-key", modelName: "reka-flash-research"); + var service = new ResearchService(_mockLogger.Object, settingsProvider); + var searchCriterias = new SearchCriterias("Test prompt"); + + // Act + var result = await service.SearchSuggestionsAsync(searchCriterias); + + // Assert - Should use default "reka-flash-research" + result.Should().NotBeNull(); + } + + [Fact] + public async Task SearchSuggestionsAsync_WithInvalidUrl_ReturnsEmptyPostSuggestions() + { + // Arrange + var settingsProvider = CreateSettingsProvider(apiKey: "test-key", baseUrl: "not-a-valid-url"); + var service = new ResearchService(_mockLogger.Object, settingsProvider); + var searchCriterias = new SearchCriterias("Test prompt"); + + // Act + var result = await service.SearchSuggestionsAsync(searchCriterias); + + // Assert + result.Should().NotBeNull(); + result.Suggestions.Should().BeNullOrEmpty(); + VerifyErrorLogged("An error occurred while fetching research suggestions"); + } + + [Fact] + public async Task SearchSuggestionsAsync_OnException_ReturnsEmptyPostSuggestions() + { + // Arrange + var settingsProvider = CreateSettingsProvider(apiKey: "test-key"); + var service = new ResearchService(_mockLogger.Object, settingsProvider); + var searchCriterias = new SearchCriterias("Test prompt"); + + // Act + var result = await service.SearchSuggestionsAsync(searchCriterias); + + // Assert + result.Should().NotBeNull(); + result.Should().BeOfType(); + } + + [Fact] + public async Task SearchSuggestionsAsync_WithValidSearchCriterias_ProcessesPrompt() + { + // Arrange + var settingsProvider = CreateSettingsProvider(apiKey: "test-key"); + var service = new ResearchService(_mockLogger.Object, settingsProvider); + var searchCriterias = new SearchCriterias("Find articles about {topic}") + { + SearchTopic = "Machine Learning", + AllowedDomains = "example.com, test.org", + BlockedDomains = "spam.com" + }; + + // Act + var result = await service.SearchSuggestionsAsync(searchCriterias); + + // Assert + result.Should().NotBeNull(); + var prompt = searchCriterias.GetSearchPrompt(); + prompt.Should().Contain("Machine Learning"); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public async Task SearchSuggestionsAsync_WithEmptyApiKey_ThrowsInvalidOperationException(string emptyKey) + { + // Arrange + var settingsProvider = CreateSettingsProvider(apiKey: emptyKey); + var service = new ResearchService(_mockLogger.Object, settingsProvider); + var searchCriterias = new SearchCriterias("Test prompt"); + + // Act + var result = await service.SearchSuggestionsAsync(searchCriterias); + + // Assert + result.Should().NotBeNull(); + result.Suggestions.Should().BeNullOrEmpty(); + VerifyErrorLogged("An error occurred while fetching research suggestions"); + } + + [Fact] + public async Task SearchSuggestionsAsync_ApiKeyPriority_AppSettingsOverridesEnvVar() + { + // Arrange - Both AppSettings and env var set, AppSettings should take precedence + var settingsProvider = CreateSettingsProvider(apiKey: "settings-key"); + var service = new ResearchService(_mockLogger.Object, settingsProvider); + var searchCriterias = new SearchCriterias("Test prompt"); + + // Act + var result = await service.SearchSuggestionsAsync(searchCriterias); + + // Assert + result.Should().NotBeNull(); + } + + private Func> CreateSettingsProvider( + string? apiKey = "test-api-key", + string? baseUrl = "https://api.reka.ai/v1", + string? modelName = "reka-flash-research") + { + return () => + { + if (string.IsNullOrWhiteSpace(apiKey)) + { + throw new InvalidOperationException("AI API key not configured"); + } + + return Task.FromResult(( + ApiKey: apiKey, + BaseUrl: baseUrl ?? "https://api.reka.ai/v1", + ModelName: modelName ?? "reka-flash-research" + )); + }; + } + + private void VerifyErrorLogged(string expectedMessagePart) + { + _mockLogger.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains(expectedMessagePart)), + It.IsAny(), + It.IsAny>()), + Times.Once); + } +} diff --git a/src/NoteBookmark.AIServices.Tests/SummaryServiceTests.cs b/src/NoteBookmark.AIServices.Tests/SummaryServiceTests.cs new file mode 100644 index 0000000..e4e3a1f --- /dev/null +++ b/src/NoteBookmark.AIServices.Tests/SummaryServiceTests.cs @@ -0,0 +1,253 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace NoteBookmark.AIServices.Tests; + +public class SummaryServiceTests +{ + private readonly Mock> _mockLogger; + + public SummaryServiceTests() + { + _mockLogger = new Mock>(); + } + + [Fact] + public async Task GenerateSummaryAsync_WithMissingApiKey_ReturnsEmptyString() + { + // Arrange + var settingsProvider = CreateSettingsProvider(apiKey: null); + var service = new SummaryService(_mockLogger.Object, settingsProvider); + + // Act + var result = await service.GenerateSummaryAsync("Summarize this text"); + + // Assert + result.Should().BeEmpty(); + VerifyErrorLogged("An error occurred while generating summary"); + } + + [Fact] + public async Task GenerateSummaryAsync_WithMissingApiKey_LogsError() + { + // Arrange + var settingsProvider = CreateSettingsProvider(apiKey: null); + var service = new SummaryService(_mockLogger.Object, settingsProvider); + + // Act + await service.GenerateSummaryAsync("Test prompt"); + + // Assert + VerifyErrorLogged("An error occurred while generating summary"); + } + + [Fact] + public async Task GenerateSummaryAsync_WithApiKeyFromAppSettings_UsesCorrectValue() + { + // Arrange + var settingsProvider = CreateSettingsProvider(apiKey: "test-api-key-from-settings"); + var service = new SummaryService(_mockLogger.Object, settingsProvider); + + // Act - Will fail to connect but won't throw missing config exception + var result = await service.GenerateSummaryAsync("Test prompt"); + + // Assert + result.Should().NotBeNull(); + } + + [Fact] + public async Task GenerateSummaryAsync_WithApiKeyFromRekaEnvVar_UsesCorrectValue() + { + // Arrange + var settingsProvider = CreateSettingsProvider(apiKey: "test-reka-key"); + var service = new SummaryService(_mockLogger.Object, settingsProvider); + + // Act + var result = await service.GenerateSummaryAsync("Test prompt"); + + // Assert + result.Should().NotBeNull(); + } + + [Fact] + public async Task GenerateSummaryAsync_WithCustomBaseUrl_UsesProvidedUrl() + { + // Arrange + const string customUrl = "https://custom.api.example.com/v1"; + var settingsProvider = CreateSettingsProvider(apiKey: "test-key", baseUrl: customUrl); + var service = new SummaryService(_mockLogger.Object, settingsProvider); + + // Act + var result = await service.GenerateSummaryAsync("Test prompt"); + + // Assert + result.Should().NotBeNull(); + } + + [Fact] + public async Task GenerateSummaryAsync_WithDefaultBaseUrl_UsesRekaApi() + { + // Arrange + var settingsProvider = CreateSettingsProvider(apiKey: "test-key", baseUrl: "https://api.reka.ai/v1"); + var service = new SummaryService(_mockLogger.Object, settingsProvider); + + // Act + var result = await service.GenerateSummaryAsync("Test prompt"); + + // Assert - Should use default "https://api.reka.ai/v1" + result.Should().NotBeNull(); + } + + [Fact] + public async Task GenerateSummaryAsync_WithCustomModelName_UsesProvidedModel() + { + // Arrange + const string customModel = "custom-model-v2"; + var settingsProvider = CreateSettingsProvider(apiKey: "test-key", modelName: customModel); + var service = new SummaryService(_mockLogger.Object, settingsProvider); + + // Act + var result = await service.GenerateSummaryAsync("Test prompt"); + + // Assert + result.Should().NotBeNull(); + } + + [Fact] + public async Task GenerateSummaryAsync_WithDefaultModelName_UsesRekaFlash31() + { + // Arrange + var settingsProvider = CreateSettingsProvider(apiKey: "test-key", modelName: "reka-flash-3.1"); + var service = new SummaryService(_mockLogger.Object, settingsProvider); + + // Act + var result = await service.GenerateSummaryAsync("Test prompt"); + + // Assert - Should use default "reka-flash-3.1" + result.Should().NotBeNull(); + } + + [Fact] + public async Task GenerateSummaryAsync_WithInvalidUrl_ReturnsEmptyString() + { + // Arrange + var settingsProvider = CreateSettingsProvider(apiKey: "test-key", baseUrl: "not-a-valid-url"); + var service = new SummaryService(_mockLogger.Object, settingsProvider); + + // Act + var result = await service.GenerateSummaryAsync("Test prompt"); + + // Assert + result.Should().BeEmpty(); + VerifyErrorLogged("An error occurred while generating summary"); + } + + [Fact] + public async Task GenerateSummaryAsync_OnException_ReturnsEmptyString() + { + // Arrange + var settingsProvider = CreateSettingsProvider(apiKey: "test-key"); + var service = new SummaryService(_mockLogger.Object, settingsProvider); + + // Act + var result = await service.GenerateSummaryAsync("Test prompt"); + + // Assert + result.Should().NotBeNull(); + result.Should().BeOfType(); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public async Task GenerateSummaryAsync_WithEmptyApiKey_ReturnsEmptyString(string emptyKey) + { + // Arrange + var settingsProvider = CreateSettingsProvider(apiKey: emptyKey); + var service = new SummaryService(_mockLogger.Object, settingsProvider); + + // Act + var result = await service.GenerateSummaryAsync("Test prompt"); + + // Assert + result.Should().BeEmpty(); + VerifyErrorLogged("An error occurred while generating summary"); + } + + [Fact] + public async Task GenerateSummaryAsync_ApiKeyPriority_AppSettingsOverridesEnvVar() + { + // Arrange - Both AppSettings and env var set, AppSettings should take precedence + var settingsProvider = CreateSettingsProvider(apiKey: "settings-key"); + var service = new SummaryService(_mockLogger.Object, settingsProvider); + + // Act + var result = await service.GenerateSummaryAsync("Test prompt"); + + // Assert + result.Should().NotBeNull(); + } + + [Theory] + [InlineData("Short prompt")] + [InlineData("This is a longer prompt that should be processed correctly by the service")] + [InlineData("Multi\nline\nprompt")] + public async Task GenerateSummaryAsync_WithVariousPrompts_HandlesCorrectly(string prompt) + { + // Arrange + var settingsProvider = CreateSettingsProvider(apiKey: "test-key"); + var service = new SummaryService(_mockLogger.Object, settingsProvider); + + // Act + var result = await service.GenerateSummaryAsync(prompt); + + // Assert + result.Should().NotBeNull(); + } + + [Fact] + public async Task GenerateSummaryAsync_WithNullPrompt_HandlesGracefully() + { + // Arrange + var settingsProvider = CreateSettingsProvider(apiKey: "test-key"); + var service = new SummaryService(_mockLogger.Object, settingsProvider); + + // Act + var result = await service.GenerateSummaryAsync(null!); + + // Assert + result.Should().NotBeNull(); + } + + private Func> CreateSettingsProvider( + string? apiKey = "test-api-key", + string? baseUrl = "https://api.reka.ai/v1", + string? modelName = "reka-flash-3.1") + { + return () => + { + if (string.IsNullOrWhiteSpace(apiKey)) + { + throw new InvalidOperationException("AI API key not configured"); + } + + return Task.FromResult(( + ApiKey: apiKey, + BaseUrl: baseUrl ?? "https://api.reka.ai/v1", + ModelName: modelName ?? "reka-flash-3.1" + )); + }; + } + + private void VerifyErrorLogged(string expectedMessagePart) + { + _mockLogger.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains(expectedMessagePart)), + It.IsAny(), + It.IsAny>()), + Times.Once); + } +} diff --git a/src/NoteBookmark.AIServices/NoteBookmark.AIServices.csproj b/src/NoteBookmark.AIServices/NoteBookmark.AIServices.csproj index 8ce1e84..c2962b9 100644 --- a/src/NoteBookmark.AIServices/NoteBookmark.AIServices.csproj +++ b/src/NoteBookmark.AIServices/NoteBookmark.AIServices.csproj @@ -1,14 +1,15 @@ - - - - - - - - - - - - - - + + + + + + + + + + + + + + + diff --git a/src/NoteBookmark.AIServices/ResearchService.cs b/src/NoteBookmark.AIServices/ResearchService.cs index bedd895..51e107c 100644 --- a/src/NoteBookmark.AIServices/ResearchService.cs +++ b/src/NoteBookmark.AIServices/ResearchService.cs @@ -1,147 +1,85 @@ -using System.Text; -using System.Text.Json; -using System.IO; -using System.Linq; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; -using Reka.SDK; -using NoteBookmark.Domain; - -namespace NoteBookmark.AIServices; - -public class ResearchService(HttpClient client, ILogger logger, IConfiguration config) -{ - private readonly HttpClient _client = client; - private readonly ILogger _logger = logger; - private const string BASE_URL = "https://api.reka.ai/v1/chat/completions"; - private const string MODEL_NAME = "reka-flash-research"; - private readonly string _apiKey = config["AppSettings:REKA_API_KEY"] ?? Environment.GetEnvironmentVariable("REKA_API_KEY") ?? throw new InvalidOperationException("REKA_API_KEY environment variable is not set."); - - public async Task SearchSuggestionsAsync(SearchCriterias searchCriterias) - { - PostSuggestions suggestions = new PostSuggestions(); - - var webSearch = new Dictionary - { - ["max_uses"] = 3 - }; - - var allowedDomains = searchCriterias.GetSplittedAllowedDomains(); - var blockedDomains = searchCriterias.GetSplittedBlockedDomains(); - - if (allowedDomains != null && allowedDomains.Length > 0) - { - webSearch["allowed_domains"] = allowedDomains; - } - else if (blockedDomains != null && blockedDomains.Length > 0) - { - webSearch["blocked_domains"] = blockedDomains; - } - - var requestPayload = new - { - model = MODEL_NAME, - - messages = new[] - { - new - { - role = "user", - content = searchCriterias.GetSearchPrompt() - } - }, - response_format = GetResponseFormat(), - research = new - { - web_search = webSearch - }, - }; - - var jsonPayload = JsonSerializer.Serialize(requestPayload, new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase - }); - - // await SaveToFile("research_request", jsonPayload); - - HttpResponseMessage? response = null; - - try - { - using var request = new HttpRequestMessage(HttpMethod.Post, BASE_URL); - request.Headers.Add("Authorization", $"Bearer {_apiKey}"); - request.Content = new StringContent(jsonPayload, Encoding.UTF8, "application/json"); - - response = await _client.SendAsync(request); - var responseContent = await response.Content.ReadAsStringAsync(); - - await SaveToFile("research_response", responseContent); - - var rekaResponse = JsonSerializer.Deserialize(responseContent); - - if (response.IsSuccessStatusCode) - { - suggestions = JsonSerializer.Deserialize(rekaResponse!.Choices![0].Message!.Content!)!; - } - else - { - throw new Exception($"Request failed with status code: {response.StatusCode}. Response: {responseContent}"); - } - } - catch (Exception ex) - { - _logger.LogError($"An error occurred while fetching research suggestions: {ex.Message}"); - } - - return suggestions; - } - - - private object GetResponseFormat() - { - return new - { - type = "json_schema", - json_schema = new - { - name = "post_suggestions", - schema = new - { - type = "object", - properties = new - { - suggestions = new - { - type = "array", - items = new - { - type = "object", - properties = new - { - title = new { type = "string" }, - author = new { type = "string" }, - summary = new { type = "string", maxLength = 100 }, - publication_date = new { type = "string", format = "date" }, - url = new { type = "string" } - }, - required = new[] { "title", "summary", "url" } - } - } - }, - required = new[] { "post_suggestions" } - } - } - }; - } - - private async Task SaveToFile(string prefix, string responseContent) - { - string datetime = DateTime.Now.ToString("yyyy-MM-dd_HH-mm"); - string fileName = $"{prefix}_{datetime}.json"; - string folderPath = "Data"; - Directory.CreateDirectory(folderPath); - string filePath = Path.Combine(folderPath, fileName); - await File.WriteAllTextAsync(filePath, responseContent); - } - +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using System.ClientModel; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.AI; +using Microsoft.Agents.AI; +using OpenAI; +using OpenAI.Chat; +using NoteBookmark.Domain; + +namespace NoteBookmark.AIServices; + +public class ResearchService +{ + private readonly ILogger _logger; + private readonly Func> _settingsProvider; + + public ResearchService( + ILogger logger, + Func> settingsProvider) + { + _logger = logger; + _settingsProvider = settingsProvider; + } + + public async Task SearchSuggestionsAsync(SearchCriterias searchCriterias) + { + PostSuggestions suggestions = new PostSuggestions(); + + try + { + var settings = await _settingsProvider(); + + IChatClient chatClient = new ChatClient( + settings.ModelName, + new ApiKeyCredential(settings.ApiKey), + new OpenAIClientOptions { Endpoint = new Uri(settings.BaseUrl) } + ).AsIChatClient(); + + var jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web) + { + TypeInfoResolver = new DefaultJsonTypeInfoResolver() + }; + + JsonElement schema = AIJsonUtilities.CreateJsonSchema(typeof(PostSuggestions), serializerOptions: jsonOptions); + + ChatOptions chatOptions = new() + { + ResponseFormat = Microsoft.Extensions.AI.ChatResponseFormat.ForJsonSchema( + schema: schema, + schemaName: "PostSuggestions", + schemaDescription: "A list of suggested posts with title, author, summary, publication date, and URL") + }; + + AIAgent agent = new ChatClientAgent(chatClient, new ChatClientAgentOptions + { + Name = "ResearchAgent", + ChatOptions = chatOptions + }); + + var prompt = searchCriterias.GetSearchPrompt(); + var response = await agent.RunAsync(prompt); + + suggestions = response.Deserialize(jsonOptions) ?? new PostSuggestions(); + + await SaveToFile("research_response", response.ToString() ?? string.Empty); + } + catch (Exception ex) + { + _logger.LogError($"An error occurred while fetching research suggestions: {ex.Message}"); + } + + return suggestions; + } + + private async Task SaveToFile(string prefix, string responseContent) + { + string datetime = DateTime.Now.ToString("yyyy-MM-dd_HH-mm"); + string fileName = $"{prefix}_{datetime}.json"; + string folderPath = "Data"; + Directory.CreateDirectory(folderPath); + string filePath = Path.Combine(folderPath, fileName); + await File.WriteAllTextAsync(filePath, responseContent); + } } \ No newline at end of file diff --git a/src/NoteBookmark.AIServices/SummaryService.cs b/src/NoteBookmark.AIServices/SummaryService.cs index 9257aa3..6ad953e 100644 --- a/src/NoteBookmark.AIServices/SummaryService.cs +++ b/src/NoteBookmark.AIServices/SummaryService.cs @@ -1,70 +1,49 @@ -using System.Text; -using System.Text.Json; -using System.IO; -using System.Linq; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; -using Reka.SDK; - -namespace NoteBookmark.AIServices; - -public class SummaryService(HttpClient client, ILogger logger, IConfiguration config) -{ - private readonly HttpClient _client = client; - private readonly ILogger _logger = logger; - private const string BASE_URL = "https://api.reka.ai/v1/chat"; - private const string MODEL_NAME = "reka-flash-3.1"; - private readonly string _apiKey = config["AppSettings:REKA_API_KEY"] ?? Environment.GetEnvironmentVariable("REKA_API_KEY") ?? throw new InvalidOperationException("REKA_API_KEY environment variable is not set."); - - public async Task GenerateSummaryAsync(string prompt) - { - string introParagraph; - - _client.Timeout = TimeSpan.FromSeconds(300); - - var requestPayload = new - { - model = MODEL_NAME, - - messages = new[] - { - new - { - role = "user", - content = prompt - } - } - }; - - var jsonPayload = JsonSerializer.Serialize(requestPayload, new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase - }); - - HttpResponseMessage? response = null; - - using var request = new HttpRequestMessage(HttpMethod.Post, BASE_URL); - request.Headers.Add("Authorization", $"Bearer {_apiKey}"); - request.Content = new StringContent(jsonPayload, Encoding.UTF8, "application/json"); - - response = await _client.SendAsync(request); - var responseContent = await response.Content.ReadAsStringAsync(); - - var rekaResponse = JsonSerializer.Deserialize(responseContent); - - if (response.IsSuccessStatusCode) - { - var textContent = rekaResponse!.Responses![0]!.Message!.Content! - .FirstOrDefault(c => c.Type == "text"); - - introParagraph = textContent?.Text ?? String.Empty; - } - else - { - throw new Exception($"Request failed with status code: {response.StatusCode}. Response: {responseContent}"); - } - - return introParagraph; - } - +using System.ClientModel; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.AI; +using Microsoft.Agents.AI; +using OpenAI; +using OpenAI.Chat; +using NoteBookmark.Domain; + +namespace NoteBookmark.AIServices; + +public class SummaryService +{ + private readonly ILogger _logger; + private readonly Func> _settingsProvider; + + public SummaryService( + ILogger logger, + Func> settingsProvider) + { + _logger = logger; + _settingsProvider = settingsProvider; + } + + public async Task GenerateSummaryAsync(string prompt) + { + try + { + var settings = await _settingsProvider(); + + IChatClient chatClient = new ChatClient( + settings.ModelName, + new ApiKeyCredential(settings.ApiKey), + new OpenAIClientOptions { Endpoint = new Uri(settings.BaseUrl) } + ).AsIChatClient(); + + AIAgent agent = new ChatClientAgent(chatClient, + instructions: "You are a helpful assistant that generates concise summaries.", + name: "SummaryAgent"); + + var response = await agent.RunAsync(prompt); + return response.ToString() ?? string.Empty; + } + catch (Exception ex) + { + _logger.LogError($"An error occurred while generating summary: {ex.Message}"); + return string.Empty; + } + } } \ No newline at end of file diff --git a/src/NoteBookmark.Api.Tests/Domain/PostSuggestionTests.cs b/src/NoteBookmark.Api.Tests/Domain/PostSuggestionTests.cs index f229a00..abe9355 100644 --- a/src/NoteBookmark.Api.Tests/Domain/PostSuggestionTests.cs +++ b/src/NoteBookmark.Api.Tests/Domain/PostSuggestionTests.cs @@ -227,4 +227,99 @@ public void DateConverter_ShouldFormatWithYearMonthDay() // Assert deserialized!.PublicationDate.Should().Match("????-??-??"); } + + [Fact] + public void Read_ShouldHandleBoolean_ReturnStringRepresentation() + { + // Arrange - AI might return boolean instead of date + var json = @"{ + ""title"": ""Test"", + ""summary"": ""Summary"", + ""publication_date"": true, + ""url"": ""https://test.com"" + }"; + + // Act + var result = JsonSerializer.Deserialize(json); + + // Assert - Should convert to string instead of throwing + result.Should().NotBeNull(); + result!.PublicationDate.Should().Be("True"); + } + + [Fact] + public void Read_ShouldHandleNumber_ParseAsTimestamp() + { + // Arrange - AI might return Unix timestamp + var json = @"{ + ""title"": ""Test"", + ""summary"": ""Summary"", + ""publication_date"": 1704067200, + ""url"": ""https://test.com"" + }"; + + // Act + var result = JsonSerializer.Deserialize(json); + + // Assert - Should convert Unix timestamp to yyyy-MM-dd + result.Should().NotBeNull(); + result!.PublicationDate.Should().Be("2024-01-01"); + } + + [Fact] + public void Read_ShouldHandleObject_ReturnNull() + { + // Arrange - AI might return object + var json = @"{ + ""title"": ""Test"", + ""summary"": ""Summary"", + ""publication_date"": { ""year"": 2024, ""month"": 1, ""day"": 15 }, + ""url"": ""https://test.com"" + }"; + + // Act + var result = JsonSerializer.Deserialize(json); + + // Assert - Should gracefully skip and return null + result.Should().NotBeNull(); + result!.PublicationDate.Should().BeNull(); + } + + [Fact] + public void Read_ShouldHandleArray_ReturnNull() + { + // Arrange - AI might return array + var json = @"{ + ""title"": ""Test"", + ""summary"": ""Summary"", + ""publication_date"": [2024, 1, 15], + ""url"": ""https://test.com"" + }"; + + // Act + var result = JsonSerializer.Deserialize(json); + + // Assert - Should gracefully skip and return null + result.Should().NotBeNull(); + result!.PublicationDate.Should().BeNull(); + } + + [Fact] + public void Read_ShouldHandleInvalidDateString_ReturnOriginal() + { + // Arrange - AI might return non-parseable date string + var json = @"{ + ""title"": ""Test"", + ""summary"": ""Summary"", + ""publication_date"": ""sometime in 2024"", + ""url"": ""https://test.com"" + }"; + + // Act + var result = JsonSerializer.Deserialize(json); + + // Assert - Should keep original string if not parseable + result.Should().NotBeNull(); + result!.PublicationDate.Should().Be("sometime in 2024"); + } } diff --git a/src/NoteBookmark.Api/AISettingsProvider.cs b/src/NoteBookmark.Api/AISettingsProvider.cs new file mode 100644 index 0000000..efcd15d --- /dev/null +++ b/src/NoteBookmark.Api/AISettingsProvider.cs @@ -0,0 +1,69 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace NoteBookmark.Api; + +/// +/// Provides AI configuration settings from the database with fallback to IConfiguration. +/// This ensures user-saved settings in Azure Table Storage take precedence over environment variables. +/// +public class AISettingsProvider : IAISettingsProvider +{ + private readonly IDataStorageService _dataStorageService; + private readonly IConfiguration _config; + private readonly ILogger _logger; + + public AISettingsProvider( + IDataStorageService dataStorageService, + IConfiguration config, + ILogger logger) + { + _dataStorageService = dataStorageService; + _config = config; + _logger = logger; + } + + public async Task<(string ApiKey, string BaseUrl, string ModelName)> GetAISettingsAsync() + { + try + { + // Try to get settings from database first (user-saved settings) + var settings = await _dataStorageService.GetSettings(); + + // Check if user has configured AI settings in the database + if (!string.IsNullOrWhiteSpace(settings.AiApiKey)) + { + _logger.LogDebug("Using AI settings from database"); + + string baseUrl = !string.IsNullOrWhiteSpace(settings.AiBaseUrl) + ? settings.AiBaseUrl + : "https://api.reka.ai/v1"; + + string modelName = !string.IsNullOrWhiteSpace(settings.AiModelName) + ? settings.AiModelName + : "reka-flash-3.1"; + + return (settings.AiApiKey, baseUrl, modelName); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to retrieve settings from database, falling back to configuration"); + } + + // Fallback to IConfiguration (environment variables, appsettings.json) + _logger.LogDebug("Using AI settings from IConfiguration fallback"); + + string? apiKey = _config["AppSettings:AiApiKey"] + ?? _config["AppSettings:REKA_API_KEY"] + ?? Environment.GetEnvironmentVariable("REKA_API_KEY"); + + if (string.IsNullOrEmpty(apiKey)) + throw new InvalidOperationException("AI API key not configured. Set AiApiKey in settings or REKA_API_KEY environment variable."); + + string fallbackBaseUrl = _config["AppSettings:AiBaseUrl"] ?? "https://api.reka.ai/v1"; + string fallbackModelName = _config["AppSettings:AiModelName"] ?? "reka-flash-3.1"; + + return (apiKey, fallbackBaseUrl, fallbackModelName); + } +} diff --git a/src/NoteBookmark.Api/IAISettingsProvider.cs b/src/NoteBookmark.Api/IAISettingsProvider.cs new file mode 100644 index 0000000..89f3f28 --- /dev/null +++ b/src/NoteBookmark.Api/IAISettingsProvider.cs @@ -0,0 +1,10 @@ +namespace NoteBookmark.Api; + +/// +/// Provides AI configuration settings from the database with fallback to IConfiguration. +/// This ensures user-saved settings take precedence over environment variables. +/// +public interface IAISettingsProvider +{ + Task<(string ApiKey, string BaseUrl, string ModelName)> GetAISettingsAsync(); +} diff --git a/src/NoteBookmark.Api/Program.cs b/src/NoteBookmark.Api/Program.cs index f8cfab6..6def1b2 100644 --- a/src/NoteBookmark.Api/Program.cs +++ b/src/NoteBookmark.Api/Program.cs @@ -1,36 +1,42 @@ -using Microsoft.Extensions.Azure; -using NoteBookmark.Api; - -var builder = WebApplication.CreateBuilder(args); - -builder.AddServiceDefaults(); -builder.AddAzureTableClient("nb-tables"); -builder.AddAzureBlobClient("nb-blobs"); - -// Add services to the container. -// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle -builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(); - -var app = builder.Build(); - -app.MapDefaultEndpoints(); - -// Configure the HTTP request pipeline. -if (app.Environment.IsDevelopment()) -{ - app.UseSwagger(); - app.UseSwaggerUI(); -} - -app.UseHttpsRedirection(); - -app.MapPostEndpoints(); -app.MapNoteEndpoints(); -app.MapSummaryEndpoints(); -app.MapSettingEndpoints(); - -app.Run(); - -// Make the Program class accessible for testing -public partial class Program { } +using Microsoft.Extensions.Azure; +using NoteBookmark.Api; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); +builder.AddAzureTableClient("nb-tables"); +builder.AddAzureBlobClient("nb-blobs"); + +// Add services to the container. +// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +// Register data storage service +builder.Services.AddScoped(); + +// Register AI settings provider +builder.Services.AddScoped(); + +var app = builder.Build(); + +app.MapDefaultEndpoints(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); + +app.MapPostEndpoints(); +app.MapNoteEndpoints(); +app.MapSummaryEndpoints(); +app.MapSettingEndpoints(); + +app.Run(); + +// Make the Program class accessible for testing +public partial class Program { } diff --git a/src/NoteBookmark.Api/SettingEndpoints.cs b/src/NoteBookmark.Api/SettingEndpoints.cs index 6301b92..d7a40aa 100644 --- a/src/NoteBookmark.Api/SettingEndpoints.cs +++ b/src/NoteBookmark.Api/SettingEndpoints.cs @@ -46,6 +46,14 @@ static async Task> SaveSettings(Settings settings, Table } var dataStorageService = new DataStorageService(tblClient, blobClient); + + // If API key is masked, preserve the existing value from database + if (settings.AiApiKey == "********") + { + var existingSettings = await dataStorageService.GetSettings(); + settings.AiApiKey = existingSettings.AiApiKey; + } + var result = await dataStorageService.SaveSettings(settings); return result ? TypedResults.Ok() : TypedResults.BadRequest(); } @@ -71,6 +79,12 @@ static async Task, BadRequest>> GetSettings(TableServiceCli settings.SummaryPrompt = "write a short introduction paragraph, without using '—', for the blog post: {content}"; } + // Security: Do not expose the API key to clients - return masked value + if (!string.IsNullOrWhiteSpace(settings.AiApiKey)) + { + settings.AiApiKey = "********"; // Masked for security + } + return settings != null ? TypedResults.Ok(settings) : TypedResults.BadRequest(); } } diff --git a/src/NoteBookmark.AppHost/AppHost.cs b/src/NoteBookmark.AppHost/AppHost.cs index 0ee93c9..d9460d7 100644 --- a/src/NoteBookmark.AppHost/AppHost.cs +++ b/src/NoteBookmark.AppHost/AppHost.cs @@ -28,6 +28,7 @@ builder.AddProject("blazor-app") .WithReference(api) + .WithReference(tables) // Server-side access to Azure Tables for unmasked settings .WaitFor(api) .WithExternalHttpEndpoints() .WithEnvironment("REKA_API_KEY", apiKey) diff --git a/src/NoteBookmark.BlazorApp/AISettingsProvider.cs b/src/NoteBookmark.BlazorApp/AISettingsProvider.cs new file mode 100644 index 0000000..71526c5 --- /dev/null +++ b/src/NoteBookmark.BlazorApp/AISettingsProvider.cs @@ -0,0 +1,79 @@ +using Azure.Data.Tables; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using NoteBookmark.Domain; + +namespace NoteBookmark.BlazorApp; + +/// +/// Server-side settings provider that retrieves unmasked AI configuration directly from Azure Table Storage. +/// This is only for internal server-side use by AI services - external API endpoints should mask secrets. +/// +public class AISettingsProvider +{ + private readonly TableServiceClient _tableClient; + private readonly IConfiguration _config; + private readonly ILogger _logger; + + public AISettingsProvider( + TableServiceClient tableClient, + IConfiguration config, + ILogger logger) + { + _tableClient = tableClient; + _config = config; + _logger = logger; + } + + public async Task<(string ApiKey, string BaseUrl, string ModelName)> GetAISettingsAsync() + { + try + { + // Direct database access - bypasses the HTTP API endpoint that masks secrets + var settingsTable = _tableClient.GetTableClient("Settings"); + await settingsTable.CreateIfNotExistsAsync(); + + var result = await settingsTable.GetEntityIfExistsAsync("setting", "setting"); + + if (result.HasValue) + { + var settings = result.Value; + + // Check if user has configured AI settings in the database + if (settings != null && !string.IsNullOrWhiteSpace(settings.AiApiKey)) + { + _logger.LogDebug("Using AI settings from database (unmasked for server-side use)"); + + string baseUrl = !string.IsNullOrWhiteSpace(settings.AiBaseUrl) + ? settings.AiBaseUrl + : "https://api.reka.ai/v1"; + + string modelName = !string.IsNullOrWhiteSpace(settings.AiModelName) + ? settings.AiModelName + : "reka-flash-3.1"; + + return (settings.AiApiKey, baseUrl, modelName); + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to retrieve settings from database, falling back to configuration"); + } + + // Fallback to IConfiguration (environment variables, appsettings.json) + _logger.LogDebug("Using AI settings from IConfiguration fallback"); + + string? apiKey = _config["AppSettings:AiApiKey"] + ?? _config["AppSettings:REKA_API_KEY"] + ?? Environment.GetEnvironmentVariable("REKA_API_KEY"); + + if (string.IsNullOrEmpty(apiKey)) + throw new InvalidOperationException("AI API key not configured. Set AiApiKey in settings or REKA_API_KEY environment variable."); + + string fallbackBaseUrl = _config["AppSettings:AiBaseUrl"] ?? "https://api.reka.ai/v1"; + string fallbackModelName = _config["AppSettings:AiModelName"] ?? "reka-flash-3.1"; + + return (apiKey, fallbackBaseUrl, fallbackModelName); + } +} diff --git a/src/NoteBookmark.BlazorApp/Components/Pages/Settings.razor b/src/NoteBookmark.BlazorApp/Components/Pages/Settings.razor index 4c57b86..4b3a16d 100644 --- a/src/NoteBookmark.BlazorApp/Components/Pages/Settings.razor +++ b/src/NoteBookmark.BlazorApp/Components/Pages/Settings.razor @@ -1,122 +1,138 @@ -@page "/settings" - -@using Microsoft.FluentUI.AspNetCore.Components.Extensions -@using NoteBookmark.Domain -@inject ILogger Logger -@inject PostNoteClient client -@inject NavigationManager Navigation -@using NoteBookmark.BlazorApp - -@rendermode InteractiveServer - - - -

Settings

- -
- - - - - - - - - - - @context - - - - - -
- -
- -@if( settings != null) -{ -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Save - - - -
-} - - -@code { - public DesignThemeModes Mode { get; set; } - public OfficeColor? OfficeColor { get; set; } - - private Domain.Settings? settings; - - protected override async Task OnInitializedAsync() - { - settings = await client.GetSettings(); - } - - private async Task SaveSettings() - { - if (settings != null) - { - await client.SaveSettings(settings); - Navigation.NavigateTo("/"); - } - } - - void OnLoaded(LoadedEventArgs e) - { - Logger.LogInformation($"Loaded: {(e.Mode == DesignThemeModes.System ? "System" : "")} {(e.IsDark ? "Dark" : "Light")}"); - } - - void OnLuminanceChanged(LuminanceChangedEventArgs e) - { - Logger.LogInformation($"Changed: {(e.Mode == DesignThemeModes.System ? "System" : "")} {(e.IsDark ? "Dark" : "Light")}"); - } - - private void IncrementCounter() - { - var cnt = Convert.ToInt32(settings!.ReadingNotesCounter)+1; - settings.ReadingNotesCounter = (cnt).ToString(); - } -} +@page "/settings" + +@using Microsoft.FluentUI.AspNetCore.Components.Extensions +@using NoteBookmark.Domain +@inject ILogger Logger +@inject PostNoteClient client +@inject NavigationManager Navigation +@using NoteBookmark.BlazorApp + +@rendermode InteractiveServer + + + +

Settings

+ +
+ + + + + + + + + + + @context + + + + + +
+ +
+ +@if( settings != null) +{ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + AI Provider Configuration + + + + + + + + + + + + + + Save + + + +
+} + + +@code { + public DesignThemeModes Mode { get; set; } + public OfficeColor? OfficeColor { get; set; } + + private Domain.Settings? settings; + + protected override async Task OnInitializedAsync() + { + settings = await client.GetSettings(); + } + + private async Task SaveSettings() + { + if (settings != null) + { + await client.SaveSettings(settings); + Navigation.NavigateTo("/"); + } + } + + void OnLoaded(LoadedEventArgs e) + { + Logger.LogInformation($"Loaded: {(e.Mode == DesignThemeModes.System ? "System" : "")} {(e.IsDark ? "Dark" : "Light")}"); + } + + void OnLuminanceChanged(LuminanceChangedEventArgs e) + { + Logger.LogInformation($"Changed: {(e.Mode == DesignThemeModes.System ? "System" : "")} {(e.IsDark ? "Dark" : "Light")}"); + } + + private void IncrementCounter() + { + var cnt = Convert.ToInt32(settings!.ReadingNotesCounter)+1; + settings.ReadingNotesCounter = (cnt).ToString(); + } +} diff --git a/src/NoteBookmark.BlazorApp/Data/research_response_2026-02-14_15-38.json b/src/NoteBookmark.BlazorApp/Data/research_response_2026-02-14_15-38.json new file mode 100644 index 0000000..c522dfa --- /dev/null +++ b/src/NoteBookmark.BlazorApp/Data/research_response_2026-02-14_15-38.json @@ -0,0 +1,25 @@ +{ + "suggestions": [ + { + "title": "5 Ways Your .NET Developers Can Get Started with Azure Machine Learning", + "author": "James Serra, Azure Developer Ecosystem blog", + "summary": "Azure Machine Learning is a cloud-based platform for building and deploying AI models. In this article we will explore how your .Net developers can get started working with Azure Machine Learning through Visual Studio Code.", + "publication_date": {}, + "url": "https://jamesmicrosoftcom/5-ways-get-started-with-azure-machine-learning-as-a-net-developer/" + }, + { + "title": "C# and C++ Machine Learning for .NET Developers and AI Researchers", + "author": "Pankaj Dua, aka ‘AI Guy’ on Microsoft’s Developer Community blog.", + "summary": "In this article we'll present how to use open-source libraries like Accord.NET to build machine learning models in your choice of languages i.e., C#, F# or even C++. We'll walk through building your 'first ML model' using popular tools that you might have never used.", + "publication_date": {}, + "url": "https://blogs.msdn.microsoft.com/ptgoa/c-cpp-companion-piece-on-ml-in-dot-net-world/" + }, + { + "title": ".NET AI – a new home for .NET Machine Learning", + "author": "Microsoft .Net blog", + "summary": "Find out about latest developments in the world of machine learning on .net, including deep dive into ONNX and .NET Core. ", + "publication_date": {}, + "url": "https://devblogs.microsoft.com/dotnet/net-ai-a-new-home-for-net-machine-learning/" + } + ] +} \ No newline at end of file diff --git a/src/NoteBookmark.BlazorApp/NoteBookmark.BlazorApp.csproj b/src/NoteBookmark.BlazorApp/NoteBookmark.BlazorApp.csproj index e9f531d..7cf93ce 100644 --- a/src/NoteBookmark.BlazorApp/NoteBookmark.BlazorApp.csproj +++ b/src/NoteBookmark.BlazorApp/NoteBookmark.BlazorApp.csproj @@ -1,6 +1,8 @@ + + diff --git a/src/NoteBookmark.BlazorApp/PostNoteClient.cs b/src/NoteBookmark.BlazorApp/PostNoteClient.cs index b6d734f..4ceee52 100644 --- a/src/NoteBookmark.BlazorApp/PostNoteClient.cs +++ b/src/NoteBookmark.BlazorApp/PostNoteClient.cs @@ -1,181 +1,181 @@ -using System; -using NoteBookmark.Domain; - -namespace NoteBookmark.BlazorApp; - -public class PostNoteClient(HttpClient httpClient) -{ - public async Task> GetUnreadPosts() - { - var posts = await httpClient.GetFromJsonAsync>("api/posts"); - return posts ?? new List(); - } - - public async Task> GetReadPosts() - { - var posts = await httpClient.GetFromJsonAsync>("api/posts/read"); - return posts ?? new List(); - } - - public async Task> GetSummaries() - { - var summaries = await httpClient.GetFromJsonAsync>("api/summary"); - return summaries ?? new List(); - } - - public async Task CreateNote(Note note) - { - var rnCounter = await httpClient.GetStringAsync("api/settings/GetNextReadingNotesCounter"); - note.PartitionKey = rnCounter; - var response = await httpClient.PostAsJsonAsync("api/notes/note", note); - response.EnsureSuccessStatusCode(); - } - - public async Task GetNote(string noteId) - { - var note = await httpClient.GetFromJsonAsync($"api/notes/note/{noteId}"); - return note; - } - - public async Task UpdateNote(Note note) - { - var response = await httpClient.PutAsJsonAsync("api/notes/note", note); - return response.IsSuccessStatusCode; - } - - public async Task DeleteNote(string noteId) - { - var response = await httpClient.DeleteAsync($"api/notes/note/{noteId}"); - return response.IsSuccessStatusCode; - } - - public async Task CreateReadingNotes() - { - var rnCounter = await httpClient.GetStringAsync("api/settings/GetNextReadingNotesCounter"); - var readingNotes = new ReadingNotes(rnCounter); - - //Get all unused notes - var unsortedNotes = await httpClient.GetFromJsonAsync>($"api/notes/GetNotesForSummary/{rnCounter}"); - - if(unsortedNotes == null || unsortedNotes.Count == 0){ - return readingNotes; - } - - Dictionary> sortedNotes = GroupNotesByCategory(unsortedNotes); - - readingNotes.Notes = sortedNotes; - readingNotes.Tags = readingNotes.GetAllUniqueTags(); - - return readingNotes; - } - - public async Task GetReadingNotes(string number) - { - ReadingNotes? readingNotes; - readingNotes = await httpClient.GetFromJsonAsync($"api/summary/{number}"); - - return readingNotes; - } - - - private Dictionary> GroupNotesByCategory(List notes) - { - var sortedNotes = new Dictionary>(); - - foreach (var note in notes) - { - var tags = note.Tags?.ToLower().Split(',') ?? Array.Empty(); - - if(string.IsNullOrEmpty(note.Category)){ - note.Category = NoteCategories.GetCategory(tags[0]); - } - - string category = note.Category; - if (sortedNotes.ContainsKey(category)) - { - sortedNotes[category].Add(note); - } - else - { - sortedNotes.Add(category, new List {note}); - } - } - - return sortedNotes; - } - - public async Task SaveReadingNotes(ReadingNotes readingNotes) - { - var response = await httpClient.PostAsJsonAsync("api/notes/SaveReadingNotes", readingNotes); - - string jsonURL = ((string)await response.Content.ReadAsStringAsync()).Replace("\"", ""); - - if (response.IsSuccessStatusCode && !string.IsNullOrEmpty(jsonURL)) - { - var summary = new Summary - { - PartitionKey = readingNotes.Number, - RowKey = readingNotes.Number, - Title = readingNotes.Title, - Id = readingNotes.Number, - IsGenerated = "true", - PublishedURL = readingNotes.PublishedUrl, - FileName = jsonURL - }; - - var summaryResponse = await httpClient.PostAsJsonAsync("api/summary/summary", summary); - return summaryResponse.IsSuccessStatusCode; - } - - return false; - } - - - public async Task GetPost(string id) - { - var post = await httpClient.GetFromJsonAsync($"api/posts/{id}"); - return post; - } - - - public async Task SavePost(Post post) - { - var response = await httpClient.PostAsJsonAsync("api/posts", post); - return response.IsSuccessStatusCode; - } - - public async Task GetSettings() - { - var settings = await httpClient.GetFromJsonAsync("api/settings"); - return settings; - } - - public async Task SaveSettings(Settings settings) - { - var response = await httpClient.PostAsJsonAsync("api/settings/SaveSettings", settings); - return response.IsSuccessStatusCode; - } - - public async Task ExtractPostDetailsAndSave(string url) - { - //var encodedUrl = System.Net.WebUtility.UrlEncode(url); - var requestBody = new {url = url}; - - var response = await httpClient.PostAsJsonAsync($"api/posts/extractPostDetails", requestBody); - // var response = await httpClient.PostAsJsonAsync($"api/posts/extractPostDetails?url={encodedUrl}", url); - return response.IsSuccessStatusCode; - } - - public async Task DeletePost(string id) - { - var response = await httpClient.DeleteAsync($"api/posts/{id}"); - return response.IsSuccessStatusCode; - } - - public async Task SaveReadingNotesMarkdown(string markdown, string number) - { - var request = new { Markdown = markdown }; - var response = await httpClient.PostAsJsonAsync($"api/summary/{number}/markdown", request); - return response.IsSuccessStatusCode; - } -} +using System; +using NoteBookmark.Domain; + +namespace NoteBookmark.BlazorApp; + +public class PostNoteClient(HttpClient httpClient) +{ + public async Task> GetUnreadPosts() + { + var posts = await httpClient.GetFromJsonAsync>("api/posts"); + return posts ?? new List(); + } + + public async Task> GetReadPosts() + { + var posts = await httpClient.GetFromJsonAsync>("api/posts/read"); + return posts ?? new List(); + } + + public async Task> GetSummaries() + { + var summaries = await httpClient.GetFromJsonAsync>("api/summary"); + return summaries ?? new List(); + } + + public async Task CreateNote(Note note) + { + var rnCounter = await httpClient.GetStringAsync("api/settings/GetNextReadingNotesCounter"); + note.PartitionKey = rnCounter; + var response = await httpClient.PostAsJsonAsync("api/notes/note", note); + response.EnsureSuccessStatusCode(); + } + + public async Task GetNote(string noteId) + { + var note = await httpClient.GetFromJsonAsync($"api/notes/note/{noteId}"); + return note; + } + + public async Task UpdateNote(Note note) + { + var response = await httpClient.PutAsJsonAsync("api/notes/note", note); + return response.IsSuccessStatusCode; + } + + public async Task DeleteNote(string noteId) + { + var response = await httpClient.DeleteAsync($"api/notes/note/{noteId}"); + return response.IsSuccessStatusCode; + } + + public async Task CreateReadingNotes() + { + var rnCounter = await httpClient.GetStringAsync("api/settings/GetNextReadingNotesCounter"); + var readingNotes = new ReadingNotes(rnCounter); + + //Get all unused notes + var unsortedNotes = await httpClient.GetFromJsonAsync>($"api/notes/GetNotesForSummary/{rnCounter}"); + + if(unsortedNotes == null || unsortedNotes.Count == 0){ + return readingNotes; + } + + Dictionary> sortedNotes = GroupNotesByCategory(unsortedNotes); + + readingNotes.Notes = sortedNotes; + readingNotes.Tags = readingNotes.GetAllUniqueTags(); + + return readingNotes; + } + + public async Task GetReadingNotes(string number) + { + ReadingNotes? readingNotes; + readingNotes = await httpClient.GetFromJsonAsync($"api/summary/{number}"); + + return readingNotes; + } + + + private Dictionary> GroupNotesByCategory(List notes) + { + var sortedNotes = new Dictionary>(); + + foreach (var note in notes) + { + var tags = note.Tags?.ToLower().Split(',') ?? Array.Empty(); + + if(string.IsNullOrEmpty(note.Category)){ + note.Category = NoteCategories.GetCategory(tags[0]); + } + + string category = note.Category; + if (sortedNotes.ContainsKey(category)) + { + sortedNotes[category].Add(note); + } + else + { + sortedNotes.Add(category, new List {note}); + } + } + + return sortedNotes; + } + + public async Task SaveReadingNotes(ReadingNotes readingNotes) + { + var response = await httpClient.PostAsJsonAsync("api/notes/SaveReadingNotes", readingNotes); + + string jsonURL = ((string)await response.Content.ReadAsStringAsync()).Replace("\"", ""); + + if (response.IsSuccessStatusCode && !string.IsNullOrEmpty(jsonURL)) + { + var summary = new Summary + { + PartitionKey = readingNotes.Number, + RowKey = readingNotes.Number, + Title = readingNotes.Title, + Id = readingNotes.Number, + IsGenerated = "true", + PublishedURL = readingNotes.PublishedUrl, + FileName = jsonURL + }; + + var summaryResponse = await httpClient.PostAsJsonAsync("api/summary/summary", summary); + return summaryResponse.IsSuccessStatusCode; + } + + return false; + } + + + public async Task GetPost(string id) + { + var post = await httpClient.GetFromJsonAsync($"api/posts/{id}"); + return post; + } + + + public async Task SavePost(Post post) + { + var response = await httpClient.PostAsJsonAsync("api/posts", post); + return response.IsSuccessStatusCode; + } + + public async Task GetSettings() + { + var settings = await httpClient.GetFromJsonAsync("api/settings"); + return settings; + } + + public async Task SaveSettings(Settings settings) + { + var response = await httpClient.PostAsJsonAsync("api/settings/SaveSettings", settings); + return response.IsSuccessStatusCode; + } + + public async Task ExtractPostDetailsAndSave(string url) + { + //var encodedUrl = System.Net.WebUtility.UrlEncode(url); + var requestBody = new {url = url}; + + var response = await httpClient.PostAsJsonAsync($"api/posts/extractPostDetails", requestBody); + // var response = await httpClient.PostAsJsonAsync($"api/posts/extractPostDetails?url={encodedUrl}", url); + return response.IsSuccessStatusCode; + } + + public async Task DeletePost(string id) + { + var response = await httpClient.DeleteAsync($"api/posts/{id}"); + return response.IsSuccessStatusCode; + } + + public async Task SaveReadingNotesMarkdown(string markdown, string number) + { + var request = new { Markdown = markdown }; + var response = await httpClient.PostAsJsonAsync($"api/summary/{number}/markdown", request); + return response.IsSuccessStatusCode; + } +} diff --git a/src/NoteBookmark.BlazorApp/Program.cs b/src/NoteBookmark.BlazorApp/Program.cs index 896b180..bdee7e0 100644 --- a/src/NoteBookmark.BlazorApp/Program.cs +++ b/src/NoteBookmark.BlazorApp/Program.cs @@ -6,40 +6,55 @@ var builder = WebApplication.CreateBuilder(args); builder.AddServiceDefaults(); +builder.AddAzureTableClient("nb-tables"); -// Register ResearchService with a manual HttpClient to bypass Aspire resilience policies -// builder.Services.AddTransient(sp => -// { -// var handler = new SocketsHttpHandler -// { -// PooledConnectionLifetime = TimeSpan.FromMinutes(5), -// ConnectTimeout = TimeSpan.FromMinutes(5) -// }; - -// var httpClient = new HttpClient(handler) -// { -// Timeout = TimeSpan.FromMinutes(5) -// }; - -// var logger = sp.GetRequiredService>(); -// var config = sp.GetRequiredService(); - -// return new ResearchService(httpClient, logger, config); -// }); - +// Add HTTP client for API calls builder.Services.AddHttpClient(client => { client.BaseAddress = new Uri("https+http://api"); }); -builder.Services.AddHttpClient(client => +// Register server-side AI settings provider (direct database access, unmasked) +builder.Services.AddScoped(); + +// Register AI services with settings provider that reads directly from database +builder.Services.AddTransient(sp => { - client.Timeout = TimeSpan.FromMinutes(5); + var logger = sp.GetRequiredService>(); + var settingsProvider = sp.GetRequiredService(); + + // Settings provider that fetches directly from database (server-side, unmasked) + Func> provider = async () => + { + var settings = await settingsProvider.GetAISettingsAsync(); + return ( + settings.ApiKey, + settings.BaseUrl, + settings.ModelName + ); + }; + + return new SummaryService(logger, provider); }); - -builder.Services.AddHttpClient(); - // .AddStandardResilienceHandler(); +builder.Services.AddTransient(sp => +{ + var logger = sp.GetRequiredService>(); + var settingsProvider = sp.GetRequiredService(); + + // Settings provider that fetches directly from database (server-side, unmasked) + Func> provider = async () => + { + var settings = await settingsProvider.GetAISettingsAsync(); + return ( + settings.ApiKey, + settings.BaseUrl, + settings.ModelName + ); + }; + + return new ResearchService(logger, provider); +}); // Add services to the container. diff --git a/src/NoteBookmark.Domain/PostSuggestion.cs b/src/NoteBookmark.Domain/PostSuggestion.cs index f6fe06a..54cd6b3 100644 --- a/src/NoteBookmark.Domain/PostSuggestion.cs +++ b/src/NoteBookmark.Domain/PostSuggestion.cs @@ -31,15 +31,57 @@ public class DateOnlyJsonConverter : JsonConverter if (reader.TokenType == JsonTokenType.Null) return null; - var dateString = reader.GetString(); - if (string.IsNullOrEmpty(dateString)) - return null; + try + { + // Handle different JSON token types the AI might return + switch (reader.TokenType) + { + case JsonTokenType.String: + var dateString = reader.GetString(); + if (string.IsNullOrEmpty(dateString)) + return null; + + // Try to parse as DateTime and format to yyyy-MM-dd + if (DateTime.TryParse(dateString, out var date)) + { + return date.ToString(DateFormat); + } + // If parsing fails, return the original string + return dateString; + + case JsonTokenType.Number: + // Handle Unix timestamp (seconds or milliseconds) + if (reader.TryGetInt64(out var timestamp)) + { + DateTime epoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + // Assume milliseconds if > year 2100 in seconds (2147483647) + var dateTime = timestamp > 2147483647 + ? epoch.AddMilliseconds(timestamp) + : epoch.AddSeconds(timestamp); + return dateTime.ToString(DateFormat); + } + break; - if (DateTime.TryParse(dateString, out var date)) + case JsonTokenType.True: + case JsonTokenType.False: + // Handle unexpected boolean - convert to string + return reader.GetBoolean().ToString(); + + case JsonTokenType.StartObject: + case JsonTokenType.StartArray: + // Handle complex types - skip and return null + reader.Skip(); + return null; + } + } + catch { - return date.ToString(DateFormat); + // If any parsing fails, skip the value and return null to gracefully degrade + try { reader.Skip(); } catch { /* ignore */ } + return null; } - return dateString; + + return null; } public override void Write(Utf8JsonWriter writer, string? value, JsonSerializerOptions options) diff --git a/src/NoteBookmark.Domain/Settings.cs b/src/NoteBookmark.Domain/Settings.cs index fe5e4eb..f37d026 100644 --- a/src/NoteBookmark.Domain/Settings.cs +++ b/src/NoteBookmark.Domain/Settings.cs @@ -1,40 +1,52 @@ -using System; -using System.ComponentModel.DataAnnotations; -using System.Runtime.Serialization; -using Azure; -using Azure.Data.Tables; - -namespace NoteBookmark.Domain; - -public class Settings: ITableEntity -{ - [DataMember(Name="last_bookmark_date")] - public string? LastBookmarkDate { get; set; } - - - [DataMember(Name="reading_notes_counter")] - public string? ReadingNotesCounter { get; set; } - - - [DataMember(Name="favorite_domains")] - public string? FavoriteDomains { get; set; } - - - [DataMember(Name="blocked_domains")] - public string? BlockedDomains { get; set; } - - - [DataMember(Name="summary_prompt")] - [ContainsPlaceholder("content")] - public string? SummaryPrompt { get; set; } - - - [DataMember(Name="search_prompt")] - [ContainsPlaceholder("topic")] - public string? SearchPrompt { get; set; } - - public required string PartitionKey { get ; set; } - public required string RowKey { get ; set; } - public DateTimeOffset? Timestamp { get; set; } - public ETag ETag { get; set; } -} +using System; +using System.ComponentModel.DataAnnotations; +using System.Runtime.Serialization; +using Azure; +using Azure.Data.Tables; + +namespace NoteBookmark.Domain; + +public class Settings: ITableEntity +{ + [DataMember(Name="last_bookmark_date")] + public string? LastBookmarkDate { get; set; } + + + [DataMember(Name="reading_notes_counter")] + public string? ReadingNotesCounter { get; set; } + + + [DataMember(Name="favorite_domains")] + public string? FavoriteDomains { get; set; } + + + [DataMember(Name="blocked_domains")] + public string? BlockedDomains { get; set; } + + + [DataMember(Name="summary_prompt")] + [ContainsPlaceholder("content")] + public string? SummaryPrompt { get; set; } + + + [DataMember(Name="search_prompt")] + [ContainsPlaceholder("topic")] + public string? SearchPrompt { get; set; } + + + [DataMember(Name="ai_api_key")] + public string? AiApiKey { get; set; } + + + [DataMember(Name="ai_base_url")] + public string? AiBaseUrl { get; set; } + + + [DataMember(Name="ai_model_name")] + public string? AiModelName { get; set; } + + public required string PartitionKey { get ; set; } + public required string RowKey { get ; set; } + public DateTimeOffset? Timestamp { get; set; } + public ETag ETag { get; set; } +}