diff --git a/app/activities/__init__.py b/app/activities/__init__.py index f33c71b..42de0a1 100644 --- a/app/activities/__init__.py +++ b/app/activities/__init__.py @@ -6,11 +6,13 @@ from .check_funding_relevance import check_funding_relevance from .extract_metadata import extract_metadata_with_llm from .extract_pdf_content import extract_pdf_text +from .resolve_metadata import resolve_metadata_suggestions from .update_workflow import update_workflow REGISTERED_ACTIVITIES = [ extract_pdf_text, extract_metadata_with_llm, + resolve_metadata_suggestions, update_workflow, check_funding_relevance, ] @@ -19,6 +21,7 @@ "REGISTERED_ACTIVITIES", "extract_pdf_text", "extract_metadata_with_llm", + "resolve_metadata_suggestions", "update_workflow", "check_funding_relevance", ] diff --git a/app/activities/check_funding_relevance.py b/app/activities/check_funding_relevance.py index 2618fe3..5c634b9 100644 --- a/app/activities/check_funding_relevance.py +++ b/app/activities/check_funding_relevance.py @@ -6,7 +6,7 @@ from pydantic import BaseModel, Field from temporalio import activity -from app.activities._llm import build_agent +from app.agent import build_agent from app.config import get_settings from app.observability import propagate_langfuse_context from app.workflows.specs import WorkflowContext diff --git a/app/activities/extract_metadata.py b/app/activities/extract_metadata.py index 50cb989..f5c1d62 100644 --- a/app/activities/extract_metadata.py +++ b/app/activities/extract_metadata.py @@ -13,10 +13,10 @@ from temporalio import activity from temporalio.common import RetryPolicy -from app.activities._llm import build_agent +from app.agent import build_agent from app.config import get_settings from app.observability import propagate_langfuse_context -from app.schemas.metadata_suggestions import ExtractedMetadata, MetadataSuggestions +from app.schemas.metadata_suggestions import ExtractedMetadata from app.workflows.specs import WorkflowContext EXTRACT_METADATA_RETRY_POLICY = RetryPolicy( @@ -97,15 +97,15 @@ def _clear_absent_fields(output: ExtractedMetadata, text: str) -> None: async def extract_metadata_with_llm( request: ExtractMetadataRequest, context: WorkflowContext, -) -> MetadataSuggestions: - """Generate typed metadata suggestions using an LLM.""" +) -> ExtractedMetadata: + """Extract raw metadata suggestions using an LLM.""" if len(request.text.strip()) < MIN_TEXT_CHARS: # No usable text: skip the LLM entirely rather than let it fabricate. - return MetadataSuggestions(suggestions=[]) + return ExtractedMetadata() agent = build_agent(get_settings().llm, ExtractedMetadata, INSTRUCTIONS) with propagate_langfuse_context(context, trace_name="extract_metadata"): result = await agent.run(request.text) _clear_absent_fields(result.output, request.text) - return result.output.to_suggestions() + return result.output diff --git a/app/activities/extract_pdf_content.py b/app/activities/extract_pdf_content.py index 715f60d..4196176 100644 --- a/app/activities/extract_pdf_content.py +++ b/app/activities/extract_pdf_content.py @@ -2,7 +2,6 @@ # SPDX-License-Identifier: MIT from datetime import timedelta -from urllib.parse import urlparse import httpx from pydantic import BaseModel @@ -10,7 +9,7 @@ from temporalio.common import RetryPolicy from temporalio.exceptions import ApplicationError -from app.config import get_settings +from app.activities.utils import http_verify from app.extractors import get_extractor from app.extractors.errors import InvalidPageSelectionError @@ -38,34 +37,13 @@ class ExtractPdfContentResponse(BaseModel): pages_extracted: list[int] # List of 1-indexed page numbers that were extracted -def _http_verify_for_pdf_url(url: str) -> bool: - settings = get_settings() - - parsed_url = urlparse(url) - hostname = (parsed_url.hostname or "").lower() - - if hostname in ("localhost", "127.0.0.1", "::1") or hostname.endswith(".localhost"): - return False - - allowlist = { - host.strip().lower() - for host in (settings.pdf_http_allowlist or "").split(",") - if host.strip() - } - - if hostname not in allowlist: - raise ValueError(f"PDF downloads from '{hostname}' are not allowed") - - return True - - @activity.defn async def extract_pdf_text( request: ExtractPdfContentRequest, ) -> ExtractPdfContentResponse: """Download PDF from a URL and extract its content using the specified extractor.""" try: - verify = _http_verify_for_pdf_url(request.url) + verify = http_verify(request.url) except ValueError as e: raise ApplicationError( str(e), diff --git a/app/activities/resolve_metadata.py b/app/activities/resolve_metadata.py new file mode 100644 index 0000000..9a292ac --- /dev/null +++ b/app/activities/resolve_metadata.py @@ -0,0 +1,253 @@ +# SPDX-FileCopyrightText: 2026 CERN. +# SPDX-License-Identifier: MIT + +"""Activity that resolves funders, awards, and licenses via Invenio vocabularies.""" + +import asyncio +import json +import logging +from datetime import timedelta + +import httpx +from pydantic import BaseModel, Field +from temporalio import activity +from temporalio.common import RetryPolicy +from temporalio.exceptions import ApplicationError + +from app.activities.utils import http_verify +from app.config import get_settings +from app.schemas.extracted_metadata import ExtractedMetadata, FunderEnum +from app.schemas.metadata_suggestions import ( + FundingSuggestion, + LicenseSuggestion, + MetadataSuggestions, +) +from app.schemas.resolved_fields import ( + ResolvedAward, + ResolvedFunder, + ResolvedFunding, + ResolvedLicense, +) + +logger = logging.getLogger(__name__) + +RESOLVE_METADATA_RETRY_POLICY = RetryPolicy( + initial_interval=timedelta(seconds=2), + backoff_coefficient=2, + maximum_interval=timedelta(seconds=30), + maximum_attempts=3, +) + +_INVENIO_HEADERS = {"Accept": "application/vnd.inveniordm.v1+json"} + +FUNDER_ROR_IDS = { + FunderEnum.NIH: "01cwqze88", + FunderEnum.NSF: "021nxhr62", + FunderEnum.UKRI: "001aqnf71", + FunderEnum.FNS: "00yjd3n13", + FunderEnum.EC: "00k4n6c32", + FunderEnum.FCT: "00snfqn58", + FunderEnum.NWO: "04jsz6e67", + FunderEnum.NHMRC: "011kf5r70", + FunderEnum.ANR: "00rbzpz17", + FunderEnum.ARC: "05mmh0f86", +} + + +class ResolveMetadataRequest(BaseModel): + """Request to resolve and generate metadata suggestions from raw metadata.""" + + metadata: ExtractedMetadata = Field(description="Raw metadata to resolve") + + +def _log_resolve_error( + field: str, instance: str, error: httpx.HTTPError | json.JSONDecodeError +) -> None: + reason = ( + "request failed" + if isinstance(error, httpx.HTTPError) + else "invalid JSON response" + ) + logger.error(f"Error resolving {field} {instance} ({reason})", exc_info=error) + + +async def _resolve_license( + client: httpx.AsyncClient, license_id: str, base_url: str +) -> ResolvedLicense: + try: + response = await client.get( + f"{base_url}/api/vocabularies/licenses/{license_id}", + headers=_INVENIO_HEADERS, + ) + if response.is_success: + data = response.json() + return ResolvedLicense( + id=data["id"], + title=data.get("title_l10n"), + description=data.get("description_l10n"), + link=data.get("props", {}).get("url"), + ) + except (httpx.HTTPError, json.JSONDecodeError) as e: + _log_resolve_error("license", license_id, e) + return ResolvedLicense(id=license_id) + + +async def _resolve_award_by_number( + client: httpx.AsyncClient, + number: str, + funder_id: str | None, + base_url: str, +) -> tuple[None, None] | tuple[ResolvedAward, ResolvedFunder]: + try: + params: dict = {"q": f"number:{number}", "size": 1} + if funder_id: + params["funders"] = funder_id + resp = await client.get( + f"{base_url}/api/awards", + params=params, + headers=_INVENIO_HEADERS, + ) + if not resp.is_success: + return None, None + hits = resp.json().get("hits", {}).get("hits", []) + if not hits: + return None, None + hit = hits[0] + return ResolvedAward( + id=hit.get("id"), + number=hit.get("number"), + title=hit.get("title_l10n"), + ), ResolvedFunder(id=hit["funder"]["id"], name=hit["funder"]["name"]) + except (httpx.HTTPError, json.JSONDecodeError) as e: + _log_resolve_error("award by number", number, e) + return None, None + + +async def _resolve_award_by_title( + client: httpx.AsyncClient, + title: str, + funder_id: str | None, + base_url: str, +) -> tuple[None, None] | tuple[ResolvedAward, ResolvedFunder]: + try: + params: dict = {"q": title, "size": 2, "sort": "bestmatch"} + if funder_id: + params["funders"] = funder_id + resp = await client.get( + f"{base_url}/api/awards", + params=params, + headers=_INVENIO_HEADERS, + ) + if not resp.is_success: + return None, None + hits = resp.json().get("hits", {}).get("hits", []) + # Use the result when unambiguous: 1 match or acronym is found in the 1st result + if len(hits) != 1: + if ( + not hits + or not (acronym := hits[0].get("acronym")) + or acronym not in title + ): + return None, None + hit = hits[0] + return ResolvedAward( + id=hit.get("id"), + number=hit.get("number"), + title=hit.get("title_l10n"), + ), ResolvedFunder(id=hit["funder"]["id"], name=hit["funder"]["name"]) + except (httpx.HTTPError, json.JSONDecodeError) as e: + _log_resolve_error("award by title", title, e) + return None, None + + +async def _resolve_funding_entry( + client: httpx.AsyncClient, + funder_name: FunderEnum | None, + title: str | None, + number: str | None, + base_url: str, +) -> ResolvedFunding | None: + ror_id = FUNDER_ROR_IDS.get(funder_name) if funder_name else None + + resolved_award: ResolvedAward | None = None + resolved_funder: ResolvedFunder | None = None + if number: + resolved_award, resolved_funder = await _resolve_award_by_number( + client, number, ror_id, base_url + ) + if resolved_award is None and title: + resolved_award, resolved_funder = await _resolve_award_by_title( + client, title, ror_id, base_url + ) + # 0 or >1 award matches: pass whatever info we have as a custom award + if resolved_award is None and (number or title): + resolved_award = ResolvedAward( + number=number or None, + title=title or None, + ) + if not resolved_funder and funder_name and ror_id: + resolved_funder = ResolvedFunder(id=ror_id, name=funder_name) + + if resolved_funder is None and resolved_award is None: + return None + return ResolvedFunding(funder=resolved_funder, award=resolved_award) + + +@activity.defn +async def resolve_metadata_suggestions( + request: ResolveMetadataRequest, +) -> MetadataSuggestions: + """Resolve funders, awards, and licenses, and return typed metadata suggestions.""" + extracted = request.metadata + result = MetadataSuggestions.from_extracted(extracted) + + base_url = get_settings().invenio_base_url + if not base_url: + logger.warning("INVENIO_BASE_URL is not configured, skipping resolution") + return result + + try: + verify = http_verify(base_url) + except ValueError as e: + raise ApplicationError( + str(e), + type="HostNotAllowed", + non_retryable=True, + ) from e + + async with httpx.AsyncClient(verify=verify) as client: + # Resolve licenses in parallel, fall back to id if unable to resolve + if extracted.license: + resolved_licenses = await asyncio.gather( + *[ + _resolve_license(client, lic.lower(), base_url) + for lic in extracted.license + ] + ) + result.suggestions.append(LicenseSuggestion(value=list(resolved_licenses))) + + # Resolve Funding (awards and funders) + funders = extracted.funding_funders + size = max( + len(extracted.funding_titles), + len(extracted.funding_numbers), + len(funders), + ) + if size: + resolved = await asyncio.gather( + *[ + _resolve_funding_entry( + client, + funders[i] if i < len(funders) else None, + extracted.at(extracted.funding_titles, i), + extracted.at(extracted.funding_numbers, i), + base_url, + ) + for i in range(size) + ] + ) + entries = [r for r in resolved if r is not None] + if entries: + result.suggestions.append(FundingSuggestion(value=entries)) + + return result diff --git a/app/activities/utils.py b/app/activities/utils.py new file mode 100644 index 0000000..1065939 --- /dev/null +++ b/app/activities/utils.py @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: 2026 CERN. +# SPDX-License-Identifier: MIT + +"""Utils for activities.""" + +from urllib.parse import urlparse + +from app.config import get_settings + + +def http_verify(url: str) -> bool: + """Whether to verify TLS when talking to an Invenio instance.""" + settings = get_settings() + + parsed_url = urlparse(url) + hostname = (parsed_url.hostname or "").lower() + + if hostname in ("localhost", "127.0.0.1", "::1") or hostname.endswith(".localhost"): + return False + + allowlist = { + host.strip().lower() + for host in (settings.http_allowlist or "").split(",") + if host.strip() + } + + if hostname not in allowlist: + raise ValueError(f"'{hostname}' is not an allowed domain") + + return True diff --git a/app/activities/_llm.py b/app/agent.py similarity index 89% rename from app/activities/_llm.py rename to app/agent.py index aeff55a..aacf1eb 100644 --- a/app/activities/_llm.py +++ b/app/agent.py @@ -22,6 +22,8 @@ "Respond immediately; do not deliberate. " "Reply with exactly one JSON object matching the schema and nothing else." ) +# Retries for output validation errors +_OUTPUT_RETRIES = 2 def _parse_llm(llm: str) -> tuple[str, str]: @@ -62,5 +64,11 @@ def build_agent(llm: str, output_type: type[T], instructions: str) -> Agent[None model, instructions=[_REASONING_LOW, instructions, _JSON_ONLY], output_type=PromptedOutput(output_type), + output_retries=_OUTPUT_RETRIES, ) - return Agent[None, T](model, instructions=instructions, output_type=output_type) + return Agent[None, T]( + model, + instructions=instructions, + output_type=output_type, + output_retries=_OUTPUT_RETRIES, + ) diff --git a/app/config.py b/app/config.py index 6a9a47e..60f23a2 100644 --- a/app/config.py +++ b/app/config.py @@ -58,6 +58,7 @@ class Settings(BaseSettings): # Security allowed_origins: list[str] = ["http://localhost:3000", "http://127.0.0.1:3000"] + http_allowlist: str | None = None # LLM # TODO Currently we have only a single workflow, so single LLM configuration @@ -75,8 +76,8 @@ class Settings(BaseSettings): langfuse_secret_key: str | None = None langfuse_base_url: str | None = None - # PDF downloads - pdf_http_allowlist: str | None = None + # Invenio instance to resolve funders, awards, and licenses against + invenio_base_url: str | None = None @property def auth_off(self) -> bool: diff --git a/app/schemas/extracted_metadata.py b/app/schemas/extracted_metadata.py new file mode 100644 index 0000000..a58c0dc --- /dev/null +++ b/app/schemas/extracted_metadata.py @@ -0,0 +1,153 @@ +# SPDX-FileCopyrightText: 2026 CERN. +# SPDX-License-Identifier: MIT + +"""Typed metadata suggestions returned by the workflow.""" + +# from __future__ import annotations + +from enum import Enum +from typing import Optional + +from pydantic import BaseModel, Field + + +class FunderEnum(str, Enum): + """Name and acronym for known funding organizations.""" + + NIH = "National Institutes of Health" + NSF = "U.S. National Science Foundation" + UKRI = "UK Research and Innovation" + FNS = "Swiss National Science Foundation" + EC = "European Commission" + FCT = "Foundation for Science and Technology" + NWO = "Dutch Research Council" + NHMRC = "National Health and Medical Research Council" + ANR = "National Agency for Research" + ARC = "Australian Research Council" + + +class ExtractedMetadata(BaseModel): + """Flat schema the LLM fills, converted to ``MetadataSuggestions``. + + Creators are parallel lists, not nested objects. gpt-oss-20b fills flat + top-level lists in a tool call but drops a field nested under each creator, + so a per-creator ``orcid`` gets lost. ``creator_orcids[i]`` and + ``creator_affiliations[i]`` belong to ``creators[i]``. + """ + + title: str | None = Field( + default=None, + description="Document title", + examples=["A Concise Title Describing the Work"], + ) + description: str | None = Field( + default=None, + description=( + "Abstract or executive summary of the document, copied word-for-word, " + "complete and unchanged; never paraphrase, shorten, or write a new " + "summary. Null if the document has no abstract or summary." + ), + examples=["The abstract of the document, word for word."], + ) + creators: list[str] = Field( + default_factory=list, + description=( + "Creator full names in ', ' format, in order. Include " + "every author named in the document; never truncate the list or use " + "et al." + ), + examples=[["Doe, Jane", "van der Berg, A."]], + ) + creator_orcids: list[str] = Field( + default_factory=list, + description=( + "ORCID iD per creator, parallel to `creators` so creator_orcids[i] " + "is the ORCID of creators[i]; empty string when an author has none. " + "Bare 16-digit form (four groups of four, last may be 'X'), no URL." + ), + examples=[["0000-0002-1111-1115", ""]], + ) + creator_affiliations: list[str] = Field( + default_factory=list, + description=( + "Affiliation per creator, parallel to `creators`; empty string when " + "unknown. Affiliations are often marked with numbers or symbols after " + "author names; resolve each author's marker to its affiliation. Copy " + "the affiliation as written, including any department or institute, " + "but leave out the marker and any street address, city, postal code, " + "or country: 'CERN, Geneva, Switzerland' -> 'CERN'. If an author has " + "several affiliations, give the first." + ), + examples=[["CERN", "Department of Physics, University of Oxford"]], + ) + doi: str | None = Field( + default=None, + description="The Digital Object Identifier, as a bare DOI without a URL prefix", + examples=["10.1234/example.5678"], + ) + publication_date: str | None = Field( + default=None, + description=( + "Publication date in ISO 8601, at the precision known: 'YYYY-MM-DD', " + "'YYYY-MM', or 'YYYY'. Normalize written dates: '17 July 2023' -> " + "'2023-07-17', 'July 2023' -> '2023-07', '2023' -> '2023'." + ), + examples=["2014-07-17", "2014-07", "2014"], + ) + license: list[str] = Field( + default_factory=list, + description=( + "SPDX id of the license. Might be preceded by 'licensed under'. Translate " + "license names to the SPDX id. For example, 'Creative Commons Attribution " + "4.0 International' should be returned as 'cc-by-4.0'." + ), + examples=[["mit", "apache-2.0", "cc-by-4.0", "gpl-3.0-only"]], + ) + copyright: str | None = Field( + default=None, + description=( + "Copyright statement. Often follows '© Copyright' and might include a " + "year, which should also be returned. Do not include the © symbol, the " + "word 'Copyright' itself, or any '(cid:N)' sequences." + ), + examples=["2025 CERN", "The Authors", "2020 Jane Doe", "The Authors 1999"], + ) + funding_titles: list[str] = Field( + default_factory=list, + description=( + "Name of funding awards or projects financing the research. Strip " + "any surrounding phrases ('funded by', 'funded under', 'with support " + "from', etc.) and trailing punctuation. Prefer this field over `funder` " + "unless only a funding organization with no specific project is available." + ), + examples=[["SCOAP3", "ObsSea4Clim", "European Citizen Science (ECS)"]], + ) + funding_numbers: list[str] = Field( + default_factory=list, + description=( + "List of funding numbers or grant agreements, parallel to `funding_titles` " + "so funding_numbers[i] is the grant agreement of funding_titles[i]; empty " + "string if there is no grant agreement stated. Might be preceded by " + "expressions such as 'grant agreement' or 'GA nr.' or 'project no.'." + ), + examples=[["101058509", "2410342", "5IK2BX005715-03"]], + ) + funding_funders: list[Optional[FunderEnum]] = Field( + default_factory=list, + description=( + "Funding organization, parallel to `funding_titles` so funding_funder" + "[i] is the funder of funding_titles[i]. Return the full name when the " + "funder matches by name or acronym: NIH = National Institutes of Health, " + "NSF = U.S. National Science Foundation, UKRI = UK Research and Innovation," + " FNS = Swiss National Science Foundation, EC = European Commission. FCT = " + "Foundation for Science and Technology, NWO = Dutch Research Council, " + "NHMRC = National Health and Medical Research Council, ANR = National " + "Agency for Research, ARC = Australian Research Council; or None." + ), + examples=[["European Commission", "U.S. National Science Foundation", None]], + ) + + @staticmethod + def at(values: list[str], i: int) -> str: + """Return the i-th parallel value, or '' when the list is shorter.""" + return values[i] if i < len(values) else "" diff --git a/app/schemas/metadata_suggestions.py b/app/schemas/metadata_suggestions.py index fb14a9a..320b428 100644 --- a/app/schemas/metadata_suggestions.py +++ b/app/schemas/metadata_suggestions.py @@ -11,6 +11,9 @@ from idutils.validators import is_orcid from pydantic import BaseModel, Field, field_validator +from .extracted_metadata import ExtractedMetadata +from .resolved_fields import ResolvedFunding, ResolvedLicense + class Creator(BaseModel): """A structured creator/author.""" @@ -97,12 +100,36 @@ def normalize_publication_date(cls, v: str) -> str: return " ".join(v.split()).strip() +class LicenseSuggestion(BaseModel): + """Suggestion for `license` .""" + + field: Literal["license"] = "license" + value: list[ResolvedLicense] + + +class CopyrightSuggestion(BaseModel): + """Suggestion for `copyright` .""" + + field: Literal["copyright"] = "copyright" + value: str + + +class FundingSuggestion(BaseModel): + """Suggestion for `funding` (awards/grants).""" + + field: Literal["funding"] = "funding" + value: list[ResolvedFunding] + + MetadataSuggestion = Annotated[ TitleSuggestion | DescriptionSuggestion | CreatorsSuggestion | DoiSuggestion - | PublicationDateSuggestion, + | PublicationDateSuggestion + | LicenseSuggestion + | CopyrightSuggestion + | FundingSuggestion, Field(discriminator="field"), ] @@ -112,107 +139,39 @@ class MetadataSuggestions(BaseModel): suggestions: list[MetadataSuggestion] - -class ExtractedMetadata(BaseModel): - """Flat schema the LLM fills, converted to ``MetadataSuggestions``. - - Creators are parallel lists, not nested objects. gpt-oss-20b fills flat - top-level lists in a tool call but drops a field nested under each creator, - so a per-creator ``orcid`` gets lost. ``creator_orcids[i]`` and - ``creator_affiliations[i]`` belong to ``creators[i]``. - """ - - title: str | None = Field( - default=None, - description="Document title", - examples=["A Concise Title Describing the Work"], - ) - description: str | None = Field( - default=None, - description=( - "Abstract or executive summary of the document, copied word-for-word, " - "complete and unchanged; never paraphrase, shorten, or write a new " - "summary. Null if the document has no abstract or summary." - ), - examples=["The abstract of the document, word for word."], - ) - creators: list[str] = Field( - default_factory=list, - description=( - "Creator full names in ', ' format, in order. Include " - "every author named in the document; never truncate the list or use " - "et al." - ), - examples=[["Doe, Jane", "van der Berg, A."]], - ) - creator_orcids: list[str] = Field( - default_factory=list, - description=( - "ORCID iD per creator, parallel to `creators` so creator_orcids[i] " - "is the ORCID of creators[i]; empty string when an author has none. " - "Bare 16-digit form (four groups of four, last may be 'X'), no URL." - ), - examples=[["0000-0002-1111-1115", ""]], - ) - creator_affiliations: list[str] = Field( - default_factory=list, - description=( - "Affiliation per creator, parallel to `creators`; empty string when " - "unknown. Affiliations are often marked with numbers or symbols after " - "author names; resolve each author's marker to its affiliation. Copy " - "the affiliation as written, including any department or institute, " - "but leave out the marker and any street address, city, postal code, " - "or country: 'CERN, Geneva, Switzerland' -> 'CERN'. If an author has " - "several affiliations, give the first." - ), - examples=[["CERN", "Department of Physics, University of Oxford"]], - ) - doi: str | None = Field( - default=None, - description="The Digital Object Identifier, as a bare DOI without a URL prefix", - examples=["10.1234/example.5678"], - ) - publication_date: str | None = Field( - default=None, - description=( - "Publication date in ISO 8601, at the precision known: 'YYYY-MM-DD', " - "'YYYY-MM', or 'YYYY'. Normalize written dates: '17 July 2023' -> " - "'2023-07-17', 'July 2023' -> '2023-07', '2023' -> '2023'." - ), - examples=["2014-07-17", "2014-07", "2014"], - ) - - @staticmethod - def _at(values: list[str], i: int) -> str: - """Return the i-th parallel value, or '' when the list is shorter.""" - return values[i] if i < len(values) else "" - - def to_suggestions(self) -> MetadataSuggestions: - """Build the typed suggestions, dropping null/empty fields.""" + @classmethod + def from_extracted(cls, metadata: ExtractedMetadata) -> MetadataSuggestions: + """Build suggestions from extracted metadata.""" suggestions: list[MetadataSuggestion] = [] - if self.title: - suggestions.append(TitleSuggestion(value=self.title)) - if self.description: - suggestions.append(DescriptionSuggestion(value=self.description)) - if self.creators: + if metadata.title: + suggestions.append(TitleSuggestion(value=metadata.title)) + if metadata.description: + suggestions.append(DescriptionSuggestion(value=metadata.description)) + if metadata.creators: value = [] - for i, name in enumerate(self.creators): + for i, name in enumerate(metadata.creators): # Validate/normalize here, not on the LLM output schema, where a # fed-back error would make the model invent a valid-looking fake. - orcid = self._at(self.creator_orcids, i) + orcid = metadata.at(metadata.creator_orcids, i) orcid = normalize_orcid(orcid).upper() if is_orcid(orcid) else None value.append( Creator( name=name, orcid=orcid, - affiliation=self._at(self.creator_affiliations, i) or None, + affiliation=( + metadata.at(metadata.creator_affiliations, i) or None + ), ) ) creators = CreatorsSuggestion(value=value) if creators.value: suggestions.append(creators) - if self.doi: - suggestions.append(DoiSuggestion(value=self.doi)) - if self.publication_date: - suggestions.append(PublicationDateSuggestion(value=self.publication_date)) - return MetadataSuggestions(suggestions=suggestions) + if metadata.doi: + suggestions.append(DoiSuggestion(value=metadata.doi)) + if metadata.publication_date: + suggestions.append( + PublicationDateSuggestion(value=metadata.publication_date) + ) + if metadata.copyright: + suggestions.append(CopyrightSuggestion(value=metadata.copyright)) + return cls(suggestions=suggestions) diff --git a/app/schemas/resolved_fields.py b/app/schemas/resolved_fields.py new file mode 100644 index 0000000..bd866ae --- /dev/null +++ b/app/schemas/resolved_fields.py @@ -0,0 +1,59 @@ +# SPDX-FileCopyrightText: 2026 CERN. +# SPDX-License-Identifier: MIT + +"""Resolved metadata fields.""" + +from pydantic import BaseModel, Field + + +class ResolvedFunder(BaseModel): + """A funding organization resolved against the funders vocabulary.""" + + id: str = Field( + description="ROR identifier", + examples=["00k4n6c32", "01cwqze88"], + ) + name: str = Field( + description="Funder name", + examples=["European Commission", "National Institutes of Health"], + ) + + +class ResolvedAward(BaseModel): + """An award or grant, resolved against the awards vocabulary.""" + + id: str | None = Field( + default=None, + description="Award identifier (funder::number); None for unresolved awards", + examples=["00k4n6c32::101058509", "01cwqze88::5K01HL130704-03"], + ) + number: str | None = Field(default=None, examples=["101058509", "5K01HL130704-03"]) + title: str | None = Field( + default=None, + examples=["SCOAP3", "Intersensory Perception of Social Events"], + ) + + +class ResolvedFunding(BaseModel): + """A funding entry with a funder and an award.""" + + funder: ResolvedFunder | None = None + award: ResolvedAward | None = None + + +class ResolvedLicense(BaseModel): + """A license resolved against the Invenio licenses vocabulary.""" + + id: str = Field(description="SPDX identifier", examples=["cc-by-4.0", "mit"]) + title: str | None = Field( + default=None, + examples=["Creative Commons Attribution 4.0 International", "MIT License"], + ) + description: str | None = None + link: str | None = Field( + default=None, + examples=[ + "https://creativecommons.org/licenses/by/4.0/legalcode", + "https://opensource.org/licenses/MIT", + ], + ) diff --git a/app/workflows/extract_metadata_workflow.py b/app/workflows/extract_metadata_workflow.py index ba65c2b..9d91242 100644 --- a/app/workflows/extract_metadata_workflow.py +++ b/app/workflows/extract_metadata_workflow.py @@ -4,9 +4,7 @@ from datetime import timedelta from pydantic import Field, HttpUrl -from pydantic_ai.durable_exec.temporal import ( - PydanticAIWorkflow, -) +from pydantic_ai.durable_exec.temporal import PydanticAIWorkflow from temporalio import workflow from app.activities.extract_metadata import ( @@ -19,6 +17,11 @@ ExtractPdfContentRequest, extract_pdf_text, ) +from app.activities.resolve_metadata import ( + RESOLVE_METADATA_RETRY_POLICY, + ResolveMetadataRequest, + resolve_metadata_suggestions, +) from app.activities.update_workflow import ( UPDATE_WORKFLOW_RETRY_POLICY, WorkflowUpdateRequest, @@ -26,10 +29,7 @@ ) from app.database.models import WorkflowStatus from app.schemas.metadata_suggestions import MetadataSuggestions -from app.workflows.specs import ( - WorkflowContext, - WorkflowParams, -) +from app.workflows.specs import WorkflowContext, WorkflowParams class ExtractMetadataParams(WorkflowParams): @@ -75,13 +75,21 @@ async def run( retry_policy=EXTRACT_PDF_TEXT_RETRY_POLICY, ) - # Activity 2: Generate metadata suggestions using LLM - result = await workflow.execute_activity( + # Activity 2: Generate raw metadata suggestions using LLM + metadata = await workflow.execute_activity( extract_metadata_with_llm, args=[ExtractMetadataRequest(text=content.text), context], start_to_close_timeout=timedelta(minutes=5), retry_policy=EXTRACT_METADATA_RETRY_POLICY, ) + + # Activity 3: Resolve funders, awards, and licenses; format suggestions + result = await workflow.execute_activity( + resolve_metadata_suggestions, + ResolveMetadataRequest(metadata=metadata), + start_to_close_timeout=timedelta(minutes=3), + retry_policy=RESOLVE_METADATA_RETRY_POLICY, + ) except Exception: await workflow.execute_activity( update_workflow, diff --git a/charts/orcha/templates/configmap.yaml b/charts/orcha/templates/configmap.yaml index 05890b2..cde3201 100644 --- a/charts/orcha/templates/configmap.yaml +++ b/charts/orcha/templates/configmap.yaml @@ -13,8 +13,11 @@ data: {{- if .Values.appConfig.langfuseBaseUrl }} LANGFUSE_BASE_URL: {{ .Values.appConfig.langfuseBaseUrl | quote }} {{- end }} - {{- if .Values.appConfig.pdfHttpAllowlist }} - PDF_HTTP_ALLOWLIST: {{ .Values.appConfig.pdfHttpAllowlist | quote }} + {{- if .Values.appConfig.httpAllowlist }} + HTTP_ALLOWLIST: {{ .Values.appConfig.httpAllowlist | quote }} + {{- end }} + {{- if .Values.appConfig.invenioBaseUrl }} + INVENIO_BASE_URL: {{ .Values.appConfig.invenioBaseUrl | quote }} {{- end }} --- apiVersion: v1 diff --git a/charts/orcha/values.yaml b/charts/orcha/values.yaml index 0250bfa..0030d3d 100644 --- a/charts/orcha/values.yaml +++ b/charts/orcha/values.yaml @@ -221,7 +221,8 @@ appConfig: allowedOrigins: '["http://localhost:3000","http://127.0.0.1:3000"]' langfuseEnabled: "False" langfuseBaseUrl: "" - pdfHttpAllowlist: "" + httpAllowlist: "" + invenioBaseUrl: "" # LLM-specific configuration llmConfig: diff --git a/docs/invenio.md b/docs/invenio.md index b67eb85..242b74a 100644 --- a/docs/invenio.md +++ b/docs/invenio.md @@ -36,7 +36,7 @@ unsigned under `RDM_ORCHA_DEV_MODE`, so `SITE_UI_URL` has to resolve from wherever Orcha runs. TLS verification is skipped for `localhost`, `127.0.0.1` and `::1`, which covers the usual self-signed development certificate. -Any other host has to appear in `PDF_HTTP_ALLOWLIST`. +Any other host has to appear in `HTTP_ALLOWLIST`. ## Enabling the deposit form button diff --git a/tests/test_extract_metadata.py b/tests/test_extract_metadata.py index 0b26c5e..9e5abd1 100644 --- a/tests/test_extract_metadata.py +++ b/tests/test_extract_metadata.py @@ -18,7 +18,7 @@ _clear_absent_fields, extract_metadata_with_llm, ) -from app.schemas.metadata_suggestions import ExtractedMetadata +from app.schemas.metadata_suggestions import ExtractedMetadata, MetadataSuggestions from app.workflows.specs import WorkflowContext SOURCE = ( @@ -100,4 +100,5 @@ def test_skip_empty_returns_no_suggestions(text): request = ExtractMetadataRequest(text=text) context = WorkflowContext(workflow_id="wf-1", tenant_id="t-1") result = asyncio.run(extract_metadata_with_llm(request, context)) - assert result.suggestions == [] + raw_suggestions = MetadataSuggestions.from_extracted(result) + assert raw_suggestions.suggestions == [] diff --git a/tests/test_resolve_metadata.py b/tests/test_resolve_metadata.py new file mode 100644 index 0000000..eb2954d --- /dev/null +++ b/tests/test_resolve_metadata.py @@ -0,0 +1,446 @@ +# SPDX-FileCopyrightText: 2026 CERN. +# SPDX-License-Identifier: MIT + +"""Tests for the resolve_metadata activity. + +Mocks the response from an invenio instance. +""" + +from unittest.mock import AsyncMock, patch + +import pytest +from httpx import Response + +from app.activities.resolve_metadata import ( + _INVENIO_HEADERS, + FUNDER_ROR_IDS, + ResolveMetadataRequest, + resolve_metadata_suggestions, +) +from app.config import get_settings +from app.schemas.extracted_metadata import ExtractedMetadata, FunderEnum +from app.schemas.metadata_suggestions import ( + DoiSuggestion, + FundingSuggestion, + LicenseSuggestion, + TitleSuggestion, +) +from app.schemas.resolved_fields import ( + ResolvedAward, + ResolvedFunder, + ResolvedFunding, + ResolvedLicense, +) + +BASE_URL = "https://invenio.test" + +LICENSES = { + "cc-by-4.0": { + "id": "cc-by-4.0", + "title_l10n": "Creative Commons Attribution 4.0 International", + "description_l10n": "The Creative Commons Attribution license.", + "props": { + "url": "https://creativecommons.org/licenses/by/4.0/legalcode", + }, + }, + "mit": { + "id": "mit", + "title_l10n": "MIT License", + "description_l10n": "A short and simple permissive license.", + "props": { + "url": "https://opensource.org/licenses/MIT", + }, + }, +} +FUNDERS = { + "EC": { + "id": "00k4n6c32", + "name": "European Commission", + }, + "NIH": { + "id": "01cwqze88", + "name": "National Institutes of Health", + }, + "NSF": { + "id": "021nxhr62", + "name": "U.S. National Science Foundation", + }, +} +AWARDS = { + "101166718": { + "id": "00k4n6c32::101166718", + "number": "101166718", + "title_l10n": "A Title", + "acronym": "TITLE", + "funder": FUNDERS["EC"], + }, + "TEST - Test Entry Standard Title": { + "id": "01cwqze88::101010101", + "number": "101010101", + "title_l10n": "TEST - Test Entry Standard Title", + "acronym": "TEST", + "funder": FUNDERS["NIH"], + }, + "Test_similar": { + "id": "021nxhr62::100000000", + "number": "100000000", + "title_l10n": "ASTA - Another Similar Test Award", + "acronym": "ASTA", + "funder": FUNDERS["NSF"], + }, +} + + +async def mocked_response(url, *args, params: dict | None = None, **kwargs): + """Returns the mocked response.""" + if "/api/vocabularies/licenses/" in url: + license_id = url.rsplit("/", 1)[-1] + if lic := LICENSES.get(license_id): + return Response(200, json=lic) + + elif "/api/awards" in url and params: + query = params["q"] + if query.startswith("number"): + number = query.rsplit(":", 1)[-1] + if award := AWARDS.get(number): + return Response(200, json={"hits": {"hits": [award]}}) + else: + awards = [AWARDS[key] for key in AWARDS if query in key] + if awards: + return Response(200, json={"hits": {"hits": awards}}) + return Response(400) + + +@pytest.fixture(autouse=True) +def base_url(monkeypatch): + """Pin INVENIO_BASE_URL for tests.""" + monkeypatch.setenv("INVENIO_BASE_URL", BASE_URL) + get_settings.cache_clear() + + +@patch("app.activities.resolve_metadata.http_verify", return_value=True) +@patch("httpx.AsyncClient.get", new_callable=AsyncMock) +async def resolve(mock_get, mock_verify, **metadata): + """Resolve metadata based on mocked response.""" + request = ResolveMetadataRequest(metadata=ExtractedMetadata(**metadata)) + mock_get.side_effect = mocked_response + result = await resolve_metadata_suggestions(request) + return result, mock_get + + +def test_funder_ror_ids_cover_all_funders(): + """Every FunderEnum member must have a ROR id, or its funder never resolves.""" + assert set(FUNDER_ROR_IDS) == set(FunderEnum) + + +@pytest.mark.asyncio +async def test_plain_fields_need_no_requests(): + """With no license or funding, nothing is resolved and nothing is called.""" + result, mock_get = await resolve(title="A Title", doi="10.1234/example.5678") + suggestions = result.suggestions + assert suggestions == [ + TitleSuggestion(field="title", value="A Title"), + DoiSuggestion(field="doi", value="10.1234/example.5678"), + ] + mock_get.assert_not_called() + + +@pytest.mark.asyncio +async def test_license_resolved(): + """A known license comes back with its title, description, and link.""" + result, mock_get = await resolve(title="A Title", license=["cc-by-4.0"]) + suggestions = result.suggestions + + assert suggestions == [ + TitleSuggestion(field="title", value="A Title"), + LicenseSuggestion( + field="license", + value=[ + ResolvedLicense( + id="cc-by-4.0", + title="Creative Commons Attribution 4.0 International", + description="The Creative Commons Attribution license.", + link="https://creativecommons.org/licenses/by/4.0/legalcode", + ) + ], + ), + ] + mock_get.assert_called_once() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "license_ids", + [["cc-by-4.0", "mit"], ["CC-BY-4.0", "MIT"]], + ids=["lowercase", "mixed_case"], +) +async def test_multiple_license_ids(license_ids): + """Several SPDX ids resolve in order, and are looked up in lowercase.""" + result, mock_get = await resolve(license=license_ids) + suggestions = result.suggestions + + assert suggestions == [ + LicenseSuggestion( + field="license", + value=[ + ResolvedLicense( + id="cc-by-4.0", + title="Creative Commons Attribution 4.0 International", + description="The Creative Commons Attribution license.", + link="https://creativecommons.org/licenses/by/4.0/legalcode", + ), + ResolvedLicense( + id="mit", + title="MIT License", + description="A short and simple permissive license.", + link="https://opensource.org/licenses/MIT", + ), + ], + ), + ] + + +@pytest.mark.asyncio +async def test_unknown_license_falls_back_to_id(): + """A 404 keeps the id and leaves the rest of the license empty.""" + result, mock_get = await resolve(license=["not-a-license"]) + suggestions = result.suggestions + + assert suggestions == [ + LicenseSuggestion(field="license", value=[ResolvedLicense(id="not-a-license")]) + ] + mock_get.assert_called_once() + + +@pytest.mark.asyncio +async def test_award_resolved_by_number(): + """A grant number match yields the award and the funder from the hit.""" + result, mock_get = await resolve( + funding_titles=["Wrong title"], + funding_numbers=["101166718"], + funding_funders=[], + ) + suggestions = result.suggestions + + assert suggestions == [ + FundingSuggestion( + field="funding", + value=[ + ResolvedFunding( + funder=ResolvedFunder(id="00k4n6c32", name="European Commission"), + award=ResolvedAward( + id="00k4n6c32::101166718", number="101166718", title="A Title" + ), + ) + ], + ) + ] + mock_get.assert_called_once_with( + f"{BASE_URL}/api/awards", + params={"q": "number:101166718", "size": 1}, + headers=_INVENIO_HEADERS, + ) + + +@pytest.mark.asyncio +async def test_known_funder_scopes_the_award_search(): + """Without a known funder the award search is not filtered by funder.""" + result, mock_get = await resolve( + funding_titles=[], + funding_numbers=["101166718"], + funding_funders=["European Commission"], + ) + suggestions = result.suggestions + + assert suggestions == [ + FundingSuggestion( + field="funding", + value=[ + ResolvedFunding( + funder=ResolvedFunder(id="00k4n6c32", name="European Commission"), + award=ResolvedAward( + id="00k4n6c32::101166718", number="101166718", title="A Title" + ), + ) + ], + ) + ] + mock_get.assert_called_once_with( + f"{BASE_URL}/api/awards", + params={"q": "number:101166718", "funders": "00k4n6c32", "size": 1}, + headers=_INVENIO_HEADERS, + ) + + +@pytest.mark.asyncio +async def test_award_resolved_by_title_when_number_misses(): + """A number with no match falls through to a unique title match.""" + result, mock_get = await resolve( + funding_titles=["TEST - Test Entry Standard Title"], + funding_numbers=["fakenumber"], + ) + suggestions = result.suggestions + + assert suggestions == [ + FundingSuggestion( + field="funding", + value=[ + ResolvedFunding( + funder=ResolvedFunder( + id="01cwqze88", name="National Institutes of Health" + ), + award=ResolvedAward( + id="01cwqze88::101010101", + number="101010101", + title="TEST - Test Entry Standard Title", + ), + ) + ], + ) + ] + + +@pytest.mark.asyncio +async def test_ambiguous_title_rejected(): + """Several title matches whose acronym is absent keep a custom award.""" + result, mock_get = await resolve(funding_titles=["Test"]) + suggestions = result.suggestions + + assert suggestions == [ + FundingSuggestion( + field="funding", + value=[ + ResolvedFunding( + funder=None, + award=ResolvedAward(id=None, number=None, title="Test"), + ) + ], + ) + ] + + +@pytest.mark.asyncio +async def test_ambiguous_title_accepted_when_acronym_matches(): + """Several matches are disambiguated by the acronym appearing in the title.""" + result, mock_get = await resolve(funding_titles=["TEST"]) + suggestions = result.suggestions + + assert suggestions == [ + FundingSuggestion( + field="funding", + value=[ + ResolvedFunding( + funder=ResolvedFunder( + id="01cwqze88", name="National Institutes of Health" + ), + award=ResolvedAward( + id="01cwqze88::101010101", + number="101010101", + title="TEST - Test Entry Standard Title", + ), + ) + ], + ) + ] + + +@pytest.mark.asyncio +async def test_no_match_keeps_number_and_title_as_custom_award(): + """When nothing resolves, the extracted number and title are kept as-is.""" + result, mock_get = await resolve( + funding_titles=["Some Project"], + funding_numbers=["12345"], + funding_funders=["National Institutes of Health"], + ) + suggestions = result.suggestions + + assert suggestions == [ + FundingSuggestion( + field="funding", + value=[ + ResolvedFunding( + funder=ResolvedFunder( + id="01cwqze88", name="National Institutes of Health" + ), + award=ResolvedAward(id=None, number="12345", title="Some Project"), + ) + ], + ) + ] + + +@pytest.mark.asyncio +async def test_funder_only(): + """A funder with no award produces a funder and no award.""" + result, mock_get = await resolve( + funding_funders=["U.S. National Science Foundation"] + ) + suggestions = result.suggestions + + assert suggestions == [ + FundingSuggestion( + field="funding", + value=[ + ResolvedFunding( + funder=ResolvedFunder( + id="021nxhr62", name="U.S. National Science Foundation" + ), + award=None, + ) + ], + ) + ] + + +@pytest.mark.asyncio +async def test_parallel_lists_of_unequal_length(): + """Entries are built for the longest list; missing positions read as empty.""" + result, mock_get = await resolve( + funding_titles=["A Title", "Second Project"], + funding_numbers=["101166718"], + funding_funders=["European Commission"], + ) + suggestions = result.suggestions + + assert suggestions == [ + FundingSuggestion( + field="funding", + value=[ + ResolvedFunding( + funder=ResolvedFunder(id="00k4n6c32", name="European Commission"), + award=ResolvedAward( + id="00k4n6c32::101166718", number="101166718", title="A Title" + ), + ), + ResolvedFunding( + funder=None, + award=ResolvedAward(id=None, number=None, title="Second Project"), + ), + ], + ), + ] + + +@pytest.mark.asyncio +async def test_no_resolved_funding(): + """Do not return a ResolvedFunding when both funder and award are None.""" + result, mock_get = await resolve( + license=["mit"], + funding_titles=[""], + funding_numbers=[""], + ) + suggestions = result.suggestions + + assert suggestions == [ + LicenseSuggestion( + field="license", + value=[ + ResolvedLicense( + id="mit", + title="MIT License", + description="A short and simple permissive license.", + link="https://opensource.org/licenses/MIT", + ), + ], + ), + ]