Coverage for src / crawler / cmds / augment / augment_cmd.py: 20%
107 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-07-15 13:33 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-07-15 13:33 +0000
1import logging
2from concurrent.futures import Future, ThreadPoolExecutor
3from typing import TYPE_CHECKING, Literal, NotRequired, TypedDict
5from history.model_data import HistoryChildDict, MatchingEventResult
6from history.models import HistoryEventStatus
7from history.utils import insert_history_event
8from matching_back.backends import get_backends
9from ptf import model_helpers
10from ptf.cmds.base_cmds import baseCmd
11from ptf.models import Article, Collection
13from crawler.cmds.augment import update_article_with_xarticle
14from crawler.models.source import Source
16if TYPE_CHECKING:
17 from collections.abc import Callable
19 from matching_back.backends import MatchingBackend
20 from ptf.models import ArticleQuerySet
22 class AugmentArticlesParams(TypedDict):
23 source_id: str
24 collection_pid: NotRequired[str]
25 issue_pids: NotRequired[list[str]]
26 update_pdf_link: NotRequired[bool]
28 from typing import NotRequired, TypedDict
30 class MatchingOperationMessage(TypedDict):
31 pid: str
32 zbl_id: "NotRequired[str]"
33 score: "NotRequired[int]"
34 status: HistoryEventStatus
35 message: "NotRequired[str]"
36 status_message: "NotRequired[str]"
39_logger = logging.getLogger(__name__)
42def compile_history_events(event_messages: "list[MatchingOperationMessage]"):
43 children = []
44 for msg in event_messages:
45 resource = model_helpers.get_resource(msg["pid"])
46 if not resource:
47 raise ValueError("Resource not found")
48 data: "HistoryChildDict" = {
49 "resource": resource,
50 "type": "zbl-id",
51 "status": msg.get("status"),
52 }
54 if "zbl_id" in msg and msg["zbl_id"] != "":
55 data["url"] = f"https://zbmath.org/{msg['zbl_id']}"
57 if "score" in msg:
58 data["score"] = msg["score"]
60 if "message" in msg:
61 data["message"] = msg["message"]
63 if "status_message" in msg:
64 data["status_message"] = msg["status_message"]
66 children.append(data)
67 return children
70class AugmentArticlesCmd(baseCmd[None]):
71 """
72 Metadata augment command.
73 Calls the desired Matching Backend defined in ptf-back.
74 """
76 required_params = ["backend", "source_id"]
78 backend: "MatchingBackend"
79 source_id: str
80 collection_pid: str | None = None
81 issue_pids: list[str] = []
82 update_pdf_link: bool = False
83 collection: "Collection | None" = None
84 queryset: "ArticleQuerySet"
85 event_messages: list[dict]
86 callback: "Callable" = lambda _: _
88 def __init__(self, params: "AugmentArticlesParams", backend: Literal["zbmath", "crossred"]):
89 self.backends = get_backends(backend)
90 super().__init__(params)
91 self.event_messages = []
92 self.queryset = Article.objects.prefetch_related("extid_set").all()
94 if self.collection_pid:
95 self.queryset = self.queryset.filter(
96 my_container__my_collection__pid=self.collection_pid
97 )
98 self.collection = Collection.objects.get(pid=self.collection_pid)
100 if self.source_id:
101 source = Source.objects.get(domain=self.source_id)
102 self.queryset = self.queryset.filter(my_container__origin__source=source)
104 if self.issue_pids:
105 self.queryset = self.queryset.filter(my_container__pid__in=self.issue_pids)
107 def internal_do(self) -> None:
108 for self.backend in self.backends:
109 _logger.info(
110 f"Start augment [{self.backend.name}] "
111 f"source={self.source_id} collection={self.collection_pid} "
112 f"({self.queryset.count()} articles)"
113 )
114 promises: "set[Future]" = set()
115 with ThreadPoolExecutor(max_workers=1) as executor:
116 for article in self.queryset.iterator(chunk_size=2000):
117 xarticle, event = self._find_article(article)
119 if event:
120 self.event_messages.append(event)
122 if not xarticle:
123 self.callback()
124 continue
126 # Handle asyncronous results and exceptions as soon as possible
127 completed: "set[Future]" = set()
128 for f in promises:
129 if f.done():
130 completed.add(f)
131 exc = f.exception()
132 if exc:
133 executor.shutdown(wait=False, cancel_futures=True)
134 raise exc
135 promises -= completed
136 # Runs the database insertion in parallel
137 promise = executor.submit(
138 update_article_with_xarticle,
139 article,
140 xarticle,
141 merge_titles=False,
142 update_pdf_link=self.update_pdf_link,
143 )
144 promise.add_done_callback(self.callback)
145 promises.add(promise)
146 _logger.info(f"Augment [{self.backend.name}] over")
148 def _find_article(self, article: Article) -> tuple:
149 """
150 Calls ArticleData from backend.
151 """
152 # With external ID
153 xarticle = self.backend.find_by_extids(article=article)
154 if xarticle:
155 _logger.debug(f"[{self.backend.name}] {article.pid}: found via External ID")
156 return xarticle, self._make_event(article, MatchingEventResult.ALREADY_PRESENT)
158 # Matching fuzzy
159 xarticle = self.backend.find_by_matching(article)
160 if xarticle:
161 _logger.debug(f"[{self.backend.name}] {article.pid}: found via Matching")
162 return xarticle, self._make_event(article, MatchingEventResult.ADDED)
164 _logger.info(
165 f"[{self.backend.name}] {article.pid}: not found with External ID nor Matching"
166 )
167 return None, self._make_event(
168 article, MatchingEventResult.NOT_FOUND, status=HistoryEventStatus.WARNING
169 )
171 def _make_event(
172 self,
173 article: Article,
174 status_message: str,
175 status: HistoryEventStatus = HistoryEventStatus.OK,
176 ) -> dict:
177 return {
178 "pid": article.pid,
179 "status": status,
180 "status_message": status_message,
181 "backend": self.backend.name,
182 }
184 def insert_history_event(self):
185 children = []
186 for msg in self.event_messages:
187 resource = model_helpers.get_resource(msg["pid"])
188 if not resource:
189 raise ValueError(f"Resource not found: {msg['pid']}")
190 data: "HistoryChildDict" = {
191 "resource": resource,
192 "type": msg.get("backend", self.backend.name),
193 "status": msg.get("status"),
194 }
195 if "score" in msg:
196 data["score"] = msg["score"]
197 if "message" in msg:
198 data["message"] = msg["message"]
199 if "status_message" in msg:
200 data["status_message"] = msg["status_message"]
201 children.append(data)
203 insert_history_event(
204 {
205 "pid": f"{self.source_id}_{self.collection_pid or ''}_{self.backend.name}_matching",
206 "col": self.collection,
207 "source": self.source_id,
208 "status": HistoryEventStatus.OK,
209 "type": "matching",
210 "children": children,
211 }
212 )