Metadata-Version: 2.4
Name: ovos-common-reading-pipeline-plugin
Version: 0.3.2
Summary: OVOS pipeline plugin that orchestrates 'read me something' across content provider skills (fairy tales, articles, news, documents and more), similar in spirit to OCP for media skills
Home-page: https://github.com/andlo/ovos-common-reading-pipeline-plugin
Author: Andreas Lorensen
Author-email: andlo@outlook.dk
License: GPL-3.0-or-later
Project-URL: Source, https://github.com/andlo/ovos-common-reading-pipeline-plugin
Project-URL: Bug Tracker, https://github.com/andlo/ovos-common-reading-pipeline-plugin/issues
Keywords: ovos pipeline plugin voice assistant reading orchestrator
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: End Users/Desktop
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
Classifier: Topic :: Home Automation
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: padacioso
Requires-Dist: ovos-workshop
Requires-Dist: ovos-bus-client
Requires-Dist: ovos-plugin-manager
Dynamic: author
Dynamic: author-email
Dynamic: classifier
Dynamic: description
Dynamic: description-content-type
Dynamic: home-page
Dynamic: keywords
Dynamic: license
Dynamic: license-file
Dynamic: project-url
Dynamic: requires-dist
Dynamic: requires-python
Dynamic: summary

# <img src='book-512.png' card_color='#40DBB0' width='50' height='50' style='vertical-align:bottom'/> Common Reading

An OVOS pipeline plugin that reads things aloud - fairy tales, articles,
news, documents, reports, and whatever else a *provider* skill wants to
offer - by orchestrating "read me something" across those provider
skills, the same way [OCP](https://openvoiceos.github.io/ovos-technical-manual/ocp/)
(ovos-common-play) orchestrates "play X" across media skills.

_"If you want your children to be intelligent, read them fairy tales. If
you want them to be more intelligent, read them more fairy tales."_
— Albert Einstein

[![Tests](https://github.com/andlo/ovos-common-reading-pipeline-plugin/actions/workflows/test.yml/badge.svg)](https://github.com/andlo/ovos-common-reading-pipeline-plugin/actions/workflows/test.yml)
[![PyPI version](https://img.shields.io/pypi/v/ovos-common-reading-pipeline-plugin.svg)](https://pypi.org/project/ovos-common-reading-pipeline-plugin/)

> **What this is:** a pipeline plugin for **narrating text-based content
> via TTS** - providers deliver plain text, and this plugin reads it
> aloud, sentence by sentence, with bookmarking and "continue" support.
>
> **What this is *not*:** an audiobook or audio-file player. If you're
> looking for pre-recorded audiobooks, radio dramas, or narrated
> readings as *audio files*, that's already well covered by existing
> **OCP** media skills (e.g. `ovos-skill-librivox`, or JarbasSkills'
> `skill-golden-audiobooks` / `skill-hppodcraft` /
> `skill-epic-horror-theatre`). This plugin solves a different problem:
> letting several *text* content providers coexist and be searched
> together, without any of them registering competing voice intents.

## Install
```bash
pip install ovos-common-reading-pipeline-plugin
```

Add it to your pipeline in `mycroft.conf` in two places: the `-high`
stage **right after your stop matcher**, and the `-low` stage after
padatious/adapt, before common query and the fallbacks (see
[How a request is recognised](#how-a-request-is-recognised)). The
`-high` stage goes (`stop_high`/`ovos-stop-pipeline-plugin-high`) - roughly
where OCP sits, not before stop. "Stop" should always be the most
reliable, highest-priority command regardless of what skill is active;
putting anything ahead of it undermines that guarantee for every skill,
not just this one. An earlier version of this README recommended
putting this plugin first - that was wrong, and has been reverted; see
the note at the end of this section for why.

**Don't copy-paste a whole pipeline block from here or anywhere else** -
your existing list already has entries specific to your install (the
exact names/order vary: `stop_high` vs `ovos-stop-pipeline-plugin-high`,
whether persona/OCP/common-query plugins are present, etc). Find your
existing `"pipeline"` array and insert one line, right after your stop
matcher:

```diff
   "pipeline": [
     "stop_high",
+    "ovos-common-reading-pipeline-plugin-high",
     "converse",
     "ocp_high",
     ...
     "adapt_medium",
+    "ovos-common-reading-pipeline-plugin-low",
     "common_qa",
     ...
   ]
```

(a real, complete example from a live install - yours will look similar
but isn't guaranteed to match exactly, which is exactly why editing your
existing list beats copying a block):

```json
"pipeline": [
  "ovos-stop-pipeline-plugin-high",
  "ovos-common-reading-pipeline-plugin-high",
  "ovos-converse-pipeline-plugin",
  "ovos-ocp-pipeline-plugin-high",
  "ovos-persona-pipeline-plugin-high",
  "ovos-padatious-pipeline-plugin-high",
  "ovos-fallback-pipeline-plugin-high",
  "ovos-adapt-pipeline-plugin-high",
  "ovos-stop-pipeline-plugin-medium",
  "ovos-adapt-pipeline-plugin-medium",
  "ovos-common-reading-pipeline-plugin-low",
  "ovos-common-query-pipeline-plugin",
  "ovos-fallback-pipeline-plugin-medium",
  "ovos-persona-pipeline-plugin-low",
  "ovos-fallback-pipeline-plugin-low"
]
```

This plugin registers its own dedicated `pause`/`continue` intents and
correctly declines (`None`) for anything it doesn't recognize or isn't
currently relevant to, so this position - right after stop, ahead of
everything else - is enough for `pause`/`continue` to reliably reach
it without needing to jump the stop queue.

The plain id `ovos-common-reading-pipeline-plugin` (what this README
recommended before 0.3.0) still works: it runs both stages at that one
spot. Adding the `-low` stage further down is what lets a skill whose own
intent matches a bare title get it first. ovos-core hands the plugin whatever is
under `intents["ovos-common-reading-pipeline-plugin"]` in `mycroft.conf`,
and reads two keys from it itself (`match_timeout`, default 10 s, and
`match_workers`, default 4). The plugin reads the keys below from the
same place, or from its settings file
(`~/.config/mycroft/skills/ovos-common-reading-pipeline-plugin.andlo/settings.json`)
when `mycroft.conf` doesn't set them:

```json
"intents": {
  "ovos-common-reading-pipeline-plugin": {
    "narration": "ssml",
    "chars_per_second": 14,
    "read_ahead": 3,
    "fastest_chars_per_second": 30,
    "wait_margin": 3
  }
}
```

| key | default | what it does |
|---|---|---|
| `narration` | off | `"ssml"` sends each sentence with an SSML version beside it (see [Narration](#narration)) |
| `chars_per_second` | `14` | speaking rate the wait after each line is sized with (see below) |
| `read_ahead` | `3` | sentences a client may hold that it has not finished saying, so the next starts the moment one ends; `1` sends one at a time (see [How a request is handled](#how-a-request-is-handled)) |
| `fastest_chars_per_second` | `30` | no sentence counts as said sooner than a voice this fast could say it, so a client that ends sentences without saying them gets the story at reading pace (see [How a request is handled](#how-a-request-is-handled)) |
| `wait_margin` | `3` | seconds added to that wait, for synthesis |

### How a request is recognised

Like OCP: the sentence has to open with a reading verb ("tell me",
"read me", "can you read", "fortæl mig", "lies mir", "raconte-moi"...),
and what follows has to name something a provider said it can read.
The pipeline itself only knows the words that take a sentence apart
(`locale/<lang>/reading.json`: verbs, "about"/"from" connectors,
articles, and the story words); **what can be read comes from the
providers** (the [vocabulary](#4-vocabulary) message):

- **`-high` stage** (right after stop): the sentence names a kind of
  text ("a story", "my horoscope", "an article about...") or a
  collection ("a story from grimm", "a grimm story"). "Tell me the
  weather", "tell me the time", "read my messages" name neither, so
  they go to the skills they belong to
  ([#50](https://github.com/andlo/ovos-common-reading-pipeline-plugin/issues/50)).
- **`-low` stage** (after padatious/adapt): a bare title ("tell me the
  little mermaid", "læs den lille havfrue"), only when a provider
  announced that title.

The sentence is then split into what goes on to the providers: the
kind of text (as the canonical name the provider announced, e.g.
`"horoscope"` for "horoskop"), the collection, and whatever is left as
the title. "Tell me my leo horoscope" is `content_type: "horoscope"`,
title `"leo"`
([#33](https://github.com/andlo/ovos-common-reading-pipeline-plugin/issues/33)).
A provider with a new kind of text ("recipe", "poem") is reachable as
soon as it announces the word, with no pipeline release
([#36](https://github.com/andlo/ovos-common-reading-pipeline-plugin/issues/36)).
Only "continue" and "pause" are still padacioso intents.

### How a request is handled

- `match_high()`/`match_low()` only recognize the utterance (a few
  milliseconds) and claim it or not. It never searches, speaks or
  waits: ovos-core 3.7 gives each plugin's `match()` 10 s on a small
  worker pool, and an utterance whose `match()` runs longer falls
  through to the later pipeline stages.
- ovos-core then dispatches `<skill_id>:<intent>`
  (`ovos-common-reading-pipeline-plugin.andlo:read_content`,
  `:read_by_collection`, `:read_by_type`, `:read_any_story`,
  `:continue`, `:pause`) to the handler the plugin registered for it.
  The handler searches the providers, asks "is it that one?" when the
  best match is unsure, announces the story, starts reading it and
  returns. That return is what ends the turn
  (`mycroft.skill.handler.complete`, then `ovos.utterance.handled`),
  about two seconds after the request (the search window), so "stop",
  "pause" and anything else reach the assistant while the story plays.
- The story is read on a background thread, a sentence at a time and
  `read_ahead` (3) sentences ahead. Each sentence is forwarded from the
  request that started the story, so it carries that request's session
  and route. A client holds at most `read_ahead` sentences it has not
  finished saying, so it can start the next the moment one ends instead
  of waiting out a round trip; each `recognizer_loop:audio_output_end`
  on that session finishes the oldest. A client that doesn't report is
  waited on for as long as each sentence takes to say: its length at
  `chars_per_second` plus `wait_margin`, rounded up to whole seconds and
  never more than 15 s (see
  [#41](https://github.com/andlo/ovos-common-reading-pipeline-plugin/issues/41)).
  No sentence is over sooner than a voice at `fastest_chars_per_second`
  could say it, whatever the client reports: a phone on vibrate that
  ended every sentence unsaid within milliseconds used to receive the
  whole story in seconds. The bookmark counts what the client finished,
  so "continue" repeats a sentence that was sent ahead and never said.
  The announcement and the other lines the plugin waits on are sized
  the same way.
- On "pause" and "stop" the session is sent `mycroft.audio.speech.stop`
  before the confirmation, so a client drops the sentences it holds
  instead of reading them after "Paused".

**If you write a client** that plays speech itself (a HiveMind
satellite, a phone app), send `recognizer_loop:audio_output_start` when
a line starts playing and `recognizer_loop:audio_output_end` when it
ends, with the session of the `speak` it came from in the context.
That is what lets the story go on the moment a sentence ends; without
it, the plugin can only guess how long each sentence takes. Say the
sentences you are sent in order, one at a time, and drop the ones still
queued on `mycroft.audio.speech.stop`.

### Narration

With `"narration": "ssml"`, every sentence of a story goes out with an
SSML version of itself in `data["utterance_ssml"]`, beside the plain
`data["utterance"]`, which is exactly what it would be without it. The
SSML is never put in `utterance`: a client that doesn't render SSML
would show or say the tags, while one that doesn't know
`utterance_ssml` simply ignores it. Each sentence is still its own
`speak` and its own `<speak>` document, so bookmarks and pacing are
unchanged. It is off by default.

The narrator only acts on what the text marks, and leaves the rest to
the voice:

- a 750 ms pause before the first sentence read (after the
  announcement or "continue") and 500 ms before the first sentence of
  every other paragraph (a provider that sends the whole story as one
  paragraph gets none);
- a dash right after the end of a sentence, the way Cosquin's text
  marks the next speaker (`maltraité?—Si tu te plains`), becomes a
  300 ms pause;
- any other dash between words (`I think—I know I think—it might be
  little Kay`) becomes a 200 ms pause; a dash between two numbers, or
  at either end of the sentence, is left alone;
- `…` is written `...`, which Phoonnx voices pause on and the single
  character they read straight through, and an ellipsis in the middle
  of a sentence gets a 250 ms pause.

```xml
<speak><break time="500ms"/>The eldest was proud <break time="200ms"/> far too proud.</speak>
```

Quoted dialogue, `!` and `?` are left alone.

### The news is left to news skills

"Read the news", "read me the latest news about France", "read me
today's news" and their equivalents in the other languages are not
claimed. This plugin sits ahead of every news skill in the pipeline, so
whatever it claims never reaches one, and no provider in this family
serves the news. No provider announces the news as a kind of text, and
a request whose title or kind of text holds a word from
`locale/<lang>/news.voc` is declined anyway. French keeps "une nouvelle", which in a
reading request is as often a short story as a news item; French asks
for the news in the plural ("les nouvelles"), which nothing here takes.

### Several users at once (HiveMind hubs)

On a HiveMind hub every connected client talks to the same ovos-core,
and so to the same instance of this plugin. Everything a story needs is
kept per session (the `session_id` in the message context): whether it
is being read, what "continue" resumes and where, and the stop flag.
One user's "stop", "pause" or "continue" only ever touches their own
story. A stop that names no session at all (the plugin shutting down)
stops every story.

Bookmarks live in the plugin's settings file, per session:

```json
{
  "sessions": {
    "<session_id>": {
      "last_content": {"skill_id": "...", "content_id": "...", "title": "..."},
      "last_lang": "fr-FR",
      "progress": {"<skill_id>::<content_id>": 12},
      "progress_splitter": {"<skill_id>::<content_id>": 2},
      "touched": 1790000000.0
    }
  }
}
```

The 50 most recently used sessions are kept, plus `default` and any
session reading right now. Settings from earlier versions (a single
`last_content`/`progress` at the top level) are moved to the `default`
session on start, so a paused story on a single device still continues.

### A separate, unresolved issue: "stop" itself may not reach this plugin at all

This is **not** a pipeline-ordering problem, and moving this plugin
around won't fix it. `stop_high` doesn't broadcast to every active
skill - it internally picks **one specific skill** to call `.stop()`
on. On at least one real install (confirmed live via
[ovos-tui-client](https://github.com/andlo/ovos-tui-client)'s activity
log), it picked a separate system skill (`ovos-skill-stop`, skill_id
`stop.openvoiceos` - which by its own description "doesn't do anything
directly" and exists for hardware/enclosure signaling) instead of
whichever skill was actually mid-session, so saying "stop" while a
story was playing did nothing at all - the reading continued right
through it. If you hit this, check whether `ovos-skill-stop` (or
similar) is installed and whether it needs to be blacklisted; this
looks like it may be a core `stop_high` skill-selection behavior worth
raising upstream rather than something fixable from this plugin's side.

You'll also want at least one *provider* skill installed, otherwise
there's nothing to read:

- [ovos-skill-andersen-tales](https://github.com/andlo/ovos-skill-andersen-tales) - Hans Christian Andersen fairy tales
- [ovos-skill-grimm-tales](https://github.com/andlo/ovos-skill-grimm-tales) - Brothers Grimm fairy tales
- [ovos-skill-andrew-lang-tales](https://github.com/andlo/ovos-skill-andrew-lang-tales) - Andrew Lang's Fairy Books (Project Gutenberg, English only, no translation)
- [ovos-skill-bechstein-tales](https://github.com/andlo/ovos-skill-bechstein-tales) - Ludwig Bechstein's German fairy tales (Project Gutenberg, German only, no translation)
- [ovos-skill-cosquin-tales](https://github.com/andlo/ovos-skill-cosquin-tales) - Emmanuel Cosquin's Lorraine folk tales (Project Gutenberg, French only, no translation)
- [ovos-skill-ovosblog](https://github.com/andlo/ovos-skill-ovosblog) - the OpenVoiceOS blog, with machine-translation support
- [ovos-skill-arxiv-papers](https://github.com/andlo/ovos-skill-arxiv-papers) - arXiv paper abstracts (`content_type: "paper"`)
- [ovos-skill-365tomorrows-stories](https://github.com/andlo/ovos-skill-365tomorrows-stories) - daily CC-licensed flash sci-fi, with machine-translation support
- [ovos-skill-horoscope-readings](https://github.com/andlo/ovos-skill-horoscope-readings) - daily zodiac horoscopes (`content_type: "horoscope"`), with machine-translation support

## Building your own provider

See [ovos-skill-common-reading-example](https://github.com/andlo/ovos-skill-common-reading-example) -
a template walking through two working patterns (RSS feeds and
static-page scraping), the bus protocol (announce your
[vocabulary](#4-vocabulary), or nothing reaches you), caching, and the judgment calls
every provider has to make for itself (translate or not, what a human
calls the source, what's worth reading aloud).

## The `ovos.common_reading.*` bus protocol

Provider skills implement this to be usable by this plugin. It's a plain
messagebus convention (like `ovos.common_play.*`) - no shared package
dependency needed.

### 1. Search

Broadcast on a matched utterance:

```
ovos.common_reading.search
{
  "phrase": "<what the user asked for, or null for 'surprise me'>",
  "collection_hint": "<raw text like 'grimm' or 'h c andersen', or null>",
  "content_type": "<the canonical kind of text asked for, as announced ('story', 'horoscope'), or null>",
  "lang": "<the language the request was made in, e.g. 'fr-FR'>",
  "requester": "<this plugin's id>"
}
```

The search (like the fetch and the ping below) is forwarded from the
user's request, so its context carries the requesting session and route.
**Answer with `message.reply(...)`**: that keeps the session on the
answer, and the plugin only takes answers for the session that asked
(two users can search at the same moment on a hub).

`lang` is the language ovos-core matched the request in. Answer in that
language, or not at all; don't take the language from the session
instead (for the `default` session, `SessionManager.get()` returns the
device's own language whatever the message carried).

`collection_hint` is set when the user names a specific source/collection
("read me a story **from Grimm**", "find Cinderella **by Andersen**"). It's
raw, unvalidated text - each provider fuzzy-matches it against its own
known friendly names and should only respond if it's a match, or if
`collection_hint` is null (in which case every provider competes as usual).

`content_type` is a similarly raw, optional hint. A provider that only
offers one kind of content can use it to stay silent when it clearly
doesn't apply, but should treat a null/missing `content_type` as "anyone
can compete".

`phrase` can also be null on its own - "read me a story from Grimm" with
no specific title named is a valid request for the hinted provider to
offer something of its own choosing.

"Tell me a story" ("raconte-moi une histoire", "erzähl mir ein
Märchen", see `locale/<lang>/ReadAnyStory.intent`) sends `phrase` and
`collection_hint` null and `content_type` `"story"`, whatever language it
was said in. A story provider answers it with **one** story of its own
choosing at confidence **0.9**: enough to be read without the "is it
that one?" question, below the 1.0 of a title somebody named. When
several providers answer, one of the equally confident answers is picked
at random.

Every provider that thinks it can help replies (within ~2s):

```
ovos.common_reading.search.response
{
  "skill_id": "<provider skill id>",
  "content_id": "<opaque id the provider will recognize later>",
  "title": "<human-readable title>",
  "author": "<author, optional>",
  "collection": "<book/collection name, optional>",
  "source": "<where the text comes from, e.g. 'grimmstories.com'>",
  "confidence": 0.0-1.0,
  "machine_translated": true/false (optional, defaults falsy)
}
```

The highest-confidence response wins (and, if it's below 0.8, this
plugin confirms with the user before continuing). If a provider sets
`"machine_translated": true`, that's disclosed as part of the
announcement right before reading starts.

`title`, `author` and `collection` are spoken as they are, in an
announcement like "La Biche blanche, par Emmanuel Cosquin, tiré du
recueil Contes populaires de Lorraine". `source` is said once, after
the last sentence: "Et voilà, c'est la fin. C'était tiré de Project
Gutenberg." A story that is stopped or paused doesn't get that line; it
comes when the story is finished, after a "continue" if need be. The
words around them come from this plugin's locale (`by_author.dialog`,
`from_collection.dialog`, `machine_translated.dialog`,
`finished_reading.dialog`), so give plain names ("Emmanuel Cosquin",
not "collected by Emmanuel Cosquin"). `content_id` is only ever sent
back to you, in the fetch, and keys the bookmark; it may differ from the
title.

### 2. Fetch

Once something is chosen (or resumed via "continue"), a *targeted*
request goes to just that provider:

```
ovos.common_reading.fetch_content.<provider_skill_id>
{"content_id": "<from the search response>", "lang": "<the language it was found in>",
 "requester": "<this plugin's id>"}
```

`lang` is the language of the search the story came from, also when
"continue" was said in another language. The provider replies once,
with `message.reply(...)` (only the answer for the requesting session is
taken), with the full text, split into paragraphs:

```
ovos.common_reading.fetch_content.response
{"paragraphs": ["First paragraph...", "Second paragraph...", ...]}
```

Reading pacing, sentence splitting, and bookmark tracking all happen
here - providers just deliver text.

### Stop, pause, and continue

Reading checks a break condition between every sentence (not just
between paragraphs), so an interruption takes effect within a sentence
or so, not at the end of a whole paragraph. Two ways to interrupt:

- **"stop"** - handled via `stop_session()`, the standard OVOS mechanism
  triggered by the platform's own stop command/button, for the session
  that said it. Not specific to this plugin's own vocabulary.
- **"pause"** - a dedicated intent this plugin matches itself (see
  `locale/<lang>/pause.intent`), rather than relying on "pause" being
  recognized as a synonym for "stop" at the OVOS core level, which
  isn't guaranteed. Functionally identical to stop (same bookmark
  preservation), just with a dialog that explicitly invites resuming
  ("say continue when you're ready") instead of sounding final.

Either way, progress is bookmarked automatically - saying **"continue"**
later picks up from the same sentence, even after a full restart,
since the bookmark and the last-read content are both stored in
persistent skill settings, not just in memory. All three only act on
the session that said them.

### 3. Ping (only used when a search comes up empty)

If a search returns zero candidates, this plugin needs to say something
honest - but "I couldn't find that" and "you don't have any reading
skills installed" are very different situations, and guessing which one
applies (or worse, silently falling back to some other language, see
[#2](https://github.com/andlo/ovos-common-reading-pipeline-plugin/issues/2))
is worse than just asking. So it asks:

```
ovos.common_reading.ping
{"lang": "<the language the request was made in>", "requester": "<this plugin's id>"}
```

A provider only answers a ping in a language it serves, with
`message.reply(...)`.

Every provider that's loaded and listening should reply, cheaply, with
no index lookup:

```
ovos.common_reading.pong
{"skill_id": "<provider skill id>", "collection": "<same collection name it uses in search responses>"}
```

This is **only** broadcast on the rare 0-candidates path, never on every
search - a normal search/fetch round trip never triggers a ping. Based
on the pongs:

- **Zero pongs** -> "you don't have any reading skills installed" (the
  most useful thing to tell the user, since it's actionable)
- **At least one pong, but nothing matched a `collection_hint`** ->
  "I don't know a source called X"
- **At least one pong, but no phrase matched at all** -> a generic
  "I couldn't find anything matching that"

This needs no language-awareness on the plugin's part: a provider that
refused to load for the device's language (the `SUPPORTED_LANGUAGES`
gate described below) never registers a ping handler either, so it
correctly stays silent here too - the same mechanism that keeps it out
of search results keeps it out of ping results, for free.

**If you're building a provider, implementing this is required, not
optional** - a provider that never pongs looks, from the pipeline's
perspective, exactly like a provider that isn't installed at all, which
produces a misleading "nothing installed" message even when your skill
is present and just didn't have a match. See
[ovos-skill-common-reading-example](https://github.com/andlo/ovos-skill-common-reading-example)
for the reference implementation.

### 4. Vocabulary

What a provider can read, in the words people use. **Required**: the
pipeline only claims a request that names a kind of text, a collection
or a title some provider announced (the story words are built in).

A provider sends one message per language it serves when it loads, and
again when the pipeline asks (it does when it loads itself):

```
ovos.common_reading.vocabulary.get
{"langs": ["en-us", "da-dk"]}
```

```
ovos.common_reading.vocabulary
{
  "skill_id": "<provider skill id>",
  "lang": "en-us",
  "content_types": {"horoscope": ["horoscope", "horoscopes", "astrology"]},
  "collections": ["grimm", "brothers grimm", "the brothers grimm"],
  "titles": ["The Frog Prince", "Rapunzel"]
}
```

- `content_types`: canonical name -> the words for it in this
  language, all inflections you want recognised ("horoskop",
  "horoskopet"). The canonical name is what the search carries as
  `content_type`. Story providers can leave it out: "story", "tale",
  "fairy tale" and their equivalents in the 8 languages are built in
  (canonical `"story"`).
- `collections`: the friendly names (below) in this language.
- `titles`: optional. What the `-low` stage recognises as a bare title
  ("tell me the little mermaid"). Leave out titles that are also
  everyday phrases.

On shutdown a provider can send `{"skill_id": ..., "remove": true}` so
its words stop being claimed.

### Friendly names

Each provider should keep a small list of names it's willing to answer
to as `collection_hint` - not just its `skill_id`, but the natural
things a person might call it: `ovos-skill-grimm-tales` might match
`"grimm"`, `"the brothers grimm"`; `ovos-skill-andersen-tales` matches
`"andersen"`, `"hans christian andersen"`, `"h c andersen"`. Matching
should be fuzzy (e.g. via `ovos_utils.parse.match_one` against the
provider's own alias list) rather than exact string equality, since STT
transcription is never perfectly consistent.

If `collection_hint` doesn't clearly match a provider's own aliases,
that provider should simply not respond to the search at all - only
providers that actually answered are considered.

**Tune your match threshold carefully.** A threshold around 0.6 can
produce false positives on short strings (e.g. "andrew lang" scoring
0.63 against "andersen" on plain character overlap, nothing to do with
meaning) - 0.85 is a safer default. Verify empirically for your own
alias list.

### Language handling

Every provider in this family handles the device's language one of two
ways:

- **Fixed set of supported languages, no translation** (`ovos-skill-andersen-tales`,
  `ovos-skill-grimm-tales`, `ovos-skill-andrew-lang-tales`): checks a
  `SUPPORTED_LANGUAGES` set at the *top of `initialize()`*, before
  building any index or registering any bus events. On an unsupported
  device language, the provider logs why and stays completely inert -
  never loads an index, never listens for `ovos.common_reading.search` -
  rather than loading fully and silently declining every search.
- **Machine translation** (`ovos-skill-ovosblog`, `ovos-skill-arxiv-papers`):
  always loads (it can't know in advance whether a translation plugin
  will be configured), matches search phrases against *translated*
  titles, and declines per-search rather than per-load if no translator
  is available.

Neither pattern ever silently serves the wrong language - the difference
is only *when* the provider decides it can't help (once, at load time,
if it has no way to translate; or per-search, if it might). See
[ovos-skill-common-reading-example](https://github.com/andlo/ovos-skill-common-reading-example)'s
module docstring (decision points #1 and #4) for the full reasoning
behind picking one over the other for your own provider.

## Category
**Entertainment**

## Tags
#reading #stories #articles #news #orchestrator #pipeline
