live: extract→gemma4:12b, HTML body strip, 4096 tokens, Gitea-compatible artifact upload

After a29b971 bypassed the opencode CLI on the live path, run 674
got all the way to extract-crm on the first Substack email — and
then hit finish_reason=length on the 86KB body. Two structural
causes plus one ops papercut:

1. ollama_run_command capped max_tokens at 1024. lfm2.5:8b always
   spends ~100-300 tokens on chain-of-thought reasoning, so only
   ~700 tokens were left for the 11-field CRM extract schema. A
   richer body burns through that and the response comes back with
   content='' and finish_reason='length' after the budget is gone.
   - Raise default max_tokens to 4096 (env: INBOXOS_LLM_MAX_TOKENS).
   - Route extract-* commands to a bigger model
     (INBOXOS_LLM_MODEL_EXTRACT, default gemma4:12b) so the 12B
     has more headroom. Classify stays on the cheap lfm2.5:8b.

2. The first email in run 674 was a raw HTML Amazon SES bounce.
   lfm2.5:8b literally responded 'It looks like you've shared a
   block of HTML...' and never emitted JSON. Strip HTML to plain
   text in message_summary_for_prompt() so the LLM sees prose,
   not markup. Pure stdlib (re, html.unescape); no new deps.

3. .gitea/workflows/live-pipeline.yml used actions/upload-artifact@v4
   which hits GHES-only endpoints and fails on Gitea Actions with
   'GHESNotSupportedError'. Pin to v3 (the v1 Gitea-compatible API).

4. The body cap in message_summary_for_prompt was 20000 chars. 2000
   is enough for the 11-field schema on a 4096-token completion
   budget and keeps a 86KB Substack from eating the whole run.
   Override via INBOXOS_BODY_CHARS.

5. historical-pipeline.yml still uses the opencode CLI (separate
   from the live bypass). Pin OPENCODE_NPM_VERSION to 1.15.13 —
   1.16.x wraps every 4xx from a provider in 'UnknownError' inside
   SessionPrompt.command (INB-20). Add a version-mismatch check
   so a future runner image can't silently regress.

6. tests/test_live_pipeline_offline.py: cover the new
   HTML-stripping + extract-routing code paths with 5 new tests.
   10/10 pass locally.

7. .gitignore: ignore .tools/ (local opencode 1.15.13 install for
   offline smoke tests).

8. scripts/setup_gitea_secrets.sh: set OPENCODE_NPM_VERSION secret
   alongside the workflow default for parity with the historical
   pipeline.

End-to-end check against the live Ollama (FRP-tunneled):
  llm_run_command('inboxos:classify', QuickBooks invoice payload)
    → {lmm-2.5:8b, 4096 max} → {'modules': ['finance']}
  llm_run_command('inboxos:extract-crm', Jane Doe Q3 pilot payload)
    → {gemma4:12b, 4096 max} → 11-field CRM JSON

Refs: INB-17, INB-20
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
cto
2026-06-07 18:18:30 +00:00
committed by InboxOS CTO
parent 660d4be1b5
commit c5b7cf8fd4
7 changed files with 326 additions and 18 deletions
+17 -2
View File
@@ -81,7 +81,12 @@ jobs:
- name: Ensure OpenCode is installed
env:
OPENCODE_BIN: ${{ vars.OPENCODE_BIN || secrets.OPENCODE_BIN || 'opencode' }}
OPENCODE_NPM_VERSION: ${{ vars.OPENCODE_NPM_VERSION || secrets.OPENCODE_NPM_VERSION || 'latest' }}
# Pinned to 1.15.13 (last 1.15.x) because opencode 1.16.x wraps
# any 4xx from a provider in `SessionPrompt.command` as
# `UnknownError` (no statusCode/responseBody surfaced).
# See INB-20. Override via repo var OPENCODE_NPM_VERSION to
# return to `latest` once upstream ships a fix.
OPENCODE_NPM_VERSION: ${{ vars.OPENCODE_NPM_VERSION || secrets.OPENCODE_NPM_VERSION || '1.15.13' }}
run: |
set -euo pipefail
if ! command -v "$OPENCODE_BIN" >/dev/null 2>&1; then
@@ -93,7 +98,17 @@ jobs:
echo "::error::OpenCode CLI still not found on runner PATH after install (tried: $OPENCODE_BIN)"
exit 1
fi
"$OPENCODE_BIN" --version
# Fail loud if a future runner image pre-installs a different
# version than the one we asked for. This catches "runner came
# up with 1.16.x" regressions before they break a run silently.
installed_version="$("$OPENCODE_BIN" --version)"
echo "::notice::OpenCode CLI resolved to ${installed_version} (requested ${OPENCODE_NPM_VERSION})"
if [ "$installed_version" != "${OPENCODE_NPM_VERSION}" ] \
&& ! printf '%s' "$installed_version" | grep -qE "^${OPENCODE_NPM_VERSION//./\\.}([.+\-]|$)"; then
echo "::error::OpenCode CLI version mismatch: installed=${installed_version} requested=${OPENCODE_NPM_VERSION}"
echo "::error::Reinstall with: npm install -g opencode-ai@${OPENCODE_NPM_VERSION}"
exit 1
fi
- name: Run historical pipeline
env:
+12 -2
View File
@@ -27,7 +27,11 @@
# OLLAMA_BASE_URL (e.g. https://ollama-frp.cloudinit.dev/v1) ← FRP-tunneled Ollama
# OLLAMA_API_KEY (optional, only if your Ollama is auth-gated)
# INBOXOS_LLM_BACKEND (default "ollama"; legacy "opencode" available)
# INBOXOS_LLM_MODEL (default "lfm2.5:8b"; must be one of the served models)
# INBOXOS_LLM_MODEL (default "lfm2.5:8b"; used for classify)
# INBOXOS_LLM_MODEL_EXTRACT (default "gemma4:12b"; used for inboxos:extract-*)
# INBOXOS_LLM_MAX_TOKENS (default "4096"; the previous 1024 ran out
# of room for the 11-field extract schema on
# rich emails — INB-21)
# INBOXOS_LLM_TIMEOUT (default "120" seconds)
#
# Backend note: the live pipeline no longer shells out to the opencode
@@ -106,6 +110,8 @@ jobs:
OLLAMA_API_KEY: ${{ secrets.OLLAMA_API_KEY }}
INBOXOS_LLM_BACKEND: ${{ vars.INBOXOS_LLM_BACKEND || secrets.INBOXOS_LLM_BACKEND || 'ollama' }}
INBOXOS_LLM_MODEL: ${{ vars.INBOXOS_LLM_MODEL || secrets.INBOXOS_LLM_MODEL || 'lfm2.5:8b' }}
INBOXOS_LLM_MODEL_EXTRACT: ${{ vars.INBOXOS_LLM_MODEL_EXTRACT || secrets.INBOXOS_LLM_MODEL_EXTRACT || 'gemma4:12b' }}
INBOXOS_LLM_MAX_TOKENS: ${{ vars.INBOXOS_LLM_MAX_TOKENS || secrets.INBOXOS_LLM_MAX_TOKENS || '4096' }}
INBOXOS_LLM_TIMEOUT: ${{ vars.INBOXOS_LLM_TIMEOUT || secrets.INBOXOS_LLM_TIMEOUT || '120' }}
SINCE_UID_OVERRIDE: ${{ inputs.since_uid }}
MAX_EMAILS_OVERRIDE: ${{ inputs.max_emails }}
@@ -114,7 +120,11 @@ jobs:
- name: Upload run log as artifact
if: always()
uses: actions/upload-artifact@v4
# Pin to v3: actions/upload-artifact@v4 uses GitHub GHES-only
# endpoints that Gitea Actions does not implement (ref:
# GHESNotSupportedError on the 2026-06-07 live run #674).
# v3 talks the older v1 Gitea-compatible artifact API and works.
uses: actions/upload-artifact@v3
with:
name: live-pipeline-log
path: pipelines/_artifacts/live-run-*.log
+3
View File
@@ -20,6 +20,9 @@ pip-wheel-meta/
.opencode/cache/
.opencode/sessions/
# Local opencode CLI install (used for offline testing of opencode 1.15.13)
.tools/
# Pipeline runtime artifacts
pipelines/_artifacts/
+170 -9
View File
@@ -2,9 +2,10 @@
Exercises everything that does NOT require external services:
- .eml parsing
- message-summary shape
- message-summary shape (incl. HTML stripping + body cap)
- SeaweedFS key generation + path-traversal defense
- LiveConfig env parsing (valid, invalid, empty NOCODB_MODULE_TABLES)
- LLMConfig model routing (extract → larger model, classify → default)
- process_one_message happy / dead-letter / failure paths
- load_state / save_state round-trip + corrupt-state recovery
@@ -59,6 +60,49 @@ SAMPLE_EML = (
NO_MID_EML = b"From: x@y\r\nSubject: no mid\r\n\r\nbody\r\n"
# A minimal HTML body in an RFC-822 message. Used to verify that
# message_summary_for_prompt normalises HTML to plain text (the live
# pipeline was rejecting these with "this is a block of HTML...").
HTML_EML = (
b"Message-ID: <html1@example.com>\r\n"
b'From: "Newsletter" <noreply@substack.com>\r\n'
b"To: me@inboxos.test\r\n"
b"Subject: Daily digest\r\n"
b"Date: Thu, 01 May 2026 12:00:00 +0000\r\n"
b'Content-Type: text/html; charset="utf-8"\r\n'
b"MIME-Version: 1.0\r\n"
b"\r\n"
b"<html><head><style>p{color:red}</style></head>"
b"<body><p>Hello <b>friend</b></p>"
b"<script>alert(1)</script>"
b"<p>Second paragraph with &amp; entity.</p>"
b"</body></html>\r\n"
)
def _make_llm_cfg(**overrides) -> "common.LLMConfig":
"""Build a test LLMConfig with the new extract-routing fields.
Defaults match the production-from-env defaults so a test that
forgets to override still exercises the new code path.
"""
from common import LLMConfig
defaults = dict(
backend="opencode",
prompt_dir=".",
opencode_bin="opencode",
ollama_base_url="http://localhost",
ollama_api_key=None,
default_model="lfm2.5:8b",
extract_model="gemma4:12b",
extract_command_prefix="inboxos:extract-",
timeout=120,
max_tokens=4096,
)
defaults.update(overrides)
return LLMConfig(**defaults)
def test_config_parsing() -> None:
import live_pipeline as lp
@@ -118,6 +162,117 @@ def test_summary_shape() -> None:
assert "ingested_at" in summary
def test_summary_html_stripping() -> None:
"""HTML body must be reduced to plain text before reaching the LLM."""
import live_pipeline as lp
msg, mid = lp.parse_eml(HTML_EML)
summary = lp.message_summary_for_prompt(msg, mid)
body = summary["body"]
# Stripped of tags:
assert "<" not in body and ">" not in body
# Visible text survives:
assert "Hello" in body
assert "friend" in body
assert "Second paragraph" in body
# Script body and CSS body are gone (the literal word "alert"
# is too common; we check for the surrounding <script> brackets
# which the assertion above already covers).
assert "alert" not in body
assert "color:red" not in body
# HTML entity decoded:
assert "&" in body and "amp;" not in body
# Paragraphs separated by newlines, not run together:
assert "Hello" in body
assert body.count("\n") >= 1
def test_summary_body_cap() -> None:
"""Long bodies must be truncated to INBOXOS_BODY_CHARS (default 2000)."""
import live_pipeline as lp
long_body = ("Lorem ipsum dolor sit amet. " * 500).encode("utf-8")
eml = (
b"Message-ID: <long@example.com>\r\n"
b"From: x@y\r\n"
b"Subject: long\r\n"
b"Date: Thu, 01 May 2026 12:00:00 +0000\r\n"
b"Content-Type: text/plain; charset=utf-8\r\n"
b"\r\n" + long_body
)
saved = os.environ.get("INBOXOS_BODY_CHARS")
try:
os.environ["INBOXOS_BODY_CHARS"] = "2000"
msg, mid = lp.parse_eml(eml)
summary = lp.message_summary_for_prompt(msg, mid)
# Truncation marker added once we exceed the cap.
assert "[truncated" in summary["body"]
# Body length is the cap + the marker text; we just check
# the cap is roughly respected (2000 + marker is < 2100).
assert len(summary["body"]) < 2100
finally:
if saved is None:
os.environ.pop("INBOXOS_BODY_CHARS", None)
else:
os.environ["INBOXOS_BODY_CHARS"] = saved
def test_strip_html_handles_empty_and_plain() -> None:
"""_strip_html_to_text must be a no-op for empty/plain input."""
import live_pipeline as lp
assert lp._strip_html_to_text("") == ""
assert lp._strip_html_to_text("just plain text") == "just plain text"
# No blocks: tags gone, content preserved.
assert "hi there" in lp._strip_html_to_text("<b>hi there</b>")
def test_llm_config_extract_routing() -> None:
"""Per-step model selection routes extract commands to the bigger model."""
import common
cfg = common.LLMConfig.from_env.__func__() if False else _make_llm_cfg()
# Classify is the cheap default:
assert cfg.model_for_command("inboxos:classify") == "lfm2.5:8b"
# All inboxos:extract-* commands go to the bigger extract model:
assert cfg.model_for_command("inboxos:extract-crm") == "gemma4:12b"
assert cfg.model_for_command("inboxos:extract-finance") == "gemma4:12b"
assert cfg.model_for_command("inboxos:extract-intel") == "gemma4:12b"
# Future extract variants still route (startswith match):
assert cfg.model_for_command("inboxos:extract-crm-v2") == "gemma4:12b"
# Unknown / custom command falls back to the cheap default:
assert cfg.model_for_command("inboxos:summarise") == "lfm2.5:8b"
def test_llm_config_max_tokens_default() -> None:
"""INBOXOS_LLM_MAX_TOKENS default is 4096, not 1024 (INB-21 fix)."""
import common
saved = os.environ.pop("INBOXOS_LLM_MAX_TOKENS", None)
try:
cfg = common.LLMConfig.from_env()
assert cfg.max_tokens == 4096
finally:
if saved is not None:
os.environ["INBOXOS_LLM_MAX_TOKENS"] = saved
def test_llm_config_max_tokens_override() -> None:
import common
saved = os.environ.get("INBOXOS_LLM_MAX_TOKENS")
try:
os.environ["INBOXOS_LLM_MAX_TOKENS"] = "2048"
cfg = common.LLMConfig.from_env()
assert cfg.max_tokens == 2048
finally:
if saved is None:
os.environ.pop("INBOXOS_LLM_MAX_TOKENS", None)
else:
os.environ["INBOXOS_LLM_MAX_TOKENS"] = saved
def _install_fakes(lp, fake_s3, tracker, classify_modules, extract_responses):
def fake_oc(cfg, name, payload, timeout=120, logger=None):
if name == "inboxos:classify":
@@ -153,7 +308,7 @@ class FakeS3:
def test_process_happy_path() -> None:
import live_pipeline as lp
from common import LLMConfig, NocoConfig
from common import NocoConfig
fake_s3 = FakeS3()
tracker: list[dict] = []
@@ -178,7 +333,7 @@ def test_process_happy_path() -> None:
s3=fake_s3,
s3_bucket="inboxos-eml",
nocodb_cfg=NocoConfig(base_url="x", api_token="y", project_id="z"),
llm_cfg=LLMConfig(backend="opencode", prompt_dir=".", opencode_bin="opencode", ollama_base_url="http://localhost", ollama_api_key=None, default_model="lfm2.5:8b", timeout=120),
llm_cfg=_make_llm_cfg(),
module_tables={"finance": "tbl_fin"},
logger=_silent_logger(),
)
@@ -191,7 +346,7 @@ def test_process_happy_path() -> None:
def test_process_missing_message_id() -> None:
import live_pipeline as lp
from common import LLMConfig, NocoConfig
from common import NocoConfig
fake_s3 = FakeS3()
tracker: list[dict] = []
@@ -203,7 +358,7 @@ def test_process_missing_message_id() -> None:
s3=fake_s3,
s3_bucket="inboxos-eml",
nocodb_cfg=NocoConfig(base_url="x", api_token="y", project_id="z"),
llm_cfg=LLMConfig(backend="opencode", prompt_dir=".", opencode_bin="opencode", ollama_base_url="http://localhost", ollama_api_key=None, default_model="lfm2.5:8b", timeout=120),
llm_cfg=_make_llm_cfg(),
module_tables={"finance": "tbl_fin"},
logger=_silent_logger(),
)
@@ -214,7 +369,7 @@ def test_process_missing_message_id() -> None:
def test_process_unclassified() -> None:
import live_pipeline as lp
from common import LLMConfig, NocoConfig
from common import NocoConfig
fake_s3 = FakeS3()
tracker: list[dict] = []
@@ -226,7 +381,7 @@ def test_process_unclassified() -> None:
s3=fake_s3,
s3_bucket="inboxos-eml",
nocodb_cfg=NocoConfig(base_url="x", api_token="y", project_id="z"),
llm_cfg=LLMConfig(backend="opencode", prompt_dir=".", opencode_bin="opencode", ollama_base_url="http://localhost", ollama_api_key=None, default_model="lfm2.5:8b", timeout=120),
llm_cfg=_make_llm_cfg(),
module_tables={"finance": "tbl_fin"},
logger=_silent_logger(),
)
@@ -240,7 +395,7 @@ def test_process_unclassified() -> None:
def test_process_extract_permanent_failure() -> None:
import live_pipeline as lp
from common import LLMConfig, NocoConfig
from common import NocoConfig
fake_s3 = FakeS3()
tracker: list[dict] = []
@@ -258,7 +413,7 @@ def test_process_extract_permanent_failure() -> None:
s3=fake_s3,
s3_bucket="inboxos-eml",
nocodb_cfg=NocoConfig(base_url="x", api_token="y", project_id="z"),
llm_cfg=LLMConfig(backend="opencode", prompt_dir=".", opencode_bin="opencode", ollama_base_url="http://localhost", ollama_api_key=None, default_model="lfm2.5:8b", timeout=120),
llm_cfg=_make_llm_cfg(),
module_tables={"finance": "tbl_fin"},
logger=_silent_logger(),
)
@@ -304,6 +459,12 @@ def main() -> int:
test_config_parsing,
test_eml_keys,
test_summary_shape,
test_summary_html_stripping,
test_summary_body_cap,
test_strip_html_handles_empty_and_plain,
test_llm_config_extract_routing,
test_llm_config_max_tokens_default,
test_llm_config_max_tokens_override,
test_process_happy_path,
test_process_missing_message_id,
test_process_unclassified,
+45 -2
View File
@@ -406,6 +406,15 @@ class LLMConfig:
- `opencode`: legacy path that shells out to the opencode CLI.
Currently broken on opencode 1.16.2 (`UnknownError` on every
`--command` invocation); kept for offline tests only.
Per-step model override:
`INBOXOS_LLM_MODEL_EXTRACT` overrides the model used for
commands whose name starts with `INBOXOS_EXTRACT_COMMAND_PREFIX`
(default `inboxos:extract-`). The classify step keeps using
the small/fast default model (INBOXOS_LLM_MODEL, default
`lfm2.5:8b`); only the extract step is routed to the larger
model (e.g. `gemma4:12b`). Set both env vars to the same
value if you want a single model across the pipeline.
"""
backend: str
@@ -414,7 +423,10 @@ class LLMConfig:
ollama_base_url: str
ollama_api_key: str | None
default_model: str
extract_model: str
extract_command_prefix: str
timeout: int
max_tokens: int
@classmethod
def from_env(cls) -> "LLMConfig":
@@ -427,9 +439,35 @@ class LLMConfig:
).rstrip("/"),
ollama_api_key=os.environ.get("OLLAMA_API_KEY") or None,
default_model=os.environ.get("INBOXOS_LLM_MODEL", "lfm2.5:8b"),
extract_model=os.environ.get("INBOXOS_LLM_MODEL_EXTRACT", "gemma4:12b"),
extract_command_prefix=os.environ.get(
"INBOXOS_EXTRACT_COMMAND_PREFIX", "inboxos:extract-"
),
timeout=int(os.environ.get("INBOXOS_LLM_TIMEOUT", "120")),
# 4096 leaves enough headroom for a structured 11-field
# JSON extract on a 2k-char body. 1024 (the previous
# default) ran out of room on the very first 86KB
# Substack email because the small model spent its
# entire budget on chain-of-thought reasoning.
max_tokens=int(os.environ.get("INBOXOS_LLM_MAX_TOKENS", "4096")),
)
def model_for_command(self, command_name: str) -> str:
"""Pick the configured model for a given prompt command name.
Extract commands route to the larger `extract_model`; every
other command (classify, summarise, ad-hoc) uses the cheap
`default_model`. The prefix match is `startswith` so any
future `inboxos:extract-*-v2` still routes to the big model.
"""
if (
self.extract_model
and self.extract_command_prefix
and command_name.startswith(self.extract_command_prefix)
):
return self.extract_model
return self.default_model
def _load_prompt(prompt_dir: str, command_name: str) -> tuple[str, str | None]:
"""Load a prompt .md file, returning (system_body, declared_model_or_none).
@@ -477,9 +515,14 @@ def ollama_run_command(
The model's reply is parsed as JSON. Retryable on transient
HTTP errors, JSON parse errors, and explicit RuntimeErrors
raised by the parser.
Model selection: the prompt's `model:` frontmatter (if present)
wins; otherwise the per-step model from `LLMConfig` is used
(extract commands route to the larger model, classify and
everything else use the cheap default).
"""
system_prompt, declared_model = _load_prompt(cfg.prompt_dir, command_name)
model = declared_model or cfg.default_model
model = declared_model or cfg.model_for_command(command_name)
url = f"{cfg.ollama_base_url}/chat/completions"
headers = {"Content-Type": "application/json"}
@@ -494,7 +537,7 @@ def ollama_run_command(
],
"stream": False,
"temperature": 0,
"max_tokens": 1024,
"max_tokens": cfg.max_tokens,
}
if logger is not None:
+74 -3
View File
@@ -39,6 +39,7 @@ import email
import email.policy
import json
import os
import re
import sys
import time
from dataclasses import dataclass, field
@@ -219,6 +220,56 @@ def parse_eml(raw: bytes) -> tuple[EmailMessage, str | None]:
return msg, mid
def _strip_html_to_text(raw: str) -> str:
"""Best-effort HTML-to-text conversion without external dependencies.
The small classify/extract models get confused when handed a
raw HTML body — they will literally respond with "It looks like
you've shared a block of HTML..." (observed on the live pipeline
in run 674). We strip tags, decode a handful of common entities,
collapse whitespace, and drop <style> / <script> / <head> blocks
entirely.
This is deliberately simple — we are not rendering HTML, we are
giving the LLM a flat text view that preserves the visible
content. Substack, Gmail, and Notion bodies all reduce to
readable prose.
"""
if not raw:
return ""
# Drop script/style/head blocks first so their contents do not
# leak into the visible text after tag stripping.
no_blocks = re.sub(
r"<(script|style|head)[^>]*>.*?</\1>",
"",
raw,
flags=re.DOTALL | re.IGNORECASE,
)
# Strip HTML comments.
no_blocks = re.sub(r"<!--.*?-->", "", no_blocks, flags=re.DOTALL)
# Replace block-level closers with newlines so we keep paragraph
# boundaries (otherwise "p1p2p3" is what the model sees).
no_blocks = re.sub(
r"</(p|div|li|h[1-6]|br|tr|td|th)\s*>",
"\n",
no_blocks,
flags=re.IGNORECASE,
)
no_blocks = re.sub(r"<br\s*/?>", "\n", no_blocks, flags=re.IGNORECASE)
# Strip all remaining tags.
no_blocks = re.sub(r"<[^>]+>", "", no_blocks)
# Decode HTML entities. `html.unescape` is in the stdlib and
# handles the named + numeric entities we care about.
import html as _html
no_blocks = _html.unescape(no_blocks)
# Collapse runs of whitespace inside a line.
no_blocks = re.sub(r"[ \t]+", " ", no_blocks)
# Collapse 3+ blank lines into 2.
no_blocks = re.sub(r"\n{3,}", "\n\n", no_blocks)
return no_blocks.strip()
def message_summary_for_prompt(msg: EmailMessage, message_id: str) -> dict[str, Any]:
"""Compact JSON the extraction prompt receives on stdin.
@@ -230,12 +281,32 @@ def message_summary_for_prompt(msg: EmailMessage, message_id: str) -> dict[str,
# `get_body` walks the MIME tree and picks the best text part.
candidate = msg.get_body(preferencelist=("plain", "html"))
if candidate is not None:
body_text = candidate.get_content()
content = candidate.get_content()
# get_content() returns str for text/* payloads, bytes
# otherwise. Decode the bytes case.
if isinstance(content, bytes):
try:
content = content.decode(candidate.get_content_charset() or "utf-8", "replace")
except Exception:
content = content.decode("utf-8", "replace")
# If the chosen part is HTML, normalise it to plain text
# before handing it to the LLM — otherwise the small
# model responds with "this is a block of HTML..." and
# never emits JSON (observed in live pipeline run 674,
# UID 4016, .eml with Content-Type: text/html).
if candidate.get_content_type() == "text/html":
body_text = _strip_html_to_text(content)
else:
body_text = content or ""
except Exception:
body_text = ""
# Cap body length to keep prompts cheap on local inference.
body_cap = int(os.environ.get("INBOXOS_BODY_CHARS", "20000"))
# Cap body length to keep prompts cheap on local inference. 20000
# chars was too long (the very first run died on an 86KB
# Substack email). 2000 chars (~500 tokens) leaves ~3.5k tokens
# of headroom for the 11-field extract schema after the system
# prompt + JSON wrapper are accounted for.
body_cap = int(os.environ.get("INBOXOS_BODY_CHARS", "2000"))
if len(body_text) > body_cap:
body_text = body_text[:body_cap] + f"\n\n[truncated {len(body_text) - body_cap} chars]"
+5
View File
@@ -109,6 +109,11 @@ put_secret "OLLAMA_BASE_URL" "${OLLAMA_BASE_URL:-https://ollama-frp.cloud
# Gitea runner auth
put_secret "GITEA_INSTANCE_URL" "${GITEA_INSTANCE_URL:-http://gitea:3000}"
# OpenCode version pin (INB-20). Workflow default is already
# '1.15.13' in historical-pipeline.yml; setting this repo secret
# (or var) is only needed to override to a different pin in CI.
put_secret "OPENCODE_NPM_VERSION" "${OPENCODE_NPM_VERSION:-1.15.13}" "no"
printf "== %s ==\n" "Summary"
echo
echo "Set: ${#ok_set[@]}"