Skip to content

Commit 086c6fe

Browse files
committed
AI chat: Improved algorithm for source references
The new algorithm also finds the reference in the original document if the AI cites text with gaps inside. It is based on a pattern matching algorithm normally used in biology to find partial overlaps in DNA strings. Therefore, the "Bio" package has been added as a new requirement (instead of "fuzzysearch", which was used before).
1 parent 5a931bc commit 086c6fe

3 files changed

Lines changed: 57 additions & 26 deletions

File tree

requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ langchain-openai>=0.1.16
2121
langchain-text-splitters>=0.2.2
2222
faiss-cpu
2323
sentence-transformers
24-
fuzzysearch
24+
Bio
2525
PyYAML
2626
json_repair
2727
pyinstaller<=6.4.0

src/qualcoder/ai_chat.py

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -40,14 +40,13 @@
4040
import sqlite3
4141
import webbrowser
4242
import re
43-
import fuzzysearch
4443

4544
from .ai_search_dialog import DialogAiSearch
4645
from .GUI.ui_ai_chat import Ui_Dialog_ai_chat
4746
from .helpers import Message
4847
from .confirm_delete import DialogConfirmDelete
4948
from .ai_prompts import PromptItem
50-
from .ai_llm import extract_ai_memo
49+
from .ai_llm import extract_ai_memo, ai_quote_search
5150
from .error_dlg import qt_exception_hook
5251
from .html_parser import html_to_text
5352

@@ -691,8 +690,8 @@ def new_text_chat(self, doc_id, doc_name, text, start_pos, prompt: PromptItem):
691690
f'Be sure to include references to the original data, using this format '
692691
'definition: `[REF: "{The text from the original data that you want to reference. '
693692
'I have to match this against the original, so it is very important that you don\'t '
694-
'change the quoted text in any way. Do not translate or correct errors, do not '
695-
'leave parts of the text out. Create a new reference for every single quote.}"]`. \n'
693+
'change the quoted text in any way. Do not translate or correct errors. Create a '
694+
'new reference for every single quote.}"]`. \n'
696695
'These references are invisible text. If you want a direct quote to be '
697696
'visible to the user, include it in the normal text and add an additional reference '
698697
'in the above format.\n'
@@ -856,6 +855,7 @@ def update_chat_window(self, scroll_to_bottom=True):
856855
finally:
857856
if scroll_to_bottom:
858857
self.ai_output_scroll_to_bottom()
858+
self.ui.plainTextEdit_question.setFocus()
859859
else:
860860
self.ui.scrollArea_ai_output.verticalScrollBar().setValue(0)
861861
self.is_updating_chat_window = False
@@ -869,21 +869,21 @@ def replace_references(self, text, streaming=False):
869869
# we are not in text analysis chat
870870
return text
871871

872-
pattern = r'\[REF: ([\"\'“”„‘’«»])(.+?)([\"\'“”„‘’«»])\]'
872+
pattern = r'\[REF: ([\"\'“”„‘’«»])(.+?)([\"\'“”„‘’«»])\]'
873+
fulltext = self.app.get_text_fulltext(self.ai_text_doc_id)
873874

874875
# Replacement function
875876
def replace_match(match):
876877
if streaming:
877878
return f'({self.ai_text_doc_name})'
878879
quote = match.group(2)
879-
# search quote with not more than 10% mismatch (Levenshtein Distance). This is done because the AI sometimes alters the text a little bit.
880-
quote_found = fuzzysearch.find_near_matches(quote, self.ai_text_text,
881-
max_l_dist=round(len(quote) * 0.1)) # result: list [Match(start=x, end=x, dist=x, matched='txt')]
882-
if len(quote_found) > 0:
883-
quote_start = quote_found[0].start + self.ai_text_start_pos
884-
quote = quote_found[0].matched
885-
fulltext = self.app.get_text_fulltext(self.ai_text_doc_id)
886-
line_start, line_end = self.app.get_line_numbers(fulltext, quote_start, quote_start + len(quote))
880+
881+
quote_start, quote_end = ai_quote_search(quote, self.ai_text_text)
882+
if quote_start > -1 < quote_end:
883+
quote = self.ai_text_text[quote_start:quote_end]
884+
quote_start += self.ai_text_start_pos
885+
quote_end += self.ai_text_start_pos
886+
line_start, line_end = self.app.get_line_numbers(fulltext, quote_start, quote_end)
887887
if line_start + line_end > 0:
888888
if line_start == line_end: # one line
889889
a = f'(<a href="quote:{self.ai_text_doc_id}_{quote_start}_{len(quote)}">{self.ai_text_doc_name}: {line_start}</a>)'

src/qualcoder/ai_llm.py

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,10 @@
4242
from .helpers import Message
4343
from .error_dlg import qt_exception_hook
4444
from .html_parser import html_to_text
45-
import fuzzysearch
4645
import json_repair
4746
import asyncio
4847
import configparser
48+
from Bio.Align import PairwiseAligner
4949

5050
max_memo_length = 1500 # Maximum length of the memo send to the AI
5151

@@ -236,6 +236,42 @@ def update_ai_models(current_models: list, current_model_index: int) -> tuple[li
236236
current_models.append(model)
237237
return current_models, current_model_index
238238

239+
def ai_quote_search(quote: str, original: str) -> tuple[int, int]:
240+
"""Searches the quote in the original text using the Smith-Waterman algorithm.
241+
This also tolerates gaps up to complete sentences in the cited text or other
242+
minor differences in the exact wording.
243+
The "PairwiseAligner" is normally used to find partial overlaps in DNA-strings.
244+
Returns -1, -1 if no match is found.
245+
"""
246+
247+
aligner = PairwiseAligner()
248+
aligner.mode = 'local'
249+
aligner.match_score = 2 # score for each matched char
250+
aligner.mismatch_score = -1 # penalty for mismatched chars (errors)
251+
aligner.open_gap_score = -0.5 # penalty for opening a gap (left out chars)
252+
aligner.extend_gap_score = -0.1 # penalty for gap continuation
253+
254+
alignments = aligner.align(original.lower(), quote.lower())
255+
if not len(alignments): # nothing found
256+
return -1, -1
257+
258+
best = alignments[0]
259+
orig_spans = best.aligned[0]
260+
if not len(orig_spans):
261+
return -1, -1
262+
263+
# combine all matched blocks from the first start to the last end:
264+
start_idx = orig_spans[0][0]
265+
end_idx = orig_spans[-1][1]
266+
267+
# only accept a match if it reaches 80% of the max score -> prevents false positives
268+
max_score = len(quote) * aligner.match_score
269+
score_fraction = best.score / max_score
270+
if score_fraction > 0.8:
271+
return start_idx, end_idx
272+
else:
273+
return -1, -1
274+
239275
class AiLLM():
240276
""" This manages the communication between qualcoder, the vectorstore
241277
and the LLM (large language model, e.g. GPT-4)."""
@@ -813,19 +849,14 @@ def _search_analyze_chunk(self, chunk, code_name, code_memo, search_prompt: Prom
813849
'quote' in res_json and res_json['quote'] != '': # found something
814850
# Adjust quote_start
815851
doc = {}
816-
doc['metadata'] = chunk.metadata
817-
818-
# search quote with not more than 30% mismatch (Levenshtein Distance).
819-
# This is done because the AI sometimes alters the text a little bit.
820-
quote_found = fuzzysearch.find_near_matches(res_json['quote'], chunk.page_content,
821-
max_l_dist=round(len(res_json['quote']) * 0.3)) # result: list [Match(start=x, end=x, dist=x, matched='txt')]
822-
if len(quote_found) > 0:
823-
doc['quote_start'] = quote_found[0].start + doc['metadata']['start_index']
824-
doc['quote'] = quote_found[0].matched
852+
doc['metadata'] = chunk.metadata
853+
quote_start, quote_end = ai_quote_search(res_json['quote'], chunk.page_content)
854+
if quote_start > -1 < quote_end:
855+
doc['quote_start'] = quote_start + doc['metadata']['start_index']
856+
doc['quote'] = chunk.page_content[quote_start:quote_end]
825857
else: # quote not found, make the whole chunk the quote
826858
doc['quote_start'] = doc['metadata']['start_index']
827-
doc['quote'] = chunk.page_content
828-
859+
doc['quote'] = chunk.page_content
829860
doc['interpretation'] = res_json['interpretation']
830861
else: # No quote means the AI discarded this chunk as not relevant
831862
doc = None

0 commit comments

Comments
 (0)