Projeto do Agent Contas ORACLE

This commit is contained in:
2026-08-19 09:35:50 -03:00
commit 950a2bcd33
1366 changed files with 177217 additions and 0 deletions

View File

@@ -0,0 +1,743 @@
from __future__ import annotations
import re
import unicodedata as ud
from typing import Any, Dict, List, Tuple
import pandas as pd
# ---------------------------------------------------------------------------
# Regex para linha de VAS
# ---------------------------------------------------------------------------
RX_VAS_LINE = re.compile(
r"""
^\d+\s+ # índice "#"
(?P<date>\d{2}/\d{2}/\d{2}) # DD/MM/YY
\s+-\s+\d{2}:\d{2}:\d{2}\s+ # " - HH:MM:SS "
[A-Z]{2}\sAREA\s\d+\s+ # "SP AREA 11"
(?P<name>.+?)\s+ # nome do serviço
\d{8,} # número chamado
""",
re.X,
)
# ---------------------------------------------------------------------------
class PDFBillingProcessor:
"""
Pós-processamento dos DataFrames gerados pelo TimBillParser.
"""
SECTIONS = [
"Fatura Resumo",
"Plano",
"DANFE-COM",
"Descontos",
"Itens Eventuais",
"Mensalidades Adicionais",
"TIM Viagem",
"Outros Valores",
"Chamadas Rede TIM",
"SVA Detalhe Total",
"Serviços Bundle Inclusos",
"Deduções",
"Roaming Internacional",
"Cobranças de Terceiros",
"Débitos de outras operadoras",
]
STRATEGIC_SERVICE_SECTIONS = {
"SVA Detalhe Total",
"Itens Eventuais",
"Serviços Bundle Inclusos",
"Mensalidades Adicionais",
"Outros Valores",
"Cobranças de Terceiros",
}
SECTION_CLASS_MAP = {
"SVA Detalhe Total": "avulso",
"Itens Eventuais": "avulso",
"Serviços Bundle Inclusos": "bundle",
}
CLASSE_VERB_MAP = {
"avulso": "cancelar",
"bundle": "falar sobre",
"estrategico": "falar sobre",
}
STRATEGIC_SERVICES = [
"Apple Music",
"Deezer",
"Disney",
"Fuze",
"Forge",
"HBO",
"Looke",
"Max Mensal",
"Netflix",
"Paramount",
"TIM Cloud Gaming",
"YouTube",
"Globoplay",
"Amazon Prime"
]
DIGIT_WORDS = {
"0": "zero",
"1": "um",
"2": "dois",
"3": "tres",
"4": "quatro",
"5": "cinco",
"6": "seis",
"7": "sete",
"8": "oito",
"9": "nove",
}
def __init__(self, *, include_danfe: bool = False):
self.include_danfe = include_danfe
self._strategic_service_patterns = [
re.compile(rf"(?:^|\s){re.escape(self._service_match_key(service))}(?:\s|$)")
for service in self.STRATEGIC_SERVICES
]
def remove_parentheses(self, text: str) -> str:
return re.sub(r'\s*\([^)]*\)', '', str(text)).strip()
def remove_plan_recognition_marker(self, text: str) -> str:
return re.sub(
r"\s*\(\s*\d+\s*/\s*P[ÓO]S\s*/\s*SMP\s*\)",
"",
str(text),
flags=re.I,
).strip()
def _format_plan_version_number(self, text: str) -> str:
return re.sub(r"(?<!\d)(\d)\s+(\d)(?!\d)", r"\1.\2", str(text))
def _format_msisdn(self, text: Any) -> str:
return re.sub(r"\D", "", str(text))
def _vocalize_digits(self, digits: str) -> str:
return " ".join(self.DIGIT_WORDS[d] for d in digits if d in self.DIGIT_WORDS)
def _collect_msisdns(self, node: Any, found: set) -> None:
"""Percorre recursivamente o payload coletando todos os msisdn presentes."""
if isinstance(node, dict):
for key, value in node.items():
if key == "msisdn" and value is not None:
msisdn = self._format_msisdn(value)
if msisdn:
found.add(msisdn)
else:
self._collect_msisdns(value, found)
elif isinstance(node, list):
for item in node:
self._collect_msisdns(item, found)
def _build_vocalized_msisdn(self, final_output: Dict[str, Any]) -> Dict[str, str]:
"""Mapeia cada msisdn da fatura para a vocalização dos seus 4 últimos dígitos."""
found: set = set()
for key, value in final_output.items():
if re.fullmatch(r"\d+", str(key)):
found.add(self._format_msisdn(key))
self._collect_msisdns(value, found)
return {
msisdn: self._vocalize_digits(msisdn[-4:])
for msisdn in sorted(found)
}
def _clean_plan_name(self, desc: str, *, format_version_number: bool = True) -> str:
text = self.remove_parentheses(self.remove_plan_recognition_marker(desc))
if format_version_number:
text = self._format_plan_version_number(text)
return text
def _discount_match_key(self, desc: str) -> str:
text = self._clean_plan_name(desc)
text = ud.normalize("NFKD", text)
text = "".join(ch for ch in text if not ud.combining(ch))
text = text.casefold()
text = re.sub(r"\b\d+/\d+\b", " ", text)
text = re.sub(r"\b\d+\b", " ", text)
text = re.sub(r"[^a-z]+", " ", text)
return re.sub(r"\s+", " ", text).strip()
def _service_match_key(self, desc: str) -> str:
text = ud.normalize("NFKD", str(desc))
text = "".join(ch for ch in text if not ud.combining(ch))
text = text.casefold()
text = re.sub(r"[^a-z0-9]+", " ", text)
return re.sub(r"\s+", " ", text).strip()
def _is_strategic_service(self, desc: Any) -> bool:
desc_key = self._service_match_key(str(desc or ""))
return any(pattern.search(desc_key) for pattern in self._strategic_service_patterns)
def _is_controle_plan(self, desc: Any) -> bool:
desc_key = self._service_match_key(str(desc or ""))
return bool(re.search(r"\b(?:controle|ctrl|crtl)\b", desc_key))
def calculate_discount(self, desc_match: str, msisdn: str, descontos_df: pd.DataFrame) -> float:
# Filtra por msisdn e verifica se o desc_match está contido no desc
descontos_filtrados = descontos_df[
(descontos_df['msisdn'] == msisdn) &
(descontos_df['desc'].str.lower().str.contains(desc_match.lower(), na=False))
]
return descontos_filtrados['value'].sum()
# ------------------------------------------------------------------
def normalize(self, raw: Dict[str, pd.DataFrame]) -> Dict[str, Any]:
all_msisdns = set()
for sec, df in raw.items():
if sec != "Fatura Resumo" and "msisdn" in df.columns:
all_msisdns.update(df["msisdn"].dropna().astype(str).unique())
final_output: Dict[str, Any] = {}
# Processa Fatura Resumo primeiro (independente de MSISDN)
if "Fatura Resumo" in raw:
fatura_raw = {"Fatura Resumo": raw["Fatura Resumo"]}
fatura_dfs = self._clean_dataframes(fatura_raw)
fatura_json = self._build_final_json(fatura_dfs)
if "Fatura Resumo" in fatura_json:
final_output["Fatura Resumo"] = fatura_json["Fatura Resumo"]
if self.include_danfe and "DANFE-COM" in raw:
danfe_json = self._build_danfe_payload(raw["DANFE-COM"], raw)
if danfe_json:
final_output["DANFE-COM"] = danfe_json
if not all_msisdns:
# Se não houver MSISDNs, processa tudo como global
dfs = self._clean_dataframes(raw)
dfs = self._process_plano(dfs)
dfs = self._process_eventuais(dfs)
dfs = self._process_vas(dfs)
dfs = self._process_descontos(dfs)
fallback_json = self._build_final_json(dfs)
for k, v in fallback_json.items():
if k not in final_output:
final_output[k] = v
final_output["vocalized_msisdn"] = self._build_vocalized_msisdn(final_output)
return final_output
for msisdn in all_msisdns:
msisdn_raw = {}
for sec, df in raw.items():
if sec == "Fatura Resumo":
continue
if "msisdn" in df.columns:
mask = df["msisdn"].astype(str) == msisdn
filtered_df = df[mask].copy()
if not filtered_df.empty:
msisdn_raw[sec] = filtered_df
else:
# Seções sem MSISDN podem ser globais? (Ex: Fatura Resumo já tratada)
pass
if msisdn_raw:
dfs = self._clean_dataframes(msisdn_raw)
dfs = self._process_plano(dfs)
dfs = self._process_eventuais(dfs)
dfs = self._process_vas(dfs)
dfs = self._process_descontos(dfs)
msisdn_json = self._build_final_json(dfs)
if msisdn_json:
final_output[self._format_msisdn(msisdn)] = msisdn_json
final_output["vocalized_msisdn"] = self._build_vocalized_msisdn(final_output)
return final_output
def _clean_dataframes(self, raw: Dict[str, pd.DataFrame]) -> Dict[str, pd.DataFrame]:
# 1) remove value == 0 exceto de fatura resumo e plano (que pode ter o bundle)
dfs = {
sec: (df if sec in ["Fatura Resumo", "Plano"] else df[df.value != 0.0]).copy()
for sec, df in raw.items() if "value" in df.columns
}
# 2) drop colunas totalmente NaN
dfs = {
sec: df.dropna(axis=1, how="all")
for sec, df in dfs.items() if not df.dropna(axis=1, how="all").empty
}
# 3) remove sufixo " - -" do campo desc
for df in dfs.values():
if "desc" in df.columns:
df["desc"] = df["desc"].str.replace(r"\s*- -\s*$", "", regex=True)
df["desc"] = df["desc"].str.strip()
return dfs
def _process_plano(self, dfs: Dict[str, pd.DataFrame]) -> Dict[str, pd.DataFrame]:
if "Plano" not in dfs:
return dfs
plano_df = dfs["Plano"]
if 'msisdn' in plano_df.columns:
plano_df['msisdn'] = plano_df['msisdn'].astype(str)
# Separa somente itens que vieram textualmente como "Incluído".
# Planos dependentes podem aparecer como R$ 0,00 e continuam sendo Plano.
if "_is_included_value" in plano_df.columns:
included_mask = plano_df["_is_included_value"].fillna(False).astype(bool)
else:
included_mask = plano_df.value == 0.0
bundle_df = plano_df[included_mask].copy()
plano_df = plano_df[~included_mask].copy()
if not bundle_df.empty:
bundle_df["value"] = "Incluído"
if "section_total" in bundle_df.columns:
bundle_df = bundle_df.drop(columns=["section_total"])
dfs["Serviços Bundle Inclusos"] = bundle_df
# Seleciona planos principais
mask_plan = plano_df["desc"].str.contains(r"PÓS/SMP", case=False, na=False)
if mask_plan.sum() == 0:
mask_plan = plano_df["desc"].str.match(r"(?i)^tim", case=False)
# Seleciona descontos
descontos_df = plano_df[plano_df['desc'].apply(lambda x: 'desc' in str(x).lower() and 'tim' in str(x).lower())]
plano_df = plano_df[mask_plan].copy()
plano_df["desc"] = plano_df["desc"].apply(self.remove_plan_recognition_marker)
dfs["_descontos_temp"] = descontos_df
if plano_df.empty:
dfs.pop("Plano", None)
else:
dfs["Plano"] = plano_df
return dfs
def _process_eventuais(self, dfs: Dict[str, pd.DataFrame]) -> Dict[str, pd.DataFrame]:
if "Itens Eventuais" in dfs:
ie_df = dfs["Itens Eventuais"]
mask_remove = (ie_df["value"] == 0) | (ie_df["desc"].str.contains("Serviços de Valor Adicionado Conteúdo", case=False, na=False))
ie_df = ie_df[~mask_remove].copy()
if ie_df.empty:
dfs.pop("Itens Eventuais")
else:
dfs["Itens Eventuais"] = ie_df
return dfs
def _process_vas(self, dfs: Dict[str, pd.DataFrame]) -> Dict[str, pd.DataFrame]:
"""Remove de 'Mensalidades Adicionais' os serviços recorrentes que já vêm
detalhados (com data de ativação) em 'SVA Detalhe Total', evitando duplicata.
A seção 'Serviços de Valor Adicionado Total' do PDF é o detalhamento que
reúne tanto os itens eventuais quanto as mensalidades recorrentes; quando o
mesmo serviço aparece nas duas seções, mantemos a cópia detalhada do SVA."""
sva_df = dfs.get("SVA Detalhe Total")
if sva_df is None or sva_df.empty or "desc" not in sva_df.columns:
return dfs
detalhados = {
(self._format_msisdn(rec.get("msisdn")), self._service_match_key(rec.get("desc")))
for rec in sva_df.to_dict(orient="records")
}
for section in ("Mensalidades Adicionais", "Itens Eventuais"):
df = dfs.get(section)
if df is None or df.empty or "desc" not in df.columns:
continue
mask_dup = df.apply(
lambda r: (
self._format_msisdn(r.get("msisdn")),
self._service_match_key(r.get("desc")),
) in detalhados,
axis=1,
)
if mask_dup.any():
df = df[~mask_dup].copy()
if df.empty:
dfs.pop(section, None)
else:
dfs[section] = df
return dfs
def _process_descontos(self, dfs: Dict[str, pd.DataFrame]) -> Dict[str, pd.DataFrame]:
if "_descontos_temp" in dfs:
descontos_df = dfs.pop("_descontos_temp")
if not descontos_df.empty:
if "section_total" in descontos_df.columns:
descontos_df = descontos_df.drop(columns=["section_total"])
descontos_df['installment'] = descontos_df['desc'].str.extract(r'(\d+/\d+)')
descontos_df['desc'] = descontos_df['desc'].apply(self._format_plan_version_number)
if "Descontos" in dfs and not dfs["Descontos"].empty:
dfs["Descontos"] = pd.concat(
[dfs["Descontos"], descontos_df],
ignore_index=True,
)
else:
dfs["Descontos"] = descontos_df
return dfs
def _clean_record(self, rec: Dict[str, Any]) -> Dict[str, Any]:
redundant_values = {
"qty": [1.0, 1],
"parcel": ["-", None],
"franchise": [None, "null", "-"],
"consumption": [None, "null"],
"period": ["-", None],
"value": [None],
"emissao": [None, "null", "-"],
}
cleaned_rec = {}
for k, v in rec.items():
if k == "section_total" or k.startswith("_"):
continue
if k == "msisdn":
v = self._format_msisdn(v)
if pd.isna(v):
v = None
elif k == "value" and isinstance(v, (int, float)):
v = self._round_money(v)
elif k == "days" and isinstance(v, float) and v.is_integer():
v = int(v)
if k in redundant_values and v in redundant_values[k]:
continue
cleaned_rec[k] = v
return cleaned_rec
def _records_for_section(self, df: pd.DataFrame) -> List[Dict[str, Any]]:
return [
self._clean_record(rec)
for rec in df.to_dict(orient="records")
]
def _split_strategic_records(
self,
df: pd.DataFrame,
section: str,
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
records = self._records_for_section(df)
if section not in self.STRATEGIC_SERVICE_SECTIONS:
return records, []
regular_records = []
strategic_records = []
for record in records:
if "desc" in record and self._is_strategic_service(record["desc"]):
record["estrategico"] = True
strategic_records.append(record)
else:
regular_records.append(record)
return regular_records, strategic_records
def _annotate_class(
self,
records: List[Dict[str, Any]],
section: str,
) -> List[Dict[str, Any]]:
section_classe = self.SECTION_CLASS_MAP.get(section)
if not section_classe:
return records
for rec in records:
# Itens estratégicos dobrados nesta seção (commit 7fc38a4e) não
# herdam a classe da seção (avulso): mantêm classe=estrategico e
# verbo "falar sobre". Os demais seguem o default da seção.
classe = "estrategico" if rec.get("estrategico") is True else section_classe
rec["classe"] = classe
verb = self.CLASSE_VERB_MAP.get(classe)
if verb:
rec["verb"] = verb
return records
def _round_money(self, value: float) -> float:
return round(float(value) + 0.0, 2)
def _is_discount_desc(self, desc: str) -> bool:
return bool(re.match(r"(?i)^\s*desc(?:onto)?\b", desc))
def _danfe_record(self, rec: Dict[str, Any]) -> Dict[str, Any]:
cleaned = {}
for k, v in rec.items():
if k in {"section_total", "msisdn", "is_total"}:
continue
if pd.isna(v):
v = None
elif k in {"preco_unit", "pis_cofins", "bc_icms", "icms", "value"} and isinstance(v, (int, float)):
v = self._round_money(v)
elif k == "qty" and isinstance(v, float) and v.is_integer():
v = int(v)
cleaned[k] = v
return cleaned
def _danfe_item_payload(self, rec: Dict[str, Any]) -> Dict[str, Any]:
item = self._danfe_record(rec)
valor_bruto = self._round_money(item.pop("value", 0.0) or 0.0)
item["valor_bruto"] = valor_bruto
item["total_descontos"] = 0.0
item["valor_final"] = valor_bruto
item["descontos"] = []
return item
def _danfe_discount_payload(self, rec: Dict[str, Any]) -> Dict[str, Any]:
discount = self._danfe_record(rec)
if "value" in discount and discount["value"] is not None:
discount["value"] = self._round_money(discount["value"])
return discount
def _desc_matches_key(self, desc_key: str, target_key: str) -> bool:
return bool(
desc_key
and target_key
and (desc_key == target_key or desc_key.startswith(target_key) or target_key.startswith(desc_key))
)
def _discount_matches_item(self, discount_desc: str, item_desc: str) -> bool:
discount_key = self._discount_match_key(discount_desc)
item_key = self._discount_match_key(item_desc)
return bool(item_key and item_key in discount_key)
def _build_danfe_payload(
self,
danfe_df: pd.DataFrame,
raw: Dict[str, pd.DataFrame],
) -> Dict[str, Any]:
if danfe_df.empty:
return {}
total_geral = None
item_records = []
for rec in danfe_df.to_dict(orient="records"):
if bool(rec.get("is_total")):
total_geral = self._round_money(rec.get("value", 0.0) or 0.0)
else:
item_records.append(rec)
plan_names: List[str] = []
if "Plano" in raw and "desc" in raw["Plano"].columns:
plano_df = raw["Plano"]
mask_plan = plano_df["desc"].str.contains(r"PÓS/SMP", case=False, na=False)
if mask_plan.sum() == 0:
mask_plan = plano_df["desc"].str.match(r"(?i)^tim", na=False)
for desc in plano_df.loc[mask_plan, "desc"].dropna().astype(str):
plan_name = self._clean_plan_name(desc, format_version_number=False)
if plan_name not in plan_names:
plan_names.append(plan_name)
if not plan_names:
for rec in item_records:
desc = str(rec.get("desc", ""))
desc_key = self._discount_match_key(desc)
if desc_key.startswith("tim ") and not self._is_discount_desc(desc):
plan_name = self._clean_plan_name(desc, format_version_number=False)
if plan_name.casefold() != "tim music" and plan_name not in plan_names:
plan_names.append(plan_name)
plan_matchers = [(name, self._discount_match_key(name)) for name in plan_names]
other_keys: List[str] = []
if "Outros Valores" in raw and "desc" in raw["Outros Valores"].columns:
for desc in raw["Outros Valores"]["desc"].dropna().astype(str):
key = self._discount_match_key(desc)
if key and key not in other_keys:
other_keys.append(key)
payload: Dict[str, Any] = {"Planos": {name: [] for name in plan_names}}
if total_geral is not None:
payload["total_geral"] = total_geral
outros_itens: List[Dict[str, Any]] = []
current_plan: str | None = None
last_item_by_plan: Dict[str, Dict[str, Any]] = {}
for rec in item_records:
desc = str(rec.get("desc", ""))
desc_key = self._discount_match_key(desc)
matched_plan = next(
(
name
for name, plan_key in plan_matchers
if self._desc_matches_key(desc_key, plan_key)
),
None,
)
is_known_other = any(
self._desc_matches_key(desc_key, other_key)
for other_key in other_keys
)
is_discount = self._is_discount_desc(desc)
if matched_plan and not is_discount:
current_plan = matched_plan
item = self._danfe_item_payload(rec)
payload["Planos"].setdefault(current_plan, []).append(item)
last_item_by_plan[current_plan] = item
continue
if is_known_other and not is_discount:
outros_itens.append(self._danfe_item_payload(rec))
continue
if is_discount:
target_item = None
if current_plan:
for item in reversed(payload["Planos"].get(current_plan, [])):
if self._discount_matches_item(desc, str(item.get("desc", ""))):
target_item = item
break
if target_item is None:
target_item = last_item_by_plan.get(current_plan)
if target_item is None:
outros_itens.append(self._danfe_discount_payload(rec))
continue
discount = self._danfe_discount_payload(rec)
target_item["descontos"].append(discount)
continue
if current_plan:
item = self._danfe_item_payload(rec)
payload["Planos"].setdefault(current_plan, []).append(item)
last_item_by_plan[current_plan] = item
else:
outros_itens.append(self._danfe_item_payload(rec))
for items in payload["Planos"].values():
for item in items:
item["descontos"].sort(
key=lambda discount: abs(float(discount.get("value") or 0.0)),
reverse=True,
)
total_descontos = sum(
float(discount.get("value") or 0.0)
for discount in item["descontos"]
)
item["total_descontos"] = self._round_money(total_descontos)
item["valor_final"] = self._round_money(
float(item.get("valor_bruto") or 0.0) + total_descontos
)
if outros_itens:
payload["Outros Itens"] = outros_itens
return payload
def _match_discount_to_plan(
self,
discount: Dict[str, Any],
plans: List[Tuple[str, Dict[str, Any], str]],
) -> str | None:
discount_key = self._discount_match_key(str(discount.get("desc", "")))
matches = [
(plan_name, plan)
for plan_name, plan, plan_key in plans
if plan_key and plan_key in discount_key
]
if not matches:
return None
discount_period = discount.get("period")
if discount_period:
for plan_name, plan in matches:
if plan.get("period") == discount_period:
return plan_name
return matches[0][0]
def _build_plan_payload(
self,
plano_df: pd.DataFrame | None,
descontos_df: pd.DataFrame | None,
) -> tuple[Dict[str, Dict[str, Any]], List[Dict[str, Any]]]:
if plano_df is None or plano_df.empty:
return {}, self._records_for_section(descontos_df) if descontos_df is not None else []
plan_records = self._records_for_section(plano_df)
discount_records = self._records_for_section(descontos_df) if descontos_df is not None else []
payload: Dict[str, Dict[str, Any]] = {}
plan_matchers: List[Tuple[str, Dict[str, Any], str]] = []
for plan in plan_records:
desc = plan.get("desc")
if not desc:
continue
plan_name = self._clean_plan_name(str(desc))
plan_payload = {}
for field in ("period", "days", "msisdn"):
if field in plan:
plan_payload[field] = plan[field]
valor_bruto = self._round_money(plan.get("value", 0.0))
plan_payload["valor_final"] = valor_bruto
for field, value in plan.items():
if field not in {"desc", "period", "days", "msisdn", "value"}:
plan_payload[field] = value
if self._is_controle_plan(desc):
plan_payload["is_controle"] = True
plan_payload["descontos"] = []
plan_payload["total_descontos"] = 0.0
plan_payload["valor_bruto"] = valor_bruto
payload[plan_name] = plan_payload
plan_matchers.append((plan_name, plan, self._discount_match_key(plan_name)))
unmatched_discounts: List[Dict[str, Any]] = []
for discount in discount_records:
plan_name = self._match_discount_to_plan(discount, plan_matchers)
if plan_name is None:
unmatched_discounts.append(discount)
continue
discount_payload = {
"desc": discount.get("desc"),
"value": discount.get("value"),
"installment": discount.get("installment"),
}
payload[plan_name]["descontos"].append(discount_payload)
for plan in payload.values():
total_descontos = sum(
float(discount.get("value") or 0.0)
for discount in plan["descontos"]
)
plan["total_descontos"] = self._round_money(total_descontos)
valor_final = self._round_money(
float(plan.get("valor_bruto") or 0.0) + total_descontos
)
plan["valor_final"] = valor_final
return payload, unmatched_discounts
def _build_final_json(self, dfs: Dict[str, pd.DataFrame]) -> Dict[str, Any]:
out: Dict[str, Any] = {}
plano_payload, descontos_restantes = self._build_plan_payload(
dfs.get("Plano"),
dfs.get("Descontos"),
)
for sec in self.SECTIONS:
if sec == "DANFE-COM":
continue
if sec == "Plano" and plano_payload:
out["Planos"] = plano_payload
continue
if sec == "Descontos" and plano_payload:
if descontos_restantes:
out[sec] = descontos_restantes
continue
if sec in dfs:
section_records, strategic_records = self._split_strategic_records(
dfs[sec],
sec,
)
all_records = section_records + strategic_records
if all_records:
out[sec] = self._annotate_class(all_records, sec)
return out