diff --git a/application/single_app/config.py b/application/single_app/config.py index 87f1177ed..7013a5d64 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -96,7 +96,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.250.228" +VERSION = "0.250.229" IS_DEVELOPMENT = is_development_env_enabled() SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax') diff --git a/application/single_app/static/js/chat/chat-citations.js b/application/single_app/static/js/chat/chat-citations.js index 077cc1396..f7717f951 100644 --- a/application/single_app/static/js/chat/chat-citations.js +++ b/application/single_app/static/js/chat/chat-citations.js @@ -45,6 +45,11 @@ export function parseCitations(message) { const citationRegex = /\(Source:\s*((?:(?!\(Source:).)+?),\s*(Page(?:s)?|Sheet(?:s)?|Location):\s*((?:(?!\(Source:).)+?)\)\s*((?:\[#.*?\]\s*)+)/gi; let result = message.replace(citationRegex, (whole, filename, locationLabel, locations, bracketSection) => { + // The bracket group's trailing \s* consumes whatever whitespace followed the last + // [#citation-id] marker, including the blank line that separates the citation from + // the next markdown block. Capture it so it can be restored on the way out. + const trailingWhitespaceMatch = /\s*$/.exec(bracketSection); + const trailingWhitespace = trailingWhitespaceMatch ? trailingWhitespaceMatch[0] : ''; const trimmedFilename = filename.trim(); const safeFilenameText = escapeHtml(trimmedFilename); let filenameHtml = safeFilenameText; @@ -129,14 +134,26 @@ export function parseCitations(message) { }); const linkedPagesText = linkedTokens.join(', '); - return `(Source: ${filenameHtml}, ${escapeHtml(locationLabel)}: ${linkedPagesText})`; + return `(Source: ${filenameHtml}, ${escapeHtml(locationLabel)}: ${linkedPagesText})${trailingWhitespace}`; }); // Cleanup pass: strip any remaining [#guid...] bracket groups that the main regex didn't match. // These appear when the model uses non-standard citation formats (e.g. "passim" instead of "Page: N"). // Pattern matches brackets containing one or more UUID-like citation IDs (with optional _suffix parts). - const guidBracketRegex = /\s*\[#?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}[^\]]*\]/gi; - result = result.replace(guidBracketRegex, ''); + const guidBracketPattern = '\\[#?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}[^\\]]*\\]'; + const guidBracketRunPattern = `(?:${guidBracketPattern}[ \\t]*)+`; + + // A bracket run that occupies a whole line is removed with its line, so the blank lines + // around it are not merged into the preceding paragraph. + result = result.replace(new RegExp(`^[ \\t]*${guidBracketRunPattern}\\r?\\n`, 'gim'), ''); + + // A bracket run that opens a line is removed along with the spacing that follows it, so the + // paragraph it introduces starts cleanly. + result = result.replace(new RegExp(`^[ \\t]*${guidBracketRunPattern}`, 'gim'), ''); + + // Anything still left is inline, so only the spaces or tabs in front of it are consumed. + // Consuming newlines here would collapse the paragraph break that precedes the bracket. + result = result.replace(new RegExp(`(?:[ \\t]*${guidBracketPattern})+`, 'gi'), ''); return result; } diff --git a/docs/explanation/fixes/CHAT_CITATION_WHITESPACE_COLLAPSE_FIX.md b/docs/explanation/fixes/CHAT_CITATION_WHITESPACE_COLLAPSE_FIX.md new file mode 100644 index 000000000..0f3afffaf --- /dev/null +++ b/docs/explanation/fixes/CHAT_CITATION_WHITESPACE_COLLAPSE_FIX.md @@ -0,0 +1,116 @@ +# Chat Citation Whitespace Collapse Fix + +Fixed in version: **0.250.229** + +Related issue: [#1289](https://github.com/microsoft/simplechat/issues/1289) + +## Issue Description + +Inline document citations in assistant chat messages deleted the whitespace that followed them. The citation link itself rendered correctly, but the text after the citation was jammed onto the end of the closing parenthesis instead of starting a new paragraph, list item, or sentence. + +In the reported message the model produced normal, well-formed markdown, but the browser rendered: + +- `... used to support cited answers in chat. (Source: application_workflows.md, Page: 1)Admins can configure the extraction approach ...` +- `... warrants it (Source: document-intelligence.md, Page: 1)For best results, upload clear, readable images.` +- `... (Source: uploading_documents.md, Page: 1)Thank you, Paul.` + +Because the first citation was inside a numbered list, the paragraph that followed it was also absorbed into list item 5 rather than closing the list. + +## Root Cause Analysis + +`parseCitations()` in `application/single_app/static/js/chat/chat-citations.js` matched inline citations with: + +```js +const citationRegex = /\(Source:\s*(...),\s*(Page(?:s)?|Sheet(?:s)?|Location):\s*(...)\)\s*((?:\[#.*?\]\s*)+)/gi; +``` + +The trailing `\s*` inside the repeated bracket group `((?:\[#.*?\]\s*)+)` is greedy and matches newlines, so it consumed the whitespace that followed the last `[#citation-id]` marker. The replacement callback returned only the rebuilt `(Source: ...)` string and never re-emitted that whitespace, so the blank line separating the citation from the next block was silently deleted. + +`parseCitations()` runs on raw markdown **before** `marked.parse()` in `renderAiMessageContent()` (`chat-messages.js`). Losing a `\n\n` therefore did not merely remove a space โ€” it changed how markdown parsed the remainder of the block, which is why the following paragraph became a continuation of the preceding list item. + +Reproduced in isolation against the production regex: + +```text +Input : "... in chat. (Source: application_workflows.md, Page: 1) [#181b54f7-..._1]\n\nAdmins can configure ..." +Output: "... in chat. (Source: application_workflows.md, Page: 1)Admins can configure ..." +``` + +### Secondary instance of the same defect + +The cleanup pass that strips leftover `[#guid]` brackets (used when the model emits a non-standard citation format) had the same class of bug in the opposite direction: + +```js +const guidBracketRegex = /\s*\[#?[0-9a-f]{8}-...[^\]]*\]/gi; +``` + +Its leading `\s*` also matched newlines, so a stray bracket that opened a paragraph took the preceding blank line with it (`text.\n\n[#guid] More` became `text. More`). + +## Technical Details + +### Files Modified + +- `application/single_app/static/js/chat/chat-citations.js` +- `application/single_app/config.py` +- `functional_tests/test_chat_citation_whitespace_preservation.py` +- `ui_tests/test_chat_citation_paragraph_spacing.py` + +### Code Changes Summary + +- `parseCitations()` now captures the trailing whitespace from the matched bracket group and appends it to the rebuilt citation string. Whitespace is restored exactly as the model emitted it, so no spacing is invented and a citation followed immediately by punctuation renders byte-identically to before. +- The leftover `[#guid]` cleanup pass was split into three ordered passes so line structure survives: + 1. A bracket run that occupies a whole line is removed together with its line. + 2. A bracket run that opens a line is removed along with the spacing that follows it, so the paragraph it introduces starts cleanly. + 3. Any remaining inline bracket run consumes only the spaces or tabs in front of it, never newlines. +- Both passes now also handle consecutive bracket runs such as `[#id-a] [#id-b]` as a unit. +- Updated `config.py` to version `0.250.229` for this fix. + +The emitted citation HTML is unchanged. These are whitespace-only changes, so there is no change to escaping, sanitization, or the XSS surface. + +## Validation + +### Test Results + +`functional_tests/test_chat_citation_whitespace_preservation.py` executes the real `parseCitations()` in a Node sandbox and then renders the result with the vendored `marked` bundle, so it asserts the user-visible block structure rather than just the regex output. + +| Check | Result | +| --- | --- | +| Reported message keeps its paragraph breaks | Pass | +| Inline citation spacing preserved (same-line text, trailing punctuation, back-to-back citations, citation id on the next line) | Pass | +| Stray `[#guid]` cleanup keeps line structure | Pass | +| Source guards for the whitespace restoration mechanism | Pass | + +All four checks fail against the pre-fix source and pass after it, so the test is a genuine regression guard. + +`ui_tests/test_chat_citation_paragraph_spacing.py` seeds the reported assistant message into the chat page and asserts the rendered DOM: the numbered list item ends at its citation, the following text renders as its own `

`, and no sentence collides with a citation's closing parenthesis. + +### Before / After + +Rendered HTML for the reported message, before: + +```html +

    +
  1. Grounded chat: ... in chat. (Source: application_workflows.md, Page: 1)Admins can configure the extraction approach ... The available modes are:
  2. +
+``` + +After: + +```html +
    +
  1. Grounded chat: ... in chat. (Source: application_workflows.md, Page: 1)
  2. +
+

Admins can configure the extraction approach for images and PDFs under Admin Settings > Search & Extract. The available modes are:

+``` + +### User Experience Improvements + +- Paragraphs, bullets, and numbered lists after a citation render in the structure the model actually produced. +- A citation followed by more text on the same line keeps its separating space. +- Back-to-back citations no longer collide. +- Copied and exported message markdown is built from the same parsed output, so it keeps its line breaks too. + +## Cross-References + +- Functional test: `functional_tests/test_chat_citation_whitespace_preservation.py` +- UI test: `ui_tests/test_chat_citation_paragraph_spacing.py` +- Related: [Citation Improvements](../features/v0.238.024/CITATION_IMPROVEMENTS.md) diff --git a/docs/explanation/fixes/index.md b/docs/explanation/fixes/index.md index 42ccf6661..e80b770e4 100644 --- a/docs/explanation/fixes/index.md +++ b/docs/explanation/fixes/index.md @@ -31,3 +31,4 @@ category: Version History - [Cosmos Container Throughput Deployer Fix](COSMOS_CONTAINER_THROUGHPUT_DEPLOYER_FIX.md) - [Workflow Task Document Picker Fix](WORKFLOW_TASK_DOCUMENT_PICKER_FIX.md) - [Workflow File Sync Prompt Context Fix](WORKFLOW_FILE_SYNC_PROMPT_CONTEXT_FIX.md) +- [Chat Citation Whitespace Collapse Fix](CHAT_CITATION_WHITESPACE_COLLAPSE_FIX.md) diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index c24f19320..5008edca0 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -2,6 +2,17 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Version](/explanation/features/) and [Fixes by Version](/explanation/fixes/). +### **(v0.250.229)** + +#### Bug Fixes + +* **Citations No Longer Eat the Line Break After Them** + * Text that came after an inline document citation was jammed onto the end of the closing parenthesis instead of starting a new paragraph โ€” you would see `(Source: uploading_documents.md, Page: 1)Thank you, Paul.` with no break at all. + * The citation parser matched the `[#citation-id]` marker along with the whitespace that followed it, then rebuilt the citation without putting that whitespace back. Because this runs on the raw markdown before it is rendered, a deleted blank line did not just remove a space โ€” it changed how the rest of the block was read, so a paragraph after a cited list item got absorbed into the list item itself. + * Spacing is now restored exactly as the model wrote it. Paragraphs, bullets, and numbered lists after a citation render in their intended structure, a citation followed by more text on the same line keeps its space, and back-to-back citations stop colliding. Copied and exported message text keeps its line breaks for the same reason. + * The cleanup pass for leftover citation markers had the same flaw in reverse and could swallow the blank line *before* a stray marker. It now only removes horizontal spacing, or the marker's whole line when it sits on one. + * (Ref: #1289, `chat-citations.js`, `parseCitations()`, chat message rendering) + ### **(v0.250.228)** #### Bug Fixes diff --git a/functional_tests/test_chat_citation_whitespace_preservation.py b/functional_tests/test_chat_citation_whitespace_preservation.py new file mode 100644 index 000000000..787736db7 --- /dev/null +++ b/functional_tests/test_chat_citation_whitespace_preservation.py @@ -0,0 +1,410 @@ +#!/usr/bin/env python3 +# test_chat_citation_whitespace_preservation.py +""" +Functional test for inline citation whitespace preservation. +Version: 0.250.229 +Implemented in: 0.250.229 + +This test ensures that parseCitations() in chat-citations.js no longer deletes the +whitespace that follows an inline document citation. The citation regex captures the +whitespace after the trailing [#citation-id] marker, and before this fix that +whitespace was dropped, collapsing the following paragraph onto the end of the +citation and, inside lists, absorbing it into the preceding list item. + +parseCitations() runs on raw markdown before marked.parse(), so the harness below +executes the real production function and then renders the result with the vendored +marked bundle to assert the user-visible block structure. + +Refs: #1289 +""" + +import json +import os +import shutil +import subprocess +import sys +import tempfile + +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +from test_support.versioning import assert_app_version_at_least + + +ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +CHAT_JS_DIR = os.path.join(ROOT_DIR, 'application', 'single_app', 'static', 'js', 'chat') +CITATIONS_FILE = os.path.join(CHAT_JS_DIR, 'chat-citations.js') +MARKED_FILE = os.path.join(CHAT_JS_DIR, 'marked.min.js') + +CITATION_ONE = '181b54f7-fcf9-479b-a58b-81a5da3ba251_1' +CITATION_TWO = '159255fa-79d6-4619-9131-819813ae7997_1' +CITATION_THREE = '5e583e44-ea82-4569-bda8-e545bf12dca4_1' +CITATION_GENERIC = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + +REPORTED_MESSAGE = ( + 'Simple Chat processes uploaded images as part of its document-ingestion pipeline:\n' + '\n' + '5. **Grounded chat:** Once processing completes, the image-derived content can be ' + 'found through hybrid keyword and vector search and used to support cited answers in ' + f'chat. (Source: application_workflows.md, Page: 1) [#{CITATION_ONE}]\n' + '\n' + 'Admins can configure the extraction approach for images and PDFs under ' + '**Admin Settings > Search & Extract**. The available modes are:\n' + '\n' + '- **Auto:** Samples the content and chooses the richer path when the document ' + f'structure warrants it (Source: document-intelligence.md, Page: 1) [#{CITATION_TWO}]\n' + '\n' + 'For best results, upload clear, readable images. ' + f'(Source: uploading_documents.md, Page: 1) [#{CITATION_THREE}]\n' + '\n' + 'Thank you, Paul.' +) + +TEST_CASES = { + 'reported': REPORTED_MESSAGE, + 'inline_space': ( + f'See the policy (Source: Policy.pdf, Page: 12) [#{CITATION_GENERIC}_12] and then act.' + ), + 'trailing_punctuation': ( + f'See the policy (Source: Policy.pdf, Page: 12) [#{CITATION_GENERIC}_12].' + ), + 'back_to_back': ( + f'Both agree (Source: A.pdf, Page: 1) [#{CITATION_GENERIC}_1] ' + f'(Source: B.pdf, Page: 2) [#{CITATION_GENERIC}_2] end.' + ), + 'citation_id_on_next_line': ( + 'Findings are consistent. (Source: Report.pdf, Page: 4)\n' + f'[#{CITATION_GENERIC}_4]\n' + '\n' + 'The next section explains why.' + ), + 'stray_bracket_own_line': ( + 'Model used passim style.\n' + '\n' + f'[#{CITATION_GENERIC}_3]\n' + '\n' + 'Next paragraph.' + ), + 'stray_bracket_starts_paragraph': ( + 'Model used passim style.\n' + '\n' + f'[#{CITATION_GENERIC}_3] Next paragraph.' + ), + 'stray_bracket_inline': ( + f'Model used passim style [#{CITATION_GENERIC}_3] mid sentence.' + ), + 'stray_bracket_consecutive': ( + f'Model used passim style [#{CITATION_GENERIC}_3] [#{CITATION_GENERIC}_4] mid sentence.' + ), +} + +NODE_HARNESS = r""" +const fs = require('fs'); +const vm = require('vm'); + +const ESCAPE_MAP = { '&': '&', '<': '<', '>': '>', '"': '"' }; + +function escapeText(value) { + return String(value).replace(/[&<>]/g, character => ESCAPE_MAP[character]); +} + +function escapeAttribute(value) { + return String(value).replace(/[&<>"]/g, character => ESCAPE_MAP[character]); +} + +class FakeElement { + constructor(tagName) { + this.tagName = tagName; + this.childNodes = []; + this.dataset = {}; + this.attributes = {}; + this.textValue = ''; + } + set textContent(value) { + this.textValue = value === null || value === undefined ? '' : String(value); + this.childNodes = []; + } + get textContent() { return this.textValue; } + set href(value) { this.attributes.href = value; } + set className(value) { this.attributes.class = value; } + set target(value) { this.attributes.target = value; } + set rel(value) { this.attributes.rel = value; } + appendChild(child) { this.childNodes.push(child); return child; } + get innerHTML() { + if (this.childNodes.length) { + return this.childNodes.map(child => child.outerHTML).join(''); + } + return escapeText(this.textValue); + } + get outerHTML() { + const parts = []; + Object.keys(this.attributes).forEach(name => { + parts.push(`${name}="${escapeAttribute(this.attributes[name])}"`); + }); + Object.keys(this.dataset).forEach(name => { + const attributeName = 'data-' + name.replace(/[A-Z]/g, match => '-' + match.toLowerCase()); + parts.push(`${attributeName}="${escapeAttribute(this.dataset[name])}"`); + }); + const attributeText = parts.length ? ' ' + parts.join(' ') : ''; + return `<${this.tagName}${attributeText}>${this.innerHTML}`; + } +} + +const documentStub = { + getElementById: () => null, + createElement: tagName => new FakeElement(tagName), + addEventListener: () => {}, +}; + +const citationSource = fs.readFileSync(process.argv[2], 'utf8') + .replace(/^\s*import\s[^;]*;\s*$/gm, '') + .replace(/^export /gm, ''); + +const citationSandbox = { + module: { exports: {} }, + console, + document: documentStub, + window: { addEventListener: () => {} }, +}; +vm.runInNewContext(citationSource + '\nmodule.exports = { parseCitations };', citationSandbox); +const { parseCitations } = citationSandbox.module.exports; + +const markedSandbox = { module: { exports: {} }, console }; +markedSandbox.exports = markedSandbox.module.exports; +vm.runInNewContext(fs.readFileSync(process.argv[4], 'utf8'), markedSandbox); +const markedParse = markedSandbox.module.exports.parse; + +const cases = JSON.parse(fs.readFileSync(process.argv[3], 'utf8')); +const results = {}; +Object.keys(cases).forEach(name => { + const citationMarkdown = parseCitations(cases[name]); + results[name] = { + markdown: citationMarkdown, + html: markedParse(citationMarkdown), + }; +}); +process.stdout.write(JSON.stringify(results)); +""" + + +def read_file_text(file_path): + with open(file_path, 'r', encoding='utf-8') as file_handle: + return file_handle.read() + + +def run_citation_harness(): + """Execute the production parseCitations() and return markdown/HTML per case.""" + node_executable = shutil.which('node') + if not node_executable: + return None + + with tempfile.TemporaryDirectory() as temp_dir: + harness_path = os.path.join(temp_dir, 'citation_harness.cjs') + cases_path = os.path.join(temp_dir, 'cases.json') + + with open(harness_path, 'w', encoding='utf-8') as harness_file: + harness_file.write(NODE_HARNESS) + with open(cases_path, 'w', encoding='utf-8') as cases_file: + json.dump(TEST_CASES, cases_file) + + completed = subprocess.run( + [node_executable, harness_path, CITATIONS_FILE, cases_path, MARKED_FILE], + capture_output=True, + text=True, + timeout=60, + ) + + assert completed.returncode == 0, f'Citation harness failed: {completed.stderr}' + return json.loads(completed.stdout) + + +def test_reported_message_keeps_its_paragraph_breaks(): + """The reported message must keep every block separate after citation parsing.""" + print('๐Ÿ” Testing reported citation message keeps paragraph breaks...') + + try: + results = run_citation_harness() + if results is None: + print('โš ๏ธ Node.js not available, skipping executable citation parsing check') + return True + + markdown = results['reported']['markdown'] + html = results['reported']['html'] + + collapsed_forms = [ + ')Admins can configure', + ')For best results', + ')Thank you, Paul.', + ] + for collapsed in collapsed_forms: + assert collapsed not in markdown, \ + f'Citation swallowed the whitespace before "{collapsed.lstrip(")")}"' + + assert ')\n\nAdmins can configure' in markdown, \ + 'Blank line between the citation and the following paragraph was not preserved' + assert ')\n\nFor best results' in markdown, \ + 'Blank line before "For best results" was not preserved' + assert ')\n\nThank you, Paul.' in markdown, \ + 'Blank line before the closing sentence was not preserved' + + assert '

Admins can configure the extraction approach' in html, \ + 'Follow-on paragraph must render as its own block, not inside the numbered list item' + assert '

For best results, upload clear, readable images.' in html, \ + 'Follow-on paragraph must render as its own block, not inside the bullet list item' + assert '

Thank you, Paul.

' in html, \ + 'Closing sentence must render as its own paragraph' + + assert '
  • Grounded chat:' in html, \ + 'The numbered list item should still render' + assert 'Admins can configure' not in html.split('')[0], \ + 'The paragraph after the citation must not be absorbed into the numbered list' + + print('โœ… Reported citation message paragraph breaks passed') + return True + + except Exception as exc: + print(f'โŒ Test failed: {exc}') + import traceback + traceback.print_exc() + return False + + +def test_inline_citation_spacing_is_preserved(): + """Inline spacing around citations must survive parsing unchanged.""" + print('๐Ÿ” Testing inline citation spacing...') + + try: + results = run_citation_harness() + if results is None: + print('โš ๏ธ Node.js not available, skipping inline citation spacing check') + return True + + inline_space = results['inline_space']['markdown'] + assert ') and then act.' in inline_space, \ + 'A citation followed by more text on the same line must keep its separating space' + + trailing_punctuation = results['trailing_punctuation']['markdown'] + assert trailing_punctuation.endswith(').'), \ + 'A citation followed immediately by punctuation must not gain whitespace' + + back_to_back = results['back_to_back']['markdown'] + assert ') (Source: B.pdf' in back_to_back, \ + 'Back-to-back citations must keep the space between them' + assert ')(Source: B.pdf' not in back_to_back, \ + 'Back-to-back citations must not collide' + + next_line = results['citation_id_on_next_line']['markdown'] + assert ')\n\nThe next section explains why.' in next_line, \ + 'A citation id on the following line must still leave the paragraph break intact' + + print('โœ… Inline citation spacing passed') + return True + + except Exception as exc: + print(f'โŒ Test failed: {exc}') + import traceback + traceback.print_exc() + return False + + +def test_stray_citation_bracket_cleanup_keeps_line_structure(): + """The leftover [#guid] cleanup pass must not consume surrounding newlines.""" + print('๐Ÿ” Testing stray citation bracket cleanup...') + + try: + results = run_citation_harness() + if results is None: + print('โš ๏ธ Node.js not available, skipping stray bracket cleanup check') + return True + + for case_name in ( + 'stray_bracket_own_line', + 'stray_bracket_starts_paragraph', + 'stray_bracket_inline', + 'stray_bracket_consecutive', + ): + markdown = results[case_name]['markdown'] + assert '[#' not in markdown, \ + f'Leftover citation bracket survived cleanup in {case_name}: {markdown!r}' + + own_line_html = results['stray_bracket_own_line']['html'] + assert '

    Model used passim style.

    ' in own_line_html, \ + 'A bracket alone on its own line must not merge the paragraphs around it' + assert '

    Next paragraph.

    ' in own_line_html, \ + 'The paragraph after a whole-line bracket must stay separate' + + starts_paragraph = results['stray_bracket_starts_paragraph'] + assert 'Model used passim style. Next paragraph.' not in starts_paragraph['markdown'], \ + 'Cleanup must not pull the following paragraph onto the previous line' + assert '

    Next paragraph.

    ' in starts_paragraph['html'], \ + 'A bracket that starts a paragraph must not destroy the paragraph break' + + inline_markdown = results['stray_bracket_inline']['markdown'] + assert inline_markdown == 'Model used passim style mid sentence.', \ + f'Inline bracket cleanup should leave a single space: {inline_markdown!r}' + + consecutive_markdown = results['stray_bracket_consecutive']['markdown'] + assert consecutive_markdown == 'Model used passim style mid sentence.', \ + f'Consecutive inline brackets should collapse cleanly: {consecutive_markdown!r}' + + print('โœ… Stray citation bracket cleanup passed') + return True + + except Exception as exc: + print(f'โŒ Test failed: {exc}') + import traceback + traceback.print_exc() + return False + + +def test_citation_source_restores_trailing_whitespace(): + """Guard the mechanism in source so the fix cannot be silently reverted.""" + print('๐Ÿ” Testing chat-citations.js source guards...') + + try: + assert_app_version_at_least( + '0.250.229', + reason='Inline citation whitespace preservation shipped in 0.250.229.', + ) + + source = read_file_text(CITATIONS_FILE) + + assert 'const trailingWhitespaceMatch = /\\s*$/.exec(bracketSection);' in source, \ + 'parseCitations must capture the whitespace consumed by the citation bracket group' + assert '${linkedPagesText})${trailingWhitespace}`' in source, \ + 'parseCitations must re-emit the captured trailing whitespace' + + assert '/\\s*\\[#?[0-9a-f]{8}-' not in source, \ + 'The stray bracket cleanup must not consume leading newlines' + assert 'guidBracketRunPattern' in source, \ + 'Stray bracket runs should be cleaned up as a unit' + assert "`^[ \\\\t]*${guidBracketRunPattern}\\\\r?\\\\n`" in source, \ + 'A whole-line stray bracket run should be removed as a complete line' + assert "`(?:[ \\\\t]*${guidBracketPattern})+`" in source, \ + 'The inline stray bracket cleanup should only consume spaces and tabs' + + print('โœ… chat-citations.js source guards passed') + return True + + except Exception as exc: + print(f'โŒ Test failed: {exc}') + import traceback + traceback.print_exc() + return False + + +if __name__ == '__main__': + tests = [ + test_reported_message_keeps_its_paragraph_breaks, + test_inline_citation_spacing_is_preserved, + test_stray_citation_bracket_cleanup_keeps_line_structure, + test_citation_source_restores_trailing_whitespace, + ] + results = [] + + for test in tests: + print(f'\n๐Ÿงช Running {test.__name__}...') + results.append(test()) + + success = all(results) + print(f'\n๐Ÿ“Š Results: {sum(results)}/{len(results)} tests passed') + sys.exit(0 if success else 1) diff --git a/ui_tests/test_chat_citation_paragraph_spacing.py b/ui_tests/test_chat_citation_paragraph_spacing.py new file mode 100644 index 000000000..030851528 --- /dev/null +++ b/ui_tests/test_chat_citation_paragraph_spacing.py @@ -0,0 +1,192 @@ +# test_chat_citation_paragraph_spacing.py +""" +UI test for paragraph spacing around inline document citations. + +Version: 0.250.229 +Implemented in: 0.250.229 + +This test ensures that text following an inline citation renders as its own block +instead of being glued onto the closing parenthesis of the citation. Before the fix, +parseCitations() consumed the newlines after the trailing [#citation-id] marker, so +the next paragraph was absorbed into the preceding list item and sentences ran +together with no space after ")". + +Refs: #1289 +""" + +import json +import os +from pathlib import Path + +import pytest +from playwright.sync_api import expect + + +BASE_URL = os.getenv("SIMPLECHAT_UI_BASE_URL", "").rstrip("/") +STORAGE_STATE = os.getenv("SIMPLECHAT_UI_STORAGE_STATE", "") + +CITATION_ONE = "181b54f7-fcf9-479b-a58b-81a5da3ba251_1" +CITATION_TWO = "159255fa-79d6-4619-9131-819813ae7997_1" +CITATION_THREE = "5e583e44-ea82-4569-bda8-e545bf12dca4_1" + +MESSAGE_CONTENT = ( + "Simple Chat processes uploaded images as part of its document-ingestion pipeline:\n" + "\n" + "5. **Grounded chat:** Once processing completes, the image-derived content can be " + "found through hybrid keyword and vector search and used to support cited answers in " + f"chat. (Source: application_workflows.md, Page: 1) [#{CITATION_ONE}]\n" + "\n" + "Admins can configure the extraction approach for images and PDFs under " + "**Admin Settings > Search & Extract**. The available modes are:\n" + "\n" + "- **Auto:** Samples the content and chooses the richer path when the document " + f"structure warrants it (Source: document-intelligence.md, Page: 1) [#{CITATION_TWO}]\n" + "\n" + "For best results, upload clear, readable images. " + f"(Source: uploading_documents.md, Page: 1) [#{CITATION_THREE}]\n" + "\n" + "Thank you, Paul." +) + + +def _require_ui_env(): + if not BASE_URL: + pytest.skip("Set SIMPLECHAT_UI_BASE_URL to run this UI test.") + if not STORAGE_STATE or not Path(STORAGE_STATE).exists(): + pytest.skip("Set SIMPLECHAT_UI_STORAGE_STATE to a valid authenticated Playwright storage state file.") + + +def _fulfill_json(route, payload, status=200): + route.fulfill( + status=status, + content_type="application/json", + body=json.dumps(payload), + ) + + +def _build_message_payload(): + hybrid_citations = [ + { + "file_name": "application_workflows.md", + "document_id": "181b54f7-fcf9-479b-a58b-81a5da3ba251", + "citation_id": CITATION_ONE, + "page_number": 1, + "chunk_id": CITATION_ONE, + }, + { + "file_name": "document-intelligence.md", + "document_id": "159255fa-79d6-4619-9131-819813ae7997", + "citation_id": CITATION_TWO, + "page_number": 1, + "chunk_id": CITATION_TWO, + }, + { + "file_name": "uploading_documents.md", + "document_id": "5e583e44-ea82-4569-bda8-e545bf12dca4", + "citation_id": CITATION_THREE, + "page_number": 1, + "chunk_id": CITATION_THREE, + }, + ] + + return { + "id": "assistant-citation-spacing-1", + "conversation_id": "test-citation-spacing", + "role": "assistant", + "content": MESSAGE_CONTENT, + "timestamp": "2026-08-18T15:32:45.480651", + "augmented": True, + "hybrid_citations": hybrid_citations, + "web_search_citations": [], + "agent_citations": [], + "metadata": {}, + } + + +@pytest.mark.ui +def test_text_after_a_citation_renders_as_its_own_block(playwright): + """Validate that citations do not absorb the paragraph that follows them.""" + _require_ui_env() + + browser = playwright.chromium.launch() + context = browser.new_context( + storage_state=STORAGE_STATE, + viewport={"width": 1440, "height": 900}, + ) + page = context.new_page() + message_payload = _build_message_payload() + + page.route( + "**/api/user/settings", + lambda route: _fulfill_json(route, {"selected_agent": None, "settings": {"enable_agents": False}}), + ) + page.route("**/api/get_conversations", lambda route: _fulfill_json(route, {"conversations": []})) + + try: + page.goto(f"{BASE_URL}/chats", wait_until="domcontentloaded") + page.wait_for_selector("#chatbox") + + page.evaluate( + """ + async (payload) => { + currentConversationId = payload.conversation_id; + window.currentConversationId = payload.conversation_id; + + const messagesModule = await import('/static/js/chat/chat-messages.js'); + messagesModule.appendMessage( + 'AI', + payload.content, + null, + payload.id, + payload.augmented, + payload.hybrid_citations, + payload.web_search_citations, + payload.agent_citations, + null, + null, + payload, + true + ); + } + """, + message_payload, + ) + + message = page.locator('.message[data-message-id="assistant-citation-spacing-1"]') + message_text = message.locator(".message-text") + expect(message_text).to_be_visible() + + # The three inline citations still render as clickable links. + expect(message_text.locator("a.citation-link")).to_have_count(3) + + # The numbered list item ends at its citation and does not swallow the next paragraph. + numbered_item = message_text.locator("ol > li") + expect(numbered_item).to_have_count(1) + expect(numbered_item).to_contain_text("Grounded chat:") + assert "Admins can configure" not in numbered_item.inner_text(), \ + "The paragraph after the citation was absorbed into the numbered list item" + + # The bullet ends at its citation and does not swallow the next paragraph either. + bullet_item = message_text.locator("ul > li") + expect(bullet_item).to_have_count(1) + expect(bullet_item).to_contain_text("Auto:") + assert "For best results" not in bullet_item.inner_text(), \ + "The paragraph after the citation was absorbed into the bullet list item" + + # Each following block renders as its own paragraph. + paragraph_texts = message_text.locator("p").all_inner_texts() + assert any(text.startswith("Admins can configure the extraction approach") for text in paragraph_texts), \ + f"Expected a standalone paragraph starting with 'Admins can configure': {paragraph_texts}" + assert any(text.startswith("For best results, upload clear, readable images.") for text in paragraph_texts), \ + f"Expected a standalone paragraph starting with 'For best results': {paragraph_texts}" + assert any(text.strip() == "Thank you, Paul." for text in paragraph_texts), \ + f"Expected 'Thank you, Paul.' to render as its own paragraph: {paragraph_texts}" + + # No sentence may collide with the closing parenthesis of a citation. + rendered_text = message_text.inner_text() + for collapsed in (")Admins can configure", ")For best results", ")Thank you, Paul."): + assert collapsed not in rendered_text, \ + f'Citation swallowed the whitespace before "{collapsed.lstrip(")")}"' + finally: + context.close() + browser.close()