Files
OSGKeyboard/Scripts/clipboard_semantics/generate_consensus_labels.py
T
2026-08-27 18:01:46 +08:00

659 lines
22 KiB
Python

#!/usr/bin/env python3
"""Prepare public-text labeling queues and build deterministic consensus silver data."""
from __future__ import annotations
import argparse
import hashlib
import json
import math
import random
import re
import unicodedata
from collections import Counter, defaultdict
from pathlib import Path
SEED = 20260827
INTENT_LABELS = (
"task",
"question",
"invitation",
"complaint",
"scheduleNegotiation",
"confirmationDecision",
"followUpReminder",
"blessing",
"replyableMessage",
)
SPECIAL_FLAGS = ("ambiguous", "quotedOrMeta")
ACTION_LABELS = (
"taskOnly",
"complaintOnly",
"both",
"questionRequest",
"neither",
)
COORDINATION_LABELS = (
"invitation",
"scheduleNegotiation",
"confirmationDecision",
"followUpReminder",
"neither",
)
PROMPT_VERSION = "clipboard-consensus-v1"
DEFAULT_DIRECTORY = Path("ModelTraining/ClipboardSemantics/Consensus")
DEFAULT_INPUT = Path("ModelTraining/ClipboardSemantics/open-training-corpus.jsonl")
def normalized_text(value: str) -> str:
return " ".join(
unicodedata.normalize("NFKC", value)
.replace("\u0000", " ")
.split()
).strip()
def stable_hash(value: str) -> int:
return int.from_bytes(hashlib.sha256(value.encode()).digest()[:8], "big")
def read_json_lines(path: Path) -> list[dict]:
return [
json.loads(line)
for line in path.read_text(encoding="utf-8").splitlines()
if line.strip()
]
def write_json_lines(path: Path, records: list[dict]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
serialized = "\n".join(
json.dumps(record, ensure_ascii=False, sort_keys=True)
for record in records
)
path.write_text(serialized + ("\n" if serialized else ""), encoding="utf-8")
def queue_priority(
record: dict,
hard_negative_texts: set[str],
) -> tuple[int, int]:
known_labels = set(record.get("knownLabels") or [])
positive_count = sum(bool(record.get(label)) for label in INTENT_LABELS)
confusion_priority = int(
bool(
known_labels
& {
"task",
"question",
"complaint",
"invitation",
"scheduleNegotiation",
"confirmationDecision",
"followUpReminder",
}
)
)
hard_negative_priority = int(
normalized_text(record["text"]).casefold() in hard_negative_texts
)
return (
hard_negative_priority * 10 + confusion_priority + positive_count,
stable_hash(record["id"]),
)
def stratified_queue(
records: list[dict],
count: int,
seed: int,
hard_negative_texts: set[str],
) -> list[dict]:
grouped: dict[tuple[str, str], list[dict]] = defaultdict(list)
for record in records:
grouped[
(
record.get("sourceDataset", "unknown"),
record.get("language", "unknown"),
)
].append(record)
for key in grouped:
grouped[key].sort(
key=lambda record: queue_priority(record, hard_negative_texts),
reverse=True,
)
keys = sorted(grouped)
rng = random.Random(seed)
rng.shuffle(keys)
offsets = {key: 0 for key in keys}
selected: list[dict] = []
while len(selected) < count:
added = False
for key in keys:
offset = offsets[key]
values = grouped[key]
if offset >= len(values):
continue
selected.append(values[offset])
offsets[key] += 1
added = True
if len(selected) == count:
break
if not added:
break
return sorted(selected, key=lambda record: record["id"])
def labeling_instructions() -> str:
return """# Clipboard semantic consensus labeling
Prompt version: clipboard-consensus-v1
Label every JSONL queue record independently. Use only the text itself; do not
infer missing conversational context. Return one JSON object per input record:
{"id":"same id","labels":["task"],"ambiguous":false,"quotedOrMeta":false,"confidence":0.98}
Allowed labels:
- task: another person is explicitly asked/assigned to perform an action.
- question: a genuine information question, including request-shaped questions.
- invitation: invitation to join an event or social activity.
- complaint: present dissatisfaction, malfunction, bad service, or unresolved problem.
- scheduleNegotiation: proposing, changing, or choosing between times; a fixed time is not enough.
- confirmationDecision: explicit approval, rejection, or selection of an option.
- followUpReminder: request to remind, check back, or follow up later/after a trigger.
- blessing: a genuine birthday, holiday, congratulations, or good-wish message.
- replyableMessage: a direct conversational message that naturally invites a response.
Rules:
- Multi-label is allowed. "Could you send it?" is task + question + replyableMessage.
- Set quotedOrMeta when intent-like words are quoted, documented, searched, or discussed.
- Set ambiguous when the text cannot be labeled without missing context.
- Empty labels mean none of the product intents.
- Negative sentiment alone is not complaint. Fixed appointments are not schedule negotiation.
- Do not expose reasoning or add fields. Confidence must be between 0 and 1.
"""
def prepare(arguments: argparse.Namespace) -> None:
records = read_json_lines(arguments.input)
requested_hard_negatives: set[str] = set()
for path in arguments.hard_negative_report:
report = json.loads(path.read_text(encoding="utf-8"))
for classifier in report.get("classifiers") or []:
for example in classifier.get("falsePositiveExamples") or []:
requested_hard_negatives.add(
normalized_text(example["text"]).casefold()
)
license_safe_texts = {
normalized_text(record["text"]).casefold() for record in records
}
matched_hard_negatives = requested_hard_negatives & license_safe_texts
queue = stratified_queue(
records,
arguments.count,
arguments.seed,
matched_hard_negatives,
)
queue_records = [
{
"id": record["id"],
"text": record["text"],
"language": record["language"],
"sourceDataset": record.get("sourceDataset"),
"sourceLicense": record.get("sourceLicense"),
"sourceURL": record.get("sourceURL"),
"sourceRevision": record.get("sourceRevision"),
"knownLabels": record.get("knownLabels", []),
"sourceLabels": {
label: bool(record.get(label))
for label in INTENT_LABELS
if label in set(record.get("knownLabels") or [])
},
}
for record in queue
]
write_json_lines(arguments.queue, queue_records)
arguments.instructions.parent.mkdir(parents=True, exist_ok=True)
instructions = labeling_instructions()
arguments.instructions.write_text(instructions, encoding="utf-8")
manifest = {
"schemaVersion": 1,
"promptVersion": PROMPT_VERSION,
"promptSHA256": hashlib.sha256(instructions.encode()).hexdigest(),
"seed": arguments.seed,
"input": str(arguments.input),
"queue": str(arguments.queue),
"recordCount": len(queue_records),
"requestedHardNegativeCount": len(requested_hard_negatives),
"licenseSafeMatchedHardNegativeCount": len(matched_hard_negatives),
"sourceCounts": dict(
sorted(
Counter(
record.get("sourceDataset") or "unknown"
for record in queue_records
).items()
)
),
"languageCounts": dict(
sorted(Counter(record["language"] for record in queue_records).items())
),
}
arguments.prepare_report.write_text(
json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
print(
f"CONSENSUS_QUEUE records={len(queue_records)} "
f"promptSHA256={manifest['promptSHA256']}"
)
def parse_labeler_argument(value: str) -> tuple[str, Path]:
name, separator, raw_path = value.partition("=")
if not separator or not name or not raw_path:
raise argparse.ArgumentTypeError("Expected LABELER=PATH")
return name, Path(raw_path)
def validate_labeler_record(record: dict, expected_ids: set[str]) -> dict:
record_id = record.get("id")
if record_id not in expected_ids:
raise ValueError(f"Unexpected labeler record id: {record_id}")
labels = record.get("labels")
if not isinstance(labels, list) or any(label not in INTENT_LABELS for label in labels):
raise ValueError(f"Unsupported labels for {record_id}: {labels}")
confidence = record.get("confidence")
if not isinstance(confidence, (int, float)) or not 0 <= confidence <= 1:
raise ValueError(f"Invalid confidence for {record_id}: {confidence}")
for flag in SPECIAL_FLAGS:
if not isinstance(record.get(flag), bool):
raise ValueError(f"Missing boolean {flag} for {record_id}")
return {
"id": record_id,
"labels": sorted(set(labels)),
"ambiguous": record["ambiguous"],
"quotedOrMeta": record["quotedOrMeta"],
"confidence": round(float(confidence), 4),
}
def action_label(labels: set[str]) -> str:
if "task" in labels and "complaint" in labels:
return "both"
if "task" in labels:
return "taskOnly"
if "complaint" in labels:
return "complaintOnly"
if "question" in labels:
return "questionRequest"
return "neither"
def coordination_label(labels: set[str]) -> str | None:
matches = [
label
for label in COORDINATION_LABELS
if label != "neither" and label in labels
]
if len(matches) > 1:
return None
return matches[0] if matches else "neither"
def cluster_signature(text: str) -> str:
normalized = unicodedata.normalize("NFKC", text).casefold()
normalized = re.sub(r"\d+", "<n>", normalized)
normalized = re.sub(r"[^\w\u4e00-\u9fff<>]+", " ", normalized)
tokens = normalized.split()
# Prefix and suffix retain intent-bearing wording while grouping slot variants.
skeleton = tokens[:10] + (["|"] + tokens[-6:] if len(tokens) > 16 else [])
return " ".join(skeleton)
def split_for(record: dict) -> str:
bucket = stable_hash(cluster_signature(record["text"])) % 100
if bucket < 70:
return "silverTrain"
if bucket < 85:
return "silverCalibration"
return "silverAcceptance"
def fleiss_kappa(
labeler_records: list[dict[str, dict]],
queue_ids: list[str],
) -> float:
if len(labeler_records) < 2 or not queue_ids:
return 0
category_counts = [0, 0]
agreement_total = 0.0
item_count = 0
rater_count = len(labeler_records)
for record_id in queue_ids:
for label in INTENT_LABELS:
yes_count = sum(
label in set(labeler[record_id]["labels"])
for labeler in labeler_records
)
no_count = rater_count - yes_count
category_counts[0] += no_count
category_counts[1] += yes_count
agreement_total += (
no_count * (no_count - 1) + yes_count * (yes_count - 1)
) / (rater_count * (rater_count - 1))
item_count += 1
observed = agreement_total / item_count
total_votes = sum(category_counts)
expected = sum((count / total_votes) ** 2 for count in category_counts)
if math.isclose(expected, 1):
return 1
return round((observed - expected) / (1 - expected), 4)
def merge(arguments: argparse.Namespace) -> None:
queue = read_json_lines(arguments.queue)
queue_by_id = {record["id"]: record for record in queue}
if len(queue_by_id) != len(queue):
raise ValueError("Consensus queue contains duplicate ids")
expected_ids = set(queue_by_id)
if len(arguments.labeler) < 3:
raise ValueError("Consensus requires at least three independent labelers")
labelers: list[tuple[str, dict[str, dict]]] = []
for name, path in arguments.labeler:
values = [
validate_labeler_record(record, expected_ids)
for record in read_json_lines(path)
]
by_id = {record["id"]: record for record in values}
missing = expected_ids.difference(by_id)
if missing:
raise ValueError(f"Labeler {name} is missing {len(missing)} records")
if len(by_id) != len(values):
raise ValueError(f"Labeler {name} contains duplicate ids")
labelers.append((name, by_id))
accepted: list[dict] = []
conflicts: list[dict] = []
for record_id in sorted(expected_ids):
queue_record = queue_by_id[record_id]
votes = Counter(
label
for _, records in labelers
for label in records[record_id]["labels"]
)
source_labels = {
label
for label, value in (queue_record.get("sourceLabels") or {}).items()
if value
}
consensus_labels = {
label
for label, count in votes.items()
if count == len(labelers) or count >= 2 and label in source_labels
}
ambiguous_votes = sum(
records[record_id]["ambiguous"] for _, records in labelers
)
quoted_votes = sum(
records[record_id]["quotedOrMeta"] for _, records in labelers
)
coordination = coordination_label(consensus_labels)
full_agreement = all(
set(records[record_id]["labels"]) == set(
labelers[0][1][record_id]["labels"]
)
and records[record_id]["ambiguous"]
== labelers[0][1][record_id]["ambiguous"]
and records[record_id]["quotedOrMeta"]
== labelers[0][1][record_id]["quotedOrMeta"]
for _, records in labelers[1:]
)
rejected_reason = None
if ambiguous_votes >= 2:
rejected_reason = "ambiguous-majority"
elif quoted_votes >= 2 and consensus_labels:
rejected_reason = "quoted-or-meta-intent"
elif coordination is None:
rejected_reason = "multiple-coordination-labels"
elif not consensus_labels and not full_agreement:
rejected_reason = "no-supported-consensus"
audit = {
"id": record_id,
"labelerVotes": dict(sorted(votes.items())),
"labelerConfidences": {
name: records[record_id]["confidence"]
for name, records in labelers
},
"labelerResponseHashes": {
name: hashlib.sha256(
json.dumps(
records[record_id],
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
).encode()
).hexdigest()
for name, records in labelers
},
"sourceLabels": sorted(source_labels),
"ambiguousVotes": ambiguous_votes,
"quotedOrMetaVotes": quoted_votes,
"fullAgreement": full_agreement,
}
if rejected_reason:
conflicts.append(
{
**queue_record,
**audit,
"rejectedReason": rejected_reason,
}
)
continue
split = split_for(queue_record)
labels = set() if quoted_votes >= 2 else consensus_labels
silver = {
"id": f"consensus-{record_id}",
"sourceRecordID": record_id,
"text": queue_record["text"],
"language": queue_record["language"],
"family": "consensus_public",
"split": split,
**{label: label in labels for label in INTENT_LABELS},
"sentiment": "neutral",
"replyable": "replyableMessage" in labels,
"actionVerifierLabel": action_label(labels),
"coordinationVerifierLabel": coordination_label(labels) or "neither",
"quotedOrMeta": quoted_votes >= 2,
"consensusAgreement": round(
max(
[votes.get(label, 0) for label in INTENT_LABELS] + [
len(labelers) if not labels and full_agreement else 0
]
)
/ len(labelers),
4,
),
"promptVersion": PROMPT_VERSION,
"sourceDataset": queue_record.get("sourceDataset"),
"sourceLicense": queue_record.get("sourceLicense"),
"sourceURL": queue_record.get("sourceURL"),
"sourceRevision": queue_record.get("sourceRevision"),
**audit,
}
accepted.append(silver)
write_json_lines(arguments.consensus, accepted)
write_json_lines(arguments.conflicts, conflicts)
for split, path in (
("silverTrain", arguments.train),
("silverCalibration", arguments.calibration),
("silverAcceptance", arguments.acceptance),
):
write_json_lines(
path,
[record for record in accepted if record["split"] == split],
)
labeler_maps = [records for _, records in labelers]
text_split_map: dict[str, set[str]] = defaultdict(set)
cluster_split_map: dict[str, set[str]] = defaultdict(set)
for record in accepted:
text_split_map[normalized_text(record["text"]).casefold()].add(record["split"])
cluster_split_map[cluster_signature(record["text"])].add(record["split"])
exact_overlap_count = sum(len(splits) > 1 for splits in text_split_map.values())
cluster_overlap_count = sum(
len(splits) > 1 for splits in cluster_split_map.values()
)
if exact_overlap_count or cluster_overlap_count:
raise ValueError("Silver split overlap validation failed")
report = {
"schemaVersion": 1,
"promptVersion": PROMPT_VERSION,
"promptSHA256": hashlib.sha256(labeling_instructions().encode()).hexdigest(),
"queueCount": len(queue),
"acceptedCount": len(accepted),
"conflictCount": len(conflicts),
"conflictRate": round(len(conflicts) / max(len(queue), 1), 4),
"duplicateTextRate": round(
(len(accepted) - len(text_split_map)) / max(len(accepted), 1),
4,
),
"overlapChecks": {
"exactTextAcrossSplits": exact_overlap_count,
"nearDuplicateClusterAcrossSplits": cluster_overlap_count,
},
"fullAgreementCount": sum(record["fullAgreement"] for record in accepted)
+ sum(record["fullAgreement"] for record in conflicts),
"fleissKappa": fleiss_kappa(
labeler_maps,
sorted(expected_ids),
),
"labelers": [name for name, _ in labelers],
"splitCounts": dict(sorted(Counter(record["split"] for record in accepted).items())),
"actionLabelCounts": dict(
sorted(Counter(record["actionVerifierLabel"] for record in accepted).items())
),
"coordinationLabelCounts": dict(
sorted(
Counter(
record["coordinationVerifierLabel"] for record in accepted
).items()
)
),
"intentPositiveCounts": {
label: sum(bool(record[label]) for record in accepted)
for label in INTENT_LABELS
},
"languageCounts": dict(
sorted(Counter(record["language"] for record in accepted).items())
),
"sourceCounts": dict(
sorted(
Counter(
record.get("sourceDataset") or "unknown"
for record in accepted
).items()
)
),
}
arguments.report.write_text(
json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
print(
f"CONSENSUS_DONE accepted={len(accepted)} conflicts={len(conflicts)} "
f"kappa={report['fleissKappa']}"
)
def parser() -> argparse.ArgumentParser:
root = argparse.ArgumentParser()
subparsers = root.add_subparsers(dest="command", required=True)
prepare_parser = subparsers.add_parser("prepare")
prepare_parser.add_argument("--input", type=Path, default=DEFAULT_INPUT)
prepare_parser.add_argument(
"--queue",
type=Path,
default=DEFAULT_DIRECTORY / "labeling-queue.jsonl",
)
prepare_parser.add_argument(
"--instructions",
type=Path,
default=DEFAULT_DIRECTORY / "labeling-instructions.md",
)
prepare_parser.add_argument(
"--prepare-report",
type=Path,
default=DEFAULT_DIRECTORY / "labeling-queue-report.json",
)
prepare_parser.add_argument("--count", type=int, default=360)
prepare_parser.add_argument("--seed", type=int, default=SEED)
prepare_parser.add_argument(
"--hard-negative-report",
action="append",
type=Path,
default=[],
)
prepare_parser.set_defaults(handler=prepare)
merge_parser = subparsers.add_parser("merge")
merge_parser.add_argument(
"--queue",
type=Path,
default=DEFAULT_DIRECTORY / "labeling-queue.jsonl",
)
merge_parser.add_argument(
"--labeler",
action="append",
type=parse_labeler_argument,
required=True,
)
merge_parser.add_argument(
"--consensus",
type=Path,
default=DEFAULT_DIRECTORY / "consensus-silver.jsonl",
)
merge_parser.add_argument(
"--conflicts",
type=Path,
default=DEFAULT_DIRECTORY / "consensus-conflicts.jsonl",
)
merge_parser.add_argument(
"--train",
type=Path,
default=DEFAULT_DIRECTORY / "silver-train.jsonl",
)
merge_parser.add_argument(
"--calibration",
type=Path,
default=DEFAULT_DIRECTORY / "silver-calibration.jsonl",
)
merge_parser.add_argument(
"--acceptance",
type=Path,
default=DEFAULT_DIRECTORY / "silver-acceptance.jsonl",
)
merge_parser.add_argument(
"--report",
type=Path,
default=DEFAULT_DIRECTORY / "consensus-report.json",
)
merge_parser.set_defaults(handler=merge)
return root
def main() -> None:
arguments = parser().parse_args()
arguments.handler(arguments)
if __name__ == "__main__":
main()