coatlearn 0.1.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.
- coatlearn/__init__.py +26 -0
- coatlearn/__main__.py +5 -0
- coatlearn/annotation/__init__.py +12 -0
- coatlearn/annotation/batch.py +90 -0
- coatlearn/annotation/dedup.py +135 -0
- coatlearn/annotation/definitions.py +216 -0
- coatlearn/annotation/judge.py +117 -0
- coatlearn/annotation/measurement.py +639 -0
- coatlearn/annotation/prediction.py +134 -0
- coatlearn/annotation/quality.py +128 -0
- coatlearn/annotation/text.py +290 -0
- coatlearn/annotation/types.py +388 -0
- coatlearn/api.py +1577 -0
- coatlearn/causality/__init__.py +5 -0
- coatlearn/causality/adjustment.py +183 -0
- coatlearn/causality/effects.py +631 -0
- coatlearn/causality/graph.py +447 -0
- coatlearn/causality/independence.py +19 -0
- coatlearn/causality/interface.py +85 -0
- coatlearn/causality/learning.py +167 -0
- coatlearn/causality/postprocessing.py +126 -0
- coatlearn/causality/priors.py +65 -0
- coatlearn/causality/statistics.py +83 -0
- coatlearn/cli/__init__.py +60 -0
- coatlearn/configuration.py +569 -0
- coatlearn/discovery/__init__.py +4 -0
- coatlearn/discovery/adj.py +703 -0
- coatlearn/discovery/mb.py +2521 -0
- coatlearn/discovery/pag.py +782 -0
- coatlearn/execution.py +200 -0
- coatlearn/models/__init__.py +8 -0
- coatlearn/models/jev.py +4 -0
- coatlearn/models/kev.py +65 -0
- coatlearn/models/kev_server.py +119 -0
- coatlearn/models/openai.py +531 -0
- coatlearn/models/presets.py +121 -0
- coatlearn/models/systemone.py +372 -0
- coatlearn/utils/__init__.py +1 -0
- coatlearn/utils/constants.py +107 -0
- coatlearn/utils/logging.py +64 -0
- coatlearn/utils/sampling.py +93 -0
- coatlearn-0.1.0.dist-info/METADATA +150 -0
- coatlearn-0.1.0.dist-info/RECORD +53 -0
- coatlearn-0.1.0.dist-info/WHEEL +5 -0
- coatlearn-0.1.0.dist-info/entry_points.txt +2 -0
- coatlearn-0.1.0.dist-info/licenses/LICENSE +202 -0
- coatlearn-0.1.0.dist-info/licenses/NOTICE +21 -0
- coatlearn-0.1.0.dist-info/licenses/data/LICENSE +22 -0
- coatlearn-0.1.0.dist-info/licenses/docs/assets/vendor/LICENSE-docsify +21 -0
- coatlearn-0.1.0.dist-info/licenses/docs/assets/vendor/LICENSE-docsify-copy-code +21 -0
- coatlearn-0.1.0.dist-info/licenses/docs/assets/vendor/LICENSE-docsify-pagination +21 -0
- coatlearn-0.1.0.dist-info/licenses/docs/assets/vendor/LICENSE-prismjs +21 -0
- coatlearn-0.1.0.dist-info/top_level.txt +1 -0
coatlearn/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""COAT: causal discovery over unstructured data with large language models.
|
|
2
|
+
|
|
3
|
+
The public surface is intentionally small::
|
|
4
|
+
|
|
5
|
+
from coatlearn import ModelConfig, RunConfig, run_mb, run_pag, run_adj
|
|
6
|
+
|
|
7
|
+
``run_mb`` / ``run_pag`` / ``run_adj`` wrap the core experiment classes
|
|
8
|
+
(``COATMB`` / ``COATPAG`` / ``COATADJ``) without re-implementing any
|
|
9
|
+
algorithm. They validate inputs first, then resolve model clients, and
|
|
10
|
+
return a strictly JSON-safe result dictionary with a conservative
|
|
11
|
+
``status`` field.
|
|
12
|
+
|
|
13
|
+
Importing this package has no side effects: no model client is
|
|
14
|
+
constructed, no credentials are read, no experiment object is created,
|
|
15
|
+
no NLTK corpus is downloaded, and no dataset is accessed. Heavy
|
|
16
|
+
dependencies (causal-learn and friends) are imported only when a ``run_*``
|
|
17
|
+
function is actually called.
|
|
18
|
+
|
|
19
|
+
Independent model, annotation and causal operations are available from their
|
|
20
|
+
functional subpackages. Importing those interfaces starts no model inference.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from coatlearn.models.openai import ModelConfig
|
|
24
|
+
from .api import RunConfig, run_mb, run_pag, run_adj
|
|
25
|
+
|
|
26
|
+
__all__ = ["ModelConfig", "RunConfig", "run_mb", "run_pag", "run_adj"]
|
coatlearn/__main__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Measure user-defined factors without running causal discovery."""
|
|
2
|
+
from .types import MeasurementSpec, FactorAnnotation, UnsupportedMeasurement
|
|
3
|
+
from .definitions import factor_definition_to_spec, general_definition_to_spec
|
|
4
|
+
from .measurement import JevAnnotator, KevAnnotator, FactorAnnotator
|
|
5
|
+
from .batch import annotate, AnnotationResult
|
|
6
|
+
from .judge import JevFactorJudge, review_definition
|
|
7
|
+
from .dedup import JevFactorDeduplicator, compare_definitions
|
|
8
|
+
|
|
9
|
+
__all__ = ["annotate", "AnnotationResult", "MeasurementSpec", "FactorAnnotation",
|
|
10
|
+
"UnsupportedMeasurement", "factor_definition_to_spec", "general_definition_to_spec",
|
|
11
|
+
"FactorAnnotator", "JevAnnotator", "KevAnnotator", "JevFactorJudge",
|
|
12
|
+
"JevFactorDeduplicator", "review_definition", "compare_definitions"]
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Batch measurement of fixed factor definitions, with no discovery or screening."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
import pandas as pd
|
|
8
|
+
|
|
9
|
+
from .definitions import factor_definition_to_spec
|
|
10
|
+
from .types import MeasurementSpec
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class AnnotationResult:
|
|
15
|
+
"""Aligned values, per-measurement metadata and the finalized definitions."""
|
|
16
|
+
|
|
17
|
+
values: pd.DataFrame
|
|
18
|
+
metadata: pd.DataFrame
|
|
19
|
+
specs: dict[str, MeasurementSpec]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def resolve_annotator(model):
|
|
23
|
+
"""Resolve a preset or model configuration after local data validation."""
|
|
24
|
+
from coatlearn.models.presets import ModelPreset
|
|
25
|
+
from coatlearn.models.jev import JevConfig
|
|
26
|
+
from coatlearn.models.systemone import SystemOneClient
|
|
27
|
+
from .measurement import JevAnnotator, decision_annotator
|
|
28
|
+
|
|
29
|
+
if isinstance(model, ModelPreset):
|
|
30
|
+
model = model._resolve("annotation")
|
|
31
|
+
if isinstance(model, JevConfig):
|
|
32
|
+
return decision_annotator(model)
|
|
33
|
+
if callable(getattr(model, "annotate_record", None)):
|
|
34
|
+
return model
|
|
35
|
+
if isinstance(model, SystemOneClient):
|
|
36
|
+
annotator = JevAnnotator(model.config)
|
|
37
|
+
annotator._system_one = model._system_one
|
|
38
|
+
annotator.stats = model.stats
|
|
39
|
+
return annotator
|
|
40
|
+
raise TypeError("model must be a Jev/Kev configuration, preset, System One client or factor annotator")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def annotate(texts, definitions: Mapping[str, str | MeasurementSpec], *, model,
|
|
44
|
+
concurrency: int = 16, batch_size: int = 8) -> AnnotationResult:
|
|
45
|
+
"""Measure fixed definitions in row order, without judging or deduplicating them.
|
|
46
|
+
|
|
47
|
+
Each request contains one text and up to batch_size factors. A pandas Series
|
|
48
|
+
retains its index, including repeated labels. Transport failures propagate;
|
|
49
|
+
a mention gate can independently record a missing measurement as NaN.
|
|
50
|
+
"""
|
|
51
|
+
if not isinstance(concurrency, int) or isinstance(concurrency, bool) or concurrency < 1:
|
|
52
|
+
raise ValueError("concurrency must be a positive integer")
|
|
53
|
+
if not isinstance(batch_size, int) or isinstance(batch_size, bool) or not 1 <= batch_size <= 8:
|
|
54
|
+
raise ValueError("batch_size must be between 1 and 8")
|
|
55
|
+
if isinstance(texts, str):
|
|
56
|
+
raise TypeError("texts must be a sequence, not a single string")
|
|
57
|
+
series = texts.copy() if isinstance(texts, pd.Series) else pd.Series(list(texts), dtype=object)
|
|
58
|
+
if series.empty or not series.map(lambda text: isinstance(text, str) and bool(text.strip())).all():
|
|
59
|
+
raise ValueError("texts must contain nonempty strings")
|
|
60
|
+
if not isinstance(definitions, Mapping) or not definitions:
|
|
61
|
+
raise ValueError("definitions must map factor names to definitions")
|
|
62
|
+
specs = {}
|
|
63
|
+
for name, definition in definitions.items():
|
|
64
|
+
spec = definition if isinstance(definition, MeasurementSpec) else factor_definition_to_spec(name, definition)
|
|
65
|
+
if spec.factor_name != name:
|
|
66
|
+
raise ValueError("Definition names must match their mapping keys")
|
|
67
|
+
specs[name] = spec.validate()
|
|
68
|
+
backend = resolve_annotator(model)
|
|
69
|
+
prepared = {name: backend.prepare_factor(name, spec) for name, spec in specs.items()}
|
|
70
|
+
names = list(prepared)
|
|
71
|
+
|
|
72
|
+
def measure(item):
|
|
73
|
+
row, text = item
|
|
74
|
+
values, metadata = {}, []
|
|
75
|
+
for start in range(0, len(names), batch_size):
|
|
76
|
+
chunk = {name: prepared[name] for name in names[start:start + batch_size]}
|
|
77
|
+
answers = backend.annotate_record(text, chunk, sample_id=row)
|
|
78
|
+
for name in chunk:
|
|
79
|
+
answer = answers[name]
|
|
80
|
+
values[name] = answer.value
|
|
81
|
+
snapshot = getattr(backend, "annotation_metadata", None)
|
|
82
|
+
record = snapshot(row, name) if callable(snapshot) else None
|
|
83
|
+
metadata.append({**(record if record is not None else answer.to_metadata()), "row": row})
|
|
84
|
+
return values, metadata
|
|
85
|
+
|
|
86
|
+
with ThreadPoolExecutor(max_workers=concurrency) as pool:
|
|
87
|
+
measured = list(pool.map(measure, enumerate(series.tolist())))
|
|
88
|
+
values = pd.DataFrame([row for row, _ in measured], index=series.index, columns=names)
|
|
89
|
+
metadata = pd.DataFrame([record for _, records in measured for record in records])
|
|
90
|
+
return AnnotationResult(values, metadata, dict(prepared))
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Jev concept-level comparison, usable standalone or via opt-in COAT dedup.
|
|
2
|
+
|
|
3
|
+
Compare original definitions without rewriting them or reusing labels across
|
|
4
|
+
non-equivalent rubrics. UNCERTAIN and technical ERROR both retain a candidate,
|
|
5
|
+
but are distinct audit outcomes. This is not a definition/label merge engine.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import asdict
|
|
10
|
+
|
|
11
|
+
from coatlearn.annotation.types import MeasurementSpec
|
|
12
|
+
from coatlearn.models.jev import JevConfig
|
|
13
|
+
|
|
14
|
+
DEFAULT_TASK_CONTEXT = "从非结构化文本中识别候选因子;比较完整定义对应的核心属性,而非预测目标值或判断因果关系。"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def normalize_factor_name(name: str) -> str:
|
|
18
|
+
"""Legacy exact-name normalization only; no substring or WordNet veto."""
|
|
19
|
+
return " ".join(name.lower().replace("_", " ").replace("-", " ").split())
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def configured_deduplicator(fields):
|
|
23
|
+
"""Validate optional core config without constructing an HTTP client."""
|
|
24
|
+
if fields is None:
|
|
25
|
+
return None, DEFAULT_TASK_CONTEXT
|
|
26
|
+
if not isinstance(fields, dict) or fields.get("backend") not in {"jev", "kev"}:
|
|
27
|
+
raise ValueError("factor_dedup requires backend='jev' or 'kev'")
|
|
28
|
+
fields = dict(fields)
|
|
29
|
+
context = fields.pop("task_context", DEFAULT_TASK_CONTEXT)
|
|
30
|
+
if not isinstance(context, str) or not context.strip():
|
|
31
|
+
raise ValueError("factor_dedup.task_context must be a nonempty string")
|
|
32
|
+
from coatlearn.models.kev import decision_config
|
|
33
|
+
return JevFactorDeduplicator(decision_config(fields)), context
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def core_dedup_policy(comparator, context):
|
|
37
|
+
"""Record actual orchestration separately from the standalone pair policy."""
|
|
38
|
+
if comparator is None:
|
|
39
|
+
return None
|
|
40
|
+
return {**comparator.policy(), "integrated_into_coat": True,
|
|
41
|
+
"task_context": context, "comparison_order": "existing_A_new_B",
|
|
42
|
+
"simple_rules": ["exact_normalized_name", "cross_cluster_exact_definition"],
|
|
43
|
+
"representative": "first_valid_measured_factor",
|
|
44
|
+
"candidate_order": "cluster_index_then_proposal_order",
|
|
45
|
+
"reverse_check": False, "confidence_threshold": None}
|
|
46
|
+
|
|
47
|
+
DEDUP_INSTRUCTIONS = """判断两个候选因子是否描述同一个核心属性。
|
|
48
|
+
同一属性的措辞、评价角度、取值范围或判定细节有所变化,不足以构成新的独立因子。例如,属性本身与针对该属性的评价,可以属于概念重复。
|
|
49
|
+
但仅仅讨论同一对象、主题相关、存在因果联系或统计相关,不构成重复。不同主体的评价或不同结果维度,也不能仅因相关而合并。
|
|
50
|
+
比较两份完整定义,不仅比较名称。
|
|
51
|
+
不改写定义,不判断哪个定义更好。"""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def dedup_question() -> dict:
|
|
55
|
+
return {
|
|
56
|
+
"type": "choice",
|
|
57
|
+
"instructions": DEDUP_INSTRUCTIONS,
|
|
58
|
+
"criteria": {
|
|
59
|
+
"DUPLICATE": "能够明确判断为同一核心属性的重复表达。",
|
|
60
|
+
"DISTINCT": "能够明确指出不同的核心属性。",
|
|
61
|
+
"UNCERTAIN": "现有定义不足以明确判断。",
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def build_state(definition_a: str, definition_b: str, task_context: str) -> str:
|
|
67
|
+
"""Only task context and verbatim definitions enter state; no reference labels."""
|
|
68
|
+
for text in (definition_a, definition_b, task_context):
|
|
69
|
+
if not isinstance(text, str) or not text.strip():
|
|
70
|
+
raise ValueError("Two nonempty definitions and task context are required")
|
|
71
|
+
return ("# Task context\n\n" + task_context
|
|
72
|
+
+ "\n\n# Factor A (verbatim)\n\n" + definition_a
|
|
73
|
+
+ "\n\n# Factor B (verbatim)\n\n" + definition_b)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class JevFactorDeduplicator:
|
|
77
|
+
"""One native Choice request per pair; no main LLM or discovery calls.
|
|
78
|
+
|
|
79
|
+
Original request and response are local audit artifacts, not public results.
|
|
80
|
+
No confidence cutoff, automatic retries beyond config, or label rewriting.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def __init__(self, config: JevConfig):
|
|
84
|
+
from coatlearn.annotation.measurement import decision_annotator
|
|
85
|
+
self.backend = decision_annotator(config)
|
|
86
|
+
question = dedup_question()
|
|
87
|
+
self.spec = MeasurementSpec(
|
|
88
|
+
factor_name="relation", jev_type="choice",
|
|
89
|
+
instructions=question["instructions"],
|
|
90
|
+
criteria=tuple(question["criteria"].items()),
|
|
91
|
+
choice_values={"DUPLICATE": 1, "DISTINCT": 0, "UNCERTAIN": -1},
|
|
92
|
+
).validate()
|
|
93
|
+
|
|
94
|
+
def policy(self) -> dict:
|
|
95
|
+
backend = self.backend.config.redacted().get("backend", "jev")
|
|
96
|
+
return {"version": "concept-dedup-v1", "backend": backend,
|
|
97
|
+
"config": self.backend.config.redacted(),
|
|
98
|
+
"questions": {"relation": dedup_question()},
|
|
99
|
+
"uncertain_action": "retain", "error_action": "retain",
|
|
100
|
+
"rewrites_definitions": False, "integrated_into_coat": False}
|
|
101
|
+
|
|
102
|
+
def stats(self) -> dict:
|
|
103
|
+
return asdict(self.backend.stats())
|
|
104
|
+
|
|
105
|
+
def evaluate(self, definition_a: str, definition_b: str, task_context: str) -> dict:
|
|
106
|
+
request = {"model": self.backend.config.model,
|
|
107
|
+
"state": build_state(definition_a, definition_b, task_context),
|
|
108
|
+
"questions": {"relation": dedup_question()}}
|
|
109
|
+
result = {"status": "error", "decision": "ERROR", "action": "retain",
|
|
110
|
+
"request": request, "response": None}
|
|
111
|
+
try:
|
|
112
|
+
response = self.backend._system_one(request)
|
|
113
|
+
result["response"] = response
|
|
114
|
+
if (response.get("model") != self.backend.config.model
|
|
115
|
+
or set(response.get("answers", {})) != {"relation"}):
|
|
116
|
+
raise ValueError("Unexpected comparator model or answer keys")
|
|
117
|
+
answer = self.backend._interpret_answer(
|
|
118
|
+
"relation", self.spec, response["answers"]["relation"], None)
|
|
119
|
+
decision = answer.raw["choice"]
|
|
120
|
+
result.update(status="ok", decision=decision,
|
|
121
|
+
action="deduplicate" if decision == "DUPLICATE" else "retain",
|
|
122
|
+
probabilities=answer.probabilities, confidence=answer.confidence)
|
|
123
|
+
except Exception as exc:
|
|
124
|
+
# Transport exceptions may contain secrets: record type only.
|
|
125
|
+
result["error_type"] = type(exc).__name__
|
|
126
|
+
return result
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def compare_definitions(definition_a, definition_b, *, model, task_context=DEFAULT_TASK_CONTEXT):
|
|
130
|
+
"""Compare two full definitions without rewriting them or running discovery."""
|
|
131
|
+
from .batch import resolve_annotator
|
|
132
|
+
backend = resolve_annotator(model)
|
|
133
|
+
comparator = JevFactorDeduplicator(backend.config)
|
|
134
|
+
comparator.backend = backend
|
|
135
|
+
return comparator.evaluate(definition_a, definition_b, task_context)
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Deterministic translation of COAT's extracted ternary factor definitions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from coatlearn.annotation.types import MeasurementSpec, UnsupportedMeasurement
|
|
10
|
+
|
|
11
|
+
__all__ = ["factor_definition_to_spec", "general_definition_to_spec"]
|
|
12
|
+
|
|
13
|
+
_GENERAL_TYPE = re.compile(r"^[-+*•]?\s*type\s*:\s*(score|choice)\s*$", re.I)
|
|
14
|
+
_VALUES = re.compile(r"^[-+*•]?\s*values\s*:\s*(.+)$", re.I)
|
|
15
|
+
_MENTIONED = re.compile(r"^[-+*•]?\s*mentioned\s+when\s*:\s*(.+)$", re.I)
|
|
16
|
+
_LEVEL = re.compile(r"^[-+*•]?\s*(\d+)\s*:\s*(.+)$")
|
|
17
|
+
_OPTION = re.compile(r"^[-+*•]?\s*([^:]+?)\s*:\s*(.+)$")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _unwrap(text: str) -> str:
|
|
21
|
+
text = text.strip()
|
|
22
|
+
while True:
|
|
23
|
+
for marker in ("**", "__", "`", "*", "_"):
|
|
24
|
+
if (
|
|
25
|
+
text.startswith(marker)
|
|
26
|
+
and text.endswith(marker)
|
|
27
|
+
and len(text) >= 2 * len(marker)
|
|
28
|
+
and text.find(marker, len(marker)) == len(text) - len(marker)
|
|
29
|
+
):
|
|
30
|
+
text = text[len(marker) : -len(marker)].strip()
|
|
31
|
+
break
|
|
32
|
+
else:
|
|
33
|
+
return text
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _unbullet(text: str) -> str:
|
|
37
|
+
return re.sub(r"^(?:[-+*•]\s+|\d+[.)]\s+)", "", text.strip()).strip()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _name(text: str) -> str:
|
|
41
|
+
text = re.sub(r"^#{1,6}\s+", "", _unbullet(_unwrap(text)))
|
|
42
|
+
return " ".join(_unwrap(text.rstrip(":")).rstrip(":").split())
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def factor_definition_to_spec(factor_name: str, factor_definition: Any) -> MeasurementSpec:
|
|
46
|
+
"""Parse exactly one named {-1, 0, 1} factor without inference or requests.
|
|
47
|
+
|
|
48
|
+
Accept the extracted four-line text or its exact ``name``/``description``
|
|
49
|
+
record. Markdown decoration and whitespace are presentation only; criterion
|
|
50
|
+
wording and label associations are retained. Unsupported input raises
|
|
51
|
+
:class:`UnsupportedMeasurement` before any backend is involved.
|
|
52
|
+
"""
|
|
53
|
+
if not isinstance(factor_name, str) or not factor_name.strip():
|
|
54
|
+
raise UnsupportedMeasurement(factor_name, "factor_name must be a nonempty string")
|
|
55
|
+
name = _name(factor_name)
|
|
56
|
+
if not name:
|
|
57
|
+
raise UnsupportedMeasurement(factor_name, "factor_name must contain a name")
|
|
58
|
+
if isinstance(factor_definition, Mapping):
|
|
59
|
+
if set(factor_definition) != {"name", "description"}:
|
|
60
|
+
raise UnsupportedMeasurement(factor_name, "expected exactly name and description")
|
|
61
|
+
record_name = factor_definition["name"]
|
|
62
|
+
if not isinstance(record_name, str) or _name(record_name).casefold() != name.casefold():
|
|
63
|
+
raise UnsupportedMeasurement(factor_name, "factor record name does not match")
|
|
64
|
+
factor_definition = factor_definition["description"]
|
|
65
|
+
if not isinstance(factor_definition, str):
|
|
66
|
+
raise UnsupportedMeasurement(factor_name, "factor definition must be text or a factor record")
|
|
67
|
+
if any(_GENERAL_TYPE.match(line.strip()) for line in factor_definition.splitlines()):
|
|
68
|
+
return general_definition_to_spec(factor_name, factor_definition)
|
|
69
|
+
lines = [line.strip() for line in factor_definition.splitlines() if line.strip()]
|
|
70
|
+
if len(lines) >= 2 and lines[0] in ("```", "```text", "```markdown") and lines[-1] == "```":
|
|
71
|
+
lines = lines[1:-1]
|
|
72
|
+
if len(lines) != 4:
|
|
73
|
+
raise UnsupportedMeasurement(factor_name, "expected a name and exactly three criterion lines")
|
|
74
|
+
if _name(lines[0]).casefold() != name.casefold():
|
|
75
|
+
raise UnsupportedMeasurement(factor_name, "definition name does not match factor_name")
|
|
76
|
+
criteria = {}
|
|
77
|
+
for line in lines[1:]:
|
|
78
|
+
line = _unwrap(_unbullet(line))
|
|
79
|
+
line = re.sub(r"^(\*\*|__|`|\*|_)([-−]?\d+\s*:?)\1", r"\2", line)
|
|
80
|
+
match = re.fullmatch(r"([-−]?\d+)\s*:\s*(.*)", line)
|
|
81
|
+
if match is None:
|
|
82
|
+
raise UnsupportedMeasurement(factor_name, "expected a numeric label and criterion")
|
|
83
|
+
label = match.group(1).replace("−", "-")
|
|
84
|
+
description = " ".join(match.group(2).split())
|
|
85
|
+
if label not in ("1", "0", "-1") or label in criteria:
|
|
86
|
+
raise UnsupportedMeasurement(factor_name, "labels must be exactly 1, 0 and -1 without duplicates")
|
|
87
|
+
if not _unwrap(description):
|
|
88
|
+
raise UnsupportedMeasurement(factor_name, "criteria must not be empty")
|
|
89
|
+
criteria[label] = description
|
|
90
|
+
if set(criteria) != {"1", "0", "-1"}:
|
|
91
|
+
raise UnsupportedMeasurement(factor_name, "labels must be exactly 1, 0 and -1")
|
|
92
|
+
return MeasurementSpec(
|
|
93
|
+
factor_name=factor_name,
|
|
94
|
+
jev_type="choice",
|
|
95
|
+
instructions=f"Factor: {name}",
|
|
96
|
+
criteria=tuple((label, criteria[label]) for label in ("1", "0", "-1")),
|
|
97
|
+
choice_values={"1": 1.0, "0": 0.0, "-1": -1.0},
|
|
98
|
+
source="parsed",
|
|
99
|
+
notes="COAT ternary criteria parsed verbatim; value is the selected label, not probability or confidence.",
|
|
100
|
+
).validate()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def general_definition_to_spec(factor_name: str, factor_definition: str) -> MeasurementSpec:
|
|
104
|
+
"""Parse a general-template factor with an explicit measurement type.
|
|
105
|
+
|
|
106
|
+
The proposal declares ``Type: score|choice``, ``Values: ...`` and
|
|
107
|
+
``Mentioned when: ...``; the mention sentence becomes a noul gate so that
|
|
108
|
+
"text does not mention this attribute" is recorded as missing rather than
|
|
109
|
+
folded into a value. Parsing is deterministic and makes no model calls.
|
|
110
|
+
"""
|
|
111
|
+
if not isinstance(factor_name, str) or not factor_name.strip():
|
|
112
|
+
raise UnsupportedMeasurement(factor_name, "factor_name must be a nonempty string")
|
|
113
|
+
name = _name(factor_name)
|
|
114
|
+
if not name:
|
|
115
|
+
raise UnsupportedMeasurement(factor_name, "factor_name must contain a name")
|
|
116
|
+
if not isinstance(factor_definition, str):
|
|
117
|
+
raise UnsupportedMeasurement(factor_name, "factor definition must be text")
|
|
118
|
+
lines = [line.strip() for line in factor_definition.splitlines() if line.strip()]
|
|
119
|
+
if len(lines) >= 2 and lines[0] in ("```", "```text", "```markdown") and lines[-1] == "```":
|
|
120
|
+
lines = lines[1:-1]
|
|
121
|
+
if len(lines) < 4 or _name(lines[0]).casefold() != name.casefold():
|
|
122
|
+
raise UnsupportedMeasurement(factor_name, "expected a name line and a general definition")
|
|
123
|
+
kind = values_text = mentioned = None
|
|
124
|
+
body = []
|
|
125
|
+
for line in lines[1:]:
|
|
126
|
+
stripped = _unwrap(_unbullet(line))
|
|
127
|
+
match = _GENERAL_TYPE.match(stripped)
|
|
128
|
+
if match and kind is None:
|
|
129
|
+
kind = match.group(1).lower()
|
|
130
|
+
continue
|
|
131
|
+
match = _VALUES.match(stripped)
|
|
132
|
+
if match and values_text is None:
|
|
133
|
+
values_text = match.group(1).strip()
|
|
134
|
+
continue
|
|
135
|
+
match = _MENTIONED.match(stripped)
|
|
136
|
+
if match and mentioned is None:
|
|
137
|
+
mentioned = " ".join(match.group(1).split())
|
|
138
|
+
continue
|
|
139
|
+
body.append(stripped)
|
|
140
|
+
if kind is None or values_text is None or mentioned is None:
|
|
141
|
+
raise UnsupportedMeasurement(
|
|
142
|
+
factor_name, "general definition needs Type, Values and Mentioned when"
|
|
143
|
+
)
|
|
144
|
+
if not _unwrap(mentioned):
|
|
145
|
+
raise UnsupportedMeasurement(factor_name, "Mentioned when must not be empty")
|
|
146
|
+
|
|
147
|
+
gate = MeasurementSpec(
|
|
148
|
+
factor_name=factor_name,
|
|
149
|
+
jev_type="noul",
|
|
150
|
+
instructions=f"Does the text explicitly mention this attribute? Factor: {name}",
|
|
151
|
+
criteria=(mentioned, "The text does not explicitly mention this attribute."),
|
|
152
|
+
source="parsed",
|
|
153
|
+
notes="Mention gate: text without an explicit statement is recorded as missing.",
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
if kind == "score":
|
|
157
|
+
try:
|
|
158
|
+
value_map = tuple(float(v.strip()) for v in values_text.split(","))
|
|
159
|
+
except ValueError:
|
|
160
|
+
raise UnsupportedMeasurement(factor_name, "score Values must be comma-separated numbers") from None
|
|
161
|
+
levels = {}
|
|
162
|
+
for line in body:
|
|
163
|
+
match = _LEVEL.match(line)
|
|
164
|
+
if match is None:
|
|
165
|
+
raise UnsupportedMeasurement(factor_name, "expected numbered score levels")
|
|
166
|
+
index = int(match.group(1))
|
|
167
|
+
if index in levels:
|
|
168
|
+
raise UnsupportedMeasurement(factor_name, "duplicate score level")
|
|
169
|
+
levels[index] = " ".join(match.group(2).split())
|
|
170
|
+
if len(value_map) < 2 or len(levels) != len(value_map):
|
|
171
|
+
raise UnsupportedMeasurement(
|
|
172
|
+
factor_name, "score needs one level per Values entry")
|
|
173
|
+
# Level labels may be 0..n-1 or the numeric values themselves; either way
|
|
174
|
+
# they are ordered ascending and paired with Values in that order.
|
|
175
|
+
ordered = sorted(levels)
|
|
176
|
+
return MeasurementSpec(
|
|
177
|
+
factor_name=factor_name,
|
|
178
|
+
jev_type="score",
|
|
179
|
+
instructions=f"Factor: {name}",
|
|
180
|
+
criteria=tuple(levels[key] for key in ordered),
|
|
181
|
+
value_map=value_map,
|
|
182
|
+
source="parsed",
|
|
183
|
+
notes="General score parsed verbatim; value is the probability-weighted expectation over Values.",
|
|
184
|
+
gate=gate,
|
|
185
|
+
).validate()
|
|
186
|
+
|
|
187
|
+
options = {}
|
|
188
|
+
for line in body:
|
|
189
|
+
match = _OPTION.match(line)
|
|
190
|
+
if match is None:
|
|
191
|
+
raise UnsupportedMeasurement(factor_name, "expected named choice options")
|
|
192
|
+
option, description = match.group(1).strip(), " ".join(match.group(2).split())
|
|
193
|
+
if option in options:
|
|
194
|
+
raise UnsupportedMeasurement(factor_name, "duplicate choice option")
|
|
195
|
+
options[option] = description
|
|
196
|
+
choice_values = {}
|
|
197
|
+
for item in values_text.split(","):
|
|
198
|
+
if "=" not in item:
|
|
199
|
+
raise UnsupportedMeasurement(factor_name, "choice Values must use option=number")
|
|
200
|
+
option, value = item.split("=", 1)
|
|
201
|
+
try:
|
|
202
|
+
choice_values[option.strip()] = float(value.strip())
|
|
203
|
+
except ValueError:
|
|
204
|
+
raise UnsupportedMeasurement(factor_name, "choice value must be numeric") from None
|
|
205
|
+
if set(choice_values) != set(options):
|
|
206
|
+
raise UnsupportedMeasurement(factor_name, "Values must cover exactly the declared options")
|
|
207
|
+
return MeasurementSpec(
|
|
208
|
+
factor_name=factor_name,
|
|
209
|
+
jev_type="choice",
|
|
210
|
+
instructions=f"Factor: {name}",
|
|
211
|
+
criteria=tuple((option, options[option]) for option in options),
|
|
212
|
+
choice_values=choice_values,
|
|
213
|
+
source="parsed",
|
|
214
|
+
notes="General choice parsed verbatim; value is the selected option's declared number.",
|
|
215
|
+
gate=gate,
|
|
216
|
+
).validate()
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Opt-in Jev factor-definition judge; separate from per-review annotation.
|
|
2
|
+
|
|
3
|
+
One native request, two independent Choice questions, both must PASS. The
|
|
4
|
+
wording-clarity instruction is the author's exact text. No schema/main LLM,
|
|
5
|
+
confidence threshold, or implicit rewrite of the proposed definition is used.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import asdict
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from coatlearn.annotation.types import MeasurementSpec, UnsupportedMeasurement
|
|
13
|
+
from coatlearn.models.jev import JevConfig
|
|
14
|
+
from coatlearn.annotation.definitions import factor_definition_to_spec
|
|
15
|
+
|
|
16
|
+
CLARITY_INSTRUCTIONS = "结合任务示例,因子名称和各取值说明是否有明确、可理解的含义?仅因实质性的文字歧义拒绝,例如指代不明、修饰语或否定范围存在不同合理解释。"
|
|
17
|
+
FORMAT_INSTRUCTIONS = "因子定义的格式是否正确:一个非空的因子名称,随后是标记为 1、0、-1 的三条非空取值说明,三个标签各出现一次且没有额外取值或内容?Markdown 修饰和空白不影响格式。"
|
|
18
|
+
FORMAT_INSTRUCTIONS_GENERAL = "因子定义的格式是否正确:一个非空的因子名称,随后声明 Type(score 或 choice)、Values、Mentioned when,并且每个取值或选项都有非空的说明?Markdown 修饰和空白不影响格式。"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def judge_questions(template: str = "simple") -> dict:
|
|
22
|
+
"""Fresh native question objects, safe to freeze in audit/provenance."""
|
|
23
|
+
format_instructions = (
|
|
24
|
+
FORMAT_INSTRUCTIONS_GENERAL if template == "general" else FORMAT_INSTRUCTIONS
|
|
25
|
+
)
|
|
26
|
+
return {
|
|
27
|
+
"clarity": {"type": "choice", "instructions": CLARITY_INSTRUCTIONS, "criteria": {
|
|
28
|
+
"PASS": "因子名称和各取值说明有明确、可理解的含义。",
|
|
29
|
+
"REJECT": "存在实质性的文字歧义。"}},
|
|
30
|
+
"format": {"type": "choice", "instructions": format_instructions, "criteria": {
|
|
31
|
+
"PASS": "格式正确。", "REJECT": "格式不正确。"}},
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def proposal_examples(prompt: str) -> str:
|
|
36
|
+
"""Extract the actual data section, never regenerate/resample examples."""
|
|
37
|
+
examples = prompt.split("# Data\n\n", 1)[1].split("# Tasks: Factor Abstraction.", 1)[0]
|
|
38
|
+
for heading in ("\n# Existing Factors", "\n# Previously Rejected Factors"):
|
|
39
|
+
examples = examples.split(heading, 1)[0]
|
|
40
|
+
return examples
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def configured_judge(fields, template="simple"):
|
|
44
|
+
"""Choose a typed backend without changing judge questions or acceptance."""
|
|
45
|
+
if fields is None:
|
|
46
|
+
return None
|
|
47
|
+
if not isinstance(fields, dict) or fields.get("backend") not in {"jev", "kev"}:
|
|
48
|
+
raise ValueError("factor_judge requires backend='jev' or 'kev'")
|
|
49
|
+
from coatlearn.models.kev import decision_config
|
|
50
|
+
return JevFactorJudge(decision_config(fields), template=template)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class JevFactorJudge:
|
|
54
|
+
"""Reuse the existing native transport and strict Choice response parser.
|
|
55
|
+
|
|
56
|
+
``evaluate`` returns a local audit record, which can contain example text
|
|
57
|
+
and final responses. It must NOT be put into the public result dictionary.
|
|
58
|
+
API/response errors are ERROR, not a semantic rejection or a normal zero.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(self, config: JevConfig, template: str = "simple"):
|
|
62
|
+
from coatlearn.annotation.measurement import decision_annotator
|
|
63
|
+
self.backend = decision_annotator(config)
|
|
64
|
+
self.template = template
|
|
65
|
+
self.specs = {
|
|
66
|
+
name: MeasurementSpec(factor_name=name, jev_type="choice", instructions=q["instructions"],
|
|
67
|
+
criteria=tuple(q["criteria"].items()), choice_values={"PASS": 1, "REJECT": 0}).validate()
|
|
68
|
+
for name, q in judge_questions(template).items()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
def policy(self) -> dict:
|
|
72
|
+
backend = self.backend.config.redacted().get("backend", "jev")
|
|
73
|
+
return {"backend": backend, "version": "clarity-format-v1", "template": self.template,
|
|
74
|
+
"config": self.backend.config.redacted(),
|
|
75
|
+
"questions": judge_questions(self.template), "acceptance": "both_PASS", "trials": 1,
|
|
76
|
+
"replaces": "legacy_quality_prescoring_and_three_LLM_judges"}
|
|
77
|
+
|
|
78
|
+
def stats(self) -> dict:
|
|
79
|
+
return asdict(self.backend.stats())
|
|
80
|
+
|
|
81
|
+
def evaluate(self, factor_name: str, definition: str, examples: str) -> dict[str, Any]:
|
|
82
|
+
result = {"status": "ok", "decision": "REJECT", "local_format": "PASS", "response": None,
|
|
83
|
+
"request": {"model": self.backend.config.model,
|
|
84
|
+
"state": "# Candidate factor definition (verbatim)\n\n" + definition
|
|
85
|
+
+ "\n\n# Original proposal examples (verbatim data section)\n" + examples,
|
|
86
|
+
"questions": judge_questions(self.template)}}
|
|
87
|
+
try:
|
|
88
|
+
factor_definition_to_spec(factor_name, definition)
|
|
89
|
+
except UnsupportedMeasurement:
|
|
90
|
+
result.update(local_format="REJECT", rejected_checks=["local_format"])
|
|
91
|
+
return result
|
|
92
|
+
try:
|
|
93
|
+
if not isinstance(examples, str) or not examples.strip():
|
|
94
|
+
raise ValueError("Original proposal examples are required")
|
|
95
|
+
response = self.backend._system_one(result["request"])
|
|
96
|
+
result["response"] = response # retain even malformed final JSON in local audit
|
|
97
|
+
if response.get("model") != self.backend.config.model or set(response.get("answers", {})) != set(self.specs):
|
|
98
|
+
raise ValueError("Unexpected judge model or answer keys")
|
|
99
|
+
answers = {}
|
|
100
|
+
for name, spec in self.specs.items():
|
|
101
|
+
annotation = self.backend._interpret_answer(name, spec, response["answers"][name], None)
|
|
102
|
+
answers[name] = {"decision": annotation.raw["choice"], "probabilities": annotation.probabilities,
|
|
103
|
+
"confidence": annotation.confidence}
|
|
104
|
+
rejected = [name for name, a in answers.items() if a["decision"] == "REJECT"]
|
|
105
|
+
result.update(answers=answers, rejected_checks=rejected, decision="REJECT" if rejected else "PASS")
|
|
106
|
+
except Exception as exc:
|
|
107
|
+
result.update(status="error", decision="ERROR", error_type=type(exc).__name__)
|
|
108
|
+
return result
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def review_definition(name, definition, *, examples, model, template="general"):
|
|
112
|
+
"""Review one definition independently of annotation or discovery."""
|
|
113
|
+
from .batch import resolve_annotator
|
|
114
|
+
backend = resolve_annotator(model)
|
|
115
|
+
judge = JevFactorJudge(backend.config, template=template)
|
|
116
|
+
judge.backend = backend
|
|
117
|
+
return judge.evaluate(name, definition, examples)
|