"""A1 · filtro mecánico, discrepancias A-B, lotes del juez y combinación final (sin modelos). python3 -I combinar.py discrepancias lee lecturas/A y lecturas/B, aplica el filtro, escribe priv/discrepancias.json y lectura/lotes_juez.json combinar con lecturas/J, escribe ../priv/lectura_final.csv y ../salidas/20_lectura_metricas.json Filtro: una cita que no está en el texto del caso (espacios normalizados, comillas tipográficas = rectas) anula su dato (cantidad → null; producto → no_consta; importe_tipo → no_consta). La cantidad exige además que su número aparezca en la cita (con . , o espacio de millares) salvo `duda: true` (suma declarada), que va al juez siempre. Concordancia (por caso × lote): misma unidad, mismo producto, mismo periodo_cantidad y cantidad a ±1 % (o las dos null).""" import json, os, sys, re, glob, hashlib, collections, csv, math L_ = '/precios/lectura'; P = os.path.dirname(L_) def nt(s): return re.sub(r'\s+', ' ', (s or '').replace('“', '"').replace('”', '"').replace('’', "'").replace('‘', "'")).strip().lower() def texto(caso): return nt(open(f'{L_}/textos/{caso}.txt', encoding='utf-8').read()) def formas(x): if x is None: return [] out = set(); xi = int(round(x)) if abs(x - round(x)) < 1e-9 else None if xi is not None: s = str(xi); out |= {s, f'{xi:,}'.replace(',', '.'), f'{xi:,}', f'{xi:,}'.replace(',', ' ')} else: s = repr(x).rstrip('0'); out |= {s, s.replace('.', ',')} ent, dec = s.split('.'); ei = int(ent); out |= {f'{ei:,}'.replace(',', '.') + ',' + dec, f'{ei:,}' + '.' + dec} return out def num_en_cita(x, cita): c = nt(cita); return any(f in c for f in formas(x)) def leer(mod): out = {}; err = [] for f in sorted(glob.glob(f'{L_}/lecturas/{mod}/lote_*.json')): try: d = json.load(open(f, encoding='utf-8')) except Exception as e: err.append((os.path.basename(f), repr(e)[:80])); continue for c in d.get('casos', []): for l in c.get('lotes', []) or []: out[(c.get('caso'), None if l.get('lote') in (None, 'null', '') else str(l.get('lote')))] = l return out, err def filtrar(l, txt, cont): l = dict(l); cont['datos'] += 1 for campo, cita in (('cantidad', 'cita_cantidad'), ('producto', 'cita_producto'), ('importe_tipo', 'cita_importe_tipo')): vacio = {'cantidad': None, 'producto': 'no_consta', 'importe_tipo': 'no_consta'}[campo] if l.get(campo) in (None, 'no_consta'): continue ci = l.get(cita) if not ci or nt(ci) not in txt: cont[f'cita_inexistente_{campo}'] += 1; l[campo] = vacio; l['filtrado_' + campo] = True; continue if campo == 'cantidad': try: l['cantidad'] = float(l['cantidad']) except Exception: l['cantidad'] = None; cont['cantidad_no_numerica'] += 1; continue if not num_en_cita(l['cantidad'], ci) and not l.get('duda'): cont['numero_no_en_cita'] += 1; l['cantidad'] = None; l['filtrado_cantidad'] = True return l CLAVES = ('producto', 'unidad', 'periodo_cantidad') def concuerdan(a, b): if any((a.get(k) or 'no_consta') != (b.get(k) or 'no_consta') for k in CLAVES): return False x, y = a.get('cantidad'), b.get('cantidad') if x is None and y is None: return True if x is None or y is None: return False return abs(x - y) <= 0.01 * max(abs(x), abs(y)) def discrepancias(): A, ea = leer('A'); B, eb = leer('B'); cont = collections.Counter(); D = []; conc = {} for k in sorted(set(A) | set(B), key=str): txt = texto(k[0]) a = filtrar(A[k], txt, cont) if k in A else None; b = filtrar(B[k], txt, cont) if k in B else None if a is not None and b is not None and concuerdan(a, b) and not a.get('duda') and not b.get('duda') \ and (a.get('importe_tipo') or 'no_consta') == (b.get('importe_tipo') or 'no_consta'): conc[str(k)] = a; continue i = 'D' + hashlib.md5(str(k).encode()).hexdigest()[:8] par = [a or {'sin_respuesta': True}, b or {'sin_respuesta': True}] orden = int(hashlib.md5(('orden' + i).encode()).hexdigest(), 16) % 2 # orden de las respuestas al azar fijo D.append(dict(id=i, caso=k[0], lote=k[1], respuesta_1=par[orden], respuesta_2=par[1 - orden], A_es=1 + orden)) json.dump(dict(concordantes=conc, discrepancias=D, contadores=cont, json_rotos=ea + eb), open(f'{P}/priv/discrepancias.json', 'w'), ensure_ascii=False, indent=1) # lotes del juez: ≤ 120.000 tokens de texto (casos distintos + discrepancias) porcaso = collections.defaultdict(list) for d in D: porcaso[d['caso']].append(d) lotes, act, tok = [], [], 0 for caso in porcaso: t = os.path.getsize(f'{L_}/textos/{caso}.txt') / 2.1 + 600 * len(porcaso[caso]) if act and tok + t > 120000: lotes.append((act, tok)); act, tok = [], 0 act.append(caso); tok += t if act: lotes.append((act, tok)) os.makedirs(f'{L_}/juez', exist_ok=True); LJ = [] for n, (casos, tok) in enumerate(lotes, 1): f = f'{L_}/juez/discrepancias_j{n:02d}.json' json.dump(dict(discrepancias=[{k: v for k, v in d.items() if k != 'A_es'} for c in casos for d in porcaso[c]]), open(f, 'w'), ensure_ascii=False, indent=1) LJ.append(dict(modelo='J', n=n, casos=casos, tokens=round(tok), prompt=f'{L_}/PROMPT_JUEZ_A1_v1.md', partes=[f] + [f'{L_}/textos/{c}.txt' for c in casos], salida=f'{L_}/lecturas/J/lote_{n:02d}.json')) json.dump(dict(lotes=LJ), open(f'{L_}/lotes_juez.json', 'w'), indent=1) print('concordantes', len(conc), 'discrepancias', len(D), 'lotes juez', len(LJ), dict(cont), 'json rotos', ea + eb) def kappa(pares): n = len(pares) if n == 0: return None po = sum(a == b for a, b in pares) / n; ca = collections.Counter(a for a, _ in pares); cb = collections.Counter(b for _, b in pares) pe = sum(ca[k] * cb[k] for k in ca) / n / n return None if pe == 1 else round((po - pe) / (1 - pe), 3) def combinar(): X = json.load(open(f'{P}/priv/discrepancias.json', encoding='utf-8')) dec = {} for f in sorted(glob.glob(f'{L_}/lecturas/J/lote_*.json')): try: for d in json.load(open(f, encoding='utf-8')).get('decisiones', []): dec[d['id']] = d except Exception as e: print('juez roto', f, e) final = {}; met = collections.Counter() for k, a in X['concordantes'].items(): final[k] = dict(a, via='concordante') for d in X['discrepancias']: k = str((d['caso'], d['lote'])); j = dec.get(d['id']) if j is None: met['sin_juez'] += 1; continue v = j.get('veredicto'); met['juez_' + str(v)] += 1 r = {'1': d['respuesta_1'], '2': d['respuesta_2'], 'ambas': d['respuesta_1' if d['A_es'] == 1 else 'respuesta_2']}.get(v) if r is None or r.get('sin_respuesta'): continue r = dict(r); it = j.get('importe_tipo') if it in ('1', '2'): r['importe_tipo'] = d[f'respuesta_{it}'].get('importe_tipo', 'no_consta') elif it == 'ninguna': r['importe_tipo'] = 'no_consta' final[k] = dict(r, via='juez') # métricas A-B (tras el filtro), sobre los casos × lote con las dos respuestas A, _ = leer('A'); B, _ = leer('B'); cont = collections.Counter(); pc = pp = pi = [] ca = cp = n2 = 0; pares_imp = [] for k in set(A) & set(B): txt = texto(k[0]); a = filtrar(A[k], txt, cont); b = filtrar(B[k], txt, cont); n2 += 1 x, y = a.get('cantidad'), b.get('cantidad') ca += (x is None and y is None) or (x is not None and y is not None and abs(x - y) <= 0.01 * max(abs(x), abs(y)) and a.get('unidad') == b.get('unidad')) cp += (a.get('producto') or 'no_consta') == (b.get('producto') or 'no_consta') pares_imp.append((a.get('importe_tipo') or 'no_consta', b.get('importe_tipo') or 'no_consta')) m = dict(casos_lote_con_A_y_B=n2, acuerdo_cantidad=round(ca / n2, 4) if n2 else None, acuerdo_producto=round(cp / n2, 4) if n2 else None, kappa_importe_tipo=kappa(pares_imp), citas_inexistentes_antes_del_filtro={k: v for k, v in cont.items() if 'cita' in k or 'numero' in k}, datos_leidos=cont['datos'], tasa_citas_inexistentes=round(sum(v for k, v in cont.items() if k.startswith('cita_inexistente')) / max(1, cont['datos']), 4), concordantes=len(X['concordantes']), discrepancias=len(X['discrepancias']), juez=dict(met), finales=len(final), finales_con_cantidad=sum(1 for v in final.values() if v.get('cantidad') is not None)) # casar con contratos casos = list(csv.DictReader(open(f'{P}/priv/lectura_casos.csv'))) contr = collections.defaultdict(list) for r in csv.DictReader(open(f'{P}/priv/muestra_contratos.csv')): contr[r['licitacion_id']].append(r) out = []; nc = collections.Counter() for c in casos: if c['estado'] != 'leer': continue filas = contr[c['licitacion_id']] for fr in filas: lk = fr['lote_id'].split('|')[-1] if fr['lote_id'] else None v = final.get(str((c['caso'], lk))) if v is None and len(filas) == 1: v = final.get(str((c['caso'], None))) if v is None: cand = [x for kk, x in final.items() if kk.startswith(str((c['caso'],))[:-2])] v = cand[0] if len(cand) == 1 and len(filas) == 1 else None nc['casado' if v else 'no_casado'] += 1 out.append(dict(source=fr['source'], id_canonical=fr['id_canonical'], licitacion_id=c['licitacion_id'], caso=c['caso'], lote=lk, via=(v or {}).get('via', 'sin_lectura'), producto=(v or {}).get('producto'), cantidad=(v or {}).get('cantidad'), unidad=(v or {}).get('unidad'), periodo=(v or {}).get('periodo_cantidad'), duracion_leida=(v or {}).get('duracion_meses'), importe_tipo=(v or {}).get('importe_tipo'), duda=(v or {}).get('duda'))) m['contratos_casados'] = dict(nc) with open(f'{P}/priv/lectura_final.csv', 'w', newline='') as f: w = csv.DictWriter(f, fieldnames=list(out[0])); w.writeheader(); w.writerows(out) json.dump(m, open(f'{P}/salidas/20_lectura_metricas.json', 'w'), indent=1, ensure_ascii=False); print(m) if __name__ == '__main__': {'discrepancias': discrepancias, 'combinar': combinar}[sys.argv[1]]()