Oprava rozpoznávania mien
This commit is contained in:
parent
4eaf74c529
commit
af61a8a273
@ -227,6 +227,24 @@ def test_shared_year_has_only_intersection_and_bound_work_details(shared_session
|
||||
assert '2019' not in rendered['answer'] and 'Ukážkový názov' in rendered['answer']
|
||||
|
||||
|
||||
def test_shared_year_with_per_person_presentation_instructions(shared_session):
|
||||
question=('V ktorých rokoch majú Ján Holp a Ondrej Megela obaja evidovanú prácu alebo projekt? '
|
||||
'Najprv uveď spoločné roky, potom pri každej osobe príslušné práce s typom a názvom, '
|
||||
'ak je uvedený. Pridaj zdroje.')
|
||||
result=gs.search_graph_with_session(shared_session,question)
|
||||
assert result['status']=='ok'
|
||||
assert result['academic_year_comparison']['shared_years']==[2020]
|
||||
context=graph_context(result)
|
||||
records=context['required_academic_records']
|
||||
assert len(records)==3 and {r['person'] for r in records}=={'Ján Holp','Ondrej Megela'}
|
||||
answer={'status':'ok','claims':records,
|
||||
'source_urls':[s['source_url'] for s in context['sources']]}
|
||||
rendered=render_graph_answer(answer,context)
|
||||
assert answer_context_issues(rendered,context)==[]
|
||||
assert 'Spoločný evidovaný rok je **2020**.' in rendered['answer']
|
||||
assert '2019' not in rendered['answer']
|
||||
|
||||
|
||||
def test_no_common_year_is_not_a_union(shared_session):
|
||||
for row in shared_session.responses[gs.WORK_FACTS_QUERY]:
|
||||
if row['person_element_id']=='p1': row['year']=2021
|
||||
|
||||
@ -56,6 +56,30 @@ def test_plural_keyword_question_does_not_hide_a_misspelled_person(people):
|
||||
assert r['resolved_query']==q.replace('Ptká','Pták')
|
||||
|
||||
|
||||
@pytest.mark.parametrize('instruction', [
|
||||
'potom pri každej osobe príslušné práce s typom a názvom, ak je uvedený',
|
||||
'potom pri každej osobe relevantné práce',
|
||||
'potom pri každom študentovi jednotlivé práce',
|
||||
'potom pre obe osoby príslušné práce',
|
||||
'potom pri vybraných osobách stručné zhrnutie',
|
||||
])
|
||||
def test_presentation_instructions_are_not_additional_people(people, instruction):
|
||||
q=('V ktorých rokoch majú Ján Holp a Ondrej Megela obaja evidovanú prácu alebo projekt? '
|
||||
'Najprv uveď spoločné roky, '+instruction+'. Pridaj zdroje.')
|
||||
r=resolve_person_names(q,people)
|
||||
assert r['status']=='ok'
|
||||
assert [m['name'] for m in r['matches']]==['Ján Holp','Ondrej Megela']
|
||||
assert r['resolved_query']==q and r['notices']==[]
|
||||
|
||||
|
||||
@pytest.mark.parametrize('mention',['Xaver Neexistujúci','xaver neexistujuci'])
|
||||
def test_generic_person_reference_does_not_hide_another_unknown_subject(people, mention):
|
||||
q=f'Porovnaj Ján Holp a {mention}. Pri každej osobe uveď príslušné práce.'
|
||||
r=resolve_person_names(q,people)
|
||||
assert r['status']=='no_answer' and r['reason']=='unknown_person'
|
||||
assert r['mention']==mention
|
||||
|
||||
|
||||
@pytest.mark.parametrize('q',[
|
||||
'Aké práce má Xaver Neexistujúci?', 'Aké práce má xaver neexistujuci?',
|
||||
'Aký je názov práce xaver neexistujuci?',
|
||||
@ -63,6 +87,8 @@ def test_plural_keyword_question_does_not_hide_a_misspelled_person(people):
|
||||
'Porovnaj xaver neexistujuci a jan holp.',
|
||||
'Porovnaj jan holp a xaver neexistujuci.',
|
||||
'Aké práce má Ján Hxxxx?', 'Aké práce má Xaver?',
|
||||
'Z kandidátov Ján Holp, neznama osoba, Ondrej Megela vyber osoby s prácou v roku 2024.',
|
||||
'Z kandidátov Ján Holp a neznama osoba vyber osoby s prácou v roku 2024.',
|
||||
])
|
||||
def test_unknown_name_cannot_turn_into_global_query(people,q):
|
||||
r=resolve_person_names(q,people)
|
||||
|
||||
@ -142,19 +142,34 @@ def test_candidates_are_actual_bound_literal_evidence(corpus):
|
||||
|
||||
|
||||
def test_unknown_second_subject_is_not_silently_ignored(corpus):
|
||||
p=prepare(corpus,'Aké problémy riešili študenti Eva Nová a Boris Noyy?')
|
||||
p=prepare(corpus,'Aké problémy riešili študenti Eva Nová a Boris Nxxxxx?')
|
||||
assert p['status']=='ambiguous' and p['reason']=='unresolved_subject'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('question',[
|
||||
'Aké problémy riešili Eva Nová a Xaver Neexistujúci?',
|
||||
'Aké problémy riešil Boris Noyy?',
|
||||
'Aké problémy riešil Boris Nxxxxx?',
|
||||
])
|
||||
def test_unresolved_subject_cannot_be_replaced_by_available_profiles(corpus,question):
|
||||
p=prepare(corpus,question)
|
||||
assert p['status']=='ambiguous' and p['reason']=='unresolved_subject'
|
||||
|
||||
|
||||
def test_content_preparation_uses_shared_typo_resolution_and_keeps_notice(corpus):
|
||||
p=prepare(corpus,'Aké problémy riešili študenti Eva Nová a Boris Noyy?')
|
||||
assert p['status']=='ok' and p['named_subjects']==['Eva Nová','Boris Nový']
|
||||
assert p['resolved_query']=='Aké problémy riešili študenti Eva Nová a Boris Nový?'
|
||||
assert any('Boris Nový' in n for n in p['name_resolution']['notices'])
|
||||
|
||||
|
||||
def test_content_preparation_does_not_treat_per_person_instructions_as_names(corpus):
|
||||
q=('Porovnaj výsledky Evy Novej a Borisa Nového. '
|
||||
'Pri každej osobe príslušné výsledky uveď samostatne. Pridaj zdroje.')
|
||||
p=prepare(corpus,q)
|
||||
assert p['status']=='ok' and p['named_subjects']==['Eva Nová','Boris Nový']
|
||||
assert p['name_resolution']['status']=='ok'
|
||||
|
||||
|
||||
def test_partial_answer_names_the_missing_aspect(corpus):
|
||||
db,raw=corpus
|
||||
with sqlite3.connect(db) as conn:
|
||||
|
||||
@ -33,8 +33,15 @@ def prepare_named_content(db_file, question: str, payload: dict) -> dict:
|
||||
return payload
|
||||
catalog=[dict(r) for r in conn.execute('SELECT path,title,author,published FROM documents WHERE COALESCE(published,1)=1')
|
||||
if PROFILE.fullmatch(r['path']) and r['title']]
|
||||
from tools.retrieval.person_names import resolve_person_names
|
||||
resolution=payload.get('name_resolution') or resolve_person_names(question,[
|
||||
{'kind':'Person','name':r['title'],'source_document':r['path']} for r in catalog])
|
||||
if resolution['status'] != 'ok':
|
||||
return clarify(payload,'unresolved_subject',resolution['message'])
|
||||
question=resolution['resolved_query']
|
||||
payload={**payload,'name_resolution':resolution,'resolved_query':question}
|
||||
identities = {normalized(m['name']): m['source_document']
|
||||
for m in (payload.get('name_resolution') or {}).get('matches',[])
|
||||
for m in resolution.get('matches',[])
|
||||
if m.get('source_document')}
|
||||
if identities:
|
||||
catalog = [r for r in catalog if identities.get(normalized(r['title'])) == r['path']]
|
||||
@ -43,21 +50,6 @@ def prepare_named_content(db_file, question: str, payload: dict) -> dict:
|
||||
named.sort(key=lambda r:re.search(person_pattern(r['title']),normalized(question)).start())
|
||||
if len(named)>4:
|
||||
return clarify(payload,'too_many_subjects','Pre podrobné obsahové porovnanie vyber najviac štyri osoby naraz.')
|
||||
from tools.graphrag.graph_search import requested_name_phrases
|
||||
first_names={normalized(r['title']).split()[0] for r in catalog}
|
||||
capitalized={normalized(match[0]) for match in re.finditer(
|
||||
r'\b[A-ZÁÄČĎÉÍĹĽŇÓÔŔŠŤÚÝŽ][^\W\d_]+(?:\s+[A-ZÁÄČĎÉÍĹĽŇÓÔŔŠŤÚÝŽ][^\W\d_]+){1,3}\b',question)}
|
||||
unknown=[]
|
||||
for phrase in requested_name_phrases(question):
|
||||
words=phrase.split()
|
||||
named_companion=(named and phrase in capitalized and
|
||||
re.search(r'\ba\s+'+re.escape(phrase)+r'\b',normalized(question)))
|
||||
if (2<=len(words)<=4 and (words[0] in first_names or named_companion) and
|
||||
not any(text_contains_value(phrase,r['title'],person=True) for r in catalog)):
|
||||
unknown.append(phrase)
|
||||
if unknown:
|
||||
return clarify(payload,'unresolved_subject','Meno „'+unknown[0]+'“ sa nepodarilo spoľahlivo priradiť. '
|
||||
'Uveď jeho presné celé meno; ostatné osoby nebudem používať ako náhradu.')
|
||||
if not named:
|
||||
return payload
|
||||
types=requested_types(selector.positive_text,[r['title'] for r in named])
|
||||
|
||||
@ -1219,46 +1219,6 @@ def person_name_match(query: str, alias: str) -> tuple[float, tuple[int, int] |
|
||||
return best
|
||||
|
||||
|
||||
def requested_name_phrases(query: str) -> list[str]:
|
||||
"""Recognise explicitly requested names, including unknown lower-case names.
|
||||
|
||||
This guard is separate from catalog resolution: an unresolved subject must
|
||||
not turn the empty person filter into an unrestricted global listing.
|
||||
"""
|
||||
normalized = normalize_for_match(query.replace(',', ' separator '))
|
||||
candidates = []
|
||||
role = re.search(
|
||||
r'\b(?:osob(?:a|y|u|e|ach|ami|am|ou)?|student(?:a|ovi|om|i|ov|ka|ky|ke|ku)?|kandidat(?:ov|i|a))\s+(.+)',
|
||||
normalized,
|
||||
)
|
||||
if role:
|
||||
tail = re.split(
|
||||
r'(?:^|\s+)(?:s(?:o)?\s+(?:nazv\w*|nazov\w*|rok\w*)|splna\w*|ma|maju|mal|mali|je|su|'
|
||||
r'ktory\w*|ktora\w*|pri\s+ktorych|v\s+roku|uved\w*|prepojen\w*|spojen\w*|'
|
||||
r'vyber|najdi|zisti|cez|pomenuj|uved\w*|vypis|ak)\b', role.group(1), maxsplit=1,
|
||||
)[0]
|
||||
candidates.extend(re.split(r'\s+(?:a|separator)\s+', tail))
|
||||
verb = re.search(r'\b(?:ma|mal|robil|riesil|venuje|venoval)\s+(.+)', normalized)
|
||||
if verb:
|
||||
candidates.append(re.split(r'\s+(?:v|s|so|a|ak|ktory\w*)\b', verb.group(1), maxsplit=1)[0])
|
||||
# Capitalised full names also cover genitive phrases such as 'práce X Y'.
|
||||
upper = 'A-ZÁÄČĎÉÍĹĽŇÓÔŔŠŤÚÝŽ'
|
||||
for match in re.finditer(rf'\b([{upper}][^\W\d_]*(?:\s+[{upper}][^\W\d_]*){{1,3}})\b', query):
|
||||
words = match.group(1).split()
|
||||
if all(not word.isupper() for word in words):
|
||||
candidates.append(normalize_for_match(match.group(1)))
|
||||
generic = {'ktore', 'ktori', 'ktora', 'ktory', 'vsetci', 'vsetky', 'vsetkych', 'maju', 'ma',
|
||||
'praca', 'prace', 'pracu', 'projekt', 'projekty', 'dokument', 'dokumenty', 'rok', 'roku',
|
||||
'klucove', 'klucovy', 'klucovym', 'tag', 'tagy', 'nazov', 'nazvy', 'temu',
|
||||
'kategoriu', 'kategorie', 'uvedenu', 'uvedeny', 'priradeny', 'priradene',
|
||||
'je', 'su', 'sa', 's', 'pri', 'pre', 'z', 'na', 'v', 'ake', 'aky', 'kto', 'kolko'}
|
||||
return list(dict.fromkeys(
|
||||
candidate.strip() for candidate in candidates
|
||||
if 1 <= len(candidate.split()) <= 5 and candidate.split()[0] not in generic
|
||||
and not re.match(r'^(?:diplomov|bakalarsk|dizertacn|timov|vyskumn|zaverecn)\w*\s+(?:prac\w*|projekt\w*)\b',candidate)
|
||||
))
|
||||
|
||||
|
||||
def named_author_phrases(query: str) -> list[str]:
|
||||
normalized = normalize_for_match(query.replace(',', ' separator '))
|
||||
match = re.search(r'\bautor(?:a|om)?(?:\s+dokument(?:u|ov))?\s+(.+)', normalized)
|
||||
@ -2385,30 +2345,12 @@ def search_graph_with_session(
|
||||
row.get("kind") == "Person" and row.get("matched_alias") in author_names
|
||||
)] + requested_authors
|
||||
|
||||
requested_names = requested_name_phrases(clean_query)
|
||||
unknown_names = []
|
||||
for requested in requested_names:
|
||||
if phrase and normalize_for_match(requested) in normalize_for_match(phrase):
|
||||
continue
|
||||
personal = [row for row in catalog if row.get("kind") in {"Person", "DocumentAuthor"}]
|
||||
known_person = any(person_name_match(requested, row.get("name", ""))[0] > 0 for row in personal)
|
||||
known_title = any(
|
||||
normalize_for_match(requested) in alias
|
||||
for row in catalog if row.get("kind") not in {"Person", "DocumentAuthor"}
|
||||
for alias in entity_aliases(row)
|
||||
)
|
||||
if len(requested.split()) == 1:
|
||||
# A verb can precede the first inflected word of a fully resolved
|
||||
# thesis title. That is a title anchor, not an unknown person.
|
||||
known_title = known_title or any(
|
||||
tokens_match(normalize_for_match(requested), word)
|
||||
for row in entities if row.get('kind') in {'Thesis', 'Work'}
|
||||
for alias in entity_aliases(row) for word in alias.split()
|
||||
)
|
||||
if not known_person and not known_title:
|
||||
unknown_names.append(requested)
|
||||
constraints["requested_person_names"] = requested_names
|
||||
constraints["unresolved_person_names"] = unknown_names
|
||||
# The shared resolver has already rejected unknown/ambiguous subjects.
|
||||
# Re-parsing prose here with a second role regex used to invent additional
|
||||
# names from presentation instructions, despite successful resolution.
|
||||
constraints["requested_person_names"] = list(dict.fromkeys(
|
||||
normalize_for_match(match['name']) for match in resolution['matches']))
|
||||
constraints["unresolved_person_names"] = []
|
||||
constraints["unresolved_document_author"] = bool(author_mentions and not requested_authors)
|
||||
aliases_to_ids: dict[tuple[str, str], set[str]] = {}
|
||||
for row in entities:
|
||||
@ -2487,8 +2429,6 @@ def search_graph_with_session(
|
||||
blocked_reasons.append('missing_documented_start_year')
|
||||
if constraints["unresolved_keyword"]:
|
||||
blocked_reasons.append("unknown_keyword")
|
||||
if unknown_names:
|
||||
blocked_reasons.append("unknown_person")
|
||||
if constraints["unresolved_document_author"]:
|
||||
blocked_reasons.append("unknown_document_author")
|
||||
if ambiguous_names:
|
||||
|
||||
@ -149,7 +149,10 @@ def test_single_token_unknown_subject_never_becomes_global_lookup(query):
|
||||
|
||||
def test_trailing_instruction_is_not_an_unknown_candidate_name():
|
||||
query = 'Over prepojenie osôb Alena Nová a Boris Malý cez kľúčové slovo RAG a pomenuj spoločné kľúčové slovo.'
|
||||
assert gs.requested_name_phrases(query) == ['alena nova', 'boris maly']
|
||||
result = gs.search_graph_with_session(FakeSession({gs.CATALOG_QUERY: catalog()}), query)
|
||||
assert result['constraints']['requested_person_names'] == ['alena nova', 'boris maly']
|
||||
assert result['constraints']['unresolved_person_names'] == []
|
||||
assert result['name_resolution']['status'] == 'ok'
|
||||
|
||||
|
||||
def test_document_subject_is_not_misread_as_the_named_document_author():
|
||||
|
||||
@ -181,20 +181,40 @@ def resolve_person_names(query: str, catalog: list[dict]) -> dict:
|
||||
title_starts = {variant for row in catalog if row.get('kind') not in {'Person','DocumentAuthor',None}
|
||||
for key in ('name','title','heading') if row.get(key) and normalize(row[key])
|
||||
for variant in name_token_variants(normalize(row[key]).split()[0])}
|
||||
first_subject = min((left for left, _, _ in selected), default=len(query))
|
||||
list_prefix = normalize(re.split(r'[.!?;\n]', query[:first_subject])[-1])
|
||||
explicit_list = bool(re.search(
|
||||
r'\b(?:kandidat(?:ov|i)|osob(?:y|ach)?|student(?:i|ov))\s*$', list_prefix))
|
||||
# Explicit subjects: full capitalized names; known given names followed by
|
||||
# an unknown surname; names after a role, possessive work noun or predicate.
|
||||
for i, token in enumerate(tokens):
|
||||
if (not any(c.isalpha() for c in words[i]) or _generic(words[i])
|
||||
or inside_quote(token.start(), token.end()) or covered(token.start(), token.end())):
|
||||
continue
|
||||
prefix = normalize(query[:token.start()])
|
||||
# A predicate in an earlier sentence cannot introduce this token:
|
||||
# "uveď práce. Pridaj zdroje" must not create a person named Pridaj.
|
||||
prefix = normalize(re.split(r'[.!?;\n]', query[:token.start()])[-1])
|
||||
role = bool(re.search(r'\b(?:osob\w*|student\w*|kandidat\w*|autor(?:a|om)?|ma|mal|robil|riesil|venuje|prace|pracu|projektu)\s*$', prefix))
|
||||
# "Pri každej osobe príslušné práce" refers back to the subjects;
|
||||
# it does not introduce a person named "príslušné". Do not use this
|
||||
# quantified role as evidence of a new name. Independent full-name
|
||||
# and known-given-name checks below still detect additional subjects.
|
||||
reference = bool(re.search(
|
||||
r'\b(?:kazd\w*|jednotliv\w*|vsetk\w*|uveden\w*|vybran\w*|dan\w*|'
|
||||
r'obe|oboch|obom|oboma|obidv\w*|tieto|tychto|tymto)\s+'
|
||||
r'(?:osob\w*|student\w*|kandidat\w*|autor\w*)\s*$', prefix))
|
||||
role = role and not reference
|
||||
following = i+1 < len(tokens) and re.fullmatch(r'\s+', query[token.end():tokens[i+1].start()])
|
||||
pair = bool(following and not _generic(words[i+1]))
|
||||
upper_pair = bool(pair and token[0][0].isupper() and tokens[i+1][0][0].isupper()
|
||||
and not token[0].isupper() and not tokens[i+1][0].isupper())
|
||||
comparison_subject = bool(selected and pair and re.search(r'\b(?:a|porovnaj)\s*$', prefix))
|
||||
if not (role or (pair and words[i] in first_names) or upper_pair or comparison_subject):
|
||||
# Lower-case unknown entries in an explicit candidate list must still
|
||||
# fail closed, even if they follow a comma or a known person.
|
||||
list_subject = explicit_list and any(
|
||||
right <= token.start() and re.fullmatch(r'\s*(?:,|a)\s*', query[right:token.start()])
|
||||
for _, right, _ in selected)
|
||||
if not (role or (pair and words[i] in first_names) or upper_pair or comparison_subject or list_subject):
|
||||
continue
|
||||
end = tokens[i+1].end() if pair else token.end()
|
||||
if covered(token.start(), end):
|
||||
|
||||
Loading…
Reference in New Issue
Block a user