sug_v6_1_2_3.visualize.py 73 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180
  1. #!/usr/bin/env python3
  2. """
  3. Steps 可视化工具
  4. 将 steps.json 转换为 HTML 可视化页面
  5. """
  6. import json
  7. import argparse
  8. from pathlib import Path
  9. from datetime import datetime
  10. HTML_TEMPLATE = """<!DOCTYPE html>
  11. <html lang="zh-CN">
  12. <head>
  13. <meta charset="UTF-8">
  14. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  15. <title>Query Optimization Steps 可视化</title>
  16. <style>
  17. * {
  18. margin: 0;
  19. padding: 0;
  20. box-sizing: border-box;
  21. }
  22. body {
  23. font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
  24. background: #f5f5f5;
  25. color: #333;
  26. line-height: 1.6;
  27. display: flex;
  28. margin: 0;
  29. padding: 0;
  30. }
  31. /* 左侧导航 */
  32. .sidebar {
  33. width: 280px;
  34. background: white;
  35. height: 100vh;
  36. position: fixed;
  37. left: 0;
  38. top: 0;
  39. overflow-y: auto;
  40. box-shadow: 2px 0 8px rgba(0,0,0,0.1);
  41. z-index: 100;
  42. }
  43. .sidebar-header {
  44. padding: 20px;
  45. background: #2563eb;
  46. color: white;
  47. font-size: 18px;
  48. font-weight: 600;
  49. }
  50. .toc {
  51. padding: 10px 0;
  52. }
  53. .toc-item {
  54. padding: 10px 20px;
  55. cursor: pointer;
  56. transition: background 0.2s;
  57. border-left: 3px solid transparent;
  58. }
  59. .toc-item:hover {
  60. background: #f0f9ff;
  61. }
  62. .toc-item.active {
  63. background: #eff6ff;
  64. border-left-color: #2563eb;
  65. color: #2563eb;
  66. font-weight: 600;
  67. }
  68. .toc-item-level-0 {
  69. font-weight: 600;
  70. color: #1a1a1a;
  71. font-size: 14px;
  72. }
  73. .toc-item-level-1 {
  74. padding-left: 35px;
  75. font-size: 13px;
  76. color: #666;
  77. }
  78. .toc-item-level-2 {
  79. padding-left: 50px;
  80. font-size: 12px;
  81. color: #999;
  82. }
  83. .toc-toggle {
  84. display: inline-block;
  85. width: 16px;
  86. height: 16px;
  87. margin-left: 5px;
  88. cursor: pointer;
  89. transition: transform 0.2s;
  90. float: right;
  91. }
  92. .toc-toggle.collapsed {
  93. transform: rotate(-90deg);
  94. }
  95. .toc-children {
  96. display: none;
  97. }
  98. .toc-children.expanded {
  99. display: block;
  100. }
  101. .container {
  102. margin-left: 280px;
  103. width: calc(100% - 280px);
  104. padding: 20px;
  105. }
  106. .header {
  107. background: white;
  108. padding: 30px;
  109. border-radius: 12px;
  110. margin-bottom: 30px;
  111. box-shadow: 0 2px 8px rgba(0,0,0,0.1);
  112. }
  113. .header h1 {
  114. font-size: 32px;
  115. margin-bottom: 20px;
  116. color: #1a1a1a;
  117. }
  118. .question-box {
  119. background: #f0f9ff;
  120. padding: 20px;
  121. border-radius: 8px;
  122. margin-bottom: 20px;
  123. border-left: 4px solid #0284c7;
  124. }
  125. .question-label {
  126. font-size: 14px;
  127. color: #0369a1;
  128. margin-bottom: 8px;
  129. font-weight: 600;
  130. }
  131. .question-text {
  132. font-size: 18px;
  133. color: #1a1a1a;
  134. line-height: 1.6;
  135. }
  136. .overview {
  137. display: flex;
  138. gap: 30px;
  139. flex-wrap: wrap;
  140. }
  141. .overview-item {
  142. flex: 1;
  143. min-width: 150px;
  144. }
  145. .overview-label {
  146. font-size: 14px;
  147. color: #666;
  148. margin-bottom: 5px;
  149. }
  150. .overview-value {
  151. font-size: 28px;
  152. font-weight: bold;
  153. color: #2563eb;
  154. }
  155. .step-section {
  156. background: white;
  157. padding: 30px;
  158. border-radius: 12px;
  159. margin-bottom: 30px;
  160. box-shadow: 0 2px 8px rgba(0,0,0,0.1);
  161. }
  162. .step-header {
  163. border-bottom: 3px solid #2563eb;
  164. padding-bottom: 15px;
  165. margin-bottom: 20px;
  166. display: flex;
  167. justify-content: space-between;
  168. align-items: center;
  169. }
  170. .step-title {
  171. font-size: 26px;
  172. color: #1a1a1a;
  173. }
  174. .step-type {
  175. background: #e0e7ff;
  176. color: #4338ca;
  177. padding: 6px 15px;
  178. border-radius: 20px;
  179. font-size: 13px;
  180. font-weight: 600;
  181. font-family: monospace;
  182. }
  183. .step-content {
  184. margin-top: 20px;
  185. }
  186. .info-grid {
  187. display: grid;
  188. grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  189. gap: 20px;
  190. margin-bottom: 20px;
  191. }
  192. .info-item {
  193. background: #f8f9fa;
  194. padding: 15px;
  195. border-radius: 8px;
  196. }
  197. .info-label {
  198. font-size: 13px;
  199. color: #666;
  200. margin-bottom: 5px;
  201. }
  202. .info-value {
  203. font-size: 20px;
  204. font-weight: bold;
  205. color: #1a1a1a;
  206. }
  207. .posts-grid {
  208. display: grid;
  209. grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
  210. gap: 20px;
  211. margin-top: 20px;
  212. padding-top: 100px;
  213. margin-top: -80px;
  214. }
  215. .post-card {
  216. background: white;
  217. border-radius: 8px;
  218. overflow: visible;
  219. transition: transform 0.2s, box-shadow 0.2s;
  220. border: 1px solid #e5e7eb;
  221. cursor: pointer;
  222. position: relative;
  223. }
  224. .post-card:hover {
  225. transform: translateY(-4px);
  226. box-shadow: 0 6px 16px rgba(0,0,0,0.15);
  227. }
  228. .post-image-wrapper {
  229. width: 100%;
  230. background: #f3f4f6;
  231. position: relative;
  232. padding-top: 133.33%; /* 3:4 aspect ratio */
  233. overflow: hidden;
  234. border-radius: 8px 8px 0 0;
  235. }
  236. .post-image {
  237. position: absolute;
  238. top: 0;
  239. left: 0;
  240. width: 100%;
  241. height: 100%;
  242. object-fit: cover;
  243. }
  244. .no-image {
  245. position: absolute;
  246. top: 50%;
  247. left: 50%;
  248. transform: translate(-50%, -50%);
  249. color: #9ca3af;
  250. font-size: 14px;
  251. }
  252. .post-type-badge {
  253. position: absolute;
  254. top: 10px;
  255. right: 10px;
  256. background: rgba(0, 0, 0, 0.7);
  257. color: white;
  258. padding: 4px 10px;
  259. border-radius: 15px;
  260. font-size: 11px;
  261. font-weight: 600;
  262. }
  263. .post-info {
  264. padding: 15px;
  265. position: relative;
  266. overflow: visible;
  267. }
  268. .post-title {
  269. font-size: 14px;
  270. font-weight: 600;
  271. margin-bottom: 8px;
  272. color: #1a1a1a;
  273. display: -webkit-box;
  274. -webkit-line-clamp: 2;
  275. -webkit-box-orient: vertical;
  276. overflow: hidden;
  277. }
  278. .post-desc {
  279. font-size: 12px;
  280. color: #6b7280;
  281. margin-bottom: 10px;
  282. display: -webkit-box;
  283. -webkit-line-clamp: 2;
  284. -webkit-box-orient: vertical;
  285. overflow: hidden;
  286. }
  287. .post-meta {
  288. display: flex;
  289. gap: 15px;
  290. margin-bottom: 8px;
  291. font-size: 12px;
  292. color: #9ca3af;
  293. }
  294. .post-meta-item {
  295. display: flex;
  296. align-items: center;
  297. gap: 4px;
  298. }
  299. .post-author {
  300. font-size: 12px;
  301. color: #6b7280;
  302. margin-bottom: 8px;
  303. }
  304. .post-id {
  305. font-size: 10px;
  306. color: #9ca3af;
  307. font-family: monospace;
  308. }
  309. .evaluation-reason {
  310. position: absolute;
  311. bottom: calc(100% + 10px);
  312. left: 50%;
  313. transform: translateX(-50%);
  314. background: #2d3748;
  315. color: white;
  316. padding: 12px 16px;
  317. border-radius: 8px;
  318. font-size: 12px;
  319. line-height: 1.5;
  320. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
  321. z-index: 1000;
  322. display: none;
  323. white-space: normal;
  324. width: 280px;
  325. }
  326. /* Tooltip 箭头 - 指向下方进度条 */
  327. .evaluation-reason::after {
  328. content: '';
  329. position: absolute;
  330. top: 100%;
  331. left: 50%;
  332. transform: translateX(-50%);
  333. border: 6px solid transparent;
  334. border-top-color: #2d3748;
  335. }
  336. .confidence-bar:hover .evaluation-reason {
  337. display: block !important;
  338. }
  339. /* Debug: 让进度条更明显可悬停 */
  340. .confidence-bar {
  341. min-height: 32px;
  342. }
  343. .evaluation-reason strong {
  344. color: #fbbf24;
  345. font-size: 13px;
  346. }
  347. .evaluation-scores {
  348. display: flex;
  349. gap: 10px;
  350. margin-top: 10px;
  351. font-size: 12px;
  352. flex-wrap: wrap;
  353. }
  354. .score-item {
  355. background: rgba(255, 255, 255, 0.15);
  356. padding: 5px 10px;
  357. border-radius: 12px;
  358. color: #fbbf24;
  359. border: 1px solid rgba(251, 191, 36, 0.3);
  360. }
  361. .confidence-bar {
  362. width: 100%;
  363. height: 32px;
  364. background: #f3f4f6;
  365. position: relative;
  366. cursor: help;
  367. display: flex;
  368. align-items: center;
  369. border-radius: 0 0 8px 8px;
  370. overflow: hidden;
  371. }
  372. .confidence-bar-fill {
  373. height: 100%;
  374. transition: width 0.5s ease-out;
  375. display: flex;
  376. align-items: center;
  377. padding: 0 12px;
  378. position: relative;
  379. }
  380. .confidence-bar-fill.confidence-low {
  381. background: linear-gradient(90deg, #ef4444, #f87171);
  382. }
  383. .confidence-bar-fill.confidence-medium {
  384. background: linear-gradient(90deg, #f59e0b, #fbbf24);
  385. }
  386. .confidence-bar-fill.confidence-high {
  387. background: linear-gradient(90deg, #10b981, #34d399);
  388. }
  389. .confidence-bar-text {
  390. color: white;
  391. font-size: 12px;
  392. font-weight: 600;
  393. white-space: nowrap;
  394. position: relative;
  395. z-index: 1;
  396. text-shadow: 0 1px 2px rgba(0,0,0,0.2);
  397. }
  398. /* 保留旧的badge样式用于兼容 */
  399. .confidence-badge {
  400. background: #10b981;
  401. color: white;
  402. padding: 4px 10px;
  403. border-radius: 15px;
  404. font-size: 12px;
  405. font-weight: bold;
  406. display: inline-block;
  407. margin-bottom: 10px;
  408. position: relative;
  409. cursor: help;
  410. }
  411. .confidence-low {
  412. background: #ef4444;
  413. }
  414. .confidence-medium {
  415. background: #f59e0b;
  416. }
  417. .confidence-high {
  418. background: #10b981;
  419. }
  420. .query-list {
  421. background: #f8f9fa;
  422. padding: 20px;
  423. border-radius: 8px;
  424. margin-top: 15px;
  425. }
  426. .query-item {
  427. background: white;
  428. padding: 15px;
  429. border-radius: 6px;
  430. margin-bottom: 10px;
  431. border-left: 3px solid #2563eb;
  432. }
  433. .query-text {
  434. font-size: 15px;
  435. font-weight: 600;
  436. color: #1a1a1a;
  437. margin-bottom: 5px;
  438. }
  439. .query-meta {
  440. font-size: 13px;
  441. color: #666;
  442. }
  443. .answer-box {
  444. background: #f0fdf4;
  445. border: 2px solid #10b981;
  446. border-radius: 8px;
  447. padding: 25px;
  448. margin-top: 20px;
  449. }
  450. .answer-header {
  451. font-size: 18px;
  452. color: #059669;
  453. margin-bottom: 15px;
  454. font-weight: 600;
  455. }
  456. .answer-content {
  457. font-size: 15px;
  458. line-height: 1.8;
  459. color: #1a1a1a;
  460. white-space: pre-wrap;
  461. }
  462. .answer-meta {
  463. margin-top: 15px;
  464. padding-top: 15px;
  465. border-top: 1px solid #d1fae5;
  466. display: flex;
  467. gap: 20px;
  468. font-size: 13px;
  469. color: #059669;
  470. }
  471. .keyword-tags {
  472. display: flex;
  473. flex-wrap: wrap;
  474. gap: 10px;
  475. margin-top: 15px;
  476. }
  477. .keyword-tag {
  478. background: #dbeafe;
  479. color: #1e40af;
  480. padding: 6px 12px;
  481. border-radius: 15px;
  482. font-size: 13px;
  483. font-weight: 500;
  484. }
  485. .level-analysis {
  486. background: #fef3c7;
  487. border-left: 4px solid #f59e0b;
  488. padding: 20px;
  489. border-radius: 6px;
  490. margin-top: 15px;
  491. }
  492. .level-analysis-title {
  493. font-size: 16px;
  494. color: #92400e;
  495. margin-bottom: 10px;
  496. font-weight: 600;
  497. }
  498. .level-analysis-text {
  499. font-size: 14px;
  500. color: #78350f;
  501. line-height: 1.8;
  502. }
  503. .timestamp {
  504. font-size: 12px;
  505. color: #9ca3af;
  506. margin-top: 10px;
  507. }
  508. a {
  509. color: #2563eb;
  510. text-decoration: none;
  511. }
  512. a:hover {
  513. text-decoration: underline;
  514. }
  515. /* 模态框样式 */
  516. .modal-overlay {
  517. display: none;
  518. position: fixed;
  519. top: 0;
  520. left: 0;
  521. right: 0;
  522. bottom: 0;
  523. background: rgba(0, 0, 0, 0.85);
  524. z-index: 1000;
  525. align-items: center;
  526. justify-content: center;
  527. padding: 20px;
  528. overflow-y: auto;
  529. }
  530. .modal-overlay.active {
  531. display: flex;
  532. }
  533. .modal-content {
  534. background: white;
  535. border-radius: 12px;
  536. max-width: 1000px;
  537. width: 100%;
  538. max-height: 90vh;
  539. overflow-y: auto;
  540. position: relative;
  541. animation: modalSlideIn 0.3s;
  542. }
  543. @keyframes modalSlideIn {
  544. from { opacity: 0; transform: translateY(-30px); }
  545. to { opacity: 1; transform: translateY(0); }
  546. }
  547. .modal-close {
  548. position: sticky;
  549. top: 0;
  550. right: 0;
  551. background: white;
  552. border: none;
  553. font-size: 36px;
  554. color: #6b7280;
  555. cursor: pointer;
  556. padding: 15px 25px;
  557. z-index: 10;
  558. text-align: right;
  559. border-bottom: 2px solid #e5e7eb;
  560. transition: color 0.2s;
  561. }
  562. .modal-close:hover {
  563. color: #1f2937;
  564. }
  565. .modal-body {
  566. padding: 30px;
  567. }
  568. .modal-title {
  569. font-size: 26px;
  570. font-weight: 700;
  571. color: #1a1a1a;
  572. margin-bottom: 15px;
  573. line-height: 1.4;
  574. }
  575. .modal-meta {
  576. display: flex;
  577. gap: 20px;
  578. flex-wrap: wrap;
  579. margin-bottom: 25px;
  580. padding-bottom: 20px;
  581. border-bottom: 1px solid #e5e7eb;
  582. }
  583. .modal-meta-item {
  584. display: flex;
  585. align-items: center;
  586. gap: 6px;
  587. font-size: 14px;
  588. color: #6b7280;
  589. }
  590. .modal-images {
  591. margin-bottom: 25px;
  592. }
  593. .modal-images-grid {
  594. display: grid;
  595. grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
  596. gap: 12px;
  597. }
  598. .modal-image-item {
  599. border-radius: 8px;
  600. overflow: hidden;
  601. border: 2px solid #e5e7eb;
  602. transition: border-color 0.2s;
  603. cursor: pointer;
  604. }
  605. .modal-image-item:hover {
  606. border-color: #2563eb;
  607. }
  608. .modal-image {
  609. width: 100%;
  610. height: auto;
  611. display: block;
  612. max-height: 250px;
  613. object-fit: cover;
  614. }
  615. .modal-section {
  616. margin-bottom: 25px;
  617. }
  618. .modal-section-title {
  619. font-size: 17px;
  620. font-weight: 600;
  621. color: #374151;
  622. margin-bottom: 12px;
  623. }
  624. .modal-text-content {
  625. font-size: 15px;
  626. color: #1f2937;
  627. line-height: 1.8;
  628. white-space: pre-wrap;
  629. background: #f9fafb;
  630. padding: 18px;
  631. border-radius: 8px;
  632. }
  633. .modal-evaluation {
  634. background: #fef3c7;
  635. border-left: 4px solid #f59e0b;
  636. padding: 18px;
  637. border-radius: 6px;
  638. }
  639. .modal-link {
  640. margin-top: 25px;
  641. padding-top: 25px;
  642. border-top: 2px solid #e5e7eb;
  643. text-align: center;
  644. }
  645. .modal-link-btn {
  646. display: inline-flex;
  647. align-items: center;
  648. gap: 10px;
  649. padding: 12px 28px;
  650. background: #2563eb;
  651. color: white;
  652. text-decoration: none;
  653. border-radius: 8px;
  654. font-size: 15px;
  655. font-weight: 600;
  656. transition: all 0.2s;
  657. }
  658. .modal-link-btn:hover {
  659. background: #1d4ed8;
  660. transform: translateY(-2px);
  661. box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3);
  662. }
  663. /* 卡片上的图片轮播指示器 */
  664. .carousel-arrow {
  665. position: absolute;
  666. top: 50%;
  667. transform: translateY(-50%);
  668. background: rgba(0, 0, 0, 0.6);
  669. color: white;
  670. border: none;
  671. width: 36px;
  672. height: 36px;
  673. border-radius: 50%;
  674. font-size: 20px;
  675. cursor: pointer;
  676. z-index: 15;
  677. display: flex;
  678. align-items: center;
  679. justify-content: center;
  680. transition: all 0.2s;
  681. opacity: 0;
  682. }
  683. .post-image-wrapper:hover .carousel-arrow {
  684. opacity: 1;
  685. }
  686. .carousel-arrow:hover {
  687. background: rgba(0, 0, 0, 0.8);
  688. transform: translateY(-50%) scale(1.1);
  689. }
  690. .carousel-arrow.left {
  691. left: 8px;
  692. }
  693. .carousel-arrow.right {
  694. right: 8px;
  695. }
  696. /* 可折叠区域样式 */
  697. .collapsible-section {
  698. margin: 20px 0;
  699. }
  700. .collapsible-header {
  701. background: #f3f4f6;
  702. padding: 12px 15px;
  703. border-radius: 8px;
  704. cursor: pointer;
  705. display: flex;
  706. align-items: center;
  707. gap: 10px;
  708. transition: background 0.2s;
  709. user-select: none;
  710. }
  711. .collapsible-header:hover {
  712. background: #e5e7eb;
  713. }
  714. .collapsible-toggle {
  715. font-size: 14px;
  716. transition: transform 0.2s;
  717. }
  718. .collapsible-toggle.collapsed {
  719. transform: rotate(-90deg);
  720. }
  721. .collapsible-title {
  722. font-weight: 600;
  723. font-size: 16px;
  724. color: #374151;
  725. }
  726. .collapsible-content {
  727. max-height: 10000px;
  728. overflow: hidden;
  729. transition: max-height 0.3s ease-out, opacity 0.3s ease-out;
  730. opacity: 1;
  731. }
  732. .collapsible-content.collapsed {
  733. max-height: 0;
  734. opacity: 0;
  735. }
  736. </style>
  737. </head>
  738. <body>
  739. <!-- 左侧导航 -->
  740. <div class="sidebar">
  741. <div class="sidebar-header">📑 目录</div>
  742. <div class="toc" id="toc"></div>
  743. </div>
  744. <!-- 主内容区 -->
  745. <div class="container">
  746. {content}
  747. </div>
  748. <!-- 模态框 -->
  749. <div id="postModal" class="modal-overlay" onclick="if(event.target === this) closeModal()">
  750. <div class="modal-content" onclick="event.stopPropagation()">
  751. <button class="modal-close" onclick="closeModal()">&times;</button>
  752. <div class="modal-body" id="modalBody">
  753. <!-- 动态内容 -->
  754. </div>
  755. </div>
  756. </div>
  757. <script>
  758. // 模态框功能
  759. function openModal(postData) {
  760. const modal = document.getElementById('postModal');
  761. const modalBody = document.getElementById('modalBody');
  762. // 构建图片网格
  763. let imagesHtml = '';
  764. if (postData.images && postData.images.length > 0) {
  765. imagesHtml = '<div class="modal-images"><div class="modal-images-grid">';
  766. postData.images.forEach((img, idx) => {
  767. imagesHtml += `<div class="modal-image-item"><img src="${img}" class="modal-image" alt="图片 ${idx + 1}"></div>`;
  768. });
  769. imagesHtml += '</div></div>';
  770. }
  771. // 构建评估详情
  772. let evalHtml = '';
  773. if (postData.evaluation) {
  774. evalHtml = `
  775. <div class="modal-section">
  776. <div class="modal-section-title">💡 评估详情</div>
  777. <div class="modal-evaluation">
  778. <div style="margin-bottom: 12px;"><strong>评估理由:</strong></div>
  779. <div style="color: #78350f; line-height: 1.8;">${postData.evaluation.reason || '无'}</div>
  780. <div class="evaluation-scores" style="margin-top: 12px;">
  781. <span class="score-item">📌 标题相关性: ${postData.evaluation.title_relevance?.toFixed(2) || '0.00'}</span>
  782. <span class="score-item">📄 内容期望: ${postData.evaluation.content_expectation?.toFixed(2) || '0.00'}</span>
  783. <span class="score-item">🎯 置信度: ${postData.evaluation.confidence_score?.toFixed(2) || '0.00'}</span>
  784. </div>
  785. </div>
  786. </div>`;
  787. }
  788. modalBody.innerHTML = `
  789. <div class="modal-title">${postData.title}</div>
  790. <div class="modal-meta">
  791. <div class="modal-meta-item">👤 ${postData.user}</div>
  792. <div class="modal-meta-item">❤️ ${postData.likes}</div>
  793. <div class="modal-meta-item">⭐ ${postData.collects}</div>
  794. <div class="modal-meta-item">💬 ${postData.comments}</div>
  795. ${postData.type === 'video' ? '<div class="modal-meta-item">📹 视频</div>' : ''}
  796. </div>
  797. ${imagesHtml}
  798. <div class="modal-section">
  799. <div class="modal-section-title">📝 描述</div>
  800. <div class="modal-text-content">${postData.desc || '无描述'}</div>
  801. </div>
  802. ${evalHtml}
  803. <div class="modal-link">
  804. <a href="${postData.url}" target="_blank" class="modal-link-btn">
  805. 🔗 在小红书中查看
  806. </a>
  807. </div>
  808. `;
  809. modal.classList.add('active');
  810. document.body.style.overflow = 'hidden';
  811. }
  812. function closeModal() {
  813. const modal = document.getElementById('postModal');
  814. modal.classList.remove('active');
  815. document.body.style.overflow = '';
  816. }
  817. // ESC键关闭模态框
  818. document.addEventListener('keydown', function(e) {
  819. if (e.key === 'Escape') {
  820. closeModal();
  821. }
  822. });
  823. // 卡片上的图片轮播(使用左右箭头按钮)
  824. function initCarousels() {
  825. document.querySelectorAll('.post-card').forEach(card => {
  826. const images = JSON.parse(card.dataset.images || '[]');
  827. if (images.length <= 1) return;
  828. let currentIndex = 0;
  829. const imgElement = card.querySelector('.post-image');
  830. const leftArrow = card.querySelector('.carousel-arrow.left');
  831. const rightArrow = card.querySelector('.carousel-arrow.right');
  832. // 左箭头点击
  833. if (leftArrow) {
  834. leftArrow.addEventListener('click', function(e) {
  835. e.stopPropagation();
  836. currentIndex = (currentIndex - 1 + images.length) % images.length;
  837. if (imgElement) {
  838. imgElement.src = images[currentIndex];
  839. }
  840. });
  841. }
  842. // 右箭头点击
  843. if (rightArrow) {
  844. rightArrow.addEventListener('click', function(e) {
  845. e.stopPropagation();
  846. currentIndex = (currentIndex + 1) % images.length;
  847. if (imgElement) {
  848. imgElement.src = images[currentIndex];
  849. }
  850. });
  851. }
  852. });
  853. }
  854. // 生成目录(显示步骤和可折叠的子项)
  855. function generateTOC() {
  856. const toc = document.getElementById('toc');
  857. const sections = document.querySelectorAll('.step-section');
  858. sections.forEach((section, index) => {
  859. const title = section.querySelector('.step-title')?.textContent || `步骤 ${index + 1}`;
  860. const id = `step-${index}`;
  861. section.id = id;
  862. // 查找该section下的直接子可折叠项(不包括嵌套的)
  863. const collapsibleSections = section.querySelectorAll(':scope > .step-content > .collapsible-section[id]');
  864. // 创建步骤项
  865. const stepItem = document.createElement('div');
  866. stepItem.className = 'toc-item toc-item-level-0';
  867. if (collapsibleSections.length > 0) {
  868. // 如果有子项,添加展开/折叠图标(箭头放在右侧)
  869. const toggleId = `toc-toggle-${index}`;
  870. stepItem.innerHTML = `<span>${title}</span><span class="toc-toggle" id="${toggleId}">▼</span>`;
  871. const toggle = stepItem.querySelector('.toc-toggle');
  872. const childrenId = `toc-children-${index}`;
  873. toggle.onclick = (e) => {
  874. e.stopPropagation();
  875. toggle.classList.toggle('collapsed');
  876. const children = document.getElementById(childrenId);
  877. if (children) {
  878. children.classList.toggle('expanded');
  879. }
  880. };
  881. } else {
  882. stepItem.textContent = title;
  883. }
  884. stepItem.onclick = (e) => {
  885. if (!e.target.classList.contains('toc-toggle')) {
  886. scrollToSection(id);
  887. }
  888. };
  889. toc.appendChild(stepItem);
  890. // 添加子项目录(支持嵌套)
  891. if (collapsibleSections.length > 0) {
  892. const childrenContainer = document.createElement('div');
  893. childrenContainer.id = `toc-children-${index}`;
  894. childrenContainer.className = 'toc-children expanded';
  895. collapsibleSections.forEach(collapsible => {
  896. const subTitle = collapsible.getAttribute('data-title') || '子项';
  897. const subId = collapsible.id;
  898. const subItem = document.createElement('div');
  899. subItem.className = 'toc-item toc-item-level-1';
  900. subItem.textContent = subTitle;
  901. subItem.onclick = () => scrollToSection(subId);
  902. childrenContainer.appendChild(subItem);
  903. // 查找该可折叠区域内的嵌套可折叠区域
  904. const nestedCollapsibles = collapsible.querySelectorAll(':scope > .collapsible-content > .collapsible-section[id]');
  905. if (nestedCollapsibles.length > 0) {
  906. nestedCollapsibles.forEach(nested => {
  907. const nestedTitle = nested.getAttribute('data-title') || '子项';
  908. const nestedId = nested.id;
  909. const nestedItem = document.createElement('div');
  910. nestedItem.className = 'toc-item toc-item-level-2';
  911. nestedItem.textContent = nestedTitle;
  912. nestedItem.onclick = () => scrollToSection(nestedId);
  913. childrenContainer.appendChild(nestedItem);
  914. });
  915. }
  916. });
  917. toc.appendChild(childrenContainer);
  918. }
  919. });
  920. }
  921. // 滚动到指定section
  922. function scrollToSection(id) {
  923. const element = document.getElementById(id);
  924. if (element) {
  925. const offset = 80;
  926. const elementPosition = element.getBoundingClientRect().top;
  927. const offsetPosition = elementPosition + window.pageYOffset - offset;
  928. window.scrollTo({
  929. top: offsetPosition,
  930. behavior: 'smooth'
  931. });
  932. // 更新active状态
  933. document.querySelectorAll('.toc-item').forEach(item => item.classList.remove('active'));
  934. event.target.classList.add('active');
  935. }
  936. }
  937. // 滚动时高亮当前section
  938. function updateActiveTOC() {
  939. const sections = document.querySelectorAll('.step-section');
  940. const tocItems = document.querySelectorAll('.toc-item');
  941. let currentIndex = -1;
  942. sections.forEach((section, index) => {
  943. const rect = section.getBoundingClientRect();
  944. if (rect.top <= 100) {
  945. currentIndex = index;
  946. }
  947. });
  948. tocItems.forEach((item, index) => {
  949. item.classList.toggle('active', index === currentIndex);
  950. });
  951. }
  952. // 初始化可折叠区域
  953. function initCollapsibles() {
  954. document.querySelectorAll('.collapsible-header').forEach(header => {
  955. header.addEventListener('click', function() {
  956. const toggle = this.querySelector('.collapsible-toggle');
  957. const content = this.nextElementSibling;
  958. if (content && content.classList.contains('collapsible-content')) {
  959. toggle.classList.toggle('collapsed');
  960. content.classList.toggle('collapsed');
  961. }
  962. });
  963. });
  964. }
  965. // 页面加载完成后初始化
  966. document.addEventListener('DOMContentLoaded', function() {
  967. initCarousels();
  968. generateTOC();
  969. initCollapsibles();
  970. window.addEventListener('scroll', updateActiveTOC);
  971. updateActiveTOC();
  972. });
  973. </script>
  974. </body>
  975. </html>
  976. """
  977. def make_collapsible(title, content, collapsed=True, section_id=None):
  978. """创建可折叠区域的HTML"""
  979. collapsed_class = " collapsed" if collapsed else ""
  980. id_attr = f' id="{section_id}"' if section_id else ""
  981. # 添加 data-title 属性用于目录生成
  982. title_attr = f' data-title="{title}"' if section_id else ""
  983. return f"""
  984. <div class="collapsible-section"{id_attr}{title_attr}>
  985. <div class="collapsible-header">
  986. <span class="collapsible-toggle{collapsed_class}">▼</span>
  987. <span class="collapsible-title">{title}</span>
  988. </div>
  989. <div class="collapsible-content{collapsed_class}">
  990. {content}
  991. </div>
  992. </div>
  993. """
  994. def get_confidence_class(score):
  995. """根据置信度分数返回CSS类"""
  996. if score >= 0.7:
  997. return "confidence-high"
  998. elif score >= 0.5:
  999. return "confidence-medium"
  1000. else:
  1001. return "confidence-low"
  1002. def escape_js_string(s):
  1003. """转义JavaScript字符串"""
  1004. import json
  1005. return json.dumps(str(s) if s else "")
  1006. def build_post_json_data(note, evaluation=None):
  1007. """构建帖子的JSON数据用于模态框"""
  1008. import json
  1009. image_list = note.get('image_list', [])
  1010. if not image_list and note.get('cover_image'):
  1011. cover = note.get('cover_image')
  1012. # cover_image 可能是字典或字符串
  1013. if isinstance(cover, dict):
  1014. image_list = [cover.get('image_url', '')]
  1015. else:
  1016. image_list = [cover]
  1017. # image_list 现在已经是 URL 字符串列表(由搜索API预处理)
  1018. images = [img if isinstance(img, str) else img.get('image_url', '') for img in image_list if img]
  1019. interact = note.get('interact_info', {})
  1020. user = note.get('user', {})
  1021. data = {
  1022. 'title': note.get('title', '无标题'),
  1023. 'desc': note.get('desc', ''),
  1024. 'user': user.get('nickname', '未知'),
  1025. 'likes': interact.get('liked_count', 0),
  1026. 'collects': interact.get('collected_count', 0),
  1027. 'comments': interact.get('comment_count', 0),
  1028. 'type': note.get('type', 'normal'),
  1029. 'url': note.get('note_url', ''),
  1030. 'images': images
  1031. }
  1032. if evaluation:
  1033. data['evaluation'] = {
  1034. 'reason': evaluation.get('reason', ''),
  1035. 'title_relevance': evaluation.get('title_relevance', 0),
  1036. 'content_expectation': evaluation.get('content_expectation', 0),
  1037. 'confidence_score': evaluation.get('confidence_score', 0)
  1038. }
  1039. return json.dumps(data, ensure_ascii=False)
  1040. def render_header(steps_data):
  1041. """渲染页面头部"""
  1042. # 获取基本信息
  1043. first_step = steps_data[0] if steps_data else {}
  1044. last_step = steps_data[-1] if steps_data else {}
  1045. original_question = ""
  1046. keywords = []
  1047. total_steps = len(steps_data)
  1048. satisfied_notes = 0
  1049. # 提取关键信息
  1050. for step in steps_data:
  1051. if step.get("step_type") == "keyword_extraction":
  1052. original_question = step.get("data", {}).get("input_question", "")
  1053. keywords = step.get("data", {}).get("keywords", [])
  1054. elif step.get("step_type") == "final_result":
  1055. satisfied_notes = step.get("data", {}).get("satisfied_notes_count", 0)
  1056. keywords_html = "".join([f'<span class="keyword-tag">{k}</span>' for k in keywords])
  1057. html = f"""
  1058. <div class="header">
  1059. <h1>🔍 Query Optimization Steps</h1>
  1060. <div class="question-box">
  1061. <div class="question-label">原始问题</div>
  1062. <div class="question-text">{original_question}</div>
  1063. </div>
  1064. {f'<div class="keyword-tags">{keywords_html}</div>' if keywords else ''}
  1065. <div class="overview">
  1066. <div class="overview-item">
  1067. <div class="overview-label">总步骤数</div>
  1068. <div class="overview-value">{total_steps}</div>
  1069. </div>
  1070. <div class="overview-item">
  1071. <div class="overview-label">满足需求的帖子</div>
  1072. <div class="overview-value">{satisfied_notes}</div>
  1073. </div>
  1074. </div>
  1075. </div>
  1076. """
  1077. return html
  1078. def render_keyword_extraction(step):
  1079. """渲染关键词提取步骤"""
  1080. data = step.get("data", {})
  1081. keywords = data.get("keywords", [])
  1082. reasoning = data.get("reasoning", "")
  1083. keywords_html = "".join([f'<span class="keyword-tag">{k}</span>' for k in keywords])
  1084. html = f"""
  1085. <div class="step-section">
  1086. <div class="step-header">
  1087. <div class="step-title">步骤 {step['step_number']}: {step['step_name']}</div>
  1088. <div class="step-type">{step['step_type']}</div>
  1089. </div>
  1090. <div class="step-content">
  1091. <div class="keyword-tags">{keywords_html}</div>
  1092. {f'<p style="margin-top: 15px; color: #666; font-size: 14px;">{reasoning}</p>' if reasoning else ''}
  1093. </div>
  1094. <div class="timestamp">⏰ {step.get('timestamp', '')}</div>
  1095. </div>
  1096. """
  1097. return html
  1098. def render_level_exploration(step):
  1099. """渲染层级探索步骤"""
  1100. data = step.get("data", {})
  1101. level = data.get("level", 0)
  1102. query_count = data.get("query_count", 0)
  1103. results = data.get("results", [])
  1104. queries_html = ""
  1105. for result in results:
  1106. query = result.get("query", "")
  1107. suggestions = result.get("suggestions", [])
  1108. # 使用标签样式显示推荐词
  1109. suggestions_tags = ""
  1110. for suggestion in suggestions:
  1111. suggestions_tags += f'<span class="keyword-tag" style="margin: 3px;">{suggestion}</span>'
  1112. queries_html += f"""
  1113. <div class="query-item">
  1114. <div class="query-text">{query}</div>
  1115. <div style="margin-top: 10px;">
  1116. <div style="color: #666; font-size: 13px; margin-bottom: 5px;">推荐词 ({len(suggestions)} 个):</div>
  1117. <div style="display: flex; flex-wrap: wrap; gap: 5px;">
  1118. {suggestions_tags}
  1119. </div>
  1120. </div>
  1121. </div>
  1122. """
  1123. html = f"""
  1124. <div class="step-section">
  1125. <div class="step-header">
  1126. <div class="step-title">步骤 {step['step_number']}: Level {level} 探索</div>
  1127. <div class="step-type">{step['step_type']}</div>
  1128. </div>
  1129. <div class="step-content">
  1130. <div class="info-grid">
  1131. <div class="info-item">
  1132. <div class="info-label">探索query数</div>
  1133. <div class="info-value">{query_count}</div>
  1134. </div>
  1135. <div class="info-item">
  1136. <div class="info-label">获得推荐词总数</div>
  1137. <div class="info-value">{data.get('total_suggestions', 0)}</div>
  1138. </div>
  1139. </div>
  1140. <div class="query-list">{queries_html}</div>
  1141. </div>
  1142. <div class="timestamp">⏰ {step.get('timestamp', '')}</div>
  1143. </div>
  1144. """
  1145. return html
  1146. def render_level_analysis(step):
  1147. """渲染层级分析步骤"""
  1148. data = step.get("data", {})
  1149. level = data.get("level", 0)
  1150. key_findings = data.get("key_findings", "")
  1151. should_evaluate = data.get("should_evaluate_now", False)
  1152. promising_signals_count = data.get("promising_signals_count", 0)
  1153. next_combinations = data.get("next_combinations", [])
  1154. promising_signals = data.get("promising_signals", [])
  1155. reasoning = data.get("reasoning", "")
  1156. step_num = step['step_number']
  1157. # 渲染推理过程
  1158. reasoning_html = ""
  1159. if reasoning:
  1160. reasoning_html = f"""
  1161. <div style="margin-top: 20px;">
  1162. <div class="level-analysis">
  1163. <div class="level-analysis-title">💭 推理过程</div>
  1164. <div class="level-analysis-text">{reasoning}</div>
  1165. </div>
  1166. </div>
  1167. """
  1168. # 渲染下一层探索
  1169. next_html = ""
  1170. if next_combinations:
  1171. next_items = "".join([f'<span class="keyword-tag">{q}</span>' for q in next_combinations])
  1172. next_html = f'<div style="margin-top: 15px;"><strong>下一层探索:</strong><div class="keyword-tags" style="margin-top: 10px;">{next_items}</div></div>'
  1173. # 渲染有价值的信号
  1174. signals_html = ""
  1175. if promising_signals:
  1176. signals_items = ""
  1177. for signal in promising_signals:
  1178. query = signal.get("query", "")
  1179. from_level = signal.get("from_level", "")
  1180. reason = signal.get("reason", "")
  1181. signals_items += f"""
  1182. <div class="query-item" style="border-left: 3px solid #10b981; padding-left: 15px;">
  1183. <div class="query-text" style="font-weight: 600;">{query}</div>
  1184. <div style="margin-top: 8px; color: #666; font-size: 13px;">
  1185. <span style="color: #10b981;">来自 Level {from_level}</span>
  1186. </div>
  1187. <div style="margin-top: 8px; color: #555; font-size: 14px; line-height: 1.5;">
  1188. {reason}
  1189. </div>
  1190. </div>
  1191. """
  1192. signals_html = make_collapsible(
  1193. f"💡 有价值的信号 ({len(promising_signals)} 个)",
  1194. f'<div style="display: flex; flex-direction: column; gap: 15px; margin-top: 10px;">{signals_items}</div>',
  1195. collapsed=True,
  1196. section_id=f"step{step_num}-signals"
  1197. )
  1198. html = f"""
  1199. <div class="step-section">
  1200. <div class="step-header">
  1201. <div class="step-title">步骤 {step['step_number']}: Level {level} 分析</div>
  1202. <div class="step-type">{step['step_type']}</div>
  1203. </div>
  1204. <div class="step-content">
  1205. <div class="level-analysis">
  1206. <div class="level-analysis-title">🔎 关键发现</div>
  1207. <div class="level-analysis-text">{key_findings}</div>
  1208. </div>
  1209. <div class="info-grid" style="margin-top: 20px;">
  1210. <div class="info-item">
  1211. <div class="info-label">有价值信号数</div>
  1212. <div class="info-value">{promising_signals_count}</div>
  1213. </div>
  1214. <div class="info-item">
  1215. <div class="info-label">是否开始评估</div>
  1216. <div class="info-value">{'是' if should_evaluate else '否'}</div>
  1217. </div>
  1218. </div>
  1219. {signals_html}
  1220. {reasoning_html}
  1221. {next_html}
  1222. </div>
  1223. <div class="timestamp">⏰ {step.get('timestamp', '')}</div>
  1224. </div>
  1225. """
  1226. return html
  1227. def render_search_results(step):
  1228. """渲染搜索结果步骤"""
  1229. data = step.get("data", {})
  1230. search_results = data.get("search_results", [])
  1231. posts_html = ""
  1232. step_num = step['step_number']
  1233. for idx, sr in enumerate(search_results):
  1234. query = sr.get("query", "")
  1235. note_count = sr.get("note_count", 0)
  1236. notes_summary = sr.get("notes_summary", [])
  1237. # 渲染该query的帖子
  1238. posts_cards = ""
  1239. for note in notes_summary:
  1240. # 获取封面图
  1241. image_list = note.get('image_list', [])
  1242. if image_list:
  1243. # image_list 已经是 URL 字符串列表,第一张就是封面
  1244. cover_url = image_list[0] if isinstance(image_list[0], str) else image_list[0].get('image_url', '')
  1245. else:
  1246. cover = note.get("cover_image", {})
  1247. cover_url = cover.get("image_url", "") if isinstance(cover, dict) else cover if cover else ""
  1248. interact = note.get("interact_info", {})
  1249. user = note.get("user", {})
  1250. # image_list 现在已经是 URL 字符串列表
  1251. images = [img if isinstance(img, str) else img.get('image_url', '') for img in image_list if img]
  1252. # 构建帖子数据用于模态框
  1253. post_data = build_post_json_data(note)
  1254. images_json = json.dumps(images)
  1255. image_html = f'<img src="{cover_url}" class="post-image" alt="{note.get("title", "")}">' if cover_url else '<div class="no-image">无图片</div>'
  1256. type_badge = ""
  1257. if note.get("type") == "video":
  1258. type_badge = '<div class="post-type-badge">📹 视频</div>'
  1259. # 轮播箭头按钮
  1260. arrows_html = ""
  1261. if len(images) > 1:
  1262. arrows_html = '''
  1263. <button class="carousel-arrow left" onclick="event.stopPropagation()">‹</button>
  1264. <button class="carousel-arrow right" onclick="event.stopPropagation()">›</button>
  1265. '''
  1266. posts_cards += f"""
  1267. <div class="post-card" onclick='openModal({post_data})' data-images='{images_json}'>
  1268. <div class="post-image-wrapper">
  1269. {image_html}
  1270. {type_badge}
  1271. {arrows_html}
  1272. </div>
  1273. <div class="post-info">
  1274. <div class="post-title">{note.get('title', '无标题')}</div>
  1275. <div class="post-desc">{note.get('desc', '')}</div>
  1276. <div class="post-meta">
  1277. <div class="post-meta-item">❤️ {interact.get('liked_count', 0)}</div>
  1278. <div class="post-meta-item">⭐ {interact.get('collected_count', 0)}</div>
  1279. <div class="post-meta-item">💬 {interact.get('comment_count', 0)}</div>
  1280. </div>
  1281. <div class="post-author">👤 {user.get('nickname', '未知')}</div>
  1282. <div class="post-id">{note.get('note_id', '')}</div>
  1283. </div>
  1284. </div>
  1285. """
  1286. # 使用可折叠区域包装每个query的搜索结果,添加唯一ID
  1287. query_content = f'<div class="posts-grid">{posts_cards}</div>'
  1288. posts_html += make_collapsible(
  1289. f"🔎 {query} (找到 {note_count} 个帖子)",
  1290. query_content,
  1291. collapsed=True,
  1292. section_id=f"step{step_num}-search-{idx}"
  1293. )
  1294. html = f"""
  1295. <div class="step-section">
  1296. <div class="step-header">
  1297. <div class="step-title">步骤 {step['step_number']}: 搜索结果</div>
  1298. <div class="step-type">{step['step_type']}</div>
  1299. </div>
  1300. <div class="step-content">
  1301. <div class="info-grid">
  1302. <div class="info-item">
  1303. <div class="info-label">搜索query数</div>
  1304. <div class="info-value">{data.get('qualified_count', 0)}</div>
  1305. </div>
  1306. </div>
  1307. {posts_html}
  1308. </div>
  1309. <div class="timestamp">⏰ {step.get('timestamp', '')}</div>
  1310. </div>
  1311. """
  1312. return html
  1313. def render_note_evaluations(step):
  1314. """渲染帖子评估步骤"""
  1315. data = step.get("data", {})
  1316. note_evaluations = data.get("note_evaluations", [])
  1317. total_satisfied = data.get("total_satisfied", 0)
  1318. evals_html = ""
  1319. step_num = step["step_number"]
  1320. for idx, query_eval in enumerate(note_evaluations):
  1321. query = query_eval.get("query", "")
  1322. satisfied_count = query_eval.get("satisfied_count", 0)
  1323. evaluated_notes = query_eval.get("evaluated_notes", [])
  1324. # 分离满足和不满足需求的帖子
  1325. satisfied_notes = [n for n in evaluated_notes if n.get('evaluation', {}).get('need_satisfaction')]
  1326. unsatisfied_notes = [n for n in evaluated_notes if not n.get('evaluation', {}).get('need_satisfaction')]
  1327. # 渲染满足需求的帖子
  1328. satisfied_cards = ""
  1329. for note in satisfied_notes:
  1330. # 获取封面图
  1331. image_list = note.get('image_list', [])
  1332. if image_list:
  1333. cover_url = image_list[0] if isinstance(image_list[0], str) else image_list[0].get('image_url', '')
  1334. else:
  1335. cover = note.get("cover_image", {})
  1336. cover_url = cover.get("image_url", "") if isinstance(cover, dict) else cover if cover else ""
  1337. interact = note.get("interact_info", {})
  1338. user = note.get("user", {})
  1339. evaluation = note.get("evaluation", {})
  1340. confidence = evaluation.get("confidence_score", 0)
  1341. # image_list 现在已经是 URL 字符串列表
  1342. images = [img if isinstance(img, str) else img.get('image_url', '') for img in image_list if img]
  1343. # 构建帖子数据用于模态框
  1344. post_data = build_post_json_data(note, evaluation)
  1345. images_json = json.dumps(images)
  1346. image_html = f'<img src="{cover_url}" class="post-image" alt="{note.get("title", "")}">' if cover_url else '<div class="no-image">无图片</div>'
  1347. type_badge = ""
  1348. if note.get("type") == "video":
  1349. type_badge = '<div class="post-type-badge">📹 视频</div>'
  1350. # 轮播箭头按钮
  1351. arrows_html = ""
  1352. if len(images) > 1:
  1353. arrows_html = '''
  1354. <button class="carousel-arrow left" onclick="event.stopPropagation()">‹</button>
  1355. <button class="carousel-arrow right" onclick="event.stopPropagation()">›</button>
  1356. '''
  1357. # 评估详情
  1358. eval_reason = evaluation.get("reason", "")
  1359. title_rel = evaluation.get("title_relevance", 0)
  1360. content_exp = evaluation.get("content_expectation", 0)
  1361. eval_details = ""
  1362. # 置信度百分比
  1363. confidence_percent = int(confidence * 100)
  1364. satisfied_cards += f"""
  1365. <div class="post-card" onclick='openModal({post_data})' data-images='{images_json}'>
  1366. <div class="post-image-wrapper">
  1367. {image_html}
  1368. {type_badge}
  1369. {arrows_html}
  1370. </div>
  1371. <div class="post-info">
  1372. <div class="post-title">{note.get('title', '无标题')}</div>
  1373. <div class="post-desc">{note.get('desc', '')}</div>
  1374. <div class="post-meta">
  1375. <div class="post-meta-item">❤️ {interact.get('liked_count', 0)}</div>
  1376. <div class="post-meta-item">⭐ {interact.get('collected_count', 0)}</div>
  1377. <div class="post-meta-item">💬 {interact.get('comment_count', 0)}</div>
  1378. </div>
  1379. <div class="post-author">👤 {user.get('nickname', '未知')}</div>
  1380. <div class="post-id">{note.get('note_id', '')}</div>
  1381. </div>
  1382. <div class="confidence-bar">
  1383. <div class="confidence-bar-fill {get_confidence_class(confidence)}" style="width: {confidence_percent}%">
  1384. <span class="confidence-bar-text">置信度: {confidence:.2f}</span>
  1385. </div>
  1386. {eval_details}
  1387. </div>
  1388. </div>
  1389. """
  1390. # 渲染不满足需求的帖子
  1391. unsatisfied_cards = ""
  1392. for note in unsatisfied_notes:
  1393. # 获取封面图
  1394. image_list = note.get('image_list', [])
  1395. if image_list:
  1396. cover_url = image_list[0] if isinstance(image_list[0], str) else image_list[0].get('image_url', '')
  1397. else:
  1398. cover = note.get("cover_image", {})
  1399. cover_url = cover.get("image_url", "") if isinstance(cover, dict) else cover if cover else ""
  1400. interact = note.get("interact_info", {})
  1401. user = note.get("user", {})
  1402. evaluation = note.get("evaluation", {})
  1403. confidence = evaluation.get("confidence_score", 0)
  1404. # image_list 现在已经是 URL 字符串列表
  1405. images = [img if isinstance(img, str) else img.get('image_url', '') for img in image_list if img]
  1406. post_data = build_post_json_data(note, evaluation)
  1407. images_json = json.dumps(images)
  1408. image_html = f'<img src="{cover_url}" class="post-image" alt="{note.get("title", "")}">' if cover_url else '<div class="no-image">无图片</div>'
  1409. type_badge = ""
  1410. if note.get("type") == "video":
  1411. type_badge = '<div class="post-type-badge">📹 视频</div>'
  1412. arrows_html = ""
  1413. if len(images) > 1:
  1414. arrows_html = '''
  1415. <button class="carousel-arrow left" onclick="event.stopPropagation()">‹</button>
  1416. <button class="carousel-arrow right" onclick="event.stopPropagation()">›</button>
  1417. '''
  1418. eval_reason = evaluation.get("reason", "")
  1419. title_rel = evaluation.get("title_relevance", 0)
  1420. content_exp = evaluation.get("content_expectation", 0)
  1421. eval_details = ""
  1422. confidence_percent = int(confidence * 100)
  1423. unsatisfied_cards += f"""
  1424. <div class="post-card" onclick='openModal({post_data})' data-images='{images_json}'>
  1425. <div class="post-image-wrapper">
  1426. {image_html}
  1427. {type_badge}
  1428. {arrows_html}
  1429. </div>
  1430. <div class="post-info">
  1431. <div class="post-title">{note.get('title', '无标题')}</div>
  1432. <div class="post-desc">{note.get('desc', '')}</div>
  1433. <div class="post-meta">
  1434. <div class="post-meta-item">❤️ {interact.get('liked_count', 0)}</div>
  1435. <div class="post-meta-item">⭐ {interact.get('collected_count', 0)}</div>
  1436. <div class="post-meta-item">💬 {interact.get('comment_count', 0)}</div>
  1437. </div>
  1438. <div class="post-author">👤 {user.get('nickname', '未知')}</div>
  1439. <div class="post-id">{note.get('note_id', '')}</div>
  1440. </div>
  1441. <div class="confidence-bar">
  1442. <div class="confidence-bar-fill {get_confidence_class(confidence)}" style="width: {confidence_percent}%">
  1443. <span class="confidence-bar-text">置信度: {confidence:.2f}</span>
  1444. </div>
  1445. {eval_details}
  1446. </div>
  1447. </div>
  1448. """
  1449. # 构建该query的评估结果,使用嵌套可折叠区域
  1450. query_sections = ""
  1451. if satisfied_cards:
  1452. query_sections += make_collapsible(
  1453. f"✅ 满足需求 ({len(satisfied_notes)} 个帖子)",
  1454. f'<div class="posts-grid">{satisfied_cards}</div>',
  1455. collapsed=True,
  1456. section_id=f"step{step_num}-eval-{idx}-satisfied"
  1457. )
  1458. if unsatisfied_cards:
  1459. query_sections += make_collapsible(
  1460. f"❌ 不满足需求 ({len(unsatisfied_notes)} 个帖子)",
  1461. f'<div class="posts-grid">{unsatisfied_cards}</div>',
  1462. collapsed=True,
  1463. section_id=f"step{step_num}-eval-{idx}-unsatisfied"
  1464. )
  1465. if query_sections:
  1466. # 使用可折叠区域包装每个query的评估结果
  1467. evals_html += make_collapsible(
  1468. f"📊 {query} ({satisfied_count}/{len(evaluated_notes)} 个满足需求)",
  1469. query_sections,
  1470. collapsed=True,
  1471. section_id=f"step{step_num}-eval-{idx}"
  1472. )
  1473. html = f"""
  1474. <div class="step-section">
  1475. <div class="step-header">
  1476. <div class="step-title">步骤 {step['step_number']}: 帖子评估结果</div>
  1477. <div class="step-type">{step['step_type']}</div>
  1478. </div>
  1479. <div class="step-content">
  1480. <div class="info-grid">
  1481. <div class="info-item">
  1482. <div class="info-label">评估的query数</div>
  1483. <div class="info-value">{data.get('query_count', 0)}</div>
  1484. </div>
  1485. <div class="info-item">
  1486. <div class="info-label">总帖子数</div>
  1487. <div class="info-value">{data.get('total_notes', 0)}</div>
  1488. </div>
  1489. <div class="info-item">
  1490. <div class="info-label">满足需求的帖子</div>
  1491. <div class="info-value">{total_satisfied}</div>
  1492. </div>
  1493. </div>
  1494. {evals_html}
  1495. </div>
  1496. <div class="timestamp">⏰ {step.get('timestamp', '')}</div>
  1497. </div>
  1498. """
  1499. return html
  1500. def render_answer_generation(step):
  1501. """渲染答案生成步骤"""
  1502. data = step.get("data", {})
  1503. result = data.get("result", {})
  1504. answer = result.get("answer", "")
  1505. confidence = result.get("confidence", 0)
  1506. summary = result.get("summary", "")
  1507. cited_notes = result.get("cited_notes", [])
  1508. # 渲染引用的帖子
  1509. cited_html = ""
  1510. for note in cited_notes:
  1511. # 获取封面图
  1512. image_list = note.get('image_list', [])
  1513. if image_list:
  1514. cover_url = image_list[0] if isinstance(image_list[0], str) else image_list[0].get('image_url', '')
  1515. else:
  1516. cover = note.get("cover_image", {})
  1517. cover_url = cover.get("image_url", "") if isinstance(cover, dict) else cover if cover else ""
  1518. interact = note.get("interact_info", {})
  1519. user = note.get("user", {})
  1520. # image_list 现在已经是 URL 字符串列表
  1521. images = [img if isinstance(img, str) else img.get('image_url', '') for img in image_list if img]
  1522. # 构建帖子数据用于模态框(包含评估信息)
  1523. eval_data = {
  1524. 'reason': note.get("reason", ""),
  1525. 'title_relevance': note.get("title_relevance", 0),
  1526. 'content_expectation': note.get("content_expectation", 0),
  1527. 'confidence_score': note.get('confidence_score', 0)
  1528. }
  1529. post_data = build_post_json_data(note, eval_data)
  1530. images_json = json.dumps(images)
  1531. image_html = f'<img src="{cover_url}" class="post-image" alt="{note.get("title", "")}">' if cover_url else '<div class="no-image">无图片</div>'
  1532. # 类型标识
  1533. type_badge = ""
  1534. if note.get("type") == "video":
  1535. type_badge = '<div class="post-type-badge">📹 视频</div>'
  1536. # 轮播箭头按钮
  1537. arrows_html = ""
  1538. if len(images) > 1:
  1539. arrows_html = '''
  1540. <button class="carousel-arrow left" onclick="event.stopPropagation()">‹</button>
  1541. <button class="carousel-arrow right" onclick="event.stopPropagation()">›</button>
  1542. '''
  1543. # 评估详情
  1544. eval_reason = note.get("reason", "")
  1545. title_rel = note.get("title_relevance", 0)
  1546. content_exp = note.get("content_expectation", 0)
  1547. eval_details = ""
  1548. # 置信度百分比
  1549. note_confidence = note.get('confidence_score', 0)
  1550. confidence_percent = int(note_confidence * 100)
  1551. cited_html += f"""
  1552. <div class="post-card" onclick='openModal({post_data})' data-images='{images_json}'>
  1553. <div class="post-image-wrapper">
  1554. {image_html}
  1555. {type_badge}
  1556. {arrows_html}
  1557. </div>
  1558. <div class="post-info">
  1559. <div class="post-title">[{note.get('index')}] {note.get('title', '无标题')}</div>
  1560. <div class="post-desc">{note.get('desc', '')}</div>
  1561. <div class="post-meta">
  1562. <div class="post-meta-item">❤️ {interact.get('liked_count', 0)}</div>
  1563. <div class="post-meta-item">⭐ {interact.get('collected_count', 0)}</div>
  1564. <div class="post-meta-item">💬 {interact.get('comment_count', 0)}</div>
  1565. </div>
  1566. <div class="post-author">👤 {user.get('nickname', '未知')}</div>
  1567. <div class="post-id">{note.get('note_id', '')}</div>
  1568. </div>
  1569. <div class="confidence-bar">
  1570. <div class="confidence-bar-fill {get_confidence_class(note_confidence)}" style="width: {confidence_percent}%">
  1571. <span class="confidence-bar-text">置信度: {note_confidence:.2f}</span>
  1572. </div>
  1573. {eval_details}
  1574. </div>
  1575. </div>
  1576. """
  1577. # 使用可折叠区域包装引用的帖子
  1578. step_num = step['step_number']
  1579. cited_section = ""
  1580. if cited_html:
  1581. cited_section = make_collapsible(
  1582. f"📌 引用的帖子 ({len(cited_notes)} 个)",
  1583. f'<div class="posts-grid">{cited_html}</div>',
  1584. collapsed=True,
  1585. section_id=f"step{step_num}-cited"
  1586. )
  1587. html = f"""
  1588. <div class="step-section">
  1589. <div class="step-header">
  1590. <div class="step-title">步骤 {step['step_number']}: 生成答案</div>
  1591. <div class="step-type">{step['step_type']}</div>
  1592. </div>
  1593. <div class="step-content">
  1594. <div class="answer-box">
  1595. <div class="answer-header">📝 生成的答案</div>
  1596. <div class="answer-content">{answer}</div>
  1597. <div class="answer-meta">
  1598. <div><strong>置信度:</strong> {confidence:.2f}</div>
  1599. <div><strong>引用帖子:</strong> {len(cited_notes)} 个</div>
  1600. </div>
  1601. </div>
  1602. {f'<p style="margin-top: 15px; color: #666;"><strong>摘要:</strong> {summary}</p>' if summary else ''}
  1603. {cited_section}
  1604. </div>
  1605. <div class="timestamp">⏰ {step.get('timestamp', '')}</div>
  1606. </div>
  1607. """
  1608. return html
  1609. def render_final_result(step):
  1610. """渲染最终结果步骤"""
  1611. data = step.get("data", {})
  1612. success = data.get("success", False)
  1613. message = data.get("message", "")
  1614. satisfied_notes_count = data.get("satisfied_notes_count", 0)
  1615. status_color = "#10b981" if success else "#ef4444"
  1616. status_text = "✅ 成功" if success else "❌ 失败"
  1617. html = f"""
  1618. <div class="step-section" style="border: 3px solid {status_color};">
  1619. <div class="step-header">
  1620. <div class="step-title">步骤 {step['step_number']}: {step['step_name']}</div>
  1621. <div class="step-type">{step['step_type']}</div>
  1622. </div>
  1623. <div class="step-content">
  1624. <div class="info-grid">
  1625. <div class="info-item" style="background: {status_color}20;">
  1626. <div class="info-label">状态</div>
  1627. <div class="info-value" style="color: {status_color};">{status_text}</div>
  1628. </div>
  1629. <div class="info-item">
  1630. <div class="info-label">满足需求的帖子</div>
  1631. <div class="info-value">{satisfied_notes_count}</div>
  1632. </div>
  1633. </div>
  1634. <p style="margin-top: 20px; font-size: 15px; color: #666;">{message}</p>
  1635. </div>
  1636. <div class="timestamp">⏰ {step.get('timestamp', '')}</div>
  1637. </div>
  1638. """
  1639. return html
  1640. def render_query_suggestion_evaluation(step):
  1641. """渲染候选query推荐词评估步骤"""
  1642. data = step.get("data", {})
  1643. candidate_count = data.get("candidate_count", 0)
  1644. results = data.get("results", [])
  1645. results_html = ""
  1646. step_num = step['step_number']
  1647. for idx, result in enumerate(results):
  1648. candidate = result.get("candidate", "")
  1649. suggestions = result.get("suggestions", [])
  1650. evaluations = result.get("evaluations", [])
  1651. # 渲染每个候选词的推荐词评估
  1652. eval_cards = ""
  1653. for evaluation in evaluations:
  1654. query = evaluation.get("query", "")
  1655. intent_match = evaluation.get("intent_match", False)
  1656. relevance_score = evaluation.get("relevance_score", 0)
  1657. reason = evaluation.get("reason", "")
  1658. intent_badge = "✅ 意图匹配" if intent_match else "❌ 意图不匹配"
  1659. intent_class = "confidence-high" if intent_match else "confidence-low"
  1660. eval_cards += f"""
  1661. <div class="query-item" style="margin: 10px 0; padding: 15px; background: white; border: 1px solid #e5e7eb; border-radius: 8px;">
  1662. <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
  1663. <div class="query-text" style="flex: 1;">{query}</div>
  1664. <div style="display: flex; gap: 10px; align-items: center;">
  1665. <span class="confidence-badge {intent_class}" style="margin: 0;">{intent_badge}</span>
  1666. <span class="confidence-badge confidence-medium" style="margin: 0;">相关性: {relevance_score:.2f}</span>
  1667. </div>
  1668. </div>
  1669. <div style="color: #666; font-size: 13px; line-height: 1.6; background: #f8f9fa; padding: 10px; border-radius: 4px;">
  1670. {reason}
  1671. </div>
  1672. </div>
  1673. """
  1674. if eval_cards:
  1675. # 使用可折叠区域包装每个候选词的推荐词列表,添加唯一ID
  1676. results_html += make_collapsible(
  1677. f"候选词: {candidate} ({len(evaluations)} 个推荐词)",
  1678. eval_cards,
  1679. collapsed=True,
  1680. section_id=f"step{step_num}-candidate-{idx}"
  1681. )
  1682. html = f"""
  1683. <div class="step-section">
  1684. <div class="step-header">
  1685. <div class="step-title">步骤 {step['step_number']}: {step['step_name']}</div>
  1686. <div class="step-type">{step['step_type']}</div>
  1687. </div>
  1688. <div class="step-content">
  1689. <div class="info-grid">
  1690. <div class="info-item">
  1691. <div class="info-label">候选query数</div>
  1692. <div class="info-value">{candidate_count}</div>
  1693. </div>
  1694. <div class="info-item">
  1695. <div class="info-label">总推荐词数</div>
  1696. <div class="info-value">{sum(len(r.get('evaluations', [])) for r in results)}</div>
  1697. </div>
  1698. </div>
  1699. {results_html}
  1700. </div>
  1701. <div class="timestamp">⏰ {step.get('timestamp', '')}</div>
  1702. </div>
  1703. """
  1704. return html
  1705. def render_filter_qualified_queries(step):
  1706. """渲染筛选合格推荐词步骤"""
  1707. data = step.get("data", {})
  1708. input_count = data.get("input_evaluation_count", 0)
  1709. qualified_count = data.get("qualified_count", 0)
  1710. min_relevance = data.get("min_relevance_score", 0.7)
  1711. all_queries = data.get("all_queries", [])
  1712. # 如果没有all_queries,使用旧的qualified_queries
  1713. if not all_queries:
  1714. all_queries = data.get("qualified_queries", [])
  1715. # 分离合格和不合格的查询
  1716. qualified_html = ""
  1717. unqualified_html = ""
  1718. for item in all_queries:
  1719. query = item.get("query", "")
  1720. from_candidate = item.get("from_candidate", "")
  1721. intent_match = item.get("intent_match", False)
  1722. relevance_score = item.get("relevance_score", 0)
  1723. reason = item.get("reason", "")
  1724. is_qualified = item.get("is_qualified", True) # 默认为True以兼容旧数据
  1725. intent_badge = "✅ 意图匹配" if intent_match else "❌ 意图不匹配"
  1726. intent_class = "confidence-high" if intent_match else "confidence-low"
  1727. # 根据相关性分数确定badge颜色
  1728. if relevance_score >= 0.8:
  1729. score_class = "confidence-high"
  1730. elif relevance_score >= 0.6:
  1731. score_class = "confidence-medium"
  1732. else:
  1733. score_class = "confidence-low"
  1734. # 确定边框颜色和背景色
  1735. if is_qualified:
  1736. border_color = "#10b981"
  1737. bg_color = "#f0fdf4"
  1738. border_left_color = "#10b981"
  1739. else:
  1740. border_color = "#e5e7eb"
  1741. bg_color = "#f9fafb"
  1742. border_left_color = "#9ca3af"
  1743. query_html = f"""
  1744. <div class="query-item" style="margin: 15px 0; padding: 15px; background: white; border: 2px solid {border_color}; border-radius: 8px;">
  1745. <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
  1746. <div style="flex: 1;">
  1747. <div class="query-text">{query}</div>
  1748. <div style="color: #9ca3af; font-size: 12px; margin-top: 5px;">来自候选词: {from_candidate}</div>
  1749. </div>
  1750. <div style="display: flex; gap: 10px; align-items: center;">
  1751. <span class="confidence-badge {intent_class}" style="margin: 0;">{intent_badge}</span>
  1752. <span class="confidence-badge {score_class}" style="margin: 0;">相关性: {relevance_score:.2f}</span>
  1753. </div>
  1754. </div>
  1755. <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};">
  1756. {reason}
  1757. </div>
  1758. </div>
  1759. """
  1760. if is_qualified:
  1761. qualified_html += query_html
  1762. else:
  1763. unqualified_html += query_html
  1764. # 构建HTML - 使用可折叠区域
  1765. step_num = step['step_number']
  1766. qualified_section = make_collapsible(
  1767. f"✅ 合格的推荐词 ({qualified_count})",
  1768. qualified_html,
  1769. collapsed=True,
  1770. section_id=f"step{step_num}-qualified"
  1771. ) if qualified_html else ''
  1772. unqualified_section = make_collapsible(
  1773. f"❌ 不合格的推荐词 ({input_count - qualified_count})",
  1774. unqualified_html,
  1775. collapsed=True,
  1776. section_id=f"step{step_num}-unqualified"
  1777. ) if unqualified_html else ''
  1778. html = f"""
  1779. <div class="step-section">
  1780. <div class="step-header">
  1781. <div class="step-title">步骤 {step['step_number']}: {step['step_name']}</div>
  1782. <div class="step-type">{step['step_type']}</div>
  1783. </div>
  1784. <div class="step-content">
  1785. <div class="info-grid">
  1786. <div class="info-item">
  1787. <div class="info-label">输入推荐词数</div>
  1788. <div class="info-value">{input_count}</div>
  1789. </div>
  1790. <div class="info-item">
  1791. <div class="info-label">合格推荐词数</div>
  1792. <div class="info-value">{qualified_count}</div>
  1793. </div>
  1794. <div class="info-item">
  1795. <div class="info-label">最低相关性</div>
  1796. <div class="info-value">{min_relevance:.2f}</div>
  1797. </div>
  1798. </div>
  1799. {qualified_section}
  1800. {unqualified_section}
  1801. </div>
  1802. <div class="timestamp">⏰ {step.get('timestamp', '')}</div>
  1803. </div>
  1804. """
  1805. return html
  1806. def render_generic_step(step):
  1807. """通用步骤渲染"""
  1808. data = step.get("data", {})
  1809. # 提取数据的简单展示
  1810. data_html = ""
  1811. if data:
  1812. data_html = "<div class='step-content'><pre style='background: #f8f9fa; padding: 15px; border-radius: 4px; overflow-x: auto; font-size: 12px;'>"
  1813. import json
  1814. data_html += json.dumps(data, ensure_ascii=False, indent=2)[:500] # 限制长度
  1815. if len(json.dumps(data)) > 500:
  1816. data_html += "\n..."
  1817. data_html += "</pre></div>"
  1818. return f"""
  1819. <div class="step-section">
  1820. <div class="step-header">
  1821. <div class="step-title">步骤 {step['step_number']}: {step['step_name']}</div>
  1822. <div class="step-type">{step['step_type']}</div>
  1823. </div>
  1824. {data_html}
  1825. <div class="timestamp">⏰ {step.get('timestamp', '')}</div>
  1826. </div>
  1827. """
  1828. def render_step(step):
  1829. """根据步骤类型渲染对应的HTML"""
  1830. step_type = step.get("step_type", "")
  1831. renderers = {
  1832. "keyword_extraction": render_keyword_extraction,
  1833. "level_exploration": render_level_exploration,
  1834. "level_analysis": render_level_analysis,
  1835. "query_suggestion_evaluation": render_query_suggestion_evaluation,
  1836. "filter_qualified_queries": render_filter_qualified_queries,
  1837. "search_qualified_queries": render_search_results,
  1838. "evaluate_search_notes": render_note_evaluations,
  1839. "answer_generation": render_answer_generation,
  1840. "final_result": render_final_result,
  1841. }
  1842. renderer = renderers.get(step_type)
  1843. if renderer:
  1844. return renderer(step)
  1845. else:
  1846. # 使用通用渲染显示数据
  1847. return render_generic_step(step)
  1848. def generate_html(steps_json_path, output_path=None):
  1849. """生成HTML可视化文件"""
  1850. # 读取 steps.json
  1851. with open(steps_json_path, 'r', encoding='utf-8') as f:
  1852. steps_data = json.load(f)
  1853. # 生成内容
  1854. content_parts = [render_header(steps_data)]
  1855. for step in steps_data:
  1856. content_parts.append(render_step(step))
  1857. content = "\n".join(content_parts)
  1858. # 生成最终HTML(使用replace而不是format来避免CSS中的花括号问题)
  1859. html = HTML_TEMPLATE.replace("{content}", content)
  1860. # 确定输出路径
  1861. if output_path is None:
  1862. steps_path = Path(steps_json_path)
  1863. output_path = steps_path.parent / "steps_visualization.html"
  1864. # 写入文件
  1865. with open(output_path, 'w', encoding='utf-8') as f:
  1866. f.write(html)
  1867. return output_path
  1868. def main():
  1869. parser = argparse.ArgumentParser(description="Steps 可视化工具")
  1870. parser.add_argument("steps_json", type=str, help="steps.json 文件路径")
  1871. parser.add_argument("-o", "--output", type=str, help="输出HTML文件路径(可选)")
  1872. args = parser.parse_args()
  1873. # 生成可视化
  1874. output_path = generate_html(args.steps_json, args.output)
  1875. print(f"✅ 可视化生成成功!")
  1876. print(f"📄 输出文件: {output_path}")
  1877. output_abs = Path(output_path).absolute() if isinstance(output_path, str) else output_path.absolute()
  1878. print(f"\n💡 在浏览器中打开查看: file://{output_abs}")
  1879. if __name__ == "__main__":
  1880. main()