Repository navigation
Feature: download the Inquire conversation as a Word document #555
Closed
branch1337
started this conversation in
Ideas
Replies: 1 comment 1 reply
|
Thank you. The Inquire page now has a conversation header with Download and Clear conversation buttons, shown once the conversation has content. The conversation is downloaded as |
1 reply
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Description
A person using Inquire can ask questions across recordings and read the answers on the page. The turns live in
inquireChatMessages(templates/inquire.html:778). Leaving the page, refreshing, or pressing Clear All drops them. Clear All assigns that array to[]and also clears the filter selections (templates/inquire.html:1177-1191). Nothing writes the questions or the answers to the server.POST /api/inquire/sessionsstores a name and filter ids (src/api/inquire.py:92-116), and the Inquire page never calls it.InquireSessionhas columns for tags, speakers, dates, and recording ids, and no column for messages or folders (src/models/inquire.py:13-45).Transcript, summary, notes, and the chat on one recording each have a download. The recording chat posts the on-screen turns to
POST /recording/<id>/download/chatand receives one Word file (src/api/recordings.py:475-609, client atstatic/js/modules/composables/chat.js:360-377). The button is the download icon in the chat header, shown only whenchatMessages.length > 0(templates/components/detail/floating-chat.html:52-56). Inquire has no such control. The conversation column (templates/inquire.html:428-431) goes from the empty state straight into the message list.The workaround is to copy the bubbles by hand. The answers are markdown, the page renders HTML, and a copy from the screen drops the structure the recording chat already keeps.
Affected Version
v0.10.19-alpha
Requested Feature
From the Inquire page, the person downloads the conversation on screen as one Word document. The control is one icon button, with no format menu. It sits on a thin bar at the top of the conversation column, above the scroller at
templates/inquire.html:431. It uses the same classes as the recording chat button (btn btn--icon btn--smandfas fa-download). The accessible name is a new string,inquire.downloadConversation. It does not reusebuttons.downloadChat("Download chat as Word document",static/locales/en.json:894), which refers to the chat on one transcription.The button is shown when at least one turn has a non-empty string
content.hasActiveFilters(templates/inquire.html:807-814) stays the condition for the filter chips and the "Filters active" hint. Filters can be set while the message list is still the empty state (templates/inquire.html:438), and in that state the button stays hidden. Clear All empties the list, so the button disappears with the turns.The click posts the turns already on screen. It does not call the model, does not search chunks, and does not create an
InquireSession. The request isPOST /api/inquire/downloadwith the session cookie and theX-CSRFTokenheader the Inquire chat call already sends (templates/inquire.html:1030-1034). The JSON body has two fields.messagesis a list of{role, content}for every turn whosecontentis a non-empty string, in screen order. A user turn is pushed before the request (templates/inquire.html:1004), and assistant text is appended on each SSE delta (templates/inquire.html:1117). A download during streaming therefore contains the question and whatever of the answer has already arrived. Stop keeps that partial text (templates/inquire.html:1147-1149), and the file includes it. The page does not sendhtml,steps,activitySummary,sources, ormemoryState.filtersis optional. The page fills it from the selections ininquireFilters(templates/inquire.html:732-739), using the names already drawn next to the checkboxes, not the id numbers and not the count pills. Tag ids (templates/inquire.html:294) are mapped throughavailableFilters.tagstotag.name(templates/inquire.html:296). Folder ids (templates/inquire.html:315) are mapped tofolder.name(templates/inquire.html:317). Speakers are already names (templates/inquire.html:337-339). Dates are theYYYY-MM-DDvalues ofdateFromanddateTo, and only the ends that are set. An id that is no longer inavailableFiltersis skipped. A group with no remaining names is omitted. When every group is empty, the field is omitted. The recording checkbox list is inside an HTML comment (templates/inquire.html:368-385), soselectedRecordingsstays[]on this page. The page does not add arecordingsgroup. The route still accepts one if a caller sends names.The response is one Word document. The media type is
application/vnd.openxmlformats-officedocument.wordprocessingml.document.Content-Dispositionisattachment; filename="inquire.docx". The name is fixed. Filter text and recording titles never enter it. The file is returned bydocx_attachment_responsefrom #554, which callssend_filewithas_attachment=Falseand then sets the header.test_download_chat_with_messages_returns_docxrecords that flippingas_attachmenttoTrueon aBytesIOreturns 500 (tests/test_cov_recordings_read.py:1755-1776).inquire.docxis ASCII, so the header is the plainfilenameform.The document shape follows the recording chat file, without a recording title and without a reasoning section:
Inquire, written withadd_unicode_heading.Export Date: YYYY-MM-DD HH:MM, fromnow_local(user_timezone(current_user))(src/utils/timezones.py:98-100andsrc/utils/timezones.py:43-46), written withadd_unicode_paragraph. That is the same clock the recording chat uses forChat Export Date(src/api/recordings.py:528-530). A user with no valid timezone gets the deployment zone, which is whatuser_timezonealready returns.Label: a, b. Groups are joined with;. Example:Tags: Planning; Speakers: Ada; Dates: 2026-01-01, 2026-01-31. The count pills on the sidebar (templates/inquire.html:397-419) stay counts. They show "2 tags" and "Date range active", and the file shows the names and the dates.append_conversation_turn: a boldYou:,Assistant:, or the title-cased role, then the turn's markdown passed throughprocess_markdown_to_docx(src/services/document.py:11), then one blank paragraph. Role labels stay the English words the recording chat hardcodes (src/api/recordings.py:540-551). The interface language translates the button, not those labels. Inquire does not passthinking, so the file has no[Model Reasoning]paragraph.Citation links that are already inside
contentstay there.process_markdown_to_docxwrites a markdown link as the link text followed by the URL in parentheses (src/services/document.py:81-83). It does not create a Word hyperlink. The Sources list the page builds in HTML (templates/inquire.html:968-986) is not part ofcontent, and the file does not add a second sources section frommessage.sources(templates/inquire.html:932). Agent steps (templates/inquire.html:509-526) and the compacted-memory divider (templates/inquire.html:485-501) are not sections of the file.data.thinkingon the Inquire stream updates the status line and is not stored on the turn (templates/inquire.html:1102-1105).Refusals:
/api/path:401withcodeauthentication_required(src/app.py:1010-1014).ENABLE_INQUIRE_MODEoff:403withcodeinquire_disabled, the same bodyinquire_chatreturns (src/api/inquire.py:843-844). The flag is read in this module (src/api/inquire.py:28), default false. The page itself redirects when the flag is off (src/api/inquire.py:51-57). The route still checks.messagesabsent:400withcodeno_messages_provided.messagesan empty list, not a list, or a list whose turns all lack a non-empty stringcontent:400withcodeno_messages_to_download.500withcodeword_document_failed.api_errorreturnserrorin the interface language and a stablecode(src/utils/localization.py:355-362). Tests assertcode.no_messages_providedandno_messages_to_downloadalready exist (static/locales/en.json:3361-3362).word_document_failedis the codedownload_chat_wordalready returns (src/api/recordings.py:607-609). No new error code.These stay as they are. The recording chat file still has the recording title, the recording date, the thinking block, the blank line between turns, and a filename taken from the title. This request moves that turn loop onto
append_conversation_turnand leaves the visible chat file the same.inquire_chatandinquire_streamstill stream the answer.POST /api/v1/inquire(src/api/api_v1.py:3240-3255) still answers a question and, when the mode is off, still returns403withcodefeature_disabled.InquireSessionis unchanged. Transcript, summary, notes, and the on-disk auto-export of a recording are unchanged. There is no new environment variable.Proposed Implementation
Apply #554 first. This request depends on it. That request adds
ensure_unicode_font,add_unicode_heading,add_unicode_paragraph, anddocx_attachment_responseinsrc/services/document.py, and points the summary, notes, and chat downloads at them. This request does not define those four functions again. Implementing this file first leaves the imports unresolved.The route goes on
inquire_bp, next toinquire_chat(src/api/inquire.py:839-860). That blueprint already serves every web Inquire call: the page, sessions, search, chat, and available filters. One route there covers the page. Copying a second download ontoinquire_v1would put the file on the versioned API, which this request leaves alone.Two arrows leave the Inquire page. The new one returns the file from the turns already on screen. The existing one still returns the stream.
POST /api/v1/inquireandPOST /recording/<id>/download/chathave no arrow in this diagram.sequenceDiagram actor Person participant Page as Inquire page participant Download as POST /api/inquire/download participant Chat as POST /api/inquire/chat Person->>Page: Click the download icon Page->>Download: role and content from inquireChatMessages, plus filter names Note over Download: No model call, no chunk read, no InquireSession Download-->>Person: inquire.docx Person->>Page: Ask a question Page->>Chat: question and history Chat-->>Page: text/event-streamsend_file,jsonify,request,current_app,login_required,current_user, andapi_errorare already imported insrc/api/inquire.py:13-20.ENABLE_INQUIRE_MODEis already the module flag atsrc/api/inquire.py:28. The Word helpers are imported fromsrc.services.documentinside thetry, next toDocument, the same way a failure to build the file becomesword_document_failed.This request also adds
conversation_role_labelandappend_conversation_turntosrc/services/document.py. The recording chat is the first caller (src/api/recordings.py:533-563). Inquire is the second. The loop leavesdownload_chat_wordand both routes call the one function.download_chat_wordpassesmessage.get('role', 'unknown'),message.get('content', ''), andmessage.get('thinking', '')with no extra coercion, so a chat turn whose role is an empty string still renders the way it does now.#554 edits
download_summary_wordand the heading block insidedownload_chat_word, so the line numbers below move. In v0.10.19-alpha the loop issrc/api/recordings.py:533-563. After that request, replace thefor message in messagesloop indownload_chat_wordwith:The Inquire route and its filter helpers live in
src/api/inquire.py:Leave the recording chat's title, dates, filename, and access check in
download_chat_word(src/api/recordings.py:477-609). Leaveinquire_v1(src/api/api_v1.py:3240-3255) as the versioned question API. Leavecreate_inquire_session(src/api/inquire.py:92-116) andInquireSession.to_dict(src/models/inquire.py:33-45) as the filter-only session. LeavedownloadChat(static/js/modules/composables/chat.js:360-404) as the client of the recording route. The Inquire page is a separate Vue app and does not import that composable.On the page, add
canDownloadInquireanddownloadInquireConversationin the samesetup()and return them from the object attemplates/inquire.html:1315.canDownloadInquireis true when some entry ofinquireChatMessageshas a non-empty stringcontent. The bar is the first child of the conversation column attemplates/inquire.html:428, before the scroller. The column ishidden lg:flexonly while the filter drawer is open, andflexotherwise (templates/inquire.html:428-429), so the button is on screen whenever that column is. Do not put a second button in the filter sidebar.downloadInquireConversationbuildsmessagesasroleandcontentonly, buildsfiltersas described above, andfetches/api/inquire/downloadwithContent-Type: application/jsonandX-CSRFTokenfrom the ref attemplates/inquire.html:726. On a non-OK response it reads the JSONerrorand shows it throughsetGlobalError, which the chat path already uses (templates/inquire.html:1150-1153). On success it downloads the blob. The saved name isinquire.docx.Add
inquire.downloadConversationin theinquireobject, afterdateRangeActive(static/locales/en.json:1876). English value:Download conversation as Word document. Add the same key inde.json,es.json,fr.json,pt-BR.json,ru.json, andzh.json, which already translateinquire.clearAll.In
docs/user-guide/inquire-mode.md, after the date-range paragraph (docs/user-guide/inquire-mode.md:39) and before "Asking Effective Questions" (docs/user-guide/inquire-mode.md:41), add a short paragraph. It says the download icon above the conversation saves the questions and answers on screen as a Word file namedinquire.docx, that the icon is absent until a turn exists, and that the file names the selected tags, folders, speakers, and dates. It says Clear All removes the conversation and the icon. It does not describe a format menu or a saved server-side transcript.Tests go in a new file,
speakr.git/tests/test_inquire_download.py. They do not jointests/test_cov_inquire.pyortests/test_cov_recordings_read.py. The new file setsapp.config['WTF_CSRF_ENABLED'] = Falsethe waytests/test_cov_inquire.py:34does, and signs the session the way_logindoes there (tests/test_cov_inquire.py:79-84). Creating a user is enough. The route does not readRecording. Patchsrc.api.inquire.ENABLE_INQUIRE_MODE. Patchsrc.utils.timezones.now_localto a fixed naive datetime so the export line is exact. Read the file withdocx.Documentover aBytesIOofresp.data, and joinparagraph.text.test_inquire_download_missing_messages_400:json={}returns400andcodeno_messages_provided.test_inquire_download_empty_messages_400:json={"messages": []}returns400andcodeno_messages_to_download.test_inquire_download_blank_turns_400: a list of{"role": "assistant", "content": ""}and a non-dict entry returns400andcodeno_messages_to_download.test_inquire_download_returns_docx: two turns, one with**roadmap**, return200, a content type containingwordprocessingml, andContent-Dispositionattachment; filename="inquire.docx". The paragraph text containsInquire,Export Date: 2026-03-04 15:06,You:,Assistant:, the question, androadmap.test_inquire_download_writes_filter_labels: filterstags: ["Planning"],folders: ["Client"],speakers: ["Ada"],recordings: ["Standup"],dates: ["2026-01-01", "2026-01-31"]produce one paragraphTags: Planning; Folders: Client; Speakers: Ada; Recordings: Standup; Dates: 2026-01-01, 2026-01-31.test_inquire_download_omits_empty_filter_line: a body with nofilters, and a body withfilters: {"tags": [], "dates": ""}, do not containTags:orDates:.test_inquire_download_ignores_steps_memory_and_html:steps,html,sources, and a top-levelconversation_memorywhose text issecret-memorydo not appear. Thecontentstring does.test_inquire_download_disabled_403: the flag false returns403andcodeinquire_disabled, including whenmessagesis missing. The mode check is first.test_inquire_download_requires_login: no session returns401andcodeauthentication_required.test_inquire_download_keeps_non_ascii: content预算and a filterTags: 会议are both present in the paragraph text.test_inquire_download_generator_failure_500:Documentraising returns500andcodeword_document_failed.Add
test_download_chat_turn_writer_keeps_thinkingintests/test_cov_recordings_read.py, besidetest_download_chat_with_messages_returns_docx(tests/test_cov_recordings_read.py:1755-1776). Post one user turn and one assistant turn whosethinkingishidden-reasoningand whosecontentisThe **roadmap**.Open the docx and assert the joined paragraph text containsYou:,Assistant:,[Model Reasoning],hidden-reasoning, androadmap. The existing chat tests only check status and content type, so they would still pass if the moved loop dropped the reasoning paragraph.test_download_chat_with_messages_returns_docx,test_download_chat_empty_messages_400, andtest_download_chat_missing_key_400stay intests/test_cov_recordings_read.pyand still cover the recording route.test_chat_disabled_403stays intests/test_cov_inquire.py.Run the new file from
speakr.gitwithpython -m pytest tests/test_inquire_download.py tests/test_cov_recordings_read.py::test_download_chat_with_messages_returns_docx tests/test_cov_recordings_read.py::test_download_chat_turn_writer_keeps_thinking tests/test_cov_recordings_read.py::test_download_chat_empty_messages_400 tests/test_cov_recordings_read.py::test_download_chat_missing_key_400.Expected Behavior
inquire.docx. The file heading isInquire, then the export time in the current user's zone, then the questions and answers that were on screen. A blank paragraph follows each turn, the same spacer the recording chat already writes (src/api/recordings.py:563).process_markdown_to_docx. A citation written as a markdown link in the answer appears as its text and URL. The HTML Sources list under the bubble is not repeated.contentwhen the SSE event saystruncated(templates/inquire.html:1123-1127).Tags:line. The sidebar pills are unchanged and still show counts.messagesfield, or only blank turns returns400and does not return a file.POST /api/inquire/downloadreturns401andauthentication_required. With the mode off, the page redirects to the recordings index as it does now (src/api/inquire.py:55-57), and the route returns403andinquire_disabled.POST /recording/<id>/download/chatstill returns the same status codes. The file still has the recording title in the heading,Chat Export Datein the metadata, and[Model Reasoning]when the assistant turn carriesthinking.POST /api/v1/inquireis unchanged.POST /api/inquire/chatstill returns the SSE stream.POST /api/inquire/sessionsstill stores filters only. AnInquireSessionrow is not created by a download.inquire.docx.Root Cause Analysis
The Inquire conversation is browser state.
sendInquireChatMessage(templates/inquire.html:1000-1048) posts the new question and a sliced history to/api/inquire/chat, then appends deltas toinquireChatMessages. The only persistence beside that array ismemoryState, which holds a summary of folded turns (templates/inquire.html:788-794) and is sent back asconversation_memoryon the next question (templates/inquire.html:1040). Clear All wipes both (templates/inquire.html:1184-1189). The session model never receives the turns (src/models/inquire.py:16-25).The Word download that already exists is keyed by a recording.
download_chat_wordloads that recording, rejects a caller who cannot read it, and builds the file from the postedmessages(src/api/recordings.py:485-496). An Inquire thread has no recording id. Putting the Inquire file on that route would invent one.inquire_chatis the call that already sees every web Inquire question (src/api/inquire.py:839-860), and it returnstext/event-stream. The download is a sibling route oninquire_bp, the same split the recording page uses between its chat anddownload_chat_word.inquire_v1looks like the other Inquire entry and is not this path. It takesquestion,filters, andhistory, callsinquire_stream, and returns a model answer (src/api/api_v1.py:3243-3255). Its disabled response usesfeature_disabled, notinquire_disabled. A download there would be a new versioned operation and would still not see the on-screen HTML, the agent timeline, or the memory divider.Additional Context
Apply #554 before this request. The Inquire route calls
add_unicode_heading,add_unicode_paragraph, anddocx_attachment_response, which that request adds. This request then addsappend_conversation_turnand switches the recording chat loop over to it.The recording filter markup is commented out (
templates/inquire.html:368-385).selectedRecordingsremains in the client state and is still posted to chat asfilter_recording_ids(templates/inquire.html:1044), but the page has no control that fills it. This request does not uncomment that picker. Arecordingsgroup appears in the file only when the postedfilters.recordingslist has names.InquireSessionhas nofilter_folderscolumn. Folder ids exist only in the page and in the chat request (templates/inquire.html:1042). The download reads the names the page sends. It does not start storing folder filters on the session, and it does not start storing the conversation.The group headings inside the file (
Tags,Folders,Speakers,Recordings,Dates,You,Assistant,Inquire,Export Date) are English, matching the recording chat file. Translating them would make this file differ from that one. The button string is translated.No new setting belongs in
config/env.asr.example. The gate remainsENABLE_INQUIRE_MODE.python-docxis already the librarydownload_chat_wordimports. This request does not add a plain-text, Markdown, or PDF download, a format menu, a versioned API download, or a write of the conversation into the auto-export directory.All reactions