|
42 | 42 | from .helpers import Message |
43 | 43 | from .error_dlg import qt_exception_hook |
44 | 44 | from .html_parser import html_to_text |
45 | | -import fuzzysearch |
46 | 45 | import json_repair |
47 | 46 | import asyncio |
48 | 47 | import configparser |
| 48 | +from Bio.Align import PairwiseAligner |
49 | 49 |
|
50 | 50 | max_memo_length = 1500 # Maximum length of the memo send to the AI |
51 | 51 |
|
@@ -236,6 +236,42 @@ def update_ai_models(current_models: list, current_model_index: int) -> tuple[li |
236 | 236 | current_models.append(model) |
237 | 237 | return current_models, current_model_index |
238 | 238 |
|
| 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 | + |
239 | 275 | class AiLLM(): |
240 | 276 | """ This manages the communication between qualcoder, the vectorstore |
241 | 277 | 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 |
813 | 849 | 'quote' in res_json and res_json['quote'] != '': # found something |
814 | 850 | # Adjust quote_start |
815 | 851 | 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] |
825 | 857 | else: # quote not found, make the whole chunk the quote |
826 | 858 | doc['quote_start'] = doc['metadata']['start_index'] |
827 | | - doc['quote'] = chunk.page_content |
828 | | - |
| 859 | + doc['quote'] = chunk.page_content |
829 | 860 | doc['interpretation'] = res_json['interpretation'] |
830 | 861 | else: # No quote means the AI discarded this chunk as not relevant |
831 | 862 | doc = None |
|
0 commit comments