/** * @copyright Sister Software * @license AGPL-3.0 * @author Teffen Ellis, et al. * * Align one permit-registry address string to the address register's key, or answer null. Alignment * is exact against {@link KeyIndex} rather than fuzzy, so a string that satisfies the whole key becomes * a training row of matched spans and one that does not becomes a board row. */ import { type KeyIndex, sigunguSpan, unitKey } from "#kr/adapters/localdata/key-index" /** * A building number: `233`, `43-1`, `지하 1476`. */ const BUILDING_NUMBER = /^(?:지하\s?)?\d+(?:-\d+)?$/u /** * A lot number: `43-1`, `산12`, `43번지`. */ const LOT_NUMBER = /^산?\d+(?:-\d+)?(?:번지)?$/u /** * A floor or unit the clerk appended: `2층`, `일부호`, `B1`, `지하1층`, `101동`. */ const UNIT_TOKEN = /^(?:지하\s?)?(?:B?\d+(?:~\d+)?(?:층|호)|\d+층|B\d+|지하\d*층?|\d+동|[가-힣]?\d*호)(?:,)?$/u /** * The shortest either form can be — 시도, 시군구 and the road or the 법정동. * No shorter value can contain the key. */ const MINIMUM_TOKENS = 3 /** * One aligned string: the spans that matched and the key they matched under. */ export interface Aligned { raw: string spanStarts: number[] spanEnds: number[] spanTags: string[] register: "registry_road" | "registry_lot" region: string sigungu: string } interface Span { start: number end: number tag: string } /** * Record a span unless it is empty or blank. * * A blank span from a clerk's double space or a zero-width one at a token's own * end would train the model on no component. */ function put(spans: Span[], text: string, start: number, end: number, tag: string): void { if (end > start && text.slice(start, end).trim()) { spans.push({ start, end, tag }) } } /** * Where each token begins in the original text, walked left to right so a repeated * token takes its own position rather than the first one's. */ function tokenPositions(text: string, tokens: readonly string[], from = 0): number[] { const positions: number[] = [] let cursor = from for (const token of tokens) { const at = text.indexOf(token, cursor) positions.push(at) cursor = at + token.length } return positions } /** * The index of the first unit-shaped token, or the length when there is none. */ function firstUnitIndex(tokens: readonly string[]): number { const found = tokens.findIndex((token) => UNIT_TOKEN.test(token)) return found === -1 ? tokens.length : found } function finish(text: string, spans: Span[], register: Aligned["register"], region: string, sigungu: string): Aligned { const sorted = spans.toSorted((a, b) => a.start - b.start || a.end - b.end || a.tag.localeCompare(b.tag)) return { raw: text, spanStarts: sorted.map((span) => span.start), spanEnds: sorted.map((span) => span.end), spanTags: sorted.map((span) => span.tag), register, region, sigungu, } } /** * Align one road-name string, or answer null. */ export function alignRoadAddress(text: string, index: KeyIndex): Aligned | null { const openAt = text.indexOf("(") const head = openAt === -1 ? text : text.slice(0, openAt) const tail = openAt === -1 ? "" : text.slice(openAt + 1) const parenthetical = tail.endsWith(")") ? tail.slice(0, -1).trim() : "" const commaAt = head.indexOf(",") const core = commaAt === -1 ? head : head.slice(0, commaAt) const detail = commaAt === -1 ? "" : head.slice(commaAt + 1) const tokens = core.split(/\s+/u).filter((token) => token.length) const positions = tokenPositions(text, tokens) if (tokens.length < MINIMUM_TOKENS || !index.regions.has(tokens[0]!)) return null const region = tokens[0]! const width = sigunguSpan(index, region, tokens, 1) // A region with no 시군구 level (세종특별자치시) lists the empty string, so its strings go region → road. if (!width && !index.sigunguByRegion.get(region)?.has("")) return null const sigungu = tokens.slice(1, 1 + width).join(" ") const unit = unitKey(region, sigungu) const roads = index.roadsByUnit.get(unit) ?? new Set() const dongs = index.dongsByUnit.get(unit) ?? new Set() let roadAt = 1 + width let eupmyeonAt: number | null = null // In an 읍/면 area the road form includes the 읍/면 between the 시군구 and the road // (`기장군 기장읍 기장해안로 205`); the register lists those names beside the 동 of the same unit. if (roadAt + 2 < tokens.length && dongs.has(tokens[roadAt]!) && roads.has(tokens[roadAt + 1]!)) { eupmyeonAt = roadAt roadAt += 1 } // A road name is one token. // A numbered branch (`대학로8길`) is part of that token in the register. if (roadAt + 1 >= tokens.length || !roads.has(tokens[roadAt]!)) return null let numberAtToken = roadAt + 1 // `달구벌대로 지하 1476`: the underground marker stands as its own token before the number, outside every span. if (tokens[numberAtToken] === "지하" && numberAtToken + 1 < tokens.length) { numberAtToken += 1 } const number = tokens[numberAtToken]! if (!BUILDING_NUMBER.test(number)) return null const spans: Span[] = [] put(spans, text, positions[0]!, positions[0]! + region.length, "region") for (let offset = 0; offset < width; offset += 1) { const token = tokens[1 + offset]! put(spans, text, positions[1 + offset]!, positions[1 + offset]! + token.length, "subregion") } if (eupmyeonAt !== null) { put(spans, text, positions[eupmyeonAt]!, positions[eupmyeonAt]! + tokens[eupmyeonAt]!.length, "dependent_locality") } put(spans, text, positions[roadAt]!, positions[roadAt]! + tokens[roadAt]!.length, "street") const numberAt = positions[numberAtToken]! put(spans, text, numberAt, numberAt + number.length, "house_number") // What follows the number, with or without a comma, reads as venue then unit, the same as the lot form. const restTokens = [...tokens.slice(numberAtToken + 1), ...detail.split(/\s+/u).filter((token) => token.length)] if (restTokens.length) { const restPositions = tokenPositions(text, restTokens, numberAt + number.length) const firstUnit = firstUnitIndex(restTokens) if (firstUnit) { put(spans, text, restPositions[0]!, restPositions[firstUnit - 1]! + restTokens[firstUnit - 1]!.length, "venue") } if (firstUnit < restTokens.length) { put(spans, text, restPositions[firstUnit]!, restPositions.at(-1)! + restTokens.at(-1)!.length, "unit") } } if (parenthetical) { const parenAt = text.indexOf(parenthetical, head.length) const innerComma = parenthetical.indexOf(",") const dong = (innerComma === -1 ? parenthetical : parenthetical.slice(0, innerComma)).trim() const building = (innerComma === -1 ? "" : parenthetical.slice(innerComma + 1)).trim() if (dongs.has(dong)) { const dongAt = text.indexOf(dong, parenAt) put(spans, text, dongAt, dongAt + dong.length, "dependent_locality") if (building) { const buildingAt = text.indexOf(building, dongAt + dong.length) put(spans, text, buildingAt, buildingAt + building.length, "venue") } } else { put(spans, text, parenAt, parenAt + parenthetical.length, "venue") } } return finish(text, spans, "registry_road", region, sigungu) } /** * Align one lot-number string, or answer null. */ export function alignLotAddress(text: string, index: KeyIndex): Aligned | null { const tokens = text.split(/\s+/u).filter((token) => token.length) const positions = tokenPositions(text, tokens) if (tokens.length < MINIMUM_TOKENS || !index.regions.has(tokens[0]!)) return null const region = tokens[0]! const width = sigunguSpan(index, region, tokens, 1) if (!width && !index.sigunguByRegion.get(region)?.has("")) return null const sigungu = tokens.slice(1, 1 + width).join(" ") const dongAt = 1 + width const dongs = index.dongsByUnit.get(unitKey(region, sigungu)) ?? new Set() if (dongAt >= tokens.length || !dongs.has(tokens[dongAt]!)) return null const dong = tokens[dongAt]! let lotAt = dongAt + 1 let ri: string | null = null if (lotAt < tokens.length && index.risByDong.get(unitKey(region, sigungu, dong))?.has(tokens[lotAt]!)) { ri = tokens[lotAt]! lotAt += 1 } if (lotAt >= tokens.length || !LOT_NUMBER.test(tokens[lotAt]!)) return null const lot = tokens[lotAt]! const spans: Span[] = [] put(spans, text, positions[0]!, positions[0]! + region.length, "region") for (let offset = 0; offset < width; offset += 1) { const token = tokens[1 + offset]! put(spans, text, positions[1 + offset]!, positions[1 + offset]! + token.length, "subregion") } put(spans, text, positions[dongAt]!, positions[dongAt]! + dong.length, "dependent_locality") if (ri) { put(spans, text, positions[lotAt - 1]!, positions[lotAt - 1]! + ri.length, "dependent_locality") } put(spans, text, positions[lotAt]!, positions[lotAt]! + lot.length, "house_number") const rest = tokens.slice(lotAt + 1) if (rest.length) { // The clerk writes the building name first and the floor/unit after it // (`교보생명빌딩 2층`, `지강빌딩 1층 일부호`): the venue is the run of tokens before the first // unit-shaped one, the unit everything from there to the end. const firstUnit = firstUnitIndex(rest) if (firstUnit) { put(spans, text, positions[lotAt + 1]!, positions[lotAt + firstUnit]! + rest[firstUnit - 1]!.length, "venue") } if (firstUnit < rest.length) { put(spans, text, positions[lotAt + 1 + firstUnit]!, positions[lotAt + rest.length]! + rest.at(-1)!.length, "unit") } } return finish(text, spans, "registry_lot", region, sigungu) }