chATLAS_Frontend 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. chATLAS_Frontend/__init__.py +0 -0
  2. chATLAS_Frontend/frontend/__init__.py +0 -0
  3. chATLAS_Frontend/frontend/auth.py +38 -0
  4. chATLAS_Frontend/frontend/chat_processing.py +63 -0
  5. chATLAS_Frontend/frontend/chat_request.py +278 -0
  6. chATLAS_Frontend/frontend/html_generation.py +361 -0
  7. chATLAS_Frontend/frontend/logging.py +387 -0
  8. chATLAS_Frontend/frontend/processing.py +254 -0
  9. chATLAS_Frontend/frontend/resources.py +126 -0
  10. chATLAS_Frontend/frontend/routes.py +250 -0
  11. chATLAS_Frontend/frontend/settings.py +177 -0
  12. chATLAS_Frontend/frontend/utils.py +11 -0
  13. chATLAS_Frontend/launch.py +73 -0
  14. chATLAS_Frontend/static/css/markdown_styling.css +147 -0
  15. chATLAS_Frontend/static/css/min.extra.css +3899 -0
  16. chATLAS_Frontend/static/css/styles.css +956 -0
  17. chATLAS_Frontend/static/css/styles_backup.css +289 -0
  18. chATLAS_Frontend/static/images/favicon.ico +0 -0
  19. chATLAS_Frontend/static/js/app/api.js +109 -0
  20. chATLAS_Frontend/static/js/app/bootstrap.js +162 -0
  21. chATLAS_Frontend/static/js/app/state.js +29 -0
  22. chATLAS_Frontend/static/js/components/chatThread.js +377 -0
  23. chATLAS_Frontend/static/js/components/composer.js +43 -0
  24. chATLAS_Frontend/static/js/components/feedback.js +31 -0
  25. chATLAS_Frontend/static/js/components/filtersPanel.js +95 -0
  26. chATLAS_Frontend/static/js/components/modeToggle.js +21 -0
  27. chATLAS_Frontend/static/js/components/settingsModal.js +48 -0
  28. chATLAS_Frontend/static/js/components/sourcePanel.js +141 -0
  29. chATLAS_Frontend/static/js/main.js +7 -0
  30. chATLAS_Frontend/static/js/vendor/marked.min.js +6 -0
  31. chATLAS_Frontend/templates/page/base.html +32 -0
  32. chATLAS_Frontend/templates/page/categories/atlas_talk.html +21 -0
  33. chATLAS_Frontend/templates/page/categories/cds.html +3 -0
  34. chATLAS_Frontend/templates/page/categories/gitlab_markdown.html +43 -0
  35. chATLAS_Frontend/templates/page/categories/indico.html +8 -0
  36. chATLAS_Frontend/templates/page/categories/twiki.html +54 -0
  37. chATLAS_Frontend/templates/page/filters.html +65 -0
  38. chATLAS_Frontend/templates/page/footer.html +13 -0
  39. chATLAS_Frontend/templates/page/header.html +93 -0
  40. chATLAS_Frontend/templates/page/index.html +40 -0
  41. chATLAS_Frontend/templates/page/searchbar.html +22 -0
  42. chATLAS_Frontend/templates/response/all_sources.html +10 -0
  43. chATLAS_Frontend/templates/response/bot_response.html +7 -0
  44. chATLAS_Frontend/templates/response/citations_block.html +4 -0
  45. chATLAS_Frontend/templates/response/error_box.html +11 -0
  46. chATLAS_Frontend/templates/response/feedback_buttons.html +10 -0
  47. chATLAS_Frontend/templates/response/macros.html +9 -0
  48. chATLAS_Frontend/templates/response/source_display_button.html +22 -0
  49. chATLAS_Frontend/templates/response/source_similarity.html +6 -0
  50. chATLAS_Frontend/templates/response/used_docs.html +8 -0
  51. chATLAS_Frontend/templates/response/user_query.html +17 -0
  52. chATLAS_Frontend/usage/__init__.py +0 -0
  53. chATLAS_Frontend/usage/db_utils.py +85 -0
  54. chATLAS_Frontend/usage/execute_query.py +123 -0
  55. chATLAS_Frontend/usage/plots.py +45 -0
  56. chATLAS_Frontend/usage/queries.py +46 -0
  57. chatlas_frontend-1.0.0.dist-info/METADATA +117 -0
  58. chatlas_frontend-1.0.0.dist-info/RECORD +61 -0
  59. chatlas_frontend-1.0.0.dist-info/WHEEL +5 -0
  60. chatlas_frontend-1.0.0.dist-info/licenses/LICENSE +201 -0
  61. chatlas_frontend-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,361 @@
1
+ import ast
2
+ import json
3
+ import logging
4
+ import os
5
+ import re
6
+
7
+ import markdown
8
+ from flask import render_template
9
+
10
+ from chATLAS_Frontend.frontend.resources import get_db_logger
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ def get_file_name(file_name, CAPTION_SWITCH):
16
+ if ":" in file_name:
17
+ return file_name, None
18
+
19
+ name_out = os.path.basename(file_name).rsplit(".", 1)[0]
20
+ caption_out = None
21
+
22
+ return name_out, caption_out
23
+
24
+
25
+ def generate_full_response(user_input, sources, answer, sources_used_by_model, assistant_mode=True):
26
+ """
27
+ Render all html for the response. Some pieces are not rendered when assistant_mode is False.
28
+
29
+ In assistant mode, citations (used docs + all sources) are placed in a hidden block
30
+ and a "Citations" button opens them in the right sidebar. In search mode, sources
31
+ are rendered inline.
32
+
33
+ Returns:
34
+ dict with keys:
35
+ - 'bot_html': the rendered bot reply fragment
36
+ - 'citations_html': the citations block HTML (assistant mode) or sources HTML (search mode)
37
+ """
38
+ _user_display, bot_display = generate_chat(answer, user_input, len(sources), assistant_mode=assistant_mode)
39
+
40
+ # Raw sources list (no show/hide button wrapper)
41
+ sources_display_raw = generate_sources_display(sources, assistant_mode=False)
42
+
43
+ if assistant_mode:
44
+ used_sources_html = generate_used_docs_display(sources, sources_used_by_model)
45
+ citations_content = used_sources_html + sources_display_raw
46
+ citations_block_html = render_template("response/citations_block.html", citations_content=citations_content)
47
+ # citations_block goes inside the bot reply so the "Citations" button
48
+ # lives in the chat row (sourcePanel.js reads it on click)
49
+ full_bot_html = bot_display + citations_block_html
50
+ else:
51
+ citations_content = sources_display_raw
52
+ full_bot_html = bot_display
53
+
54
+ return {"bot_html": full_bot_html, "citations_html": citations_content}
55
+
56
+
57
+ def _used_doc_utility_for_source(doc_utility_map: dict, source_metadata_name: str) -> str | None:
58
+ """
59
+ Return the utility for a retrieved source if it is listed in used_docs (exact or suffix match).
60
+
61
+ Inputs:
62
+ doc_utility_map: Map from document name (as returned by LLM) to utility string.
63
+ source_metadata_name: The 'name' from the retrieved source's metadata.
64
+ Outputs:
65
+ Utility string if the source is in used_docs, else None.
66
+ """
67
+ stripped = (source_metadata_name or "").strip('"').strip("'")
68
+ if not stripped:
69
+ return None
70
+ if stripped in doc_utility_map:
71
+ return doc_utility_map[stripped]
72
+ for key, utility in doc_utility_map.items():
73
+ if key == stripped:
74
+ return utility
75
+ if key.endswith("/" + stripped) or (key and key.rstrip("/").endswith("/" + stripped)):
76
+ return utility
77
+ return None
78
+
79
+
80
+ def generate_used_docs_display(sources, used_docs):
81
+ """
82
+ Generates html to display the sources used by the model in the response.
83
+
84
+ Parameters:
85
+ ----------
86
+ used_docs : list of dict
87
+ List of dictionaries containing document information used by the model.
88
+ Each dictionary has 'name' and 'utility' keys.
89
+
90
+ sources: list Document objects
91
+
92
+ Returns:
93
+ --------
94
+ str
95
+ HTML representation of the used documents
96
+ """
97
+ # Enforce that used_docs must be a list
98
+ if not isinstance(used_docs, list):
99
+ raise TypeError(f"used_docs must be a list of dictionaries, not {type(used_docs)}")
100
+
101
+ if not used_docs:
102
+ # If there are no used docs, render a template with empty data
103
+ return render_template("response/used_docs.html", has_sources=False)
104
+
105
+ # Get source ids for feedback buttons and sources data
106
+ db_logger = get_db_logger()
107
+ source_ids = db_logger.get_most_recent_source_ids()
108
+
109
+ # Create a mapping of document names to their utility values (exact keys from LLM)
110
+ doc_utility_map = {}
111
+ for doc in used_docs:
112
+ if isinstance(doc, dict) and "name" in doc and "utility" in doc:
113
+ name = doc["name"]
114
+ if isinstance(name, str):
115
+ doc_utility_map[name.strip('"').strip("'")] = doc["utility"]
116
+ else:
117
+ doc_utility_map[str(name).strip('"').strip("'")] = doc["utility"]
118
+
119
+ # Prepare the source data: include a source if it matches used_docs (exact or suffix match)
120
+ source_entries = []
121
+ for source in sources:
122
+ source_name = source.metadata.get("name", "Unknown")
123
+ utility = _used_doc_utility_for_source(doc_utility_map, source_name)
124
+ if utility is None:
125
+ continue
126
+ score_metric = "rrf_score" if "rrf_score" in source.metadata else "similarity"
127
+ source_data = extract_source_data(source, source_ids, score_metric=score_metric)
128
+ source_data["utility"] = utility
129
+ source_entries.append(source_data)
130
+
131
+ # Render the template with the prepared data
132
+ return render_template("response/used_docs.html", has_sources=True, sources=source_entries)
133
+
134
+
135
+ def extract_source_data(source, source_ids, score_metric="similarity"):
136
+ """Format the source data to a dictionary for html generation"""
137
+
138
+ importance_thresholds = {"similarity": 0.5, "rrf_score": 0.015}
139
+
140
+ if score_metric not in importance_thresholds:
141
+ raise ValueError(f"Unknown score metric: {score_metric}")
142
+
143
+ source_name = source.metadata.get("name", "Unknown")
144
+ source_name_key = source.metadata.get("name")
145
+ source_id = source_ids.get(source_name_key)
146
+ score = round(source.metadata.get(score_metric, 0), 3)
147
+ url = source.metadata.get("url", "#")
148
+ is_important = score > importance_thresholds[score_metric]
149
+ NAME_OF_SOURCE, summary = get_file_name(source_name, True)
150
+
151
+ return {
152
+ "url": url,
153
+ "display_name": NAME_OF_SOURCE,
154
+ "summary": summary,
155
+ "similarity_score": score,
156
+ "similarity_formatted": f"{score:.3f}",
157
+ "is_important": is_important,
158
+ # Add variables needed by feedback_buttons.html
159
+ "button_type": "source",
160
+ "feedback_id": source_id,
161
+ }
162
+
163
+
164
+ def generate_sources_display(sources, assistant_mode=True):
165
+ """
166
+ Generates html for the used sources, grouped by Type and SearchType
167
+ with scrollable container.
168
+
169
+ In assistant mode, the sources are wrapped in a button to show/hide them.
170
+ """
171
+
172
+ if not sources:
173
+ all_sources_html = render_template("response/all_sources.html", all_sources_data=[])
174
+ return (
175
+ render_template("response/source_display_button.html", sources_display=all_sources_html)
176
+ if assistant_mode
177
+ else all_sources_html
178
+ )
179
+
180
+ # Get the ids of the sources
181
+ db_logger = get_db_logger()
182
+ source_ids = db_logger.get_most_recent_source_ids()
183
+
184
+ if not source_ids:
185
+ source_ids = {}
186
+
187
+ # Group sources by type (twiki, indico, CDS ...) and search type (vector, text)
188
+ grouped_by_source_and_search_type = {}
189
+
190
+ if "rrf_score" in sources[0].metadata:
191
+ score_metric = "rrf_score"
192
+ for source in sources:
193
+ source.metadata["search_type"] = "RRF"
194
+
195
+ else:
196
+ score_metric = "similarity"
197
+
198
+ for source in sources:
199
+ source_type = source.metadata.get("source", "Unknown Type")
200
+ search_type = source.metadata.get("search_type", "Unknown Search")
201
+ grouped_by_source_and_search_type.setdefault(source_type, {}).setdefault(search_type, []).append(source)
202
+
203
+ # Prepare data structure for template context
204
+ all_sources_data = []
205
+
206
+ # For each source type (twiki, indico, CDS ...)
207
+ for source_type, sources_by_search_type in grouped_by_source_and_search_type.items():
208
+ search_types_data = []
209
+
210
+ # For each search type (vector, text)
211
+ for search_type, sources_list in sources_by_search_type.items():
212
+ source_entries = []
213
+
214
+ # For each individual source
215
+ for source in sources_list:
216
+ source_data = extract_source_data(source, source_ids, score_metric=score_metric)
217
+
218
+ source_entries.append(source_data)
219
+
220
+ # Add this search type's data
221
+ search_types_data.append({"search_type": search_type, "sources": source_entries})
222
+
223
+ # Add this source type's data
224
+ all_sources_data.append({"source_type": source_type, "search_types": search_types_data})
225
+
226
+ # Render everything at once
227
+ all_sources_html = render_template("response/all_sources.html", all_sources_data=all_sources_data)
228
+
229
+ # wrap source display behind a "Show Full Sources" button
230
+ if assistant_mode:
231
+ all_sources_html = render_template("response/source_display_button.html", sources_display=all_sources_html)
232
+
233
+ return all_sources_html
234
+
235
+
236
+ def fix_markdown_links(text):
237
+ """
238
+ Converts links from [[url][label]] format to standard [label](url) Markdown,
239
+ but only if the url looks like a valid web address.
240
+ """
241
+ pattern = r"\[\[(https?://[^\[\]]+|www\.[^\[\]]+)\]\[([^\[\]]+)\]\]"
242
+ return re.sub(pattern, r"[\2](\1)", text)
243
+
244
+
245
+ def parse_answer(chatbot_answer):
246
+ """
247
+ Parse the LLM output to extract the actual answer and JSON.
248
+ """
249
+ # Clean up the input from markdown/code block markers
250
+ cleaned_answer = chatbot_answer.strip()
251
+ cleaned_answer = cleaned_answer.removeprefix("```json")
252
+ cleaned_answer = cleaned_answer.removeprefix("```")
253
+ cleaned_answer = cleaned_answer.removesuffix("```")
254
+
255
+ try:
256
+ # Handle cases where JSON is escaped
257
+ if cleaned_answer.startswith("{") and '"' in cleaned_answer:
258
+ cleaned_answer = json.loads(cleaned_answer)
259
+ # If cleaned_answer is still a string, try parsing as JSON
260
+ parsed_ans = json.loads(cleaned_answer) if isinstance(cleaned_answer, str) else cleaned_answer
261
+
262
+ answer = parsed_ans.get("text", "")
263
+ # Fix malformed markdown links if there are any
264
+ answer = fix_markdown_links(answer)
265
+
266
+ used_docs = parsed_ans.get("sources", [])
267
+ return answer, used_docs
268
+ except json.JSONDecodeError:
269
+ try:
270
+ # Fallback: attempt parsing using ast.literal_eval
271
+ parsed_ans = ast.literal_eval(cleaned_answer)
272
+ if isinstance(parsed_ans, dict):
273
+ return parsed_ans.get("text", ""), parsed_ans.get("sources", [])
274
+ except (ValueError, SyntaxError):
275
+ pass
276
+ except Exception as e:
277
+ print(f"Parsing error: {e}")
278
+
279
+ # Fallback: return raw text if parsing fails
280
+ logger.warning(
281
+ "parse_answer fallback: returning raw text. chatbot_answer=%r",
282
+ chatbot_answer[:500] if chatbot_answer else chatbot_answer,
283
+ )
284
+ return str(chatbot_answer), []
285
+
286
+
287
+ def post_process_markdown(html_content):
288
+ """
289
+ Clean up markdown-generated HTML to fix:
290
+ - Code blocks with language identifiers
291
+ - Code blocks that don't have <pre> tags
292
+ """
293
+ # Fix code blocks with language identifiers
294
+ html_content = re.sub(r"<code>(\w+)\n", r"<code>", html_content)
295
+
296
+ # Fix code blocks that don't have <pre> tags
297
+ html_content = re.sub(r"<br />\s*<code>", r"<pre><code>", html_content)
298
+ html_content = re.sub(r"</code>\s*<br />", r"</code></pre>", html_content)
299
+
300
+ return html_content
301
+
302
+
303
+ def generate_chat(answer, user_input, num_sources, assistant_mode=True):
304
+ """
305
+ Renders the HTML templates for the bot response and feedback button.
306
+ Returns (user_output, bot_display) — user_output is kept for compatibility
307
+ but is no longer included in the JSON response blob (the client renders the
308
+ user message itself before sending).
309
+ """
310
+ user_output = render_template("response/user_query.html", user_input=user_input, result_count=num_sources)
311
+
312
+ if assistant_mode:
313
+ if answer:
314
+ # --- Preprocess for code blocks ---
315
+ answer = re.sub(r"```(\w*)", r"```\1", answer)
316
+
317
+ code_blocks = re.findall(r"```(?:\w*)\n(.*?)```", answer, re.DOTALL)
318
+ for block in code_blocks:
319
+ lines = block.split("\n")
320
+ non_empty_lines = [line for line in lines if line.strip()]
321
+ if non_empty_lines:
322
+ indentation = min(len(line) - len(line.lstrip()) for line in non_empty_lines if line.strip())
323
+ normalized_block = "\n".join(line[indentation:] if line.strip() else line for line in lines)
324
+ answer = answer.replace(block, normalized_block)
325
+
326
+ # Ensure a blank line before any table
327
+ answer = re.sub(r"(?m)(?<!\|)\n(?=\|)", r"\n\n", answer)
328
+
329
+ # --- Markdown conversion ---
330
+ # Note: nl2br is intentionally excluded — it converts bare newlines to <br>
331
+ # before the list/heading parsers run, which breaks bullet list rendering.
332
+ extensions = [
333
+ "markdown.extensions.fenced_code",
334
+ "markdown.extensions.codehilite",
335
+ "markdown.extensions.tables",
336
+ "markdown.extensions.extra",
337
+ ]
338
+ extension_configs = {
339
+ "markdown.extensions.codehilite": {
340
+ "css_class": "codehilite",
341
+ "linenums": False,
342
+ "guess_lang": False,
343
+ "use_pygments": False,
344
+ },
345
+ "markdown.extensions.fenced_code": {"lang_prefix": ""},
346
+ }
347
+ processed_answer = markdown.markdown(answer, extensions=extensions, extension_configs=extension_configs)
348
+ processed_answer = post_process_markdown(processed_answer)
349
+ else:
350
+ processed_answer = ""
351
+
352
+ bot_display = render_template(
353
+ "response/bot_response.html",
354
+ bot_response=processed_answer,
355
+ button_type="response",
356
+ feedback_id=get_db_logger().get_most_recent_query_id(),
357
+ )
358
+ else:
359
+ bot_display = ""
360
+
361
+ return user_output, bot_display