Files
Investidor-V2/tools/prepare-financial-institutions-source.py

229 lines
23 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Read the supplied source; prepare existing catalogue references, never live data."""
from pathlib import Path
import argparse, hashlib, json, re, unicodedata, zipfile
import xml.etree.ElementTree as ET
parser=argparse.ArgumentParser()
parser.add_argument('source', type=Path)
parser.add_argument('--output', type=Path, required=True)
args=parser.parse_args()
assert args.source.name == 'Instituições financeiras em Moçambique.docx'
ns={'w':'http://schemas.openxmlformats.org/wordprocessingml/2006/main'}
raw=args.source.read_bytes();digest=hashlib.sha256(raw).hexdigest()
assert digest=='119bb5514d2777849b70aebfec21d28a41885cbdc7d9827fbebd764fe994c108', 'Reconcile a changed source before preparing another edition'
def clean(s): return re.sub(r'\s+',' ',s).strip()
def norm(s): return re.sub(r'[^a-z0-9]+',' ',unicodedata.normalize('NFKD',s).encode('ascii','ignore').decode().lower()).strip()
def slug(s): return norm(s).replace(' ','-')[:75]
with zipfile.ZipFile(args.source) as z:
body=ET.fromstring(z.read('word/document.xml')).find('w:body',ns)
tables={};paragraphs=[];heading='';number=0
for node in body:
if node.tag.endswith('}p'):
text=clean(''.join(node.itertext()))
if not text or 'PAGEREF' in text or text.startswith('TOC '): continue
if re.match(r'^\d+\.',text): heading=text
paragraphs.append((heading,text))
elif node.tag.endswith('}tbl'):
number+=1
tables[number]=(heading,[[clean(' '.join(c.itertext())) for c in r.findall('w:tc',ns)] for r in node.findall('w:tr',ns)])
base=json.loads((Path(__file__).resolve().parents[1]/'database/data/funding-references.json').read_text(encoding='utf-8'))
refs={};coverage=[]
category={'commercial_bank':'banking','investment_bank':'banking','bank':'banking','microbank':'credit','cooperative':'credit','savings':'credit','investment_manager':'investment','investment_company':'investment','pension_manager':'pensions','insurer':'insurance','reinsurer':'insurance','microinsurer':'insurance','broker':'intermediation','agent':'intermediation','reinsurance_broker':'intermediation','exchange_house':'exchange','electronic_money':'payments','interbank':'payments','supervisor':'supervision','other':'other'}
services={'banking':['investment','working_capital','guarantee','payments'],'credit':['investment','working_capital'],'investment':['investment_management','equity'],'pensions':['pension'],'insurance':['insurance'],'intermediation':['insurance'],'exchange':['foreign_exchange'],'payments':['payments'],'supervision':['other'],'other':['other']}
def key(name):
n=norm(name)
if 'gestora' in n and 'standard bank' in n: return 'standard-bank-pension-manager'
if 'nbc' in n and ('sgfp' in n or 'pensoes' in n): return 'nbc-pension-manager'
if 'letshego' in n and 'financial services' in n: return 'letshego-microbank-historical'
patterns=[('standard bank','standard-bank'),('millennium','millennium-bim'),('bci','bci'),('moza banco','moza'),('banco nacional de investimento','bni'),('bni','bni'),('gapi','gapi'),('bfs','bfs'),('socremo','socremo'),('banco letshego','letshego-bank'),('letshego','letshego-bank'),('ac microbanco','ac-microbanco'),('xtenda','xtenda'),('yingwe microbanco','yingwe-microbanco'),('my bucks','my-bucks'),('mybucks','my-bucks'),('microbanco confianca','confianca-microbanco'),('caixa financeira de catandica','caixa-catandica'),('caixa financeira de caia','caixa-caia'),('futuro microbanco','futuro-microbanco'),('access bank','access-bank'),('african banking corporation','abc-historical'),('banco big','banco-big'),('first capital','first-capital-bank'),('ecobank','ecobank'),('nedbank','nedbank'),('uba','uba'),('vista bank','vista-bank'),('fnb','fnb'),('absa','absa'),('issm','issm'),('global alliance','global-alliance'),('fidelidade','fidelidade'),('sanlam mocambique vida','sanlam-vida'),('mocambique companhia de seguros','mcs-seguradora'),('empresa mocambicana de seguros','emose'),('emose','emose'),('emergitus','emeritus'),('emeritus','emeritus'),('companhia de seguros indico','indico'),('britam','britam'),('arko companhia','arko'),('palma companhia','palma'),('hollard vida','hollard-vida'),('hollard mocambique','hollard-mocambique'),('confianca micro seguros','confianca-microseguros'),('caixa das mulheres de nacala','women-nacala-credit-cooperative'),('cooperativa de credito das mulheres de nacala','women-nacala-credit-cooperative'),('sociedade cooperativa de credito das mulheres de nacala','women-nacala-credit-cooperative')]
for needle,value in patterns:
if n==needle or n.startswith(needle+' ') or needle in n and needle in ['sociedade cooperativa de credito das mulheres de nacala','empresa mocambicana de seguros','banco nacional de investimento']:return value
n=re.sub(r' (?:s a|sa|sarl|scrl|lda|ei|su)$','',n)
if 'cooperativa' in n and 'pemba' in n and 'mulheres' in n:return 'women-pemba-credit-cooperative'
return 'fin-'+slug(n)
def nature(t):
return 'bank' if t in ['commercial_bank','investment_bank','bank'] else 'microfinance' if t in ['microbank','cooperative','savings'] else 'other'
def evidence(table,row,text):return {'id':digest[:16]+f':t{table}:r{row}','section':tables[table][0],'text':clean(text)}
def ensure(name,t,identity=None):
existing=identity or key(name)
if len(existing)>80:existing=existing[:67]+'-'+hashlib.sha256(existing.encode()).hexdigest()[:12]
if t is None: t=refs[existing]['draft']['institution_types'][0] if existing in refs else 'other'
k=existing
if k not in refs:
if k in base['references'] and base['references'][k]['draft']['kind']=='institution': ref=json.loads(json.dumps(base['references'][k]))
else:
ref={'edition':'institutions-nov2026-v1','provenance':[], 'draft':{'title':name,'kind':'institution','summary':'Instituição identificada no documento fornecido. Categoria, localização e contactos são informativos; produtos, disponibilidade e condições não documentados permanecem por confirmar.','manager_name':name,'source_url':None,'source_reference':args.source.name+' — Novembro de 2026','source_checked_on':'2026-11-10','source_status':'identified','coverage':'unknown','coverage_text':'Cobertura dos serviços/financiamento por confirmar. A presença física não determina a cobertura.','territory_ids':[],'sectors_scope':'unknown','sector_ids':[],'resource_ids':[],'criteria':[],'windows':[],'institution_key':k,'institution_nature':nature(t),'contacts':[],'locations':[],'products':[]}}
d=ref['draft'];d.update({'source_document_name':args.source.name,'source_document_sha256':digest,'institution_categories':[],'institution_types':[],'institution_services':[],'institution_status':'reported','aliases':[],'source_rows':[]})
ref['edition']='institutions-nov2026-v1'
ref['provenance'].append({'reference':args.source.name+'; edição Novembro de 2026; afirmações e limites do original conservados','sha256':digest,'checked_on':'2026-11-10'})
refs[k]=ref
d=refs[k]['draft']
for field,value in [('institution_types',t),('institution_categories',category[t])]:
if value not in d[field]: d[field].append(value)
for service in services[category[t]]:
if service not in d['institution_services']:d['institution_services'].append(service)
if name not in d['aliases']:d['aliases'].append(name)
return k
def attach(k,e):
if e not in refs[k]['draft']['source_rows']:refs[k]['draft']['source_rows'].append(e)
def origin(text):
n=norm(text)
if any(x in n for x in ['confirmar','directorio','diretorio','nao encontrada','nao encontrado','nao obtida','nao confirmad','nao e a mesma','nao confirmei']):return 'to_confirm'
if any(x in n for x in ['antiga','2021','2023','historica']):return 'historical'
return 'reported'
def province(text):
n=norm(text)
pairs=[('maputo provincia','Maputo Província'),('provincia de maputo','Maputo Província'),('cidade de maputo','Maputo Cidade'),('maputo cidade','Maputo Cidade'),('matola','Maputo Província'),('marracuene','Maputo Província'),('boane','Maputo Província'),('matutuine','Maputo Província'),('gaza','Gaza'),('xai xai','Gaza'),('inhambane','Inhambane'),('maxixe','Inhambane'),('morrumbene','Inhambane'),('sofala','Sofala'),('beira','Sofala'),('manica','Manica'),('chimoio','Manica'),('catandica','Manica'),('tete','Tete'),('angonia','Tete'),('zambezia','Zambezia'),('quelimane','Zambezia'),('nampula','Nampula'),('nacala','Nampula'),('niassa','Niassa'),('lichinga','Niassa'),('cuamba','Niassa'),('cabo delgado','Cabo Delgado'),('pemba','Cabo Delgado'),('montepuez','Cabo Delgado'),('maputo','Maputo Cidade')]
for needle,target in pairs:
if needle in n:return target
return None
def source(e,state):return {'source_url':None,'source_reference':args.source.name+' — '+e['section']+'; '+e['id']+'; fonte declarada no original','source_document_name':args.source.name,'source_document_sha256':digest,'checked_on':'2026-11-10','evidence_status':state,'origin':'Documento fornecido; não é uma nova verificação do regulador','source_refs':[e['id']]}
def location(k,label,address,place,e,role='branch',state=None,hours=''):
d=refs[k]['draft'];state=state or origin(address+' '+place)
if not address or any(x in norm(address) for x in ['nao encontrad','nao obtid','moradas nao confirmad']):state='unlocated'
identity=hashlib.sha256((k+'|'+role+'|'+norm(label)+'|'+norm(address)+'|'+norm(place)).encode()).hexdigest()[:24]
for old in d['locations']:
if old.get('id')==identity:
if e['id'] not in old['source_refs']:old['source_refs'].append(e['id'])
return
loc={**source(e,state),'id':identity,'role':role,'label':label[:250],'address':address[:1500],'territory_id':None,'location_text':place[:1000],'hours':hours[:1000]}
d['locations'].append(loc)
p=province(place)
if p:refs[k].setdefault('location_territory_names',{})[str(len(d['locations'])-1)]=p
def contacts(k,text,e,state=None):
if not text or norm(text) in ['nao encontrado','nao encontrada','nao obtido','nao encontrada nas 115 entradas lidas','']:return
d=refs[k]['draft'];state=state or origin(text)
emails=re.findall(r'[\w.+-]+@[\w.-]+\.[a-zA-Z]{2,}',text)
values=[('email',v) for v in emails]
# Preserve the exact business telephone assertion, including qualifiers; do not fabricate missing digits.
if re.search(r'\d{2}[\s\d()+/;-]{5,}',text):values.append(('phone',text[:1000]))
for kind,value in values:
identity=hashlib.sha256((k+'|'+kind+'|'+norm(value)).encode()).hexdigest()[:24]
if not any(c.get('id')==identity or c['kind']==kind and c['value']==value for c in d['contacts']):d['contacts'].append({**source(e,state),'id':identity,'kind':kind,'value':value})
def add(table,row,k,e):coverage.append({'table':table,'row':row,'institutions':[k],'source_row':e['id'],'status':'represented'});attach(k,e)
def typ(text):
n=norm(text)
for part,result in [('gestora de fundos de investimento','investment_manager'),('gestora de fundos de pensoes','pension_manager'),('cooperativa','cooperative'),('microbanco','microbank'),('microbancos','microbank'),('micro segur','microinsurer'),('supervisor','supervisor'),('investimento publico','investment_bank'),('banco de investimento','investment_bank'),('banco comercial','commercial_bank'),('moeda electronica','electronic_money'),('interbancarios','interbank'),('sociedade de investimento','investment_company')]:
if part in n:return result
return 'bank' if 'banco' in n else 'other'
# Lists with stable regulator numbers are identities, not fuzzy matches by name.
broker_names={}
for row in tables[26][1][1:]:
for n in [0,2]:
if row[n]:broker_names[int(row[n])]=row[n+1]
for t,(section,rows) in tables.items():
for j,row in enumerate(rows[1:],1):
e=evidence(t,j,' | '.join(row))
if t in [12,19,29]:coverage.append({'table':t,'row':j,'status':'aggregate_or_gap_not_an_institution','source_row':e['id']});continue
if t==26:
keys=[]
for n in [0,2]:
if row[n]:
k=ensure(row[n+1],'broker','issm-broker-'+row[n].zfill(3));attach(k,e);keys.append(k)
coverage.append({'table':t,'row':j,'institutions':keys,'source_row':e['id'],'status':'represented'});continue
if t==27:
keys=[]
for n in [0,2]:
if row[n]:
k=ensure(row[n+1],'agent','issm-agent-'+row[n].zfill(3));attach(k,e);keys.append(k)
coverage.append({'table':t,'row':j,'institutions':keys,'source_row':e['id'],'status':'represented'});continue
if t==25:
m=re.match(r'Sim\s*\(\s*n[ºo°]?\s*(\d+)',row[5],re.I)
k=ensure(row[0],'broker','issm-broker-'+m.group(1).zfill(3) if m else 'broker-unconfirmed-'+slug(row[0]));add(t,j,k,e)
refs[k]['draft']['register_reference']=row[5]
location(k,'Morada indicada para a corretora',row[1],row[1],e,'contact_point',origin(row[1]+' '+row[3]))
contacts(k,row[2]+' '+row[4],e,origin(row[3]+' '+row[4]));continue
if t in [1,3,6,13,14]:
if t==14 and j in [4,5]:
names=['Bayport Financial Services','Caixa Mulher'] if j==4 else ['CCOM Maputo Norte','CCOM Maputo Sul','CCOM Matola','Associação dos Camponeses de Macuvulana','Caixa Comunitária de Chókwè','Diocese de Nacala','CCOM Montepuez','CCOM Chiúre','CCOM Namuno','CCOM Ancuabe','CCOM Balama']
keys=[]
for name in names:
k=ensure(name,'microbank' if j==4 else 'savings');attach(k,e);keys.append(k)
if j==5 and name=='Diocese de Nacala':location(k,'Contacto documentado','Paço Episcopal S. Justino de Jacobis, Nacala-Porto','Nampula',e,'contact_point','historical')
coverage.append({'table':t,'row':j,'institutions':keys,'source_row':e['id'],'status':'represented_split_explicit_names'});continue
k=ensure(row[0],typ(row[1]));add(t,j,k,e)
if t==1:location(k,'Sede',row[2],'Cidade de Maputo',e,'headquarters');contacts(k,row[3],e)
elif t==3:location(k,'Localização da gestora indicada na fonte',row[2],row[2],e,'contact_point');refs[k]['draft']['register_reference']=row[3]
elif t==13:location(k,'Local indicado na lista do Banco de Moçambique',row[3],row[2],e,'contact_point','historical')
elif t==14:
if j==1:refs[k]['draft'].update({'institution_status':'merged','successor_key':'access-bank'})
elif j in [2,3]:location(k,'Sede por confirmar',row[2],'Maputo',e,'headquarters','to_confirm')
continue
if t in [2,4,5,7,8,9,10,11,15]:
if t==2:name='Standard Bank';place,label,address,hours=row
elif t==4:name,label,address,quality=row;place='Cidade de Maputo';hours=''
elif t in [5,7,9,10]:name={5:'Standard Bank',7:'Socremo',9:'Millennium bim',10:'BCI'}[t];place,label,address=row;hours=''
elif t==8:name,place,address,quality=row;label='Sede' if 'sede' in norm(name+' '+address) else place;hours=''
elif t==11:name='BCI';place,label,address,quality=row;hours=''
else:name,place,label,address,quality=row;hours=''
k=ensure(name,'microbank' if t in [7,8] and not norm(name).startswith('banco letshego') else 'commercial_bank');add(t,j,k,e)
state=origin(' | '.join(row))
if t==4 and 'BCI' in name and j>1:state='to_confirm'
location(k,label,address,place,e,'headquarters' if 'sede' in norm(label) else 'branch',state,hours);continue
if t in [16,18,20,28]:
if t==16:name,address,phone,quality,email=row;place='Cidade de Maputo';tpe=None
elif t==18:name,address,phone,hours=row;place=address;tpe='exchange_house';quality='Directório — a confirmar';email=''
elif t==20:place,name,phone,quality=row;address='';email='';tpe='supervisor' if 'ISSM' in name else 'investment_manager' if 'BFS' in name else None
else:name,address,phone,_=row;place=address;quality='Resumo do documento';email='';tpe='reinsurer' if 'resseguradora' in name else 'insurer'
k=ensure(name,tpe);add(t,j,k,e);contacts(k,phone+' '+email,e,origin(quality+' '+phone))
if address:location(k,'Sede ou contacto indicado na fonte',address,place,e,'contact_point',origin(quality+' '+address))
continue
if t==17:
names=row[0].split(', ') if j==7 else [row[0]]
keys=[]
for name in names:
k=ensure(name,'exchange_house');attach(k,e);keys.append(k);location(k,'Morada indicada no documento',row[2],row[1],e,'contact_point','unlocated' if j==7 else 'historical')
coverage.append({'table':t,'row':j,'institutions':keys,'source_row':e['id'],'status':'represented'});continue
if t in [21,22,23,24]:
if t==24:place,name,address,phone=row;tpe='insurer';label=name.split('–')[-1].strip();base_name=name.split('–')[0].strip()
else:place,name,ramo,address,phone=row;tpe={21:'insurer',22:'reinsurer',23:'microinsurer'}[t];label='Sede';base_name=name
k=ensure(base_name,tpe);add(t,j,k,e);location(k,label,address,place,e,'branch' if t==24 else 'headquarters');contacts(k,phone,e)
continue
raise RuntimeError(f'Unaccounted table {t}, row {j}')
# Named entries outside tables; retain uncertainty instead of executing prose as instructions.
for section,text in paragraphs:
if section.startswith('15.6') and not re.match(r'^15\.6',text):
for name in ['Chicomo','Renaissance','Pioneer Reinsurance Brokers','Makhaly','Gateway']:
k=ensure(name,'reinsurance_broker');attach(k,{'id':digest[:16]+':p15.6:'+slug(name),'section':section,'text':text})
k=ensure('Safeline','microinsurer');attach(k,{'id':digest[:16]+':p15.6:safeline','section':section,'text':text})
if section.startswith('11.') and 'Executivo Câmbios' in text:
for name in ['Executivo Câmbios','Sara Moçambique','Al-Meca Câmbios','Acácio Câmbios']:
k=ensure(name,'exchange_house');refs[k]['draft']['institution_status']='historical';attach(k,{'id':digest[:16]+':p11:'+slug(name),'section':section,'text':text})
for k,ref in refs.items():
types=ref['draft']['institution_types']
if 'bank' in types and any(t in types for t in ['commercial_bank','investment_bank']):types.remove('bank')
if k=='letshego-microbank-historical':ref['draft']['institution_status']='unknown'
# Explicit branch/contact facts in prose are not discarded merely because they are outside a table.
def paragraph_evidence(section,text,tag):return {'id':digest[:16]+':p:'+tag,'section':section,'text':text}
for section,text in paragraphs:
if section.startswith('7.') and text.startswith('Além dos Mpoint'):
e=paragraph_evidence(section,text,'bim-commercial-names');k=ensure('Millennium bim','commercial_bank');attach(k,e)
names=['Rovuma 191','Rovuma 522','Shoprite Maputo','Baía Mall','Zimpeto Mall','Mica','Marés','Zimpeto Mercado','Shoprite Matola','Parque dos Poetas Prestige','Polana Shopping','Quedas (Centro Comercial Shoprite)','Manica Shopping Center','Matema (Centro Comercial Terrace Africa)','Shoprite Beira','Muahivire']
for name in names:location(k,name,'Morada por confirmar',name,e,'branch','unlocated')
elif section.startswith('7.') and text.startswith('Morada encontrada: Polana Shopping'):
e=paragraph_evidence(section,text,'bim-directory-addresses');k=ensure('Millennium bim','commercial_bank');attach(k,e)
location(k,'Polana Shopping','Loja 43, piso 0, Polana Shopping & Office Center, Av. 24 de Julho, nº 11','Maputo',e,'branch','reported')
location(k,'Rovuma — correspondência aos balcões por confirmar','Rua da Sé, nº 114, R/C, Maputo','Maputo',e,'contact_point','to_confirm');contacts(k,'21 312 471',e,'to_confirm')
elif section.startswith('15.5') and text.startswith('Lista do ISSM, com os 115'):
e=paragraph_evidence(section,text,'older-broker-names')
for name in ['Real Risk','Quorus']:
k=ensure(name,'broker','broker-unconfirmed-'+slug(name));refs[k]['draft']['institution_status']='unknown';attach(k,e)
k=key('Safeline')
if k in refs:refs[k]['draft']['institution_status']='unknown';refs[k]['draft']['register_reference']='A fonte menciona Safeline em listas de corretoras e como micro-seguradora; identidade/categoria actual por confirmar.';attach(k,e)
elif section.startswith('15.6') and text.startswith('O ISSM licencia 39'):
e=paragraph_evidence(section,text,'agent-and-reinsurance-contacts')
for k,label,address,state in [('issm-agent-026','Capital Consultoria & Agentes de Seguros','Av. 25 de Setembro, nº 1147, 2A, Maputo','to_confirm'),('issm-agent-027','Carlyle Partners','Rua Eça de Queiroz, nº 135, Coop, Maputo','to_confirm'),('issm-agent-030','Possível morada de homónimo CBM; identidade por confirmar','Rua Fernão Lopes, nº 218, Sommerschield, Maputo','conflict'),('issm-agent-016','Possível morada de homónimo Prestígio; identidade por confirmar','Av. Albert Lithuli, Maputo','conflict')]:
attach(k,e);location(k,label,address,'Cidade de Maputo',e,'contact_point',state)
contacts('issm-agent-026','86 442 8633',e,'to_confirm');contacts('issm-agent-001','+258 21 083 820; geral@segurodirecto.co.mz',e,'reported')
for name,address in [('Renaissance','Av. 25 de Setembro, nº 1230, edifício 33 Andares, R/C, porta 3'),('Gateway','Rua Rofinho de Oliveira, nº 68, 1º andar, Bairro Central')]:
k=key(name);attach(k,e);location(k,'Contacto de directório, a confirmar',address,'Cidade de Maputo',e,'contact_point','to_confirm')
elif section.startswith('15.4') and text.startswith('Só encontrei delegações publicadas'):
e=paragraph_evidence(section,text,'palma-delegations-without-address');k=ensure('Palma Companhia de Seguros','insurer');attach(k,e)
for name in ['Pemba','Quelimane','Beira','Nampula','Tete']:location(k,'Agência indicada: '+name,'Morada por confirmar',name,e,'branch','unlocated')
# Whole source notes remain documentary evidence, never operational commands.
notes=[{'section':s,'text':t} for s,t in paragraphs if not re.match(r'^\d+\.',t)]
result={'format':'investmoz-funding-references-v1','source':{'name':args.source.name,'sha256':digest,'edition':'Novembro de 2026'},'references':refs,'source_coverage':coverage,'source_notes':notes}
args.output.parent.mkdir(parents=True,exist_ok=True)
args.output.write_text(json.dumps(result,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
print(json.dumps({'institutions':len(refs),'locations':sum(len(x['draft']['locations']) for x in refs.values()),'contacts':sum(len(x['draft']['contacts']) for x in refs.values()),'table_rows_accounted':len(coverage),'categories':sorted({c for x in refs.values() for c in x['draft']['institution_categories']}),'source_sha256':digest}))