mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-10-30 03:56:23 -05:00 
			
		
		
		
	more like this searching
This commit is contained in:
		| @@ -24,6 +24,12 @@ | |||||||
|  |  | ||||||
|     </div> |     </div> | ||||||
|  |  | ||||||
|  |     <button type="button" class="btn btn-sm btn-outline-primary mr-2" (click)="moreLike()"> | ||||||
|  |         <svg class="buttonicon" fill="currentColor"> | ||||||
|  |             <use xlink:href="assets/bootstrap-icons.svg#three-dots" /> | ||||||
|  |         </svg> | ||||||
|  |         <span class="d-none d-lg-inline"> More like this</span> | ||||||
|  |     </button> | ||||||
|  |  | ||||||
|     <button type="button" class="btn btn-sm btn-outline-primary" (click)="close()"> |     <button type="button" class="btn btn-sm btn-outline-primary" (click)="close()"> | ||||||
|         <svg class="buttonicon" fill="currentColor"> |         <svg class="buttonicon" fill="currentColor"> | ||||||
|   | |||||||
| @@ -168,6 +168,10 @@ export class DocumentDetailComponent implements OnInit { | |||||||
|  |  | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   moreLike() { | ||||||
|  |     this.router.navigate(["search"], {queryParams: {more_like:this.document.id}}) | ||||||
|  |   } | ||||||
|  |  | ||||||
|   hasNext() { |   hasNext() { | ||||||
|     return this.documentListViewService.hasNext(this.documentId) |     return this.documentListViewService.hasNext(this.documentId) | ||||||
|   } |   } | ||||||
|   | |||||||
| @@ -25,6 +25,12 @@ | |||||||
|  |  | ||||||
|         <div class="d-flex justify-content-between align-items-center"> |         <div class="d-flex justify-content-between align-items-center"> | ||||||
|           <div class="btn-group"> |           <div class="btn-group"> | ||||||
|  |             <a routerLink="/search" [queryParams]="{'more_like': document.id}" class="btn btn-sm btn-outline-secondary"> | ||||||
|  |               <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-three-dots" viewBox="0 0 16 16"> | ||||||
|  |                 <path fill-rule="evenodd" d="M3 9.5a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3z"/> | ||||||
|  |               </svg> | ||||||
|  |               More like this | ||||||
|  |             </a> | ||||||
|             <a routerLink="/documents/{{document.id}}" class="btn btn-sm btn-outline-secondary"> |             <a routerLink="/documents/{{document.id}}" class="btn btn-sm btn-outline-secondary"> | ||||||
|               <svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-pencil" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> |               <svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-pencil" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> | ||||||
|                 <path fill-rule="evenodd" d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5L13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175l-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/> |                 <path fill-rule="evenodd" d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5L13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175l-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/> | ||||||
| @@ -45,10 +51,13 @@ | |||||||
|               </svg> |               </svg> | ||||||
|               Download |               Download | ||||||
|             </a> |             </a> | ||||||
|  |             <ngb-progressbar [type]="searchScoreClass" [value]="searchScore" style="width: 100px; height: 5px; margin: 10px;" [max]="1"></ngb-progressbar> | ||||||
|  |              | ||||||
|           </div> |           </div> | ||||||
|  |            | ||||||
|           <small class="text-muted">Created: {{document.created | date}}</small> |           <small class="text-muted">Created: {{document.created | date}}</small> | ||||||
|         </div> |         </div> | ||||||
|  |          | ||||||
|       </div> |       </div> | ||||||
|     </div> |     </div> | ||||||
|   </div> |   </div> | ||||||
|   | |||||||
| @@ -24,6 +24,19 @@ export class DocumentCardLargeComponent implements OnInit { | |||||||
|   @Output() |   @Output() | ||||||
|   clickCorrespondent = new EventEmitter<number>() |   clickCorrespondent = new EventEmitter<number>() | ||||||
|  |  | ||||||
|  |   @Input() | ||||||
|  |   searchScore: number | ||||||
|  |  | ||||||
|  |   get searchScoreClass() { | ||||||
|  |     if (this.searchScore > 0.7) { | ||||||
|  |       return "success" | ||||||
|  |     } else if (this.searchScore > 0.3) { | ||||||
|  |       return "warning" | ||||||
|  |     } else { | ||||||
|  |       return "danger" | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|   ngOnInit(): void { |   ngOnInit(): void { | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,4 +1,4 @@ | |||||||
| .match { | .match { | ||||||
|     color: black; |     color: black; | ||||||
|     background-color: orange; |     background-color: rgb(255, 211, 66); | ||||||
| } | } | ||||||
| @@ -3,7 +3,12 @@ | |||||||
|  |  | ||||||
| <div *ngIf="errorMessage" class="alert alert-danger">Invalid search query: {{errorMessage}}</div> | <div *ngIf="errorMessage" class="alert alert-danger">Invalid search query: {{errorMessage}}</div> | ||||||
|  |  | ||||||
| <p> | <p *ngIf="more_like"> | ||||||
|  |     Showing documents similar to | ||||||
|  |     <a routerLink="/documents/{{more_like}}">{{more_like_doc?.original_file_name}}</a> | ||||||
|  | </p> | ||||||
|  |  | ||||||
|  | <p *ngIf="query"> | ||||||
|     Search string: <i>{{query}}</i> |     Search string: <i>{{query}}</i> | ||||||
|     <ng-container *ngIf="correctedQuery"> |     <ng-container *ngIf="correctedQuery"> | ||||||
|         - Did you mean "<a [routerLink]="" (click)="searchCorrectedQuery()">{{correctedQuery}}</a>"? |         - Did you mean "<a [routerLink]="" (click)="searchCorrectedQuery()">{{correctedQuery}}</a>"? | ||||||
| @@ -15,7 +20,8 @@ | |||||||
|     <p>{{resultCount}} result(s)</p> |     <p>{{resultCount}} result(s)</p> | ||||||
|     <app-document-card-large *ngFor="let result of results" |     <app-document-card-large *ngFor="let result of results" | ||||||
|         [document]="result.document" |         [document]="result.document" | ||||||
|         [details]="result.highlights"> |         [details]="result.highlights" | ||||||
|  |         [searchScore]="result.score / maxScore"> | ||||||
|  |  | ||||||
| </app-document-card-large> | </app-document-card-large> | ||||||
| </div> | </div> | ||||||
|   | |||||||
| @@ -1,6 +1,9 @@ | |||||||
| import { Component, OnInit } from '@angular/core'; | import { Component, OnInit } from '@angular/core'; | ||||||
| import { ActivatedRoute, Router } from '@angular/router'; | import { ActivatedRoute, Router } from '@angular/router'; | ||||||
|  | import { PaperlessDocument } from 'src/app/data/paperless-document'; | ||||||
|  | import { PaperlessDocumentType } from 'src/app/data/paperless-document-type'; | ||||||
| import { SearchHit } from 'src/app/data/search-result'; | import { SearchHit } from 'src/app/data/search-result'; | ||||||
|  | import { DocumentService } from 'src/app/services/rest/document.service'; | ||||||
| import { SearchService } from 'src/app/services/rest/search.service'; | import { SearchService } from 'src/app/services/rest/search.service'; | ||||||
|  |  | ||||||
| @Component({ | @Component({ | ||||||
| @@ -14,6 +17,10 @@ export class SearchComponent implements OnInit { | |||||||
|  |  | ||||||
|   query: string = "" |   query: string = "" | ||||||
|  |  | ||||||
|  |   more_like: number | ||||||
|  |  | ||||||
|  |   more_like_doc: PaperlessDocument | ||||||
|  |  | ||||||
|   searching = false |   searching = false | ||||||
|  |  | ||||||
|   currentPage = 1 |   currentPage = 1 | ||||||
| @@ -26,11 +33,23 @@ export class SearchComponent implements OnInit { | |||||||
|  |  | ||||||
|   errorMessage: string |   errorMessage: string | ||||||
|  |  | ||||||
|   constructor(private searchService: SearchService, private route: ActivatedRoute, private router: Router) { } |   get maxScore() { | ||||||
|  |     return this.results?.length > 0 ? this.results[0].score : 100 | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   constructor(private searchService: SearchService, private route: ActivatedRoute, private router: Router, private documentService: DocumentService) { } | ||||||
|  |  | ||||||
|   ngOnInit(): void { |   ngOnInit(): void { | ||||||
|     this.route.queryParamMap.subscribe(paramMap => { |     this.route.queryParamMap.subscribe(paramMap => { | ||||||
|       this.query = paramMap.get('query') |       this.query = paramMap.get('query') | ||||||
|  |       this.more_like = paramMap.has('more_like') ? +paramMap.get('more_like') : null | ||||||
|  |       if (this.more_like) { | ||||||
|  |         this.documentService.get(this.more_like).subscribe(r => { | ||||||
|  |           this.more_like_doc = r | ||||||
|  |         }) | ||||||
|  |       } else { | ||||||
|  |         this.more_like_doc = null | ||||||
|  |       } | ||||||
|       this.searching = true |       this.searching = true | ||||||
|       this.currentPage = 1 |       this.currentPage = 1 | ||||||
|       this.loadPage() |       this.loadPage() | ||||||
| @@ -39,13 +58,14 @@ export class SearchComponent implements OnInit { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   searchCorrectedQuery() { |   searchCorrectedQuery() { | ||||||
|     this.router.navigate(["search"], {queryParams: {query: this.correctedQuery}}) |     this.router.navigate(["search"], {queryParams: {query: this.correctedQuery, more_like: this.more_like}}) | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   loadPage(append: boolean = false) { |   loadPage(append: boolean = false) { | ||||||
|     this.errorMessage = null |     this.errorMessage = null | ||||||
|     this.correctedQuery = null |     this.correctedQuery = null | ||||||
|     this.searchService.search(this.query, this.currentPage).subscribe(result => { |  | ||||||
|  |     this.searchService.search(this.query, this.currentPage, this.more_like).subscribe(result => { | ||||||
|       if (append) { |       if (append) { | ||||||
|         this.results.push(...result.results) |         this.results.push(...result.results) | ||||||
|       } else { |       } else { | ||||||
|   | |||||||
| @@ -15,11 +15,17 @@ export class SearchService { | |||||||
|    |    | ||||||
|   constructor(private http: HttpClient, private documentService: DocumentService) { } |   constructor(private http: HttpClient, private documentService: DocumentService) { } | ||||||
|  |  | ||||||
|   search(query: string, page?: number): Observable<SearchResult> { |   search(query: string, page?: number, more_like?: number): Observable<SearchResult> { | ||||||
|     let httpParams = new HttpParams().set('query', query) |     let httpParams = new HttpParams() | ||||||
|  |     if (query) { | ||||||
|  |       httpParams = httpParams.set('query', query) | ||||||
|  |     } | ||||||
|     if (page) { |     if (page) { | ||||||
|       httpParams = httpParams.set('page', page.toString()) |       httpParams = httpParams.set('page', page.toString()) | ||||||
|     } |     } | ||||||
|  |     if (more_like) { | ||||||
|  |       httpParams = httpParams.set('more_like', more_like.toString()) | ||||||
|  |     } | ||||||
|     return this.http.get<SearchResult>(`${environment.apiBaseUrl}search/`, {params: httpParams}).pipe( |     return this.http.get<SearchResult>(`${environment.apiBaseUrl}search/`, {params: httpParams}).pipe( | ||||||
|       map(result => { |       map(result => { | ||||||
|         result.results.forEach(hit => this.documentService.addObservablesToDocument(hit.document)) |         result.results.forEach(hit => this.documentService.addObservablesToDocument(hit.document)) | ||||||
|   | |||||||
| @@ -3,7 +3,7 @@ import os | |||||||
| from contextlib import contextmanager | from contextlib import contextmanager | ||||||
|  |  | ||||||
| from django.conf import settings | from django.conf import settings | ||||||
| from whoosh import highlight | from whoosh import highlight, classify, query | ||||||
| from whoosh.fields import Schema, TEXT, NUMERIC, KEYWORD, DATETIME | from whoosh.fields import Schema, TEXT, NUMERIC, KEYWORD, DATETIME | ||||||
| from whoosh.highlight import Formatter, get_text | from whoosh.highlight import Formatter, get_text | ||||||
| from whoosh.index import create_in, exists_in, open_dir | from whoosh.index import create_in, exists_in, open_dir | ||||||
| @@ -120,22 +120,39 @@ def remove_document_from_index(document): | |||||||
|  |  | ||||||
|  |  | ||||||
| @contextmanager | @contextmanager | ||||||
| def query_page(ix, querystring, page): | def query_page(ix, page, querystring, more_like_doc_id, more_like_doc_content): | ||||||
|     searcher = ix.searcher() |     searcher = ix.searcher() | ||||||
|     try: |     try: | ||||||
|         qp = MultifieldParser( |         if querystring: | ||||||
|             ["content", "title", "correspondent", "tag", "type"], |             qp = MultifieldParser( | ||||||
|             ix.schema) |                 ["content", "title", "correspondent", "tag", "type"], | ||||||
|         qp.add_plugin(DateParserPlugin()) |                 ix.schema) | ||||||
|  |             qp.add_plugin(DateParserPlugin()) | ||||||
|  |             str_q = qp.parse(querystring) | ||||||
|  |             corrected = searcher.correct_query(str_q, querystring) | ||||||
|  |         else: | ||||||
|  |             str_q = None | ||||||
|  |             corrected = None | ||||||
|  |  | ||||||
|  |         if more_like_doc_id: | ||||||
|  |             docnum = searcher.document_number(id=more_like_doc_id) | ||||||
|  |             kts = searcher.key_terms_from_text('content', more_like_doc_content, numterms=20, | ||||||
|  |                                            model=classify.Bo1Model, normalize=False) | ||||||
|  |             more_like_q = query.Or([query.Term('content', word, boost=weight) | ||||||
|  |                           for word, weight in kts]) | ||||||
|  |             result_page = searcher.search_page(more_like_q, page, filter=str_q, mask={docnum}) | ||||||
|  |         elif str_q: | ||||||
|  |             result_page = searcher.search_page(str_q, page) | ||||||
|  |         else: | ||||||
|  |             raise ValueError( | ||||||
|  |                 "Either querystring or more_like_doc_id is required." | ||||||
|  |             ) | ||||||
|  |  | ||||||
|         q = qp.parse(querystring) |  | ||||||
|         result_page = searcher.search_page(q, page) |  | ||||||
|         result_page.results.fragmenter = highlight.ContextFragmenter( |         result_page.results.fragmenter = highlight.ContextFragmenter( | ||||||
|             surround=50) |             surround=50) | ||||||
|         result_page.results.formatter = JsonFormatter() |         result_page.results.formatter = JsonFormatter() | ||||||
|  |  | ||||||
|         corrected = searcher.correct_query(q, querystring) |         if corrected and corrected.query != str_q: | ||||||
|         if corrected.query != q: |  | ||||||
|             corrected_query = corrected.string |             corrected_query = corrected.string | ||||||
|         else: |         else: | ||||||
|             corrected_query = None |             corrected_query = None | ||||||
|   | |||||||
| @@ -335,14 +335,19 @@ class SearchView(APIView): | |||||||
|                 } |                 } | ||||||
|  |  | ||||||
|     def get(self, request, format=None): |     def get(self, request, format=None): | ||||||
|         if 'query' not in request.query_params: |  | ||||||
|             return Response({ |  | ||||||
|                 'count': 0, |  | ||||||
|                 'page': 0, |  | ||||||
|                 'page_count': 0, |  | ||||||
|                 'results': []}) |  | ||||||
|  |  | ||||||
|         query = request.query_params['query'] |         if 'query' in request.query_params: | ||||||
|  |             query = request.query_params['query'] | ||||||
|  |         else: | ||||||
|  |             query = None | ||||||
|  |  | ||||||
|  |         if 'more_like' in request.query_params: | ||||||
|  |             more_like_id = request.query_params['more_like'] | ||||||
|  |             more_like_content = Document.objects.get(id=more_like_id).content | ||||||
|  |         else: | ||||||
|  |             more_like_id = None | ||||||
|  |             more_like_content = None | ||||||
|  |  | ||||||
|         try: |         try: | ||||||
|             page = int(request.query_params.get('page', 1)) |             page = int(request.query_params.get('page', 1)) | ||||||
|         except (ValueError, TypeError): |         except (ValueError, TypeError): | ||||||
| @@ -352,7 +357,7 @@ class SearchView(APIView): | |||||||
|             page = 1 |             page = 1 | ||||||
|  |  | ||||||
|         try: |         try: | ||||||
|             with index.query_page(self.ix, query, page) as (result_page, |             with index.query_page(self.ix, page, query, more_like_id, more_like_content) as (result_page, | ||||||
|                                                             corrected_query): |                                                             corrected_query): | ||||||
|                 return Response( |                 return Response( | ||||||
|                     {'count': len(result_page), |                     {'count': len(result_page), | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user
	 jonaswinkler
					jonaswinkler