diff --git a/.gitignore b/.gitignore index 36090e21b382d3a1217ee2f220e0fc58f8dfaae8..e138c348c28ecd45aa0e01335f9afca25389a7f1 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ res/text/** dist/ build/ test-corpus/ +TESTING.pdf diff --git a/README.md b/README.md index 14e30eba41680335f271b8cb1312d3592d896770..939d3b21bef998b485674d9d10d23b077be5d2bf 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,8 @@ Universidad Nacional Autónoma de México, 2021 - flake8 - pytest - pyinstaller - - fpdf2 + - fpdf2 + - bs4 # Distribución Los lanzamientos de Requex pueden identificarse con etiquetas de Git que diff --git a/doc/diagramas-clase/requerimiento.png b/doc/diagramas-clase/requerimiento.png index e317a4cacb9ae0ad0d630635fb8b64bca5e34632..bcb89082005ac87f17b91d879cd1c9cc85be36a8 100644 Binary files a/doc/diagramas-clase/requerimiento.png and b/doc/diagramas-clase/requerimiento.png differ diff --git a/doc/diagramas-clase/requerimiento.uxf b/doc/diagramas-clase/requerimiento.uxf index 638e0e199f1cbaff891286da87c6c77bddd4bcc4..4ddec5aeb45a7840bf9da2ec061abeea4f60f993 100644 --- a/doc/diagramas-clase/requerimiento.uxf +++ b/doc/diagramas-clase/requerimiento.uxf @@ -7,7 +7,7 @@ 190 90 480 - 330 + 340 Package::requex.modelos Requerimiento @@ -25,6 +25,7 @@ Requerimiento +__init__(id: int, titulo: str, descripcion: str, estado: tuple (str, str)) +verificar_completo(): bool +__str__(): str ++__repr__(): str +__eq__(otro: object): bool +__hash__(): int _+obtener_estados_validos(): list de str, list de str_ diff --git a/src/requex/__init__.py b/src/requex/__init__.py index 56c7e6cbcb3cbe447a9990c5d2b686259e04d3e3..4775aa7f76618edd8f45caf7904f376f8d034ca5 100755 --- a/src/requex/__init__.py +++ b/src/requex/__init__.py @@ -100,9 +100,7 @@ class Requex: self.__estado = Requex.ESTADO_EXTRACCION_REQ minero = MineroRequerimientos() corpus = self.__cargar_corpus(dir_corpus) - textos_corpus = [] - for texto in corpus.values(): - textos_corpus.append(texto) + textos_corpus = [texto for texto in corpus.values()] requerimientos, glosario = minero.extraer_requerimientos(textos_corpus) del minero self.__especificacion_requerimientos.alcances = 'El presente documento'\ diff --git a/src/requex/extractor.py b/src/requex/extractor.py index a7eb741dc31568996f0733dae33e1477dad8cae3..f7d532ba5b01e04771774a3f0c6e927d5bd21fca 100644 --- a/src/requex/extractor.py +++ b/src/requex/extractor.py @@ -20,9 +20,8 @@ import numpy as np import os import re -# import spacy -# spacy.load('es_core_news_md') import es_core_news_md +from bs4 import BeautifulSoup from gensim.corpora import Dictionary from gensim.models.phrases import Phrases, Phraser @@ -62,6 +61,17 @@ class MineroRequerimientos: archivo_modelo_perifrasis_verbal = os.sep.join(['res', 'modelos', 'perifrasis_verbal.mod']) + patron_genero = re.compile(r'Gender=\w+') + patron_numero = re.compile(r'Number=\w+') + patron_url = re.compile( + r"(?i)\b((?:https?://|www\d{0,3}[.]|[a-z0-9.\-]+[.][a-z]{2,4}/)" + + r"(?:[^\s()<>]+|\(([^\s()<>]+|(\([^\s()<>]+\)))*\))+(?:\(([^\s()<>]+|" + + r"(\([^\s()<>]+\)))*\)|[^\s`!()\[\]{};:'\".,<>?«»“”‘’]))") + patron_interrogativo = re.compile(r'\w+\? |\?$') + patron_codigo = re.compile( + r'(?:\w+\d* ?)(?:\.\w+\d* ?)*(?:\(.*\)|' + + r'[+\-/*!=]?= ?(?:["\']?\w*["\']|\d+))') + def __init__(self, relevancia_min=75): if relevancia_min < 0 or relevancia_min > 100: raise AttributeError('La relevancia mínima debe ser un número ' @@ -334,12 +344,7 @@ class MineroRequerimientos: vectores_relevancia): requerimientos = set() glosario = set() - # TODO probablemente deban ser atributos de clase - patron_genero = re.compile(r'Gender=\w+') - patron_numero = re.compile(r'Number=\w+') for oracion in oraciones_etiquetadas: - # DEBUG los requerimientos podrían detectarse descritos en más de - # una sola oración titulo = '' descripcion = '' terminos_encontrados = [] @@ -347,37 +352,44 @@ class MineroRequerimientos: ultima_categoria_detectada = None completar_titulo = True verbo_compuesto = None + indice_primer_token = 0 + contar_tokens = True for token in oracion: + if contar_tokens: + indice_primer_token += 1 if token.is_space: continue titulo, descripcion, es_patron_valido,\ ultima_categoria_detectada, completar_titulo,\ verbo_compuesto, terminos_encontrados =\ self.__extrae_requerimiento( - titulo, descripcion, token, patron_numero, - patron_genero, es_patron_valido, + titulo, descripcion, token, es_patron_valido, ultima_categoria_detectada, colocaciones, completar_titulo, verbo_compuesto, terminos_encontrados) - if not titulo: - continue - self.__agregar_requerimiento(requerimientos, titulo, descripcion) + if titulo: + contar_tokens = False self.__agregar_terminos_glosario(glosario, terminos_encontrados, vectores_relevancia) - logger_extraccion_req.debug(requerimientos) - logger_extraccion_terms.debug(glosario) + titulo = titulo.strip().capitalize() + descripcion = descripcion.strip().capitalize() + titulo = self.__limpiar_texto(titulo) + descripcion = self.__limpiar_texto(descripcion) + if not titulo or indice_primer_token > len(oracion) * 0.65 or\ + not self.__es_descripcion_valida(descripcion): + continue + self.__agregar_requerimiento(requerimientos, titulo, descripcion) + self.__registra_informacion_extraida(requerimientos, glosario) return requerimientos, glosario - def __extrae_requerimiento(self, titulo, descripcion, token, patron_numero, - patron_genero, es_patron_valido, - ultima_categoria_detectada, colocaciones, - completar_titulo, verbo_compuesto, + def __extrae_requerimiento(self, titulo, descripcion, token, + es_patron_valido, ultima_categoria_detectada, + colocaciones, completar_titulo, verbo_compuesto, terminos_encontrados): pronombre_anterior, es_patron_valido, completar_titulo =\ self.__identifica_extrae_texto_patron( - token, patron_numero, patron_genero, es_patron_valido, - ultima_categoria_detectada, colocaciones, completar_titulo, - verbo_compuesto) + token, es_patron_valido, ultima_categoria_detectada, + colocaciones, completar_titulo, verbo_compuesto) if not es_patron_valido: return titulo, descripcion, es_patron_valido,\ ultima_categoria_detectada, completar_titulo, verbo_compuesto,\ @@ -391,8 +403,7 @@ class MineroRequerimientos: terminos_encontrados # (SUST*+VERB*+ADJ*+ADV*)+ - def __identifica_extrae_texto_patron(self, token, patron_numero, - patron_genero, es_patron_valido, + def __identifica_extrae_texto_patron(self, token, es_patron_valido, ultima_categoria_detectada, colocaciones, completar_titulo, verbo_compuesto): @@ -409,7 +420,6 @@ class MineroRequerimientos: es_patron_valido, ultima_categoria_detectada, verbo_compuesto,\ completar_titulo, pronombre_anterior =\ self.__detecta_patron_verbal(token, es_patron_valido, - patron_numero, patron_genero, ultima_categoria_detectada, colocaciones, verbo_compuesto, completar_titulo, @@ -437,10 +447,10 @@ class MineroRequerimientos: return es_patron_valido, ultima_categoria_detectada, completar_titulo,\ pronombre_anterior, verbo_compuesto - def __detecta_patron_verbal(self, token, es_patron_valido, patron_numero, - patron_genero, ultima_categoria_detectada, - colocaciones, verbo_compuesto, - completar_titulo, pronombre_anterior): + def __detecta_patron_verbal(self, token, es_patron_valido, + ultima_categoria_detectada, colocaciones, + verbo_compuesto, completar_titulo, + pronombre_anterior): if token.lemma_ in self.__verbos_clave: es_patron_valido = True elif not es_patron_valido and token.lemma_ in self.__verbos_perifrasis\ @@ -450,22 +460,22 @@ class MineroRequerimientos: completar_titulo, pronombre_anterior, ultima_categoria_detectada,\ verbo_compuesto = self.__detecta_posicion_patron_verbal( token, completar_titulo, ultima_categoria_detectada, - pronombre_anterior, verbo_compuesto, patron_genero, - patron_numero, colocaciones) + pronombre_anterior, verbo_compuesto, colocaciones) return es_patron_valido, ultima_categoria_detectada, verbo_compuesto,\ completar_titulo, pronombre_anterior def __detecta_posicion_patron_verbal(self, token, completar_titulo, ultima_categoria_detectada, pronombre_anterior, verbo_compuesto, - patron_genero, patron_numero, colocaciones): if ultima_categoria_detectada in ['NOUN', 'VERB']: token_anterior = token.doc[verbo_compuesto] - if patron_genero.match(token_anterior.tag_) ==\ - patron_genero.match(token.tag_) and\ - patron_numero.match(token_anterior.tag_)\ - == patron_numero.match(token.tag_): + if MineroRequerimientos.patron_genero.findall( + token_anterior.tag_) ==\ + MineroRequerimientos.patron_genero.findall(token.tag_) and\ + MineroRequerimientos.patron_numero.findall( + token_anterior.tag_) ==\ + MineroRequerimientos.patron_numero.findall(token.tag_): colocaciones.add(Colocacion( str(token.doc[verbo_compuesto:token.i]), ' ')) elif ultima_categoria_detectada in ['ADJ', 'ADV']: @@ -493,17 +503,25 @@ class MineroRequerimientos: if not titulo: titulo = pronombre_anterior descripcion = pronombre_anterior - if not token.is_punct: + if (not token.is_punct and + not token.doc[token.i - 1].is_left_punct) or\ + (token.is_left_punct and + not token.doc[token.i - 1].is_left_punct): titulo += ' ' titulo += token.text - if not token.is_punct: + if (not token.is_punct and not token.doc[token.i - 1].is_left_punct) or\ + (token.is_left_punct and + not token.doc[token.i - 1].is_left_punct): descripcion += ' ' descripcion += token.text return titulo, descripcion def __prepara_terminos_glosario(self, token, terminos_encontrados, colocaciones): - if token.lemma_ not in terminos_encontrados: + if token.lemma_ not in terminos_encontrados and not token.is_stop and\ + not token.is_punct and not token.like_num and\ + not token.like_url and not token.like_email and\ + len(token.lemma_) >= 2: if token.lemma_ in colocaciones: terminos_encontrados +=\ Colocacion.colocaciones_con_termino(colocaciones, @@ -524,18 +542,15 @@ class MineroRequerimientos: break def __agregar_requerimiento(self, requerimientos, titulo, descripcion): - titulo = titulo.strip().capitalize() - descripcion = descripcion.strip().capitalize() - titulo = self.__limpiar_texto(titulo) - descripcion = self.__limpiar_texto(descripcion) - if len(titulo) < 3 or len(descripcion) < 5: - return estado = 'Identificado' - id = len(requerimientos) + 1 - requerimiento = Requerimiento(id, titulo, descripcion, estado) - if self.__es_requerimiento_unico(requerimiento, - requerimientos): - requerimientos.add(requerimiento) + try: + id = len(requerimientos) + 1 + requerimiento = Requerimiento(id, titulo, descripcion, estado) + if self.__es_requerimiento_unico(requerimiento, + requerimientos): + requerimientos.add(requerimiento) + except AttributeError: + pass def __obtener_pronombre_anterior(self, token): posicion_anterior = token.i - 1 @@ -612,6 +627,30 @@ class MineroRequerimientos: texto = texto[:indice_original + 2] + texto[i - 1:] return texto + def __es_descripcion_valida(self, descripcion): + if MineroRequerimientos.patron_interrogativo.findall(descripcion): + return False + lon_urs = 0 + for urls in MineroRequerimientos.patron_url.findall(descripcion): + lon_urs += len(urls) + if lon_urs >= len(descripcion) * 0.5: + return False + return not self.__es_codigo(descripcion) + + def __es_codigo(self, texto): + texto_sn_espacios = texto.replace(' ', '') + lon_xml = 0 + for xml in BeautifulSoup(texto_sn_espacios, 'html.parser').find_all(): + lon_xml += len(str(xml)) + if lon_xml >= len(texto_sn_espacios) * 0.5: + return True + lon_codigo = 0 + for codigo in MineroRequerimientos.patron_codigo.findall(texto): + lon_codigo += len(codigo) + if lon_codigo >= len(texto) * 0.5: + return True + return False + def __es_requerimiento_unico(self, requerimiento, requerimientos): if requerimiento in requerimientos: return False @@ -623,7 +662,7 @@ class MineroRequerimientos: else req.titulo cambios_titulo = [d for d in difflib.ndiff( titulo_menor, titulo_mayor) if d[0] == '-'] - if cambios_titulo: + if len(set(cambios_titulo)) >= 3: continue descripcion_menor = req.descripcion\ if len(req.descripcion) <= len(requerimiento.descripcion) else\ @@ -632,7 +671,7 @@ class MineroRequerimientos: if descripcion_menor is req.descripcion else req.descripcion cambios_desc = [d for d in difflib.ndiff( descripcion_menor, descripcion_mayor) if d[0] == '-'] - if cambios_desc: + if len(set(cambios_desc)) >= 3: continue if titulo_menor is req.titulo: req.titulo = requerimiento.titulo @@ -641,6 +680,11 @@ class MineroRequerimientos: return False return True + def __registra_informacion_extraida(self, requerimientos, glosario): + for req in requerimientos: + logger_extraccion_req.debug(req) + logger_extraccion_terms.debug(glosario) + def obtener_sustantivos_clave(): return ['sistema', 'aplicación', 'software', 'usuario', 'participante', 'paquete', 'mensaje', 'función', 'módulo', 'interfaz', diff --git a/src/requex/modelos.py b/src/requex/modelos.py index 436eb1c382ffb20ca9e1e76a753a8caca9708d59..f8b13d74446e9fac8f55821148dfb4b6bb55a57c 100644 --- a/src/requex/modelos.py +++ b/src/requex/modelos.py @@ -16,6 +16,9 @@ import copy from datetime import datetime import logging +import re +import string + logger = logging.getLogger(__name__) @@ -246,6 +249,9 @@ class EspecificacionRequerimientosSoftware: class Requerimiento: + clase_caracteres_vacios = '[' + string.whitespace\ + + r'\.,;:¿\?\'"¡!·\(\)\{\}\-_\[\]]' + def __init__(self, id, titulo, descripcion, estado): if id is None: raise AttributeError('El identificador de un requerimiento no ' @@ -278,9 +284,9 @@ class Requerimiento: raise AttributeError('El titulo de un requerimiento debe ser ' + 'una cadena') titulo = titulo.strip() - if len(titulo) < 3: + if len(re.sub(Requerimiento.clase_caracteres_vacios, '', titulo)) < 3: raise AttributeError('El titulo de un requerimiento debe tener' - + ' cuando menos tres caracteres') + + ' cuando menos tres caracteres no vacios') self.__titulo = titulo @property @@ -315,9 +321,7 @@ class Requerimiento: raise AttributeError('La descripción de un requerimiento debe' + ' ser una cadena') descripcion = descripcion.strip() - if len(descripcion) < 5: - raise AttributeError('La descripción de un requerimiento ' - + 'debe tener cuando menos 5 caracteres') + self.__valida_descripcion(descripcion) self.__descripcion = descripcion @property @@ -459,13 +463,41 @@ class Requerimiento: + ' bien ' + str(estados_essence) + ' o una ' + 'combinación de un valor en cada conjunto') + def __valida_descripcion(self, descripcion): + if len(re.sub(Requerimiento.clase_caracteres_vacios, + '', descripcion)) < 7: + raise AttributeError('La descripción de un requerimiento debe tener' + + ' cuando menos 7 caracteres no vacíos') + no_palabras = 0 + for palabra in descripcion.split(): + if re.sub(Requerimiento.clase_caracteres_vacios, '', palabra): + no_palabras += 1 + if no_palabras >= 3: + break + if no_palabras < 3: + raise AttributeError('La descripción de un requerimiento debe ' + + 'formarse por al menos tres palabras') + def __str__(self): - return 'Requerimiento(id=' + str(self.id) + ',titulo=' + self.titulo\ - + ',prioridad=' + str(self.prioridad) + ',descripcion='\ - + self.descripcion + ',estado=' + str(self.estado)\ - + ',volatilidad=' + str(self.volatilidad) + ',tipo='\ - + str(self.tipo) + ',riesgo=' + self.riesgo\ - + ',clausula_organizacion=' + str(self.clausula_organizacion) + ')' + return self.__repr__() + + def __repr__(self): + repr = 'Requerimiento(id=' + str(self.id) + ',titulo=' + self.titulo\ + + ',descripcion=' + self.descripcion + if self.prioridad is not None: + repr += ',prioridad=' + str(self.prioridad) + if self.estado is not None: + repr += ',estado=' + str(self.estado) + if self.volatilidad is not None: + repr += ',volatilidad=' + str(self.volatilidad) + if self.tipo is not None: + repr += ',tipo=' + str(self.tipo) + if self.riesgo: + repr += ',riesgo=' + self.riesgo + if self.clausula_organizacion is not None: + repr += ',clausula_organizacion=' + str(self.clausula_organizacion) + repr += ')' + return repr def __eq__(self, otro): if not otro or type(otro) is not Requerimiento: