semantic search

This commit is contained in:
Hamid Reza Mohammadi 2026-09-11 16:45:47 +03:30
parent 7f3bcebad7
commit 64fdb6318c
10 changed files with 6548 additions and 12 deletions

View File

@ -311,9 +311,12 @@
' <a href="javascript:void(0);" onclick="ssWidgetSearchGlobally()" class="ss-widget-scope-clear">(جستجوی سراسری)</a></p>';
}
response.results.forEach(function (item) {
response.results.forEach(function (item, index) {
var rank = index + 1;
html += '<div class="ss-widget-result">';
html += '<a class="ss-widget-result-title" href="' + ssWidgetEscapeHtml(item.fullUrl) + '">' + ssWidgetEscapeHtml(item.fullTitle || item.title) + '</a>';
html += '<a class="ss-widget-result-title" href="' + ssWidgetEscapeHtml(item.fullUrl) + '" onclick="ssWidgetReportClick(' +
JSON.stringify(response.logId) + ',' + JSON.stringify(item.poemId) + ',' + rank + ')">' +
ssWidgetEscapeHtml(item.fullTitle || item.title) + '</a>';
html += ssWidgetRenderVerses(item.verses);
html += '</div>';
});
@ -321,6 +324,29 @@
resultsEl.innerHTML = html;
}
// Fire-and-forget - does not preventDefault or delay navigation in any way, just queues the
// click report in the background while the browser proceeds to the poem page normally.
// sendBeacon is specifically designed for "report something right as the page is about to
// unload" - a plain fetch can get cancelled mid-flight by the navigation; sendBeacon (or a
// keepalive fetch as fallback for older browsers) doesn't have that problem.
function ssWidgetReportClick(logId, poemId, rank) {
if (!logId) return; // the search itself failed to log - nothing to correlate a click to
var payload = JSON.stringify({ logId: logId, poemId: poemId, rank: rank });
var url = ssWidgetApiRoot + "/api/ganjoor/search/semantic/click";
if (navigator.sendBeacon) {
navigator.sendBeacon(url, new Blob([payload], { type: "application/json" }));
} else {
fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: payload,
keepalive: true
}).catch(function () { });
}
}
// Same hemistich-pairing logic as mini-ganjoor's renderVerses (https://ganjoor.github.io/mini/)
// and SemanticSearch.cshtml - reused as-is rather than reinvented a third time.
function ssWidgetRenderVerses(verses) {

View File

@ -61,5 +61,21 @@ namespace RMuseum.Controllers
return BadRequest(exp.ToString());
}
}
/// <summary>
/// Fire-and-forget click reporting — called via navigator.sendBeacon (or a keepalive
/// fetch as fallback) right as a result link is clicked, so it can complete even as the
/// browser navigates away. ReportClickAsync itself is fully best-effort (never throws in
/// a way that matters here), so this always returns 200 regardless of whether the
/// underlying write actually succeeded — the caller isn't listening for the response
/// either way.
/// </summary>
[HttpPost("search/semantic/click")]
[ProducesResponseType((int)HttpStatusCode.OK)]
public async Task<IActionResult> SemanticSearchClick([FromBody] SemanticSearchClickDto click)
{
await _semanticSearchService.ReportClickAsync(click);
return Ok();
}
}
}

View File

@ -10,6 +10,7 @@ using RSecurityBackend.DbContext;
using RSecurityBackend.Models.Auth.Db;
using System;
using RMuseum.Models.Ganjoor;
using RMuseum.Models.Ganjoor.SemanticSearch;
using RMuseum.Models.MusicCatalogue;
using RMuseum.Models.Accounting;
using Microsoft.Extensions.Configuration;
@ -353,6 +354,12 @@ namespace RMuseum.DbContext
/// </summary>
public DbSet<GanjoorPoem> GanjoorPoems { get; set; }
/// <summary>
/// Semantic search query log — see SemanticSearchQueryLog for what is (and deliberately
/// isn't) recorded
/// </summary>
public DbSet<SemanticSearchQueryLog> SemanticSearchQueryLogs { get; set; }
/// <summary>
/// Ganjoor Verses
/// </summary>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,46 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace RMuseum.Migrations
{
/// <inheritdoc />
public partial class AddSemanticSearchQueryLog : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "SemanticSearchQueryLogs",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Query = table.Column<string>(type: "nvarchar(max)", nullable: true),
DateTimeUtc = table.Column<DateTime>(type: "datetime2", nullable: false),
RequestedTopK = table.Column<int>(type: "int", nullable: false),
ResultCount = table.Column<int>(type: "int", nullable: false),
TopResultScore = table.Column<float>(type: "real", nullable: true),
ScopeDetected = table.Column<bool>(type: "bit", nullable: false),
DetectedPoetName = table.Column<string>(type: "nvarchar(max)", nullable: true),
DetectedCategoryName = table.Column<string>(type: "nvarchar(max)", nullable: true),
ScopeDetectionDisabled = table.Column<bool>(type: "bit", nullable: false),
ClickedPoemId = table.Column<int>(type: "int", nullable: true),
ClickedResultRank = table.Column<int>(type: "int", nullable: true),
ClickedAtUtc = table.Column<DateTime>(type: "datetime2", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_SemanticSearchQueryLogs", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "SemanticSearchQueryLogs");
}
}
}

View File

@ -2651,6 +2651,55 @@ namespace RMuseum.Migrations
b.ToTable("GanjoorPoemMusicTracks");
});
modelBuilder.Entity("RMuseum.Models.Ganjoor.SemanticSearch.SemanticSearchQueryLog", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime?>("ClickedAtUtc")
.HasColumnType("datetime2");
b.Property<int?>("ClickedPoemId")
.HasColumnType("int");
b.Property<int?>("ClickedResultRank")
.HasColumnType("int");
b.Property<DateTime>("DateTimeUtc")
.HasColumnType("datetime2");
b.Property<string>("DetectedCategoryName")
.HasColumnType("nvarchar(max)");
b.Property<string>("DetectedPoetName")
.HasColumnType("nvarchar(max)");
b.Property<string>("Query")
.HasColumnType("nvarchar(max)");
b.Property<int>("RequestedTopK")
.HasColumnType("int");
b.Property<int>("ResultCount")
.HasColumnType("int");
b.Property<bool>("ScopeDetected")
.HasColumnType("bit");
b.Property<bool>("ScopeDetectionDisabled")
.HasColumnType("bit");
b.Property<float?>("TopResultScore")
.HasColumnType("real");
b.HasKey("Id");
b.ToTable("SemanticSearchQueryLogs");
});
modelBuilder.Entity("RMuseum.Models.Ganjoor.UpdatingRelSectsLog", b =>
{
b.Property<int>("Id")

View File

@ -83,6 +83,27 @@ namespace RMuseum.Models.Ganjoor.SemanticSearch
/// </summary>
public string DetectedPoetName { get; set; }
public string DetectedCategoryName { get; set; }
/// <summary>
/// Id of the SemanticSearchQueryLog row written for this request, or null if logging
/// itself failed (best-effort — see SemanticSearchService.SearchAsync — a logging
/// failure must never fail the search itself). The UI passes this back with
/// POST search/semantic/click if/when a result gets clicked, so the click can be
/// correlated to the search that produced it.
/// </summary>
public int? LogId { get; set; }
}
/// <summary>
/// Reported by the UI, fire-and-forget, if and when a person actually clicks through to one
/// of the results — see SemanticSearchQueryLog for what this updates.
/// </summary>
public class SemanticSearchClickDto
{
public int LogId { get; set; }
public int PoemId { get; set; }
/// <summary>1-based position of the clicked result in the list that was returned</summary>
public int Rank { get; set; }
}
}

View File

@ -0,0 +1,33 @@
using System;
namespace RMuseum.Models.Ganjoor.SemanticSearch
{
/// <summary>
/// A lightweight, privacy-conscious log of semantic search usage — query text and result
/// metadata only. Deliberately NO user identifiers: no IP address, no user agent, no
/// session/user id. Exists so ranking decisions (the AI-summary down-ranking, a future
/// couplet-level search, anything else) can be evaluated against real usage instead of a
/// handful of manually spot-checked queries.
/// </summary>
public class SemanticSearchQueryLog
{
public int Id { get; set; }
public string Query { get; set; }
public DateTime DateTimeUtc { get; set; }
public int RequestedTopK { get; set; }
public int ResultCount { get; set; }
public float? TopResultScore { get; set; }
public bool ScopeDetected { get; set; }
public string DetectedPoetName { get; set; }
public string DetectedCategoryName { get; set; }
public bool ScopeDetectionDisabled { get; set; }
// Filled in later, via POST search/semantic/click, if and only if a result actually gets
// clicked. Null/null/null (the expected common case — most searches presumably end
// without a click, or the person is just browsing results) is itself useful signal, not
// missing data.
public int? ClickedPoemId { get; set; }
public int? ClickedResultRank { get; set; }
public DateTime? ClickedAtUtc { get; set; }
}
}

View File

@ -3448,6 +3448,16 @@
semantic ("find a poem about...") search
</summary>
</member>
<member name="M:RMuseum.Controllers.SemanticSearchController.SemanticSearchClick(RMuseum.Models.Ganjoor.SemanticSearch.SemanticSearchClickDto)">
<summary>
Fire-and-forget click reporting — called via navigator.sendBeacon (or a keepalive
fetch as fallback) right as a result link is clicked, so it can complete even as the
browser navigates away. ReportClickAsync itself is fully best-effort (never throws in
a way that matters here), so this always returns 200 regardless of whether the
underlying write actually succeeded — the caller isn't listening for the response
either way.
</summary>
</member>
<member name="M:RMuseum.Controllers.SiteBannersController.AddSiteBanner">
<summary>
add site banner (send form) with these fields: alt, url and an image attachment
@ -3681,6 +3691,12 @@
Ganjoor Poems
</summary>
</member>
<member name="P:RMuseum.DbContext.RMuseumDbContext.SemanticSearchQueryLogs">
<summary>
Semantic search query log — see SemanticSearchQueryLog for what is (and deliberately
isn't) recorded
</summary>
</member>
<member name="P:RMuseum.DbContext.RMuseumDbContext.GanjoorVerses">
<summary>
Ganjoor Verses
@ -4695,6 +4711,18 @@
<member name="M:RMuseum.Migrations.RSecNewFields.BuildTargetModel(Microsoft.EntityFrameworkCore.ModelBuilder)">
<inheritdoc />
</member>
<member name="T:RMuseum.Migrations.AddSemanticSearchQueryLog">
<inheritdoc />
</member>
<member name="M:RMuseum.Migrations.AddSemanticSearchQueryLog.Up(Microsoft.EntityFrameworkCore.Migrations.MigrationBuilder)">
<inheritdoc />
</member>
<member name="M:RMuseum.Migrations.AddSemanticSearchQueryLog.Down(Microsoft.EntityFrameworkCore.Migrations.MigrationBuilder)">
<inheritdoc />
</member>
<member name="M:RMuseum.Migrations.AddSemanticSearchQueryLog.BuildTargetModel(Microsoft.EntityFrameworkCore.ModelBuilder)">
<inheritdoc />
</member>
<member name="T:RMuseum.Models.Accounting.DonationExpenditure">
<summary>
donation expenditures
@ -11385,6 +11413,16 @@
explicit PoetId/CatId narrows further, it doesn't replace detection.
</summary>
</member>
<member name="P:RMuseum.Models.Ganjoor.SemanticSearch.SemanticSearchRequestDto.DisableScopeDetection">
<summary>
Skips auto-detection entirely for this request — the "search globally instead"
escape hatch. Needed because substring-based detection is genuinely ambiguous
sometimes: "شمع و پروانه" is both a common poetic theme AND the literal title of a
book by a specific poet, so a query using it as a theme could get silently locked to
that one book with no way out otherwise. An explicit PoetId/CatId still applies even
with this set — that's a deliberate scope the caller asked for, not something guessed.
</summary>
</member>
<member name="P:RMuseum.Models.Ganjoor.SemanticSearch.SemanticSearchVerseDto.Position">
<summary>
matches GanjoorVerse.VersePosition.ToString() (e.g. "Right"/"Left") - same convention
@ -11414,6 +11452,33 @@
without any name being recognized in the free text).
</summary>
</member>
<member name="P:RMuseum.Models.Ganjoor.SemanticSearch.SemanticSearchResponseDto.LogId">
<summary>
Id of the SemanticSearchQueryLog row written for this request, or null if logging
itself failed (best-effort — see SemanticSearchService.SearchAsync — a logging
failure must never fail the search itself). The UI passes this back with
POST search/semantic/click if/when a result gets clicked, so the click can be
correlated to the search that produced it.
</summary>
</member>
<member name="T:RMuseum.Models.Ganjoor.SemanticSearch.SemanticSearchClickDto">
<summary>
Reported by the UI, fire-and-forget, if and when a person actually clicks through to one
of the results — see SemanticSearchQueryLog for what this updates.
</summary>
</member>
<member name="P:RMuseum.Models.Ganjoor.SemanticSearch.SemanticSearchClickDto.Rank">
<summary>1-based position of the clicked result in the list that was returned</summary>
</member>
<member name="T:RMuseum.Models.Ganjoor.SemanticSearch.SemanticSearchQueryLog">
<summary>
A lightweight, privacy-conscious log of semantic search usage — query text and result
metadata only. Deliberately NO user identifiers: no IP address, no user agent, no
session/user id. Exists so ranking decisions (the AI-summary down-ranking, a future
couplet-level search, anything else) can be evaluated against real usage instead of a
handful of manually spot-checked queries.
</summary>
</member>
<member name="T:RMuseum.Models.Ganjoor.UpdatingRelSectsLog">
<summary>
Updating related sections logs
@ -23119,6 +23184,12 @@
</summary>
<param name="roleManager"></param>
</member>
<member name="M:RMuseum.Services.Implementation.ISemanticSearchService.ReportClickAsync(RMuseum.Models.Ganjoor.SemanticSearch.SemanticSearchClickDto)">
<summary>
Best-effort — see the implementation for why this should never throw in a way the
caller needs to handle.
</summary>
</member>
<member name="T:RMuseum.Services.Implementation.SemanticSearchService">
<summary>
Deliberately NOT a GanjoorService partial, unlike everything else in this project.
@ -23142,6 +23213,14 @@
one must not be able to disable the other.
</summary>
</member>
<member name="M:RMuseum.Services.Implementation.SemanticSearchService.IsAiGeneratedSummary(System.String)">
<summary>
ganjoor-data's own editing workflow requires this exact prefix be removed once a
human has reviewed/edited a poem's summary — its continued presence is a direct,
already-existing signal for "not yet human-reviewed," not something this project
invented or has to infer.
</summary>
</member>
<member name="F:RMuseum.Services.Implementation.SemanticSearchService.PersianStopWords">
<summary>
Common Persian function words stripped out before keyword-matching a query against
@ -23162,15 +23241,25 @@
</summary>
</member>
<member name="M:RMuseum.Services.Implementation.SemanticSearchService.SelectPreviewVerses(System.Collections.Generic.List{RMuseum.Models.Ganjoor.GanjoorVerse},System.Collections.Generic.List{System.String},System.Int32)">
<summary>
Looks for the couplet (Right/Left verse pair) whose combined text contains the most
query keywords, and returns it (plus, if there's room within previewVerseCount, the
couplet immediately following it, for a little reading continuity rather than a
single isolated pair). Falls back to the poem's opening verses — the previous,
always-the-same-lines behavior — if no keyword appears anywhere in the scanned
verses, or if there were no real keywords to search for at all (a query that was
entirely stopwords, or empty after stripping them).
</summary>
<summary>
Looks for the couplet (Right/Left verse pair) whose combined text contains the most
query keywords, and returns it (plus, if there's room within previewVerseCount, the
couplet immediately following it, for a little reading continuity rather than a
single isolated pair). Falls back to the poem's opening verses — the previous,
always-the-same-lines behavior — if no keyword appears anywhere in the scanned
verses, or if there were no real keywords to search for at all (a query that was
entirely stopwords, or empty after stripping them).
Matches against CoupletSummary (when present) as well as the raw verse text — the
summary is clean, modern-language prose, while the verses themselves are archaic and
metaphorical and often won't literally contain a query's keywords even when the
couplet is genuinely on-topic. CoupletSummary is only ever read from the FIRST verse
of the pair (the "Right"/anchor position) — ganjoor-data stores it once per couplet
there, never on the second ("Left") verse; a small number of "Left" rows do carry a
stray value (a data anomaly, not a second legitimate copy), and this deliberately
never reads it from that position, matching how the data is actually meant to be laid
out rather than how a few rows happen to look.
</summary>
</member>
<member name="M:RMuseum.Services.Implementation.SemanticSearchService.GetDescendantCategoryIdsAsync(RMuseum.DbContext.RMuseumDbContext,System.Int32)">
<summary>
@ -24604,6 +24693,20 @@
SUPPOSED to have it (ganjgah.ir) needs the key explicitly set to "True", not left implicit.
</summary>
</member>
<member name="P:RMuseum.Utils.SemanticSearch.LazySemanticSearchResources.AiSummaryScorePenalty">
<summary>
A gentle re-ranking multiplier applied to results whose PoemSummary is still
AI-generated and un-reviewed (detected by the "هوش مصنوعی:" prefix ganjoor-data's own
editing workflow requires removing once a human has reviewed/edited a summary — see
SemanticSearchService for how this is actually applied). A soft nudge, not a filter:
~95% of summaries currently carry this prefix, so excluding them outright would gut
coverage for most queries. Configurable (SemanticSearch:AiSummaryScorePenalty) rather
than hardcoded, since the right effect size here is a judgment call worth tuning
without a redeploy. Read here (not directly in SemanticSearchService) purely to reuse
the config-reading this class already does — this value has nothing to do with the
lazy-loaded embeddings/model themselves and is available even when Enabled is false.
</summary>
</member>
<member name="M:RMuseum.Utils.SemanticSearch.LazySemanticSearchResources.TryGetResources(RMuseum.Utils.SemanticSearch.EmbeddingIndex@,RMuseum.Utils.SemanticSearch.QueryEmbedder@,System.String@)">
<summary>
Attempts to load the resources on first call (subsequent calls reuse the same result,

View File

@ -13,6 +13,12 @@ namespace RMuseum.Services.Implementation
public interface ISemanticSearchService
{
Task<SemanticSearchResponseDto> SearchAsync(SemanticSearchRequestDto request);
/// <summary>
/// Best-effort — see the implementation for why this should never throw in a way the
/// caller needs to handle.
/// </summary>
Task ReportClickAsync(SemanticSearchClickDto click);
}
/// <summary>
@ -216,11 +222,66 @@ namespace RMuseum.Services.Implementation
}).ToList(),
});
}
// Best-effort logging, using the same context already open for this request - a
// logging failure must never fail a real search, so any exception here is
// swallowed and response.LogId simply stays null (ReportClickAsync already
// no-ops safely if it's ever called with a null/0 LogId from a search that
// couldn't be logged).
try
{
var log = new SemanticSearchQueryLog
{
Query = request.Query,
DateTimeUtc = DateTime.UtcNow,
RequestedTopK = k,
ResultCount = response.Results.Count,
TopResultScore = response.Results.Count > 0 ? (float?)response.Results[0].Score : null,
ScopeDetected = !string.IsNullOrEmpty(response.DetectedPoetName) || !string.IsNullOrEmpty(response.DetectedCategoryName),
DetectedPoetName = response.DetectedPoetName,
DetectedCategoryName = response.DetectedCategoryName,
ScopeDetectionDisabled = request.DisableScopeDetection,
};
context.SemanticSearchQueryLogs.Add(log);
await context.SaveChangesAsync();
response.LogId = log.Id;
}
catch (Exception)
{
// logging is best-effort only - never worth failing a real search over
}
}
return response;
}
public async Task ReportClickAsync(SemanticSearchClickDto click)
{
if (click == null || click.LogId <= 0)
return;
try
{
using (RMuseumDbContext context = new RMuseumDbContext(new DbContextOptions<RMuseumDbContext>()))
{
var log = await context.SemanticSearchQueryLogs.FindAsync(click.LogId);
if (log != null)
{
log.ClickedPoemId = click.PoemId;
log.ClickedResultRank = click.Rank;
log.ClickedAtUtc = DateTime.UtcNow;
await context.SaveChangesAsync();
}
}
}
catch (Exception)
{
// click reporting is best-effort only - the click/navigation has already
// happened regardless of whether this write succeeds, never worth surfacing an
// error to the (fire-and-forget) caller for this
}
}
/// <summary>
/// ganjoor-data's own editing workflow requires this exact prefix be removed once a
/// human has reviewed/edited a poem's summary — its continued presence is a direct,