Processing Profiles
News needs context. An engineering deep dive needs an explanation of the solution. A profile tells Horizon what to look for, how to judge it, and what to write—using Markdown prompts and a JSON block definition.
Profiles define reusable editorial rules. Your sources, AI model, score thresholds, digest limits, languages, and delivery channels stay in the runtime configuration. Each item is routed to one profile; a candidate list lets AI choose among several, rather than process the item with all of them.
Built-in Profiles
| Profile | What it looks for | What it produces |
|---|---|---|
tech-news |
Releases, incidents, research results, and technology-industry developments | Concrete changes and necessary background, with relevant past coverage, impact, and community discussion when useful |
tech-blog |
Engineering deep dives, tutorials, investigations, and retrospectives | Background, solution, and takeaways |
finance-news |
Markets, macroeconomics, company finance, and economically material policy | Summary and background; direct impact when useful |
ai-creator |
AI developments with potential for content creation | Summary; timely hooks, content angles, and community discussion when useful |
Start with a built-in profile, then adjust its prompts or blocks to match your reading needs.
Directory Layout
Each profile lives under profiles/<id>/ with the same four-file layout:
profiles/tech-blog/
|-- profile.json
|-- match.md
|-- analysis.md
`-- enrichment.md
profile.jsondefines the profile contract.match.mdtells automatic routing what content belongs to the profile.analysis.mddefines the first-pass analysis and scoring rubric.enrichment.mddefines how to write the localized output blocks.
Try a Different Reading Style
For an engineering feed, use tech-blog to extract the problem, solution, and
lessons. Add an entry like this to sources.rss:
{
"name": "NVIDIA CUDA Technical Blog",
"url": "https://developer.nvidia.com/blog/tag/cuda/feed/",
"profile": "tech-blog",
"content_extractor": "trafilatura"
}
trafilatura is included in the base install. It fetches the article body;
extraction failures fall back to the feed excerpt. The blog profile uses larger
input budgets and head-middle-tail sampling for long articles.
To create your own profile:
- Copy a profile directory and give its
profile.jsona uniqueidand name. - Edit
match.mdandanalysis.mdto describe the content and evaluation criteria. - Set the output blocks in
profile.jsonand their writing instructions inenrichment.md. - Set a source’s
profileto your new ID, then tune its threshold underprocessing.profile_settings.
No Python changes are needed when using the existing block type and tools. Each new profile also joins the candidates for unrestricted automatic routing.
Contributing a Profile
A profile is a reusable editorial policy for a content domain, not a user account, source list, or collection of personal interests. It tells Horizon:
- which items belong to the domain (
match.md), - how to evaluate and score them (
analysis.md), and - which content blocks to produce and how to write them (
profile.jsonandenrichment.md).
To contribute a built-in profile, add profiles/<id>/ with those four files and
open a focused pull request. No Python changes are normally required.
Before submitting, check that the profile:
- covers a clear content domain that is useful to more than one source list or individual user,
- has a meaningful routing, evaluation, or output difference from existing profiles,
- states both what belongs and what does not belong in
match.md, - defines a concrete 0-10 rubric in
analysis.md, - keeps generated blocks specific, non-overlapping, and grounded in supplied content or declared tools,
- contains no credentials, private sources, or user-specific thresholds, and
- passes
uv run pytest tests/test_profiles.py tests/test_prompting.py -q.
Built-in profiles are loaded as automatic-routing candidates, so maintainers may ask contributors to narrow ambiguous matching rules or clarify overlap before a profile is merged.
Configure discovery in data/config.json:
{
"processing": {
"profiles_dir": "profiles",
"default_profile": "tech-news",
"profile_settings": {
"tech-news": {
"threshold": 7.0,
"topic_dedup": true
},
"tech-blog": {
"threshold": 4.0,
"topic_dedup": false
}
}
}
}
default_profile must name a loaded profile. Horizon fails to start if no
profiles are found or the default does not exist.
Profile Schema
{
"id": "tech-news",
"name": "Technology News",
"display_names": {
"zh": "科技新闻"
},
"match": "match.md",
"analysis": "analysis.md",
"content": {
"analysis_max_chars": 1000,
"enrichment_max_chars": 8000,
"sampling": "prefix"
},
"enrichment": {
"prompt": "enrichment.md",
"blocks": [
{
"id": "summary",
"type": "section",
"tools": [],
"primary": true
},
{
"id": "background",
"type": "section",
"tools": ["history_search", "web_search"]
},
{
"id": "community_discussion",
"type": "section",
"tools": [],
"optional": true
}
]
}
}
| Field | Description |
|---|---|
id |
Unique profile ID. It starts with a lowercase letter and may contain lowercase letters, digits, _, and -. |
name |
Human-readable name used in the matching catalog. |
display_names |
Optional language-keyed names used as digest section headings. |
match |
Profile-relative path to the matching prompt. |
analysis |
Profile-relative path to the analysis prompt. |
content |
Input budgets and long-content sampling strategy for AI stages. |
enrichment.prompt |
Profile-relative path to the enrichment prompt. |
enrichment.blocks |
Contract for localized output blocks. At least one block is required. |
Block IDs use the same format as profile IDs and must be unique within a
profile. The only supported block type is "section". Blocks are required by
default; set optional to true when they may be omitted.
| Block field | Description |
|---|---|
id |
Unique block ID within the profile. |
type |
Must be "section". |
tools |
Tools allowed for this block. Declare it on every block; use [] when none are allowed. |
optional |
Whether output may omit the block. Defaults to false. |
primary |
Render this required block directly below the item title without a block heading. At most one block may be primary. Defaults to false. |
Prompt paths cannot escape their profile directory. Unknown fields are rejected in profile JSON.
Source Routing
Set profile on a source entry to route its items directly:
{
"sources": {
"rss": [
{
"name": "Example",
"url": "https://example.com/feed.xml",
"profile": "tech-news"
}
]
}
}
Set profile to an array to restrict automatic matching to a candidate subset:
{
"channel": "zaihuapd",
"profile": ["tech-news", "finance-news"]
}
Routing follows these rules:
- An explicit profile ID uses that profile and skips AI matching.
- A missing
profileor"auto"invokes AI matching against every loaded profile’smatch.md. - A non-empty profile array invokes AI matching only against those candidates.
- Unknown, duplicate, blank, or
"auto"entries in a candidate array are errors. - If candidate matching fails, Horizon uses
processing.default_profilewhen it is a candidate, otherwise the first candidate. Unrestricted matching falls back toprocessing.default_profile.
All source types support profile routing. For sources with nested entries, put
profile on the item-producing configuration, such as a GitHub entry, RSS feed,
Reddit subreddit or user, or OpenBB watchlist. Top-level single configurations,
such as Hacker News, Twitter, OSS Insight, GDELT, and Google News, carry the
field directly.
Analysis
After routing, Horizon sends the item to the selected profile’s analysis.md
prompt. A successful analysis contains a 0-10 score, a reason, a one-sentence
summary, and tags. A failed analysis may be stored with a null score. The profile
owns the rubric, so profiles can evaluate different content forms by different
standards.
Filtering
Filtering is a user preference configured by profile ID under
processing.profile_settings:
{
"processing": {
"profile_settings": {
"tech-news": {
"threshold": 8.0
}
}
}
}
threshold must be between 0 and 10. Horizon keeps items whose analysis score
is greater than or equal to that threshold. Set it to null or omit settings for
a profile to bypass score filtering. An MCP threshold supplied for a single
operation takes precedence over these configured values.
The top-level collection configuration controls time_window_hours. Optional
balanced digest limits such as category_groups and max_items belong to the
top-level digest configuration and run after profile filtering and topic
deduplication.
Enrichment Blocks And Tools
enrichment.blocks defines the exact block IDs available to output.
Required blocks must be present; optional blocks can be omitted when they add no
useful content. Generated output cannot contain unknown or duplicate blocks.
Tools are allowed per block through its tools array. The built-in tools are
web_search (external web results) and history_search (past Horizon digests).
A block may use a tool only when it explicitly declares it. Use an empty array
for blocks that need no tools. Unknown tools are rejected when the enricher is
initialized.
Tool planning receives each block’s required or optional status. For required blocks with tools, the prompt asks the model to use a tool unless the source already provides enough evidence. A search is not guaranteed; tool failures do not make a required block optional.
Search-backed statements cite tool results through source references. Horizon rejects references that were not returned by a tool call.
Historical news search
history_search reads the existing horizon-YYYY-MM-DD*.md files under your
data directory’s summaries/ folder. It uses local BM25 ranking over individual
news titles, main summaries, and tags, with extra weight for titles. The index is
loaded lazily once per enrichment batch; it needs no additional dependency,
database, embedding service, or AI relevance-scoring call.
Tool arguments:
{"query": "vLLM batch inference", "days": 90}
queryis required. Prefer specific product, project, or event names; include useful aliases rather than broad terms such as “AI news”.daysis optional, defaults to 90, and accepts 1–365. Only digests before the current item’s UTC publication date (and before today) are eligible.- Dates come from filenames and identify the digest, not necessarily the original event date.
- Each item gets at most one history search, shared across output languages. It returns at most three candidates, each with a dated title, original URL, archive filename, and up to 500 characters of the main summary.
- Results exclude the current URL and collapse repeated links, including translations and fragment/trailing-slash variants. Earlier background, historical callbacks, comments, and reference lists are not indexed as evidence.
- Tokenization uses Latin words and overlapping two-character Chinese tokens. It can match Chinese phrases without an extra segmenter, but does not infer synonyms or cross-language equivalence. No matching terms means no results.
The built-in tech-news profile uses history in its existing background block.
Its prompt requires a direct predecessor, follow-up, or concrete change, limits
historical references to two, and discards candidates connected only by a broad
topic or company name. Results are candidates, not proof of a relationship.
Normal block generation decides whether to use them, so their short excerpts
add some input tokens but no separate AI screening stage.
To try it, keep past Markdown digests in the same data directory used by the
next run. CLI --data-dir and the existing Docker data mount are respected. MCP
uses summaries/ beside its config file; per-run MCP artifacts alone are not
searched (export a digest with save_to_horizon_data to include it). An empty or
missing archive simply returns no results. Scheduled runners need to restore
past digest files before running; publishing old Pages posts alone does not
make them available in a fresh checkout.
Content Selection
Profiles can control how much source content each AI stage receives:
{
"content": {
"analysis_max_chars": 16000,
"enrichment_max_chars": 24000,
"sampling": "head-middle-tail"
}
}
sampling accepts "prefix" or "head-middle-tail". Prefix sampling preserves
the compact behavior used by news profiles. Head-middle-tail sampling keeps the
opening, a middle excerpt, and the conclusion of long-form content. Both limits
must be between 500 and 100000 characters.
Topic Deduplication
AI topic deduplication is also a runtime preference. Disable it for profiles where different treatments of the same subject should remain separate:
{
"processing": {
"profile_settings": {
"tech-blog": {
"topic_dedup": false
}
}
}
}
topic_dedup defaults to true when it or the profile’s settings are omitted.
Topic deduplication runs within each resolved profile. Disabling it does not disable the orchestrator’s earlier URL deduplication: items with the same normalized URL and requested profile or candidate list are still merged before analysis. Different requested routes stay separate at that stage; individual scrapers may also deduplicate their results before routing.
Localized Output
For each language in ai.languages, enrichment produces a localized artifact
with:
- a title;
- the profile’s required and applicable optional section blocks; and
- cited external sources referenced by those blocks.
Artifacts generated for zh are normalized to Simplified Chinese before they
are stored and rendered, including older artifacts read during rendering.
The Markdown briefing renders a block marked primary directly below the item
title and before the source line, without a redundant block heading. Profiles
without a primary block show the source first and then render every block under
its bold localized title on the same line as its content. External references
follow the blocks when used. Items
are grouped by Profile: the briefing title is H1, localized Profile names are H2
sections, and items are H3 headings. Set digest.profile_order to control the H2
section priority. When this list is non-empty, loaded Profiles omitted from it
are appended in discovery order; unknown or duplicate IDs are rejected. With an
empty or omitted list, sections follow the order in which profiles first appear
in the selected items. The final Markdown is rendered by code, without another
AI summarization call.