Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/app/api/api_v1/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from fastapi import APIRouter

from src.app.api.api_v1.endpoints import chat, metric, micro_learning
from src.app.api.api_v1.endpoints import bibliography, chat, metric, micro_learning
from src.app.search.api import router as search_router
from src.app.tutor.api import router as tutor_router
from src.app.user.api import router as user_router
Expand All @@ -16,6 +16,9 @@
micro_learning.router, prefix="/micro_learning", tags=["micro_learning"]
)
api_router.include_router(user_router.router, prefix="/user", tags=["user"])
api_router.include_router(
bibliography.router, prefix="/bibliography", tags=["bibliography"]
)


api_tags_metadata = [
Expand Down Expand Up @@ -43,4 +46,8 @@
"name": "metric",
"description": "Metric information",
},
{
"name": "bibliography",
"description": "Bibliography exporters for documents collected by welearn",
},
]
17 changes: 17 additions & 0 deletions src/app/api/api_v1/endpoints/bibliography.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from fastapi import APIRouter
from starlette.responses import PlainTextResponse

from src.app.models.bibliography import DocumentIDs
from src.app.services.helpers import welearn_document_to_ris
from src.app.services.sql_db.queries import get_documents_by_ids

router = APIRouter()


@router.post("/export_bibliography", response_class=PlainTextResponse)
async def export_bibliography(body: DocumentIDs) -> str:
documents_ids = [str(u) for u in body.documents_ids]
docs = get_documents_by_ids(documents_ids)

records = [welearn_document_to_ris(doc) for doc in docs]
return "\n".join(records) + ("\n" if records else "")
7 changes: 7 additions & 0 deletions src/app/models/bibliography.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from uuid import UUID

from pydantic import BaseModel


class DocumentIDs(BaseModel):
documents_ids: list[UUID]
79 changes: 77 additions & 2 deletions src/app/services/helpers.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import datetime
import re
from functools import cache
from typing import Any, List, Optional, cast
Expand All @@ -8,7 +9,7 @@
from json_repair import JSONReturnType
from langdetect import detect_langs
from qdrant_client.http.models import models
from welearn_database.data.models import EmbeddingModel
from welearn_database.data.models import EmbeddingModel, WeLearnDocument

from src.app.models.collections import Collection
from src.app.models.documents import JourneySectionType
Expand Down Expand Up @@ -134,7 +135,7 @@ def stringify_docs_content(docs: List[Any]) -> str:

documents = "\n\n".join(articles)
except Exception as e:
logger.error("Error in stringify_docs_content: %s", e)
logger.exception("Error in stringify_docs_content: %s", e)
return ""

return documents.strip()
Expand Down Expand Up @@ -256,3 +257,77 @@ async def collection_and_model_id_according_lang(
f"Embedding model '{collection_info.model}' not found in the database."
)
return collection_info, emb_models


def ris_line(tag: str, value: Any) -> Optional[str]:
"""Formate une ligne RIS, ou None si la valeur est vide."""
if value is None or value == "":
return None
return f"{tag} - {value}"


def compute_ris_doctype(doc_type: Any | None) -> str:
match doc_type:
case "book":
return "BOOK"
case "chapter":
return "CHAP"
case "article":
return "JFULL"
case _:
return "ELEC"


def compute_publication_date_for_ris(pub_date: str | int | float) -> str:
try:
tmp_ret = datetime.date.fromtimestamp(int(pub_date)).isoformat()
return tmp_ret.replace("-", "/")
except Exception as e:
logger.warning(
"Exception occurs during publication formatting, field returned empty: %s",
e,
)
return ""
Comment thread
Copilot marked this conversation as resolved.


def compute_authors_for_ris(authors: list[dict[str, Any]]) -> list[str]:
ret: list[str] = []
for author in authors:
line = ris_line("AU", author.get("name"))
if line:
ret.append(line)
return ret


@log_time_and_error_sync
def welearn_document_to_ris(doc: WeLearnDocument) -> str:
details = doc.details or {}

lines: list[str] = []

doc_type = details.get("type", None)
lines.append(ris_line("TY", compute_ris_doctype(doc_type)))
lines.append(ris_line("ID", doc.id))
lines.append(ris_line("TI", doc.title))
lines.append(ris_line("UR", doc.url))
lines.append(ris_line("AB", doc.description))
lines.append(ris_line("DB", doc.corpus.source_name))
lines.append(ris_line("LA", doc.lang))
pub_date = details.get("publication_date", None)
if pub_date:
ris_pub_date = compute_publication_date_for_ris(pub_date=pub_date)
if ris_pub_date:
lines.append(ris_line("PY", ris_pub_date))
if doc.doi:
lines.append(ris_line("DO", doc.doi))
authors = details.get("authors", None)
if authors:
lines.extend(compute_authors_for_ris(authors=authors))
publisher = details.get("publisher", None)
if publisher:
lines.append(ris_line("PB", publisher))
license_url = details.get("license_url", None)
if license_url:
lines.append(ris_line("C1", license_url))
lines.append("ER - ")
return "\n".join([line for line in lines if line])
26 changes: 16 additions & 10 deletions src/app/services/sql_db/queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from qdrant_client.http.models import ScoredPoint
from sqlalchemy import func, select
from sqlalchemy.orm import joinedload
from welearn_database.data.enumeration import Step
from welearn_database.data.models import (
Category,
Expand Down Expand Up @@ -82,18 +83,23 @@ def get_document_qty_table_info_sync() -> (
) # type: ignore


def get_documents_by_ids(documents_ids: list[str]) -> list[WeLearnDocument]:
with session_maker() as s:
documents = (
s.query(WeLearnDocument)
.where(WeLearnDocument.id.in_(documents_ids))
.options(joinedload(WeLearnDocument.corpus))
.all()
Comment thread
lpi-tn marked this conversation as resolved.
)

ret = list(documents)

return ret


def get_documents_payload_by_ids_sync(documents_ids: list[str]) -> list[Document]:
with session_maker() as s:
documents = s.execute(
select(
WeLearnDocument.title,
WeLearnDocument.url,
WeLearnDocument.corpus_id,
WeLearnDocument.id,
WeLearnDocument.description,
WeLearnDocument.details,
).where(WeLearnDocument.id.in_(documents_ids))
).all()
documents = get_documents_by_ids(documents_ids=documents_ids)

# Batch fetch corpora
corpus_ids = list({doc.corpus_id for doc in documents})
Expand Down
73 changes: 73 additions & 0 deletions src/app/tests/api/api_v1/test_bibliography.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import unittest
from unittest import mock

from fastapi.testclient import TestClient

from src.app.core.config import settings
from src.main import app

client = TestClient(app)


@mock.patch(
"src.app.shared.infra.security.check_api_key_sync",
new=mock.MagicMock(return_value=True),
)
class BibliographyApiTests(unittest.IsolatedAsyncioTestCase):
@mock.patch("src.app.api.api_v1.endpoints.bibliography.welearn_document_to_ris")
@mock.patch("src.app.api.api_v1.endpoints.bibliography.get_documents_by_ids")
async def test_export_bibliography_success(
self, get_documents_by_ids_mock, welearn_document_to_ris_mock, *mocks
):
doc1 = mock.sentinel.doc1
doc2 = mock.sentinel.doc2
get_documents_by_ids_mock.return_value = [doc1, doc2]
welearn_document_to_ris_mock.side_effect = [
"TY - JFULL\nER - ",
"TY - BOOK\nER - ",
]

payload = {
"documents_ids": [
"12345678-1234-5678-1234-567812345678",
"87654321-4321-8765-4321-876543218765",
]
}
response = client.post(
f"{settings.API_V1_STR}/bibliography/export_bibliography",
json=payload,
headers={"X-API-Key": "test"},
)

self.assertEqual(response.status_code, 200)
self.assertEqual(response.text, "TY - JFULL\nER - \nTY - BOOK\nER - \n")
get_documents_by_ids_mock.assert_called_once_with(payload["documents_ids"])
welearn_document_to_ris_mock.assert_has_calls(
[mock.call(doc1), mock.call(doc2)]
)

@mock.patch("src.app.api.api_v1.endpoints.bibliography.welearn_document_to_ris")
@mock.patch("src.app.api.api_v1.endpoints.bibliography.get_documents_by_ids")
async def test_export_bibliography_empty_documents(
self, get_documents_by_ids_mock, welearn_document_to_ris_mock, *mocks
):
get_documents_by_ids_mock.return_value = []

response = client.post(
f"{settings.API_V1_STR}/bibliography/export_bibliography",
json={"documents_ids": ["12345678-1234-5678-1234-567812345678"]},
headers={"X-API-Key": "test"},
)

self.assertEqual(response.status_code, 200)
self.assertEqual(response.text, "")
welearn_document_to_ris_mock.assert_not_called()

def test_export_bibliography_invalid_payload(self, *mocks):
response = client.post(
f"{settings.API_V1_STR}/bibliography/export_bibliography",
json={"documents_ids": ["not-a-uuid"]},
headers={"X-API-Key": "test"},
)

self.assertEqual(response.status_code, 422)
Loading
Loading