wangxi 1 săptămână în urmă
părinte
comite
4d95663a7f

+ 18 - 4
html/index.html

@@ -221,6 +221,8 @@
       border-style: dashed;
     }
 
+    .answer-timing { margin-top: 10px; font-size: 12px; color: #64748b; }
+
     #status {
       padding: 8px 22px;
       min-height: 26px;
@@ -361,8 +363,8 @@
   <link rel="stylesheet" href="answer_markdown.css?v=20260904-qa">
   <script src="vendor/marked.umd.js?v=17.0.5"></script>
   <script src="answer_markdown.js?v=20260904-qa"></script>
-  <link rel="stylesheet" href="answer_cache.css?v=20260907-cache">
-  <script src="answer_cache.js?v=20260907-cache"></script>
+  <link rel="stylesheet" href="answer_cache.css?v=20260909-timing">
+  <script src="answer_cache.js?v=20260909-timing"></script>
   <script>
     const $ = (id) => document.getElementById(id);
     const chatEl = $("chat");
@@ -416,6 +418,7 @@
 
     function addProgressStep(node, label, container) {
       const chips = Array.from(container.querySelectorAll(".step-chip"));
+      if (chips.length && chips[chips.length - 1].title === node) return;
       chips.forEach((chip) => chip.classList.remove("active"));
       chips.forEach((chip) => chip.classList.add("done"));
       const chip = document.createElement("span");
@@ -464,6 +467,15 @@
       return div;
     }
 
+    function attachAnswerTiming(bubble, payload) {
+      bubble.querySelector('.answer-timing')?.remove();
+      if (typeof payload.elapsed_sec !== "number" || !Number.isFinite(payload.elapsed_sec)) return;
+      const timing = document.createElement("div");
+      timing.className = "answer-timing";
+      timing.textContent = `回答耗时 ${payload.elapsed_sec} 秒`;
+      bubble.appendChild(timing);
+    }
+
     function appendSystem(text) {
       return appendMessage("system", text);
     }
@@ -614,7 +626,8 @@
             const payload = JSON.parse(data);
             if (typeof payload.answer === "string") answerText = payload.answer;
             renderAnswer(answerEl, answerText);
-            AnswerCacheUI.attach(assistantBubble, payload, threadId, apiBase());
+            attachAnswerTiming(assistantBubble, payload);
+            window.AnswerCacheUI?.attach(assistantBubble, payload, threadId, apiBase());
             finishProgressFlow(progressEl, "完成");
             setStatus(`完成,耗时 ${payload.elapsed_sec || 0} 秒`);
           } catch (_) {}
@@ -680,7 +693,8 @@
         hideConfirm();
         if (activeProgressEl) finishProgressFlow(activeProgressEl, "完成");
         const answerBubble = appendMessage("assistant", payload.answer || "");
-        AnswerCacheUI.attach(answerBubble, payload, threadId, apiBase());
+        attachAnswerTiming(answerBubble, payload);
+        window.AnswerCacheUI?.attach(answerBubble, payload, threadId, apiBase());
         setStatus(`完成,耗时 ${payload.elapsed_sec || 0} 秒`);
       } else {
         hideConfirm();

+ 1 - 1
html/knowledge_graph_3d.html

@@ -301,7 +301,7 @@
         <div class="spacer"></div>
         <button id="chatToggle" class="chat-toggle">收起</button>
       </div>
-      <iframe id="chatFrame" src="index.html?v=20260907-cache" title="申勤物业知识助手"></iframe>
+      <iframe id="chatFrame" src="index.html?v=20260909-timing" title="申勤物业知识助手"></iframe>
     </div>
   </div>
 

+ 12 - 0
scripts/test_answer_cache.py

@@ -201,6 +201,18 @@ class ApiCacheTests(unittest.IsolatedAsyncioTestCase):
         return api.AnswerReviewRequest(answer_id=review['answer_id'],
                                        checkpoint_id=review['checkpoint_id'], **extra)
 
+    async def test_cache_miss_reports_understanding_before_model_starts(self):
+        stream = api._astream_run('t1', Q, True, True)
+        try:
+            self.assertEqual((await anext(stream))['node'], 'answer_cache')
+            self.assertEqual((await anext(stream))['node'], 'understand')
+            self.assertEqual(self.mocks[6].call_count, 0)
+            self.assertEqual(self.mocks[2].call_count, 1)
+            remaining = [event async for event in stream]
+            self.assertEqual(remaining[-1]['type'], 'done')
+        finally:
+            await stream.aclose()
+
     async def test_full_flow_requires_click_then_repeat_uses_no_models(self):
         first = (await self.ask())['state']
         self.assertTrue(first['cache_review']['eligible'])

+ 5 - 2
scripts/test_answer_cache_ui.cjs

@@ -24,7 +24,7 @@ const root = path.resolve(__dirname, '../html');
       return route.fulfill({body:fs.readFileSync(file),contentType:file.endsWith('.js')?'application/javascript':file.endsWith('.css')?'text/css':'text/html'});
     });
     await page.goto('http://qa.test/');
-    assert.ok((await page.locator('#chatFrame').getAttribute('src')).includes('20260907-cache'));
+    assert.ok((await page.locator('#chatFrame').getAttribute('src')).includes('20260909-timing'));
     const frame = await (await page.locator('#chatFrame').elementHandle()).contentFrame();
     await frame.waitForFunction(() => typeof askStream === 'function' && Boolean(window.AnswerCacheUI));
     await frame.evaluate(async () => {
@@ -37,9 +37,11 @@ const root = path.resolve(__dirname, '../html');
       try {await askStream('员工有多少人',true,true);} finally {window.fetch=original;}
     });
     assert.equal(requests.length,0,'rendering an answer must not cache it');
+    assert.equal(await frame.locator('.answer-timing').last().innerText(),'回答耗时 0.01 秒');
     const button=frame.locator('.answer-cache-button').last();
     await button.click();
     await frame.waitForFunction(() => document.querySelector('.answer-cache-button')?.textContent==='撤销缓存');
+    assert.equal(await frame.locator('.answer-timing').last().innerText(),'回答耗时 0.01 秒');
     assert.equal(requests.length,1);
     assert.equal(requests[0].method,'POST');
     assert.deepEqual(requests[0].body,{answer_id:'answer1',checkpoint_id:'checkpoint1',entry_id:null});
@@ -60,11 +62,12 @@ const root = path.resolve(__dirname, '../html');
     // Resume rendering gets the same explicit approval control.
     await frame.evaluate(async () => {
       const original=window.fetch;
-      window.fetch=async () => new Response(JSON.stringify({status:'ok',answer:'确认后完成',
+      window.fetch=async () => new Response(JSON.stringify({status:'ok',answer:'确认后完成',elapsed_sec:1.25,
         cache_review:{eligible:true,answer_id:'answer2',checkpoint_id:'checkpoint2',cached:false}}));
       try {await resumeThread('确认');} finally {window.fetch=original;}
     });
     assert.equal(await frame.locator('.answer-cache-button').count(),2);
+    assert.deepEqual(await frame.locator('.answer-timing').allTextContents(),['回答耗时 0.01 秒','回答耗时 1.25 秒']);
     await frame.evaluate(() => {
       const bubble=appendMessage('assistant','历史确认答案');
       AnswerCacheUI.attach(bubble,{cache_review:{eligible:true,answer_id:'hit',checkpoint_id:'cphit',

+ 2 - 1
src/step4_web/api.py

@@ -247,7 +247,7 @@ async def _astream_run(thread_id: str, query: str,
             if enable_reuse and not previous.next:
                 yield {"type": "node", "node": "answer_cache", "payload": {}}
                 entry = await _answer_cache.lookup(request)
-                current = release_snapshot()
+                current = release_snapshot() if entry else None
                 if (entry and current['data_version'] == request['data_version']
                         and current['schema_version'] == request['schema_version']):
                     state = restored_state(entry, previous.values or {}, request, inp['answer_id'])
@@ -261,6 +261,7 @@ async def _astream_run(thread_id: str, query: str,
                     return
         except (OSError, ValueError, KeyError):
             inp['cache_request'] = {}  # Local version metadata unavailable: normal path.
+    yield {"type": "node", "node": "understand", "payload": {}}
     while True:
         interrupted: object | None = None
         async for item in _get_graph().astream(inp, config, stream_mode=["updates", "custom"]):