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: