# Translation and text-analysis patterns

Deeper material for the `natural-language` skill ([../SKILL.md](../SKILL.md)).

## Plugging in a Create ML model

A text classifier or word tagger trained in Create ML is loaded as an
`MLModel`, wrapped in `NLModel`, and then either attached to a tagger or
called directly.

```swift
import CoreML
import NaturalLanguage

func makeTicketTagger(modelURL: URL) throws -> (NLTagger, NLModel) {
    let compiled = try MLModel(contentsOf: modelURL)
    let wrapper = try NLModel(mlModel: compiled)
    let routing = NLTagScheme("TicketRouting")
    let tagger = NLTagger(tagSchemes: [routing])
    tagger.setModels([wrapper], forTagScheme: routing)
    return (tagger, wrapper)
}

func triage(_ ticket: String, with wrapper: NLModel) -> (String?, [String: Double]) {
    let top = wrapper.predictedLabel(for: ticket)
    let spread = wrapper.predictedLabelHypotheses(for: ticket, maximumCount: 3)
    return (top, spread)
}
```

## Contextual embeddings

`NLContextualEmbedding` produces vectors that depend on the surrounding
words, so "bank" in "river bank" and "bank loan" get different values. The
initializer is failable, and the model assets may need a download first.

```swift
func tokenVectors(for text: String) async throws -> [[Double]] {
    guard let model = NLContextualEmbedding(language: .english) else { return [] }

    if !model.hasAvailableAssets {
        let outcome = try await model.requestAssets()
        guard outcome == .available else { return [] }
    }

    try model.load()
    defer { model.unload() }

    let result = try model.embeddingResult(for: text, language: .english)
    var vectors: [[Double]] = []
    let whole = text.startIndex..<text.endIndex
    result.enumerateTokenVectors(in: whole) { vector, _ in
        vectors.append(vector)
        return true
    }
    return vectors
}
```

A completion-handler form, `requestAssets { result, error in }`, is also
available. To see which models exist for a language:

```swift
let candidates = NLContextualEmbedding.contextualEmbeddings(
    forValues: [.languages: [NLLanguage.german.rawValue]]
)
for model in candidates {
    print(model.modelIdentifier, model.dimension, model.maximumSequenceLength)
}
```

## Gazetteers for your own vocabulary

A gazetteer maps known terms to labels and overrides or supplements what the
tagger would say. Useful for product names, internal teams or medical terms.

```swift
let catalogue = try NLGazetteer(
    dictionary: [
        "Product": ["Aurora Hub", "Nimbus Cam"],
        "Team": ["Platform Guild", "Night Shift"]
    ],
    language: .english
)
let tagger = NLTagger(tagSchemes: [.nameType])
tagger.setGazetteers([catalogue], for: .nameType)

try NLGazetteer.write(["Product": ["Aurora Hub"]],
                      language: .english,
                      to: cacheFolder.appendingPathComponent("products.gazetteer"))
```

## One tagger, several schemes

Load all the schemes you need up front and query them from the same pass.

```swift
let multi = NLTagger(tagSchemes: [.lexicalClass, .nameType, .lemma])
multi.string = note
multi.enumerateTags(in: note.startIndex..<note.endIndex,
                    unit: .word,
                    scheme: .nameTypeOrLexicalClass,
                    options: [.omitPunctuation, .omitWhitespace, .joinNames]) { kind, span in
    let (root, _) = multi.tag(at: span.lowerBound, unit: .word, scheme: .lemma)
    rows.append((String(note[span]), kind?.rawValue ?? "?", root?.rawValue))
    return true
}
```

## Translating text in place

With a `replacementAction`, the system sheet offers a Replace button that
hands you the translated string.

```swift
struct DraftEditor: View {
    @State private var draft = ""
    @State private var translating = false

    var body: some View {
        TextEditor(text: $draft)
            .toolbar { Button("Translate") { translating = true } }
            .translationPresentation(isPresented: $translating, text: draft) { translated in
                draft = translated
            }
    }
}
```

## Choosing a translation strategy

Requires iOS 26.4 or macOS 26.4. Translation always runs on the device.
`.highFidelity` prefers the Apple Intelligence translation models when the
device has them; `.lowLatency` uses the classic translation models. Neither
sends text to a server.

```swift
if #available(iOS 26.4, macOS 26.4, *) {
    request = TranslationSession.Configuration(
        source: Locale.Language(identifier: "fr"),
        target: Locale.Language(identifier: "en"),
        preferredStrategy: .lowLatency
    )
}
```

## Getting a session ready

Download models before the user is waiting on a result:

```swift
struct PhrasebookScreen: View {
    @State private var warmUp: TranslationSession.Configuration?

    var body: some View {
        PhraseList()
            .onAppear { warmUp = .init(source: .init(identifier: "es"), target: .init(identifier: "en")) }
            .translationTask(warmUp) { @concurrent session in
                do {
                    try await session.prepareTranslation()   // triggers any download now
                } catch {
                    // declined download, cancellation, or an unsupported pair
                }
            }
    }
}
```

`PhraseList` stands for the app's own content view. The closure is
`@concurrent` for the same reason as in the main skill: the session is not
`Sendable` and cannot be used from the main actor.

`TranslationSession(installedSource:target:)` (iOS 26, macOS 26) builds a
session without any view. The initializer does not throw; the session works
only with models already on the device, so translating fails when either
language is missing. Create it only after `LanguageAvailability` reports
`.installed`; a `.translationTask` session, in contrast, can ask the user to
download.

```swift
@available(iOS 26, macOS 26, *)
func quietSession(from source: Locale.Language, to target: Locale.Language) async -> TranslationSession? {
    let state = await LanguageAvailability().status(from: source, to: target)
    guard state == .installed else { return nil }
    return TranslationSession(installedSource: source, target: target)
}
```

## A live analysis panel in SwiftUI

```swift
import NaturalLanguage
import Observation
import SwiftUI

@Observable
@MainActor
final class TextInsights {
    private(set) var wordCount = 0
    private(set) var languageCode = "-"
    private(set) var tone = "neutral"

    func update(for text: String) {
        let words = NLTokenizer(unit: .word)
        words.string = text
        let everything = text.startIndex..<text.endIndex
        wordCount = words.tokens(for: everything).count

        languageCode = NLLanguageRecognizer.dominantLanguage(for: text)?.rawValue ?? "-"

        let scorer = NLTagger(tagSchemes: [.sentimentScore])
        scorer.string = text
        let (moodTag, _) = scorer.tag(at: everything.lowerBound, unit: .paragraph, scheme: .sentimentScore)
        let value = moodTag.flatMap { Double($0.rawValue) } ?? 0
        tone = value > 0.1 ? "positive" : value < -0.1 ? "negative" : "neutral"
    }
}

struct InsightsPanel: View {
    @State private var input = ""
    @State private var insights = TextInsights()

    var body: some View {
        Form {
            TextField("Type something", text: $input, axis: .vertical)
                .onChange(of: input) { _, text in insights.update(for: text) }
            LabeledContent("Words", value: "\(insights.wordCount)")
            LabeledContent("Language", value: insights.languageCode)
            LabeledContent("Tone", value: insights.tone)
        }
    }
}
```

### Tagger assets

Some schemes need assets that are fetched on demand:

```swift
NLTagger.requestAssets(for: .korean, tagScheme: .lexicalClass) { outcome, _ in
    switch outcome {
    case .available: break       // ready to tag
    case .notAvailable: break    // this scheme is not offered for the language
    case .error: break           // retry later
    @unknown default: break
    }
}
```

## Lemmas for search

Reducing words to their dictionary form makes "running" and "ran" match
"run", and "were" match "be". Handy for indexing and search keys.

```swift
func lemmas(in text: String) -> [String] {
    let tagger = NLTagger(tagSchemes: [.lemma])
    tagger.string = text
    var roots: [String] = []
    let fullRange = text.startIndex..<text.endIndex
    tagger.enumerateTags(in: fullRange,
                         unit: .word,
                         scheme: .lemma,
                         options: [.omitPunctuation, .omitWhitespace]) { tag, span in
        roots.append(tag?.rawValue ?? String(text[span]))
        return true
    }
    return roots
}
```
