| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180 |
- #!/usr/bin/env python3
- """
- Steps 可视化工具
- 将 steps.json 转换为 HTML 可视化页面
- """
- import json
- import argparse
- from pathlib import Path
- from datetime import datetime
- HTML_TEMPLATE = """<!DOCTYPE html>
- <html lang="zh-CN">
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>Query Optimization Steps 可视化</title>
- <style>
- * {
- margin: 0;
- padding: 0;
- box-sizing: border-box;
- }
- body {
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
- background: #f5f5f5;
- color: #333;
- line-height: 1.6;
- display: flex;
- margin: 0;
- padding: 0;
- }
- /* 左侧导航 */
- .sidebar {
- width: 280px;
- background: white;
- height: 100vh;
- position: fixed;
- left: 0;
- top: 0;
- overflow-y: auto;
- box-shadow: 2px 0 8px rgba(0,0,0,0.1);
- z-index: 100;
- }
- .sidebar-header {
- padding: 20px;
- background: #2563eb;
- color: white;
- font-size: 18px;
- font-weight: 600;
- }
- .toc {
- padding: 10px 0;
- }
- .toc-item {
- padding: 10px 20px;
- cursor: pointer;
- transition: background 0.2s;
- border-left: 3px solid transparent;
- }
- .toc-item:hover {
- background: #f0f9ff;
- }
- .toc-item.active {
- background: #eff6ff;
- border-left-color: #2563eb;
- color: #2563eb;
- font-weight: 600;
- }
- .toc-item-level-0 {
- font-weight: 600;
- color: #1a1a1a;
- font-size: 14px;
- }
- .toc-item-level-1 {
- padding-left: 35px;
- font-size: 13px;
- color: #666;
- }
- .toc-item-level-2 {
- padding-left: 50px;
- font-size: 12px;
- color: #999;
- }
- .toc-toggle {
- display: inline-block;
- width: 16px;
- height: 16px;
- margin-left: 5px;
- cursor: pointer;
- transition: transform 0.2s;
- float: right;
- }
- .toc-toggle.collapsed {
- transform: rotate(-90deg);
- }
- .toc-children {
- display: none;
- }
- .toc-children.expanded {
- display: block;
- }
- .container {
- margin-left: 280px;
- width: calc(100% - 280px);
- padding: 20px;
- }
- .header {
- background: white;
- padding: 30px;
- border-radius: 12px;
- margin-bottom: 30px;
- box-shadow: 0 2px 8px rgba(0,0,0,0.1);
- }
- .header h1 {
- font-size: 32px;
- margin-bottom: 20px;
- color: #1a1a1a;
- }
- .question-box {
- background: #f0f9ff;
- padding: 20px;
- border-radius: 8px;
- margin-bottom: 20px;
- border-left: 4px solid #0284c7;
- }
- .question-label {
- font-size: 14px;
- color: #0369a1;
- margin-bottom: 8px;
- font-weight: 600;
- }
- .question-text {
- font-size: 18px;
- color: #1a1a1a;
- line-height: 1.6;
- }
- .overview {
- display: flex;
- gap: 30px;
- flex-wrap: wrap;
- }
- .overview-item {
- flex: 1;
- min-width: 150px;
- }
- .overview-label {
- font-size: 14px;
- color: #666;
- margin-bottom: 5px;
- }
- .overview-value {
- font-size: 28px;
- font-weight: bold;
- color: #2563eb;
- }
- .step-section {
- background: white;
- padding: 30px;
- border-radius: 12px;
- margin-bottom: 30px;
- box-shadow: 0 2px 8px rgba(0,0,0,0.1);
- }
- .step-header {
- border-bottom: 3px solid #2563eb;
- padding-bottom: 15px;
- margin-bottom: 20px;
- display: flex;
- justify-content: space-between;
- align-items: center;
- }
- .step-title {
- font-size: 26px;
- color: #1a1a1a;
- }
- .step-type {
- background: #e0e7ff;
- color: #4338ca;
- padding: 6px 15px;
- border-radius: 20px;
- font-size: 13px;
- font-weight: 600;
- font-family: monospace;
- }
- .step-content {
- margin-top: 20px;
- }
- .info-grid {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
- gap: 20px;
- margin-bottom: 20px;
- }
- .info-item {
- background: #f8f9fa;
- padding: 15px;
- border-radius: 8px;
- }
- .info-label {
- font-size: 13px;
- color: #666;
- margin-bottom: 5px;
- }
- .info-value {
- font-size: 20px;
- font-weight: bold;
- color: #1a1a1a;
- }
- .posts-grid {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
- gap: 20px;
- margin-top: 20px;
- padding-top: 100px;
- margin-top: -80px;
- }
- .post-card {
- background: white;
- border-radius: 8px;
- overflow: visible;
- transition: transform 0.2s, box-shadow 0.2s;
- border: 1px solid #e5e7eb;
- cursor: pointer;
- position: relative;
- }
- .post-card:hover {
- transform: translateY(-4px);
- box-shadow: 0 6px 16px rgba(0,0,0,0.15);
- }
- .post-image-wrapper {
- width: 100%;
- background: #f3f4f6;
- position: relative;
- padding-top: 133.33%; /* 3:4 aspect ratio */
- overflow: hidden;
- border-radius: 8px 8px 0 0;
- }
- .post-image {
- position: absolute;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- object-fit: cover;
- }
- .no-image {
- position: absolute;
- top: 50%;
- left: 50%;
- transform: translate(-50%, -50%);
- color: #9ca3af;
- font-size: 14px;
- }
- .post-type-badge {
- position: absolute;
- top: 10px;
- right: 10px;
- background: rgba(0, 0, 0, 0.7);
- color: white;
- padding: 4px 10px;
- border-radius: 15px;
- font-size: 11px;
- font-weight: 600;
- }
- .post-info {
- padding: 15px;
- position: relative;
- overflow: visible;
- }
- .post-title {
- font-size: 14px;
- font-weight: 600;
- margin-bottom: 8px;
- color: #1a1a1a;
- display: -webkit-box;
- -webkit-line-clamp: 2;
- -webkit-box-orient: vertical;
- overflow: hidden;
- }
- .post-desc {
- font-size: 12px;
- color: #6b7280;
- margin-bottom: 10px;
- display: -webkit-box;
- -webkit-line-clamp: 2;
- -webkit-box-orient: vertical;
- overflow: hidden;
- }
- .post-meta {
- display: flex;
- gap: 15px;
- margin-bottom: 8px;
- font-size: 12px;
- color: #9ca3af;
- }
- .post-meta-item {
- display: flex;
- align-items: center;
- gap: 4px;
- }
- .post-author {
- font-size: 12px;
- color: #6b7280;
- margin-bottom: 8px;
- }
- .post-id {
- font-size: 10px;
- color: #9ca3af;
- font-family: monospace;
- }
- .evaluation-reason {
- position: absolute;
- bottom: calc(100% + 10px);
- left: 50%;
- transform: translateX(-50%);
- background: #2d3748;
- color: white;
- padding: 12px 16px;
- border-radius: 8px;
- font-size: 12px;
- line-height: 1.5;
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
- z-index: 1000;
- display: none;
- white-space: normal;
- width: 280px;
- }
- /* Tooltip 箭头 - 指向下方进度条 */
- .evaluation-reason::after {
- content: '';
- position: absolute;
- top: 100%;
- left: 50%;
- transform: translateX(-50%);
- border: 6px solid transparent;
- border-top-color: #2d3748;
- }
- .confidence-bar:hover .evaluation-reason {
- display: block !important;
- }
- /* Debug: 让进度条更明显可悬停 */
- .confidence-bar {
- min-height: 32px;
- }
- .evaluation-reason strong {
- color: #fbbf24;
- font-size: 13px;
- }
- .evaluation-scores {
- display: flex;
- gap: 10px;
- margin-top: 10px;
- font-size: 12px;
- flex-wrap: wrap;
- }
- .score-item {
- background: rgba(255, 255, 255, 0.15);
- padding: 5px 10px;
- border-radius: 12px;
- color: #fbbf24;
- border: 1px solid rgba(251, 191, 36, 0.3);
- }
- .confidence-bar {
- width: 100%;
- height: 32px;
- background: #f3f4f6;
- position: relative;
- cursor: help;
- display: flex;
- align-items: center;
- border-radius: 0 0 8px 8px;
- overflow: hidden;
- }
- .confidence-bar-fill {
- height: 100%;
- transition: width 0.5s ease-out;
- display: flex;
- align-items: center;
- padding: 0 12px;
- position: relative;
- }
- .confidence-bar-fill.confidence-low {
- background: linear-gradient(90deg, #ef4444, #f87171);
- }
- .confidence-bar-fill.confidence-medium {
- background: linear-gradient(90deg, #f59e0b, #fbbf24);
- }
- .confidence-bar-fill.confidence-high {
- background: linear-gradient(90deg, #10b981, #34d399);
- }
- .confidence-bar-text {
- color: white;
- font-size: 12px;
- font-weight: 600;
- white-space: nowrap;
- position: relative;
- z-index: 1;
- text-shadow: 0 1px 2px rgba(0,0,0,0.2);
- }
- /* 保留旧的badge样式用于兼容 */
- .confidence-badge {
- background: #10b981;
- color: white;
- padding: 4px 10px;
- border-radius: 15px;
- font-size: 12px;
- font-weight: bold;
- display: inline-block;
- margin-bottom: 10px;
- position: relative;
- cursor: help;
- }
- .confidence-low {
- background: #ef4444;
- }
- .confidence-medium {
- background: #f59e0b;
- }
- .confidence-high {
- background: #10b981;
- }
- .query-list {
- background: #f8f9fa;
- padding: 20px;
- border-radius: 8px;
- margin-top: 15px;
- }
- .query-item {
- background: white;
- padding: 15px;
- border-radius: 6px;
- margin-bottom: 10px;
- border-left: 3px solid #2563eb;
- }
- .query-text {
- font-size: 15px;
- font-weight: 600;
- color: #1a1a1a;
- margin-bottom: 5px;
- }
- .query-meta {
- font-size: 13px;
- color: #666;
- }
- .answer-box {
- background: #f0fdf4;
- border: 2px solid #10b981;
- border-radius: 8px;
- padding: 25px;
- margin-top: 20px;
- }
- .answer-header {
- font-size: 18px;
- color: #059669;
- margin-bottom: 15px;
- font-weight: 600;
- }
- .answer-content {
- font-size: 15px;
- line-height: 1.8;
- color: #1a1a1a;
- white-space: pre-wrap;
- }
- .answer-meta {
- margin-top: 15px;
- padding-top: 15px;
- border-top: 1px solid #d1fae5;
- display: flex;
- gap: 20px;
- font-size: 13px;
- color: #059669;
- }
- .keyword-tags {
- display: flex;
- flex-wrap: wrap;
- gap: 10px;
- margin-top: 15px;
- }
- .keyword-tag {
- background: #dbeafe;
- color: #1e40af;
- padding: 6px 12px;
- border-radius: 15px;
- font-size: 13px;
- font-weight: 500;
- }
- .level-analysis {
- background: #fef3c7;
- border-left: 4px solid #f59e0b;
- padding: 20px;
- border-radius: 6px;
- margin-top: 15px;
- }
- .level-analysis-title {
- font-size: 16px;
- color: #92400e;
- margin-bottom: 10px;
- font-weight: 600;
- }
- .level-analysis-text {
- font-size: 14px;
- color: #78350f;
- line-height: 1.8;
- }
- .timestamp {
- font-size: 12px;
- color: #9ca3af;
- margin-top: 10px;
- }
- a {
- color: #2563eb;
- text-decoration: none;
- }
- a:hover {
- text-decoration: underline;
- }
- /* 模态框样式 */
- .modal-overlay {
- display: none;
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background: rgba(0, 0, 0, 0.85);
- z-index: 1000;
- align-items: center;
- justify-content: center;
- padding: 20px;
- overflow-y: auto;
- }
- .modal-overlay.active {
- display: flex;
- }
- .modal-content {
- background: white;
- border-radius: 12px;
- max-width: 1000px;
- width: 100%;
- max-height: 90vh;
- overflow-y: auto;
- position: relative;
- animation: modalSlideIn 0.3s;
- }
- @keyframes modalSlideIn {
- from { opacity: 0; transform: translateY(-30px); }
- to { opacity: 1; transform: translateY(0); }
- }
- .modal-close {
- position: sticky;
- top: 0;
- right: 0;
- background: white;
- border: none;
- font-size: 36px;
- color: #6b7280;
- cursor: pointer;
- padding: 15px 25px;
- z-index: 10;
- text-align: right;
- border-bottom: 2px solid #e5e7eb;
- transition: color 0.2s;
- }
- .modal-close:hover {
- color: #1f2937;
- }
- .modal-body {
- padding: 30px;
- }
- .modal-title {
- font-size: 26px;
- font-weight: 700;
- color: #1a1a1a;
- margin-bottom: 15px;
- line-height: 1.4;
- }
- .modal-meta {
- display: flex;
- gap: 20px;
- flex-wrap: wrap;
- margin-bottom: 25px;
- padding-bottom: 20px;
- border-bottom: 1px solid #e5e7eb;
- }
- .modal-meta-item {
- display: flex;
- align-items: center;
- gap: 6px;
- font-size: 14px;
- color: #6b7280;
- }
- .modal-images {
- margin-bottom: 25px;
- }
- .modal-images-grid {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
- gap: 12px;
- }
- .modal-image-item {
- border-radius: 8px;
- overflow: hidden;
- border: 2px solid #e5e7eb;
- transition: border-color 0.2s;
- cursor: pointer;
- }
- .modal-image-item:hover {
- border-color: #2563eb;
- }
- .modal-image {
- width: 100%;
- height: auto;
- display: block;
- max-height: 250px;
- object-fit: cover;
- }
- .modal-section {
- margin-bottom: 25px;
- }
- .modal-section-title {
- font-size: 17px;
- font-weight: 600;
- color: #374151;
- margin-bottom: 12px;
- }
- .modal-text-content {
- font-size: 15px;
- color: #1f2937;
- line-height: 1.8;
- white-space: pre-wrap;
- background: #f9fafb;
- padding: 18px;
- border-radius: 8px;
- }
- .modal-evaluation {
- background: #fef3c7;
- border-left: 4px solid #f59e0b;
- padding: 18px;
- border-radius: 6px;
- }
- .modal-link {
- margin-top: 25px;
- padding-top: 25px;
- border-top: 2px solid #e5e7eb;
- text-align: center;
- }
- .modal-link-btn {
- display: inline-flex;
- align-items: center;
- gap: 10px;
- padding: 12px 28px;
- background: #2563eb;
- color: white;
- text-decoration: none;
- border-radius: 8px;
- font-size: 15px;
- font-weight: 600;
- transition: all 0.2s;
- }
- .modal-link-btn:hover {
- background: #1d4ed8;
- transform: translateY(-2px);
- box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3);
- }
- /* 卡片上的图片轮播指示器 */
- .carousel-arrow {
- position: absolute;
- top: 50%;
- transform: translateY(-50%);
- background: rgba(0, 0, 0, 0.6);
- color: white;
- border: none;
- width: 36px;
- height: 36px;
- border-radius: 50%;
- font-size: 20px;
- cursor: pointer;
- z-index: 15;
- display: flex;
- align-items: center;
- justify-content: center;
- transition: all 0.2s;
- opacity: 0;
- }
- .post-image-wrapper:hover .carousel-arrow {
- opacity: 1;
- }
- .carousel-arrow:hover {
- background: rgba(0, 0, 0, 0.8);
- transform: translateY(-50%) scale(1.1);
- }
- .carousel-arrow.left {
- left: 8px;
- }
- .carousel-arrow.right {
- right: 8px;
- }
- /* 可折叠区域样式 */
- .collapsible-section {
- margin: 20px 0;
- }
- .collapsible-header {
- background: #f3f4f6;
- padding: 12px 15px;
- border-radius: 8px;
- cursor: pointer;
- display: flex;
- align-items: center;
- gap: 10px;
- transition: background 0.2s;
- user-select: none;
- }
- .collapsible-header:hover {
- background: #e5e7eb;
- }
- .collapsible-toggle {
- font-size: 14px;
- transition: transform 0.2s;
- }
- .collapsible-toggle.collapsed {
- transform: rotate(-90deg);
- }
- .collapsible-title {
- font-weight: 600;
- font-size: 16px;
- color: #374151;
- }
- .collapsible-content {
- max-height: 10000px;
- overflow: hidden;
- transition: max-height 0.3s ease-out, opacity 0.3s ease-out;
- opacity: 1;
- }
- .collapsible-content.collapsed {
- max-height: 0;
- opacity: 0;
- }
- </style>
- </head>
- <body>
- <!-- 左侧导航 -->
- <div class="sidebar">
- <div class="sidebar-header">📑 目录</div>
- <div class="toc" id="toc"></div>
- </div>
- <!-- 主内容区 -->
- <div class="container">
- {content}
- </div>
- <!-- 模态框 -->
- <div id="postModal" class="modal-overlay" onclick="if(event.target === this) closeModal()">
- <div class="modal-content" onclick="event.stopPropagation()">
- <button class="modal-close" onclick="closeModal()">×</button>
- <div class="modal-body" id="modalBody">
- <!-- 动态内容 -->
- </div>
- </div>
- </div>
- <script>
- // 模态框功能
- function openModal(postData) {
- const modal = document.getElementById('postModal');
- const modalBody = document.getElementById('modalBody');
- // 构建图片网格
- let imagesHtml = '';
- if (postData.images && postData.images.length > 0) {
- imagesHtml = '<div class="modal-images"><div class="modal-images-grid">';
- postData.images.forEach((img, idx) => {
- imagesHtml += `<div class="modal-image-item"><img src="${img}" class="modal-image" alt="图片 ${idx + 1}"></div>`;
- });
- imagesHtml += '</div></div>';
- }
- // 构建评估详情
- let evalHtml = '';
- if (postData.evaluation) {
- evalHtml = `
- <div class="modal-section">
- <div class="modal-section-title">💡 评估详情</div>
- <div class="modal-evaluation">
- <div style="margin-bottom: 12px;"><strong>评估理由:</strong></div>
- <div style="color: #78350f; line-height: 1.8;">${postData.evaluation.reason || '无'}</div>
- <div class="evaluation-scores" style="margin-top: 12px;">
- <span class="score-item">📌 标题相关性: ${postData.evaluation.title_relevance?.toFixed(2) || '0.00'}</span>
- <span class="score-item">📄 内容期望: ${postData.evaluation.content_expectation?.toFixed(2) || '0.00'}</span>
- <span class="score-item">🎯 置信度: ${postData.evaluation.confidence_score?.toFixed(2) || '0.00'}</span>
- </div>
- </div>
- </div>`;
- }
- modalBody.innerHTML = `
- <div class="modal-title">${postData.title}</div>
- <div class="modal-meta">
- <div class="modal-meta-item">👤 ${postData.user}</div>
- <div class="modal-meta-item">❤️ ${postData.likes}</div>
- <div class="modal-meta-item">⭐ ${postData.collects}</div>
- <div class="modal-meta-item">💬 ${postData.comments}</div>
- ${postData.type === 'video' ? '<div class="modal-meta-item">📹 视频</div>' : ''}
- </div>
- ${imagesHtml}
- <div class="modal-section">
- <div class="modal-section-title">📝 描述</div>
- <div class="modal-text-content">${postData.desc || '无描述'}</div>
- </div>
- ${evalHtml}
- <div class="modal-link">
- <a href="${postData.url}" target="_blank" class="modal-link-btn">
- 🔗 在小红书中查看
- </a>
- </div>
- `;
- modal.classList.add('active');
- document.body.style.overflow = 'hidden';
- }
- function closeModal() {
- const modal = document.getElementById('postModal');
- modal.classList.remove('active');
- document.body.style.overflow = '';
- }
- // ESC键关闭模态框
- document.addEventListener('keydown', function(e) {
- if (e.key === 'Escape') {
- closeModal();
- }
- });
- // 卡片上的图片轮播(使用左右箭头按钮)
- function initCarousels() {
- document.querySelectorAll('.post-card').forEach(card => {
- const images = JSON.parse(card.dataset.images || '[]');
- if (images.length <= 1) return;
- let currentIndex = 0;
- const imgElement = card.querySelector('.post-image');
- const leftArrow = card.querySelector('.carousel-arrow.left');
- const rightArrow = card.querySelector('.carousel-arrow.right');
- // 左箭头点击
- if (leftArrow) {
- leftArrow.addEventListener('click', function(e) {
- e.stopPropagation();
- currentIndex = (currentIndex - 1 + images.length) % images.length;
- if (imgElement) {
- imgElement.src = images[currentIndex];
- }
- });
- }
- // 右箭头点击
- if (rightArrow) {
- rightArrow.addEventListener('click', function(e) {
- e.stopPropagation();
- currentIndex = (currentIndex + 1) % images.length;
- if (imgElement) {
- imgElement.src = images[currentIndex];
- }
- });
- }
- });
- }
- // 生成目录(显示步骤和可折叠的子项)
- function generateTOC() {
- const toc = document.getElementById('toc');
- const sections = document.querySelectorAll('.step-section');
- sections.forEach((section, index) => {
- const title = section.querySelector('.step-title')?.textContent || `步骤 ${index + 1}`;
- const id = `step-${index}`;
- section.id = id;
- // 查找该section下的直接子可折叠项(不包括嵌套的)
- const collapsibleSections = section.querySelectorAll(':scope > .step-content > .collapsible-section[id]');
- // 创建步骤项
- const stepItem = document.createElement('div');
- stepItem.className = 'toc-item toc-item-level-0';
- if (collapsibleSections.length > 0) {
- // 如果有子项,添加展开/折叠图标(箭头放在右侧)
- const toggleId = `toc-toggle-${index}`;
- stepItem.innerHTML = `<span>${title}</span><span class="toc-toggle" id="${toggleId}">▼</span>`;
- const toggle = stepItem.querySelector('.toc-toggle');
- const childrenId = `toc-children-${index}`;
- toggle.onclick = (e) => {
- e.stopPropagation();
- toggle.classList.toggle('collapsed');
- const children = document.getElementById(childrenId);
- if (children) {
- children.classList.toggle('expanded');
- }
- };
- } else {
- stepItem.textContent = title;
- }
- stepItem.onclick = (e) => {
- if (!e.target.classList.contains('toc-toggle')) {
- scrollToSection(id);
- }
- };
- toc.appendChild(stepItem);
- // 添加子项目录(支持嵌套)
- if (collapsibleSections.length > 0) {
- const childrenContainer = document.createElement('div');
- childrenContainer.id = `toc-children-${index}`;
- childrenContainer.className = 'toc-children expanded';
- collapsibleSections.forEach(collapsible => {
- const subTitle = collapsible.getAttribute('data-title') || '子项';
- const subId = collapsible.id;
- const subItem = document.createElement('div');
- subItem.className = 'toc-item toc-item-level-1';
- subItem.textContent = subTitle;
- subItem.onclick = () => scrollToSection(subId);
- childrenContainer.appendChild(subItem);
- // 查找该可折叠区域内的嵌套可折叠区域
- const nestedCollapsibles = collapsible.querySelectorAll(':scope > .collapsible-content > .collapsible-section[id]');
- if (nestedCollapsibles.length > 0) {
- nestedCollapsibles.forEach(nested => {
- const nestedTitle = nested.getAttribute('data-title') || '子项';
- const nestedId = nested.id;
- const nestedItem = document.createElement('div');
- nestedItem.className = 'toc-item toc-item-level-2';
- nestedItem.textContent = nestedTitle;
- nestedItem.onclick = () => scrollToSection(nestedId);
- childrenContainer.appendChild(nestedItem);
- });
- }
- });
- toc.appendChild(childrenContainer);
- }
- });
- }
- // 滚动到指定section
- function scrollToSection(id) {
- const element = document.getElementById(id);
- if (element) {
- const offset = 80;
- const elementPosition = element.getBoundingClientRect().top;
- const offsetPosition = elementPosition + window.pageYOffset - offset;
- window.scrollTo({
- top: offsetPosition,
- behavior: 'smooth'
- });
- // 更新active状态
- document.querySelectorAll('.toc-item').forEach(item => item.classList.remove('active'));
- event.target.classList.add('active');
- }
- }
- // 滚动时高亮当前section
- function updateActiveTOC() {
- const sections = document.querySelectorAll('.step-section');
- const tocItems = document.querySelectorAll('.toc-item');
- let currentIndex = -1;
- sections.forEach((section, index) => {
- const rect = section.getBoundingClientRect();
- if (rect.top <= 100) {
- currentIndex = index;
- }
- });
- tocItems.forEach((item, index) => {
- item.classList.toggle('active', index === currentIndex);
- });
- }
- // 初始化可折叠区域
- function initCollapsibles() {
- document.querySelectorAll('.collapsible-header').forEach(header => {
- header.addEventListener('click', function() {
- const toggle = this.querySelector('.collapsible-toggle');
- const content = this.nextElementSibling;
- if (content && content.classList.contains('collapsible-content')) {
- toggle.classList.toggle('collapsed');
- content.classList.toggle('collapsed');
- }
- });
- });
- }
- // 页面加载完成后初始化
- document.addEventListener('DOMContentLoaded', function() {
- initCarousels();
- generateTOC();
- initCollapsibles();
- window.addEventListener('scroll', updateActiveTOC);
- updateActiveTOC();
- });
- </script>
- </body>
- </html>
- """
- def make_collapsible(title, content, collapsed=True, section_id=None):
- """创建可折叠区域的HTML"""
- collapsed_class = " collapsed" if collapsed else ""
- id_attr = f' id="{section_id}"' if section_id else ""
- # 添加 data-title 属性用于目录生成
- title_attr = f' data-title="{title}"' if section_id else ""
- return f"""
- <div class="collapsible-section"{id_attr}{title_attr}>
- <div class="collapsible-header">
- <span class="collapsible-toggle{collapsed_class}">▼</span>
- <span class="collapsible-title">{title}</span>
- </div>
- <div class="collapsible-content{collapsed_class}">
- {content}
- </div>
- </div>
- """
- def get_confidence_class(score):
- """根据置信度分数返回CSS类"""
- if score >= 0.7:
- return "confidence-high"
- elif score >= 0.5:
- return "confidence-medium"
- else:
- return "confidence-low"
- def escape_js_string(s):
- """转义JavaScript字符串"""
- import json
- return json.dumps(str(s) if s else "")
- def build_post_json_data(note, evaluation=None):
- """构建帖子的JSON数据用于模态框"""
- import json
- image_list = note.get('image_list', [])
- if not image_list and note.get('cover_image'):
- cover = note.get('cover_image')
- # cover_image 可能是字典或字符串
- if isinstance(cover, dict):
- image_list = [cover.get('image_url', '')]
- else:
- image_list = [cover]
- # image_list 现在已经是 URL 字符串列表(由搜索API预处理)
- images = [img if isinstance(img, str) else img.get('image_url', '') for img in image_list if img]
- interact = note.get('interact_info', {})
- user = note.get('user', {})
- data = {
- 'title': note.get('title', '无标题'),
- 'desc': note.get('desc', ''),
- 'user': user.get('nickname', '未知'),
- 'likes': interact.get('liked_count', 0),
- 'collects': interact.get('collected_count', 0),
- 'comments': interact.get('comment_count', 0),
- 'type': note.get('type', 'normal'),
- 'url': note.get('note_url', ''),
- 'images': images
- }
- if evaluation:
- data['evaluation'] = {
- 'reason': evaluation.get('reason', ''),
- 'title_relevance': evaluation.get('title_relevance', 0),
- 'content_expectation': evaluation.get('content_expectation', 0),
- 'confidence_score': evaluation.get('confidence_score', 0)
- }
- return json.dumps(data, ensure_ascii=False)
- def render_header(steps_data):
- """渲染页面头部"""
- # 获取基本信息
- first_step = steps_data[0] if steps_data else {}
- last_step = steps_data[-1] if steps_data else {}
- original_question = ""
- keywords = []
- total_steps = len(steps_data)
- satisfied_notes = 0
- # 提取关键信息
- for step in steps_data:
- if step.get("step_type") == "keyword_extraction":
- original_question = step.get("data", {}).get("input_question", "")
- keywords = step.get("data", {}).get("keywords", [])
- elif step.get("step_type") == "final_result":
- satisfied_notes = step.get("data", {}).get("satisfied_notes_count", 0)
- keywords_html = "".join([f'<span class="keyword-tag">{k}</span>' for k in keywords])
- html = f"""
- <div class="header">
- <h1>🔍 Query Optimization Steps</h1>
- <div class="question-box">
- <div class="question-label">原始问题</div>
- <div class="question-text">{original_question}</div>
- </div>
- {f'<div class="keyword-tags">{keywords_html}</div>' if keywords else ''}
- <div class="overview">
- <div class="overview-item">
- <div class="overview-label">总步骤数</div>
- <div class="overview-value">{total_steps}</div>
- </div>
- <div class="overview-item">
- <div class="overview-label">满足需求的帖子</div>
- <div class="overview-value">{satisfied_notes}</div>
- </div>
- </div>
- </div>
- """
- return html
- def render_keyword_extraction(step):
- """渲染关键词提取步骤"""
- data = step.get("data", {})
- keywords = data.get("keywords", [])
- reasoning = data.get("reasoning", "")
- keywords_html = "".join([f'<span class="keyword-tag">{k}</span>' for k in keywords])
- html = f"""
- <div class="step-section">
- <div class="step-header">
- <div class="step-title">步骤 {step['step_number']}: {step['step_name']}</div>
- <div class="step-type">{step['step_type']}</div>
- </div>
- <div class="step-content">
- <div class="keyword-tags">{keywords_html}</div>
- {f'<p style="margin-top: 15px; color: #666; font-size: 14px;">{reasoning}</p>' if reasoning else ''}
- </div>
- <div class="timestamp">⏰ {step.get('timestamp', '')}</div>
- </div>
- """
- return html
- def render_level_exploration(step):
- """渲染层级探索步骤"""
- data = step.get("data", {})
- level = data.get("level", 0)
- query_count = data.get("query_count", 0)
- results = data.get("results", [])
- queries_html = ""
- for result in results:
- query = result.get("query", "")
- suggestions = result.get("suggestions", [])
- # 使用标签样式显示推荐词
- suggestions_tags = ""
- for suggestion in suggestions:
- suggestions_tags += f'<span class="keyword-tag" style="margin: 3px;">{suggestion}</span>'
- queries_html += f"""
- <div class="query-item">
- <div class="query-text">{query}</div>
- <div style="margin-top: 10px;">
- <div style="color: #666; font-size: 13px; margin-bottom: 5px;">推荐词 ({len(suggestions)} 个):</div>
- <div style="display: flex; flex-wrap: wrap; gap: 5px;">
- {suggestions_tags}
- </div>
- </div>
- </div>
- """
- html = f"""
- <div class="step-section">
- <div class="step-header">
- <div class="step-title">步骤 {step['step_number']}: Level {level} 探索</div>
- <div class="step-type">{step['step_type']}</div>
- </div>
- <div class="step-content">
- <div class="info-grid">
- <div class="info-item">
- <div class="info-label">探索query数</div>
- <div class="info-value">{query_count}</div>
- </div>
- <div class="info-item">
- <div class="info-label">获得推荐词总数</div>
- <div class="info-value">{data.get('total_suggestions', 0)}</div>
- </div>
- </div>
- <div class="query-list">{queries_html}</div>
- </div>
- <div class="timestamp">⏰ {step.get('timestamp', '')}</div>
- </div>
- """
- return html
- def render_level_analysis(step):
- """渲染层级分析步骤"""
- data = step.get("data", {})
- level = data.get("level", 0)
- key_findings = data.get("key_findings", "")
- should_evaluate = data.get("should_evaluate_now", False)
- promising_signals_count = data.get("promising_signals_count", 0)
- next_combinations = data.get("next_combinations", [])
- promising_signals = data.get("promising_signals", [])
- reasoning = data.get("reasoning", "")
- step_num = step['step_number']
- # 渲染推理过程
- reasoning_html = ""
- if reasoning:
- reasoning_html = f"""
- <div style="margin-top: 20px;">
- <div class="level-analysis">
- <div class="level-analysis-title">💭 推理过程</div>
- <div class="level-analysis-text">{reasoning}</div>
- </div>
- </div>
- """
- # 渲染下一层探索
- next_html = ""
- if next_combinations:
- next_items = "".join([f'<span class="keyword-tag">{q}</span>' for q in next_combinations])
- next_html = f'<div style="margin-top: 15px;"><strong>下一层探索:</strong><div class="keyword-tags" style="margin-top: 10px;">{next_items}</div></div>'
- # 渲染有价值的信号
- signals_html = ""
- if promising_signals:
- signals_items = ""
- for signal in promising_signals:
- query = signal.get("query", "")
- from_level = signal.get("from_level", "")
- reason = signal.get("reason", "")
- signals_items += f"""
- <div class="query-item" style="border-left: 3px solid #10b981; padding-left: 15px;">
- <div class="query-text" style="font-weight: 600;">{query}</div>
- <div style="margin-top: 8px; color: #666; font-size: 13px;">
- <span style="color: #10b981;">来自 Level {from_level}</span>
- </div>
- <div style="margin-top: 8px; color: #555; font-size: 14px; line-height: 1.5;">
- {reason}
- </div>
- </div>
- """
- signals_html = make_collapsible(
- f"💡 有价值的信号 ({len(promising_signals)} 个)",
- f'<div style="display: flex; flex-direction: column; gap: 15px; margin-top: 10px;">{signals_items}</div>',
- collapsed=True,
- section_id=f"step{step_num}-signals"
- )
- html = f"""
- <div class="step-section">
- <div class="step-header">
- <div class="step-title">步骤 {step['step_number']}: Level {level} 分析</div>
- <div class="step-type">{step['step_type']}</div>
- </div>
- <div class="step-content">
- <div class="level-analysis">
- <div class="level-analysis-title">🔎 关键发现</div>
- <div class="level-analysis-text">{key_findings}</div>
- </div>
- <div class="info-grid" style="margin-top: 20px;">
- <div class="info-item">
- <div class="info-label">有价值信号数</div>
- <div class="info-value">{promising_signals_count}</div>
- </div>
- <div class="info-item">
- <div class="info-label">是否开始评估</div>
- <div class="info-value">{'是' if should_evaluate else '否'}</div>
- </div>
- </div>
- {signals_html}
- {reasoning_html}
- {next_html}
- </div>
- <div class="timestamp">⏰ {step.get('timestamp', '')}</div>
- </div>
- """
- return html
- def render_search_results(step):
- """渲染搜索结果步骤"""
- data = step.get("data", {})
- search_results = data.get("search_results", [])
- posts_html = ""
- step_num = step['step_number']
- for idx, sr in enumerate(search_results):
- query = sr.get("query", "")
- note_count = sr.get("note_count", 0)
- notes_summary = sr.get("notes_summary", [])
- # 渲染该query的帖子
- posts_cards = ""
- for note in notes_summary:
- # 获取封面图
- image_list = note.get('image_list', [])
- if image_list:
- # image_list 已经是 URL 字符串列表,第一张就是封面
- cover_url = image_list[0] if isinstance(image_list[0], str) else image_list[0].get('image_url', '')
- else:
- cover = note.get("cover_image", {})
- cover_url = cover.get("image_url", "") if isinstance(cover, dict) else cover if cover else ""
- interact = note.get("interact_info", {})
- user = note.get("user", {})
- # image_list 现在已经是 URL 字符串列表
- images = [img if isinstance(img, str) else img.get('image_url', '') for img in image_list if img]
- # 构建帖子数据用于模态框
- post_data = build_post_json_data(note)
- images_json = json.dumps(images)
- image_html = f'<img src="{cover_url}" class="post-image" alt="{note.get("title", "")}">' if cover_url else '<div class="no-image">无图片</div>'
- type_badge = ""
- if note.get("type") == "video":
- type_badge = '<div class="post-type-badge">📹 视频</div>'
- # 轮播箭头按钮
- arrows_html = ""
- if len(images) > 1:
- arrows_html = '''
- <button class="carousel-arrow left" onclick="event.stopPropagation()">‹</button>
- <button class="carousel-arrow right" onclick="event.stopPropagation()">›</button>
- '''
- posts_cards += f"""
- <div class="post-card" onclick='openModal({post_data})' data-images='{images_json}'>
- <div class="post-image-wrapper">
- {image_html}
- {type_badge}
- {arrows_html}
- </div>
- <div class="post-info">
- <div class="post-title">{note.get('title', '无标题')}</div>
- <div class="post-desc">{note.get('desc', '')}</div>
- <div class="post-meta">
- <div class="post-meta-item">❤️ {interact.get('liked_count', 0)}</div>
- <div class="post-meta-item">⭐ {interact.get('collected_count', 0)}</div>
- <div class="post-meta-item">💬 {interact.get('comment_count', 0)}</div>
- </div>
- <div class="post-author">👤 {user.get('nickname', '未知')}</div>
- <div class="post-id">{note.get('note_id', '')}</div>
- </div>
- </div>
- """
- # 使用可折叠区域包装每个query的搜索结果,添加唯一ID
- query_content = f'<div class="posts-grid">{posts_cards}</div>'
- posts_html += make_collapsible(
- f"🔎 {query} (找到 {note_count} 个帖子)",
- query_content,
- collapsed=True,
- section_id=f"step{step_num}-search-{idx}"
- )
- html = f"""
- <div class="step-section">
- <div class="step-header">
- <div class="step-title">步骤 {step['step_number']}: 搜索结果</div>
- <div class="step-type">{step['step_type']}</div>
- </div>
- <div class="step-content">
- <div class="info-grid">
- <div class="info-item">
- <div class="info-label">搜索query数</div>
- <div class="info-value">{data.get('qualified_count', 0)}</div>
- </div>
- </div>
- {posts_html}
- </div>
- <div class="timestamp">⏰ {step.get('timestamp', '')}</div>
- </div>
- """
- return html
- def render_note_evaluations(step):
- """渲染帖子评估步骤"""
- data = step.get("data", {})
- note_evaluations = data.get("note_evaluations", [])
- total_satisfied = data.get("total_satisfied", 0)
- evals_html = ""
- step_num = step["step_number"]
- for idx, query_eval in enumerate(note_evaluations):
- query = query_eval.get("query", "")
- satisfied_count = query_eval.get("satisfied_count", 0)
- evaluated_notes = query_eval.get("evaluated_notes", [])
- # 分离满足和不满足需求的帖子
- satisfied_notes = [n for n in evaluated_notes if n.get('evaluation', {}).get('need_satisfaction')]
- unsatisfied_notes = [n for n in evaluated_notes if not n.get('evaluation', {}).get('need_satisfaction')]
- # 渲染满足需求的帖子
- satisfied_cards = ""
- for note in satisfied_notes:
- # 获取封面图
- image_list = note.get('image_list', [])
- if image_list:
- cover_url = image_list[0] if isinstance(image_list[0], str) else image_list[0].get('image_url', '')
- else:
- cover = note.get("cover_image", {})
- cover_url = cover.get("image_url", "") if isinstance(cover, dict) else cover if cover else ""
- interact = note.get("interact_info", {})
- user = note.get("user", {})
- evaluation = note.get("evaluation", {})
- confidence = evaluation.get("confidence_score", 0)
- # image_list 现在已经是 URL 字符串列表
- images = [img if isinstance(img, str) else img.get('image_url', '') for img in image_list if img]
- # 构建帖子数据用于模态框
- post_data = build_post_json_data(note, evaluation)
- images_json = json.dumps(images)
- image_html = f'<img src="{cover_url}" class="post-image" alt="{note.get("title", "")}">' if cover_url else '<div class="no-image">无图片</div>'
- type_badge = ""
- if note.get("type") == "video":
- type_badge = '<div class="post-type-badge">📹 视频</div>'
- # 轮播箭头按钮
- arrows_html = ""
- if len(images) > 1:
- arrows_html = '''
- <button class="carousel-arrow left" onclick="event.stopPropagation()">‹</button>
- <button class="carousel-arrow right" onclick="event.stopPropagation()">›</button>
- '''
- # 评估详情
- eval_reason = evaluation.get("reason", "")
- title_rel = evaluation.get("title_relevance", 0)
- content_exp = evaluation.get("content_expectation", 0)
- eval_details = ""
- # 置信度百分比
- confidence_percent = int(confidence * 100)
- satisfied_cards += f"""
- <div class="post-card" onclick='openModal({post_data})' data-images='{images_json}'>
- <div class="post-image-wrapper">
- {image_html}
- {type_badge}
- {arrows_html}
- </div>
- <div class="post-info">
- <div class="post-title">{note.get('title', '无标题')}</div>
- <div class="post-desc">{note.get('desc', '')}</div>
- <div class="post-meta">
- <div class="post-meta-item">❤️ {interact.get('liked_count', 0)}</div>
- <div class="post-meta-item">⭐ {interact.get('collected_count', 0)}</div>
- <div class="post-meta-item">💬 {interact.get('comment_count', 0)}</div>
- </div>
- <div class="post-author">👤 {user.get('nickname', '未知')}</div>
- <div class="post-id">{note.get('note_id', '')}</div>
- </div>
- <div class="confidence-bar">
- <div class="confidence-bar-fill {get_confidence_class(confidence)}" style="width: {confidence_percent}%">
- <span class="confidence-bar-text">置信度: {confidence:.2f}</span>
- </div>
- {eval_details}
- </div>
- </div>
- """
- # 渲染不满足需求的帖子
- unsatisfied_cards = ""
- for note in unsatisfied_notes:
- # 获取封面图
- image_list = note.get('image_list', [])
- if image_list:
- cover_url = image_list[0] if isinstance(image_list[0], str) else image_list[0].get('image_url', '')
- else:
- cover = note.get("cover_image", {})
- cover_url = cover.get("image_url", "") if isinstance(cover, dict) else cover if cover else ""
- interact = note.get("interact_info", {})
- user = note.get("user", {})
- evaluation = note.get("evaluation", {})
- confidence = evaluation.get("confidence_score", 0)
- # image_list 现在已经是 URL 字符串列表
- images = [img if isinstance(img, str) else img.get('image_url', '') for img in image_list if img]
- post_data = build_post_json_data(note, evaluation)
- images_json = json.dumps(images)
- image_html = f'<img src="{cover_url}" class="post-image" alt="{note.get("title", "")}">' if cover_url else '<div class="no-image">无图片</div>'
- type_badge = ""
- if note.get("type") == "video":
- type_badge = '<div class="post-type-badge">📹 视频</div>'
- arrows_html = ""
- if len(images) > 1:
- arrows_html = '''
- <button class="carousel-arrow left" onclick="event.stopPropagation()">‹</button>
- <button class="carousel-arrow right" onclick="event.stopPropagation()">›</button>
- '''
- eval_reason = evaluation.get("reason", "")
- title_rel = evaluation.get("title_relevance", 0)
- content_exp = evaluation.get("content_expectation", 0)
- eval_details = ""
- confidence_percent = int(confidence * 100)
- unsatisfied_cards += f"""
- <div class="post-card" onclick='openModal({post_data})' data-images='{images_json}'>
- <div class="post-image-wrapper">
- {image_html}
- {type_badge}
- {arrows_html}
- </div>
- <div class="post-info">
- <div class="post-title">{note.get('title', '无标题')}</div>
- <div class="post-desc">{note.get('desc', '')}</div>
- <div class="post-meta">
- <div class="post-meta-item">❤️ {interact.get('liked_count', 0)}</div>
- <div class="post-meta-item">⭐ {interact.get('collected_count', 0)}</div>
- <div class="post-meta-item">💬 {interact.get('comment_count', 0)}</div>
- </div>
- <div class="post-author">👤 {user.get('nickname', '未知')}</div>
- <div class="post-id">{note.get('note_id', '')}</div>
- </div>
- <div class="confidence-bar">
- <div class="confidence-bar-fill {get_confidence_class(confidence)}" style="width: {confidence_percent}%">
- <span class="confidence-bar-text">置信度: {confidence:.2f}</span>
- </div>
- {eval_details}
- </div>
- </div>
- """
- # 构建该query的评估结果,使用嵌套可折叠区域
- query_sections = ""
- if satisfied_cards:
- query_sections += make_collapsible(
- f"✅ 满足需求 ({len(satisfied_notes)} 个帖子)",
- f'<div class="posts-grid">{satisfied_cards}</div>',
- collapsed=True,
- section_id=f"step{step_num}-eval-{idx}-satisfied"
- )
- if unsatisfied_cards:
- query_sections += make_collapsible(
- f"❌ 不满足需求 ({len(unsatisfied_notes)} 个帖子)",
- f'<div class="posts-grid">{unsatisfied_cards}</div>',
- collapsed=True,
- section_id=f"step{step_num}-eval-{idx}-unsatisfied"
- )
- if query_sections:
- # 使用可折叠区域包装每个query的评估结果
- evals_html += make_collapsible(
- f"📊 {query} ({satisfied_count}/{len(evaluated_notes)} 个满足需求)",
- query_sections,
- collapsed=True,
- section_id=f"step{step_num}-eval-{idx}"
- )
- html = f"""
- <div class="step-section">
- <div class="step-header">
- <div class="step-title">步骤 {step['step_number']}: 帖子评估结果</div>
- <div class="step-type">{step['step_type']}</div>
- </div>
- <div class="step-content">
- <div class="info-grid">
- <div class="info-item">
- <div class="info-label">评估的query数</div>
- <div class="info-value">{data.get('query_count', 0)}</div>
- </div>
- <div class="info-item">
- <div class="info-label">总帖子数</div>
- <div class="info-value">{data.get('total_notes', 0)}</div>
- </div>
- <div class="info-item">
- <div class="info-label">满足需求的帖子</div>
- <div class="info-value">{total_satisfied}</div>
- </div>
- </div>
- {evals_html}
- </div>
- <div class="timestamp">⏰ {step.get('timestamp', '')}</div>
- </div>
- """
- return html
- def render_answer_generation(step):
- """渲染答案生成步骤"""
- data = step.get("data", {})
- result = data.get("result", {})
- answer = result.get("answer", "")
- confidence = result.get("confidence", 0)
- summary = result.get("summary", "")
- cited_notes = result.get("cited_notes", [])
- # 渲染引用的帖子
- cited_html = ""
- for note in cited_notes:
- # 获取封面图
- image_list = note.get('image_list', [])
- if image_list:
- cover_url = image_list[0] if isinstance(image_list[0], str) else image_list[0].get('image_url', '')
- else:
- cover = note.get("cover_image", {})
- cover_url = cover.get("image_url", "") if isinstance(cover, dict) else cover if cover else ""
- interact = note.get("interact_info", {})
- user = note.get("user", {})
- # image_list 现在已经是 URL 字符串列表
- images = [img if isinstance(img, str) else img.get('image_url', '') for img in image_list if img]
- # 构建帖子数据用于模态框(包含评估信息)
- eval_data = {
- 'reason': note.get("reason", ""),
- 'title_relevance': note.get("title_relevance", 0),
- 'content_expectation': note.get("content_expectation", 0),
- 'confidence_score': note.get('confidence_score', 0)
- }
- post_data = build_post_json_data(note, eval_data)
- images_json = json.dumps(images)
- image_html = f'<img src="{cover_url}" class="post-image" alt="{note.get("title", "")}">' if cover_url else '<div class="no-image">无图片</div>'
- # 类型标识
- type_badge = ""
- if note.get("type") == "video":
- type_badge = '<div class="post-type-badge">📹 视频</div>'
- # 轮播箭头按钮
- arrows_html = ""
- if len(images) > 1:
- arrows_html = '''
- <button class="carousel-arrow left" onclick="event.stopPropagation()">‹</button>
- <button class="carousel-arrow right" onclick="event.stopPropagation()">›</button>
- '''
- # 评估详情
- eval_reason = note.get("reason", "")
- title_rel = note.get("title_relevance", 0)
- content_exp = note.get("content_expectation", 0)
- eval_details = ""
- # 置信度百分比
- note_confidence = note.get('confidence_score', 0)
- confidence_percent = int(note_confidence * 100)
- cited_html += f"""
- <div class="post-card" onclick='openModal({post_data})' data-images='{images_json}'>
- <div class="post-image-wrapper">
- {image_html}
- {type_badge}
- {arrows_html}
- </div>
- <div class="post-info">
- <div class="post-title">[{note.get('index')}] {note.get('title', '无标题')}</div>
- <div class="post-desc">{note.get('desc', '')}</div>
- <div class="post-meta">
- <div class="post-meta-item">❤️ {interact.get('liked_count', 0)}</div>
- <div class="post-meta-item">⭐ {interact.get('collected_count', 0)}</div>
- <div class="post-meta-item">💬 {interact.get('comment_count', 0)}</div>
- </div>
- <div class="post-author">👤 {user.get('nickname', '未知')}</div>
- <div class="post-id">{note.get('note_id', '')}</div>
- </div>
- <div class="confidence-bar">
- <div class="confidence-bar-fill {get_confidence_class(note_confidence)}" style="width: {confidence_percent}%">
- <span class="confidence-bar-text">置信度: {note_confidence:.2f}</span>
- </div>
- {eval_details}
- </div>
- </div>
- """
- # 使用可折叠区域包装引用的帖子
- step_num = step['step_number']
- cited_section = ""
- if cited_html:
- cited_section = make_collapsible(
- f"📌 引用的帖子 ({len(cited_notes)} 个)",
- f'<div class="posts-grid">{cited_html}</div>',
- collapsed=True,
- section_id=f"step{step_num}-cited"
- )
- html = f"""
- <div class="step-section">
- <div class="step-header">
- <div class="step-title">步骤 {step['step_number']}: 生成答案</div>
- <div class="step-type">{step['step_type']}</div>
- </div>
- <div class="step-content">
- <div class="answer-box">
- <div class="answer-header">📝 生成的答案</div>
- <div class="answer-content">{answer}</div>
- <div class="answer-meta">
- <div><strong>置信度:</strong> {confidence:.2f}</div>
- <div><strong>引用帖子:</strong> {len(cited_notes)} 个</div>
- </div>
- </div>
- {f'<p style="margin-top: 15px; color: #666;"><strong>摘要:</strong> {summary}</p>' if summary else ''}
- {cited_section}
- </div>
- <div class="timestamp">⏰ {step.get('timestamp', '')}</div>
- </div>
- """
- return html
- def render_final_result(step):
- """渲染最终结果步骤"""
- data = step.get("data", {})
- success = data.get("success", False)
- message = data.get("message", "")
- satisfied_notes_count = data.get("satisfied_notes_count", 0)
- status_color = "#10b981" if success else "#ef4444"
- status_text = "✅ 成功" if success else "❌ 失败"
- html = f"""
- <div class="step-section" style="border: 3px solid {status_color};">
- <div class="step-header">
- <div class="step-title">步骤 {step['step_number']}: {step['step_name']}</div>
- <div class="step-type">{step['step_type']}</div>
- </div>
- <div class="step-content">
- <div class="info-grid">
- <div class="info-item" style="background: {status_color}20;">
- <div class="info-label">状态</div>
- <div class="info-value" style="color: {status_color};">{status_text}</div>
- </div>
- <div class="info-item">
- <div class="info-label">满足需求的帖子</div>
- <div class="info-value">{satisfied_notes_count}</div>
- </div>
- </div>
- <p style="margin-top: 20px; font-size: 15px; color: #666;">{message}</p>
- </div>
- <div class="timestamp">⏰ {step.get('timestamp', '')}</div>
- </div>
- """
- return html
- def render_query_suggestion_evaluation(step):
- """渲染候选query推荐词评估步骤"""
- data = step.get("data", {})
- candidate_count = data.get("candidate_count", 0)
- results = data.get("results", [])
- results_html = ""
- step_num = step['step_number']
- for idx, result in enumerate(results):
- candidate = result.get("candidate", "")
- suggestions = result.get("suggestions", [])
- evaluations = result.get("evaluations", [])
- # 渲染每个候选词的推荐词评估
- eval_cards = ""
- for evaluation in evaluations:
- query = evaluation.get("query", "")
- intent_match = evaluation.get("intent_match", False)
- relevance_score = evaluation.get("relevance_score", 0)
- reason = evaluation.get("reason", "")
- intent_badge = "✅ 意图匹配" if intent_match else "❌ 意图不匹配"
- intent_class = "confidence-high" if intent_match else "confidence-low"
- eval_cards += f"""
- <div class="query-item" style="margin: 10px 0; padding: 15px; background: white; border: 1px solid #e5e7eb; border-radius: 8px;">
- <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
- <div class="query-text" style="flex: 1;">{query}</div>
- <div style="display: flex; gap: 10px; align-items: center;">
- <span class="confidence-badge {intent_class}" style="margin: 0;">{intent_badge}</span>
- <span class="confidence-badge confidence-medium" style="margin: 0;">相关性: {relevance_score:.2f}</span>
- </div>
- </div>
- <div style="color: #666; font-size: 13px; line-height: 1.6; background: #f8f9fa; padding: 10px; border-radius: 4px;">
- {reason}
- </div>
- </div>
- """
- if eval_cards:
- # 使用可折叠区域包装每个候选词的推荐词列表,添加唯一ID
- results_html += make_collapsible(
- f"候选词: {candidate} ({len(evaluations)} 个推荐词)",
- eval_cards,
- collapsed=True,
- section_id=f"step{step_num}-candidate-{idx}"
- )
- html = f"""
- <div class="step-section">
- <div class="step-header">
- <div class="step-title">步骤 {step['step_number']}: {step['step_name']}</div>
- <div class="step-type">{step['step_type']}</div>
- </div>
- <div class="step-content">
- <div class="info-grid">
- <div class="info-item">
- <div class="info-label">候选query数</div>
- <div class="info-value">{candidate_count}</div>
- </div>
- <div class="info-item">
- <div class="info-label">总推荐词数</div>
- <div class="info-value">{sum(len(r.get('evaluations', [])) for r in results)}</div>
- </div>
- </div>
- {results_html}
- </div>
- <div class="timestamp">⏰ {step.get('timestamp', '')}</div>
- </div>
- """
- return html
- def render_filter_qualified_queries(step):
- """渲染筛选合格推荐词步骤"""
- data = step.get("data", {})
- input_count = data.get("input_evaluation_count", 0)
- qualified_count = data.get("qualified_count", 0)
- min_relevance = data.get("min_relevance_score", 0.7)
- all_queries = data.get("all_queries", [])
- # 如果没有all_queries,使用旧的qualified_queries
- if not all_queries:
- all_queries = data.get("qualified_queries", [])
- # 分离合格和不合格的查询
- qualified_html = ""
- unqualified_html = ""
- for item in all_queries:
- query = item.get("query", "")
- from_candidate = item.get("from_candidate", "")
- intent_match = item.get("intent_match", False)
- relevance_score = item.get("relevance_score", 0)
- reason = item.get("reason", "")
- is_qualified = item.get("is_qualified", True) # 默认为True以兼容旧数据
- intent_badge = "✅ 意图匹配" if intent_match else "❌ 意图不匹配"
- intent_class = "confidence-high" if intent_match else "confidence-low"
- # 根据相关性分数确定badge颜色
- if relevance_score >= 0.8:
- score_class = "confidence-high"
- elif relevance_score >= 0.6:
- score_class = "confidence-medium"
- else:
- score_class = "confidence-low"
- # 确定边框颜色和背景色
- if is_qualified:
- border_color = "#10b981"
- bg_color = "#f0fdf4"
- border_left_color = "#10b981"
- else:
- border_color = "#e5e7eb"
- bg_color = "#f9fafb"
- border_left_color = "#9ca3af"
- query_html = f"""
- <div class="query-item" style="margin: 15px 0; padding: 15px; background: white; border: 2px solid {border_color}; border-radius: 8px;">
- <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
- <div style="flex: 1;">
- <div class="query-text">{query}</div>
- <div style="color: #9ca3af; font-size: 12px; margin-top: 5px;">来自候选词: {from_candidate}</div>
- </div>
- <div style="display: flex; gap: 10px; align-items: center;">
- <span class="confidence-badge {intent_class}" style="margin: 0;">{intent_badge}</span>
- <span class="confidence-badge {score_class}" style="margin: 0;">相关性: {relevance_score:.2f}</span>
- </div>
- </div>
- <div style="color: #666; font-size: 13px; line-height: 1.6; background: {bg_color}; padding: 10px; border-radius: 4px; border-left: 3px solid {border_left_color};">
- {reason}
- </div>
- </div>
- """
- if is_qualified:
- qualified_html += query_html
- else:
- unqualified_html += query_html
- # 构建HTML - 使用可折叠区域
- step_num = step['step_number']
- qualified_section = make_collapsible(
- f"✅ 合格的推荐词 ({qualified_count})",
- qualified_html,
- collapsed=True,
- section_id=f"step{step_num}-qualified"
- ) if qualified_html else ''
- unqualified_section = make_collapsible(
- f"❌ 不合格的推荐词 ({input_count - qualified_count})",
- unqualified_html,
- collapsed=True,
- section_id=f"step{step_num}-unqualified"
- ) if unqualified_html else ''
- html = f"""
- <div class="step-section">
- <div class="step-header">
- <div class="step-title">步骤 {step['step_number']}: {step['step_name']}</div>
- <div class="step-type">{step['step_type']}</div>
- </div>
- <div class="step-content">
- <div class="info-grid">
- <div class="info-item">
- <div class="info-label">输入推荐词数</div>
- <div class="info-value">{input_count}</div>
- </div>
- <div class="info-item">
- <div class="info-label">合格推荐词数</div>
- <div class="info-value">{qualified_count}</div>
- </div>
- <div class="info-item">
- <div class="info-label">最低相关性</div>
- <div class="info-value">{min_relevance:.2f}</div>
- </div>
- </div>
- {qualified_section}
- {unqualified_section}
- </div>
- <div class="timestamp">⏰ {step.get('timestamp', '')}</div>
- </div>
- """
- return html
- def render_generic_step(step):
- """通用步骤渲染"""
- data = step.get("data", {})
- # 提取数据的简单展示
- data_html = ""
- if data:
- data_html = "<div class='step-content'><pre style='background: #f8f9fa; padding: 15px; border-radius: 4px; overflow-x: auto; font-size: 12px;'>"
- import json
- data_html += json.dumps(data, ensure_ascii=False, indent=2)[:500] # 限制长度
- if len(json.dumps(data)) > 500:
- data_html += "\n..."
- data_html += "</pre></div>"
- return f"""
- <div class="step-section">
- <div class="step-header">
- <div class="step-title">步骤 {step['step_number']}: {step['step_name']}</div>
- <div class="step-type">{step['step_type']}</div>
- </div>
- {data_html}
- <div class="timestamp">⏰ {step.get('timestamp', '')}</div>
- </div>
- """
- def render_step(step):
- """根据步骤类型渲染对应的HTML"""
- step_type = step.get("step_type", "")
- renderers = {
- "keyword_extraction": render_keyword_extraction,
- "level_exploration": render_level_exploration,
- "level_analysis": render_level_analysis,
- "query_suggestion_evaluation": render_query_suggestion_evaluation,
- "filter_qualified_queries": render_filter_qualified_queries,
- "search_qualified_queries": render_search_results,
- "evaluate_search_notes": render_note_evaluations,
- "answer_generation": render_answer_generation,
- "final_result": render_final_result,
- }
- renderer = renderers.get(step_type)
- if renderer:
- return renderer(step)
- else:
- # 使用通用渲染显示数据
- return render_generic_step(step)
- def generate_html(steps_json_path, output_path=None):
- """生成HTML可视化文件"""
- # 读取 steps.json
- with open(steps_json_path, 'r', encoding='utf-8') as f:
- steps_data = json.load(f)
- # 生成内容
- content_parts = [render_header(steps_data)]
- for step in steps_data:
- content_parts.append(render_step(step))
- content = "\n".join(content_parts)
- # 生成最终HTML(使用replace而不是format来避免CSS中的花括号问题)
- html = HTML_TEMPLATE.replace("{content}", content)
- # 确定输出路径
- if output_path is None:
- steps_path = Path(steps_json_path)
- output_path = steps_path.parent / "steps_visualization.html"
- # 写入文件
- with open(output_path, 'w', encoding='utf-8') as f:
- f.write(html)
- return output_path
- def main():
- parser = argparse.ArgumentParser(description="Steps 可视化工具")
- parser.add_argument("steps_json", type=str, help="steps.json 文件路径")
- parser.add_argument("-o", "--output", type=str, help="输出HTML文件路径(可选)")
- args = parser.parse_args()
- # 生成可视化
- output_path = generate_html(args.steps_json, args.output)
- print(f"✅ 可视化生成成功!")
- print(f"📄 输出文件: {output_path}")
- output_abs = Path(output_path).absolute() if isinstance(output_path, str) else output_path.absolute()
- print(f"\n💡 在浏览器中打开查看: file://{output_abs}")
- if __name__ == "__main__":
- main()
|