Skip to content
Merged
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
16 changes: 12 additions & 4 deletions src/pydocvi/invariants.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ def check_entry(
_p02(msgid, msgstr, spans),
_p04(msgid, msgstr),
_p05(msgid, msgstr),
_p06(msgstr),
_p06(msgid, msgstr),
_p07(msgstr),
_p08(msgstr),
_p09(msgid, msgstr, spans),
Expand Down Expand Up @@ -163,9 +163,17 @@ def _p05(msgid: str, msgstr: str) -> Violation | None:
return None


def _p06(msgstr: str) -> Violation | None:
"""No narration, in either language."""
found = textguard.find(msgstr)
def _p06(msgid: str, msgstr: str) -> Violation | None:
"""No narration, in either language, that the source did not license.

Against the pair rather than the translation alone. The English is what
decides whether "Lưu ý:" opening a string is the model talking or the model
translating a ``msgid`` that opens with "Note:", and reading the Vietnamese
on its own cannot tell those apart. It got it wrong in the first real run and
threw away a whole batch of good translations for it, which this rule can do
because it is one of the three that reject the batch rather than the entry.
"""
found = textguard.find(msgstr, msgid)
return Violation(rule="P06", detail=str(found)) if found else None


Expand Down
143 changes: 96 additions & 47 deletions src/pydocvi/textguard.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
"""Catching a model that talked instead of translating.

Every phrase here has one property: no Vietnamese translation of a sentence from
the Python documentation contains it. "Here is the translation" is not a
sentence about ``dict.setdefault``, and neither is an apology.
the Python documentation contains it *unless the English said the same thing*.
"Here is the translation" is not a sentence about ``dict.setdefault``, and
neither is an apology.

That qualification is the whole design, and it was not here at first. The guard
read the translation alone, so a faithful rendering of a ``msgid`` beginning
``Note:`` came back as ``Lưu ý:`` and was refused as narration. It cost more
than a wrong entry: ``P06`` rejects the whole batch, so one false positive threw
away 28 good translations in the first real run, and 89 of the corpus's 2 776
batches contain a string shaped that way. Each phrase now carries the English it
is allowed to be a translation of, and a phrase whose licence the ``msgid``
matches is left alone.

The lists are short on purpose. A long list of phrases becomes a filter that
occasionally deletes a real translation, and a wrongly refused entry is worse
Expand All @@ -13,50 +23,72 @@
import re
from dataclasses import dataclass

#: English narration. Anchored at the start of the string or of a line, because
#: "the following" appears legitimately inside documentation prose all the time
#: and only means narration when the answer opens with it.
_ENGLISH = (
r"here (?:is|are) the (?:translation|translated|following)",
r"(?:i|we) (?:have|'ve) translated",
r"(?:i|we) (?:cannot|can't|am unable|are unable)",
r"(?:i'm |i am )?sorry",
r"as an ai\b",
r"(?:note|please note) that",
r"the (?:above|following) (?:is|are) (?:the |my )?translation",
r"let me know if",
r"hope this helps",
r"translation:",
#: A phrase the model has no business writing, and the English that would make
#: it a translation rather than an aside.
#:
#: ``licence`` is deliberately looser than the phrase it excuses. It is asked
#: only whether the source raised the subject at all, because the Vietnamese for
#: "note" is "lưu ý" wherever in the sentence the English put it, and a licence
#: that insisted on the same position would fail on every sentence Vietnamese
#: reorders. Being loose costs a narrated entry that happens to sit beside a
#: source mentioning notes; being strict costs correct translations by the
#: hundred, and the first is the cheaper way to be wrong.
_PHRASES: tuple[tuple[str, str], ...] = (
# English narration. Anchored where the phrase only means narration at the
# start of a line, loose where it means nothing else anywhere.
(r"here (?:is|are) the (?:translation|translated|following)", ""),
(r"(?:i|we) (?:have|'ve) translated", ""),
(r"(?:i|we) (?:cannot|can't|am unable|are unable)", ""),
(r"(?:i'm |i am )?sorry", r"\bsorry\b"),
(r"as an ai\b", ""),
(r"(?:note|please note) that", r"\bnotes?\b"),
(r"the (?:above|following) (?:is|are) (?:the |my )?translation", ""),
(r"let me know if", ""),
(r"hope this helps", ""),
(r"translation:", r"\btranslations?\b"),
# Vietnamese narration. A model that switches language mid-task narrates in
# the target language as readily as in the source, and this half of the list
# is the one that would be missing from a guard written by somebody who only
# tested in English.
(r"đây là bản dịch", ""),
(r"dưới đây là", r"\b(?:the following|below|as follows|here(?:'s| is| are))\b"),
(r"sau đây là bản dịch", ""),
(r"tôi (?:đã dịch|không thể|xin lỗi)", ""),
(r"xin lỗi", r"\bsorry\b"),
(r"bản dịch(?: tiếng việt)?:", r"\btranslations?\b"),
(r"lưu ý(?: rằng)?:", r"\bnotes?\b"),
(r"hy vọng", r"\bhopes?\b"),
)

#: Vietnamese narration. A model that switches language mid-task narrates in the
#: target language as readily as in the source, and this half of the list is the
#: one that would be missing from a guard written by somebody who only tested in
#: English.
_VIETNAMESE = (
r"đây là bản dịch",
r"dưới đây là",
r"sau đây là bản dịch",
r"tôi (?:đã dịch|không thể|xin lỗi)",
r"xin lỗi",
r"bản dịch(?: tiếng việt)?:",
r"lưu ý(?: rằng)?:",
r"hy vọng",
)
_FLAGS = re.IGNORECASE | re.MULTILINE


@dataclass(frozen=True, slots=True)
class _Guard:
phrase: re.Pattern[str]
licence: re.Pattern[str] | None


_NARRATION = re.compile(
"|".join(f"(?:{phrase})" for phrase in (*_ENGLISH, *_VIETNAMESE)),
re.IGNORECASE | re.MULTILINE,
_GUARDS = tuple(
_Guard(
phrase=re.compile(phrase, _FLAGS),
licence=re.compile(licence, _FLAGS) if licence else None,
)
for phrase, licence in _PHRASES
)

#: A fence or a rule opening the answer. The model presenting its work rather
#: than doing it, which is the same failure wearing different clothes.
#: than doing it, which is the same failure wearing different clothes. No
#: licence: the corpus has literal blocks, but a protected one reaches this
#: module as a placeholder and never as three backticks.
_FENCE = re.compile(r"^\s*(?:```|~~~|---)")

#: A parenthetical aside about the translation itself, in either language.
#: No licence either, for the same reason: the CPython documentation does not
#: contain bracketed notes from its own translator.
_ASIDE = re.compile(
r"[(\[](?:[^)\]]{0,40})(?:translator|dịch giả|người dịch|note to|ghi chú của)[^)\]]*[)\]]",
re.IGNORECASE,
_FLAGS,
)


Expand All @@ -71,23 +103,40 @@ def __str__(self) -> str:
return f"{self.phrase!r} at offset {self.where}"


def find(text: str) -> Narration | None:
def find(text: str, source: str = "") -> Narration | None:
"""The first sign the model was talking about the work rather than doing it.

Only the first. One is enough to refuse the entry, and reporting three
phrases from one narrated paragraph makes the report longer without making
the decision any different.
``source`` is the ``msgid`` the translation came from. A phrase whose licence
the source matches is a translation of something the English said and is left
alone. Passing no source means nothing is licensed, which is the right answer
for a caller that has a string and no idea what it was made from.

Only the first phrase is reported. One is enough to refuse the entry, and
naming three phrases from one narrated paragraph makes the report longer
without making the decision any different.
"""
fence = _FENCE.match(text)
if fence:
return Narration(phrase=fence.group(0).strip(), where=0)
for pattern in (_NARRATION, _ASIDE):
match = pattern.search(text)
if match:
return Narration(phrase=match.group(0).strip(), where=match.start())
return None

hits = [
match
for guard in _GUARDS
if (match := guard.phrase.search(text)) and not _licensed(guard, source)
]
aside = _ASIDE.search(text)
if aside:
hits.append(aside)
if not hits:
return None
first = min(hits, key=lambda match: match.start())
return Narration(phrase=first.group(0).strip(), where=first.start())


def _licensed(guard: _Guard, source: str) -> bool:
return bool(source) and guard.licence is not None and bool(guard.licence.search(source))


def clean(text: str) -> bool:
"""Whether a string is free of narration."""
return find(text) is None
def clean(text: str, source: str = "") -> bool:
"""Whether a string is free of narration the source did not license."""
return find(text, source) is None
17 changes: 17 additions & 0 deletions tests/test_invariants.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,3 +171,20 @@ def test_handing_the_english_back_is_always_refused(self, msgid: str) -> None:
if not msgid.strip():
return
assert "P05" in rules(msgid, msgid)


class TestP06AgainstThePair:
"""``P06`` reads the English too, because it is one of the three rules that
condemns the whole batch and so the one that can least afford to be wrong."""

def test_a_translated_note_is_not_narration(self) -> None:
broken = invariants.check_entry(
"Note: this is unsupported.", "Lưu ý: điều này không được hỗ trợ.", ()
)
assert broken == []

def test_narration_the_source_did_not_license_still_fails(self) -> None:
broken = invariants.check_entry(
"Return a sorted list.", "Đây là bản dịch: trả về danh sách.", ()
)
assert [one.rule for one in broken] == ["P06"]
54 changes: 54 additions & 0 deletions tests/test_textguard.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,57 @@ def test_a_finding_says_where_it_was(self) -> None:
assert found is not None
assert found.where > 0
assert "offset" in str(found)


class TestWhatTheSourceLicenses:
"""The half of this module that was missing until a real run found it.

Every case here is a faithful translation that the guard used to refuse. The
first one is the string itself: it opened a batch of 28 in
``library/functions.po``, and ``P06`` rejects the whole batch, so one wrong
answer here threw away 27 correct translations beside it.
"""

def test_a_note_in_the_source_licenses_a_note_in_the_translation(self) -> None:
msgid = "Note: Unlike :func:`iter`, :func:`aiter` has no 2-argument variant."
msgstr = "Lưu ý: Không giống như ⟦1⟧, ⟦2⟧ không có biến thể 2 đối số."
assert not textguard.clean(msgstr)
assert textguard.clean(msgstr, msgid)

def test_the_following_licenses_duoi_day_la(self) -> None:
assert textguard.clean("Dưới đây là các tùy chọn.", "The following are the options.")

def test_hope_licenses_hy_vong(self) -> None:
assert textguard.clean("Chúng tôi hy vọng điều này.", "We hope this.")

def test_sorry_licenses_xin_loi(self) -> None:
assert textguard.clean("Xin lỗi, không tìm thấy.", "Sorry, not found.")

def test_a_source_that_says_nothing_of_the_kind_licenses_nothing(self) -> None:
"""The licence is the source raising the subject, not the source
existing. A string about sorting does not excuse an apology."""
assert not textguard.clean("Xin lỗi, chuỗi này khó hiểu.", "Return a sorted list.")

def test_a_phrase_with_no_licence_fires_however_the_source_reads(self) -> None:
"""Some phrases are never a translation of anything. The English
documentation does not say "as an AI", so nothing can excuse it."""
assert not textguard.clean("As an AI, I note this.", "As an AI system would note that.")

def test_a_fence_is_never_licensed(self) -> None:
"""A literal block reaches this module as a placeholder, never as three
backticks, so a fence in an answer is always the model presenting."""
assert not textguard.clean("```\nTrả về danh sách.\n```", "```\nsorted(x)\n```")

def test_the_leftmost_phrase_is_the_one_reported_not_the_first_listed(self) -> None:
found = textguard.find("Trả về danh sách. Xin lỗi. Here is the translation.")
assert found is not None
assert found.phrase.lower() == "xin lỗi"

def test_the_contraction_licenses_it_too(self) -> None:
"""From ``library/functions.po``. "Here's an example" is how the
documentation opens a code block, and it was the last human translation
in the corpus this guard still refused."""
assert textguard.clean(
"Dưới đây là ví dụ tính nghịch đảo của ``38`` theo modulo ``97``::",
"Here's an example of computing an inverse for ``38`` modulo ``97``::",
)
Loading