docketry 0.1.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.
- docketry/__init__.py +8 -0
- docketry/cite.py +253 -0
- docketry/cite_client.py +68 -0
- docketry/classify.py +60 -0
- docketry/cli.py +634 -0
- docketry/config.py +77 -0
- docketry/envelope.py +166 -0
- docketry/extract.py +195 -0
- docketry/gates/__init__.py +28 -0
- docketry/gates/builtin.py +164 -0
- docketry/gates/classifier.py +28 -0
- docketry/gates/notice.py +59 -0
- docketry/lint.py +172 -0
- docketry/mailbox.py +77 -0
- docketry/manifest.py +105 -0
- docketry/notices.py +268 -0
- docketry/pipeline.py +157 -0
- docketry/store.py +340 -0
- docketry/webui.py +202 -0
- docketry-0.1.0.dist-info/METADATA +343 -0
- docketry-0.1.0.dist-info/RECORD +25 -0
- docketry-0.1.0.dist-info/WHEEL +4 -0
- docketry-0.1.0.dist-info/entry_points.txt +2 -0
- docketry-0.1.0.dist-info/licenses/LICENSE +202 -0
- docketry-0.1.0.dist-info/licenses/NOTICE +4 -0
docketry/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Docketry: a local, gate-enforced port that email flows into.
|
|
2
|
+
|
|
3
|
+
Working name — Docketry is the base layer of a family of small open-source
|
|
4
|
+
legal workflow tools. It never reaches into a mailbox the firm works from;
|
|
5
|
+
it drains a dedicated intake mailbox the firm forwards into, normalizes each
|
|
6
|
+
message, and enforces guardrail gates before anything moves downstream.
|
|
7
|
+
"""
|
|
8
|
+
__version__ = "0.1.0"
|
docketry/cite.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Citation verification: the existence + name + quote + pin check.
|
|
2
|
+
|
|
3
|
+
Four factual checks against CourtListener, and nothing else:
|
|
4
|
+
|
|
5
|
+
1. The cited case exists (volume/reporter/page resolves).
|
|
6
|
+
2. The case NAME matches what that reporter cite actually resolves to —
|
|
7
|
+
the hallucination class where a real citation carries an invented name.
|
|
8
|
+
3. Quoted language attributed to the case actually appears in the opinion.
|
|
9
|
+
4. A pin cite points at the page where the quoted language sits (via the
|
|
10
|
+
opinion's star pagination, when the source provides it).
|
|
11
|
+
|
|
12
|
+
This module never says anything is good law, current, binding, or apt. It
|
|
13
|
+
reports that a citation string does not match the document it points to, and
|
|
14
|
+
it degrades loudly: no network means extraction-only, never a silent pass.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
|
|
21
|
+
_GENERIC_TOKENS = {
|
|
22
|
+
"state", "company", "companies", "insurance", "corp", "corporation",
|
|
23
|
+
"inc", "llc", "llp", "co", "of", "the", "and", "in", "re", "ex", "rel",
|
|
24
|
+
"et", "al", "v", "vs", "a", "an", "county", "city", "board", "dept",
|
|
25
|
+
"department", "america", "american", "national", "united", "states",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
_QUOTE_RX = re.compile(r'[“"]([^“”"]{25,}?)[”"]')
|
|
29
|
+
_STAR_RX = re.compile(r'<span[^>]*class="star-pagination"[^>]*label="(\d+)"[^>]*>')
|
|
30
|
+
_TAG_RX = re.compile(r"<[^>]+>")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class CiteError(RuntimeError):
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class Citation:
|
|
39
|
+
text: str # the citation as written, e.g. "260 So. 3d 323"
|
|
40
|
+
plaintiff: str
|
|
41
|
+
defendant: str
|
|
42
|
+
pin_page: int | None
|
|
43
|
+
span: tuple[int, int] # character span in the source text
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class Lookup:
|
|
48
|
+
exists: bool
|
|
49
|
+
case_name: str = ""
|
|
50
|
+
cluster_id: int | None = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class CiteFinding:
|
|
55
|
+
citation: str
|
|
56
|
+
check: str # exists | name | quote | pin
|
|
57
|
+
severity: str # fail | warn | info
|
|
58
|
+
summary: str
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class Report:
|
|
63
|
+
citations: list[Citation]
|
|
64
|
+
short_citations: int = 0
|
|
65
|
+
findings: list[CiteFinding] = field(default_factory=list)
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def failed(self) -> bool:
|
|
69
|
+
return any(f.severity == "fail" for f in self.findings)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _normalize(s: str) -> str:
|
|
73
|
+
s = s.replace("“", '"').replace("”", '"')
|
|
74
|
+
s = s.replace("‘", "'").replace("’", "'")
|
|
75
|
+
return re.sub(r"\s+", " ", s).strip().lower()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _tokens(name: str) -> set[str]:
|
|
79
|
+
return {
|
|
80
|
+
t for t in re.findall(r"[a-z0-9]+", name.lower())
|
|
81
|
+
if len(t) > 2 and t not in _GENERIC_TOKENS
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def extract_citations(text: str) -> list[Citation]:
|
|
86
|
+
"""Full case citations via eyecite. Needs the 'cite' extra."""
|
|
87
|
+
return _extract(text)[0]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def citation_inventory(text: str) -> tuple[list[Citation], int]:
|
|
91
|
+
"""(full citations, count of short-form/supra/id citations).
|
|
92
|
+
|
|
93
|
+
Short-form cites ("329 So. 3d at 153") depend on an antecedent full cite;
|
|
94
|
+
when the fulls live outside the document (a redline, an excerpt, a brief
|
|
95
|
+
section), the shorts are UNVERIFIABLE AS WRITTEN — and that must be said
|
|
96
|
+
loudly, never reported as "0 citations found".
|
|
97
|
+
"""
|
|
98
|
+
return _extract(text)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _extract(text: str) -> tuple[list[Citation], int]:
|
|
102
|
+
try:
|
|
103
|
+
import eyecite
|
|
104
|
+
from eyecite.models import (
|
|
105
|
+
FullCaseCitation, IdCitation, ShortCaseCitation, SupraCitation,
|
|
106
|
+
)
|
|
107
|
+
except ImportError:
|
|
108
|
+
raise CiteError(
|
|
109
|
+
"citation extraction needs the 'cite' extra:"
|
|
110
|
+
" pip install 'docketry[cite]'"
|
|
111
|
+
) from None
|
|
112
|
+
|
|
113
|
+
out: list[Citation] = []
|
|
114
|
+
n_short = 0
|
|
115
|
+
for cite in eyecite.get_citations(text):
|
|
116
|
+
if isinstance(cite, (ShortCaseCitation, SupraCitation, IdCitation)):
|
|
117
|
+
n_short += 1
|
|
118
|
+
continue
|
|
119
|
+
if not isinstance(cite, FullCaseCitation):
|
|
120
|
+
continue
|
|
121
|
+
meta = cite.metadata
|
|
122
|
+
pin = None
|
|
123
|
+
if meta.pin_cite:
|
|
124
|
+
m = re.search(r"\d+", meta.pin_cite)
|
|
125
|
+
pin = int(m.group()) if m else None
|
|
126
|
+
out.append(
|
|
127
|
+
Citation(
|
|
128
|
+
text=cite.corrected_citation(),
|
|
129
|
+
plaintiff=meta.plaintiff or "",
|
|
130
|
+
defendant=meta.defendant or "",
|
|
131
|
+
pin_page=pin,
|
|
132
|
+
span=cite.span(),
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
return out, n_short
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def name_matches(plaintiff: str, defendant: str, resolved_name: str) -> bool:
|
|
139
|
+
"""Side-aware significant-token check of the cited name vs the resolved one.
|
|
140
|
+
|
|
141
|
+
Every cited side that has significant tokens must land at least one of
|
|
142
|
+
them in the corresponding side of the resolved caption (either order, to
|
|
143
|
+
tolerate cross-appeals); a consolidated/inre caption without "v." is
|
|
144
|
+
checked against the whole name.
|
|
145
|
+
"""
|
|
146
|
+
cited = [(i, s) for i, s in enumerate((_tokens(plaintiff), _tokens(defendant))) if s]
|
|
147
|
+
if not cited:
|
|
148
|
+
return True # nothing asserted, nothing to contradict
|
|
149
|
+
resolved_sides = [
|
|
150
|
+
_tokens(part) for part in re.split(r"\s+v\.?\s+", resolved_name, maxsplit=1)
|
|
151
|
+
]
|
|
152
|
+
if len(resolved_sides) == 2:
|
|
153
|
+
if all(toks & resolved_sides[i] for i, toks in cited):
|
|
154
|
+
return True
|
|
155
|
+
# A full swap is a legitimate cross-appeal caption; a single cited
|
|
156
|
+
# side matching only the OPPOSITE side is the classic wrong-name
|
|
157
|
+
# hallucination (e.g. the defendant's surname promoted to plaintiff),
|
|
158
|
+
# so the swap is only honored when BOTH sides swap cleanly.
|
|
159
|
+
if len(cited) == 2 and all(toks & resolved_sides[1 - i] for i, toks in cited):
|
|
160
|
+
return True
|
|
161
|
+
return False
|
|
162
|
+
whole = _tokens(resolved_name)
|
|
163
|
+
return all(toks & whole for _, toks in cited)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def quotes_near(text: str, span: tuple[int, int], *, window: int = 600) -> list[str]:
|
|
167
|
+
"""Quoted passages (>=25 chars) in the window before the citation."""
|
|
168
|
+
region = text[max(0, span[0] - window):span[0]]
|
|
169
|
+
return [m.group(1) for m in _QUOTE_RX.finditer(region)]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def star_pages(opinion_html: str) -> list[tuple[int, str]]:
|
|
173
|
+
"""(page_label, normalized text of the segment that FOLLOWS it)."""
|
|
174
|
+
out: list[tuple[int, str]] = []
|
|
175
|
+
matches = list(_STAR_RX.finditer(opinion_html))
|
|
176
|
+
for i, m in enumerate(matches):
|
|
177
|
+
end = matches[i + 1].start() if i + 1 < len(matches) else len(opinion_html)
|
|
178
|
+
segment = _TAG_RX.sub(" ", opinion_html[m.end():end])
|
|
179
|
+
out.append((int(m.group(1)), _normalize(segment)))
|
|
180
|
+
return out
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def verify(text: str, client) -> Report:
|
|
184
|
+
"""Run the four checks over every full citation in `text`.
|
|
185
|
+
|
|
186
|
+
`client` provides lookup(citation_text) -> Lookup and
|
|
187
|
+
opinion_text(cluster_id) -> (plain_text, html_or_None).
|
|
188
|
+
"""
|
|
189
|
+
citations, n_short = citation_inventory(text)
|
|
190
|
+
report = Report(citations=citations, short_citations=n_short)
|
|
191
|
+
if n_short and not citations:
|
|
192
|
+
report.findings.append(CiteFinding(
|
|
193
|
+
"(short-form)", "exists", "fail",
|
|
194
|
+
f"{n_short} short-form citation(s) (e.g. '329 So. 3d at 153') with"
|
|
195
|
+
" no full citation anywhere in this document — unverifiable as"
|
|
196
|
+
" written; a filed brief must carry the full cites",
|
|
197
|
+
))
|
|
198
|
+
elif n_short:
|
|
199
|
+
report.findings.append(CiteFinding(
|
|
200
|
+
"(short-form)", "exists", "info",
|
|
201
|
+
f"{n_short} short-form citation(s) ride on the full citations"
|
|
202
|
+
" verified above",
|
|
203
|
+
))
|
|
204
|
+
for cite in citations:
|
|
205
|
+
lookup: Lookup = client.lookup(cite.text)
|
|
206
|
+
if not lookup.exists:
|
|
207
|
+
report.findings.append(
|
|
208
|
+
CiteFinding(cite.text, "exists", "fail",
|
|
209
|
+
f"{cite.text}: no case found at this citation")
|
|
210
|
+
)
|
|
211
|
+
continue
|
|
212
|
+
cited_name = f"{cite.plaintiff} v. {cite.defendant}".strip(" v.")
|
|
213
|
+
if not name_matches(cite.plaintiff, cite.defendant, lookup.case_name):
|
|
214
|
+
report.findings.append(
|
|
215
|
+
CiteFinding(
|
|
216
|
+
cite.text, "name", "fail",
|
|
217
|
+
f"cited as \"{cited_name}\" but {cite.text} resolves to"
|
|
218
|
+
f" \"{lookup.case_name}\"",
|
|
219
|
+
)
|
|
220
|
+
)
|
|
221
|
+
quotes = quotes_near(text, cite.span)
|
|
222
|
+
if quotes and lookup.cluster_id is not None:
|
|
223
|
+
opinion_plain, opinion_html = client.opinion_text(lookup.cluster_id)
|
|
224
|
+
norm_opinion = _normalize(opinion_plain)
|
|
225
|
+
pages = star_pages(opinion_html) if opinion_html else []
|
|
226
|
+
for q in quotes:
|
|
227
|
+
nq = _normalize(q).rstrip(".;:,!?")
|
|
228
|
+
if nq not in norm_opinion and not any(nq in seg for _, seg in pages):
|
|
229
|
+
report.findings.append(
|
|
230
|
+
CiteFinding(
|
|
231
|
+
cite.text, "quote", "fail",
|
|
232
|
+
f"quoted language not found in {lookup.case_name}:"
|
|
233
|
+
f" \"{q[:80]}...\"" if len(q) > 80 else
|
|
234
|
+
f"quoted language not found in {lookup.case_name}:"
|
|
235
|
+
f" \"{q}\"",
|
|
236
|
+
)
|
|
237
|
+
)
|
|
238
|
+
elif cite.pin_page is not None and pages:
|
|
239
|
+
hit = [label for label, seg in pages if nq in seg]
|
|
240
|
+
if hit and cite.pin_page not in hit:
|
|
241
|
+
report.findings.append(
|
|
242
|
+
CiteFinding(
|
|
243
|
+
cite.text, "pin", "warn",
|
|
244
|
+
f"pin cite says p. {cite.pin_page} but the"
|
|
245
|
+
f" quoted language sits at p. {hit[0]}",
|
|
246
|
+
)
|
|
247
|
+
)
|
|
248
|
+
if not report.findings or report.findings[-1].citation != cite.text:
|
|
249
|
+
report.findings.append(
|
|
250
|
+
CiteFinding(cite.text, "exists", "info",
|
|
251
|
+
f"{cite.text} -> {lookup.case_name}")
|
|
252
|
+
)
|
|
253
|
+
return report
|
docketry/cite_client.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""CourtListener client for citation verification. Needs the 'cite' extra.
|
|
2
|
+
|
|
3
|
+
Thin and honest: the user's own token (COURTLISTENER_TOKEN), tight timeouts,
|
|
4
|
+
and every network failure surfaces as CiteError — offline degrades to
|
|
5
|
+
extraction-only in the CLI, never to a silent pass.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
from .cite import CiteError, Lookup
|
|
12
|
+
|
|
13
|
+
BASE = "https://www.courtlistener.com/api/rest/v4"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CourtListenerClient:
|
|
17
|
+
def __init__(self, token: str | None = None, base_url: str = BASE, timeout: float = 30.0):
|
|
18
|
+
try:
|
|
19
|
+
import httpx
|
|
20
|
+
except ImportError:
|
|
21
|
+
raise CiteError(
|
|
22
|
+
"network verification needs the 'cite' extra:"
|
|
23
|
+
" pip install 'docketry[cite]'"
|
|
24
|
+
) from None
|
|
25
|
+
headers = {"User-Agent": "docketry cite-verify"}
|
|
26
|
+
token = token or os.environ.get("COURTLISTENER_TOKEN")
|
|
27
|
+
if token:
|
|
28
|
+
headers["Authorization"] = f"Token {token}"
|
|
29
|
+
self._http = httpx.Client(base_url=base_url, headers=headers, timeout=timeout)
|
|
30
|
+
|
|
31
|
+
def close(self) -> None:
|
|
32
|
+
self._http.close()
|
|
33
|
+
|
|
34
|
+
def lookup(self, citation_text: str) -> Lookup:
|
|
35
|
+
try:
|
|
36
|
+
resp = self._http.post("/citation-lookup/", data={"text": citation_text})
|
|
37
|
+
resp.raise_for_status()
|
|
38
|
+
results = resp.json()
|
|
39
|
+
except Exception as e:
|
|
40
|
+
raise CiteError(f"citation lookup failed for {citation_text!r}: {e}") from e
|
|
41
|
+
for row in results:
|
|
42
|
+
if row.get("status") == 200 and row.get("clusters"):
|
|
43
|
+
cluster = row["clusters"][0]
|
|
44
|
+
return Lookup(
|
|
45
|
+
exists=True,
|
|
46
|
+
case_name=cluster.get("case_name", ""),
|
|
47
|
+
cluster_id=cluster.get("id"),
|
|
48
|
+
)
|
|
49
|
+
return Lookup(exists=False)
|
|
50
|
+
|
|
51
|
+
def opinion_text(self, cluster_id: int) -> tuple[str, str | None]:
|
|
52
|
+
try:
|
|
53
|
+
resp = self._http.get(
|
|
54
|
+
"/opinions/", params={"cluster__id": cluster_id, "fields": "plain_text,xml_harvard,html_with_citations"}
|
|
55
|
+
)
|
|
56
|
+
resp.raise_for_status()
|
|
57
|
+
rows = resp.json().get("results", [])
|
|
58
|
+
except Exception as e:
|
|
59
|
+
raise CiteError(f"opinion fetch failed for cluster {cluster_id}: {e}") from e
|
|
60
|
+
if not rows:
|
|
61
|
+
return "", None
|
|
62
|
+
op = rows[0]
|
|
63
|
+
html = op.get("xml_harvard") or op.get("html_with_citations") or None
|
|
64
|
+
plain = op.get("plain_text") or ""
|
|
65
|
+
if not plain and html:
|
|
66
|
+
import re
|
|
67
|
+
plain = re.sub(r"<[^>]+>", " ", html)
|
|
68
|
+
return plain, html
|
docketry/classify.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Deterministic litigation-document classifier.
|
|
2
|
+
|
|
3
|
+
Types a filed document from its title first (high confidence), body anchors
|
|
4
|
+
second (medium), and falls back to correspondence (low). No model calls, no
|
|
5
|
+
network, no cost — LLMs are a last resort that this module deliberately is
|
|
6
|
+
not. The classifier only ever PROPOSES: writes are staged for approval and
|
|
7
|
+
applied fill-only by a human with the declared authority (see store/CLI).
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
LABELS = (
|
|
14
|
+
"amended_complaint", "complaint", "answer",
|
|
15
|
+
"motion_msj", "motion_dismiss", "motion_compel", "motion",
|
|
16
|
+
"order", "notice_of_hearing", "notice",
|
|
17
|
+
"discovery_request", "discovery_response",
|
|
18
|
+
"subpoena", "deposition", "correspondence",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
_T = [ # title anchors, ordered: first match wins
|
|
22
|
+
("amended_complaint", r"amended\s+(?:complaint|petition)"),
|
|
23
|
+
("order", r"^(?:agreed\s+|proposed\s+)?order\b|order\s+(?:granting|denying|on|setting)"),
|
|
24
|
+
("motion_msj", r"motion\s+for\s+(?:final\s+)?summary\s+judgment"),
|
|
25
|
+
("motion_dismiss", r"motion\s+to\s+dismiss"),
|
|
26
|
+
("motion_compel", r"motion\s+to\s+compel"),
|
|
27
|
+
("deposition", r"(?:notice\s+of\s+taking\s+)?deposition\s+of|depo(?:sition)?\s+transcript"),
|
|
28
|
+
("notice_of_hearing", r"notice\s+of\s+hearing"),
|
|
29
|
+
("subpoena", r"subpoena"),
|
|
30
|
+
("discovery_response", r"(?:response|objection)s?\s+to\s+.*(?:interrogator|production|admission|discovery)"),
|
|
31
|
+
("discovery_request", r"interrogator|request\s+(?:for|to)\s+produc|request\s+for\s+admission"),
|
|
32
|
+
("answer", r"\banswer\b.*affirmative\s+defenses|\banswer\s+to\b|defendant'?s?\s+answer"),
|
|
33
|
+
("motion", r"\bmotion\b"),
|
|
34
|
+
("complaint", r"\b(?:complaint|petition)\b"),
|
|
35
|
+
("notice", r"\bnotice\b"),
|
|
36
|
+
]
|
|
37
|
+
_TITLE_ANCHORS = [(label, re.compile(rx, re.IGNORECASE)) for label, rx in _T]
|
|
38
|
+
|
|
39
|
+
_B = [ # body anchors, checked in the first 3000 chars
|
|
40
|
+
("order", r"ORDERED\s+AND\s+ADJUDGED|it\s+is\s+hereby\s+ORDERED"),
|
|
41
|
+
("complaint", r"COMES\s+NOW.{0,200}(?:complaint|sues?\s+defendant)"),
|
|
42
|
+
("discovery_request", r"propounds?\s+the\s+following\s+interrogator"),
|
|
43
|
+
("answer", r"answers?\s+the\s+complaint\s+and\s+(?:asserts?\s+)?affirmative\s+defenses"),
|
|
44
|
+
("deposition", r"APPEARANCES.{0,600}(?:COURT\s+REPORTER|STENOGRAPH)"),
|
|
45
|
+
]
|
|
46
|
+
_BODY_ANCHORS = [(label, re.compile(rx, re.IGNORECASE | re.DOTALL)) for label, rx in _B]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def classify(title: str, text: str = "") -> tuple[str, str]:
|
|
50
|
+
"""(label, tier) — tier: high (title anchor) / medium (body) / low."""
|
|
51
|
+
title = (title or "").replace("_", " ").replace("-", " ")
|
|
52
|
+
for label, rx in _TITLE_ANCHORS:
|
|
53
|
+
if rx.search(title):
|
|
54
|
+
return label, "high"
|
|
55
|
+
head = (text or "")[:3000]
|
|
56
|
+
if head:
|
|
57
|
+
for label, rx in _BODY_ANCHORS:
|
|
58
|
+
if rx.search(head):
|
|
59
|
+
return label, "medium"
|
|
60
|
+
return "correspondence", "low"
|