chatHistoryPersistence.test.js 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273
  1. import { describe, expect, it } from 'vitest'
  2. import {
  3. buildAIMessageUpdatePayload,
  4. buildPersistedAIMessageContent,
  5. extractRelatedQuestions,
  6. hydratePersistedReports,
  7. normalizeReportsForPersistence,
  8. shouldClearSummaryForOnlineAnswer,
  9. splitHtmlIntoTypewriterChunks
  10. } from './chatHistoryPersistence'
  11. describe('chatHistoryPersistence', () => {
  12. it('fills report fields from _fullContent before persistence', () => {
  13. const reports = [
  14. {
  15. file_index: 1,
  16. status: 'completed',
  17. report: {
  18. display_name: '',
  19. summary: '',
  20. analysis: '',
  21. clauses: ''
  22. },
  23. _fullContent: {
  24. display_name: 'bridge-spec.pdf',
  25. summary: 'full summary',
  26. analysis: 'full analysis',
  27. clauses: 'full clauses'
  28. }
  29. }
  30. ]
  31. expect(normalizeReportsForPersistence(reports)).toEqual([
  32. expect.objectContaining({
  33. report: {
  34. display_name: 'bridge-spec.pdf',
  35. summary: 'full summary',
  36. analysis: 'full analysis',
  37. clauses: 'full clauses'
  38. }
  39. })
  40. ])
  41. })
  42. it('repairs persisted reports when history reloads from older incomplete content', () => {
  43. const reports = [
  44. {
  45. file_index: 2,
  46. status: 'completed',
  47. report: {
  48. display_name: '',
  49. summary: '',
  50. analysis: '',
  51. clauses: ''
  52. },
  53. _fullContent: {
  54. display_name: 'concrete-maintenance.pdf',
  55. summary: 'saved summary',
  56. analysis: 'saved analysis',
  57. clauses: ''
  58. }
  59. },
  60. {
  61. type: 'category_title',
  62. category: 'National Standards',
  63. number: 'I',
  64. count: 1
  65. }
  66. ]
  67. expect(hydratePersistedReports(reports)).toEqual([
  68. expect.objectContaining({
  69. report: {
  70. display_name: 'concrete-maintenance.pdf',
  71. summary: 'saved summary',
  72. analysis: 'saved analysis',
  73. clauses: ''
  74. }
  75. }),
  76. expect.objectContaining({
  77. type: 'category_title',
  78. category: 'National Standards'
  79. })
  80. ])
  81. })
  82. it('builds structured content for professional replies before the stream finishes', () => {
  83. const content = buildPersistedAIMessageContent({
  84. reports: [],
  85. summary: '',
  86. _fullSummary: 'The assistant identified a professional question and is analyzing the relevant standards.',
  87. webSearchRaw: null,
  88. webSearchSummary: null,
  89. hasWebSearchResults: false,
  90. content: ''
  91. })
  92. expect(JSON.parse(content)).toEqual({
  93. reports: [],
  94. webSearchRaw: null,
  95. webSearchSummary: null,
  96. hasWebSearchResults: false,
  97. summary: 'The assistant identified a professional question and is analyzing the relevant standards.'
  98. })
  99. })
  100. it('persists the full summary instead of the partial typewriter text', () => {
  101. const content = buildPersistedAIMessageContent({
  102. reports: [],
  103. summary: 'Short partial summary',
  104. _fullSummary: 'Long complete summary that should be stored instead of the partial typewriter text.',
  105. webSearchRaw: null,
  106. webSearchSummary: null,
  107. hasWebSearchResults: false,
  108. content: ''
  109. })
  110. expect(JSON.parse(content).summary).toBe(
  111. 'Long complete summary that should be stored instead of the partial typewriter text.'
  112. )
  113. })
  114. it('keeps thinking content in structured professional payloads', () => {
  115. const content = buildPersistedAIMessageContent({
  116. reports: [
  117. {
  118. status: 'completed',
  119. report: { display_name: 'file.pdf', summary: 'overview', analysis: '', clauses: '' }
  120. }
  121. ],
  122. summary: '',
  123. _fullSummary: 'full overview',
  124. thinkingContent: 'found the relevant standards and completed the analysis',
  125. content: ''
  126. })
  127. expect(JSON.parse(content).thinkingContent).toBe(
  128. 'found the relevant standards and completed the analysis'
  129. )
  130. })
  131. it('builds an update payload for completed non-typing messages', () => {
  132. const payload = buildAIMessageUpdatePayload({
  133. type: 'ai',
  134. isTyping: false,
  135. ai_message_id: 26911,
  136. reports: [],
  137. summary: '',
  138. _fullSummary: 'The construction flow should follow inspection, assembly, trial hoisting, erection, and acceptance.',
  139. content: ''
  140. })
  141. expect(payload).toEqual({
  142. aiMessageId: 26911,
  143. content: JSON.stringify({
  144. reports: [],
  145. webSearchRaw: null,
  146. webSearchSummary: null,
  147. hasWebSearchResults: false,
  148. summary: 'The construction flow should follow inspection, assembly, trial hoisting, erection, and acceptance.'
  149. })
  150. })
  151. })
  152. it('does not build an update payload for blank AI content', () => {
  153. expect(buildAIMessageUpdatePayload({
  154. type: 'ai',
  155. isTyping: false,
  156. ai_message_id: 26911,
  157. reports: [],
  158. summary: '',
  159. content: ''
  160. })).toBeNull()
  161. })
  162. it('returns plain text for direct AI answers without structured report data', () => {
  163. expect(buildPersistedAIMessageContent({
  164. reports: [],
  165. content: 'Hello, I am here.',
  166. summary: '',
  167. _fullSummary: ''
  168. })).toBe('Hello, I am here.')
  169. })
  170. it('keeps the intent summary when a professional reply receives an online answer', () => {
  171. expect(shouldClearSummaryForOnlineAnswer({
  172. isProfessionalQuestion: true,
  173. summary: 'The assistant understood the professional scaffold plan question.'
  174. })).toBe(false)
  175. })
  176. it('clears the intent summary for non-professional online answers to avoid duplicate text', () => {
  177. expect(shouldClearSummaryForOnlineAnswer({
  178. isProfessionalQuestion: false,
  179. summary: 'Hello, I can help.'
  180. })).toBe(true)
  181. })
  182. it('extracts related questions from stored JSON payloads', () => {
  183. expect(
  184. extractRelatedQuestions('{"questions":["How is it applied on site?","What risks should be checked first?","Which standards are usually cited?"]}')
  185. ).toEqual([
  186. 'How is it applied on site?',
  187. 'What risks should be checked first?',
  188. 'Which standards are usually cited?'
  189. ])
  190. })
  191. it('extracts related questions from legacy newline text', () => {
  192. expect(
  193. extractRelatedQuestions('1. How is it applied on site?\n2. What risks should be checked first?\n3. Which standards are usually cited?')
  194. ).toEqual([
  195. 'How is it applied on site?',
  196. 'What risks should be checked first?',
  197. 'Which standards are usually cited?'
  198. ])
  199. })
  200. it('supports API payloads that already expose a questions array', () => {
  201. expect(
  202. extractRelatedQuestions({
  203. questions: [
  204. 'How is it applied on site?',
  205. 'What risks should be checked first?',
  206. 'Which standards are usually cited?',
  207. 'This extra question should be trimmed'
  208. ]
  209. })
  210. ).toEqual([
  211. 'How is it applied on site?',
  212. 'What risks should be checked first?',
  213. 'Which standards are usually cited?'
  214. ])
  215. })
  216. it('filters prompt leakage and placeholder related questions', () => {
  217. expect(
  218. extractRelatedQuestions({
  219. questions: [
  220. 'Thinking Process:',
  221. '**Analyze the Request:**',
  222. '**Role:** Professional question recommendation assistant focused on infrastructure construction technology.',
  223. 'q1',
  224. '问题1'
  225. ]
  226. })
  227. ).toEqual([])
  228. })
  229. it('splits rendered HTML into safe typewriter chunks', () => {
  230. expect(
  231. splitHtmlIntoTypewriterChunks('<p>Hello <strong>world</strong></p>')
  232. ).toEqual([
  233. '<p>',
  234. 'H',
  235. 'e',
  236. 'l',
  237. 'l',
  238. 'o',
  239. ' ',
  240. '<strong>',
  241. 'w',
  242. 'o',
  243. 'r',
  244. 'l',
  245. 'd',
  246. '</strong>',
  247. '</p>'
  248. ])
  249. })
  250. })