diff --git a/README.md b/README.md
index de349b1..243c4bc 100644
--- a/README.md
+++ b/README.md
@@ -7,10 +7,10 @@ OSGKeyboard 是面向 iPhone、iPad 与 Mac 的语音输入工具。iOS 键盘



-
+

-[官网](https://hkgood.github.io/OSGKeyboard/) · [English](./README.en.md) · [隐私政策](https://hkgood.github.io/OSGKeyboard/privacy/) · [更新记录](./CHANGELOG.md)
+[官网](https://hkgood.github.io/OSGKeyboard/) · [TestFlight 公测](https://testflight.apple.com/join/c2Bz4qK9) · [English](./README.en.md) · [隐私政策](https://hkgood.github.io/OSGKeyboard/privacy/) · [更新记录](./CHANGELOG.md)
diff --git a/Scripts/check_app_group_wiring.py b/Scripts/check_app_group_wiring.py
new file mode 100755
index 0000000..d5590be
--- /dev/null
+++ b/Scripts/check_app_group_wiring.py
@@ -0,0 +1,264 @@
+#!/usr/bin/env python3
+"""Validate the App Group / Keychain wiring that the app hard-depends on.
+
+Why this is a CI gate and not a unit test: `AppGroup.defaults`,
+`AppGroupStore`, `FlowSessionBridge` and `FlowSessionPolicy` all call
+`fatalError` when the App Group container is missing, by design — a silent
+`.standard` fallback would desync the keyboard extension from the host app and
+produce "I set my API key and nothing happens". The cost of that design is that
+a single typo or a dropped entitlement turns into a keyboard that crashes on
+every presentation for every user. Nothing else catches it: the failure needs a
+signed build on a real device.
+
+Checks (all static, no Xcode required):
+
+ 1. `AppGroup.identifier` in Swift matches `com.apple.security.application-groups`
+ in every entitlements file that ships an App Group, and in `project.yml`.
+ 2. Both the iOS app and the keyboard extension declare the App Group — they
+ are the two processes that must share one suite.
+ 3. `keychain-access-groups` lists `$(AppIdentifierPrefix)com.osgkeyboard.shared`
+ FIRST in every target. `Keychain.swift` deliberately relies on the default
+ access group (the first entry) instead of passing `kSecAttrAccessGroup`,
+ so a reorder silently moves every stored API key to a different store.
+ 4. `project.yml` and the checked-in `.entitlements` files agree. XcodeGen
+ regenerates the latter from the former, so a drifted `.entitlements` means
+ someone hand-edited the generated output.
+
+Run: python3 Scripts/check_app_group_wiring.py
+ python3 Scripts/check_app_group_wiring.py --self-test
+"""
+
+from __future__ import annotations
+
+import plistlib
+import re
+import shutil
+import sys
+import tempfile
+from pathlib import Path
+
+REPO = Path(__file__).resolve().parent.parent
+
+APP_GROUP_SWIFT = REPO / "OSGKeyboardShared/Constants/AppGroup.swift"
+PROJECT_YML = REPO / "project.yml"
+
+# target label -> (entitlements path, must declare an App Group)
+TARGETS = {
+ "OSGKeyboard (iOS app)": ("OSGKeyboard/OSGKeyboard.entitlements", True),
+ "OSGKeyboardExt (keyboard)": ("OSGKeyboardExt/OSGKeyboardExt.entitlements", True),
+ "OSGKeyboardMac (menu bar)": ("OSGKeyboardMac/OSGKeyboardMac.entitlements", False),
+}
+
+APP_GROUP_KEY = "com.apple.security.application-groups"
+KEYCHAIN_KEY = "keychain-access-groups"
+EXPECTED_DEFAULT_KEYCHAIN_GROUP = "$(AppIdentifierPrefix)com.osgkeyboard.shared"
+
+errors: list[str] = []
+
+
+def fail(message: str) -> None:
+ errors.append(message)
+
+
+def swift_app_group_identifier() -> str | None:
+ text = APP_GROUP_SWIFT.read_text(encoding="utf-8")
+ match = re.search(
+ r'public\s+static\s+let\s+identifier\s*=\s*"([^"]+)"', text
+ )
+ if not match:
+ fail(f"{APP_GROUP_SWIFT.relative_to(REPO)}: could not find `AppGroup.identifier`")
+ return None
+ return match.group(1)
+
+
+def load_entitlements(path: Path) -> dict | None:
+ if not path.exists():
+ fail(f"{path.relative_to(REPO)}: entitlements file is missing")
+ return None
+ try:
+ with path.open("rb") as handle:
+ return plistlib.load(handle)
+ except Exception as error: # noqa: BLE001 - surfaced verbatim to the developer
+ fail(f"{path.relative_to(REPO)}: not a readable plist ({error})")
+ return None
+
+
+def project_yml_blocks() -> dict[str, str]:
+ """Split `project.yml` into one text block per `entitlements:` stanza."""
+ text = PROJECT_YML.read_text(encoding="utf-8")
+ blocks: dict[str, str] = {}
+ for match in re.finditer(
+ r"^ entitlements:\n path:\s*(\S+)\n(.*?)(?=^ \w|\Z)",
+ text,
+ re.MULTILINE | re.DOTALL,
+ ):
+ blocks[match.group(1)] = match.group(2)
+ return blocks
+
+
+def yaml_list_after(block: str, key: str) -> list[str] | None:
+ """Reads a simple `key:` / `- value` list out of one entitlements stanza."""
+ pattern = re.compile(rf"^\s*{re.escape(key)}:\s*\n((?:\s*(?:#.*)?\n|\s*-\s*\S.*\n)+)", re.MULTILINE)
+ match = pattern.search(block)
+ if not match:
+ return None
+ values = []
+ for line in match.group(1).splitlines():
+ stripped = line.strip()
+ if not stripped or stripped.startswith("#"):
+ continue
+ if not stripped.startswith("- "):
+ break
+ values.append(stripped[2:].strip())
+ return values
+
+
+def main() -> int:
+ identifier = swift_app_group_identifier()
+ if identifier is None:
+ print("\n".join(f"✗ {error}" for error in errors))
+ return 1
+
+ print(f"AppGroup.identifier = {identifier}")
+
+ yml_blocks = project_yml_blocks()
+
+ for label, (relative_path, requires_app_group) in TARGETS.items():
+ before = len(errors)
+ path = REPO / relative_path
+ plist = load_entitlements(path)
+ if plist is None:
+ continue
+
+ groups = plist.get(APP_GROUP_KEY)
+ if requires_app_group:
+ if not groups:
+ fail(f"{label}: entitlements declare no `{APP_GROUP_KEY}`")
+ elif identifier not in groups:
+ fail(
+ f"{label}: entitlements declare {groups!r}, "
+ f"which does not contain AppGroup.identifier ({identifier!r})"
+ )
+
+ keychain = plist.get(KEYCHAIN_KEY)
+ if not keychain:
+ fail(f"{label}: entitlements declare no `{KEYCHAIN_KEY}`")
+ elif keychain[0] != EXPECTED_DEFAULT_KEYCHAIN_GROUP:
+ fail(
+ f"{label}: `{KEYCHAIN_KEY}` starts with {keychain[0]!r}; "
+ f"Keychain.swift relies on {EXPECTED_DEFAULT_KEYCHAIN_GROUP!r} "
+ "being the FIRST (default) entry"
+ )
+
+ block = yml_blocks.get(relative_path)
+ if block is None:
+ fail(f"{label}: project.yml has no `entitlements: path: {relative_path}` stanza")
+ print(f" ✗ {label}")
+ continue
+
+ yml_groups = yaml_list_after(block, APP_GROUP_KEY)
+ if requires_app_group and (yml_groups or []) != (groups or []):
+ fail(
+ f"{label}: project.yml `{APP_GROUP_KEY}` ({yml_groups!r}) "
+ f"disagrees with the generated entitlements ({groups!r})"
+ )
+
+ yml_keychain = yaml_list_after(block, KEYCHAIN_KEY)
+ if (yml_keychain or []) != (keychain or []):
+ fail(
+ f"{label}: project.yml `{KEYCHAIN_KEY}` ({yml_keychain!r}) "
+ f"disagrees with the generated entitlements ({keychain!r})"
+ )
+
+ if len(errors) == before:
+ print(f" ✓ {label}")
+ else:
+ print(f" ✗ {label}")
+
+ if errors:
+ print()
+ for error in errors:
+ print(f"✗ {error}")
+ print(
+ "\nThe App Group is a hard dependency: the keyboard extension calls "
+ "fatalError() when its container is missing, so a mismatch here ships "
+ "a keyboard that crashes on every presentation."
+ )
+ return 1
+
+ print("\nApp Group and Keychain wiring OK.")
+ return 0
+
+
+def self_test() -> int:
+ """Proves the guard fails on the two regressions it exists to catch.
+
+ A checker that cannot fail is worse than no checker — it reads green
+ forever while the thing it guards rots.
+ """
+ global REPO, APP_GROUP_SWIFT, PROJECT_YML, errors
+
+ original_repo = REPO
+ cases = [
+ (
+ "App Group typo in the keyboard extension",
+ "OSGKeyboardExt/OSGKeyboardExt.entitlements",
+ lambda text: text.replace(
+ "group.com.osgkeyboard.shared", "group.com.osgkeyboard.shard"
+ ),
+ ),
+ (
+ "keychain-access-groups reordered in the iOS app",
+ "OSGKeyboard/OSGKeyboard.entitlements",
+ lambda text: text.replace(
+ "$(AppIdentifierPrefix)com.osgkeyboard.shared\n"
+ "\t\t$(AppIdentifierPrefix)com.osgkeyboard.ios",
+ "$(AppIdentifierPrefix)com.osgkeyboard.ios\n"
+ "\t\t$(AppIdentifierPrefix)com.osgkeyboard.shared",
+ ),
+ ),
+ ]
+
+ failures = 0
+ for label, relative_path, mutate in cases:
+ with tempfile.TemporaryDirectory() as temporary:
+ sandbox = Path(temporary) / "repo"
+ shutil.copytree(
+ original_repo,
+ sandbox,
+ symlinks=True,
+ ignore=shutil.ignore_patterns(
+ ".git", "build", "DerivedData", "ThirdParty", ".claude"
+ ),
+ )
+ target = sandbox / relative_path
+ mutated = mutate(target.read_text(encoding="utf-8"))
+ if mutated == target.read_text(encoding="utf-8"):
+ print(f"✗ self-test could not apply the mutation for: {label}")
+ failures += 1
+ continue
+ target.write_text(mutated, encoding="utf-8")
+
+ REPO = sandbox
+ APP_GROUP_SWIFT = sandbox / "OSGKeyboardShared/Constants/AppGroup.swift"
+ PROJECT_YML = sandbox / "project.yml"
+ errors = []
+ code = main()
+ REPO = original_repo
+ APP_GROUP_SWIFT = original_repo / "OSGKeyboardShared/Constants/AppGroup.swift"
+ PROJECT_YML = original_repo / "project.yml"
+ errors = []
+
+ if code == 0:
+ print(f"✗ self-test: guard did NOT catch: {label}")
+ failures += 1
+ else:
+ print(f"✓ self-test: guard catches: {label}")
+
+ return 1 if failures else 0
+
+
+if __name__ == "__main__":
+ if "--self-test" in sys.argv[1:]:
+ sys.exit(self_test())
+ sys.exit(main())
diff --git a/Scripts/check_l10n_keys.sh b/Scripts/check_l10n_keys.sh
index 2f5e285..4583084 100755
--- a/Scripts/check_l10n_keys.sh
+++ b/Scripts/check_l10n_keys.sh
@@ -57,10 +57,61 @@ compare_pair "$APP_ZH_KEYS" "$APP_EN_KEYS" "App Localizable zh-Hans vs en"
compare_pair "$EXT_EN_KEYS" "$EXT_ZH_KEYS" "Extension Keyboard en vs zh-Hans"
compare_pair "$EXT_ZH_KEYS" "$EXT_EN_KEYS" "Extension Keyboard zh-Hans vs en"
+# --- Host-app bare LocalizedStringKey check -------------------------------
+#
+# SwiftUI resolves a bare `Text("some.key")` through `Bundle.main`, whose
+# localization is fixed at launch from the *system* language. That silently
+# ignores the in-app language override (Settings → App Language), so such a
+# call site renders the system language no matter what the user picked.
+# `.environment(\.locale, …)` does NOT fix this — it only drives formatting.
+#
+# The host app must therefore go through `AppL10n.string` / `AppL10n.format`,
+# which resolve the `.lproj` sub-bundle manually. The keyboard extension and
+# the Mac app already do this via `ExtL10n` / `MacL10n`.
+KEY_RE='"[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z0-9_+-]+)+"'
+BARE_RE="(\\.(navigationTitle|navigationBarTitle|alert|confirmationDialog|help|accessibilityLabel|accessibilityHint)|[^A-Za-z0-9_](Text|Button|Section|Label|Toggle|TextField|Picker|Stepper|Link|NavigationLink))\\([[:space:]]*${KEY_RE}"
+
+bare_hits="$(
+ grep -rnE "$BARE_RE|LocalizedStringKey\\([[:space:]]*${KEY_RE}" \
+ --include='*.swift' "$ROOT/OSGKeyboard" 2>/dev/null \
+ | grep -v '/Tests/' \
+ | grep -vE '^[^:]+:[0-9]+:[[:space:]]*//' \
+ || true
+)"
+
+if [[ -n "$bare_hits" ]]; then
+ echo "❌ Host app uses bare LocalizedStringKey — these ignore the in-app language override:"
+ echo "$bare_hits" | sed "s|^$ROOT/||" | sed 's/^/ /'
+ echo ""
+ echo " Wrap the key: Text(\"foo.bar\") → Text(AppL10n.string(\"foo.bar\"))"
+ fail=1
+fi
+
+# The host app must not reintroduce `LocalizedStringKey` at all: any value of
+# that type is resolved by SwiftUI through `Bundle.main` and so ignores the
+# in-app language override. Keys travel as `String` and are resolved at the
+# render site with `AppL10n.string`.
+lsk_hits="$(
+ grep -rn 'LocalizedStringKey' --include='*.swift' "$ROOT/OSGKeyboard" 2>/dev/null \
+ | grep -v '/Tests/' \
+ | grep -vE '^[^:]+:[0-9]+:[[:space:]]*//' \
+ || true
+)"
+
+if [[ -n "$lsk_hits" ]]; then
+ echo "❌ Host app reintroduced LocalizedStringKey — it resolves via Bundle.main (system language):"
+ echo "$lsk_hits" | sed "s|^$ROOT/||" | sed 's/^/ /'
+ echo ""
+ echo " Store the key as String and resolve at render: Text(AppL10n.string(key))"
+ fail=1
+fi
+
if [[ "$fail" -ne 0 ]]; then
echo ""
- echo "L10n key parity check failed."
+ echo "L10n check failed."
exit 1
fi
echo "✅ L10n key parity check passed (Shared / App / Extension en ↔ zh-Hans)."
+echo "✅ Host app has no bare LocalizedStringKey call sites."
+echo "✅ Host app does not use LocalizedStringKey."
diff --git a/Scripts/clipboard_semantics/build_authored_splits.py b/Scripts/clipboard_semantics/build_authored_splits.py
new file mode 100644
index 0000000..dcd105d
--- /dev/null
+++ b/Scripts/clipboard_semantics/build_authored_splits.py
@@ -0,0 +1,219 @@
+#!/usr/bin/env python3
+"""Split authored batches into train and evaluation corpora by scenario family.
+
+Whole families move together. A family is one scenario (for example
+"invitation to a meal" or "received thanks for a blessing"), so a family-level
+split forces the evaluation set to test scenarios the model never trained on
+instead of paraphrases of them. Text overlap between the two sides is therefore
+impossible by construction, and the script verifies it anyway.
+
+Positive and negative families are assigned separately so both sides keep a
+usable label balance.
+"""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import unicodedata
+from collections import Counter, defaultdict
+from pathlib import Path
+
+
+SEED = 20260903
+AUTHORED_DIRECTORY = Path("ModelTraining/ClipboardSemantics/Authored")
+INTENT_FIELDS = (
+ "task",
+ "question",
+ "invitation",
+ "complaint",
+ "scheduleNegotiation",
+ "confirmationDecision",
+ "followUpReminder",
+ "blessing",
+ "replyable",
+)
+SOURCE_DATASET = "authored-v1"
+SOURCE_LICENSE = "OSGKeyboard project license"
+
+
+def parse_arguments() -> argparse.Namespace:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("batches", nargs="*", type=Path)
+ parser.add_argument("--seed", type=int, default=SEED)
+ parser.add_argument(
+ "--evaluation-family-fraction",
+ type=float,
+ default=0.30,
+ help="Share of families in each polarity group reserved for evaluation.",
+ )
+ parser.add_argument(
+ "--sample-weight",
+ type=float,
+ default=1.0,
+ help="Training sample weight recorded on every authored train record.",
+ )
+ parser.add_argument(
+ "--train-output",
+ type=Path,
+ default=AUTHORED_DIRECTORY / "authored-train-corpus.jsonl",
+ )
+ parser.add_argument(
+ "--evaluation-output",
+ type=Path,
+ default=AUTHORED_DIRECTORY / "authored-eval-corpus.jsonl",
+ )
+ parser.add_argument(
+ "--report",
+ type=Path,
+ default=AUTHORED_DIRECTORY / "authored-split-report.json",
+ )
+ return parser.parse_args()
+
+
+def normalized_text(value: str) -> str:
+ return " ".join(unicodedata.normalize("NFKC", value).split()).strip()
+
+
+def fingerprint(value: str) -> str:
+ return normalized_text(value).casefold()
+
+
+def load_records(path: Path) -> list[dict]:
+ return [
+ json.loads(line)
+ for line in path.read_text(encoding="utf-8").splitlines()
+ if line.strip()
+ ]
+
+
+def polarity_group(family: str) -> str:
+ """Negative families carry a `neg` marker in their leading token."""
+
+ return "negative" if "neg_" in family else "positive"
+
+
+def intent_group(family: str) -> str:
+ return family.split("_", 1)[0].removesuffix("neg")
+
+
+def family_priority(family: str, seed: int) -> str:
+ return hashlib.sha256(f"{seed}|{family}".encode()).hexdigest()
+
+
+def assign_families(families: set[str], seed: int, fraction: float) -> dict[str, str]:
+ """Reserve a deterministic share of each intent/polarity cell for evaluation."""
+
+ cells: dict[tuple[str, str], list[str]] = defaultdict(list)
+ for family in families:
+ cells[(intent_group(family), polarity_group(family))].append(family)
+
+ assignment: dict[str, str] = {}
+ for cell, values in sorted(cells.items()):
+ ordered = sorted(values, key=lambda value: family_priority(value, seed))
+ reserved = max(1, round(len(ordered) * fraction))
+ for index, family in enumerate(ordered):
+ assignment[family] = "evaluation" if index < reserved else "train"
+ return assignment
+
+
+def main() -> None:
+ arguments = parse_arguments()
+ batches = arguments.batches or sorted(
+ path
+ for path in AUTHORED_DIRECTORY.glob("*.jsonl")
+ if not path.name.startswith("authored-")
+ )
+ records: list[dict] = []
+ for path in batches:
+ records.extend(load_records(path))
+
+ assignment = assign_families(
+ {record["family"] for record in records},
+ arguments.seed,
+ arguments.evaluation_family_fraction,
+ )
+
+ train: list[dict] = []
+ evaluation: list[dict] = []
+ for record in records:
+ target = assignment[record["family"]]
+ emitted = {
+ "id": record["id"],
+ "text": normalized_text(record["text"]),
+ "language": record["language"],
+ "family": record["family"],
+ "sentiment": record["sentiment"],
+ **{field: record[field] for field in INTENT_FIELDS},
+ "sourceDataset": SOURCE_DATASET,
+ "sourceLicense": SOURCE_LICENSE,
+ "provenance": "llm-authored",
+ "knownLabels": list(INTENT_FIELDS),
+ }
+ if target == "train":
+ emitted["split"] = "train"
+ emitted["sampleWeight"] = arguments.sample_weight
+ train.append(emitted)
+ else:
+ emitted["split"] = "authoredEvaluation"
+ evaluation.append(emitted)
+
+ train_texts = {fingerprint(record["text"]) for record in train}
+ evaluation_texts = {fingerprint(record["text"]) for record in evaluation}
+ leaked = sorted(train_texts & evaluation_texts)
+
+ for path, values in (
+ (arguments.train_output, train),
+ (arguments.evaluation_output, evaluation),
+ ):
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(
+ "\n".join(
+ json.dumps(value, ensure_ascii=False, sort_keys=True)
+ for value in values
+ )
+ + "\n",
+ encoding="utf-8",
+ )
+
+ def positives(values: list[dict]) -> dict:
+ return {
+ field: sum(1 for value in values if value[field] is True)
+ for field in INTENT_FIELDS
+ }
+
+ report = {
+ "schemaVersion": 1,
+ "seed": arguments.seed,
+ "batches": [str(path) for path in batches],
+ "evaluationFamilyFraction": arguments.evaluation_family_fraction,
+ "sampleWeight": arguments.sample_weight,
+ "families": len(assignment),
+ "familyAssignment": dict(sorted(assignment.items())),
+ "train": {
+ "records": len(train),
+ "families": len({value["family"] for value in train}),
+ "languages": dict(Counter(value["language"] for value in train)),
+ "positives": positives(train),
+ },
+ "evaluation": {
+ "records": len(evaluation),
+ "families": len({value["family"] for value in evaluation}),
+ "languages": dict(Counter(value["language"] for value in evaluation)),
+ "positives": positives(evaluation),
+ },
+ "validation": {
+ "trainEvaluationTextOverlap": len(leaked),
+ "leakedExamples": leaked[:5],
+ },
+ }
+ arguments.report.write_text(
+ json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
+ encoding="utf-8",
+ )
+ print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/Scripts/clipboard_semantics/prepare_product_blind_holdout_v2.py b/Scripts/clipboard_semantics/prepare_product_blind_holdout_v2.py
new file mode 100644
index 0000000..ca86b17
--- /dev/null
+++ b/Scripts/clipboard_semantics/prepare_product_blind_holdout_v2.py
@@ -0,0 +1,387 @@
+#!/usr/bin/env python3
+"""Prepare the v2 product blind holdout with stratified, IPW-correctable sampling.
+
+The v1 blind holdout has 120 records and as few as two positives per intent, so
+it cannot separate a real quality regression from sampling noise. This script
+builds a larger queue from real, clipboard-shaped licensed text.
+
+Every eligible pool record is assigned to exactly one stratum by a rarest-first
+intent priority, so the design is a clean stratified sample: each emitted record
+carries `inclusionProbability` = selected/available for its stratum, and
+prevalence-corrected metrics follow from Horvitz-Thompson weighting. Rare
+intents are oversampled for statistical power without biasing reported rates.
+
+Weak source labels drive stratification only. They are written to the sealed
+provenance file and never to the blind queue.
+"""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import re
+import unicodedata
+from collections import Counter, defaultdict
+from pathlib import Path
+
+
+SEED = 20260902
+MODEL_TRAINING = Path("ModelTraining/ClipboardSemantics")
+OUTPUT_DIRECTORY = MODEL_TRAINING / "ProductBlindHoldoutV2"
+DEFAULT_SOURCES = (
+ MODEL_TRAINING / "comprehensive-online-holdout-corpus.jsonl",
+ MODEL_TRAINING / "online-real-holdout-corpus.jsonl",
+)
+
+# Intents in rarest-first order. A record joins the first stratum it matches,
+# which is what gives the scarce intents their own guaranteed sampling cells.
+INTENT_PRIORITY = (
+ "blessing",
+ "confirmationDecision",
+ "scheduleNegotiation",
+ "invitation",
+ "followUpReminder",
+ "complaint",
+ "task",
+ "question",
+)
+REPLYABLE_STRATUM = "replyableOnly"
+NEGATIVE_STRATUM = "noWeakIntent"
+LANGUAGES = ("en", "zh-Hans")
+
+# Families that are real text but not clipboard-shaped. CPED is television
+# dialogue and GoEmotions is short Reddit reaction text; both are dominated by
+# sub-15-character conversational turns that no user copies to a clipboard.
+EXCLUDED_FAMILY_PREFIXES = ("online_cped_", "online_goemotions_")
+
+PII_PATTERN = re.compile(
+ r"(?:[\w.+-]+@[\w.-]+\.\w+)|(?:\+?\d[\d ()-]{8,}\d)|"
+ r"(?:\b\d{3}-\d{2}-\d{4}\b)",
+ re.IGNORECASE,
+)
+INTENT_FIELDS = (*INTENT_PRIORITY, "replyable")
+
+
+def parse_arguments() -> argparse.Namespace:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--source", action="append", type=Path, default=[])
+ parser.add_argument("--output-directory", type=Path, default=OUTPUT_DIRECTORY)
+ parser.add_argument("--seed", type=int, default=SEED)
+ parser.add_argument(
+ "--minimum-characters",
+ type=int,
+ default=25,
+ help="Clipboard-shape floor. Shorter real text is conversational turns.",
+ )
+ parser.add_argument("--maximum-characters", type=int, default=1_500)
+ parser.add_argument(
+ "--rare-stratum-target",
+ type=int,
+ default=50,
+ help="Reviewed records to draw from each scarce intent stratum.",
+ )
+ parser.add_argument(
+ "--common-stratum-target",
+ type=int,
+ default=120,
+ help="Reviewed records to draw from each abundant stratum.",
+ )
+ parser.add_argument(
+ "--common-strata",
+ default="question,replyableOnly,noWeakIntent",
+ help="Comma-separated strata that use --common-stratum-target.",
+ )
+ parser.add_argument(
+ "--training-corpus",
+ action="append",
+ default=[],
+ type=Path,
+ help="Additional JSONL whose text must not appear in the queue.",
+ )
+ return parser.parse_args()
+
+
+def normalized_text(value: str) -> str:
+ value = unicodedata.normalize("NFKC", value.replace("\u0000", " "))
+ return " ".join(value.split()).strip()
+
+
+def fingerprint(value: str) -> str:
+ return normalized_text(value).casefold()
+
+
+def file_sha256(path: Path) -> str:
+ return hashlib.sha256(path.read_bytes()).hexdigest()
+
+
+def load_records(path: Path) -> list[dict]:
+ return [
+ json.loads(line)
+ for line in path.read_text(encoding="utf-8").splitlines()
+ if line.strip()
+ ]
+
+
+def protected_fingerprints(paths: list[Path]) -> set[str]:
+ values: set[str] = set()
+ for path in paths:
+ for record in load_records(path):
+ values.add(fingerprint(record["text"]))
+ return values
+
+
+def is_real_source(record: dict) -> bool:
+ """Reject template-generated families, which are not real user text."""
+
+ family = record.get("family") or ""
+ return family.startswith("online_") and not family.startswith(
+ EXCLUDED_FAMILY_PREFIXES
+ )
+
+
+def stratum_of(record: dict) -> str:
+ for intent in INTENT_PRIORITY:
+ if record.get(intent) is True:
+ return intent
+ if record.get("replyable") is True:
+ return REPLYABLE_STRATUM
+ return NEGATIVE_STRATUM
+
+
+def stable_priority(record_id: str, seed: int, salt: str) -> bytes:
+ return hashlib.sha256(f"{seed}|{salt}|{record_id}".encode()).digest()
+
+
+def eligible_pool(
+ sources: list[Path],
+ *,
+ minimum_characters: int,
+ maximum_characters: int,
+ protected: set[str],
+) -> tuple[list[dict], Counter]:
+ rejected: Counter = Counter()
+ seen: set[str] = set()
+ pool: list[dict] = []
+ for path in sources:
+ for record in load_records(path):
+ if not is_real_source(record):
+ rejected["notRealClipboardShapedSource"] += 1
+ continue
+ if record.get("language") not in LANGUAGES:
+ rejected["unsupportedLanguage"] += 1
+ continue
+ text = normalized_text(record.get("text") or "")
+ if not minimum_characters <= len(text) <= maximum_characters:
+ rejected["lengthOutsideClipboardShape"] += 1
+ continue
+ key = fingerprint(text)
+ if key in protected:
+ rejected["overlapsProtectedCorpus"] += 1
+ continue
+ if key in seen:
+ rejected["duplicateText"] += 1
+ continue
+ if PII_PATTERN.search(text):
+ rejected["detectedPII"] += 1
+ continue
+ seen.add(key)
+ candidate = dict(record)
+ candidate["_text"] = text
+ candidate["_stratum"] = stratum_of(record)
+ pool.append(candidate)
+ return pool, rejected
+
+
+def select(
+ pool: list[dict],
+ *,
+ seed: int,
+ rare_target: int,
+ common_target: int,
+ common_strata: set[str],
+) -> tuple[list[dict], list[dict]]:
+ """Draw each stratum independently and report unfilled cells."""
+
+ by_cell: dict[tuple[str, str], list[dict]] = defaultdict(list)
+ for record in pool:
+ by_cell[(record["language"], record["_stratum"])].append(record)
+
+ all_strata = (*INTENT_PRIORITY, REPLYABLE_STRATUM, NEGATIVE_STRATUM)
+ selected: list[dict] = []
+ gaps: list[dict] = []
+ for language in LANGUAGES:
+ for stratum in all_strata:
+ target = common_target if stratum in common_strata else rare_target
+ available = by_cell.get((language, stratum), [])
+ drawn = sorted(
+ available,
+ key=lambda value: stable_priority(value["id"], seed, stratum),
+ )[:target]
+ probability = len(drawn) / len(available) if available else 0.0
+ for record in drawn:
+ record["_inclusionProbability"] = probability
+ record["_stratumAvailable"] = len(available)
+ record["_stratumSelected"] = len(drawn)
+ selected.append(record)
+ if len(drawn) < target:
+ gaps.append(
+ {
+ "language": language,
+ "stratum": stratum,
+ "target": target,
+ "available": len(available),
+ "selected": len(drawn),
+ "mustAuthor": target - len(drawn),
+ }
+ )
+ return selected, gaps
+
+
+def write_jsonl(path: Path, records: list[dict]) -> None:
+ path.write_text(
+ "\n".join(
+ json.dumps(record, ensure_ascii=False, sort_keys=True)
+ for record in records
+ )
+ + "\n",
+ encoding="utf-8",
+ )
+
+
+def main() -> None:
+ arguments = parse_arguments()
+ sources = arguments.source or list(DEFAULT_SOURCES)
+ common_strata = {
+ value.strip() for value in arguments.common_strata.split(",") if value.strip()
+ }
+
+ protected = protected_fingerprints(arguments.training_corpus)
+ pool, rejected = eligible_pool(
+ sources,
+ minimum_characters=arguments.minimum_characters,
+ maximum_characters=arguments.maximum_characters,
+ protected=protected,
+ )
+ selected, gaps = select(
+ pool,
+ seed=arguments.seed,
+ rare_target=arguments.rare_stratum_target,
+ common_target=arguments.common_stratum_target,
+ common_strata=common_strata,
+ )
+
+ output_directory = arguments.output_directory
+ output_directory.mkdir(parents=True, exist_ok=True)
+
+ queue: list[dict] = []
+ provenance: list[dict] = []
+ template: list[dict] = []
+ for index, source in enumerate(
+ sorted(
+ selected,
+ key=lambda value: stable_priority(value["id"], arguments.seed, "queue"),
+ ),
+ start=1,
+ ):
+ review_id = f"product-blind-v2-{index:05d}"
+ queue.append(
+ {
+ "id": review_id,
+ "text": source["_text"],
+ "language": source["language"],
+ "annotationStatus": "unreviewed",
+ }
+ )
+ provenance.append(
+ {
+ "id": review_id,
+ "language": source["language"],
+ "sourceRecordID": source["id"],
+ "sourceDataset": source.get("sourceDataset"),
+ "sourceLicense": source.get("sourceLicense"),
+ "sourceURL": source.get("sourceURL"),
+ "family": source.get("family"),
+ "samplingStratum": source["_stratum"],
+ "inclusionProbability": source["_inclusionProbability"],
+ "stratumAvailable": source["_stratumAvailable"],
+ "stratumSelected": source["_stratumSelected"],
+ "weakSourceLabels": {
+ field: source.get(field) for field in INTENT_FIELDS
+ },
+ }
+ )
+ template.append(
+ {
+ "id": review_id,
+ **{field: None for field in INTENT_FIELDS},
+ "sentiment": None,
+ "ambiguous": None,
+ "confidence": None,
+ "evidence": "",
+ "notes": "",
+ }
+ )
+
+ queue_path = output_directory / "review-queue.jsonl"
+ provenance_path = output_directory / "sealed-provenance.jsonl"
+ write_jsonl(queue_path, queue)
+ write_jsonl(provenance_path, provenance)
+ write_jsonl(output_directory / "annotator-a.jsonl", template)
+ write_jsonl(output_directory / "annotator-b.jsonl", template)
+
+ cell_counts: Counter = Counter(
+ f"{value['language']}:{value['samplingStratum']}" for value in provenance
+ )
+ manifest = {
+ "schemaVersion": 1,
+ "seed": arguments.seed,
+ "status": "awaiting-double-human-annotation",
+ "humanReviewComplete": False,
+ "policy": (
+ "Evaluation-only stratified queue drawn from real licensed text. "
+ "Weak source labels are stratification input only and are never "
+ "gold. Never merge these records into training."
+ ),
+ "design": (
+ "Single-stage stratified sample. Each record belongs to exactly one "
+ "(language, stratum) cell; divide by inclusionProbability for "
+ "prevalence-corrected Horvitz-Thompson metrics."
+ ),
+ "sources": [str(path) for path in sources],
+ "clipboardShapeFilter": {
+ "excludedFamilyPrefixes": list(EXCLUDED_FAMILY_PREFIXES),
+ "minimumCharacters": arguments.minimum_characters,
+ "maximumCharacters": arguments.maximum_characters,
+ },
+ "poolRecords": len(pool),
+ "rejected": dict(rejected),
+ "records": len(queue),
+ "languages": dict(Counter(value["language"] for value in queue)),
+ "cells": dict(sorted(cell_counts.items())),
+ "unfilledCells": gaps,
+ "authoringRequired": sum(gap["mustAuthor"] for gap in gaps),
+ "validation": {
+ "duplicateNormalizedTexts": len(queue)
+ - len({fingerprint(value["text"]) for value in queue}),
+ "configuredTrainingOverlap": sum(
+ fingerprint(value["text"]) in protected for value in queue
+ ),
+ "containsDetectedPII": any(
+ PII_PATTERN.search(value["text"]) for value in queue
+ ),
+ },
+ "artifacts": {
+ "reviewQueueSHA256": file_sha256(queue_path),
+ "sealedProvenanceSHA256": file_sha256(provenance_path),
+ },
+ }
+ (output_directory / "manifest.json").write_text(
+ json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
+ encoding="utf-8",
+ )
+ print(json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/Scripts/clipboard_semantics/validate_authored_corpus.py b/Scripts/clipboard_semantics/validate_authored_corpus.py
new file mode 100644
index 0000000..6e80c1a
--- /dev/null
+++ b/Scripts/clipboard_semantics/validate_authored_corpus.py
@@ -0,0 +1,257 @@
+#!/usr/bin/env python3
+"""Validate LLM-authored clipboard corpus batches before they enter any split.
+
+Authored records exist because four socially scarce intents (blessing,
+invitation, confirmationDecision, scheduleNegotiation) have no commercially
+licensed real-text source. Their whole value is that they are not
+template-generated, so this script measures template-ness directly instead of
+trusting the author.
+
+Checks:
+ * schema, label states, and duplicate ids/text
+ * PII shapes and frozen-holdout overlap
+ * distinct-n diversity and the most repeated 4-gram
+ * prefix concentration, which is how slot-filled templates leak a fingerprint
+ * per-family label balance, so a family is never a pure label proxy
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import re
+import sys
+import unicodedata
+from collections import Counter, defaultdict
+from pathlib import Path
+
+
+MODEL_TRAINING = Path("ModelTraining/ClipboardSemantics")
+INTENT_FIELDS = (
+ "task",
+ "question",
+ "invitation",
+ "complaint",
+ "scheduleNegotiation",
+ "confirmationDecision",
+ "followUpReminder",
+ "blessing",
+ "replyable",
+)
+SENTIMENTS = {"positive", "neutral", "negative"}
+REQUIRED_FIELDS = ("id", "text", "language", "family", "sentiment", *INTENT_FIELDS)
+LANGUAGES = {"en", "zh-Hans"}
+PII_PATTERN = re.compile(
+ r"(?:[\w.+-]+@[\w.-]+\.\w+)|(?:\+?\d[\d ()-]{8,}\d)|"
+ r"(?:\b\d{3}-\d{2}-\d{4}\b)",
+ re.IGNORECASE,
+)
+
+# Diversity floors. Template corpora sit far below these because slot filling
+# reuses one skeleton per family.
+MINIMUM_DISTINCT_4GRAM_RATIO = 0.80
+MAXIMUM_TOP_4GRAM_SHARE = 0.06
+MAXIMUM_PREFIX_SHARE = 0.10
+
+
+def parse_arguments() -> argparse.Namespace:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("batches", nargs="+", type=Path)
+ parser.add_argument(
+ "--holdout",
+ action="append",
+ type=Path,
+ default=[],
+ help="Frozen holdout JSONL whose text must not be reused.",
+ )
+ parser.add_argument("--report", type=Path)
+ parser.add_argument(
+ "--compare-corpus",
+ type=Path,
+ default=MODEL_TRAINING / "clipboard_semantic_corpus.jsonl",
+ help="Existing template corpus, scored with the same diversity metrics.",
+ )
+ return parser.parse_args()
+
+
+def normalized_text(value: str) -> str:
+ value = unicodedata.normalize("NFKC", value)
+ return " ".join(value.split()).strip()
+
+
+def fingerprint(value: str) -> str:
+ return normalized_text(value).casefold()
+
+
+def load_records(path: Path) -> list[dict]:
+ return [
+ json.loads(line)
+ for line in path.read_text(encoding="utf-8").splitlines()
+ if line.strip()
+ ]
+
+
+def character_ngrams(value: str, size: int) -> list[str]:
+ text = normalized_text(value)
+ return [text[index : index + size] for index in range(len(text) - size + 1)]
+
+
+def diversity_metrics(texts: list[str]) -> dict:
+ """Distinct-n and concentration statistics over character 4-grams."""
+
+ grams: Counter = Counter()
+ for text in texts:
+ grams.update(character_ngrams(text, 4))
+ total = sum(grams.values())
+ if not total:
+ return {"distinct4GramRatio": 0.0, "topGramShare": 1.0, "topGram": ""}
+ top_gram, top_count = grams.most_common(1)[0]
+ prefixes = Counter(normalized_text(text)[:6] for text in texts)
+ top_prefix, prefix_count = prefixes.most_common(1)[0]
+ return {
+ "distinct4GramRatio": round(len(grams) / total, 4),
+ "topGramShare": round(top_count / total, 4),
+ "topGram": top_gram,
+ "topPrefix": top_prefix,
+ "topPrefixShare": round(prefix_count / len(texts), 4),
+ "totalGrams": total,
+ "distinctGrams": len(grams),
+ }
+
+
+def validate(records: list[dict], protected: set[str]) -> list[str]:
+ errors: list[str] = []
+ seen_ids: set[str] = set()
+ seen_text: set[str] = set()
+ for record in records:
+ identifier = record.get("id", "")
+ for field in REQUIRED_FIELDS:
+ if field not in record:
+ errors.append(f"{identifier}: missing field {field}")
+ if identifier in seen_ids:
+ errors.append(f"{identifier}: duplicate id")
+ seen_ids.add(identifier)
+
+ text = normalized_text(record.get("text") or "")
+ if not text:
+ errors.append(f"{identifier}: empty text")
+ continue
+ key = fingerprint(text)
+ if key in seen_text:
+ errors.append(f"{identifier}: duplicate text")
+ seen_text.add(key)
+ if key in protected:
+ errors.append(f"{identifier}: text overlaps a frozen holdout")
+ if PII_PATTERN.search(text):
+ errors.append(f"{identifier}: detected PII shape")
+ if record.get("language") not in LANGUAGES:
+ errors.append(f"{identifier}: unsupported language {record.get('language')}")
+ if record.get("sentiment") not in SENTIMENTS:
+ errors.append(f"{identifier}: bad sentiment {record.get('sentiment')}")
+ for field in INTENT_FIELDS:
+ if not isinstance(record.get(field), bool):
+ errors.append(f"{identifier}: {field} must be a boolean")
+ return errors
+
+
+def family_balance(records: list[dict]) -> dict:
+ by_family: dict[str, list[dict]] = defaultdict(list)
+ for record in records:
+ by_family[record["family"]].append(record)
+ return {
+ family: {
+ "records": len(values),
+ "positives": {
+ field: sum(1 for value in values if value.get(field) is True)
+ for field in INTENT_FIELDS
+ if any(value.get(field) is True for value in values)
+ },
+ }
+ for family, values in sorted(by_family.items())
+ }
+
+
+def main() -> int:
+ arguments = parse_arguments()
+ records: list[dict] = []
+ for path in arguments.batches:
+ records.extend(load_records(path))
+
+ protected: set[str] = set()
+ for path in arguments.holdout:
+ for record in load_records(path):
+ protected.add(fingerprint(record["text"]))
+
+ errors = validate(records, protected)
+ texts = [record["text"] for record in records if record.get("text")]
+ metrics = diversity_metrics(texts)
+
+ by_language = {
+ language: diversity_metrics(
+ [record["text"] for record in records if record.get("language") == language]
+ )
+ for language in sorted({record.get("language") for record in records} - {None})
+ }
+
+ comparison = {}
+ if arguments.compare_corpus and arguments.compare_corpus.is_file():
+ compare_records = load_records(arguments.compare_corpus)
+ for language in sorted(by_language):
+ subset = [
+ record["text"]
+ for record in compare_records
+ if record.get("language") == language
+ ]
+ if subset:
+ comparison[language] = diversity_metrics(subset)
+
+ gates: dict[str, dict] = {}
+ for language, values in by_language.items():
+ gates[language] = {
+ "distinct4GramRatio": {
+ "actual": values["distinct4GramRatio"],
+ "required": MINIMUM_DISTINCT_4GRAM_RATIO,
+ "passed": values["distinct4GramRatio"] >= MINIMUM_DISTINCT_4GRAM_RATIO,
+ },
+ "topGramShare": {
+ "actual": values["topGramShare"],
+ "maximum": MAXIMUM_TOP_4GRAM_SHARE,
+ "passed": values["topGramShare"] <= MAXIMUM_TOP_4GRAM_SHARE,
+ },
+ "topPrefixShare": {
+ "actual": values["topPrefixShare"],
+ "maximum": MAXIMUM_PREFIX_SHARE,
+ "passed": values["topPrefixShare"] <= MAXIMUM_PREFIX_SHARE,
+ },
+ }
+
+ all_passed = not errors and all(
+ check["passed"] for language in gates.values() for check in language.values()
+ )
+ report = {
+ "schemaVersion": 1,
+ "batches": [str(path) for path in arguments.batches],
+ "records": len(records),
+ "languages": dict(Counter(record.get("language") for record in records)),
+ "families": len({record.get("family") for record in records}),
+ "positivesByIntent": {
+ field: sum(1 for record in records if record.get(field) is True)
+ for field in INTENT_FIELDS
+ },
+ "diversity": metrics,
+ "diversityByLanguage": by_language,
+ "templateCorpusComparison": comparison,
+ "gates": gates,
+ "familyBalance": family_balance(records),
+ "errors": errors,
+ "allChecksPassed": all_passed,
+ }
+ output = json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True)
+ if arguments.report:
+ arguments.report.write_text(output + "\n", encoding="utf-8")
+ print(output)
+ return 0 if all_passed else 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/Scripts/generate_extract_events_shortcut.py b/Scripts/generate_extract_events_shortcut.py
deleted file mode 100644
index 7abafa1..0000000
--- a/Scripts/generate_extract_events_shortcut.py
+++ /dev/null
@@ -1,273 +0,0 @@
-#!/usr/bin/env python3
-"""Build the companion Shortcut for the Extract Events skill.
-
-Writes an unsigned binary plist that `shortcuts sign` can notarize.
-
-Workflow (must materialize Shortcut Input as Text first — Split Text's
-WFInput/ExtensionInput binding finishes the run without iterating):
-
- Text (Shortcut Input)
- → Split Text (new lines)
- → Repeat Each
- → Split Text (|)
- → Get items 1–4 (start, end, title, location)
- → If end == ALLDAY
- → Add New Event (all-day)
- Otherwise
- → Add New Event (timed)
-"""
-
-from __future__ import annotations
-
-import plistlib
-from pathlib import Path
-
-ROOT = Path(__file__).resolve().parents[1]
-OUT_DIR = ROOT / "OSGKeyboard" / "Resources" / "Shortcuts"
-UNSIGNED = OUT_DIR / "OSGExtractEvents.unsigned.shortcut"
-SIGNED = OUT_DIR / "OSGExtractEvents.shortcut"
-SHORTCUT_NAME = "OSGExtractEvents"
-
-OBJECT_REPLACEMENT = "\ufffc"
-
-TEXT_UUID = "A1B2C3D0-1111-4E00-8A5A-FD7E1B0C396F"
-SPLIT_LINES_UUID = "A1B2C3D1-2222-4F11-9A6B-0E8F2C1D4A70"
-REPEAT_GROUP = "A1B2C3D2-3333-4E02-B3C4-91F0E6A7B812"
-REPEAT_START = "A1B2C3D3-4444-4F13-A4D5-02A1F7B8C923"
-SPLIT_FIELDS_UUID = "A1B2C3D4-5555-4014-B5E6-13B2A8C9D034"
-START_UUID = "A1B2C3D5-6666-4125-C6F7-24C3B9DAE145"
-END_UUID = "A1B2C3D6-7777-4236-D7A8-35D4CAEBF256"
-TITLE_UUID = "A1B2C3D7-8888-4347-E8B9-46E5DBF0C367"
-LOCATION_UUID = "A1B2C3D8-9999-4458-F9CA-57F6EC01D478"
-IF_GROUP = "A1B2C3D9-AAAA-4569-0ADB-6807FD12E589"
-IF_START = "A1B2C3DA-BBBB-467A-1BEC-79180E23F69A"
-EVENT_ALLDAY_UUID = "A1B2C3DB-CCCC-478B-2CFD-8A291F3407AB"
-IF_ELSE = "A1B2C3DC-DDDD-489C-3D0E-9B3A204518BC"
-EVENT_TIMED_UUID = "A1B2C3DD-EEEE-49AD-4E1F-AC4B315629CD"
-IF_END = "A1B2C3DE-FFFF-4ABE-5F20-BD5C42673ADE"
-REPEAT_END = "A1B2C3DF-0000-4BCF-6031-CE6D53784BEF"
-
-
-def attachment(value: dict) -> dict:
- return {"Value": value, "WFSerializationType": "WFTextTokenAttachment"}
-
-
-def token_string(attachment_value: dict) -> dict:
- return {
- "Value": {
- "attachmentsByRange": {"{0, 1}": attachment_value},
- "string": OBJECT_REPLACEMENT,
- },
- "WFSerializationType": "WFTextTokenString",
- }
-
-
-def shortcut_input_text() -> dict:
- return token_string({"Type": "ExtensionInput"})
-
-
-def action_output_text(output_name: str, output_uuid: str) -> dict:
- return token_string(
- {
- "Aggrandizements": [],
- "OutputName": output_name,
- "OutputUUID": output_uuid,
- "Type": "ActionOutput",
- }
- )
-
-
-def action_output_attachment(output_name: str, output_uuid: str) -> dict:
- return attachment(
- {
- "Aggrandizements": [],
- "OutputName": output_name,
- "OutputUUID": output_uuid,
- "Type": "ActionOutput",
- }
- )
-
-
-def named_variable_text(variable_name: str) -> dict:
- return token_string(
- {
- "Aggrandizements": [],
- "Type": "Variable",
- "VariableName": variable_name,
- }
- )
-
-
-def if_input(output_name: str, output_uuid: str) -> dict:
- return {
- "Type": "Variable",
- "Variable": {
- "Value": {
- "Aggrandizements": [],
- "OutputName": output_name,
- "OutputUUID": output_uuid,
- "Type": "ActionOutput",
- },
- "WFSerializationType": "WFTextTokenAttachment",
- },
- }
-
-
-def get_item(uuid: str, name: str, index: int) -> dict:
- return {
- "WFWorkflowActionIdentifier": "is.workflow.actions.getitemfromlist",
- "WFWorkflowActionParameters": {
- "UUID": uuid,
- "CustomOutputName": name,
- "WFInput": action_output_attachment("字段", SPLIT_FIELDS_UUID),
- "WFItemSpecifier": "Item At Index",
- "WFItemIndex": index,
- },
- }
-
-
-def add_event(
- uuid: str,
- *,
- all_day: bool,
- include_end: bool,
-) -> dict:
- parameters = {
- "UUID": uuid,
- "WFCalendarItemTitle": action_output_text("标题", TITLE_UUID),
- "WFCalendarItemLocation": action_output_text("地点", LOCATION_UUID),
- "WFCalendarItemDates": True,
- "WFCalendarItemStartDate": action_output_text("开始", START_UUID),
- "WFCalendarItemAllDay": all_day,
- "WFCalendarItemShowComposer": False,
- "ShowComposeSheet": False,
- "WFShowWhenRun": False,
- }
- if include_end:
- parameters["WFCalendarItemEndDate"] = action_output_text("结束", END_UUID)
- return {
- "WFWorkflowActionIdentifier": "is.workflow.actions.addnewevent",
- "WFWorkflowActionParameters": parameters,
- }
-
-
-def workflow() -> dict:
- return {
- "WFWorkflowClientVersion": "3600.0.4",
- "WFWorkflowClientRelease": "26.0",
- "WFWorkflowMinimumClientVersion": 900,
- "WFWorkflowMinimumClientVersionString": "900",
- "WFWorkflowName": SHORTCUT_NAME,
- "WFWorkflowHasShortcutInputVariables": True,
- "WFWorkflowHasOutputFallback": False,
- "WFWorkflowImportQuestions": [],
- "WFWorkflowTypes": [],
- "WFWorkflowInputContentItemClasses": [
- "WFStringContentItem",
- "WFRichTextContentItem",
- ],
- "WFWorkflowOutputContentItemClasses": [],
- "WFWorkflowNoInputBehavior": {
- "Name": "WFWorkflowNoInputBehaviorAskForInput",
- "Parameters": {
- "ItemClass": "WFStringContentItem",
- },
- },
- "WFWorkflowIcon": {
- "WFWorkflowIconGlyphNumber": 59675,
- "WFWorkflowIconStartColor": 4286828031,
- },
- "WFWorkflowActions": [
- {
- "WFWorkflowActionIdentifier": "is.workflow.actions.gettext",
- "WFWorkflowActionParameters": {
- "UUID": TEXT_UUID,
- "CustomOutputName": "快捷指令文本",
- "WFTextActionText": shortcut_input_text(),
- },
- },
- {
- "WFWorkflowActionIdentifier": "is.workflow.actions.text.split",
- "WFWorkflowActionParameters": {
- "UUID": SPLIT_LINES_UUID,
- "CustomOutputName": "行",
- "text": action_output_text("快捷指令文本", TEXT_UUID),
- "WFTextSeparator": "New Lines",
- },
- },
- {
- "WFWorkflowActionIdentifier": "is.workflow.actions.repeat.each",
- "WFWorkflowActionParameters": {
- "UUID": REPEAT_START,
- "GroupingIdentifier": REPEAT_GROUP,
- "WFControlFlowMode": 0,
- "WFInput": action_output_attachment("行", SPLIT_LINES_UUID),
- },
- },
- {
- "WFWorkflowActionIdentifier": "is.workflow.actions.text.split",
- "WFWorkflowActionParameters": {
- "UUID": SPLIT_FIELDS_UUID,
- "CustomOutputName": "字段",
- "text": named_variable_text("Repeat Item"),
- "WFTextSeparator": "Custom",
- "WFTextCustomSeparator": "|",
- },
- },
- get_item(START_UUID, "开始", 1),
- get_item(END_UUID, "结束", 2),
- get_item(TITLE_UUID, "标题", 3),
- get_item(LOCATION_UUID, "地点", 4),
- {
- "WFWorkflowActionIdentifier": "is.workflow.actions.conditional",
- "WFWorkflowActionParameters": {
- "UUID": IF_START,
- "GroupingIdentifier": IF_GROUP,
- "WFControlFlowMode": 0,
- "WFCondition": 4,
- "WFConditionalActionString": "ALLDAY",
- "WFInput": if_input("结束", END_UUID),
- },
- },
- add_event(EVENT_ALLDAY_UUID, all_day=True, include_end=False),
- {
- "WFWorkflowActionIdentifier": "is.workflow.actions.conditional",
- "WFWorkflowActionParameters": {
- "UUID": IF_ELSE,
- "GroupingIdentifier": IF_GROUP,
- "WFControlFlowMode": 1,
- },
- },
- add_event(EVENT_TIMED_UUID, all_day=False, include_end=True),
- {
- "WFWorkflowActionIdentifier": "is.workflow.actions.conditional",
- "WFWorkflowActionParameters": {
- "UUID": IF_END,
- "GroupingIdentifier": IF_GROUP,
- "WFControlFlowMode": 2,
- },
- },
- {
- "WFWorkflowActionIdentifier": "is.workflow.actions.repeat.each",
- "WFWorkflowActionParameters": {
- "UUID": REPEAT_END,
- "GroupingIdentifier": REPEAT_GROUP,
- "WFControlFlowMode": 2,
- },
- },
- ],
- }
-
-
-def main() -> None:
- OUT_DIR.mkdir(parents=True, exist_ok=True)
- UNSIGNED.write_bytes(plistlib.dumps(workflow(), fmt=plistlib.FMT_BINARY))
- print(f"wrote {UNSIGNED}")
- print(
- "sign with: shortcuts sign --mode anyone "
- f"--input {UNSIGNED} --output {SIGNED}"
- )
-
-
-if __name__ == "__main__":
- main()
diff --git a/Scripts/generate_extract_todos_shortcut.py b/Scripts/generate_extract_todos_shortcut.py
deleted file mode 100644
index 089e7d3..0000000
--- a/Scripts/generate_extract_todos_shortcut.py
+++ /dev/null
@@ -1,170 +0,0 @@
-#!/usr/bin/env python3
-"""Build the companion Shortcut for the Extract Tasks skill.
-
-Writes an unsigned binary plist that `shortcuts sign` can notarize.
-
-Workflow (must materialize Shortcut Input as Text first — Split Text's
-WFInput/ExtensionInput binding finishes the run without iterating):
-
- Text (Shortcut Input)
- → Split Text (new lines)
- → Repeat Each
- → Add New Reminder (title = Repeat Item, default list, no composer)
-"""
-
-from __future__ import annotations
-
-import plistlib
-from pathlib import Path
-
-ROOT = Path(__file__).resolve().parents[1]
-OUT_DIR = ROOT / "OSGKeyboard" / "Resources" / "Shortcuts"
-UNSIGNED = OUT_DIR / "OSGExtractTodos.unsigned.shortcut"
-SIGNED = OUT_DIR / "OSGExtractTodos.shortcut"
-SHORTCUT_NAME = "OSGExtractTodos"
-
-OBJECT_REPLACEMENT = "\ufffc"
-
-TEXT_UUID = "6A2D0A80-3B1C-4E00-8A5A-FD7E1B0C396F"
-SPLIT_UUID = "7A3E1B90-4C2D-4F11-9A6B-0E8F2C1D4A70"
-REPEAT_GROUP = "2B9C8D11-55AA-4E02-B3C4-91F0E6A7B812"
-REPEAT_START = "3C0D9E22-66BB-4F13-A4D5-02A1F7B8C923"
-REMINDER_UUID = "4D1E0F33-77CC-4014-B5E6-13B2A8C9D034"
-REPEAT_END = "5E2F1044-88DD-4125-C6F7-24C3B9DAE145"
-
-
-def attachment(value: dict) -> dict:
- return {"Value": value, "WFSerializationType": "WFTextTokenAttachment"}
-
-
-def token_string(attachment_value: dict) -> dict:
- return {
- "Value": {
- "attachmentsByRange": {"{0, 1}": attachment_value},
- "string": OBJECT_REPLACEMENT,
- },
- "WFSerializationType": "WFTextTokenString",
- }
-
-
-def shortcut_input_text() -> dict:
- return token_string({"Type": "ExtensionInput"})
-
-
-def action_output_text(output_name: str, output_uuid: str) -> dict:
- return token_string(
- {
- "Aggrandizements": [],
- "OutputName": output_name,
- "OutputUUID": output_uuid,
- "Type": "ActionOutput",
- }
- )
-
-
-def named_variable_text(variable_name: str) -> dict:
- return token_string(
- {
- "Aggrandizements": [],
- "Type": "Variable",
- "VariableName": variable_name,
- }
- )
-
-
-def workflow() -> dict:
- return {
- "WFWorkflowClientVersion": "3600.0.4",
- "WFWorkflowClientRelease": "26.0",
- "WFWorkflowMinimumClientVersion": 900,
- "WFWorkflowMinimumClientVersionString": "900",
- "WFWorkflowName": SHORTCUT_NAME,
- "WFWorkflowHasShortcutInputVariables": True,
- "WFWorkflowHasOutputFallback": False,
- "WFWorkflowImportQuestions": [],
- # Empty types: a normal library shortcut. QuickActions/ActionExtension
- # made `run-shortcut?name=` open Shortcuts without running it.
- "WFWorkflowTypes": [],
- "WFWorkflowInputContentItemClasses": [
- "WFStringContentItem",
- "WFRichTextContentItem",
- ],
- "WFWorkflowOutputContentItemClasses": [],
- "WFWorkflowNoInputBehavior": {
- "Name": "WFWorkflowNoInputBehaviorAskForInput",
- "Parameters": {
- "ItemClass": "WFStringContentItem",
- },
- },
- "WFWorkflowIcon": {
- "WFWorkflowIconGlyphNumber": 59511,
- "WFWorkflowIconStartColor": 2071128575,
- },
- "WFWorkflowActions": [
- {
- "WFWorkflowActionIdentifier": "is.workflow.actions.gettext",
- "WFWorkflowActionParameters": {
- "UUID": TEXT_UUID,
- "CustomOutputName": "快捷指令文本",
- "WFTextActionText": shortcut_input_text(),
- },
- },
- {
- "WFWorkflowActionIdentifier": "is.workflow.actions.text.split",
- "WFWorkflowActionParameters": {
- "UUID": SPLIT_UUID,
- # Split Text reads `text`, not `WFInput`.
- "text": action_output_text("快捷指令文本", TEXT_UUID),
- "WFTextSeparator": "New Lines",
- },
- },
- {
- "WFWorkflowActionIdentifier": "is.workflow.actions.repeat.each",
- "WFWorkflowActionParameters": {
- "UUID": REPEAT_START,
- "GroupingIdentifier": REPEAT_GROUP,
- "WFControlFlowMode": 0,
- "WFInput": attachment(
- {
- "Aggrandizements": [],
- "OutputName": "Split Text",
- "OutputUUID": SPLIT_UUID,
- "Type": "ActionOutput",
- }
- ),
- },
- },
- {
- "WFWorkflowActionIdentifier": "is.workflow.actions.addnewreminder",
- "WFWorkflowActionParameters": {
- "UUID": REMINDER_UUID,
- "WFCalendarItemTitle": named_variable_text("Repeat Item"),
- "WFCalendarItemShowComposer": False,
- "ShowComposeSheet": False,
- "WFShowWhenRun": False,
- },
- },
- {
- "WFWorkflowActionIdentifier": "is.workflow.actions.repeat.each",
- "WFWorkflowActionParameters": {
- "UUID": REPEAT_END,
- "GroupingIdentifier": REPEAT_GROUP,
- "WFControlFlowMode": 2,
- },
- },
- ],
- }
-
-
-def main() -> None:
- OUT_DIR.mkdir(parents=True, exist_ok=True)
- UNSIGNED.write_bytes(plistlib.dumps(workflow(), fmt=plistlib.FMT_BINARY))
- print(f"wrote {UNSIGNED}")
- print(
- "sign with: shortcuts sign --mode anyone "
- f"--input {UNSIGNED} --output {SIGNED}"
- )
-
-
-if __name__ == "__main__":
- main()
diff --git a/Tests/suite-manifest.json b/Tests/suite-manifest.json
index a82ffdc..57b0a71 100644
--- a/Tests/suite-manifest.json
+++ b/Tests/suite-manifest.json
@@ -14,6 +14,7 @@
"platform": "ios",
"tests": [
"OSGKeyboardTests/AppGroupConfigurationTests",
+ "OSGKeyboardTests/AppDistributionChannelTests",
"OSGKeyboardTests/AppGroupOnboardingStoreTests",
"OSGKeyboardTests/ConfigurationStoreTests",
"OSGKeyboardTests/KeychainTests",
@@ -79,6 +80,7 @@
"OSGKeyboardTests/PolishPromptComposerQuestionTests",
"OSGKeyboardTests/PolishOutputValidatorTests",
"OSGKeyboardTests/PolishStylePackTests",
+ "OSGKeyboardTests/PolishStyleCorpusExportStoreTests",
"OSGKeyboardTests/PolishStyleLearningServiceTests",
"OSGKeyboardTests/LLMClientTests",
"OSGKeyboardTests/TranscriptLanguageDetectorTests",
@@ -142,8 +144,13 @@
"platform": "ios",
"tests": [
"OSGKeyboardTests/FlowHandoffPolicyTests",
+ "OSGKeyboardTests/FlowFailureDiagnosticsTests",
+ "OSGKeyboardTests/FlowLevelStoreTests",
+ "OSGKeyboardTests/FlowPiPLossReportGateTests",
+ "OSGKeyboardTests/FlowSessionWarningReportGateTests",
"OSGKeyboardTests/FlowHomePiPStatusPolicyTests",
"OSGKeyboardTests/FlowPiPRecoveryPolicyTests",
+ "OSGKeyboardTests/FlowAdaptiveDownsamplerTests",
"OSGKeyboardTests/FlowSessionBridgeTests",
"OSGKeyboardTests/FlowSessionPolicyTests",
"OSGKeyboardTests/FlowBudgetAndMergeTests",
@@ -194,6 +201,13 @@
"OSGKeyboardTests/ClipboardHistoryPolicyTests",
"OSGKeyboardTests/ClipboardHistoryStoreTests",
"OSGKeyboardTests/KeyboardExtensionMemoryBudgetTests",
+ "OSGKeyboardTests/KeyboardInstallationProbeTests",
+ "OSGKeyboardTests/AIReplyVariantTests",
+ "OSGKeyboardTests/ClipboardReplyFeedbackStoreTests",
+ "OSGKeyboardTests/ClipboardSemanticShadowMetricsStoreTests",
+ "OSGKeyboardTests/KeyboardExtensionMemoryReliefTests",
+ "OSGKeyboardTests/InternalDiagnosticsUploaderTests",
+ "OSGKeyboardTests/AppL10nFallbackTests",
"OSGKeyboardTests/OfficialSkillCatalogTests",
"OSGKeyboardTests/FrequentTermStoreTests"
]
diff --git a/docs/assets/whats-new/monthly-usage-en.png b/docs/assets/whats-new/monthly-usage-en.png
new file mode 100644
index 0000000..09cf07a
Binary files /dev/null and b/docs/assets/whats-new/monthly-usage-en.png differ
diff --git a/docs/assets/whats-new/monthly-usage-zh.png b/docs/assets/whats-new/monthly-usage-zh.png
new file mode 100644
index 0000000..db02094
Binary files /dev/null and b/docs/assets/whats-new/monthly-usage-zh.png differ
diff --git a/docs/assets/whats-new/smart-reply-en.mp4 b/docs/assets/whats-new/smart-reply-en.mp4
new file mode 100644
index 0000000..6d5a912
Binary files /dev/null and b/docs/assets/whats-new/smart-reply-en.mp4 differ
diff --git a/docs/assets/whats-new/smart-reply-zh.mp4 b/docs/assets/whats-new/smart-reply-zh.mp4
new file mode 100644
index 0000000..ad91071
Binary files /dev/null and b/docs/assets/whats-new/smart-reply-zh.mp4 differ
diff --git a/docs/osgkeyboardversion.html b/docs/osgkeyboardversion.html
index defc91d..2762dbf 100644
--- a/docs/osgkeyboardversion.html
+++ b/docs/osgkeyboardversion.html
@@ -15,8 +15,10 @@
--card: #ffffff;
--border: rgba(0, 0, 0, 0.08);
--media-border: rgba(0, 0, 0, 0.06);
- --accent: #2f6fed;
- --chip-bg: rgba(47, 111, 237, 0.1);
+ /* Brand green — matches OSGColor.brandAccent (#3AA05A) in the app. */
+ --accent: #3aa05a;
+ --chip-bg: rgba(58, 160, 90, 0.1);
+ --card-shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 10px 26px rgba(0, 0, 0, 0.05);
}
html[data-theme="dark"] {
@@ -27,8 +29,10 @@
--card: #161618;
--border: rgba(255, 255, 255, 0.1);
--media-border: rgba(255, 255, 255, 0.08);
- --accent: #6b9fff;
- --chip-bg: rgba(107, 159, 255, 0.16);
+ /* Brightened brand green for legibility on dark surfaces. */
+ --accent: #57c47d;
+ --chip-bg: rgba(87, 196, 125, 0.16);
+ --card-shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 12px 30px rgba(0, 0, 0, 0.35);
}
* { box-sizing: border-box; }
@@ -85,6 +89,8 @@
border-radius: 16px;
padding: 18px 16px 8px;
margin-bottom: 16px;
+ /* Faint directional long shadow — mirrors the app's card depth. */
+ box-shadow: var(--card-shadow);
}
.version-block.visible { display: block; }
@@ -153,9 +159,14 @@
margin: 0 auto;
border-radius: 14px;
border: 0.5px solid var(--media-border);
- background: #000;
}
+ /* Screenshots and demo clips are light captures — keep a light backing in
+ both themes so a not-yet-painted frame never disappears against the dark
+ page background (e.g. before autoplay starts). */
+ .wn-media img { background: #ffffff; }
+ .wn-media video { background: #ffffff; }
+
/* Landscape-ish clip (near-square) can sit a bit wider. */
.wn-media.wide video,
.wn-media.wide img {
@@ -178,80 +189,16 @@
暂无与当前版本匹配的更新说明。
No release notes match this app version.
-
-
- 2.0.3
+
+
+
+
+ 2.1.0
-
- 英文个性词推荐
-
- - 除中文输入习惯外,键盘现在也会学习你反复使用的英文生词,并推荐加入个人词库
- - 中英文推荐词都需要你确认后才会加入,原有的中文学习记录不受影响
-
-
-
-
- 界面一致性优化
-
- - 统一卡片间距、设置页字体和菜单对齐方式,账号与购买记录的排版更整齐
- - 一级页面与详情页之间的标签栏过渡更自然
-
-
-
-
- 问题修复
-
- - 重装 App 或重新回到前台后,Apple 账号登录状态恢复更可靠,登录过程中的加载提示保持一致
- - 在不同 App 之间切换后,中英文候选词可靠恢复,且不会残留上一个输入框的内容
-
-
-
-
-
-
- English Personal-Term Suggestions
-
- - Alongside Chinese typing habits, the keyboard now learns rare English words you use repeatedly and suggests adding them to your Personal Dictionary
- - Suggestions in either language are added only after you confirm, and existing Chinese learning history is preserved
-
-
-
-
- Interface Consistency
-
- - Card spacing, settings typography, menu alignment, and the account and purchase-history rows are now consistent
- - Tab-bar transitions between primary pages and detail pages feel smoother
-
-
-
-
- Fixes
-
- - Apple sign-in is restored more reliably after a reinstall or when returning to the foreground, with a consistent loading state
- - Chinese and English candidates recover reliably after switching apps, without leaking state from the previous text field
-
-
-
-
-
-
-
- 2.0.2
-
-
-
- UI 与交互全面升级
-
- - iPhone 使用 iOS 26 原生 Liquid Glass 标签栏与页面过渡,切换标签时会保留各页面的导航状态
- - 技能页改为更清晰的已安装与未安装列表;点按技能进入完整详情页,集中管理安装、快捷指令配置和自定义技能编辑
- - 重新整理首页、账号、AI 服务和个人词库中的信息层级;缺少必要配置时,会直接说明原因并提供下一步入口
-
-
-
-
新增 Apple 账号登录
+
全新 Apple 账号登录
@@ -275,49 +222,75 @@
- 新增个人专属风格
+ 全新 App 界面与视觉
+
+ - iPhone 使用 iOS 26 原生 Liquid Glass 标签栏与页面过渡,切换标签时保留各页面的导航状态
+ - 技能页改为更清晰的已安装与未安装列表,点按进入详情页,集中管理安装、快捷指令配置和自定义技能编辑
+ - 重新整理首页、账号、AI 服务与个人词库的信息层级;缺少必要配置时会直接说明原因并提供下一步入口
+ - 卡片统一加入淡雅的定向长投影;系统弹窗与模态操作改用自适应黑白配色,不再继承绿色,主次更清晰
+
+
+
+
+ 首页焕新:月度用量日历与积分
+
+
+ - 首页与 Mac 总览用当月日历替换近七天图表,日期圆点的深浅代表当天听写字数,一眼看清整月节奏
+ - 积分与邀请卡片从设置迁到首页统计上方,登录前也能看清免费注册与邀请奖励
+ - 可直接从「听写字数」和「个性词库」卡片进入历史记录或个性词库
+
+
+
+
+ 智能剪贴板技能与智能回复
+
+
+
+
+ - 端侧模型与本地规则会对剪贴板内容进行语义识别,并从完整技能目录中智能推荐最多五个相关操作(回复、翻译、总结、澄清、共情、商务回复、整理清单等)
+ - 对可回复的消息,「回复」会给出普通 / 正式 / 轻松趣味三种个性化候选;键盘分别学习你的听写与回复偏好,让候选越来越像你
+ - 遇到邀约、任务、祝福、澄清、共情等需要明确表态的场景,提供三种固定且清晰标注的立场,绝不替你猜测意图
+ - 复制单个电话号码可直接拨号或新建联系人;复制网页链接可直接打开,或提取公开网页正文进行总结
+ - 识别与推荐在设备端完成;只有运行需要 AI 的技能时内容才会发送给所选服务,选择反馈仅保留在本机
+
+
+
+
+ 个人专属风格
- - 可从自己的听写表达与明确修改中学习说话风格,生成专属于你的措辞和表达习惯
- - 个人风格可应用于剪贴板回复,在不改变原意的前提下调整用词与节奏,让回复更像你自己
- - 用于学习的文字语料仅在你主动点击生成时发送给所选 AI 服务,生成结果需要确认后才会保存和启用
+ - 从你的听写表达和明确修改中学习说话风格,生成专属于你的措辞与表达习惯
+ - 应用到剪贴板回复时,在不改变原意的前提下让用词和节奏更像你自己
+ - 用于学习的语料仅在你点击「生成」时发送给所选 AI 服务,结果需确认后才会保存启用
- 新增智能剪贴板技能
-
- - 端侧模型与本地规则会对剪贴板内容进行基础语义识别,并从完整技能目录中智能推荐最多五个相关操作
- - 可识别任务、邀约、问句、情绪和语言,推荐回复、翻译、总结、澄清、共情、商务回复或整理清单等技能
- - 复制单个电话号码时可直接拨号或新建联系人;复制网页链接时可直接打开,或提取公开网页内容进行总结
- - 识别和推荐过程在设备端完成;只有主动运行需要 AI 的技能时,相关内容才会发送给所选服务
-
-
-
-
- 新增自动推荐个人热词
+ 更聪明的输入
- - 根据键盘中反复输入的新词自动生成个人热词推荐,让常用名称、术语和表达更容易再次输入
- - 推荐词必须由你明确确认后才会加入个人词库;本地学习不会记录完整击键内容
+ - 根据键盘中反复输入的新词自动推荐个人热词,让常用名称、术语与表达更容易再次输入;本地学习不会记录完整击键内容
+ - 在中文输入习惯之外,也会学习你反复使用的英文生词并推荐加入个人词库;中英文推荐词都需你确认后才会加入
+
+
+
+
+ 问题修复与其他
+
+ - Apple 账号登录状态更可靠:令牌轮换更稳,已撤销的登录不会被恢复,切换 App 后中英文候选词可靠恢复
+ - 推荐热词按真实来源标记,移除词库列表中的内部使用次数,iPhone 确认弹窗指向所选热词
+ - 移除自愿打赏,积分包成为唯一的 App 内购买项
-
- UI and Interaction, Rebuilt
-
- - iPhone now uses the native iOS 26 Liquid Glass tab bar and transitions while preserving each tab's navigation state
- - Skills are organized into clearer Installed and Not Installed lists; tap one to manage installation, Shortcut setup, or custom-skill editing on a dedicated detail page
- - Home, Account, AI Service, and Personal Dictionary now have a clearer hierarchy, with direct explanations and next steps when setup is incomplete
-
-
-
New Sign in with Apple
@@ -344,45 +317,77 @@
- New Personal Style
+ A New App Interface and Visual Language
+
+ - iPhone uses the native iOS 26 Liquid Glass tab bar and transitions while preserving each tab's navigation state
+ - Skills are organized into clearer Installed and Not Installed lists; tap one to manage installation, Shortcut setup, or custom-skill editing on a dedicated detail page
+ - Home, Account, AI Service, and Personal Dictionary have a clearer hierarchy, with direct explanations and next steps when setup is incomplete
+ - Card surfaces gain a faint directional long shadow; system dialogs and modal actions use adaptive black or white instead of the green accent
+
+
+
+
+ Home, Refreshed: Monthly Usage Calendar and Credits
+
+
+ - Home and the Mac Dashboard replace the seven-day chart with a full current-month calendar whose date-circle opacity reflects each day's dictation characters
+ - The credits and invitation card moves from Settings to Home above the usage metrics, explaining free signup and referral rewards before you sign in
+ - Open History or Personal Dictionary directly from their metric cards
+
+
+
+
+ Smart Clipboard Skills and Replies
+
+
+
+
+ - On-device models and local rules recognize clipboard content and recommend up to five relevant actions from the full catalog (Reply, Translate, Summarize, Clarify, Empathy, Business Reply, Organize List, and more)
+ - For a message worth answering, Reply offers Ordinary / Formal / Playful personalized choices; the keyboard learns your dictation and reply preferences separately so they sound more like you
+ - For invitations, tasks, blessings, clarifications, and empathy — where intent must not be guessed — it offers three fixed, clearly labeled positions
+ - A copied phone number can open calling or a prefilled new-contact form; a copied web link can open directly or be summarized from bounded public webpage content
+ - Recognition and recommendation stay on device; content is sent to the selected service only when a skill needs AI, and choice feedback stays on this device
+
+
+
+
+ Personal Style
- - Learn a personal speaking style from your dictation and explicit edits, capturing the wording and expression patterns that make your voice distinct
- - Apply that style to clipboard replies so wording and rhythm sound more like you without changing the original meaning
- - Learning text is sent to the selected AI service only when you explicitly tap Generate, and the result must be reviewed before it is saved and enabled
+ - Learn a personal speaking style from your dictation and explicit edits, capturing the wording and expression that make your voice distinct
+ - Apply it to clipboard replies so wording and rhythm sound more like you, without changing the original meaning
+ - Learning text is sent to the selected AI service only when you tap Generate, and the result is saved only after you review it
- New Smart Clipboard Skills
-
- - On-device models and local rules perform lightweight semantic recognition, then recommend up to five relevant actions from the complete skill catalog
- - Tasks, invitations, questions, sentiment, and language can surface skills such as Reply, Translate, Summarize, Clarify, Empathy, Business Reply, or Organize List
- - A single copied phone number can open calling or a prefilled new-contact form; a copied web link can open directly or be summarized from bounded public webpage content
- - Recognition and recommendation stay on device. Content is sent to the selected service only after you run a skill that requires AI
-
-
-
-
- New Automatic Personal-Term Suggestions
+ Smarter Typing
- - Repeated new terms from keyboard input become suggestions, making frequently used names, terminology, and expressions easier to type again
- - A suggestion joins Personal Dictionary only after your explicit confirmation; local learning does not record complete keystrokes
+ - Repeated new terms from keyboard input become suggestions, making frequently used names, terminology, and expressions easier to type again; local learning does not record complete keystrokes
+ - Beyond Chinese typing habits, the keyboard learns rare English words you use repeatedly and suggests them; suggestions in either language are added only after you confirm
+
+
+
+
+ Fixes and Other Changes
+
+ - Apple sign-in is more durable: steadier token rotation, a revoked login is no longer restored, and Chinese/English candidates recover reliably after switching apps
+ - Hotword recommendations are labeled by their real source, internal usage counts are removed from dictionary rows, and the iPhone confirmation popover points at the selected hotword
+ - The voluntary tip purchase is removed; credit packs are now the only in-app purchase
-
-
-
-
-
+
+
diff --git a/docs/privacy/index.html b/docs/privacy/index.html
index 6edbe01..e1d2cae 100644
--- a/docs/privacy/index.html
+++ b/docs/privacy/index.html
@@ -43,7 +43,7 @@
Speaking-style learning — voice history can retain the displayed final text and, when available, the corrected ASR text from before AI polishing plus a deduplicated snapshot of the polish-style prompt used for that take. The pre-polish text is not shown in History. Only after you tap “Generate My Style” are eligible before-and-after pairs and their prior style prompts sent to your configured AI service. Native speech and explicit user edits are used to infer recurring habits; historical prompts are supplied only to distinguish and remove AI-added style. The generated prompt is shown for review and is not saved or activated until you confirm it. When you later invoke a conversational clipboard-reply skill while a reviewed user-created style is active, that style prompt is sent with the copied text so the reply can follow your wording and rhythm; built-in polish personalities are not attached. User-configured requests go directly to that provider; managed-credit requests go through account.osglab.com.
AI mode questions — in AI keyboard mode, your spoken question text—or a question you tap from an idle suggestion—is sent to the selected AI service. User-configured requests go directly to that provider; managed-credit requests go through account.osglab.com. When the selected service supports server-side web search, it may retrieve public web results for time-sensitive answers.
AI suggestions and OSGKeyboard web pages — the main app may request public suggestion titles from key.osglab.com; no voice, transcript, clipboard body, API key, or ordinary keystroke content is included in that request. Opening the update page requests public content from download.osglab.com. As with ordinary HTTPS sites, those hosts and their infrastructure may process connection metadata such as IP address and request time.
- AI Agent skills, Shortcuts, and maps — when you deliberately run a skill, the selected or copied text and skill prompt are sent to your configured LLM provider. For Reminders, Calendar, or Notes exports, the generated result is passed on-device to the Apple Shortcut you install; OSGKeyboard does not receive the resulting item. For navigation, an address or route is placed in a URL opened by Apple Maps, Amap, or Baidu Maps, and the selected map provider's policy applies.
+ AI Agent skills, Shortcuts, and maps — when you deliberately run a skill, the selected or copied text and skill prompt are sent to your configured LLM provider. For Reminders and Calendar exports, the app writes the generated items directly through Apple's EventKit on this device, after iOS asks you for permission; Calendar is requested with write-only access, so the app can add events but never reads your existing ones. Nothing about these items is sent to OSGKeyboard servers. For Notes exports, the generated result is passed on-device to the Apple Shortcut you install; OSGKeyboard does not receive the resulting item. For navigation, an address or route is placed in a URL opened by Apple Maps, Amap, or Baidu Maps, and the selected map provider's policy applies.
API credentials — your user-configured provider keys are stored in the iOS Keychain and shared with the keyboard through a shared Keychain group, never through UserDefaults. When you enable iCloud settings sync, API keys replicate through Apple's iCloud Keychain to your other signed-in devices — not through iCloud Key-Value Store JSON.
Optional OSGKeyboard account and purchases — no account is required for local transcription or user-configured providers. If you choose Sign in with Apple, the account service processes the Apple identity token, a pseudonymous OSGKeyboard account identifier, optional display name, device-integrity evidence, credit balance, referral state, and StoreKit transaction identifiers needed to verify consumable credit purchases. Account session tokens stay in the main app's private Keychain; the keyboard extension receives only a short-lived, scope-limited managed-service grant. You can sign out or delete the account in the app.
App preferences — engine mode, recognition language, polish intensity, translation target, handedness, and keyboard settings are stored in App Group UserDefaults so the main app and keyboard extension stay in sync. Device-local clipboard consent switches are not activated by iCloud settings sync.
@@ -60,7 +60,7 @@
What we do not collect
- We do not log or upload ordinary keystrokes. Only the on-device Chinese candidate-learning data described above is retained locally.
- - We do not use third-party analytics, crash-reporting, advertising SDKs, ATT, or IDFA.
+ - We do not use third-party analytics, crash-reporting, advertising SDKs, ATT, or IDFA. OSGKeyboard records Apple MetricKit crash and hang diagnostics on your device so you can export them from Settings ▸ Diagnostics; App Store builds never upload them. Beta builds distributed through TestFlight additionally upload these diagnostics to
account.osglab.com, and you can turn that off in the same screen. The reports contain crash metadata only — exception type, signal, termination reason, binary names and a truncated call stack — never audio, transcripts, clipboard content, prompts, or API keys.
- We do not upload raw audio on a local engine. Audio leaves the device only after you select a user-configured cloud engine or managed credits.
- We do not sell personal data.
@@ -108,7 +108,7 @@
说话风格学习 — 语音历史可同时保留界面展示的最终文本、可用时保留 AI 润色前且已经过确定性纠错的 ASR 文本,以及当次使用的润色风格 Prompt 去重快照;润色前文本不会在历史界面显示。只有当你主动点击「生成我的风格」后,符合条件的润色前后文本对及对应历史 Prompt 才会发送给你配置的 AI 服务。原生口述与用户明确编辑用于归纳反复出现的习惯;历史 Prompt 只用于识别并排除 AI 附加风格。生成结果会先展示供你检查,确认保存前不会写入或启用。之后,当你启用了经检查的用户自定义风格并主动使用口语回复类剪贴板技能时,该风格 Prompt 会随复制文本一起发送,用于贴近你的用词与节奏;内置润色人格不会附加。用户自备请求直达所选服务商;托管积分请求经 account.osglab.com 处理。
AI 模式问题 — 在 AI 键盘模式下,语音转写后的问题文字或你点选的建议会发送到当前选择的 AI 服务。用户自备请求直达所选服务商;托管积分请求经 account.osglab.com 处理。所选服务支持联网搜索时,可能检索公开网页结果回答时效性问题。
AI 建议与 OSGKeyboard 网页 — 主 App 可能从 key.osglab.com 请求公开建议标题;该请求不包含语音、转写、剪贴板正文、API Key 或普通击键内容。打开更新页时会从 download.osglab.com 请求公开内容。与普通 HTTPS 网站相同,相关主机与基础设施可能处理 IP 地址、请求时间等连接元数据。
- AI Agent 技能、快捷指令与地图 — 仅在你主动运行技能时,所选或复制的文字及技能提示词才会发送到你配置的 LLM 服务商。导出提醒事项、日历或备忘录时,生成结果会在设备端交给你安装的 Apple 快捷指令;OSGKeyboard 不会收到最终创建的项目。导航时,地址或路线会写入 URL 并交给 Apple 地图、高德地图或百度地图,相应地图服务商的政策适用。
+ AI Agent 技能、快捷指令与地图 — 仅在你主动运行技能时,所选或复制的文字及技能提示词才会发送到你配置的 LLM 服务商。导出提醒事项与日历时,App 在本机经 iOS 授权后通过 Apple EventKit 直接写入;日历仅申请「仅写入」权限,只能新增日程、无法读取你已有的日程,相关内容不会发送到 OSGKeyboard 服务器。导出备忘录时,生成结果会在设备端交给你安装的 Apple 快捷指令;OSGKeyboard 不会收到最终创建的项目。导航时,地址或路线会写入 URL 并交给 Apple 地图、高德地图或百度地图,相应地图服务商的政策适用。
API 凭证 — 用户自备服务商的 API Key 保存在设备 Keychain,通过共享 Keychain 组与键盘扩展共享,不会写入 UserDefaults。开启iCloud 设置同步后,API 密钥经 Apple iCloud 钥匙串同步到你其他已登录设备,不会写入 iCloud 键值存储 JSON。
可选 OSGKeyboard 账号与购买 — 本地转写和用户自备服务商无需账号。若你选择 Apple 登录,账号服务会处理 Apple 身份令牌、匿名化的 OSGKeyboard 账号标识、可选昵称、设备完整性证明、积分余额、邀请状态,以及核验消耗型积分包所需的 StoreKit 交易标识。账号会话令牌只保存在主 App 私有 Keychain;键盘扩展仅获得短时、限权的托管服务凭证。你可以在 App 内退出或注销账号。
应用偏好 — 引擎模式、识别语言、润色档位、翻译目标、握持偏好与键盘设置等保存在 App Group UserDefaults,用于主 App 与键盘扩展之间的状态同步。本机剪贴板许可开关不会被 iCloud 设置同步开启。
@@ -125,7 +125,7 @@
我们不收集的内容
- 我们不会记录或上传你平时在键盘上的击键内容。
- - 我们不会集成第三方分析、崩溃上报或广告 SDK,也不使用 ATT 或 IDFA。
+ - 我们不会集成第三方分析、崩溃上报或广告 SDK,也不使用 ATT 或 IDFA。OSGKeyboard 会在你的设备上记录 Apple MetricKit 崩溃与卡顿诊断,供你在「设置 ▸ 诊断」中导出;App Store 版本不会上传。通过 TestFlight 分发的内测版本会额外将这些诊断上传至
account.osglab.com,可在同一页面关闭。报告只包含崩溃元数据——异常类型、信号、终止原因、二进制名称与截断的调用栈——不包含录音、转写文本、剪贴板内容、提示词或 API 密钥。
- 使用本地引擎时,我们不会上传原始录音。只有你选择用户自备云端识别或托管积分后,录音才会离开设备。
- 我们不会出售个人数据。
diff --git a/project.yml b/project.yml
index e7e1dc3..2300d5d 100644
--- a/project.yml
+++ b/project.yml
@@ -48,8 +48,8 @@ settings:
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS: YES
STRING_CATALOG_GENERATE_SYMBOLS: YES
CLANG_CXX_LANGUAGE_STANDARD: c++17
- MARKETING_VERSION: "2.0.3"
- CURRENT_PROJECT_VERSION: "94"
+ MARKETING_VERSION: "2.2.0"
+ CURRENT_PROJECT_VERSION: "96"
# 签名配置来自 Signing.local.xcconfig(gitignored,不会被覆盖)
# 项目级签名 xcconfig,适用于所有 target
@@ -68,10 +68,18 @@ targets:
sources:
- path: OSGKeyboard/AppIcon.icon
buildPhase: resources
+ # Empty Settings bundle: guarantees the app owns a Settings entry so
+ # `openSettingsURLString` lands on the app's own page instead of the
+ # Settings root or the Apps list.
+ - path: OSGKeyboard/Settings.bundle
+ type: folder
+ buildPhase: resources
- path: OSGKeyboard
excludes:
- "AppIcon.icon"
- "OSGKeyboard.icon"
+ # Declared explicitly under `resources` so it ships as a bundle.
+ - "Settings.bundle/**"
# Legacy PNG app icons must not coexist with AppIcon.icon — actool
# crashes when both are passed. iOS 26 uses Icon Composer only.
- "Assets.xcassets/AppIcon.appiconset"
@@ -94,12 +102,6 @@ targets:
buildPhase: resources
# `.shortcut` is not a XcodeGen-known resource type; list each file
# as an explicit resource source or it never lands in the app bundle.
- - path: OSGKeyboard/Resources/Shortcuts/OSGExtractTodos.shortcut
- type: file
- buildPhase: resources
- - path: OSGKeyboard/Resources/Shortcuts/OSGExtractEvents.shortcut
- type: file
- buildPhase: resources
- path: OSGKeyboard/Resources/Shortcuts/OSGSaveToNotes.shortcut
type: file
buildPhase: resources
@@ -184,6 +186,8 @@ targets:
NSMicrophoneUsageDescription: "OSGKeyboard uses the microphone for voice dictation, including active Flow sessions while you type in other apps."
NSSpeechRecognitionUsageDescription: "OSGKeyboard uses speech recognition to transcribe your voice. Audio is processed on-device by default, or sent to your configured speech provider only when you enable cloud recognition."
NSContactsUsageDescription: "OSGKeyboard uses Contacts only when you choose Create Contact for a copied phone number."
+ NSRemindersFullAccessUsageDescription: "OSGKeyboard adds reminders only when you choose Extract To-dos for copied text."
+ NSCalendarsFullAccessUsageDescription: "OSGKeyboard adds calendar events only when you choose Extract Events for copied text."
UIBackgroundModes:
- audio
BGTaskSchedulerPermittedIdentifiers:
@@ -303,6 +307,12 @@ targets:
buildPhase: resources
- path: OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings
buildPhase: resources
+ # The extension shows its own microphone / speech prompts, so it needs
+ # its own InfoPlist.strings — the host app's copy does not cover it.
+ - path: OSGKeyboardExt/en.lproj/InfoPlist.strings
+ buildPhase: resources
+ - path: OSGKeyboardExt/zh-Hans.lproj/InfoPlist.strings
+ buildPhase: resources
resources:
- path: OSGKeyboardExt/Assets.xcassets
- path: OSGKeyboardExt/PrivacyInfo.xcprivacy