semantic search

This commit is contained in:
Hamid Reza Mohammadi 2026-09-10 14:12:26 +03:30
parent 851ea607a2
commit 3d8b0867d8
4 changed files with 317 additions and 216 deletions

View File

@ -1,147 +0,0 @@
@page
@model GanjooRazor.Pages.SemanticSearchModel
@{
Layout = "_Layout";
ViewData["Title"] = "جست‌وجوی معنایی";
}
@section Head {
<meta name="description" content="یک شعر را با توصیف معنای آن پیدا کنید — نه با جست‌وجوی کلمه‌به‌کلمه." />
}
<div id="semantic-search-page" dir="rtl" style="max-width: 760px; margin: 0 auto; padding: 24px 16px 60px;">
<h1 style="text-align:center; color: var(--brand); -webkit-text-fill-color: var(--brand);">جست‌وجوی معنایی</h1>
<p style="text-align:center; color: var(--text-muted); margin-bottom: 24px;">
به‌جای جست‌وجوی کلمه‌به‌کلمه، توصیف کنید دنبال چه مضمونی هستید — مثلاً «شعری دربارهٔ بی‌وفایی دنیا».
<br />
<span style="font-size:0.85rem;">اگر نام یک سخنور یا یک بخش/کتاب خاص را هم در جمله بیاورید (مثلاً «در کدام شعر حافظ» یا «در کدام بخش شاهنامه»)، جست‌وجو فقط در همان محدوده انجام می‌شود.</span>
</p>
<form id="semantic-search-form" style="display:flex; gap:8px; margin-bottom: 24px;">
<input
type="text"
id="semantic-query"
name="query"
placeholder="مثلاً: شعری در مورد بی‌وفایی دنیا"
autocomplete="off"
style="flex:1; padding:10px 14px; border-radius:8px; border:1px solid var(--border-gold); background: var(--bg-page); color: inherit; font-size:1rem;"
/>
<input type="submit" value="جست‌وجو" />
</form>
<div id="semantic-search-status" style="text-align:center; color: var(--text-muted); display:none; margin-bottom: 16px;"></div>
<div id="semantic-search-results"></div>
</div>
@section Scripts {
<script>
(function () {
"use strict";
var form = document.getElementById("semantic-search-form");
var input = document.getElementById("semantic-query");
var status = document.getElementById("semantic-search-status");
var results = document.getElementById("semantic-search-results");
form.addEventListener("submit", function (e) {
e.preventDefault();
var query = input.value.trim();
if (!query) return;
runSearch(query);
});
function runSearch(query) {
results.innerHTML = "";
status.style.display = "block";
status.textContent = "در حال جست‌وجو...";
$.ajax({
type: "POST",
url: "?handler=Search",
data: { query: query },
success: function (response) {
status.style.display = "none";
renderResults(response);
},
error: function (xhr) {
status.style.display = "block";
if (xhr.status === 503) {
status.textContent = "در حال حاضر جست‌وجوی معنایی در دسترس نیست. لطفاً کمی بعد دوباره امتحان کنید.";
} else if (xhr.responseText) {
// show the real server error text rather than a generic message -
// whatever RMuseum actually said, so a real failure is diagnosable
// from what's on screen instead of needing another round-trip
status.textContent = "خطا: " + xhr.responseText;
} else {
status.textContent = "خطایی رخ داد (کد " + xhr.status + "). لطفاً دوباره تلاش کنید.";
}
}
});
}
function renderResults(response) {
if (!response || !response.results || response.results.length === 0) {
status.style.display = "block";
status.textContent = "نتیجه‌ای پیدا نشد.";
return;
}
var scopeNote = "";
if (response.detectedPoetName || response.detectedCategoryName) {
var scopeText = [response.detectedPoetName, response.detectedCategoryName].filter(Boolean).join(" » ");
scopeNote = '<p style="text-align:center; color: var(--gold, var(--text-muted)); font-size:0.88rem; margin-bottom:16px;">نتایج محدود به: ' + escapeHtml(scopeText) + '</p>';
}
var html = scopeNote;
response.results.forEach(function (item) {
html += '<div class="sitem poem-excerpt" style="margin-bottom:16px; padding:14px 16px; border:1px solid var(--border-gold); border-radius:10px;">';
html += '<a href="' + escapeAttr(item.fullUrl) + '" style="font-weight:600; font-size:1.05rem;">' + escapeHtml(item.fullTitle || item.title) + '</a>';
html += renderVerses(item.verses);
html += '</div>';
});
results.innerHTML = html;
}
// Same hemistich-pairing logic as mini-ganjoor's renderVerses (see
// https://ganjoor.github.io/mini/) - right/left couplet pairs render side by side,
// everything else (Comment, Paragraph, etc.) renders as a plain line. Reused as-is
// rather than reinvented, since it's already proven correct there.
function renderVerses(verses) {
if (!verses || verses.length === 0) return "";
var out = [];
var i = 0;
while (i < verses.length) {
var v = verses[i];
var pos = v.position;
if (pos === "Right" && verses[i + 1] && verses[i + 1].position === "Left") {
out.push(
'<div style="display:flex; justify-content:space-between; gap:16px; padding:4px 0;">' +
'<div style="flex:1;">' + escapeHtml(v.text) + '</div>' +
'<div style="flex:1;">' + escapeHtml(verses[i + 1].text) + '</div>' +
'</div>'
);
i += 2;
} else {
out.push('<div style="padding:4px 0;">' + escapeHtml(v.text) + '</div>');
i += 1;
}
}
return '<div style="margin-top:10px; color: var(--text-muted); font-size:0.95rem; line-height:2;">' + out.join("") + '</div>';
}
function escapeHtml(s) {
if (s == null) return "";
return String(s).replace(/[&<>"']/g, function (c) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
});
}
function escapeAttr(s) {
return escapeHtml(s);
}
})();
</script>
}

View File

@ -1,69 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using System;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
namespace GanjooRazor.Pages
{
/// <summary>
/// "find a poem about..." chatbox. Deliberately calls APIRoot.SemanticSearchUrl, NOT
/// APIRoot.Url/InternetUrl — see the comment on that property in APIRoot.cs. Currently a
/// physically separate domain/app pool (ganjgah.ir) from api.ganjoor.net, so a problem with
/// this one feature can't affect the main site or its API, a precaution proven necessary by
/// an actual production incident during development, not a hypothetical one.
/// </summary>
[IgnoreAntiforgeryToken(Order = 1001)]
public class SemanticSearchModel : PageModel
{
public void OnGet()
{
}
/// <summary>
/// proxies the query to the semantic search API and passes its JSON straight back to the
/// page's own JS — no local re-modeling of the response, so there's no risk of a shape
/// mismatch between the two sides silently dropping a field
/// </summary>
public async Task<IActionResult> OnPostSearchAsync(string query)
{
if (string.IsNullOrWhiteSpace(query))
{
return BadRequest("لطفاً عبارتی برای جست‌وجو وارد کنید.");
}
using (HttpClient client = new HttpClient())
{
client.Timeout = TimeSpan.FromSeconds(30); // query-time ONNX inference is not instant - give it real room, but not unbounded
var requestBody = "{\"query\":" + System.Text.Json.JsonSerializer.Serialize(query) + ",\"topK\":10}";
var content = new StringContent(requestBody, Encoding.UTF8, "application/json");
HttpResponseMessage response;
try
{
response = await client.PostAsync($"{APIRoot.SemanticSearchUrl}/api/ganjoor/search/semantic", content);
}
catch (Exception)
{
// a network-level failure reaching the isolated semantic-search domain -
// exactly the kind of failure this domain-level isolation is meant to
// contain to just this feature, so report it plainly rather than let an
// unhandled exception propagate
return StatusCode((int)HttpStatusCode.ServiceUnavailable,
"در حال حاضر جست‌وجوی معنایی در دسترس نیست. لطفاً کمی بعد دوباره امتحان کنید.");
}
string responseBody = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
return StatusCode((int)response.StatusCode, responseBody);
}
return Content(responseBody, "application/json");
}
}
}
}

View File

@ -49,6 +49,8 @@
<script src="/js/vaabd.js?version=10"></script>
<partial name="_SemanticSearchWidgetPartial" />
@await RenderSectionAsync("Scripts", required: false)
</body>
</html>

View File

@ -0,0 +1,315 @@
@{
// Global floating "find a poem about..." widget — included once from _Layout.cshtml, so it
// appears on every page that opts into that shared layout, same guarantee the theme toggle
// has. Self-contained CSS (scoped under #ss-widget* ids) rather than added to p8.css
// deliberately - avoids needing a cache-busting version bump on that large shared file for
// every iteration of a still-settling feature, at the cost of not being in the "official"
// stylesheet yet. Reuses real site CSS variables (--card-inner, --border-gold, --brand,
// --text-muted, --bg-page) throughout for visual consistency regardless.
//
// Calls APIRoot.SemanticSearchUrl DIRECTLY from browser JS (not a server-side proxy) - see
// the comment on SemanticSearchUrl in APIRoot.cs for why it's a separate, currently-isolated
// domain. CORS is already wide open on that backend (Startup.cs: SetIsOriginAllowed(_ =>
// true)), confirmed before choosing this approach rather than guessed.
}
<style>
#ss-widget-launcher {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 999;
display: inline-flex;
align-items: center;
justify-content: center;
width: 52px;
height: 52px;
border-radius: 50%;
background: var(--card-inner);
border: 1.5px solid var(--border-gold);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
cursor: pointer;
transition: all 0.25s ease;
}
#ss-widget-launcher:hover {
background: var(--card-nested);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
#ss-widget-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
z-index: 998;
}
#ss-widget-panel {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 1000;
width: 360px;
max-width: calc(100vw - 40px);
max-height: 70vh;
display: flex;
flex-direction: column;
background: var(--bg-page);
border: 1.5px solid var(--border-gold);
border-radius: 14px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.25);
overflow: hidden;
}
#ss-widget-panel.ss-widget-maximized {
top: 5vh;
bottom: 5vh;
left: 50%;
right: auto;
transform: translateX(-50%);
width: min(760px, 92vw);
max-height: none;
}
#ss-widget-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
border-bottom: 1px solid var(--border-gold);
font-weight: 600;
color: var(--brand);
-webkit-text-fill-color: var(--brand);
flex-shrink: 0;
}
#ss-widget-header .ss-widget-actions a {
color: var(--text-muted);
margin-inline-start: 12px;
cursor: pointer;
text-decoration: none;
font-size: 1.15rem;
}
#ss-widget-form {
display: flex;
gap: 8px;
padding: 10px 14px;
flex-shrink: 0;
}
#ss-widget-query {
flex: 1;
padding: 8px 12px;
border-radius: 8px;
border: 1px solid var(--border-gold);
background: var(--card-inner);
color: inherit;
font-size: 0.95rem;
}
#ss-widget-status {
padding: 0 14px 10px;
color: var(--text-muted);
font-size: 0.9rem;
display: none;
flex-shrink: 0;
}
#ss-widget-results {
overflow-y: auto;
padding: 0 14px 14px;
flex: 1;
}
.ss-widget-result {
margin-bottom: 12px;
padding: 10px 12px;
border: 1px solid var(--border-gold);
border-radius: 10px;
}
.ss-widget-result a.ss-widget-result-title {
font-weight: 600;
font-size: 0.98rem;
color: var(--brand);
-webkit-text-fill-color: var(--brand);
}
.ss-widget-verses {
margin-top: 6px;
color: var(--text-muted);
font-size: 0.88rem;
line-height: 1.9;
}
.ss-widget-couplet {
display: flex;
justify-content: space-between;
gap: 10px;
padding: 2px 0;
}
.ss-widget-scope-note {
text-align: center;
color: var(--gold, var(--text-muted));
font-size: 0.85rem;
margin-bottom: 10px;
}
</style>
<a href="javascript:void(0);" id="ss-widget-launcher" title="جست‌وجوی معنایی — پیدا کردن شعر با توصیف معنای آن" onclick="ssWidgetOpen()">
<svg height="24px" viewBox="0 -960 960 960" width="24px" fill="var(--gold, currentColor)">
<path d="M240-400h480v-80H240v80Zm0-120h480v-80H240v80Zm0-120h480v-80H240v80ZM80-80v-720q0-33 23.5-56.5T160-880h640q33 0 56.5 23.5T880-800v480q0 33-23.5 56.5T800-240H240L80-80Zm126-240h594v-480H160v525l46-45Zm-46 0v-480 480Z" />
</svg>
</a>
<div id="ss-widget-backdrop" style="display:none;" onclick="ssWidgetUnmaximize()"></div>
<div id="ss-widget-panel" style="display:none;">
<div id="ss-widget-header">
<span>جست‌وجوی معنایی</span>
<div class="ss-widget-actions">
<a href="javascript:void(0);" id="ss-widget-maximize-btn" onclick="ssWidgetToggleMaximize()" title="بزرگ‌نمایی">⤢</a>
<a href="javascript:void(0);" onclick="ssWidgetClose()" title="بستن">×</a>
</div>
</div>
<form id="ss-widget-form" onsubmit="return ssWidgetSubmit(event);">
<input type="text" id="ss-widget-query" placeholder="مثلاً: شعری در مورد بی‌وفایی دنیا" autocomplete="off" />
<input type="submit" value="جست‌وجو" />
</form>
<div id="ss-widget-status"></div>
<div id="ss-widget-results"></div>
</div>
<script>
var ssWidgetApiRoot = "@APIRoot.SemanticSearchUrl";
var ssWidgetMaximized = false;
function ssWidgetOpen() {
document.getElementById("ss-widget-launcher").style.display = "none";
document.getElementById("ss-widget-panel").style.display = "flex";
document.getElementById("ss-widget-query").focus();
}
function ssWidgetClose() {
document.getElementById("ss-widget-launcher").style.display = "inline-flex";
document.getElementById("ss-widget-panel").style.display = "none";
ssWidgetUnmaximize();
}
function ssWidgetToggleMaximize() {
if (ssWidgetMaximized) {
ssWidgetUnmaximize();
} else {
ssWidgetMaximized = true;
document.getElementById("ss-widget-panel").classList.add("ss-widget-maximized");
document.getElementById("ss-widget-backdrop").style.display = "block";
document.getElementById("ss-widget-maximize-btn").textContent = "⤡";
document.getElementById("ss-widget-maximize-btn").title = "بازگشت به حالت کوچک";
}
}
function ssWidgetUnmaximize() {
ssWidgetMaximized = false;
document.getElementById("ss-widget-panel").classList.remove("ss-widget-maximized");
document.getElementById("ss-widget-backdrop").style.display = "none";
document.getElementById("ss-widget-maximize-btn").textContent = "⤢";
document.getElementById("ss-widget-maximize-btn").title = "بزرگ‌نمایی";
}
function ssWidgetSubmit(e) {
e.preventDefault();
var query = document.getElementById("ss-widget-query").value.trim();
if (!query) return false;
ssWidgetRunSearch(query);
return false;
}
function ssWidgetRunSearch(query) {
var resultsEl = document.getElementById("ss-widget-results");
var statusEl = document.getElementById("ss-widget-status");
resultsEl.innerHTML = "";
statusEl.style.display = "block";
statusEl.textContent = "در حال جست‌وجو...";
$.ajax({
type: "POST",
url: ssWidgetApiRoot + "/api/ganjoor/search/semantic",
contentType: "application/json",
data: JSON.stringify({ query: query, topK: 10 }),
success: function (response) {
statusEl.style.display = "none";
ssWidgetRenderResults(response);
},
error: function (xhr) {
statusEl.style.display = "block";
if (xhr.status === 503) {
statusEl.textContent = "در حال حاضر جست‌وجوی معنایی در دسترس نیست. لطفاً کمی بعد دوباره امتحان کنید.";
} else if (xhr.responseText) {
statusEl.textContent = "خطا: " + xhr.responseText;
} else {
statusEl.textContent = "خطایی رخ داد (کد " + xhr.status + "). لطفاً دوباره تلاش کنید.";
}
}
});
}
function ssWidgetRenderResults(response) {
var resultsEl = document.getElementById("ss-widget-results");
var statusEl = document.getElementById("ss-widget-status");
if (!response || !response.results || response.results.length === 0) {
statusEl.style.display = "block";
statusEl.textContent = "نتیجه‌ای پیدا نشد.";
return;
}
var html = "";
if (response.detectedPoetName || response.detectedCategoryName) {
var scopeText = [response.detectedPoetName, response.detectedCategoryName].filter(Boolean).join(" » ");
html += '<p class="ss-widget-scope-note">نتایج محدود به: ' + ssWidgetEscapeHtml(scopeText) + '</p>';
}
response.results.forEach(function (item) {
html += '<div class="ss-widget-result">';
html += '<a class="ss-widget-result-title" href="' + ssWidgetEscapeHtml(item.fullUrl) + '">' + ssWidgetEscapeHtml(item.fullTitle || item.title) + '</a>';
html += ssWidgetRenderVerses(item.verses);
html += '</div>';
});
resultsEl.innerHTML = html;
}
// 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) {
if (!verses || verses.length === 0) return "";
var out = [];
var i = 0;
while (i < verses.length) {
var v = verses[i];
if (v.position === "Right" && verses[i + 1] && verses[i + 1].position === "Left") {
out.push(
'<div class="ss-widget-couplet">' +
'<div style="flex:1;">' + ssWidgetEscapeHtml(v.text) + '</div>' +
'<div style="flex:1;">' + ssWidgetEscapeHtml(verses[i + 1].text) + '</div>' +
'</div>'
);
i += 2;
} else {
out.push('<div style="padding:2px 0;">' + ssWidgetEscapeHtml(v.text) + '</div>');
i += 1;
}
}
return '<div class="ss-widget-verses">' + out.join("") + '</div>';
}
function ssWidgetEscapeHtml(s) {
if (s == null) return "";
return String(s).replace(/[&<>"']/g, function (c) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
});
}
</script>