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.
- structverify/__init__.py +83 -0
- structverify/adaptation/__init__.py +0 -0
- structverify/adaptation/adapter_trainer.py +341 -0
- structverify/adaptation/feedback_store.py +31 -0
- structverify/adaptation/kosis_crawler.py +317 -0
- structverify/adaptation/sample_builder.py +149 -0
- structverify/adaptation/synthetic_generator.py +320 -0
- structverify/adaptation/update_embeddings.py +178 -0
- structverify/agent/__init__.py +21 -0
- structverify/agent/builder_agent.py +226 -0
- structverify/agent/conformance_agent.py +171 -0
- structverify/agent/dependency_planner.py +151 -0
- structverify/agent/indexing_agent.py +153 -0
- structverify/agent/indexing_planner.py +169 -0
- structverify/agent/integration_example.py +182 -0
- structverify/agent/loop.py +1165 -0
- structverify/agent/memory.py +207 -0
- structverify/agent/planner.py +817 -0
- structverify/agent/prompts/__init__.py +15 -0
- structverify/agent/prompts/planner_prompts.py +219 -0
- structverify/agent/prompts/reflect_prompts.py +387 -0
- structverify/agent/reflect.py +227 -0
- structverify/agent/runtime_agent.py +1272 -0
- structverify/agent/schemas.py +262 -0
- structverify/agent/source_profiler.py +229 -0
- structverify/agent/tools/__init__.py +64 -0
- structverify/agent/tools/base.py +222 -0
- structverify/agent/tools/calculate.py +244 -0
- structverify/agent/tools/catalog_search.py +859 -0
- structverify/agent/tools/deep_explore.py +293 -0
- structverify/agent/tools/explore_catalog.py +423 -0
- structverify/agent/tools/fetch_evidence.py +922 -0
- structverify/agent/tools/finish.py +423 -0
- structverify/agent/tools/meta_explore.py +267 -0
- structverify/agent/tools/query_rewriter.py +134 -0
- structverify/agent/tools/read_original.py +144 -0
- structverify/agent/tools/replan.py +365 -0
- structverify/agent/workspace.py +958 -0
- structverify/api.py +804 -0
- structverify/config/default.yaml +350 -0
- structverify/core/__init__.py +0 -0
- structverify/core/config_loader.py +30 -0
- structverify/core/pipeline.py +280 -0
- structverify/core/schemas.py +362 -0
- structverify/detection/__init__.py +26 -0
- structverify/detection/_config.py +163 -0
- structverify/detection/_llm.py +24 -0
- structverify/detection/candidate/__init__.py +1 -0
- structverify/detection/candidate/heuristic.py +60 -0
- structverify/detection/candidate/llm.py +51 -0
- structverify/detection/candidate_scorer.py +81 -0
- structverify/detection/claim_detector.py +164 -0
- structverify/detection/claims/__init__.py +1 -0
- structverify/detection/claims/worthiness.py +142 -0
- structverify/detection/domain/__init__.py +1 -0
- structverify/detection/domain/classify.py +84 -0
- structverify/detection/domain/preview.py +36 -0
- structverify/detection/domain/registry.py +99 -0
- structverify/detection/domain_classifier.py +75 -0
- structverify/detection/prompts/__init__.py +1 -0
- structverify/detection/prompts/candidate.py +38 -0
- structverify/detection/prompts/claim_worthiness.py +48 -0
- structverify/detection/prompts/domain.py +41 -0
- structverify/detection/prompts/schema.py +508 -0
- structverify/detection/prompts_loader.py +167 -0
- structverify/detection/schema/__init__.py +1 -0
- structverify/detection/schema/expand.py +83 -0
- structverify/detection/schema/induce.py +441 -0
- structverify/detection/schema/regenerate.py +162 -0
- structverify/detection/schema/temporal_hints.py +130 -0
- structverify/detection/schema/validate.py +193 -0
- structverify/detection/schema_inductor.py +112 -0
- structverify/detection/synthetic_generator.py +270 -0
- structverify/explanation/__init__.py +0 -0
- structverify/explanation/_config.py +18 -0
- structverify/explanation/_llm.py +25 -0
- structverify/explanation/explainer.py +183 -0
- structverify/explanation/fallback.py +29 -0
- structverify/explanation/formatters.py +75 -0
- structverify/explanation/prompts/__init__.py +1 -0
- structverify/explanation/prompts/match.py +27 -0
- structverify/explanation/prompts/mismatch.py +20 -0
- structverify/explanation/prompts/multihop.py +16 -0
- structverify/explanation/prompts/unverifiable.py +17 -0
- structverify/graph/__init__.py +0 -0
- structverify/graph/claim_graph.py +226 -0
- structverify/graph/document_graph.py +487 -0
- structverify/graph/graph_builder.py +238 -0
- structverify/graph/graph_multihop.py +335 -0
- structverify/graph/graph_store.py +281 -0
- structverify/graph/provenance.py +52 -0
- structverify/memory/__init__.py +44 -0
- structverify/memory/agent_memory.py +142 -0
- structverify/memory/embedder.py +69 -0
- structverify/memory/exemplar_store.py +241 -0
- structverify/memory/normalizer.py +91 -0
- structverify/memory/schema.py +119 -0
- structverify/memory/storage/__init__.py +29 -0
- structverify/memory/storage/jsonl_store.py +117 -0
- structverify/memory/working_memory.py +370 -0
- structverify/preprocessing/Dockerfile.scraper +27 -0
- structverify/preprocessing/__init__.py +0 -0
- structverify/preprocessing/extractor.py +574 -0
- structverify/preprocessing/pdf/__init__.py +16 -0
- structverify/preprocessing/pdf/fields.py +95 -0
- structverify/preprocessing/pdf/markdown.py +107 -0
- structverify/preprocessing/pdf/models.py +34 -0
- structverify/preprocessing/pdf/ocr.py +172 -0
- structverify/preprocessing/pdf/pipeline.py +74 -0
- structverify/preprocessing/pdf/reader.py +119 -0
- structverify/preprocessing/pdf/scoring.py +61 -0
- structverify/preprocessing/scraper_sandbox.py +561 -0
- structverify/preprocessing/segmenter.py +48 -0
- structverify/preprocessing/sir_builder.py +240 -0
- structverify/progress.py +591 -0
- structverify/retrieval/__init__.py +0 -0
- structverify/retrieval/base.py +208 -0
- structverify/retrieval/base_connector.py +85 -0
- structverify/retrieval/catalog_ranker.py +300 -0
- structverify/retrieval/catalog_search.py +583 -0
- structverify/retrieval/chunking.py +92 -0
- structverify/retrieval/custom_csv_source.py +386 -0
- structverify/retrieval/custom_db_source.py +396 -0
- structverify/retrieval/custom_docs_source.py +152 -0
- structverify/retrieval/dimension_resolver.py +281 -0
- structverify/retrieval/evidence_subgraph.py +63 -0
- structverify/retrieval/kosis_connector.py +1192 -0
- structverify/retrieval/kosis_relevance.py +142 -0
- structverify/retrieval/kosis_source.py +1541 -0
- structverify/retrieval/query_builder.py +72 -0
- structverify/retrieval/registry.py +133 -0
- structverify/retrieval/relevance_judge.py +141 -0
- structverify/retrieval/row_matcher.py +267 -0
- structverify/storage/__init__.py +0 -0
- structverify/storage/db_manager.py +157 -0
- structverify/storage/dwh_manager.py +92 -0
- structverify/storage/init_db.py +99 -0
- structverify/storage/raw_storage.py +29 -0
- structverify/training/__init__.py +26 -0
- structverify/training/curator.py +124 -0
- structverify/training/dataset.py +134 -0
- structverify/training/doctor.py +99 -0
- structverify/training/evalgate.py +96 -0
- structverify/training/generate.py +101 -0
- structverify/training/loop.py +116 -0
- structverify/training/recipe/train_mlx.py +99 -0
- structverify/training/recipe/train_qlora.py +104 -0
- structverify/training/tasks.py +79 -0
- structverify/utils/__init__.py +0 -0
- structverify/utils/embedding_client.py +248 -0
- structverify/utils/llm_client.py +809 -0
- structverify/utils/logger.py +81 -0
- structverify/verification/__init__.py +0 -0
- structverify/verification/_config.py +45 -0
- structverify/verification/adapters.py +405 -0
- structverify/verification/conformance.py +117 -0
- structverify/verification/decide_verdict.py +216 -0
- structverify/verification/decide_verdict_agent.py +454 -0
- structverify/verification/growth_diff.py +267 -0
- structverify/verification/row_match.py +345 -0
- structverify/verification/units.py +64 -0
- structverify/verification/verdict_thresholds.py +232 -0
- structverify/verification/verifier.py +84 -0
- structverify-0.3.0.dist-info/METADATA +903 -0
- structverify-0.3.0.dist-info/RECORD +168 -0
- structverify-0.3.0.dist-info/WHEEL +5 -0
- structverify-0.3.0.dist-info/licenses/LICENSE +21 -0
- structverify-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""detection/candidate/llm.py — candidate scoring Teacher LLM.
|
|
2
|
+
|
|
3
|
+
candidate_scorer.py에서 분리 (로직 move-only).
|
|
4
|
+
|
|
5
|
+
TODO [김예슬]: domain-packs/{domain}/prompts.yaml candidate 예시 주입
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from structverify.detection._config import candidate_llm_label_floor, model_tier_for
|
|
12
|
+
from structverify.detection._llm import get_llm_client
|
|
13
|
+
from structverify.detection.prompts.candidate import CANDIDATE_PROMPT
|
|
14
|
+
from structverify.detection.prompts_loader import resolve_prompt_for_step
|
|
15
|
+
from structverify.utils.logger import get_logger
|
|
16
|
+
|
|
17
|
+
logger = get_logger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def _score_candidate_llm(
|
|
21
|
+
sentence: str,
|
|
22
|
+
*,
|
|
23
|
+
config: dict,
|
|
24
|
+
threshold: float,
|
|
25
|
+
domain: str | None = None,
|
|
26
|
+
) -> tuple[float, bool, str, dict[str, Any]]:
|
|
27
|
+
llm = get_llm_client(config)
|
|
28
|
+
base = CANDIDATE_PROMPT.format(sentence=sentence)
|
|
29
|
+
prompt = resolve_prompt_for_step(base, domain, config, step="candidate")
|
|
30
|
+
result = await llm.generate_json(
|
|
31
|
+
prompt=prompt,
|
|
32
|
+
system_prompt="팩트체크 candidate detector. JSON으로만 답하세요.",
|
|
33
|
+
model_tier=model_tier_for(config, "candidate_score"),
|
|
34
|
+
)
|
|
35
|
+
# score = float(result.get("candidate_score", 0.0))
|
|
36
|
+
# label = bool(result.get("candidate_label", score >= threshold))
|
|
37
|
+
# signals = result.get("signals", {}) or {}
|
|
38
|
+
# signals["reason"] = result.get("reason")
|
|
39
|
+
# return score, label, "teacher_llm", signals
|
|
40
|
+
score = float(result.get("candidate_score", 0.0) or 0.0)
|
|
41
|
+
label = bool(result.get("candidate_label", score >= threshold))
|
|
42
|
+
|
|
43
|
+
# LLM이 label=true인데 score를 0으로 주는 경우 방어
|
|
44
|
+
if label and score < threshold:
|
|
45
|
+
score = max(score, candidate_llm_label_floor(config))
|
|
46
|
+
|
|
47
|
+
signals = result.get("signals", {}) or {}
|
|
48
|
+
signals["reason"] = result.get("reason")
|
|
49
|
+
signals["raw_llm_result"] = result
|
|
50
|
+
|
|
51
|
+
return score, label, "teacher_llm", signals
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""
|
|
2
|
+
detection/candidate_scorer.py — 문장 단위 검증 후보 점수화
|
|
3
|
+
|
|
4
|
+
[김예슬]
|
|
5
|
+
- Teacher LLM 기반 0~1 점수화 로직 담당
|
|
6
|
+
- heuristic fallback은 운영 안정성을 위한 보조 수단
|
|
7
|
+
- 학습 데이터 충분 누적 후 소형 분류 모델(LoRA fine-tuned)로 교체 계획
|
|
8
|
+
|
|
9
|
+
[설계 원칙]
|
|
10
|
+
- regex/rule만으로 후보를 결정하지 않는다.
|
|
11
|
+
- surface signal + teacher LLM + weak supervision 규칙을 결합할 수 있는 인터페이스 제공.
|
|
12
|
+
- 현재 버전: "teacher LLM + heuristic fallback" 구조.
|
|
13
|
+
- 이후 작은 classifier를 붙일 때 이 파일만 교체하면 된다.
|
|
14
|
+
|
|
15
|
+
[LLM 학습 계획]
|
|
16
|
+
Phase 1: Teacher LLM (HCX-DASH-001)이 직접 판단 → 결과를 학습 샘플로 저장
|
|
17
|
+
Phase 2: Step 0 합성 데이터 + 운영 피드백 누적 → LoRA fine-tuning
|
|
18
|
+
Phase 3: 학습된 경량 모델로 교체 (비용 절감 + 속도 향상)
|
|
19
|
+
|
|
20
|
+
출력
|
|
21
|
+
- candidate_score: 0~1
|
|
22
|
+
- candidate_label: bool
|
|
23
|
+
- candidate_source: 점수 출처
|
|
24
|
+
- candidate_signals: 분석용 signal
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from structverify.detection._config import candidate_detection_config
|
|
31
|
+
from structverify.detection.candidate.heuristic import _score_candidate_heuristic
|
|
32
|
+
from structverify.detection.candidate.llm import _score_candidate_llm
|
|
33
|
+
from structverify.utils.logger import get_logger
|
|
34
|
+
|
|
35
|
+
logger = get_logger(__name__)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def score_candidate(
|
|
39
|
+
sentence: str,
|
|
40
|
+
config: dict | None = None,
|
|
41
|
+
context: dict[str, Any] | None = None,
|
|
42
|
+
) -> tuple[float, bool, str, dict[str, Any]]:
|
|
43
|
+
"""
|
|
44
|
+
문장 후보 점수 계산.
|
|
45
|
+
|
|
46
|
+
현재 로직
|
|
47
|
+
1) teacher LLM 시도 (HCX-DASH-001 경량 모델)
|
|
48
|
+
2) 실패 시 heuristic fallback
|
|
49
|
+
|
|
50
|
+
TODO [김예슬]: 도메인 컨텍스트 활용
|
|
51
|
+
- context["domain"]을 프롬프트에 주입하여 도메인별 판단 기준 적용
|
|
52
|
+
- domain-packs/{domain}/prompts.yaml의 candidate 예시 주입
|
|
53
|
+
|
|
54
|
+
TODO [김예슬]: 학습 데이터 수집 로직 추가
|
|
55
|
+
- teacher LLM 판단 결과를 DB에 저장 (sample_builder.py 연동)
|
|
56
|
+
- 나중에 LoRA fine-tuning에 활용
|
|
57
|
+
|
|
58
|
+
TODO [김예슬]: 소형 분류 모델 교체 로직 (Phase 3)
|
|
59
|
+
- 학습된 adapter 경로 확인 → 있으면 PEFT 모델 추론
|
|
60
|
+
- adapter_path = config.get("adaptation", {}).get("adapter_path")
|
|
61
|
+
- if adapter_path: return _score_with_trained_model(sentence, adapter_path)
|
|
62
|
+
"""
|
|
63
|
+
config = config or {}
|
|
64
|
+
cd_cfg = candidate_detection_config(config)
|
|
65
|
+
use_llm = cd_cfg.get("teacher_llm_fallback", True)
|
|
66
|
+
threshold = float(cd_cfg.get("threshold", 0.65))
|
|
67
|
+
domain = (context or {}).get("domain")
|
|
68
|
+
|
|
69
|
+
if use_llm:
|
|
70
|
+
try:
|
|
71
|
+
return await _score_candidate_llm(
|
|
72
|
+
sentence,
|
|
73
|
+
config=config,
|
|
74
|
+
threshold=threshold,
|
|
75
|
+
domain=domain,
|
|
76
|
+
)
|
|
77
|
+
except Exception as e:
|
|
78
|
+
logger.warning(f"candidate LLM 판별 실패 — heuristic fallback 사용: {e}")
|
|
79
|
+
|
|
80
|
+
# fallback: LLM 실패 시만 사용
|
|
81
|
+
return _score_candidate_heuristic(sentence, threshold=threshold)
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""
|
|
2
|
+
detection/claim_detector.py — 검증 가능 주장 탐지 (Step 4)
|
|
3
|
+
|
|
4
|
+
[김예슬]
|
|
5
|
+
- check-worthiness 프롬프트 설계 및 튜닝 담당
|
|
6
|
+
- candidate scoring → LLM 2차 판별 구조 담당
|
|
7
|
+
- domain-packs 기반 도메인별 프롬프트 주입
|
|
8
|
+
|
|
9
|
+
[변경 요약]
|
|
10
|
+
- 기존: has_numeric=True 문장만 필터 → LLM check-worthiness
|
|
11
|
+
- 변경: LLM/학습 기반 sentence candidate scoring → 상위 후보만 LLM check-worthiness
|
|
12
|
+
|
|
13
|
+
[설계 원칙]
|
|
14
|
+
- Regex 필터(has_numeric 등) 로 1차 후보를 결정하지 않습니다.
|
|
15
|
+
- candidate_scorer.py의 Teacher LLM이 0~1 점수를 계산하고,
|
|
16
|
+
threshold 이상인 문장만 이 check-worthiness 단계로 전달됩니다.
|
|
17
|
+
- 즉, Step 4를 다음 두 단계로 분리합니다:
|
|
18
|
+
1) Sentence Candidate Detection (candidate_scorer.py — Teacher LLM)
|
|
19
|
+
2) Claim Detection / Check-Worthiness (LLM 중량 모델)
|
|
20
|
+
|
|
21
|
+
[박재윤 - 2026-05-14]: CHECK_WORTHY_PROMPT 개선
|
|
22
|
+
· 예보/예상/전망 수치 → false 기준 명시
|
|
23
|
+
· 순위 표현 단독 → false 기준 명시
|
|
24
|
+
· 외국 기관 발표 수치 → false 기준 명시
|
|
25
|
+
· positive/negative 예시 추가
|
|
26
|
+
|
|
27
|
+
[박재윤 - 2026-05-18]: CHECK_WORTHY_PROMPT 검증 가능 기준 보강
|
|
28
|
+
· 기준 1번: "공식 통계 연결" → "정부/공공기관 발표 수치" 로 구체화
|
|
29
|
+
· 공시가격 변동률 등 부동산 수치 positive 예시 추가
|
|
30
|
+
"""
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import asyncio # 병렬처리
|
|
34
|
+
|
|
35
|
+
from structverify.core.schemas import Claim, SIRDocument, SourceOffset
|
|
36
|
+
from structverify.detection.candidate_scorer import score_candidate
|
|
37
|
+
from structverify.detection._config import candidate_detection_config, claim_min_confidence
|
|
38
|
+
from structverify.detection._llm import get_llm_client
|
|
39
|
+
from structverify.detection.claims.worthiness import _check_worthiness
|
|
40
|
+
from structverify.utils.logger import get_logger
|
|
41
|
+
|
|
42
|
+
logger = get_logger(__name__)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def detect_claims(
|
|
46
|
+
sir_doc: SIRDocument,
|
|
47
|
+
config: dict | None = None,
|
|
48
|
+
) -> list[Claim]:
|
|
49
|
+
"""
|
|
50
|
+
SIR Tree에서 검증 가능한 주장 탐지.
|
|
51
|
+
|
|
52
|
+
단계
|
|
53
|
+
1) LLM 기반 sentence candidate scoring
|
|
54
|
+
2) high-score 문장만 check-worthiness 판별 (LLM 중량 모델)
|
|
55
|
+
3) threshold 이상 claim만 Claim 객체로 변환
|
|
56
|
+
|
|
57
|
+
TODO [김예슬]: claim_type 분류 정확도 개선
|
|
58
|
+
- "increase": 증가/상승/올랐다
|
|
59
|
+
- "decrease": 감소/하락/내렸다
|
|
60
|
+
- "scale": 규모/비율/수준 언급
|
|
61
|
+
- "comparison": A가 B보다 높다/낮다
|
|
62
|
+
- "forecast": 전망/예상/목표
|
|
63
|
+
"""
|
|
64
|
+
config = config or {}
|
|
65
|
+
llm = get_llm_client(config)
|
|
66
|
+
|
|
67
|
+
cd_cfg = candidate_detection_config(config)
|
|
68
|
+
candidate_threshold = float(cd_cfg.get("threshold", 0.65))
|
|
69
|
+
min_conf = claim_min_confidence(config)
|
|
70
|
+
|
|
71
|
+
concurrency = int(cd_cfg.get("concurrency", candidate_detection_config(config).get("concurrency", 4)))
|
|
72
|
+
sem = asyncio.Semaphore(concurrency)
|
|
73
|
+
|
|
74
|
+
sentence_items = []
|
|
75
|
+
for block in sir_doc.blocks:
|
|
76
|
+
for sent in block.sentences:
|
|
77
|
+
sentence_items.append((block, sent))
|
|
78
|
+
|
|
79
|
+
async def score_one(block, sent):
|
|
80
|
+
async with sem:
|
|
81
|
+
score, label, source, signals = await score_candidate(
|
|
82
|
+
sentence=sent.text,
|
|
83
|
+
config=config,
|
|
84
|
+
context={
|
|
85
|
+
"block_id": block.block_id,
|
|
86
|
+
"domain": sir_doc.detected_domain,
|
|
87
|
+
},
|
|
88
|
+
)
|
|
89
|
+
return block, sent, score, label, source, signals
|
|
90
|
+
|
|
91
|
+
# 1) candidate scoring 병렬 처리
|
|
92
|
+
score_results = await asyncio.gather(
|
|
93
|
+
*[score_one(block, sent) for block, sent in sentence_items],
|
|
94
|
+
return_exceptions=True,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
candidates = []
|
|
98
|
+
|
|
99
|
+
for result in score_results:
|
|
100
|
+
if isinstance(result, Exception):
|
|
101
|
+
logger.warning(f"candidate scoring 실패: {result}")
|
|
102
|
+
continue
|
|
103
|
+
|
|
104
|
+
block, sent, score, label, source, signals = result
|
|
105
|
+
|
|
106
|
+
sent.candidate_score = score
|
|
107
|
+
sent.candidate_label = label
|
|
108
|
+
sent.candidate_source = source
|
|
109
|
+
sent.candidate_signals = signals
|
|
110
|
+
|
|
111
|
+
if score >= candidate_threshold and label:
|
|
112
|
+
candidates.append((block, sent))
|
|
113
|
+
|
|
114
|
+
logger.info(f"candidate 문장: {len(candidates)}건")
|
|
115
|
+
|
|
116
|
+
domain = sir_doc.detected_domain
|
|
117
|
+
|
|
118
|
+
async def check_one(block, sent):
|
|
119
|
+
async with sem:
|
|
120
|
+
cw_score, claim_type, canonical_type = await _check_worthiness(
|
|
121
|
+
llm,
|
|
122
|
+
sent.text,
|
|
123
|
+
config=config,
|
|
124
|
+
domain=domain,
|
|
125
|
+
)
|
|
126
|
+
return block, sent, cw_score, claim_type, canonical_type
|
|
127
|
+
|
|
128
|
+
# 2) check-worthiness도 병렬 처리
|
|
129
|
+
check_results = await asyncio.gather(
|
|
130
|
+
*[check_one(block, sent) for block, sent in candidates],
|
|
131
|
+
return_exceptions=True,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
claims: list[Claim] = []
|
|
135
|
+
|
|
136
|
+
for result in check_results:
|
|
137
|
+
if isinstance(result, Exception):
|
|
138
|
+
logger.warning(f"check-worthiness 실패: {result}")
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
block, sent, cw_score, claim_type, canonical_type = result
|
|
142
|
+
|
|
143
|
+
if cw_score < min_conf:
|
|
144
|
+
continue
|
|
145
|
+
|
|
146
|
+
claims.append(
|
|
147
|
+
Claim(
|
|
148
|
+
doc_id=sir_doc.doc_id,
|
|
149
|
+
block_id=block.block_id,
|
|
150
|
+
sent_id=sent.sent_id,
|
|
151
|
+
claim_text=sent.text,
|
|
152
|
+
claim_type=claim_type,
|
|
153
|
+
canonical_type=canonical_type,
|
|
154
|
+
check_worthy_score=cw_score,
|
|
155
|
+
graph_anchor_id=sent.graph_anchor_id,
|
|
156
|
+
source_offset=SourceOffset(
|
|
157
|
+
char_start=sent.char_offset_start,
|
|
158
|
+
char_end=sent.char_offset_end,
|
|
159
|
+
page=block.source_offset.page if block.source_offset else None,
|
|
160
|
+
),
|
|
161
|
+
)
|
|
162
|
+
)
|
|
163
|
+
logger.info(f"검증 가능 주장: {len(claims)}건")
|
|
164
|
+
return claims
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""detection/claims — Step 4 check-worthiness·claim 조립."""
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""detection/claims/worthiness.py — check-worthiness LLM 판별.
|
|
2
|
+
|
|
3
|
+
claim_detector.py에서 분리 (로직 move-only).
|
|
4
|
+
|
|
5
|
+
TODO [김예슬]: 오류 응답 처리 강화 (재시도, score 클램핑)
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from structverify.core.schemas import ClaimType
|
|
10
|
+
from structverify.detection.prompts.claim_worthiness import CHECK_WORTHY_PROMPT
|
|
11
|
+
from structverify.detection._config import claim_worthy_score_floor, model_tier_for
|
|
12
|
+
from structverify.detection.prompts_loader import resolve_step_prompt
|
|
13
|
+
from structverify.utils.llm_client import LLMClient
|
|
14
|
+
from structverify.utils.logger import get_logger
|
|
15
|
+
|
|
16
|
+
logger = get_logger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# 연결된 소스에 적응하는 check-worthiness 프롬프트 (KOSIS 가정 제거).
|
|
20
|
+
# config["_source_profile"] 가 있을 때 사용 — "정부 통계?" 대신 "이 소스로 검증되나?".
|
|
21
|
+
_SOURCE_AWARE_PROMPT = """아래 문장이 '연결된 검증 데이터'로 대조 가능한 수치 기반 사실 주장인지 판별하세요.
|
|
22
|
+
|
|
23
|
+
[연결된 검증 데이터]
|
|
24
|
+
{source_desc}
|
|
25
|
+
이 데이터가 다루는 지표(일부): {indicators}
|
|
26
|
+
|
|
27
|
+
[검증 가능 기준 — is_check_worthy=true]
|
|
28
|
+
1. 위 데이터의 지표와 관련된, 구체적 수치를 담은 *과거/현재 실측* 사실 주장
|
|
29
|
+
(절대값·비율·증감·금액·개수·수량 등 모두 포함)
|
|
30
|
+
2. 단순 일정/발언 소개/감상이 아닌, 수치로 검증 가능한 사실 주장
|
|
31
|
+
|
|
32
|
+
[검증 불가 기준 — is_check_worthy=false]
|
|
33
|
+
- 예보/예상/전망/목표 수치 ("목표 성장률 3%")
|
|
34
|
+
- 순위 표현만 ("역대 최대", "1위")
|
|
35
|
+
- 단순 발언/의견 ("전문가는 ~라고 말했다")
|
|
36
|
+
- 위 데이터가 다루지 않는 주제의 수치
|
|
37
|
+
|
|
38
|
+
문장: "{sentence}"
|
|
39
|
+
중요:
|
|
40
|
+
- is_check_worthy=true이면 score는 반드시 0.5 이상
|
|
41
|
+
- is_check_worthy=false이면 score는 반드시 0.5 미만
|
|
42
|
+
- JSON만 출력. 설명 금지.
|
|
43
|
+
JSON:
|
|
44
|
+
{{"is_check_worthy": false, "score": 0.0, "claim_type": null}}"""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# 에이전틱(text-to-SQL) 소스용 — 원시 DB라 SQL 집계로 임의 지표를 만들 수 있으므로
|
|
48
|
+
# "제안 지표에 없는 주제"라고 쉽게 거부하지 않는다(지역·부문·연도별 분해 등 포함).
|
|
49
|
+
_AGENTIC_SOURCE_PROMPT = """아래 문장이 '연결된 원시 데이터베이스'로 SQL 집계를 통해 대조 가능한 수치 기반 사실 주장인지 판별하세요.
|
|
50
|
+
|
|
51
|
+
[연결된 검증 데이터]
|
|
52
|
+
{source_desc}
|
|
53
|
+
이 원시 데이터로 SQL 집계(합계·평균·건수, 지역별·부문별·세그먼트별·연도별 분해 등)를 자유롭게 만들 수 있습니다.
|
|
54
|
+
참고 지표(일부): {indicators}
|
|
55
|
+
|
|
56
|
+
[검증 가능 기준 — is_check_worthy=true]
|
|
57
|
+
1. 위 데이터의 도메인과 관련된, 구체적 수치를 담은 과거/현재 실측 주장
|
|
58
|
+
(절대값·비율·증감·금액·개수·수량, 그리고 지역별·부문별·연도별 *분해*까지 모두 포함)
|
|
59
|
+
2. 숫자를 동반한 비교/순위 주장도 그 숫자가 검증 대상이면 true
|
|
60
|
+
(예: "유럽이 4,369억 달러로 1위" → 4,369억이 검증 대상이므로 true)
|
|
61
|
+
|
|
62
|
+
[검증 불가 기준 — is_check_worthy=false]
|
|
63
|
+
- 예보/예상/전망/목표 수치 ("목표 성장률 3%")
|
|
64
|
+
- *숫자 없는* 순수 순위·감상 ("역대 최대", "업계 최고")
|
|
65
|
+
- 단순 발언/의견
|
|
66
|
+
- 데이터 도메인과 전혀 무관한 주제의 수치
|
|
67
|
+
|
|
68
|
+
문장: "{sentence}"
|
|
69
|
+
중요:
|
|
70
|
+
- is_check_worthy=true이면 score는 반드시 0.5 이상
|
|
71
|
+
- is_check_worthy=false이면 score는 반드시 0.5 미만
|
|
72
|
+
- JSON만 출력. 설명 금지.
|
|
73
|
+
JSON:
|
|
74
|
+
{{"is_check_worthy": false, "score": 0.0, "claim_type": null}}"""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _resolve_worthy_prompt(sentence: str, config: dict | None, domain: str | None) -> str:
|
|
78
|
+
"""소스 프로파일이 있으면 소스-인지 프롬프트, 없으면 기존(도메인팩) 프롬프트.
|
|
79
|
+
|
|
80
|
+
에이전틱 소스면 SQL로 임의 집계가 가능하므로 더 관대한 프롬프트를 쓴다.
|
|
81
|
+
"""
|
|
82
|
+
sp = (config or {}).get("_source_profile")
|
|
83
|
+
if sp and sp.get("indicators"):
|
|
84
|
+
_agentic = bool((sp.get("retrieval_plan") or {}).get("agentic"))
|
|
85
|
+
_tmpl = _AGENTIC_SOURCE_PROMPT if _agentic else _SOURCE_AWARE_PROMPT
|
|
86
|
+
return _tmpl.format(
|
|
87
|
+
sentence=sentence,
|
|
88
|
+
source_desc=sp.get("description") or "연결된 회사/사용자 데이터",
|
|
89
|
+
indicators=", ".join(sp["indicators"][:50]), # headline 지표 포함되게 상한↑
|
|
90
|
+
)
|
|
91
|
+
return resolve_step_prompt(
|
|
92
|
+
CHECK_WORTHY_PROMPT, {"sentence": sentence},
|
|
93
|
+
domain, config, step="claim_worthiness",
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def _check_worthiness(
|
|
98
|
+
llm: LLMClient,
|
|
99
|
+
sentence: str,
|
|
100
|
+
*,
|
|
101
|
+
config: dict | None = None,
|
|
102
|
+
domain: str | None = None,
|
|
103
|
+
) -> tuple[float, str | None, ClaimType | None]:
|
|
104
|
+
"""
|
|
105
|
+
LLM 기반 check-worthiness 판별 (2차 정밀 판별).
|
|
106
|
+
candidate detection 이후 상위 후보에만 적용.
|
|
107
|
+
"""
|
|
108
|
+
try:
|
|
109
|
+
# 연결된 소스가 있으면 소스-인지 프롬프트, 없으면 기존(도메인팩) 프롬프트.
|
|
110
|
+
prompt = _resolve_worthy_prompt(sentence, config, domain)
|
|
111
|
+
r = await llm.generate_json(
|
|
112
|
+
prompt,
|
|
113
|
+
system_prompt="팩트체크 check-worthiness classifier. 반드시 JSON만 출력하세요.",
|
|
114
|
+
model_tier=model_tier_for(config, "claim_worthiness"),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
is_check_worthy = bool(r.get("is_check_worthy", False))
|
|
118
|
+
score = float(r.get("score", 0.0) or 0.0)
|
|
119
|
+
|
|
120
|
+
# true인데 score=0으로 오는 문제 방어
|
|
121
|
+
if is_check_worthy and score <= 0.0:
|
|
122
|
+
score = claim_worthy_score_floor(config)
|
|
123
|
+
|
|
124
|
+
score = max(0.0, min(score, 1.0))
|
|
125
|
+
|
|
126
|
+
if not is_check_worthy:
|
|
127
|
+
return 0.0, None, None
|
|
128
|
+
raw_type = r.get("claim_type")
|
|
129
|
+
canonical = r.get("canonical_type")
|
|
130
|
+
|
|
131
|
+
claim_type = raw_type if raw_type and raw_type != "null" else None
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
canonical_type = ClaimType(canonical) if canonical else None
|
|
135
|
+
except ValueError:
|
|
136
|
+
canonical_type = None
|
|
137
|
+
|
|
138
|
+
return score, claim_type, canonical_type
|
|
139
|
+
|
|
140
|
+
except Exception as e:
|
|
141
|
+
logger.error(f"check-worthiness 실패: {e}")
|
|
142
|
+
return 0.0, None, None
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""detection/domain — Step 3 도메인 분류 (registry·preview·LLM)."""
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""detection/domain/classify.py — 도메인 LLM 분류 실행.
|
|
2
|
+
|
|
3
|
+
domain_classifier.py에서 분리 (로직 move-only, 동작 변경 없음).
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from structverify.core.schemas import SIRDocument
|
|
8
|
+
from structverify.detection.domain.preview import _build_text_preview
|
|
9
|
+
from structverify.detection.domain.registry import (
|
|
10
|
+
DEFAULT_SEED_DOMAINS,
|
|
11
|
+
DOMAIN_NAME_PATTERN,
|
|
12
|
+
DomainRegistry,
|
|
13
|
+
)
|
|
14
|
+
from structverify.detection._config import (
|
|
15
|
+
domain_confidence_threshold,
|
|
16
|
+
domain_registry_path,
|
|
17
|
+
model_tier_for,
|
|
18
|
+
)
|
|
19
|
+
from structverify.detection._llm import get_llm_client
|
|
20
|
+
from structverify.detection.prompts.domain import DOMAIN_CLASSIFY_PROMPT
|
|
21
|
+
from structverify.utils.logger import get_logger
|
|
22
|
+
|
|
23
|
+
logger = get_logger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def _classify_domain_with_llm(
|
|
27
|
+
sir_doc: SIRDocument,
|
|
28
|
+
config: dict | None = None,
|
|
29
|
+
) -> tuple[str, str]:
|
|
30
|
+
"""레지스트리 + LLM으로 (domain, description) 반환."""
|
|
31
|
+
config = config or {}
|
|
32
|
+
registry_path = domain_registry_path(config)
|
|
33
|
+
registry = DomainRegistry(registry_path)
|
|
34
|
+
|
|
35
|
+
preview = _build_text_preview(sir_doc)
|
|
36
|
+
domain_list_str = registry.format_for_prompt()
|
|
37
|
+
llm = get_llm_client(config)
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
result = await llm.generate_json(
|
|
41
|
+
prompt=DOMAIN_CLASSIFY_PROMPT.format(
|
|
42
|
+
domain_list=domain_list_str,
|
|
43
|
+
text_preview=preview,
|
|
44
|
+
),
|
|
45
|
+
system_prompt="도메인 분류 전문가. JSON으로만 답하세요.",
|
|
46
|
+
model_tier=model_tier_for(config, "domain_classify", default="light"),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
raw_domain = result.get("domain", "general")
|
|
50
|
+
description = result.get("description", "")
|
|
51
|
+
is_new = bool(result.get("is_new", False))
|
|
52
|
+
confidence = float(result.get("confidence", 0.0))
|
|
53
|
+
reason = result.get("reason", "")
|
|
54
|
+
|
|
55
|
+
# 도메인 형식 검증
|
|
56
|
+
if not DOMAIN_NAME_PATTERN.match(raw_domain):
|
|
57
|
+
logger.warning(f"도메인 형식 오류 '{raw_domain}' → general")
|
|
58
|
+
raw_domain, description = "general", DEFAULT_SEED_DOMAINS["general"]
|
|
59
|
+
|
|
60
|
+
# confidence 낮으면 general
|
|
61
|
+
if confidence < domain_confidence_threshold(config):
|
|
62
|
+
logger.warning(f"confidence 낮음 ({confidence:.2f}) → general")
|
|
63
|
+
raw_domain, description = "general", DEFAULT_SEED_DOMAINS["general"]
|
|
64
|
+
|
|
65
|
+
# 신규 도메인이면 레지스트리에 저장
|
|
66
|
+
if is_new and raw_domain != "general":
|
|
67
|
+
registry.register(raw_domain, description)
|
|
68
|
+
|
|
69
|
+
# 기존 도메인이면 레지스트리의 공식 설명 사용 (LLM 설명이 다를 수 있음)
|
|
70
|
+
if not is_new:
|
|
71
|
+
registered = registry.load()
|
|
72
|
+
description = registered.get(raw_domain, description)
|
|
73
|
+
|
|
74
|
+
domain = raw_domain
|
|
75
|
+
logger.info(
|
|
76
|
+
f"도메인 분류: {domain} ({'신규' if is_new else '기존'}) "
|
|
77
|
+
f"confidence={confidence:.2f}, reason={reason}"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
except Exception as e:
|
|
81
|
+
logger.error(f"도메인 분류 실패: {e}")
|
|
82
|
+
domain, description = "general", DEFAULT_SEED_DOMAINS["general"]
|
|
83
|
+
|
|
84
|
+
return domain, description
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""detection/domain/preview.py — SIR 문서 미리보기 텍스트.
|
|
2
|
+
|
|
3
|
+
domain_classifier.py에서 분리 (로직 move-only).
|
|
4
|
+
|
|
5
|
+
[김예슬 - 2026-04-22] _build_text_preview — 블록 타입 고려
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from structverify.core.schemas import SIRDocument
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _build_text_preview(sir_doc: SIRDocument, max_chars: int = 600) -> str:
|
|
13
|
+
"""
|
|
14
|
+
SIR 문서에서 분류에 유용한 미리보기 텍스트를 구성한다.
|
|
15
|
+
heading 블록 우선, 이후 paragraph 추가. table/list 제외.
|
|
16
|
+
"""
|
|
17
|
+
from structverify.core.schemas import BlockType
|
|
18
|
+
|
|
19
|
+
parts: list[str] = []
|
|
20
|
+
total = 0
|
|
21
|
+
|
|
22
|
+
for block in sir_doc.blocks:
|
|
23
|
+
if block.type == BlockType.HEADING and block.content:
|
|
24
|
+
parts.append(block.content.strip())
|
|
25
|
+
total += len(block.content)
|
|
26
|
+
if total >= max_chars:
|
|
27
|
+
break
|
|
28
|
+
|
|
29
|
+
for block in sir_doc.blocks:
|
|
30
|
+
if block.type == BlockType.PARAGRAPH and block.content:
|
|
31
|
+
parts.append(block.content.strip())
|
|
32
|
+
total += len(block.content)
|
|
33
|
+
if total >= max_chars:
|
|
34
|
+
break
|
|
35
|
+
|
|
36
|
+
return " ".join(parts)[:max_chars]
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""detection/domain/registry.py — 도메인 레지스트리 (registry.yaml).
|
|
2
|
+
|
|
3
|
+
domain_classifier.py에서 분리 (로직 move-only, 동작 변경 없음).
|
|
4
|
+
|
|
5
|
+
[김예슬 - 2026-04-23] DomainRegistry — LLM 도메인 파편화 방지
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
import yaml
|
|
13
|
+
|
|
14
|
+
from structverify.utils.logger import get_logger
|
|
15
|
+
|
|
16
|
+
logger = get_logger(__name__)
|
|
17
|
+
|
|
18
|
+
# [김예슬 - 2026-04-23] DomainRegistry — LLM 도메인 파편화 방지
|
|
19
|
+
# confidence_threshold 기본값 → detection/config.yaml (domain.confidence_threshold)
|
|
20
|
+
CONFIDENCE_THRESHOLD = 0.6 # re-export 호환; 런타임은 config.domain_confidence_threshold()
|
|
21
|
+
DOMAIN_NAME_PATTERN = re.compile(r"^[a-z][a-z_]{0,29}$")
|
|
22
|
+
|
|
23
|
+
# 기본 시드 도메인 — 레지스트리 파일이 없을 때 초기값으로 사용
|
|
24
|
+
DEFAULT_SEED_DOMAINS: dict[str, str] = {
|
|
25
|
+
"agriculture": "농림수산식품 (농가, 경작면적, 수확량, 축산, 어업)",
|
|
26
|
+
"economy": "경제/경기 (GDP, 성장률, 소비, 수출입, 물가, 산업생산)",
|
|
27
|
+
"finance": "금융/증권 (금리, 환율, 주가, 대출, 가계부채, 보험)",
|
|
28
|
+
"population": "인구/가구 (출생, 사망, 혼인, 고령화, 인구구조)",
|
|
29
|
+
"employment": "고용/노동/임금 (취업률, 실업률, 임금, 근로시간)",
|
|
30
|
+
"healthcare": "보건/의료 (질병, 의료기관, 사망률, 건강보험)",
|
|
31
|
+
"education": "교육 (학생, 학교, 교육비, 진학률, 입시)",
|
|
32
|
+
"policy": "정책/행정 (예산, 법률, 복지, 지원금, 정부)",
|
|
33
|
+
"environment": "환경/에너지 (기후, 탄소, 재생에너지, 환경오염)",
|
|
34
|
+
"general": "분류 불가 또는 복합 도메인",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class DomainRegistry:
|
|
39
|
+
"""
|
|
40
|
+
도메인 레지스트리 — {domain: description} 매핑을 파일로 영속 관리.
|
|
41
|
+
|
|
42
|
+
registry.yaml 구조:
|
|
43
|
+
agriculture: "농림수산식품 (농가, 경작면적, ...)"
|
|
44
|
+
economy: "경제/경기 (GDP, 성장률, ...)"
|
|
45
|
+
real_estate: "부동산 (아파트, 매매가, ...)" ← 런타임에 추가됨
|
|
46
|
+
|
|
47
|
+
사용법:
|
|
48
|
+
registry = DomainRegistry("domain-packs/registry.yaml")
|
|
49
|
+
domains = registry.load() # {domain: description} 반환
|
|
50
|
+
registry.register("real_estate", "부동산 관련 통계")
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self, registry_path: str = "domain-packs/registry.yaml"):
|
|
54
|
+
self.registry_path = registry_path
|
|
55
|
+
|
|
56
|
+
def load(self) -> dict[str, str]:
|
|
57
|
+
"""
|
|
58
|
+
레지스트리 파일 로드.
|
|
59
|
+
파일이 없으면 DEFAULT_SEED_DOMAINS를 파일로 저장 후 반환.
|
|
60
|
+
"""
|
|
61
|
+
if not os.path.exists(self.registry_path):
|
|
62
|
+
logger.info(f"레지스트리 없음 → 시드 도메인으로 초기화: {self.registry_path}")
|
|
63
|
+
self._save(DEFAULT_SEED_DOMAINS)
|
|
64
|
+
return dict(DEFAULT_SEED_DOMAINS)
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
with open(self.registry_path, encoding="utf-8") as f:
|
|
68
|
+
data = yaml.safe_load(f) or {}
|
|
69
|
+
return {k: str(v) for k, v in data.items()}
|
|
70
|
+
except Exception as e:
|
|
71
|
+
logger.warning(f"레지스트리 로드 실패 → 시드 사용: {e}")
|
|
72
|
+
return dict(DEFAULT_SEED_DOMAINS)
|
|
73
|
+
|
|
74
|
+
def register(self, domain: str, description: str) -> None:
|
|
75
|
+
"""
|
|
76
|
+
새 도메인을 레지스트리에 추가하고 파일로 저장.
|
|
77
|
+
이미 있으면 무시.
|
|
78
|
+
"""
|
|
79
|
+
current = self.load()
|
|
80
|
+
if domain in current:
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
current[domain] = description
|
|
84
|
+
self._save(current)
|
|
85
|
+
logger.info(f"새 도메인 등록: {domain} — {description}")
|
|
86
|
+
|
|
87
|
+
def _save(self, data: dict[str, str]) -> None:
|
|
88
|
+
os.makedirs(os.path.dirname(self.registry_path) or ".", exist_ok=True)
|
|
89
|
+
with open(self.registry_path, "w", encoding="utf-8") as f:
|
|
90
|
+
yaml.dump(data, f, allow_unicode=True, sort_keys=True)
|
|
91
|
+
|
|
92
|
+
def format_for_prompt(self) -> str:
|
|
93
|
+
"""
|
|
94
|
+
프롬프트 주입용 문자열 생성.
|
|
95
|
+
예: "- agriculture: 농림수산식품 (농가, 경작면적, ...)"
|
|
96
|
+
"""
|
|
97
|
+
domains = self.load()
|
|
98
|
+
lines = [f"- {k}: {v}" for k, v in sorted(domains.items())]
|
|
99
|
+
return "\n".join(lines)
|