@precisa-saude/fhir-ocr-utils 0.37.2 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"sources":["/home/runner/work/fhir-brasil/fhir-brasil/packages/ocr-utils/dist/index.cjs","../src/anchor.ts","../src/anchor-lexicon.ts","../src/body-region.ts","../src/method-variant.ts","../src/extraction-schema.ts","../src/extraction-to-lab-result.ts","../src/extraction-validator.ts","../src/reference-bound.ts","../src/llm-client.ts"],"names":["getDefinitionByCode"],"mappings":"AAAA;ACaA;AAEE;AACA;AACA;AAAA,2CACK;ADZP;AACA;AEAO,IAAM,wBAAA,kBAA0B,IAAI,GAAA,CAAI;AAAA,EAC7C,KAAA;AAAA,EACA,KAAA;AAAA,EACA,IAAA;AAAA,EACA,KAAA;AAAA,EACA,KAAA;AAAA,EACA,KAAA;AAAA,EACA,KAAA;AAAA,EACA,KAAA;AAAA,EACA,KAAA;AAAA,EACA,KAAA;AAAA,EACA,KAAA;AAAA,EACA,KAAA;AAAA,EACA,KAAA;AAAA,EACA,KAAA;AAAA,EACA,KAAA;AAAA,EACA,KAAA;AAAA,EACA,KAAA;AAAA,EACA,KAAA;AAAA,EACA,OAAA;AAAA,EACA,MAAA;AAAA,EACA,KAAA;AAAA,EACA,KAAA;AAAA,EACA,KAAA;AAAA,EACA,KAAA;AAAA,EACA,KAAA;AAAA,EACA,KAAA;AAAA,EACA,KAAA;AAAA,EACA,KAAA;AAAA,EACA,KAAA;AAAA,EACA,KAAA;AAAA,EACA,KAAA;AAAA,EACA,KAAA;AAAA,EACA;AACF,CAAC,CAAA;AAQM,IAAM,uBAAA,kBAAyB,IAAI,GAAA,CAAI;AAAA,EAC5C,UAAA;AAAA;AAAA,EACA,WAAA;AAAA;AAAA,EACA,MAAA;AAAA;AAAA,EACA,MAAA;AAAA;AAAA,EACA,YAAA;AAAA;AAAA,EACA,KAAA;AAAA;AAAA,EACA,QAAA;AAAA;AAAA,EACA,QAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAKA,WAAA;AAAA,EACA,OAAA;AAAA,EACA,MAAA;AAAA,EACA,UAAA;AAAA,EACA,cAAA;AAAA,EACA,aAAA;AAAA,EACA,YAAA;AAAA,EACA,OAAA;AAAA,EACA,SAAA;AAAA,EACA;AACF,CAAC,CAAA;AAOM,IAAM,wBAAA,kBAA0B,IAAI,GAAA,CAAI;AAAA,EAC7C,QAAA;AAAA,EACA,QAAA;AAAA,EACA,SAAA;AAAA,EACA,SAAA;AAAA,EACA,SAAA;AAAA,EACA,UAAA;AAAA,EACA,SAAA;AAAA,EACA,UAAA;AAAA,EACA,SAAA;AAAA,EACA,OAAA;AAAA,EACA,OAAA;AAAA,EACA,QAAA;AAAA,EACA,WAAA;AAAA,EACA,WAAA;AAAA,EACA,UAAA;AAAA,EACA,QAAA;AAAA,EACA,SAAA;AAAA,EACA,cAAA;AAAA,EACA,SAAA;AAAA,EACA,UAAA;AAAA,EACA,UAAA;AAAA,EACA,UAAA;AAAA,EACA,UAAA;AAAA,EACA,UAAA;AAAA,EACA,SAAA;AAAA,EACA,QAAA;AAAA,EACA,WAAA;AAAA,EACA,WAAA;AAAA,EACA,UAAA;AAAA,EACA,UAAA;AAAA,EACA,UAAA;AAAA,EACA,SAAA;AAAA,EACA,UAAA;AAAA,EACA,WAAA;AAAA,EACA,OAAA;AAAA,EACA,MAAA;AAAA,EACA,OAAA;AAAA,EACA,UAAA;AAAA,EACA,OAAA;AAAA,EACA,QAAA;AAAA,EACA,QAAA;AAAA,EACA,OAAA;AAAA,EACA,cAAA;AAAA,EACA;AACF,CAAC,CAAA;AFHD;AACA;AG1GA,IAAM,6BAAA,kBAA+B,IAAI,GAAA,CAAI;AAAA,EAC3C,KAAA;AAAA,EACA,YAAA;AAAA,EACA,aAAA;AAAA,EACA,SAAA;AAAA,EACA,UAAA;AAAA,EACA;AACF,CAAC,CAAA;AAMD,IAAM,gBAAA,kBAAkB,IAAI,GAAA,CAAI,CAAC,aAAA,EAAe,cAAA,EAAgB,aAAA,EAAe,cAAc,CAAC,CAAA;AAG9F,IAAM,YAAA,EAAc,MAAA,CAAO,GAAA,CAAA,wFAAA,CAAA;AAG3B,IAAM,UAAA,EAAY,MAAA,CAAO,GAAA,CAAA,qCAAA,CAAA;AAOzB,IAAM,sBAAA,EAAwB,IAAI,MAAA;AAAA,EAChC,MAAA,CAAO,GAAA,CAAA,iBAAA,EAAuB,WAAW,CAAA,kCAAA,EAAqC,SAAS,CAAA,MAAA,CAAA;AAAA,EACvF;AACF,CAAA;AAGA,IAAM,oBAAA,EAAsB,IAAI,MAAA,CAAO,MAAA,CAAO,GAAA,CAAA,iBAAA,EAAuB,SAAS,CAAA,GAAA,CAAA,EAAO,GAAG,CAAA;AAgBjF,SAAS,qBAAA,CAAsB,IAAA,EAAc,MAAA,EAAyB;AAC3E,EAAA,GAAA,CAAI,4BAAA,CAA6B,GAAA,CAAI,IAAI,CAAA,EAAG;AAC1C,IAAA,OAAO,qBAAA,CAAsB,IAAA,CAAK,MAAM,CAAA;AAAA,EAC1C;AACA,EAAA,GAAA,CAAI,eAAA,CAAgB,GAAA,CAAI,IAAI,CAAA,EAAG;AAC7B,IAAA,OAAO,mBAAA,CAAoB,IAAA,CAAK,MAAM,CAAA;AAAA,EACxC;AACA,EAAA,OAAO,KAAA;AACT;AAGA,IAAM,sBAAA,EAAwB,IAAI,GAAA;AAAA,EAChC,CAAC,GAAG,4BAA4B,CAAA,CAAE,MAAA,CAAO,CAAC,IAAA,EAAA,GAAS,KAAA,IAAS,YAAY;AAC1E,CAAA;AAMA,IAAM,oBAAA,EAAsB,4CAAA;AAgBrB,SAAS,iBAAA,CAAkB,IAAA,EAAc,KAAA,EAAwB;AACtE,EAAA,OAAO,qBAAA,CAAsB,GAAA,CAAI,IAAI,EAAA,GAAK,mBAAA,CAAoB,IAAA,CAAK,KAAK,CAAA;AAC1E;AASO,IAAM,oBAAA,kBAAsB,IAAI,GAAA,CAAI;AAAA,EACzC,mBAAA;AAAA,EACA,eAAA;AAAA,EACA,qBAAA;AAAA,EACA,qBAAA;AAAA,EACA,oBAAA;AAAA,EACA,eAAA;AAAA,EACA;AACF,CAAC,CAAA;AAGD,IAAM,uBAAA,EAAmC;AAAA,EACvC,mBAAA;AAAA,EACA,sBAAA;AAAA,EACA,iBAAA;AAAA,EACA;AACF,CAAA;AAOA,IAAM,0BAAA,EAAsC,CAAC,aAAA,EAAe,kBAAA,EAAoB,aAAa,CAAA;AAa7F,IAAM,iBAAA,EAAmB,uBAAA;AAElB,SAAS,eAAA,CAAgB,IAAA,EAAuB;AACrD,EAAA,GAAA,CAAI,yBAAA,CAA0B,IAAA,CAAK,CAAC,EAAA,EAAA,GAAO,EAAA,CAAG,IAAA,CAAK,IAAI,CAAC,CAAA,EAAG;AACzD,IAAA,OAAO,KAAA;AAAA,EACT;AACA,EAAA,OAAO,sBAAA,CAAuB,IAAA,CAAK,CAAC,EAAA,EAAA,GAAO,EAAA,CAAG,IAAA,CAAK,IAAI,CAAC,EAAA,GAAK,gBAAA,CAAiB,IAAA,CAAK,IAAI,CAAA;AACzF;AHyBA;AACA;AI5KA;AAKA,IAAM,uBAAA,EAAyB,CAAA;AAsB/B,SAAS,iBAAA,CACP,IAAA,EACA,QAAA,EACA,KAAA,EACA,gBAAA,EACA,cAAA,EACA,UAAA,EAC4C;AAC5C,EAAA,MAAM,SAAA,kBAAW,uCAAA,IAAwB,CAAA,2BAAG,cAAA,6BAAgB,MAAA;AAAA,IAC1D,CAAC,CAAA,EAAA,GAAM,CAAA,CAAE,IAAA,CAAK,EAAA,CAAG,OAAA,EAAS,EAAA,GAAK,CAAA,CAAE,IAAA,CAAK,EAAA,CAAG,OAAA,EAAS;AAAA,EACpD,GAAA;AACA,EAAA,GAAA,CAAI,CAAC,SAAA,GAAY,QAAA,CAAS,OAAA,IAAW,CAAA,EAAG,OAAO,KAAA,CAAA;AAE/C,EAAA,MAAM,MAAA,EAAQ,KAAA,CAAM,SAAA,CAAU,CAAC,CAAA,EAAA,GAAM,CAAA,CAAE,MAAA,GAAS,SAAA,GAAY,SAAA,GAAY,CAAA,CAAE,GAAG,CAAA;AAC7E,EAAA,GAAA,CAAI,MAAA,IAAU,CAAA,CAAA,EAAI,OAAO,KAAA,CAAA;AACzB,EAAA,MAAM,OAAA,EAAmB,CAAC,CAAA;AAC1B,EAAA,GAAA,CAAI,MAAA,EAAQ,EAAA,GAAK,CAAC,gBAAA,CAAiB,GAAA,CAAI,MAAA,EAAQ,CAAC,CAAA,EAAG,MAAA,CAAO,IAAA,CAAK,MAAA,EAAQ,CAAC,CAAA;AACxE,EAAA,MAAA,CAAO,IAAA,CAAK,KAAK,CAAA;AACjB,EAAA,IAAA,CAAA,IAAS,EAAA,EAAI,MAAA,EAAQ,CAAA,EAAG,EAAA,EAAI,KAAA,CAAM,OAAA,GAAU,EAAA,GAAK,MAAA,EAAQ,sBAAA,EAAwB,EAAA,GAAK,CAAA,EAAG;AACvF,IAAA,GAAA,CAAI,gBAAA,CAAiB,GAAA,CAAI,CAAC,CAAA,EAAG,KAAA;AAC7B,IAAA,MAAA,CAAO,IAAA,CAAK,CAAC,CAAA;AAAA,EACf;AACA,EAAA,MAAM,KAAA,EAAO,MAAA,CAAO,GAAA,CAAI,CAAC,CAAA,EAAA,GAAM,cAAA,CAAe,KAAA,CAAM,KAAA,CAAM,CAAC,CAAA,CAAG,KAAA,EAAO,KAAA,CAAM,CAAC,CAAA,CAAG,GAAG,CAAC,CAAA,CAAE,IAAA,CAAK,IAAI,CAAA;AAE9F,EAAA,MAAM,MAAA,EAAQ,QAAA,CAAS,OAAA,CAAQ,CAAC,OAAA,EAAA,GAAY;AAC1C,IAAA,MAAM,IAAA,EAAM,CAAC,GAAG,OAAA,CAAQ,IAAA,CAAK,EAAA,EAAI,GAAG,OAAA,CAAQ,IAAA,CAAK,EAAE,CAAA,CAAE,IAAA,CAAK,CAAC,CAAA,EAAA,GAAM,UAAA,CAAW,IAAA,EAAM,CAAC,CAAC,CAAA;AACpF,IAAA,OAAO,IAAA,EAAM,CAAC,EAAE,GAAA,EAAK,KAAA,EAAO,OAAA,CAAQ,MAAM,CAAC,EAAA,EAAI,CAAC,CAAA;AAAA,EAClD,CAAC,CAAA;AACD,EAAA,OAAO,KAAA,CAAM,OAAA,IAAW,EAAA,EAAI,KAAA,CAAM,CAAC,EAAA,EAAI,KAAA,CAAA;AACzC;AAGO,SAAS,gBAAA,CACd,kBAAA,EACA,SAAA,EACA,OAAA,EACM;AACN,EAAA,MAAM,MAAA,mBAAQ,kBAAA,CAAmB,GAAA,CAAI,SAAS,CAAA,0BAAK,IAAI,GAAA,CAAY,GAAA;AACnE,EAAA,IAAA,CAAA,MAAW,MAAA,GAAS,OAAA,EAAS,KAAA,CAAM,GAAA,CAAI,KAAA,CAAM,IAAI,CAAA;AACjD,EAAA,kBAAA,CAAmB,GAAA,CAAI,SAAA,EAAW,KAAK,CAAA;AACzC;AAQO,SAAS,oBAAA,CACd,OAAA,EACA,cAAA,EACA,kBAAA,EACA,UAAA,EACM;AACN,EAAA,MAAM,MAAA,EAAgB,CAAC,CAAA;AACvB,EAAA,IAAA,CAAA,IAAS,MAAA,EAAQ,CAAA,EAAG,MAAA,GAAS,cAAA,CAAe,MAAA,EAAA,EAAU;AACpD,IAAA,MAAM,IAAA,EAAM,cAAA,CAAe,OAAA,CAAQ,IAAA,EAAM,KAAK,CAAA;AAC9C,IAAA,KAAA,CAAM,IAAA,CAAK,EAAE,GAAA,EAAK,IAAA,IAAQ,CAAA,EAAA,EAAK,cAAA,CAAe,OAAA,EAAS,GAAA,EAAK,MAAM,CAAC,CAAA;AACnE,IAAA,GAAA,CAAI,IAAA,IAAQ,CAAA,CAAA,EAAI,KAAA;AAChB,IAAA,MAAA,EAAQ,IAAA,EAAM,CAAA;AAAA,EAChB;AACA,EAAA,IAAA,CAAA,MAAW,MAAA,GAAS,OAAA,EAAS;AAC3B,IAAA,MAAM,iBAAA,kBAAmB,IAAI,GAAA,CAAY,CAAA;AACzC,IAAA,KAAA,CAAM,OAAA,CAAQ,CAAC,IAAA,EAAM,CAAA,EAAA,GAAM;AACzB,MAAA,MAAM,MAAA,EAAQ,kBAAA,CAAmB,GAAA,CAAI,IAAA,CAAK,KAAK,CAAA;AAC/C,MAAA,GAAA,CAAI,MAAA,GAAS,CAAC,GAAG,KAAK,CAAA,CAAE,IAAA,CAAK,CAAC,CAAA,EAAA,GAAM,EAAA,IAAM,KAAA,CAAM,IAAI,CAAA,EAAG,gBAAA,CAAiB,GAAA,CAAI,CAAC,CAAA;AAAA,IAC/E,CAAC,CAAA;AACD,IAAA,MAAM,QAAA,EAAU,iBAAA;AAAA,MACd,KAAA,CAAM,IAAA;AAAA,MACN,KAAA,CAAM,QAAA;AAAA,MACN,KAAA;AAAA,MACA,gBAAA;AAAA,MACA,cAAA;AAAA,MACA;AAAA,IACF,CAAA;AACA,IAAA,GAAA,CAAI,OAAA,EAAS;AACX,MAAA,KAAA,CAAM,YAAA,EAAc,OAAA,CAAQ,KAAA;AAC5B,MAAA,KAAA,CAAM,UAAA,EAAY,OAAA,CAAQ,GAAA;AAAA,IAC5B;AAAA,EACF;AACF;AJ0HA;AACA;AClLO,IAAM,0BAAA,EAA4B,CAAA;AAMlC,IAAM,qBAAA,EAAuB,GAAA;AAM7B,IAAM,qBAAA,EAAuB,GAAA;AAGpC,IAAM,yBAAA,EAA2B,CAAA;AAgCjC,SAAS,aAAA,CAAc,IAAA,EAAsB;AAC3C,EAAA,OAAO,IAAA,CACJ,SAAA,CAAU,KAAK,CAAA,CACf,OAAA,CAAQ,kBAAA,EAAoB,EAAE,CAAA,CAC9B,WAAA,CAAY,CAAA,CACZ,OAAA,CAAQ,+BAAA,EAAiC,GAAG,CAAA,CAC5C,OAAA,CAAQ,WAAA,EAAa,GAAG,CAAA;AAC7B;AAEA,SAAS,SAAA,CAAU,IAAA,EAAsB;AACvC,EAAA,OAAO,UAAA,CAAW,aAAA,CAAc,IAAI,CAAC,CAAA;AACvC;AAGA,IAAI,kBAAA,EAAkD,IAAA;AAEtD,SAAS,aAAA,CAAc,UAAA,EAA6B;AAClD,EAAA,OAAO,UAAA,CAAW,OAAA,GAAU,EAAA,GAAK,uBAAA,CAAwB,GAAA,CAAI,UAAU,CAAA;AACzE;AAEA,SAAS,cAAA,CAAA,EAAwC;AAC/C,EAAA,GAAA,CAAI,CAAC,iBAAA,EAAmB;AACtB,IAAA,MAAM,IAAA,kBAAM,IAAI,GAAA,CAAsB,CAAA;AACtC,IAAA,IAAA,CAAA,MAAW,QAAA,GAAW,WAAA,CAAY,CAAA,EAAG;AACnC,MAAA,IAAA,CAAA,MAAW,KAAA,GAAQ,OAAA,CAAQ,KAAA,EAAO;AAChC,QAAA,MAAM,WAAA,EAAa,aAAA,CAAc,IAAI,CAAA,CAAE,IAAA,CAAK,CAAA;AAC5C,QAAA,GAAA,CAAI,CAAC,WAAA,GAAc,CAAC,aAAA,CAAc,UAAU,CAAA,EAAG;AAC7C,UAAA,QAAA;AAAA,QACF;AACA,QAAA,MAAM,KAAA,EAAO,UAAA,CAAW,KAAA,CAAM,GAAG,CAAA,CAAE,CAAC,CAAA;AACpC,QAAA,GAAA,CAAI,GAAA,CAAI,IAAA,EAAM,CAAC,oBAAI,GAAA,CAAI,GAAA,CAAI,IAAI,CAAA,UAAK,CAAC,GAAA,EAAI,UAAU,CAAC,CAAA;AAAA,MACtD;AAAA,IACF;AACA,IAAA,kBAAA,EAAoB,GAAA;AAAA,EACtB;AACA,EAAA,OAAO,iBAAA;AACT;AAGA,SAAS,qBAAA,CAAsB,IAAA,EAAuB;AACpD,EAAA,MAAM,KAAA,kBAAO,iBAAA,qBAAkB,IAAA,mBAAK,IAAI,CAAA,4BAAA,CAAI,CAAC,GAAA;AAC7C,EAAA,GAAA,CAAI,CAAC,IAAA,EAAM;AACT,IAAA,OAAO,KAAA;AAAA,EACT;AACA,EAAA,OAAA,kBAAQ,cAAA,CAAe,CAAA,CAAE,GAAA,CAAI,IAAI,CAAA,UAAK,CAAC,GAAA,CAAA,CAAG,IAAA,CAAK,CAAC,IAAA,EAAA,GAAS;AACvD,IAAA,GAAA,CAAI,CAAC,IAAA,CAAK,UAAA,CAAW,IAAI,CAAA,EAAG;AAC1B,MAAA,OAAO,KAAA;AAAA,IACT;AACA,IAAA,MAAM,MAAA,EAAQ,IAAA,CAAK,KAAA,CAAM,IAAA,CAAK,MAAM,CAAA,CAAE,OAAA,CAAQ,IAAA,EAAM,EAAE,CAAA;AACtD,IAAA,OAAO,CAAC,gBAAA,CAAiB,IAAA,CAAK,KAAK,CAAA;AAAA,EACrC,CAAC,CAAA;AACH;AAmBA,SAAS,UAAA,CAAW,IAAA,EAAsB;AACxC,EAAA,OAAO,IAAA,CAAK,OAAA;AAAA,IAAQ,kCAAA;AAAA,IAAoC,CAAC,KAAA,EAAO,KAAA,EAAe,MAAA,EAAA,GAC7E,qBAAA,CAAsB,IAAA,CAAK,KAAA,CAAM,OAAA,EAAS,EAAA,EAAI,KAAA,CAAM,MAAM,CAAC,EAAA,EAAI,MAAA,EAAQ;AAAA,EACzE,CAAA;AACF;AASA,IAAM,yBAAA,EAAqC;AAAA,EACzC,qBAAA;AAAA;AAAA,EACA,kBAAA;AAAA;AAAA,EACA,iBAAA;AAAA;AAAA,EACA,uBAAA;AAAA;AAAA,EACA,cAAA;AAAA;AAAA,EACA,YAAA;AAAA,EACA,iBAAA;AAAA,EACA,YAAA;AAAA,EACA,cAAA;AAAA,EACA,gBAAA;AAAA,EACA,cAAA;AAAA,EACA,aAAA;AAAA,EACA,gBAAA;AAAA,EACA,mBAAA;AAAA,EACA,cAAA;AAAA,EACA,aAAA;AAAA,EACA,aAAA;AAAA,EACA,WAAA;AAAA,EACA,eAAA;AAAA,EACA,aAAA;AAAA,EACA;AACF,CAAA;AAEA,IAAM,cAAA,EAAgB,IAAA;AAGtB,IAAI,iBAAA,EAAuC,IAAA;AAE3C,SAAS,aAAA,CAAA,EAA6B;AACpC,EAAA,GAAA,CAAI,CAAC,gBAAA,EAAkB;AACrB,IAAA,iBAAA,EAAmB,IAAI,GAAA;AAAA,MACrB,MAAA,CAAO,IAAA,CAAK,kBAAY,CAAA,CACrB,GAAA,CAAI,CAAC,IAAA,EAAA,GAAS,SAAA,CAAU,IAAI,CAAA,CAAE,IAAA,CAAK,CAAC,CAAA,CACpC,MAAA,CAAO,OAAO;AAAA,IACnB,CAAA;AAAA,EACF;AACA,EAAA,OAAO,gBAAA;AACT;AAqBA,IAAI,eAAA,EAAkD,IAAA;AACtD,IAAI,mBAAA,EAAsD,IAAA;AAE1D,SAAS,WAAA,CAAA,EAAwC;AAC/C,EAAA,GAAA,CAAI,CAAC,cAAA,EAAgB;AACnB,IAAA,eAAA,EAAiB,wCAAA,CAAqB;AAAA,EACxC;AACA,EAAA,OAAO,cAAA;AACT;AAEA,SAAS,YAAA,CAAa,IAAA,EAAsB;AAC1C,EAAA,OAAO,IAAA,CAAK,OAAA,CAAQ,qBAAA,EAAuB,MAAM,CAAA;AACnD;AAmBA,SAAS,gBAAA,CAAiB,cAAA,EAAgC;AACxD,EAAA,MAAM,KAAA,EAAO,cAAA,CAAe,KAAA,CAAM,GAAG,CAAA,CAAE,GAAA,CAAI,YAAY,CAAA,CAAE,IAAA,CAAK,YAAY,CAAA;AAC1E,EAAA,MAAM,OAAA,EAAS,SAAA,CAAU,IAAA,CAAK,cAAc,EAAA,EAAI,KAAA,EAAO,EAAA;AACvD,EAAA,OAAO,IAAI,MAAA,CAAO,CAAA,mBAAA,EAAsB,IAAI,CAAA,EAAA;AAC9C;AAEsE;AAC3B,EAAA;AACD,EAAA;AAC1C;AAOiD;AACb,EAAA;AACzB,IAAA;AACT,EAAA;AACkC,EAAA;AACpC;AAEqD;AAC1B,EAAA;AACkB,IAAA;AACJ,IAAA;AACD,MAAA;AACG,QAAA;AACD,QAAA;AAChC,UAAA;AACF,QAAA;AAC6B,QAAA;AAClB,QAAA;AACyB,UAAA;AACV,UAAA;AAC1B,QAAA;AACkB,QAAA;AACW,UAAA;AACb,UAAA;AACwB,UAAA;AAC5B,UAAA;AACX,QAAA;AACH,MAAA;AACF,IAAA;AACqB,IAAA;AACvB,EAAA;AACO,EAAA;AACT;AAEuF;AACxC,EAAA;AACA,EAAA;AACP,EAAA;AACxC;AAEkD;AACV,EAAA;AACxC;AAMiD;AACjB,EAAA;AACrB,IAAA;AACT,EAAA;AACiC,EAAA;AACF,EAAA;AACU,IAAA;AAC9B,MAAA;AACT,IAAA;AACF,EAAA;AACO,EAAA;AACT;AASgE;AAC7B,EAAA;AACU,EAAA;AACL,IAAA;AAClC,MAAA;AACF,IAAA;AACoB,IAAA;AACU,IAAA;AACZ,IAAA;AACA,IAAA;AACmB,IAAA;AACE,IAAA;AACA,MAAA;AACtB,MAAA;AACkB,MAAA;AACnC,IAAA;AACF,EAAA;AACO,EAAA;AACT;AAMwE;AAC7C,EAAA;AACE,EAAA;AACd,EAAA;AACyB,EAAA;AACM,IAAA;AAC1B,MAAA;AAChB,IAAA;AACwC,IAAA;AACE,IAAA;AACF,IAAA;AAC1C,EAAA;AAC2C,EAAA;AAC3B,IAAA;AAChB,EAAA;AAC6B,EAAA;AACY,EAAA;AAC3C;AAkBkC;AACI,EAAA;AAC1B,IAAA;AACV,EAAA;AACsC,EAAA;AACD,EAAA;AACJ,EAAA;AACQ,EAAA;AACJ,IAAA;AACjC,MAAA;AACF,IAAA;AAC8B,IAAA;AACZ,IAAA;AACA,IAAA;AACW,IAAA;AACU,IAAA;AACJ,MAAA;AACQ,MAAA;AACA,MAAA;AACA,MAAA;AACL,MAAA;AAGhB,MAAA;AAEmB,QAAA;AAErC,MAAA;AACqC,QAAA;AACtB,QAAA;AACjB,MAAA;AACyB,MAAA;AAC3B,IAAA;AACF,EAAA;AACO,EAAA;AACT;AAa+D;AAC9B,EAAA;AACU,IAAA;AACzC,EAAA;AAC+B,EAAA;AACC,EAAA;AACW,IAAA;AACd,IAAA;AAEE,MAAA;AAG7B,IAAA;AACgB,IAAA;AACS,MAAA;AACzB,IAAA;AACF,EAAA;AACO,EAAA;AACT;AAgBoE;AACvC,EAAA;AACa,EAAA;AACQ,EAAA;AACF,EAAA;AACnB,EAAA;AACR,EAAA;AACkB,IAAA;AACO,IAAA;AAC5C,EAAA;AAEwC,EAAA;AAEN,IAAA;AACF,IAAA;AACK,IAAA;AACL,IAAA;AAChB,IAAA;AACsB,MAAA;AACxB,MAAA;AACuB,QAAA;AACJ,QAAA;AACI,QAAA;AACjC,MAAA;AACyB,MAAA;AAC3B,IAAA;AACqC,IAAA;AACxB,IAAA;AACX,MAAA;AACF,IAAA;AAEqC,IAAA;AAED,IAAA;AACD,IAAA;AACI,IAAA;AACH,MAAA;AAChC,QAAA;AACF,MAAA;AAEsC,MAAA;AACpC,QAAA;AACF,MAAA;AAEqC,MAAA;AACnC,QAAA;AACF,MAAA;AAEiB,MAAA;AACI,MAAA;AACN,QAAA;AACM,MAAA;AACN,QAAA;AACf,MAAA;AAEsC,MAAA;AAGvB,MAAA;AAEH,MAAA;AACiB,QAAA;AACb,UAAA;AACZ,UAAA;AACa,UAAA;AACM,UAAA;AACC,UAAA;AACrB,QAAA;AACH,MAAA;AACF,IAAA;AACF,EAAA;AAEA,EAAA;AAAuC,IAAA;AAAG,IAAA;AAAgB,IAAA;AAC7B,IAAA;AAC7B,EAAA;AAE6C,EAAA;AACb,EAAA;AAEzB,EAAA;AACc,IAAA;AACnB,IAAA;AACO,IAAA;AACiB,MAAA;AACtB,MAAA;AAC6B,MAAA;AAC/B,IAAA;AACF,EAAA;AACF;AAKgE;AACvB,EAAA;AACzC;ADnC+C;AACA;AK1fV;AAC9B,EAAA;AACI,EAAA;AACa,EAAA;AACV,EAAA;AACE,IAAA;AACG,MAAA;AACN,MAAA;AACiB,QAAA;AACV,QAAA;AACE,UAAA;AACG,YAAA;AACJ,YAAA;AACA,YAAA;AACH,YAAA;AACR,UAAA;AACO,UAAA;AACyB,YAAA;AAE5B,YAAA;AAEJ,UAAA;AACM,UAAA;AACS,YAAA;AACP,YAAA;AACR,UAAA;AACc,UAAA;AACkB,YAAA;AAE5B,YAAA;AAGJ,UAAA;AACc,UAAA;AACkB,YAAA;AAE5B,YAAA;AAGJ,UAAA;AACY,UAAA;AAER,YAAA;AAEI,YAAA;AACR,UAAA;AACM,UAAA;AACS,YAAA;AACP,YAAA;AACR,UAAA;AACO,UAAA;AACyB,YAAA;AACjB,YAAA;AACf,UAAA;AACF,QAAA;AACU,QAAA;AACR,UAAA;AACA,UAAA;AACA,UAAA;AACA,UAAA;AACA,UAAA;AACA,UAAA;AACA,UAAA;AACA,UAAA;AACF,QAAA;AACM,QAAA;AACR,MAAA;AACM,MAAA;AACR,IAAA;AACgB,IAAA;AACsB,MAAA;AAElC,MAAA;AAEJ,IAAA;AACgB,IAAA;AACsB,MAAA;AAElC,MAAA;AAEJ,IAAA;AACF,EAAA;AAC2C,EAAA;AACpC,EAAA;AACD,EAAA;AACR;AL8e+C;AACA;AM/nBF;AA2CW;AACd,EAAA;AACI,EAAA;AACA,EAAA;AACrC,EAAA;AACT;AAIE;AAEqC,EAAA;AACJ,EAAA;AACF,EAAA;AAEU,EAAA;AAIG,IAAA;AACvB,IAAA;AAEZ,IAAA;AACL,MAAA;AACiB,QAAA;AACE,QAAA;AACF,QAAA;AAAA;AAAA;AAGwB,QAAA;AACT,QAAA;AACA,QAAA;AAC9B,QAAA;AACQ,QAAA;AACC,QAAA;AACX,MAAA;AACF,IAAA;AACD,EAAA;AAEM,EAAA;AACL,IAAA;AACiB,IAAA;AACT,IAAA;AACN,MAAA;AAC4B,MAAA;AACY,MAAA;AACtB,MAAA;AAClB,MAAA;AACA,MAAA;AACF,IAAA;AACF,EAAA;AACF;AN6kB+C;AACA;AO5qBzBA;AP8qByB;AACA;AQlpB7C;AAIA;AAaa;AAEwE;AAC5C,EAAA;AAID,IAAA;AAKG,IAAA;AACb,IAAA;AACE,IAAA;AACvB,IAAA;AACT,EAAA;AAEO,EAAA;AACT;AAQuF;AACjD,EAAA;AAEG,EAAA;AACA,EAAA;AAGF,EAAA;AACM,EAAA;AAKJ,EAAA;AACI,EAAA;AACZ,EAAA;AAGX,EAAA;AAEtB;AR2mB+C;AACA;AOvoBpB;AAIZ;AAG+B;AAChB,EAAA;AAEF,EAAA;AACO,EAAA;AAEI,EAAA;AACC,EAAA;AACO,IAAA;AACH,EAAA;AACF,EAAA;AACF,EAAA;AACG,IAAA;AAEI,EAAA;AAC/B,IAAA;AAES,EAAA;AACQ,IAAA;AACA,IAAA;AACnB,EAAA;AACkC,IAAA;AACtB,MAAA;AACxB,EAAA;AAEO,EAAA;AACT;AAOyD;AACvB,EAAA;AACK,EAAA;AACb,IAAA;AACkB,IAAA;AAC1C,EAAA;AACO,EAAA;AACT;AAasB;AACuB,EAAA;AACF,EAAA;AACG,EAAA;AACH,EAAA;AACG,EAAA;AAC9C;AAS8B;AACR,EAAA;AAEA,EAAA;AACX,IAAA;AACM,MAAA;AACF,MAAA;AACE,MAAA;AACqB,MAAA;AACzB,MAAA;AACT,IAAA;AACF,EAAA;AACoC,EAAA;AAC3B,IAAA;AACM,MAAA;AACF,MAAA;AACE,MAAA;AACqB,MAAA;AACzB,MAAA;AACT,IAAA;AACF,EAAA;AAE6C,EAAA;AACL,EAAA;AACD,EAAA;AAEH,EAAA;AACC,IAAA;AACV,IAAA;AACe,MAAA;AACtC,MAAA;AACF,IAAA;AAEkB,IAAA;AAEL,IAAA;AACsB,MAAA;AAGrB,MAAA;AACI,QAAA;AACc,UAAA;AACrB,UAAA;AACG,UAAA;AACT,QAAA;AACD,QAAA;AACF,MAAA;AAIsC,MAAA;AAEb,MAAA;AACR,MAAA;AACD,QAAA;AACI,UAAA;AACX,UAAA;AACG,UAAA;AACT,QAAA;AACD,QAAA;AACF,MAAA;AACF,IAAA;AAKyC,IAAA;AAC3C,EAAA;AAEO,EAAA;AACL,IAAA;AACS,IAAA;AACT,IAAA;AACQ,IAAA;AAC6B,MAAA;AACA,MAAA;AACrC,IAAA;AACO,IAAA;AACT,EAAA;AACF;AAMwB;AACkB,EAAA;AAC1C;APglB+C;AACA;ASjwBd;AAkB2B;AACnD,EAAA;AACL,IAAA;AACA,IAAA;AACA,IAAA;AACoC,IAAA;AACpC,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACS,EAAA;AACb;AAGyC;AACxB,EAAA;AACkB,EAAA;AACnC;AAY0B;AACe,EAAA;AAKX,EAAA;AACiB,IAAA;AAC7C,EAAA;AAI2C,EAAA;AAEhB,EAAA;AAGV,EAAA;AAC2B,IAAA;AACxC,IAAA;AAC+B,IAAA;AAE3B,IAAA;AACmB,MAAA;AACwB,QAAA;AACjC,QAAA;AACR,MAAA;AAED,IAAA;AACQ,IAAA;AACd,EAAA;AAGQ,EAAA;AACc,IAAA;AACZ,IAAA;AACJ,MAAA;AACa,MAAA;AACwB,MAAA;AAC1C,IAAA;AACQ,IAAA;AAC6B,IAAA;AACtC,EAAA;AAE4B,EAAA;AACa,EAAA;AAIL,EAAA;AACT,IAAA;AAC9B,EAAA;AAEkB,EAAA;AAC0B,IAAA;AAC5C,EAAA;AAEkC,EAAA;AAGM,EAAA;AACZ,EAAA;AAExB,EAAA;AACyC,IAAA;AACrC,EAAA;AACU,IAAA;AAA6E;AAC/F,EAAA;AACF;AT+sB+C;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA","file":"/home/runner/work/fhir-brasil/fhir-brasil/packages/ocr-utils/dist/index.cjs","sourcesContent":[null,"/**\n * OCR Anchor — Biomarker text anchoring\n *\n * Scans OCR text for biomarker names BEFORE sending to LLM.\n * This prevents hallucination by constraining what biomarkers\n * the LLM is allowed to extract.\n *\n * Matching is deliberately conservative: a name only anchors when it appears\n * as a whole token, is not swallowed by a longer biomarker name, is not inside\n * a genetic report line, and — for generic single-word names — sits on a line\n * that actually carries a value.\n */\n\nimport {\n type BiomarkerSearchPattern,\n generateFilteredLLMReference,\n getAllSearchPatterns,\n UNIT_TO_UCUM,\n} from '@precisa-saude/fhir';\n\nimport {\n CONTEXT_REQUIRED_NAMES,\n QUALITATIVE_VALUE_TERMS,\n UNAMBIGUOUS_SHORT_NAMES,\n} from './anchor-lexicon';\nimport {\n followedByPercent,\n hasGirthContext,\n qualifiedByBodyRegion,\n SKINFOLD_SITE_CODES,\n} from './body-region';\nimport { attachMethodVariants, recordAnchorLine } from './method-variant';\n\nexport interface AnchorMatch {\n code: string;\n confidence: number;\n loinc?: string;\n matchedName: string;\n /** A pista que escolheu o `methodLoinc`. Ver `method-variant.ts`. */\n methodCue?: string;\n /** O LOINC por método que o texto afirma; vem da varredura, nunca do modelo. */\n methodLoinc?: string;\n position: number;\n}\n\nexport interface AnchorResult {\n filteredReference: string;\n matches: AnchorMatch[];\n stats: {\n totalPatterns: number;\n matchedCount: number;\n scanTimeMs: number;\n };\n}\n\n/**\n * Confidence assigned to a specific biomarker name found on a line that also\n * carries a value (a number, a unit, or an expected qualitative term).\n */\nexport const CONFIDENCE_VALUE_ADJACENT = 1.0;\n\n/**\n * Confidence assigned to a specific biomarker name with no value evidence\n * nearby — a section heading, or a mention in prose.\n */\nexport const CONFIDENCE_NAME_ONLY = 0.7;\n\n/**\n * Confidence assigned to a generic/ambiguous name (`Color`, `Protein`,\n * `Blood`, …) that only anchored because a value was found next to it.\n */\nexport const CONFIDENCE_AMBIGUOUS = 0.4;\n\n/** Cap on how many occurrences of the same name are inspected per document. */\nconst MAX_OCCURRENCES_PER_NAME = 5;\n\n/**\n * Normalize text for comparison:\n * - Removes diacritics (ã→a, ç→c, é→e)\n * - Converts to lowercase\n * - Treats a hyphen that joins words as a space\n * - Collapses horizontal whitespace, but KEEPS line breaks — the line is the\n * context window used to decide whether a match is a real biomarker mention\n *\n * O hífen entre palavras vira espaço porque o catálogo e o laboratório\n * discordam sobre ele o tempo todo: o catálogo escreve \"Proteína C-Reativa\" e\n * \"High-Density Lipoprotein\", e os laudos imprimem \"Proteína C Reativa\" e\n * \"High Density Lipoprotein\". Sem essa equivalência, 82 dos 159 nomes com\n * hífen deixam de ancorar na grafia que o documento usa.\n *\n * Não era teórico: um GGT de verdade foi descartado como alucinação em 27\n * laudos porque o documento escrevia \"Gama glutamil transferase\" e o catálogo\n * \"Gama-Glutamil Transferase\". Um caractere derrubava o valor antes de\n * qualquer validação.\n *\n * A troca exige **letra antes** do hífen, e por isso não toca em número:\n * o `-2.5` de um T-score e o `0-5` de uma faixa de urina seguem intactos.\n * Trocar sem essa guarda apagaria o sinal de um valor negativo, que é bem\n * pior que o problema original.\n *\n * O pré-filtro de substring do `collectCandidates` compara a chave com o texto\n * já normalizado, então a equivalência precisa nascer aqui: aplicada só na\n * regex, o `includes` descartaria o nome antes de ela rodar.\n *\n * A vírgula entre palavras é tratada à parte, em `foldCommas`.\n */\nfunction normalizeBase(text: string): string {\n return text\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .toLowerCase()\n .replace(/(?<=\\p{L})-(?=[\\p{L}\\p{N}])/gu, ' ')\n .replace(/[^\\S\\n]+/g, ' ');\n}\n\nfunction normalize(text: string): string {\n return foldCommas(normalizeBase(text));\n}\n\n/** Nomes do catálogo já normalizados, indexados pela primeira palavra. */\nlet cachedNamesByHead: Map<string, string[]> | null = null;\n\nfunction isIndexedName(normalized: string): boolean {\n return normalized.length >= 3 || UNAMBIGUOUS_SHORT_NAMES.has(normalized);\n}\n\nfunction getNamesByHead(): Map<string, string[]> {\n if (!cachedNamesByHead) {\n const map = new Map<string, string[]>();\n for (const pattern of getPatterns()) {\n for (const name of pattern.names) {\n const normalized = normalizeBase(name).trim();\n if (!normalized || !isIndexedName(normalized)) {\n continue;\n }\n const head = normalized.split(' ')[0]!;\n map.set(head, [...(map.get(head) ?? []), normalized]);\n }\n }\n cachedNamesByHead = map;\n }\n return cachedNamesByHead;\n}\n\n/** Algum nome do catálogo começa, como palavra inteira, no início de `text`? */\nfunction startsWithCatalogName(text: string): boolean {\n const head = /^[\\p{L}\\p{N}]+/u.exec(text)?.[0];\n if (!head) {\n return false;\n }\n return (getNamesByHead().get(head) ?? []).some((name) => {\n if (!text.startsWith(name)) {\n return false;\n }\n const after = text.slice(name.length).replace(/^s/, '');\n return !/^[\\p{L}\\p{N}]/u.test(after);\n });\n}\n\n/**\n * A vírgula entre palavras vira espaço quando o que vem depois dela não é,\n * sozinho, um nome do catálogo.\n *\n * A Quest imprime o exame no formato \"EXAME, QUALIFICADOR\" (\"PSA, FREE\"), e o\n * catálogo nem sempre traz a grafia com vírgula. Sem a troca, \"psa, free\" não\n * casava \"PSA Free\" e o PSA total ancorava pelo \"psa\" solto.\n *\n * A guarda é a mesma da quebra de linha: numa lista \"Colesterol, HDL, LDL\",\n * cada item depois da vírgula é um exame próprio, e juntar daria \"Colesterol\n * HDL\" e perderia o colesterol total. Quando o catálogo precisa da vírgula\n * mesmo assim, ele lista a grafia com vírgula (\"Magnesium, RBC\"), e ela casa\n * literalmente, porque o nome do catálogo passa pela mesma regra.\n *\n * Só vale com letra dos dois lados, então a vírgula decimal (\"0,4\") fica\n * intacta.\n */\nfunction foldCommas(text: string): string {\n return text.replace(/(?<=\\p{L}),([^\\S\\n]*)(?=\\p{L})/gu, (comma, space: string, offset: number) =>\n startsWithCatalogName(text.slice(offset + 1 + space.length)) ? comma : ' ',\n );\n}\n\n/**\n * Signals that a line comes from a genetic/molecular report rather than from a\n * panel of measured values. Gene symbols collide with biomarker names (`APOB`\n * the gene vs. `ApoB` the lipoprotein), so the context — not a static HGNC\n * blocklist — is what tells them apart. Blocking the token itself would break\n * real lipid panels.\n */\nconst GENETIC_CONTEXT_PATTERNS: RegExp[] = [\n /\\b[nx][mrpc]_\\d{6,}/, // RefSeq: NM_000384.2, NP_, NR_, XM_\n /\\bens[gtp]\\d{6,}/, // Ensembl: ENSG00000084674\n /\\bp\\.[a-z]{3}\\d/, // HGVS proteína: p.Trp448*\n /\\bc\\.\\d+[acgt]?[>_+-]/, // HGVS codificante: c.1234A>G, c.76_78del\n /\\brs\\d{4,}\\b/, // dbSNP\n /\\bgenes?\\b/,\n /\\bvariante?s?\\b/,\n /\\bexons?\\b/,\n /\\bzygosity\\b/,\n /\\bzigosidade\\b/,\n /\\balleles?\\b/,\n /\\balelos?\\b/,\n /\\bmutations?\\b/,\n /\\bmutac(ao|oes)\\b/,\n /\\bpathogenic/,\n /\\bpatogenic/,\n /\\bheterozyg/,\n /\\bhomozyg/,\n /\\bheterozigot/,\n /\\bhomozigot/,\n /\\bsequence change\\b/,\n];\n\nconst DIGIT_PATTERN = /\\d/;\n\n/** Unit tokens reused from the core catalog instead of a parallel list. */\nlet cachedUnitTokens: Set<string> | null = null;\n\nfunction getUnitTokens(): Set<string> {\n if (!cachedUnitTokens) {\n cachedUnitTokens = new Set(\n Object.keys(UNIT_TO_UCUM)\n .map((unit) => normalize(unit).trim())\n .filter(Boolean),\n );\n }\n return cachedUnitTokens;\n}\n\ninterface PatternEntry {\n ambiguous: boolean;\n code: string;\n loinc?: string;\n original: string;\n}\n\ninterface NamePattern {\n entries: PatternEntry[];\n /** Built on first use — most names never match a given document. */\n regex: RegExp | null;\n}\n\ninterface Candidate {\n end: number;\n entries: PatternEntry[];\n start: number;\n}\n\nlet cachedPatterns: BiomarkerSearchPattern[] | null = null;\nlet cachedNamePatterns: Map<string, NamePattern> | null = null;\n\nfunction getPatterns(): BiomarkerSearchPattern[] {\n if (!cachedPatterns) {\n cachedPatterns = getAllSearchPatterns();\n }\n return cachedPatterns;\n}\n\nfunction escapeRegExp(text: string): string {\n return text.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n}\n\n/**\n * Build a whole-token matcher for a normalized name.\n *\n * Lookarounds instead of `\\b` because names may start or end with a non-word\n * character (`Lp(a)`), where `\\b` asserts the wrong thing.\n *\n * A multi-word name must sit on a single line: in the column layouts labs\n * print, consecutive lines are separate biomarkers, and allowing a line break\n * inside a name turns \"Colesterol\\nHDL\" into the name \"Colesterol HDL\".\n * A wrapped name still anchors through its head token when that token is a\n * name of its own (\"Colesterol\\nTotal\" → `Cholesterol`). A name whose head is\n * not a name of its own is recovered by `collectWrappedCandidates`.\n *\n * The trailing optional `s` keeps the plurals labs actually print\n * (\"Proteínas\", \"Cetonas\") anchored to the singular catalog name — without\n * letting `proteína` match inside `proteinúria`.\n */\nfunction buildNamePattern(normalizedName: string): RegExp {\n const body = normalizedName.split(' ').map(escapeRegExp).join('[^\\\\S\\\\n]+');\n const plural = /\\p{L}$/u.test(normalizedName) ? 's?' : '';\n return new RegExp(`(?<![\\\\p{L}\\\\p{N}])${body}${plural}(?![\\\\p{L}\\\\p{N}])`, 'gu');\n}\n\nfunction isQualitativeUrine(pattern: BiomarkerSearchPattern): boolean {\n const categories = Array.isArray(pattern.category) ? pattern.category : [pattern.category];\n return categories.includes('urina') && !pattern.unit;\n}\n\n/**\n * A name is ambiguous when it is a single token that also reads as ordinary\n * text. Multi-word names (`Occult Blood`, `Urine Protein`) are specific enough\n * on their own.\n */\nfunction isAmbiguousName(normalizedName: string, pattern: BiomarkerSearchPattern): boolean {\n if (normalizedName.includes(' ')) {\n return false;\n }\n return CONTEXT_REQUIRED_NAMES.has(normalizedName) || isQualitativeUrine(pattern);\n}\n\nfunction getNamePatterns(): Map<string, NamePattern> {\n if (!cachedNamePatterns) {\n const map = new Map<string, NamePattern>();\n for (const pattern of getPatterns()) {\n for (const name of pattern.names) {\n const normalized = normalize(name).trim();\n if (!normalized || !isIndexedName(normalized)) {\n continue;\n }\n let slot = map.get(normalized);\n if (!slot) {\n slot = { entries: [], regex: null };\n map.set(normalized, slot);\n }\n slot.entries.push({\n ambiguous: isAmbiguousName(normalized, pattern),\n code: pattern.code,\n ...(pattern.loinc && { loinc: pattern.loinc }),\n original: name,\n });\n }\n }\n cachedNamePatterns = map;\n }\n return cachedNamePatterns;\n}\n\nfunction getLineBounds(text: string, position: number): { end: number; start: number } {\n const start = text.lastIndexOf('\\n', position) + 1;\n const nextBreak = text.indexOf('\\n', position);\n return { end: nextBreak === -1 ? text.length : nextBreak, start };\n}\n\nfunction hasGeneticContext(line: string): boolean {\n return GENETIC_CONTEXT_PATTERNS.some((pattern) => pattern.test(line));\n}\n\n/**\n * Does this line carry something that looks like a measured result?\n * A digit, a known unit, or an expected qualitative term.\n */\nfunction hasValueEvidence(line: string): boolean {\n if (DIGIT_PATTERN.test(line)) {\n return true;\n }\n const unitTokens = getUnitTokens();\n for (const token of line.split(/[^\\p{L}\\p{N}%/]+/u)) {\n if (token && (unitTokens.has(token) || QUALITATIVE_VALUE_TERMS.has(token))) {\n return true;\n }\n }\n return false;\n}\n\n/**\n * Cheap pre-filter before the (much costlier) boundary regex.\n *\n * Sound because `normalize` collapses horizontal whitespace to a single space\n * and a name never spans a line break: whenever the pattern can match, the\n * literal name is a substring of the text.\n */\nfunction collectCandidates(normalizedText: string): Candidate[] {\n const candidates: Candidate[] = [];\n for (const [name, slot] of getNamePatterns()) {\n if (!normalizedText.includes(name)) {\n continue;\n }\n const { entries } = slot;\n const regex = (slot.regex ??= buildNamePattern(name));\n regex.lastIndex = 0;\n let occurrences = 0;\n let match = regex.exec(normalizedText);\n while (match !== null && occurrences < MAX_OCCURRENCES_PER_NAME) {\n candidates.push({ end: match.index + match[0].length, entries, start: match.index });\n occurrences += 1;\n match = regex.exec(normalizedText);\n }\n }\n return candidates;\n}\n\n/**\n * Junta cada quebra de linha, com o espaço em volta, num espaço só, e guarda\n * para cada caractere do texto juntado a posição dele no texto original.\n */\nfunction joinLines(text: string): { joined: string; offsets: number[] } {\n const parts: string[] = [];\n const offsets: number[] = [];\n let cursor = 0;\n for (const lineBreak of text.matchAll(/[^\\S\\n]*\\n[^\\S\\n]*/g)) {\n for (let i = cursor; i < lineBreak.index; i += 1) {\n offsets.push(i);\n }\n parts.push(text.slice(cursor, lineBreak.index), ' ');\n offsets.push(lineBreak.index + lineBreak[0].indexOf('\\n'));\n cursor = lineBreak.index + lineBreak[0].length;\n }\n for (let i = cursor; i < text.length; i += 1) {\n offsets.push(i);\n }\n parts.push(text.slice(cursor));\n return { joined: parts.join(''), offsets };\n}\n\n/**\n * Nomes quebrados em duas linhas.\n *\n * A Quest imprime \"TISSUE TRANSGLUTAMINASE\" numa linha e \"AB, IGA <1.0 U/mL\"\n * na seguinte. Linha a linha, nenhum nome do tTG aparece inteiro, e o \"IGA\"\n * solto da segunda linha ancorava a IgA total. Aqui o texto é lido também com\n * cada linha emendada na seguinte, e o casamento que atravessa a quebra entra\n * como candidato normal: o `resolveOverlaps` faz o nome longo engolir o\n * \"IGA\" que está dentro dele.\n *\n * Emendar linhas é o que a regra de uma linha por nome evita, então só vale\n * com três guardas. O nome atravessa exatamente uma quebra. O pedaço antes da\n * quebra não é, sozinho, um nome do catálogo: \"Colesterol\\nHDL\" são dois\n * exames na coluna, não \"Colesterol HDL\". E a primeira linha não traz valor,\n * porque uma linha com resultado já é uma linha completa.\n */\nfunction collectWrappedCandidates(normalizedText: string): Candidate[] {\n if (!normalizedText.includes('\\n')) {\n return [];\n }\n const { joined, offsets } = joinLines(normalizedText);\n const namePatterns = getNamePatterns();\n const candidates: Candidate[] = [];\n for (const [name, slot] of namePatterns) {\n if (!name.includes(' ') || !joined.includes(name)) {\n continue;\n }\n const regex = (slot.regex ??= buildNamePattern(name));\n regex.lastIndex = 0;\n let occurrences = 0;\n let match = regex.exec(joined);\n while (match !== null && occurrences < MAX_OCCURRENCES_PER_NAME) {\n const start = offsets[match.index]!;\n const end = offsets[match.index + match[0].length - 1]! + 1;\n const lineBreak = normalizedText.indexOf('\\n', start);\n const nextBreak = normalizedText.indexOf('\\n', lineBreak + 1);\n const wrapped = lineBreak !== -1 && lineBreak < end && (nextBreak === -1 || nextBreak >= end);\n if (\n wrapped &&\n !namePatterns.has(normalizedText.slice(start, lineBreak).trim()) &&\n !hasValueEvidence(\n normalizedText.slice(getLineBounds(normalizedText, start).start, lineBreak),\n )\n ) {\n candidates.push({ end, entries: slot.entries, start });\n occurrences += 1;\n }\n match = regex.exec(joined);\n }\n }\n return candidates;\n}\n\n/**\n * Longest match wins: drop a match fully contained in a longer one, so\n * `Cholesterol` doesn't anchor inside `HDL Cholesterol` and `Blood` doesn't\n * anchor inside `Blood Glucose`.\n *\n * Strictly longer, not longer-or-equal: containment plus equal length means an\n * identical span, which only happens when two distinct catalog names match the\n * same text (a singular and its plural form, say). Dropping one of those by\n * catalog order would silently lose a code, and losing an anchor is worse than\n * keeping both — `findBiomarkersInText` dedups per code anyway.\n */\nfunction resolveOverlaps(candidates: Candidate[]): Candidate[] {\n const sorted = [...candidates].sort(\n (a, b) => b.end - b.start - (a.end - a.start) || a.start - b.start,\n );\n const accepted: Candidate[] = [];\n for (const candidate of sorted) {\n const length = candidate.end - candidate.start;\n const swallowed = accepted.some(\n (other) =>\n other.start <= candidate.start &&\n candidate.end <= other.end &&\n other.end - other.start > length,\n );\n if (!swallowed) {\n accepted.push(candidate);\n }\n }\n return accepted;\n}\n\ninterface LineContext {\n genetic: boolean;\n girth: boolean;\n hasValue: boolean;\n}\n\n/**\n * Find all biomarker names present in OCR text.\n *\n * Matching is whole-token, longest-match-wins, and context-aware: matches\n * inside genetic report lines are discarded, and generic names only anchor\n * when a value sits on the same line. Returns one match per biomarker code —\n * the highest-confidence occurrence.\n */\nexport function findBiomarkersInText(ocrText: string): AnchorResult {\n const startTime = Date.now();\n const normalizedText = normalize(ocrText);\n const bestByCode = new Map<string, AnchorMatch>();\n const contexts = new Map<string, LineContext>();\n const anchoredLineStarts = new Map<number, Set<string>>(); // ver `attachMethodVariants`\n const candidates = [\n ...collectCandidates(normalizedText),\n ...collectWrappedCandidates(normalizedText),\n ];\n\n for (const candidate of resolveOverlaps(candidates)) {\n // O contexto é a linha do nome, ou as duas linhas de um nome quebrado.\n const lineStart = getLineBounds(normalizedText, candidate.start).start;\n const lineEnd = getLineBounds(normalizedText, candidate.end - 1).end;\n const key = `${lineStart}:${lineEnd}`;\n let context = contexts.get(key);\n if (!context) {\n const line = normalizedText.slice(lineStart, lineEnd);\n context = {\n genetic: hasGeneticContext(line),\n girth: hasGirthContext(line),\n hasValue: hasValueEvidence(line),\n };\n contexts.set(key, context);\n }\n const { genetic, girth, hasValue } = context;\n if (genetic) {\n continue;\n }\n\n recordAnchorLine(anchoredLineStarts, lineStart, candidate.entries);\n\n const before = normalizedText.slice(lineStart, candidate.start);\n const after = normalizedText.slice(candidate.end, lineEnd);\n for (const entry of candidate.entries) {\n if (entry.ambiguous && !hasValue) {\n continue;\n }\n\n if (qualifiedByBodyRegion(entry.code, before) || followedByPercent(entry.code, after)) {\n continue;\n }\n\n if (girth && SKINFOLD_SITE_CODES.has(entry.code)) {\n continue;\n }\n\n let confidence = CONFIDENCE_NAME_ONLY;\n if (entry.ambiguous) {\n confidence = CONFIDENCE_AMBIGUOUS;\n } else if (hasValue) {\n confidence = CONFIDENCE_VALUE_ADJACENT;\n }\n\n const existing = bestByCode.get(entry.code);\n const better =\n !existing ||\n confidence > existing.confidence ||\n (confidence === existing.confidence && candidate.start < existing.position);\n if (better) {\n bestByCode.set(entry.code, {\n code: entry.code,\n confidence,\n loinc: entry.loinc,\n matchedName: entry.original,\n position: candidate.start,\n });\n }\n }\n }\n\n attachMethodVariants(bestByCode.values(), normalizedText, anchoredLineStarts, (text, cue) =>\n buildNamePattern(normalize(cue).trim()).test(text),\n );\n\n const matches = Array.from(bestByCode.values()).sort((a, b) => a.position - b.position);\n const scanTimeMs = Date.now() - startTime;\n\n return {\n filteredReference: generateFilteredLLMReference(matches.map((m) => m.code)),\n matches,\n stats: {\n matchedCount: matches.length,\n scanTimeMs,\n totalPatterns: getPatterns().length,\n },\n };\n}\n\n/**\n * Get the list of matched biomarker codes from an anchor result.\n */\nexport function getMatchedCodes(result: AnchorResult): string[] {\n return result.matches.map((m) => m.code);\n}\n","/**\n * Vocabulário do pré-scan de âncoras: listas de termos que decidem se um nome\n * curto ou genérico pode ancorar sozinho. Separado do `anchor.ts` só para\n * manter o arquivo do algoritmo legível.\n */\n\n/** Siglas curtas liberadas do corte de tamanho mínimo do `getNamePatterns`. */\nexport const UNAMBIGUOUS_SHORT_NAMES = new Set([\n 'hdl',\n 'ldl',\n 'lh',\n 'tsh',\n 'crp',\n 'pcr',\n 'ggt',\n 'alt',\n 'ast',\n 'bun',\n 'wbc',\n 'rbc',\n 'mcv',\n 'mch',\n 'rdw',\n 'mpv',\n 'psa',\n 'fsh',\n 'hba1c',\n 'egfr',\n 'acr',\n 'esr',\n 'vhs',\n 'bmc',\n 'bmd',\n 'vat',\n 'dxa',\n 'dmo',\n 'cmo',\n 'ffm',\n 'lbm',\n 'mlg',\n 'tav',\n]);\n\n/**\n * Single-word catalog names that are ordinary words in EN/PT, so seeing them\n * proves nothing on its own. They only anchor when the line also carries a\n * value. Qualitative urine markers (`Color`, `Protein`, `Blood`, …) are\n * detected automatically — see `isQualitativeUrine` — and don't belong here.\n */\nexport const CONTEXT_REQUIRED_NAMES = new Set([\n 'bacteria', // Bacteria_Urine — tem unidade, escapa da regra automática\n 'bacterias', // Bacteria_Urine\n 'lead', // Lead — verbo/substantivo comuníssimo em inglês\n 'peso', // TotalMass\n 'saturation', // TransferrinSaturation — \"oxygen saturation\", \"saturation index\"\n 'tap', // ProthrombinTime — \"tap\" em inglês\n 'volume', // VATVolume\n 'weight', // TotalMass\n // Sítios de dobra pelo nome nu. São partes do corpo antes de serem medidas,\n // e aparecem em prosa: num laudo de DEXA real, \"hips and thighs\" e\n // \"abdominal region\" ancoravam dobra cutânea que o documento não tem.\n // Exigir valor na linha separa a tabela do parágrafo.\n 'abdominal',\n 'chest',\n 'coxa',\n 'peitoral',\n 'subescapular',\n 'subscapular',\n 'suprailiac',\n 'thigh',\n 'triceps',\n 'tricipital',\n]);\n\n/**\n * Qualitative results expected next to a non-numeric biomarker\n * (urine dipstick, sediment, appearance). Normalized, single tokens —\n * \"não reagente\" is covered by `reagente`, \"não detectado\" by `detectado`.\n */\nexport const QUALITATIVE_VALUE_TERMS = new Set([\n 'absent',\n 'alguns',\n 'amarela',\n 'amarelo',\n 'anormal',\n 'ausencia',\n 'ausente',\n 'ausentes',\n 'citrino',\n 'claro',\n 'clear',\n 'cloudy',\n 'colorless',\n 'detectado',\n 'detected',\n 'escuro',\n 'incolor',\n 'indetectavel',\n 'limpido',\n 'moderada',\n 'moderado',\n 'negativa',\n 'negative',\n 'negativo',\n 'normais',\n 'normal',\n 'numerosos',\n 'ocasional',\n 'positiva',\n 'positive',\n 'positivo',\n 'present',\n 'presente',\n 'presentes',\n 'raras',\n 'raro',\n 'raros',\n 'reagente',\n 'trace',\n 'traces',\n 'tracos',\n 'turvo',\n 'undetectable',\n 'yellow',\n]);\n","/**\n * Desambiguação por região do corpo: o qualificador de região antes de um nome\n * de composição corporal, e a dobra cutânea que na verdade é circunferência.\n * Separado do `anchor.ts` pelo mesmo motivo do `anchor-lexicon.ts`: manter o\n * arquivo do algoritmo legível.\n */\n\n/**\n * Medidas de corpo inteiro da composição corporal, cujo nome genérico (\"Fat\n * Mass\", \"Total Mass\", \"Lean Mass\") reaparece dentro do rótulo de uma região.\n *\n * Na tabela de equilíbrio muscular do DEXA as linhas se chamam \"Arms Total\",\n * \"Right Arm\" e \"Arms Difference\", e o modelo devolve \"Arms Difference Fat\n * Mass\" ou \"Arms Total Mass\". Sem guarda, o \"fat mass\" de dentro desses rótulos\n * ancorava `FatMass`, que é a gordura do corpo todo.\n */\nconst WHOLE_BODY_COMPOSITION_CODES = new Set([\n 'BMC',\n 'BodyFatPct',\n 'FatFreeMass',\n 'FatMass',\n 'LeanMass',\n 'TotalMass',\n]);\n\n/**\n * Códigos que medem os dois membros somados. Precedidos de um lado só (\"Right\n * Arm Fat Mass\"), o rótulo fala de um braço, e o código, dos dois.\n */\nconst LIMB_PAIR_CODES = new Set(['ArmsFatMass', 'ArmsLeanMass', 'LegsFatMass', 'LegsLeanMass']);\n\n/** Região do corpo, normalizada (sem acento, minúscula). */\nconst BODY_REGION = String.raw`(?:arms?|legs?|trunk|head|android|gynoid|bracos?|pernas?|tronco|cabeca|androide|ginoide)`;\n\n/** Lado do corpo, normalizado. */\nconst BODY_SIDE = String.raw`(?:right|left|direit[oa]|esquerd[oa])`;\n\n/**\n * Texto que termina numa região do corpo, com qualificadores de linha da\n * tabela no meio (\"arms \", \"arms total \", \"right arm \", \"arms difference \").\n * Testado contra o trecho da linha que vem antes do nome casado.\n */\nconst ENDS_WITH_BODY_REGION = new RegExp(\n String.raw`(?<![\\p{L}\\p{N}])${BODY_REGION}(?: (?:total|difference|diferenca|${BODY_SIDE}))* ?$`,\n 'u',\n);\n\n/** Texto que termina num lado do corpo (\"right \", \"left \"). */\nconst ENDS_WITH_BODY_SIDE = new RegExp(String.raw`(?<![\\p{L}\\p{N}])${BODY_SIDE} ?$`, 'u');\n\n/**\n * O nome casou dentro do rótulo de uma região ou de um lado do corpo, e o\n * código mede outra coisa.\n *\n * Na densitometria, \"Fat Mass\" é a gordura do corpo todo, e \"Arms Difference\n * Fat Mass\" é a diferença entre os braços: o qualificador vem antes do nome, e\n * o casamento por palavra inteira não o enxerga. Do mesmo jeito, \"Arm Fat\n * Mass\" é sinônimo de `ArmsFatMass`, os dois braços, e em \"Right Arm Fat Mass\"\n * o rótulo é de um braço só.\n *\n * Só olha o que vem imediatamente antes do nome, na mesma linha. Uma região\n * em outro ponto da linha (o cabeçalho de uma tabela, uma coluna vizinha) não\n * qualifica o nome.\n */\nexport function qualifiedByBodyRegion(code: string, before: string): boolean {\n if (WHOLE_BODY_COMPOSITION_CODES.has(code)) {\n return ENDS_WITH_BODY_REGION.test(before);\n }\n if (LIMB_PAIR_CODES.has(code)) {\n return ENDS_WITH_BODY_SIDE.test(before);\n }\n return false;\n}\n\n/** Medidas de massa do corpo inteiro: as de `WHOLE_BODY_COMPOSITION_CODES` menos o percentual. */\nconst WHOLE_BODY_MASS_CODES = new Set(\n [...WHOLE_BODY_COMPOSITION_CODES].filter((code) => code !== 'BodyFatPct'),\n);\n\n/**\n * Texto que começa num percentual: o sinal sozinho, entre parênteses, ou\n * colado a um número (\"%\", \"(%)\", \"23.1%\", \"23,1 %\").\n */\nconst STARTS_WITH_PERCENT = /^ ?(?:\\( ?% ?\\)|%|[-+]?\\d+(?:[.,]\\d+)? ?%)/;\n\n/**\n * O nome de uma massa do corpo inteiro casou, e o que vem logo depois dele é\n * um percentual.\n *\n * A tabela de tendência da densitometria Live Lean tem duas colunas \"Total\n * Fat\", uma em \"(%)\" e outra em \"(lbs)\", e só a segunda é a massa de gordura.\n * A citação do percentual chega como \"Total Fat 23.1%\", e sem esta guarda o\n * \"Total Fat\" ancorava `FatMass` nela. O percentual tem nome próprio no\n * catálogo quando o laudo o escreve colado ao rótulo (\"Total Fat %\", \"Total\n * Fat (%)\"), e o nome mais longo já ganha; esta guarda cobre a citação em que o\n * sinal só aparece no número.\n *\n * Só olha o que vem imediatamente depois do nome, na mesma linha.\n */\nexport function followedByPercent(code: string, after: string): boolean {\n return WHOLE_BODY_MASS_CODES.has(code) && STARTS_WITH_PERCENT.test(after);\n}\n\n/**\n * Sítios de dobra cutânea cujo nome nu também nomeia uma circunferência:\n * \"Coxa\" aparece tanto em \"Dobra Cutânea Coxa\" quanto em \"Circunferência da\n * Coxa\". O termo nu precisa existir como alias, porque há laudo que imprime\n * só o sítio na coluna, então a desambiguação tem que vir do contexto da\n * linha, como já se faz com laudo genético.\n */\nexport const SKINFOLD_SITE_CODES = new Set([\n 'SkinfoldAbdominal',\n 'SkinfoldChest',\n 'SkinfoldMidaxillary',\n 'SkinfoldSubscapular',\n 'SkinfoldSuprailiac',\n 'SkinfoldThigh',\n 'SkinfoldTriceps',\n]);\n\n/** Uma linha de circunferência ou perímetro não mede dobra. */\nconst GIRTH_CONTEXT_PATTERNS: RegExp[] = [\n /\\bcircumference\\b/,\n /\\bcircunferencias?\\b/,\n /\\bperimetros?\\b/,\n /\\bgirth\\b/,\n];\n\n/**\n * Só bloqueia quando a linha fala de circunferência e não fala de dobra:\n * \"Dobra Cutânea Coxa\" e \"Thigh Skinfold\" continuam ancorando normalmente,\n * e uma linha que traga as duas palavras é ambígua demais para descartar.\n */\nconst SKINFOLD_CONTEXT_PATTERNS: RegExp[] = [/\\bdobras?\\b/, /\\bskin ?folds?\\b/, /\\bpregas?\\b/];\n\n/**\n * Medida em centímetros numa linha de sítio corporal.\n *\n * Dobra cutânea é em milímetros, sempre: um valor em cm no mesmo sítio é\n * circunferência. É o desambiguador mais forte que existe aqui, porque não\n * depende de a folha escrever a palavra \"circunferência\", e num laudo de\n * antropometria a coluna costuma trazer só o sítio e o número.\n *\n * Rejeita cm em vez de exigir mm: há folha que imprime a unidade no cabeçalho\n * da coluna e não em cada linha, e exigir mm perderia essas.\n */\nconst CENTIMETRE_VALUE = /\\d\\s*(?:,\\d+\\s*)?cm\\b/;\n\nexport function hasGirthContext(line: string): boolean {\n if (SKINFOLD_CONTEXT_PATTERNS.some((re) => re.test(line))) {\n return false;\n }\n return GIRTH_CONTEXT_PATTERNS.some((re) => re.test(line)) || CENTIMETRE_VALUE.test(line);\n}\n","/**\n * O método impresso no laudo escolhe o código LOINC (PRE-465).\n *\n * Roda depois da ancoragem, sobre o melhor casamento de cada código. Só os\n * biomarcadores com `methodVariants` de pista declarada são examinados, e a\n * decisão é da varredura: o modelo não escolhe método.\n */\nimport { getDefinitionByCode } from '@precisa-saude/fhir';\n\nimport type { AnchorMatch } from './anchor';\n\n/** Quantas linhas depois do exame a pista de método ainda pertence a ele. */\nconst METHOD_CUE_LINES_BELOW = 6;\n\n/** `true` quando `cue` aparece em `text` por token inteiro, como os nomes. */\nexport type CueMatcher = (text: string, cue: string) => boolean;\n\ninterface Line {\n end: number;\n start: number;\n}\n\n/**\n * A variante por método que o texto afirma para um exame ancorado.\n *\n * Lê a linha do exame, a de cima e as seguintes até o próximo exame ancorado,\n * no máximo `METHOD_CUE_LINES_BELOW` linhas. As seguintes contam porque a pista\n * que existe em laudo real é rodapé: um laboratório imprime, abaixo do LDL, que\n * o valor foi calculado por Martin-Hopkins. Parar no próximo exame impede que o\n * método de um vire o do outro.\n *\n * Pistas de duas variantes no mesmo trecho deixam o exame sem método, porque\n * escolher uma seria chute.\n */\nfunction findMethodVariant(\n code: string,\n position: number,\n lines: Line[],\n otherAnchorLines: Set<number>,\n normalizedText: string,\n matchesCue: CueMatcher,\n): { cue: string; loinc: string } | undefined {\n const variants = getDefinitionByCode(code)?.methodVariants?.filter(\n (v) => v.cues.en.length > 0 || v.cues.pt.length > 0,\n );\n if (!variants || variants.length === 0) return undefined;\n\n const index = lines.findIndex((l) => l.start <= position && position <= l.end);\n if (index === -1) return undefined;\n const window: number[] = [];\n if (index > 0 && !otherAnchorLines.has(index - 1)) window.push(index - 1);\n window.push(index);\n for (let i = index + 1; i < lines.length && i <= index + METHOD_CUE_LINES_BELOW; i += 1) {\n if (otherAnchorLines.has(i)) break;\n window.push(i);\n }\n const text = window.map((i) => normalizedText.slice(lines[i]!.start, lines[i]!.end)).join('\\n');\n\n const found = variants.flatMap((variant) => {\n const cue = [...variant.cues.pt, ...variant.cues.en].find((c) => matchesCue(text, c));\n return cue ? [{ cue, loinc: variant.loinc }] : [];\n });\n return found.length === 1 ? found[0] : undefined;\n}\n\n/** Registra os códigos que ancoraram na linha que começa em `lineStart`. */\nexport function recordAnchorLine(\n anchoredLineStarts: Map<number, Set<string>>,\n lineStart: number,\n entries: { code: string }[],\n): void {\n const codes = anchoredLineStarts.get(lineStart) ?? new Set<string>();\n for (const entry of entries) codes.add(entry.code);\n anchoredLineStarts.set(lineStart, codes);\n}\n\n/**\n * Preenche `methodLoinc` e `methodCue` nos casamentos cujo texto afirma o método.\n *\n * `anchoredLineStarts` diz em que linha cada código ancorou, para a pista não\n * atravessar de um exame para o seguinte.\n */\nexport function attachMethodVariants(\n matches: Iterable<AnchorMatch>,\n normalizedText: string,\n anchoredLineStarts: Map<number, Set<string>>,\n matchesCue: CueMatcher,\n): void {\n const lines: Line[] = [];\n for (let start = 0; start <= normalizedText.length; ) {\n const end = normalizedText.indexOf('\\n', start);\n lines.push({ end: end === -1 ? normalizedText.length : end, start });\n if (end === -1) break;\n start = end + 1;\n }\n for (const match of matches) {\n const otherAnchorLines = new Set<number>();\n lines.forEach((line, i) => {\n const codes = anchoredLineStarts.get(line.start);\n if (codes && [...codes].some((c) => c !== match.code)) otherAnchorLines.add(i);\n });\n const variant = findMethodVariant(\n match.code,\n match.position,\n lines,\n otherAnchorLines,\n normalizedText,\n matchesCue,\n );\n if (variant) {\n match.methodLoinc = variant.loinc;\n match.methodCue = variant.cue;\n }\n }\n}\n","/**\n * Contrato de saída para extração de laudo por modelo.\n *\n * Este é o **contrato de interoperabilidade**, e não um prompt. Ele descreve a\n * forma do JSON que qualquer modelo precisa devolver para o resto do toolkit\n * conseguir conferir e converter o resultado. Não diz como pedir isso ao\n * modelo, não traz instrução de comportamento e não depende de fornecedor:\n * quem usa liga do jeito que a plataforma dele permitir (saída estruturada,\n * gramática, tool use ou simples prompt com validação por cima).\n *\n * As descrições são deliberadamente neutras. Regra de comportamento (\"nunca\n * infira\", \"copie literalmente\") é ajuste de prompt, muda de modelo para\n * modelo e não pertence a um contrato público.\n *\n * As descrições são as únicas strings em inglês do pacote, e isso é\n * deliberado: o schema é contrato de integração lido por quem consome de fora\n * do Brasil, e uma descrição em pt-BR não ajuda ninguém em Colônia ou Madri.\n * O resto da documentação segue a regra do ecossistema.\n *\n * O campo `sourceText` existe porque é o que torna a conferência possível:\n * sem o trecho que originou o valor não dá para auditar a extração depois.\n *\n * A descrição dele diz \"linha\" e não \"trecho\" porque \"trecho\" é largo demais:\n * num laudo da Quest o `qwen3-4b-2507` citou a nota de rodapé inteira do ANA\n * SCREEN, oito linhas de explicação, e quem confere recebeu um parágrafo aceso\n * no lugar da linha do resultado. A citação continua sendo do documento, só\n * que grande demais para servir de referência.\n *\n * `collectionDate` e `laboratoryName` moram no topo porque valem para o laudo\n * inteiro, e não para uma medida. Sem a data não sai Bundle FHIR: o mapeador\n * recusa montar um sem ela, então um contrato que não pede a data entrega\n * biomarcador que não vira recurso.\n *\n * O `loinc` é obrigatório apesar de aceitar `null`. Opcional, ele some: num\n * laudo da Labcorp o `granite-4.1-8b` leu os cinco exames certos e devolveu\n * todos sem o campo, e a conferência recusou os cinco. Obrigatório, o modelo\n * precisa decidir e responder `null` quando nenhum código serve, que é uma\n * resposta auditável em vez de um silêncio. Sem isto, qual modelo funciona\n * depende de o modelo lembrar de preencher campo opcional.\n *\n * `referenceMin` e `referenceMax` são obrigatórios pelo mesmo motivo do\n * `loinc`, e o erro que eles deixavam passar era pior que o silêncio. Num laudo\n * da Labcorp, com a faixa impressa na mesma linha, `ministral-3-8b` e\n * `granite-4.1-8b` devolveram o **valor medido** no lugar do limite superior\n * sempre que o resultado encostava no topo da faixa: 5,7 em vez de 5,6 na\n * hemoglobina glicada, 7,0 em vez de 8,4 no ácido úrico. Os dois também\n * arredondaram limites impressos, 149 virando 150 e 24,9 virando 25.\n *\n * Uma faixa errada não parece errada: ela vira um \"normal\" ou um \"alterado\" que\n * ninguém questiona, e o valor ao lado está certo. Por isso a descrição diz as\n * duas coisas que o modelo confundia, que o limite não é a medida e que os\n * dígitos são os do laudo, e o campo passa a exigir resposta: `null` quando o\n * laudo não publica limite é auditável, um campo ausente não é.\n *\n * Campo que aceita mais de um tipo usa `anyOf`, e não `type: [...]`. As duas\n * formas são JSON Schema válido, mas decodificador restrito não engole a\n * segunda: o LM Studio recusa a geração com `'type' must be a string`. Como o\n * ponto do contrato é servir a qualquer modelo, vale a forma mais aceita.\n */\nexport const LAB_EXTRACTION_SCHEMA = {\n $id: 'https://fhir-brasil.dev.br/schemas/lab-extraction.json',\n $schema: 'https://json-schema.org/draft/2020-12/schema',\n additionalProperties: false,\n properties: {\n biomarkers: {\n description: 'The measurements read from the report.',\n items: {\n additionalProperties: false,\n properties: {\n confidence: {\n description: 'Confidence in this reading, from 0 to 1.',\n maximum: 1,\n minimum: 0,\n type: 'number',\n },\n loinc: {\n anyOf: [{ type: 'string' }, { type: 'null' }],\n description:\n 'A LOINC code from the allowed list, or null when none of them applies. ' +\n 'Required: answer null rather than omitting the field.',\n },\n name: {\n description: 'The measurement name as the report prints it.',\n type: 'string',\n },\n referenceMax: {\n anyOf: [{ type: 'number' }, { type: 'null' }],\n description:\n 'Upper bound of the reference range as printed on the report, or null when ' +\n 'the report prints no upper bound. The bound describes the test, not this ' +\n 'result, and keeps the digits the report prints.',\n },\n referenceMin: {\n anyOf: [{ type: 'number' }, { type: 'null' }],\n description:\n 'Lower bound of the reference range as printed on the report, or null when ' +\n 'the report prints no lower bound. The bound describes the test, not this ' +\n 'result, and keeps the digits the report prints.',\n },\n sourceText: {\n description:\n 'The line of the report where this measurement and its value are printed. ' +\n 'A line, not the explanatory block around it.',\n type: 'string',\n },\n unit: {\n description: 'Unit as the report prints it. Empty string when there is none.',\n type: 'string',\n },\n value: {\n anyOf: [{ type: 'number' }, { type: 'string' }],\n description: 'Numeric value, or text for a qualitative result.',\n },\n },\n required: [\n 'name',\n 'value',\n 'unit',\n 'sourceText',\n 'confidence',\n 'loinc',\n 'referenceMin',\n 'referenceMax',\n ],\n type: 'object',\n },\n type: 'array',\n },\n collectionDate: {\n anyOf: [{ type: 'string' }, { type: 'null' }],\n description:\n 'The date the specimen was collected, as ISO 8601 (YYYY-MM-DD), or null when the ' +\n 'report does not print one. Required: answer null rather than omitting the field.',\n },\n laboratoryName: {\n anyOf: [{ type: 'string' }, { type: 'null' }],\n description:\n 'The laboratory that issued the report, as printed, or null when it is not stated. ' +\n 'Required: answer null rather than omitting the field.',\n },\n },\n required: ['biomarkers', 'collectionDate', 'laboratoryName'],\n title: 'Laboratory report extraction',\n type: 'object',\n} as const;\n\n/** Uma grandeza como o modelo devolve, antes de qualquer conferência. */\nexport interface ExtractedBiomarker {\n confidence: number;\n loinc?: string | null;\n name: string;\n referenceMax?: number | null;\n referenceMin?: number | null;\n sourceText: string;\n unit: string;\n value: number | string;\n}\n\n/** O objeto inteiro que o modelo devolve. */\nexport interface ExtractionPayload {\n biomarkers: ExtractedBiomarker[];\n /** Data da coleta em ISO 8601, ou `null` quando o laudo não imprime uma. */\n collectionDate: string | null;\n /** Laboratório que emitiu o laudo, como impresso, ou `null`. */\n laboratoryName: string | null;\n}\n","import { loincToCode, methodVariantOf } from '@precisa-saude/fhir';\n\nimport type { ExtractedBiomarker } from './extraction-schema.js';\n\n/**\n * Converte grandezas já conferidas no envelope que o `fhir-bio convert` come.\n *\n * O laudo e o paciente não vêm do modelo: o contrato de extração cobre só as\n * grandezas. Os dois saem daqui com valores sintéticos e óbvios, na mesma\n * linha do `fhir-rnds-sandbox`, para a demo rodar de ponta a ponta sem inventar\n * identidade de ninguém. Quem integra de verdade troca os dois pelo que já tem.\n */\nexport interface LabResultEnvelope {\n observations: {\n biomarkerCode: string;\n biomarkerName: string;\n flag: 'H' | 'L' | '';\n /** Ver `LabObservationData.methodLoinc`. */\n methodLoinc?: string;\n referenceMax?: number;\n referenceMin?: number;\n reportId: string;\n unit: string;\n value: number | string;\n }[];\n profile: { name: string; userId: string };\n report: {\n collectionDate: string;\n createdAt: string;\n overallStatus: 'ANORMAL' | 'NORMAL';\n processingStatus: 'complete';\n reportId: string;\n userId: string;\n };\n}\n\nexport interface ToLabResultOptions {\n collectionDate?: string;\n reportId?: string;\n userId?: string;\n}\n\n/** `H`/`L` só quando o próprio laudo trouxe a faixa. Nunca inferida daqui. */\nfunction flagFor(b: ExtractedBiomarker): 'H' | 'L' | '' {\n if (typeof b.value !== 'number') return '';\n if (typeof b.referenceMax === 'number' && b.value > b.referenceMax) return 'H';\n if (typeof b.referenceMin === 'number' && b.value < b.referenceMin) return 'L';\n return '';\n}\n\nexport function extractionToLabResult(\n biomarkers: ExtractedBiomarker[],\n options: ToLabResultOptions = {},\n): LabResultEnvelope {\n const reportId = options.reportId ?? 'laudo-demo';\n const userId = options.userId ?? 'paciente-demo';\n const collectionDate = options.collectionDate ?? new Date().toISOString().slice(0, 10);\n\n const observations = biomarkers.flatMap((b) => {\n // Sem código interno não há como converter, e o LOINC sozinho não basta\n // para o `convert`. Cai fora em silêncio porque a checagem de ancoragem já\n // rodou antes: o que chega aqui sem código é grandeza fora do catálogo.\n const code = b.loinc ? loincToCode(b.loinc) : undefined;\n if (!code) return [];\n\n return [\n {\n biomarkerCode: code,\n biomarkerName: b.name,\n flag: flagFor(b),\n // O código por método já passou pela varredura no validador; aqui só\n // atravessa, e só quando é variante declarada do biomarcador.\n ...(b.loinc && methodVariantOf(code, b.loinc) ? { methodLoinc: b.loinc } : {}),\n ...(typeof b.referenceMax === 'number' ? { referenceMax: b.referenceMax } : {}),\n ...(typeof b.referenceMin === 'number' ? { referenceMin: b.referenceMin } : {}),\n reportId,\n unit: b.unit,\n value: b.value,\n },\n ];\n });\n\n return {\n observations,\n profile: { name: 'Paciente de Demonstração', userId },\n report: {\n collectionDate,\n createdAt: `${collectionDate}T00:00:00Z`,\n overallStatus: observations.some((o) => o.flag !== '') ? 'ANORMAL' : 'NORMAL',\n processingStatus: 'complete',\n reportId,\n userId,\n },\n };\n}\n","import { codeToLoinc, getDefinitionByCode, loincToCode } from '@precisa-saude/fhir';\n\nimport type { AnchorResult } from './anchor.js';\nimport type { ExtractedBiomarker, ExtractionPayload } from './extraction-schema.js';\nimport { placeSingleBound } from './reference-bound.js';\n\n/**\n * Conferência da saída do modelo contra o contrato e contra a ancoragem.\n *\n * São duas checagens, e as duas são determinísticas:\n *\n * 1. **Forma.** O objeto bate com `LAB_EXTRACTION_SCHEMA`. Modelo que devolve\n * texto solto, campo faltando ou tipo errado é recusado aqui, o que deixa\n * a qualidade do modelo virar problema de cobertura e nunca de correção.\n * 2. **Ancoragem.** O código veio da lista que a varredura liberou. Código que\n * o laudo não mencionou é descartado, que é a falha cara: um valor\n * plausível pendurado num exame que não estava na página.\n *\n * A validação de citação, a correção de código contra nome impresso e a\n * política de confiança não moram aqui.\n *\n * Sem dependência de runtime além do `@precisa-saude/fhir`: a checagem de\n * forma é escrita à mão porque o schema é pequeno e o pacote não carrega\n * validador de JSON Schema.\n */\n\n/** Por que uma grandeza foi recusada. */\nexport type RejectionReason = 'not-anchored' | 'schema';\n\nexport interface RejectedBiomarker {\n /** Mensagem legível, já em pt-BR, dizendo o que falhou. */\n detail: string;\n /** O que o modelo devolveu, sem alteração, para o consumidor poder logar. */\n raw: unknown;\n reason: RejectionReason;\n}\n\nexport interface ExtractionValidationResult {\n accepted: ExtractedBiomarker[];\n /** Erros do objeto inteiro, quando nem dá para chegar nas grandezas. */\n errors: string[];\n rejected: RejectedBiomarker[];\n /**\n * O que vale para o laudo inteiro, e não para uma medida.\n *\n * Sai daqui em vez de o consumidor ler do objeto cru porque é aqui que a\n * forma é conferida: string ou `null`, nunca o que o modelo inventar. Sem a\n * data não há Bundle FHIR, então ela precisa atravessar a conferência em vez\n * de ficar para trás.\n */\n report: { collectionDate: string | null; laboratoryName: string | null };\n /** `true` quando o objeto tem forma válida, mesmo que toda grandeza caia. */\n valid: boolean;\n}\n\nexport interface ValidateExtractionOptions {\n /**\n * Resultado da ancoragem sobre o mesmo texto que foi ao modelo. Sem ele a\n * checagem de ancoragem não roda e só a forma é conferida, que é um modo\n * deliberadamente mais fraco: serve para inspecionar saída de modelo sem o\n * laudo em mãos.\n */\n anchors?: AnchorResult;\n}\n\nconst isRecord = (v: unknown): v is Record<string, unknown> =>\n typeof v === 'object' && v !== null && !Array.isArray(v);\n\n/** String, ou `null` para qualquer outra coisa, inclusive campo ausente. */\nconst nullableString = (v: unknown): string | null =>\n typeof v === 'string' && v !== '' ? v : null;\n\n/** Confere uma grandeza contra o schema. Devolve a lista de problemas. */\nfunction schemaErrors(raw: unknown): string[] {\n if (!isRecord(raw)) return ['não é um objeto'];\n\n const errors: string[] = [];\n const { confidence, loinc, name, referenceMax, referenceMin, sourceText, unit, value } = raw;\n\n if (typeof name !== 'string' || name.length === 0) errors.push('`name` ausente ou vazio');\n if (typeof sourceText !== 'string' || sourceText.length === 0)\n errors.push('`sourceText` ausente ou vazio');\n if (typeof unit !== 'string') errors.push('`unit` ausente');\n if (typeof value !== 'number' && typeof value !== 'string') errors.push('`value` ausente');\n if (typeof confidence !== 'number' || confidence < 0 || confidence > 1)\n errors.push('`confidence` fora de 0..1');\n\n if (loinc !== undefined && loinc !== null && typeof loinc !== 'string')\n errors.push('`loinc` não é string nem null');\n\n for (const [key, v] of [\n ['referenceMax', referenceMax],\n ['referenceMin', referenceMin],\n ] as const) {\n if (v !== undefined && v !== null && typeof v !== 'number')\n errors.push(`\\`${key}\\` não é número nem null`);\n }\n\n return errors;\n}\n\n/**\n * O conjunto de códigos que a varredura liberou, pelos dois lados: o LOINC e o\n * código interno. O modelo devolve LOINC, mas aceitar o código interno também\n * evita recusar consumidor que prefira trabalhar com ele.\n */\nfunction allowedKeys(anchors: AnchorResult): Set<string> {\n const allowed = new Set<string>();\n for (const match of anchors.matches) {\n allowed.add(match.code);\n if (match.loinc) allowed.add(match.loinc);\n }\n return allowed;\n}\n\n/**\n * O código por método é da varredura, e não do modelo.\n *\n * Quando o biomarcador tem `methodVariants`, o LOINC aceito é o que a\n * varredura achou no texto (`methodLoinc`) ou, sem pista, o código sem método.\n * Um código por método que o modelo devolva por conta própria vira o código sem\n * método: o modelo não tem como provar o método, e o texto não o afirmou.\n */\nfunction withScannedMethod(\n biomarker: ExtractedBiomarker,\n anchors: AnchorResult,\n): ExtractedBiomarker {\n const code = biomarker.loinc ? loincToCode(biomarker.loinc) : undefined;\n if (!code || !getDefinitionByCode(code)?.methodVariants?.length) return biomarker;\n const scanned = anchors.matches.find((m) => m.code === code)?.methodLoinc;\n const loinc = scanned ?? codeToLoinc(code);\n return loinc && loinc !== biomarker.loinc ? { ...biomarker, loinc } : biomarker;\n}\n\n/**\n * Confere a saída de um modelo contra o contrato e, quando a ancoragem é\n * fornecida, contra a lista de códigos que a varredura liberou.\n */\nexport function validateExtraction(\n raw: unknown,\n options: ValidateExtractionOptions = {},\n): ExtractionValidationResult {\n const { anchors } = options;\n\n if (!isRecord(raw)) {\n return {\n accepted: [],\n errors: ['a saída não é um objeto JSON'],\n rejected: [],\n report: { collectionDate: null, laboratoryName: null },\n valid: false,\n };\n }\n if (!Array.isArray(raw.biomarkers)) {\n return {\n accepted: [],\n errors: ['`biomarkers` ausente ou não é lista'],\n rejected: [],\n report: { collectionDate: null, laboratoryName: null },\n valid: false,\n };\n }\n\n const allowed = anchors ? allowedKeys(anchors) : undefined;\n const accepted: ExtractedBiomarker[] = [];\n const rejected: RejectedBiomarker[] = [];\n\n for (const entry of raw.biomarkers) {\n const problems = schemaErrors(entry);\n if (problems.length > 0) {\n rejected.push({ detail: problems.join('; '), raw: entry, reason: 'schema' });\n continue;\n }\n\n const biomarker = entry as unknown as ExtractedBiomarker;\n\n if (allowed) {\n const loinc = biomarker.loinc ?? undefined;\n // Sem código não há o que conferir contra a ancoragem, e aceitar assim\n // deixaria passar justamente o caso que a varredura existe para pegar.\n if (!loinc) {\n rejected.push({\n detail: `\"${biomarker.name}\" veio sem código LOINC`,\n raw: entry,\n reason: 'not-anchored',\n });\n continue;\n }\n // O `?? ''` de antes nunca deixava código inválido passar, porque string\n // vazia não entra no conjunto de permitidos, mas obrigava quem lê a\n // provar isso. A forma explícita não precisa de prova.\n const internalCode = loincToCode(loinc);\n const isAnchored =\n allowed.has(loinc) || (internalCode !== undefined && allowed.has(internalCode));\n if (!isAnchored) {\n rejected.push({\n detail: `${loinc} não foi ancorado no texto de origem`,\n raw: entry,\n reason: 'not-anchored',\n });\n continue;\n }\n }\n\n // O lado de um limite solto é decidido aqui, e não pelo modelo: a linha\n // impressa diz o sinal, e os modelos abertos erram o lado sem que a\n // descrição do contrato os corrija. Ver `reference-bound.ts`.\n accepted.push(placeSingleBound(anchors ? withScannedMethod(biomarker, anchors) : biomarker));\n }\n\n return {\n accepted,\n errors: [],\n rejected,\n report: {\n collectionDate: nullableString(raw.collectionDate),\n laboratoryName: nullableString(raw.laboratoryName),\n },\n valid: true,\n };\n}\n\n/** Só a lista de grandezas aprovadas, para quem não quer o relatório inteiro. */\nexport function acceptedBiomarkers(\n raw: unknown,\n options: ValidateExtractionOptions = {},\n): ExtractedBiomarker[] {\n return validateExtraction(raw, options).accepted;\n}\n\nexport type { ExtractedBiomarker, ExtractionPayload };\n","/**\n * Põe um limite solto do lado certo, lendo o sinal impresso no laudo.\n *\n * Laudo que publica \"< 90\" ou \"até 90\" no lugar de uma faixa não tem limite\n * inferior, e o número sozinho precisa cair em `referenceMax`. Os modelos\n * abertos erram esse lado de um jeito que a descrição do contrato não\n * consertou: medido no mesmo laudo da Labcorp, com o campo obrigatório, o\n * `ministral-3-8b` devolveu 90 em `referenceMin` e o `granite-4.1-8b` devolveu\n * 90 em `referenceMax`, e explicar o sinal na descrição não mudou nenhum dos\n * dois. Por isso a correção é determinística e não mais texto no contrato.\n *\n * A decisão sai do `sourceText`, que já é a linha impressa e já é obrigatório.\n * Sem sinal na linha nada muda: o que o modelo respondeu passa inteiro, porque\n * sem evidência impressa não há o que corrigir.\n *\n * O outro lado fica `null`, e não zero. Zero é um piso que o laudo não\n * publicou, e o contrato promete o que está impresso; quem desenha decide\n * como mostrar a ausência.\n */\nimport type { ExtractedBiomarker } from './extraction-schema.js';\n\n/**\n * Sinal de limite superior: o valor fica abaixo do número.\n *\n * \"até\" fecha sem `\\b` de propósito: `é` não é caractere de palavra, então não\n * existe fronteira entre ele e o espaço seguinte e o `\\b` nunca casava. Quem\n * faz o papel de fronteira aqui é o `\\s*$` que fecha o padrão.\n */\nconst ATE =\n /(?:<|≤|<=|menor\\s+que|menor\\s+ou\\s+igual|abaixo\\s+de|at[ée]|under|less\\s+than)\\s*[:=]?\\s*$/iu;\n\n/** Sinal de limite inferior: o valor fica acima do número. */\nconst ACIMA =\n /(?:>|≥|>=|maior\\s+que|maior\\s+ou\\s+igual|acima\\s+de|superior\\s+a|over|greater\\s+than)\\s*[:=]?\\s*$/iu;\n\n/**\n * De que lado o número solto cai, lendo o que vem imediatamente antes dele.\n *\n * A linha é varrida por número, e cada um é comparado **por valor** com o\n * limite. Comparar grafia não servia: o laudo imprime \"24,9\" onde o JSON traz\n * 24.9, e imprime \"90,0\" onde o modelo devolveu 90.\n *\n * Achado o número, o que decide é o trecho anterior a ele, e por isso a âncora\n * `$` nos dois padrões: numa linha como \"Apolipoprotein B 102 High mg/dL < 90\"\n * quem manda é o `<` colado no 90, e não um `>` noutro ponto da linha.\n */\nconst NUMERO = /\\d+(?:[.,]\\d+)?/gu;\n\nconst ladoDoLimite = (sourceText: string, bound: number): 'max' | 'min' | undefined => {\n for (const achado of sourceText.matchAll(NUMERO)) {\n // Comparação por valor, e não por grafia. Laudo brasileiro imprime \"24,9\"\n // e o número chega como 24.9, e um laudo que escreve \"90,0\" é o mesmo 90\n // que o modelo devolveu. Procurar a grafia do JavaScript errava os dois.\n if (Number(achado[0].replace(',', '.')) !== bound) continue;\n\n // Só a primeira ocorrência decide. Com o mesmo número em dois sinais na\n // mesma linha o desempate é arbitrário de qualquer jeito, e parar aqui\n // deixa o comportamento fixo em vez de depender da ordem dos padrões.\n const antes = sourceText.slice(0, achado.index);\n if (ATE.test(antes)) return 'max';\n if (ACIMA.test(antes)) return 'min';\n return undefined;\n }\n\n return undefined;\n};\n\n/**\n * Corrige o lado de uma grandeza que veio com um limite só.\n *\n * Devolve a mesma grandeza quando não há o que decidir: faixa com as duas\n * pontas, faixa sem nenhuma, ou linha sem sinal impresso.\n */\nexport const placeSingleBound = (biomarker: ExtractedBiomarker): ExtractedBiomarker => {\n const { referenceMax, referenceMin, sourceText } = biomarker;\n\n const temMin = typeof referenceMin === 'number';\n const temMax = typeof referenceMax === 'number';\n // Os dois preenchidos com o mesmo número é o outro jeito de o modelo errar:\n // ele não soube escolher e repetiu. Conta como limite solto.\n const repetido = temMin && temMax && referenceMin === referenceMax;\n if (!repetido && temMin === temMax) return biomarker;\n\n // Com as duas pontas repetidas o `temMin` é verdadeiro e o número sai do\n // `referenceMin`, que é o mesmo dos dois lados. Dito aqui porque a expressão\n // sozinha parece escolher um lado quando na verdade tanto faz.\n const bound = (temMin ? referenceMin : referenceMax) as number;\n const lado = ladoDoLimite(sourceText, bound);\n if (lado === undefined) return biomarker;\n\n return lado === 'max'\n ? { ...biomarker, referenceMax: bound, referenceMin: null }\n : { ...biomarker, referenceMax: null, referenceMin: bound };\n};\n","import { findBiomarkersInText } from './anchor.js';\nimport { LAB_EXTRACTION_SCHEMA } from './extraction-schema.js';\n\n/**\n * Cliente mínimo para endpoint compatível com OpenAI.\n *\n * Isto é **conveniência, não contrato**. O contrato é o\n * `LAB_EXTRACTION_SCHEMA`, e o toolkit funciona inteiro sem esta função: quem\n * integra chama o próprio modelo do jeito que a plataforma dele permitir e\n * entrega o JSON ao `validateExtraction`. Esta função existe para a demo rodar\n * de uma ponta à outra sem um `curl` no meio.\n *\n * `/v1/chat/completions` é o que praticamente todo mundo fala: LM Studio,\n * Ollama, llama.cpp, vLLM, OpenRouter, OpenAI, e a Anthropic pelo endpoint de\n * compatibilidade. Por isso não há SDK de fornecedor aqui, e por isso o pacote\n * continua sem dependência de runtime: `fetch` é do Node.\n *\n * A chave **nunca** entra por argumento de linha de comando, só por variável de\n * ambiente: argumento fica no histórico do shell e na lista de processos.\n */\nexport interface ExtractOptions {\n apiKey?: string;\n baseUrl: string;\n /**\n * Cabeçalhos a mais em cada chamada, para endpoint atrás de proxy que exige\n * credencial própria (o Cloudflare Access, por exemplo, pede\n * `CF-Access-Client-Id` e `CF-Access-Client-Secret`).\n *\n * Não substituem `content-type` nem `authorization`: o corpo é sempre JSON e\n * a chave do modelo continua vindo de `apiKey`. Um cabeçalho de proxy que\n * trocasse um dos dois quebraria a chamada de um jeito difícil de ler.\n */\n headers?: Record<string, string>;\n model: string;\n /**\n * Modo de saída estruturada. O padrão é negociar sozinho.\n *\n * Aqui é onde a compatibilidade quebra de verdade: o LM Studio recusa\n * `json_object` com 400 e só aceita `json_schema`, a OpenAI aceita os dois,\n * e servidor mais simples não conhece o campo. Como nenhum valor serve a\n * todos, a primeira tentativa vai com `json_schema` e, se o servidor recusar,\n * a segunda vai sem nada. Quem quiser fixar um modo passa ele aqui.\n *\n * Vale lembrar que isto mexe em **aproveitamento**, não em correção: saída\n * malformada é recusada pela conferência de qualquer jeito.\n */\n responseFormat?: 'auto' | 'json_object' | 'json_schema' | 'none';\n /** Milissegundos até desistir. Modelo local frio demora para carregar. */\n timeoutMs?: number;\n}\n\n/** Os dois cabeçalhos que o cliente monta e que `headers` não pode trocar. */\nconst RESERVED_HEADERS = new Set(['authorization', 'content-type']);\n\nexport interface ExtractResult {\n /** O JSON que o modelo devolveu, ainda sem conferência nenhuma. */\n payload: unknown;\n /** Texto cru da resposta, guardado para quando o parse falha. */\n raw: string;\n tookMs: number;\n}\n\n/**\n * O prompt é deliberadamente curto e neutro.\n *\n * Ele diz o que devolver e nada sobre como ler um laudo. Toda a instrução de\n * comportamento que um extrator de produção carrega é ajuste que muda de modelo\n * para modelo, e não pertence a um pacote público. O que sustenta a qualidade\n * aqui não é o prompt: é a conferência que roda depois.\n */\nfunction buildPrompt(text: string, allowed: string): string {\n return [\n 'Extract the laboratory results from the report below.',\n '',\n 'Return JSON matching this schema, and nothing else:',\n JSON.stringify(LAB_EXTRACTION_SCHEMA),\n '',\n 'Use only LOINC codes from this list:',\n allowed,\n '',\n 'REPORT:',\n text,\n ].join('\\n');\n}\n\n/** Modelo costuma embrulhar o JSON em cerca de markdown. Tira a cerca. */\nfunction stripFence(raw: string): string {\n const fenced = /```(?:json)?\\s*([\\s\\S]*?)```/.exec(raw);\n return (fenced?.[1] ?? raw).trim();\n}\n\n/**\n * Manda o laudo e a lista ancorada ao modelo e devolve o que ele respondeu.\n *\n * Não confere nada: a saída vai para o `validateExtraction`, que é onde a\n * ancoragem é cobrada. Separar os dois é proposital, porque é o que deixa\n * trocar de modelo sem mexer na parte que garante o resultado.\n */\nexport async function extractWithModel(\n text: string,\n options: ExtractOptions,\n): Promise<ExtractResult> {\n const { apiKey, baseUrl, headers = {}, model, responseFormat, timeoutMs = 300_000 } = options;\n\n // Nome de cabeçalho não diferencia maiúscula, e um objeto sim: sem comparar\n // em minúsculas, `Authorization` passaria ao lado do `authorization` daqui e\n // o `fetch` juntaria os dois num valor só.\n const extraHeaders = Object.fromEntries(\n Object.entries(headers).filter(([name]) => !RESERVED_HEADERS.has(name.toLowerCase())),\n );\n\n // A própria ancoragem já monta a lista de permitidos, então o prompt e a\n // conferência bebem exatamente da mesma fonte.\n const allowed = findBiomarkersInText(text).filteredReference;\n\n const startedAt = Date.now();\n\n const formatBody = (mode: 'json_object' | 'json_schema' | 'none'): string =>\n JSON.stringify({\n messages: [{ content: buildPrompt(text, allowed), role: 'user' }],\n model,\n ...(mode === 'json_object' ? { response_format: { type: 'json_object' } } : {}),\n ...(mode === 'json_schema'\n ? {\n response_format: {\n json_schema: { name: 'lab_extraction', schema: LAB_EXTRACTION_SCHEMA, strict: true },\n type: 'json_schema',\n },\n }\n : {}),\n temperature: 0,\n });\n\n const post = async (mode: 'json_object' | 'json_schema' | 'none'): Promise<Response> =>\n fetch(`${baseUrl.replace(/\\/$/, '')}/chat/completions`, {\n body: formatBody(mode),\n headers: {\n ...extraHeaders,\n 'content-type': 'application/json',\n ...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}),\n },\n method: 'POST',\n signal: AbortSignal.timeout(timeoutMs),\n });\n\n const mode = responseFormat ?? 'auto';\n let response = await post(mode === 'auto' ? 'json_schema' : mode);\n\n // Servidor que não conhece o modo estrito responde 4xx. A segunda tentativa\n // vai sem nada, que é o denominador comum, e só então o erro sobe.\n if (!response.ok && mode === 'auto' && response.status >= 400 && response.status < 500) {\n response = await post('none');\n }\n\n if (!response.ok) {\n throw new Error(`${String(response.status)} de ${baseUrl}: ${await response.text()}`);\n }\n\n const body = (await response.json()) as {\n choices?: { message?: { content?: string } }[];\n };\n const raw = body.choices?.[0]?.message?.content ?? '';\n const tookMs = Date.now() - startedAt;\n\n try {\n return { payload: JSON.parse(stripFence(raw)), raw, tookMs };\n } catch {\n throw new Error(`O modelo não devolveu JSON analisável. Resposta crua:\\n${raw.slice(0, 500)}`);\n }\n}\n"]}
1
+ {"version":3,"sources":["/home/runner/work/fhir-brasil/fhir-brasil/packages/ocr-utils/dist/index.cjs","../src/anchor.ts","../src/anchor-lexicon.ts","../src/body-region.ts","../src/method-variant.ts","../src/urinalysis-section.ts","../src/extraction-schema.ts","../src/extraction-to-lab-result.ts","../src/extraction-validator.ts","../src/reference-bound.ts","../src/llm-client.ts"],"names":["getDefinitionByCode"],"mappings":"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","file":"/home/runner/work/fhir-brasil/fhir-brasil/packages/ocr-utils/dist/index.cjs","sourcesContent":[null,"/**\n * OCR Anchor — Biomarker text anchoring\n *\n * Scans OCR text for biomarker names BEFORE sending to LLM.\n * This prevents hallucination by constraining what biomarkers\n * the LLM is allowed to extract.\n *\n * Matching is deliberately conservative: a name only anchors when it appears\n * as a whole token, is not swallowed by a longer biomarker name, is not inside\n * a genetic report line, and — for generic single-word names — sits on a line\n * that actually carries a value.\n */\n\nimport {\n type BiomarkerSearchPattern,\n generateFilteredLLMReference,\n getAllSearchPatterns,\n UNIT_TO_UCUM,\n} from '@precisa-saude/fhir';\n\nimport {\n CONTEXT_REQUIRED_NAMES,\n QUALITATIVE_VALUE_TERMS,\n UNAMBIGUOUS_SHORT_NAMES,\n} from './anchor-lexicon';\nimport {\n followedByPercent,\n hasGirthContext,\n qualifiedByBodyRegion,\n SKINFOLD_SITE_CODES,\n} from './body-region';\nimport { attachMethodVariants, recordAnchorLine } from './method-variant';\nimport { applyUrinalysisSection, type SectionDeps, sectionDepsFrom } from './urinalysis-section';\n\nexport interface AnchorMatch {\n code: string;\n confidence: number;\n loinc?: string;\n matchedName: string;\n /** A pista que escolheu o `methodLoinc`. Ver `method-variant.ts`. */\n methodCue?: string;\n /** O LOINC por método que o texto afirma; vem da varredura, nunca do modelo. */\n methodLoinc?: string;\n position: number;\n}\n\nexport interface AnchorResult {\n filteredReference: string;\n matches: AnchorMatch[];\n stats: {\n totalPatterns: number;\n matchedCount: number;\n scanTimeMs: number;\n };\n}\n\n/**\n * Confidence assigned to a specific biomarker name found on a line that also\n * carries a value (a number, a unit, or an expected qualitative term).\n */\nexport const CONFIDENCE_VALUE_ADJACENT = 1.0;\n\n/**\n * Confidence assigned to a specific biomarker name with no value evidence\n * nearby — a section heading, or a mention in prose.\n */\nexport const CONFIDENCE_NAME_ONLY = 0.7;\n\n/**\n * Confidence assigned to a generic/ambiguous name (`Color`, `Protein`,\n * `Blood`, …) that only anchored because a value was found next to it.\n */\nexport const CONFIDENCE_AMBIGUOUS = 0.4;\n\n/** Cap on how many occurrences of the same name are inspected per document. */\nconst MAX_OCCURRENCES_PER_NAME = 5;\n\n/**\n * Normalize text for comparison:\n * - Removes diacritics (ã→a, ç→c, é→e)\n * - Converts to lowercase\n * - Treats a hyphen that joins words as a space\n * - Collapses horizontal whitespace, but KEEPS line breaks — the line is the\n * context window used to decide whether a match is a real biomarker mention\n *\n * O hífen entre palavras vira espaço porque o catálogo e o laboratório\n * discordam sobre ele o tempo todo: o catálogo escreve \"Proteína C-Reativa\" e\n * \"High-Density Lipoprotein\", e os laudos imprimem \"Proteína C Reativa\" e\n * \"High Density Lipoprotein\". Sem essa equivalência, 82 dos 159 nomes com\n * hífen deixam de ancorar na grafia que o documento usa.\n *\n * Não era teórico: um GGT de verdade foi descartado como alucinação em 27\n * laudos porque o documento escrevia \"Gama glutamil transferase\" e o catálogo\n * \"Gama-Glutamil Transferase\". Um caractere derrubava o valor antes de\n * qualquer validação.\n *\n * A troca exige **letra antes** do hífen, e por isso não toca em número:\n * o `-2.5` de um T-score e o `0-5` de uma faixa de urina seguem intactos.\n * Trocar sem essa guarda apagaria o sinal de um valor negativo, que é bem\n * pior que o problema original.\n *\n * O pré-filtro de substring do `collectCandidates` compara a chave com o texto\n * já normalizado, então a equivalência precisa nascer aqui: aplicada só na\n * regex, o `includes` descartaria o nome antes de ela rodar.\n *\n * A vírgula entre palavras é tratada à parte, em `foldCommas`.\n */\nfunction normalizeBase(text: string): string {\n return text\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .toLowerCase()\n .replace(/(?<=\\p{L})-(?=[\\p{L}\\p{N}])/gu, ' ')\n .replace(/[^\\S\\n]+/g, ' ');\n}\n\nfunction normalize(text: string): string {\n return foldCommas(normalizeBase(text));\n}\n\n/** Nomes do catálogo já normalizados, indexados pela primeira palavra. */\nlet cachedNamesByHead: Map<string, string[]> | null = null;\n\nfunction isIndexedName(normalized: string): boolean {\n return normalized.length >= 3 || UNAMBIGUOUS_SHORT_NAMES.has(normalized);\n}\n\nfunction getNamesByHead(): Map<string, string[]> {\n if (!cachedNamesByHead) {\n const map = new Map<string, string[]>();\n for (const pattern of getPatterns()) {\n for (const name of pattern.names) {\n const normalized = normalizeBase(name).trim();\n if (!normalized || !isIndexedName(normalized)) {\n continue;\n }\n const head = normalized.split(' ')[0]!;\n map.set(head, [...(map.get(head) ?? []), normalized]);\n }\n }\n cachedNamesByHead = map;\n }\n return cachedNamesByHead;\n}\n\n/** Algum nome do catálogo começa, como palavra inteira, no início de `text`? */\nfunction startsWithCatalogName(text: string): boolean {\n const head = /^[\\p{L}\\p{N}]+/u.exec(text)?.[0];\n if (!head) {\n return false;\n }\n return (getNamesByHead().get(head) ?? []).some((name) => {\n if (!text.startsWith(name)) {\n return false;\n }\n const after = text.slice(name.length).replace(/^s/, '');\n return !/^[\\p{L}\\p{N}]/u.test(after);\n });\n}\n\n/**\n * A vírgula entre palavras vira espaço quando o que vem depois dela não é,\n * sozinho, um nome do catálogo.\n *\n * A Quest imprime o exame no formato \"EXAME, QUALIFICADOR\" (\"PSA, FREE\"), e o\n * catálogo nem sempre traz a grafia com vírgula. Sem a troca, \"psa, free\" não\n * casava \"PSA Free\" e o PSA total ancorava pelo \"psa\" solto.\n *\n * A guarda é a mesma da quebra de linha: numa lista \"Colesterol, HDL, LDL\",\n * cada item depois da vírgula é um exame próprio, e juntar daria \"Colesterol\n * HDL\" e perderia o colesterol total. Quando o catálogo precisa da vírgula\n * mesmo assim, ele lista a grafia com vírgula (\"Magnesium, RBC\"), e ela casa\n * literalmente, porque o nome do catálogo passa pela mesma regra.\n *\n * Só vale com letra dos dois lados, então a vírgula decimal (\"0,4\") fica\n * intacta.\n */\nfunction foldCommas(text: string): string {\n return text.replace(/(?<=\\p{L}),([^\\S\\n]*)(?=\\p{L})/gu, (comma, space: string, offset: number) =>\n startsWithCatalogName(text.slice(offset + 1 + space.length)) ? comma : ' ',\n );\n}\n\n/**\n * Signals that a line comes from a genetic/molecular report rather than from a\n * panel of measured values. Gene symbols collide with biomarker names (`APOB`\n * the gene vs. `ApoB` the lipoprotein), so the context — not a static HGNC\n * blocklist — is what tells them apart. Blocking the token itself would break\n * real lipid panels.\n */\nconst GENETIC_CONTEXT_PATTERNS: RegExp[] = [\n /\\b[nx][mrpc]_\\d{6,}/, // RefSeq: NM_000384.2, NP_, NR_, XM_\n /\\bens[gtp]\\d{6,}/, // Ensembl: ENSG00000084674\n /\\bp\\.[a-z]{3}\\d/, // HGVS proteína: p.Trp448*\n /\\bc\\.\\d+[acgt]?[>_+-]/, // HGVS codificante: c.1234A>G, c.76_78del\n /\\brs\\d{4,}\\b/, // dbSNP\n /\\bgenes?\\b/,\n /\\bvariante?s?\\b/,\n /\\bexons?\\b/,\n /\\bzygosity\\b/,\n /\\bzigosidade\\b/,\n /\\balleles?\\b/,\n /\\balelos?\\b/,\n /\\bmutations?\\b/,\n /\\bmutac(ao|oes)\\b/,\n /\\bpathogenic/,\n /\\bpatogenic/,\n /\\bheterozyg/,\n /\\bhomozyg/,\n /\\bheterozigot/,\n /\\bhomozigot/,\n /\\bsequence change\\b/,\n];\n\nconst DIGIT_PATTERN = /\\d/;\n\n/** Unit tokens reused from the core catalog instead of a parallel list. */\nlet cachedUnitTokens: Set<string> | null = null;\n\nfunction getUnitTokens(): Set<string> {\n if (!cachedUnitTokens) {\n cachedUnitTokens = new Set(\n Object.keys(UNIT_TO_UCUM)\n .map((unit) => normalize(unit).trim())\n .filter(Boolean),\n );\n }\n return cachedUnitTokens;\n}\n\ninterface PatternEntry {\n ambiguous: boolean;\n code: string;\n loinc?: string;\n original: string;\n}\n\ninterface NamePattern {\n entries: PatternEntry[];\n /** Built on first use — most names never match a given document. */\n regex: RegExp | null;\n}\n\ninterface Candidate {\n end: number;\n entries: PatternEntry[];\n start: number;\n}\n\nlet cachedPatterns: BiomarkerSearchPattern[] | null = null;\nlet cachedNamePatterns: Map<string, NamePattern> | null = null;\n\nfunction getPatterns(): BiomarkerSearchPattern[] {\n if (!cachedPatterns) {\n cachedPatterns = getAllSearchPatterns();\n }\n return cachedPatterns;\n}\n\nfunction escapeRegExp(text: string): string {\n return text.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n}\n\n/**\n * Build a whole-token matcher for a normalized name.\n *\n * Lookarounds instead of `\\b` because names may start or end with a non-word\n * character (`Lp(a)`), where `\\b` asserts the wrong thing.\n *\n * A multi-word name must sit on a single line: in the column layouts labs\n * print, consecutive lines are separate biomarkers, and allowing a line break\n * inside a name turns \"Colesterol\\nHDL\" into the name \"Colesterol HDL\".\n * A wrapped name still anchors through its head token when that token is a\n * name of its own (\"Colesterol\\nTotal\" → `Cholesterol`). A name whose head is\n * not a name of its own is recovered by `collectWrappedCandidates`.\n *\n * The trailing optional `s` keeps the plurals labs actually print\n * (\"Proteínas\", \"Cetonas\") anchored to the singular catalog name — without\n * letting `proteína` match inside `proteinúria`.\n */\nfunction buildNamePattern(normalizedName: string): RegExp {\n const body = normalizedName.split(' ').map(escapeRegExp).join('[^\\\\S\\\\n]+');\n const plural = /\\p{L}$/u.test(normalizedName) ? 's?' : '';\n return new RegExp(`(?<![\\\\p{L}\\\\p{N}])${body}${plural}(?![\\\\p{L}\\\\p{N}])`, 'gu');\n}\n\nfunction isQualitativeUrine(pattern: BiomarkerSearchPattern): boolean {\n const categories = Array.isArray(pattern.category) ? pattern.category : [pattern.category];\n return categories.includes('urina') && !pattern.unit;\n}\n\n/**\n * A name is ambiguous when it is a single token that also reads as ordinary\n * text. Multi-word names (`Occult Blood`, `Urine Protein`) are specific enough\n * on their own.\n */\nfunction isAmbiguousName(normalizedName: string, pattern: BiomarkerSearchPattern): boolean {\n if (normalizedName.includes(' ')) {\n return false;\n }\n return CONTEXT_REQUIRED_NAMES.has(normalizedName) || isQualitativeUrine(pattern);\n}\n\nfunction getNamePatterns(): Map<string, NamePattern> {\n if (!cachedNamePatterns) {\n const map = new Map<string, NamePattern>();\n for (const pattern of getPatterns()) {\n for (const name of pattern.names) {\n const normalized = normalize(name).trim();\n if (!normalized || !isIndexedName(normalized)) {\n continue;\n }\n let slot = map.get(normalized);\n if (!slot) {\n slot = { entries: [], regex: null };\n map.set(normalized, slot);\n }\n slot.entries.push({\n ambiguous: isAmbiguousName(normalized, pattern),\n code: pattern.code,\n ...(pattern.loinc && { loinc: pattern.loinc }),\n original: name,\n });\n }\n }\n cachedNamePatterns = map;\n }\n return cachedNamePatterns;\n}\n\nfunction getLineBounds(text: string, position: number): { end: number; start: number } {\n const start = text.lastIndexOf('\\n', position) + 1;\n const nextBreak = text.indexOf('\\n', position);\n return { end: nextBreak === -1 ? text.length : nextBreak, start };\n}\n\nfunction hasGeneticContext(line: string): boolean {\n return GENETIC_CONTEXT_PATTERNS.some((pattern) => pattern.test(line));\n}\n\n/**\n * Does this line carry something that looks like a measured result?\n * A digit, a known unit, or an expected qualitative term.\n */\nfunction hasValueEvidence(line: string): boolean {\n if (DIGIT_PATTERN.test(line)) {\n return true;\n }\n const unitTokens = getUnitTokens();\n for (const token of line.split(/[^\\p{L}\\p{N}%/]+/u)) {\n if (token && (unitTokens.has(token) || QUALITATIVE_VALUE_TERMS.has(token))) {\n return true;\n }\n }\n return false;\n}\n\n/**\n * Cheap pre-filter before the (much costlier) boundary regex.\n *\n * Sound because `normalize` collapses horizontal whitespace to a single space\n * and a name never spans a line break: whenever the pattern can match, the\n * literal name is a substring of the text.\n */\nfunction collectCandidates(normalizedText: string): Candidate[] {\n const candidates: Candidate[] = [];\n for (const [name, slot] of getNamePatterns()) {\n if (!normalizedText.includes(name)) {\n continue;\n }\n const { entries } = slot;\n const regex = (slot.regex ??= buildNamePattern(name));\n regex.lastIndex = 0;\n let occurrences = 0;\n let match = regex.exec(normalizedText);\n while (match !== null && occurrences < MAX_OCCURRENCES_PER_NAME) {\n candidates.push({ end: match.index + match[0].length, entries, start: match.index });\n occurrences += 1;\n match = regex.exec(normalizedText);\n }\n }\n return candidates;\n}\n\n/**\n * Junta cada quebra de linha, com o espaço em volta, num espaço só, e guarda\n * para cada caractere do texto juntado a posição dele no texto original.\n */\nfunction joinLines(text: string): { joined: string; offsets: number[] } {\n const parts: string[] = [];\n const offsets: number[] = [];\n let cursor = 0;\n for (const lineBreak of text.matchAll(/[^\\S\\n]*\\n[^\\S\\n]*/g)) {\n for (let i = cursor; i < lineBreak.index; i += 1) {\n offsets.push(i);\n }\n parts.push(text.slice(cursor, lineBreak.index), ' ');\n offsets.push(lineBreak.index + lineBreak[0].indexOf('\\n'));\n cursor = lineBreak.index + lineBreak[0].length;\n }\n for (let i = cursor; i < text.length; i += 1) {\n offsets.push(i);\n }\n parts.push(text.slice(cursor));\n return { joined: parts.join(''), offsets };\n}\n\n/**\n * Nomes quebrados em duas linhas.\n *\n * A Quest imprime \"TISSUE TRANSGLUTAMINASE\" numa linha e \"AB, IGA <1.0 U/mL\"\n * na seguinte. Linha a linha, nenhum nome do tTG aparece inteiro, e o \"IGA\"\n * solto da segunda linha ancorava a IgA total. Aqui o texto é lido também com\n * cada linha emendada na seguinte, e o casamento que atravessa a quebra entra\n * como candidato normal: o `resolveOverlaps` faz o nome longo engolir o\n * \"IGA\" que está dentro dele.\n *\n * Emendar linhas é o que a regra de uma linha por nome evita, então só vale\n * com três guardas. O nome atravessa exatamente uma quebra. O pedaço antes da\n * quebra não é, sozinho, um nome do catálogo: \"Colesterol\\nHDL\" são dois\n * exames na coluna, não \"Colesterol HDL\". E a primeira linha não traz valor,\n * porque uma linha com resultado já é uma linha completa.\n */\nfunction collectWrappedCandidates(normalizedText: string): Candidate[] {\n if (!normalizedText.includes('\\n')) {\n return [];\n }\n const { joined, offsets } = joinLines(normalizedText);\n const namePatterns = getNamePatterns();\n const candidates: Candidate[] = [];\n for (const [name, slot] of namePatterns) {\n if (!name.includes(' ') || !joined.includes(name)) {\n continue;\n }\n const regex = (slot.regex ??= buildNamePattern(name));\n regex.lastIndex = 0;\n let occurrences = 0;\n let match = regex.exec(joined);\n while (match !== null && occurrences < MAX_OCCURRENCES_PER_NAME) {\n const start = offsets[match.index]!;\n const end = offsets[match.index + match[0].length - 1]! + 1;\n const lineBreak = normalizedText.indexOf('\\n', start);\n const nextBreak = normalizedText.indexOf('\\n', lineBreak + 1);\n const wrapped = lineBreak !== -1 && lineBreak < end && (nextBreak === -1 || nextBreak >= end);\n if (\n wrapped &&\n !namePatterns.has(normalizedText.slice(start, lineBreak).trim()) &&\n !hasValueEvidence(\n normalizedText.slice(getLineBounds(normalizedText, start).start, lineBreak),\n )\n ) {\n candidates.push({ end, entries: slot.entries, start });\n occurrences += 1;\n }\n match = regex.exec(joined);\n }\n }\n return candidates;\n}\n\n/**\n * Longest match wins: drop a match fully contained in a longer one, so\n * `Cholesterol` doesn't anchor inside `HDL Cholesterol` and `Blood` doesn't\n * anchor inside `Blood Glucose`.\n *\n * Strictly longer, not longer-or-equal: containment plus equal length means an\n * identical span, which only happens when two distinct catalog names match the\n * same text (a singular and its plural form, say). Dropping one of those by\n * catalog order would silently lose a code, and losing an anchor is worse than\n * keeping both — `findBiomarkersInText` dedups per code anyway.\n */\nfunction resolveOverlaps(candidates: Candidate[]): Candidate[] {\n const sorted = [...candidates].sort(\n (a, b) => b.end - b.start - (a.end - a.start) || a.start - b.start,\n );\n const accepted: Candidate[] = [];\n for (const candidate of sorted) {\n const length = candidate.end - candidate.start;\n const swallowed = accepted.some(\n (other) =>\n other.start <= candidate.start &&\n candidate.end <= other.end &&\n other.end - other.start > length,\n );\n if (!swallowed) {\n accepted.push(candidate);\n }\n }\n return accepted;\n}\n\n/** As peças do `anchor.ts` que a regra de seção usa. Ver `urinalysis-section.ts`. */\nlet cachedSectionDeps: SectionDeps | null = null;\nconst getSectionDeps = (): SectionDeps =>\n (cachedSectionDeps ??= sectionDepsFrom(\n getPatterns(),\n buildNamePattern,\n hasValueEvidence,\n resolveOverlaps,\n ));\n\ninterface LineContext {\n genetic: boolean;\n girth: boolean;\n hasValue: boolean;\n}\n\n/**\n * Find all biomarker names present in OCR text.\n *\n * Matching is whole-token, longest-match-wins, and context-aware: matches\n * inside genetic report lines are discarded, and generic names only anchor\n * when a value sits on the same line. Returns one match per biomarker code —\n * the highest-confidence occurrence.\n */\nexport function findBiomarkersInText(ocrText: string): AnchorResult {\n const startTime = Date.now();\n const normalizedText = normalize(ocrText);\n const bestByCode = new Map<string, AnchorMatch>();\n const contexts = new Map<string, LineContext>();\n const anchoredLineStarts = new Map<number, Set<string>>(); // ver `attachMethodVariants`\n const candidates = [\n ...collectCandidates(normalizedText),\n ...collectWrappedCandidates(normalizedText),\n ];\n\n for (const candidate of applyUrinalysisSection(normalizedText, candidates, getSectionDeps())) {\n // O contexto é a linha do nome, ou as duas linhas de um nome quebrado.\n const lineStart = getLineBounds(normalizedText, candidate.start).start;\n const lineEnd = getLineBounds(normalizedText, candidate.end - 1).end;\n const key = `${lineStart}:${lineEnd}`;\n let context = contexts.get(key);\n if (!context) {\n const line = normalizedText.slice(lineStart, lineEnd);\n context = {\n genetic: hasGeneticContext(line),\n girth: hasGirthContext(line),\n hasValue: hasValueEvidence(line),\n };\n contexts.set(key, context);\n }\n const { genetic, girth, hasValue } = context;\n if (genetic) {\n continue;\n }\n\n recordAnchorLine(anchoredLineStarts, lineStart, candidate.entries);\n\n const before = normalizedText.slice(lineStart, candidate.start);\n const after = normalizedText.slice(candidate.end, lineEnd);\n for (const entry of candidate.entries) {\n if (entry.ambiguous && !hasValue) {\n continue;\n }\n\n if (qualifiedByBodyRegion(entry.code, before) || followedByPercent(entry.code, after)) {\n continue;\n }\n\n if (girth && SKINFOLD_SITE_CODES.has(entry.code)) {\n continue;\n }\n\n let confidence = CONFIDENCE_NAME_ONLY;\n if (entry.ambiguous) {\n confidence = CONFIDENCE_AMBIGUOUS;\n } else if (hasValue) {\n confidence = CONFIDENCE_VALUE_ADJACENT;\n }\n\n const existing = bestByCode.get(entry.code);\n const better =\n !existing ||\n confidence > existing.confidence ||\n (confidence === existing.confidence && candidate.start < existing.position);\n if (better) {\n bestByCode.set(entry.code, {\n code: entry.code,\n confidence,\n loinc: entry.loinc,\n matchedName: entry.original,\n position: candidate.start,\n });\n }\n }\n }\n\n attachMethodVariants(bestByCode.values(), normalizedText, anchoredLineStarts, (text, cue) =>\n buildNamePattern(normalize(cue).trim()).test(text),\n );\n\n const matches = Array.from(bestByCode.values()).sort((a, b) => a.position - b.position);\n const scanTimeMs = Date.now() - startTime;\n\n return {\n filteredReference: generateFilteredLLMReference(matches.map((m) => m.code)),\n matches,\n stats: {\n matchedCount: matches.length,\n scanTimeMs,\n totalPatterns: getPatterns().length,\n },\n };\n}\n\n/**\n * Get the list of matched biomarker codes from an anchor result.\n */\nexport function getMatchedCodes(result: AnchorResult): string[] {\n return result.matches.map((m) => m.code);\n}\n","/**\n * Vocabulário do pré-scan de âncoras: listas de termos que decidem se um nome\n * curto ou genérico pode ancorar sozinho. Separado do `anchor.ts` só para\n * manter o arquivo do algoritmo legível.\n */\n\n/** Siglas curtas liberadas do corte de tamanho mínimo do `getNamePatterns`. */\nexport const UNAMBIGUOUS_SHORT_NAMES = new Set([\n 'hdl',\n 'ldl',\n 'lh',\n 'tsh',\n 'crp',\n 'pcr',\n 'ggt',\n 'alt',\n 'ast',\n 'bun',\n 'wbc',\n 'rbc',\n 'mcv',\n 'mch',\n 'rdw',\n 'mpv',\n 'psa',\n 'fsh',\n 'hba1c',\n 'egfr',\n 'acr',\n 'esr',\n 'vhs',\n 'bmc',\n 'bmd',\n 'vat',\n 'dxa',\n 'dmo',\n 'cmo',\n 'ffm',\n 'lbm',\n 'mlg',\n 'tav',\n]);\n\n/**\n * Single-word catalog names that are ordinary words in EN/PT, so seeing them\n * proves nothing on its own. They only anchor when the line also carries a\n * value. Qualitative urine markers (`Color`, `Protein`, `Blood`, …) are\n * detected automatically — see `isQualitativeUrine` — and don't belong here.\n */\nexport const CONTEXT_REQUIRED_NAMES = new Set([\n 'bacteria', // Bacteria_Urine — tem unidade, escapa da regra automática\n 'bacterias', // Bacteria_Urine\n 'lead', // Lead — verbo/substantivo comuníssimo em inglês\n 'peso', // TotalMass\n 'saturation', // TransferrinSaturation — \"oxygen saturation\", \"saturation index\"\n 'tap', // ProthrombinTime — \"tap\" em inglês\n 'volume', // VATVolume\n 'weight', // TotalMass\n // Sítios de dobra pelo nome nu. São partes do corpo antes de serem medidas,\n // e aparecem em prosa: num laudo de DEXA real, \"hips and thighs\" e\n // \"abdominal region\" ancoravam dobra cutânea que o documento não tem.\n // Exigir valor na linha separa a tabela do parágrafo.\n 'abdominal',\n 'chest',\n 'coxa',\n 'peitoral',\n 'subescapular',\n 'subscapular',\n 'suprailiac',\n 'thigh',\n 'triceps',\n 'tricipital',\n]);\n\n/**\n * Qualitative results expected next to a non-numeric biomarker\n * (urine dipstick, sediment, appearance). Normalized, single tokens —\n * \"não reagente\" is covered by `reagente`, \"não detectado\" by `detectado`.\n */\nexport const QUALITATIVE_VALUE_TERMS = new Set([\n 'absent',\n 'alguns',\n 'amarela',\n 'amarelo',\n 'anormal',\n 'ausencia',\n 'ausente',\n 'ausentes',\n 'citrino',\n 'claro',\n 'clear',\n 'cloudy',\n 'colorless',\n 'detectado',\n 'detected',\n 'escuro',\n 'incolor',\n 'indetectavel',\n 'limpido',\n 'moderada',\n 'moderado',\n 'negativa',\n 'negative',\n 'negativo',\n 'normais',\n 'normal',\n 'numerosos',\n 'ocasional',\n 'positiva',\n 'positive',\n 'positivo',\n 'present',\n 'presente',\n 'presentes',\n 'raras',\n 'raro',\n 'raros',\n 'reagente',\n 'trace',\n 'traces',\n 'tracos',\n 'turvo',\n 'undetectable',\n 'yellow',\n]);\n","/**\n * Desambiguação por região do corpo: o qualificador de região antes de um nome\n * de composição corporal, e a dobra cutânea que na verdade é circunferência.\n * Separado do `anchor.ts` pelo mesmo motivo do `anchor-lexicon.ts`: manter o\n * arquivo do algoritmo legível.\n */\n\n/**\n * Medidas de corpo inteiro da composição corporal, cujo nome genérico (\"Fat\n * Mass\", \"Total Mass\", \"Lean Mass\") reaparece dentro do rótulo de uma região.\n *\n * Na tabela de equilíbrio muscular do DEXA as linhas se chamam \"Arms Total\",\n * \"Right Arm\" e \"Arms Difference\", e o modelo devolve \"Arms Difference Fat\n * Mass\" ou \"Arms Total Mass\". Sem guarda, o \"fat mass\" de dentro desses rótulos\n * ancorava `FatMass`, que é a gordura do corpo todo.\n */\nconst WHOLE_BODY_COMPOSITION_CODES = new Set([\n 'BMC',\n 'BodyFatPct',\n 'FatFreeMass',\n 'FatMass',\n 'LeanMass',\n 'TotalMass',\n]);\n\n/**\n * Códigos que medem os dois membros somados. Precedidos de um lado só (\"Right\n * Arm Fat Mass\"), o rótulo fala de um braço, e o código, dos dois.\n */\nconst LIMB_PAIR_CODES = new Set(['ArmsFatMass', 'ArmsLeanMass', 'LegsFatMass', 'LegsLeanMass']);\n\n/** Região do corpo, normalizada (sem acento, minúscula). */\nconst BODY_REGION = String.raw`(?:arms?|legs?|trunk|head|android|gynoid|bracos?|pernas?|tronco|cabeca|androide|ginoide)`;\n\n/** Lado do corpo, normalizado. */\nconst BODY_SIDE = String.raw`(?:right|left|direit[oa]|esquerd[oa])`;\n\n/**\n * Texto que termina numa região do corpo, com qualificadores de linha da\n * tabela no meio (\"arms \", \"arms total \", \"right arm \", \"arms difference \").\n * Testado contra o trecho da linha que vem antes do nome casado.\n */\nconst ENDS_WITH_BODY_REGION = new RegExp(\n String.raw`(?<![\\p{L}\\p{N}])${BODY_REGION}(?: (?:total|difference|diferenca|${BODY_SIDE}))* ?$`,\n 'u',\n);\n\n/** Texto que termina num lado do corpo (\"right \", \"left \"). */\nconst ENDS_WITH_BODY_SIDE = new RegExp(String.raw`(?<![\\p{L}\\p{N}])${BODY_SIDE} ?$`, 'u');\n\n/**\n * O nome casou dentro do rótulo de uma região ou de um lado do corpo, e o\n * código mede outra coisa.\n *\n * Na densitometria, \"Fat Mass\" é a gordura do corpo todo, e \"Arms Difference\n * Fat Mass\" é a diferença entre os braços: o qualificador vem antes do nome, e\n * o casamento por palavra inteira não o enxerga. Do mesmo jeito, \"Arm Fat\n * Mass\" é sinônimo de `ArmsFatMass`, os dois braços, e em \"Right Arm Fat Mass\"\n * o rótulo é de um braço só.\n *\n * Só olha o que vem imediatamente antes do nome, na mesma linha. Uma região\n * em outro ponto da linha (o cabeçalho de uma tabela, uma coluna vizinha) não\n * qualifica o nome.\n */\nexport function qualifiedByBodyRegion(code: string, before: string): boolean {\n if (WHOLE_BODY_COMPOSITION_CODES.has(code)) {\n return ENDS_WITH_BODY_REGION.test(before);\n }\n if (LIMB_PAIR_CODES.has(code)) {\n return ENDS_WITH_BODY_SIDE.test(before);\n }\n return false;\n}\n\n/** Medidas de massa do corpo inteiro: as de `WHOLE_BODY_COMPOSITION_CODES` menos o percentual. */\nconst WHOLE_BODY_MASS_CODES = new Set(\n [...WHOLE_BODY_COMPOSITION_CODES].filter((code) => code !== 'BodyFatPct'),\n);\n\n/**\n * Texto que começa num percentual: o sinal sozinho, entre parênteses, ou\n * colado a um número (\"%\", \"(%)\", \"23.1%\", \"23,1 %\").\n */\nconst STARTS_WITH_PERCENT = /^ ?(?:\\( ?% ?\\)|%|[-+]?\\d+(?:[.,]\\d+)? ?%)/;\n\n/**\n * O nome de uma massa do corpo inteiro casou, e o que vem logo depois dele é\n * um percentual.\n *\n * A tabela de tendência da densitometria Live Lean tem duas colunas \"Total\n * Fat\", uma em \"(%)\" e outra em \"(lbs)\", e só a segunda é a massa de gordura.\n * A citação do percentual chega como \"Total Fat 23.1%\", e sem esta guarda o\n * \"Total Fat\" ancorava `FatMass` nela. O percentual tem nome próprio no\n * catálogo quando o laudo o escreve colado ao rótulo (\"Total Fat %\", \"Total\n * Fat (%)\"), e o nome mais longo já ganha; esta guarda cobre a citação em que o\n * sinal só aparece no número.\n *\n * Só olha o que vem imediatamente depois do nome, na mesma linha.\n */\nexport function followedByPercent(code: string, after: string): boolean {\n return WHOLE_BODY_MASS_CODES.has(code) && STARTS_WITH_PERCENT.test(after);\n}\n\n/**\n * Sítios de dobra cutânea cujo nome nu também nomeia uma circunferência:\n * \"Coxa\" aparece tanto em \"Dobra Cutânea Coxa\" quanto em \"Circunferência da\n * Coxa\". O termo nu precisa existir como alias, porque há laudo que imprime\n * só o sítio na coluna, então a desambiguação tem que vir do contexto da\n * linha, como já se faz com laudo genético.\n */\nexport const SKINFOLD_SITE_CODES = new Set([\n 'SkinfoldAbdominal',\n 'SkinfoldChest',\n 'SkinfoldMidaxillary',\n 'SkinfoldSubscapular',\n 'SkinfoldSuprailiac',\n 'SkinfoldThigh',\n 'SkinfoldTriceps',\n]);\n\n/** Uma linha de circunferência ou perímetro não mede dobra. */\nconst GIRTH_CONTEXT_PATTERNS: RegExp[] = [\n /\\bcircumference\\b/,\n /\\bcircunferencias?\\b/,\n /\\bperimetros?\\b/,\n /\\bgirth\\b/,\n];\n\n/**\n * Só bloqueia quando a linha fala de circunferência e não fala de dobra:\n * \"Dobra Cutânea Coxa\" e \"Thigh Skinfold\" continuam ancorando normalmente,\n * e uma linha que traga as duas palavras é ambígua demais para descartar.\n */\nconst SKINFOLD_CONTEXT_PATTERNS: RegExp[] = [/\\bdobras?\\b/, /\\bskin ?folds?\\b/, /\\bpregas?\\b/];\n\n/**\n * Medida em centímetros numa linha de sítio corporal.\n *\n * Dobra cutânea é em milímetros, sempre: um valor em cm no mesmo sítio é\n * circunferência. É o desambiguador mais forte que existe aqui, porque não\n * depende de a folha escrever a palavra \"circunferência\", e num laudo de\n * antropometria a coluna costuma trazer só o sítio e o número.\n *\n * Rejeita cm em vez de exigir mm: há folha que imprime a unidade no cabeçalho\n * da coluna e não em cada linha, e exigir mm perderia essas.\n */\nconst CENTIMETRE_VALUE = /\\d\\s*(?:,\\d+\\s*)?cm\\b/;\n\nexport function hasGirthContext(line: string): boolean {\n if (SKINFOLD_CONTEXT_PATTERNS.some((re) => re.test(line))) {\n return false;\n }\n return GIRTH_CONTEXT_PATTERNS.some((re) => re.test(line)) || CENTIMETRE_VALUE.test(line);\n}\n","/**\n * O método impresso no laudo escolhe o código LOINC (PRE-465).\n *\n * Roda depois da ancoragem, sobre o melhor casamento de cada código. Só os\n * biomarcadores com `methodVariants` de pista declarada são examinados, e a\n * decisão é da varredura: o modelo não escolhe método.\n */\nimport { getDefinitionByCode } from '@precisa-saude/fhir';\n\nimport type { AnchorMatch } from './anchor';\n\n/** Quantas linhas depois do exame a pista de método ainda pertence a ele. */\nconst METHOD_CUE_LINES_BELOW = 6;\n\n/** `true` quando `cue` aparece em `text` por token inteiro, como os nomes. */\nexport type CueMatcher = (text: string, cue: string) => boolean;\n\ninterface Line {\n end: number;\n start: number;\n}\n\n/**\n * A variante por método que o texto afirma para um exame ancorado.\n *\n * Lê a linha do exame, a de cima e as seguintes até o próximo exame ancorado,\n * no máximo `METHOD_CUE_LINES_BELOW` linhas. As seguintes contam porque a pista\n * que existe em laudo real é rodapé: um laboratório imprime, abaixo do LDL, que\n * o valor foi calculado por Martin-Hopkins. Parar no próximo exame impede que o\n * método de um vire o do outro.\n *\n * Pistas de duas variantes no mesmo trecho deixam o exame sem método, porque\n * escolher uma seria chute.\n */\nfunction findMethodVariant(\n code: string,\n position: number,\n lines: Line[],\n otherAnchorLines: Set<number>,\n normalizedText: string,\n matchesCue: CueMatcher,\n): { cue: string; loinc: string } | undefined {\n const variants = getDefinitionByCode(code)?.methodVariants?.filter(\n (v) => v.cues.en.length > 0 || v.cues.pt.length > 0,\n );\n if (!variants || variants.length === 0) return undefined;\n\n const index = lines.findIndex((l) => l.start <= position && position <= l.end);\n if (index === -1) return undefined;\n const window: number[] = [];\n if (index > 0 && !otherAnchorLines.has(index - 1)) window.push(index - 1);\n window.push(index);\n for (let i = index + 1; i < lines.length && i <= index + METHOD_CUE_LINES_BELOW; i += 1) {\n if (otherAnchorLines.has(i)) break;\n window.push(i);\n }\n const text = window.map((i) => normalizedText.slice(lines[i]!.start, lines[i]!.end)).join('\\n');\n\n const found = variants.flatMap((variant) => {\n const cue = [...variant.cues.pt, ...variant.cues.en].find((c) => matchesCue(text, c));\n return cue ? [{ cue, loinc: variant.loinc }] : [];\n });\n return found.length === 1 ? found[0] : undefined;\n}\n\n/** Registra os códigos que ancoraram na linha que começa em `lineStart`. */\nexport function recordAnchorLine(\n anchoredLineStarts: Map<number, Set<string>>,\n lineStart: number,\n entries: { code: string }[],\n): void {\n const codes = anchoredLineStarts.get(lineStart) ?? new Set<string>();\n for (const entry of entries) codes.add(entry.code);\n anchoredLineStarts.set(lineStart, codes);\n}\n\n/**\n * Preenche `methodLoinc` e `methodCue` nos casamentos cujo texto afirma o método.\n *\n * `anchoredLineStarts` diz em que linha cada código ancorou, para a pista não\n * atravessar de um exame para o seguinte.\n */\nexport function attachMethodVariants(\n matches: Iterable<AnchorMatch>,\n normalizedText: string,\n anchoredLineStarts: Map<number, Set<string>>,\n matchesCue: CueMatcher,\n): void {\n const lines: Line[] = [];\n for (let start = 0; start <= normalizedText.length; ) {\n const end = normalizedText.indexOf('\\n', start);\n lines.push({ end: end === -1 ? normalizedText.length : end, start });\n if (end === -1) break;\n start = end + 1;\n }\n for (const match of matches) {\n const otherAnchorLines = new Set<number>();\n lines.forEach((line, i) => {\n const codes = anchoredLineStarts.get(line.start);\n if (codes && [...codes].some((c) => c !== match.code)) otherAnchorLines.add(i);\n });\n const variant = findMethodVariant(\n match.code,\n match.position,\n lines,\n otherAnchorLines,\n normalizedText,\n matchesCue,\n );\n if (variant) {\n match.methodLoinc = variant.loinc;\n match.methodCue = variant.cue;\n }\n }\n}\n","import type { BiomarkerSearchPattern } from '@precisa-saude/fhir';\n\n/**\n * Contexto de seção da urinálise.\n *\n * O exame de urina imprime o analito pelo nome nu: \"GLUCOSE\", \"PH\", \"WBC\",\n * \"RBC\", \"PROTEIN\". Fora de contexto esses nomes são do sangue (a glicose do\n * soro, o leucograma, o eritrograma), e o catálogo os guarda assim, então a\n * linha \"GLUCOSE NEGATIVE\" de um painel de urina ancorava a glicose do soro, e\n * a glicose da urina que o modelo devolvia era recusada como não ancorada.\n * Trocar o sentido do nome no catálogo quebraria o soro. O que decide é a\n * seção: debaixo do cabeçalho da urinálise, o nome nu é da urina.\n *\n * Separado do `anchor.ts` pelo mesmo motivo do `body-region.ts`: manter o\n * arquivo do algoritmo legível. O `anchor.ts` injeta o casamento de nomes e a\n * resolução de sobreposição (`SectionDeps`); aqui ficam o vocabulário, a\n * máquina de estados que delimita a seção e a troca dos candidatos.\n */\n\n/**\n * Cabeçalhos que abrem uma seção de urinálise, normalizados (sem acento,\n * minúsculos). Só grafias que o repositório já mostra: \"URINALYSIS\" é o nome\n * do painel no laudo da Quest; \"Urina tipo I\" está nos testes de material do\n * `core`; \"Rotina de urina\" e \"EAS\" estão no ValueSet TUSS do IG\n * (`BRTUSSProcedimentosLabVS.fsh`); \"Urinálise\" é o nome do grupo no mesmo\n * ValueSet. O cabeçalho precisa abrir a linha, e o resto da linha é\n * qualificador (\"URINALYSIS, COMPLETE W/REFLEX TO CULTURE\").\n */\nconst HEADER_NAMES = String.raw`(?:urinalysis|urinalise|urina tipo i|rotina de urina|eas)(?![\\p{L}\\p{N}])`;\nconst URINALYSIS_HEADER = new RegExp(`^${HEADER_NAMES}`, 'u');\nconst ANY_HEADER = new RegExp(String.raw`(?:^|\\n)[^\\S\\n]*` + HEADER_NAMES, 'u');\n\n/** Atalho barato: sem cabeçalho de urinálise no texto, não há seção. */\nexport function mentionsUrinalysisHeader(normalizedText: string): boolean {\n return ANY_HEADER.test(normalizedText);\n}\n\n/**\n * Nomes nus que, dentro da seção, são o analito da urina. Normalizados; o\n * plural impresso (\"Cetonas\", \"Bactérias\") vem do `s?` do casamento.\n *\n * As grafias em português são os mesmos analitos nos laudos brasileiros\n * (\"Glicose\", \"Leucócitos\", \"Hemácias\"), que o catálogo também guarda como\n * nomes do sangue. Os que já são nomes da urina no catálogo (`Color`,\n * `Ketones`, …) entram para ancorar mesmo sem valor na linha, que é o caso do\n * texto em colunas, em que o nome e o resultado não dividem a linha.\n */\nexport const URINALYSIS_SECTION_NAMES: ReadonlyMap<string, string> = new Map([\n ['bacteria', 'Bacteria_Urine'],\n ['bacterias', 'Bacteria_Urine'],\n ['bilirrubina', 'Bilirubin_Urine'],\n ['bilirubin', 'Bilirubin_Urine'],\n ['cetonas', 'Ketones_Urine'],\n ['color', 'Color_Urine'],\n ['cor', 'Color_Urine'],\n ['glicose', 'Glucose_Urine'],\n ['glucose', 'Glucose_Urine'],\n ['hemacias', 'RBC_Urine'],\n ['ketones', 'Ketones_Urine'],\n ['leucocitos', 'Leukocytes_Urine'],\n ['nitrite', 'Nitrite_Urine'],\n ['nitrito', 'Nitrite_Urine'],\n ['ph', 'pH_Urine'],\n ['protein', 'Protein_Urine'],\n ['proteina', 'Protein_Urine'],\n ['rbc', 'RBC_Urine'],\n ['wbc', 'Leukocytes_Urine'],\n]);\n\n/** O nome casado (talvez no plural) é um nome nu da seção? */\nexport function isSectionName(matched: string): boolean {\n return (\n URINALYSIS_SECTION_NAMES.has(matched) || URINALYSIS_SECTION_NAMES.has(matched.replace(/s$/, ''))\n );\n}\n\n/** O que se sabe de cada linha do texto normalizado. */\nexport interface SectionLine {\n /**\n * A linha casa um exame que não é da urina nem nome nu da seção\n * (\"CREATININE\", \"WHITE BLOOD CELL COUNT\"): é outro painel.\n */\n foreign: boolean;\n hasValue: boolean;\n /** A linha casa algum nome do catálogo ou algum nome nu da seção. */\n known: boolean;\n text: string;\n}\n\nconst MENTIONS_URINE = /(?<![\\p{L}\\p{N}])urin[ae](?![\\p{L}\\p{N}])/u;\n\n/**\n * Índices das linhas que estão dentro de uma seção de urinálise. A linha do\n * cabeçalho não entra.\n *\n * A seção começa no cabeçalho e termina na primeira linha que pareça o começo\n * de outra coisa. Laudo não marca o fim de seção, então o fim é inferido, e o\n * erro é empurrado para o lado barato: na dúvida a seção acaba, e o nome nu\n * volta ao sentido do sangue, que é o comportamento de antes desta regra.\n * Acaba em:\n *\n * - uma linha com exame de outro painel (`foreign`);\n * - uma linha sem valor e sem nome conhecido, que é o jeito de um cabeçalho\n * (\"COMPREHENSIVE METABOLIC PANEL\", \"Bioquímica\"). Sem esta, a \"GLUCOSE\" do\n * painel seguinte viraria glicose da urina. Linha que fala de urina (\"Material:\n * Urina\") não encerra;\n * - o fim do texto.\n *\n * Linha em branco não encerra nem conta. O limite conhecido: uma linha de\n * sedimento que o catálogo não tem e que chega sem valor (\"MUCUS\" sozinho, no\n * texto em colunas) também encerra, e o que vem depois dela volta ao sentido\n * de fora da seção.\n */\nexport function urinalysisLineIndexes(lines: readonly SectionLine[]): Set<number> {\n const inside = new Set<number>();\n let open = false;\n lines.forEach((line, index) => {\n const text = line.text.trim();\n if (URINALYSIS_HEADER.test(text) && !/\\d/.test(text)) {\n open = true;\n return;\n }\n if (!open || !text) {\n return;\n }\n if (line.foreign || (!line.known && !line.hasValue && !MENTIONS_URINE.test(text))) {\n open = false;\n return;\n }\n inside.add(index);\n });\n return inside;\n}\n\n/** A entrada de um nome casado, com a mesma forma da `PatternEntry` do `anchor.ts`. */\nexport interface SectionEntry {\n ambiguous: boolean;\n code: string;\n loinc?: string;\n original: string;\n}\n\n/** Um casamento de nome: o trecho no texto normalizado e as entradas dele. */\nexport interface SectionCandidate {\n end: number;\n entries: SectionEntry[];\n start: number;\n}\n\ninterface SectionPattern {\n entry: SectionEntry;\n name: string;\n regex: RegExp;\n}\n\n/**\n * O que a regra precisa do `anchor.ts`, injetado para este arquivo não\n * depender do algoritmo de casamento (e o `anchor.ts` não crescer).\n */\nexport interface SectionDeps {\n hasValue: (line: string) => boolean;\n patterns: SectionPattern[];\n resolve: (candidates: SectionCandidate[]) => SectionCandidate[];\n urineCodes: ReadonlySet<string>;\n}\n\n/** Monta as dependências a partir do catálogo: os códigos da urina saem da categoria. */\nexport function sectionDepsFrom(\n catalog: readonly BiomarkerSearchPattern[],\n buildPattern: (normalizedName: string) => RegExp,\n hasValue: SectionDeps['hasValue'],\n resolve: SectionDeps['resolve'],\n): SectionDeps {\n const loincByCode = new Map(catalog.map((p) => [p.code, p.loinc]));\n const isUrine = (p: BiomarkerSearchPattern) =>\n (Array.isArray(p.category) ? p.category : [p.category]).includes('urina');\n return {\n hasValue,\n patterns: [...URINALYSIS_SECTION_NAMES].map(([name, code]) => {\n const loinc = loincByCode.get(code);\n const entry = { ambiguous: false, code, ...(loinc && { loinc }), original: name };\n return { entry, name, regex: buildPattern(name) };\n }),\n resolve,\n urineCodes: new Set(catalog.filter(isUrine).map((p) => p.code)),\n };\n}\n\nfunction* matchesIn(text: string, name: string, regex: RegExp): Generator<RegExpExecArray> {\n if (!text.includes(name)) return;\n regex.lastIndex = 0;\n for (let match = regex.exec(text); match !== null; match = regex.exec(text)) {\n yield match;\n }\n}\n\n/**\n * Resolve os candidatos, aplicando a regra de seção quando há cabeçalho de\n * urinálise no texto.\n *\n * O candidato da seção entra no lugar do candidato comum de mesmo trecho, e\n * não ao lado dele: \"GLUCOSE\" na urinálise ancora `Glucose_Urine`, e a glicose\n * do soro não ancora pela mesma linha. Um nome mais longo continua ganhando\n * (\"URINE GLUCOSE\", \"OCCULT BLOOD\"), porque o resultado passa de novo pelo\n * `resolve`. Sem cabeçalho, é só o `resolve`, e nada muda.\n */\nexport function applyUrinalysisSection(\n normalizedText: string,\n candidates: SectionCandidate[],\n deps: SectionDeps,\n): SectionCandidate[] {\n const resolved = deps.resolve(candidates);\n if (!mentionsUrinalysisHeader(normalizedText)) {\n return resolved;\n }\n const lineStarts = [0];\n for (let i = normalizedText.indexOf('\\n'); i !== -1; i = normalizedText.indexOf('\\n', i + 1)) {\n lineStarts.push(i + 1);\n }\n const lines: SectionLine[] = lineStarts.map((start, index) => {\n const end = index + 1 < lineStarts.length ? lineStarts[index + 1]! - 1 : normalizedText.length;\n const text = normalizedText.slice(start, end);\n const known = deps.patterns.some(\n ({ name, regex }) => !matchesIn(text, name, regex).next().done,\n );\n return { foreign: false, hasValue: deps.hasValue(text), known, text };\n });\n for (const candidate of resolved) {\n let index = lineStarts.length - 1;\n while (lineStarts[index]! > candidate.start) index -= 1;\n const line = lines[index]!;\n line.known = true;\n const matched = normalizedText.slice(candidate.start, candidate.end);\n if (!isSectionName(matched) && !candidate.entries.some((e) => deps.urineCodes.has(e.code))) {\n line.foreign = true;\n }\n }\n const inside = urinalysisLineIndexes(lines);\n if (!inside.size) {\n return resolved;\n }\n\n const sectionCandidates: SectionCandidate[] = [];\n for (const index of inside) {\n const start = lineStarts[index]!;\n for (const { entry, name, regex } of deps.patterns) {\n for (const match of matchesIn(lines[index]!.text, name, regex)) {\n const at = start + match.index;\n sectionCandidates.push({ end: at + match[0].length, entries: [entry], start: at });\n }\n }\n }\n const replaced = (c: SectionCandidate) =>\n sectionCandidates.some((s) => s.start === c.start && s.end === c.end);\n return deps.resolve([...candidates.filter((c) => !replaced(c)), ...sectionCandidates]);\n}\n","/**\n * Contrato de saída para extração de laudo por modelo.\n *\n * Este é o **contrato de interoperabilidade**, e não um prompt. Ele descreve a\n * forma do JSON que qualquer modelo precisa devolver para o resto do toolkit\n * conseguir conferir e converter o resultado. Não diz como pedir isso ao\n * modelo, não traz instrução de comportamento e não depende de fornecedor:\n * quem usa liga do jeito que a plataforma dele permitir (saída estruturada,\n * gramática, tool use ou simples prompt com validação por cima).\n *\n * As descrições são deliberadamente neutras. Regra de comportamento (\"nunca\n * infira\", \"copie literalmente\") é ajuste de prompt, muda de modelo para\n * modelo e não pertence a um contrato público.\n *\n * As descrições são as únicas strings em inglês do pacote, e isso é\n * deliberado: o schema é contrato de integração lido por quem consome de fora\n * do Brasil, e uma descrição em pt-BR não ajuda ninguém em Colônia ou Madri.\n * O resto da documentação segue a regra do ecossistema.\n *\n * O campo `sourceText` existe porque é o que torna a conferência possível:\n * sem o trecho que originou o valor não dá para auditar a extração depois.\n *\n * A descrição dele diz \"linha\" e não \"trecho\" porque \"trecho\" é largo demais:\n * num laudo da Quest o `qwen3-4b-2507` citou a nota de rodapé inteira do ANA\n * SCREEN, oito linhas de explicação, e quem confere recebeu um parágrafo aceso\n * no lugar da linha do resultado. A citação continua sendo do documento, só\n * que grande demais para servir de referência.\n *\n * `collectionDate` e `laboratoryName` moram no topo porque valem para o laudo\n * inteiro, e não para uma medida. Sem a data não sai Bundle FHIR: o mapeador\n * recusa montar um sem ela, então um contrato que não pede a data entrega\n * biomarcador que não vira recurso.\n *\n * O `loinc` é obrigatório apesar de aceitar `null`. Opcional, ele some: num\n * laudo da Labcorp o `granite-4.1-8b` leu os cinco exames certos e devolveu\n * todos sem o campo, e a conferência recusou os cinco. Obrigatório, o modelo\n * precisa decidir e responder `null` quando nenhum código serve, que é uma\n * resposta auditável em vez de um silêncio. Sem isto, qual modelo funciona\n * depende de o modelo lembrar de preencher campo opcional.\n *\n * `referenceMin` e `referenceMax` são obrigatórios pelo mesmo motivo do\n * `loinc`, e o erro que eles deixavam passar era pior que o silêncio. Num laudo\n * da Labcorp, com a faixa impressa na mesma linha, `ministral-3-8b` e\n * `granite-4.1-8b` devolveram o **valor medido** no lugar do limite superior\n * sempre que o resultado encostava no topo da faixa: 5,7 em vez de 5,6 na\n * hemoglobina glicada, 7,0 em vez de 8,4 no ácido úrico. Os dois também\n * arredondaram limites impressos, 149 virando 150 e 24,9 virando 25.\n *\n * Uma faixa errada não parece errada: ela vira um \"normal\" ou um \"alterado\" que\n * ninguém questiona, e o valor ao lado está certo. Por isso a descrição diz as\n * duas coisas que o modelo confundia, que o limite não é a medida e que os\n * dígitos são os do laudo, e o campo passa a exigir resposta: `null` quando o\n * laudo não publica limite é auditável, um campo ausente não é.\n *\n * Campo que aceita mais de um tipo usa `anyOf`, e não `type: [...]`. As duas\n * formas são JSON Schema válido, mas decodificador restrito não engole a\n * segunda: o LM Studio recusa a geração com `'type' must be a string`. Como o\n * ponto do contrato é servir a qualquer modelo, vale a forma mais aceita.\n */\nexport const LAB_EXTRACTION_SCHEMA = {\n $id: 'https://fhir-brasil.dev.br/schemas/lab-extraction.json',\n $schema: 'https://json-schema.org/draft/2020-12/schema',\n additionalProperties: false,\n properties: {\n biomarkers: {\n description: 'The measurements read from the report.',\n items: {\n additionalProperties: false,\n properties: {\n confidence: {\n description: 'Confidence in this reading, from 0 to 1.',\n maximum: 1,\n minimum: 0,\n type: 'number',\n },\n loinc: {\n anyOf: [{ type: 'string' }, { type: 'null' }],\n description:\n 'A LOINC code from the allowed list, or null when none of them applies. ' +\n 'Required: answer null rather than omitting the field.',\n },\n name: {\n description: 'The measurement name as the report prints it.',\n type: 'string',\n },\n referenceMax: {\n anyOf: [{ type: 'number' }, { type: 'null' }],\n description:\n 'Upper bound of the reference range as printed on the report, or null when ' +\n 'the report prints no upper bound. The bound describes the test, not this ' +\n 'result, and keeps the digits the report prints.',\n },\n referenceMin: {\n anyOf: [{ type: 'number' }, { type: 'null' }],\n description:\n 'Lower bound of the reference range as printed on the report, or null when ' +\n 'the report prints no lower bound. The bound describes the test, not this ' +\n 'result, and keeps the digits the report prints.',\n },\n sourceText: {\n description:\n 'The line of the report where this measurement and its value are printed. ' +\n 'A line, not the explanatory block around it.',\n type: 'string',\n },\n unit: {\n description: 'Unit as the report prints it. Empty string when there is none.',\n type: 'string',\n },\n value: {\n anyOf: [{ type: 'number' }, { type: 'string' }],\n description: 'Numeric value, or text for a qualitative result.',\n },\n },\n required: [\n 'name',\n 'value',\n 'unit',\n 'sourceText',\n 'confidence',\n 'loinc',\n 'referenceMin',\n 'referenceMax',\n ],\n type: 'object',\n },\n type: 'array',\n },\n collectionDate: {\n anyOf: [{ type: 'string' }, { type: 'null' }],\n description:\n 'The date the specimen was collected, as ISO 8601 (YYYY-MM-DD), or null when the ' +\n 'report does not print one. Required: answer null rather than omitting the field.',\n },\n laboratoryName: {\n anyOf: [{ type: 'string' }, { type: 'null' }],\n description:\n 'The laboratory that issued the report, as printed, or null when it is not stated. ' +\n 'Required: answer null rather than omitting the field.',\n },\n },\n required: ['biomarkers', 'collectionDate', 'laboratoryName'],\n title: 'Laboratory report extraction',\n type: 'object',\n} as const;\n\n/** Uma grandeza como o modelo devolve, antes de qualquer conferência. */\nexport interface ExtractedBiomarker {\n confidence: number;\n loinc?: string | null;\n name: string;\n referenceMax?: number | null;\n referenceMin?: number | null;\n sourceText: string;\n unit: string;\n value: number | string;\n}\n\n/** O objeto inteiro que o modelo devolve. */\nexport interface ExtractionPayload {\n biomarkers: ExtractedBiomarker[];\n /** Data da coleta em ISO 8601, ou `null` quando o laudo não imprime uma. */\n collectionDate: string | null;\n /** Laboratório que emitiu o laudo, como impresso, ou `null`. */\n laboratoryName: string | null;\n}\n","import { loincToCode, methodVariantOf } from '@precisa-saude/fhir';\n\nimport type { ExtractedBiomarker } from './extraction-schema.js';\n\n/**\n * Converte grandezas já conferidas no envelope que o `fhir-bio convert` come.\n *\n * O laudo e o paciente não vêm do modelo: o contrato de extração cobre só as\n * grandezas. Os dois saem daqui com valores sintéticos e óbvios, na mesma\n * linha do `fhir-rnds-sandbox`, para a demo rodar de ponta a ponta sem inventar\n * identidade de ninguém. Quem integra de verdade troca os dois pelo que já tem.\n */\nexport interface LabResultEnvelope {\n observations: {\n biomarkerCode: string;\n biomarkerName: string;\n flag: 'H' | 'L' | '';\n /** Ver `LabObservationData.methodLoinc`. */\n methodLoinc?: string;\n referenceMax?: number;\n referenceMin?: number;\n reportId: string;\n unit: string;\n value: number | string;\n }[];\n profile: { name: string; userId: string };\n report: {\n collectionDate: string;\n createdAt: string;\n overallStatus: 'ANORMAL' | 'NORMAL';\n processingStatus: 'complete';\n reportId: string;\n userId: string;\n };\n}\n\nexport interface ToLabResultOptions {\n collectionDate?: string;\n reportId?: string;\n userId?: string;\n}\n\n/** `H`/`L` só quando o próprio laudo trouxe a faixa. Nunca inferida daqui. */\nfunction flagFor(b: ExtractedBiomarker): 'H' | 'L' | '' {\n if (typeof b.value !== 'number') return '';\n if (typeof b.referenceMax === 'number' && b.value > b.referenceMax) return 'H';\n if (typeof b.referenceMin === 'number' && b.value < b.referenceMin) return 'L';\n return '';\n}\n\nexport function extractionToLabResult(\n biomarkers: ExtractedBiomarker[],\n options: ToLabResultOptions = {},\n): LabResultEnvelope {\n const reportId = options.reportId ?? 'laudo-demo';\n const userId = options.userId ?? 'paciente-demo';\n const collectionDate = options.collectionDate ?? new Date().toISOString().slice(0, 10);\n\n const observations = biomarkers.flatMap((b) => {\n // Sem código interno não há como converter, e o LOINC sozinho não basta\n // para o `convert`. Cai fora em silêncio porque a checagem de ancoragem já\n // rodou antes: o que chega aqui sem código é grandeza fora do catálogo.\n const code = b.loinc ? loincToCode(b.loinc) : undefined;\n if (!code) return [];\n\n return [\n {\n biomarkerCode: code,\n biomarkerName: b.name,\n flag: flagFor(b),\n // O código por método já passou pela varredura no validador; aqui só\n // atravessa, e só quando é variante declarada do biomarcador.\n ...(b.loinc && methodVariantOf(code, b.loinc) ? { methodLoinc: b.loinc } : {}),\n ...(typeof b.referenceMax === 'number' ? { referenceMax: b.referenceMax } : {}),\n ...(typeof b.referenceMin === 'number' ? { referenceMin: b.referenceMin } : {}),\n reportId,\n unit: b.unit,\n value: b.value,\n },\n ];\n });\n\n return {\n observations,\n profile: { name: 'Paciente de Demonstração', userId },\n report: {\n collectionDate,\n createdAt: `${collectionDate}T00:00:00Z`,\n overallStatus: observations.some((o) => o.flag !== '') ? 'ANORMAL' : 'NORMAL',\n processingStatus: 'complete',\n reportId,\n userId,\n },\n };\n}\n","import { codeToLoinc, getDefinitionByCode, loincToCode } from '@precisa-saude/fhir';\n\nimport type { AnchorResult } from './anchor.js';\nimport type { ExtractedBiomarker, ExtractionPayload } from './extraction-schema.js';\nimport { placeSingleBound } from './reference-bound.js';\n\n/**\n * Conferência da saída do modelo contra o contrato e contra a ancoragem.\n *\n * São duas checagens, e as duas são determinísticas:\n *\n * 1. **Forma.** O objeto bate com `LAB_EXTRACTION_SCHEMA`. Modelo que devolve\n * texto solto, campo faltando ou tipo errado é recusado aqui, o que deixa\n * a qualidade do modelo virar problema de cobertura e nunca de correção.\n * 2. **Ancoragem.** O código veio da lista que a varredura liberou. Código que\n * o laudo não mencionou é descartado, que é a falha cara: um valor\n * plausível pendurado num exame que não estava na página.\n *\n * A validação de citação, a correção de código contra nome impresso e a\n * política de confiança não moram aqui.\n *\n * Sem dependência de runtime além do `@precisa-saude/fhir`: a checagem de\n * forma é escrita à mão porque o schema é pequeno e o pacote não carrega\n * validador de JSON Schema.\n */\n\n/** Por que uma grandeza foi recusada. */\nexport type RejectionReason = 'not-anchored' | 'schema';\n\nexport interface RejectedBiomarker {\n /** Mensagem legível, já em pt-BR, dizendo o que falhou. */\n detail: string;\n /** O que o modelo devolveu, sem alteração, para o consumidor poder logar. */\n raw: unknown;\n reason: RejectionReason;\n}\n\nexport interface ExtractionValidationResult {\n accepted: ExtractedBiomarker[];\n /** Erros do objeto inteiro, quando nem dá para chegar nas grandezas. */\n errors: string[];\n rejected: RejectedBiomarker[];\n /**\n * O que vale para o laudo inteiro, e não para uma medida.\n *\n * Sai daqui em vez de o consumidor ler do objeto cru porque é aqui que a\n * forma é conferida: string ou `null`, nunca o que o modelo inventar. Sem a\n * data não há Bundle FHIR, então ela precisa atravessar a conferência em vez\n * de ficar para trás.\n */\n report: { collectionDate: string | null; laboratoryName: string | null };\n /** `true` quando o objeto tem forma válida, mesmo que toda grandeza caia. */\n valid: boolean;\n}\n\nexport interface ValidateExtractionOptions {\n /**\n * Resultado da ancoragem sobre o mesmo texto que foi ao modelo. Sem ele a\n * checagem de ancoragem não roda e só a forma é conferida, que é um modo\n * deliberadamente mais fraco: serve para inspecionar saída de modelo sem o\n * laudo em mãos.\n */\n anchors?: AnchorResult;\n}\n\nconst isRecord = (v: unknown): v is Record<string, unknown> =>\n typeof v === 'object' && v !== null && !Array.isArray(v);\n\n/** String, ou `null` para qualquer outra coisa, inclusive campo ausente. */\nconst nullableString = (v: unknown): string | null =>\n typeof v === 'string' && v !== '' ? v : null;\n\n/** Confere uma grandeza contra o schema. Devolve a lista de problemas. */\nfunction schemaErrors(raw: unknown): string[] {\n if (!isRecord(raw)) return ['não é um objeto'];\n\n const errors: string[] = [];\n const { confidence, loinc, name, referenceMax, referenceMin, sourceText, unit, value } = raw;\n\n if (typeof name !== 'string' || name.length === 0) errors.push('`name` ausente ou vazio');\n if (typeof sourceText !== 'string' || sourceText.length === 0)\n errors.push('`sourceText` ausente ou vazio');\n if (typeof unit !== 'string') errors.push('`unit` ausente');\n if (typeof value !== 'number' && typeof value !== 'string') errors.push('`value` ausente');\n if (typeof confidence !== 'number' || confidence < 0 || confidence > 1)\n errors.push('`confidence` fora de 0..1');\n\n if (loinc !== undefined && loinc !== null && typeof loinc !== 'string')\n errors.push('`loinc` não é string nem null');\n\n for (const [key, v] of [\n ['referenceMax', referenceMax],\n ['referenceMin', referenceMin],\n ] as const) {\n if (v !== undefined && v !== null && typeof v !== 'number')\n errors.push(`\\`${key}\\` não é número nem null`);\n }\n\n return errors;\n}\n\n/**\n * O conjunto de códigos que a varredura liberou, pelos dois lados: o LOINC e o\n * código interno. O modelo devolve LOINC, mas aceitar o código interno também\n * evita recusar consumidor que prefira trabalhar com ele.\n */\nfunction allowedKeys(anchors: AnchorResult): Set<string> {\n const allowed = new Set<string>();\n for (const match of anchors.matches) {\n allowed.add(match.code);\n if (match.loinc) allowed.add(match.loinc);\n }\n return allowed;\n}\n\n/**\n * O código por método é da varredura, e não do modelo.\n *\n * Quando o biomarcador tem `methodVariants`, o LOINC aceito é o que a\n * varredura achou no texto (`methodLoinc`) ou, sem pista, o código sem método.\n * Um código por método que o modelo devolva por conta própria vira o código sem\n * método: o modelo não tem como provar o método, e o texto não o afirmou.\n */\nfunction withScannedMethod(\n biomarker: ExtractedBiomarker,\n anchors: AnchorResult,\n): ExtractedBiomarker {\n const code = biomarker.loinc ? loincToCode(biomarker.loinc) : undefined;\n if (!code || !getDefinitionByCode(code)?.methodVariants?.length) return biomarker;\n const scanned = anchors.matches.find((m) => m.code === code)?.methodLoinc;\n const loinc = scanned ?? codeToLoinc(code);\n return loinc && loinc !== biomarker.loinc ? { ...biomarker, loinc } : biomarker;\n}\n\n/**\n * Confere a saída de um modelo contra o contrato e, quando a ancoragem é\n * fornecida, contra a lista de códigos que a varredura liberou.\n */\nexport function validateExtraction(\n raw: unknown,\n options: ValidateExtractionOptions = {},\n): ExtractionValidationResult {\n const { anchors } = options;\n\n if (!isRecord(raw)) {\n return {\n accepted: [],\n errors: ['a saída não é um objeto JSON'],\n rejected: [],\n report: { collectionDate: null, laboratoryName: null },\n valid: false,\n };\n }\n if (!Array.isArray(raw.biomarkers)) {\n return {\n accepted: [],\n errors: ['`biomarkers` ausente ou não é lista'],\n rejected: [],\n report: { collectionDate: null, laboratoryName: null },\n valid: false,\n };\n }\n\n const allowed = anchors ? allowedKeys(anchors) : undefined;\n const accepted: ExtractedBiomarker[] = [];\n const rejected: RejectedBiomarker[] = [];\n\n for (const entry of raw.biomarkers) {\n const problems = schemaErrors(entry);\n if (problems.length > 0) {\n rejected.push({ detail: problems.join('; '), raw: entry, reason: 'schema' });\n continue;\n }\n\n const biomarker = entry as unknown as ExtractedBiomarker;\n\n if (allowed) {\n const loinc = biomarker.loinc ?? undefined;\n // Sem código não há o que conferir contra a ancoragem, e aceitar assim\n // deixaria passar justamente o caso que a varredura existe para pegar.\n if (!loinc) {\n rejected.push({\n detail: `\"${biomarker.name}\" veio sem código LOINC`,\n raw: entry,\n reason: 'not-anchored',\n });\n continue;\n }\n // O `?? ''` de antes nunca deixava código inválido passar, porque string\n // vazia não entra no conjunto de permitidos, mas obrigava quem lê a\n // provar isso. A forma explícita não precisa de prova.\n const internalCode = loincToCode(loinc);\n const isAnchored =\n allowed.has(loinc) || (internalCode !== undefined && allowed.has(internalCode));\n if (!isAnchored) {\n rejected.push({\n detail: `${loinc} não foi ancorado no texto de origem`,\n raw: entry,\n reason: 'not-anchored',\n });\n continue;\n }\n }\n\n // O lado de um limite solto é decidido aqui, e não pelo modelo: a linha\n // impressa diz o sinal, e os modelos abertos erram o lado sem que a\n // descrição do contrato os corrija. Ver `reference-bound.ts`.\n accepted.push(placeSingleBound(anchors ? withScannedMethod(biomarker, anchors) : biomarker));\n }\n\n return {\n accepted,\n errors: [],\n rejected,\n report: {\n collectionDate: nullableString(raw.collectionDate),\n laboratoryName: nullableString(raw.laboratoryName),\n },\n valid: true,\n };\n}\n\n/** Só a lista de grandezas aprovadas, para quem não quer o relatório inteiro. */\nexport function acceptedBiomarkers(\n raw: unknown,\n options: ValidateExtractionOptions = {},\n): ExtractedBiomarker[] {\n return validateExtraction(raw, options).accepted;\n}\n\nexport type { ExtractedBiomarker, ExtractionPayload };\n","/**\n * Põe um limite solto do lado certo, lendo o sinal impresso no laudo.\n *\n * Laudo que publica \"< 90\" ou \"até 90\" no lugar de uma faixa não tem limite\n * inferior, e o número sozinho precisa cair em `referenceMax`. Os modelos\n * abertos erram esse lado de um jeito que a descrição do contrato não\n * consertou: medido no mesmo laudo da Labcorp, com o campo obrigatório, o\n * `ministral-3-8b` devolveu 90 em `referenceMin` e o `granite-4.1-8b` devolveu\n * 90 em `referenceMax`, e explicar o sinal na descrição não mudou nenhum dos\n * dois. Por isso a correção é determinística e não mais texto no contrato.\n *\n * A decisão sai do `sourceText`, que já é a linha impressa e já é obrigatório.\n * Sem sinal na linha nada muda: o que o modelo respondeu passa inteiro, porque\n * sem evidência impressa não há o que corrigir.\n *\n * O outro lado fica `null`, e não zero. Zero é um piso que o laudo não\n * publicou, e o contrato promete o que está impresso; quem desenha decide\n * como mostrar a ausência.\n */\nimport type { ExtractedBiomarker } from './extraction-schema.js';\n\n/**\n * Sinal de limite superior: o valor fica abaixo do número.\n *\n * \"até\" fecha sem `\\b` de propósito: `é` não é caractere de palavra, então não\n * existe fronteira entre ele e o espaço seguinte e o `\\b` nunca casava. Quem\n * faz o papel de fronteira aqui é o `\\s*$` que fecha o padrão.\n */\nconst ATE =\n /(?:<|≤|<=|menor\\s+que|menor\\s+ou\\s+igual|abaixo\\s+de|at[ée]|under|less\\s+than)\\s*[:=]?\\s*$/iu;\n\n/** Sinal de limite inferior: o valor fica acima do número. */\nconst ACIMA =\n /(?:>|≥|>=|maior\\s+que|maior\\s+ou\\s+igual|acima\\s+de|superior\\s+a|over|greater\\s+than)\\s*[:=]?\\s*$/iu;\n\n/**\n * De que lado o número solto cai, lendo o que vem imediatamente antes dele.\n *\n * A linha é varrida por número, e cada um é comparado **por valor** com o\n * limite. Comparar grafia não servia: o laudo imprime \"24,9\" onde o JSON traz\n * 24.9, e imprime \"90,0\" onde o modelo devolveu 90.\n *\n * Achado o número, o que decide é o trecho anterior a ele, e por isso a âncora\n * `$` nos dois padrões: numa linha como \"Apolipoprotein B 102 High mg/dL < 90\"\n * quem manda é o `<` colado no 90, e não um `>` noutro ponto da linha.\n */\nconst NUMERO = /\\d+(?:[.,]\\d+)?/gu;\n\nconst ladoDoLimite = (sourceText: string, bound: number): 'max' | 'min' | undefined => {\n for (const achado of sourceText.matchAll(NUMERO)) {\n // Comparação por valor, e não por grafia. Laudo brasileiro imprime \"24,9\"\n // e o número chega como 24.9, e um laudo que escreve \"90,0\" é o mesmo 90\n // que o modelo devolveu. Procurar a grafia do JavaScript errava os dois.\n if (Number(achado[0].replace(',', '.')) !== bound) continue;\n\n // Só a primeira ocorrência decide. Com o mesmo número em dois sinais na\n // mesma linha o desempate é arbitrário de qualquer jeito, e parar aqui\n // deixa o comportamento fixo em vez de depender da ordem dos padrões.\n const antes = sourceText.slice(0, achado.index);\n if (ATE.test(antes)) return 'max';\n if (ACIMA.test(antes)) return 'min';\n return undefined;\n }\n\n return undefined;\n};\n\n/**\n * Corrige o lado de uma grandeza que veio com um limite só.\n *\n * Devolve a mesma grandeza quando não há o que decidir: faixa com as duas\n * pontas, faixa sem nenhuma, ou linha sem sinal impresso.\n */\nexport const placeSingleBound = (biomarker: ExtractedBiomarker): ExtractedBiomarker => {\n const { referenceMax, referenceMin, sourceText } = biomarker;\n\n const temMin = typeof referenceMin === 'number';\n const temMax = typeof referenceMax === 'number';\n // Os dois preenchidos com o mesmo número é o outro jeito de o modelo errar:\n // ele não soube escolher e repetiu. Conta como limite solto.\n const repetido = temMin && temMax && referenceMin === referenceMax;\n if (!repetido && temMin === temMax) return biomarker;\n\n // Com as duas pontas repetidas o `temMin` é verdadeiro e o número sai do\n // `referenceMin`, que é o mesmo dos dois lados. Dito aqui porque a expressão\n // sozinha parece escolher um lado quando na verdade tanto faz.\n const bound = (temMin ? referenceMin : referenceMax) as number;\n const lado = ladoDoLimite(sourceText, bound);\n if (lado === undefined) return biomarker;\n\n return lado === 'max'\n ? { ...biomarker, referenceMax: bound, referenceMin: null }\n : { ...biomarker, referenceMax: null, referenceMin: bound };\n};\n","import { findBiomarkersInText } from './anchor.js';\nimport { LAB_EXTRACTION_SCHEMA } from './extraction-schema.js';\n\n/**\n * Cliente mínimo para endpoint compatível com OpenAI.\n *\n * Isto é **conveniência, não contrato**. O contrato é o\n * `LAB_EXTRACTION_SCHEMA`, e o toolkit funciona inteiro sem esta função: quem\n * integra chama o próprio modelo do jeito que a plataforma dele permitir e\n * entrega o JSON ao `validateExtraction`. Esta função existe para a demo rodar\n * de uma ponta à outra sem um `curl` no meio.\n *\n * `/v1/chat/completions` é o que praticamente todo mundo fala: LM Studio,\n * Ollama, llama.cpp, vLLM, OpenRouter, OpenAI, e a Anthropic pelo endpoint de\n * compatibilidade. Por isso não há SDK de fornecedor aqui, e por isso o pacote\n * continua sem dependência de runtime: `fetch` é do Node.\n *\n * A chave **nunca** entra por argumento de linha de comando, só por variável de\n * ambiente: argumento fica no histórico do shell e na lista de processos.\n */\nexport interface ExtractOptions {\n apiKey?: string;\n baseUrl: string;\n /**\n * Cabeçalhos a mais em cada chamada, para endpoint atrás de proxy que exige\n * credencial própria (o Cloudflare Access, por exemplo, pede\n * `CF-Access-Client-Id` e `CF-Access-Client-Secret`).\n *\n * Não substituem `content-type` nem `authorization`: o corpo é sempre JSON e\n * a chave do modelo continua vindo de `apiKey`. Um cabeçalho de proxy que\n * trocasse um dos dois quebraria a chamada de um jeito difícil de ler.\n */\n headers?: Record<string, string>;\n model: string;\n /**\n * Modo de saída estruturada. O padrão é negociar sozinho.\n *\n * Aqui é onde a compatibilidade quebra de verdade: o LM Studio recusa\n * `json_object` com 400 e só aceita `json_schema`, a OpenAI aceita os dois,\n * e servidor mais simples não conhece o campo. Como nenhum valor serve a\n * todos, a primeira tentativa vai com `json_schema` e, se o servidor recusar,\n * a segunda vai sem nada. Quem quiser fixar um modo passa ele aqui.\n *\n * Vale lembrar que isto mexe em **aproveitamento**, não em correção: saída\n * malformada é recusada pela conferência de qualquer jeito.\n */\n responseFormat?: 'auto' | 'json_object' | 'json_schema' | 'none';\n /** Milissegundos até desistir. Modelo local frio demora para carregar. */\n timeoutMs?: number;\n}\n\n/** Os dois cabeçalhos que o cliente monta e que `headers` não pode trocar. */\nconst RESERVED_HEADERS = new Set(['authorization', 'content-type']);\n\nexport interface ExtractResult {\n /** O JSON que o modelo devolveu, ainda sem conferência nenhuma. */\n payload: unknown;\n /** Texto cru da resposta, guardado para quando o parse falha. */\n raw: string;\n tookMs: number;\n}\n\n/**\n * O prompt é deliberadamente curto e neutro.\n *\n * Ele diz o que devolver e nada sobre como ler um laudo. Toda a instrução de\n * comportamento que um extrator de produção carrega é ajuste que muda de modelo\n * para modelo, e não pertence a um pacote público. O que sustenta a qualidade\n * aqui não é o prompt: é a conferência que roda depois.\n */\nfunction buildPrompt(text: string, allowed: string): string {\n return [\n 'Extract the laboratory results from the report below.',\n '',\n 'Return JSON matching this schema, and nothing else:',\n JSON.stringify(LAB_EXTRACTION_SCHEMA),\n '',\n 'Use only LOINC codes from this list:',\n allowed,\n '',\n 'REPORT:',\n text,\n ].join('\\n');\n}\n\n/** Modelo costuma embrulhar o JSON em cerca de markdown. Tira a cerca. */\nfunction stripFence(raw: string): string {\n const fenced = /```(?:json)?\\s*([\\s\\S]*?)```/.exec(raw);\n return (fenced?.[1] ?? raw).trim();\n}\n\n/**\n * Manda o laudo e a lista ancorada ao modelo e devolve o que ele respondeu.\n *\n * Não confere nada: a saída vai para o `validateExtraction`, que é onde a\n * ancoragem é cobrada. Separar os dois é proposital, porque é o que deixa\n * trocar de modelo sem mexer na parte que garante o resultado.\n */\nexport async function extractWithModel(\n text: string,\n options: ExtractOptions,\n): Promise<ExtractResult> {\n const { apiKey, baseUrl, headers = {}, model, responseFormat, timeoutMs = 300_000 } = options;\n\n // Nome de cabeçalho não diferencia maiúscula, e um objeto sim: sem comparar\n // em minúsculas, `Authorization` passaria ao lado do `authorization` daqui e\n // o `fetch` juntaria os dois num valor só.\n const extraHeaders = Object.fromEntries(\n Object.entries(headers).filter(([name]) => !RESERVED_HEADERS.has(name.toLowerCase())),\n );\n\n // A própria ancoragem já monta a lista de permitidos, então o prompt e a\n // conferência bebem exatamente da mesma fonte.\n const allowed = findBiomarkersInText(text).filteredReference;\n\n const startedAt = Date.now();\n\n const formatBody = (mode: 'json_object' | 'json_schema' | 'none'): string =>\n JSON.stringify({\n messages: [{ content: buildPrompt(text, allowed), role: 'user' }],\n model,\n ...(mode === 'json_object' ? { response_format: { type: 'json_object' } } : {}),\n ...(mode === 'json_schema'\n ? {\n response_format: {\n json_schema: { name: 'lab_extraction', schema: LAB_EXTRACTION_SCHEMA, strict: true },\n type: 'json_schema',\n },\n }\n : {}),\n temperature: 0,\n });\n\n const post = async (mode: 'json_object' | 'json_schema' | 'none'): Promise<Response> =>\n fetch(`${baseUrl.replace(/\\/$/, '')}/chat/completions`, {\n body: formatBody(mode),\n headers: {\n ...extraHeaders,\n 'content-type': 'application/json',\n ...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}),\n },\n method: 'POST',\n signal: AbortSignal.timeout(timeoutMs),\n });\n\n const mode = responseFormat ?? 'auto';\n let response = await post(mode === 'auto' ? 'json_schema' : mode);\n\n // Servidor que não conhece o modo estrito responde 4xx. A segunda tentativa\n // vai sem nada, que é o denominador comum, e só então o erro sobe.\n if (!response.ok && mode === 'auto' && response.status >= 400 && response.status < 500) {\n response = await post('none');\n }\n\n if (!response.ok) {\n throw new Error(`${String(response.status)} de ${baseUrl}: ${await response.text()}`);\n }\n\n const body = (await response.json()) as {\n choices?: { message?: { content?: string } }[];\n };\n const raw = body.choices?.[0]?.message?.content ?? '';\n const tookMs = Date.now() - startedAt;\n\n try {\n return { payload: JSON.parse(stripFence(raw)), raw, tookMs };\n } catch {\n throw new Error(`O modelo não devolveu JSON analisável. Resposta crua:\\n${raw.slice(0, 500)}`);\n }\n}\n"]}
package/dist/index.js CHANGED
@@ -235,6 +235,124 @@ function attachMethodVariants(matches, normalizedText, anchoredLineStarts, match
235
235
  }
236
236
  }
237
237
 
238
+ // src/urinalysis-section.ts
239
+ var HEADER_NAMES = String.raw`(?:urinalysis|urinalise|urina tipo i|rotina de urina|eas)(?![\p{L}\p{N}])`;
240
+ var URINALYSIS_HEADER = new RegExp(`^${HEADER_NAMES}`, "u");
241
+ var ANY_HEADER = new RegExp(String.raw`(?:^|\n)[^\S\n]*` + HEADER_NAMES, "u");
242
+ function mentionsUrinalysisHeader(normalizedText) {
243
+ return ANY_HEADER.test(normalizedText);
244
+ }
245
+ var URINALYSIS_SECTION_NAMES = /* @__PURE__ */ new Map([
246
+ ["bacteria", "Bacteria_Urine"],
247
+ ["bacterias", "Bacteria_Urine"],
248
+ ["bilirrubina", "Bilirubin_Urine"],
249
+ ["bilirubin", "Bilirubin_Urine"],
250
+ ["cetonas", "Ketones_Urine"],
251
+ ["color", "Color_Urine"],
252
+ ["cor", "Color_Urine"],
253
+ ["glicose", "Glucose_Urine"],
254
+ ["glucose", "Glucose_Urine"],
255
+ ["hemacias", "RBC_Urine"],
256
+ ["ketones", "Ketones_Urine"],
257
+ ["leucocitos", "Leukocytes_Urine"],
258
+ ["nitrite", "Nitrite_Urine"],
259
+ ["nitrito", "Nitrite_Urine"],
260
+ ["ph", "pH_Urine"],
261
+ ["protein", "Protein_Urine"],
262
+ ["proteina", "Protein_Urine"],
263
+ ["rbc", "RBC_Urine"],
264
+ ["wbc", "Leukocytes_Urine"]
265
+ ]);
266
+ function isSectionName(matched) {
267
+ return URINALYSIS_SECTION_NAMES.has(matched) || URINALYSIS_SECTION_NAMES.has(matched.replace(/s$/, ""));
268
+ }
269
+ var MENTIONS_URINE = /(?<![\p{L}\p{N}])urin[ae](?![\p{L}\p{N}])/u;
270
+ function urinalysisLineIndexes(lines) {
271
+ const inside = /* @__PURE__ */ new Set();
272
+ let open = false;
273
+ lines.forEach((line, index) => {
274
+ const text = line.text.trim();
275
+ if (URINALYSIS_HEADER.test(text) && !/\d/.test(text)) {
276
+ open = true;
277
+ return;
278
+ }
279
+ if (!open || !text) {
280
+ return;
281
+ }
282
+ if (line.foreign || !line.known && !line.hasValue && !MENTIONS_URINE.test(text)) {
283
+ open = false;
284
+ return;
285
+ }
286
+ inside.add(index);
287
+ });
288
+ return inside;
289
+ }
290
+ function sectionDepsFrom(catalog, buildPattern, hasValue, resolve) {
291
+ const loincByCode = new Map(catalog.map((p) => [p.code, p.loinc]));
292
+ const isUrine = (p) => (Array.isArray(p.category) ? p.category : [p.category]).includes("urina");
293
+ return {
294
+ hasValue,
295
+ patterns: [...URINALYSIS_SECTION_NAMES].map(([name, code]) => {
296
+ const loinc = loincByCode.get(code);
297
+ const entry = { ambiguous: false, code, ...loinc && { loinc }, original: name };
298
+ return { entry, name, regex: buildPattern(name) };
299
+ }),
300
+ resolve,
301
+ urineCodes: new Set(catalog.filter(isUrine).map((p) => p.code))
302
+ };
303
+ }
304
+ function* matchesIn(text, name, regex) {
305
+ if (!text.includes(name)) return;
306
+ regex.lastIndex = 0;
307
+ for (let match = regex.exec(text); match !== null; match = regex.exec(text)) {
308
+ yield match;
309
+ }
310
+ }
311
+ function applyUrinalysisSection(normalizedText, candidates, deps) {
312
+ const resolved = deps.resolve(candidates);
313
+ if (!mentionsUrinalysisHeader(normalizedText)) {
314
+ return resolved;
315
+ }
316
+ const lineStarts = [0];
317
+ for (let i = normalizedText.indexOf("\n"); i !== -1; i = normalizedText.indexOf("\n", i + 1)) {
318
+ lineStarts.push(i + 1);
319
+ }
320
+ const lines = lineStarts.map((start, index) => {
321
+ const end = index + 1 < lineStarts.length ? lineStarts[index + 1] - 1 : normalizedText.length;
322
+ const text = normalizedText.slice(start, end);
323
+ const known = deps.patterns.some(
324
+ ({ name, regex }) => !matchesIn(text, name, regex).next().done
325
+ );
326
+ return { foreign: false, hasValue: deps.hasValue(text), known, text };
327
+ });
328
+ for (const candidate of resolved) {
329
+ let index = lineStarts.length - 1;
330
+ while (lineStarts[index] > candidate.start) index -= 1;
331
+ const line = lines[index];
332
+ line.known = true;
333
+ const matched = normalizedText.slice(candidate.start, candidate.end);
334
+ if (!isSectionName(matched) && !candidate.entries.some((e) => deps.urineCodes.has(e.code))) {
335
+ line.foreign = true;
336
+ }
337
+ }
338
+ const inside = urinalysisLineIndexes(lines);
339
+ if (!inside.size) {
340
+ return resolved;
341
+ }
342
+ const sectionCandidates = [];
343
+ for (const index of inside) {
344
+ const start = lineStarts[index];
345
+ for (const { entry, name, regex } of deps.patterns) {
346
+ for (const match of matchesIn(lines[index].text, name, regex)) {
347
+ const at = start + match.index;
348
+ sectionCandidates.push({ end: at + match[0].length, entries: [entry], start: at });
349
+ }
350
+ }
351
+ }
352
+ const replaced = (c) => sectionCandidates.some((s) => s.start === c.start && s.end === c.end);
353
+ return deps.resolve([...candidates.filter((c) => !replaced(c)), ...sectionCandidates]);
354
+ }
355
+
238
356
  // src/anchor.ts
239
357
  var CONFIDENCE_VALUE_ADJACENT = 1;
240
358
  var CONFIDENCE_NAME_ONLY = 0.7;
@@ -481,6 +599,13 @@ function resolveOverlaps(candidates) {
481
599
  }
482
600
  return accepted;
483
601
  }
602
+ var cachedSectionDeps = null;
603
+ var getSectionDeps = () => cachedSectionDeps ??= sectionDepsFrom(
604
+ getPatterns(),
605
+ buildNamePattern,
606
+ hasValueEvidence,
607
+ resolveOverlaps
608
+ );
484
609
  function findBiomarkersInText(ocrText) {
485
610
  const startTime = Date.now();
486
611
  const normalizedText = normalize(ocrText);
@@ -491,7 +616,7 @@ function findBiomarkersInText(ocrText) {
491
616
  ...collectCandidates(normalizedText),
492
617
  ...collectWrappedCandidates(normalizedText)
493
618
  ];
494
- for (const candidate of resolveOverlaps(candidates)) {
619
+ for (const candidate of applyUrinalysisSection(normalizedText, candidates, getSectionDeps())) {
495
620
  const lineStart = getLineBounds(normalizedText, candidate.start).start;
496
621
  const lineEnd = getLineBounds(normalizedText, candidate.end - 1).end;
497
622
  const key = `${lineStart}:${lineEnd}`;