structverify 0.3.0__py3-none-any.whl

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 (168) hide show
  1. structverify/__init__.py +83 -0
  2. structverify/adaptation/__init__.py +0 -0
  3. structverify/adaptation/adapter_trainer.py +341 -0
  4. structverify/adaptation/feedback_store.py +31 -0
  5. structverify/adaptation/kosis_crawler.py +317 -0
  6. structverify/adaptation/sample_builder.py +149 -0
  7. structverify/adaptation/synthetic_generator.py +320 -0
  8. structverify/adaptation/update_embeddings.py +178 -0
  9. structverify/agent/__init__.py +21 -0
  10. structverify/agent/builder_agent.py +226 -0
  11. structverify/agent/conformance_agent.py +171 -0
  12. structverify/agent/dependency_planner.py +151 -0
  13. structverify/agent/indexing_agent.py +153 -0
  14. structverify/agent/indexing_planner.py +169 -0
  15. structverify/agent/integration_example.py +182 -0
  16. structverify/agent/loop.py +1165 -0
  17. structverify/agent/memory.py +207 -0
  18. structverify/agent/planner.py +817 -0
  19. structverify/agent/prompts/__init__.py +15 -0
  20. structverify/agent/prompts/planner_prompts.py +219 -0
  21. structverify/agent/prompts/reflect_prompts.py +387 -0
  22. structverify/agent/reflect.py +227 -0
  23. structverify/agent/runtime_agent.py +1272 -0
  24. structverify/agent/schemas.py +262 -0
  25. structverify/agent/source_profiler.py +229 -0
  26. structverify/agent/tools/__init__.py +64 -0
  27. structverify/agent/tools/base.py +222 -0
  28. structverify/agent/tools/calculate.py +244 -0
  29. structverify/agent/tools/catalog_search.py +859 -0
  30. structverify/agent/tools/deep_explore.py +293 -0
  31. structverify/agent/tools/explore_catalog.py +423 -0
  32. structverify/agent/tools/fetch_evidence.py +922 -0
  33. structverify/agent/tools/finish.py +423 -0
  34. structverify/agent/tools/meta_explore.py +267 -0
  35. structverify/agent/tools/query_rewriter.py +134 -0
  36. structverify/agent/tools/read_original.py +144 -0
  37. structverify/agent/tools/replan.py +365 -0
  38. structverify/agent/workspace.py +958 -0
  39. structverify/api.py +804 -0
  40. structverify/config/default.yaml +350 -0
  41. structverify/core/__init__.py +0 -0
  42. structverify/core/config_loader.py +30 -0
  43. structverify/core/pipeline.py +280 -0
  44. structverify/core/schemas.py +362 -0
  45. structverify/detection/__init__.py +26 -0
  46. structverify/detection/_config.py +163 -0
  47. structverify/detection/_llm.py +24 -0
  48. structverify/detection/candidate/__init__.py +1 -0
  49. structverify/detection/candidate/heuristic.py +60 -0
  50. structverify/detection/candidate/llm.py +51 -0
  51. structverify/detection/candidate_scorer.py +81 -0
  52. structverify/detection/claim_detector.py +164 -0
  53. structverify/detection/claims/__init__.py +1 -0
  54. structverify/detection/claims/worthiness.py +142 -0
  55. structverify/detection/domain/__init__.py +1 -0
  56. structverify/detection/domain/classify.py +84 -0
  57. structverify/detection/domain/preview.py +36 -0
  58. structverify/detection/domain/registry.py +99 -0
  59. structverify/detection/domain_classifier.py +75 -0
  60. structverify/detection/prompts/__init__.py +1 -0
  61. structverify/detection/prompts/candidate.py +38 -0
  62. structverify/detection/prompts/claim_worthiness.py +48 -0
  63. structverify/detection/prompts/domain.py +41 -0
  64. structverify/detection/prompts/schema.py +508 -0
  65. structverify/detection/prompts_loader.py +167 -0
  66. structverify/detection/schema/__init__.py +1 -0
  67. structverify/detection/schema/expand.py +83 -0
  68. structverify/detection/schema/induce.py +441 -0
  69. structverify/detection/schema/regenerate.py +162 -0
  70. structverify/detection/schema/temporal_hints.py +130 -0
  71. structverify/detection/schema/validate.py +193 -0
  72. structverify/detection/schema_inductor.py +112 -0
  73. structverify/detection/synthetic_generator.py +270 -0
  74. structverify/explanation/__init__.py +0 -0
  75. structverify/explanation/_config.py +18 -0
  76. structverify/explanation/_llm.py +25 -0
  77. structverify/explanation/explainer.py +183 -0
  78. structverify/explanation/fallback.py +29 -0
  79. structverify/explanation/formatters.py +75 -0
  80. structverify/explanation/prompts/__init__.py +1 -0
  81. structverify/explanation/prompts/match.py +27 -0
  82. structverify/explanation/prompts/mismatch.py +20 -0
  83. structverify/explanation/prompts/multihop.py +16 -0
  84. structverify/explanation/prompts/unverifiable.py +17 -0
  85. structverify/graph/__init__.py +0 -0
  86. structverify/graph/claim_graph.py +226 -0
  87. structverify/graph/document_graph.py +487 -0
  88. structverify/graph/graph_builder.py +238 -0
  89. structverify/graph/graph_multihop.py +335 -0
  90. structverify/graph/graph_store.py +281 -0
  91. structverify/graph/provenance.py +52 -0
  92. structverify/memory/__init__.py +44 -0
  93. structverify/memory/agent_memory.py +142 -0
  94. structverify/memory/embedder.py +69 -0
  95. structverify/memory/exemplar_store.py +241 -0
  96. structverify/memory/normalizer.py +91 -0
  97. structverify/memory/schema.py +119 -0
  98. structverify/memory/storage/__init__.py +29 -0
  99. structverify/memory/storage/jsonl_store.py +117 -0
  100. structverify/memory/working_memory.py +370 -0
  101. structverify/preprocessing/Dockerfile.scraper +27 -0
  102. structverify/preprocessing/__init__.py +0 -0
  103. structverify/preprocessing/extractor.py +574 -0
  104. structverify/preprocessing/pdf/__init__.py +16 -0
  105. structverify/preprocessing/pdf/fields.py +95 -0
  106. structverify/preprocessing/pdf/markdown.py +107 -0
  107. structverify/preprocessing/pdf/models.py +34 -0
  108. structverify/preprocessing/pdf/ocr.py +172 -0
  109. structverify/preprocessing/pdf/pipeline.py +74 -0
  110. structverify/preprocessing/pdf/reader.py +119 -0
  111. structverify/preprocessing/pdf/scoring.py +61 -0
  112. structverify/preprocessing/scraper_sandbox.py +561 -0
  113. structverify/preprocessing/segmenter.py +48 -0
  114. structverify/preprocessing/sir_builder.py +240 -0
  115. structverify/progress.py +591 -0
  116. structverify/retrieval/__init__.py +0 -0
  117. structverify/retrieval/base.py +208 -0
  118. structverify/retrieval/base_connector.py +85 -0
  119. structverify/retrieval/catalog_ranker.py +300 -0
  120. structverify/retrieval/catalog_search.py +583 -0
  121. structverify/retrieval/chunking.py +92 -0
  122. structverify/retrieval/custom_csv_source.py +386 -0
  123. structverify/retrieval/custom_db_source.py +396 -0
  124. structverify/retrieval/custom_docs_source.py +152 -0
  125. structverify/retrieval/dimension_resolver.py +281 -0
  126. structverify/retrieval/evidence_subgraph.py +63 -0
  127. structverify/retrieval/kosis_connector.py +1192 -0
  128. structverify/retrieval/kosis_relevance.py +142 -0
  129. structverify/retrieval/kosis_source.py +1541 -0
  130. structverify/retrieval/query_builder.py +72 -0
  131. structverify/retrieval/registry.py +133 -0
  132. structverify/retrieval/relevance_judge.py +141 -0
  133. structverify/retrieval/row_matcher.py +267 -0
  134. structverify/storage/__init__.py +0 -0
  135. structverify/storage/db_manager.py +157 -0
  136. structverify/storage/dwh_manager.py +92 -0
  137. structverify/storage/init_db.py +99 -0
  138. structverify/storage/raw_storage.py +29 -0
  139. structverify/training/__init__.py +26 -0
  140. structverify/training/curator.py +124 -0
  141. structverify/training/dataset.py +134 -0
  142. structverify/training/doctor.py +99 -0
  143. structverify/training/evalgate.py +96 -0
  144. structverify/training/generate.py +101 -0
  145. structverify/training/loop.py +116 -0
  146. structverify/training/recipe/train_mlx.py +99 -0
  147. structverify/training/recipe/train_qlora.py +104 -0
  148. structverify/training/tasks.py +79 -0
  149. structverify/utils/__init__.py +0 -0
  150. structverify/utils/embedding_client.py +248 -0
  151. structverify/utils/llm_client.py +809 -0
  152. structverify/utils/logger.py +81 -0
  153. structverify/verification/__init__.py +0 -0
  154. structverify/verification/_config.py +45 -0
  155. structverify/verification/adapters.py +405 -0
  156. structverify/verification/conformance.py +117 -0
  157. structverify/verification/decide_verdict.py +216 -0
  158. structverify/verification/decide_verdict_agent.py +454 -0
  159. structverify/verification/growth_diff.py +267 -0
  160. structverify/verification/row_match.py +345 -0
  161. structverify/verification/units.py +64 -0
  162. structverify/verification/verdict_thresholds.py +232 -0
  163. structverify/verification/verifier.py +84 -0
  164. structverify-0.3.0.dist-info/METADATA +903 -0
  165. structverify-0.3.0.dist-info/RECORD +168 -0
  166. structverify-0.3.0.dist-info/WHEEL +5 -0
  167. structverify-0.3.0.dist-info/licenses/LICENSE +21 -0
  168. structverify-0.3.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,487 @@
1
+ """
2
+ graph/document_graph.py — 문서 레벨 시간 그래프 빌더 (멀티홉 핵심)
3
+
4
+ [설계 의도]
5
+ LLM agent가 문서 전체를 한 번에 보고 다음 정보를 추출하여 그래프 노드/엣지로 박는다:
6
+ - 문서의 anchor 시점 (이 문서가 기준으로 삼는 연도/시점)
7
+ - 모든 시간 표현 (어떤 언어든, 어떤 형태든)
8
+ - 시간 표현 간 참조 관계 (coreference)
9
+
10
+ [멀티홉 검증 흐름]
11
+ ClaimNode "작년 평균기온 14.8도"
12
+ ─ HAS_TEMPORAL → TemporalExprNode("작년", sent=s0002)
13
+ ├─ RELATIVE_TO → DocumentNode(anchor_year=2024)
14
+ └─ RESOLVES_TO → ResolvedTimeNode("2023")
15
+
16
+ schema_inductor가 ClaimGraph로 traverse하여 resolved time을 prompt에 주입
17
+ verifier가 KOSIS row 매칭 시 resolved time을 사용
18
+
19
+ [도메인 확장성]
20
+ - LLM agent가 모든 해석을 담당. 룰 매핑 일체 없음.
21
+ - 한국어 "작년/재작년/지난해", 영어 "last year/yesterday", 중국어 "去年" 모두 처리.
22
+ - 새 도메인 추가 시 prompt 수정 없이 작동 (LLM 일반화).
23
+
24
+ [참고]
25
+ - HOVER (Jiang et al., EMNLP 2020): 멀티홉 사실 검증
26
+ - GraphRAG (arXiv 2501.00309): 그래프 기반 다중 hop 추론
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import re
31
+ from typing import Any
32
+
33
+ from structverify.core.schemas import (
34
+ GraphEdge, GraphEdgeType, GraphNode, GraphNodeType, SIRDocument,
35
+ )
36
+ from structverify.utils.llm_client import LLMClient
37
+ from structverify.utils.logger import get_logger
38
+
39
+ logger = get_logger(__name__)
40
+
41
+
42
+ # ── LLM agent prompt ────────────────────────────────────────────────────────
43
+ # 핵심: 룰 매핑 없음, LLM이 anchor 추출 + 모든 시간 표현 + coref를 동시에 풀어냄
44
+
45
+ _TEMPORAL_AGENT_PROMPT = """당신은 문서의 시간 정보를 정밀하게 분석하는 전문가입니다.
46
+ 문서 전체를 읽고 두 가지 정보를 JSON으로 추출하세요.
47
+
48
+ # 추출 대상
49
+
50
+ ## 1) anchor: 이 문서가 "기준 시점"으로 삼는 시점
51
+ - ⚠️ **최우선 규칙**: 본문에 기사 작성일/발행일이 있으면 (예: "작성일자 2025-01-01",
52
+ "2025.01.01", "입력 2025-01-01 11:34", "발행: 2025년 1월 1일") **그 날짜의 연도를
53
+ anchor로 무조건 선택**한다. 본문 내용("작년 기온이 가장 높았다" 등)이 다른 연도를
54
+ 암시하더라도 작성일 연도가 절대 우선이다. "작년/지난해"는 작성일 연도 - 1 이다.
55
+ - 작성일이 없을 때만: 본문에 "OOOO년은", "지난 OOOO년" 등으로 명시된 연도를 anchor로
56
+ - 명시된 anchor가 여러 개라면 본문 서술 시점의 기준이 되는 것을 선택
57
+ - 본문에 명시되지 않은 경우 anchor_year=null
58
+ - 무엇을 근거로 anchor를 정했는지 anchor_evidence에 짧게 기록
59
+ (작성일을 썼다면 "작성일자 2025-01-01 → anchor_year=2025"처럼 기록)
60
+
61
+ ## 2) temporal_expressions: 본문의 모든 시간 표현 목록
62
+ 모든 시간 표현을 빠짐없이 수집하고, anchor와 다른 문장 참조를 활용해 절대 시점으로 풀어내세요.
63
+
64
+ 시간 표현의 예시 (어떤 언어든):
65
+ - 상대 표현: "작년", "재작년", "지난해", "올해", "내년", "전년", "last year", "去年"
66
+ - 부분 시점: "9월", "1∼11월", "Q3", "지난 분기", "여름철"
67
+ - 참조 표현: "이는", "같은 기간", "the same period", "それ"
68
+ - 절대 표현: "2024년", "2024-09", "1973년"
69
+
70
+ 각 표현마다:
71
+ - sent_id: 그 표현이 등장하는 문장 id
72
+ ⚠️ **반드시 입력에서 [...] 안에 표시된 정확한 ID 문자열을 그대로 복사**할 것.
73
+ 예: 입력에 "[b0001_s0003] ..."이면 sent_id="b0001_s0003" 으로 답하세요.
74
+ prefix를 생략하거나 줄여서 "s0003"으로 답하지 마세요.
75
+ - expression: 원문 그대로의 표현
76
+ - resolved: 절대 시점 문자열로 풀어냄
77
+ · 단일 연도: "2023"
78
+ · 연-월: "2024-09"
79
+ · 기간 범위: "2022-01..2022-11"
80
+ · 풀어낼 수 없으면 null
81
+ - resolution_basis: 풀이 근거를 한 줄로
82
+ · 예: "anchor_year - 1 = 2023"
83
+ · 예: "s0009의 '1∼11월'을 anchor_year-2(2022)에 적용"
84
+ - refers_to_sent_id: "이는", "같은 기간" 등 다른 문장의 시점을 가리키는 표현이면
85
+ 그 참조 대상 문장의 sent_id (역시 입력 형식 그대로). 그렇지 않으면 null.
86
+
87
+ # 입력 문서
88
+
89
+ {document}
90
+
91
+ # 출력 (JSON)
92
+
93
+ 다른 설명 없이 JSON만 출력하세요.
94
+ """
95
+
96
+
97
+ # Structured Outputs 강제 — JSON 파싱 실패 없음
98
+ _TEMPORAL_OUTPUT_SCHEMA: dict[str, Any] = {
99
+ "type": "object",
100
+ "properties": {
101
+ "anchor_year": {
102
+ "type": ["integer", "null"],
103
+ "description": "문서의 기준 연도. 명시되지 않았으면 null."
104
+ },
105
+ "anchor_evidence": {
106
+ "type": "string",
107
+ "description": "anchor 추출 근거가 된 문장 또는 짧은 메모."
108
+ },
109
+ "temporal_expressions": {
110
+ "type": "array",
111
+ "items": {
112
+ "type": "object",
113
+ "properties": {
114
+ "sent_id": {"type": "string"},
115
+ "expression": {"type": "string"},
116
+ "resolved": {"type": ["string", "null"]},
117
+ "resolution_basis": {"type": "string"},
118
+ "refers_to_sent_id": {"type": ["string", "null"]},
119
+ },
120
+ "required": ["sent_id", "expression", "resolved", "resolution_basis"],
121
+ },
122
+ },
123
+ },
124
+ "required": ["anchor_year", "anchor_evidence", "temporal_expressions"],
125
+ }
126
+
127
+
128
+ # ── 메인 빌더 ───────────────────────────────────────────────────────────────
129
+
130
+ async def build_document_temporal_graph(
131
+ sir_doc: SIRDocument,
132
+ config: dict | None = None,
133
+ ) -> tuple[list[GraphNode], list[GraphEdge]]:
134
+ """
135
+ 문서 레벨 LLM agent 1회 호출 → 시간 그래프 빌드.
136
+
137
+ Args:
138
+ sir_doc: SIR Document (blocks/sentences 포함)
139
+ config: LLM 설정
140
+
141
+ Returns:
142
+ ([DocumentNode, TemporalExprNodes, ResolvedTimeNodes],
143
+ [HAS_TEMPORAL, RELATIVE_TO, RESOLVES_TO, REFERS_TO 엣지])
144
+
145
+ Note:
146
+ - SentenceNode/BlockNode는 graph_builder.py가 만드는 기존 노드를 재사용
147
+ (sent.graph_anchor_id 그대로 참조)
148
+ - LLM 1회 호출만으로 전체 문서의 시간 정보 추출
149
+ - 실패 시 빈 리스트 반환 — 다운스트림은 anchor 정보 없이 작동
150
+ """
151
+ config = config or {}
152
+ llm = LLMClient(config=config.get("llm", {}))
153
+
154
+ doc_text = _format_document_with_ids(sir_doc)
155
+ if not doc_text.strip():
156
+ logger.warning("temporal graph: 빈 문서 — skip")
157
+ return [], []
158
+
159
+ try:
160
+ result = await llm.generate_structured(
161
+ prompt=_TEMPORAL_AGENT_PROMPT.format(document=doc_text),
162
+ schema=_TEMPORAL_OUTPUT_SCHEMA,
163
+ system_prompt=(
164
+ "문서 시간 분석 전문가. 모든 시간 표현을 빠짐없이 수집하고, "
165
+ "anchor와 문장 간 참조를 통해 가능한 한 절대 시점으로 풀어내세요. "
166
+ "JSON으로만 답하세요."
167
+ ),
168
+ )
169
+ except Exception as e:
170
+ logger.warning(f"temporal agent 호출 실패: {e}")
171
+ return [], []
172
+
173
+ return _materialize_graph(sir_doc, result)
174
+
175
+
176
+ # ── 그래프 materialize ──────────────────────────────────────────────────────
177
+
178
+ def _materialize_graph(
179
+ sir_doc: SIRDocument,
180
+ agent_result: dict[str, Any],
181
+ ) -> tuple[list[GraphNode], list[GraphEdge]]:
182
+ """LLM 출력 JSON → GraphNode/GraphEdge 변환."""
183
+ nodes: list[GraphNode] = []
184
+ edges: list[GraphEdge] = []
185
+
186
+ # 1) DocumentNode
187
+ doc_node_id = f"node:doc:{sir_doc.doc_id.hex[:8]}"
188
+ anchor_year = agent_result.get("anchor_year")
189
+ anchor_evidence = agent_result.get("anchor_evidence")
190
+
191
+ # ── [v6.19] 작성일 기반 anchor 덮어쓰기 ──────────────────────────────
192
+ # LLM이 본문 내용에 끌려 anchor를 잘못 잡을 수 있어, 본문에 명시된
193
+ # 작성일/발행일 연도가 있으면 그것을 anchor로 강제한다.
194
+ article_year = _extract_article_year(sir_doc)
195
+ anchor_corrected = False
196
+ if article_year is not None and article_year != anchor_year:
197
+ logger.info(
198
+ f"temporal graph: anchor_year를 작성일 기준으로 보정 "
199
+ f"{anchor_year} → {article_year} (LLM 추론값 무시)"
200
+ )
201
+ anchor_year = article_year
202
+ anchor_corrected = True
203
+ anchor_evidence = (
204
+ f"본문 작성일 기준 anchor_year={article_year} "
205
+ f"(LLM 추론값 대신 보정)"
206
+ )
207
+
208
+ nodes.append(GraphNode(
209
+ node_id=doc_node_id,
210
+ node_type=GraphNodeType.DOCUMENT,
211
+ label=f"Document(anchor_year={anchor_year})",
212
+ properties={
213
+ "anchor_year": anchor_year,
214
+ "anchor_evidence": anchor_evidence,
215
+ "source_uri": sir_doc.source_uri,
216
+ "source_type": sir_doc.source_type.value if sir_doc.source_type else None,
217
+ },
218
+ ))
219
+
220
+ # sent_id → graph_anchor_id 매핑 (sentence 노드 id 검증용)
221
+ sent_id_to_anchor = _build_sent_anchor_map(sir_doc)
222
+
223
+ # 2) TemporalExprNodes + 엣지들
224
+ seen_resolved: set[str] = set()
225
+ expressions = agent_result.get("temporal_expressions", []) or []
226
+
227
+ skipped_unmatched = 0
228
+ skipped_ambiguous = 0
229
+
230
+ for i, te in enumerate(expressions):
231
+ sent_id = te.get("sent_id")
232
+ expression = te.get("expression", "")
233
+ resolved = te.get("resolved")
234
+ basis = te.get("resolution_basis", "") or ""
235
+ refers_to = te.get("refers_to_sent_id")
236
+
237
+ # [v6.19] anchor가 작성일 기준으로 보정됐으면 상대 표현 resolved도 재계산
238
+ if anchor_corrected and anchor_year is not None:
239
+ new_resolved = _recompute_resolved(
240
+ expression, basis, resolved, anchor_year
241
+ )
242
+ if new_resolved != resolved:
243
+ logger.info(
244
+ f"temporal: '{expression}' resolved 재계산 "
245
+ f"{resolved!r} → {new_resolved!r} (anchor={anchor_year})"
246
+ )
247
+ resolved = new_resolved
248
+
249
+ if not sent_id or not expression:
250
+ continue
251
+
252
+ # 정확 매칭 → 실패 시 suffix 매칭 (LLM이 prefix 누락하는 경우 방어)
253
+ sent_anchor = _resolve_sent_id(sent_id, sent_id_to_anchor)
254
+ if sent_anchor is None:
255
+ skipped_unmatched += 1
256
+ logger.warning(
257
+ f"temporal: sent_id={sent_id!r} 매칭 실패 (정확/suffix 모두) — skip"
258
+ )
259
+ continue
260
+ if sent_anchor == "AMBIGUOUS":
261
+ skipped_ambiguous += 1
262
+ logger.warning(
263
+ f"temporal: sent_id={sent_id!r} 모호 (suffix 매칭 다수) — skip"
264
+ )
265
+ continue
266
+
267
+ # TemporalExpr 노드
268
+ te_node_id = f"node:temporal:{sent_id}:{i}"
269
+ nodes.append(GraphNode(
270
+ node_id=te_node_id,
271
+ node_type=GraphNodeType.TEMPORAL_EXPR,
272
+ label=expression,
273
+ properties={
274
+ "sent_id": sent_id,
275
+ "expression": expression,
276
+ "resolution_basis": basis,
277
+ "resolved_value": resolved,
278
+ },
279
+ ))
280
+
281
+ # Sentence ─HAS_TEMPORAL→ TemporalExpr
282
+ edges.append(GraphEdge(
283
+ from_node=sent_anchor,
284
+ to_node=te_node_id,
285
+ edge_type=GraphEdgeType.HAS_TEMPORAL,
286
+ ))
287
+
288
+ # TemporalExpr ─RELATIVE_TO→ Document (anchor에 의존하는 표현)
289
+ # LLM이 resolution_basis에 anchor를 언급했는지로 판단
290
+ # (룰 매핑이 아니라 LLM이 자체 판단한 결과를 그래프에 박음)
291
+ if anchor_year is not None and "anchor" in basis.lower():
292
+ edges.append(GraphEdge(
293
+ from_node=te_node_id,
294
+ to_node=doc_node_id,
295
+ edge_type=GraphEdgeType.RELATIVE_TO,
296
+ ))
297
+
298
+ # TemporalExpr ─RESOLVES_TO→ ResolvedTime
299
+ if resolved:
300
+ rt_node_id = f"node:resolved:{resolved}"
301
+ if rt_node_id not in seen_resolved:
302
+ nodes.append(GraphNode(
303
+ node_id=rt_node_id,
304
+ node_type=GraphNodeType.RESOLVED_TIME,
305
+ label=resolved,
306
+ properties={"value": resolved},
307
+ ))
308
+ seen_resolved.add(rt_node_id)
309
+ edges.append(GraphEdge(
310
+ from_node=te_node_id,
311
+ to_node=rt_node_id,
312
+ edge_type=GraphEdgeType.RESOLVES_TO,
313
+ ))
314
+
315
+ # TemporalExpr ─REFERS_TO→ 다른 Sentence (coreference)
316
+ # 예: "재작년 같은 기간" → 앞 문장 "작년 1∼11월"을 참조
317
+ if refers_to:
318
+ ref_anchor = _resolve_sent_id(refers_to, sent_id_to_anchor)
319
+ if ref_anchor and ref_anchor != "AMBIGUOUS":
320
+ edges.append(GraphEdge(
321
+ from_node=te_node_id,
322
+ to_node=ref_anchor,
323
+ edge_type=GraphEdgeType.REFERS_TO,
324
+ ))
325
+
326
+ logger.info(
327
+ f"document temporal graph: anchor_year={anchor_year}, "
328
+ f"temporal_expressions={len(expressions)} "
329
+ f"(skipped_unmatched={skipped_unmatched}, skipped_ambiguous={skipped_ambiguous}), "
330
+ f"nodes={len(nodes)}, edges={len(edges)}"
331
+ )
332
+ return nodes, edges
333
+
334
+
335
+ # ── 헬퍼 ────────────────────────────────────────────────────────────────────
336
+
337
+ def _resolve_sent_id(
338
+ sent_id: str,
339
+ sent_id_to_anchor: dict[str, str],
340
+ ) -> str | None:
341
+ """
342
+ LLM이 답한 sent_id를 sir_doc의 실제 sent_id에 매칭.
343
+
344
+ 1) 정확 매칭 우선
345
+ 2) 실패 시 suffix 매칭 (LLM이 "b0001_s0003"을 "s0003"으로 줄여 답한 경우)
346
+ 3) suffix 매칭이 여러 개면 모호 → "AMBIGUOUS" 반환 (skip 시그널)
347
+ 4) 매칭 안 되면 None
348
+
349
+ Returns:
350
+ graph_anchor_id (str), "AMBIGUOUS", 또는 None
351
+ """
352
+ # 1) 정확 매칭
353
+ direct = sent_id_to_anchor.get(sent_id)
354
+ if direct:
355
+ return direct
356
+
357
+ # 2) suffix 매칭 — block prefix 없이 답한 경우 ("s0003" ← "b0001_s0003")
358
+ candidates = [
359
+ v for k, v in sent_id_to_anchor.items()
360
+ if k.endswith(f"_{sent_id}") or k == sent_id
361
+ ]
362
+ if len(candidates) == 1:
363
+ return candidates[0]
364
+ if len(candidates) > 1:
365
+ return "AMBIGUOUS" # 여러 block에 같은 suffix → 어느 거 가리키는지 모름
366
+ return None
367
+
368
+ def _format_document_with_ids(sir_doc: SIRDocument) -> str:
369
+ """LLM 입력용으로 문서를 [sent_id] 접두사와 함께 포맷."""
370
+ lines = []
371
+ for block in sir_doc.blocks:
372
+ for sent in block.sentences:
373
+ text = sent.text.strip()
374
+ if text:
375
+ lines.append(f"[{sent.sent_id}] {text}")
376
+ return "\n".join(lines)
377
+
378
+
379
+ def _build_sent_anchor_map(sir_doc: SIRDocument) -> dict[str, str]:
380
+ """sent_id → graph_anchor_id 매핑."""
381
+ mapping = {}
382
+ for block in sir_doc.blocks:
383
+ for sent in block.sentences:
384
+ if sent.graph_anchor_id:
385
+ mapping[sent.sent_id] = sent.graph_anchor_id
386
+ return mapping
387
+
388
+ # ── [v6.19] 작성일 기반 anchor 보조 가드 ──────────────────────────────────
389
+ # LLM이 본문 내용("작년 가장 더웠다")에 끌려 anchor를 잘못 잡는 경우가 있어
390
+ # (예: 작성일 2025인데 anchor=2024), 본문에 명시된 작성일/발행일 연도를
391
+ # deterministic하게 추출해 anchor를 덮어쓴다.
392
+
393
+ # "작성일자 2025-01-01", "입력 2025.01.01 11:34", "2025-01-01 11:34" 등
394
+ _DATE_PATTERNS = [
395
+ re.compile(r"(20\d{2})\s?[.\-/년]\s?\d{1,2}\s?[.\-/월]\s?\d{1,2}"),
396
+ ]
397
+ # 작성일 맥락 키워드 — 이 단어 근처의 날짜만 작성일로 인정
398
+ _DATE_CONTEXT = ("작성", "발행", "입력", "등록", "송고", "보도")
399
+
400
+
401
+ def _extract_article_year(sir_doc: SIRDocument) -> int | None:
402
+ """본문에서 기사 작성일/발행일의 연도를 추출.
403
+
404
+ 작성일 맥락 키워드(_DATE_CONTEXT)가 같은 문장에 있는 날짜를 우선.
405
+ 맥락 키워드가 없으면, 본문 맨 앞쪽(상위 3문장)의 날짜를 후보로 본다
406
+ (기사 헤더에 날짜만 단독으로 오는 경우 대응).
407
+ 없으면 None — LLM이 뽑은 anchor를 그대로 둔다.
408
+ """
409
+ context_year: int | None = None
410
+ sent_idx = 0
411
+ for block in sir_doc.blocks:
412
+ for sent in block.sentences:
413
+ text = (sent.text or "").strip()
414
+ sent_idx += 1
415
+ if not text:
416
+ continue
417
+ for pat in _DATE_PATTERNS:
418
+ m = pat.search(text)
419
+ if not m:
420
+ continue
421
+ year = int(m.group(1))
422
+ if not (2000 <= year <= 2099):
423
+ continue
424
+ has_ctx = any(kw in text for kw in _DATE_CONTEXT)
425
+ if has_ctx:
426
+ return year # 작성일 맥락 — 즉시 확정
427
+ if context_year is None and sent_idx <= 3:
428
+ context_year = year # 헤더 부근 날짜 — 후보로 보관
429
+ return context_year
430
+
431
+
432
+ # ── [v6.19] anchor 보정 시 상대 시간표현 resolved 재계산 ──────────────────
433
+ # anchor를 작성일 기준으로 덮어쓰면, LLM이 옛 anchor로 풀어둔 resolved 값
434
+ # ("작년"→2023)이 어긋난다. resolution_basis의 offset(anchor-1 등)을 읽어
435
+ # 새 anchor 기준으로 다시 계산한다.
436
+
437
+ # "작년/지난해/전년" = -1, "재작년" = -2, "올해/금년" = 0, "내년" = +1
438
+ _RELATIVE_OFFSETS = {
439
+ "재작년": -2, "지지난해": -2,
440
+ "작년": -1, "지난해": -1, "전년": -1, "지난 해": -1,
441
+ "올해": 0, "금년": 0, "당해": 0, "올 해": 0,
442
+ "내년": 1, "명년": 1,
443
+ }
444
+
445
+
446
+ def _offset_from_basis(basis: str) -> int | None:
447
+ """resolution_basis 문자열에서 anchor 대비 offset을 추출.
448
+
449
+ "anchor_year - 1 = 2023" → -1, "anchor_year-2(2022)" → -2,
450
+ "anchor_year" 단독 → 0. 못 찾으면 None.
451
+ """
452
+ if not basis or "anchor" not in basis.lower():
453
+ return None
454
+ m = re.search(r"anchor[_ ]?year\s*([+\-])\s*(\d+)", basis, re.IGNORECASE)
455
+ if m:
456
+ sign = -1 if m.group(1) == "-" else 1
457
+ return sign * int(m.group(2))
458
+ # offset 표기 없이 anchor_year만 언급 → 같은 해
459
+ return 0
460
+
461
+
462
+ def _recompute_resolved(
463
+ expression: str, basis: str, resolved: Any, new_anchor: int,
464
+ ) -> Any:
465
+ """상대 시간표현의 resolved를 new_anchor 기준으로 재계산.
466
+
467
+ - expression이 상대 표현(_RELATIVE_OFFSETS)이면 그 offset 우선 사용
468
+ - 아니면 resolution_basis의 anchor offset 사용
469
+ - 둘 다 없으면 원본 resolved 유지 (절대 표현 "2024년" 등은 건드리지 않음)
470
+ """
471
+ expr = (expression or "").strip()
472
+ offset: int | None = None
473
+ for kw, off in _RELATIVE_OFFSETS.items():
474
+ if kw in expr:
475
+ offset = off
476
+ break
477
+ if offset is None:
478
+ offset = _offset_from_basis(basis)
479
+ if offset is None:
480
+ return resolved # 절대 표현 — 그대로
481
+
482
+ new_year = new_anchor + offset
483
+ # resolved가 'YYYY-MM' / 'YYYY-MM..YYYY-MM' 형태면 연도 부분만 치환
484
+ if isinstance(resolved, str) and resolved:
485
+ # 연도(YYYY)를 new_year로 교체, 월/범위는 유지
486
+ return re.sub(r"20\d{2}", str(new_year), resolved)
487
+ return str(new_year)