korean-datetime 1.0.0__tar.gz

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.
Files changed (77) hide show
  1. korean_datetime-1.0.0/.github/workflows/ci.yml +48 -0
  2. korean_datetime-1.0.0/.github/workflows/release.yml +53 -0
  3. korean_datetime-1.0.0/.gitignore +13 -0
  4. korean_datetime-1.0.0/LICENSE +21 -0
  5. korean_datetime-1.0.0/PKG-INFO +459 -0
  6. korean_datetime-1.0.0/README.md +431 -0
  7. korean_datetime-1.0.0/docs/benchmark.md +84 -0
  8. korean_datetime-1.0.0/docs/comparison.md +106 -0
  9. korean_datetime-1.0.0/docs/expectation-dsl.md +441 -0
  10. korean_datetime-1.0.0/docs/releasing.md +31 -0
  11. korean_datetime-1.0.0/docs/tutorial.md +288 -0
  12. korean_datetime-1.0.0/pyproject.toml +76 -0
  13. korean_datetime-1.0.0/scripts/aihub_benchmark.py +185 -0
  14. korean_datetime-1.0.0/scripts/aihub_eval.py +241 -0
  15. korean_datetime-1.0.0/scripts/check.py +50 -0
  16. korean_datetime-1.0.0/scripts/compare_libraries.py +500 -0
  17. korean_datetime-1.0.0/scripts/evaluate.py +64 -0
  18. korean_datetime-1.0.0/scripts/gold.py +191 -0
  19. korean_datetime-1.0.0/scripts/vendor.py +111 -0
  20. korean_datetime-1.0.0/src/korean_datetime/__init__.py +58 -0
  21. korean_datetime-1.0.0/src/korean_datetime/__main__.py +61 -0
  22. korean_datetime-1.0.0/src/korean_datetime/core/__init__.py +29 -0
  23. korean_datetime-1.0.0/src/korean_datetime/core/clock.py +50 -0
  24. korean_datetime-1.0.0/src/korean_datetime/core/evaluation.py +189 -0
  25. korean_datetime-1.0.0/src/korean_datetime/core/numerals.py +92 -0
  26. korean_datetime-1.0.0/src/korean_datetime/core/scanner.py +195 -0
  27. korean_datetime-1.0.0/src/korean_datetime/core/types.py +24 -0
  28. korean_datetime-1.0.0/src/korean_datetime/py.typed +0 -0
  29. korean_datetime-1.0.0/src/korean_datetime/temporal/__init__.py +37 -0
  30. korean_datetime-1.0.0/src/korean_datetime/temporal/ambiguity.py +34 -0
  31. korean_datetime-1.0.0/src/korean_datetime/temporal/calendar_math.py +71 -0
  32. korean_datetime-1.0.0/src/korean_datetime/temporal/context_hour.py +111 -0
  33. korean_datetime-1.0.0/src/korean_datetime/temporal/data/__init__.py +0 -0
  34. korean_datetime-1.0.0/src/korean_datetime/temporal/data/holidays.json +34 -0
  35. korean_datetime-1.0.0/src/korean_datetime/temporal/evaluation.py +156 -0
  36. korean_datetime-1.0.0/src/korean_datetime/temporal/expectation.py +730 -0
  37. korean_datetime-1.0.0/src/korean_datetime/temporal/frame.py +363 -0
  38. korean_datetime-1.0.0/src/korean_datetime/temporal/holiday_calendar.py +141 -0
  39. korean_datetime-1.0.0/src/korean_datetime/temporal/holidays.py +84 -0
  40. korean_datetime-1.0.0/src/korean_datetime/temporal/lexicon.py +324 -0
  41. korean_datetime-1.0.0/src/korean_datetime/temporal/lunar.py +216 -0
  42. korean_datetime-1.0.0/src/korean_datetime/temporal/model.py +95 -0
  43. korean_datetime-1.0.0/src/korean_datetime/temporal/options.py +96 -0
  44. korean_datetime-1.0.0/src/korean_datetime/temporal/parser.py +131 -0
  45. korean_datetime-1.0.0/src/korean_datetime/temporal/postprocess.py +119 -0
  46. korean_datetime-1.0.0/src/korean_datetime/temporal/ranges.py +145 -0
  47. korean_datetime-1.0.0/src/korean_datetime/temporal/relative.py +91 -0
  48. korean_datetime-1.0.0/src/korean_datetime/temporal/resolve.py +184 -0
  49. korean_datetime-1.0.0/src/korean_datetime/temporal/resolve_date.py +333 -0
  50. korean_datetime-1.0.0/src/korean_datetime/temporal/resolve_time.py +140 -0
  51. korean_datetime-1.0.0/src/korean_datetime/temporal/rules.py +448 -0
  52. korean_datetime-1.0.0/src/korean_datetime/temporal/tokens.py +95 -0
  53. korean_datetime-1.0.0/tests/conftest.py +24 -0
  54. korean_datetime-1.0.0/tests/data/temporal_anchor.jsonl +486 -0
  55. korean_datetime-1.0.0/tests/data/temporal_gold.jsonl +619 -0
  56. korean_datetime-1.0.0/tests/helpers.py +52 -0
  57. korean_datetime-1.0.0/tests/references.py +73 -0
  58. korean_datetime-1.0.0/tests/test_ambiguity.py +133 -0
  59. korean_datetime-1.0.0/tests/test_api.py +156 -0
  60. korean_datetime-1.0.0/tests/test_context_hour.py +73 -0
  61. korean_datetime-1.0.0/tests/test_core.py +95 -0
  62. korean_datetime-1.0.0/tests/test_cycle.py +72 -0
  63. korean_datetime-1.0.0/tests/test_discourse.py +83 -0
  64. korean_datetime-1.0.0/tests/test_docs.py +81 -0
  65. korean_datetime-1.0.0/tests/test_evaluation.py +204 -0
  66. korean_datetime-1.0.0/tests/test_expectation_dsl.py +190 -0
  67. korean_datetime-1.0.0/tests/test_holiday_injection.py +91 -0
  68. korean_datetime-1.0.0/tests/test_holidays.py +76 -0
  69. korean_datetime-1.0.0/tests/test_invariants.py +113 -0
  70. korean_datetime-1.0.0/tests/test_options.py +69 -0
  71. korean_datetime-1.0.0/tests/test_reference_time.py +106 -0
  72. korean_datetime-1.0.0/tests/test_relative_words.py +132 -0
  73. korean_datetime-1.0.0/tests/test_scripts.py +199 -0
  74. korean_datetime-1.0.0/tests/test_tutorial.py +41 -0
  75. korean_datetime-1.0.0/tests/test_vague.py +53 -0
  76. korean_datetime-1.0.0/tests/test_vendoring.py +64 -0
  77. korean_datetime-1.0.0/uv.lock +619 -0
@@ -0,0 +1,48 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ workflow_dispatch:
8
+ workflow_call:
9
+
10
+ permissions:
11
+ contents: read
12
+
13
+ jobs:
14
+ check:
15
+ runs-on: ubuntu-latest
16
+ strategy:
17
+ fail-fast: false
18
+ matrix:
19
+ python: ['3.10', '3.11', '3.12', '3.13']
20
+ steps:
21
+ - uses: actions/checkout@v4
22
+ - uses: astral-sh/setup-uv@v6
23
+ with:
24
+ version: '0.8.22'
25
+ - run: uv sync --locked --python '${{ matrix.python }}'
26
+ - run: uv run python scripts/check.py
27
+
28
+ package:
29
+ runs-on: ubuntu-latest
30
+ steps:
31
+ - uses: actions/checkout@v4
32
+ - uses: actions/setup-python@v5
33
+ with:
34
+ python-version: '3.13'
35
+ - run: python -m pip install build twine
36
+ - run: python -m build
37
+ - run: python -m twine check --strict dist/*
38
+ - name: Install wheel in an isolated environment
39
+ run: |
40
+ python -m venv /tmp/package-smoke
41
+ /tmp/package-smoke/bin/pip install dist/*.whl
42
+ cd /tmp
43
+ /tmp/package-smoke/bin/python -c 'from datetime import datetime; from korean_datetime import parse; assert parse("내일", now=datetime(2026, 10, 1)).start == datetime(2026, 10, 2); assert parse("추석", now=datetime(2026, 1, 1)) is not None'
44
+ - uses: actions/upload-artifact@v4
45
+ with:
46
+ name: distributions
47
+ path: dist/*
48
+ if-no-files-found: error
@@ -0,0 +1,53 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ permissions:
8
+ contents: read
9
+
10
+ jobs:
11
+ validate:
12
+ runs-on: ubuntu-latest
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ - uses: actions/setup-python@v5
16
+ with:
17
+ python-version: '3.13'
18
+ - name: Check release tag against package versions
19
+ env:
20
+ RELEASE_TAG: ${{ github.event.release.tag_name }}
21
+ run: |
22
+ python - <<'PY'
23
+ import ast
24
+ import os
25
+ import pathlib
26
+ import tomllib
27
+ version = tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['version']
28
+ tree = ast.parse(pathlib.Path('src/korean_datetime/__init__.py').read_text())
29
+ exported = next(ast.literal_eval(node.value) for node in tree.body
30
+ if isinstance(node, ast.Assign)
31
+ and any(isinstance(t, ast.Name) and t.id == '__version__' for t in node.targets))
32
+ assert os.environ['RELEASE_TAG'] == f'v{version}', 'Release tag and package version differ'
33
+ assert exported == version, '__version__ and package version differ'
34
+ PY
35
+
36
+ checks:
37
+ needs: validate
38
+ uses: ./.github/workflows/ci.yml
39
+
40
+ publish:
41
+ needs: checks
42
+ runs-on: ubuntu-latest
43
+ environment:
44
+ name: pypi
45
+ url: https://pypi.org/project/korean-datetime/
46
+ permissions:
47
+ id-token: write
48
+ steps:
49
+ - uses: actions/download-artifact@v4
50
+ with:
51
+ name: distributions
52
+ path: dist/
53
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,13 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ build/
5
+ dist/
6
+ .venv/
7
+ venv/
8
+ .pytest_cache/
9
+ .coverage
10
+ htmlcov/
11
+ .DS_Store
12
+ .mypy_cache/
13
+ .ruff_cache/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jeong Jin Lee
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,459 @@
1
+ Metadata-Version: 2.5
2
+ Name: korean-datetime
3
+ Version: 1.0.0
4
+ Summary: Korean temporal expression parser: 한국어 자연어 날짜·시간·기간 표현(다음주 월요일 저녁 7시, 추석 연휴, 3시부터 5시까지)을 datetime 구간으로 변환. 의존성 없음
5
+ Project-URL: Homepage, https://github.com/jjlee6496/korean-datetime
6
+ Project-URL: Documentation, https://github.com/jjlee6496/korean-datetime/blob/main/docs/tutorial.md
7
+ Project-URL: Benchmark, https://github.com/jjlee6496/korean-datetime/blob/main/docs/benchmark.md
8
+ Project-URL: Issues, https://github.com/jjlee6496/korean-datetime/issues
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: date-extraction,date-parser,dateparser,datetime,hangul,korean,korean-nlp,natural-language-date,nlp,relative-date,temporal-expression,time-parser,timex,날짜,시간,자연어,한국어
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Natural Language :: Korean
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
24
+ Classifier: Topic :: Text Processing :: Linguistic
25
+ Classifier: Typing :: Typed
26
+ Requires-Python: >=3.10
27
+ Description-Content-Type: text/markdown
28
+
29
+ # korean-datetime
30
+
31
+ [![CI](https://github.com/jjlee6496/korean-datetime/actions/workflows/ci.yml/badge.svg)](https://github.com/jjlee6496/korean-datetime/actions/workflows/ci.yml)
32
+ [![PyPI](https://img.shields.io/pypi/v/korean-datetime)](https://pypi.org/project/korean-datetime/)
33
+ ![Python](https://img.shields.io/badge/python-3.10%2B-blue)
34
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
35
+
36
+ **Korean natural-language date & time parser** — extracts temporal expressions from Korean text
37
+ ("다음주 월요일 저녁 7시", "추석 연휴", "3시부터 5시까지") and resolves them to datetime ranges with granularity and ambiguity flags.
38
+ Zero dependencies, Python 3.10+.
39
+
40
+ 한국어 문장에서 **날짜·시간·기간 표현**을 찾아 `[start, end)` datetime 구간으로 바꾸는 파서입니다. 상대 날짜, 오전/오후 추정, 음력 명절, 범위를 다루고, 추정한 부분은 모호성으로 표시합니다. **표준 라이브러리만 사용합니다** (Python 3.10+).
41
+
42
+ ```bash
43
+ pip install korean-datetime
44
+ ```
45
+
46
+ - "다음주 월요일 저녁 7시 반", "추석 연휴", "3시부터 5시까지", "2시간 30분 후", "지난 3일간"
47
+ - 모호하게 추정한 부분은 값과 함께 표시 (오전/오후, 연도 넘김 등)
48
+ - 기준 시각에 상관없이 돌아가는 식 기반 정답셋으로 정량 평가
49
+
50
+ ### 외부 데이터에서의 성능
51
+
52
+ AIHub Training의 유형별 표본 **327개 시간 표현**과 **시간 표현이 없는 200개 문장**을 같은 기준 시각으로 비교했습니다. 아래는 기존 측정 결과이며, 전체 한국어 문장에 대한 정확도 보장은 아닙니다.
53
+
54
+ | 지표 | korean-datetime | Duckling | dateparser |
55
+ |---|---:|---:|---:|
56
+ | 값까지 맞힌 비율 (미검출 포함) | 76% (뉴스용 `cycle=nearest`: 82%) | 72% | 8% |
57
+ | 위치 검출률 | 94% | 98% | 19% |
58
+ | 시간 표현이 없는 문장의 오탐률 ↓ | 2% | 12% | 6% |
59
+
60
+ 기본 설정끼리 비교했으며 `nearest`만 별도로 표시했습니다. Duckling은 더 많이 검출하고, korean-datetime은 이 표본에서 오탐이 더 적었습니다. **검출 범위와 오탐 사이의 선택**으로 봐 주세요. [비교 방법·실패 사례](docs/comparison.md)
61
+
62
+ 전체 Training의 기본 설정 결과는 다음과 같습니다. 여기서 값 정확도는 **인식한 표현 중 값이 맞은 비율**로, 위 표의 미검출까지 포함한 비율과 분모가 다릅니다.
63
+
64
+ | 분야 | 재현율 | 정밀도 | 값 정확도 |
65
+ |---|---:|---:|---:|
66
+ | 뉴스 | 0.765 | 0.938 | 0.841 |
67
+ | 대화 | 0.679 | 0.961 | 0.918 |
68
+ | 역사 | 0.662 | 0.968 | 0.864 |
69
+
70
+ [Training/Validation 전체 결과와 옵션별 차이](docs/benchmark.md). 내부 정답셋의 회귀 성적과 외부 데이터 성능은 구분합니다.
71
+
72
+ 처음이라면 **[튜토리얼](docs/tutorial.md)**부터 보세요. 상황별 문제와 해결, 실제 결과값을 예시로 정리했습니다.
73
+
74
+ ```python
75
+ from datetime import datetime
76
+ from korean_datetime import parse, parse_all
77
+
78
+ now = datetime(2026, 9, 28, 14, 30) # 월요일
79
+
80
+ r = parse("다음주 월요일 저녁 7시 반에 보자", now=now)
81
+ r.start, r.kind, r.grain, r.text
82
+ # (datetime(2026, 10, 5, 19, 30), Kind.DATETIME, Grain.MINUTE, '다음주 월요일 저녁 7시 반')
83
+
84
+ [x.text for x in parse_all("내일 3시에 보고 모레 5시에 또 보자", now=now)]
85
+ # ['내일 3시', '모레 5시']
86
+ ```
87
+
88
+ ## 설치 / 개발
89
+
90
+ 개발 버전은 `pip install git+https://github.com/jjlee6496/korean-datetime`으로 설치합니다. [버전 정책과 배포 절차](docs/releasing.md)를 참고하세요.
91
+
92
+ ```bash
93
+ uv sync # 개발 의존성(pytest, ruff, mypy) 포함
94
+ uv run python scripts/check.py # 커밋 전 전체 검사 (ruff, mypy, 정답셋, pytest)
95
+ uv run pytest # 테스트 + 정량 평가 리포트만
96
+ ```
97
+
98
+ ## 스크립트 (`scripts/`)
99
+
100
+ 모든 스크립트는 `--help`를 지원하고 `tests/test_scripts.py`에서 실제로 실행해 검증합니다.
101
+
102
+ | 스크립트 | 용도 | 예 |
103
+ |---|---|---|
104
+ | `check.py` | ruff → ruff format → mypy(strict) → 정답셋 검사 → pytest. 끝까지 실행하고 요약 | `check.py --full` (전체 스윕 포함) |
105
+ | `gold.py check` | 정답셋 형식·중복·식 오류·문서 누락 검사 | `gold.py check` |
106
+ | `gold.py add` | 식을 여러 기준 시각에서 계산해 파서와 비교한 뒤 추가. 다르면 거부 (TDD로 먼저 넣을 땐 `--allow-mismatch`) | `gold.py add --category weekday --text "다음 금요일" --expect "next(FRI)"` |
107
+ | `gold.py show` | 한 문장의 파서 값과 식 값을 기준 시각별로 비교 | `gold.py show "3시" --now 2026-09-28T23:50` |
108
+ | `evaluate.py` | 원하는 기준 시각·기간으로 정답셋 리포트 (실패 재현) | `evaluate.py --from 2026-12-25 --to 2027-01-05 --at 23:50` |
109
+ | `vendor.py` | 설치 없이 복사 + 매니페스트, 복사본 수정 여부 검사 | `vendor.py myapp/_vendor`, `vendor.py --check myapp/_vendor/korean_datetime` |
110
+ | `aihub_eval.py` | AI허브 시간 표현 탐지 데이터(TIMEX3)로 실제 문장 평가. 데이터는 저장소에 넣지 않음 | `aihub_eval.py <라벨링데이터 폴더> --samples 20` |
111
+ | `aihub_benchmark.py` | 옵션별 × Training/Validation 전체 표를 마크다운으로 (`docs/benchmark.md`) | `aihub_benchmark.py <데이터 루트> > docs/benchmark.md` |
112
+ | `compare_libraries.py` | Duckling·dateparser와 같은 문장으로 비교 (`docs/comparison.md`). 비교 라이브러리는 이 스크립트에서만 씀 | `uv run --with dateparser python scripts/compare_libraries.py --aihub <데이터 루트>` |
113
+
114
+ 일회성 작업(데이터 한 번 변환 등)은 저장소에 넣지 않고, 반복해서 쓰는 작업만 `scripts/`에 둡니다.
115
+
116
+ ## 결과 모델
117
+
118
+ 모든 결과는 반열린 구간 `[start, end)`와 정밀도(`grain`)로 표현합니다.
119
+
120
+ | 입력 | kind | grain | start ~ end |
121
+ |---|---|---|---|
122
+ | 내일 | date | day | 09-29 00:00 ~ 09-30 00:00 |
123
+ | 이번 주말 | date | day | 10-03 ~ 10-05 |
124
+ | 다음달 | date | month | 10-01 ~ 11-01 |
125
+ | 저녁 7시 | time | hour | 19:00 ~ 20:00 |
126
+ | 저녁 | time | hour | 18:00 ~ 21:00 |
127
+ | 내일 3시 | datetime | hour | 09-29 15:00 ~ 16:00 |
128
+ | 1시간 후 | datetime | minute | 15:30 ~ 15:31 |
129
+ | 3시부터 5시까지 | time | hour | 15:00 ~ 17:00 (`is_range=True`) |
130
+
131
+ - `value`: kind에 맞는 대표값 (`date` / `time` / `datetime`)
132
+ - `span`, `text`: 원문 위치와 문자열
133
+ - `ambiguities`: 추정이 들어간 부분 (값은 그대로). 호출하는 쪽이 보고 되물을지 정합니다
134
+ ```python
135
+ parse("6월 3일", now=now).ambiguities # (Ambiguity.CYCLE_SHIFTED,) 올해는 지나서 내년으로 정함
136
+ parse("다음 주말", now=now).ambiguities # (Ambiguity.NEXT_WEEKEND,)
137
+ parse("내일", now=now).ambiguities # ()
138
+ ```
139
+ 종류: `meridiem`, `noon_or_midnight`, `cycle_shifted`, `same_weekday`, `day_attribution`, `multi_day_time`, `calendar_row_spill`, `next_weekend`, `two_digit_year` ([자세히](docs/expectation-dsl.md#56-모호성-표시)). 오전/오후 표시는 기준 시각과 상관없이 문장만 보고 정해짐
140
+ - `to_dict()`: JSON 직렬화용 dict
141
+ - 편의 함수: `parse_date`, `parse_time`, `parse_datetime`
142
+
143
+ ## 기준 시각 (`now`)
144
+
145
+ "내일", "3시", "1시간 후"는 기준 시각에서 계산합니다. 기준 시각은 다음 순서로 정해집니다.
146
+
147
+ | 순위 | 방법 | 쓰는 곳 |
148
+ |---|---|---|
149
+ | 1 | `parse(text, now=...)` | 특정 시각 기준으로 계산할 때 (테스트, 재처리) |
150
+ | 2 | `with reference_time(...):` | **요청 단위**: 미들웨어에서 한 번 정하면 그 요청 안의 모든 호출이 같은 기준을 씀 |
151
+ | 3 | `ParseOptions(timezone=...)` | 둘 다 없을 때 서버 시각을 그 시간대로 읽음 |
152
+ | 4 | (없음) | 서버 로컬 시각 `datetime.now()` |
153
+
154
+ ```python
155
+ from datetime import datetime
156
+ from zoneinfo import ZoneInfo
157
+ from korean_datetime import ParseOptions, TemporalParser, parse_time, reference_time
158
+
159
+ KST = ZoneInfo("Asia/Seoul") # Windows에서는 pip install tzdata 필요
160
+
161
+ # 웹 서버: 요청마다 기준 시각을 한 번 정함 (ContextVar라 스레드·비동기 요청끼리 섞이지 않음)
162
+ @app.middleware("http")
163
+ async def set_request_time(request, call_next):
164
+ with reference_time(datetime.now(KST)):
165
+ return await call_next(request)
166
+
167
+ # 핸들러 안에서는 now 없이 호출해도 요청 시각 기준
168
+ parse_time("3시")
169
+
170
+ # 기준 시각을 따로 정하지 않는 곳(배치 등)에서는 시간대만 고정
171
+ parser = TemporalParser(ParseOptions(timezone=KST))
172
+ ```
173
+
174
+ 서버가 UTC라면 시간대를 꼭 정해야 합니다. 정하지 않으면 한국 시각 00:00~09:00에는 "오늘", "내일"이 하루 어긋납니다.
175
+ 요청 단위로 정해 두면 한 요청 안에서 여러 번 호출해도 자정이나 분이 넘어갈 때 결과가 흔들리지 않습니다.
176
+
177
+ ## 해석 규칙과 옵션
178
+
179
+ ```python
180
+ from korean_datetime import AmbiguousHour, Cycle, ParseOptions, TemporalParser
181
+
182
+ parser = TemporalParser(ParseOptions(cycle=Cycle.NEAREST, ambiguous_hour=AmbiguousHour.PM))
183
+ ```
184
+
185
+ | 옵션 | 기본값 | 설명 |
186
+ |---|---|---|
187
+ | `cycle` | `FUTURE` | 연/월/날짜가 생략된 표현("15일", "6월 3일", "금요일", "추석")의 주기. `FUTURE`: 지났으면 다음 주기("15일"@20일 → 다음 달, "오전 10시"@14시 → 내일) / `PAST`: 오늘 포함 가장 최근 / `NEAREST`: 이전·이번·다음 중 가장 가까운 것 / `CURRENT`: 넘기지 않음. "올해", "이번달"처럼 명시하거나 "지난", "오는"이 붙으면 그쪽을 따름 |
188
+ | `ambiguous_hour` | `NEAREST_FUTURE` | 오전/오후 없는 1~12시. 날짜가 없거나 오늘이면 기준 시각 이후 가장 가까운 시각("3시"@14:30 → 15:00, "2시"@14:30 → 내일 02:00), 다른 날이면 `DAYTIME` 규칙. 그 외 `DAYTIME`, `PM`, `AS_IS`, `CONTEXT`(같은 텍스트의 앞 시각·시간대 말로 정함: "오후 6시에 끝나고 8시 영화" → 20시. AI허브 대화 오전/오후 일치율 0.753, `DAYTIME` 0.681, [벤치마크](docs/benchmark.md)) |
189
+ | `daytime_start` | `7` | `DAYTIME` 규칙에서 오전으로 볼 최소 시각 (7 → 7~11시 오전, 1~6시 오후) |
190
+ | `timezone` | `None` | 기준 시각이 없을 때 서버 시각을 읽을 시간대 |
191
+ | `compact_dates` | `False` | 구분자 없는 `1015`, `261015`를 날짜로 인식 (오탐이 많아 기본 꺼짐) |
192
+ | `vague` | `False` | "최근", "요즘", "향후" 같은 막연한 때를 `Kind.VAGUE`로 인식. 값은 기준일, 방향은 `direction`(recent/past/future). 어휘는 `temporal/lexicon.py`의 `VAGUE_WORDS` 표 하나에서 넣고 뺌. AI허브 뉴스 재현율 0.765 → 0.808, 정밀도 0.938 → 0.928 ([벤치마크](docs/benchmark.md)) |
193
+
194
+ ### 용도별 권장 설정
195
+
196
+ 생략된 연·월·날짜를 어느 쪽 주기로 볼지는 글의 성격에 따라 다릅니다. 기본값은 약속·예약처럼 **앞으로 할 일**을 말하는 대화에 맞춰져 있습니다.
197
+
198
+ | 용도 | 설정 | 이유 |
199
+ |---|---|---|
200
+ | 채팅·예약·일정 요청 (기본) | `ParseOptions()` (`cycle=FUTURE`) | "15일에 보자", "3시"는 대부분 앞으로의 일 |
201
+ | 뉴스·보도자료 | `ParseOptions(cycle=Cycle.NEAREST)` | 지난 일과 앞으로의 일정이 섞임. AI허브 뉴스 값 정확도 0.841 → **0.908** |
202
+ | 일지·회고·완료 보고 | `ParseOptions(cycle=Cycle.PAST)` | 거의 전부 지난 일일 때만 ("15일에 다녀왔다") |
203
+ | 옛 글 다시 처리 | 위 설정 + `now=작성 시각` | "오늘", "지난주"는 글을 쓴 시각 기준이어야 함 |
204
+
205
+ ```python
206
+ from korean_datetime import Cycle, ParseOptions, TemporalParser
207
+
208
+ news = TemporalParser(ParseOptions(cycle=Cycle.NEAREST))
209
+ news.parse("29일 첫 신청을 받았다", now=datetime(2021, 11, 1)).start # 2021-10-29 (3일 전이 28일 뒤보다 가까움)
210
+ news.parse("15일부터 접수한다", now=datetime(2021, 11, 1)).start # 2021-11-15 (14일 뒤가 17일 전보다 가까움)
211
+ ```
212
+
213
+ AI허브 뉴스(Training)에서 `cycle`별 값 정확도: `FUTURE` 0.841, `PAST` 0.860, `CURRENT` 0.904, `NEAREST` 0.908 ([벤치마크](docs/benchmark.md)).
214
+ 뉴스를 "과거 우선"으로 처리하면 오히려 손해입니다. "3월부터 등교 권고", "19일부터 예약"처럼 앞으로의 일정 기사가 많기 때문입니다.
215
+
216
+ 그 밖의 규칙:
217
+ - 시간대가 있으면 우선: "밤 1시" → 다음 날 01:00, "밤 12시" → 다음 날 00:00, "자정" → 그날 24:00
218
+ - 24시간제/시각 표기는 그대로: "13시", "05:00", "3pm"
219
+ - 요일만 있으면 오늘을 제외한 다음 그 요일 ("금요일"@금요일 → 다음 주)
220
+ - 주차는 **달력 줄 기준**: 1일이 든 월~일 줄이 첫째 주, 말일이 든 줄이 마지막 주 ("셋째 주 토요일" = 달력 셋째 줄의 토요일, 앞뒤 달 날짜일 수 있음. 첫 줄의 앞 달 칸이 이미 지난 날이면 +1주)
221
+ - "셋째 토요일", "마지막 금요일", "셋째 주말"처럼 **주 없이** 쓰면 그 달의 N번째 요일
222
+ - 존재하지 않는 날짜("2월 30일", "2026-02-30", "13월", "25시", "32일")는 인식하지 않음. 단 연/월이 생략됐으면 그 날짜가 있는 다음 주기로 ("31일"@9월 → 10월 31일, "2월 29일"@2026 → 2028년)
223
+ - "지난 금요일" → 오늘 이전 가장 가까운 금요일, "이번 금요일" → 이번 주 금요일, "지난 추석" → 가장 최근 추석, "지난 저녁" → 어제 저녁
224
+ - "지난 3일" → 오늘 이전 가장 최근의 3일 / "지난 3일간", "지난 3일 동안" → 3일 전부터 어제까지 (`is_range=True`, 주·개월·년도 같음)
225
+ - "17시 5분 전"처럼 시각 뒤 오프셋은 **적용한 결과**가 미래가 되도록 (16:57에 말하면 내일 16:55)
226
+ - "다가오는", "오는", "매월·매달·매년·매주"가 붙으면 오늘 날짜라도 **시각이 지났으면** 다음 주기 ("다가오는 31일 오전 9시"@1월 31일 09:01 → 3월 31일). 붙지 않으면 날짜 단위로만 판단 ("31일 오전 9시"@같은 시각 → 오늘 09:00). 반복(매월)은 다음 한 번만 돌려줌
227
+ - "오늘로부터 한 달 후", "내일부터 3일 뒤"의 `부터`는 범위가 아니라 기준점. 단 `까지`가 붙으면 범위이고 끝은 **시작 기준** ("3시부터 30분 뒤까지" = 15:00~15:30, "오후 5시 5분 전부터 두 시간 후까지" = 16:55~18:55)
228
+ - "A부터 그다음 월요일까지"의 월요일은 A 뒤에서 찾음
229
+ - 앞 날짜의 전날/다음 날: "다음 주 화요일의 전날", "추석 다음날", "이번 주말 전날"(= 금요일). "그다음 날"처럼 날짜 없이 쓰면 같은 문장의 바로 앞 표현을 이어받음 ("다음 주 화요일과 그다음 날 오전 9시" → 화요일, 수요일 9시). 이어받을 표현이 없으면 인식하지 않음
230
+ - 정정 "A가 아니라 B"는 B만 돌려주고, B에 빠진 정보는 A에서 이어받음 ("내일 3시가 아니라 5시" → 내일 5시)
231
+ - 연 + N번째 요일/주: 1~4번째는 1월, 끝에서 1~4번째는 12월 ("올해 마지막 금요일", "내년 첫 월요일", "내년 마지막 주 금요일"). "올해 다섯째 금요일"처럼 달을 정할 수 없으면 인식하지 않음
232
+ - 월 뒤의 "N일 이후/이전/전/후"는 날짜 ("11월 9일 이후" = 11월 9일, "9일 후"가 아님). "지난 12월", "오는 3월"은 가장 가까운 지난/다가올 12월·3월
233
+ - 목록 "A과 B", "A 및 B"도 선택지처럼 B가 A의 빠진 정보를 이어받음 ("지난해 11월과 12월" → 둘 다 지난해)
234
+ - 동형어는 인식하지 않음: "전달"(전달하다), "공시", "한시 지원"(한시적), "S22 시리즈", "임상 2/3상", "낼 수"(내다), "차주의"(借主)·"금주를"(禁酒). "곧", "당장", "즉시"는 시각 앞에서만 ("곧 3시")
235
+ - **문맥이 필요한 표현은 인식하지 않음**: "그날", "그다음 주", "그해", "거기서 세 시간", "그 전날"처럼 앞 문장·대화의 때를 가리키는 말, "영업일"처럼 회사·기관 달력이 필요한 단위. 틀린 값을 내는 것보다 비워 두는 쪽을 택함. 인용문 속 "내일"은 호출하는 쪽이 그 글의 작성 시각을 `now`로 넘겨야 함
236
+ - 선택지 "A 아니면/또는/이나 B"는 결과를 각각 돌려주고, B에 빠진 정보는 A에서 이어받음 ("다음주 월요일 아니면 화요일" → 다음주 화요일, "내일 오후 3시 아니면 5시" → 내일 17시)
237
+ - 시간대 이름("뉴욕 시간")은 해석하지 않고 기준 시각의 시간대로 계산함. 시간대 변환은 호출하는 쪽에서
238
+ - 기간으로 쓰인 숫자는 날짜로 보지 않음 ("3일간", "7일 이내", "30분 동안")
239
+ - 범위 "A부터 B까지"에서 B의 생략된 연/월은 A 기준으로 해석하고, 뒤집힌 범위("10월 5일부터 10월 3일까지")는 합치지 않음
240
+ - 설날·추석 등 음력 명절은 천문 계산으로 변환 (`lunar_to_solar`, 1900~2100년, KST 기준)
241
+
242
+ ## 지원 표현
243
+
244
+ | 분류 | 예 |
245
+ |---|---|
246
+ | 상대 일 | 오늘, 내일, 모레, 글피, 그글피, 어제, 그저께, 그끄저께, 명일, 익일, 작일 |
247
+ | 기간 전후 | 3일 뒤, 이틀 후, 보름 뒤, 일주일 후, 2주 후, 한 달 뒤, 3개월 후, 1년 후, 크리스마스 3일 전 |
248
+ | 주/요일 | 지난 금요일, 이번주 금요일, 다음주 월요일, 다다음주, 저번주, 차주, 주말, 다음 주말, 주중, 다음주초, 목욜 |
249
+ | 월/일 | 10월 15일, 시월 십오일, 4월달 14일, 15일, 다음달 1일, 이번달 말일, 월말, 다음달 초, 중순 |
250
+ | 주차 | 다음달 첫째주 금요일, 마지막주 일요일, 12월 둘째주, 10월 셋째주 주말, 2주차 |
251
+ | 연 | 올해, 내년, 2027년, 상반기, 하반기, 연말, 내년 1분기 |
252
+ | 형식 | 2026-10-01, 2026.10.1, 20261101, 25.07.15, 10/15, 07-15, 2026-10-05T14:00, 10월 5일(월) 오후 2시 |
253
+ | 시각 | 3시, 세시 반, 열한시 십오분, 15:30, 오후 3시, 저녁 7시 반, 새벽 2시, 정오, 자정, 3pm |
254
+ | 시간대 | 새벽, 아침, 아침 일찍, 오전, 점심, 낮, 오후, 저녁, 퇴근하고, 해질녘, 밤, 심야 |
255
+ | 상대 시각 | 지금, 1시간 후, 30분 전, 2시간 30분 후, 한 시간 반 뒤, 10분 있다가, 17시 5분 전 |
256
+ | 기념일 | 신정, 설날, 설 연휴, 정월 대보름, 삼일절, 어린이날, 부처님 오신 날, 현충일, 광복절, 추석, 추석 연휴, 한글날, 크리스마스 이브 … |
257
+ | 범위 | 3시부터 5시까지, 오후 3시~5시, 3~5시, 내일부터 모레까지, 10월 3일부터 5일까지 |
258
+
259
+ ### 반복형 상대 표현
260
+
261
+ 반복되는 접두어는 표에 하나씩 적지 않고 규칙으로 선언합니다([`temporal/lexicon.py`](src/korean_datetime/temporal/lexicon.py)의 `RepeatRule`). 반복은 최대 4회까지 인식합니다.
262
+
263
+ | 규칙 | 예 |
264
+ |---|---|
265
+ | 다음·담 앞에 "다" | 다음주(+1), 다다음주(+2), 다다다음주(+3), 다담달(+2) |
266
+ | 저번·지난·전 앞에 같은 글자 | 저저번주(-2), 지지지난달(-3), 전전주(-2), 전전전주(-3) |
267
+ | 후년 앞에 "후", 작년 앞에 "재" | 후년(+2), 후후년(+3) / 재작년(-2), 재재작년(-3) |
268
+ | 제·저께 앞 "그·끄" 음절 수 | 그제(-2), 그끄제·그그제(-3), 그그그제(-4) |
269
+ | 글피 앞에 "그" | 글피(+3), 그글피(+4), 그그글피(+5) |
270
+
271
+ 한자어 표현: 금일·당일·명일·익일·명후일·익익일·작일·전일, 금주·차주·내주·익주, 금월·당월·익월·전월·전전월·내달, 금년·명년·익년·작년·전년.
272
+ `전주`(지명), `거년`(옛말)은 넣지 않았습니다.
273
+
274
+ ### 기념일·공휴일 데이터 주입
275
+
276
+ 내장 기념일은 [`temporal/data/holidays.json`](src/korean_datetime/temporal/data/holidays.json)(양력/음력, 연휴 기간, 다른 기념일 기준 오프셋)에 항목을 추가하면 됩니다.
277
+ 대체공휴일·임시공휴일처럼 정책으로 정해지는 날은 계산할 수 없으므로, **외부 데이터를 주입**합니다. 외부 라이브러리는 의존성이 아닙니다.
278
+
279
+ ```python
280
+ import holidays # 예: python-holidays. {date: 이름} 매핑이면 무엇이든 가능
281
+ from korean_datetime import BuiltinHolidays, ChainedHolidays, DateTableHolidays, ParseOptions, parse
282
+
283
+ external = DateTableHolidays(holidays.KR(years=range(2025, 2031), language="ko"),
284
+ aliases={"창립기념일": ["회사 생일"]})
285
+ options = ParseOptions(holidays=ChainedHolidays(external, BuiltinHolidays())) # 주입 데이터 우선, 없으면 내장
286
+ parse("추석 대체 휴일", options=options) # python-holidays의 이름 형식
287
+ ```
288
+
289
+ - `DateTableHolidays`: `{date: name}` 매핑이나 `(date, name)` 목록을 받습니다. 연속된 날짜는 하나의 기간이 됩니다.
290
+ - `ChainedHolidays`: 앞 달력이 우선이고, 그해 데이터가 없으면 다음 달력을 봅니다.
291
+ - 직접 구현하려면 `HolidayCalendar` 프로토콜(`names`, `span_names`, `span`)만 만족하면 됩니다.
292
+ - "쉬는 날인지" 판단 같은 공휴일 정책 API는 보류 상태입니다.
293
+
294
+ 어휘(상대 표현, 시간대, 방향어 등)는 `temporal/lexicon.py`의 표를 고치면 됩니다.
295
+
296
+ ## 회귀 테스트 / 내부 정답셋
297
+
298
+ 이 지표는 **지원하도록 정의한 표현의 회귀 검사**용입니다. `TOTAL = 1.000`은 내부 정답셋을 통과했다는 뜻이며, 한국어 문장 전반에서 정확도 100%라는 뜻이 아닙니다. 실제 문장 성능은 아래 [AIHub benchmark](#벤치마크-실제-문장)를 참고하세요.
299
+
300
+ 정답은 **기준 시각에서 계산하는 식**으로 정의합니다. 그래서 같은 정답셋을 어떤 날짜·시각으로도 돌릴 수 있습니다.
301
+
302
+ ```json
303
+ {"category": "weekday", "text": "금요일", "expect": "next(FRI)"}
304
+ {"category": "month_day", "text": "6월 3일", "expect": "future(md(6, 3))"}
305
+ {"category": "datetime", "text": "다음주 월요일 저녁 7시", "expect": "week(1).weekday(MON).at(19:00)"}
306
+ {"category": "range", "text": "3시부터 5시까지", "expect": "nearest(3:00).to_after(nearest(5:00))"}
307
+ {"category": "negative", "text": "오일 교환하러 가", "expect": "none"}
308
+ ```
309
+
310
+ 식의 **모든 타입·함수·메서드·규칙과 정답셋 관리 절차**는 [`docs/expectation-dsl.md`](docs/expectation-dsl.md)에 정리되어 있습니다.
311
+ 식 평가기는 파서와 같은 실수를 하지 않도록 파서 코드를 쓰지 않고 표준 `calendar`만으로 구현했습니다(음력만 공유).
312
+
313
+ | 파일 | 내용 |
314
+ |---|---|
315
+ | `tests/data/temporal_gold.jsonl` | 기대값 식 정답셋 611건, 22개 카테고리 (인식하지 않아야 하는 케이스 100건 포함) |
316
+ | `tests/data/temporal_anchor.jsonl` | **손으로 계산한 절대값** 485건, 기준 시각 56개(평상시 2026-09-28 14:30, 연말 2027-12-31 23:10, 윤일 2028-02-29 08:05, 월말·주말·자정 직전·직후 경계 사례). 식 평가기 자체를 검증하는 용도 |
317
+
318
+ | 실행 | 기준 시각 | 검사 건수 |
319
+ |---|---|---|
320
+ | `uv run pytest` | **고정 목록 292개**: 5일 간격, 월초·월말·윤일, 하루 중 8개 시각, KST 시간대 12개 | 약 18만 |
321
+ | (위와 함께) | **실행 시점의 실제 현재 시각** 1개 — 별도 스모크 테스트 | 611 |
322
+ | `uv run pytest -m slow` | 2026~2027 매일, 주 1회 하루 중 8개 시각, 경계일, KST (1,584개) | 약 97만 |
323
+
324
+ 테스트 요약에 두 가지 리포트가 나오고, 임계값(`tests/test_evaluation.py`)에 못 미치면 실패합니다.
325
+
326
+ ```
327
+ category n precision recall f1 value_acc accuracy
328
+ clock 13432 1.000 1.000 1.000 1.000 1.000
329
+ ...
330
+ TOTAL 178412 1.000 1.000 1.000 1.000 1.000
331
+
332
+ ambiguity tp fp fn precision recall
333
+ meridiem 5840 0 0 1.000 1.000
334
+ ...
335
+ TOTAL 31822 0 0 1.000 1.000
336
+ ```
337
+
338
+ - **값 지표**: precision / recall(인식 여부), `value_acc`(인식한 것 중 값까지 맞은 비율), accuracy(전체)
339
+ - **모호성 지표**: 종류별로 모호성 표시가 기대와 같은지. 기대 모호성은 식이 기준 시각마다 계산한 것과 정답셋의 `ambiguous`를 합친 것
340
+
341
+ 그 외 테스트:
342
+ - `test_invariants.py`: 기준일 730일 각각에서 "내일 = 기준+1", "지난 금요일은 기준 이전 7일 안의 금요일" 같은 규칙 위반이 0건인지 검증
343
+ - `test_holidays.py`: 음력 변환을 한국천문연구원 역서 날짜(2019~2026년 설날·추석·부처님 오신 날)로 검증
344
+ - `test_docs.py`: DSL 문서가 코드·정답셋과 맞는지 (모든 함수·메서드·카테고리가 문서에 있는지, 문서의 식 예시가 실행되는지)
345
+ - `test_vendoring.py`: 설치 없이 다른 이름으로 복사해도 동작하는지
346
+ - 전체: 테스트 493개(기본 492 + 전체 스윕 1), `ruff`·`mypy --strict` 통과
347
+
348
+ ## 벤치마크 (실제 문장)
349
+
350
+ 위 정답셋은 직접 만든 것이라 회귀 방지용입니다. 실제 성능은 외부 데이터로 잽니다.
351
+
352
+ 데이터 출처: AI허브(한국지능정보사회진흥원) [「시간 표현 탐지 데이터」](https://aihub.or.kr). 데이터는 이 저장소에 포함하지 않습니다.
353
+
354
+ **전체 표: [docs/benchmark.md](docs/benchmark.md)** (옵션별 × Training/Validation × 뉴스/대화/역사)
355
+
356
+ ### 다른 라이브러리와 비교
357
+
358
+ 같은 문장, 같은 기준 시각으로 [Duckling](https://github.com/facebook/duckling)(`ko_KR`), [dateparser](https://github.com/scrapinghub/dateparser)(`ko`)와 비교했습니다. 표현 유형별 표와 방법은 **[docs/comparison.md](docs/comparison.md)**에 있습니다.
359
+ AI허브 Training에서 표현 유형별로 뽑은 327개 표현과, 시간 표현이 없는 문장 200개를 썼습니다. 세 라이브러리 모두 기본 설정입니다.
360
+
361
+ | | korean-datetime | Duckling | dateparser |
362
+ |---|---:|---:|---:|
363
+ | 값까지 맞힌 비율 | **76%** (`cycle=nearest` 82%) | 72% | 8% |
364
+ | 위치만 찾은 비율 | 94% | **98%** | 19% |
365
+ | 시간 표현이 없는 문장의 오탐률 (낮을수록 좋음) | **2%** | 12% | 6% |
366
+ | 문장당 시간 (중앙값) | 0.22ms | 3~7ms ¹ | 0.2ms ² |
367
+
368
+ ¹ HTTP 서버 호출이고, arm64 맥에서 amd64 이미지를 에뮬레이션으로 돌린 값이라 실행마다 3~7ms로 흔들립니다(네 번 측정). 빈 문장만 보내도 1ms 안팎이 걸립니다. 리눅스 amd64에서 다시 재는 것이 공정합니다.
369
+ ² 한국어 문장 대부분에서 아무것도 찾지 못해 빨리 끝납니다.
370
+
371
+ - korean-datetime은 위치를 덜 찾습니다. 놓친 것은 대부분 혼자 쓴 "전날", "이튿날"로, 앞 문장을 가리켜서 일부러 비워 두는 표현입니다.
372
+ - 대신 찾은 것의 값이 더 정확하고 오탐이 적습니다. Duckling은 "2천 명분", "춘천" 같은 숫자와 낱말 조각을 시간으로 잡는 경우가 있습니다.
373
+ - Microsoft Recognizers-Text는 한국어 DateTime 모델이 아직 등록되지 않아 비교에서 뺐습니다.
374
+
375
+ 기본 설정, Training(뉴스 98,545 · 대화 91,876 · 역사 26,330개 표현):
376
+
377
+ | 분야 | 재현율 | 정밀도 | 값 정확도 |
378
+ |---|---:|---:|---:|
379
+ | 뉴스 | 0.765 | 0.938 | 0.841 |
380
+ | 대화 | 0.679 | 0.961 | 0.918 |
381
+ | 역사 | 0.662 | 0.968 | 0.864 |
382
+
383
+ 옵션을 바꿨을 때 가장 크게 달라지는 것 (Training):
384
+
385
+ | 옵션 | 지표 | 기본값 | 바꾼 값 | 언제 |
386
+ |---|---|---:|---:|---|
387
+ | `cycle=NEAREST` | 뉴스 값 정확도 | 0.841 | **0.908** | 뉴스·보도자료 |
388
+ | `ambiguous_hour=CONTEXT` | 대화 오전/오후 일치율 (695건) | 0.485 | **0.753** | 대화 기록·로그 (실시간 아님) |
389
+ | `vague=True` | 대화 재현율 / 정밀도 | 0.679 / 0.961 | **0.747** / 0.955 | "최근", "향후"도 필요할 때 |
390
+
391
+ 재현율이 낮은 건 대부분 이 라이브러리가 기본으로 다루지 않는 표현 때문입니다. "최근", "요즘"(대화 정답의 74%), "가을", "19세기", 문맥 지시("이날", "그때")가 그렇습니다.
392
+ 날짜·시각이 정해진 표현만 보면 재현율은 0.86~0.92입니다.
393
+
394
+ ```bash
395
+ uv run python scripts/aihub_benchmark.py "<…>/01-1.정식개방데이터" > docs/benchmark.md # 전체 표 다시 만들기
396
+ uv run python scripts/aihub_eval.py "<…>/Validation/02.라벨링데이터" --cycle nearest --samples 20 # 한 설정, 실패 예시
397
+ ```
398
+
399
+ ## 명령행
400
+
401
+ ```bash
402
+ uv run korean-datetime "내일 저녁 7시" --now 2026-09-28T14:30
403
+ {"text": "내일 저녁 7시", "span": [0, 8], "kind": "datetime", "grain": "hour", ...}
404
+
405
+ uv run korean-datetime "내일 3시에 보고 모레 5시" --all --ambiguous-hour pm
406
+ ```
407
+
408
+ ## 구조
409
+
410
+ ```
411
+ docs/
412
+ ├── tutorial.md # 상황별 사용법 (예시 결과는 tests/test_tutorial.py가 실제로 실행해 확인)
413
+ ├── benchmark.md # AI허브 데이터 옵션별 벤치마크 (scripts/aihub_benchmark.py가 생성)
414
+ ├── comparison.md # Duckling·dateparser 비교 (scripts/compare_libraries.py가 생성)
415
+ └── expectation-dsl.md # 기대값 식(DSL) 전체 정리, 정답셋 관리 절차
416
+ src/korean_datetime/
417
+ ├── core/ # 날짜/시간이 쓰는 기반: scanner(경계·조사), numerals(한글 수사), clock(기준 시각), types, evaluation
418
+ ├── temporal/ # 날짜/시간
419
+ │ ├── rules.py # 정규식 토큰 규칙 (scan)
420
+ │ ├── postprocess.py # 기간 + 방향 병합 ("1시간 30분 후")
421
+ │ ├── frame.py # 토큰 → 표현 슬롯 조립 (순위가 커지는 방향으로만)
422
+ │ ├── resolve*.py # 슬롯 → 구간 해석
423
+ │ ├── ranges.py # "A부터 B까지"
424
+ │ ├── ambiguity.py # 모호성 종류
425
+ │ ├── relative.py # 반복형 상대 표현 문법
426
+ │ ├── holiday_calendar.py # 기념일 달력 (외부 데이터 주입 지점)
427
+ │ ├── lunar.py # 음력 변환 (천문 계산)
428
+ │ ├── expectation.py # 기대값 식 (기준 시각 무관 정답셋)
429
+ │ ├── lexicon.py, data/holidays.json # 어휘·기념일 데이터
430
+ │ └── parser.py # 공개 API
431
+ └── __main__.py # 명령행
432
+ ```
433
+
434
+ ## 설치 없이 복사해서 쓰기 (vendoring)
435
+
436
+ 외부 의존성이 없고 모든 import가 상대 경로라서, **`src/korean_datetime` 폴더를 통째로 복사**하면 됩니다. 이름과 위치는 자유입니다.
437
+
438
+ ```bash
439
+ uv run python scripts/vendor.py <내 프로젝트>/myapp/_vendor # 복사 + VENDORED.json(버전·파일 해시)
440
+ uv run python scripts/vendor.py --check <내 프로젝트>/myapp/_vendor/korean_datetime # 복사본이 수정됐는지
441
+ ```
442
+ ```python
443
+ from myapp._vendor.korean_datetime import parse, reference_time
444
+ ```
445
+
446
+ - **필요한 것**: Python 3.10 이상. `temporal/data/holidays.json`을 반드시 같이 복사해야 합니다(폴더째 복사하면 포함됨).
447
+ - **보장**: `tests/test_vendoring.py`가 설치된 패키지 없이(`python -S`) 다른 이름으로 복사한 사본을 실행해서 확인합니다. 패키지 이름을 절대 경로로 쓰는 코드가 들어오면 이 테스트가 실패합니다.
448
+ - **복사본은 고치지 않기**: 수정은 이 저장소에서 하고 `vendor.py`로 다시 복사합니다. 복사본이 수정돼 있으면 `vendor.py`가 덮어쓰기를 거부합니다(`--force` 필요). `VENDORED.json`이 없는 폴더는 `--force`로도 덮어쓰지 않습니다.
449
+ - **주의**: 설치본과 복사본을 한 프로세스에서 같이 쓰면 서로 다른 모듈입니다. 예를 들어 설치본의 `reference_time()`은 복사본에 적용되지 않습니다. 한 가지만 쓰세요.
450
+ - **빼도 되는 것** (런타임에 안 씀): `__main__.py`(CLI), `temporal/expectation.py`·`temporal/evaluation.py`·`core/evaluation.py`(정답셋 평가 도구). 약 1,000줄이지만 `__init__.py`의 import도 함께 정리해야 해서, 보통은 통째로 복사하는 쪽이 간단합니다.
451
+
452
+ ## 1.0.0 변경 사항 (0.1.0 대비)
453
+
454
+ API를 새로 설계했습니다. 기존 `DateNormalizer`/`TimeNormalizer`, `extract_and_normalize`, `time_range`/`reference_type`, `PatternNormalizer`/`Rule`, `fixed_now`는 제거되었습니다.
455
+ 구 코드에 있던 주요 오류("12월25일" → 2월 25일, "다음달 15일" → 이번 달, "05:00" → 날짜 5일·17시, "열한시 십오분" → 23:10, "24시" 예외, 설날/추석 미지원)는 재설계로 해결되었습니다.
456
+
457
+ ## 라이선스
458
+
459
+ [MIT](LICENSE). 벤치마크에 쓴 AI허브 데이터는 포함하지 않으며, 그 이용 조건은 AI허브를 따릅니다.