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:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
|
||||
@@ -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 & 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
@@ -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:
|
||||
|
||||
@@ -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]"
|
||||
|
||||
|
||||
@@ -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[@]}"
|
||||
|
||||
Reference in New Issue
Block a user