Commit 16d28bf873926a1801d39fb6cffbaacab52d3c27

Authored by tangwang
1 parent 8c8b9d84

漏斗信息呈现,便于调整参数

frontend/static/css/style.css
... ... @@ -357,16 +357,22 @@ body {
357 357 color: #555;
358 358 border-left: 1px dashed #eee;
359 359 padding-left: 12px;
360   - max-height: 260px;
  360 + max-height: 540px;
361 361 overflow: auto;
362 362 }
363 363  
364 364 .product-debug-title {
365 365 font-weight: 600;
366   - margin-bottom: 6px;
  366 + margin-bottom: 8px;
367 367 color: #333;
368 368 }
369 369  
  370 +.product-debug-subtitle {
  371 + margin: 10px 0 6px;
  372 + font-weight: 600;
  373 + color: #666;
  374 +}
  375 +
370 376 .product-debug-line {
371 377 margin-bottom: 2px;
372 378 }
... ... @@ -418,6 +424,191 @@ body {
418 424 word-break: break-word;
419 425 }
420 426  
  427 +.debug-panel {
  428 + display: flex;
  429 + flex-direction: column;
  430 + gap: 14px;
  431 + padding: 12px;
  432 + font-family: Menlo, Consolas, "Courier New", monospace;
  433 + font-size: 12px;
  434 +}
  435 +
  436 +.debug-section-block {
  437 + background: #fff;
  438 + border: 1px solid #e8e8e8;
  439 + border-radius: 10px;
  440 + padding: 14px;
  441 +}
  442 +
  443 +.debug-section-title {
  444 + font-size: 13px;
  445 + font-weight: 700;
  446 + color: #222;
  447 + margin-bottom: 10px;
  448 +}
  449 +
  450 +.debug-stage-grid {
  451 + display: grid;
  452 + grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
  453 + gap: 12px;
  454 +}
  455 +
  456 +.debug-stage-card {
  457 + border: 1px solid #ececec;
  458 + border-radius: 8px;
  459 + padding: 12px;
  460 + background: linear-gradient(180deg, #fff 0%, #fafafa 100%);
  461 +}
  462 +
  463 +.debug-stage-title {
  464 + font-size: 13px;
  465 + font-weight: 700;
  466 + color: #333;
  467 +}
  468 +
  469 +.debug-stage-subtitle {
  470 + margin: 4px 0 8px;
  471 + color: #888;
  472 + font-size: 11px;
  473 +}
  474 +
  475 +.debug-metrics {
  476 + display: grid;
  477 + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
  478 + gap: 8px;
  479 +}
  480 +
  481 +.debug-metric {
  482 + padding: 8px 9px;
  483 + background: #f7f7f7;
  484 + border-radius: 6px;
  485 + border: 1px solid #efefef;
  486 +}
  487 +
  488 +.debug-metric-label {
  489 + font-size: 11px;
  490 + color: #777;
  491 + margin-bottom: 2px;
  492 +}
  493 +
  494 +.debug-metric-value {
  495 + color: #222;
  496 + font-weight: 600;
  497 + word-break: break-word;
  498 +}
  499 +
  500 +.debug-score-pills {
  501 + display: flex;
  502 + flex-wrap: wrap;
  503 + gap: 8px;
  504 + margin-bottom: 6px;
  505 +}
  506 +
  507 +.debug-score-pill {
  508 + display: inline-flex;
  509 + align-items: center;
  510 + gap: 8px;
  511 + padding: 6px 10px;
  512 + border-radius: 999px;
  513 + border: 1px solid #e3e3e3;
  514 + background: #f7f7f7;
  515 +}
  516 +
  517 +.debug-score-pill-label {
  518 + color: #666;
  519 +}
  520 +
  521 +.debug-score-pill-value {
  522 + color: #111;
  523 + font-weight: 700;
  524 +}
  525 +
  526 +.tone-es {
  527 + background: #f8f1ff;
  528 + border-color: #e6d5ff;
  529 +}
  530 +
  531 +.tone-coarse {
  532 + background: #eef8ff;
  533 + border-color: #cae8ff;
  534 +}
  535 +
  536 +.tone-fine {
  537 + background: #f3fbef;
  538 + border-color: #d8f1c8;
  539 +}
  540 +
  541 +.tone-rerank {
  542 + background: #fff4e8;
  543 + border-color: #ffd9b0;
  544 +}
  545 +
  546 +.tone-final {
  547 + background: #fff1f0;
  548 + border-color: #ffc9c4;
  549 +}
  550 +
  551 +.tone-neutral {
  552 + background: #f5f5f5;
  553 +}
  554 +
  555 +.debug-details {
  556 + margin-top: 10px;
  557 +}
  558 +
  559 +.debug-details summary {
  560 + cursor: pointer;
  561 + color: #555;
  562 + font-weight: 600;
  563 +}
  564 +
  565 +.debug-json-pre {
  566 + margin-top: 8px;
  567 + padding: 10px;
  568 + background: #f5f5f5;
  569 + border-radius: 6px;
  570 + overflow: auto;
  571 + max-height: 240px;
  572 + white-space: pre-wrap;
  573 + word-break: break-word;
  574 +}
  575 +
  576 +.debug-timing-list {
  577 + display: flex;
  578 + flex-direction: column;
  579 + gap: 8px;
  580 +}
  581 +
  582 +.debug-timing-row {
  583 + display: grid;
  584 + grid-template-columns: 220px 1fr 90px;
  585 + gap: 10px;
  586 + align-items: center;
  587 +}
  588 +
  589 +.debug-timing-label {
  590 + color: #444;
  591 +}
  592 +
  593 +.debug-timing-bar-wrap {
  594 + height: 10px;
  595 + background: #f0f0f0;
  596 + border-radius: 999px;
  597 + overflow: hidden;
  598 +}
  599 +
  600 +.debug-timing-bar {
  601 + height: 100%;
  602 + background: linear-gradient(90deg, #f39c12 0%, #e74c3c 100%);
  603 + border-radius: 999px;
  604 +}
  605 +
  606 +.debug-timing-value {
  607 + text-align: right;
  608 + color: #666;
  609 + font-weight: 600;
  610 +}
  611 +
421 612 .product-debug-link {
422 613 display: inline-block;
423 614 margin-top: 0;
... ... @@ -687,10 +878,41 @@ footer span {
687 878 }
688 879  
689 880 .product-grid {
690   - grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
691   - gap: 15px;
692 881 padding: 15px;
693 882 }
  883 +
  884 + .product-card {
  885 + flex-direction: column;
  886 + }
  887 +
  888 + .product-main {
  889 + width: 100%;
  890 + }
  891 +
  892 + .product-image-wrapper {
  893 + width: 100%;
  894 + max-width: 320px;
  895 + }
  896 +
  897 + .product-debug {
  898 + width: 100%;
  899 + border-left: none;
  900 + border-top: 1px dashed #eee;
  901 + padding-left: 0;
  902 + padding-top: 12px;
  903 + }
  904 +
  905 + .debug-stage-grid {
  906 + grid-template-columns: 1fr;
  907 + }
  908 +
  909 + .debug-timing-row {
  910 + grid-template-columns: 1fr;
  911 + }
  912 +
  913 + .debug-timing-value {
  914 + text-align: left;
  915 + }
694 916  
695 917 .pagination {
696 918 padding: 20px 15px;
... ... @@ -699,10 +921,6 @@ footer span {
699 921 }
700 922  
701 923 @media (max-width: 480px) {
702   - .product-grid {
703   - grid-template-columns: repeat(2, 1fr);
704   - }
705   -
706 924 .header-left {
707 925 gap: 15px;
708 926 }
... ...
frontend/static/js/app.js
... ... @@ -407,89 +407,12 @@ function displayResults(data) {
407 407  
408 408 let debugHtml = '';
409 409 if (debug) {
410   - const esScore = typeof debug.es_score === 'number' ? debug.es_score.toFixed(4) : String(debug.es_score ?? '');
411   - const es_score_normalized = typeof debug.es_score_normalized === 'number'
412   - ? debug.es_score_normalized.toFixed(4)
413   - : (debug.es_score_normalized == null ? '' : String(debug.es_score_normalized));
414   - const rerankScore = typeof debug.rerank_score === 'number'
415   - ? debug.rerank_score.toFixed(4)
416   - : (debug.rerank_score == null ? '' : String(debug.rerank_score));
417   -
418   - const fusedScore = typeof debug.fused_score === 'number'
419   - ? debug.fused_score.toFixed(4)
420   - : (debug.fused_score == null ? '' : String(debug.fused_score));
421   -
422   - // Build multilingual title info
423   - let titleLines = '';
424   - if (debug.title_multilingual && typeof debug.title_multilingual === 'object') {
425   - Object.entries(debug.title_multilingual).forEach(([lang, val]) => {
426   - if (val) {
427   - titleLines += `<div class="product-debug-line">title.${escapeHtml(String(lang))}: ${escapeHtml(String(val))}</div>`;
428   - }
429   - });
430   - }
431   -
432   - const resultJson = customStringify(result);
433   - const rawUrl = `${API_BASE_URL}/search/es-doc/${encodeURIComponent(spuId)}?tenant_id=${encodeURIComponent(tenantId)}`;
434   - const rerankInputHtml = debug.rerank_input
435   - ? `
436   - <details open>
437   - <summary>Rerank input</summary>
438   - <pre style="background: #f5f5f5; padding: 10px; overflow: auto; max-height: 220px;">${escapeHtml(customStringify(debug.rerank_input))}</pre>
439   - </details>
440   - `
441   - : '';
442   - const styleIntentHtml = debug.style_intent_sku
443   - ? `
444   - <details open>
445   - <summary>Selected SKU</summary>
446   - <pre style="background: #f5f5f5; padding: 10px; overflow: auto; max-height: 220px;">${escapeHtml(customStringify(debug.style_intent_sku))}</pre>
447   - </details>
448   - `
449   - : '';
450   - const matchedQueriesHtml = debug.matched_queries
451   - ? `
452   - <details open>
453   - <summary>matched_queries</summary>
454   - <pre style="background: #f5f5f5; padding: 10px; overflow: auto; max-height: 220px;">${escapeHtml(customStringify(debug.matched_queries))}</pre>
455   - </details>
456   - `
457   - : '';
458   -
459   - debugHtml = `
460   - <div class="product-debug">
461   - <div class="product-debug-title">Ranking Debug</div>
462   - <div class="product-debug-line">spu_id: ${escapeHtml(String(spuId || ''))}</div>
463   - <div class="product-debug-line">Position before rerank: ${escapeHtml(String(debug.initial_rank ?? ''))}</div>
464   - <div class="product-debug-line">Position after rerank: ${escapeHtml(String(debug.final_rank ?? ''))}</div>
465   - <div class="product-debug-line">ES score: ${esScore}</div>
466   - <div class="product-debug-line">ES normalized: ${es_score_normalized}</div>
467   - <div class="product-debug-line">Rerank score: ${rerankScore}</div>
468   - <div class="product-debug-line">rerank_factor: ${escapeHtml(String(debug.rerank_factor ?? ''))}</div>
469   - <div class="product-debug-line">text_score: ${escapeHtml(String(debug.text_score ?? ''))}</div>
470   - <div class="product-debug-line">text_factor: ${escapeHtml(String(debug.text_factor ?? ''))}</div>
471   - <div class="product-debug-line">knn_score: ${escapeHtml(String(debug.knn_score ?? ''))}</div>
472   - <div class="product-debug-line">knn_factor: ${escapeHtml(String(debug.knn_factor ?? ''))}</div>
473   - <div class="product-debug-line">Fused score: ${fusedScore}</div>
474   - ${titleLines}
475   - ${rerankInputHtml}
476   - ${styleIntentHtml}
477   - ${matchedQueriesHtml}
478   - <div class="product-debug-actions">
479   - <button type="button" class="product-debug-inline-result-btn"
480   - data-action="toggle-result-inline-doc"
481   - data-result-json="${escapeAttr(resultJson)}">
482   - 在结果中显示当前结果数据
483   - </button>
484   - <a class="product-debug-link" href="${rawUrl}" target="_blank" rel="noopener noreferrer">
485   - 查看 ES 原始文档
486   - </a>
487   - </div>
488   - <div class="product-result-doc-panel" hidden>
489   - <pre class="product-result-doc-pre"></pre>
490   - </div>
491   - </div>
492   - `;
  410 + debugHtml = buildProductDebugHtml({
  411 + debug,
  412 + result,
  413 + spuId,
  414 + tenantId,
  415 + });
493 416 }
494 417  
495 418 html += `
... ... @@ -527,6 +450,126 @@ function displayResults(data) {
527 450 grid.innerHTML = html;
528 451 }
529 452  
  453 +function formatDebugNumber(value, digits = 4) {
  454 + if (typeof value === 'number' && Number.isFinite(value)) {
  455 + return value.toFixed(digits);
  456 + }
  457 + return value == null || value === '' ? 'N/A' : String(value);
  458 +}
  459 +
  460 +function renderMetricList(items) {
  461 + const rows = items
  462 + .filter((item) => item && item.value !== undefined && item.value !== null && item.value !== '')
  463 + .map((item) => `
  464 + <div class="debug-metric">
  465 + <div class="debug-metric-label">${escapeHtml(item.label)}</div>
  466 + <div class="debug-metric-value">${escapeHtml(String(item.value))}</div>
  467 + </div>
  468 + `)
  469 + .join('');
  470 + return rows ? `<div class="debug-metrics">${rows}</div>` : '';
  471 +}
  472 +
  473 +function renderScorePills(items) {
  474 + const pills = items
  475 + .filter((item) => item && item.value !== undefined && item.value !== null && item.value !== '')
  476 + .map((item) => `
  477 + <div class="debug-score-pill ${item.tone || ''}">
  478 + <span class="debug-score-pill-label">${escapeHtml(item.label)}</span>
  479 + <span class="debug-score-pill-value">${escapeHtml(String(item.value))}</span>
  480 + </div>
  481 + `)
  482 + .join('');
  483 + return pills ? `<div class="debug-score-pills">${pills}</div>` : '';
  484 +}
  485 +
  486 +function renderJsonDetails(title, payload, open = false) {
  487 + if (!payload || (typeof payload === 'object' && Object.keys(payload).length === 0)) {
  488 + return '';
  489 + }
  490 + return `
  491 + <details class="debug-details" ${open ? 'open' : ''}>
  492 + <summary>${escapeHtml(title)}</summary>
  493 + <pre class="debug-json-pre">${escapeHtml(customStringify(payload))}</pre>
  494 + </details>
  495 + `;
  496 +}
  497 +
  498 +function buildProductDebugHtml({ debug, result, spuId, tenantId }) {
  499 + const resultJson = customStringify(result);
  500 + const rawUrl = `${API_BASE_URL}/search/es-doc/${encodeURIComponent(spuId)}?tenant_id=${encodeURIComponent(tenantId)}`;
  501 +
  502 + const rankSummary = renderMetricList([
  503 + { label: 'Initial Rank', value: debug.initial_rank ?? 'N/A' },
  504 + { label: 'Final Rank', value: debug.final_rank ?? 'N/A' },
  505 + { label: 'Rank Delta', value: (debug.initial_rank && debug.final_rank) ? String(debug.initial_rank - debug.final_rank) : 'N/A' },
  506 + { label: 'SPU', value: spuId || 'N/A' },
  507 + ]);
  508 +
  509 + const stageScores = renderScorePills([
  510 + { label: 'ES', value: formatDebugNumber(debug.es_score), tone: 'tone-es' },
  511 + { label: 'ES Norm', value: formatDebugNumber(debug.es_score_normalized), tone: 'tone-neutral' },
  512 + { label: 'Coarse', value: formatDebugNumber(debug.coarse_score), tone: 'tone-coarse' },
  513 + { label: 'Fine', value: formatDebugNumber(debug.fine_score), tone: 'tone-fine' },
  514 + { label: 'Rerank', value: formatDebugNumber(debug.rerank_score), tone: 'tone-rerank' },
  515 + { label: 'Fused', value: formatDebugNumber(debug.fused_score), tone: 'tone-final' },
  516 + ]);
  517 +
  518 + const factorMetrics = renderMetricList([
  519 + { label: 'coarse_text_factor', value: formatDebugNumber(debug.coarse_text_factor) },
  520 + { label: 'coarse_knn_factor', value: formatDebugNumber(debug.coarse_knn_factor) },
  521 + { label: 'text_factor', value: formatDebugNumber(debug.text_factor) },
  522 + { label: 'knn_factor', value: formatDebugNumber(debug.knn_factor) },
  523 + { label: 'fine_factor', value: formatDebugNumber(debug.fine_factor) },
  524 + { label: 'rerank_factor', value: formatDebugNumber(debug.rerank_factor) },
  525 + ]);
  526 +
  527 + const signalMetrics = renderMetricList([
  528 + { label: 'text_score', value: formatDebugNumber(debug.text_score) },
  529 + { label: 'text_source', value: formatDebugNumber(debug.text_source_score) },
  530 + { label: 'text_translation', value: formatDebugNumber(debug.text_translation_score) },
  531 + { label: 'text_primary', value: formatDebugNumber(debug.text_primary_score) },
  532 + { label: 'text_support', value: formatDebugNumber(debug.text_support_score) },
  533 + { label: 'knn_score', value: formatDebugNumber(debug.knn_score) },
  534 + { label: 'text_knn', value: formatDebugNumber(debug.text_knn_score) },
  535 + { label: 'image_knn', value: formatDebugNumber(debug.image_knn_score) },
  536 + ]);
  537 +
  538 + const titlePayload = {};
  539 + if (debug.title_multilingual) titlePayload.title = debug.title_multilingual;
  540 + if (debug.brief_multilingual) titlePayload.brief = debug.brief_multilingual;
  541 + if (debug.vendor_multilingual) titlePayload.vendor = debug.vendor_multilingual;
  542 +
  543 + return `
  544 + <div class="product-debug">
  545 + <div class="product-debug-title">Ranking Funnel</div>
  546 + ${rankSummary}
  547 + ${stageScores}
  548 + <div class="product-debug-subtitle">Fusion Factors</div>
  549 + ${factorMetrics}
  550 + <div class="product-debug-subtitle">Signal Breakdown</div>
  551 + ${signalMetrics}
  552 + ${renderJsonDetails('Rerank Input', debug.rerank_input, true)}
  553 + ${renderJsonDetails('Selected SKU', debug.style_intent_sku, true)}
  554 + ${renderJsonDetails('Matched Queries', debug.matched_queries, false)}
  555 + ${renderJsonDetails('Multilingual Fields', titlePayload, false)}
  556 + <div class="product-debug-actions">
  557 + <button type="button" class="product-debug-inline-result-btn"
  558 + data-action="toggle-result-inline-doc"
  559 + data-result-json="${escapeAttr(resultJson)}">
  560 + 在结果中显示当前结果数据
  561 + </button>
  562 + <a class="product-debug-link" href="${rawUrl}" target="_blank" rel="noopener noreferrer">
  563 + 查看 ES 原始文档
  564 + </a>
  565 + </div>
  566 + <div class="product-result-doc-panel" hidden>
  567 + <pre class="product-result-doc-pre"></pre>
  568 + </div>
  569 + </div>
  570 + `;
  571 +}
  572 +
530 573 // Display facets as filter tags (一级分类 + 三个属性分面)
531 574 function displayFacets(facets) {
532 575 if (!facets || !Array.isArray(facets)) {
... ... @@ -919,127 +962,174 @@ function formatIntentDetectionHtml(intent) {
919 962 return block;
920 963 }
921 964  
  965 +function buildStageCard(title, subtitle, metrics, extraHtml = '') {
  966 + return `
  967 + <div class="debug-stage-card">
  968 + <div class="debug-stage-title">${escapeHtml(title)}</div>
  969 + ${subtitle ? `<div class="debug-stage-subtitle">${escapeHtml(subtitle)}</div>` : ''}
  970 + ${renderMetricList(metrics)}
  971 + ${extraHtml}
  972 + </div>
  973 + `;
  974 +}
  975 +
  976 +function renderTimingBars(stageTimings) {
  977 + if (!stageTimings || typeof stageTimings !== 'object') {
  978 + return '';
  979 + }
  980 + const orderedStages = [
  981 + 'query_parsing',
  982 + 'query_building',
  983 + 'elasticsearch_search_primary',
  984 + 'coarse_ranking',
  985 + 'style_sku_prepare_hits',
  986 + 'fine_ranking',
  987 + 'reranking',
  988 + 'elasticsearch_page_fill',
  989 + 'result_processing',
  990 + 'total_search',
  991 + ];
  992 + const entries = Object.entries(stageTimings)
  993 + .sort((a, b) => {
  994 + const ai = orderedStages.indexOf(a[0]);
  995 + const bi = orderedStages.indexOf(b[0]);
  996 + return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
  997 + });
  998 + const total = Number(stageTimings.total_search || 0);
  999 + return `
  1000 + <div class="debug-timing-list">
  1001 + ${entries.map(([stage, duration]) => {
  1002 + const numeric = Number(duration) || 0;
  1003 + const width = total > 0 ? Math.max(2, Math.round((numeric / total) * 100)) : 2;
  1004 + return `
  1005 + <div class="debug-timing-row">
  1006 + <div class="debug-timing-label">${escapeHtml(stage)}</div>
  1007 + <div class="debug-timing-bar-wrap"><div class="debug-timing-bar" style="width:${width}%"></div></div>
  1008 + <div class="debug-timing-value">${numeric.toFixed(2)}ms</div>
  1009 + </div>
  1010 + `;
  1011 + }).join('')}
  1012 + </div>
  1013 + `;
  1014 +}
  1015 +
  1016 +function buildGlobalFunnelHtml(data, debugInfo) {
  1017 + const queryAnalysis = debugInfo.query_analysis || {};
  1018 + const searchParams = debugInfo.search_params || {};
  1019 + const featureFlags = debugInfo.feature_flags || {};
  1020 + const esResponse = debugInfo.es_response || {};
  1021 + const esQueryContext = debugInfo.es_query_context || {};
  1022 + const coarseInfo = debugInfo.coarse_rank || {};
  1023 + const fineInfo = debugInfo.fine_rank || {};
  1024 + const rerankInfo = debugInfo.rerank || {};
  1025 + const translations = queryAnalysis.translations || {};
  1026 +
  1027 + const summaryHtml = `
  1028 + <div class="debug-section-block">
  1029 + <div class="debug-section-title">Query Context</div>
  1030 + ${renderMetricList([
  1031 + { label: 'original_query', value: queryAnalysis.original_query || 'N/A' },
  1032 + { label: 'rewritten_query', value: queryAnalysis.rewritten_query || 'N/A' },
  1033 + { label: 'detected_language', value: queryAnalysis.detected_language || 'N/A' },
  1034 + { label: 'index_languages', value: (queryAnalysis.index_languages || []).join(', ') || 'N/A' },
  1035 + { label: 'query_tokens', value: (queryAnalysis.query_tokens || []).join(', ') || 'N/A' },
  1036 + { label: 'translation_enabled', value: featureFlags.translation_enabled ? 'enabled' : 'disabled' },
  1037 + { label: 'embedding_enabled', value: featureFlags.embedding_enabled ? 'enabled' : 'disabled' },
  1038 + { label: 'style_intent_active', value: featureFlags.style_intent_active ? 'yes' : 'no' },
  1039 + ])}
  1040 + ${Object.keys(translations).length ? renderJsonDetails('Translations', translations, true) : ''}
  1041 + ${formatIntentDetectionHtml(queryAnalysis.intent_detection ?? queryAnalysis.style_intent_profile)}
  1042 + </div>
  1043 + `;
  1044 +
  1045 + const funnelHtml = `
  1046 + <div class="debug-section-block">
  1047 + <div class="debug-section-title">Ranking Funnel</div>
  1048 + <div class="debug-stage-grid">
  1049 + ${buildStageCard('ES Recall', 'First-pass retrieval', [
  1050 + { label: 'fetch_from', value: searchParams.es_fetch_from ?? 0 },
  1051 + { label: 'fetch_size', value: searchParams.es_fetch_size ?? 'N/A' },
  1052 + { label: 'total_hits', value: esResponse.total_hits ?? 'N/A' },
  1053 + { label: 'es_took_ms', value: esResponse.took_ms ?? 'N/A' },
  1054 + { label: 'include_named_queries_score', value: esQueryContext.include_named_queries_score ? 'yes' : 'no' },
  1055 + ])}
  1056 + ${buildStageCard('Coarse Rank', 'Lexical + vector fusion only', [
  1057 + { label: 'docs_in', value: coarseInfo.docs_in ?? searchParams.es_fetch_size ?? 'N/A' },
  1058 + { label: 'docs_out', value: coarseInfo.docs_out ?? 'N/A' },
  1059 + { label: 'formula', value: 'text x knn' },
  1060 + ], coarseInfo.fusion ? renderJsonDetails('Coarse Fusion', coarseInfo.fusion, false) : '')}
  1061 + ${buildStageCard('Fine Rank', 'Lightweight reranker', [
  1062 + { label: 'service_url', value: fineInfo.service_url || 'N/A' },
  1063 + { label: 'docs', value: fineInfo.docs ?? fineInfo.top_n ?? 'N/A' },
  1064 + { label: 'top_n', value: fineInfo.top_n ?? 'N/A' },
  1065 + { label: 'query_template', value: fineInfo.query_template || 'N/A' },
  1066 + ], fineInfo.meta ? renderJsonDetails('Fine Meta', fineInfo.meta, false) : '')}
  1067 + ${buildStageCard('Final Rerank', 'Heavy reranker + final fusion', [
  1068 + { label: 'service_url', value: rerankInfo.service_url || 'N/A' },
  1069 + { label: 'docs', value: rerankInfo.docs ?? 'N/A' },
  1070 + { label: 'top_n', value: rerankInfo.top_n ?? 'N/A' },
  1071 + { label: 'query_template', value: rerankInfo.query_template || 'N/A' },
  1072 + ], `${rerankInfo.fusion ? renderJsonDetails('Final Fusion', rerankInfo.fusion, false) : ''}${rerankInfo.meta ? renderJsonDetails('Rerank Meta', rerankInfo.meta, false) : ''}`)}
  1073 + ${buildStageCard('Page Return', 'Final slice returned to UI', [
  1074 + { label: 'from', value: searchParams.from_ ?? 0 },
  1075 + { label: 'size', value: searchParams.size ?? 'N/A' },
  1076 + { label: 'returned', value: (data.results || []).length },
  1077 + { label: 'max_score', value: formatDebugNumber(esResponse.max_score, 3) },
  1078 + ])}
  1079 + </div>
  1080 + </div>
  1081 + `;
  1082 +
  1083 + const timingHtml = `
  1084 + <div class="debug-section-block">
  1085 + <div class="debug-section-title">Timing Breakdown</div>
  1086 + ${renderTimingBars(debugInfo.stage_timings)}
  1087 + </div>
  1088 + `;
  1089 +
  1090 + const rawPayloadHtml = `
  1091 + <div class="debug-section-block">
  1092 + <div class="debug-section-title">Raw Payloads</div>
  1093 + ${renderJsonDetails('ES Query DSL', debugInfo.es_query, false)}
  1094 + ${renderJsonDetails('ES Query Context', debugInfo.es_query_context, false)}
  1095 + ${renderJsonDetails('Search Params', debugInfo.search_params, false)}
  1096 + </div>
  1097 + `;
  1098 +
  1099 + return `
  1100 + <div class="debug-panel">
  1101 + ${summaryHtml}
  1102 + ${funnelHtml}
  1103 + ${timingHtml}
  1104 + ${rawPayloadHtml}
  1105 + </div>
  1106 + `;
  1107 +}
  1108 +
922 1109 // Display debug info
923 1110 function displayDebugInfo(data) {
924 1111 const debugInfoDiv = document.getElementById('debugInfo');
925   -
  1112 +
926 1113 if (!state.debug || !data.debug_info) {
927   - // If debug mode is off or no debug info, show basic query info
928 1114 if (data.query_info) {
929   - let html = '<div style="padding: 10px;">';
930   - html += `<div><strong>original_query:</strong> ${escapeHtml(data.query_info.original_query || 'N/A')}</div>`;
931   - html += `<div><strong>detected_language:</strong> ${escapeHtml(data.query_info.detected_language || 'N/A')}</div>`;
932   - html += '</div>';
933   - debugInfoDiv.innerHTML = html;
  1115 + debugInfoDiv.innerHTML = `
  1116 + <div class="debug-panel">
  1117 + <div class="debug-section-block">
  1118 + <div class="debug-section-title">Query Context</div>
  1119 + ${renderMetricList([
  1120 + { label: 'original_query', value: data.query_info.original_query || 'N/A' },
  1121 + { label: 'detected_language', value: data.query_info.detected_language || 'N/A' },
  1122 + ])}
  1123 + </div>
  1124 + </div>
  1125 + `;
934 1126 } else {
935 1127 debugInfoDiv.innerHTML = '';
936 1128 }
937 1129 return;
938 1130 }
939   -
940   - // Display comprehensive debug info when debug mode is on
941   - const debugInfo = data.debug_info;
942   - let html = '<div style="padding: 10px; font-family: monospace; font-size: 12px;">';
943   -
944   - // Query Analysis
945   - if (debugInfo.query_analysis) {
946   - html += '<div style="margin-bottom: 15px;"><strong style="font-size: 14px;">Query Analysis:</strong>';
947   - html += `<div>original_query: ${escapeHtml(debugInfo.query_analysis.original_query || 'N/A')}</div>`;
948   - html += `<div>query_normalized: ${escapeHtml(debugInfo.query_analysis.query_normalized || 'N/A')}</div>`;
949   - html += `<div>rewritten_query: ${escapeHtml(debugInfo.query_analysis.rewritten_query || 'N/A')}</div>`;
950   - html += `<div>detected_language: ${escapeHtml(debugInfo.query_analysis.detected_language || 'N/A')}</div>`;
951   - html += `<div>index_languages: ${escapeHtml((debugInfo.query_analysis.index_languages || []).join(', ') || 'N/A')}</div>`;
952   - html += `<div>query_tokens: ${escapeHtml((debugInfo.query_analysis.query_tokens || []).join(', ') || 'N/A')}</div>`;
953   -
954   - if (debugInfo.query_analysis.translations && Object.keys(debugInfo.query_analysis.translations).length > 0) {
955   - html += '<div>translations: ';
956   - for (const [lang, translation] of Object.entries(debugInfo.query_analysis.translations)) {
957   - if (translation) {
958   - html += `${lang}: ${escapeHtml(translation)}; `;
959   - }
960   - }
961   - html += '</div>';
962   - }
963   -
964   - if (debugInfo.query_analysis.boolean_ast) {
965   - html += `<div>boolean_ast: ${escapeHtml(debugInfo.query_analysis.boolean_ast)}</div>`;
966   - }
967 1131  
968   - const intentPayload = debugInfo.query_analysis.intent_detection ?? debugInfo.query_analysis.style_intent_profile;
969   - html += formatIntentDetectionHtml(intentPayload);
970   -
971   - html += '</div>';
972   - }
973   -
974   - // Feature Flags
975   - if (debugInfo.feature_flags) {
976   - html += '<div style="margin-bottom: 15px;"><strong style="font-size: 14px;">Feature Flags:</strong>';
977   - html += `<div>translation_enabled: ${debugInfo.feature_flags.translation_enabled ? 'enabled' : 'disabled'}</div>`;
978   - html += `<div>embedding_enabled: ${debugInfo.feature_flags.embedding_enabled ? 'enabled' : 'disabled'}</div>`;
979   - html += `<div>rerank_enabled: ${debugInfo.feature_flags.rerank_enabled ? 'enabled' : 'disabled'}</div>`;
980   - if (debugInfo.feature_flags.style_intent_enabled !== undefined) {
981   - html += `<div>style_intent_enabled: ${debugInfo.feature_flags.style_intent_enabled ? 'enabled' : 'disabled'}</div>`;
982   - }
983   - if (debugInfo.feature_flags.style_intent_active !== undefined) {
984   - html += `<div>style_intent_active: ${debugInfo.feature_flags.style_intent_active ? 'yes' : 'no'}</div>`;
985   - }
986   - html += '</div>';
987   - }
988   -
989   - // ES Response
990   - if (debugInfo.es_response) {
991   - html += '<div style="margin-bottom: 15px;"><strong style="font-size: 14px;">ES Response:</strong>';
992   - html += `<div>took_ms: ${debugInfo.es_response.took_ms}ms</div>`;
993   - html += `<div>total_hits: ${debugInfo.es_response.total_hits}</div>`;
994   - html += `<div>max_score: ${debugInfo.es_response.max_score?.toFixed(3) || 0}</div>`;
995   - html += `<div>es_score_normalization_factor: ${escapeHtml(String(debugInfo.es_response.es_score_normalization_factor ?? ''))}</div>`;
996   - html += '</div>';
997   - }
998   -
999   - if (debugInfo.rerank) {
1000   - html += '<div style="margin-bottom: 15px;"><strong style="font-size: 14px;">Rerank:</strong>';
1001   - html += `<div>query_template: ${escapeHtml(debugInfo.rerank.query_template || 'N/A')}</div>`;
1002   - html += `<div>doc_template: ${escapeHtml(debugInfo.rerank.doc_template || 'N/A')}</div>`;
1003   - html += `<div>query_text: ${escapeHtml(debugInfo.rerank.query_text || 'N/A')}</div>`;
1004   - html += `<div>docs: ${escapeHtml(String(debugInfo.rerank.docs ?? ''))}</div>`;
1005   - html += `<div>top_n: ${escapeHtml(String(debugInfo.rerank.top_n ?? ''))}</div>`;
1006   - if (debugInfo.rerank.fusion) {
1007   - html += '<div>fusion:</div>';
1008   - html += `<pre style="background: #f5f5f5; padding: 10px; overflow: auto; max-height: 160px;">${escapeHtml(customStringify(debugInfo.rerank.fusion))}</pre>`;
1009   - }
1010   - html += '</div>';
1011   - }
1012   -
1013   - // Stage Timings
1014   - if (debugInfo.stage_timings) {
1015   - html += '<div style="margin-bottom: 15px;"><strong style="font-size: 14px;">Stage Timings:</strong>';
1016   - const bounds = debugInfo.stage_time_bounds_ms || {};
1017   - for (const [stage, duration] of Object.entries(debugInfo.stage_timings)) {
1018   - const b = bounds[stage];
1019   - if (b && b.start_unix_ms != null && b.end_unix_ms != null) {
1020   - html += `<div>${stage}: ${Number(duration).toFixed(2)}ms <span style="color:#666">(start ${b.start_unix_ms} → end ${b.end_unix_ms} unix ms)</span></div>`;
1021   - } else {
1022   - html += `<div>${stage}: ${Number(duration).toFixed(2)}ms</div>`;
1023   - }
1024   - }
1025   - html += '</div>';
1026   - }
1027   -
1028   - // ES Query
1029   - if (debugInfo.es_query) {
1030   - html += '<div style="margin-bottom: 15px;"><strong style="font-size: 14px;">ES Query DSL:</strong>';
1031   - html += `<pre style="background: #f5f5f5; padding: 10px; overflow: auto; max-height: 400px;">${escapeHtml(customStringify(debugInfo.es_query))}</pre>`;
1032   - html += '</div>';
1033   - }
1034   -
1035   - if (debugInfo.es_query_context) {
1036   - html += '<div style="margin-bottom: 15px;"><strong style="font-size: 14px;">ES Query Context:</strong>';
1037   - html += `<pre style="background: #f5f5f5; padding: 10px; overflow: auto; max-height: 240px;">${escapeHtml(customStringify(debugInfo.es_query_context))}</pre>`;
1038   - html += '</div>';
1039   - }
1040   -
1041   - html += '</div>';
1042   - debugInfoDiv.innerHTML = html;
  1132 + debugInfoDiv.innerHTML = buildGlobalFunnelHtml(data, data.debug_info);
1043 1133 }
1044 1134  
1045 1135 // Custom JSON stringify that compresses numeric arrays (like embeddings) to single line
... ... @@ -1070,4 +1160,3 @@ function formatDate(dateStr) {
1070 1160 return dateStr;
1071 1161 }
1072 1162 }
1073   -
... ...
search/searcher.py
... ... @@ -897,6 +897,16 @@ class Searcher:
897 897 if doc_id is None:
898 898 continue
899 899 rerank_debug_by_doc[str(doc_id)] = item
  900 + coarse_debug_raw = context.get_intermediate_result('coarse_rank_scores', None)
  901 + coarse_debug_by_doc: Dict[str, Dict[str, Any]] = {}
  902 + if isinstance(coarse_debug_raw, list):
  903 + for item in coarse_debug_raw:
  904 + if not isinstance(item, dict):
  905 + continue
  906 + doc_id = item.get("doc_id")
  907 + if doc_id is None:
  908 + continue
  909 + coarse_debug_by_doc[str(doc_id)] = item
900 910 fine_debug_raw = context.get_intermediate_result('fine_rank_scores', None)
901 911 fine_debug_by_doc: Dict[str, Dict[str, Any]] = {}
902 912 if isinstance(fine_debug_raw, list):
... ... @@ -937,6 +947,9 @@ class Searcher:
937 947 rerank_debug = None
938 948 if doc_id is not None:
939 949 rerank_debug = rerank_debug_by_doc.get(str(doc_id))
  950 + coarse_debug = None
  951 + if doc_id is not None:
  952 + coarse_debug = coarse_debug_by_doc.get(str(doc_id))
940 953 fine_debug = None
941 954 if doc_id is not None:
942 955 fine_debug = fine_debug_by_doc.get(str(doc_id))
... ... @@ -974,6 +987,11 @@ class Searcher:
974 987 "vendor_multilingual": vendor_multilingual,
975 988 }
976 989  
  990 + if coarse_debug:
  991 + debug_entry["coarse_score"] = coarse_debug.get("coarse_score")
  992 + debug_entry["coarse_text_factor"] = coarse_debug.get("coarse_text_factor")
  993 + debug_entry["coarse_knn_factor"] = coarse_debug.get("coarse_knn_factor")
  994 +
977 995 # 若存在重排调试信息,则补充 doc 级别的融合分数信息
978 996 if rerank_debug:
979 997 debug_entry["doc_id"] = rerank_debug.get("doc_id")
... ...