pub-analyzer 0.2.0__py3-none-any.whl → 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of pub-analyzer might be problematic. Click here for more details.

Files changed (41) hide show
  1. pub_analyzer/css/body.tcss +48 -35
  2. pub_analyzer/css/buttons.tcss +0 -4
  3. pub_analyzer/css/main.tcss +18 -12
  4. pub_analyzer/css/summary.tcss +75 -0
  5. pub_analyzer/internal/identifier.py +26 -0
  6. pub_analyzer/internal/report.py +73 -31
  7. pub_analyzer/internal/templates/author/author_summary.typ +112 -0
  8. pub_analyzer/internal/templates/author/report.typ +16 -3
  9. pub_analyzer/internal/templates/author/sources.typ +7 -5
  10. pub_analyzer/internal/templates/author/works.typ +119 -32
  11. pub_analyzer/internal/templates/author/works_extended.typ +5 -6
  12. pub_analyzer/main.py +8 -3
  13. pub_analyzer/models/author.py +9 -25
  14. pub_analyzer/models/concept.py +19 -0
  15. pub_analyzer/models/institution.py +11 -1
  16. pub_analyzer/models/report.py +14 -14
  17. pub_analyzer/models/source.py +59 -3
  18. pub_analyzer/models/topic.py +59 -0
  19. pub_analyzer/models/work.py +23 -0
  20. pub_analyzer/widgets/author/cards.py +6 -5
  21. pub_analyzer/widgets/author/core.py +11 -10
  22. pub_analyzer/widgets/common/summary.py +7 -0
  23. pub_analyzer/widgets/institution/core.py +10 -9
  24. pub_analyzer/widgets/report/cards.py +10 -11
  25. pub_analyzer/widgets/report/concept.py +47 -0
  26. pub_analyzer/widgets/report/core.py +14 -0
  27. pub_analyzer/widgets/report/grants.py +46 -0
  28. pub_analyzer/widgets/report/source.py +11 -4
  29. pub_analyzer/widgets/report/topic.py +55 -0
  30. pub_analyzer/widgets/report/work.py +45 -9
  31. pub_analyzer/widgets/search/results.py +8 -8
  32. pub_analyzer/widgets/sidebar.py +11 -2
  33. {pub_analyzer-0.2.0.dist-info → pub_analyzer-0.4.0.dist-info}/METADATA +8 -7
  34. pub_analyzer-0.4.0.dist-info/RECORD +69 -0
  35. {pub_analyzer-0.2.0.dist-info → pub_analyzer-0.4.0.dist-info}/WHEEL +1 -1
  36. pub_analyzer/css/author.tcss +0 -82
  37. pub_analyzer/css/institution.tcss +0 -82
  38. pub_analyzer/internal/templates/author/author_resume.typ +0 -96
  39. pub_analyzer-0.2.0.dist-info/RECORD +0 -64
  40. {pub_analyzer-0.2.0.dist-info → pub_analyzer-0.4.0.dist-info}/LICENSE +0 -0
  41. {pub_analyzer-0.2.0.dist-info → pub_analyzer-0.4.0.dist-info}/entry_points.txt +0 -0
@@ -1,46 +1,133 @@
1
1
  // Works
2
2
  = Works.
3
3
 
4
- #linebreak()
4
+ #let works_metrics_card(title: "Title", graph, body) = {
5
+ grid(
6
+ rows: (18pt, 175pt, 60pt),
7
+ columns: 100%,
8
+
9
+ [
10
+ #block(width: 100%, height: 100%)[
11
+ #align(center + horizon)[#text(style: "italic")[#title]]
12
+ ]
13
+ ],
14
+ [
15
+ #block(width: 100%, height: 100%)[
16
+ #align(center + horizon)[#graph]
17
+ ]
18
+ ],
19
+ [
20
+ #block(width: 100%, height: 100%, inset: (x: 5pt, y: 10pt))[#body]
21
+ ],
22
+ )
23
+ }
5
24
 
6
25
  #grid(
7
26
  columns: (1fr, 1fr, 1fr),
8
- column-gutter: 30pt,
27
+ column-gutter: 15pt,
9
28
  [
10
- #align(center)[_Citation metrics_]
11
- #parbreak()
12
- - *Count:* {{ report.citation_resume.type_a_count + report.citation_resume.type_b_count }}
13
- - *Type A:* {{ report.citation_resume.type_a_count }}
14
- - *Type B:* {{ report.citation_resume.type_b_count }}
29
+ #let graph = {
30
+ canvas(length: 35%, {
31
+ chart.piechart(
32
+ (
33
+ {{ report.citation_summary.type_a_count }}, // Type A
34
+ {{ report.citation_summary.type_b_count }} // Type B
35
+ ),
36
+ radius: 1,
37
+ slice-style: (BLUE, GREEN),
38
+ outer-label: (content: "%", radius: 115%),
39
+ )
40
+ })
41
+ }
42
+
43
+ #works_metrics_card(title: "Citation metrics", graph)[
44
+ #grid(
45
+ rows: auto, row-gutter: 10pt,
46
+ columns: (1fr, 1fr),
47
+
48
+ grid.cell(colspan: 2)[
49
+ *Count:* {{ report.citation_summary.type_a_count + report.citation_summary.type_b_count }}
50
+ ],
51
+ [#box(height: 7pt, width: 7pt, fill: BLUE) *Type A:* {{ report.citation_summary.type_a_count }}],
52
+ [#box(height: 7pt, width: 7pt, fill: GREEN) *Type B:* {{ report.citation_summary.type_b_count }}],
53
+ )
54
+ ]
15
55
  ],
16
56
  [
17
- #align(center)[_Work Type_]
18
- #parbreak()
19
- {% for work_type in report.works_type_resume %}
20
- - *{{ work_type.type_name }}:* {{ work_type.count }}
21
- {% endfor %}
57
+ #let graph = {
58
+ canvas(length: 35%, {
59
+ chart.columnchart(
60
+ size: (2.45, 2.0),
61
+ y-grid: false,
62
+ bar-style: palette.new(
63
+ base: (stroke: none, fill: none),
64
+ colors: colors
65
+ ),
66
+ (
67
+ {% for work_type in report.works_type_summary[:4] %}
68
+ ("{{ work_type.type_name[:2]|capitalize }}", {{ work_type.count }}),
69
+ {% endfor %}
70
+ )
71
+ )
72
+ })
73
+ }
74
+ #works_metrics_card(title: "Work Type", graph)[
75
+ #grid(
76
+ rows: auto, row-gutter: 10pt,
77
+ columns: (1fr, 1fr),
78
+ column-gutter: 5pt,
79
+
80
+ grid.cell(colspan: 2)[
81
+ *Count:* {{ report.open_access_summary.model_dump().items()|sum(attribute="1") }}
82
+ ],
83
+
84
+ {% for work_type in report.works_type_summary[:4] %}
85
+ [
86
+ #box(height: 7pt, width: 7pt, fill: colors.at({{ loop.index0 }})) *{{ work_type.type_name|capitalize }}:* {{ work_type.count }}
87
+ ],
88
+ {% endfor %}
89
+ )
90
+ ]
22
91
  ],
23
92
  [
24
- #align(center)[_Open Access_]
25
- #parbreak()
26
- #grid(
27
- columns: (1fr, 1fr),
28
- column-gutter: 15pt,
29
- [
30
- - *gold:* {{report.open_access_resume.gold}}
31
- - *green:* {{report.open_access_resume.green}}
32
- - *hybrid:* {{report.open_access_resume.hybrid}}
33
- ],
34
- [
35
- - *bronze:* {{report.open_access_resume.bronze}}
36
- - *closed:* {{report.open_access_resume.closed}}
37
- ],
38
- )
93
+ #let graph = {
94
+ canvas(length: 35%, {
95
+ chart.piechart(
96
+ (
97
+ {{report.open_access_summary.gold}}, // Gold
98
+ {{report.open_access_summary.green}}, // Green
99
+ {{report.open_access_summary.hybrid}}, // Hybrid
100
+ {{report.open_access_summary.bronze}}, // Bronze
101
+ {{report.open_access_summary.closed}}, // Closed
102
+ ),
103
+ radius: 1,
104
+ inner-radius: .4,
105
+ slice-style: (YELLOW, GREEN, RED, BLUE, GRAY),
106
+ outer-label: (content: "%", radius: 115%),
107
+ )
108
+ })
109
+ }
110
+ #works_metrics_card(title: "Open Access", graph)[
111
+ #grid(
112
+ rows: auto, row-gutter: 10pt,
113
+ columns: (1fr, 1fr, 1fr),
114
+ column-gutter: 5pt,
115
+
116
+ grid.cell(colspan: 3)[
117
+ *Count:* {{ report.open_access_summary.model_dump().items()|sum(attribute="1") }}
118
+ ],
119
+
120
+ [#box(height: 7pt, width: 7pt, fill: YELLOW) *Gold:* {{report.open_access_summary.gold}}],
121
+ [#box(height: 7pt, width: 7pt, fill: GREEN) *Green:* {{report.open_access_summary.green}}],
122
+ [#box(height: 7pt, width: 7pt, fill: BLUE) *Bronze:* {{report.open_access_summary.bronze}}],
123
+
124
+ [#box(height: 7pt, width: 7pt, fill: GRAY) *Closed:* {{report.open_access_summary.closed}}],
125
+ [#box(height: 7pt, width: 7pt, fill: RED) *Hybrid:* {{report.open_access_summary.hybrid}}],
126
+ )
127
+ ]
39
128
  ],
40
129
  )
41
130
 
42
- #linebreak()
43
-
44
131
  #align(center, text(11pt)[Works from {{ report.works[0].work.publication_year }} to {{ report.works[-1].work.publication_year }}])
45
132
  #table(
46
133
  columns: (auto, 3fr, auto, auto, auto, auto, auto, auto, auto),
@@ -56,9 +143,9 @@
56
143
  [{{ work.work.type }}],
57
144
  [{% if work.work.ids.doi %}#underline([#link("{{ work.work.ids.doi }}")[DOI]]){% else %}-{% endif %}],
58
145
  [{{ work.work.publication_date }}],
59
- [{{ work.citation_resume.type_a_count + work.citation_resume.type_b_count }}],
60
- [{{ work.citation_resume.type_a_count }}],
61
- [{{ work.citation_resume.type_b_count }}],
146
+ [{{ work.citation_summary.type_a_count + work.citation_summary.type_b_count }}],
147
+ [{{ work.citation_summary.type_a_count }}],
148
+ [{{ work.citation_summary.type_b_count }}],
62
149
  [{{ work.work.open_access.oa_status.value }}],
63
150
  {% endfor %}
64
151
  )
@@ -1,11 +1,10 @@
1
- == Extended info.
2
1
  {% for work in report.works %}
3
2
 
4
3
  {% if not loop.first %}
5
4
  #pagebreak()
6
5
  {% endif %}
7
6
 
8
- === #text()[#"{{ work.work.title.replace('"', '\\"') }}"] <work_{{ loop.index }}>
7
+ == #text()[#"{{ work.work.title.replace('"', '\\"') }}"] <work_{{ loop.index }}>
9
8
 
10
9
  #linebreak()
11
10
 
@@ -39,9 +38,9 @@
39
38
  [
40
39
  #align(center)[_Citation_]
41
40
  #parbreak()
42
- - *Count:* {{ work.citation_resume.type_a_count + work.citation_resume.type_b_count }}
43
- - *Type A:* {{ work.citation_resume.type_a_count }}
44
- - *Type B:* {{ work.citation_resume.type_b_count }}
41
+ - *Count:* {{ work.citation_summary.type_a_count + work.citation_summary.type_b_count }}
42
+ - *Type A:* {{ work.citation_summary.type_a_count }}
43
+ - *Type B:* {{ work.citation_summary.type_b_count }}
45
44
  ],
46
45
  )
47
46
 
@@ -83,7 +82,7 @@
83
82
  // Content
84
83
  {% for location in work.work.locations %}
85
84
  {% if location.source %}
86
- [{{ loop.index }}],
85
+ [#underline([#link(label("source_{{ location.source.id.path.rpartition("/")[2] }}"))[{{ loop.index }}]])],
87
86
  [#underline([#link("{{ location.landing_page_url }}")[#"{{ location.source.display_name }}"]])],
88
87
  [{{ location.source.host_organization_name or "-" }}],
89
88
  [{{ location.source.type }}],
pub_analyzer/main.py CHANGED
@@ -18,16 +18,17 @@ from pub_analyzer.widgets.sidebar import SideBar
18
18
  class PubAnalyzerApp(App[DOMNode]):
19
19
  """Pub Analyzer App entrypoint."""
20
20
 
21
+ TITLE = "Pub Analyzer"
22
+
21
23
  CSS_PATH: ClassVar[CSSPathType | None] = [
22
- "css/author.tcss",
23
24
  "css/body.tcss",
24
25
  "css/buttons.tcss",
25
26
  "css/checkbox.tcss",
26
27
  "css/collapsible.tcss",
27
28
  "css/datatable.tcss",
28
- "css/institution.tcss",
29
29
  "css/main.tcss",
30
30
  "css/report.tcss",
31
+ "css/summary.tcss",
31
32
  "css/search.tcss",
32
33
  "css/tabs.tcss",
33
34
  "css/tree.tcss",
@@ -42,8 +43,12 @@ class PubAnalyzerApp(App[DOMNode]):
42
43
 
43
44
  def compose(self) -> ComposeResult:
44
45
  """Create child widgets for the app."""
46
+ footer = Footer()
47
+ footer.upper_case_keys = True
48
+ footer.ctrl_to_caret = False
49
+
45
50
  yield Body()
46
- yield Footer()
51
+ yield footer
47
52
 
48
53
  def action_toggle_dark(self) -> None:
49
54
  """Toggle dark mode."""
@@ -2,7 +2,7 @@
2
2
 
3
3
  from typing import TypeAlias
4
4
 
5
- from pydantic import BaseModel, ConfigDict, Field, HttpUrl, field_validator
5
+ from pydantic import BaseModel, Field, HttpUrl
6
6
 
7
7
  from pub_analyzer.models.institution import DehydratedInstitution
8
8
 
@@ -16,19 +16,11 @@ AuthorOpenAlexKey: TypeAlias = str
16
16
  class AuthorIDs(BaseModel):
17
17
  """IDs from an Author."""
18
18
 
19
- openalex: str
20
- orcid: str | None = ""
21
- scopus: str | None = ""
22
- twitter: str | None = ""
23
- wikipedia: str | None = ""
24
-
25
- # Allowing a value to be assigned during validation.
26
- model_config = ConfigDict(validate_assignment=True)
27
-
28
- @field_validator("scopus", "twitter", "wikipedia")
29
- def set_default(cls, value: str) -> str:
30
- """Define a default text."""
31
- return value or ""
19
+ openalex: AuthorOpenAlexID
20
+ orcid: HttpUrl | None = None
21
+ scopus: HttpUrl | None = None
22
+ twitter: HttpUrl | None = None
23
+ wikipedia: HttpUrl | None = None
32
24
 
33
25
 
34
26
  class AuthorYearCount(BaseModel):
@@ -60,7 +52,7 @@ class Author(BaseModel):
60
52
  works_count: int
61
53
  cited_by_count: int
62
54
 
63
- last_known_institution: DehydratedInstitution | None
55
+ last_known_institutions: list[DehydratedInstitution]
64
56
  counts_by_year: list[AuthorYearCount]
65
57
 
66
58
  summary_stats: AuthorSummaryStats
@@ -81,16 +73,8 @@ class AuthorResult(BaseModel):
81
73
 
82
74
  id: AuthorOpenAlexID
83
75
  display_name: str
84
- hint: str | None = ""
76
+ hint: str | None = None
85
77
  cited_by_count: int
86
78
  works_count: int
87
79
  entity_type: str
88
- external_id: str | None = ""
89
-
90
- # Allowing a value to be assigned during validation.
91
- model_config = ConfigDict(validate_assignment=True)
92
-
93
- @field_validator("hint", "external_id")
94
- def set_default(cls, value: str) -> str:
95
- """Define a default text."""
96
- return value or ""
80
+ external_id: str | None = None
@@ -0,0 +1,19 @@
1
+ """Concept model from OpenAlex API Schema definition."""
2
+
3
+ from pydantic import BaseModel, HttpUrl
4
+
5
+
6
+ class DehydratedConcept(BaseModel):
7
+ """Stripped-down Concept Model."""
8
+
9
+ id: HttpUrl
10
+ """The OpenAlex ID for this concept."""
11
+ display_name: str
12
+ """The English-language label of the concept."""
13
+
14
+ wikidata: HttpUrl
15
+ """The Wikidata ID for this concept. All OpenAlex concepts are also Wikidata concepts."""
16
+ level: int
17
+ """The level in the concept. Lower-level concepts are more general, and higher-level concepts are more specific."""
18
+ score: float
19
+ """The strength of the connection between the work and this concept (higher is stronger)."""
@@ -77,6 +77,12 @@ class InstitutionRole(BaseModel):
77
77
  works_count: int
78
78
 
79
79
 
80
+ class International(BaseModel):
81
+ """The institution's display name in different languages."""
82
+
83
+ display_name: dict[str, str]
84
+
85
+
80
86
  class Institution(BaseModel):
81
87
  """Universities and other organizations to which authors claim affiliations."""
82
88
 
@@ -87,11 +93,15 @@ class Institution(BaseModel):
87
93
  country_code: str
88
94
  type: InstitutionType
89
95
  homepage_url: HttpUrl | None = None
96
+ image_url: HttpUrl | None = None
97
+
98
+ display_name_acronyms: list[str]
99
+ international: International
90
100
 
91
101
  works_count: int
92
102
  cited_by_count: int
93
- counts_by_year: list[InstitutionYearCount]
94
103
  summary_stats: InstitutionSummaryStats
104
+ counts_by_year: list[InstitutionYearCount]
95
105
 
96
106
  geo: InstitutionGeo
97
107
  roles: list[InstitutionRole]
@@ -6,7 +6,7 @@ from pydantic import BaseModel
6
6
 
7
7
  from .author import Author
8
8
  from .institution import Institution
9
- from .source import DehydratedSource
9
+ from .source import Source
10
10
  from .work import OpenAccessStatus, Work
11
11
 
12
12
 
@@ -24,7 +24,7 @@ class CitationReport(BaseModel):
24
24
  citation_type: CitationType
25
25
 
26
26
 
27
- class CitationResume(BaseModel):
27
+ class CitationSummary(BaseModel):
28
28
  """Summary of citation information in all works."""
29
29
 
30
30
  type_a_count: int = 0
@@ -38,7 +38,7 @@ class CitationResume(BaseModel):
38
38
  self.type_b_count += 1
39
39
 
40
40
 
41
- class OpenAccessResume(BaseModel):
41
+ class OpenAccessSummary(BaseModel):
42
42
  """Open Access Type counter."""
43
43
 
44
44
  gold: int = 0
@@ -75,13 +75,13 @@ class WorkReport(BaseModel):
75
75
  work: Work
76
76
  cited_by: list[CitationReport]
77
77
 
78
- citation_resume: CitationResume
78
+ citation_summary: CitationSummary
79
79
 
80
80
 
81
- class SourcesResume(BaseModel):
81
+ class SourcesSummary(BaseModel):
82
82
  """Sources model with stats."""
83
83
 
84
- sources: list[DehydratedSource]
84
+ sources: list[Source]
85
85
 
86
86
 
87
87
  class AuthorReport(BaseModel):
@@ -90,10 +90,10 @@ class AuthorReport(BaseModel):
90
90
  author: Author
91
91
  works: list[WorkReport]
92
92
 
93
- citation_resume: CitationResume
94
- open_access_resume: OpenAccessResume
95
- works_type_resume: list[WorkTypeCounter]
96
- sources_resume: SourcesResume
93
+ citation_summary: CitationSummary
94
+ open_access_summary: OpenAccessSummary
95
+ works_type_summary: list[WorkTypeCounter]
96
+ sources_summary: SourcesSummary
97
97
 
98
98
 
99
99
  class InstitutionReport(BaseModel):
@@ -102,7 +102,7 @@ class InstitutionReport(BaseModel):
102
102
  institution: Institution
103
103
  works: list[WorkReport]
104
104
 
105
- citation_resume: CitationResume
106
- open_access_resume: OpenAccessResume
107
- works_type_resume: list[WorkTypeCounter]
108
- sources_resume: SourcesResume
105
+ citation_summary: CitationSummary
106
+ open_access_summary: OpenAccessSummary
107
+ works_type_summary: list[WorkTypeCounter]
108
+ sources_summary: SourcesSummary
@@ -1,21 +1,77 @@
1
1
  """Sources models from OpenAlex API Schema definition."""
2
2
 
3
- from pydantic import BaseModel, HttpUrl
3
+ from pydantic import BaseModel, Field, HttpUrl
4
+
5
+
6
+ class SourceSummaryStats(BaseModel):
7
+ """Citation metrics for this Source."""
8
+
9
+ two_yr_mean_citedness: float = Field(..., alias="2yr_mean_citedness")
10
+ """The 2-year mean citedness for this source. Also known as impact factor."""
11
+ h_index: int
12
+ """The h-index for this source."""
13
+ i10_index: int
14
+ """The i-10 index for this source."""
15
+
16
+
17
+ class SourceYearCount(BaseModel):
18
+ """Summary of published papers and number of citations in a year."""
19
+
20
+ year: int
21
+ """Year."""
22
+ works_count: int
23
+ """The number of Works this source hosts in this year."""
24
+ cited_by_count: int
25
+ """The total number of Works that cite a Work hosted in this source in this year."""
4
26
 
5
27
 
6
28
  class DehydratedSource(BaseModel):
7
29
  """Stripped-down Source Model."""
8
30
 
9
31
  id: HttpUrl
32
+ """The OpenAlex ID for this source."""
10
33
  display_name: str
34
+ """The name of the source."""
11
35
 
12
36
  issn_l: str | None = None
37
+ """The ISSN-L identifying this source. The ISSN-L designating a single canonical ISSN
38
+ for all media versions of the title. It's usually the same as the print ISSN.
39
+ """
13
40
  issn: list[str] | None = None
41
+ """The ISSNs used by this source. An ISSN identifies all continuing resources, irrespective
42
+ of their medium (print or electronic). [More info](https://www.issn.org/){target=_blank}.
43
+ """
14
44
 
15
- is_oa: bool | None = False
16
- is_in_doaj: bool | None = False
45
+ is_oa: bool
46
+ """Whether this is currently fully-open-access source."""
47
+ is_in_doaj: bool
48
+ """Whether this is a journal listed in the [Directory of Open Access Journals](https://doaj.org){target=_blank} (DOAJ)."""
17
49
 
18
50
  host_organization: HttpUrl | None = None
51
+ """The host organization for this source as an OpenAlex ID. This will be an
52
+ [Institution.id][pub_analyzer.models.institution.Institution.id] if the source is a repository,
53
+ and a Publisher.id if the source is a journal, conference, or eBook platform
54
+ """
19
55
  host_organization_name: str | None = None
56
+ """The display_name from the host_organization."""
20
57
 
21
58
  type: str
59
+ """The type of source, which will be one of: `journal`, `repository`, `conference`,
60
+ `ebook platform`, or `book series`.
61
+ """
62
+
63
+
64
+ class Source(DehydratedSource):
65
+ """Where works are hosted."""
66
+
67
+ homepage_url: HttpUrl | None = None
68
+ """The homepage for this source's website."""
69
+
70
+ is_in_doaj: bool
71
+ """Whether this is a journal listed in the Directory of Open Access Journals (DOAJ)."""
72
+
73
+ summary_stats: SourceSummaryStats
74
+ """Citation metrics for this source."""
75
+
76
+ counts_by_year: list[SourceYearCount]
77
+ """works_count and cited_by_count for each of the last ten years, binned by year."""
@@ -0,0 +1,59 @@
1
+ """Topics models from OpenAlex API Schema definition."""
2
+
3
+ from pydantic import BaseModel, HttpUrl
4
+
5
+
6
+ class TopicIDs(BaseModel):
7
+ """External identifiers for a Topic."""
8
+
9
+ openalex: HttpUrl
10
+ """The OpenAlex ID for this Topic."""
11
+ wikipedia: HttpUrl | None = None
12
+ """This topic's Wikipedia page URL."""
13
+
14
+
15
+ class TopicLevel(BaseModel):
16
+ """Topic level information."""
17
+
18
+ id: HttpUrl
19
+ """ID for the topic level. For more info, consult the
20
+ [OpenAlex topic mapping table](https://docs.google.com/spreadsheets/d/1v-MAq64x4YjhO7RWcB-yrKV5D_2vOOsxl4u6GBKEXY8/){target=_blank}.
21
+ """
22
+ display_name: str
23
+ """The English-language label of the level."""
24
+
25
+
26
+ class DehydratedTopic(BaseModel):
27
+ """Stripped-down Topic Model."""
28
+
29
+ id: HttpUrl
30
+ """The OpenAlex ID for this Topic."""
31
+ display_name: str
32
+ """The English-language label of the topic."""
33
+ score: float
34
+ """The strength of the connection between the work and this topic (higher is stronger)."""
35
+
36
+ domain: TopicLevel
37
+ """The highest level in the Topics structure."""
38
+ field: TopicLevel
39
+ """The second-highest level in the Topics structure."""
40
+ subfield: TopicLevel
41
+ """The third-highest level in the Topics structure."""
42
+
43
+
44
+ class Topic(DehydratedTopic):
45
+ """Labels which can be used to describe what a paper is about."""
46
+
47
+ ids: TopicIDs
48
+ """All the external identifiers for a Topic."""
49
+ description: str
50
+ """A description of this topic, generated by AI."""
51
+ keywords: list[str]
52
+ """Keywords consisting of one or several words each, meant to represent
53
+ the content of the papers in the topic.
54
+ """
55
+
56
+ works_count: int
57
+ """The number of works tagged with this topic."""
58
+ cited_by_count: int
59
+ """The number of citations to works that have been tagged with this topic."""
@@ -6,7 +6,9 @@ from typing import Any
6
6
  from pydantic import BaseModel, HttpUrl, field_validator
7
7
 
8
8
  from .author import DehydratedAuthor
9
+ from .concept import DehydratedConcept
9
10
  from .source import DehydratedSource
11
+ from .topic import DehydratedTopic
10
12
 
11
13
 
12
14
  class WorkIDs(BaseModel):
@@ -72,6 +74,22 @@ class ArticleProcessingCharge(BaseModel):
72
74
  value_usd: int | None
73
75
 
74
76
 
77
+ class Grant(BaseModel):
78
+ """Grant Model Object from OpenAlex API definition."""
79
+
80
+ funder: HttpUrl
81
+ funder_display_name: str
82
+ award_id: str | None = None
83
+
84
+
85
+ class Keyword(BaseModel):
86
+ """Keyword extracted from the work's title and confidence score."""
87
+
88
+ id: HttpUrl
89
+ display_name: str
90
+ score: float
91
+
92
+
75
93
  class Work(BaseModel):
76
94
  """Work Model Object from OpenAlex API definition."""
77
95
 
@@ -97,6 +115,11 @@ class Work(BaseModel):
97
115
  To use a verified number that respects the applied filters use [WorkReport][pub_analyzer.models.report.WorkReport].
98
116
  """
99
117
 
118
+ grants: list[Grant]
119
+ keywords: list[Keyword]
120
+ concepts: list[DehydratedConcept]
121
+ topics: list[DehydratedTopic]
122
+
100
123
  referenced_works: list[HttpUrl]
101
124
  cited_by_api_url: HttpUrl
102
125
 
@@ -54,11 +54,12 @@ class LastInstitutionCard(Card):
54
54
  """Compose card."""
55
55
  yield Label("[italic]Last Institution:[/italic]", classes="card-title")
56
56
 
57
- if self.author.last_known_institution:
58
- ror = self.author.last_known_institution.ror
59
- institution_name = self.author.last_known_institution.display_name
57
+ if self.author.last_known_institutions:
58
+ last_known_institution = self.author.last_known_institutions[0]
59
+ ror = last_known_institution.ror
60
+ institution_name = last_known_institution.display_name
60
61
 
61
62
  with Vertical(classes="card-container"):
62
63
  yield Label(f"""[bold]Name:[/bold] [@click=app.open_link('{quote(str(ror))}')]{institution_name}[/]""")
63
- yield Label(f"[bold]Country:[/bold] {self.author.last_known_institution.country_code}")
64
- yield Label(f"[bold]Type:[/bold] {self.author.last_known_institution.type.value}")
64
+ yield Label(f"[bold]Country:[/bold] {last_known_institution.country_code}")
65
+ yield Label(f"[bold]Type:[/bold] {last_known_institution.type.value}")