|
| 1 | +""" |
| 2 | +Tests of rebuilding the text and the character offsets of a document read from CoNLL-U |
| 3 | +""" |
| 4 | + |
| 5 | +import warnings |
| 6 | + |
| 7 | +import pytest |
| 8 | + |
| 9 | +from stanza.utils.conll import CoNLL |
| 10 | +from stanza.tests import * |
| 11 | + |
| 12 | +pytestmark = pytest.mark.pipeline |
| 13 | + |
| 14 | +# an MWT which splits into pieces of the surface form, a couple SpaceAfter=No, |
| 15 | +# and a SpacesAfter at the end of the sentence |
| 16 | +ENGLISH = """ |
| 17 | +# sent_id = 1 |
| 18 | +# text = She can't swim, sadly. |
| 19 | +1\tShe\tshe\tPRON\tPRP\t_\t3\tnsubj\t_\t_ |
| 20 | +2-3\tcan't\t_\t_\t_\t_\t_\t_\t_\t_ |
| 21 | +2\tca\tcan\tAUX\tMD\t_\t4\taux\t_\t_ |
| 22 | +3\tn't\tnot\tPART\tRB\t_\t4\tadvmod\t_\t_ |
| 23 | +4\tswim\tswim\tVERB\tVB\t_\t0\troot\t_\tSpaceAfter=No |
| 24 | +5\t,\t,\tPUNCT\t,\t_\t6\tpunct\t_\t_ |
| 25 | +6\tsadly\tsadly\tADV\tRB\t_\t4\tadvmod\t_\tSpaceAfter=No |
| 26 | +7\t.\t.\tPUNCT\t.\t_\t4\tpunct\t_\tSpacesAfter=\\n |
| 27 | +
|
| 28 | +# sent_id = 2 |
| 29 | +# text = Bye! |
| 30 | +1\tBye\tbye\tINTJ\tUH\t_\t0\troot\t_\tSpaceAfter=No |
| 31 | +2\t!\t!\tPUNCT\t.\t_\t1\tpunct\t_\t_ |
| 32 | +""".lstrip() |
| 33 | + |
| 34 | +ENGLISH_TEXT = "She can't swim, sadly.\nBye!" |
| 35 | + |
| 36 | +# `al` is an MWT which is not a substring split of its words |
| 37 | +SPANISH = """ |
| 38 | +# sent_id = 1 |
| 39 | +# text = Vamos al mar. |
| 40 | +1\tVamos\tir\tVERB\t_\t_\t0\troot\t_\t_ |
| 41 | +2-3\tal\t_\t_\t_\t_\t_\t_\t_\t_ |
| 42 | +2\ta\ta\tADP\t_\t_\t4\tcase\t_\t_ |
| 43 | +3\tel\tel\tDET\t_\t_\t4\tdet\t_\t_ |
| 44 | +4\tmar\tmar\tNOUN\t_\t_\t1\tobl\t_\tSpaceAfter=No |
| 45 | +5\t.\t.\tPUNCT\t_\t_\t1\tpunct\t_\t_ |
| 46 | +""".lstrip() |
| 47 | + |
| 48 | +# the text comment says there are spaces around the quotes, |
| 49 | +# the MISC says there are not |
| 50 | +INCONSISTENT = """ |
| 51 | +# sent_id = 1 |
| 52 | +# text = Il a dit « bonjour ». |
| 53 | +1\tIl\til\tPRON\t_\t_\t3\tnsubj\t_\t_ |
| 54 | +2\ta\tavoir\tAUX\t_\t_\t3\taux\t_\t_ |
| 55 | +3\tdit\tdire\tVERB\t_\t_\t0\troot\t_\t_ |
| 56 | +4\t«\t«\tPUNCT\t_\t_\t6\tpunct\t_\tSpaceAfter=No |
| 57 | +5\tbonjour\tbonjour\tNOUN\t_\t_\t3\tobj\t_\tSpaceAfter=No |
| 58 | +6\t»\t»\tPUNCT\t_\t_\t5\tpunct\t_\tSpaceAfter=No |
| 59 | +7\t.\t.\tPUNCT\t_\t_\t3\tpunct\t_\t_ |
| 60 | +""".lstrip() |
| 61 | + |
| 62 | +# the SpaceAfter=No which matters here is on the range line of the MWT, |
| 63 | +# which is where UD puts it, rather than on the last word |
| 64 | +MWT_SPACE_AFTER = """ |
| 65 | +# sent_id = 1 |
| 66 | +# text = Nadie puede representarme. |
| 67 | +1\tNadie\tnadie\tPRON\t_\tNumber=Sing|PronType=Neg\t3\tnsubj\t_\t_ |
| 68 | +2\tpuede\tpoder\tAUX\t_\tVerbForm=Fin\t3\taux\t_\t_ |
| 69 | +3-4\trepresentarme\t_\t_\t_\t_\t_\t_\t_\tSpaceAfter=No |
| 70 | +3\trepresentar\trepresentar\tVERB\t_\tVerbForm=Inf\t0\troot\t_\t_ |
| 71 | +4\tme\tyo\tPRON\t_\tNumber=Sing|Person=1\t3\tobj\t_\t_ |
| 72 | +5\t.\t.\tPUNCT\t_\tPunctType=Peri\t3\tpunct\t_\t_ |
| 73 | +""".lstrip() |
| 74 | + |
| 75 | +# the words of the MWT have their own MISC, but none of it is about |
| 76 | +# whitespace, so the SpaceAfter=No on the range line is still the one that counts |
| 77 | +MWT_UNRELATED_WORD_MISC = """ |
| 78 | +# sent_id = 1 |
| 79 | +# text = Nadie puede representarme. |
| 80 | +1\tNadie\tnadie\tPRON\t_\tNumber=Sing|PronType=Neg\t3\tnsubj\t_\t_ |
| 81 | +2\tpuede\tpoder\tAUX\t_\tVerbForm=Fin\t3\taux\t_\t_ |
| 82 | +3-4\trepresentarme\t_\t_\t_\t_\t_\t_\t_\tSpaceAfter=No |
| 83 | +3\trepresentar\trepresentar\tVERB\t_\tVerbForm=Inf\t0\troot\t_\tNER=O |
| 84 | +4\tme\tyo\tPRON\t_\tNumber=Sing|Person=1\t3\tobj\t_\tNER=B-PER|Translit=me |
| 85 | +5\t.\t.\tPUNCT\t_\tPunctType=Peri\t3\tpunct\t_\t_ |
| 86 | +""".lstrip() |
| 87 | + |
| 88 | +def check_offsets(doc): |
| 89 | + """Every token and every word with an offset should index back to itself""" |
| 90 | + for sentence in doc.sentences: |
| 91 | + for token in sentence.tokens: |
| 92 | + assert doc.text[token.start_char:token.end_char] == token.text |
| 93 | + for word in token.words: |
| 94 | + if word.start_char is not None: |
| 95 | + assert doc.text[word.start_char:word.end_char] == word.text |
| 96 | + |
| 97 | +def test_no_offsets_by_default(): |
| 98 | + """Reading a document without the flag should not invent offsets""" |
| 99 | + doc = CoNLL.conll2doc(input_str=ENGLISH) |
| 100 | + assert doc.text is None |
| 101 | + for sentence in doc.sentences: |
| 102 | + for token in sentence.tokens: |
| 103 | + assert token.start_char is None |
| 104 | + assert token.end_char is None |
| 105 | + |
| 106 | +def test_text_and_offsets(): |
| 107 | + doc = CoNLL.conll2doc(input_str=ENGLISH, reconstruct_text=True) |
| 108 | + assert doc.text == ENGLISH_TEXT |
| 109 | + check_offsets(doc) |
| 110 | + tokens = doc.sentences[0].tokens |
| 111 | + assert (tokens[0].start_char, tokens[0].end_char) == (0, 3) |
| 112 | + # the MWT is split across its words |
| 113 | + assert [(word.start_char, word.end_char) for word in tokens[1].words] == [(4, 6), (6, 9)] |
| 114 | + |
| 115 | +def test_sentence_text(): |
| 116 | + doc = CoNLL.conll2doc(input_str=ENGLISH, reconstruct_text=True) |
| 117 | + assert [sentence.text for sentence in doc.sentences] == ["She can't swim, sadly.", "Bye!"] |
| 118 | + |
| 119 | +def test_no_text_comments(): |
| 120 | + """The offsets should come out the same when rebuilt from SpaceAfter alone""" |
| 121 | + without = "\n".join(x for x in ENGLISH.split("\n") if not x.startswith("# text")) |
| 122 | + doc = CoNLL.conll2doc(input_str=without, reconstruct_text=True) |
| 123 | + assert doc.text == ENGLISH_TEXT |
| 124 | + check_offsets(doc) |
| 125 | + |
| 126 | +def test_unsplittable_mwt(): |
| 127 | + """`al` is not made of its words, so the words get no offsets, but the token does""" |
| 128 | + doc = CoNLL.conll2doc(input_str=SPANISH, reconstruct_text=True) |
| 129 | + assert doc.text == "Vamos al mar." |
| 130 | + check_offsets(doc) |
| 131 | + token = doc.sentences[0].tokens[1] |
| 132 | + assert (token.start_char, token.end_char) == (6, 8) |
| 133 | + assert all(word.start_char is None for word in token.words) |
| 134 | + |
| 135 | +def test_inconsistent_annotation(): |
| 136 | + """When the text comment and the MISC disagree, the text wins, with a warning""" |
| 137 | + with pytest.warns(UserWarning): |
| 138 | + doc = CoNLL.conll2doc(input_str=INCONSISTENT, reconstruct_text=True) |
| 139 | + assert doc.text == "Il a dit « bonjour »." |
| 140 | + check_offsets(doc) |
| 141 | + |
| 142 | +def test_mwt_space_after(): |
| 143 | + """UD marks SpaceAfter on the range line of an MWT, not on its last word""" |
| 144 | + doc = CoNLL.conll2doc(input_str=MWT_SPACE_AFTER, reconstruct_text=True) |
| 145 | + assert doc.text == "Nadie puede representarme." |
| 146 | + check_offsets(doc) |
| 147 | + |
| 148 | +def test_mwt_space_after_no_text_comment(): |
| 149 | + """The same, with only the SpaceAfter annotations to go on""" |
| 150 | + without = "\n".join(x for x in MWT_SPACE_AFTER.split("\n") if not x.startswith("# text")) |
| 151 | + doc = CoNLL.conll2doc(input_str=without, reconstruct_text=True) |
| 152 | + assert doc.text == "Nadie puede representarme." |
| 153 | + check_offsets(doc) |
| 154 | + |
| 155 | +def test_mwt_space_after_on_word(): |
| 156 | + """Some treebanks put the annotation on the last word instead""" |
| 157 | + moved = MWT_SPACE_AFTER.replace("representarme\t_\t_\t_\t_\t_\t_\t_\tSpaceAfter=No", "representarme\t_\t_\t_\t_\t_\t_\t_\t_") |
| 158 | + moved = moved.replace("Number=Sing|Person=1\t3\tobj\t_\t_", "Number=Sing|Person=1\t3\tobj\t_\tSpaceAfter=No") |
| 159 | + without = "\n".join(x for x in moved.split("\n") if not x.startswith("# text")) |
| 160 | + doc = CoNLL.conll2doc(input_str=without, reconstruct_text=True) |
| 161 | + assert doc.text == "Nadie puede representarme." |
| 162 | + check_offsets(doc) |
| 163 | + |
| 164 | +def test_unrelated_word_misc(): |
| 165 | + """MISC on a word which says nothing about whitespace should not mask the token's annotation |
| 166 | +
|
| 167 | + Without the `# text` comment there is nothing else to fall back on, so |
| 168 | + reading the word's MISC as "no annotation means a space" would put a |
| 169 | + space in front of the period |
| 170 | + """ |
| 171 | + without = "\n".join(x for x in MWT_UNRELATED_WORD_MISC.split("\n") if not x.startswith("# text")) |
| 172 | + doc = CoNLL.conll2doc(input_str=without, reconstruct_text=True) |
| 173 | + assert doc.text == "Nadie puede representarme." |
| 174 | + check_offsets(doc) |
| 175 | + |
| 176 | +def test_unrelated_word_misc_no_warning(): |
| 177 | + """The same document with its `# text` comment should agree, and so not warn""" |
| 178 | + with warnings.catch_warnings(): |
| 179 | + warnings.simplefilter("error") |
| 180 | + doc = CoNLL.conll2doc(input_str=MWT_UNRELATED_WORD_MISC, reconstruct_text=True) |
| 181 | + assert doc.text == "Nadie puede representarme." |
| 182 | + check_offsets(doc) |
| 183 | + |
| 184 | +def test_misc_unchanged(): |
| 185 | + """Rebuilding the text should not rewrite the MISC column""" |
| 186 | + plain = CoNLL.conll2doc(input_str=ENGLISH) |
| 187 | + offsets = CoNLL.conll2doc(input_str=ENGLISH, reconstruct_text=True) |
| 188 | + assert "{:C-o}".format(plain) == "{:C-o}".format(offsets) |
0 commit comments